From 5ff4f3ae053f9b2a30f35ba3a27c671eff4c7922 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sat, 25 Jul 2026 19:44:43 +0100 Subject: [PATCH] Union same-paint layers in one pass, not pairwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MergeAdjacent folded RegionMask::union over each run of same-paint layers, and every union allocates a mask over the combined bounding box and copies both inputs into it. The accumulator reaches full-canvas size after the first few merges, so each remaining layer reallocated and rewrote the whole canvas — O(n * width * height) for a run of n layers. A single palette color is the worst case, since every layer then shares a paint and the entire stack folds into one accumulator: 474 layers at 1400x775 spent ~0.8s of the 1.17s conversion there. --max-colors escaped it only because runs of identical consecutive paints stay short. RegionMask::union_all sizes the destination from the combined bounding box in one cheap pass, then blits each source exactly once; union delegates to it with two elements. MergeAdjacent groups a run and unions it as a whole. Gum tree with a one-color palette goes 1.17s -> 0.31s (now under the no-palette baseline, since one merged layer leaves less geometry to fit), with byte-identical output. Only the --palette and --max-colors paths construct MergeAdjacent, so the default pipeline is untouched. --- crates/vtracer/src/colorfit/merge.rs | 32 ++++++++++++++++----- crates/vtracer/src/ir/region.rs | 43 +++++++++++++++++++++------- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/vtracer/src/colorfit/merge.rs b/crates/vtracer/src/colorfit/merge.rs index 5ad734b..1f17beb 100644 --- a/crates/vtracer/src/colorfit/merge.rs +++ b/crates/vtracer/src/colorfit/merge.rs @@ -1,4 +1,4 @@ -use crate::ir::{Layer, Segmentation}; +use crate::ir::{Layer, RegionMask, Segmentation}; use super::ColorFitter; @@ -8,21 +8,39 @@ use super::ColorFitter; #[derive(Debug, Clone, Default)] pub struct MergeAdjacent; +/// Collapse one run of same-paint layers and push the result. +/// +/// The whole run is unioned in a single pass — folding pairwise would reallocate +/// and rewrite a canvas-sized accumulator once per layer. See +/// [`RegionMask::union_all`]. +fn flush(run: &mut Vec, out: &mut Vec) { + match run.len() { + 0 => {} + 1 => out.push(run.pop().expect("run is non-empty")), + _ => { + let paint = run[0].paint; + let masks: Vec<&RegionMask> = run.iter().map(|l| &l.mask).collect(); + let mask = RegionMask::union_all(&masks); + out.push(Layer { paint, mask }); + run.clear(); + } + } +} + impl ColorFitter for MergeAdjacent { fn fit(&self, seg: &mut Segmentation) { if seg.layers.len() < 2 { return; } let mut merged: Vec = Vec::with_capacity(seg.layers.len()); + let mut run: Vec = Vec::new(); for layer in seg.layers.drain(..) { - if let Some(last) = merged.last_mut() { - if last.paint == layer.paint { - last.mask = last.mask.union(&layer.mask); - continue; - } + if run.first().is_some_and(|first| first.paint != layer.paint) { + flush(&mut run, &mut merged); } - merged.push(layer); + run.push(layer); } + flush(&mut run, &mut merged); seg.layers = merged; } } diff --git a/crates/vtracer/src/ir/region.rs b/crates/vtracer/src/ir/region.rs index 1373c95..77ac9a4 100644 --- a/crates/vtracer/src/ir/region.rs +++ b/crates/vtracer/src/ir/region.rs @@ -43,24 +43,47 @@ impl RegionMask { /// Combine two masks into one covering the union of their bounding boxes. /// Foreground is the OR of both; this is used by the layer-merge step. pub fn union(&self, other: &RegionMask) -> RegionMask { - let left = self.offset.x.min(other.offset.x); - let top = self.offset.y.min(other.offset.y); - let right = (self.offset.x + self.image.width as i32) - .max(other.offset.x + other.image.width as i32); - let bottom = (self.offset.y + self.image.height as i32) - .max(other.offset.y + other.image.height as i32); + Self::union_all(&[self, other]) + } + + /// Union any number of masks in one pass: size the destination from the + /// combined bounding box, then blit each source into it exactly once. + /// + /// Folding [`union`](Self::union) instead costs one full-size allocation and + /// rewrite of the accumulator *per input*. That is quadratic in the canvas + /// area, and it bites precisely when a palette snap leaves a long run of + /// same-paint layers for [`MergeAdjacent`](crate::colorfit::MergeAdjacent): + /// the accumulator grows to the full canvas after the first few merges, so + /// every remaining layer copies the entire canvas again. + /// + /// An empty input yields an empty mask at the origin. + pub fn union_all(masks: &[&RegionMask]) -> RegionMask { + let Some((first, rest)) = masks.split_first() else { + return RegionMask::new(BinaryImage::new_w_h(0, 0), PointI32 { x: 0, y: 0 }); + }; + + let mut left = first.offset.x; + let mut top = first.offset.y; + let mut right = first.offset.x + first.image.width as i32; + let mut bottom = first.offset.y + first.image.height as i32; + for m in rest { + left = left.min(m.offset.x); + top = top.min(m.offset.y); + right = right.max(m.offset.x + m.image.width as i32); + bottom = bottom.max(m.offset.y + m.image.height as i32); + } let width = (right - left) as usize; let height = (bottom - top) as usize; let mut image = BinaryImage::new_w_h(width, height); - for src in [self, other] { + for src in masks { + let dx = (src.offset.x - left) as usize; + let dy = (src.offset.y - top) as usize; for y in 0..src.image.height { for x in 0..src.image.width { if src.image.get_pixel(x, y) { - let gx = (src.offset.x + x as i32 - left) as usize; - let gy = (src.offset.y + y as i32 - top) as usize; - image.set_pixel(gx, gy, true); + image.set_pixel(x + dx, y + dy, true); } } }