Cutout: merge neighbouring mosaic regions within one gradient step
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled

The stacked hierarchy deliberately splits smooth areas into gradient layers
one deepen_diff apart — that is what makes stacking look continuous. When
cutout flattens those layers into a mosaic, the layering degenerates into
abutting faces with barely distinguishable fills that clustering would have
treated as one region.

Add LabelMap::merge_similar: agglomerative union-find over the flattened
adjacency graph using the clustering color metric (sum of per-channel
absolute diffs, merge when <= deepen_diff). Most-similar pairs union first
and each merged region's color is re-derived as the area-weighted mean, so
gradient chains only coalesce while they genuinely stay within the
threshold — no transitive collapse. compose_mosaic runs it between
flattening and boundary extraction; Compositing::Mosaic carries the
threshold and Config wires it to layer_difference (gradient step), so there
is no new knob.

On the gum-tree sample (poster preset, cutout) this drops 919 faces to 745
with no visible difference. Covered by unit tests for the merge semantics
(running means, OUTSIDE handling, zero threshold) plus a compose-level test
that gradient strips coalesce into one face; goldens and the stacked/cutout
equivalence suite are unaffected.
This commit is contained in:
Chris Tsang
2026-07-26 23:31:31 +01:00
parent f6c8a139a4
commit 2b0f316778
5 changed files with 270 additions and 10 deletions
+12 -4
View File
@@ -16,7 +16,13 @@ pub enum Compositing {
/// Independent per-region closed outlines, stacked bottom-to-top.
Stacked(Box<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic(Box<dyn SegmentFitter>),
Mosaic {
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.
merge_diff: i32,
},
}
impl Compositing {
@@ -24,7 +30,9 @@ impl Compositing {
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
Compositing::Mosaic(fitter) => compose_mosaic(seg, fitter.as_ref()),
Compositing::Mosaic { fitter, merge_diff } => {
compose_mosaic(seg, fitter.as_ref(), *merge_diff)
}
}
}
@@ -37,10 +45,10 @@ impl Compositing {
pub fn compose_with(&self, seg: &Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
match self {
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), ctx),
Compositing::Mosaic(fitter) => {
Compositing::Mosaic { fitter, merge_diff } => {
ctx.check()?;
ctx.report(Phase::Compose, 0.0);
let doc = compose_mosaic(seg, fitter.as_ref());
let doc = compose_mosaic(seg, fitter.as_ref(), *merge_diff);
ctx.check()?;
ctx.report(Phase::Compose, 1.0);
Ok(doc)
+7 -1
View File
@@ -274,7 +274,13 @@ impl Config {
pub fn build(&self) -> Result<Pipeline, Error> {
let compositing = match self.hierarchical {
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
Hierarchical::Cutout => Compositing::Mosaic(self.segment_fitter()),
Hierarchical::Cutout => Compositing::Mosaic {
fitter: self.segment_fitter(),
// Rejoin flattened neighbours the gradient layering split:
// clustering itself considers colors within one gradient step
// to be the same region (`deepen_diff`).
merge_diff: self.layer_difference,
},
};
Ok(Pipeline {
+10 -3
View File
@@ -14,9 +14,16 @@ use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
use super::graph::BoundaryGraph;
use super::{LabelMap, Segmentation};
/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose.
pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter) -> VectorDoc {
let map = LabelMap::from_segmentation(seg);
/// Run the full mosaic pipeline: flatten → merge similar neighbours →
/// boundary graph → faces → fit → compose.
///
/// `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.
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);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
+240
View File
@@ -87,6 +87,140 @@ impl LabelMap {
}
self.labels[y as usize * self.width as usize + x as usize]
}
/// Merge neighbouring regions whose colors are within `max_diff` of each
/// other (the metric is the clustering one: sum of per-channel absolute
/// differences, and clustering keeps neighbours together when
/// `diff <= deepen_diff`).
///
/// The stacked hierarchy deliberately splits a gradient into layers one
/// `deepen_diff` apart — that's what makes stacking smooth. Flattened into
/// a mosaic, that layering degenerates into abutting faces with barely
/// distinguishable fills. This pass undoes it: agglomerative union-find
/// 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`.
pub fn merge_similar(&mut self, max_diff: i32) {
let n = self.paints.len();
if max_diff <= 0 || n < 2 {
return;
}
// Area and summed color per region, for weighted mean colors.
let mut area = vec![0u64; n];
for &l in &self.labels {
if l != OUTSIDE {
area[l as usize] += 1;
}
}
let mut sum: Vec<[u64; 3]> = (0..n)
.map(|i| {
let c = self.paints[i].color();
[
c.r as u64 * area[i],
c.g as u64 * area[i],
c.b as u64 * area[i],
]
})
.collect();
// Adjacency pairs (right/down scan covers 4-connectivity once).
let (w, h) = (self.width as i32, self.height as i32);
let mut pairs: Vec<(RegionId, RegionId)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for y in 0..h {
for x in 0..w {
let a = self.label(x, y);
if a == OUTSIDE {
continue;
}
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
let b = self.label(nx, ny);
if b == OUTSIDE || b == a {
continue;
}
let key = (a.min(b), a.max(b));
if seen.insert(key) {
pairs.push(key);
}
}
}
}
let diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> i32 {
let mut d = 0i64;
for k in 0..3 {
d += ((sa[k] / aa.max(1)) as i64 - (sb[k] / ab.max(1)) as i64).abs();
}
d as i32
};
// Most-similar pairs first, so gradient chains coalesce around their
// closest links; ties break on ids for determinism.
pairs.sort_by_key(|&(a, b)| {
(
diff(&sum[a as usize], area[a as usize], &sum[b as usize], area[b as usize]),
a,
b,
)
});
let mut parent: Vec<RegionId> = (0..n as RegionId).collect();
fn find(parent: &mut [RegionId], mut i: RegionId) -> RegionId {
while parent[i as usize] != i {
parent[i as usize] = parent[parent[i as usize] as usize];
i = parent[i as usize];
}
i
}
// Colors move as regions absorb one another, so re-sweep the candidate
// pairs until nothing merges. Each union is O(α); the sweep count is
// tiny in practice (colors only ever move toward each other's mean).
loop {
let mut changed = false;
for &(a, b) in &pairs {
let ra = find(&mut parent, a);
let rb = find(&mut parent, b);
if ra == rb {
continue;
}
let (ia, ib) = (ra as usize, rb as usize);
if diff(&sum[ia], area[ia], &sum[ib], area[ib]) <= max_diff {
parent[ib] = ra;
for k in 0..3 {
sum[ia][k] += sum[ib][k];
}
area[ia] += area[ib];
changed = true;
}
}
if !changed {
break;
}
}
// Compact surviving roots into dense ids and rewrite labels + paints.
let mut remap: Vec<RegionId> = vec![OUTSIDE; n];
let mut paints: Vec<Paint> = Vec::new();
for l in &mut self.labels {
if *l == OUTSIDE {
continue;
}
let root = find(&mut parent, *l);
if remap[root as usize] == OUTSIDE {
remap[root as usize] = paints.len() as RegionId;
let (s, a) = (&sum[root as usize], area[root as usize].max(1));
paints.push(Paint::Solid(visioncortex::Color::new(
(s[0] / a) as u8,
(s[1] / a) as u8,
(s[2] / a) as u8,
)));
}
*l = remap[root as usize];
}
self.paints = paints;
}
}
#[cfg(test)]
@@ -347,6 +481,112 @@ mod tests {
assert!(checked > 0, "expected some open segments");
}
/// Build a label map with explicit per-region gray levels.
fn gray_grid(width: u32, height: u32, labels: Vec<RegionId>, grays: &[u8]) -> LabelMap {
LabelMap {
width,
height,
labels,
paints: grays
.iter()
.map(|&g| Paint::Solid(Color::new(g, g, g)))
.collect(),
}
}
#[test]
fn merge_similar_rejoins_close_neighbours() {
// Three vertical strips: 100 | 106 | 220. Diff(0,1) = 18 ≤ 20 → merge;
// the merged mean (103) vs 220 stays far apart.
#[rustfmt::skip]
let mut map = gray_grid(3, 2, vec![
0, 1, 2,
0, 1, 2,
], &[100, 106, 220]);
map.merge_similar(20);
assert_eq!(map.paints.len(), 2, "strips 0 and 1 merge; 2 survives");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(0, 0), map.label(2, 0));
// Area-weighted mean of two equal strips of 100 and 106.
assert_eq!(map.paints[map.label(0, 0) as usize].color().r, 103);
assert_pixel_roundtrip(&map);
}
#[test]
fn merge_similar_uses_running_means_not_original_colors() {
// Gradient chain 100 | 103 | 106 with threshold 9 (grays g apart diff
// by 3g across the three channels). The closest pair merges first
// (ties broken by id → strips 0,1 → mean 101); the merged region vs
// 106 is then 15 apart, over threshold — the chain must NOT collapse
// transitively into one region on the strength of the original colors.
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 103, 106]);
map.merge_similar(9);
assert_eq!(map.paints.len(), 2, "running mean stops the chain");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(1, 0), map.label(2, 0));
}
#[test]
fn merge_similar_ignores_outside_and_non_neighbours() {
// Two same-colored regions separated by OUTSIDE: not adjacent, so they
// must stay distinct faces (merging them would create a disjoint
// region, which face assembly handles, but the ids must stay honest to
// the partition).
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, OUTSIDE, 1], &[100, 100]);
map.merge_similar(20);
assert_eq!(map.paints.len(), 2, "non-adjacent regions never merge");
assert_eq!(map.label(1, 0), OUTSIDE, "outside pixels are untouched");
assert_pixel_roundtrip(&map);
}
#[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]);
map.merge_similar(0);
assert_eq!(map.labels, labels);
assert_eq!(map.paints.len(), 2);
}
#[test]
fn compose_mosaic_merges_gradient_faces() {
use super::compose_mosaic;
use super::fit::PixelSegmentFitter;
use crate::ir::{Layer, RegionMask, Segmentation};
use visioncortex::BinaryImage;
// A 6x2 canvas of three 2px strips, one gradient step apart (diff 6),
// as bottom-to-top layers — exactly what a stacked gradient flattens
// into. With merging they are one face; without, three.
let mut seg = Segmentation::new(6, 2);
for (i, g) in [(0, 100u8), (1, 102), (2, 104)] {
let mut image = BinaryImage::new_w_h(2, 2);
for y in 0..2 {
for x in 0..2 {
image.set_pixel(x, y, true);
}
}
seg.layers.push(Layer {
paint: Paint::Solid(Color::new(g, g, g)),
mask: RegionMask::new(
image,
visioncortex::PointI32 { x: i * 2, y: 0 },
),
});
}
let unmerged = compose_mosaic(&seg, &PixelSegmentFitter, 0);
let merged = compose_mosaic(&seg, &PixelSegmentFitter, 16);
assert_eq!(unmerged.shapes.len(), 3);
assert_eq!(merged.shapes.len(), 1, "gradient strips coalesce into one face");
assert_eq!(merged.shapes[0].paint.color().r, 102, "area-weighted mean");
}
#[test]
fn random_maps_roundtrip() {
// Deterministic LCG; connectivity not required.