Draw the repairs, before anything sharpens what they removed

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>
This commit is contained in:
2026-08-26 20:17:51 +02:00
co-authored by Claude Opus 5
parent 6997c0f7ac
commit 5323608051
17 changed files with 597 additions and 49 deletions
+8 -1
View File
@@ -30,6 +30,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, Subj
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_segment::{SemanticModel, SemanticOptions, Shaped};
use dr_types::ColourSpace;
@@ -315,7 +316,13 @@ fn render_stack(
.render(stack, None, Some(&subjects), pw, ph)
.expect("rasterise masks");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
adjust
.render_masked(source, &shader, ow, oh, Some(array))
.expect("render");
+2 -4
View File
@@ -1367,8 +1367,7 @@ mod tests {
// instead of the kernel under test. The chain still
// carries the resolve pass that finishes the render, and
// that generated source is worth compiling too.
let scale = g.render_scale(img.size(), (16, 16));
let detail = g.compose_detail(scale);
let detail = g.compose_detail(img.size(), (16, 16));
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
pass.render_detailed(&img, &shader, 16, 16, None, &detail, key)
.unwrap_or_else(|e| {
@@ -1774,8 +1773,7 @@ mod tests {
// find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512);
let scale = g.render_scale((512, 512), (w, h));
let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb);
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
assert!(
!detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled"
+2 -1
View File
@@ -87,7 +87,8 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
+4 -2
View File
@@ -101,7 +101,8 @@ fn render(
let _ = ctx;
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
@@ -402,7 +403,8 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
assert!(detail.is_empty());
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
+8 -1
View File
@@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace;
@@ -65,7 +66,13 @@ fn render_at(
h: u32,
) -> Vec<u8> {
let source = grey_at(ctx, w, h);
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, w, h).expect("rasterise");
+4 -2
View File
@@ -108,7 +108,8 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
@@ -596,7 +597,8 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
// description of "a two-pixel surface structure is not present in a
// 128-pixel rendering".
let scale = graph.render_scale(source.size(), (128, 128));
let composed = graph.compose_detail_for(scale, ColourSpace::Srgb);
let composed =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
assert_eq!(
composed.len(),
1,
+22 -3
View File
@@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass, SubjectMasks};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, Framing};
use dr_segment::Shaped;
use dr_types::ColourSpace;
@@ -83,7 +84,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
.render(stack, None, Some(&subjects), PROXY, PROXY)
.expect("rasterise");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, out, out, Some(array))
@@ -95,7 +102,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
/// thumbnail were doing.
fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
let source = grey(ctx, 64);
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, out, out).expect("render");
adjust.export_pixels().expect("readback").0
@@ -211,7 +224,13 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, None, None, w, h).expect("rasterise");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, w, h, Some(array))
+2 -1
View File
@@ -73,7 +73,8 @@ fn render_at(
scale: RenderScale,
) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
.expect("render");
+245
View File
@@ -0,0 +1,245 @@
//! 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"
);
}