From c50d96e94977be56b2aaa36a0adab2c41ff6141a Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 26 Sep 2026 22:04:05 -0400 Subject: [PATCH] Repair hot and dead photosites before the demosaic A hot photosite went into the demosaic as it was read, and came out as a coloured cross three pixels wide that nothing later could take back out. Night and long exposures showed them; the defect-map reader added for FR-RAW-3 was never wired in, and a CR2 carries no map anyway. A pass over the mosaic now runs ahead of the demosaic, into a second buffer. A photosite is hot when it reads more than twice every same-colour photosite in its 5x5 window plus 2% of the range, and more than twice each of its eight immediate neighbours of any colour. The second half keeps stars and glints: real light reaches the sensor through a lens and an anti-aliasing filter and lights a patch, so the photosites beside it are lit too, where a hot photosite's are dark. It is replaced by its brightest same-colour neighbour, which invents nothing. Dead photosites are the mirror case, judged only where the neighbourhood is above 5%, so shadow noise clipped at black is left alone. The colour of each photosite comes from a 6x6 sensor-anchored tile, so Bayer and X-Trans share the pass. Export and every other path that demosaics get it too, and there is no setting: the repair only fires where a single photosite disagrees with everything around it. Cost, warm, on a Canon 6D frame (RTX 3050): 91-99 ms to demosaic before, 94-98 ms after; the extra pass is inside the run-to-run noise. Tests render a frame with and without the defect and compare the finished pixels. Without the repair a hot photosite showed by 230 and a dead one by 168; with it neither shows, and a 3x3 highlight at white survives. --- core/dr-gpu/src/demosaic.rs | 256 +++++++++++++++++++++++- core/dr-gpu/src/shaders/hot_pixels.wgsl | 185 +++++++++++++++++ core/dr-gpu/tests/hot_pixels.rs | 142 +++++++++++++ docs/dev/traceability.md | 12 +- 4 files changed, 588 insertions(+), 7 deletions(-) create mode 100644 core/dr-gpu/src/shaders/hot_pixels.wgsl create mode 100644 core/dr-gpu/tests/hot_pixels.rs diff --git a/core/dr-gpu/src/demosaic.rs b/core/dr-gpu/src/demosaic.rs index 9e15f1a..26ab581 100644 --- a/core/dr-gpu/src/demosaic.rs +++ b/core/dr-gpu/src/demosaic.rs @@ -57,6 +57,32 @@ struct XTransParams { tile: [u32; 4], } +/// Uniform block for the hot-pixel repair. Layout must match +/// `hot_pixels.wgsl`. +/// +/// One block for both colour filter arrays: the repair asks only "which +/// photosites share this one's colour", and a 6×6 tile answers that for a +/// Bayer cell as well as for X-Trans. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct HotPixelParams { + crop_x: u32, + crop_y: u32, + width: u32, + height: u32, + stride: u32, + words: u32, + row_invocations: u32, + samples: u32, + black: [f32; 4], + inv_range: [f32; 4], + tile: [u32; 4], +} + +/// The repair's workgroup width. Must match `@workgroup_size` in +/// `hot_pixels.wgsl`. +const HOT_PIXEL_GROUP: u32 = 64; + /// A demosaiced image living on the GPU. /// /// RGBA16Float, scene-referred, camera colour space. This is the input every @@ -437,6 +463,8 @@ pub struct Demosaicer { pipeline: wgpu::ComputePipeline, xtrans_pipeline: wgpu::ComputePipeline, bind_group_layout: wgpu::BindGroupLayout, + hot_pixel_pipeline: wgpu::ComputePipeline, + hot_pixel_layout: wgpu::BindGroupLayout, } impl Demosaicer { @@ -527,11 +555,15 @@ impl Demosaicer { cache: None, }); + let (hot_pixel_pipeline, hot_pixel_layout) = hot_pixel_pipeline(ctx); + Ok(Self { ctx: ctx.clone(), pipeline, xtrans_pipeline, bind_group_layout, + hot_pixel_pipeline, + hot_pixel_layout, }) } @@ -558,8 +590,12 @@ impl Demosaicer { // the buffer outlive the `if` that chose them. let bayer_params; let xtrans_params; + // Kept for the hot-pixel repair: finding the X-Trans phase reads the + // whole frame on the CPU, and once per photograph is enough. + let mut xtrans_tile = None; let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() { xtrans_params = xtrans_params_for(raw, width, height); + xtrans_tile = Some(xtrans_params.tile); (&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params)) } else { let pattern = match raw.cfa_pattern { @@ -598,6 +634,64 @@ impl Demosaicer { usage: wgpu::BufferUsages::STORAGE, }); + // TRACES: FR-RAW-3 + // The mosaic the demosaic actually reads: the readout with its hot and + // dead photosites repaired. A second buffer rather than in place, + // because every photosite's verdict reads its neighbours' originals. + let repaired = self.ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("raw-repaired"), + size: raw_buf.size(), + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + let words = packed.len() as u32; + let groups = words.div_ceil(HOT_PIXEL_GROUP).max(1); + // A 24 MP readout is 190,000 workgroups, past the 65,535 one + // dispatch dimension may hold, so the grid folds into rows. + let groups_x = groups.min( + self.ctx + .device + .limits() + .max_compute_workgroups_per_dimension, + ); + let groups_y = groups.div_ceil(groups_x); + let hot_params = hot_pixel_params( + raw, + (width, height), + words, + groups_x * HOT_PIXEL_GROUP, + xtrans_tile, + ); + let hot_params_buf = + self.ctx + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hot-pixel-params"), + contents: bytemuck::bytes_of(&hot_params), + usage: wgpu::BufferUsages::UNIFORM, + }); + let hot_bind_group = self + .ctx + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("hot-pixel-bg"), + layout: &self.hot_pixel_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: raw_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: hot_params_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: repaired.as_entire_binding(), + }, + ], + }); + let params_buf = self .ctx .device @@ -636,7 +730,7 @@ impl Demosaicer { entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: raw_buf.as_entire_binding(), + resource: repaired.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, @@ -655,6 +749,18 @@ impl Demosaicer { .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("demosaic-encoder"), }); + // Two passes in one submission. wgpu orders a storage write in one + // pass before a read of the same buffer in the next, so the demosaic + // sees every repair. + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("hot-pixel-pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&self.hot_pixel_pipeline); + pass.set_bind_group(0, &hot_bind_group, &[]); + pass.dispatch_workgroups(groups_x, groups_y, 1); + } { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("demosaic-pass"), @@ -960,6 +1066,136 @@ fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) { /// TRACES: FR-RAW-5 /// Everything the X-Trans shader needs about one image. +/// TRACES: FR-RAW-3 +/// The hot-pixel repair's pipeline and its three bindings: the readout, the +/// uniform block, and the repaired copy it writes. +fn hot_pixel_pipeline(ctx: &GpuContext) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) { + let shader = ctx + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("hot-pixels"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/hot_pixels.wgsl").into()), + }); + let storage = |binding, read_only| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + let layout = ctx + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("hot-pixel-bgl"), + entries: &[ + storage(0, true), + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + storage(2, false), + ], + }); + let pipeline_layout = ctx + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hot-pixel-layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = ctx + .device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("hot-pixel-pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + (pipeline, layout) +} + +/// The colour of each position of a Bayer cell, row-major, for the pattern +/// the decoder reported: 0=R, 1=G, 2=B. `None` for anything that is not a +/// 2×2 pattern. +fn bayer_cell(pattern: CfaPattern) -> Option<[u32; 4]> { + match pattern { + CfaPattern::Rggb => Some([0, 1, 1, 2]), + CfaPattern::Bggr => Some([2, 1, 1, 0]), + CfaPattern::Grbg => Some([1, 0, 2, 1]), + CfaPattern::Gbrg => Some([1, 2, 0, 1]), + _ => None, + } +} + +/// A Bayer cell as the 6×6 sensor-anchored tile the repair indexes. +/// +/// The decoder's pattern is phased for the *crop* origin, and the tile is +/// indexed by sensor coordinate, so each position is shifted by the crop. +/// Six is even, so a column's parity modulo 6 is its parity outright and the +/// cell repeats cleanly. +fn pack_bayer_tile(cell: [u32; 4], crop_x: u32, crop_y: u32) -> [u32; 4] { + let mut out = [0u32; 4]; + for row in 0..6u32 { + for col in 0..6u32 { + let i = (((row + crop_y) & 1) * 2 + ((col + crop_x) & 1)) as usize; + out[(row >> 1) as usize] |= cell[i] << ((row & 1) * 12 + col * 2); + } + } + out +} + +/// The repair's uniforms for one readout. +/// +/// `xtrans_tile` is the tile the X-Trans demosaic was given, when it was one; +/// anything else must be a Bayer pattern, which `run` has already checked. +fn hot_pixel_params( + raw: &RawImage, + (width, height): (u32, u32), + words: u32, + row_invocations: u32, + xtrans_tile: Option<[u32; 4]>, +) -> HotPixelParams { + let (black, inv_range, tile) = match (xtrans_tile, bayer_cell(raw.cfa_pattern)) { + (Some(tile), _) => { + let (black, inv_range) = xtrans_levels(raw); + ([black; 4], [inv_range; 4], tile) + } + (None, Some(cell)) => ( + black_per_cell(raw), + inv_range_per_cell(raw), + pack_bayer_tile(cell, raw.crop.x, raw.crop.y), + ), + // Not reached from `run`, which refuses any other pattern before + // this. A zero tile judges every photosite against all of its + // neighbours, which is right for a sensor with no colour filter. + (None, None) => (black_per_cell(raw), inv_range_per_cell(raw), [0; 4]), + }; + HotPixelParams { + crop_x: raw.crop.x, + crop_y: raw.crop.y, + width, + height, + stride: raw.width, + words, + row_invocations, + samples: raw.data.len() as u32, + black, + inv_range, + tile, + } +} + fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams { let (black, inv_range) = xtrans_levels(raw); let wb = wb_gains(raw); @@ -994,6 +1230,24 @@ mod tests { } } + /// The repair's tile is indexed by sensor coordinate, the decoder's + /// pattern by crop coordinate. A crop at an odd origin must shift one + /// into the other, or the repair compares red with green. + #[test] + fn the_bayer_tile_is_anchored_to_the_sensor_not_the_crop() { + let cell = bayer_cell(CfaPattern::Rggb).unwrap(); + let colour = |tile: [u32; 4], x: u32, y: u32| { + (tile[((y % 6) >> 1) as usize] >> (((y % 6) & 1) * 12 + (x % 6) * 2)) & 3 + }; + for (cx, cy) in [(0, 0), (1, 0), (0, 1), (1, 1), (7, 4)] { + let tile = pack_bayer_tile(cell, cx, cy); + // Red is the crop's first photosite, wherever the crop starts. + assert_eq!(colour(tile, cx, cy), 0, "crop at ({cx}, {cy})"); + assert_eq!(colour(tile, cx + 1, cy + 1), 2, "crop at ({cx}, {cy})"); + assert_eq!(colour(tile, cx + 1, cy), 1, "crop at ({cx}, {cy})"); + } + } + #[test] fn unclamped_half_keeps_shadows_signs_and_highlights() { // A 14-bit LSB, normalised: subnormal in f16, and must not be zero. diff --git a/core/dr-gpu/src/shaders/hot_pixels.wgsl b/core/dr-gpu/src/shaders/hot_pixels.wgsl new file mode 100644 index 0000000..9181a5a --- /dev/null +++ b/core/dr-gpu/src/shaders/hot_pixels.wgsl @@ -0,0 +1,185 @@ +// Hot and dead photosite repair, on the raw mosaic, before demosaic. +// +// A hot photosite reads far above anything the light put there — a leaky +// well, lit by its own dark current on a long or high-ISO exposure. Left in, +// the demosaic spreads it into its neighbours' interpolated channels and it +// becomes a coloured cross, three pixels wide, that no later stage can take +// back out: by then it is five pixels of plausible colour rather than one +// photosite of nonsense. So it is repaired here, where it is still one value. +// +// **What counts as hot.** A photosite far above *every* photosite of its own +// colour in its 5x5 window, and also far above every one of its eight +// immediate neighbours whatever their colour. The second half is what keeps a +// star or a glint: real light arrives through a lens and an anti-aliasing +// filter, so even the sharpest point lands on a patch of photosites, and the +// ones beside it are lit too. A hot photosite's neighbours are as dark as the +// rest of the frame. Dead photosites are the mirror image and are handled the +// same way. +// +// **What it becomes.** The brightest (for a hot photosite) or darkest (for a +// dead one) same-colour neighbour — the value nearest to what it read that +// the neighbourhood can vouch for. An average would soften the one case this +// gets wrong, a real highlight that happened to pass both tests; a clamp to +// the neighbourhood's range cannot invent anything. +// +// Written for either colour filter array: the colour of a photosite comes from +// a 6x6 tile anchored to the sensor, which holds the X-Trans pattern as it is +// and a Bayer 2x2 cell repeated nine times. + +struct HotPixelParams { + // The cropped area, in sensor photosites. Only photosites inside it are + // judged, and only photosites inside it are asked as neighbours — the + // masked border sits at black and would make everything look hot. + crop_x: u32, + crop_y: u32, + width: u32, + height: u32, + // Row stride of the readout, in samples, and the number of u32 words. + stride: u32, + words: u32, + // How many invocations one row of the dispatch grid holds, so a frame + // wider than a dispatch dimension can be addressed as two. + row_invocations: u32, + // Samples in the readout. One less than twice `words` when the count is + // odd, and the padding half of the last word is never judged. + samples: u32, + // Per-position black levels and reciprocal ranges, indexed by the + // photosite's parity within the *crop*: (y&1)*2 + (x&1) counted from its + // origin, as the demosaic counts them. + black: vec4, + inv_range: vec4, + // The 6x6 colour tile, two bits per photosite, indexed by sensor + // coordinate modulo 6: word k holds row 2k in its low 12 bits and row + // 2k+1 in the next 12. The fourth word is padding. + tile: vec4, +} + +@group(0) @binding(0) var raw: array; +@group(0) @binding(1) var params: HotPixelParams; +@group(0) @binding(2) var repaired: array; + +// How far above its brightest neighbour a photosite must read to be hot, as a +// ratio and a margin in normalised units. Twice the neighbourhood and two +// percent of the range above it: far enough that shot noise in a lit area +// never qualifies, near enough that a hot photosite in a night sky — reading +// a third of the range over a sky at one percent — always does. +const HOT_RATIO: f32 = 2.0; +const HOT_MARGIN: f32 = 0.02; +// A dead photosite reads under half its darkest neighbour, and only counts +// where that neighbour is at least this bright: in the shadows, a photosite +// at zero is noise that clipped at the black point, not a defect. +const DEAD_RATIO: f32 = 0.5; +const DEAD_FLOOR: f32 = 0.05; + +fn value_at(index: u32) -> u32 { + let word = raw[index >> 1u]; + return select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u); +} + +fn colour_at(sx: u32, sy: u32) -> u32 { + let row = sy % 6u; + let col = sx % 6u; + let word = params.tile[row >> 1u]; + return (word >> ((row & 1u) * 12u + col * 2u)) & 3u; +} + +// A raw value against its own black level and range. Compared rather than +// stored, so it is left unclamped at the top: a hot photosite above white is +// still more above white than its neighbours are. +fn level(sx: u32, sy: u32, v: u32) -> f32 { + let cell = ((sy - params.crop_y) & 1u) * 2u + ((sx - params.crop_x) & 1u); + return max(f32(v) - params.black[cell], 0.0) * params.inv_range[cell]; +} + +// The value to store for the photosite at `index`. +fn repair(index: u32) -> u32 { + let v = value_at(index); + let sx = index % params.stride; + let sy = index / params.stride; + if (sx < params.crop_x || sy < params.crop_y + || sx >= params.crop_x + params.width || sy >= params.crop_y + params.height) { + return v; + } + + let centre = level(sx, sy, v); + let colour = colour_at(sx, sy); + + var same_hi = -1.0; + var same_lo = 1.0e9; + var same_hi_raw = v; + var same_lo_raw = v; + var same_count = 0u; + var adjacent_hi = 0.0; + var adjacent_lo = 1.0e9; + + for (var dy = -2; dy <= 2; dy++) { + for (var dx = -2; dx <= 2; dx++) { + if (dx == 0 && dy == 0) { + continue; + } + let nx = i32(sx) + dx; + let ny = i32(sy) + dy; + if (nx < i32(params.crop_x) || ny < i32(params.crop_y) + || nx >= i32(params.crop_x + params.width) + || ny >= i32(params.crop_y + params.height)) { + continue; + } + let nsx = u32(nx); + let nsy = u32(ny); + let nv = value_at(nsy * params.stride + nsx); + let n = level(nsx, nsy, nv); + + if (abs(dx) <= 1 && abs(dy) <= 1) { + adjacent_hi = max(adjacent_hi, n); + adjacent_lo = min(adjacent_lo, n); + } + if (colour_at(nsx, nsy) == colour) { + same_count += 1u; + if (n > same_hi) { + same_hi = n; + same_hi_raw = nv; + } + if (n < same_lo) { + same_lo = n; + same_lo_raw = nv; + } + } + } + } + + // A corner of the crop can leave a photosite with a single same-colour + // neighbour, and one witness is not a neighbourhood. + if (same_count < 2u) { + return v; + } + + let hot_line_same = same_hi * HOT_RATIO + HOT_MARGIN; + let hot_line_adjacent = adjacent_hi * HOT_RATIO + HOT_MARGIN; + if (centre > hot_line_same && centre > hot_line_adjacent) { + return same_hi_raw; + } + if (same_lo >= DEAD_FLOOR && centre < same_lo * DEAD_RATIO + && centre < adjacent_lo * DEAD_RATIO) { + return same_lo_raw; + } + return v; +} + +// One invocation per u32 word: two photosites, packed as the demosaic reads +// them. A word may straddle two rows when the stride is odd, which `repair` +// does not mind — it addresses by sample index. +@compute @workgroup_size(64, 1, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + let word = gid.y * params.row_invocations + gid.x; + if (word >= params.words) { + return; + } + let lo = repair(word * 2u); + // The padding half of an odd-length readout is copied, not judged: it is + // not a photosite, and the demosaic never addresses it. + var hi = raw[word] >> 16u; + if (word * 2u + 1u < params.samples) { + hi = repair(word * 2u + 1u); + } + repaired[word] = (lo & 0xFFFFu) | (hi << 16u); +} diff --git a/core/dr-gpu/tests/hot_pixels.rs b/core/dr-gpu/tests/hot_pixels.rs new file mode 100644 index 0000000..0262ee5 --- /dev/null +++ b/core/dr-gpu/tests/hot_pixels.rs @@ -0,0 +1,142 @@ +//! TRACES: FR-RAW-3 +//! Hot and dead photosite repair, end to end on a device. +//! +//! Each test renders a frame twice — once with a defect, once without — and +//! compares the finished pixels. That is the only comparison that means +//! anything: the repair happens on the mosaic, and what a photographer would +//! see of a defect it missed is the coloured cross the demosaic makes of it. + +use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; +use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; +use dr_pipeline::EditGraph; + +const SIZE: u32 = 36; +const WHITE: u16 = 4095; + +fn ctx() -> Option { + pollster::block_on(GpuContext::new_headless()).ok() +} + +/// A flat frame at `level`, with `set` applied to its photosites. +fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage { + let mut data = vec![level; (SIZE * SIZE) as usize]; + for &(x, y, v) in set { + data[(y * SIZE + x) as usize] = v; + } + RawImage { + width: SIZE, + height: SIZE, + data, + cfa_pattern: pattern, + black_level: [0; 4], + white_level: WHITE, + wb_coeffs: [1.0, 1.0, 1.0, 1.0], + color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), + base_curve: BaseCurve::IDENTITY, + samples_per_pixel: 1, + profile: None, + make: String::new(), + model: String::new(), + crop: CropRect { + x: 0, + y: 0, + width: SIZE, + height: SIZE, + }, + } +} + +fn render(ctx: &GpuContext, raw: &RawImage) -> Vec { + let source = Demosaicer::new(ctx) + .expect("demosaicer") + .run(raw) + .expect("demosaic"); + let shader = EditGraph::default_chain().compose(); + let mut adjust = AdjustPass::new(ctx); + adjust.render(&source, &shader, SIZE, SIZE).expect("render"); + adjust.export_pixels().expect("readback").0 +} + +/// The largest channel difference between two renders. +fn worst(a: &[u8], b: &[u8]) -> u8 { + a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap() +} + +const MIDDLE: u32 = SIZE / 2; + +/// **The feature.** A photosite at white in a dark frame — a hot pixel in a +/// night sky — leaves no trace in the rendered picture. +#[test] +fn a_hot_photosite_in_a_dark_frame_is_invisible() { + let Some(ctx) = ctx() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[])); + for (x, y) in [ + (MIDDLE, MIDDLE), + (MIDDLE + 1, MIDDLE), + (MIDDLE + 1, MIDDLE + 1), + ] { + let hot = render(&ctx, &frame(CfaPattern::Rggb, 40, &[(x, y, WHITE)])); + let diff = worst(&clean, &hot); + assert!( + diff <= 1, + "a hot photosite at ({x}, {y}) still shows, by {diff}" + ); + } +} + +/// The same for one stuck dark in a lit area. +#[test] +fn a_dead_photosite_in_a_lit_frame_is_invisible() { + let Some(ctx) = ctx() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let clean = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[])); + let dead = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[(MIDDLE, MIDDLE, 0)])); + let diff = worst(&clean, &dead); + assert!(diff <= 1, "a dead photosite still shows, by {diff}"); +} + +/// **What it must not eat.** A point of real light lands on a patch of +/// photosites, not one — so a 3×3 highlight survives, even at its brightest. +#[test] +fn a_small_real_highlight_survives() { + let Some(ctx) = ctx() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut star = Vec::new(); + for dy in 0..3 { + for dx in 0..3 { + star.push((MIDDLE - 1 + dx, MIDDLE - 1 + dy, WHITE)); + } + } + let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[])); + let lit = render(&ctx, &frame(CfaPattern::Rggb, 40, &star)); + let at = ((MIDDLE * SIZE + MIDDLE) * 4 + 1) as usize; + assert!( + lit[at] > clean[at] + 100, + "the highlight was repaired away: {} against a background of {}", + lit[at], + clean[at] + ); +} + +/// The Fujifilm path goes through the same repair, with its own tile. +#[test] +fn a_hot_photosite_on_x_trans_is_invisible() { + let Some(ctx) = ctx() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let clean = render(&ctx, &frame(CfaPattern::XTrans, 40, &[])); + let hot = render( + &ctx, + &frame(CfaPattern::XTrans, 40, &[(MIDDLE, MIDDLE, WHITE)]), + ); + let diff = worst(&clean, &hot); + assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}"); +} diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index aa774c4..79cb0f5 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 465 | -| TRACES tags found | 1893 | +| Source files scanned | 467 | +| TRACES tags found | 1896 | | Requirements defined | 192 | | Requirements deferred (post-v1) | 24 | | Requirements covered | 161 | @@ -79,7 +79,7 @@ _None._ | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:276`](../../core/dr-pipeline/src/descriptor.rs#L276), [`core/dr-pipeline/src/framing.rs:555`](../../core/dr-pipeline/src/framing.rs#L555), [`core/dr-pipeline/src/graph.rs:60`](../../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/operation.rs:391`](../../core/dr-pipeline/src/operation.rs#L391), [`ui/dr-ui/ui/app.slint:2964`](../../ui/dr-ui/ui/app.slint#L2964) | | FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:393`](../../core/dr-pipeline/src/graph.rs#L393), [`core/dr-pipeline/src/graph.rs:47`](../../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/mask.rs:1746`](../../core/dr-pipeline/src/mask.rs#L1746), [`core/dr-pipeline/src/preset.rs:927`](../../core/dr-pipeline/src/preset.rs#L927), [`ui/dr-ui/src/develop/rows.rs:928`](../../ui/dr-ui/src/develop/rows.rs#L928) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1057`](../../core/dr-gpu/src/adjust.rs#L1057), [`core/dr-gpu/src/adjust.rs:114`](../../core/dr-gpu/src/adjust.rs#L114), [`core/dr-gpu/src/adjust.rs:1297`](../../core/dr-gpu/src/adjust.rs#L1297), [`core/dr-gpu/src/adjust.rs:1391`](../../core/dr-gpu/src/adjust.rs#L1391), [`core/dr-gpu/src/adjust.rs:84`](../../core/dr-gpu/src/adjust.rs#L84), [`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:468`](../../core/dr-pipeline/src/framing.rs#L468), [`core/dr-pipeline/src/graph.rs:997`](../../core/dr-pipeline/src/graph.rs#L997), [`core/dr-pipeline/src/operation.rs:32`](../../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:429`](../../core/dr-pipeline/src/operation.rs#L429), [`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:127`](../../core/dr-decode/src/lib.rs#L127), [`core/dr-decode/src/lib.rs:777`](../../core/dr-decode/src/lib.rs#L777), [`core/dr-decode/src/lib.rs:817`](../../core/dr-decode/src/lib.rs#L817), [`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:473`](../../core/dr-decode/src/profile.rs#L473), [`core/dr-decode/src/profile.rs:507`](../../core/dr-decode/src/profile.rs#L507), [`core/dr-decode/src/profile.rs:678`](../../core/dr-decode/src/profile.rs#L678), [`core/dr-gpu/src/adjust.rs:1278`](../../core/dr-gpu/src/adjust.rs#L1278), [`core/dr-gpu/src/adjust.rs:37`](../../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/demosaic.rs:142`](../../core/dr-gpu/src/demosaic.rs#L142), [`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:2240`](../../core/dr-pipeline/src/operation.rs#L2240), [`core/dr-pipeline/src/operation.rs:2323`](../../core/dr-pipeline/src/operation.rs#L2323), [`core/dr-pipeline/src/operation.rs:2348`](../../core/dr-pipeline/src/operation.rs#L2348), [`core/dr-pipeline/src/operation.rs:2363`](../../core/dr-pipeline/src/operation.rs#L2363), [`core/dr-pipeline/src/operation.rs:2387`](../../core/dr-pipeline/src/operation.rs#L2387), [`core/dr-pipeline/src/operation.rs:336`](../../core/dr-pipeline/src/operation.rs#L336), [`core/dr-pipeline/src/operation.rs:528`](../../core/dr-pipeline/src/operation.rs#L528), [`core/dr-pipeline/src/operation.rs:538`](../../core/dr-pipeline/src/operation.rs#L538), [`core/dr-pipeline/src/operation.rs:832`](../../core/dr-pipeline/src/operation.rs#L832) | +| 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:127`](../../core/dr-decode/src/lib.rs#L127), [`core/dr-decode/src/lib.rs:777`](../../core/dr-decode/src/lib.rs#L777), [`core/dr-decode/src/lib.rs:817`](../../core/dr-decode/src/lib.rs#L817), [`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:473`](../../core/dr-decode/src/profile.rs#L473), [`core/dr-decode/src/profile.rs:507`](../../core/dr-decode/src/profile.rs#L507), [`core/dr-decode/src/profile.rs:678`](../../core/dr-decode/src/profile.rs#L678), [`core/dr-gpu/src/adjust.rs:1278`](../../core/dr-gpu/src/adjust.rs#L1278), [`core/dr-gpu/src/adjust.rs:37`](../../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/demosaic.rs:112`](../../core/dr-gpu/src/demosaic.rs#L112), [`core/dr-gpu/src/demosaic.rs:168`](../../core/dr-gpu/src/demosaic.rs#L168), [`core/dr-gpu/tests/base_curve.rs:1`](../../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:2240`](../../core/dr-pipeline/src/operation.rs#L2240), [`core/dr-pipeline/src/operation.rs:2323`](../../core/dr-pipeline/src/operation.rs#L2323), [`core/dr-pipeline/src/operation.rs:2348`](../../core/dr-pipeline/src/operation.rs#L2348), [`core/dr-pipeline/src/operation.rs:2363`](../../core/dr-pipeline/src/operation.rs#L2363), [`core/dr-pipeline/src/operation.rs:2387`](../../core/dr-pipeline/src/operation.rs#L2387), [`core/dr-pipeline/src/operation.rs:336`](../../core/dr-pipeline/src/operation.rs#L336), [`core/dr-pipeline/src/operation.rs:528`](../../core/dr-pipeline/src/operation.rs#L528), [`core/dr-pipeline/src/operation.rs:538`](../../core/dr-pipeline/src/operation.rs#L538), [`core/dr-pipeline/src/operation.rs:832`](../../core/dr-pipeline/src/operation.rs#L832) | | FR-DEV-3f | [`core/dr-film/src/bake.rs:161`](../../core/dr-film/src/bake.rs#L161), [`core/dr-film/src/bake.rs:384`](../../core/dr-film/src/bake.rs#L384), [`core/dr-film/src/bake.rs:52`](../../core/dr-film/src/bake.rs#L52), [`core/dr-film/src/bake.rs:662`](../../core/dr-film/src/bake.rs#L662), [`core/dr-film/src/bake.rs:689`](../../core/dr-film/src/bake.rs#L689), [`core/dr-film/src/bake.rs:710`](../../core/dr-film/src/bake.rs#L710), [`core/dr-film/src/bake.rs:747`](../../core/dr-film/src/bake.rs#L747), [`core/dr-film/src/bake.rs:95`](../../core/dr-film/src/bake.rs#L95), [`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:152`](../../core/dr-gpu/src/adjust.rs#L152), [`core/dr-gpu/src/adjust.rs:422`](../../core/dr-gpu/src/adjust.rs#L422), [`core/dr-gpu/src/adjust.rs:601`](../../core/dr-gpu/src/adjust.rs#L601), [`core/dr-gpu/src/adjust.rs:731`](../../core/dr-gpu/src/adjust.rs#L731), [`core/dr-gpu/src/adjust.rs:77`](../../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:1`](../../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-gpu/tests/film_sim.rs:214`](../../core/dr-gpu/tests/film_sim.rs#L214), [`core/dr-gpu/tests/film_sim.rs:348`](../../core/dr-gpu/tests/film_sim.rs#L348), [`core/dr-gpu/tests/film_sim.rs:387`](../../core/dr-gpu/tests/film_sim.rs#L387), [`core/dr-gpu/tests/film_sim.rs:416`](../../core/dr-gpu/tests/film_sim.rs#L416), [`core/dr-gpu/tests/film_sim.rs:456`](../../core/dr-gpu/tests/film_sim.rs#L456), [`core/dr-pipeline/src/graph.rs:103`](../../core/dr-pipeline/src/graph.rs#L103), [`core/dr-pipeline/src/graph.rs:168`](../../core/dr-pipeline/src/graph.rs#L168), [`core/dr-pipeline/src/graph.rs:566`](../../core/dr-pipeline/src/graph.rs#L566), [`core/dr-pipeline/src/mask.rs:2452`](../../core/dr-pipeline/src/mask.rs#L2452), [`core/dr-pipeline/src/mask.rs:2485`](../../core/dr-pipeline/src/mask.rs#L2485), [`core/dr-pipeline/src/operation.rs:1322`](../../core/dr-pipeline/src/operation.rs#L1322), [`core/dr-pipeline/src/operation.rs:1556`](../../core/dr-pipeline/src/operation.rs#L1556), [`core/dr-pipeline/src/operation.rs:1584`](../../core/dr-pipeline/src/operation.rs#L1584), [`core/dr-pipeline/src/operation.rs:1630`](../../core/dr-pipeline/src/operation.rs#L1630), [`core/dr-pipeline/src/operation.rs:2240`](../../core/dr-pipeline/src/operation.rs#L2240), [`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/operation.rs:321`](../../core/dr-pipeline/src/operation.rs#L321), [`core/dr-pipeline/src/operation.rs:336`](../../core/dr-pipeline/src/operation.rs#L336), [`core/dr-pipeline/src/operation.rs:936`](../../core/dr-pipeline/src/operation.rs#L936), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:131`](../../core/dr-pipeline/src/ops/film_sim.rs#L131), [`core/dr-pipeline/src/ops/film_sim.rs:183`](../../core/dr-pipeline/src/ops/film_sim.rs#L183), [`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:230`](../../core/dr-pipeline/src/ops/film_sim.rs#L230), [`core/dr-pipeline/src/ops/film_sim.rs:320`](../../core/dr-pipeline/src/ops/film_sim.rs#L320), [`core/dr-pipeline/src/ops/film_sim.rs:433`](../../core/dr-pipeline/src/ops/film_sim.rs#L433), [`core/dr-pipeline/src/ops/film_sim.rs:45`](../../core/dr-pipeline/src/ops/film_sim.rs#L45), [`core/dr-pipeline/src/preset.rs:1293`](../../core/dr-pipeline/src/preset.rs#L1293), [`core/dr-pipeline/src/preset.rs:1303`](../../core/dr-pipeline/src/preset.rs#L1303), [`core/dr-pipeline/src/preset.rs:1312`](../../core/dr-pipeline/src/preset.rs#L1312), [`core/dr-pipeline/src/preset.rs:1328`](../../core/dr-pipeline/src/preset.rs#L1328), [`core/dr-pipeline/src/preset.rs:1340`](../../core/dr-pipeline/src/preset.rs#L1340), [`core/dr-pipeline/src/preset.rs:1356`](../../core/dr-pipeline/src/preset.rs#L1356), [`core/dr-pipeline/src/preset.rs:1529`](../../core/dr-pipeline/src/preset.rs#L1529), [`core/dr-pipeline/src/preset.rs:1544`](../../core/dr-pipeline/src/preset.rs#L1544), [`core/dr-pipeline/src/preset.rs:248`](../../core/dr-pipeline/src/preset.rs#L248), [`core/dr-pipeline/src/preset.rs:689`](../../core/dr-pipeline/src/preset.rs#L689), [`core/dr-pipeline/src/preset.rs:778`](../../core/dr-pipeline/src/preset.rs#L778), [`core/dr-pipeline/src/sidecar.rs:1077`](../../core/dr-pipeline/src/sidecar.rs#L1077), [`core/dr-pipeline/src/sidecar.rs:133`](../../core/dr-pipeline/src/sidecar.rs#L133), [`core/dr-pipeline/src/sidecar.rs:217`](../../core/dr-pipeline/src/sidecar.rs#L217), [`core/dr-pipeline/src/sidecar.rs:2724`](../../core/dr-pipeline/src/sidecar.rs#L2724), [`core/dr-pipeline/src/sidecar.rs:2800`](../../core/dr-pipeline/src/sidecar.rs#L2800), [`core/dr-pipeline/src/sidecar.rs:595`](../../core/dr-pipeline/src/sidecar.rs#L595), [`core/dr-pipeline/src/sidecar.rs:898`](../../core/dr-pipeline/src/sidecar.rs#L898), [`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/render.rs:1256`](../../ui/dr-ui/src/develop/render.rs#L1256), [`ui/dr-ui/src/develop/render.rs:1296`](../../ui/dr-ui/src/develop/render.rs#L1296), [`ui/dr-ui/src/develop/render.rs:1331`](../../ui/dr-ui/src/develop/render.rs#L1331), [`ui/dr-ui/src/develop/render.rs:684`](../../ui/dr-ui/src/develop/render.rs#L684), [`ui/dr-ui/src/develop/render.rs:701`](../../ui/dr-ui/src/develop/render.rs#L701), [`ui/dr-ui/src/develop/render.rs:713`](../../ui/dr-ui/src/develop/render.rs#L713), [`ui/dr-ui/src/develop/render.rs:751`](../../ui/dr-ui/src/develop/render.rs#L751), [`ui/dr-ui/src/develop/render.rs:788`](../../ui/dr-ui/src/develop/render.rs#L788), [`ui/dr-ui/src/develop/render.rs:799`](../../ui/dr-ui/src/develop/render.rs#L799), [`ui/dr-ui/src/develop/render.rs:856`](../../ui/dr-ui/src/develop/render.rs#L856), [`ui/dr-ui/src/develop/render.rs:907`](../../ui/dr-ui/src/develop/render.rs#L907), [`ui/dr-ui/src/develop/render.rs:933`](../../ui/dr-ui/src/develop/render.rs#L933), [`ui/dr-ui/src/develop/session.rs:588`](../../ui/dr-ui/src/develop/session.rs#L588), [`ui/dr-ui/src/develop/session.rs:608`](../../ui/dr-ui/src/develop/session.rs#L608), [`ui/dr-ui/src/develop/tabs.rs:80`](../../ui/dr-ui/src/develop/tabs.rs#L80), [`ui/dr-ui/src/develop_ui.rs:447`](../../ui/dr-ui/src/develop_ui.rs#L447), [`ui/dr-ui/src/lib.rs:856`](../../ui/dr-ui/src/lib.rs#L856), [`ui/dr-ui/src/lib.rs:921`](../../ui/dr-ui/src/lib.rs#L921), [`ui/dr-ui/src/library/sidecar.rs:324`](../../ui/dr-ui/src/library/sidecar.rs#L324), [`ui/dr-ui/src/library/sidecar.rs:49`](../../ui/dr-ui/src/library/sidecar.rs#L49), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:595`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L595), [`ui/dr-ui/src/presets.rs:1586`](../../ui/dr-ui/src/presets.rs#L1586), [`ui/dr-ui/src/presets.rs:422`](../../ui/dr-ui/src/presets.rs#L422), [`ui/dr-ui/tests/film_list_reaches_every_stock.rs:1`](../../ui/dr-ui/tests/film_list_reaches_every_stock.rs#L1), [`ui/dr-ui/ui/adjust.slint:1071`](../../ui/dr-ui/ui/adjust.slint#L1071), [`ui/dr-ui/ui/adjust.slint:1081`](../../ui/dr-ui/ui/adjust.slint#L1081), [`ui/dr-ui/ui/adjust.slint:1298`](../../ui/dr-ui/ui/adjust.slint#L1298) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:450`](../../core/dr-decode/src/lib.rs#L450), [`core/dr-decode/src/preview.rs:29`](../../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1477`](../../core/dr-pipeline/src/framing.rs#L1477), [`core/dr-pipeline/src/framing.rs:486`](../../core/dr-pipeline/src/framing.rs#L486), [`core/dr-pipeline/src/framing.rs:704`](../../core/dr-pipeline/src/framing.rs#L704), [`core/dr-types/src/lib.rs:339`](../../core/dr-types/src/lib.rs#L339), [`core/dr-types/src/lib.rs:447`](../../core/dr-types/src/lib.rs#L447), [`core/dr-types/src/lib.rs:459`](../../core/dr-types/src/lib.rs#L459), [`core/dr-types/src/lib.rs:475`](../../core/dr-types/src/lib.rs#L475), [`ui/dr-ui/src/develop/masks.rs:280`](../../ui/dr-ui/src/develop/masks.rs#L280), [`ui/dr-ui/src/develop/segmentation.rs:114`](../../ui/dr-ui/src/develop/segmentation.rs#L114), [`ui/dr-ui/src/segmentation.rs:499`](../../ui/dr-ui/src/segmentation.rs#L499) | | FR-DEV-3i | [`core/dr-pipeline/src/mask.rs:558`](../../core/dr-pipeline/src/mask.rs#L558), [`core/dr-segment/src/lib.rs:1`](../../core/dr-segment/src/lib.rs#L1), [`ui/dr-ui/src/segmentation.rs:1`](../../ui/dr-ui/src/segmentation.rs#L1) | @@ -110,7 +110,7 @@ _None._ | FR-MRG-10 | [`core/dr-gpu/src/merge.rs:1`](../../core/dr-gpu/src/merge.rs#L1), [`core/dr-gpu/src/shaders/merge.wgsl:1`](../../core/dr-gpu/src/shaders/merge.wgsl#L1), [`core/dr-pano/src/lib.rs:1`](../../core/dr-pano/src/lib.rs#L1) | | FR-MRG-11 | [`core/dr-gpu/src/merge.rs:1`](../../core/dr-gpu/src/merge.rs#L1), [`core/dr-gpu/src/shaders/merge.wgsl:1`](../../core/dr-gpu/src/shaders/merge.wgsl#L1) | | FR-MRG-2 | [`core/dr-gpu/src/adjust.rs:104`](../../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:1441`](../../core/dr-gpu/src/adjust.rs#L1441), [`core/dr-gpu/src/adjust.rs:1570`](../../core/dr-gpu/src/adjust.rs#L1570), [`core/dr-gpu/src/adjust.rs:536`](../../core/dr-gpu/src/adjust.rs#L536), [`core/dr-pipeline/src/graph.rs:858`](../../core/dr-pipeline/src/graph.rs#L858), [`core/dr-pipeline/src/operation.rs:1020`](../../core/dr-pipeline/src/operation.rs#L1020), [`core/dr-pipeline/src/operation.rs:2440`](../../core/dr-pipeline/src/operation.rs#L2440), [`core/dr-pipeline/src/operation.rs:453`](../../core/dr-pipeline/src/operation.rs#L453), [`core/dr-pipeline/src/operation.rs:671`](../../core/dr-pipeline/src/operation.rs#L671), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1) | -| FR-MRG-3 | [`core/dr-decode/examples/linear_dng.rs:1`](../../core/dr-decode/examples/linear_dng.rs#L1), [`core/dr-decode/src/lib.rs:143`](../../core/dr-decode/src/lib.rs#L143), [`core/dr-decode/src/lib.rs:150`](../../core/dr-decode/src/lib.rs#L150), [`core/dr-decode/src/lib.rs:604`](../../core/dr-decode/src/lib.rs#L604), [`core/dr-decode/src/profile.rs:395`](../../core/dr-decode/src/profile.rs#L395), [`core/dr-export/src/dng.rs:1`](../../core/dr-export/src/dng.rs#L1), [`core/dr-gpu/src/demosaic.rs:276`](../../core/dr-gpu/src/demosaic.rs#L276), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1) | +| FR-MRG-3 | [`core/dr-decode/examples/linear_dng.rs:1`](../../core/dr-decode/examples/linear_dng.rs#L1), [`core/dr-decode/src/lib.rs:143`](../../core/dr-decode/src/lib.rs#L143), [`core/dr-decode/src/lib.rs:150`](../../core/dr-decode/src/lib.rs#L150), [`core/dr-decode/src/lib.rs:604`](../../core/dr-decode/src/lib.rs#L604), [`core/dr-decode/src/profile.rs:395`](../../core/dr-decode/src/profile.rs#L395), [`core/dr-export/src/dng.rs:1`](../../core/dr-export/src/dng.rs#L1), [`core/dr-gpu/src/demosaic.rs:302`](../../core/dr-gpu/src/demosaic.rs#L302), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1) | | FR-MRG-4 | [`core/dr-export/src/inscribed.rs:1`](../../core/dr-export/src/inscribed.rs#L1), [`core/dr-pano/src/fill.rs:1`](../../core/dr-pano/src/fill.rs#L1), [`core/dr-pano/src/migan.rs:1`](../../core/dr-pano/src/migan.rs#L1), [`core/dr-pano/src/projection.rs:1`](../../core/dr-pano/src/projection.rs#L1), [`ui/dr-ui/src/library/paths.rs:365`](../../ui/dr-ui/src/library/paths.rs#L365), [`ui/dr-ui/src/library/paths.rs:369`](../../ui/dr-ui/src/library/paths.rs#L369), [`ui/dr-ui/src/merge.rs:147`](../../ui/dr-ui/src/merge.rs#L147), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1), [`ui/dr-ui/ui/merge.slint:46`](../../ui/dr-ui/ui/merge.slint#L46) | | FR-MRG-5 | [`core/dr-pano/src/align.rs:1`](../../core/dr-pano/src/align.rs#L1), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-6 | [`core/dr-pipeline/src/sidecar.rs:1055`](../../core/dr-pipeline/src/sidecar.rs#L1055), [`core/dr-pipeline/src/sidecar.rs:116`](../../core/dr-pipeline/src/sidecar.rs#L116), [`core/dr-pipeline/src/sidecar.rs:126`](../../core/dr-pipeline/src/sidecar.rs#L126), [`core/dr-pipeline/src/sidecar.rs:2284`](../../core/dr-pipeline/src/sidecar.rs#L2284), [`core/dr-pipeline/src/sidecar.rs:860`](../../core/dr-pipeline/src/sidecar.rs#L860), [`ui/dr-ui/src/merge.rs:849`](../../ui/dr-ui/src/merge.rs#L849) | @@ -146,9 +146,9 @@ _None._ | FR-PLAT-WIN-3 | [`apps/darkroom-desktop/src/main.rs:19`](../../apps/darkroom-desktop/src/main.rs#L19) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:265`](../../core/dr-decode/src/lib.rs#L265), [`core/dr-types/src/lib.rs:132`](../../core/dr-types/src/lib.rs#L132), [`core/dr-types/src/lib.rs:203`](../../core/dr-types/src/lib.rs#L203) | | FR-RAW-2 | [`core/dr-decode/src/decoder.rs:1`](../../core/dr-decode/src/decoder.rs#L1), [`core/dr-decode/src/decoder.rs:26`](../../core/dr-decode/src/decoder.rs#L26), [`core/dr-decode/src/decoder.rs:59`](../../core/dr-decode/src/decoder.rs#L59), [`core/dr-decode/src/decoder.rs:95`](../../core/dr-decode/src/decoder.rs#L95), [`ui/dr-ui/src/decoder_seam.rs:184`](../../ui/dr-ui/src/decoder_seam.rs#L184), [`ui/dr-ui/src/decoder_seam.rs:1`](../../ui/dr-ui/src/decoder_seam.rs#L1), [`ui/dr-ui/src/decoder_seam.rs:215`](../../ui/dr-ui/src/decoder_seam.rs#L215), [`ui/dr-ui/src/decoder_seam.rs:256`](../../ui/dr-ui/src/decoder_seam.rs#L256), [`ui/dr-ui/src/export.rs:604`](../../ui/dr-ui/src/export.rs#L604), [`ui/dr-ui/src/export.rs:848`](../../ui/dr-ui/src/export.rs#L848), [`ui/dr-ui/src/import.rs:95`](../../ui/dr-ui/src/import.rs#L95), [`ui/dr-ui/src/library/sweep.rs:279`](../../ui/dr-ui/src/library/sweep.rs#L279), [`ui/dr-ui/src/library/sweep.rs:601`](../../ui/dr-ui/src/library/sweep.rs#L601), [`ui/dr-ui/src/library/thumbnails_gen.rs:100`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L100), [`ui/dr-ui/src/merge.rs:105`](../../ui/dr-ui/src/merge.rs#L105), [`ui/dr-ui/src/repairs.rs:242`](../../ui/dr-ui/src/repairs.rs#L242) | -| FR-RAW-3 | [`core/dr-decode/src/lib.rs:161`](../../core/dr-decode/src/lib.rs#L161), [`core/dr-decode/src/lib.rs:552`](../../core/dr-decode/src/lib.rs#L552), [`core/dr-decode/src/locate.rs:1435`](../../core/dr-decode/src/locate.rs#L1435) | +| FR-RAW-3 | [`core/dr-decode/src/lib.rs:161`](../../core/dr-decode/src/lib.rs#L161), [`core/dr-decode/src/lib.rs:552`](../../core/dr-decode/src/lib.rs#L552), [`core/dr-decode/src/locate.rs:1435`](../../core/dr-decode/src/locate.rs#L1435), [`core/dr-gpu/src/demosaic.rs:1069`](../../core/dr-gpu/src/demosaic.rs#L1069), [`core/dr-gpu/src/demosaic.rs:637`](../../core/dr-gpu/src/demosaic.rs#L637), [`core/dr-gpu/tests/hot_pixels.rs:1`](../../core/dr-gpu/tests/hot_pixels.rs#L1) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../../core/dr-decode/src/error.rs#L1), [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30), [`ui/dr-ui/src/lib.rs:292`](../../ui/dr-ui/src/lib.rs#L292) | -| FR-RAW-5 | [`core/dr-decode/src/lib.rs:189`](../../core/dr-decode/src/lib.rs#L189), [`core/dr-gpu/src/demosaic.rs:34`](../../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:758`](../../core/dr-gpu/src/demosaic.rs#L758), [`core/dr-gpu/src/demosaic.rs:837`](../../core/dr-gpu/src/demosaic.rs#L837), [`core/dr-gpu/src/demosaic.rs:961`](../../core/dr-gpu/src/demosaic.rs#L961) | +| FR-RAW-5 | [`core/dr-decode/src/lib.rs:189`](../../core/dr-decode/src/lib.rs#L189), [`core/dr-gpu/src/demosaic.rs:1067`](../../core/dr-gpu/src/demosaic.rs#L1067), [`core/dr-gpu/src/demosaic.rs:34`](../../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:864`](../../core/dr-gpu/src/demosaic.rs#L864), [`core/dr-gpu/src/demosaic.rs:943`](../../core/dr-gpu/src/demosaic.rs#L943) | | FR-UI-1 | [`core/dr-types/src/settings.rs:1390`](../../core/dr-types/src/settings.rs#L1390), [`core/dr-types/src/settings.rs:492`](../../core/dr-types/src/settings.rs#L492), [`core/dr-types/src/settings.rs:512`](../../core/dr-types/src/settings.rs#L512), [`ui/dr-ui/src/develop_ui.rs:204`](../../ui/dr-ui/src/develop_ui.rs#L204), [`ui/dr-ui/src/lib.rs:123`](../../ui/dr-ui/src/lib.rs#L123), [`ui/dr-ui/src/lib.rs:138`](../../ui/dr-ui/src/lib.rs#L138), [`ui/dr-ui/src/lib.rs:160`](../../ui/dr-ui/src/lib.rs#L160), [`ui/dr-ui/src/lib.rs:3211`](../../ui/dr-ui/src/lib.rs#L3211), [`ui/dr-ui/src/lib.rs:3230`](../../ui/dr-ui/src/lib.rs#L3230), [`ui/dr-ui/src/lib.rs:3257`](../../ui/dr-ui/src/lib.rs#L3257), [`ui/dr-ui/src/lib.rs:3308`](../../ui/dr-ui/src/lib.rs#L3308), [`ui/dr-ui/src/lib.rs:3341`](../../ui/dr-ui/src/lib.rs#L3341), [`ui/dr-ui/src/library_ui/grid.rs:232`](../../ui/dr-ui/src/library_ui/grid.rs#L232), [`ui/dr-ui/src/masks_ui.rs:1608`](../../ui/dr-ui/src/masks_ui.rs#L1608), [`ui/dr-ui/src/settings_ui.rs:143`](../../ui/dr-ui/src/settings_ui.rs#L143), [`ui/dr-ui/ui/adjust.slint:1000`](../../ui/dr-ui/ui/adjust.slint#L1000), [`ui/dr-ui/ui/app.slint:3084`](../../ui/dr-ui/ui/app.slint#L3084), [`ui/dr-ui/ui/app.slint:665`](../../ui/dr-ui/ui/app.slint#L665), [`ui/dr-ui/ui/app.slint:810`](../../ui/dr-ui/ui/app.slint#L810), [`ui/dr-ui/ui/identity.slint:249`](../../ui/dr-ui/ui/identity.slint#L249), [`ui/dr-ui/ui/library.slint:1447`](../../ui/dr-ui/ui/library.slint#L1447), [`ui/dr-ui/ui/settings.slint:129`](../../ui/dr-ui/ui/settings.slint#L129), [`ui/dr-ui/ui/settings.slint:660`](../../ui/dr-ui/ui/settings.slint#L660), [`ui/dr-ui/ui/toolrail.slint:156`](../../ui/dr-ui/ui/toolrail.slint#L156), [`ui/dr-ui/ui/toolrail.slint:89`](../../ui/dr-ui/ui/toolrail.slint#L89), [`ui/dr-ui/ui/widgets.slint:1117`](../../ui/dr-ui/ui/widgets.slint#L1117) | | FR-UI-2 | [`ui/dr-ui/src/collections_ui/controller.rs:45`](../../ui/dr-ui/src/collections_ui/controller.rs#L45), [`ui/dr-ui/src/collections_ui/controller.rs:83`](../../ui/dr-ui/src/collections_ui/controller.rs#L83), [`ui/dr-ui/src/collections_ui/drag.rs:246`](../../ui/dr-ui/src/collections_ui/drag.rs#L246), [`ui/dr-ui/src/collections_ui/drag.rs:256`](../../ui/dr-ui/src/collections_ui/drag.rs#L256), [`ui/dr-ui/src/collections_ui/press.rs:453`](../../ui/dr-ui/src/collections_ui/press.rs#L453), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:121`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L121), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:138`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L138), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:415`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L415), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:287`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L287), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:123`](../../ui/dr-ui/src/lib.rs#L123), [`ui/dr-ui/src/library_ui/filter_bar.rs:351`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L351), [`ui/dr-ui/src/library_ui/window.rs:1000`](../../ui/dr-ui/src/library_ui/window.rs#L1000), [`ui/dr-ui/ui/app.slint:137`](../../ui/dr-ui/ui/app.slint#L137), [`ui/dr-ui/ui/app.slint:3481`](../../ui/dr-ui/ui/app.slint#L3481), [`ui/dr-ui/ui/app.slint:794`](../../ui/dr-ui/ui/app.slint#L794), [`ui/dr-ui/ui/collections.slint:1354`](../../ui/dr-ui/ui/collections.slint#L1354), [`ui/dr-ui/ui/controls.slint:626`](../../ui/dr-ui/ui/controls.slint#L626), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1334`](../../ui/dr-ui/ui/library.slint#L1334), [`ui/dr-ui/ui/library.slint:1367`](../../ui/dr-ui/ui/library.slint#L1367), [`ui/dr-ui/ui/library.slint:1691`](../../ui/dr-ui/ui/library.slint#L1691), [`ui/dr-ui/ui/library.slint:1698`](../../ui/dr-ui/ui/library.slint#L1698), [`ui/dr-ui/ui/library.slint:1799`](../../ui/dr-ui/ui/library.slint#L1799), [`ui/dr-ui/ui/library.slint:3135`](../../ui/dr-ui/ui/library.slint#L3135), [`ui/dr-ui/ui/library.slint:572`](../../ui/dr-ui/ui/library.slint#L572), [`ui/dr-ui/ui/presets.slint:45`](../../ui/dr-ui/ui/presets.slint#L45), [`ui/dr-ui/ui/settings.slint:143`](../../ui/dr-ui/ui/settings.slint#L143) | | FR-UI-3 | [`ui/dr-ui/src/collections_ui/controller.rs:143`](../../ui/dr-ui/src/collections_ui/controller.rs#L143), [`ui/dr-ui/src/collections_ui/controller.rs:348`](../../ui/dr-ui/src/collections_ui/controller.rs#L348), [`ui/dr-ui/src/collections_ui/controller.rs:361`](../../ui/dr-ui/src/collections_ui/controller.rs#L361), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:287`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L287), [`ui/dr-ui/src/develop/masks.rs:361`](../../ui/dr-ui/src/develop/masks.rs#L361), [`ui/dr-ui/src/develop/repairs.rs:49`](../../ui/dr-ui/src/develop/repairs.rs#L49), [`ui/dr-ui/src/library_ui/grid.rs:218`](../../ui/dr-ui/src/library_ui/grid.rs#L218), [`ui/dr-ui/src/masks_ui.rs:1700`](../../ui/dr-ui/src/masks_ui.rs#L1700), [`ui/dr-ui/src/masks_ui.rs:1722`](../../ui/dr-ui/src/masks_ui.rs#L1722), [`ui/dr-ui/src/masks_ui.rs:339`](../../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2288`](../../ui/dr-ui/ui/app.slint#L2288), [`ui/dr-ui/ui/collections.slint:1228`](../../ui/dr-ui/ui/collections.slint#L1228), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:78`](../../ui/dr-ui/ui/collections.slint#L78), [`ui/dr-ui/ui/collections.slint:978`](../../ui/dr-ui/ui/collections.slint#L978), [`ui/dr-ui/ui/masks.slint:1246`](../../ui/dr-ui/ui/masks.slint#L1246), [`ui/dr-ui/ui/widgets.slint:72`](../../ui/dr-ui/ui/widgets.slint#L72) |