From f87bf6ebc08ee73507e05db2cfd2a314a55124e1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 18:17:29 +0200 Subject: [PATCH] Charge a colour mode for its rarity, and keep the verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refinement worked and could not be controlled. Pruning modes below a share threshold made the flag's removal a *discrete* event: below the line its Mahalanobis distance was enormous and nothing rescued it, above the line it sat at zero and nothing removed it. A control over that would appear dead through most of its travel and then start eating sky. So the prune is gone. A mode is charged `−ln(share × k)` nats, floored at zero, and that cost enters both tests — doubled in the chi-square, which is a squared distance, and directly in the log density. Rarity becomes a distance rather than a threshold, and the things a photographer wants to remove separate along it. Measured on the synthetic frame the tests build: a flag holding 1.6% of the sky is more than half gone by **2.95 nats** and a cloud bank holding a third of it survives to **5.75**. The whole interval between them is somewhere a control can sit. `the_flag_goes_before_the_cloud_does` pins the ordering, which is the property that makes one slider worth offering at all. Measured against an even split rather than against one, so raising `clusters` describes a category more finely without making every colour in it look rarer. Floored at zero so a dominant mode earns no *discount* — a bonus there would let the commonest colour outvote a bad chi-square, which is the one direction this must not bend. `Refinement` holds the per-pixel verdict, quantised to a byte over ±16 nats — an eighth of a nat per step, far finer than the narrowest transition the gate can be asked for, and the same size as the coverage buffer it sits beside. `apply` is then a smoothstep, and the model is never consulted again. That is `distance.rs`'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. The blur moved with it, from the gate to the verdict. Smoothing the evidence rather than the decision means it is paid for once in `compute` instead of on every frame of a drag, and it is the better thing to smooth in any case. `apply` at `STRICTNESS_OFF` returns the weights untouched without reading the verdict at all. A control whose off position is *very nearly* the unrefined mask cannot answer "is this helping"; one whose off position is the unrefined mask can. `strictness_zero_changes_nothing` holds it to that, and `strictness_is_monotonic` holds the rest of the travel to only ever removing more — a slider that gave weight back partway up would be one whose direction nobody could predict. 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 placement is a calibration. Which is the honest argument for a control rather than a constant, and why the default sits at half scale instead of at the flag's measured crossing. The example sweeps the whole range and writes a frame per nat, because the question a photographer asks of a slider is where to put it, and that needs the travel rather than a point on it. Verified: fmt clean, clippy -D warnings clean, 60 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-segment/examples/scene.rs | 87 +++- core/dr-segment/src/lib.rs | 5 +- core/dr-segment/src/refine.rs | 801 +++++++++++++++++++----------- docs/traceability.md | 2 +- 4 files changed, 588 insertions(+), 307 deletions(-) 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) |