Charge a colour mode for its rarity, and keep the verdict

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 18:30:40 +02:00
co-authored by Claude Opus 5
parent 4f4abd335f
commit f87bf6ebc0
4 changed files with 588 additions and 307 deletions
+56 -17
View File
@@ -24,13 +24,21 @@
//! came from* rather than as an abstract grey field. PPM for the same reason //! 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. //! the other examples use it: no encoder dependency, and every viewer reads it.
//! //!
//! And `<prefix>-<category>-refined.ppm` beside it, which is the same category //! And `<prefix>-<category>-refined-<strictness>.ppm` beside it — the same
//! after [`dr_segment::refine_category`] has cut it back to the pixels whose //! category after [`dr_segment::Refinement`] has cut it back to the pixels
//! colour agrees with it. Both, never one: whether that refinement is an //! whose colour agrees with it, at each point of a sweep across the whole
//! improvement is a comparative judgement — did the flag come out of the sky, //! strictness range the application's slider offers.
//! 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 //! A sweep rather than one image, for two reasons. Whether the refinement is
//! is the number to be suspicious of when it is large. //! 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 //! 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 //! 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 // judgement this example exists to support is comparative — is the
// flag out, and is the sky still there — and it cannot be made from // flag out, and is the sky still there — and it cannot be made from
// one image. // 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 start = Instant::now();
let (refined, what) = dr_segment::refine_category( let refinement = dr_segment::Refinement::compute(
&mask, &mask,
&rgb, &rgb,
width, width,
@@ -131,28 +144,54 @@ fn main() {
scene.cell_pixels(), scene.cell_pixels(),
&dr_segment::RefineOptions::default(), &dr_segment::RefineOptions::default(),
); );
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 took = start.elapsed();
match what {
dr_segment::Refined::Applied { removed } => { let cut = if total > 0.0 {
(total - refined.iter().sum::<f32>()) / total
} else {
0.0
};
println!( println!(
" refined in {took:?}: {:.1}% of the weight cut", " strictness {strictness:>4.1}: {:5.1}% cut ({took:?})",
removed * 100.0 cut * 100.0
); );
write_overlay( write_overlay(
&format!("{prefix}-{name}-refined.ppm"), &format!("{prefix}-{name}-refined-{strictness:.1}.ppm"),
&rgb, &rgb,
&refined, &refined,
width, width,
height, height,
); );
} }
dr_segment::Refined::Skipped(why) => {
println!(" left coarse: {why:?}");
}
}
} }
} }
/// 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")] #[cfg(feature = "embedded-scene-model")]
fn embedded() -> SceneModel { fn embedded() -> SceneModel {
SceneModel::embedded().expect("could not load the embedded scene model") SceneModel::embedded().expect("could not load the embedded scene model")
+4 -1
View File
@@ -64,7 +64,10 @@ pub mod semantic;
pub use distance::{signed_distance, Falloff, Morphology, Shaped}; pub use distance::{signed_distance, Falloff, Morphology, Shaped};
pub use hierarchy::{Edge, Merge, MergeTree, RegionField}; pub use hierarchy::{Edge, Merge, MergeTree, RegionField};
pub use prior::{Membership, PriorOptions}; 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")] #[cfg(feature = "semantic")]
pub use scene::{Category, Scene, SceneModel}; pub use scene::{Category, Scene, SceneModel};
#[cfg(feature = "semantic")] #[cfg(feature = "semantic")]
+484 -245
View File
@@ -2,11 +2,11 @@
//! //!
//! # The problem, as a number //! # The problem, as a number
//! //!
//! The `scene` module keeps the model's native `[1, 150, 80, 80]` logit grid, so //! The `scene` module keeps the model's native `[1, 150, 80, 80]` logit grid,
//! one cell is eight of the graph's input pixels across. At the 1600px proxy //! so one cell is eight of the graph's input pixels across. At the 1600px
//! the application segments at, the letterbox scale is `0.4` and **one cell is //! proxy the application segments at, the letterbox scale is `0.4` and **one
//! 20 proxy pixels** — which the bilinear in `Scene::rasterise` then spreads //! cell is 20 proxy pixels** — which the bilinear in `Scene::rasterise` then
//! across one more either side. //! spreads across one more either side.
//! //!
//! So a flag in the sky, or a chimney, or a bare branch, sits inside a handful //! 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 //! 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 //! 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. //! k-means, which is GrabCut's mixture without the EM.
//! //!
//! 3. **Small modes are dropped** ([`RefineOptions::min_cluster`]). This is //! 3. **A mode is charged for its rarity.** A *small* flag deep in the sky has
//! the step that makes the whole thing work on the case it was built for. //! both a high weight and a large distance from the mask's boundary, so it
//! A *small* flag deep in the sky has both a high weight and a large //! lands in the interior sample and fits itself a mode. It cannot be
//! distance from the mask's boundary, so it lands in the interior sample //! excluded geometrically. What separates it from a real appearance of the
//! and would teach the model its own colour. It cannot be excluded //! category is that it explains almost none of the category — so a colour
//! geometrically — but it is a few percent of the sky, and a mode holding a //! whose only explanation is a thin mode is charged `−ln(share)` nats for
//! few percent of the confident interior is far likelier to be an intruder //! it, and a flag ends up far less plausible than the sky around it without
//! than a real appearance of the category. //! ever being ruled out by fiat.
//! //!
//! 4. **Two tests, and a pixel must pass both.** An absolute one — is this //! 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 //! 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 //! alone would leave at even odds. The comparative test is what stops the
//! absolute one from needing a per-category constant. //! 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 //! # Two properties that make it safe to apply blind
//! //!
//! **It is subtractive.** The output is the input times a factor in `0..=1`, //! **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 //! 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. //! was never in the mask cannot be pulled into it.
//! //!
//! **The partition survives.** `scene.rs` rests entirely on the //! **The partition survives.** `scene.rs` rests entirely on the categories
//! categories summing to at most one at every pixel — that is what lets two //! summing to at most one at every pixel — that is what lets two adjacent
//! adjacent grades be feathered without painting both into the overlap. //! grades be feathered without painting both into the overlap. Multiplying by
//! Multiplying by a factor in `0..=1` cannot raise a sum, so refining every //! a factor in `0..=1` cannot raise a sum, so refining every category
//! category independently still leaves a partition. The weight taken off the //! independently still leaves a partition. The weight taken off the flag lands
//! flag lands in the unlisted remainder, which is exactly where a flag //! in the unlisted remainder, which is exactly where a flag belongs: ADE20K
//! belongs: ADE20K has no class for one. //! 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.
//! //!
//! # And when it cannot tell //! # And when it cannot tell
//! //!
//! Every path that lacks the evidence to judge returns the weights untouched //! Every path that lacks the evidence to judge refuses to build a
//! and says so in [`Refined::Skipped`], rather than returning a plausible //! [`Refinement`] at all and says why, rather than returning a plausible one.
//! answer. An empty seed set fitted to a distribution would reject *every* //! An empty seed set fitted to a distribution would reject *every* pixel, and
//! pixel, and a sky that silently vanished from a mask is the kind of failure //! a sky that silently vanished from a mask is the kind of failure nobody
//! nobody attributes to the right place. //! attributes to the right place.
use crate::distance::signed_distance; 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)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct RefineOptions { pub struct RefineOptions {
/// Weight at or above which a pixel may seed the interior distribution. /// Weight at or above which a pixel may seed the interior distribution.
@@ -117,19 +135,11 @@ pub struct RefineOptions {
/// Modes fitted per side. /// Modes fitted per side.
/// ///
/// Four covers blue, cloud and horizon haze with one spare. More would fit /// Rarity is measured against an even split ([`Mode::rarity`]), so this
/// the intruder its own mode reliably enough to keep it, which is the /// number does not shift the verdicts on its own — raising it buys a finer
/// opposite of the point. /// description of the category without making every colour look rarer.
pub clusters: usize, 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. /// How much luminance counts against chrominance in judging a colour.
/// ///
/// Well under one, and that is the difference between this working and not /// 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. /// gradient, for the same reason.
pub luma_weight: f32, pub luma_weight: f32,
/// Squared Mahalanobis distance beyond which a colour is implausible under /// Squared Mahalanobis distance at which a colour stops being plausible
/// the category. /// under the category.
/// ///
/// Chi-square with three degrees of freedom: `11.34` is the 99th /// Chi-square with three degrees of freedom: `11.34` is the 99th
/// percentile, so under the fitted model one confident pixel in a hundred /// 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, pub outlier: f32,
/// Width of the band, in nats, over which the gate falls from keep to cut. /// 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. /// the place a person is looking.
pub soften: f32, 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 /// 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 /// individual pixels over the line in both directions. Blurring the
/// removes the speckle and costs nothing at the boundary, which is already /// evidence rather than the decision means it is paid for once, in
/// soft by [`RefineOptions::soften`]. /// [`Refinement::compute`], instead of on every frame of a drag.
pub smooth_px: f32, pub smooth_px: f32,
/// Samples below which a side is too small to fit anything to. /// Samples below which a side is too small to fit anything to.
@@ -178,7 +189,6 @@ impl Default for RefineOptions {
seed_weight: 0.9, seed_weight: 0.9,
margin_cells: 1.5, margin_cells: 1.5,
clusters: 4, clusters: 4,
min_cluster: 0.03,
luma_weight: 0.25, luma_weight: 0.25,
outlier: 11.34, outlier: 11.34,
soften: 1.5, 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 /// Past this a category's own dominant colours have gone, so a control
/// category it asked about and this module does not. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason { pub enum SkipReason {
/// Nothing survived the erosion — the category is present but everywhere /// Nothing survived the erosion — the category is present but everywhere
@@ -205,13 +242,11 @@ pub enum SkipReason {
Mismatched, Mismatched,
} }
/// What a refinement did. /// What a one-shot [`refine_category`] did.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum Refined { pub enum Refined {
/// Applied. `removed` is the fraction of the category's original total /// 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 /// weight the gate took away.
/// one with a flag in it, and large enough to be worth a look in the log
/// if the colour model has gone wrong.
Applied { Applied {
removed: f32, removed: f32,
}, },
@@ -245,35 +280,62 @@ const MAX_SAMPLES: usize = 20_000;
/// (docs/segmentation.md §6). /// (docs/segmentation.md §6).
const ITERATIONS: usize = 12; const ITERATIONS: usize = 12;
/// TRACES: FR-DEV-3 /// Half-width of the verdict scale, in nats.
/// Cut a coarse category mask back to the pixels that belong to it.
/// ///
/// `weights` is one category's coverage at `width * height`, as /// The verdict is stored as a byte, so it needs an end. Sixteen is comfortably
/// `Scene::rasterise` produces it. `rgb` is the same picture, tightly packed /// past [`STRICTNESS_MAX`] plus a soften band, and it puts the quantisation
/// `f32` RGB — the proxy the model itself read, so the two describe one frame. /// 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.
/// ///
/// `cell_pixels` is how many pixels of *this* buffer one logit cell spans; see /// Built once per image by [`Refinement::compute`]; read by
/// `Scene::cell_pixels`, which is where the caller should get it rather than /// [`Refinement::apply`] as often as a slider moves.
/// re-deriving a letterbox inverse. #[derive(Debug, Clone, PartialEq)]
/// pub struct Refinement {
/// Returns the refined weights and what was done to them. On any /// Per pixel, how much better the category explains this colour than the
/// [`Refined::Skipped`] the weights come back identical to the input. /// alternatives, in nats, quantised over ±[`VERDICT_RANGE`].
pub fn refine_category( ///
/// 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<u8>,
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], weights: &[f32],
rgb: &[f32], rgb: &[f32],
width: usize, width: usize,
height: usize, height: usize,
cell_pixels: f32, cell_pixels: f32,
options: &RefineOptions, options: &RefineOptions,
) -> (Vec<f32>, Refined) { ) -> Result<Self, SkipReason> {
let pixels = width * height; let pixels = width * height;
if weights.len() != pixels || rgb.len() != pixels * 3 || pixels == 0 { if weights.len() != pixels || rgb.len() != pixels * 3 || pixels == 0 {
return (weights.to_vec(), Refined::Skipped(SkipReason::Mismatched)); return Err(SkipReason::Mismatched);
} }
// The distance field is over the *thresholded* weights, so the boundary it // The distance field is over the *thresholded* weights, so the
// measures from is where the model stopped being sure — not where the mask // boundary it measures from is where the model stopped being sure —
// will eventually be cut, which is what this function is deciding. // not where the mask will eventually be cut, which is what the
// strictness decides later.
let coverage: Vec<u8> = weights let coverage: Vec<u8> = weights
.iter() .iter()
.map(|&w| (w.clamp(0.0, 1.0) * 255.0).round() as u8) .map(|&w| (w.clamp(0.0, 1.0) * 255.0).round() as u8)
@@ -285,51 +347,135 @@ pub fn refine_category(
let interior = sample(&distance, rgb, options.luma_weight, |d| d >= margin); let interior = sample(&distance, rgb, options.luma_weight, |d| d >= margin);
if interior.len() < options.min_samples { if interior.len() < options.min_samples {
return (weights.to_vec(), Refined::Skipped(SkipReason::NoInterior)); return Err(SkipReason::NoInterior);
} }
let exterior = sample(&distance, rgb, options.luma_weight, |d| d <= -margin); let exterior = sample(&distance, rgb, options.luma_weight, |d| d <= -margin);
if exterior.len() < options.min_samples { if exterior.len() < options.min_samples {
return (weights.to_vec(), Refined::Skipped(SkipReason::NoExterior)); return Err(SkipReason::NoExterior);
} }
let inside = fit(&interior, options.clusters, options.min_cluster); let inside = fit(&interior, options.clusters);
// The exterior keeps every mode it finds. Pruning there would be the wrong let outside = fit(&exterior, options.clusters);
// 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]; let mut verdict = vec![0.0f32; pixels];
for (p, cell) in gate.iter_mut().enumerate() { for (p, cell) in verdict.iter_mut().enumerate() {
let x = feature(rgb, p, options.luma_weight); let x = feature(rgb, p, options.luma_weight);
// Squared Mahalanobis for the absolute test, because that is what is // Squared Mahalanobis for the absolute test, because that is what
// chi-square distributed; the density — the same distance with the // is chi-square distributed; the density — the same distance with
// mode's own spread folded in — for the comparative one, so that a // the mode's own spread folded in — for the comparative one, so
// tight mode and a loose one are compared fairly. // 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 (maha_in, nll_in) = nearest(&inside, &x);
let (_, nll_out) = nearest(&outside, &x); let (_, nll_out) = nearest(&outside, &x);
let plausible = 0.5 * (options.outlier - maha_in); let plausible = 0.5 * (options.outlier - maha_in);
let likelier = nll_out - nll_in; let likelier = nll_out - nll_in;
let verdict = plausible.min(likelier); *cell = plausible.min(likelier);
*cell = smoothstep(-options.soften, options.soften, verdict);
} }
// The evidence is blurred, not the decision — see
// `RefineOptions::smooth_px`.
if options.smooth_px >= 1.0 { if options.smooth_px >= 1.0 {
gate = blur(&gate, width, height, options.smooth_px.round() as usize); 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<f32> {
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<f32> {
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<u8> {
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
/// Fit and apply in one call, for a caller with no control to offer.
///
/// 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<f32>, Refined) {
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 before: f32 = weights.iter().sum();
let refined: Vec<f32> = weights.iter().zip(&gate).map(|(&w, &g)| w * g).collect();
let after: f32 = refined.iter().sum(); let after: f32 = refined.iter().sum();
let removed = if before > 0.0 { let removed = if before > 0.0 {
((before - after) / before).clamp(0.0, 1.0) ((before - after) / before).clamp(0.0, 1.0)
} else { } else {
0.0 0.0
}; };
(refined, Refined::Applied { removed }) (refined, Refined::Applied { removed })
}
Err(why) => (weights.to_vec(), Refined::Skipped(why)),
}
}
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
}
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. /// 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 /// `0.5 * ln|Σ|`, precomputed because it is constant per mode and the
/// inner loop runs once per pixel. /// inner loop runs once per pixel.
half_log_det: f32, 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`. /// Fit `k` modes to one side.
fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec<Mode> { fn fit(samples: &[[f32; FEATURES]], k: usize) -> Vec<Mode> {
let k = k.max(1).min(samples.len()); let k = k.max(1).min(samples.len());
let mut centres = seed_centres(samples, k); let mut centres = seed_centres(samples, k);
let k = centres.len(); let k = centres.len();
@@ -439,7 +600,7 @@ fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec<Mode> {
} }
} }
// 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 sums = vec![[0.0f32; FEATURES]; k];
let mut squares = vec![[0.0f32; FEATURES]; k]; let mut squares = vec![[0.0f32; FEATURES]; k];
let mut counts = vec![0usize; k]; let mut counts = vec![0usize; k];
@@ -452,9 +613,9 @@ fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec<Mode> {
counts[c] += 1; 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 modes = Vec::new();
let mut largest: Option<(usize, Mode)> = None;
for c in 0..k { for c in 0..k {
if counts[c] == 0 { if counts[c] == 0 {
continue; continue;
@@ -468,26 +629,12 @@ fn fit(samples: &[[f32; FEATURES]], k: usize, min_share: f32) -> Vec<Mode> {
variance[f] = (squares[c][f] / n - mean[f] * mean[f]).max(VARIANCE_FLOOR); variance[f] = (squares[c][f] / n - mean[f] * mean[f]).max(VARIANCE_FLOOR);
half_log_det += 0.5 * variance[f].ln(); half_log_det += 0.5 * variance[f].ln();
} }
let mode = Mode { modes.push(Mode {
mean, mean,
variance, variance,
half_log_det, half_log_det,
}; rarity: -((n / total) * even).ln().min(0.0),
});
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));
} }
modes modes
} }
@@ -530,7 +677,13 @@ fn seed_centres(samples: &[[f32; FEATURES]], k: usize) -> Vec<[f32; FEATURES]> {
centres 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 /// 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 /// 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; let d = v - mean;
maha += d * d / variance; 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 { if nll < best.1 {
best = (maha, nll); best = (maha + 2.0 * mode.rarity, nll);
} }
} }
best best
@@ -565,9 +718,9 @@ fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
/// Separable box blur, clamped at the edges. /// Separable box blur, clamped at the edges.
/// ///
/// A box rather than a gaussian because it is being applied to a gate that is /// A box rather than a gaussian because what is wanted is the removal of
/// already soft: what is wanted is the removal of single-pixel speckle, and the /// single-pixel speckle, and the shape of the kernel that does it does not
/// shape of the kernel that does it does not matter. /// matter.
fn blur(src: &[f32], width: usize, height: usize, radius: usize) -> Vec<f32> { fn blur(src: &[f32], width: usize, height: usize, radius: usize) -> Vec<f32> {
if radius == 0 || width == 0 || height == 0 { if radius == 0 || width == 0 || height == 0 {
return src.to_vec(); return src.to_vec();
@@ -624,13 +777,12 @@ mod tests {
/// ///
/// Large enough that an intruder can be a realistic *fraction* of the /// Large enough that an intruder can be a realistic *fraction* of the
/// category rather than a realistic number of pixels — which is what /// category rather than a realistic number of pixels — which is what
/// [`RefineOptions::min_cluster`] is measured in, and getting that /// rarity is measured in, and getting that proportion wrong is the
/// proportion wrong is the difference between this method working and not. /// difference between this method working and not.
const EDGE: usize = 192; const EDGE: usize = 192;
/// A frame with a blue upper half and a green lower half, and a category /// A blue upper half over a green lower half, and a category mask that
/// mask that claims the whole upper half plus a smear over the boundary — /// claims the whole upper half — which is what the coarse grid produces.
/// which is what the coarse grid actually produces.
fn landscape(w: usize, h: usize) -> (Vec<f32>, Vec<f32>) { fn landscape(w: usize, h: usize) -> (Vec<f32>, Vec<f32>) {
let mut rgb = vec![0.0f32; w * h * 3]; let mut rgb = vec![0.0f32; w * h * 3];
let mut weights = vec![0.0f32; w * h]; let mut weights = vec![0.0f32; w * h];
@@ -685,74 +837,145 @@ mod tests {
sum / ((x1 - x0) * (y1 - y0)) as f32 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<f32>, Vec<f32>) {
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 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 coarse mask, must come back out at the default strictness — 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.
#[test] #[test]
fn a_flag_in_the_sky_is_removed() { fn a_flag_in_the_sky_is_removed() {
let (w, h) = (EDGE, EDGE); let (rgb, weights) = with(FLAG, RED);
let (mut rgb, mut weights) = landscape(w, h); let (out, what) = refine_category(
intrude( &weights,
&mut rgb, &rgb,
&mut weights, EDGE,
w, EDGE,
(88, 40, 104, 56), CELL,
[0.75, 0.10, 0.12], STRICTNESS_DEFAULT,
&RefineOptions::default(),
); );
let (out, what) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default());
assert!( assert!(
matches!(what, Refined::Applied { .. }), matches!(what, Refined::Applied { .. }),
"should have had the evidence to judge: {what:?}" "should have had the evidence to judge: {what:?}"
); );
// Inside the flag, clear of its own blurred edge. let inside = mean(&out, EDGE, FLAG_CORE);
let inside = mean(&out, w, (92, 44, 100, 52));
assert!(inside < 0.2, "the flag should be cut out, got {inside}"); 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, EDGE, (8, 8, 60, 60));
let sky = mean(&out, w, (8, 8, 60, 60));
assert!(sky > 0.8, "the sky around it should survive, got {sky}"); 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 cloud is a legitimate part of the sky and is separated from the blue
/// a surprise. /// by luminance alone, which is what `luma_weight` is for — and it holds a
/// /// large share, which is what rarity is for.
/// 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.
#[test] #[test]
fn an_intruder_larger_than_min_cluster_survives() { fn a_cloud_is_not_mistaken_for_an_intruder() {
let (w, h) = (EDGE, EDGE); let (rgb, weights) = with(CLOUD, WHITE);
let (mut rgb, mut weights) = landscape(w, h); let (out, _) = refine_category(
intrude( &weights,
&mut rgb, &rgb,
&mut weights, EDGE,
w, EDGE,
(60, 20, 120, 80), CELL,
[0.75, 0.10, 0.12], 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()); /// The property that makes a single slider worth offering.
let inside = mean(&out, w, (75, 35, 105, 65)); ///
/// 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!( assert!(
inside > 0.7, flag_at < cloud_at,
"a 22% intruder is kept — see this test's comment: {inside}" "the flag must go first: flag at {flag_at}, cloud at {cloud_at}"
); );
} }
assert!(
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. /// The refinement may only ever take weight away.
/// ///
@@ -761,17 +984,16 @@ mod tests {
/// asserted rather than left as a property of the arithmetic. /// asserted rather than left as a property of the arithmetic.
#[test] #[test]
fn refinement_is_subtractive() { fn refinement_is_subtractive() {
let (w, h) = (EDGE, EDGE); let (rgb, weights) = with(FLAG, RED);
let (mut rgb, mut weights) = landscape(w, h); let (out, _) = refine_category(
intrude( &weights,
&mut rgb, &rgb,
&mut weights, EDGE,
w, EDGE,
(88, 40, 104, 56), CELL,
[0.75, 0.1, 0.12], 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() { for (p, (&before, &after)) in weights.iter().zip(&out).enumerate() {
assert!( assert!(
after <= before + 1e-6, 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 /// The coverage path must agree with the float one, since the application
/// by luminance alone, which is exactly what `luma_weight` is for. /// uses the first and every test here uses the second.
///
/// Without the down-weighting this test fails and the flag test passes,
/// which is why both are here.
#[test] #[test]
fn a_cloud_is_not_mistaken_for_an_intruder() { fn the_coverage_path_agrees_with_the_float_one() {
let (w, h) = (EDGE, EDGE); let (rgb, weights) = with(FLAG, RED);
let (mut rgb, mut weights) = landscape(w, h); let r = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &RefineOptions::default())
// Big and pale — near-neutral, much brighter than the blue. .expect("both sides present");
intrude(
&mut rgb,
&mut weights,
w,
(30, 12, 150, 60),
[0.92, 0.94, 0.96],
);
let (out, _) = refine_category(&weights, &rgb, w, h, CELL, &RefineOptions::default()); let coverage: Vec<u8> = weights.iter().map(|&w| (w * 255.0).round() as u8).collect();
let cloud = mean(&out, w, (50, 24, 130, 48)); let bytes = r.apply_coverage(&coverage, STRICTNESS_DEFAULT);
assert!(cloud > 0.7, "the cloud should stay sky, got {cloud}"); 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 /// No confident interior means no distribution, and no distribution must
/// mean "leave it alone" rather than "reject everything". /// mean "leave it alone" rather than "reject everything".
#[test] #[test]
fn a_mask_thinner_than_the_grid_is_left_alone() { fn a_mask_thinner_than_the_grid_is_left_alone() {
let (w, h) = (EDGE, EDGE); let (rgb, _) = landscape(EDGE, EDGE);
let (rgb, _) = landscape(w, h);
// A three-pixel stripe: narrower than one cell, so the erosion at // A three-pixel stripe: narrower than one cell, so the erosion at
// 1.5 cells empties it. // 1.5 cells empties it.
let mut weights = vec![0.0f32; w * h]; let mut weights = vec![0.0f32; EDGE * EDGE];
for y in 0..h { for y in 0..EDGE {
for x in 60..63 { 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!(what, Refined::Skipped(SkipReason::NoInterior));
assert_eq!(out, weights, "a skip must return the input untouched"); 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. /// A frame that is entirely one category has nothing to contrast against.
#[test] #[test]
fn a_frame_of_nothing_but_sky_is_left_alone() { fn a_frame_of_nothing_but_sky_is_left_alone() {
let (w, h) = (EDGE, EDGE); let rgb = vec![0.6f32; EDGE * EDGE * 3];
let rgb = vec![0.6f32; w * h * 3]; let weights = vec![1.0f32; EDGE * EDGE];
let weights = vec![1.0f32; w * h];
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!(what, Refined::Skipped(SkipReason::NoExterior));
assert_eq!(out, weights); assert_eq!(out, weights);
} }
@@ -844,26 +1082,27 @@ mod tests {
/// seeding uses a fixed sequence and the iteration count is fixed. /// seeding uses a fixed sequence and the iteration count is fixed.
#[test] #[test]
fn the_same_input_gives_the_same_answer() { fn the_same_input_gives_the_same_answer() {
let (w, h) = (EDGE, EDGE); let (rgb, weights) = with(FLAG, RED);
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 opts = RefineOptions::default(); let opts = RefineOptions::default();
let (a, _) = refine_category(&weights, &rgb, w, h, CELL, &opts); let a = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &opts).unwrap();
let (b, _) = refine_category(&weights, &rgb, w, h, CELL, &opts); let b = Refinement::compute(&weights, &rgb, EDGE, EDGE, CELL, &opts).unwrap();
assert_eq!(a, b); assert_eq!(
a.apply(&weights, STRICTNESS_DEFAULT),
b.apply(&weights, STRICTNESS_DEFAULT)
);
} }
#[test] #[test]
fn buffers_that_disagree_are_refused() { fn buffers_that_disagree_are_refused() {
let (out, what) = let (out, what) = refine_category(
refine_category(&[1.0; 4], &[0.5; 6], 2, 2, CELL, &RefineOptions::default()); &[1.0; 4],
&[0.5; 6],
2,
2,
CELL,
STRICTNESS_DEFAULT,
&RefineOptions::default(),
);
assert_eq!(what, Refined::Skipped(SkipReason::Mismatched)); assert_eq!(what, Refined::Skipped(SkipReason::Mismatched));
assert_eq!(out, vec![1.0; 4]); assert_eq!(out, vec![1.0; 4]);
} }
+1 -1
View File
File diff suppressed because one or more lines are too long