mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-26 05:51:21 -07:00
Watershed: synthetic tests pinning the algorithm's semantics
Five new cases exercising behavior rather than plumbing: - diagonal_touch_does_not_connect — regions are 4-connected; same-colored squares meeting at a corner stay separate basins. - nested_regions — frame/ring/core flat zones come out as three exact regions, and the holed ring face survives the mosaic. - volume_extinction_prefers_vivid_over_large — the hierarchy's ranking attribute: a 9 px black dot (volume ~1150) outlives a 100 px barely-different patch (volume ~400) when cutting to two regions. - plateaus_with_ramps — the antialiased-boundary shape: three plateaus joined by short ramps cut to three regions whose means stay near the plateau values; ramps neither form regions nor drag the means. - degenerate_geometries — 1x1, 16x1, and 1x16 images segment correctly. assert_stack additionally bounds the layer count by the merge tree (at most 2K-1 layers).
This commit is contained in:
@@ -40,13 +40,15 @@ fn flatten(seg: &Segmentation) -> Vec<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The stacked-hierarchy invariants: the bottom layer is a solid full canvas
|
/// The stacked-hierarchy invariants: the bottom layer is a solid full canvas
|
||||||
/// (so overdraw is seam-free), every pixel is covered, and the flattened
|
/// (so overdraw is seam-free), every pixel is covered, the flattened
|
||||||
/// partition has exactly `regions` distinct labels.
|
/// partition has exactly `regions` distinct labels, and the stack size is
|
||||||
|
/// bounded by the merge tree (at most 2·regions − 1 layers).
|
||||||
fn assert_stack(seg: &Segmentation, regions: usize) {
|
fn assert_stack(seg: &Segmentation, regions: usize) {
|
||||||
let (w, h) = (seg.width as usize, seg.height as usize);
|
let (w, h) = (seg.width as usize, seg.height as usize);
|
||||||
let bottom = &seg.layers[0].mask;
|
let bottom = &seg.layers[0].mask;
|
||||||
assert_eq!((bottom.width(), bottom.height()), (w, h), "bottom layer is full-canvas");
|
assert_eq!((bottom.width(), bottom.height()), (w, h), "bottom layer is full-canvas");
|
||||||
assert_eq!(bottom.area(), w * h, "bottom layer is solid");
|
assert_eq!(bottom.area(), w * h, "bottom layer is solid");
|
||||||
|
assert!(seg.layers.len() <= 2 * regions.max(1) - 1, "stack bounded by the merge tree");
|
||||||
|
|
||||||
let labels = flatten(seg);
|
let labels = flatten(seg);
|
||||||
assert!(labels.iter().all(|&l| l != usize::MAX), "every pixel covered");
|
assert!(labels.iter().all(|&l| l != usize::MAX), "every pixel covered");
|
||||||
@@ -271,6 +273,187 @@ fn cutout_keeps_watershed_partition() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regions are 4-connected: two same-colored squares touching only at a
|
||||||
|
/// corner are separate basins (and so are the two squares of the other color).
|
||||||
|
#[test]
|
||||||
|
fn diagonal_touch_does_not_connect() {
|
||||||
|
let img = image(16, 16, |x, y| {
|
||||||
|
if (x / 8 + y / 8) % 2 == 0 {
|
||||||
|
(30, 30, 30)
|
||||||
|
} else {
|
||||||
|
(220, 220, 220)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let seg = WatershedFrontend {
|
||||||
|
detail: 255,
|
||||||
|
min_area: 0,
|
||||||
|
}
|
||||||
|
.segment(&img)
|
||||||
|
.unwrap();
|
||||||
|
let labels = flatten(&seg);
|
||||||
|
assert_eq!(regions(&seg), 4, "four quadrants, none diagonally joined");
|
||||||
|
assert_ne!(labels[2 * 16 + 2], labels[10 * 16 + 10], "dark squares separate");
|
||||||
|
assert_ne!(labels[2 * 16 + 10], labels[10 * 16 + 2], "light squares separate");
|
||||||
|
assert_stack(&seg, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nested flat zones — a frame around a ring around a core — come out as
|
||||||
|
/// three exact regions, and the ring face (which has a hole) survives both
|
||||||
|
/// compositors.
|
||||||
|
#[test]
|
||||||
|
fn nested_regions() {
|
||||||
|
// Background frame 230, square ring 40 (4..28 minus 10..22), core 130.
|
||||||
|
let img = image(32, 32, |x, y| {
|
||||||
|
let ring = (4..28).contains(&x) && (4..28).contains(&y);
|
||||||
|
let core = (10..22).contains(&x) && (10..22).contains(&y);
|
||||||
|
if core {
|
||||||
|
(130, 130, 130)
|
||||||
|
} else if ring {
|
||||||
|
(40, 40, 40)
|
||||||
|
} else {
|
||||||
|
(230, 230, 230)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let seg = WatershedFrontend {
|
||||||
|
detail: 255,
|
||||||
|
min_area: 0,
|
||||||
|
}
|
||||||
|
.segment(&img)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(regions(&seg), 3, "frame + ring + core");
|
||||||
|
let labels = flatten(&seg);
|
||||||
|
let at = |x: usize, y: usize| labels[y * 32 + x];
|
||||||
|
assert_ne!(at(1, 1), at(6, 6), "frame vs ring");
|
||||||
|
assert_ne!(at(6, 6), at(16, 16), "ring vs core");
|
||||||
|
assert_ne!(at(1, 1), at(16, 16), "frame vs core");
|
||||||
|
assert_stack(&seg, 3);
|
||||||
|
|
||||||
|
// The same nesting through the mosaic: three faces, ring with a hole.
|
||||||
|
let cfg = Config {
|
||||||
|
clustering: Clustering::Watershed,
|
||||||
|
hierarchical: Hierarchical::Cutout,
|
||||||
|
watershed_detail: 255,
|
||||||
|
filter_speckle: 0,
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
let doc = cfg.build().unwrap().run(&img).unwrap();
|
||||||
|
assert_eq!(doc.shapes.len(), 3, "nested faces survive the mosaic");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume extinction, the hierarchy's ranking attribute: a small but vivid
|
||||||
|
/// basin (large color rise) outlives a bigger but faint one. Cutting to two
|
||||||
|
/// regions must keep the black dot, not the barely-different patch.
|
||||||
|
#[test]
|
||||||
|
fn volume_extinction_prefers_vivid_over_large() {
|
||||||
|
let img = image(48, 32, |x, y| {
|
||||||
|
if (4..7).contains(&x) && (4..7).contains(&y) {
|
||||||
|
(0, 0, 0) // 9 px, rise ~128: volume ≈ 1150
|
||||||
|
} else if (20..30).contains(&x) && (10..20).contains(&y) {
|
||||||
|
(132, 132, 132) // 100 px, rise 4: volume ≈ 400
|
||||||
|
} else {
|
||||||
|
(128, 128, 128)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let seg = WatershedFrontend {
|
||||||
|
detail: 26, // target = 2 regions
|
||||||
|
min_area: 0,
|
||||||
|
}
|
||||||
|
.segment(&img)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(regions(&seg), 2);
|
||||||
|
let labels = flatten(&seg);
|
||||||
|
// The surviving split isolates the dot: its 9 pixels share a label that
|
||||||
|
// appears nowhere else.
|
||||||
|
let dot = labels[5 * 48 + 5];
|
||||||
|
let dot_area = labels.iter().filter(|&&l| l == dot).count();
|
||||||
|
assert_eq!(dot_area, 9, "the vivid dot is the kept region");
|
||||||
|
assert_eq!(
|
||||||
|
labels[15 * 48 + 25],
|
||||||
|
labels[0],
|
||||||
|
"the faint patch merged into the background"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plateaus joined by short ramps — the antialiased-boundary shape. Cutting to
|
||||||
|
/// three regions recovers the plateaus, with each region's mean close to its
|
||||||
|
/// plateau value (ramp pixels split between the sides they descend from).
|
||||||
|
#[test]
|
||||||
|
fn plateaus_with_ramps() {
|
||||||
|
// Columns: 40 ×20 | ramp ×2 | 128 ×20 | ramp ×2 | 216 ×20.
|
||||||
|
let level = |x: usize| -> u8 {
|
||||||
|
match x {
|
||||||
|
0..=19 => 40,
|
||||||
|
20 => 69,
|
||||||
|
21 => 99,
|
||||||
|
22..=41 => 128,
|
||||||
|
42 => 157,
|
||||||
|
43 => 187,
|
||||||
|
_ => 216,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let img = image(64, 16, |x, _| {
|
||||||
|
let v = level(x);
|
||||||
|
(v, v, v)
|
||||||
|
});
|
||||||
|
let seg = WatershedFrontend {
|
||||||
|
detail: 40, // target = 3 regions
|
||||||
|
min_area: 4,
|
||||||
|
}
|
||||||
|
.segment(&img)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(regions(&seg), 3);
|
||||||
|
// Means sit near the plateau values — the ramps don't form regions of
|
||||||
|
// their own or drag a mean far off.
|
||||||
|
let mut means: Vec<u8> = seg
|
||||||
|
.layers
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(3)
|
||||||
|
.map(|l| l.paint.color().r)
|
||||||
|
.collect();
|
||||||
|
means.sort_unstable();
|
||||||
|
for (mean, plateau) in means.iter().zip([40u8, 128, 216]) {
|
||||||
|
assert!(
|
||||||
|
mean.abs_diff(plateau) <= 20,
|
||||||
|
"region mean {mean} strays from plateau {plateau}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Degenerate geometries: single pixel, single row, single column.
|
||||||
|
#[test]
|
||||||
|
fn degenerate_geometries() {
|
||||||
|
let one = image(1, 1, |_, _| (7, 8, 9));
|
||||||
|
let seg = WatershedFrontend {
|
||||||
|
detail: 128,
|
||||||
|
min_area: 0,
|
||||||
|
}
|
||||||
|
.segment(&one)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seg.layers.len(), 1);
|
||||||
|
assert_stack(&seg, 1);
|
||||||
|
|
||||||
|
let row = image(16, 1, |x, _| if x < 8 { (0, 0, 0) } else { (255, 255, 255) });
|
||||||
|
let seg = WatershedFrontend {
|
||||||
|
detail: 128,
|
||||||
|
min_area: 0,
|
||||||
|
}
|
||||||
|
.segment(&row)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(regions(&seg), 2, "single row splits");
|
||||||
|
assert_stack(&seg, 2);
|
||||||
|
|
||||||
|
let col = image(1, 16, |_, y| if y < 8 { (0, 0, 0) } else { (255, 255, 255) });
|
||||||
|
let seg = WatershedFrontend {
|
||||||
|
detail: 128,
|
||||||
|
min_area: 0,
|
||||||
|
}
|
||||||
|
.segment(&col)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(regions(&seg), 2, "single column splits");
|
||||||
|
assert_stack(&seg, 2);
|
||||||
|
}
|
||||||
|
|
||||||
/// …but identical-color neighbours still collapse into one face: regions that
|
/// …but identical-color neighbours still collapse into one face: regions that
|
||||||
/// snap to the same palette entry and share a boundary must not keep a useless
|
/// snap to the same palette entry and share a boundary must not keep a useless
|
||||||
/// edge between them. (The dark region sits between them in stack order, so
|
/// edge between them. (The dark region sits between them in stack order, so
|
||||||
|
|||||||
Reference in New Issue
Block a user