Take the tone from the hole and the texture from beside it

A clone gets the texture right and the level wrong. Dust on a gradient sky
is copied from a patch a little lighter than the hole it fills, and the
repair reads as a disc even though every grain in it is correct — which is
why FR-DEV-8 asks for heal and not only for clone.

Heal adds the membrane: the difference between the two neighbourhoods,
sampled at twenty-four points around the rim and interpolated across the
disc by inverse square distance. Solving the Poisson problem properly is
tens of Jacobi iterations, and an iteration here is a dispatch — sixty
dispatches to remove a dust spot is not a frame budget. The closed form
costs one loop over the rim, no state, and no second pass.

The spec called for mean-value weights; inverse squares are two
transcendentals per sample cheaper and agree wherever the boundary
difference varies smoothly, which is every repair anyone makes. What
decides whether that trade holds is the measurement, so the measurement is
the test: on a ramp steep enough to leave a clone wrong by 38 levels out
of 255, the heal is wrong by 0. docs/spot-removal.md §6.1 records what
shipped and what it would take to go back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:22:10 +02:00
co-authored by Claude Opus 5
parent 5323608051
commit f00b3ae924
4 changed files with 233 additions and 32 deletions
+144
View File
@@ -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<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;
// 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"
);
}
+57 -2
View File
@@ -533,6 +533,19 @@ fn spot_tap(p: vec2<f32>) -> vec3<f32> {
}",
}];
/// 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<f32>(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<f32>(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,
});
}
+29 -27
View File
@@ -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.
+3 -3
View File
@@ -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) |