diff --git a/Cargo.lock b/Cargo.lock index 170d148..a3e6a6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1462,6 +1462,7 @@ version = "0.6.0" dependencies = [ "bytemuck", "dr-decode", + "dr-film", "dr-pipeline", "dr-segment", "dr-types", diff --git a/core/dr-film/src/bake.rs b/core/dr-film/src/bake.rs index b862a6f..9a6ee69 100644 --- a/core/dr-film/src/bake.rs +++ b/core/dr-film/src/bake.rs @@ -28,7 +28,7 @@ //! dye mixing is smooth, so putting the curve in the 3D LUT would force it //! three times larger for the same error. -use crate::profile::{Profile, CURVE_SAMPLES}; +use crate::profile::Profile; use crate::spectrum::{illuminant, Spectrum, Viewing}; use crate::tables::{SPECTRUM, SRGB_BASIS}; @@ -84,8 +84,16 @@ pub struct Baked { pub curves: Vec<[f32; 3]>, pub curve_log_min: f32, pub curve_log_max: f32, - /// Density to linear sRGB, `LUT_SIZE³` entries in x-major order, uniform - /// over `[0, density_max]` on each axis. + /// Density to linear sRGB, `LUT_SIZE³` entries uniform over + /// `[0, density_max]` on each axis. + /// + /// **The red axis varies fastest**, then green, then blue — that is, + /// `lut[(b * size + g) * size + r]`. Stated because it is not the order + /// this loop reads most naturally, and it is not arbitrary: it is the + /// order a 3D texture upload expects, so the consumer can hand the slice + /// straight to the driver. Filling it the other way round renders a + /// picture with red and blue transposed, which looks like a plausible + /// photograph of the wrong colour. pub lut: Vec<[f32; 3]>, pub density_max: f32, pub lut_size: usize, @@ -132,7 +140,8 @@ impl Baked { let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] } * if dy == 0 { 1.0 - frac[1] } else { frac[1] } * if dz == 0 { 1.0 - frac[2] } else { frac[2] }; - let e = self.lut[((base[0] + dx) * n + base[1] + dy) * n + base[2] + dz]; + let e = self.lut + [((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx]; for c in 0..3 { out[c] += w * e[c]; } @@ -255,13 +264,16 @@ pub fn bake(recipe: &Recipe) -> Baked { let n = LUT_SIZE; let mut lut = Vec::with_capacity(n * n * n); - for x in 0..n { - for y in 0..n { - for z in 0..n { + // Blue outermost and red innermost, so the red axis varies fastest. See + // `Baked::lut`: this is the layout a 3D texture upload wants, and getting + // it backwards transposes red and blue in the finished picture. + for b in 0..n { + for g in 0..n { + for r in 0..n { let density = [ - density_max * x as f32 / (n - 1) as f32, - density_max * y as f32 / (n - 1) as f32, - density_max * z as f32 / (n - 1) as f32, + density_max * r as f32 / (n - 1) as f32, + density_max * g as f32 / (n - 1) as f32, + density_max * b as f32 / (n - 1) as f32, ]; lut.push(match recipe.print.zip(balance) { Some((paper, offsets)) => { @@ -323,6 +335,7 @@ fn invert_mean_curve(paper: &Profile, density: f32) -> f32 { #[cfg(test)] mod tests { use super::*; + use crate::profile::CURVE_SAMPLES; fn profile(yaml: &str) -> Profile { Profile::parse(yaml).unwrap() @@ -446,6 +459,37 @@ mod tests { assert!(worst < 1.0 / 255.0, "worst LUT error {worst} exceeds one code value"); } + #[test] + fn the_lut_stores_red_along_its_fastest_axis() { + // The layout a 3D texture upload expects, and the one bug this whole + // decomposition is most exposed to: fill it the other way round and + // the picture comes back with red and blue transposed -- entirely + // plausible-looking, and wrong. Asserted here rather than only in the + // end-to-end GPU test, because that one needs a device and this one + // does not. + let film = kodachrome(); + let baked = bake(&Recipe::new(&film, None)); + let n = baked.lut_size; + + // Step one along each axis from the origin, and check that the entry + // found is the one the *density* moved along that axis should give. + let viewing = Viewing::new(&film.viewing_illuminant); + let step = baked.density_max / (n - 1) as f32; + for (axis, offset) in [(0usize, 1usize), (1, n), (2, n * n)] { + let mut density = [0.0f32; 3]; + density[axis] = step; + let expected = viewing.to_srgb(&film.transmittance(density)); + let stored = baked.lut[offset]; + for c in 0..3 { + assert!( + (stored[c] - expected[c]).abs() < 1e-4, + "axis {axis} is not at stride {offset}: stored {stored:?}, \ + the density one step along that axis gives {expected:?}" + ); + } + } + } + #[test] fn the_lut_is_the_size_it_says_it_is() { let film = kodachrome(); diff --git a/core/dr-gpu/Cargo.toml b/core/dr-gpu/Cargo.toml index 1d7c9cd..4249082 100644 --- a/core/dr-gpu/Cargo.toml +++ b/core/dr-gpu/Cargo.toml @@ -31,6 +31,10 @@ env_logger.workspace = true # encode once, and scale between a proxy and an export. A dev-dependency, so a # shipping `dr-gpu` does not carry it. dr-pipeline = { workspace = true, features = ["detail-probe"] } +# The film stocks, for the end-to-end test only. The library half deliberately +# does not link them: `dr-gpu` binds tables it is handed and has no opinion on +# where a stock comes from. +dr-film.workspace = true # The local-adjustment example needs the model, which the library half of this # crate deliberately does not: `dr-gpu` holds the shaders, and the inference # runtime belongs to whoever is asking a question about the picture. diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index 27c5241..878453b 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -74,6 +74,13 @@ pub struct AdjustPass { current: usize, /// Bound at `@binding(3)` when the edit carries no mask layers. empty_masks: wgpu::TextureView, + /// TRACES: FR-DEV-3f + /// Bound at `@binding(4)` and `@binding(5)` when no film stock is loaded, + /// which is the state of every photograph in the catalogue by default. + empty_film_curves: wgpu::TextureView, + empty_film_lut: wgpu::TextureView, + /// The loaded stock's tables, once uploaded. See [`Self::set_film`]. + film: Option, /// TRACES: FR-DEV-3 | FR-DEV-3d /// The neighbourhood stage — sharpening, noise reduction, clarity and the /// rest of FR-DEV-3's detail set, which cannot be fused into the shader @@ -121,7 +128,168 @@ struct Target { height: u32, } +/// The texture format both film tables are uploaded in. +/// +/// 32-bit float, and not the half-float the rest of the pipeline prefers: the +/// LUT is half a megabyte either way at the size it is baked at, and a density +/// carries its precision straight into a colour. Halving a table this small +/// would trade the one thing it is for the one thing it is not short of. +const FILM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; + +/// TRACES: FR-DEV-3f +/// A baked film stock, resident on the GPU. +struct FilmTextures { + curves: wgpu::TextureView, + lut: wgpu::TextureView, + /// What the resident tables were built from, so an unchanged stock is not + /// re-uploaded. Every frame would otherwise push half a megabyte across + /// the bus to arrive at the bytes already there. + key: u64, +} + +/// A cheap content key for a set of tables. +/// +/// Not a cryptographic hash and not trying to be: it decides whether to skip an +/// upload, and the cost of a collision is a stale lookup on a stock the user +/// just changed. Every field that *shapes* the tables goes in whole; the tables +/// themselves are sampled, because two stocks agreeing on the matrix, both +/// domains and every eighth entry are the same stock. +fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut mix = |bits: u32| { + h ^= u64::from(bits); + h = h.wrapping_mul(0x1000_0000_01b3); + }; + for row in &t.exposure_matrix { + for v in row { + mix(v.to_bits()); + } + } + for v in [t.curve_log_min, t.curve_log_max, t.density_max, t.lut_size as f32] { + mix(v.to_bits()); + } + for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) { + mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22)); + } + h +} + +/// Pad RGB triples to the RGBA the upload wants. +/// +/// The alpha is never read — the shader takes `.rgb` from the lookup and +/// indexes the curve by channel — so it is written as one rather than left +/// undefined, which keeps a dump of the texture legible if anyone has to look. +fn to_rgba(triples: &[[f32; 3]]) -> Vec { + let mut out = Vec::with_capacity(triples.len() * 4); + for t in triples { + out.extend_from_slice(&[t[0], t[1], t[2], 1.0]); + } + out +} + impl AdjustPass { + /// TRACES: FR-DEV-3f + /// Make a baked film stock current, or clear it. + /// + /// Separate from `render` rather than another argument to it, because a + /// stock changes when a person picks one and a frame is rendered sixty + /// times a second. Threading half a megabyte through the render path would + /// invite exactly the per-frame upload the key below exists to avoid. + pub fn set_film(&mut self, tables: Option<&dr_pipeline::ops::FilmTables>) { + let Some(t) = tables else { + self.film = None; + return; + }; + let key = film_key(t); + if self.film.as_ref().is_some_and(|f| f.key == key) { + return; + } + if !t.is_well_formed() { + // Refused here as well as in the operation, because this is the + // last point before a shader indexes the result. The two checks + // are cheap and the failure they prevent is a driver-dependent + // read past the end of a texture. + log::error!("adjust: refusing malformed film tables"); + self.film = None; + return; + } + + let curves = self.upload_film( + "adjust-film-curves", + wgpu::TextureDimension::D2, + wgpu::Extent3d { + width: t.curves.len() as u32, + height: 1, + depth_or_array_layers: 1, + }, + &to_rgba(&t.curves), + ); + let n = t.lut_size as u32; + let lut = self.upload_film( + "adjust-film-lut", + wgpu::TextureDimension::D3, + wgpu::Extent3d { width: n, height: n, depth_or_array_layers: n }, + &to_rgba(&t.lut), + ); + self.film = Some(FilmTextures { curves, lut, key }); + } + + /// Create a texture and write `data` into it in one go. + fn upload_film( + &self, + label: &str, + dimension: wgpu::TextureDimension, + size: wgpu::Extent3d, + data: &[f32], + ) -> wgpu::TextureView { + let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension, + format: FILM_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.ctx.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + bytemuck::cast_slice(data), + wgpu::TexelCopyBufferLayout { + offset: 0, + // Four channels of four bytes. Stated from the format rather + // than from the data's length, so a short upload is a wgpu + // error naming the texture instead of a skewed lookup. + bytes_per_row: Some(size.width * 16), + rows_per_image: Some(size.height), + }, + size, + ); + let mut descriptor = wgpu::TextureViewDescriptor { + label: Some(label), + ..Default::default() + }; + if dimension == wgpu::TextureDimension::D3 { + descriptor.dimension = Some(wgpu::TextureViewDimension::D3); + } + texture.create_view(&descriptor) + } + + /// The curve texture to bind: the loaded stock's, or the placeholder. + fn film_curves_view(&self) -> &wgpu::TextureView { + self.film.as_ref().map_or(&self.empty_film_curves, |f| &f.curves) + } + + /// The density lookup to bind: the loaded stock's, or the placeholder. + fn film_lut_view(&self) -> &wgpu::TextureView { + self.film.as_ref().map_or(&self.empty_film_lut, |f| &f.lut) + } + pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; pub fn new(ctx: &GpuContext) -> Self { @@ -175,6 +343,42 @@ impl AdjustPass { ..Default::default() }); + // TRACES: FR-DEV-3f + // What binds to the film slots when no stock is loaded, which is the + // state of every photograph in the catalogue by default. The shader + // declares both unconditionally so that one bind group layout serves + // every generated shader; these cost sixteen bytes each and no branch. + let empty_film_curves = ctx + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("adjust-empty-film-curves"), + size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FILM_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()); + let empty_film_lut = ctx + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("adjust-empty-film-lut"), + size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: FILM_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&wgpu::TextureViewDescriptor { + label: Some("adjust-empty-film-lut-view"), + dimension: Some(wgpu::TextureViewDimension::D3), + ..Default::default() + }); + Self { ctx: ctx.clone(), bind_group_layout, @@ -183,6 +387,9 @@ impl AdjustPass { targets: [None, None], current: 0, empty_masks, + empty_film_curves, + empty_film_lut, + film: None, detail: DetailRunner::new(ctx), linear_bind_group_layout, linear_pipeline_layout, @@ -254,6 +461,36 @@ impl AdjustPass { }, count: None, }, + // TRACES: FR-DEV-3f + // A film stock's characteristic curves, and the density + // lookup carrying everything downstream of them. Present + // in every layout for the reason the masks above are, and + // bound to placeholders when no stock is loaded. + // + // Declared unfilterable, and correctly: the generated + // shader interpolates both by hand with `textureLoad`, + // because this pipeline binds no sampler and adding one + // for two lookups would cost a binding in every shader. + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, ], }) } @@ -476,6 +713,14 @@ impl AdjustPass { masks.map_or(&self.empty_masks, |m| m.view()), ), }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(self.film_curves_view()), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(self.film_lut_view()), + }, ], }); @@ -580,6 +825,11 @@ impl AdjustPass { .detail .colour_target(detail.len(), width, height) .clone(); + // Cloned for the same reason `colour_view` is: `self.detail` is + // borrowed mutably across the encode below, so the film views cannot + // be read off `self` at the point the bind group is built. + let film_curves = self.film_curves_view().clone(); + let film_lut = self.film_lut_view().clone(); let mut enc = self .ctx @@ -622,6 +872,14 @@ impl AdjustPass { masks.map_or(&self.empty_masks, |m| m.view()), ), }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&film_curves), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&film_lut), + }, ], }); let pipeline = self @@ -1424,6 +1682,25 @@ mod tests { ); } + /// Tables shaped like a real stock's, with values that are not. + /// + /// This test is about whether the largest possible shader compiles and + /// dispatches, not about what it renders — `tests/film_sim.rs` is where + /// the pixels are checked against the model. Flat values keep the two + /// concerns apart. + fn film_test_tables() -> dr_pipeline::ops::FilmTables { + const N: usize = 32; + dr_pipeline::ops::FilmTables { + exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]], + curves: vec![[0.5, 0.5, 0.5]; dr_pipeline::ops::film_sim::CURVE_SAMPLES], + curve_log_min: -3.0, + curve_log_max: 4.0, + lut: vec![[0.5, 0.5, 0.5]; N * N * N], + density_max: 3.0, + lut_size: N, + } + } + #[test] fn the_whole_chain_at_once_compiles() { // Individually-valid fragments can still collide when combined — @@ -1454,6 +1731,15 @@ mod tests { } } + // `film_sim` is the one operation no parameter can activate: it needs + // a stock's measured tables. Loaded here so that "every operation at + // once" means what it says — and so that this test compiles the + // largest shader the pipeline can actually generate, which is the one + // with a film in it. + let tables = film_test_tables(); + g.set_film_tables(Some(tables.clone())); + pass.set_film(Some(&tables)); + // Cropped, so the render is against an output size that is not the // source size — the case where a wrong dispatch or a wrong texture // allocation would show up. diff --git a/core/dr-gpu/tests/film_sim.rs b/core/dr-gpu/tests/film_sim.rs new file mode 100644 index 0000000..120f4e1 --- /dev/null +++ b/core/dr-gpu/tests/film_sim.rs @@ -0,0 +1,157 @@ +//! TRACES: FR-DEV-3f +//! A film stock, end to end on a device. +//! +//! The tests either side of this one check halves, and neither would catch the +//! failure that matters. `dr-film` asserts the spectral model reproduces a +//! reference implementation written in another language; `dr-pipeline` asserts +//! the generated WGSL evaluates a film in the right place and suppresses the +//! camera profile's rendering. Both pass if the tables are uploaded +//! transposed, or the density lookup is indexed in the wrong axis order, or +//! the curve texture is read a channel out — every one of which renders a +//! plausible photograph with the wrong colours in it. +//! +//! So this renders real pixels through the real shader and compares them +//! against the same stock evaluated on the CPU. That closes the chain: the CPU +//! model is checked against the reference, and the shader is checked against +//! the CPU model. + +use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; +use dr_film::bake::{bake, Recipe}; +use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; +use dr_pipeline::ops::FilmTables; +use dr_pipeline::EditGraph; + +const SIZE: u32 = 16; + +fn ctx() -> Option { + pollster::block_on(GpuContext::new_headless()).ok() +} + +/// A flat RGGB frame at `level` out of 65535. +/// +/// Identity matrix and neutral balance, so the only thing that can move a +/// pixel is the film. A real body's matrix would make every assertion below a +/// statement about that body instead. +fn flat_raw(level: u16) -> RawImage { + RawImage { + width: SIZE, + height: SIZE, + data: vec![level; (SIZE * SIZE) as usize], + cfa_pattern: CfaPattern::Rggb, + black_level: [0; 4], + white_level: u16::MAX, + 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]), + // Off deliberately: a film replaces the camera's rendering, and + // leaving a curve here would test the suppression rather than the + // film. `dr-pipeline` asserts the suppression on the generated source. + base_curve: BaseCurve::IDENTITY, + crop: CropRect { x: 0, y: 0, width: SIZE, height: SIZE }, + } +} + +/// `dr-film`'s baked output in the layout `dr-pipeline` binds. +/// +/// The conversion is spelled out rather than derived, because it is exactly +/// the seam this test exists to check: the two crates share no types on +/// purpose, and a field pasted into the wrong slot here is invisible until +/// pixels come back wrong. +fn tables(baked: &dr_film::Baked) -> FilmTables { + FilmTables { + exposure_matrix: baked.exposure_matrix, + curves: baked.curves.clone(), + curve_log_min: baked.curve_log_min, + curve_log_max: baked.curve_log_max, + lut: baked.lut.clone(), + density_max: baked.density_max, + lut_size: baked.lut_size, + } +} + +/// Render a flat frame through a stock and return the centre pixel, 0..1. +/// +/// The centre rather than a corner: a demosaic invents its edges, and the +/// border of a 16x16 frame is not where anyone should read a tone off. +fn rendered(ctx: &GpuContext, level: u16, baked: &dr_film::Baked) -> [f32; 3] { + let source = Demosaicer::new(ctx) + .expect("demosaicer") + .run(&flat_raw(level)) + .expect("demosaic"); + + let mut graph = EditGraph::default_chain(); + graph.set_film_tables(Some(tables(baked))); + let shader = graph.compose(); + + let mut adjust = AdjustPass::new(ctx); + adjust.set_film(Some(&tables(baked))); + adjust.render(&source, &shader, SIZE, SIZE).expect("render"); + + let (pixels, _, _) = adjust.export_pixels().expect("readback"); + let c = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize; + // Undo the sRGB encode the fused pass applies on the way out, so the + // comparison happens in the linear space the CPU model works in. + [0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0)) +} + +fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } +} + +#[test] +fn a_stock_renders_on_the_gpu_the_way_it_does_on_the_cpu() { + let Some(ctx) = ctx() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let film = dr_film::find("kodak_kodachrome_64").expect("stock"); + let baked = bake(&Recipe::new(film, None)); + + for level in [4_000u16, 12_000, 30_000, 50_000] { + // What the shader was handed, expressed the way the CPU model reads + // it: a flat RGGB frame at `level` demosaics to that fraction of full + // scale in all three channels, and the identity matrix leaves it there. + let input = f32::from(level) / f32::from(u16::MAX); + let expected = baked.apply([input; 3]); + let got = rendered(&ctx, level, &baked); + + for c in 0..3 { + assert!( + (got[c] - expected[c]).abs() < 0.02, + "level {level}, channel {c}: GPU gave {:.4}, the model says {:.4}\n\ + got {got:?}\n want {expected:?}", + got[c], + expected[c] + ); + } + } +} + +#[test] +fn a_negative_and_its_print_are_not_the_same_picture() { + // The print stage is where the orange mask goes and where the picture + // turns the right way up. If the paper profile were being ignored -- a + // plausible wiring mistake, since both are just "a stock" -- the two + // renders would agree, and a scanned negative would be offered as a + // photograph. + let Some(ctx) = ctx() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let film = dr_film::find("kodak_portra_400").expect("stock"); + let paper = dr_film::default_print(film).expect("paper"); + + let scanned = rendered(&ctx, 12_000, &bake(&Recipe::new(film, None))); + let printed = rendered(&ctx, 12_000, &bake(&Recipe::new(film, Some(paper)))); + + assert!( + scanned[0] > scanned[2] * 3.0, + "the scanned negative has lost its orange mask: {scanned:?}" + ); + let spread = printed.iter().cloned().fold(f32::MIN, f32::max) + - printed.iter().cloned().fold(f32::MAX, f32::min); + assert!(spread < 0.06, "the print of a neutral is not neutral: {printed:?}"); +} diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index e4a10b0..b2e7ad7 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -212,11 +212,32 @@ half in Rust would be worse than either alone. Currently hand-written: `tone_curve` (one widget over four curves of five interpolated points — master, red, green, blue — each reaching the shader only when it has been moved), `colour_mixer` (thirty-six faceted parameters from -twelve computed hue bands), `capture_sharpen` (a separable convolution) and -`noise_reduction` (a kernel, and one that decides how many dispatches to emit -at each resolution) — the last two for the reason the next section gives. -`vignetting` is hand-written too but is not in the develop chain — it -carries lens-profile coefficients that are not parameters. `distortion` and +twelve computed hue bands), `film_sim` (a stock's measured tables, which are +not parameters, and the one node that declares `Operation::renders` — see +below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a +kernel, and one that decides how many dispatches to emit at each resolution) — +the last two for the reason the next section gives. `vignetting` is +hand-written too but is not in the develop chain — it carries lens-profile +coefficients that are not parameters. + +## The node that renders + +`film_sim` is the only operation that returns `true` from +`Operation::renders`, and it is worth knowing why before writing a second one. + +Every other node *adjusts* a picture. That one *makes* it: a film stock's +characteristic curve does the camera profile's base curve's job, from +measurements rather than from a curve somebody drew. Running both renders the +scene twice — the camera's rendering, and then a film's rendering of *that* — +which looks like neither and reads as a colour-management bug with no +colour-management bug to find. + +So a node declaring `renders` takes camera RGB and hands back linear sRGB, and +in exchange the composer emits neither the base curve nor the conversion out of +camera space. Both halves move to the node, together: the base curve is defined +in camera RGB and the matrix is what leaves it, so a node replacing one has +necessarily replaced the other. `compose_full` keeps them as a single string +for exactly that reason — it is what makes getting half of it right impossible. `distortion` and `aberration` are `Warp`s rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it. diff --git a/core/dr-pipeline/ops/film_sim.yaml b/core/dr-pipeline/ops/film_sim.yaml new file mode 100644 index 0000000..ce43cfd --- /dev/null +++ b/core/dr-pipeline/ops/film_sim.yaml @@ -0,0 +1,22 @@ +id: film_sim +order: 25 +attributes: [tone, colour] +rust: FilmSim + +why_rust: | + It carries a stock's measured tables — an exposure matrix, three + characteristic curves and a density lookup — which are not parameters and + which no `uniforms:` expression could produce. Its neutral is "no stock + loaded" rather than a set of values, and it is the one node that declares + `Operation::renders`, so the composer omits the camera profile's base curve + and the conversion out of camera space on its behalf. + +placement: | + After white balance and exposure, and before everything else. + + Those two are what the camera did — interpreting the sensor, and correcting + the amount of light that reached it — and they are only meaningful on + scene-linear values, which is what a film has to be handed. Everything below + is a decision about the picture, and a decision about the picture belongs + after the film has rendered it, exactly as it does when you scan a frame and + then work on the scan. diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 32f7cac..74804c1 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -255,6 +255,20 @@ impl EditGraph { /// Clamping here rather than in each operation means an operation never /// has to defend against an out-of-range value, and a corrupt sidecar /// cannot reach a shader. + /// TRACES: FR-DEV-3f + /// Load a baked film stock, or clear it. + /// + /// Offered to every operation rather than to the one that wants it, + /// because the graph holds `Box` and knowing which concrete + /// type is which is exactly what it is organised not to know (ARCH §3.4). + /// The default implementation ignores it, so this costs a virtual call per + /// node on an action a user takes by hand. + pub fn set_film_tables(&mut self, tables: Option) { + for op in &mut self.ops { + op.set_film_tables(tables.as_ref()); + } + } + pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) { if op == crate::framing::ID { let Some(desc) = self.framing.descriptor().param(param) else { diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 35006d1..12e2bcb 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -99,6 +99,24 @@ mod tests { g.set_param(desc.id, p.id, v); } } + + // `film_sim` is the one node a moved parameter cannot activate: it + // needs a stock's measured tables, which are not parameters and which + // no slider produces. So it is loaded explicitly here. + // + // This is the single per-node step in an otherwise generic helper, and + // it is deliberate rather than an oversight: a node that carries + // measurements is a real second kind of node, and pretending otherwise + // would mean silently leaving it out of every test that uses this. + g.set_film_tables(Some(crate::ops::FilmTables { + exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]], + curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES], + curve_log_min: -3.0, + curve_log_max: 4.0, + lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], + density_max: 3.0, + lut_size: 32, + })); g } diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index e9be1d7..e4193eb 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -262,6 +262,40 @@ pub trait Operation: Send + Sync { Affects::Colour } + /// TRACES: FR-DEV-3f + /// Hand this operation a stock's measured tables, if it wants them. + /// + /// Default: ignore them, which is right for every operation that is a + /// function of its parameters alone. + /// + /// A named method rather than a downcast or a bag of profiles, because + /// there is one caller and inventing a general mechanism for it would be + /// guessing at the shape of the next one. `vignetting`, `distortion` and + /// `aberration` already carry lens measurements through `set_profile` and + /// are not yet reached from the graph at all; when they are, this is the + /// shape it should take. + fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {} + + /// TRACES: FR-DEV-3e | FR-DEV-3f + /// Whether this operation *is* the rendering, rather than an adjustment to + /// one. + /// + /// Almost everything returns `false`. An operation that returns `true` + /// takes camera RGB and hands back linear sRGB, and in exchange the + /// composer emits neither the camera profile's base curve nor the + /// conversion out of camera space — because this operation has done both. + /// + /// The reason it is a trait method and not a flag the caller sets is the + /// one [`compose_full`] gives for deciding the output mode the same way: a + /// caller that got it wrong would produce a shader that compiles, runs, and + /// renders the picture twice. `film_sim` is the operation this exists for — + /// a stock's characteristic curve does the base curve's job, from + /// measurements, and running both is the camera's rendering of the scene + /// followed by a film's rendering of *that*. + fn renders(&self) -> bool { + false + } + /// TRACES: FR-DEV-3 | FR-DEV-8 /// This operation's neighbourhood stage, if it has one. /// @@ -478,6 +512,11 @@ pub fn compose_full( OutputMode::Encoded }; + // Whether an operation has taken over the rendering. Decided from the + // operations for the same reason `output_mode` is: a caller that got it + // wrong would produce a shader that compiles and renders the picture twice. + let op_renders = ops.iter().any(|o| o.is_active() && o.renders()); + let mut uniform_fields = String::new(); let mut uniform_values: Vec = Vec::new(); let mut body = String::new(); @@ -658,6 +697,82 @@ pub fn compose_full( ), }; + // The camera profile's rendering, which an operation may have taken over. + // + // Emitted as a unit because the two halves belong together: the base curve + // is defined in camera RGB and the matrix is what leaves it, so an + // operation that replaces one has necessarily replaced the other. Keeping + // them as one string is what makes that impossible to get half right. + let rendering_tail = if op_renders { + " // The camera profile's base curve and the conversion out of camera\n // space are both absent: an operation declaring `Operation::renders`\n // has done both, and doing them again would render the picture twice.\n" + .to_string() + } else { + format!( +" // ==== camera profile: the base curve (FR-DEV-3e) ==== + // + // Marked with `====` and not the `----` an operation block carries: this + // is not one, and the difference is what several tests count on to tell + // an edit apart from the reading of a file. + // + // The stage between demosaic and the working space that turns a correct + // exposure into a photograph. Sensor data is scene-referred and nearly + // linear; nothing anybody looks at is. Rendering it straight out is the + // dcraw default, and it is flat, dark through the midtones and clips its + // highlights instead of rolling them off. + // + // **In camera RGB, and after the adjustments**, which is a deliberate pair + // of choices: + // + // - Before the matrix, because that is where a base curve is defined and + // where every other converter applies one. The curve was tuned against + // this body's own primaries; moving it after the conversion would apply + // a Canon rendering to sRGB values and change what it does. + // - After exposure and the tonal operations, because those are corrections + // to *capture* and are only meaningful on linear values. A stop is a + // doubling; run exposure after a curve and it stops being one. + // + // Per channel rather than on luminance. It desaturates the extremes + // slightly, and that is the point — it is what makes a blown sky roll + // toward white rather than toward a saturated corner of the gamut, and it + // is what the camera's own JPEG does. + // + // The branch is on a uniform, so the whole dispatch takes the same path. + // It is off for a JPEG and any other already-rendered source, which must + // not be rendered twice, and for a body the profile database declines to + // offer any curve for at all. + if (u.base_curve_last.z > 0.5) {{ + c = vec3( + curve_eval( + u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, + u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, + u.base_curve_last.x, u.base_curve_last.y, c.r, + ), + curve_eval( + u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, + u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, + u.base_curve_last.x, u.base_curve_last.y, c.g, + ), + curve_eval( + u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, + u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, + u.base_curve_last.x, u.base_curve_last.y, c.b, + ), + ); + }} + + // Camera space -> linear sRGB. Applied after the adjustments so white + // balance and exposure act on sensor-native values, which is where they + // are physically meaningful. + // + // Identity for a non-linear source, which is already in sRGB primaries. + c = vec3( + dot(u.cam_to_srgb_0.rgb, c), + dot(u.cam_to_srgb_1.rgb, c), + dot(u.cam_to_srgb_2.rgb, c), + ); +") + }; + let source = format!( "// GENERATED — do not edit. // @@ -678,6 +793,13 @@ struct Params {{ // changed with the edit would mean rebuilding the pipeline layout, and the // cost of the unused declaration is a 1x1 placeholder texture. @group(0) @binding(3) var masks: texture_2d_array; +// A film stock's baked tables (FR-DEV-3f): the characteristic curves, and the +// density lookup that carries everything downstream of them. Declared +// unconditionally for the same reason the masks above are — one bind group +// layout for every generated shader — and bound to 1x1 placeholders when no +// stock is loaded, which costs eight bytes and no branch. +@group(0) @binding(4) var film_curves: texture_2d; +@group(0) @binding(5) var film_lut_texture: texture_3d; {sampler_helper}{helper_src}{encode_output} // Display-encoded sRGB back to linear, for sources that arrive that way. @@ -746,69 +868,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ c = mix(c, neutral, clipped); }} {body} - // ==== camera profile: the base curve (FR-DEV-3e) ==== - // - // Marked with `====` and not the `----` an operation block carries: this - // is not one, and the difference is what several tests count on to tell - // an edit apart from the reading of a file. - // - // The stage between demosaic and the working space that turns a correct - // exposure into a photograph. Sensor data is scene-referred and nearly - // linear; nothing anybody looks at is. Rendering it straight out is the - // dcraw default, and it is flat, dark through the midtones and clips its - // highlights instead of rolling them off. - // - // **In camera RGB, and after the adjustments**, which is a deliberate pair - // of choices: - // - // - Before the matrix, because that is where a base curve is defined and - // where every other converter applies one. The curve was tuned against - // this body's own primaries; moving it after the conversion would apply - // a Canon rendering to sRGB values and change what it does. - // - After exposure and the tonal operations, because those are corrections - // to *capture* and are only meaningful on linear values. A stop is a - // doubling; run exposure after a curve and it stops being one. - // - // Per channel rather than on luminance. It desaturates the extremes - // slightly, and that is the point — it is what makes a blown sky roll - // toward white rather than toward a saturated corner of the gamut, and it - // is what the camera's own JPEG does. - // - // The branch is on a uniform, so the whole dispatch takes the same path. - // It is off for a JPEG and any other already-rendered source, which must - // not be rendered twice, and for a body the profile database declines to - // offer any curve for at all. - if (u.base_curve_last.z > 0.5) {{ - c = vec3( - curve_eval( - u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, - u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, - u.base_curve_last.x, u.base_curve_last.y, c.r, - ), - curve_eval( - u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, - u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, - u.base_curve_last.x, u.base_curve_last.y, c.g, - ), - curve_eval( - u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, - u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, - u.base_curve_last.x, u.base_curve_last.y, c.b, - ), - ); - }} - - // Camera space -> linear sRGB. Applied after the adjustments so white - // balance and exposure act on sensor-native values, which is where they - // are physically meaningful. - // - // Identity for a non-linear source, which is already in sRGB primaries. - c = vec3( - dot(u.cam_to_srgb_0.rgb, c), - dot(u.cam_to_srgb_1.rgb, c), - dot(u.cam_to_srgb_2.rgb, c), - ); -{to_output} +{rendering_tail}{to_output} {store} }} ", @@ -1325,6 +1385,73 @@ mod tests { assert!(wb < op, "as-shot white balance must precede the operations"); } + #[test] + fn a_rendering_operation_takes_over_the_base_curve_and_the_camera_matrix() { + // TRACES: FR-DEV-3e | FR-DEV-3f + // A film stock's characteristic curve does the base curve's job, and + // the film node converts out of camera space itself. Emitting the + // profile's rendering as well would render the scene twice and convert + // it twice — a picture that comes out looking like neither the camera's + // rendering nor the film's, with a colour-management bug's signature + // and no colour-management bug to find. + let mut film = crate::ops::FilmSim::new(); + film.set_film_tables(Some(&crate::ops::FilmTables { + exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]], + curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES], + curve_log_min: -3.0, + curve_log_max: 4.0, + lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], + density_max: 3.0, + lut_size: 32, + })); + assert!(film.is_active(), "the fixture did not load"); + + let source = compose(&[Box::new(film) as Box]).source; + assert!( + source.contains("---- film_sim ----"), + "the operation itself must still be emitted" + ); + assert!( + !source.contains("base_curve_last.z > 0.5"), + "the base curve is still being applied on top of the film" + ); + // Asserted on the composer's own comment, not on the conversion + // itself: the film fragment performs exactly the same three dot + // products, so a substring search cannot tell the composer's copy from + // the operation's. What must be gone is the *second* one. + assert!( + !source.contains("Camera space -> linear sRGB"), + "the composer converted out of camera space after the film already had" + ); + assert_eq!( + source.matches("dot(u.cam_to_srgb_0.rgb, c)").count(), + 1, + "camera space is left exactly once, and it is the film that does it" + ); + } + + #[test] + fn an_operation_that_does_not_render_leaves_the_profile_alone() { + // The other half, and the one that would fail silently: a bug that + // suppressed the tail unconditionally renders every ordinary edit + // flat and uncorrected, which reads as a broken camera profile. + let source = compose(&[fake(&DESC_A, 2.0, false)]).source; + assert!(source.contains("base_curve_last.z > 0.5")); + assert!(source.contains("Camera space -> linear sRGB")); + } + + #[test] + fn an_inactive_film_node_leaves_the_profile_alone() { + // `renders()` is a property of the type, but the suppression must key + // off whether it is *active*. A film node sitting in the chain with no + // stock loaded is the default state of every photograph in the + // catalogue, and it must not disturb the camera's own rendering. + let film: Box = Box::new(crate::ops::FilmSim::new()); + let source = compose(&[film, fake(&DESC_A, 2.0, false)]).source; + assert!(source.contains("base_curve_last.z > 0.5")); + assert!(source.contains("Camera space -> linear sRGB")); + } + #[test] fn the_camera_matrix_is_applied_after_the_operations() { // Adjustments are meaningful in sensor-native space, where highlight diff --git a/core/dr-pipeline/src/ops/film_sim.rs b/core/dr-pipeline/src/ops/film_sim.rs new file mode 100644 index 0000000..e3baf5b --- /dev/null +++ b/core/dr-pipeline/src/ops/film_sim.rs @@ -0,0 +1,410 @@ +//! TRACES: FR-DEV-3f +//! Film simulation — the stock renders the picture. +//! +//! # Why this one replaces the base curve +//! +//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The base +//! curve exists because sensor data is scene-referred and nothing anybody looks +//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job, +//! from measurements, with a toe and a shoulder that were coated onto acetate +//! rather than drawn. Running both renders the image twice — the camera's +//! JPEG-ish rendering, and then a film's rendering of that — which is not what +//! either is for and looks like neither. +//! +//! So this node declares [`Operation::renders`], and the composer answers by +//! emitting neither the base curve nor the camera matrix. Both jobs move here: +//! the fragment takes camera RGB, converts it to linear sRGB itself with the +//! matrix already in the uniform block, and returns linear sRGB. That is a +//! contract worth stating plainly, because a node that got half of it wrong +//! would produce a picture that renders perfectly and is wrong everywhere. +//! +//! # Why the tables are not parameters +//! +//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they +//! are measurements of a physical thing, not something a slider moves. The +//! sliders here are exposure and print exposure, which are what a photographer +//! and a printer actually control. `dr-film` turns a stock plus those two +//! numbers into [`FilmTables`]; this node knows only the layout. +//! +//! Declared as a plain struct here rather than imported, so that dr-pipeline +//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does +//! with `Pa`. + +use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; +use crate::operation::{Operation, Uniform}; + +pub const ID: OpId = OpId("film_sim"); +pub const EXPOSURE: ParamId = ParamId("exposure"); +pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure"); + +/// How many samples a characteristic curve carries. +/// +/// Must agree with `dr_film::profile::CURVE_SAMPLES`. Restated rather than +/// imported because importing it is exactly the dependency this crate does not +/// take; [`FilmTables::is_well_formed`] is what stops the two drifting. +pub const CURVE_SAMPLES: usize = 256; + +/// The uniform field names the fragment reads the exposure matrix from. +/// +/// A table rather than a formatted string, because a `Uniform`'s name is +/// `&'static str`: building one per composition would mean leaking a string +/// every time a slider moved. +static MATRIX_FIELDS: [[&str; 3]; 3] = [ + ["m00", "m01", "m02"], + ["m10", "m11", "m12"], + ["m20", "m21", "m22"], +]; + +static DESCRIPTOR: OpDescriptor = OpDescriptor { + // Tone and colour both, and not `Effect`: a stock is not something applied + // on top of a photograph, it is what the photograph was made on. + attributes: &[Attribute::Tone, Attribute::Colour], + id: ID, + label: LocalizedKey("op.film_sim"), + params: &[ + ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0), + ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0), + ], +}; + +/// A stock reduced to what a shader runs, as `dr-film` bakes it. +/// +/// Layout is the contract between the two crates, so it is written down here +/// and checked rather than assumed: +/// +/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`. +/// - `curves` — `CURVE_SAMPLES` density triples, uniform over +/// `[curve_log_min, curve_log_max]`. +/// - `lut` — `lut_size³` linear sRGB triples in x-major order, uniform over +/// `[0, density_max]` on each axis. +#[derive(Debug, Clone, PartialEq)] +pub struct FilmTables { + pub exposure_matrix: [[f32; 3]; 3], + pub curves: Vec<[f32; 3]>, + pub curve_log_min: f32, + pub curve_log_max: f32, + pub lut: Vec<[f32; 3]>, + pub density_max: f32, + pub lut_size: usize, +} + +impl FilmTables { + /// Whether these tables are the shape the shader will index them at. + /// + /// Checked on the way in, because the failure otherwise is a shader + /// sampling past the end of a texture: undefined, silent, and different on + /// every driver. + pub fn is_well_formed(&self) -> bool { + self.curves.len() == CURVE_SAMPLES + && self.lut_size >= 2 + && self.lut.len() == self.lut_size.pow(3) + && self.density_max > 0.0 + && self.curve_log_max > self.curve_log_min + } +} + +/// TRACES: FR-DEV-3f +#[derive(Debug, Default, Clone)] +pub struct FilmSim { + exposure: f32, + print_exposure: f32, + tables: Option, +} + +impl FilmSim { + pub fn new() -> Self { + Self::default() + } + + /// Load a baked stock, or clear it. + /// + /// Malformed tables are refused rather than stored: an operation that is + /// active but cannot be indexed is worse than one that is off, because the + /// first renders garbage and the second renders the photograph. + pub fn set_tables(&mut self, tables: Option) { + match tables { + Some(t) if !t.is_well_formed() => { + log::error!( + "film_sim: refusing malformed tables ({} curve samples, {} lut entries at size {})", + t.curves.len(), + t.lut.len(), + t.lut_size + ); + self.tables = None; + } + other => self.tables = other, + } + } + + /// The loaded stock's tables, for whoever has to upload them. + pub fn tables(&self) -> Option<&FilmTables> { + self.tables.as_ref() + } +} + +impl Operation for FilmSim { + fn descriptor(&self) -> &'static OpDescriptor { + &DESCRIPTOR + } + + fn set_param(&mut self, id: ParamId, value: f32) { + match id { + EXPOSURE => self.exposure = value, + PRINT_EXPOSURE => self.print_exposure = value, + _ => log::warn!("film_sim: unknown parameter {id}"), + } + } + + fn param(&self, id: ParamId) -> f32 { + match id { + EXPOSURE => self.exposure, + PRINT_EXPOSURE => self.print_exposure, + _ => 0.0, + } + } + + /// Active exactly when a stock is loaded. + /// + /// Not "when a slider has moved", which is the rule everywhere else and + /// would be wrong here: a stock at zero exposure compensation is the whole + /// point of choosing it, and a node that went quiet at its defaults would + /// mean picking a film did nothing until you also nudged something. + fn is_active(&self) -> bool { + self.tables.is_some() + } + + /// This node renders; the camera's own rendering must not also run. + fn renders(&self) -> bool { + true + } + + fn set_film_tables(&mut self, tables: Option<&FilmTables>) { + self.set_tables(tables.cloned()); + } + + fn uniforms(&self) -> Vec { + let Some(t) = &self.tables else { + return Vec::new(); + }; + let m = t.exposure_matrix; + // Exposure rides in the matrix on the CPU when the stock is baked, so + // what is left here is the *shader's* copy of the same nine numbers. + // Spelled out one at a time because a uniform is a named `f32` in this + // pipeline and a matrix would be a second kind of thing for one caller. + let mut out = Vec::with_capacity(MATRIX_FIELDS.len() + 5); + for (l, row) in m.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + out.push(Uniform { name: MATRIX_FIELDS[l][c], value: *v }); + } + } + out.push(Uniform { name: "log_min", value: t.curve_log_min }); + out.push(Uniform { name: "log_max", value: t.curve_log_max }); + out.push(Uniform { name: "density_max", value: t.density_max }); + out.push(Uniform { name: "lut_size", value: t.lut_size as f32 }); + out.push(Uniform { name: "print_exposure", value: self.print_exposure }); + out + } + + fn wgsl_body(&self) -> String { + // Filtered by hand rather than through a sampler, which is what the + // framing prologue already does for the source: this pipeline has no + // sampler binding, and adding one to interpolate two lookups would + // cost a binding in every shader whether or not a film is loaded. + "\ +// Camera RGB to linear sRGB. The film's exposure matrix is defined against +// sRGB primaries, and this node has taken over the conversion the composer +// would otherwise have emitted at the end — see `Operation::renders`. +let scene = vec3( + dot(u.cam_to_srgb_0.rgb, c), + dot(u.cam_to_srgb_1.rgb, c), + dot(u.cam_to_srgb_2.rgb, c), +); + +// What each emulsion layer was exposed to. A matrix, exactly: the scene +// spectrum reconstructed from an sRGB triple is linear in that triple, so the +// integral over wavelength collapsed into these nine numbers when the stock +// was baked. +let exposure = vec3( + dot(vec3(m00, m01, m02), scene), + dot(vec3(m10, m11, m12), scene), + dot(vec3(m20, m21, m22), scene), +); +// 1e-10 rather than a clamp to zero: a black pixel has to land somewhere on +// the curve, and the toe is where it belongs. +let log_exposure = log10(max(exposure, vec3(0.0)) + 1e-10); + +// The characteristic curve: what density each layer develops to. Clamped, not +// extrapolated — past the shoulder a real emulsion stops responding, and +// extrapolating would turn a blown highlight into a colour cast that grows the +// more it is overexposed. +let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min), + vec3(0.0), vec3(1.0))); + +// Dye absorption, the print through the negative, the paper, the viewing +// illuminant and the chromatic adaptation — all of which take exactly three +// numbers in, which is why they fit in one lookup. +c = film_lut(clamp(density / density_max, vec3(0.0), vec3(1.0)), lut_size);" + .into() + } + + fn helpers(&self) -> &'static [crate::operation::Helper] { + &HELPERS + } +} + +static HELPERS: [crate::operation::Helper; 3] = [ + crate::operation::Helper { + name: "log10", + source: "\ +// WGSL has no log10, and `log2(x) * log10(2)` is the cheap identity for it. +fn log10(v: vec3) -> vec3 { + return log2(v) * 0.30103; +}", + }, + crate::operation::Helper { + name: "film_curve", + source: "\ +// Three characteristic curves, sampled from a 256-wide texture and +// interpolated by hand. `t` is already normalised to the curve's domain. +fn film_curve(t: vec3) -> vec3 { + let samples = u32(textureDimensions(film_curves).x); + let last = f32(samples - 1u); + var out = vec3(0.0); + for (var ch = 0u; ch < 3u; ch = ch + 1u) { + let x = t[ch] * last; + let i = min(u32(floor(x)), samples - 2u); + let f = x - f32(i); + let a = textureLoad(film_curves, vec2(i32(i), 0), 0); + let b = textureLoad(film_curves, vec2(i32(i) + 1, 0), 0); + out[ch] = mix(a[ch], b[ch], f); + } + return out; +}", + }, + crate::operation::Helper { + name: "film_lut", + source: "\ +// Trilinear interpolation of the density lookup, by hand for the same reason +// the curve above is: there is no sampler bound, and the eight loads are +// cache-neighbours. +fn film_lut(t: vec3, size: f32) -> vec3 { + let n = i32(size); + let x = t * (size - 1.0); + let base = min(vec3(floor(x)), vec3(n - 2)); + let f = x - vec3(base); + + var out = vec3(0.0); + for (var dx = 0; dx < 2; dx = dx + 1) { + let wx = select(1.0 - f.x, f.x, dx == 1); + for (var dy = 0; dy < 2; dy = dy + 1) { + let wy = select(1.0 - f.y, f.y, dy == 1); + for (var dz = 0; dz < 2; dz = dz + 1) { + let wz = select(1.0 - f.z, f.z, dz == 1); + let p = base + vec3(dx, dy, dz); + out = out + wx * wy * wz + * textureLoad(film_lut_texture, p, 0).rgb; + } + } + } + return out; +}", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + fn tables() -> FilmTables { + FilmTables { + exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]], + curves: vec![[0.0, 0.0, 0.0]; CURVE_SAMPLES], + curve_log_min: -3.0, + curve_log_max: 4.0, + lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], + density_max: 3.0, + lut_size: 32, + } + } + + #[test] + fn it_starts_inactive() { + assert!(!FilmSim::new().is_active()); + } + + #[test] + fn loading_a_stock_is_what_turns_it_on() { + // Not a moved slider, which is the rule for every other node. Choosing + // a film has to do something on its own, or picking one would appear + // to be broken until you also nudged the exposure. + let mut op = FilmSim::new(); + op.set_tables(Some(tables())); + assert!(op.is_active()); + op.set_tables(None); + assert!(!op.is_active()); + } + + #[test] + fn malformed_tables_are_refused_rather_than_stored() { + // The alternative is a shader indexing past the end of a texture, + // which is undefined, silent, and different on every driver. + let mut op = FilmSim::new(); + let mut bad = tables(); + bad.lut.truncate(10); + op.set_tables(Some(bad)); + assert!(!op.is_active(), "malformed tables were accepted"); + } + + #[test] + fn a_short_curve_is_refused_too() { + let mut op = FilmSim::new(); + let mut bad = tables(); + bad.curves.truncate(CURVE_SAMPLES - 1); + op.set_tables(Some(bad)); + assert!(!op.is_active()); + } + + #[test] + fn it_declares_itself_a_rendering_transform() { + // The whole reason the composer skips the base curve and the camera + // matrix. If this ever returned false the picture would be rendered + // twice and converted twice, which looks like a colour management bug + // a long way from here. + assert!(FilmSim::new().renders()); + } + + #[test] + fn the_matrix_reaches_the_shader_in_the_order_the_fragment_reads_it() { + // `m01` must be layer 0's response to sRGB green. A transposed matrix + // compiles, runs, and swaps the picture's colours. + let mut op = FilmSim::new(); + op.set_tables(Some(tables())); + let uniforms = op.uniforms(); + let named = |n: &str| uniforms.iter().find(|u| u.name == n).unwrap().value; + assert_eq!(named("m01"), 0.5); + assert_eq!(named("m10"), 0.1); + assert_eq!(named("m22"), 4.0); + } + + #[test] + fn an_inactive_node_publishes_no_uniforms() { + assert!(FilmSim::new().uniforms().is_empty()); + } + + #[test] + fn the_fragment_converts_out_of_camera_space_itself() { + // It has to: it has taken over the conversion the composer would + // otherwise emit at the end. + let mut op = FilmSim::new(); + op.set_tables(Some(tables())); + let wgsl = op.wgsl_body(); + assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}"); + } + + #[test] + fn every_helper_defines_the_function_it_names() { + for h in HELPERS { + assert!(h.source.contains(&format!("fn {}(", h.name)), "{}", h.name); + } + } +} diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index 1f00893..ef9283c 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -62,6 +62,7 @@ pub mod capture_sharpen; pub mod colour_mixer; pub mod curve; pub mod distortion; +pub mod film_sim; pub mod local_contrast; pub mod noise_reduction; pub mod vignetting; @@ -71,6 +72,7 @@ pub use capture_sharpen::CaptureSharpen; pub use colour_mixer::ColourMixer; pub use curve::ToneCurve; pub use distortion::Distortion; +pub use film_sim::{FilmSim, FilmTables}; // Clarity and texture are one implementation at two scales; see the module's // documentation for why that is two nodes and not one. pub use local_contrast::{Clarity, Texture}; diff --git a/docs/requirements.md b/docs/requirements.md index 62fdf1a..8ce0b58 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -326,6 +326,29 @@ own JPEG. ΔE2000 validation against ColorChecker references applies once DCP su simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag to auto-apply a matching render for Fujifilm files. +**Spectral film simulation, in addition rather than instead** (`dr-film`). Where a stock's +measurements exist, simulate the physics instead of replaying a grade: spectral sensitivity exposes +three emulsion layers, characteristic curves develop them to densities, dye densities absorb, and a +paper profile prints the negative with the enlarger's filtration solved rather than dialled. A +scanned negative is therefore orange and inverted, because that is what a negative is. + +Two things this buys that a LUT cannot. The parameters stay **physical** — opening up a stop moves +the picture along the film's own curve, shoulder and all, rather than scaling a number baked at one +exposure. And the **data cost inverts**: a stock is ~17 kB of published measurements where one +HaldCLUT is ~800 kB of one person's grade. + +A film simulation is a *rendering*, not an adjustment, so it replaces the camera profile's base +curve and the conversion out of camera space (`Operation::renders`) — applying both would render +the scene twice. + +*Acceptance:* a neutral scene printed through a colour negative's own paper renders neutral to +within 0.06 in linear sRGB; the baked lookup's interpolation error stays under one 8-bit code +value; and the shader agrees with the CPU model, which agrees in turn with an independent +reference implementation. + +**Open:** how the chosen stock persists. Sidecar parameters are `f32` and the stock list is +data-driven, so neither an index nor a name fits the existing shape. + **FR-DEV-3g — AI denoise.** Learned denoising operating in the raw domain, ideally jointly with demosaic. diff --git a/docs/traceability.md b/docs/traceability.md index a232573..2226ad9 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 189 | -| TRACES tags found | 564 | +| Source files scanned | 192 | +| TRACES tags found | 576 | | Requirements defined | 177 | | Requirements covered | 90 | | **Coverage** | **50.8%** (90/177) | @@ -51,31 +51,31 @@ _None._ | FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) | -| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:120`](../core/dr-pipeline/src/graph.rs#L120), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`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:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`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:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:1329`](../core/dr-pipeline/src/sidecar.rs#L1329), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`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:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:3003`](../ui/dr-ui/src/develop.rs#L3003), [`ui/dr-ui/src/develop.rs:3057`](../ui/dr-ui/src/develop.rs#L3057), [`ui/dr-ui/src/develop.rs:3101`](../ui/dr-ui/src/develop.rs#L3101), [`ui/dr-ui/src/develop.rs:3151`](../ui/dr-ui/src/develop.rs#L3151), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1222`](../ui/dr-ui/src/lib.rs#L1222), [`ui/dr-ui/src/lib.rs:1874`](../ui/dr-ui/src/lib.rs#L1874), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`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:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1905`](../ui/dr-ui/ui/app.slint#L1905), [`ui/dr-ui/ui/app.slint:903`](../ui/dr-ui/ui/app.slint#L903) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) | +| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2146`](../core/dr-gpu/src/adjust.rs#L2146), [`core/dr-gpu/src/adjust.rs:632`](../core/dr-gpu/src/adjust.rs#L632), [`core/dr-gpu/src/adjust.rs:751`](../core/dr-gpu/src/adjust.rs#L751), [`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:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:120`](../core/dr-pipeline/src/graph.rs#L120), [`core/dr-pipeline/src/graph.rs:411`](../core/dr-pipeline/src/graph.rs#L411), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`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:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`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:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:1329`](../core/dr-pipeline/src/sidecar.rs#L1329), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`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:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:3003`](../ui/dr-ui/src/develop.rs#L3003), [`ui/dr-ui/src/develop.rs:3057`](../ui/dr-ui/src/develop.rs#L3057), [`ui/dr-ui/src/develop.rs:3101`](../ui/dr-ui/src/develop.rs#L3101), [`ui/dr-ui/src/develop.rs:3151`](../ui/dr-ui/src/develop.rs#L3151), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1222`](../ui/dr-ui/src/lib.rs#L1222), [`ui/dr-ui/src/lib.rs:1874`](../ui/dr-ui/src/lib.rs#L1874), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`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:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1905`](../ui/dr-ui/ui/app.slint#L1905), [`ui/dr-ui/ui/app.slint:903`](../ui/dr-ui/ui/app.slint#L903) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | | FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3730`](../ui/dr-ui/src/develop.rs#L3730) | -| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:429`](../core/dr-gpu/tests/capture_sharpen.rs#L429), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:426`](../core/dr-pipeline/src/graph.rs#L426), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | -| 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:690`](../core/dr-gpu/src/adjust.rs#L690), [`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:1341`](../core/dr-pipeline/src/operation.rs#L1341), [`core/dr-pipeline/src/operation.rs:1366`](../core/dr-pipeline/src/operation.rs#L1366), [`core/dr-pipeline/src/operation.rs:1381`](../core/dr-pipeline/src/operation.rs#L1381), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:510`](../core/dr-pipeline/src/operation.rs#L510) | -| FR-DEV-3f | [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:57`](../core/dr-film/src/profile.rs#L57) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1022`](../core/dr-gpu/src/adjust.rs#L1022), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:751`](../core/dr-gpu/src/adjust.rs#L751), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/tests/capture_sharpen.rs:429`](../core/dr-gpu/tests/capture_sharpen.rs#L429), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:440`](../core/dr-pipeline/src/graph.rs#L440), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | +| 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:948`](../core/dr-gpu/src/adjust.rs#L948), [`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:1390`](../core/dr-pipeline/src/operation.rs#L1390), [`core/dr-pipeline/src/operation.rs:1468`](../core/dr-pipeline/src/operation.rs#L1468), [`core/dr-pipeline/src/operation.rs:1493`](../core/dr-pipeline/src/operation.rs#L1493), [`core/dr-pipeline/src/operation.rs:1508`](../core/dr-pipeline/src/operation.rs#L1508), [`core/dr-pipeline/src/operation.rs:1532`](../core/dr-pipeline/src/operation.rs#L1532), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:549`](../core/dr-pipeline/src/operation.rs#L549) | +| FR-DEV-3f | [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:57`](../core/dr-film/src/profile.rs#L57), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:191`](../core/dr-gpu/src/adjust.rs#L191), [`core/dr-gpu/src/adjust.rs:346`](../core/dr-gpu/src/adjust.rs#L346), [`core/dr-gpu/src/adjust.rs:464`](../core/dr-gpu/src/adjust.rs#L464), [`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-pipeline/src/graph.rs:258`](../core/dr-pipeline/src/graph.rs#L258), [`core/dr-pipeline/src/operation.rs:1390`](../core/dr-pipeline/src/operation.rs#L1390), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:106`](../core/dr-pipeline/src/ops/film_sim.rs#L106), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1) | | 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:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) | -| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | +| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:751`](../core/dr-gpu/src/adjust.rs#L751), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | | FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2654`](../ui/dr-ui/src/develop.rs#L2654), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1214`](../ui/dr-ui/src/lib.rs#L1214) | | 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:2594`](../ui/dr-ui/src/develop.rs#L2594), [`ui/dr-ui/src/develop.rs:2615`](../ui/dr-ui/src/develop.rs#L2615), [`ui/dr-ui/src/lib.rs:1186`](../ui/dr-ui/src/lib.rs#L1186), [`ui/dr-ui/src/library.rs:1528`](../ui/dr-ui/src/library.rs#L1528), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2512`](../ui/dr-ui/src/library_ui.rs#L2512), [`ui/dr-ui/src/library_ui.rs:2907`](../ui/dr-ui/src/library_ui.rs#L2907), [`ui/dr-ui/src/library_ui.rs:431`](../ui/dr-ui/src/library_ui.rs#L431), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1305`](../ui/dr-ui/ui/library.slint#L1305), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:912`](../ui/dr-ui/ui/library.slint#L912), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | -| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1783`](../core/dr-gpu/src/adjust.rs#L1783), [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:1945`](../core/dr-gpu/src/adjust.rs#L1945), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`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:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:367`](../core/dr-pipeline/src/graph.rs#L367), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`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:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`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:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2792`](../ui/dr-ui/src/develop.rs#L2792), [`ui/dr-ui/src/develop.rs:3275`](../ui/dr-ui/src/develop.rs#L3275), [`ui/dr-ui/src/develop.rs:3309`](../ui/dr-ui/src/develop.rs#L3309), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | +| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2069`](../core/dr-gpu/src/adjust.rs#L2069), [`core/dr-gpu/src/adjust.rs:2146`](../core/dr-gpu/src/adjust.rs#L2146), [`core/dr-gpu/src/adjust.rs:2231`](../core/dr-gpu/src/adjust.rs#L2231), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:751`](../core/dr-gpu/src/adjust.rs#L751), [`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:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:381`](../core/dr-pipeline/src/graph.rs#L381), [`core/dr-pipeline/src/graph.rs:411`](../core/dr-pipeline/src/graph.rs#L411), [`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:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`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:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2792`](../ui/dr-ui/src/develop.rs#L2792), [`ui/dr-ui/src/develop.rs:3275`](../ui/dr-ui/src/develop.rs#L3275), [`ui/dr-ui/src/develop.rs:3309`](../ui/dr-ui/src/develop.rs#L3309), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | | FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4377`](../ui/dr-ui/src/develop.rs#L4377), [`ui/dr-ui/src/develop.rs:4409`](../ui/dr-ui/src/develop.rs#L4409), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1273`](../ui/dr-ui/src/lib.rs#L1273), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:357`](../core/dr-pipeline/src/graph.rs#L357), [`core/dr-pipeline/src/graph.rs:410`](../core/dr-pipeline/src/graph.rs#L410), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2379`](../core/dr-gpu/src/adjust.rs#L2379), [`core/dr-pipeline/src/graph.rs:371`](../core/dr-pipeline/src/graph.rs#L371), [`core/dr-pipeline/src/graph.rs:424`](../core/dr-pipeline/src/graph.rs#L424), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:590`](../ui/dr-ui/src/settings_ui.rs#L590) | | FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:1797`](../ui/dr-ui/src/lib.rs#L1797), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3277`](../ui/dr-ui/src/library_ui.rs#L3277), [`ui/dr-ui/src/library_ui.rs:522`](../ui/dr-ui/src/library_ui.rs#L522), [`ui/dr-ui/src/library_ui.rs:6133`](../ui/dr-ui/src/library_ui.rs#L6133), [`ui/dr-ui/src/library_ui.rs:6210`](../ui/dr-ui/src/library_ui.rs#L6210), [`ui/dr-ui/src/library_ui.rs:6222`](../ui/dr-ui/src/library_ui.rs#L6222), [`ui/dr-ui/src/library_ui.rs:625`](../ui/dr-ui/src/library_ui.rs#L625), [`ui/dr-ui/src/library_ui.rs:682`](../ui/dr-ui/src/library_ui.rs#L682), [`ui/dr-ui/ui/app.slint:1374`](../ui/dr-ui/ui/app.slint#L1374), [`ui/dr-ui/ui/app.slint:994`](../ui/dr-ui/ui/app.slint#L994), [`ui/dr-ui/ui/library.slint:1313`](../ui/dr-ui/ui/library.slint#L1313), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:927`](../ui/dr-ui/ui/library.slint#L927) | | FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1222`](../core/dr-decode/src/locate.rs#L1222), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1049`](../core/dr-gpu/src/adjust.rs#L1049), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) | | FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1565`](../ui/dr-ui/src/library.rs#L1565), [`ui/dr-ui/src/library.rs:439`](../ui/dr-ui/src/library.rs#L439), [`ui/dr-ui/src/library.rs:733`](../ui/dr-ui/src/library.rs#L733), [`ui/dr-ui/src/library.rs:930`](../ui/dr-ui/src/library.rs#L930), [`ui/dr-ui/src/library_ui.rs:1571`](../ui/dr-ui/src/library_ui.rs#L1571), [`ui/dr-ui/src/library_ui.rs:3277`](../ui/dr-ui/src/library_ui.rs#L3277), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |