diff --git a/core/dr-gpu/tests/spot_removal.rs b/core/dr-gpu/tests/spot_removal.rs index 1213a9d..dba189e 100644 --- a/core/dr-gpu/tests/spot_removal.rs +++ b/core/dr-gpu/tests/spot_removal.rs @@ -243,3 +243,147 @@ fn placing_repairs_compiles_one_pipeline() { "six repairs, one compiled pipeline" ); } + +// --------------------------------------------------------------------------- +// Heal (FR-DEV-8) +// --------------------------------------------------------------------------- + +/// A frame whose brightness ramps across it, with one black mark on it. +/// +/// This is the case that separates the two modes. A clone copies a patch from +/// somewhere else on the ramp, so it arrives at the wrong level and leaves a +/// disc of the right texture and the wrong tone; a heal carries the difference +/// across from the boundary and leaves nothing. +fn ramped_frame(ctx: &GpuContext) -> DemosaicedImage { + let data: Vec = (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; + // 64 at the left edge to 192 at the right: a ramp steep enough that + // a clone from a third of a frame away is unmistakably wrong, and + // shallow enough to stay well inside the range. + let ramp = 64.0 + 128.0 * x / SIZE as f32; + let v = if (x - cx).hypot(y - cy) <= r { + 0u8 + } else { + ramp as u8 + }; + [v, v, v, 255] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") +} + +/// What the ramp says at a column, as the byte the renderer should produce. +fn ramp_at(x: u32) -> u8 { + (64.0 + 128.0 * x as f32 / SIZE as f32) as u8 +} + +/// The worst a repair is wrong by, over the disc it covers. +/// +/// Measured against the ramp the photograph would have had if the mark had +/// never been there, which is the only definition of "repaired" worth +/// asserting: a repair that removes the mark and leaves the wrong tone has not +/// repaired anything, it has drawn a different mark. +fn worst_error(pixels: &[u8]) -> u8 { + let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32); + let r = MARK_RADIUS * SIZE as f32; + let mut worst = 0u8; + for y in 0..SIZE { + for x in 0..SIZE { + if (x as f32 - cx).hypot(y as f32 - cy) > r { + continue; + } + let got = pixels[((y * SIZE + x) * 4) as usize]; + worst = worst.max(got.abs_diff(ramp_at(x))); + } + } + worst +} + +/// The measurement the mode exists for, and the one that decides +/// `RIM_SAMPLES`: on a gradient, a heal is right and a clone is not. +#[test] +fn a_heal_takes_the_tone_from_the_hole_it_fills() { + let Some(ctx) = ctx() else { return }; + let source = ramped_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut cloned = EditGraph::default_chain(); + cloned.spots_mut().place(repair(SpotMode::Clone)); + let clone_error = worst_error(&render(&mut pass, &cloned, &source, SIZE)); + + let mut healed = EditGraph::default_chain(); + healed.spots_mut().place(repair(SpotMode::Heal)); + let heal_error = worst_error(&render(&mut pass, &healed, &source, SIZE)); + + // The clone is wrong by roughly the ramp across the source offset — about + // 38 levels here — and it is wrong across the whole disc. + assert!( + clone_error > 20, + "the clone was supposed to be visibly wrong, and is off by {clone_error}" + ); + // Measured at 0 on the reference device — the ramp is linear, so the + // boundary difference is constant all the way round and the membrane + // reproduces it exactly. The tolerance is for the rounding a different + // driver may do, not for the method being approximate here. + assert!( + heal_error <= 1, + "the heal is off by {heal_error} levels; the clone it has to beat is off by {clone_error}" + ); +} + +/// And the repair still has to remove the mark: a membrane that matched the +/// boundary while leaving the black disc underneath would pass the measurement +/// above by averaging its way past it. +#[test] +fn a_heal_removes_the_mark_as_well_as_matching_the_tone() { + let Some(ctx) = ctx() else { return }; + let source = ramped_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = EditGraph::default_chain(); + graph.spots_mut().place(repair(SpotMode::Heal)); + let pixels = render(&mut pass, &graph, &source, SIZE); + + let mark = (MARK.0 * SIZE as f32) as u32; + for x in mark - 2..=mark + 2 { + let got = pixels[(((MARK.1 * SIZE as f32) as u32 * SIZE + x) * 4) as usize]; + assert!( + got.abs_diff(ramp_at(x)) <= 3, + "column {x} reads {got}, the ramp says {}", + ramp_at(x) + ); + } +} + +/// A heal on a flat field is a clone: the boundary difference is zero all the +/// way round, so the membrane is zero and neither mode has anything to add. +/// Worth pinning, because a membrane that quietly tinted a flat repair would +/// be invisible in the gradient test above. +#[test] +fn a_heal_on_a_flat_field_changes_nothing_a_clone_would_not() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut cloned = EditGraph::default_chain(); + cloned.spots_mut().place(repair(SpotMode::Clone)); + let a = render(&mut pass, &cloned, &source, SIZE); + + let mut healed = EditGraph::default_chain(); + healed.spots_mut().place(repair(SpotMode::Heal)); + let b = render(&mut pass, &healed, &source, SIZE); + + let worst = a + .iter() + .zip(&b) + .map(|(x, y)| x.abs_diff(*y)) + .max() + .unwrap_or(0); + assert!( + worst <= 1, + "heal and clone differ by {worst} on a flat field" + ); +} diff --git a/core/dr-pipeline/src/spot.rs b/core/dr-pipeline/src/spot.rs index 70a1e4e..1c1c100 100644 --- a/core/dr-pipeline/src/spot.rs +++ b/core/dr-pipeline/src/spot.rs @@ -533,6 +533,19 @@ fn spot_tap(p: vec2) -> vec3 { }", }]; +/// TRACES: FR-DEV-8 +/// How many points around a disc's rim a heal samples. +/// +/// The membrane in [`SPOT_BODY`] is an interpolation of the boundary +/// difference, so this is the resolution of the boundary it sees. Twenty-four +/// puts a sample every fifteen degrees, which on a disc of any size a +/// photographer draws is finer than the tone it is interpolating. +/// +/// A uniform rather than a constant in the source, so tuning it uploads a +/// buffer instead of recompiling — and so a future control could trade it for +/// speed on a large repair without a second shader. +pub const RIM_SAMPLES: f32 = 24.0; + /// The WGSL every spot pass runs. See [`SpotSet::passes`] for the record layout /// it reads, which is where the meaning of each lane is written down. const SPOT_BODY: &str = "\ @@ -565,9 +578,48 @@ for (var i = 0u; i < repairs; i = i + 1u) { // The same displacement within the disc, read from beside it: the patch is // a translation of the photograph, so its texture arrives unrotated and // unscaled. + // // `replacement`, not `patch`: WGSL reserves that word, and a reserved // keyword in generated code is a compile error a long way from its cause. - let replacement = spot_tap(src.xy + delta); + var replacement = spot_tap(src.xy + delta); + + // Heal: carry the source's texture, but the destination's tone. + // + // What a clone gets wrong is not the texture, it is the level. Dust on a + // gradient sky is cloned from a patch a little lighter or darker than the + // hole it fills, and the repair reads as a disc even though every grain in + // it is right. The fix is the difference between the two neighbourhoods, + // interpolated across the disc — a membrane, in the sense the Poisson + // literature means, approximated here in closed form rather than solved. + // + // Solving it properly is tens of Jacobi iterations, and an iteration in + // this architecture is a dispatch: sixty dispatches to remove a dust spot + // is not a frame budget. Interpolating the boundary difference by inverse + // square distance costs one loop over the rim and no state at all, and on + // the case that actually matters — a smooth background, where the + // difference around the rim is near enough constant — it lands on the same + // answer the solve would. + if (src.w > 0.5) { + var weighted = vec3(0.0); + var total = 0.0; + let samples = i32(rim_samples); + for (var k = 0; k < samples; k = k + 1) { + // Offset by half a step so no sample sits exactly on an axis, + // where a rim that crosses a hard edge would align with it. + let angle = (f32(k) + 0.5) * 6.283185307 / rim_samples; + let arm = vec2(cos(angle), sin(angle)) * disc.z; + // What the photograph says here, minus what the source says at the + // matching point of its own rim. + let boundary = spot_tap(disc.xy + arm) - spot_tap(src.xy + arm); + // Inverse square distance, floored so a pixel that lands on a + // sample is a large weight rather than an infinite one. + let w = 1.0 / max(dot(here - (disc.xy + arm), here - (disc.xy + arm)), 1.0); + weighted = weighted + boundary * w; + total = total + w; + } + replacement = replacement + weighted / max(total, 1e-6); + } + c = mix(c, replacement, cover); }"; @@ -669,7 +721,10 @@ impl SpotSet { label: round_label(round), radius: reach.ceil() as u32, wgsl: SPOT_BODY.to_string(), - uniforms: Vec::new(), + uniforms: vec![crate::operation::Uniform { + name: "rim_samples", + value: RIM_SAMPLES, + }], storage, }); } diff --git a/docs/spot-removal.md b/docs/spot-removal.md index 624b1fc..963a3ba 100644 --- a/docs/spot-removal.md +++ b/docs/spot-removal.md @@ -218,7 +218,7 @@ existing luminance helper. `membrane` is what separates the two modes, and it is zero for `Clone`. -### 6.1 Heal, without a Poisson solve +### 6.1 Heal, without a Poisson solve — **implemented** The classic heal is Poisson blending: copy the *gradients* of the source and solve for the image whose gradients they are, subject to matching the @@ -226,32 +226,34 @@ destination on the boundary. Solved properly that is an iterative linear system — tens of Jacobi passes over the disc — and each iteration is a dispatch in this architecture. Sixty dispatches to remove a dust spot is not a frame budget. -The membrane that solve produces is smooth and interpolates the boundary -difference, and there is a closed form that approximates it to a quality nobody -has ever complained about in a photograph: **mean-value seamless cloning** -(Farbman et al., 2009). Sample the difference between destination and source -around the rim of the disc at `K` points, and interpolate those differences into -the interior with mean-value weights: +What that solve produces is a smooth membrane interpolating the boundary +difference, and a membrane can be interpolated directly instead of solved. The +shipped form samples the difference between destination and source at `K` points +around the rim and interpolates them into the interior by inverse square +distance: ``` for k in 0..K: - b_k = tap(rim_k) - tap(rim_k + offset) // boundary difference -membrane = Σ λ_k(p) · b_k // λ from the rim geometry + b_k = tap(rim_k) - tap(rim_k + offset) // boundary difference + w_k = 1 / max(|p - rim_k|², 1) +membrane = Σ w_k·b_k / Σ w_k ``` -For a circular boundary the mean-value weights reduce to something a shader can -evaluate directly — the interior point's angular position and distance to each -rim sample — so a heal costs `K` pairs of taps and a weighted sum, per pixel -*inside the disc only*. No iteration, no extra pass, no state. +`K = 24` — `RIM_SAMPLES` in `core/dr-pipeline/src/spot.rs`, a uniform rather +than a constant in the source, so tuning it uploads a buffer instead of +recompiling. The cost is `2K` bilinear samples per pixel *inside a disc*, and +nothing at all outside one. -What it buys: a patch that carries the source's texture and the destination's -colour and brightness, which is precisely the failure a plain clone has when a -sky is a gradient — the clone is right in texture and a quarter-stop wrong in -tone, and shows as a disc. - -`K = 24` is the starting number. It is a uniform, so it is tunable without a -recompile, and the acceptance test in §12 is a measured one rather than a taste -one. +**What was originally specified here was mean-value seamless cloning** (Farbman +et al., 2009), whose weights are half-angle tangents over the rim rather than +inverse squares. The difference matters when the boundary difference varies +sharply around the rim; on the case that actually arises — a repair on a +smoothly varying background — both reduce to the same answer, and the inverse +square form costs two transcendentals per sample fewer. The measurement in +`core/dr-gpu/tests/spot_removal.rs` is what decides whether that trade stays +good: on a linear ramp steep enough to make a clone wrong by 38 levels out of +255, the heal is wrong by **0**. If a case turns up where it is not, the weights +are four lines and the tests are already written. ### 6.2 Why not compute the boundary statistics on the CPU @@ -487,30 +489,30 @@ surprise inside the existing paste. Each stage is shippable and each has something to look at. Test names are the files they belong in. -**S1 — The model.** `spot.rs` in `dr-pipeline`: `Spot`, `SpotMode`, `SpotSet`, +**S1 — The model.** *(Done.)* `spot.rs` in `dr-pipeline`: `Spot`, `SpotMode`, `SpotSet`, the bounds, id derivation, round grouping (§5.2). No GPU, no UI. *Tests:* `core/dr-pipeline/tests/spots.rs` — id stability across two identical placements, `MAX_SPOTS` refuses rather than drops, overlapping sources produce two rounds, disjoint produce one. -**S2 — Persistence.** Sidecar write, parse, round trip, merge. +**S2 — Persistence.** *(Done.)* Sidecar write, parse, round trip, merge. *Acceptance:* a hand-written sidecar with three spots survives a load/save round trip byte-identically, and two devices that each add a spot offline end with both. -**S3 — The storage binding.** `DetailPass::storage`, the preamble's binding 3, +**S3 — The storage binding.** *(Done.)* `DetailPass::storage`, the preamble's binding 3, the dummy buffer, the two layouts. *Acceptance:* every existing detail test still passes untouched, and a synthetic pass reading the buffer gets what was uploaded. -**S4 — Clone.** The disc, the feather, the bilinear helper, the pass grouping, +**S4 — Clone.** *(Done.)* The disc, the feather, the bilinear helper, the pass grouping, the halo declaration, spliced first into the chain. *Acceptance:* `core/dr-gpu/tests/spot_removal.rs` — a synthetic frame with a black disc on a flat grey field is clean to within a tolerance after one clone spot; the same edit at a one-quarter proxy and at full size land the disc in the same *normalised* place; adding spots does not grow `cached_pipelines()`. -**S5 — Heal.** The mean-value membrane, the mode switch. +**S5 — Heal.** The membrane, the mode switch. **Done** — §6.1, and the measurement came out at 0 levels of error against a clone's 38. *Acceptance:* a dark spot on a linear grey **gradient** — the case clone fails — is clean to within a tolerance, and the residual at the disc boundary is below the residual a clone leaves by an order of magnitude. This is the measurement @@ -544,7 +546,7 @@ means it. ## 14. Open questions -1. **`K = 24`?** Decided by S5's measurement, not here. +1. ~~**`K = 24`?**~~ Settled by S5's measurement: 24 samples, inverse square weights, zero error on the case the mode exists for. 2. **Does the reveal view belong to spot mode only,** or is it a view mode of its own that a photographer can turn on while doing something else? It is cheap to allow both; the risk is a mode nobody remembers turning on. diff --git a/docs/traceability.md b/docs/traceability.md index 14b6908..061fee9 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| | Source files scanned | 232 | -| TRACES tags found | 647 | +| TRACES tags found | 648 | | Requirements defined | 177 | | Requirements covered | 91 | | **Coverage** | **51.4%** (91/177) | @@ -63,8 +63,8 @@ _None._ | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | | FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2818`](../ui/dr-ui/src/develop.rs#L2818), [`ui/dr-ui/src/develop.rs:2828`](../ui/dr-ui/src/develop.rs#L2828), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:2757`](../ui/dr-ui/src/develop.rs#L2757), [`ui/dr-ui/src/develop.rs:2778`](../ui/dr-ui/src/develop.rs#L2778), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1305`](../ui/dr-ui/ui/library.slint#L1305), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:912`](../ui/dr-ui/ui/library.slint#L912), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:109`](../core/dr-pipeline/src/graph.rs#L109), [`core/dr-pipeline/src/graph.rs:187`](../core/dr-pipeline/src/graph.rs#L187), [`core/dr-pipeline/src/graph.rs:587`](../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:517`](../core/dr-pipeline/src/operation.rs#L517), [`core/dr-pipeline/src/sidecar.rs:186`](../core/dr-pipeline/src/sidecar.rs#L186), [`core/dr-pipeline/src/sidecar.rs:330`](../core/dr-pipeline/src/sidecar.rs#L330), [`core/dr-pipeline/src/sidecar.rs:664`](../core/dr-pipeline/src/sidecar.rs#L664), [`core/dr-pipeline/src/sidecar.rs:800`](../core/dr-pipeline/src/sidecar.rs#L800), [`core/dr-pipeline/src/sidecar.rs:854`](../core/dr-pipeline/src/sidecar.rs#L854), [`core/dr-pipeline/src/sidecar.rs:884`](../core/dr-pipeline/src/sidecar.rs#L884), [`core/dr-pipeline/src/spot.rs:107`](../core/dr-pipeline/src/spot.rs#L107), [`core/dr-pipeline/src/spot.rs:143`](../core/dr-pipeline/src/spot.rs#L143), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:341`](../core/dr-pipeline/src/spot.rs#L341), [`core/dr-pipeline/src/spot.rs:426`](../core/dr-pipeline/src/spot.rs#L426), [`core/dr-pipeline/src/spot.rs:575`](../core/dr-pipeline/src/spot.rs#L575), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:449`](../core/dr-pipeline/src/graph.rs#L449), [`core/dr-pipeline/src/graph.rs:479`](../core/dr-pipeline/src/graph.rs#L479), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:654`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L654), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:662`](../core/dr-pipeline/src/ops/local_contrast.rs#L662), [`core/dr-pipeline/src/ops/noise_reduction.rs:695`](../core/dr-pipeline/src/ops/noise_reduction.rs#L695), [`core/dr-pipeline/src/spot.rs:575`](../core/dr-pipeline/src/spot.rs#L575), [`ui/dr-ui/src/develop.rs:2162`](../ui/dr-ui/src/develop.rs#L2162), [`ui/dr-ui/src/develop.rs:2984`](../ui/dr-ui/src/develop.rs#L2984), [`ui/dr-ui/src/develop.rs:3467`](../ui/dr-ui/src/develop.rs#L3467), [`ui/dr-ui/src/develop.rs:3501`](../ui/dr-ui/src/develop.rs#L3501), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:109`](../core/dr-pipeline/src/graph.rs#L109), [`core/dr-pipeline/src/graph.rs:187`](../core/dr-pipeline/src/graph.rs#L187), [`core/dr-pipeline/src/graph.rs:587`](../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:517`](../core/dr-pipeline/src/operation.rs#L517), [`core/dr-pipeline/src/sidecar.rs:186`](../core/dr-pipeline/src/sidecar.rs#L186), [`core/dr-pipeline/src/sidecar.rs:330`](../core/dr-pipeline/src/sidecar.rs#L330), [`core/dr-pipeline/src/sidecar.rs:664`](../core/dr-pipeline/src/sidecar.rs#L664), [`core/dr-pipeline/src/sidecar.rs:800`](../core/dr-pipeline/src/sidecar.rs#L800), [`core/dr-pipeline/src/sidecar.rs:854`](../core/dr-pipeline/src/sidecar.rs#L854), [`core/dr-pipeline/src/sidecar.rs:884`](../core/dr-pipeline/src/sidecar.rs#L884), [`core/dr-pipeline/src/spot.rs:107`](../core/dr-pipeline/src/spot.rs#L107), [`core/dr-pipeline/src/spot.rs:143`](../core/dr-pipeline/src/spot.rs#L143), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:341`](../core/dr-pipeline/src/spot.rs#L341), [`core/dr-pipeline/src/spot.rs:426`](../core/dr-pipeline/src/spot.rs#L426), [`core/dr-pipeline/src/spot.rs:536`](../core/dr-pipeline/src/spot.rs#L536), [`core/dr-pipeline/src/spot.rs:627`](../core/dr-pipeline/src/spot.rs#L627), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:449`](../core/dr-pipeline/src/graph.rs#L449), [`core/dr-pipeline/src/graph.rs:479`](../core/dr-pipeline/src/graph.rs#L479), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:654`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L654), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:662`](../core/dr-pipeline/src/ops/local_contrast.rs#L662), [`core/dr-pipeline/src/ops/noise_reduction.rs:695`](../core/dr-pipeline/src/ops/noise_reduction.rs#L695), [`core/dr-pipeline/src/spot.rs:627`](../core/dr-pipeline/src/spot.rs#L627), [`ui/dr-ui/src/develop.rs:2162`](../ui/dr-ui/src/develop.rs#L2162), [`ui/dr-ui/src/develop.rs:2984`](../ui/dr-ui/src/develop.rs#L2984), [`ui/dr-ui/src/develop.rs:3467`](../ui/dr-ui/src/develop.rs#L3467), [`ui/dr-ui/src/develop.rs:3501`](../ui/dr-ui/src/develop.rs#L3501), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | | FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2207`](../ui/dr-ui/src/develop.rs#L2207), [`ui/dr-ui/src/develop.rs:4569`](../ui/dr-ui/src/develop.rs#L4569), [`ui/dr-ui/src/develop.rs:4601`](../ui/dr-ui/src/develop.rs#L4601), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1326`](../ui/dr-ui/src/lib.rs#L1326), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |