From 39cc49061e8a1228a24802bce448ff260bee8dbd Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Mon, 27 Jul 2026 15:29:55 +0100 Subject: [PATCH] Cutout: merge_diff 0 still merges identical-color faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/vtracer/src/compose/mod.rs | 3 ++- crates/vtracer/src/config.rs | 3 ++- crates/vtracer/src/mosaic/compose.rs | 3 ++- crates/vtracer/src/mosaic/mod.rs | 22 +++++++++++++---- crates/vtracer/tests/watershed.rs | 36 +++++++++++++++++++++++++++- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/vtracer/src/compose/mod.rs b/crates/vtracer/src/compose/mod.rs index 4943647..d78dff6 100644 --- a/crates/vtracer/src/compose/mod.rs +++ b/crates/vtracer/src/compose/mod.rs @@ -20,7 +20,8 @@ pub enum Compositing { fitter: Box, /// 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, }, } diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index dee7984..0966423 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -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, diff --git a/crates/vtracer/src/mosaic/compose.rs b/crates/vtracer/src/mosaic/compose.rs index 2d77db6..cb4ee1e 100644 --- a/crates/vtracer/src/mosaic/compose.rs +++ b/crates/vtracer/src/mosaic/compose.rs @@ -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); diff --git a/crates/vtracer/src/mosaic/mod.rs b/crates/vtracer/src/mosaic/mod.rs index 177a061..b44b89c 100644 --- a/crates/vtracer/src/mosaic/mod.rs +++ b/crates/vtracer/src/mosaic/mod.rs @@ -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); } diff --git a/crates/vtracer/tests/watershed.rs b/crates/vtracer/tests/watershed.rs index 5ce6b60..8e25a18 100644 --- a/crates/vtracer/tests/watershed.rs +++ b/crates/vtracer/tests/watershed.rs @@ -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" + ); +}