Give a mask an edge treatment, and find out the watershed has none worth having

Two things, and the second is why the first matters more than expected.

Mask layers gain a feather, a falloff curve and a morphology, all defined
against a signed distance from the boundary rather than as separate
features — one exact distance field answers "how soft" and "how far" at
once, so dilation is a threshold at -r, erosion one at +r, and closing and
opening are one of each in sequence. The compound pair costs a second
distance field, which is why they are named rather than presented as a
radius that happens to be signed. Types, defaults and sidecar round-trip
only; the field itself is next.

`edge-feather` and `edge-falloff`, not `feather` and `falloff`, because a
radial mask already writes `feather` for the fraction of its radius it
ramps over. Same word, different quantity, different units — sharing the
key would have made an existing file ambiguous.

The diagnostic that provoked this is committed as an ignored test, because
"does the ladder land on things a person means" is the question S15 exists
to answer and it should not depend on whoever still has the script. On
bus.jpg it answers badly: 35,075 regions at blur 2 over an 810x1080 frame,
and cutting that to 400 gives *one* region covering nearly the whole
picture plus 399 noise specks. Not over-segmentation — collapse. Almost
every saddle is near zero, so the merge order joins everything meaningful
before it joins anything spurious, and a global cut spends its entire
budget on grain.

So the granularity ladder does not currently work on a photograph, and the
region masks built on it inherit that. Recorded rather than worked around:
the next commits move local masking onto the model's instances, where the
edge treatment above is what makes a quarter-resolution mask usable.
This commit is contained in:
2026-08-22 08:39:17 +02:00
parent 12d320cf33
commit b1433ad4a9
4 changed files with 340 additions and 4 deletions
+93
View File
@@ -713,4 +713,97 @@ mod tests {
assert_eq!(a.region_count(), b.region_count());
assert_eq!(a.regions_at(0.5, 0.5), b.regions_at(0.5, 0.5));
}
/// Dump the granularity ladder for a real photograph, to look at.
///
/// Ignored, because it needs a file and writes several megabytes. It is
/// committed rather than kept in a scratch directory because "does the
/// ladder land on things a person means" is the question the whole spike
/// exists to answer (docs/segmentation.md §11 step 2), and it should be
/// answerable by anyone who picks this up rather than by whoever still has
/// the script.
///
/// ```sh
/// # A P6 PPM, which needs no decoder here:
/// # magick photo.jpg -colorspace sRGB photo.ppm
/// DARKROOM_TEST_PPM=photo.ppm DARKROOM_OUT=/tmp/ws \
/// cargo test -p dr-ui --release ladder_for_a_photograph -- --ignored --nocapture
/// ```
#[test]
#[ignore = "needs a photograph; run by hand when judging the segmentation"]
fn ladder_for_a_photograph() {
let Ok(path) = std::env::var("DARKROOM_TEST_PPM") else {
eprintln!("set DARKROOM_TEST_PPM to a P6 .ppm");
return;
};
let prefix = std::env::var("DARKROOM_OUT").unwrap_or_else(|_| "ladder".into());
let Some(ctx) = context() else {
eprintln!("no adapter");
return;
};
let (rgb8, w, h) = read_ppm(&path);
println!("image {w} × {h}");
let rgba: Vec<u8> = rgb8
.chunks_exact(3)
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
let source = dr_gpu::DemosaicedImage::from_rgba8(&ctx, &rgba, w as u32, h as u32)
.expect("upload");
let rgb: Vec<f32> = rgb8.iter().map(|&v| v as f32 / 255.0).collect();
for blur in [2, 5, 9] {
let mut options = Options {
semantic: false,
..Options::default()
};
options.segment.blur_radius = blur;
let t = std::time::Instant::now();
let mut seg = compute(&ctx, &source, &rgb, w, h, &options).expect("segmentation");
println!(
"blur {blur:>2} {} regions in {:.0} ms",
seg.region_count(),
t.elapsed().as_secs_f32() * 1000.0
);
for level in [1200, 400, 120, 40, 12] {
seg.set_level(level);
let (px, ow, oh) = seg.overlay_rgba();
let rgb: Vec<u8> = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
write_ppm(&format!("{prefix}-b{blur}-l{level}.ppm"), &rgb, ow, oh);
}
}
println!("wrote {prefix}-b*-l*.ppm");
}
fn read_ppm(path: &str) -> (Vec<u8>, usize, usize) {
let bytes = std::fs::read(path).expect("read ppm");
// P6\n<w> <h>\n<max>\n<binary>. Tokens are whitespace-separated, and
// comments are not handled because the writer above never emits them.
let mut fields = Vec::new();
let mut i = 0;
while fields.len() < 4 {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
fields.push(String::from_utf8_lossy(&bytes[start..i]).to_string());
}
assert_eq!(fields[0], "P6", "expected a binary PPM");
let w: usize = fields[1].parse().expect("width");
let h: usize = fields[2].parse().expect("height");
(bytes[i + 1..].to_vec(), w, h)
}
fn write_ppm(path: &str, rgb: &[u8], w: u32, h: u32) {
use std::io::Write as _;
let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create"));
write!(f, "P6\n{w} {h}\n255\n").expect("header");
f.write_all(rgb).expect("body");
}
}