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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user