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:
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user