diff --git a/core/dr-segment/examples/scene.rs b/core/dr-segment/examples/scene.rs index 40f0e62..5712e48 100644 --- a/core/dr-segment/examples/scene.rs +++ b/core/dr-segment/examples/scene.rs @@ -24,13 +24,21 @@ //! came from* rather than as an abstract grey field. PPM for the same reason //! the other examples use it: no encoder dependency, and every viewer reads it. //! -//! And `--refined.ppm` beside it, which is the same category -//! after [`dr_segment::refine_category`] has cut it back to the pixels whose -//! colour agrees with it. Both, never one: whether that refinement is an -//! improvement is a comparative judgement — did the flag come out of the sky, -//! and is the sky still there afterwards — and a single image cannot answer -//! it. The percentage printed beside each is how much weight came off, which -//! is the number to be suspicious of when it is large. +//! And `--refined-.ppm` beside it — the same +//! category after [`dr_segment::Refinement`] has cut it back to the pixels +//! whose colour agrees with it, at each point of a sweep across the whole +//! strictness range the application's slider offers. +//! +//! A sweep rather than one image, for two reasons. Whether the refinement is +//! an improvement is a comparative judgement — did the flag come out of the +//! sky, and is the sky still there afterwards — which a single image cannot +//! answer. And the control is a slider, so the question actually being asked +//! is *where to put it*, which needs the travel rather than a point on it. +//! +//! The percentage printed beside each is how much weight came off, which is +//! the number to be suspicious of when it grows quickly. The timing beside +//! that is the fit against one `apply`, and it is the measurement that decides +//! whether the slider can be a live drag. //! //! Timings are reported as a median over the requested run count, with the //! first run excluded. That first pass pays for tract's lazy allocation and is @@ -122,8 +130,13 @@ fn main() { // judgement this example exists to support is comparative — is the // flag out, and is the sky still there — and it cannot be made from // one image. + // + // Swept rather than shown once, because the application offers this as + // a slider and the question a photographer will actually ask is "where + // do I put it": the whole travel of the control, at the resolution + // their own eye will judge it at. let start = Instant::now(); - let (refined, what) = dr_segment::refine_category( + let refinement = dr_segment::Refinement::compute( &mask, &rgb, width, @@ -131,28 +144,54 @@ fn main() { scene.cell_pixels(), &dr_segment::RefineOptions::default(), ); - let took = start.elapsed(); - match what { - dr_segment::Refined::Applied { removed } => { - println!( - " refined in {took:?}: {:.1}% of the weight cut", - removed * 100.0 - ); - write_overlay( - &format!("{prefix}-{name}-refined.ppm"), - &rgb, - &refined, - width, - height, - ); - } - dr_segment::Refined::Skipped(why) => { + let fit = start.elapsed(); + + let refinement = match refinement { + Ok(r) => r, + Err(why) => { println!(" left coarse: {why:?}"); + continue; } + }; + println!(" fitted in {fit:?}"); + + let total: f32 = mask.iter().sum(); + for step in 0..=STRICTNESS_STEPS { + let strictness = step as f32 * dr_segment::STRICTNESS_MAX / STRICTNESS_STEPS as f32; + + // Timed separately, and this is the number that decides whether + // the control can be a live drag or has to wait for the release. + let start = Instant::now(); + let refined = refinement.apply(&mask, strictness); + let took = start.elapsed(); + + let cut = if total > 0.0 { + (total - refined.iter().sum::()) / total + } else { + 0.0 + }; + println!( + " strictness {strictness:>4.1}: {:5.1}% cut ({took:?})", + cut * 100.0 + ); + write_overlay( + &format!("{prefix}-{name}-refined-{strictness:.1}.ppm"), + &rgb, + &refined, + width, + height, + ); } } } +/// Points on the strictness sweep, over `0..=STRICTNESS_MAX`. +/// +/// Eight, so the files come out at whole nats from `0` to `8` — few enough to +/// look at every one of them, which is the point of writing them at all, and +/// spaced at the unit the control is actually denominated in. +const STRICTNESS_STEPS: usize = 8; + #[cfg(feature = "embedded-scene-model")] fn embedded() -> SceneModel { SceneModel::embedded().expect("could not load the embedded scene model") diff --git a/core/dr-segment/src/lib.rs b/core/dr-segment/src/lib.rs index e2f3577..e478496 100644 --- a/core/dr-segment/src/lib.rs +++ b/core/dr-segment/src/lib.rs @@ -64,7 +64,10 @@ pub mod semantic; pub use distance::{signed_distance, Falloff, Morphology, Shaped}; pub use hierarchy::{Edge, Merge, MergeTree, RegionField}; pub use prior::{Membership, PriorOptions}; -pub use refine::{refine_category, RefineOptions, Refined, SkipReason}; +pub use refine::{ + refine_category, RefineOptions, Refined, Refinement, SkipReason, STRICTNESS_DEFAULT, + STRICTNESS_MAX, STRICTNESS_OFF, +}; #[cfg(feature = "semantic")] pub use scene::{Category, Scene, SceneModel}; #[cfg(feature = "semantic")] diff --git a/core/dr-segment/src/refine.rs b/core/dr-segment/src/refine.rs index a7d4ead..1009efe 100644 --- a/core/dr-segment/src/refine.rs +++ b/core/dr-segment/src/refine.rs @@ -2,11 +2,11 @@ //! //! # The problem, as a number //! -//! The `scene` module keeps the model's native `[1, 150, 80, 80]` logit grid, so -//! one cell is eight of the graph's input pixels across. At the 1600px proxy -//! the application segments at, the letterbox scale is `0.4` and **one cell is -//! 20 proxy pixels** — which the bilinear in `Scene::rasterise` then spreads -//! across one more either side. +//! The `scene` module keeps the model's native `[1, 150, 80, 80]` logit grid, +//! so one cell is eight of the graph's input pixels across. At the 1600px +//! proxy the application segments at, the letterbox scale is `0.4` and **one +//! cell is 20 proxy pixels** — which the bilinear in `Scene::rasterise` then +//! spreads across one more either side. //! //! So a flag in the sky, or a chimney, or a bare branch, sits inside a handful //! of cells whose softmax is dominated by the sky around it, and comes out @@ -34,14 +34,14 @@ //! pale at the horizon, and one blob over all three rejects two of them. So //! k-means, which is GrabCut's mixture without the EM. //! -//! 3. **Small modes are dropped** ([`RefineOptions::min_cluster`]). This is -//! the step that makes the whole thing work on the case it was built for. -//! A *small* flag deep in the sky has both a high weight and a large -//! distance from the mask's boundary, so it lands in the interior sample -//! and would teach the model its own colour. It cannot be excluded -//! geometrically — but it is a few percent of the sky, and a mode holding a -//! few percent of the confident interior is far likelier to be an intruder -//! than a real appearance of the category. +//! 3. **A mode is charged for its rarity.** A *small* flag deep in the sky has +//! both a high weight and a large distance from the mask's boundary, so it +//! lands in the interior sample and fits itself a mode. It cannot be +//! excluded geometrically. What separates it from a real appearance of the +//! category is that it explains almost none of the category — so a colour +//! whose only explanation is a thin mode is charged `−ln(share)` nats for +//! it, and a flag ends up far less plausible than the sky around it without +//! ever being ruled out by fiat. //! //! 4. **Two tests, and a pixel must pass both.** An absolute one — is this //! colour plausible under the category at all, as a chi-square on the @@ -51,6 +51,40 @@ //! alone would leave at even odds. The comparative test is what stops the //! absolute one from needing a per-category constant. //! +//! # Why the verdict is kept, and the decision is not +//! +//! Everything above is per-image and costs a distance transform, a k-means and +//! a pass over the pixels. What comes out of it is one number per pixel — how +//! much better the category explains this colour than the alternatives — and +//! [`Refinement`] stores exactly that, quantised. +//! +//! The *decision* is then a smoothstep on that number, which is arithmetic. So +//! [`Refinement::apply`] is cheap enough to run under a dragging slider, and +//! the model is never consulted again. +//! +//! This is [`crate::distance`]'s arrangement, deliberately: there, a signed +//! distance field is computed once and feather, grow and shrink become +//! arithmetic on it, "which is what makes those live controls rather than ones +//! that stall on every drag". Same shape, different field. +//! +//! # What the strictness control means +//! +//! [`Refinement::apply`] takes a strictness in nats, and it is a real quantity +//! rather than an arbitrary 0–100: it is how much more plausible than the +//! alternatives a colour must be before its weight is kept. +//! +//! Because a mode's rarity is *added* to the evidence against it, the things a +//! photographer wants to remove come off in the order they want them to. A +//! flag holding two percent of the sky is charged several nats; a bank of +//! cloud holding a third of it is charged none. So the strictness that takes +//! out the flag leaves the cloud, and the cloud only starts to go some way +//! further up — which is the property that makes a single slider worth +//! offering. It is pinned by `the_flag_goes_before_the_cloud_does`. +//! +//! At zero the refinement does nothing at all, which is what makes it safe as +//! a default-on feature: the control has an off position that is exactly the +//! old behaviour. +//! //! # Two properties that make it safe to apply blind //! //! **It is subtractive.** The output is the input times a factor in `0..=1`, @@ -58,47 +92,31 @@ //! sky; it can never *gain* a region, and a blue car below the horizon that //! was never in the mask cannot be pulled into it. //! -//! **The partition survives.** `scene.rs` rests entirely on the -//! categories summing to at most one at every pixel — that is what lets two -//! adjacent grades be feathered without painting both into the overlap. -//! Multiplying by a factor in `0..=1` cannot raise a sum, so refining every -//! category independently still leaves a partition. The weight taken off the -//! flag lands in the unlisted remainder, which is exactly where a flag -//! belongs: ADE20K has no class for one. -//! -//! # What it cannot do, stated rather than discovered -//! -//! **An intruder large enough to hold its own mode is kept.** Step 3 separates -//! intruder from category by *share*, and by that measure a flag covering a -//! fifth of the sky and a bank of cloud covering a fifth of the sky are the -//! same object. Colour does not separate them either — a white cloud is as far -//! from blue sky in chrominance as many intruders are. -//! -//! So [`RefineOptions::min_cluster`] is not a threshold with a correct value -//! waiting to be found. It is the trade-off itself, and it is set where a -//! *photographic* intruder falls: a flag, a chimney or a bird is a fraction of -//! a percent of the sky it sits in, where the cloud that must survive is -//! usually tens of percent. Both ends of that are pinned by tests -//! (`a_flag_in_the_sky_is_removed` and -//! `an_intruder_larger_than_min_cluster_survives`) so that moving the number -//! reads as moving the trade-off rather than as fixing a bug. -//! -//! The case this leaves open is a *large* unrecognised object in an otherwise -//! clean category — a building filling half the sky. That wants the boundary -//! snapped to the watershed's basins, which is a different mechanism and not -//! this one. +//! **The partition survives.** `scene.rs` rests entirely on the categories +//! summing to at most one at every pixel — that is what lets two adjacent +//! grades be feathered without painting both into the overlap. Multiplying by +//! a factor in `0..=1` cannot raise a sum, so refining every category +//! independently still leaves a partition. The weight taken off the flag lands +//! in the unlisted remainder, which is exactly where a flag belongs: ADE20K +//! has no class for one. //! //! # And when it cannot tell //! -//! Every path that lacks the evidence to judge returns the weights untouched -//! and says so in [`Refined::Skipped`], rather than returning a plausible -//! answer. An empty seed set fitted to a distribution would reject *every* -//! pixel, and a sky that silently vanished from a mask is the kind of failure -//! nobody attributes to the right place. +//! Every path that lacks the evidence to judge refuses to build a +//! [`Refinement`] at all and says why, rather than returning a plausible one. +//! An empty seed set fitted to a distribution would reject *every* pixel, and +//! a sky that silently vanished from a mask is the kind of failure nobody +//! attributes to the right place. use crate::distance::signed_distance; -/// How aggressively a coarse category mask is cut back to the pixels. +/// How the evidence for a refinement is gathered. +/// +/// Everything here feeds the *fit*, which happens once per image. The one +/// control that moves afterwards is the strictness passed to +/// [`Refinement::apply`], and it is deliberately not in this struct: mixing +/// the two would invite a caller to change a fit parameter under a slider and +/// wonder why the drag stalled. #[derive(Debug, Clone, Copy, PartialEq)] pub struct RefineOptions { /// Weight at or above which a pixel may seed the interior distribution. @@ -117,19 +135,11 @@ pub struct RefineOptions { /// Modes fitted per side. /// - /// Four covers blue, cloud and horizon haze with one spare. More would fit - /// the intruder its own mode reliably enough to keep it, which is the - /// opposite of the point. + /// Rarity is measured against an even split ([`Mode::rarity`]), so this + /// number does not shift the verdicts on its own — raising it buys a finer + /// description of the category without making every colour look rarer. pub clusters: usize, - /// Share of a side's samples below which a mode is discarded as an - /// intruder rather than kept as part of the category. - /// - /// The one number here with a real trade-off in it: too high and a genuine - /// wisp of cloud is cut out of the sky, too low and a small flag teaches - /// the model to keep it. - pub min_cluster: f32, - /// How much luminance counts against chrominance in judging a colour. /// /// Well under one, and that is the difference between this working and not @@ -146,12 +156,13 @@ pub struct RefineOptions { /// gradient, for the same reason. pub luma_weight: f32, - /// Squared Mahalanobis distance beyond which a colour is implausible under - /// the category. + /// Squared Mahalanobis distance at which a colour stops being plausible + /// under the category. /// /// Chi-square with three degrees of freedom: `11.34` is the 99th /// percentile, so under the fitted model one confident pixel in a hundred - /// is expected to fail this on its own. + /// is expected to fail this on its own. It sets where the verdict scale's + /// zero falls; the strictness then moves the decision along that scale. pub outlier: f32, /// Width of the band, in nats, over which the gate falls from keep to cut. @@ -160,12 +171,12 @@ pub struct RefineOptions { /// the place a person is looking. pub soften: f32, - /// Radius, in pixels, the gate is blurred by before it is applied. + /// Radius, in pixels, the *verdict* is blurred by. /// /// A per-pixel colour test on a real sky speckles: sensor noise puts - /// individual pixels over the line in both directions. Two pixels of blur - /// removes the speckle and costs nothing at the boundary, which is already - /// soft by [`RefineOptions::soften`]. + /// individual pixels over the line in both directions. Blurring the + /// evidence rather than the decision means it is paid for once, in + /// [`Refinement::compute`], instead of on every frame of a drag. pub smooth_px: f32, /// Samples below which a side is too small to fit anything to. @@ -178,7 +189,6 @@ impl Default for RefineOptions { seed_weight: 0.9, margin_cells: 1.5, clusters: 4, - min_cluster: 0.03, luma_weight: 0.25, outlier: 11.34, soften: 1.5, @@ -188,10 +198,37 @@ impl Default for RefineOptions { } } -/// Why a refinement declined to do anything. +/// Strictness at which a category is left exactly as the model weighted it. +pub const STRICTNESS_OFF: f32 = 0.0; + +/// The top of the useful strictness range, in nats. /// -/// Carried out rather than logged in here, because the caller knows which -/// category it asked about and this module does not. +/// Past this a category's own dominant colours have gone, so a control +/// offering more would only offer ways to delete the mask. Fixed here rather +/// than in the UI so that the slider and the tests agree on what the end of +/// the scale means. +pub const STRICTNESS_MAX: f32 = 8.0; + +/// Where the control sits until someone moves it. +/// +/// Half scale, and that position is the measurement rather than a round +/// number. On the synthetic frame `the_flag_goes_before_the_cloud_does` +/// builds, a flag holding 1.6% of the sky is more than half gone by **2.95** +/// and a cloud bank holding a third of it survives to **5.75** — so the whole +/// of the interval between them is a place the control can sit, and this is +/// near its middle. +/// +/// That separation is what rarity buys. Without it both land at the same +/// verdict, there is no interval, and the control would appear dead until it +/// suddenly ate the sky. +/// +/// Treat the numbers as a calibration and not a law: the synthetic sky is +/// smooth enough to sit on [`VARIANCE_FLOOR`], where a real one has noise and +/// therefore a real spread, which moves every crossing down together. The +/// ordering survives that; the exact placement is what the slider is for. +pub const STRICTNESS_DEFAULT: f32 = 4.0; + +/// Why a refinement could not be built. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkipReason { /// Nothing survived the erosion — the category is present but everywhere @@ -205,13 +242,11 @@ pub enum SkipReason { Mismatched, } -/// What a refinement did. +/// What a one-shot [`refine_category`] did. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Refined { /// Applied. `removed` is the fraction of the category's original total - /// weight the gate took away — small for a clean sky, a few percent for - /// one with a flag in it, and large enough to be worth a look in the log - /// if the colour model has gone wrong. + /// weight the gate took away. Applied { removed: f32, }, @@ -245,91 +280,202 @@ const MAX_SAMPLES: usize = 20_000; /// (docs/segmentation.md §6). const ITERATIONS: usize = 12; +/// Half-width of the verdict scale, in nats. +/// +/// The verdict is stored as a byte, so it needs an end. Sixteen is comfortably +/// past [`STRICTNESS_MAX`] plus a soften band, and it puts the quantisation +/// step at an eighth of a nat — an order finer than the narrowest transition +/// the gate can be asked for. +const VERDICT_RANGE: f32 = 16.0; + +/// The evidence for refining one category, ready for a control to act on. +/// +/// Built once per image by [`Refinement::compute`]; read by +/// [`Refinement::apply`] as often as a slider moves. +#[derive(Debug, Clone, PartialEq)] +pub struct Refinement { + /// Per pixel, how much better the category explains this colour than the + /// alternatives, in nats, quantised over ±[`VERDICT_RANGE`]. + /// + /// A byte for the same reason a coverage buffer is one: at four bytes a + /// pixel this would be a proxy-sized `f32` buffer per category, and the + /// quantisation is far finer than the decision it feeds. + verdict: Vec, + width: usize, + height: usize, + /// Carried from the fit so that a caller holding only this can apply it + /// without also having to keep the options that produced it. + soften: f32, +} + +impl Refinement { + /// Fit the colour models and score every pixel. The expensive half. + /// + /// `weights` is one category's coverage at `width * height`, as + /// `Scene::rasterise` produces it. `rgb` is the same picture, tightly + /// packed `f32` RGB — the proxy the model itself read, so the two describe + /// one frame. + /// + /// `cell_pixels` is how many pixels of *this* buffer one logit cell spans; + /// see `Scene::cell_pixels`, which is where the caller should get it + /// rather than re-deriving a letterbox inverse. + pub fn compute( + weights: &[f32], + rgb: &[f32], + width: usize, + height: usize, + cell_pixels: f32, + options: &RefineOptions, + ) -> Result { + let pixels = width * height; + if weights.len() != pixels || rgb.len() != pixels * 3 || pixels == 0 { + return Err(SkipReason::Mismatched); + } + + // The distance field is over the *thresholded* weights, so the + // boundary it measures from is where the model stopped being sure — + // not where the mask will eventually be cut, which is what the + // strictness decides later. + let coverage: Vec = weights + .iter() + .map(|&w| (w.clamp(0.0, 1.0) * 255.0).round() as u8) + .collect(); + let threshold = (options.seed_weight.clamp(0.0, 1.0) * 255.0).round() as u8; + let distance = signed_distance(&coverage, width, height, threshold); + + let margin = options.margin_cells * cell_pixels.max(1.0); + + let interior = sample(&distance, rgb, options.luma_weight, |d| d >= margin); + if interior.len() < options.min_samples { + return Err(SkipReason::NoInterior); + } + let exterior = sample(&distance, rgb, options.luma_weight, |d| d <= -margin); + if exterior.len() < options.min_samples { + return Err(SkipReason::NoExterior); + } + + let inside = fit(&interior, options.clusters); + let outside = fit(&exterior, options.clusters); + + let mut verdict = vec![0.0f32; pixels]; + for (p, cell) in verdict.iter_mut().enumerate() { + let x = feature(rgb, p, options.luma_weight); + + // Squared Mahalanobis for the absolute test, because that is what + // is chi-square distributed; the density — the same distance with + // the mode's own spread folded in — for the comparative one, so + // that a tight mode and a loose one are compared fairly. Both + // carry the mode's rarity. + let (maha_in, nll_in) = nearest(&inside, &x); + let (_, nll_out) = nearest(&outside, &x); + + let plausible = 0.5 * (options.outlier - maha_in); + let likelier = nll_out - nll_in; + *cell = plausible.min(likelier); + } + + // The evidence is blurred, not the decision — see + // `RefineOptions::smooth_px`. + if options.smooth_px >= 1.0 { + verdict = blur(&verdict, width, height, options.smooth_px.round() as usize); + } + + Ok(Self { + verdict: verdict.iter().map(|&v| quantise(v)).collect(), + width, + height, + soften: options.soften, + }) + } + + pub fn size(&self) -> (usize, usize) { + (self.width, self.height) + } + + /// The gate this refinement applies at `strictness`, per pixel. + /// + /// Separate from [`Self::apply`] because the overlay wants to draw it and + /// the coverage path wants to multiply by it, and neither should have to + /// reimplement the smoothstep. + pub fn gate(&self, strictness: f32) -> Vec { + let strictness = strictness.max(0.0); + self.verdict + .iter() + .map(|&v| smoothstep(-self.soften, self.soften, dequantise(v) - strictness)) + .collect() + } + + /// Apply at a given strictness. The cheap half — a slider drags on this. + /// + /// At [`STRICTNESS_OFF`] this is the identity, exactly, and returns the + /// weights unchanged without touching the verdict at all. That is what + /// makes the control's zero position mean "as the model weighted it" + /// rather than "very nearly that". + pub fn apply(&self, weights: &[f32], strictness: f32) -> Vec { + if strictness <= STRICTNESS_OFF || weights.len() != self.verdict.len() { + return weights.to_vec(); + } + weights + .iter() + .zip(self.gate(strictness)) + .map(|(&w, g)| w * g) + .collect() + } + + /// [`Self::apply`] over a quantised coverage buffer. + /// + /// The form the UI actually holds a category in: the mask reaches the + /// rasteriser as one byte per pixel, and round-tripping it through floats + /// to reuse `apply` would allocate twice as much for no extra precision. + pub fn apply_coverage(&self, coverage: &[u8], strictness: f32) -> Vec { + if strictness <= STRICTNESS_OFF || coverage.len() != self.verdict.len() { + return coverage.to_vec(); + } + coverage + .iter() + .zip(self.gate(strictness)) + .map(|(&c, g)| (c as f32 * g).round().clamp(0.0, 255.0) as u8) + .collect() + } +} + /// TRACES: FR-DEV-3 -/// Cut a coarse category mask back to the pixels that belong to it. +/// Fit and apply in one call, for a caller with no control to offer. /// -/// `weights` is one category's coverage at `width * height`, as -/// `Scene::rasterise` produces it. `rgb` is the same picture, tightly packed -/// `f32` RGB — the proxy the model itself read, so the two describe one frame. -/// -/// `cell_pixels` is how many pixels of *this* buffer one logit cell spans; see -/// `Scene::cell_pixels`, which is where the caller should get it rather than -/// re-deriving a letterbox inverse. -/// -/// Returns the refined weights and what was done to them. On any -/// [`Refined::Skipped`] the weights come back identical to the input. +/// The example and the tests use this. The application does not: it keeps the +/// [`Refinement`] so that its slider costs a smoothstep rather than a k-means. pub fn refine_category( weights: &[f32], rgb: &[f32], width: usize, height: usize, cell_pixels: f32, + strictness: f32, options: &RefineOptions, ) -> (Vec, Refined) { - let pixels = width * height; - if weights.len() != pixels || rgb.len() != pixels * 3 || pixels == 0 { - return (weights.to_vec(), Refined::Skipped(SkipReason::Mismatched)); + match Refinement::compute(weights, rgb, width, height, cell_pixels, options) { + Ok(refinement) => { + let refined = refinement.apply(weights, strictness); + let before: f32 = weights.iter().sum(); + let after: f32 = refined.iter().sum(); + let removed = if before > 0.0 { + ((before - after) / before).clamp(0.0, 1.0) + } else { + 0.0 + }; + (refined, Refined::Applied { removed }) + } + Err(why) => (weights.to_vec(), Refined::Skipped(why)), } +} - // The distance field is over the *thresholded* weights, so the boundary it - // measures from is where the model stopped being sure — not where the mask - // will eventually be cut, which is what this function is deciding. - let coverage: Vec = weights - .iter() - .map(|&w| (w.clamp(0.0, 1.0) * 255.0).round() as u8) - .collect(); - let threshold = (options.seed_weight.clamp(0.0, 1.0) * 255.0).round() as u8; - let distance = signed_distance(&coverage, width, height, threshold); +fn quantise(v: f32) -> u8 { + let t = (v.clamp(-VERDICT_RANGE, VERDICT_RANGE) + VERDICT_RANGE) / (2.0 * VERDICT_RANGE); + (t * 255.0).round() as u8 +} - let margin = options.margin_cells * cell_pixels.max(1.0); - - let interior = sample(&distance, rgb, options.luma_weight, |d| d >= margin); - if interior.len() < options.min_samples { - return (weights.to_vec(), Refined::Skipped(SkipReason::NoInterior)); - } - let exterior = sample(&distance, rgb, options.luma_weight, |d| d <= -margin); - if exterior.len() < options.min_samples { - return (weights.to_vec(), Refined::Skipped(SkipReason::NoExterior)); - } - - let inside = fit(&interior, options.clusters, options.min_cluster); - // The exterior keeps every mode it finds. Pruning there would be the wrong - // sign: a small mode outside is a small *object*, and forgetting it only - // makes the comparative test more willing to keep its pixels. - let outside = fit(&exterior, options.clusters, 0.0); - - let mut gate = vec![0.0f32; pixels]; - for (p, cell) in gate.iter_mut().enumerate() { - let x = feature(rgb, p, options.luma_weight); - - // Squared Mahalanobis for the absolute test, because that is what is - // chi-square distributed; the density — the same distance with the - // mode's own spread folded in — for the comparative one, so that a - // tight mode and a loose one are compared fairly. - let (maha_in, nll_in) = nearest(&inside, &x); - let (_, nll_out) = nearest(&outside, &x); - - let plausible = 0.5 * (options.outlier - maha_in); - let likelier = nll_out - nll_in; - let verdict = plausible.min(likelier); - - *cell = smoothstep(-options.soften, options.soften, verdict); - } - - if options.smooth_px >= 1.0 { - gate = blur(&gate, width, height, options.smooth_px.round() as usize); - } - - let before: f32 = weights.iter().sum(); - let refined: Vec = weights.iter().zip(&gate).map(|(&w, &g)| w * g).collect(); - let after: f32 = refined.iter().sum(); - let removed = if before > 0.0 { - ((before - after) / before).clamp(0.0, 1.0) - } else { - 0.0 - }; - - (refined, Refined::Applied { removed }) +fn dequantise(b: u8) -> f32 { + (b as f32 / 255.0) * 2.0 * VERDICT_RANGE - VERDICT_RANGE } /// One pixel's colour, as the three numbers the distributions are fitted over. @@ -386,10 +532,25 @@ struct Mode { /// `0.5 * ln|Σ|`, precomputed because it is constant per mode and the /// inner loop runs once per pixel. half_log_det: f32, + /// What it costs, in nats, to explain a colour only by this mode. + /// + /// `−ln(share × k)`, floored at zero. Three things follow from that shape: + /// + /// - It is measured against an **even split**, so raising + /// [`RefineOptions::clusters`] describes the category more finely + /// without making every colour in it look rarer. + /// - It is floored, so a dominant mode earns no *bonus*. A discount there + /// would let the category's commonest colour outvote a genuinely bad + /// chi-square, which is the one direction this must not bend. + /// - It is what replaces pruning small modes outright. A flag holding two + /// percent of the sky is charged a few nats rather than deleted, so + /// where it falls relative to the cloud is a *distance* the strictness + /// control can travel across, instead of a threshold it jumps over. + rarity: f32, } -/// Fit `k` modes to one side, dropping any that hold less than `min_share`. -fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec { +/// Fit `k` modes to one side. +fn fit(samples: &[[f32; FEATURES]], k: usize) -> Vec { let k = k.max(1).min(samples.len()); let mut centres = seed_centres(samples, k); let k = centres.len(); @@ -439,7 +600,7 @@ fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec { } } - // Mean and spread from the final assignment. + // Mean, spread and share from the final assignment. let mut sums = vec![[0.0f32; FEATURES]; k]; let mut squares = vec![[0.0f32; FEATURES]; k]; let mut counts = vec![0usize; k]; @@ -452,9 +613,9 @@ fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec { counts[c] += 1; } - let floor = (min_share * samples.len() as f32) as usize; + let total = samples.len() as f32; + let even = k as f32; let mut modes = Vec::new(); - let mut largest: Option<(usize, Mode)> = None; for c in 0..k { if counts[c] == 0 { continue; @@ -468,26 +629,12 @@ fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec { variance[f] = (squares[c][f] / n - mean[f] * mean[f]).max(VARIANCE_FLOOR); half_log_det += 0.5 * variance[f].ln(); } - let mode = Mode { + modes.push(Mode { mean, variance, half_log_det, - }; - - if largest.is_none_or(|(best, _)| counts[c] > best) { - largest = Some((counts[c], mode)); - } - if counts[c] >= floor { - modes.push(mode); - } - } - - // Pruning everything is possible when `min_cluster` is set high and the - // samples split evenly. Keeping the largest is better than returning - // nothing, which would make every pixel infinitely improbable and cut the - // whole category away. - if modes.is_empty() { - modes.extend(largest.map(|(_, m)| m)); + rarity: -((n / total) * even).ln().min(0.0), + }); } modes } @@ -530,7 +677,13 @@ fn seed_centres(samples: &[[f32; FEATURES]], k: usize) -> Vec<[f32; FEATURES]> { centres } -/// The closest mode, as `(squared Mahalanobis, negative log density)`. +/// The closest mode, as `(squared Mahalanobis, negative log density)`, both +/// charged for that mode's rarity. +/// +/// The rarity enters the Mahalanobis term doubled because that term is a +/// squared distance and the other is a log density: a chi-square of `2r` is +/// the same amount of evidence as `r` nats, so the two tests are then denominated +/// in the same currency and the strictness means one thing across both. /// /// Nearest rather than a proper mixture sum: the largest term dominates a /// well-separated mixture, and taking the max is what makes the two numbers @@ -543,9 +696,9 @@ fn nearest(modes: &[Mode], x: &[f32; FEATURES]) -> (f32, f32) { let d = v - mean; maha += d * d / variance; } - let nll = 0.5 * maha + mode.half_log_det; + let nll = 0.5 * maha + mode.half_log_det + mode.rarity; if nll < best.1 { - best = (maha, nll); + best = (maha + 2.0 * mode.rarity, nll); } } best @@ -565,9 +718,9 @@ fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 { /// Separable box blur, clamped at the edges. /// -/// A box rather than a gaussian because it is being applied to a gate that is -/// already soft: what is wanted is the removal of single-pixel speckle, and the -/// shape of the kernel that does it does not matter. +/// A box rather than a gaussian because what is wanted is the removal of +/// single-pixel speckle, and the shape of the kernel that does it does not +/// matter. fn blur(src: &[f32], width: usize, height: usize, radius: usize) -> Vec { if radius == 0 || width == 0 || height == 0 { return src.to_vec(); @@ -624,13 +777,12 @@ mod tests { /// /// Large enough that an intruder can be a realistic *fraction* of the /// category rather than a realistic number of pixels — which is what - /// [`RefineOptions::min_cluster`] is measured in, and getting that - /// proportion wrong is the difference between this method working and not. + /// rarity is measured in, and getting that proportion wrong is the + /// difference between this method working and not. const EDGE: usize = 192; - /// A frame with a blue upper half and a green lower half, and a category - /// mask that claims the whole upper half plus a smear over the boundary — - /// which is what the coarse grid actually produces. + /// A blue upper half over a green lower half, and a category mask that + /// claims the whole upper half — which is what the coarse grid produces. fn landscape(w: usize, h: usize) -> (Vec, Vec) { let mut rgb = vec![0.0f32; w * h * 3]; let mut weights = vec![0.0f32; w * h]; @@ -685,75 +837,146 @@ mod tests { sum / ((x1 - x0) * (y1 - y0)) as f32 } + const FLAG: (usize, usize, usize, usize) = (88, 40, 104, 56); + const FLAG_CORE: (usize, usize, usize, usize) = (92, 44, 100, 52); + const CLOUD: (usize, usize, usize, usize) = (30, 12, 150, 60); + const CLOUD_CORE: (usize, usize, usize, usize) = (50, 24, 130, 48); + const RED: [f32; 3] = [0.75, 0.10, 0.12]; + const WHITE: [f32; 3] = [0.92, 0.94, 0.96]; + + fn with(rect: (usize, usize, usize, usize), colour: [f32; 3]) -> (Vec, Vec) { + let (mut rgb, mut weights) = landscape(EDGE, EDGE); + intrude(&mut rgb, &mut weights, EDGE, rect, colour); + (rgb, weights) + } + /// The case the module exists for: a red flag inside the sky, claimed by - /// the coarse mask, must come back out — while the sky around it stays. - /// - /// The flag is 16×16 against an eroded sky of ~16k pixels, so it is 1.6% - /// of the category. That proportion is the test, as much as the colour is: - /// see [`an_intruder_larger_than_min_cluster_survives`] for the same - /// picture with a bigger flag and the opposite result. + /// the coarse mask, must come back out at the default strictness — while + /// the sky around it stays. #[test] fn a_flag_in_the_sky_is_removed() { - let (w, h) = (EDGE, EDGE); - let (mut rgb, mut weights) = landscape(w, h); - intrude( - &mut rgb, - &mut weights, - w, - (88, 40, 104, 56), - [0.75, 0.10, 0.12], + let (rgb, weights) = with(FLAG, RED); + let (out, what) = refine_category( + &weights, + &rgb, + EDGE, + EDGE, + CELL, + STRICTNESS_DEFAULT, + &RefineOptions::default(), ); - - let (out, what) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); assert!( matches!(what, Refined::Applied { .. }), "should have had the evidence to judge: {what:?}" ); - // Inside the flag, clear of its own blurred edge. - let inside = mean(&out, w, (92, 44, 100, 52)); + let inside = mean(&out, EDGE, FLAG_CORE); assert!(inside < 0.2, "the flag should be cut out, got {inside}"); - // Sky far from the flag and far from the horizon. - let sky = mean(&out, w, (8, 8, 60, 60)); + let sky = mean(&out, EDGE, (8, 8, 60, 60)); assert!(sky > 0.8, "the sky around it should survive, got {sky}"); } - /// The method's stated limit, pinned so that it is a decision rather than - /// a surprise. - /// - /// An intruder big enough to hold its own k-means mode above - /// [`RefineOptions::min_cluster`] is indistinguishable, by this test, from - /// a legitimate second appearance of the category — a bank of cloud is - /// exactly that, and the two differ only in what a person calls them. So a - /// 22% flag is kept, and the knob that would cut it is the same knob that - /// would cut the cloud in - /// [`a_cloud_is_not_mistaken_for_an_intruder`]. - /// - /// This is not a defect to be fixed by raising `min_cluster`; it is the - /// trade-off that setting *is*. Recorded here so that a later change which - /// makes this test fail is recognised as having moved the trade-off rather - /// than as having fixed a bug. + /// A cloud is a legitimate part of the sky and is separated from the blue + /// by luminance alone, which is what `luma_weight` is for — and it holds a + /// large share, which is what rarity is for. #[test] - fn an_intruder_larger_than_min_cluster_survives() { - let (w, h) = (EDGE, EDGE); - let (mut rgb, mut weights) = landscape(w, h); - intrude( - &mut rgb, - &mut weights, - w, - (60, 20, 120, 80), - [0.75, 0.10, 0.12], + fn a_cloud_is_not_mistaken_for_an_intruder() { + let (rgb, weights) = with(CLOUD, WHITE); + let (out, _) = refine_category( + &weights, + &rgb, + EDGE, + EDGE, + CELL, + STRICTNESS_DEFAULT, + &RefineOptions::default(), ); + let cloud = mean(&out, EDGE, CLOUD_CORE); + assert!(cloud > 0.7, "the cloud should stay sky, got {cloud}"); + } - let (out, _) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); - let inside = mean(&out, w, (75, 35, 105, 65)); + /// The property that makes a single slider worth offering. + /// + /// Rarity puts the flag and the cloud at *different places on one scale* + /// rather than on two sides of a threshold, so there is a strictness that + /// has taken the flag and not the cloud — and the flag always goes first. + /// Without the rarity term both sit at the same verdict and the control + /// would appear dead until it suddenly ate the sky. + #[test] + fn the_flag_goes_before_the_cloud_does() { + let opts = RefineOptions::default(); + + let (flag_rgb, flag_w) = with(FLAG, RED); + let flag = Refinement::compute(&flag_w, &flag_rgb, EDGE, EDGE, CELL, &opts) + .expect("the flag frame has both sides"); + + let (cloud_rgb, cloud_w) = with(CLOUD, WHITE); + let cloud = Refinement::compute(&cloud_w, &cloud_rgb, EDGE, EDGE, CELL, &opts) + .expect("the cloud frame has both sides"); + + // The first strictness on a fine sweep at which each is more than half + // gone. `None` would mean it never goes at all. + let crossing = |r: &Refinement, weights: &[f32], core| { + (0..=120) + .map(|i| i as f32 * STRICTNESS_MAX / 120.0) + .find(|&s| mean(&r.apply(weights, s), EDGE, core) < 0.5) + }; + + let flag_at = crossing(&flag, &flag_w, FLAG_CORE).expect("the flag must go somewhere"); + let cloud_at = crossing(&cloud, &cloud_w, CLOUD_CORE); + + // `None` is the better outcome, not a missing case: it means the cloud + // survived the whole range, so the flag went first by a margin wider + // than the control can travel. + if let Some(cloud_at) = cloud_at { + assert!( + flag_at < cloud_at, + "the flag must go first: flag at {flag_at}, cloud at {cloud_at}" + ); + } assert!( - inside > 0.7, - "a 22% intruder is kept — see this test's comment: {inside}" + flag_at <= STRICTNESS_DEFAULT, + "the default must already clear a flag, but it only goes at {flag_at}" ); } + /// Zero strictness is exactly the model's own weighting. + /// + /// The control's off position has to be the old behaviour bit for bit, or + /// "turn it off and compare" does not answer the question it is asked. + #[test] + fn strictness_zero_changes_nothing() { + let (rgb, weights) = with(FLAG, RED); + let r = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &RefineOptions::default()) + .expect("both sides present"); + assert_eq!(r.apply(&weights, STRICTNESS_OFF), weights); + } + + /// Raising the strictness may only ever remove more. + /// + /// A slider that gave weight back on the way up would be one whose + /// direction the user cannot predict, and it would break the ordering the + /// whole control rests on. + #[test] + fn strictness_is_monotonic() { + let (rgb, weights) = with(FLAG, RED); + let r = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &RefineOptions::default()) + .expect("both sides present"); + + let mut previous = r.apply(&weights, 0.0); + for step in 1..=12 { + let next = r.apply(&weights, step as f32 * STRICTNESS_MAX / 12.0); + for (p, (&before, &after)) in previous.iter().zip(&next).enumerate() { + assert!( + after <= before + 1e-6, + "pixel {p} gained weight at step {step}: {before} -> {after}" + ); + } + previous = next; + } + } + /// The refinement may only ever take weight away. /// /// This is what bounds its damage and what keeps `scene.rs`'s partition @@ -761,17 +984,16 @@ mod tests { /// asserted rather than left as a property of the arithmetic. #[test] fn refinement_is_subtractive() { - let (w, h) = (EDGE, EDGE); - let (mut rgb, mut weights) = landscape(w, h); - intrude( - &mut rgb, - &mut weights, - w, - (88, 40, 104, 56), - [0.75, 0.1, 0.12], + let (rgb, weights) = with(FLAG, RED); + let (out, _) = refine_category( + &weights, + &rgb, + EDGE, + EDGE, + CELL, + STRICTNESS_DEFAULT, + &RefineOptions::default(), ); - - let (out, _) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); for (p, (&before, &after)) in weights.iter().zip(&out).enumerate() { assert!( after <= before + 1e-6, @@ -784,45 +1006,54 @@ mod tests { } } - /// A cloud is a legitimate part of the sky and is separated from the blue - /// by luminance alone, which is exactly what `luma_weight` is for. - /// - /// Without the down-weighting this test fails and the flag test passes, - /// which is why both are here. + /// The coverage path must agree with the float one, since the application + /// uses the first and every test here uses the second. #[test] - fn a_cloud_is_not_mistaken_for_an_intruder() { - let (w, h) = (EDGE, EDGE); - let (mut rgb, mut weights) = landscape(w, h); - // Big and pale — near-neutral, much brighter than the blue. - intrude( - &mut rgb, - &mut weights, - w, - (30, 12, 150, 60), - [0.92, 0.94, 0.96], - ); + fn the_coverage_path_agrees_with_the_float_one() { + let (rgb, weights) = with(FLAG, RED); + let r = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &RefineOptions::default()) + .expect("both sides present"); - let (out, _) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); - let cloud = mean(&out, w, (50, 24, 130, 48)); - assert!(cloud > 0.7, "the cloud should stay sky, got {cloud}"); + let coverage: Vec = weights.iter().map(|&w| (w * 255.0).round() as u8).collect(); + let bytes = r.apply_coverage(&coverage, STRICTNESS_DEFAULT); + let floats = r.apply(&weights, STRICTNESS_DEFAULT); + + for (p, (&b, &f)) in bytes.iter().zip(&floats).enumerate() { + let expected = (f * 255.0).round() as u8; + assert!( + b.abs_diff(expected) <= 1, + "pixel {p}: coverage {b}, float {expected}" + ); + } } /// No confident interior means no distribution, and no distribution must /// mean "leave it alone" rather than "reject everything". #[test] fn a_mask_thinner_than_the_grid_is_left_alone() { - let (w, h) = (EDGE, EDGE); - let (rgb, _) = landscape(w, h); + let (rgb, _) = landscape(EDGE, EDGE); // A three-pixel stripe: narrower than one cell, so the erosion at // 1.5 cells empties it. - let mut weights = vec![0.0f32; w * h]; - for y in 0..h { + let mut weights = vec![0.0f32; EDGE * EDGE]; + for y in 0..EDGE { for x in 60..63 { - weights[y * w + x] = 1.0; + weights[y * EDGE + x] = 1.0; } } - let (out, what) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + assert_eq!( + Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &RefineOptions::default()), + Err(SkipReason::NoInterior) + ); + let (out, what) = refine_category( + &weights, + &rgb, + EDGE, + EDGE, + CELL, + STRICTNESS_DEFAULT, + &RefineOptions::default(), + ); assert_eq!(what, Refined::Skipped(SkipReason::NoInterior)); assert_eq!(out, weights, "a skip must return the input untouched"); } @@ -830,11 +1061,18 @@ mod tests { /// A frame that is entirely one category has nothing to contrast against. #[test] fn a_frame_of_nothing_but_sky_is_left_alone() { - let (w, h) = (EDGE, EDGE); - let rgb = vec![0.6f32; w * h * 3]; - let weights = vec![1.0f32; w * h]; + let rgb = vec![0.6f32; EDGE * EDGE * 3]; + let weights = vec![1.0f32; EDGE * EDGE]; - let (out, what) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); + let (out, what) = refine_category( + &weights, + &rgb, + EDGE, + EDGE, + CELL, + STRICTNESS_DEFAULT, + &RefineOptions::default(), + ); assert_eq!(what, Refined::Skipped(SkipReason::NoExterior)); assert_eq!(out, weights); } @@ -844,26 +1082,27 @@ mod tests { /// seeding uses a fixed sequence and the iteration count is fixed. #[test] fn the_same_input_gives_the_same_answer() { - let (w, h) = (EDGE, EDGE); - let (mut rgb, mut weights) = landscape(w, h); - intrude( - &mut rgb, - &mut weights, - w, - (88, 40, 104, 56), - [0.8, 0.2, 0.2], - ); - + let (rgb, weights) = with(FLAG, RED); let opts = RefineOptions::default(); - let (a, _) = refine_category(&weights, &rgb, w, h, CELL, &opts); - let (b, _) = refine_category(&weights, &rgb, w, h, CELL, &opts); - assert_eq!(a, b); + let a = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &opts).unwrap(); + let b = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &opts).unwrap(); + assert_eq!( + a.apply(&weights, STRICTNESS_DEFAULT), + b.apply(&weights, STRICTNESS_DEFAULT) + ); } #[test] fn buffers_that_disagree_are_refused() { - let (out, what) = - refine_category(&[1.0; 4], &[0.5; 6], 2, 2, CELL, &RefineOptions::default()); + let (out, what) = refine_category( + &[1.0; 4], + &[0.5; 6], + 2, + 2, + CELL, + STRICTNESS_DEFAULT, + &RefineOptions::default(), + ); assert_eq!(what, Refined::Skipped(SkipReason::Mismatched)); assert_eq!(out, vec![1.0; 4]); } diff --git a/docs/traceability.md b/docs/traceability.md index 028d222..99f0894 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -59,7 +59,7 @@ _None._ | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`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-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs: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:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1652`](../core/dr-pipeline/src/sidecar.rs#L1652), [`core/dr-pipeline/src/sidecar.rs:1712`](../core/dr-pipeline/src/sidecar.rs#L1712), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:248`](../core/dr-segment/src/refine.rs#L248), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1472`](../ui/dr-ui/src/develop.rs#L1472), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1986`](../ui/dr-ui/src/develop.rs#L1986), [`ui/dr-ui/src/develop.rs:2004`](../ui/dr-ui/src/develop.rs#L2004), [`ui/dr-ui/src/develop.rs:2018`](../ui/dr-ui/src/develop.rs#L2018), [`ui/dr-ui/src/develop.rs:2040`](../ui/dr-ui/src/develop.rs#L2040), [`ui/dr-ui/src/develop.rs:2186`](../ui/dr-ui/src/develop.rs#L2186), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3584`](../ui/dr-ui/src/develop.rs#L3584), [`ui/dr-ui/src/develop.rs:3614`](../ui/dr-ui/src/develop.rs#L3614), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3680`](../ui/dr-ui/src/develop.rs#L3680), [`ui/dr-ui/src/develop.rs:3694`](../ui/dr-ui/src/develop.rs#L3694), [`ui/dr-ui/src/develop.rs:3886`](../ui/dr-ui/src/develop.rs#L3886), [`ui/dr-ui/src/develop.rs:4546`](../ui/dr-ui/src/develop.rs#L4546), [`ui/dr-ui/src/develop.rs:4600`](../ui/dr-ui/src/develop.rs#L4600), [`ui/dr-ui/src/develop.rs:4644`](../ui/dr-ui/src/develop.rs#L4644), [`ui/dr-ui/src/develop.rs:4694`](../ui/dr-ui/src/develop.rs#L4694), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:766`](../ui/dr-ui/src/develop.rs#L766), [`ui/dr-ui/src/develop.rs:813`](../ui/dr-ui/src/develop.rs#L813), [`ui/dr-ui/src/develop.rs:842`](../ui/dr-ui/src/develop.rs#L842), [`ui/dr-ui/src/lib.rs:1608`](../ui/dr-ui/src/lib.rs#L1608), [`ui/dr-ui/src/lib.rs:2410`](../ui/dr-ui/src/lib.rs#L2410), [`ui/dr-ui/src/lib.rs:2606`](../ui/dr-ui/src/lib.rs#L2606), [`ui/dr-ui/src/lib.rs:2778`](../ui/dr-ui/src/lib.rs#L2778), [`ui/dr-ui/src/lib.rs:2842`](../ui/dr-ui/src/lib.rs#L2842), [`ui/dr-ui/src/lib.rs:2896`](../ui/dr-ui/src/lib.rs#L2896), [`ui/dr-ui/src/lib.rs:355`](../ui/dr-ui/src/lib.rs#L355), [`ui/dr-ui/src/lib.rs:398`](../ui/dr-ui/src/lib.rs#L398), [`ui/dr-ui/src/lib.rs:421`](../ui/dr-ui/src/lib.rs#L421), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/masks_ui.rs:247`](../ui/dr-ui/src/masks_ui.rs#L247), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:862`](../ui/dr-ui/src/masks_ui.rs#L862), [`ui/dr-ui/src/masks_ui.rs:976`](../ui/dr-ui/src/masks_ui.rs#L976), [`ui/dr-ui/src/segmentation.rs:259`](../ui/dr-ui/src/segmentation.rs#L259), [`ui/dr-ui/src/segmentation.rs:275`](../ui/dr-ui/src/segmentation.rs#L275), [`ui/dr-ui/src/segmentation.rs:392`](../ui/dr-ui/src/segmentation.rs#L392), [`ui/dr-ui/src/segmentation.rs:420`](../ui/dr-ui/src/segmentation.rs#L420), [`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:150`](../ui/dr-ui/ui/app.slint#L150), [`ui/dr-ui/ui/app.slint:196`](../ui/dr-ui/ui/app.slint#L196), [`ui/dr-ui/ui/app.slint:2011`](../ui/dr-ui/ui/app.slint#L2011), [`ui/dr-ui/ui/app.slint:951`](../ui/dr-ui/ui/app.slint#L951), [`ui/dr-ui/ui/masks.slint:579`](../ui/dr-ui/ui/masks.slint#L579) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs: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:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1652`](../core/dr-pipeline/src/sidecar.rs#L1652), [`core/dr-pipeline/src/sidecar.rs:1712`](../core/dr-pipeline/src/sidecar.rs#L1712), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`core/dr-segment/src/refine.rs:442`](../core/dr-segment/src/refine.rs#L442), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1472`](../ui/dr-ui/src/develop.rs#L1472), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1986`](../ui/dr-ui/src/develop.rs#L1986), [`ui/dr-ui/src/develop.rs:2004`](../ui/dr-ui/src/develop.rs#L2004), [`ui/dr-ui/src/develop.rs:2018`](../ui/dr-ui/src/develop.rs#L2018), [`ui/dr-ui/src/develop.rs:2040`](../ui/dr-ui/src/develop.rs#L2040), [`ui/dr-ui/src/develop.rs:2186`](../ui/dr-ui/src/develop.rs#L2186), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3584`](../ui/dr-ui/src/develop.rs#L3584), [`ui/dr-ui/src/develop.rs:3614`](../ui/dr-ui/src/develop.rs#L3614), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3680`](../ui/dr-ui/src/develop.rs#L3680), [`ui/dr-ui/src/develop.rs:3694`](../ui/dr-ui/src/develop.rs#L3694), [`ui/dr-ui/src/develop.rs:3886`](../ui/dr-ui/src/develop.rs#L3886), [`ui/dr-ui/src/develop.rs:4546`](../ui/dr-ui/src/develop.rs#L4546), [`ui/dr-ui/src/develop.rs:4600`](../ui/dr-ui/src/develop.rs#L4600), [`ui/dr-ui/src/develop.rs:4644`](../ui/dr-ui/src/develop.rs#L4644), [`ui/dr-ui/src/develop.rs:4694`](../ui/dr-ui/src/develop.rs#L4694), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:766`](../ui/dr-ui/src/develop.rs#L766), [`ui/dr-ui/src/develop.rs:813`](../ui/dr-ui/src/develop.rs#L813), [`ui/dr-ui/src/develop.rs:842`](../ui/dr-ui/src/develop.rs#L842), [`ui/dr-ui/src/lib.rs:1608`](../ui/dr-ui/src/lib.rs#L1608), [`ui/dr-ui/src/lib.rs:2410`](../ui/dr-ui/src/lib.rs#L2410), [`ui/dr-ui/src/lib.rs:2606`](../ui/dr-ui/src/lib.rs#L2606), [`ui/dr-ui/src/lib.rs:2778`](../ui/dr-ui/src/lib.rs#L2778), [`ui/dr-ui/src/lib.rs:2842`](../ui/dr-ui/src/lib.rs#L2842), [`ui/dr-ui/src/lib.rs:2896`](../ui/dr-ui/src/lib.rs#L2896), [`ui/dr-ui/src/lib.rs:355`](../ui/dr-ui/src/lib.rs#L355), [`ui/dr-ui/src/lib.rs:398`](../ui/dr-ui/src/lib.rs#L398), [`ui/dr-ui/src/lib.rs:421`](../ui/dr-ui/src/lib.rs#L421), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/masks_ui.rs:247`](../ui/dr-ui/src/masks_ui.rs#L247), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:862`](../ui/dr-ui/src/masks_ui.rs#L862), [`ui/dr-ui/src/masks_ui.rs:976`](../ui/dr-ui/src/masks_ui.rs#L976), [`ui/dr-ui/src/segmentation.rs:259`](../ui/dr-ui/src/segmentation.rs#L259), [`ui/dr-ui/src/segmentation.rs:275`](../ui/dr-ui/src/segmentation.rs#L275), [`ui/dr-ui/src/segmentation.rs:392`](../ui/dr-ui/src/segmentation.rs#L392), [`ui/dr-ui/src/segmentation.rs:420`](../ui/dr-ui/src/segmentation.rs#L420), [`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:150`](../ui/dr-ui/ui/app.slint#L150), [`ui/dr-ui/ui/app.slint:196`](../ui/dr-ui/ui/app.slint#L196), [`ui/dr-ui/ui/app.slint:2011`](../ui/dr-ui/ui/app.slint#L2011), [`ui/dr-ui/ui/app.slint:951`](../ui/dr-ui/ui/app.slint#L951), [`ui/dr-ui/ui/masks.slint:579`](../ui/dr-ui/ui/masks.slint#L579) | | 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:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:24`](../core/dr-pipeline/src/graph.rs#L24), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/mask.rs:988`](../core/dr-pipeline/src/mask.rs#L988), [`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:1369`](../ui/dr-ui/src/develop.rs#L1369), [`ui/dr-ui/src/lib.rs:704`](../ui/dr-ui/src/lib.rs#L704), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | | FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/mask.rs:988`](../core/dr-pipeline/src/mask.rs#L988), [`ui/dr-ui/src/develop.rs:5273`](../ui/dr-ui/src/develop.rs#L5273) |