Render a film stock on the GPU, and let it take over the rendering

The stock model landed in dr-film with no way to see it. This is the
pipeline node, the two texture bindings it reads, and the end-to-end test
that proves the shader agrees with the model.

The design point is that a film simulation is not an adjustment. Every
other node changes a picture; this one makes it. A stock's characteristic
curve does the camera profile's base curve's job -- from measurements
rather than from a curve somebody drew -- so running both renders the
scene twice: the camera's rendering, and then a film's rendering of that.
It looks like neither, and it reads as a colour-management bug with no
colour-management bug to find.

So `Operation::renders` is new. A node declaring it takes camera RGB and
hands back linear sRGB, and the composer emits neither the base curve nor
the conversion out of camera space. Both halves move together, and the
composer keeps them as one string precisely so that getting half of it
right is impossible.

The tables are not parameters, for the reason vignetting's coefficients
are not: they are measurements. dr-pipeline declares the layout as a plain
struct and keeps its no-dependency property; the two crates share no types
on purpose. `EditGraph::set_film_tables` offers them to every node rather
than to the one that wants them, because knowing which concrete type is
which is what the graph is organised not to know.

Bindings 4 and 5 follow the masks precedent: declared unconditionally so
one bind group layout serves every generated shader, bound to 1x1
placeholders when no stock is loaded. Both are interpolated by hand with
textureLoad -- this pipeline binds no sampler, and adding one for two
lookups would cost a binding in every shader. Uploads are keyed on content
so an unchanged stock does not push half a megabyte across the bus per
frame.

The end-to-end test earned its place immediately: it found the density
lookup being filled z-fastest while a 3D texture upload wants x-fastest,
so the red and blue axes were transposed. Green matched exactly, which is
what that bug looks like -- a plausible photograph of the wrong colour,
and one that every unit test on either side of the seam passes. dr-film
now pins the layout in a test that needs no device, and states it where
the field is declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:12:56 +02:00
co-authored by Claude Opus 5
parent b6a95e1965
commit 4a2fcb6d22
14 changed files with 1222 additions and 93 deletions
Generated
+1
View File
@@ -1462,6 +1462,7 @@ version = "0.6.0"
dependencies = [
"bytemuck",
"dr-decode",
"dr-film",
"dr-pipeline",
"dr-segment",
"dr-types",
+54 -10
View File
@@ -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();
+4
View File
@@ -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.
+286
View File
@@ -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<FilmTextures>,
/// 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<f32> {
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.
+157
View File
@@ -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<GpuContext> {
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:?}");
}
+26 -5
View File
@@ -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.
+22
View File
@@ -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.
+14
View File
@@ -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<dyn Operation>` 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<crate::ops::FilmTables>) {
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 {
+18
View File
@@ -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
}
+190 -63
View File
@@ -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<f32> = 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<f32>(
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<f32>(
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<f32>;
// 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<f32>;
@group(0) @binding(5) var film_lut_texture: texture_3d<f32>;
{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<u32>) {{
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<f32>(
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<f32>(
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<dyn Operation>]).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<dyn Operation> = 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
+410
View File
@@ -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<FilmTables>,
}
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<FilmTables>) {
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<Uniform> {
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<f32>(
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<f32>(
dot(vec3<f32>(m00, m01, m02), scene),
dot(vec3<f32>(m10, m11, m12), scene),
dot(vec3<f32>(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<f32>(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<f32>(0.0), vec3<f32>(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<f32>(0.0), vec3<f32>(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<f32>) -> vec3<f32> {
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<f32>) -> vec3<f32> {
let samples = u32(textureDimensions(film_curves).x);
let last = f32(samples - 1u);
var out = vec3<f32>(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>(i32(i), 0), 0);
let b = textureLoad(film_curves, vec2<i32>(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<f32>, size: f32) -> vec3<f32> {
let n = i32(size);
let x = t * (size - 1.0);
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
let f = x - vec3<f32>(base);
var out = vec3<f32>(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<i32>(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);
}
}
}
+2
View File
@@ -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};
+23
View File
@@ -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.
+15 -15
View File
@@ -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) |