diff --git a/core/dr-pipeline/ops/dehaze.yaml b/core/dr-pipeline/ops/dehaze.yaml new file mode 100644 index 0000000..02ac516 --- /dev/null +++ b/core/dr-pipeline/ops/dehaze.yaml @@ -0,0 +1,56 @@ +# A hand-written node, and a neighbourhood one: the veil it removes is measured +# from the pixels around the one it is writing, so it runs in the detail stage +# rather than as a fragment in the fused pass. See `../src/detail.rs` for why +# that stage exists and `README.md`'s "Nodes that read their neighbours" for +# the contract. +# +# As with every `rust:` node, its descriptor, parameters and behaviour come +# from the type; this file exists so that `ops/` remains the one place the +# pipeline's order is written down. +id: dehaze +order: 125 + +rust: Dehaze + +why_rust: | + Haze is defined by what the pixels around a pixel are doing, and the schema + above describes a function of one colour — `wgsl:` is handed `c` and no + coordinate, which is the wall the detail stage exists on the other side of. + It declares `Affects::Detail` and returns five `DetailPass`es: four that + erode the dark channel into a per-pixel veil, and one that inverts the + scattering model with the transmission that veil implies. + + Nor is it four facts. The erosion is split into two exact stages per axis so + that a patch 1% of the frame wide costs its square root in taps, and the + split is arithmetic over the render size that has to be recomputed every + frame. Stretching this schema to express it would produce a worse language + than Rust, aimed at one caller. + +placement: | + First among the compositional detail nodes: after noise reduction and + capture sharpening, before clarity and texture. + + After noise reduction because dehaze divides by a transmission below one, so + it amplifies whatever noise is in the veiled distance by exactly the factor + it recovers the contrast by. Running it first would ask the denoiser to + remove grain that dehaze had already multiplied — the same argument clarity + records, and stronger here, because the amplification is largest in the low- + contrast regions where noise is most visible. + + Before clarity and texture, and for the reason that orders those two against + each other: coarse before fine. Dehaze acts on the widest structure in the + frame, the veil that varies with distance, and clarity's base should be + computed on the picture as the veil has left it rather than on a modelling + that is about to be divided out. + + What this cannot honour, and it is worth writing down rather than leaving to + be rediscovered: dehaze shifts colour. It subtracts a grey term and rescales, + so it changes saturation everywhere the veil is thick, and the colour + controls would ideally be correcting the picture that leaves here. They + cannot be. The detail stage runs as a *group* after every point operation, + because a neighbourhood pass is a separate dispatch reading a texture the + fused pass has already finished writing — so an `order:` placing this node + ahead of `vibrance` or `colour_mixer` would be a lie the chain cannot tell. + Interleaving the two would mean splitting the fused pass in half around this + one, which costs a second full-frame dispatch and intermediate on every edit + in the catalogue, whether or not it uses dehaze at all. diff --git a/core/dr-pipeline/src/ops/dehaze.rs b/core/dr-pipeline/src/ops/dehaze.rs new file mode 100644 index 0000000..8ef5630 --- /dev/null +++ b/core/dr-pipeline/src/ops/dehaze.rs @@ -0,0 +1,742 @@ +//! TRACES: FR-DEV-18 | FR-DSP-1 +//! Dehaze — measuring the veil distance puts over a subject, and dividing it +//! back out. +//! +//! Haze is not a tone problem wearing a spatial disguise, which is why none of +//! the controls already in the chain can remove it. Atmospheric scattering +//! composites an *airlight* over the scene in proportion to how far away each +//! part of it is: +//! +//! ```text +//! I = J·t + A·(1 − t), t = e^(−β·d) +//! ``` +//! +//! `J` is the scene, `A` the airlight, `t` the transmission and `d` the +//! distance. The two things it does — lifting the black point towards `A` and +//! compressing contrast towards it — are both *per-pixel*, because `d` is. A +//! black point that clears the mountains crushes the foreground; a contrast +//! curve that clears the mountains does the same. So the operation has to +//! estimate `t` at every pixel, and estimating it is the whole of the work. +//! +//! # How the transmission is estimated +//! +//! The dark-channel prior: in a haze-free patch of an ordinary photograph, at +//! least one of the three channels is close to zero *somewhere* — a shadow, a +//! dark surface, a saturated colour whose complementary channels are empty. +//! Wherever that local minimum sits well above zero instead, something +//! additive has lifted it, and the amount it has been lifted by is `A·(1 − t)`. +//! So the veil is a local minimum over the channels and over a patch, and the +//! transmission follows from it. +//! +//! The prior fails on a genuinely bright, genuinely haze-free subject — snow, +//! a white wall filling the patch — where it reads the brightness as veil and +//! this operation darkens it. That is the known failure of the prior and it is +//! why the amount is a slider a photographer sets by eye rather than an +//! automatic correction applied on open. +//! +//! # Why the airlight is taken as neutral, and as one +//! +//! The literature estimates `A` from the brightest few pixels of the dark +//! channel — a *whole-frame* reduction, which this stage cannot perform. The +//! detail chain hands each pass the pass before it at a fixed fraction of the +//! render size; there is no reduction to a single value in it, and adding one +//! would be a second chain of `log(n)` dispatches whose shape changes with +//! every viewport. +//! +//! It is not needed. Airlight is the illuminant scattered towards the camera, +//! and white balance is the first node in the chain — by the time the detail +//! stage runs, the illuminant has already been driven to neutral, so `A` is +//! grey and only its *magnitude* is unknown. Taking that magnitude as one — as +//! bright as diffuse white — makes the estimated veil a fixed multiple of the +//! true one, and a fixed multiple of the veil is exactly what the amount +//! slider already scales. The unknown lands on a control the photographer is +//! setting by eye anyway, rather than on a reduction the architecture would +//! have to grow to compute. +//! +//! # In linear light, deliberately unlike clarity +//! +//! [`crate::ops::local_contrast`] works in stops, because a perceptual local +//! contrast control has to mean the same thing in a highlight and in a shadow. +//! This operation is the opposite case: it inverts a physical model stated in +//! linear radiance, where the veil is an *additive* term. Taking logarithms +//! first would turn the subtraction into something that is not the inverse of +//! anything, and the correction would stop being a correction. The intermediate +//! this stage reads is linear and scene-referred, so the model applies to it as +//! written. +//! +//! # The radius is a fraction of the frame +//! +//! [`RenderScale`] names two units and choosing the wrong one is the mistake a +//! neighbourhood operation makes silently. The patch is compositional, so it +//! takes [`RenderScale::frame_fraction`] — the unit clarity, texture and a mask +//! feather already use — and never [`RenderScale::source_pixels`]. +//! +//! The test is whose property the length is. Capture sharpening's radius +//! belongs to the *sensor*: it stands for the spread of a point across +//! photosites and does not change when the frame is cropped. This one belongs +//! to the *picture*: the patch has to be large enough to contain something +//! dark and small enough that the veil it measures is still local, and both of +//! those are statements about how much of the composition it covers. Crop into +//! a quarter of the frame and the depth structure now fills it, so the patch +//! that measures it really has grown — which `frame_fraction` gives, because +//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs. +//! +//! Stated in raw pixels it would be a different photograph on screen and in +//! the file: the develop view renders at whatever the viewport needs +//! (FR-DSP-1), so a patch tuned at one-third scale would be three times too +//! narrow relative to the picture in the export — and the export is the only +//! render anybody keeps. +//! +//! Unlike texture, the pass is **not** dropped when the patch rounds small. +//! Texture's absence on a thumbnail is the honest answer, because a two-pixel +//! surface structure is not present in a 300-pixel rendering of the frame at +//! all. Dehaze changes the overall tone and colour of the picture, and a +//! thumbnail disagreeing with the develop view about *that* reads as a bug +//! rather than as a scale. So the patch is floored at one pixel instead. +//! +//! # What it costs, and the identity that makes it affordable +//! +//! A minimum over a patch is separable, as a Gaussian is: minimum along x, +//! then along y. That alone is not enough. The patch is 1% of the shorter edge +//! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that +//! measured 34 ms for clarity and became `docs/technical-debt.md` TD-4. +//! +//! A minimum has a property a Gaussian does not: **erosions compose by adding +//! their structuring elements**. The minimum over a contiguous run of `d` +//! pixels, followed by the minimum over `k` pixels spaced `d` apart, is the +//! minimum over the whole `k·d`-wide window, because `{0…d−1} ⊕ {0, d, …} = +//! {0…kd−1}`. With `d ≈ √W` the window costs `d + k ≈ 2√W` taps instead of +//! `W` — 16 rather than 61 at 4K — and it is the **same filter**, not an +//! approximation of one. +//! +//! That distinction is the whole reason this is allowed here while the strided +//! kernel `local_contrast` refuses is not. A stride samples an image that is +//! not band-limited and aliases: high-frequency content folds down into the +//! base, the base is subtracted, and the aliasing arrives as low-frequency +//! mottling across smooth gradients. This decomposition samples nothing — it +//! evaluates the exact minimum over every pixel of the window, in two steps. +//! +//! It is also why this operation does not use the reduced chain that TD-4 gave +//! clarity. The runner holds one reduced buffer, so every scaled pass in a +//! chain must declare the same `output_scale`; clarity's steps down with the +//! viewport, so a second operation choosing its own would disagree with it at +//! some window sizes and not others. Four cheap full-resolution passes cost +//! less than that coupling, and the decomposition is what makes them cheap. +//! +//! # The artefact this does not fix +//! +//! An erosion by a wide element carries a dark object's value out to the +//! patch radius around it, so the transmission map says "no haze here" in a +//! band around every dark foreground shape and the correction falls off +//! inside it. That band is the classic dark-channel halo, and the published +//! answer to it is a guided filter or a matting Laplacian, which refines the +//! transmission against the picture's own edges. +//! +//! Neither is done. A guided filter is a second chain carrying two more +//! moments per pixel, and this stage hands each pass exactly one scalar lane +//! (see [`DetailPass::wgsl`]); its regularisation parameter is a number no +//! requirement supplies and would be a guess dressed up as a constant. The +//! erosion of a continuous image is continuous, so what is left is a gradient +//! rather than an edge — an under-correction near dark objects, not a ring +//! around them. + +use std::sync::{Arc, LazyLock}; + +use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; +use crate::detail::{DetailPass, DetailStage, RenderScale}; +use crate::operation::{Affects, Helper, Operation, Uniform}; + +pub const ID: OpId = OpId("dehaze"); + +/// The one parameter, so the sidecar key reads `dehaze.amount`. +pub const AMOUNT: ParamId = ParamId("amount"); + +/// The patch the veil is measured over, as a fraction of the frame's shorter +/// edge — a **half**-width, so the window is twice this plus one. +/// +/// 1% — 30 px either side on a 4000 x 3000 frame. The two ends of the range +/// are set by opposite failures of the prior. Narrower, and an ordinary patch +/// of sky or skin contains nothing dark, so the minimum reads brightness as +/// veil and the operation darkens the subject. Wider, and the minimum stops +/// being local: it starts reporting the darkest thing in a large region of the +/// frame, so a single shadow suppresses the correction across a quarter of the +/// picture. +/// +/// It is within a factor of two of the 15 x 15 patch the dark-channel +/// literature uses on the ~600 px images it is demonstrated at, which is the +/// same fraction of a frame stated the other way round. +const PATCH: f32 = 0.01; + +/// The fraction of the estimated veil that full slider travel removes. +/// +/// Not one, and this is a decision about the photograph rather than a safety +/// margin. Aerial perspective is how a picture says "far away"; removing all +/// of it flattens a landscape into a cut-out, which is the look that makes +/// heavy dehaze recognisable as an effect. Keeping a twentieth of the veil +/// leaves distance reading as distance at the top of the slider. +const MAX_OMEGA: f32 = 0.95; + +/// The smallest transmission the recovery will divide by. +/// +/// Where the veil is nearly total there is nothing left to recover — the +/// signal that survived is a few percent of the airlight, and dividing it back +/// up amplifies whatever noise came with it by the same factor. A tenth is the +/// floor the dark-channel literature uses and it means the same thing here: at +/// worst this operation multiplies by ten, and a region that hits the floor +/// keeps a trace of haze rather than becoming a picture of its own noise. +const MIN_TRANSMISSION: f32 = 0.1; + +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: ID, + label: LocalizedKey("op.dehaze"), + params: vec![ParamDescriptor::amount("amount", "param.dehaze.amount")], + attributes: vec![Attribute::Detail], + }) +}); + +/// The darkest channel at a pixel, floored at zero. +/// +/// Declared here rather than in `_helpers.yaml` because it is not a shared +/// idea: it is the dark-channel prior's own statistic and means nothing +/// outside this file. +const DARK_CHANNEL: Helper = Helper { + name: "dark_channel", + source: "\ +// The smallest of the three channels — the dark-channel prior's statistic, +// before the minimum over a patch is taken. +// +// Floored at zero because the intermediate is unclipped and scene-referred, so +// an out-of-gamut colour arrives with a negative channel. A negative veil +// would come back through the recovery as extra contrast in exactly the +// pixels the working space could not represent, which is a bright fringe +// arriving from a control the photographer reads as haze removal. +fn dark_channel(c: vec3) -> f32 { + return max(min(min(c.r, c.g), c.b), 0.0); +}", +}; + +static HELPERS: &[Helper] = &[DARK_CHANNEL]; + +/// TRACES: FR-DEV-18 +/// Dehaze: estimate the atmospheric veil from the picture and divide it out. +/// +/// `Default` is derived rather than written out: the neutral of this operation +/// is an amount of zero and nothing else, so there is no second fact for a +/// hand-written impl to state. Capture sharpening needs one because its radius +/// has no neutral value — a blur of zero width is not the identity, it is a +/// kernel that does not exist — and the patch here is a constant rather than a +/// parameter, so that problem does not arise. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Dehaze { + /// −100…100, exactly as the slider reports it. Negative *adds* haze — see + /// [`Self::omega`]. + amount: f32, +} + +impl Dehaze { + pub fn new() -> Self { + Self::default() + } + + /// Start from a slider position, for tests and presets. + pub fn with_amount(amount: f32) -> Self { + Self { amount } + } + + /// The patch's half-width at this render, in **render pixels**. + /// + /// The one unit conversion this operation performs, and a method rather + /// than a line inside [`Self::passes`] so that a test can state what it + /// expects without repeating the rounding rule — a test that recomputed it + /// would agree with a bug in it. + /// + /// Floored at one pixel rather than allowed to reach zero: see the module + /// documentation for why this operation does not drop its pass on a + /// thumbnail the way texture does. + pub fn patch(&self, scale: RenderScale) -> u32 { + scale.frame_fraction(PATCH).round().max(1.0) as u32 + } + + /// How much of the estimated veil this setting removes. + /// + /// Negative at a negative amount, and the same formula then *adds* haze: + /// the recovery becomes a composite of airlight over the picture in + /// proportion to the veil already measured. So negative dehaze deepens the + /// aerial perspective a scene already has rather than fogging it evenly, + /// which is what a photographer asking for atmosphere means — and a scene + /// with no haze in it stays clear, because there is no veil to scale. + fn omega(&self) -> f32 { + self.amount / 100.0 * MAX_OMEGA + } +} + +/// A minimum filter of half-width `radius`, split into two exact stages. +/// +/// See the module documentation: eroding by a contiguous run and then by a set +/// of points spaced one run apart erodes by the sum of the two, which is the +/// whole window. This is the arithmetic of that split, in one place, because +/// both axes need it and a second copy is a second chance to get the centring +/// wrong. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Split { + /// Length of the contiguous run the first pass takes the minimum over. + pub run: u32, + /// How many runs the second pass chains together, spaced `run` apart. + pub span: u32, + /// What the second pass subtracts from its offsets to centre the window. + /// + /// The composite covers `run * span` pixels, which is at least the window + /// asked for and can be one or two more; the surplus falls on the far side + /// rather than being trimmed, because trimming it would need a third pass + /// and a patch a pixel wider on one side is not a visible difference in a + /// field this smooth. + pub shift: i32, +} + +impl Split { + /// Split a window of half-width `radius`. + /// + /// `run` is the square root of the window rather than any other divisor + /// because `d + ceil(W/d)` is smallest there — the two stages cost the + /// same, which is what minimises their sum for a fixed product. + pub fn of(radius: u32) -> Self { + let width = 2 * radius + 1; + let run = (width as f32).sqrt().ceil().max(1.0) as u32; + let span = width.div_ceil(run); + Self { + run, + span, + shift: ((run * span - 1) / 2) as i32, + } + } + + /// The furthest the second pass reads, in pixels. + /// + /// Stated rather than assumed symmetric: the composite window is centred + /// to within a pixel and not exactly, so the two directions can differ by + /// one. An understated radius is a seam at every tile boundary (ARCH + /// §5.3), which is the kind of artefact that looks like a driver bug. + pub fn reach(&self) -> u32 { + let far = (self.span.saturating_sub(1) * self.run) as i32 - self.shift; + self.shift.max(far).max(0) as u32 + } +} + +impl Operation for Dehaze { + fn descriptor(&self) -> Arc { + Arc::clone(&DESCRIPTOR) + } + + fn set_param(&mut self, _id: ParamId, value: f32) { + self.amount = value; + } + + fn param(&self, _id: ParamId) -> f32 { + self.amount + } + + fn is_active(&self) -> bool { + self.amount != 0.0 + } + + /// Never called: a neighbourhood operation contributes no fused fragment, + /// and `compose_full` filters it out before asking. + fn wgsl_body(&self) -> String { + String::new() + } + + fn uniforms(&self) -> Vec { + Vec::new() + } + + fn affects(&self) -> Affects { + Affects::Detail + } + + fn detail(&self) -> Option<&dyn DetailStage> { + Some(self) + } + + fn helpers(&self) -> &'static [Helper] { + HELPERS + } +} + +impl DetailStage for Dehaze { + fn passes(&self, scale: RenderScale) -> Vec { + let split = Split::of(self.patch(scale)); + + // The run stage's uniforms are the same on both axes, and so are the + // span stage's. Only the offset expression differs, which is what + // `erode_run` and `erode_span` take as an argument — each filter + // written once, so the two axes cannot drift into being different + // filters. + let run = vec![Uniform { + name: "run", + value: split.run as f32, + }]; + let span = vec![ + Uniform { + name: "span", + value: split.span as f32, + }, + Uniform { + name: "stride", + value: split.run as f32, + }, + Uniform { + name: "shift", + value: split.shift as f32, + }, + ]; + + vec![ + DetailPass { + output_scale: 1, + label: "veil-run-x", + // The run starts at this pixel and walks forward, so it reads + // `run - 1` beyond itself and nothing behind. + radius: split.run.saturating_sub(1), + storage: Vec::new(), + uniforms: run.clone(), + wgsl: erode_run(Axis::X), + }, + DetailPass { + output_scale: 1, + label: "veil-span-x", + radius: split.reach(), + storage: Vec::new(), + uniforms: span.clone(), + wgsl: erode_span(Axis::X), + }, + DetailPass { + output_scale: 1, + label: "veil-run-y", + radius: split.run.saturating_sub(1), + storage: Vec::new(), + uniforms: run, + wgsl: erode_run(Axis::Y), + }, + DetailPass { + output_scale: 1, + label: "veil-span-y", + radius: split.reach(), + storage: Vec::new(), + uniforms: span, + wgsl: erode_span(Axis::Y), + }, + DetailPass { + output_scale: 1, + label: "clear", + // Reads only the pixel it writes: the veil arrived in the + // scratch lane four passes ago. + radius: 0, + storage: Vec::new(), + uniforms: vec![ + Uniform { + name: "omega", + value: self.omega(), + }, + Uniform { + name: "min_transmission", + value: MIN_TRANSMISSION, + }, + ], + wgsl: CLEAR.to_string(), + }, + ] + } +} + +/// Which way a separable half runs. +#[derive(Clone, Copy)] +enum Axis { + X, + Y, +} + +impl Axis { + /// The offset expression for a scalar step `i` along this axis. + fn offset(&self, step: &str) -> String { + match self { + Axis::X => format!("vec2({step}, 0)"), + Axis::Y => format!("vec2(0, {step})"), + } + } +} + +/// The first stage: the minimum over a contiguous run. +/// +/// Along x it reads the colour and reduces it to the dark channel; along y the +/// dark channel is already in the scratch lane, so it reads that instead. +/// Doing the channel minimum again on the second axis would be reducing a +/// scalar and would quietly discard the x erosion. +/// +/// Neither stage touches `c`. The recovery needs the original colour *and* the +/// veil in the same place at the same time, and the ping-pong hands each pass +/// only what the pass before it wrote — so the veil travels in `aux` and the +/// colour rides through untouched. See [`DetailPass::wgsl`]. +fn erode_run(axis: Axis) -> String { + let source = match axis { + Axis::X => "dark_channel(tap(coord, OFFSET))", + Axis::Y => "tap_aux(coord, OFFSET)", + }; + let first = source.replace("OFFSET", &axis.offset("0")); + let rest = source.replace("OFFSET", &axis.offset("i")); + + format!( + "\ +// Half of the erosion's first stage: the minimum over `run` contiguous pixels, +// walking forward from this one. The second stage chains these together, and +// the two structuring elements add up to the whole patch — which is why this +// one is not centred and does not need to be. +let n = i32(run); +var veil = {first}; +for (var i = 1; i < n; i = i + 1) {{ + veil = min(veil, {rest}); +}} +aux = veil;" + ) +} + +/// The second stage: the minimum over `span` points spaced `stride` apart. +/// +/// Each of those points already holds the minimum over the run that starts +/// there, so this reads the whole window while touching `span` pixels of it. +/// `shift` is what centres the composite on the pixel being written; without +/// it the veil would be measured from a patch lying entirely to one side, and +/// the correction would appear to lag the picture by half a patch. +fn erode_span(axis: Axis) -> String { + let offset = axis.offset("j * s - o"); + let first = axis.offset("-o"); + + format!( + "\ +// The erosion's second stage. `span` taps, spaced a whole run apart, each +// standing for the run that begins at it — so the minimum over the patch costs +// `run + span` taps rather than the `run * span` pixels it covers, and it is +// the exact minimum over all of them rather than a sample of them. +let n = i32(span); +let s = i32(stride); +let o = i32(shift); +var veil = tap_aux(coord, {first}); +for (var j = 1; j < n; j = j + 1) {{ + veil = min(veil, tap_aux(coord, {offset})); +}} +aux = veil;" + ) +} + +/// The recovery: invert the scattering model with the transmission the erosion +/// implies. +const CLEAR: &str = "\ +// The veil the four erosion passes measured: the smallest channel anywhere in +// the patch around this pixel, which the dark-channel prior reads as the +// airlight that has been composited over the scene here. +// +// Capped at one because the airlight is taken as diffuse white (see the module +// documentation) and the intermediate is unclipped: a specular highlight +// arrives at three, and three units of `veil` would drive the transmission +// negative and turn the recovery inside out. A value above one is a highlight, +// not more haze. The lower bound is already guaranteed by `dark_channel`. +let veil = min(aux, 1.0); + +// `omega * veil` is the part of the veil this setting removes — negative at a +// negative amount, where the same expression composites airlight back on and +// deepens the aerial perspective instead. +let lifted = omega * veil; + +// The transmission implied by that veil, floored. Where the veil is nearly +// total the surviving signal is a few percent of the airlight, and dividing it +// back up amplifies its noise by the same factor; the floor is what makes the +// worst case a trace of remaining haze rather than a picture of the noise. +// Adding haze cannot reach it — `lifted` is negative there, so the +// transmission is above one and the max never bites. +let t = max(1.0 - lifted, min_transmission); + +// The scattering model, inverted: I = J·t + A·(1 − t) with A taken as one, so +// J = (I − A·(1 − t)) / t. Applied per channel rather than as a gain on +// luminance, and that is the difference from every other control in this +// stage: the veil is grey, so subtracting it *is* a chromaticity change, and +// it is the right one — a haze-veiled distance is desaturated because the +// airlight diluted it, and removing the airlight is what gives the colour +// back. +c = (c - lifted) / t;"; + +#[cfg(test)] +mod tests { + use super::*; + use crate::detail::compose_detail; + use dr_types::ColourSpace; + + fn ops(amount: f32) -> Vec> { + vec![Box::new(Dehaze::with_amount(amount))] + } + + fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail { + compose_detail(&ops(amount), scale, ColourSpace::Srgb) + } + + #[test] + fn dehaze_starts_neutral_and_costs_nothing() { + // The rule the whole pipeline rests on. An unedited photograph must not + // pay for a slider nobody has touched — and this one is five dispatches + // when it is on, so "nothing" here is a worthwhile amount of nothing. + assert!(!Dehaze::new().is_active()); + assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty()); + } + + #[test] + fn the_patch_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() { + // TRACES: FR-DSP-1 — the decision the units of this file rest on. + // + // The patch has to contain something dark and has to stay local, and + // both are statements about how much of the *composition* it covers. So + // it must be the same proportion of the picture on a proxy as in the + // export, which `frame_fraction` gives and `source_pixels` would not: + // at one-third scale a source-pixel patch would be three times too + // narrow relative to the frame, and the export — the only render + // anybody keeps — would be the one that looked nothing like what was + // tuned. + // + // frame_fraction takes the *shorter* edge, and PATCH is 0.01: + // 300 → 0.01 × 300 = 3 + // 3000 → 0.01 × 3000 = 30 + // 4500 → 0.01 × 4500 = 45 + for (w, h, expected) in [(400u32, 300u32, 3u32), (4000, 3000, 30), (6000, 4500, 45)] { + let patch = Dehaze::with_amount(50.0).patch(RenderScale::full((w, h))); + assert_eq!(patch, expected, "{w}x{h}"); + assert!( + (patch as f32 / w.min(h) as f32 - PATCH).abs() < 0.002, + "the patch drifted from its declared fraction at {w}x{h}" + ); + } + } + + #[test] + fn a_thumbnail_still_gets_the_correction() { + // Deliberately unlike texture, which contributes no pass once its + // kernel rounds to nothing. Texture's absence at that size is honest — + // a two-pixel surface structure is not in the picture. Dehaze changes + // the overall tone and colour, so a thumbnail that disagreed with the + // develop view about it would read as a bug. + let tiny = RenderScale::full((48, 32)); + assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1); + assert_eq!(composed(50.0, tiny).len(), 5); + } + + #[test] + fn the_split_erosion_covers_the_whole_patch_and_costs_its_square_root() { + // The identity the affordability of this operation rests on: eroding by + // a run of `d` and then by `k` points spaced `d` apart erodes by the + // whole `k·d` window, because structuring elements add. If the product + // ever falls short the patch is silently narrower than the one this + // file documents, and nothing else would say so. + // + // 30 px either side → a window of 61 + // run = ceil(sqrt(61)) = 8 + // span = ceil(61 / 8) = 8 → covers 64 >= 61 + let split = Split::of(30); + assert_eq!(split.run, 8); + assert_eq!(split.span, 8); + assert!(split.run * split.span >= 61, "the window is not covered"); + assert!( + split.run + split.span < 61, + "the split costs more taps than the window it replaces" + ); + // Centred to within the pixel the odd surplus leaves over: the window + // covers [-31, 32] around the pixel being written. + assert_eq!(split.shift, 31); + assert_eq!(split.reach(), 31); + } + + #[test] + fn a_patch_of_one_pixel_still_splits_into_something_that_runs() { + // The degenerate end, which a thumbnail reaches. A window of three + // splits into two runs of two, covering four — one pixel more than + // asked for, on the far side, which is the surplus `Split::shift` + // documents rather than an error to correct with a third pass. + let split = Split::of(1); + assert_eq!(split.run, 2); + assert_eq!(split.span, 2); + assert!(split.run * split.span >= 3, "the window is not covered"); + assert_eq!(split.shift, 1); + assert_eq!(split.reach(), 1); + } + + #[test] + fn the_chain_is_four_erosions_and_a_recovery() { + // The shape of the operation, asserted where it is cheap to assert. + // The erosions leave the colour alone and hand the veil forward in the + // scratch lane; only the last pass touches `c`, which is what makes an + // unsharp-mask-shaped operation expressible in a chain that hands each + // pass exactly one texture. + let composed = composed(60.0, RenderScale::full((2000, 1500))); + let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect(); + assert_eq!( + labels, + [ + "dehaze/veil-run-x", + "dehaze/veil-span-x", + "dehaze/veil-run-y", + "dehaze/veil-span-y", + "dehaze/clear", + ] + ); + + // Only the last writes the display texture, so the output transform + // happens exactly once (FR-DEV-2). + assert!(composed.passes[..4].iter().all(|p| !p.writes_output)); + assert!(composed.passes[4].writes_output); + + // Nothing here uses the reduced chain — see the module documentation + // for why a second operation cannot pick its own `output_scale` while + // the runner holds one reduced buffer. + assert!(composed.passes.iter().all(|p| p.output_scale == 1)); + + // Every pass declares a uniform block the GPU will accept: a struct + // whose size is not a multiple of sixteen bytes is rejected outright by + // the WGSL uniform address space rules. + assert!(composed.passes.iter().all(|p| p.uniforms.len() % 4 == 0)); + assert!(composed + .passes + .iter() + .all(|p| p.uniforms.iter().all(|v| v.is_finite()))); + } + + #[test] + fn adding_haze_is_the_same_expression_with_the_sign_turned_round() { + // Negative dehaze is the forward model rather than a second operation: + // it composites airlight back on in proportion to the veil already + // measured, so it deepens the aerial perspective a scene has instead of + // fogging it evenly — and a scene with no haze in it stays clear. + assert!(Dehaze::with_amount(-100.0).is_active()); + assert!(Dehaze::with_amount(-40.0).omega() < 0.0); + let symmetric = Dehaze::with_amount(-40.0).omega() + Dehaze::with_amount(40.0).omega(); + assert!(symmetric.abs() < 1e-6, "the two directions disagree"); + // And at the top of the range some veil is deliberately left behind, so + // a landscape keeps its distance rather than becoming a cut-out. + assert!(Dehaze::with_amount(100.0).omega() < 1.0); + } + + #[test] + fn the_veil_is_measured_from_the_colour_once_and_then_from_the_lane() { + // The one asymmetry between the two axes, and the one that would be + // invisible if it were wrong: x reduces the colour to its dark channel, + // y erodes the scalar x left behind. Taking the channel minimum again + // on the second axis would reduce a scalar and quietly discard the + // whole x erosion — a patch half the width this file documents, with + // nothing to say so. + let composed = composed(60.0, RenderScale::full((2000, 1500))); + assert!(composed.passes[0].source.contains("dark_channel(tap(coord")); + assert!(!composed.passes[2].source.contains("dark_channel(tap(coord")); + // The helper is still emitted for every pass of the operation, and it + // must define the function it is named for or the shader fails to + // compile a long way from here. + assert!(composed + .passes + .iter() + .all(|p| p.source.contains("fn dark_channel("))); + } +} diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index 46caca1..0e5f63a 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -69,6 +69,7 @@ pub mod aberration; pub mod capture_sharpen; pub mod colour_mixer; pub mod curve; +pub mod dehaze; pub mod distortion; pub mod film_sim; pub mod local_contrast; @@ -79,6 +80,7 @@ pub use aberration::Aberration; pub use capture_sharpen::CaptureSharpen; pub use colour_mixer::ColourMixer; pub use curve::ToneCurve; +pub use dehaze::Dehaze; pub use distortion::Distortion; pub use film_sim::{FilmSim, FilmTables}; // Clarity and texture are one implementation at two scales; see the module's diff --git a/core/dr-pipeline/tests/declared_parity.rs b/core/dr-pipeline/tests/declared_parity.rs index bce9d46..5c420b1 100644 --- a/core/dr-pipeline/tests/declared_parity.rs +++ b/core/dr-pipeline/tests/declared_parity.rs @@ -32,8 +32,8 @@ //! # What is not covered, and why that is honest //! //! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`, -//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture` — names a -//! hand-written type and has no declaration to interpret. It is not skipped +//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze` — +//! names a hand-written type and has no declaration to interpret. It is not skipped //! silently: [`every_declared_node_is_checked`] asserts the two sets partition //! `ops/` between them, so a node that stops being declared cannot quietly //! drop out of this file's coverage. @@ -398,6 +398,7 @@ fn every_declared_node_is_checked() { "capture_sharpen", "clarity", "colour_mixer", + "dehaze", "film_sim", "noise_reduction", "texture", diff --git a/docs/requirements.md b/docs/requirements.md index 5db8d96..2cd0aec 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -455,6 +455,19 @@ photographer leaves a RAW editor for a pixel editor mid-workflow. This is not th pixel editing excluded by §1.3 — it is a standard parameterised develop operation, and the brush infrastructure required by FR-DEV-3's masks already covers most of the cost. +**FR-DEV-18 — Dehaze.** Remove, or add, the atmospheric veil that distance puts between the camera +and the subject, as a develop operation with a single symmetric amount. The transmission is +estimated from the photograph rather than supplied by the photographer, and every length the +estimate depends on is stored as a fraction of the frame, so that what is judged on screen is what +lands in the exported file (FR-DSP-1). + +Haze is the one degradation the tone and colour controls cannot reach, because it is spatially +varying: a black point that clears the mountains crushes the foreground, and a contrast curve that +clears the mountains does the same. It belongs with texture and clarity in FR-DEV-3's adjustment +set as a member of the compositional detail family — the operations whose radius is a property of +the picture rather than of the sensor — and it runs in the same neighbourhood stage, for the same +reason: it is defined by what the pixels around a pixel are doing. + ### 3.4 Display and interaction **FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually diff --git a/docs/traceability.md b/docs/traceability.md index f577fa7..19c7bfe 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 336 | -| TRACES tags found | 1125 | -| Requirements defined | 180 | -| Requirements covered | 127 | -| **Coverage** | **70.6%** (127/180) | +| Source files scanned | 338 | +| TRACES tags found | 1128 | +| Requirements defined | 181 | +| Requirements covered | 128 | +| **Coverage** | **70.7%** (128/181) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 94 | 125 | +| FR | 95 | 126 | | NFR | 30 | 49 | | R | 3 | 6 | @@ -58,6 +58,7 @@ _None._ | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:506`](../core/dr-catalog/src/schema.rs#L506), [`core/dr-catalog/src/schema.rs:573`](../core/dr-catalog/src/schema.rs#L573), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/align.rs:291`](../core/dr-face/src/align.rs#L291), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:330`](../ui/dr-ui/src/faces.rs#L330), [`ui/dr-ui/src/faces.rs:346`](../ui/dr-ui/src/faces.rs#L346), [`ui/dr-ui/src/identity_ui.rs:540`](../ui/dr-ui/src/identity_ui.rs#L540), [`ui/dr-ui/src/lib.rs:1256`](../ui/dr-ui/src/lib.rs#L1256), [`ui/dr-ui/src/library.rs:3293`](../ui/dr-ui/src/library.rs#L3293), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385), [`ui/dr-ui/src/library.rs:3606`](../ui/dr-ui/src/library.rs#L3606), [`ui/dr-ui/src/library.rs:3742`](../ui/dr-ui/src/library.rs#L3742), [`ui/dr-ui/src/library.rs:3778`](../ui/dr-ui/src/library.rs#L3778), [`ui/dr-ui/ui/settings.slint:427`](../ui/dr-ui/ui/settings.slint#L427), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:283`](../ui/dr-ui/ui/identity.slint#L283) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | +| FR-DEV-18 | [`core/dr-pipeline/src/ops/dehaze.rs:1`](../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:221`](../core/dr-pipeline/src/ops/dehaze.rs#L221) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403) | | FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2217`](../core/dr-gpu/src/adjust.rs#L2217), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/coverage.rs:122`](../core/dr-pipeline/src/coverage.rs#L122), [`core/dr-pipeline/src/coverage.rs:1`](../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:498`](../core/dr-pipeline/src/framing.rs#L498), [`core/dr-pipeline/src/framing.rs:753`](../core/dr-pipeline/src/framing.rs#L753), [`core/dr-pipeline/src/graph.rs:209`](../core/dr-pipeline/src/graph.rs#L209), [`core/dr-pipeline/src/graph.rs:290`](../core/dr-pipeline/src/graph.rs#L290), [`core/dr-pipeline/src/graph.rs:735`](../core/dr-pipeline/src/graph.rs#L735), [`core/dr-pipeline/src/mask.rs:151`](../core/dr-pipeline/src/mask.rs#L151), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:535`](../core/dr-pipeline/src/operation.rs#L535), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:1165`](../core/dr-pipeline/src/sidecar.rs#L1165), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1831`](../core/dr-pipeline/src/sidecar.rs#L1831), [`core/dr-pipeline/src/sidecar.rs:1891`](../core/dr-pipeline/src/sidecar.rs#L1891), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:850`](../core/dr-segment/src/refine.rs#L850), [`ui/dr-ui/src/develop.rs:103`](../ui/dr-ui/src/develop.rs#L103), [`ui/dr-ui/src/develop.rs:1517`](../ui/dr-ui/src/develop.rs#L1517), [`ui/dr-ui/src/develop.rs:165`](../ui/dr-ui/src/develop.rs#L165), [`ui/dr-ui/src/develop.rs:1933`](../ui/dr-ui/src/develop.rs#L1933), [`ui/dr-ui/src/develop.rs:2147`](../ui/dr-ui/src/develop.rs#L2147), [`ui/dr-ui/src/develop.rs:2165`](../ui/dr-ui/src/develop.rs#L2165), [`ui/dr-ui/src/develop.rs:2179`](../ui/dr-ui/src/develop.rs#L2179), [`ui/dr-ui/src/develop.rs:2201`](../ui/dr-ui/src/develop.rs#L2201), [`ui/dr-ui/src/develop.rs:2347`](../ui/dr-ui/src/develop.rs#L2347), [`ui/dr-ui/src/develop.rs:2445`](../ui/dr-ui/src/develop.rs#L2445), [`ui/dr-ui/src/develop.rs:328`](../ui/dr-ui/src/develop.rs#L328), [`ui/dr-ui/src/develop.rs:3502`](../ui/dr-ui/src/develop.rs#L3502), [`ui/dr-ui/src/develop.rs:3562`](../ui/dr-ui/src/develop.rs#L3562), [`ui/dr-ui/src/develop.rs:365`](../ui/dr-ui/src/develop.rs#L365), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:3993`](../ui/dr-ui/src/develop.rs#L3993), [`ui/dr-ui/src/develop.rs:4059`](../ui/dr-ui/src/develop.rs#L4059), [`ui/dr-ui/src/develop.rs:4073`](../ui/dr-ui/src/develop.rs#L4073), [`ui/dr-ui/src/develop.rs:4265`](../ui/dr-ui/src/develop.rs#L4265), [`ui/dr-ui/src/develop.rs:5093`](../ui/dr-ui/src/develop.rs#L5093), [`ui/dr-ui/src/develop.rs:5214`](../ui/dr-ui/src/develop.rs#L5214), [`ui/dr-ui/src/develop.rs:5370`](../ui/dr-ui/src/develop.rs#L5370), [`ui/dr-ui/src/develop.rs:5506`](../ui/dr-ui/src/develop.rs#L5506), [`ui/dr-ui/src/develop.rs:5550`](../ui/dr-ui/src/develop.rs#L5550), [`ui/dr-ui/src/develop.rs:5600`](../ui/dr-ui/src/develop.rs#L5600), [`ui/dr-ui/src/develop.rs:588`](../ui/dr-ui/src/develop.rs#L588), [`ui/dr-ui/src/develop.rs:649`](../ui/dr-ui/src/develop.rs#L649), [`ui/dr-ui/src/develop.rs:803`](../ui/dr-ui/src/develop.rs#L803), [`ui/dr-ui/src/develop.rs:850`](../ui/dr-ui/src/develop.rs#L850), [`ui/dr-ui/src/develop.rs:879`](../ui/dr-ui/src/develop.rs#L879), [`ui/dr-ui/src/lib.rs:1696`](../ui/dr-ui/src/lib.rs#L1696), [`ui/dr-ui/src/lib.rs:2518`](../ui/dr-ui/src/lib.rs#L2518), [`ui/dr-ui/src/lib.rs:2714`](../ui/dr-ui/src/lib.rs#L2714), [`ui/dr-ui/src/lib.rs:2886`](../ui/dr-ui/src/lib.rs#L2886), [`ui/dr-ui/src/lib.rs:2950`](../ui/dr-ui/src/lib.rs#L2950), [`ui/dr-ui/src/lib.rs:3004`](../ui/dr-ui/src/lib.rs#L3004), [`ui/dr-ui/src/lib.rs:389`](../ui/dr-ui/src/lib.rs#L389), [`ui/dr-ui/src/lib.rs:432`](../ui/dr-ui/src/lib.rs#L432), [`ui/dr-ui/src/lib.rs:455`](../ui/dr-ui/src/lib.rs#L455), [`ui/dr-ui/src/library.rs:547`](../ui/dr-ui/src/library.rs#L547), [`ui/dr-ui/src/masks_ui.rs:249`](../ui/dr-ui/src/masks_ui.rs#L249), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:884`](../ui/dr-ui/src/masks_ui.rs#L884), [`ui/dr-ui/src/masks_ui.rs:998`](../ui/dr-ui/src/masks_ui.rs#L998), [`ui/dr-ui/src/presets.rs:409`](../ui/dr-ui/src/presets.rs#L409), [`ui/dr-ui/src/segmentation.rs:116`](../ui/dr-ui/src/segmentation.rs#L116), [`ui/dr-ui/src/segmentation.rs:330`](../ui/dr-ui/src/segmentation.rs#L330), [`ui/dr-ui/src/segmentation.rs:346`](../ui/dr-ui/src/segmentation.rs#L346), [`ui/dr-ui/src/segmentation.rs:476`](../ui/dr-ui/src/segmentation.rs#L476), [`ui/dr-ui/src/segmentation.rs:504`](../ui/dr-ui/src/segmentation.rs#L504), [`ui/dr-ui/ui/adjust.slint:547`](../ui/dr-ui/ui/adjust.slint#L547), [`ui/dr-ui/ui/adjust.slint:666`](../ui/dr-ui/ui/adjust.slint#L666), [`ui/dr-ui/ui/app.slint:1008`](../ui/dr-ui/ui/app.slint#L1008), [`ui/dr-ui/ui/app.slint:156`](../ui/dr-ui/ui/app.slint#L156), [`ui/dr-ui/ui/app.slint:202`](../ui/dr-ui/ui/app.slint#L202), [`ui/dr-ui/ui/app.slint:2085`](../ui/dr-ui/ui/app.slint#L2085), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/develop.slint:220`](../ui/dr-ui/ui/develop.slint#L220), [`ui/dr-ui/ui/masks.slint:612`](../ui/dr-ui/ui/masks.slint#L612) | | FR-DEV-3a | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-pipeline/src/graph.rs:25`](../core/dr-pipeline/src/graph.rs#L25), [`core/dr-pipeline/src/graph.rs:337`](../core/dr-pipeline/src/graph.rs#L337), [`core/dr-pipeline/src/graph.rs:47`](../core/dr-pipeline/src/graph.rs#L47), [`core/dr-pipeline/src/graph.rs:60`](../core/dr-pipeline/src/graph.rs#L60), [`core/dr-pipeline/src/mask.rs:1108`](../core/dr-pipeline/src/mask.rs#L1108), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1414`](../ui/dr-ui/src/develop.rs#L1414), [`ui/dr-ui/src/lib.rs:749`](../ui/dr-ui/src/lib.rs#L749), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | @@ -68,11 +69,11 @@ _None._ | FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:103`](../core/dr-pipeline/src/graph.rs#L103), [`core/dr-pipeline/src/graph.rs:153`](../core/dr-pipeline/src/graph.rs#L153), [`core/dr-pipeline/src/graph.rs:444`](../core/dr-pipeline/src/graph.rs#L444), [`core/dr-pipeline/src/operation.rs:1137`](../core/dr-pipeline/src/operation.rs#L1137), [`core/dr-pipeline/src/operation.rs:1165`](../core/dr-pipeline/src/operation.rs#L1165), [`core/dr-pipeline/src/operation.rs:1211`](../core/dr-pipeline/src/operation.rs#L1211), [`core/dr-pipeline/src/operation.rs:1762`](../core/dr-pipeline/src/operation.rs#L1762), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:139`](../core/dr-pipeline/src/ops/film_sim.rs#L139), [`core/dr-pipeline/src/ops/film_sim.rs:163`](../core/dr-pipeline/src/ops/film_sim.rs#L163), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:341`](../core/dr-pipeline/src/ops/film_sim.rs#L341), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:97`](../core/dr-pipeline/src/ops/film_sim.rs#L97), [`core/dr-pipeline/src/sidecar.rs:113`](../core/dr-pipeline/src/sidecar.rs#L113), [`core/dr-pipeline/src/sidecar.rs:169`](../core/dr-pipeline/src/sidecar.rs#L169), [`core/dr-pipeline/src/sidecar.rs:2177`](../core/dr-pipeline/src/sidecar.rs#L2177), [`core/dr-pipeline/src/sidecar.rs:2253`](../core/dr-pipeline/src/sidecar.rs#L2253), [`core/dr-pipeline/src/sidecar.rs:535`](../core/dr-pipeline/src/sidecar.rs#L535), [`core/dr-pipeline/src/sidecar.rs:779`](../core/dr-pipeline/src/sidecar.rs#L779), [`core/dr-pipeline/src/sidecar.rs:911`](../core/dr-pipeline/src/sidecar.rs#L911), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3710`](../ui/dr-ui/src/develop.rs#L3710), [`ui/dr-ui/src/develop.rs:3727`](../ui/dr-ui/src/develop.rs#L3727), [`ui/dr-ui/src/develop.rs:3739`](../ui/dr-ui/src/develop.rs#L3739), [`ui/dr-ui/src/develop.rs:3777`](../ui/dr-ui/src/develop.rs#L3777), [`ui/dr-ui/src/develop.rs:3786`](../ui/dr-ui/src/develop.rs#L3786), [`ui/dr-ui/src/develop.rs:3889`](../ui/dr-ui/src/develop.rs#L3889), [`ui/dr-ui/src/develop.rs:4302`](../ui/dr-ui/src/develop.rs#L4302), [`ui/dr-ui/src/develop.rs:4317`](../ui/dr-ui/src/develop.rs#L4317), [`ui/dr-ui/src/lib.rs:2492`](../ui/dr-ui/src/lib.rs#L2492), [`ui/dr-ui/src/lib.rs:682`](../ui/dr-ui/src/lib.rs#L682), [`ui/dr-ui/src/lib.rs:740`](../ui/dr-ui/src/lib.rs#L740), [`ui/dr-ui/src/library.rs:538`](../ui/dr-ui/src/library.rs#L538), [`ui/dr-ui/src/library.rs:800`](../ui/dr-ui/src/library.rs#L800), [`ui/dr-ui/src/presets.rs:417`](../ui/dr-ui/src/presets.rs#L417), [`ui/dr-ui/ui/adjust.slint:1072`](../ui/dr-ui/ui/adjust.slint#L1072), [`ui/dr-ui/ui/adjust.slint:996`](../ui/dr-ui/ui/adjust.slint#L996), [`ui/dr-ui/ui/app.slint:2699`](../ui/dr-ui/ui/app.slint#L2699), [`ui/dr-ui/ui/app.slint:718`](../ui/dr-ui/ui/app.slint#L718) | | 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:1067`](../core/dr-pipeline/src/framing.rs#L1067), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:498`](../core/dr-pipeline/src/framing.rs#L498), [`core/dr-types/src/lib.rs:339`](../core/dr-types/src/lib.rs#L339), [`core/dr-types/src/lib.rs:447`](../core/dr-types/src/lib.rs#L447), [`core/dr-types/src/lib.rs:459`](../core/dr-types/src/lib.rs#L459), [`core/dr-types/src/lib.rs:475`](../core/dr-types/src/lib.rs#L475), [`ui/dr-ui/src/develop.rs:140`](../ui/dr-ui/src/develop.rs#L140), [`ui/dr-ui/src/develop.rs:2364`](../ui/dr-ui/src/develop.rs#L2364), [`ui/dr-ui/src/segmentation.rs:476`](../ui/dr-ui/src/segmentation.rs#L476) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:351`](../core/dr-gpu/src/lib.rs#L351), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:465`](../core/dr-pipeline/src/graph.rs#L465), [`core/dr-pipeline/src/graph.rs:513`](../core/dr-pipeline/src/graph.rs#L513), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3739`](../ui/dr-ui/src/develop.rs#L3739), [`ui/dr-ui/src/develop.rs:4317`](../ui/dr-ui/src/develop.rs#L4317), [`ui/dr-ui/src/develop.rs:4347`](../ui/dr-ui/src/develop.rs#L4347), [`ui/dr-ui/src/develop.rs:4360`](../ui/dr-ui/src/develop.rs#L4360), [`ui/dr-ui/src/develop.rs:4372`](../ui/dr-ui/src/develop.rs#L4372), [`ui/dr-ui/src/develop.rs:4388`](../ui/dr-ui/src/develop.rs#L4388), [`ui/dr-ui/src/develop.rs:4420`](../ui/dr-ui/src/develop.rs#L4420), [`ui/dr-ui/src/develop.rs:4424`](../ui/dr-ui/src/develop.rs#L4424), [`ui/dr-ui/src/develop.rs:4443`](../ui/dr-ui/src/develop.rs#L4443), [`ui/dr-ui/src/develop.rs:4459`](../ui/dr-ui/src/develop.rs#L4459), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:231`](../ui/dr-ui/src/labels.rs#L231), [`ui/dr-ui/src/lib.rs:1631`](../ui/dr-ui/src/lib.rs#L1631), [`ui/dr-ui/src/lib.rs:1674`](../ui/dr-ui/src/lib.rs#L1674), [`ui/dr-ui/src/lib.rs:1682`](../ui/dr-ui/src/lib.rs#L1682), [`ui/dr-ui/src/lib.rs:2688`](../ui/dr-ui/src/lib.rs#L2688), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:465`](../core/dr-pipeline/src/graph.rs#L465), [`core/dr-pipeline/src/graph.rs:513`](../core/dr-pipeline/src/graph.rs#L513), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3739`](../ui/dr-ui/src/develop.rs#L3739), [`ui/dr-ui/src/develop.rs:4317`](../ui/dr-ui/src/develop.rs#L4317), [`ui/dr-ui/src/develop.rs:4347`](../ui/dr-ui/src/develop.rs#L4347), [`ui/dr-ui/src/develop.rs:4360`](../ui/dr-ui/src/develop.rs#L4360), [`ui/dr-ui/src/develop.rs:4372`](../ui/dr-ui/src/develop.rs#L4372), [`ui/dr-ui/src/develop.rs:4388`](../ui/dr-ui/src/develop.rs#L4388), [`ui/dr-ui/src/develop.rs:4420`](../ui/dr-ui/src/develop.rs#L4420), [`ui/dr-ui/src/develop.rs:4424`](../ui/dr-ui/src/develop.rs#L4424), [`ui/dr-ui/src/develop.rs:4443`](../ui/dr-ui/src/develop.rs#L4443), [`ui/dr-ui/src/develop.rs:4459`](../ui/dr-ui/src/develop.rs#L4459), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:232`](../ui/dr-ui/src/labels.rs#L232), [`ui/dr-ui/src/lib.rs:1631`](../ui/dr-ui/src/lib.rs#L1631), [`ui/dr-ui/src/lib.rs:1674`](../ui/dr-ui/src/lib.rs#L1674), [`ui/dr-ui/src/lib.rs:1682`](../ui/dr-ui/src/lib.rs#L1682), [`ui/dr-ui/src/lib.rs:2688`](../ui/dr-ui/src/lib.rs#L2688), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:381`](../core/dr-pipeline/src/preset.rs#L381), [`core/dr-pipeline/src/preset.rs:414`](../core/dr-pipeline/src/preset.rs#L414), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:4259`](../ui/dr-ui/src/develop.rs#L4259), [`ui/dr-ui/src/develop.rs:4280`](../ui/dr-ui/src/develop.rs#L4280), [`ui/dr-ui/src/lib.rs:1598`](../ui/dr-ui/src/lib.rs#L1598), [`ui/dr-ui/src/lib.rs:2297`](../ui/dr-ui/src/lib.rs#L2297), [`ui/dr-ui/src/lib.rs:2326`](../ui/dr-ui/src/lib.rs#L2326), [`ui/dr-ui/src/library.rs:2219`](../ui/dr-ui/src/library.rs#L2219), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:528`](../ui/dr-ui/src/library.rs#L528), [`ui/dr-ui/src/library_ui.rs:3199`](../ui/dr-ui/src/library_ui.rs#L3199), [`ui/dr-ui/src/library_ui.rs:3599`](../ui/dr-ui/src/library_ui.rs#L3599), [`ui/dr-ui/src/library_ui.rs:554`](../ui/dr-ui/src/library_ui.rs#L554), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:679`](../ui/dr-ui/src/presets.rs#L679), [`ui/dr-ui/src/presets.rs:713`](../ui/dr-ui/src/presets.rs#L713), [`ui/dr-ui/ui/adjust.slint:740`](../ui/dr-ui/ui/adjust.slint#L740), [`ui/dr-ui/ui/adjust.slint:767`](../ui/dr-ui/ui/adjust.slint#L767), [`ui/dr-ui/ui/adjust.slint:801`](../ui/dr-ui/ui/adjust.slint#L801), [`ui/dr-ui/ui/app.slint:1627`](../ui/dr-ui/ui/app.slint#L1627), [`ui/dr-ui/ui/app.slint:2788`](../ui/dr-ui/ui/app.slint#L2788), [`ui/dr-ui/ui/app.slint:802`](../ui/dr-ui/ui/app.slint#L802), [`ui/dr-ui/ui/app.slint:813`](../ui/dr-ui/ui/app.slint#L813), [`ui/dr-ui/ui/library.slint:1564`](../ui/dr-ui/ui/library.slint#L1564), [`ui/dr-ui/ui/library.slint:1570`](../ui/dr-ui/ui/library.slint#L1570), [`ui/dr-ui/ui/library.slint:4117`](../ui/dr-ui/ui/library.slint#L4117), [`ui/dr-ui/ui/library.slint:4132`](../ui/dr-ui/ui/library.slint#L4132), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:101`](../ui/dr-ui/ui/settings.slint#L101), [`ui/dr-ui/ui/settings.slint:595`](../ui/dr-ui/ui/settings.slint#L595) | | FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:4388`](../ui/dr-ui/src/develop.rs#L4388), [`ui/dr-ui/src/develop.rs:4420`](../ui/dr-ui/src/develop.rs#L4420), [`ui/dr-ui/src/lib.rs:1682`](../ui/dr-ui/src/lib.rs#L1682), [`ui/dr-ui/src/lib.rs:2688`](../ui/dr-ui/src/lib.rs#L2688), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | | FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:115`](../core/dr-pipeline/src/graph.rs#L115), [`core/dr-pipeline/src/graph.rs:231`](../core/dr-pipeline/src/graph.rs#L231), [`core/dr-pipeline/src/graph.rs:859`](../core/dr-pipeline/src/graph.rs#L859), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:579`](../core/dr-pipeline/src/operation.rs#L579), [`core/dr-pipeline/src/sidecar.rs:1011`](../core/dr-pipeline/src/sidecar.rs#L1011), [`core/dr-pipeline/src/sidecar.rs:185`](../core/dr-pipeline/src/sidecar.rs#L185), [`core/dr-pipeline/src/sidecar.rs:354`](../core/dr-pipeline/src/sidecar.rs#L354), [`core/dr-pipeline/src/sidecar.rs:791`](../core/dr-pipeline/src/sidecar.rs#L791), [`core/dr-pipeline/src/sidecar.rs:927`](../core/dr-pipeline/src/sidecar.rs#L927), [`core/dr-pipeline/src/sidecar.rs:981`](../core/dr-pipeline/src/sidecar.rs#L981), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2516`](../ui/dr-ui/src/develop.rs#L2516), [`ui/dr-ui/src/develop.rs:2554`](../ui/dr-ui/src/develop.rs#L2554), [`ui/dr-ui/src/develop.rs:2619`](../ui/dr-ui/src/develop.rs#L2619), [`ui/dr-ui/src/develop.rs:2702`](../ui/dr-ui/src/develop.rs#L2702), [`ui/dr-ui/src/develop.rs:2716`](../ui/dr-ui/src/develop.rs#L2716), [`ui/dr-ui/src/develop.rs:833`](../ui/dr-ui/src/develop.rs#L833), [`ui/dr-ui/src/labels.rs:53`](../ui/dr-ui/src/labels.rs#L53), [`ui/dr-ui/src/lib.rs:1703`](../ui/dr-ui/src/lib.rs#L1703), [`ui/dr-ui/src/lib.rs:2806`](../ui/dr-ui/src/lib.rs#L2806), [`ui/dr-ui/src/lib.rs:394`](../ui/dr-ui/src/lib.rs#L394), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:832`](../ui/dr-ui/ui/adjust.slint#L832), [`ui/dr-ui/ui/app.slint:146`](../ui/dr-ui/ui/app.slint#L146), [`ui/dr-ui/ui/app.slint:1988`](../ui/dr-ui/ui/app.slint#L1988), [`ui/dr-ui/ui/app.slint:2163`](../ui/dr-ui/ui/app.slint#L2163), [`ui/dr-ui/ui/app.slint:2600`](../ui/dr-ui/ui/app.slint#L2600), [`ui/dr-ui/ui/icons.slint:338`](../ui/dr-ui/ui/icons.slint#L338), [`ui/dr-ui/ui/spots.slint:172`](../ui/dr-ui/ui/spots.slint#L172), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2140`](../core/dr-gpu/src/adjust.rs#L2140), [`core/dr-gpu/src/adjust.rs:2217`](../core/dr-gpu/src/adjust.rs#L2217), [`core/dr-gpu/src/adjust.rs:2302`](../core/dr-gpu/src/adjust.rs#L2302), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:172`](../core/dr-gpu/src/lib.rs#L172), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-gpu/src/lib.rs:70`](../core/dr-gpu/src/lib.rs#L70), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:705`](../core/dr-pipeline/src/graph.rs#L705), [`core/dr-pipeline/src/graph.rs:735`](../core/dr-pipeline/src/graph.rs#L735), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:3150`](../ui/dr-ui/src/develop.rs#L3150), [`ui/dr-ui/src/develop.rs:4714`](../ui/dr-ui/src/develop.rs#L4714), [`ui/dr-ui/src/develop.rs:5724`](../ui/dr-ui/src/develop.rs#L5724), [`ui/dr-ui/src/develop.rs:5758`](../ui/dr-ui/src/develop.rs#L5758), [`ui/dr-ui/src/lib.rs:890`](../ui/dr-ui/src/lib.rs#L890), [`ui/dr-ui/src/lib.rs:93`](../ui/dr-ui/src/lib.rs#L93), [`ui/dr-ui/src/lib.rs:949`](../ui/dr-ui/src/lib.rs#L949) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2140`](../core/dr-gpu/src/adjust.rs#L2140), [`core/dr-gpu/src/adjust.rs:2217`](../core/dr-gpu/src/adjust.rs#L2217), [`core/dr-gpu/src/adjust.rs:2302`](../core/dr-gpu/src/adjust.rs#L2302), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:172`](../core/dr-gpu/src/lib.rs#L172), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-gpu/src/lib.rs:70`](../core/dr-gpu/src/lib.rs#L70), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:705`](../core/dr-pipeline/src/graph.rs#L705), [`core/dr-pipeline/src/graph.rs:735`](../core/dr-pipeline/src/graph.rs#L735), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/dehaze.rs:1`](../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:592`](../core/dr-pipeline/src/ops/dehaze.rs#L592), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:3150`](../ui/dr-ui/src/develop.rs#L3150), [`ui/dr-ui/src/develop.rs:4714`](../ui/dr-ui/src/develop.rs#L4714), [`ui/dr-ui/src/develop.rs:5724`](../ui/dr-ui/src/develop.rs#L5724), [`ui/dr-ui/src/develop.rs:5758`](../ui/dr-ui/src/develop.rs#L5758), [`ui/dr-ui/src/lib.rs:890`](../ui/dr-ui/src/lib.rs#L890), [`ui/dr-ui/src/lib.rs:93`](../ui/dr-ui/src/lib.rs#L93), [`ui/dr-ui/src/lib.rs:949`](../ui/dr-ui/src/lib.rs#L949) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:497`](../core/dr-pipeline/src/operation.rs#L497), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:3180`](../ui/dr-ui/src/develop.rs#L3180), [`ui/dr-ui/src/develop.rs:7152`](../ui/dr-ui/src/develop.rs#L7152), [`ui/dr-ui/src/lib.rs:1659`](../ui/dr-ui/src/lib.rs#L1659), [`ui/dr-ui/src/lib.rs:3189`](../ui/dr-ui/src/lib.rs#L3189) | @@ -112,7 +113,7 @@ _None._ | FR-PLAT-AND-6 | [`apps/darkroom-android/src/lib.rs:388`](../apps/darkroom-android/src/lib.rs#L388) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:999`](../ui/dr-ui/src/lib.rs#L999), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) | -| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:674`](../core/dr-pipeline/src/descriptor.rs#L674), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:417`](../core/dr-pipeline/tests/declared_parity.rs#L417) | +| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:674`](../core/dr-pipeline/src/descriptor.rs#L674), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:418`](../core/dr-pipeline/tests/declared_parity.rs#L418) | | FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | | FR-PLG-8 | [`core/dr-pipeline/src/sidecar.rs:1924`](../core/dr-pipeline/src/sidecar.rs#L1924), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:132`](../core/dr-types/src/lib.rs#L132), [`core/dr-types/src/lib.rs:203`](../core/dr-types/src/lib.rs#L203) | @@ -163,7 +164,7 @@ _None._ ## Not yet tagged -53 of 180 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +53 of 181 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 5121fef..3229ece 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -145,6 +145,7 @@ fn catalogued(key: &str) -> Option<&'static str> { // "Capture Sharpen" would name a distinction the photographer cannot // see from there. "op.capture_sharpen" => "Sharpening", + "op.dehaze" => "Dehaze", "op.framing" => "Crop & Rotate", // "Lens Vignetting", not the "Vignetting" `derive` would produce, and // the qualifier is doing real work. This operation corrects the corner