Union same-paint layers in one pass, not pairwise

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.
This commit is contained in:
Chris Tsang
2026-07-25 19:44:43 +01:00
parent 84ba49f0b9
commit 5ff4f3ae05
2 changed files with 58 additions and 17 deletions
+25 -7
View File
@@ -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<Layer>, out: &mut Vec<Layer>) {
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<Layer> = Vec::with_capacity(seg.layers.len());
let mut run: Vec<Layer> = 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;
}
}
+33 -10
View File
@@ -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);
}
}
}