From b049e45b88b9b7711ed0019d0107a40210578db0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 00:21:35 -0700 Subject: [PATCH] Image Trace: Auto Grouping looks up containing paths in a grid instead of testing every pair Auto Grouping compared the box of every traced path with the box of every path that can hold something, and with Overlapping it also compared each grouped path with every path below it. On a 1400 x 1400 px noise image traced with Limited 8, Overlapping (18,653 paths), parents() made 442,998,298 box tests and took 3.0 s in a release build. parents() now keeps the holders and, for stacked traces, the paths below in a grid of about one cell per path. A path's candidates are the holders in the cell of its box center, smallest first, and the paths below it come from the cells its box reaches. When a box reaches more cells or entries than there are paths below it, the old scan is used. The cells grow larger when the boxes would otherwise be listed in more than 32 cells each on average. The same trace now takes 142,289 box tests and 0.08 s, and every parent is the same as before. The 20,000-path limit is unchanged. The tests compare parents() with the pairwise version on rings, faces and noise traced Abutting and Overlapping, check that a dense trace takes at most 64 box tests per path while the pairwise version takes more, check that 300 nested discs take no more box tests than the pairwise version, and check that boxes covering the whole image make the cells larger. --- crates/trace/src/group.rs | 300 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 288 insertions(+), 12 deletions(-) diff --git a/crates/trace/src/group.rs b/crates/trace/src/group.rs index 50b165f1a..b51ae5790 100644 --- a/crates/trace/src/group.rs +++ b/crates/trace/src/group.rs @@ -84,7 +84,7 @@ const MAX_GROUPED_PATHS: usize = 20_000; /// `paths` paints in its own order: where the paths can overlap (`stacked`), a path only gets a parent below it, and only /// if no path that would then come after it instead of before it shares any of its box. pub(crate) fn parents(paths: &[TracedPath], w: usize, h: usize, stacked: bool) -> Vec> { - // Each path is tested against the others (quadratic): past this many, they stay ungrouped. + // Deeply nested paths are still tested against each other (quadratic): past this many, they stay ungrouped. if paths.len() > MAX_GROUPED_PATHS { return vec![None; paths.len()]; } @@ -97,27 +97,60 @@ pub(crate) fn parents(paths: &[TracedPath], w: usize, h: usize, stacked: bool) - let mut by_area: Vec = (0..paths.len()).filter(|&p| outers.get(p).and_then(Option::as_ref).is_some_and(|o| o.holds)).collect(); let area = |p: &usize| outers.get(*p).and_then(Option::as_ref).map_or(0.0, |o| o.bbox.area()); by_area.sort_by(|a, b| area(a).total_cmp(&area(b))); + // The paths something can lie inside, smallest first, with their boxes and NEAR around them. + // A box that holds another holds its center. The paper holds nothing. + let holder_boxes: Vec<(usize, Rect)> = by_area + .iter() + .filter_map(|&p| { + let o = outers.get(p)?.as_ref()?.bbox; + let covers = o.x0 <= paper.x0 && o.y0 <= paper.y0 && o.x1 >= paper.x1 && o.y1 >= paper.y1; + (!covers).then(|| (p, o.inflate(NEAR, NEAR))) + }) + .collect(); + // Where paths can overlap, every path with its box, bottom-most first. + let below_boxes: Vec<(usize, Rect)> = + if stacked { outers.iter().enumerate().filter_map(|(y, oy)| Some((y, oy.as_ref()?.bbox))).collect() } else { vec![] }; + let grid = Grid::new(w, h, holder_boxes.iter().chain(&below_boxes).map(|&(_, r)| r)); + // For each cell, the paths in those lists whose box reaches it, in the same order. + let holders_at = grid.lists(&holder_boxes); + let below_at = grid.lists(&below_boxes); for (x, ox) in outers.iter().enumerate() { let Some(ox) = ox else { continue }; // The paths whose box holds its own, smallest first. - let mut holders = by_area.iter().filter_map(|&p| Some((p, outers.get(p)?.as_ref()?))).filter(|&(p, op)| { + let candidates = holders_at.get(grid.cell(ox.bbox.center())).into_iter().flatten(); + let mut holders = candidates.filter_map(|&p| Some((p, outers.get(p)?.as_ref()?))).filter(|&(p, op)| { + #[cfg(test)] + BOX_TESTS.with(|n| n.set(n.get() + 1)); let (o, i) = (op.bbox, ox.bbox); - o.x0 - NEAR <= i.x0 - && o.y0 - NEAR <= i.y0 - && o.x1 + NEAR >= i.x1 - && o.y1 + NEAR >= i.y1 - && o.area() > i.area() - && !(stacked && p > x) - && !(o.x0 <= paper.x0 && o.y0 <= paper.y0 && o.x1 >= paper.x1 && o.y1 >= paper.y1) + o.x0 - NEAR <= i.x0 && o.y0 - NEAR <= i.y0 && o.x1 + NEAR >= i.x1 && o.y1 + NEAR >= i.y1 && o.area() > i.area() && !(stacked && p > x) }); // The paths under `x` that share some of its box: it may not come to lie under any of them. // (Found only once some path holds its first point: most paths are inside none.) let under = std::cell::OnceCell::new(); let under = || { under.get_or_init(|| { - (0..x) - .filter(|&y| outers.get(y).and_then(Option::as_ref).is_some_and(|oy| oy.bbox.intersect(ox.bbox).area() > 0.0)) - .collect::>() + let overlap = |y: usize| { + #[cfg(test)] + BOX_TESTS.with(|n| n.set(n.get() + 1)); + outers.get(y).and_then(Option::as_ref).map(|oy| oy.bbox.intersect(ox.bbox)).filter(|r| r.area() > 0.0) + }; + // The paths below `x` in each cell its box reaches, unless that is more paths to + // look at than there are below it. + let mut lists = vec![]; + let mut count = 0; + let few = grid.reach(ox.bbox) <= x + && grid.cells(ox.bbox).all(|c| { + let list = below_at.get(c).map_or(&[][..], |list| list.get(..list.partition_point(|&y| y < x)).unwrap_or_default()); + count += list.len(); + lists.push((c, list)); + count <= x + }); + if !few { + return (0..x).filter(|&y| overlap(y).is_some()).collect::>(); + } + // A path in several of the cells is taken from the one its overlap with `x` starts in. + let from = |c: usize, y: usize| overlap(y).is_some_and(|r| grid.cell(r.origin()) == c); + lists.into_iter().flat_map(|(c, list)| list.iter().copied().filter(move |&y| from(c, y))).collect() }) }; let found = holders.find(|&(p, op)| { @@ -136,6 +169,69 @@ pub(crate) fn parents(paths: &[TracedPath], w: usize, h: usize, stacked: bool) - parent } +/// The image cut into square cells, to find the paths whose boxes reach a place without comparing +/// every pair of paths. Places outside the image belong to its edge cells. +struct Grid { + size: f64, + cols: usize, + rows: usize, +} + +impl Grid { + /// How many cells each box may be listed in on average. + const BUDGET: usize = 32; + + /// About one cell for each of `boxes`, with larger cells where the boxes would otherwise be + /// listed in more than [`Grid::BUDGET`] cells each on average. + fn new(w: usize, h: usize, boxes: impl Iterator + Clone) -> Self { + let n = boxes.clone().count(); + let mut size = (w as f64 * h as f64 / n.clamp(1, 1 << 20) as f64).sqrt().max(1.0); + loop { + let count = |len: usize| ((len as f64 / size).ceil() as usize).max(1); + let grid = Self { size, cols: count(w), rows: count(h) }; + let listed = boxes.clone().fold(0usize, |sum, r| sum.saturating_add(grid.reach(r))); + if listed <= Self::BUDGET.saturating_mul(n) || (grid.cols == 1 && grid.rows == 1) { + return grid; + } + size *= 2.0; + } + } + + /// How many cells `r` reaches. + fn reach(&self, r: Rect) -> usize { + let span = |a: f64, b: f64, count: usize| self.index(b, count).saturating_sub(self.index(a, count)) + 1; + span(r.x0, r.x1, self.cols).saturating_mul(span(r.y0, r.y1, self.rows)) + } + + /// The column (or row, of `count`) that `v` falls in. + fn index(&self, v: f64, count: usize) -> usize { + (v / self.size).floor().clamp(0.0, count.saturating_sub(1) as f64) as usize + } + + fn cell(&self, p: Point) -> usize { + self.index(p.y, self.rows) * self.cols + self.index(p.x, self.cols) + } + + /// The cells `r` reaches. + fn cells(&self, r: Rect) -> impl Iterator { + let (cols, (c0, c1)) = (self.cols, (self.index(r.x0, self.cols), self.index(r.x1, self.cols))); + (self.index(r.y0, self.rows)..=self.index(r.y1, self.rows)).flat_map(move |row| (c0..=c1).map(move |col| row * cols + col)) + } + + /// For each cell, the `items` whose box reaches it, in the order they come in. + fn lists(&self, items: &[(usize, Rect)]) -> Vec> { + let mut lists = vec![vec![]; self.cols * self.rows]; + for &(i, r) in items { + for c in self.cells(r) { + if let Some(list) = lists.get_mut(c) { + list.push(i); + } + } + } + lists + } +} + /// Whether `y` is drawn after `p` and all that is inside `p`: it is in another branch of the /// nesting, and that branch starts above the one `p` is in. fn comes_after(parent: &[Option], depth: &[usize], y: usize, p: usize) -> bool { @@ -158,6 +254,12 @@ fn comes_after(parent: &[Option], depth: &[usize], y: usize, p: usize) -> a > b } +#[cfg(test)] +thread_local! { + /// How many times [`parents`] has compared two paths' boxes on this thread. + static BOX_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { use super::*; @@ -192,4 +294,178 @@ mod tests { // Where paths cannot overlap, either order paints the same. assert_eq!(parents(&paths, 100, 100, false), [None, None, Some(0)]); } + + /// Smooth noise: a value per 3 px lattice point, blended between them. + fn noise(w: u32, h: u32) -> crate::Raster { + let at = |x: u32, y: u32| { + let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77); + v ^= v >> 15; + v = v.wrapping_mul(0x2C1B_3C6D); + v ^= v >> 12; + f64::from(v & 0xFF) + }; + crate::Raster::from_fn(w, h, |x, y| { + let (gx, gy) = (x / 3, y / 3); + let (fx, fy) = (f64::from(x % 3) / 3.0, f64::from(y % 3) / 3.0); + let top = at(gx, gy) * (1.0 - fx) + at(gx + 1, gy) * fx; + let bottom = at(gx, gy + 1) * (1.0 - fx) + at(gx + 1, gy + 1) * fx; + let v = (top * (1.0 - fy) + bottom * fy).round() as u8; + [v, v, v, 255] + }) + } + + /// Rings inside rings: three colors in 5 px bands around four centers. + fn rings() -> crate::Raster { + const COLORS: [[u8; 4]; 3] = [[20, 20, 20, 255], [200, 40, 40, 255], [250, 250, 250, 255]]; + crate::Raster::from_fn(160, 160, |x, y| { + let (cx, cy) = (if x < 80 { 40.0 } else { 120.0 }, if y < 80 { 40.0 } else { 120.0 }); + let d = Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5).distance(Point::new(cx, cy)); + COLORS.get((d / 5.0) as usize % 3).copied().unwrap_or_default() + }) + } + + /// Nine faces on white: a yellow disc with two dark eyes and a dark mouth. + fn faces() -> crate::Raster { + crate::Raster::from_fn(150, 150, |x, y| { + let (px, py) = (f64::from(x % 50) + 0.5, f64::from(y % 50) + 0.5); + let at = |cx: f64, cy: f64| Point::new(px, py).distance(Point::new(cx, cy)); + if at(18.0, 20.0) <= 3.0 || at(32.0, 20.0) <= 3.0 || ((17.0..33.0).contains(&px) && (31.0..35.0).contains(&py)) { + [30, 30, 30, 255] + } else if at(25.0, 25.0) <= 20.0 { + [240, 200, 40, 255] + } else { + [255, 255, 255, 255] + } + }) + } + + fn traced(img: &crate::Raster, method: crate::Method) -> Vec { + let params = crate::TraceParams { + mode: crate::Mode::Color, + palette: crate::Palette::Limited, + colors: 8, + paths: 95.0, + corners: 50.0, + noise: 1, + method, + ..crate::TraceParams::default() + }; + crate::trace(img, ¶ms).paths + } + + /// [`parents`] comparing the box of every path with every other's. + fn parents_by_pairs(paths: &[TracedPath], w: usize, h: usize, stacked: bool) -> Vec> { + let outers: Vec> = paths.iter().map(outer).collect(); + let paper = Rect::new(1.0, 1.0, w as f64 - 1.0, h as f64 - 1.0); + let mut parent: Vec> = vec![None; paths.len()]; + let mut depth = vec![0usize; paths.len()]; + let mut by_area: Vec = (0..paths.len()).filter(|&p| outers.get(p).and_then(Option::as_ref).is_some_and(|o| o.holds)).collect(); + let area = |p: &usize| outers.get(*p).and_then(Option::as_ref).map_or(0.0, |o| o.bbox.area()); + by_area.sort_by(|a, b| area(a).total_cmp(&area(b))); + for (x, ox) in outers.iter().enumerate() { + let Some(ox) = ox else { continue }; + let mut holders = by_area.iter().filter_map(|&p| Some((p, outers.get(p)?.as_ref()?))).filter(|&(p, op)| { + BOX_TESTS.with(|n| n.set(n.get() + 1)); + let (o, i) = (op.bbox, ox.bbox); + o.x0 - NEAR <= i.x0 + && o.y0 - NEAR <= i.y0 + && o.x1 + NEAR >= i.x1 + && o.y1 + NEAR >= i.y1 + && o.area() > i.area() + && !(stacked && p > x) + && !(o.x0 <= paper.x0 && o.y0 <= paper.y0 && o.x1 >= paper.x1 && o.y1 >= paper.y1) + }); + let under = std::cell::OnceCell::new(); + let under = || { + under.get_or_init(|| { + (0..x) + .filter(|&y| { + BOX_TESTS.with(|n| n.set(n.get() + 1)); + outers.get(y).and_then(Option::as_ref).is_some_and(|oy| oy.bbox.intersect(ox.bbox).area() > 0.0) + }) + .collect::>() + }) + }; + let found = holders.find(|&(p, op)| { + ox.pts.first().is_some_and(|pt| inside_or_near(*pt, op)) + && (!stacked || under().iter().all(|&y| !comes_after(&parent, &depth, y, p))) + && ox.pts.iter().all(|pt| inside_or_near(*pt, op)) + }); + if let Some((p, _)) = found { + parent[x] = Some(p); + depth[x] = depth[p] + 1; + } + } + parent + } + + #[test] + fn the_grid_finds_the_parents_that_comparing_every_pair_finds() { + let mut grouped = 0; + for img in [rings(), faces(), noise(50, 50)] { + for method in [crate::Method::Abutting, crate::Method::Overlapping] { + let paths = traced(&img, method); + // The image's own size, and sizes that leave paths outside it. + for (w, h) in [(img.width as usize, img.height as usize), (40, 90), (0, 0)] { + for stacked in [false, true] { + let want = parents_by_pairs(&paths, w, h, stacked); + assert_eq!(parents(&paths, w, h, stacked), want, "{method:?} {w} x {h}, stacked {stacked}"); + grouped += want.iter().filter(|p| p.is_some()).count(); + } + } + } + } + assert!(grouped > 1000, "{grouped}"); + } + + #[test] + fn a_dense_trace_is_grouped_with_a_few_box_tests_per_path() { + let img = noise(120, 120); + for (method, stacked) in [(crate::Method::Abutting, false), (crate::Method::Overlapping, true)] { + let paths = traced(&img, method); + let n = paths.len(); + let counted = |by_pairs: bool| { + BOX_TESTS.with(|n| n.set(0)); + let found = if by_pairs { parents_by_pairs(&paths, 120, 120, stacked) } else { parents(&paths, 120, 120, stacked) }; + (found, BOX_TESTS.with(std::cell::Cell::get)) + }; + let (found, tests) = counted(false); + let (want, pairs) = counted(true); + assert!(n > 500, "{method:?}: {n} paths"); + assert_eq!(found, want, "{method:?}"); + assert!(tests <= 64 * n, "{method:?}: {tests} box tests for {n} paths"); + assert!(pairs > 64 * n, "{method:?}: {pairs} box tests by pairs for {n} paths"); + } + } + + #[test] + fn nested_paths_take_no_more_box_tests_than_comparing_every_pair() { + // 300 discs on one center, each a little smaller than the one below it: every box + // overlaps every other and reaches most of the grid. + let paths: Vec = (0..300).map(|i| disc((100.0, 100.0), 95.0 - f64::from(i) * 0.3, 0)).collect(); + for stacked in [false, true] { + BOX_TESTS.with(|n| n.set(0)); + let found = parents(&paths, 200, 200, stacked); + let tests = BOX_TESTS.with(std::cell::Cell::get); + BOX_TESTS.with(|n| n.set(0)); + let want = parents_by_pairs(&paths, 200, 200, stacked); + let pairs = BOX_TESTS.with(std::cell::Cell::get); + assert_eq!(found, want, "stacked {stacked}"); + assert!(want.iter().skip(1).all(Option::is_some), "stacked {stacked}: {want:?}"); + assert!(tests <= pairs, "stacked {stacked}: {tests} box tests, {pairs} by pairs"); + } + } + + #[test] + fn boxes_that_reach_everywhere_make_the_cells_larger() { + let boxes: Vec<(usize, Rect)> = (0..1000).map(|i| (i, Rect::new(0.0, 0.0, 1000.0, 1000.0))).collect(); + let grid = Grid::new(1000, 1000, boxes.iter().map(|&(_, r)| r)); + let listed: usize = grid.lists(&boxes).iter().map(Vec::len).sum(); + assert!(listed <= Grid::BUDGET * boxes.len(), "{listed}"); + // Small boxes keep about one cell each. + let boxes: Vec<(usize, Rect)> = + (0..1000).map(|i| (i, Rect::new(0.0, 0.0, 2.0, 2.0) + kurbo::Vec2::new((i % 40) as f64 * 25.0, (i / 40) as f64 * 40.0))).collect(); + let grid = Grid::new(1000, 1000, boxes.iter().map(|&(_, r)| r)); + assert!(grid.cols * grid.rows >= 900, "{} x {}", grid.cols, grid.rows); + } }