diff --git a/core/dr-gpu/src/detail.rs b/core/dr-gpu/src/detail.rs index 1c417ff..5c70c49 100644 --- a/core/dr-gpu/src/detail.rs +++ b/core/dr-gpu/src/detail.rs @@ -49,6 +49,32 @@ //! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)` //! textures: one for a single-pass operation, two for a separable blur, three //! however long the chain gets after that. +//! +//! # The reduced chain, and why a second one was needed +//! +//! A pass may declare [`dr_pipeline::detail::DetailPass::output_scale`] and +//! write a target a fraction of the render size — clarity's base does, which +//! is what TD-4 bought back. Such a pass cannot be part of the ping-pong +//! above, and the reason is the shape of an unsharp mask rather than anything +//! about textures: the pass that *combines* needs the blur **and** the +//! full-resolution colour, and a colour that has been through a quarter-scale +//! target is no longer full resolution. If the scaled passes wrote into the +//! main chain they would destroy the very thing the last pass is going to +//! subtract from. +//! +//! So there are two chains. The full-resolution one carries the colour and is +//! untouched by a scaled pass; the reduced one carries the base. A scaled pass +//! reads the reduced chain if anything has been written to it and the +//! full-resolution chain otherwise — which is exactly "read what the pass +//! before you wrote", the same rule as before. A full-resolution pass always +//! reads the full-resolution chain, and sees the reduced one through binding 4 +//! as `reduced_at()`. +//! +//! One reduced buffer, not one per operation. Two operations both wanting a +//! reduced base in the same frame would need more, and nothing does: clarity +//! is the only caller and texture's band is a decade finer, so it must stay at +//! full resolution. A `debug_assert` in [`DetailRunner::encode`] holds that +//! claim rather than leaving it as a comment. use std::collections::HashMap; @@ -149,8 +175,14 @@ pub(crate) struct DetailRunner { /// Compiled pipelines by pass structure hash. cache: HashMap, pool: Intermediates, + /// The reduced chain — see the module documentation. Its own pool rather + /// than more slots in `pool`, because its textures are a different size + /// and [`Intermediates::ensure`] drops the lot when the size changes. + reduced: Intermediates, /// See [`placeholder_instances`]. no_instances: wgpu::Buffer, + /// See [`placeholder_reduced`]. + no_reduced: wgpu::TextureView, } struct Layout { @@ -174,6 +206,32 @@ fn placeholder_instances(ctx: &GpuContext) -> wgpu::Buffer { }) } +/// What binding 4 holds for a pass that never calls `reduced_at`. +/// +/// The same trick as [`placeholder_instances`], for the same reason: one bind +/// group layout has to serve a pass that reads the reduced chain and a pass +/// that has never heard of it, and a binding cannot be left unbound. 1x1 and +/// allocated once, so the cost of the arrangement is four bytes for the life +/// of the runner. +fn placeholder_reduced(ctx: &GpuContext) -> wgpu::TextureView { + ctx.device + .create_texture(&wgpu::TextureDescriptor { + label: Some("detail-reduced-placeholder"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: INTERMEDIATE_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()) +} + impl DetailRunner { pub(crate) fn new(ctx: &GpuContext) -> Self { Self { @@ -182,7 +240,9 @@ impl DetailRunner { to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"), cache: HashMap::new(), pool: Intermediates::new(), + reduced: Intermediates::new(), no_instances: placeholder_instances(ctx), + no_reduced: placeholder_reduced(ctx), } } @@ -221,18 +281,93 @@ impl DetailRunner { self.compile(pass)?; } - for (index, pass) in chain.passes.iter().enumerate() { - // Read what the previous pass wrote; write the next slot, or the - // display texture if this is the last one. `index % 2` alternates - // between slots 1 and 2, so a pass never reads the texture it is - // writing — which on a compute pass is not an error the driver - // reports, merely a picture that depends on scheduling. - let source_slot = if index == 0 { 0 } else { 2 - (index % 2) }; - let source = &self.pool.slots[source_slot].view; + // The reduced chain's size, allocated once for the whole chain. + // + // One scale per chain, so the first scaled pass names the only scale + // there is — see the module documentation for why one buffer is + // enough, and the assertion for what would have to change. + if let Some(scale) = chain + .passes + .iter() + .map(|p| p.output_scale) + .find(|&scale| scale > 1) + { + debug_assert!( + chain + .passes + .iter() + .all(|p| p.output_scale == 1 || p.output_scale == scale), + "two reduced scales in one chain, and the runner holds one \ + reduced buffer" + ); + // Two, for the ping-pong the reduce and the two blur halves need. + // A separable blur cannot read the texture it is writing. + self.reduced.ensure( + &self.ctx, + 2, + width.div_ceil(scale).max(1), + height.div_ceil(scale).max(1), + ); + } + + // Where each chain last wrote. `full` starts at slot 0 — what the + // fused colour pass left there — and `carried` starts empty, which is + // what makes the first scaled pass read the colour rather than an + // uninitialised base. + let mut full = 0usize; + let mut full_writes = 0usize; + let mut carried: Option = None; + let mut reduced_writes = 0usize; + + for pass in chain.passes.iter() { + let scaled = pass.output_scale > 1; + + // The last pass carries the output transform into the display + // texture, which is the render size by definition. A scaled pass + // there would bind a shader dispatching over a quarter-size grid + // to a full-size target and write a quarter of the picture — a + // wrong image rather than a validation failure, so it is caught + // here and named. + if scaled && pass.writes_output { + return Err(GpuError::ShaderCompilation(format!( + "detail pass {} declares output_scale {} and is last in \ + the chain; the output transform is written at the render \ + size", + pass.label, pass.output_scale + ))); + } + + let (dispatch_w, dispatch_h) = if scaled { + ( + width.div_ceil(pass.output_scale).max(1), + height.div_ceil(pass.output_scale).max(1), + ) + } else { + (width, height) + }; + + // Read what the previous pass in *this pass's own chain* wrote; + // write the next slot of it, or the display texture if this is the + // last pass. Alternating slots is what stops a pass reading the + // texture it is writing — on a compute pass that is not an error + // the driver reports, merely a picture that depends on scheduling. + let source = match (scaled, carried) { + (true, Some(slot)) => &self.reduced.slots[slot].view, + _ => &self.pool.slots[full].view, + }; let destination = if pass.writes_output { output + } else if scaled { + &self.reduced.slots[reduced_writes % 2].view } else { - &self.pool.slots[1 + (index % 2)].view + &self.pool.slots[1 + (full_writes % 2)].view + }; + // Binding 4. Present for every pass, because one bind group layout + // serves both kinds; a pass that never calls `reduced_at` gets the + // 1x1 placeholder and never reads it. + let reduced_source = match carried { + Some(slot) => &self.reduced.slots[slot].view, + None => &self.no_reduced, }; let layout = if pass.writes_output { &self.to_output @@ -290,6 +425,10 @@ impl DetailRunner { binding: 3, resource: instances.as_entire_binding(), }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(reduced_source), + }, ], }); @@ -304,7 +443,23 @@ impl DetailRunner { }); compute.set_pipeline(pipeline); compute.set_bind_group(0, &bind_group, &[]); - compute.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1); + drop(compute); + + if pass.writes_output { + // Nothing downstream to hand anything to. + } else if scaled { + carried = Some(reduced_writes % 2); + reduced_writes += 1; + } else { + full = 1 + (full_writes % 2); + full_writes += 1; + // A full-resolution pass consumes the reduced chain. It is the + // combine — the base has been subtracted and now lives in the + // colour — so a later operation must not be handed a base + // belonging to this one. + carried = None; + } } Ok(chain.passes.len()) @@ -375,7 +530,10 @@ impl DetailRunner { /// created. For tests — see [`crate::MaskPass::allocations`] for the /// regression this shape of counter exists to catch. pub(crate) fn allocations(&self) -> usize { - self.pool.allocations + // Both pools. A reduced buffer reallocated every frame is exactly the + // regression this counter exists to catch, and counting only the + // full-resolution one would hide it. + self.pool.allocations + self.reduced.allocations } } @@ -439,6 +597,21 @@ impl Layout { // that never reads the buffer costs nothing for it being // bound. storage_entry(3), + // The reduced chain, for a pass that calls `reduced_at`. + // Bound on every layout for the same reason binding 3 is: + // a pass that never reads it costs nothing for it being + // there, and two more layouts would cost a great deal more + // than that. + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, ], }); diff --git a/core/dr-gpu/tests/detail_instances.rs b/core/dr-gpu/tests/detail_instances.rs index ec4d6a8..c508995 100644 --- a/core/dr-gpu/tests/detail_instances.rs +++ b/core/dr-gpu/tests/detail_instances.rs @@ -67,6 +67,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { .to_string(); ComposedDetailPass { + output_scale: 1, label: "test/instances".to_string(), source, uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0], diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs index ce491c3..3468340 100644 --- a/core/dr-gpu/tests/local_contrast.rs +++ b/core/dr-gpu/tests/local_contrast.rs @@ -27,6 +27,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; use dr_pipeline::descriptor::{OpId, ParamId}; +use dr_pipeline::detail::RenderScale; use dr_pipeline::ops::local_contrast::Clarity; use dr_pipeline::{Affects, EditGraph, OutputMode}; use dr_types::ColourSpace; @@ -323,6 +324,87 @@ fn a_proxy_and_an_export_agree_about_the_effect() { ); } +#[test] +fn crossing_the_reduction_threshold_does_not_change_the_picture() { + // TRACES: FR-DSP-3 — `docs/technical-debt.md` TD-4, held in pixels. + // + // Clarity's base is computed on a reduced grid, and how reduced depends on + // the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma + // falls, because a quarter of a small sigma is not a Gaussian any more. + // The whole claim of the optimisation is that this is invisible — that a + // base sampled at a quarter is not an approximation of the full-resolution + // one but the same band-limited function, sampled where it is still fully + // determined. + // + // Every other test in this file measures one form against itself. This is + // the only one that measures the forms against *each other*, and it is the + // one that would fail if the reduction were quietly softening the control, + // shifting it half a reduced pixel, or blocking the base into 4x4 squares. + // + // The two sizes are chosen to sit either side of a step-down: sigma is + // 1.2% of the shorter edge, so 512 gives 6.1 px and reduces by four, while + // 288 gives 3.5 px — under `MIN_REDUCED_SIGMA` once quartered — and + // reduces by two. Asserted rather than assumed, because the whole test is + // vacuous if both sides land on the same reduction. + let Some(ctx) = ctx() else { return }; + assert_eq!( + Clarity::with_amount(100.0).reduction(RenderScale::full((512, 512))), + 4 + ); + assert_eq!( + Clarity::with_amount(100.0).reduction(RenderScale::full((288, 288))), + 2 + ); + + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + // Peak excursion in stops and the fraction of the frame it covers — the + // same two numbers `a_proxy_and_an_export_agree_about_the_effect` uses, + // and for the same reason: both are scale-free, so they are comparable + // between two renders of different sizes. + let measure = |out: u32| -> (f32, f32) { + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, out), + out, + out / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, out), + out, + out / 2, + ); + let moved: Vec = (0..out as usize) + .map(|x| stops(edited[x], plain[x]).abs()) + .collect(); + let peak = moved.iter().cloned().fold(0.0f32, f32::max); + let touched = moved.iter().filter(|m| **m > peak * 0.1).count(); + (peak, touched as f32 / out as f32) + }; + + let (quartered_peak, quartered_reach) = measure(512); + let (halved_peak, halved_reach) = measure(288); + + // The same tolerances the proxy/export test uses. They are not loose: the + // bound this operation guarantees is 0.35 stops, so 0.03 is under a tenth + // of the full excursion. + assert!( + (quartered_peak - halved_peak).abs() < 0.03, + "a quarter-scale base gives {quartered_peak:.3} stops and a half-scale \ + one {halved_peak:.3}; the reduction is supposed to be invisible" + ); + assert!( + (quartered_reach - halved_reach).abs() < 0.02, + "the effect covers {quartered_reach:.3} of the frame reduced by four \ + and {halved_reach:.3} reduced by two; the base has changed width" + ); + assert!( + quartered_peak > 0.1, + "{quartered_peak:.3} stops — two flat images would also agree" + ); +} + #[test] fn texture_acts_at_a_finer_scale_than_clarity() { // The whole reason there are two nodes. If the two controls ever reach the @@ -485,10 +567,15 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() { let mut graph = graph_with(CLARITY, 40.0); render(&mut pass, &graph, &source, 256); assert_eq!(pass.colour_dispatches(), 1); + // Four at this size: reduce, the two blur halves on the reduced grid, and + // the combine. σ is 1.2% of 256 px, so `LocalContrast::reduction` lands on + // a half here rather than the quarter a desktop viewport gets — the number + // is the viewport's, and what this test is about is that it does not + // change while the slider moves. assert_eq!( pass.detail_dispatches(), - 2, - "a separable mask is two passes" + 4, + "a reduced separable mask is four passes" ); let pipelines = pass.cached_detail_pipelines(); @@ -501,17 +588,21 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() { 1, "the fused colour pass re-ran for a change it does not depend on" ); - assert_eq!(pass.detail_dispatches(), 8); + // Four renders of a four-pass chain. The counter accumulates, so this is + // the claim that every one of those renders ran the detail stage and only + // the detail stage. + assert_eq!(pass.detail_dispatches(), 16); assert_eq!( pass.cached_detail_pipelines(), pipelines, "an amount is a uniform, not a shader" ); - // Turning on the other control adds its own pair, and only its own pair. + // Turning on the other control adds its own pair, and only its own pair: + // 16, plus clarity's four again, plus texture's two. graph.set_param(TEXTURE, AMOUNT, 40.0); render(&mut pass, &graph, &source, 256); - assert_eq!(pass.detail_dispatches(), 12); + assert_eq!(pass.detail_dispatches(), 22); assert_eq!(pass.colour_dispatches(), 1); } @@ -536,7 +627,11 @@ fn the_two_controls_stack_without_overwriting_each_other() { graph.set_param(TEXTURE, AMOUNT, 80.0); let mut pass = AdjustPass::new(&ctx); let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2); - assert_eq!(pass.detail_dispatches(), 4); + // Clarity's four plus texture's two. Texture is never reduced — its band + // is a decade finer than clarity's, so a coarser grid could not hold its + // base — and the two operations keeping different pass counts here is that + // asymmetry showing through. + assert_eq!(pass.detail_dispatches(), 6); let edge = (SIZE / 2) as usize; // Both sides of the edge move the way local contrast moves them... diff --git a/core/dr-pipeline/src/detail.rs b/core/dr-pipeline/src/detail.rs index 8026d52..d24f263 100644 --- a/core/dr-pipeline/src/detail.rs +++ b/core/dr-pipeline/src/detail.rs @@ -301,6 +301,40 @@ pub struct DetailPass { /// the kind of artefact that looks like a driver bug. State it honestly. pub radius: u32, + /// How much smaller than the render this pass writes. + /// + /// `1` is the ordinary case and means "the render size", which is what + /// every pass did before this field existed. A larger value writes a + /// target that many times smaller on each axis, into a **second** chain + /// held alongside the full-resolution one — see [`Self::wgsl`] for how the + /// two are addressed, and the module documentation for why there are two. + /// + /// # Why a pass may want this + /// + /// A blur wide enough to be a *base* — clarity's is 1.2% of the frame, + /// 52 render pixels at 4K — holds no spatial frequency a quarter-scale + /// grid cannot represent. Computing it at the render size therefore buys + /// nothing and costs everything: 105 taps over 8.3 M pixels, twice, which + /// measured at 34 ms and is where `docs/technical-debt.md` TD-4 came from. + /// At a quarter it is a sixteenth of the pixels at a quarter of the + /// radius, and the result is not an approximation of the full-resolution + /// base — it is the same band-limited function, sampled where it is still + /// Nyquist-safe. + /// + /// [`Self::radius`] stays in this pass's **own** pixels, so a pass at + /// scale 4 with a radius of 13 declares 13, not 52. The halo it implies + /// for a tile scheduler is `radius * output_scale`, and + /// [`ComposedDetail::radius`] is what performs that multiplication — + /// stating the radius in the grid the loop actually runs in is what keeps + /// the shader and the declaration the same number. + /// + /// **Never the last pass.** The final pass carries the output transform + /// and writes the display texture, which is full resolution by + /// definition; a scaled pass in that position is a codegen bug and + /// `dr-gpu` refuses it rather than binding a shader to a target of the + /// wrong size. + pub output_scale: u32, + /// The WGSL body. /// /// Reads and writes `c`, a `vec3` of **linear sRGB**, pre-loaded with @@ -319,6 +353,19 @@ pub struct DetailPass { /// pixel that survives to the next pass**, pre-loaded with what the /// previous pass left there and written back out unless the body /// assigns it. + /// - `reduced_at(coord) -> f32` — the **reduced chain's** scalar at this + /// pixel, + /// bilinearly upsampled. Zero unless a scaled pass ran earlier in this + /// operation; see [`Self::output_scale`]. + /// + /// `coord` is always in *this pass's own* output grid, and `tap` maps it + /// into the source's grid for you. A pass at [`Self::output_scale`] 4 + /// therefore addresses its own quarter-size target with `coord`, while + /// `tap(coord, offset)` offsets in **source** pixels — which is what lets + /// a reduce pass average the 4 x 4 block a single output pixel covers by + /// looping `offset` over it. Where source and target are the same size the + /// mapping is the identity, so every pass written before scaling existed + /// behaves exactly as it did. /// /// # Why `aux` exists /// @@ -424,6 +471,8 @@ pub struct ComposedDetailPass { pub storage: Vec<[f32; 4]>, /// See [`DetailPass::radius`]. pub radius: u32, + /// See [`DetailPass::output_scale`]. + pub output_scale: u32, /// Whether this pass writes the display/export texture rather than another /// linear intermediate. /// @@ -457,8 +506,18 @@ impl ComposedDetail { } /// The widest halo any pass needs, in render pixels (ARCH §5.3). + /// + /// A pass declares its radius in its own grid, so a scaled pass's has to + /// be multiplied back up before the maxima are comparable: 13 reduced + /// pixels at scale 4 reach exactly as far across the photograph as 52 + /// render pixels do, and a scheduler comparing the two unscaled would size + /// a halo at a quarter of what the pass actually reads. pub fn radius(&self) -> u32 { - self.passes.iter().map(|p| p.radius).max().unwrap_or(0) + self.passes + .iter() + .map(|p| p.radius.saturating_mul(p.output_scale)) + .max() + .unwrap_or(0) } } @@ -572,6 +631,7 @@ pub fn compose_detail_with( RESOLVE_ID, &[], &DetailPass { + output_scale: 1, label: "resolve", radius: 0, wgsl: String::new(), @@ -723,6 +783,25 @@ struct Params {{ // pass leaves this bound to a single empty element and never looks at it. See // `DetailPass::storage` for why the list is not in the uniform block. @group(0) @binding(3) var instances: array>; +// The reduced chain — what a scaled pass most recently wrote, at whatever +// fraction of the render size it declared. Bound to a 1x1 placeholder for +// every pass that never calls `base`, so that one bind group layout serves a +// pass which uses it and a pass which has never heard of it. +@group(0) @binding(4) var reduced: texture_2d; + +// Where in `source` this output pixel begins. +// +// The ratio is 1 whenever a pass writes what it reads, which is every pass +// that does not set `output_scale` — the multiply and the divide cancel +// exactly, so the ordinary case is unchanged and pays two integer operations +// for the privilege. A scaled pass gets the top-left of the block it covers, +// which is what makes `tap`'s offsets mean *source* pixels and lets a reduce +// pass walk its own footprint. +fn source_origin(coord: vec2) -> vec2 {{ + let src = vec2(textureDimensions(source)); + let dst = vec2(textureDimensions(output)); + return coord * src / max(dst, vec2(1)); +}} // A neighbour, clamped to the edge of the image. // @@ -732,13 +811,48 @@ struct Params {{ // classic way a first convolution goes wrong. fn tap(coord: vec2, offset: vec2) -> vec3 {{ let last = vec2(textureDimensions(source)) - vec2(1); - return textureLoad(source, clamp(coord + offset, vec2(0), last), 0).rgb; + let at = source_origin(coord) + offset; + return textureLoad(source, clamp(at, vec2(0), last), 0).rgb; }} // The same neighbour's scratch lane — see `aux` in the body below. fn tap_aux(coord: vec2, offset: vec2) -> f32 {{ let last = vec2(textureDimensions(source)) - vec2(1); - return textureLoad(source, clamp(coord + offset, vec2(0), last), 0).a; + let at = source_origin(coord) + offset; + return textureLoad(source, clamp(at, vec2(0), last), 0).a; +}} + +// The reduced chain, read at this pass's own resolution. +// +// Named `reduced_at` rather than `base` because `base` is a natural local in a +// body that has just computed one — spot removal already has such a local, and +// a function shadowed by a variable is a compile error a long way from its +// cause. +// +// Bilinear, and on pixel *centres* rather than corners: the reduce pass took +// its sample at the centre of the block it averaged, so an upsample that +// treated the grids as corner-aligned would shift the base by half a reduced +// pixel — two full pixels at scale 4, which on a wide unsharp mask is a base +// offset from the image it is subtracted from, and reads as a directional +// smear along every edge. +// +// Nearest would be cheaper and is not enough: the base is subtracted from the +// full-resolution image, so any blockiness in it appears in the *difference* +// at full contrast. That is a visible 4-pixel grid over the whole frame. +fn reduced_at(coord: vec2) -> f32 {{ + let rd = vec2(textureDimensions(reduced)); + let dst = vec2(max(textureDimensions(output), vec2(1u))); + let p = (vec2(coord) + vec2(0.5)) * rd / dst - vec2(0.5); + let last = vec2(rd) - vec2(1); + let base_px = vec2(floor(p)); + let f = fract(p); + + let s00 = textureLoad(reduced, clamp(base_px, vec2(0), last), 0).a; + let s10 = textureLoad(reduced, clamp(base_px + vec2(1, 0), vec2(0), last), 0).a; + let s01 = textureLoad(reduced, clamp(base_px + vec2(0, 1), vec2(0), last), 0).a; + let s11 = textureLoad(reduced, clamp(base_px + vec2(1, 1), vec2(0), last), 0).a; + + return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y); }} {helper_src}{encode_fn} @@ -786,6 +900,10 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ uniforms: uniform_values, storage: pass.storage.clone(), radius: pass.radius, + // Clamped rather than trusted: a zero would divide by nothing in the + // dispatch size and a declaration is data, which since FR-PLG-2 can + // come from a file this build did not write. + output_scale: pass.output_scale.max(1), writes_output, structure_hash, } diff --git a/core/dr-pipeline/src/detail/probe.rs b/core/dr-pipeline/src/detail/probe.rs index f5c6c73..d188c98 100644 --- a/core/dr-pipeline/src/detail/probe.rs +++ b/core/dr-pipeline/src/detail/probe.rs @@ -142,6 +142,7 @@ impl DetailStage for BoxBlur { .iter() .enumerate() .map(|(axis, _)| DetailPass { + output_scale: 1, label: if axis == 0 { "horizontal" } else { "vertical" }, radius: r, // A convolution, not a list: nothing to bind at binding 3. diff --git a/core/dr-pipeline/src/ops/capture_sharpen.rs b/core/dr-pipeline/src/ops/capture_sharpen.rs index 6bda4d2..6739e83 100644 --- a/core/dr-pipeline/src/ops/capture_sharpen.rs +++ b/core/dr-pipeline/src/ops/capture_sharpen.rs @@ -359,6 +359,7 @@ impl DetailStage for CaptureSharpen { ["horizontal", "vertical"] .into_iter() .map(|label| DetailPass { + output_scale: 1, label, radius: extent, // A convolution, not a list: nothing to bind at binding 3. @@ -412,6 +413,7 @@ impl DetailStage for CaptureSharpen { /// texture, which is a rounding error against the dispatches around it. fn nothing_to_sharpen() -> DetailPass { DetailPass { + output_scale: 1, label: "unresolved", // Reads only the pixel it writes, so a tile needs no halo at all. radius: 0, diff --git a/core/dr-pipeline/src/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs index b030a9d..3808b27 100644 --- a/core/dr-pipeline/src/ops/local_contrast.rs +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -141,16 +141,35 @@ //! # What this costs //! //! Clarity's kernel is large — of the order of a hundred taps per pass at -//! preview resolution — and the two passes are the honest, exact separable -//! Gaussian rather than a sparse approximation of one. A strided kernel would -//! be several times cheaper and is deliberately not taken: undersampling an -//! image that is not band-limited aliases high-frequency content down into the -//! base, the base is then subtracted, and the aliasing arrives in the output as +//! preview resolution — and each half is the honest, exact separable Gaussian +//! rather than a sparse approximation of one. A strided kernel would be +//! several times cheaper and is deliberately not taken: undersampling an image +//! that is not band-limited aliases high-frequency content down into the base, +//! the base is then subtracted, and the aliasing arrives in the output as //! low-frequency mottling across smooth gradients. Mottled skies are precisely -//! the artefact this control must not have. The right optimisation is a base -//! computed at reduced resolution, which needs a detail stage that can write a -//! smaller target than it reads; that is a change to [`crate::detail`], not to -//! this file. +//! the artefact this control must not have. +//! +//! Run at the render size, that measured **34 ms at 4K** — seven times the +//! entire fused point chain, for one slider — which is `docs/technical-debt.md` +//! TD-4 and is what [`Recipe::base_scale`] now answers. The base is computed on +//! a grid a quarter the size on each axis: a sixteenth of the pixels at a +//! quarter of the radius. +//! +//! **This is not the strided kernel wearing a hat**, and the difference is +//! exactly the paragraph above. A stride samples an image that is not band- +//! limited and aliases; the reduction *band-limits first* — that is what the +//! `reduce` pass is for and why it is a separate dispatch — and only then +//! samples. What is thrown away is content the base could not represent at any +//! resolution, because a Gaussian at σ = 26 px has nothing above one cycle per +//! 26 px in it and the quarter-scale grid carries one cycle per 8 px. So the +//! reduced base is not an approximation of the full-resolution base; it is the +//! same band-limited function, sampled where it is still fully determined. +//! +//! Which is also why [`LocalContrast::reduction`] steps down and why texture +//! never reduces at all. The argument holds only while the reduced grid can +//! still carry the Gaussian, and the moment it cannot, the honest answer is +//! the full-resolution one — which is the cheap case anyway, because the +//! viewport that produced it is small. use std::marker::PhantomData; use std::sync::{Arc, LazyLock}; @@ -176,6 +195,14 @@ pub const AMOUNT: ParamId = ParamId("amount"); /// pipeline. const TRUNCATION: f32 = 2.0; +/// The smallest σ, in reduced pixels, worth running a Gaussian over. +/// +/// One pixel, which with [`TRUNCATION`] is a five-tap kernel — the narrowest +/// that still has a shape. Below it the weights collapse towards a single tap +/// and the blur that survives is the reduce pass's box, which is a different +/// filter with a different edge response. See [`LocalContrast::reduction`]. +const MIN_REDUCED_SIGMA: f32 = 1.0; + /// Everything that makes one of these two controls the control it is. /// /// A struct rather than four associated constants so that the differences @@ -200,6 +227,15 @@ pub struct Recipe { /// (4) — true for clarity, false for texture, and that asymmetry is /// deliberate. midtone_taper: bool, + /// The most this band's base may be shrunk before it is blurred. + /// + /// A ceiling, not the answer — [`LocalContrast::reduction`] steps it down + /// on a viewport too small to carry it. `1` refuses the optimisation + /// outright, which is the only correct value for a band whose σ is already + /// a few pixels. + /// + /// Must be a power of two: the step-down halves. + base_scale: u32, } /// The band of spatial frequencies a control acts on. @@ -235,6 +271,22 @@ impl Band for Coarse { threshold: 0.35, gain: 1.0, midtone_taper: true, + // A quarter, which is what `docs/technical-debt.md` TD-4 bought back. + // + // σ is 1.2% of the shorter edge — 26 px at 4K — so the base holds no + // spatial frequency anywhere near the quarter-scale Nyquist of one + // cycle per 8 px. Computing it there is not an approximation of the + // full-resolution base; it is the same band-limited function sampled + // where it is still fully determined. What it costs is a sixteenth of + // the pixels at a quarter of the radius, about a sixty-fourth of the + // work, against the 34 ms this control measured at 4K. + // + // Not an eighth. σ/8 is 3.2 px at 4K and under two on a 1080p + // viewport, which is where the reduce pass's own box filter starts + // doing more of the blurring than the Gaussian does — and the halo + // behaviour this operation is careful about is a property of the + // Gaussian. + base_scale: 4, }; } @@ -256,6 +308,13 @@ impl Band for Fine { // equal numbers on the two sliders should land at comparable strength. gain: 1.25, midtone_taper: false, + // Never reduced, and this is the reason the scale belongs to the band + // rather than to the stage. Texture's σ is a decade finer — 2.6 px at + // 4K — so a quarter-scale grid would not hold its base at all: the + // reduce pass's 4x4 box is already wider than the Gaussian it would be + // prefiltering, and what came back would be a blur of the wrong width + // rather than a cheaper blur of the right one. + base_scale: 1, }; } @@ -385,6 +444,32 @@ impl LocalContrast { (self.sigma(scale) * TRUNCATION).round().max(0.0) as u32 } + /// TRACES: FR-DSP-3 + /// The factor this render's base is computed at — 1 meaning "the render + /// size", as everything did before TD-4. + /// + /// [`Recipe::base_scale`] is a ceiling rather than the answer, because a + /// reduced grid still has to hold a Gaussian. At a quarter of a small + /// viewport clarity's σ falls under a pixel, and a kernel of one or two + /// taps is not a Gaussian — it is the reduce pass's own box filter with a + /// rounding error on top, which would make the control change character on + /// a window resize rather than merely get cheaper. + /// + /// So the reduction steps down by halves until the reduced σ is worth + /// convolving: a quarter on a desktop viewport, a half on a small one, + /// none on a thumbnail. Stepping down rather than switching off keeps most + /// of the saving in the middle of the range, and the case it gives up on + /// is the one that was already cheap — the cost is `radius x pixels` and a + /// small viewport is small in both. + pub fn reduction(&self, scale: RenderScale) -> u32 { + let sigma = self.sigma(scale); + let mut reduction = B::RECIPE.base_scale.max(1); + while reduction > 1 && sigma / (reduction as f32) < MIN_REDUCED_SIGMA { + reduction /= 2; + } + reduction + } + /// Stops of local contrast at this slider position. fn gain(&self) -> f32 { self.amount / 100.0 * B::RECIPE.gain @@ -481,27 +566,134 @@ impl DetailStage for LocalContrast { value: self.gain(), }); + let reduction = self.reduction(scale); + if reduction == 1 { + // The full-resolution form, unchanged: blur x into the scratch + // lane, then finish along y and apply the mask in one pass. + return vec![ + DetailPass { + output_scale: 1, + label: "base", + radius, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), + uniforms: shape, + wgsl: BASE_X.to_string(), + }, + DetailPass { + output_scale: 1, + label: "combine", + radius, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), + uniforms: combine, + wgsl: combine_body(B::RECIPE.midtone_taper, Base::Convolved), + }, + ]; + } + + // The reduced form. Four passes rather than two, and cheaper than the + // two by a factor of about `reduction²`: three of them run on a grid + // that many times smaller on each axis, and the one that does not is a + // single bilinear read. + // + // The σ and the radius are the band's own, divided — not recomputed + // from `RenderScale`, which knows nothing about this grid. Deriving + // them from the numbers the full-resolution path uses is what keeps + // the two forms the same filter, so that crossing the threshold in + // `reduction` does not change the picture. + let reduced_sigma = sigma / reduction as f32; + // At least one tap either side. `reduction` has already guaranteed + // σ >= MIN_REDUCED_SIGMA, so this floor is a belt on top of a brace. + let reduced_radius = ((reduced_sigma * TRUNCATION).round() as u32).max(1); + let reduced_shape = vec![ + Uniform { + name: "radius", + value: reduced_radius as f32, + }, + Uniform { + name: "inv_variance", + value: 1.0 / (reduced_sigma * reduced_sigma), + }, + ]; + + // The combining pass no longer convolves anything, so it needs neither + // the radius nor the variance — only the two numbers the unsharp mask + // itself is made of. + let mask = vec![ + Uniform { + name: "threshold", + value: B::RECIPE.threshold, + }, + Uniform { + name: "gain", + value: self.gain(), + }, + ]; + vec![ DetailPass { - label: "base", - radius, - // A convolution, not a list: nothing to bind at binding 3. + output_scale: reduction, + label: "reduce", + // Reads only the block it writes, so it reaches no further + // than the pixel it is producing and a tile needs no halo for + // it. The halo the *chain* needs comes from the blurs below. + radius: 0, storage: Vec::new(), - uniforms: shape, - wgsl: BASE_X.to_string(), + uniforms: vec![Uniform { + name: "reduction", + value: reduction as f32, + }], + wgsl: REDUCE.to_string(), }, DetailPass { - label: "combine", - radius, - // A convolution, not a list: nothing to bind at binding 3. + output_scale: reduction, + label: "base-x", + radius: reduced_radius, storage: Vec::new(), - uniforms: combine, - wgsl: combine_body(B::RECIPE.midtone_taper), + uniforms: reduced_shape.clone(), + wgsl: reduced_blur(Axis::X), + }, + DetailPass { + output_scale: reduction, + label: "base-y", + radius: reduced_radius, + storage: Vec::new(), + uniforms: reduced_shape, + wgsl: reduced_blur(Axis::Y), + }, + DetailPass { + output_scale: 1, + label: "combine", + // One bilinear read of the reduced chain, which reaches one + // reduced pixel — `reduction` render pixels — around itself. + // Stated rather than left at zero because an understated + // radius is a tile seam, and a seam is worth more than the + // three lines it costs to be accurate here. + radius: reduction, + storage: Vec::new(), + uniforms: mask, + wgsl: combine_body(B::RECIPE.midtone_taper, Base::Reduced), }, ] } } +/// Which way a separable half runs. +enum Axis { + X, + Y, +} + +/// Where the combining pass finds the base it subtracts. +enum Base { + /// Convolved along y by the combining pass itself, out of the scratch + /// lane the previous pass wrote. The full-resolution form. + Convolved, + /// Already finished, on the reduced chain, and read back up. + Reduced, +} + /// Half of the base, along x. /// /// Deliberately does not touch `c`: the pass after this one needs the @@ -533,13 +725,114 @@ for (var i = -r; i <= r; i = i + 1) { // exist. aux = sum / weight;"; +/// Band-limit the image onto the reduced grid, in log luminance. +/// +/// A pass of its own rather than something the first blur half does on the +/// way past, because it is a different filter doing a different job: this one +/// exists so that the Gaussian's *input* is representable on the coarse grid. +/// Sampling every fourth pixel instead would alias — a shimmer that changes +/// when the viewport is resized, which is the classic way a mip-based blur +/// goes wrong and is very hard to attribute to a clarity slider. +/// +/// A box over exactly the block the output pixel covers. Not a wider or +/// prettier prefilter: the Gaussian that follows is 8σ wide on this grid, so +/// what a better prefilter would buy is a correction of a fraction of a +/// reduced pixel to a curve four pixels across, and it would cost taps on the +/// only pass here that reads the full-resolution image. +/// +/// **In log luminance, not linear.** The base is a mean of logarithms — that +/// is what makes `detail` a ratio and the whole operation exposure-invariant +/// (see the module documentation, halo control 1). Averaging linear values +/// here and taking the logarithm later is a different number, and the +/// difference is precisely the local contrast this operation exists to +/// measure: it would be quietly subtracted out of every block. +const REDUCE: &str = "\ +// The colour rides through untouched, as it does in every pass of this +// operation — though here it is untouched and also unused: the reduced chain +// carries a scalar, and the colour the combining pass subtracts from is the +// full-resolution one it reads from the other chain. +let s = i32(reduction); +var sum = 0.0; +for (var y = 0; y < s; y = y + 1) { + for (var x = 0; x < s; x = x + 1) { + sum = sum + log_luma(tap(coord, vec2(x, y))); + } +} +aux = sum / f32(s * s);"; + +/// One half of the reduced separable Gaussian. +/// +/// Reads the scratch lane rather than the colour, which is the one line that +/// differs from [`BASE_X`]: by this point the log-luminance conversion has +/// already been done, once, by the reduce pass. Doing it again per tap would +/// be a logarithm inside the inner loop for a value that cannot have changed. +fn reduced_blur(axis: Axis) -> String { + let offset = match axis { + Axis::X => "vec2(i, 0)", + Axis::Y => "vec2(0, i)", + }; + format!( + "\ +// Half of a separable Gaussian over the reduced base, in log luminance. +// +// Weights are evaluated rather than tabulated, as in `BASE_X` and for the same +// reason — and here the loop is a quarter as long, which is the whole point. +let r = i32(radius); +var sum = 0.0; +var weight = 0.0; +for (var i = -r; i <= r; i = i + 1) {{ + let f = f32(i); + let w = exp(-0.5 * f * f * inv_variance); + sum = sum + w * tap_aux(coord, {offset}); + weight = weight + w; +}} +// Normalised by the weights actually summed, so a kernel clamped at the +// border averages the pixels that exist rather than fading towards zero. +aux = sum / weight;" + ) +} + /// The second pass: finish the base along y, then apply the mask. /// /// Generated rather than constant because the midtone taper is present for /// clarity and absent for texture. Emitting the line only where it applies /// keeps texture's shader honest about not having one, and saves it a uniform /// and two helper functions it would never call. -fn combine_body(midtone_taper: bool) -> String { +fn combine_body(midtone_taper: bool, base: Base) -> String { + // Where the base comes from — the one thing that differs between the two + // forms. Everything below this line is the unsharp mask itself, written + // once, so the reduced form cannot drift into being a different operation + // from the full-resolution one it replaces. + let base = match base { + Base::Convolved => "\ +// The other half of the base, then the unsharp mask itself. +// +// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still +// the colour the colour pass produced — which is the arrangement that makes an +// unsharp mask expressible in a chain that hands on one texture per pass. +let r = i32(radius); +var sum = 0.0; +var weight = 0.0; +for (var i = -r; i <= r; i = i + 1) { + let f = f32(i); + let w = exp(-0.5 * f * f * inv_variance); + sum = sum + w * tap_aux(coord, vec2(0, i)); + weight = weight + w; +} +let base = sum / weight;" + .to_string(), + Base::Reduced => "\ +// The base, finished on the reduced chain and read back up bilinearly. `c` is +// the full-resolution colour, straight off the other chain — which is why the +// reduced passes had to leave that chain alone, and why this pass reads two +// textures rather than one. +// +// One read where the full-resolution form runs a 105-tap convolution. That +// difference *is* TD-4. +let base = reduced_at(coord);" + .to_string(), + }; + let weight = if midtone_taper { "\n\ // Clarity only: tapered to nothing at both ends of the range. See\n\ @@ -556,21 +849,7 @@ fn combine_body(midtone_taper: bool) -> String { format!( "\ -// The other half of the base, then the unsharp mask itself. -// -// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still -// the colour the colour pass produced — which is the arrangement that makes an -// unsharp mask expressible in a chain that hands on one texture per pass. -let r = i32(radius); -var sum = 0.0; -var weight = 0.0; -for (var i = -r; i <= r; i = i + 1) {{ - let f = f32(i); - let w = exp(-0.5 * f * f * inv_variance); - sum = sum + w * tap_aux(coord, vec2(0, i)); - weight = weight + w; -}} -let base = sum / weight; +{base} // Local contrast, in **stops**. Both terms are logarithms, so this is a ratio: // `detail` says how much brighter this pixel is than its surroundings, and @@ -632,14 +911,34 @@ mod tests { // forty-pixel blur for a control sitting at zero. let scale = RenderScale::full((2000, 1500)); let only_clarity = composed(50.0, 0.0, scale); - assert_eq!(only_clarity.len(), 2, "one operation, two passes"); + // Four at this viewport, because clarity's base is computed reduced + // here — reduce, two blur halves, combine. The number is the band's + // and the viewport's, not a constant; what this test is about is that + // *all* of them belong to clarity. + assert_eq!(only_clarity.len(), 4, "one operation, its own passes"); assert!(only_clarity .passes .iter() .all(|p| p.label.starts_with("clarity/"))); + // Both on: clarity's four plus texture's two. Texture stays at the + // render size whatever the viewport — its band is a decade finer, so + // a reduced grid could not hold its base — and that asymmetry is the + // reason the scale belongs to the band rather than to the stage. let both = composed(50.0, 50.0, scale); - assert_eq!(both.len(), 4); + assert_eq!(both.len(), 6); + assert_eq!( + both.passes + .iter() + .filter(|p| p.label.starts_with("texture/")) + .count(), + 2 + ); + assert!(both + .passes + .iter() + .filter(|p| p.label.starts_with("texture/")) + .all(|p| p.output_scale == 1)); } #[test] @@ -759,21 +1058,46 @@ mod tests { // passes on one texture per pass. If the blur pass ever writes `c`, // the combining pass has nothing to subtract the base *from* and the // operation silently becomes a blur. - let passes = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500))); - let base = &passes[0]; - let combine = &passes[1]; + // Both forms, because they are two chains and the property has to + // hold in each. A thumbnail is too small to carry a reduced base and + // takes the two-pass path; a desktop viewport takes the four-pass one. + for scale in [ + RenderScale::full((160, 120)), + RenderScale::full((2000, 1500)), + ] { + let passes = Clarity::with_amount(50.0).passes(scale); + let (combine, blurs) = passes.split_last().expect("clarity is active"); - assert!(base.wgsl.contains("aux = sum / weight;")); + for blur in blurs { + assert!( + !blur.wgsl.contains("c = "), + "{} must leave the colour alone: {}", + blur.label, + blur.wgsl + ); + assert!( + blur.wgsl.contains("aux ="), + "{} must put its result in the scratch lane", + blur.label + ); + } + + // However the base was arrived at, the mask subtracts it from the + // colour the *colour* pass produced. That is the whole property: + // an unsharp mask needs the blur and the original together, and + // neither chain may have overwritten the original on the way. + assert!(combine.wgsl.contains("log_luma(c) - base")); + } + + // And the two forms differ in exactly one place — where `base` came + // from. A reduced combine does no convolution at all. + let reduced = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500))); + let combine = reduced.last().expect("clarity is active"); + assert!(combine.wgsl.contains("let base = reduced_at(coord);")); assert!( - !base.wgsl.contains("c = "), - "the blur pass must leave the colour alone: {}", - base.wgsl + !combine.wgsl.contains("tap_aux("), + "a reduced combine has nothing left to convolve" ); - assert!( - combine.wgsl.contains("tap_aux("), - "the combining pass must read the blur out of the scratch lane" - ); - assert!(combine.wgsl.contains("log_luma(c) - base")); } #[test] @@ -795,8 +1119,29 @@ mod tests { let clarity = Clarity::with_amount(50.0).kernel(scale); assert_eq!(composed.radius(), clarity, "the widest pass sets the halo"); for pass in &composed.passes { + // The reduce pass is the one honest exception: it reads exactly + // the block it writes and no further, so a tile computing it needs + // no halo at all. Every other pass reaches somewhere and must say + // so. + if pass.label.ends_with("/reduce") { + assert_eq!(pass.radius, 0, "the reduce pass reads only its own block"); + continue; + } assert!(pass.radius > 0, "{} declared no reach", pass.label); } + + // The equality above is the claim worth restating: a reduced base + // reaches exactly as far across the photograph as the full-resolution + // one it replaces. `radius x output_scale`, 9 x 4 against 36, which is + // what makes the reduction invisible to a tile scheduler. + let reduced = Clarity::with_amount(50.0); + assert_eq!(reduced.reduction(scale), 4); + let base_x = composed + .passes + .iter() + .find(|p| p.label.ends_with("/base-x")) + .expect("a reduced chain has an x half"); + assert_eq!(base_x.radius * base_x.output_scale, clarity); } #[test] @@ -805,7 +1150,8 @@ mod tests { // saturates at the threshold, so the largest overshoot a full-travel // slider can produce is `gain * threshold` stops however violent the // edge — a bound that holds by construction rather than by tuning. - let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; + let passes = Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500))); + let combine = passes.last().expect("clarity is active"); assert!(combine .wgsl .contains("threshold * tanh(detail / threshold)")); @@ -824,7 +1170,8 @@ mod tests { // Both at full travel, which is what makes them a bound and not a // measurement: no picture, and no edge in any picture, can produce more. assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6); - let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; + let texture_passes = Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500))); + let texture = texture_passes.last().expect("texture is active"); assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6); } @@ -834,8 +1181,10 @@ mod tests { // highlight and fabric in a shadow, which is exactly where clarity // must not. let scale = RenderScale::full((2000, 1500)); - let clarity = &Clarity::with_amount(50.0).passes(scale)[1]; - let texture = &Texture::with_amount(50.0).passes(scale)[1]; + let clarity_passes = Clarity::with_amount(50.0).passes(scale); + let texture_passes = Texture::with_amount(50.0).passes(scale); + let clarity = clarity_passes.last().expect("clarity is active"); + let texture = texture_passes.last().expect("texture is active"); assert!(clarity.wgsl.contains("midtone_weight(luminance(c))")); assert!(!texture.wgsl.contains("midtone_weight")); @@ -865,15 +1214,24 @@ mod tests { let up = Clarity::with_amount(50.0).passes(scale); let down = Clarity::with_amount(-50.0).passes(scale); let gain = |p: &[DetailPass]| { - p[1].uniforms + p.last() + .expect("clarity is active") + .uniforms .iter() .find(|u| u.name == "gain") .unwrap() .value }; assert!((gain(&up) + gain(&down)).abs() < 1e-6); - // Same kernel either way — the direction is a sign, not a scale. - assert_eq!(up[0].radius, down[0].radius); + // Same kernel either way — the direction is a sign, not a scale. Read + // off the blur halves rather than the first pass, which since the + // reduced base exists is a downscale carrying no radius at all. + let radii = |p: &[DetailPass]| { + p.iter() + .map(|d| (d.radius, d.output_scale)) + .collect::>() + }; + assert_eq!(radii(&up), radii(&down)); } #[test] diff --git a/core/dr-pipeline/src/ops/noise_reduction.rs b/core/dr-pipeline/src/ops/noise_reduction.rs index e35afcb..d1c30e6 100644 --- a/core/dr-pipeline/src/ops/noise_reduction.rs +++ b/core/dr-pipeline/src/ops/noise_reduction.rs @@ -437,6 +437,7 @@ impl DetailStage for NoiseReduction { let luma = self.luminance_kernel(scale); if luma > 0 { passes.push(DetailPass { + output_scale: 1, label: "luminance", radius: luma, // A convolution, not a list: nothing to bind at binding 3. @@ -473,6 +474,7 @@ impl DetailStage for NoiseReduction { // from the other and the result acquires a diagonal bias. for (index, (sx, sy)) in [(1.0, 0.0), (0.0, 1.0)].into_iter().enumerate() { passes.push(DetailPass { + output_scale: 1, label: if index == 0 { "chroma-horizontal" } else { diff --git a/core/dr-pipeline/src/spot.rs b/core/dr-pipeline/src/spot.rs index 0a96031..fe80bc7 100644 --- a/core/dr-pipeline/src/spot.rs +++ b/core/dr-pipeline/src/spot.rs @@ -764,6 +764,7 @@ impl SpotSet { } passes.push(DetailPass { + output_scale: 1, label: round_label(round), radius: reach.ceil() as u32, wgsl: SPOT_BODY.to_string(), diff --git a/docs/traceability.md b/docs/traceability.md index 595e606..da501cd 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 | 290 | -| TRACES tags found | 843 | +| TRACES tags found | 845 | | Requirements defined | 179 | | Requirements covered | 107 | | **Coverage** | **59.8%** (107/179) | @@ -57,11 +57,11 @@ _None._ | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2849`](../ui/dr-ui/src/library.rs#L2849), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`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:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1461`](../ui/dr-ui/src/lib.rs#L1461), [`ui/dr-ui/src/lib.rs:2162`](../ui/dr-ui/src/lib.rs#L2162), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/app.slint:793`](../ui/dr-ui/ui/app.slint#L793), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`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:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1461`](../ui/dr-ui/src/lib.rs#L1461), [`ui/dr-ui/src/lib.rs:2162`](../ui/dr-ui/src/lib.rs#L2162), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1766`](../ui/dr-ui/ui/app.slint#L1766), [`ui/dr-ui/ui/app.slint:793`](../ui/dr-ui/ui/app.slint#L793), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:616`](../ui/dr-ui/src/lib.rs#L616), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | | FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4636`](../ui/dr-ui/src/develop.rs#L4636) | -| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | | FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2136`](../ui/dr-ui/src/lib.rs#L2136), [`ui/dr-ui/src/lib.rs:549`](../ui/dr-ui/src/lib.rs#L549), [`ui/dr-ui/src/lib.rs:607`](../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/library.rs:497`](../ui/dr-ui/src/library.rs#L497), [`ui/dr-ui/src/library.rs:745`](../ui/dr-ui/src/library.rs#L745), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2256`](../ui/dr-ui/ui/app.slint#L2256), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:205`](../core/dr-pipeline/src/framing.rs#L205), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:927`](../core/dr-pipeline/src/framing.rs#L927), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1992`](../ui/dr-ui/src/develop.rs#L1992), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | @@ -69,9 +69,9 @@ _None._ | FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | | 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:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1376`](../ui/dr-ui/src/lib.rs#L1376), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:487`](../ui/dr-ui/src/library.rs#L487), [`ui/dr-ui/src/library_ui.rs:2592`](../ui/dr-ui/src/library_ui.rs#L2592), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:549`](../ui/dr-ui/src/settings_ui.rs#L549), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | | FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1447`](../ui/dr-ui/src/lib.rs#L1447), [`ui/dr-ui/src/lib.rs:2332`](../ui/dr-ui/src/lib.rs#L2332), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| 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:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`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), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1468`](../ui/dr-ui/src/lib.rs#L1468), [`ui/dr-ui/src/lib.rs:2429`](../ui/dr-ui/src/lib.rs#L2429), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1671`](../ui/dr-ui/ui/app.slint#L1671), [`ui/dr-ui/ui/app.slint:1844`](../ui/dr-ui/ui/app.slint#L1844), [`ui/dr-ui/ui/app.slint:2162`](../ui/dr-ui/ui/app.slint#L2162), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| 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:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`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:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`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:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:757`](../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/lib.rs:816`](../ui/dr-ui/src/lib.rs#L816) | -| FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:387`](../core/dr-gpu/src/detail.rs#L387), [`core/dr-gpu/src/detail.rs:592`](../core/dr-gpu/src/detail.rs#L592), [`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:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`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), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1468`](../ui/dr-ui/src/lib.rs#L1468), [`ui/dr-ui/src/lib.rs:2429`](../ui/dr-ui/src/lib.rs#L2429), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1671`](../ui/dr-ui/ui/app.slint#L1671), [`ui/dr-ui/ui/app.slint:1844`](../ui/dr-ui/ui/app.slint#L1844), [`ui/dr-ui/ui/app.slint:2162`](../ui/dr-ui/ui/app.slint#L2162), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| 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:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`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:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`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:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`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:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:757`](../ui/dr-ui/src/lib.rs#L757), [`ui/dr-ui/src/lib.rs:816`](../ui/dr-ui/src/lib.rs#L816) | +| FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1424`](../ui/dr-ui/src/lib.rs#L1424), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635) | | 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:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1524`](../ui/dr-ui/src/lib.rs#L1524), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`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) |