A spot set now composes detail passes of its own, one per round, and they go ahead of every operation's kernel. That placement is the decision worth recording: a sharpening pass reads a neighbourhood, so sharpening a dust mark before removing it smears its edge into pixels the repair's disc does not cover, and what survives is a faint over-sharpened ring around an otherwise perfect patch. It also disagrees with ARCH §5.2, which draws spot removal after clarity — docs/spot-removal.md §5.1 is where that is argued out. Every length reaching the shader is in render pixels, converted here where the framing is in scope. Both the centre and the source go through `Framing::output_at` — the same map the fused pass applies to every pixel — so a rotated photograph rotates the offset with no trigonometry, and the radius is found by mapping a point one radius above the centre and measuring, rather than by multiplying by a ratio this function has no business knowing about. The tests turn and crop the frame and expect the mark to stay gone, which is the property that arrangement buys. compose_full now takes the spot set, because a photograph with a repair and no sharpening still has a detail stage: a fused pass that encoded its own output there would quantise twice and bind to a texture of the wrong format. compose_detail_for takes the source size for the same kind of reason — a RenderScale describes the region on screen, and a spot is stored against the photograph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
8.4 KiB
Rust
246 lines
8.4 KiB
Rust
//! TRACES: FR-DEV-8
|
|
//! Repairs, drawn on a real device.
|
|
//!
|
|
//! `dr-pipeline`'s tests assert the model and the record packing; nothing there
|
|
//! can say whether the disc lands where the photographer put it. That is what
|
|
//! this file is for, and the cases it covers are the ones where a repair goes
|
|
//! wrong *quietly*:
|
|
//!
|
|
//! - the mark is still there, because the disc landed beside it;
|
|
//! - the repair works on screen and not in the export, because a length was
|
|
//! converted in the wrong units;
|
|
//! - the repair works until the photograph is cropped or rotated, because the
|
|
//! framing was applied to the pixels and not to the spot.
|
|
//!
|
|
//! The frame is a flat grey field with one black mark on it, which makes every
|
|
//! assertion here countable: a repair either leaves dark pixels or it does not.
|
|
|
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
|
use dr_pipeline::spot::{Spot, SpotMode};
|
|
use dr_pipeline::{Affects, EditGraph};
|
|
use dr_types::ColourSpace;
|
|
|
|
const SIZE: u32 = 128;
|
|
const GREY: u8 = 128;
|
|
|
|
/// Where the mark is, in normalised coordinates, and how big it is in frame
|
|
/// units. Off-centre on both axes so that a repair landing on a mirrored or
|
|
/// transposed position fails rather than passing by symmetry.
|
|
const MARK: (f32, f32) = (0.3, 0.65);
|
|
const MARK_RADIUS: f32 = 0.03;
|
|
|
|
fn ctx() -> Option<GpuContext> {
|
|
match pollster::block_on(GpuContext::new_headless()) {
|
|
Ok(c) => Some(c),
|
|
Err(e) => {
|
|
eprintln!("skipping: no GPU adapter ({e})");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A flat grey frame with one black mark on it — a dust spot, idealised.
|
|
fn marked_frame(ctx: &GpuContext) -> DemosaicedImage {
|
|
let data: Vec<u8> = (0..SIZE * SIZE)
|
|
.flat_map(|i| {
|
|
let (x, y) = ((i % SIZE) as f32, (i / SIZE) as f32);
|
|
let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32);
|
|
let r = MARK_RADIUS * SIZE as f32;
|
|
let v = if (x - cx).hypot(y - cy) <= r {
|
|
0u8
|
|
} else {
|
|
GREY
|
|
};
|
|
[v, v, v, 255]
|
|
})
|
|
.collect();
|
|
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
|
}
|
|
|
|
/// A repair covering the mark, reading from clean grey to its right.
|
|
///
|
|
/// The disc is twice the mark, so the mark sits entirely inside the solid core
|
|
/// and none of it falls in the feathered rim — otherwise this file would be
|
|
/// asserting a blend rather than a repair.
|
|
fn repair(mode: SpotMode) -> Spot {
|
|
let mut spot = Spot::new(MARK, (0.3, 0.0), MARK_RADIUS * 2.0);
|
|
spot.mode = mode;
|
|
spot
|
|
}
|
|
|
|
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
|
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
|
let (w, h) = (w.min(out), h.min(out));
|
|
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb);
|
|
let key = graph.invalidation().through(Affects::Colour);
|
|
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
|
.expect("render");
|
|
pass.export_pixels().expect("readback").0
|
|
}
|
|
|
|
/// How many pixels are darker than anything a grey field contains.
|
|
///
|
|
/// The mark is the only dark thing in the frame, so this counts what is left of
|
|
/// it — and counts it wherever it ended up, which is what makes the same
|
|
/// assertion work after a crop or a rotation.
|
|
fn dark_pixels(pixels: &[u8]) -> usize {
|
|
pixels.chunks_exact(4).filter(|p| p[0] < GREY - 24).count()
|
|
}
|
|
|
|
/// The whole feature in one assertion: the mark is there, and then it is not.
|
|
#[test]
|
|
fn a_clone_removes_the_mark() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let source = marked_frame(&ctx);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let before = dark_pixels(&render(
|
|
&mut pass,
|
|
&EditGraph::default_chain(),
|
|
&source,
|
|
SIZE,
|
|
));
|
|
assert!(before > 20, "the frame is supposed to have a mark on it");
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.spots_mut().place(repair(SpotMode::Clone));
|
|
|
|
let after = dark_pixels(&render(&mut pass, &graph, &source, SIZE));
|
|
assert_eq!(after, 0, "{before} dark pixels before, {after} after");
|
|
}
|
|
|
|
/// The grey the repair lays down has to be the *photograph's* grey. A repair
|
|
/// that removes the mark by darkening or brightening the disc passes the count
|
|
/// above and is still visibly a disc.
|
|
#[test]
|
|
fn the_patch_is_the_photograph_and_not_an_approximation_of_it() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let source = marked_frame(&ctx);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.spots_mut().place(repair(SpotMode::Clone));
|
|
let pixels = render(&mut pass, &graph, &source, SIZE);
|
|
|
|
let centre =
|
|
((MARK.1 * SIZE as f32) as u32 * SIZE + (MARK.0 * SIZE as f32) as u32) as usize * 4;
|
|
assert!(
|
|
pixels[centre].abs_diff(GREY) <= 2,
|
|
"the repaired centre reads {}, the field is {GREY}",
|
|
pixels[centre]
|
|
);
|
|
}
|
|
|
|
/// A repair is stored as a fraction of the frame, so it must land in the same
|
|
/// *place* whatever size the frame is drawn at — which is the difference
|
|
/// between a preview that tells the truth and an export that does not
|
|
/// (FR-DSP-1).
|
|
#[test]
|
|
fn a_proxy_and_an_export_repair_the_same_thing() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let source = marked_frame(&ctx);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.spots_mut().place(repair(SpotMode::Clone));
|
|
|
|
assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), 0);
|
|
assert_eq!(
|
|
dark_pixels(&render(&mut pass, &graph, &source, SIZE / 4)),
|
|
0,
|
|
"the repair missed the mark at a quarter size"
|
|
);
|
|
}
|
|
|
|
/// The framing is applied to the spot, not to the pixels afterwards. If the
|
|
/// centre were mapped and the offset were not, this is the test that fails: the
|
|
/// disc would land on the mark and read from the wrong side of the frame.
|
|
#[test]
|
|
fn a_rotated_photograph_carries_its_repairs_round_with_it() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let source = marked_frame(&ctx);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.spots_mut().place(repair(SpotMode::Clone));
|
|
graph.rotate_quarters(1);
|
|
|
|
assert_eq!(
|
|
dark_pixels(&render(&mut pass, &graph, &source, SIZE)),
|
|
0,
|
|
"the mark came back when the frame was turned"
|
|
);
|
|
}
|
|
|
|
/// And a crop, which moves the origin and the scale at once.
|
|
#[test]
|
|
fn a_crop_carries_its_repairs_with_it() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let source = marked_frame(&ctx);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.spots_mut().place(repair(SpotMode::Clone));
|
|
graph.set_crop(dr_pipeline::CropRect {
|
|
x: 0.1,
|
|
y: 0.4,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
|
|
assert_eq!(
|
|
dark_pixels(&render(&mut pass, &graph, &source, SIZE)),
|
|
0,
|
|
"the mark is inside this crop and the repair no longer covers it"
|
|
);
|
|
}
|
|
|
|
/// Opacity is a real control: at zero the repair is off, and the mark is
|
|
/// exactly as it was.
|
|
#[test]
|
|
fn a_transparent_repair_draws_nothing() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let source = marked_frame(&ctx);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let bare = dark_pixels(&render(
|
|
&mut pass,
|
|
&EditGraph::default_chain(),
|
|
&source,
|
|
SIZE,
|
|
));
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut spot = repair(SpotMode::Clone);
|
|
spot.set_opacity(0.0);
|
|
graph.spots_mut().place(spot);
|
|
|
|
assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), bare);
|
|
}
|
|
|
|
/// Placing repairs must not recompile: the list is in a storage buffer and the
|
|
/// shader never learns how long it is, so a photographer working through a
|
|
/// dusty sky pays one compilation.
|
|
#[test]
|
|
fn placing_repairs_compiles_one_pipeline() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let source = marked_frame(&ctx);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
for i in 0..6 {
|
|
let y = 0.1 + 0.1 * i as f32;
|
|
graph
|
|
.spots_mut()
|
|
.place(Spot::new((0.5, y), (0.1, 0.0), 0.02));
|
|
render(&mut pass, &graph, &source, SIZE);
|
|
}
|
|
|
|
assert_eq!(
|
|
pass.cached_detail_pipelines(),
|
|
1,
|
|
"six repairs, one compiled pipeline"
|
|
);
|
|
}
|