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
+63 -24
View File
@@ -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 `<prefix>-<category>-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 `<prefix>-<category>-refined-<strictness>.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::<f32>()) / 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")
+4 -1
View File
@@ -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")]
File diff suppressed because it is too large Load Diff