Cutout: merge_diff 0 still merges identical-color faces

Making watershed's cutout native (merge_diff = 0) silently disabled the
same-color merge too: merge_similar early-returned on a non-positive
threshold, so two adjacent faces with the very same fill — e.g. after a
palette snap — kept a useless boundary between them. A threshold of 0 now
means 'merge only identical-color neighbours' (a boundary between two
same-colored faces is never useful); negative disables merging entirely.

The regression test snaps two boundary-sharing regions to the same
palette entry with a third region between them in stack order, so the
layer-level MergeAdjacent (consecutive runs only) cannot mask the mosaic
merge: 3 faces with the bug, 2 with the fix. The zero-threshold unit test
now asserts the new semantics, and negative-threshold identity.
This commit is contained in:
Chris Tsang
2026-07-27 15:29:55 +01:00
parent e2b47dab0f
commit 39cc49061e
5 changed files with 59 additions and 8 deletions
+2 -1
View File
@@ -20,7 +20,8 @@ pub enum Compositing {
fitter: Box<dyn SegmentFitter>,
/// Merge flattened neighbours whose colors are within this diff —
/// rejoins regions the stacked gradient layering had split. Usually
/// the clustering gradient step; `0` disables merging.
/// the clustering gradient step; `0` still merges identical-color
/// neighbours, negative disables merging entirely.
merge_diff: i32,
},
}
+2 -1
View File
@@ -297,7 +297,8 @@ impl Config {
// clustering itself considers colors within one gradient step
// to be the same region (`deepen_diff`). The watershed
// hierarchy already decided every merge, so its partition
// passes to the mosaic untouched.
// keeps its shape — only identical-color neighbours (e.g.
// after a palette snap) still collapse into one face.
merge_diff: match self.clustering {
Clustering::Watershed => 0,
_ => self.layer_difference,
+2 -1
View File
@@ -20,7 +20,8 @@ use super::{LabelMap, Segmentation};
/// `merge_diff` is the color-difference threshold for
/// [`LabelMap::merge_similar`]; pass the clustering `deepen_diff`
/// (gradient step) so the flattened mosaic rejoins what only the stacked
/// gradient layering had split. `0` disables merging.
/// gradient layering had split. `0` still merges identical-color
/// neighbours; negative disables merging entirely.
pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter, merge_diff: i32) -> VectorDoc {
let mut map = LabelMap::from_segmentation(seg);
map.merge_similar(merge_diff);
+18 -4
View File
@@ -100,9 +100,13 @@ impl LabelMap {
/// over the adjacency graph, most-similar pairs first, with each merged
/// region's color re-derived as the area-weighted mean so chains only
/// combine while they genuinely stay within `max_diff`.
///
/// `max_diff == 0` still merges *identical*-color neighbours — a boundary
/// between two same-colored faces is never useful. Pass a negative value
/// to disable merging entirely.
pub fn merge_similar(&mut self, max_diff: i32) {
let n = self.paints.len();
if max_diff <= 0 || n < 2 {
if max_diff < 0 || n < 2 {
return;
}
@@ -545,10 +549,20 @@ mod tests {
}
#[test]
fn merge_similar_zero_threshold_is_identity() {
let labels = vec![0, 1, 0, 1];
let mut map = gray_grid(2, 2, labels.clone(), &[100, 101]);
fn merge_similar_zero_threshold_merges_only_identical_colors() {
// Regions 0 and 1 share a color; region 2 differs by one level. At
// threshold 0 the identical pair merges, the near-identical one stays.
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 100, 101]);
map.merge_similar(0);
assert_eq!(map.paints.len(), 2, "identical neighbours merge at 0");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(1, 0), map.label(2, 0));
// A negative threshold disables merging entirely.
let labels = vec![0, 1, 0, 1];
let mut map = gray_grid(2, 2, labels.clone(), &[100, 100]);
map.merge_similar(-1);
assert_eq!(map.labels, labels);
assert_eq!(map.paints.len(), 2);
}
+35 -1
View File
@@ -2,7 +2,7 @@
//! absorption, and the hierarchy stack / cached re-cut behavior.
use vtracer::frontend::{Frontend, WatershedFrontend, WatershedHierarchy};
use vtracer::{ColorImage, Clustering, Config, Hierarchical, Segmentation, Session};
use vtracer::{Color, ColorImage, Clustering, Config, Hierarchical, Segmentation, Session};
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> (u8, u8, u8)) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
@@ -270,3 +270,37 @@ fn cutout_keeps_watershed_partition() {
"watershed partition must pass to the mosaic unmerged"
);
}
/// …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
/// edge between them. (The dark region sits between them in stack order, so
/// the layer-level `MergeAdjacent` cannot be the one doing the merging — only
/// the mosaic's same-color merge can.)
#[test]
fn cutout_merges_identical_palette_faces() {
let img = image(32, 32, |x, y| {
if y < 16 {
if x < 16 {
(200, 200, 200) // A: top-left
} else {
(20, 20, 20) // C: top-right
}
} else {
(180, 180, 180) // B: bottom, touches A
}
});
let cfg = Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: 255,
filter_speckle: 0,
palette: vec![Color::new(255, 255, 255), Color::new(0, 0, 0)],
..Config::default()
};
let doc = cfg.build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
2,
"A and B snap to the same palette color and share a boundary — one face"
);
}