From b4ad756721b7185474629aa43773677e81805765 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:23:35 -0800 Subject: [PATCH 1/3] Image Trace: Overlapping fills each shape whole and stacks what it encloses above it An Overlapping layer covers the colors above it within its area, so an area of another color that a shape enclosed was a hole in that shape and in the shape of every layer around it, and its outline was fitted once for each of them. In a code-drawn test, a white disc in a black ring that four grays touch had six copies of its outline. With Fills and without Create Strokes, each shape is now filled whole, by a fill rule of VectorCraft's own. The area inside a filled hole is traced on its own and drawn after the shapes around it, so its outline is fitted once. An area of one color is drawn from the hole's outline. A shape keeps a hole where the area holds a transparent pixel or a color that Ignore Color or Keep Colors leaves out, and a hole smaller than Noise is filled with the shape's color as before. Nested areas are traced within a work budget of VectorCraft's own: four times the image's pixels (counted as at least 65,536) times its colors, each area costing its box times its colors. An area that costs more than what is left keeps its holes and is not charged. Create Strokes and outlines without Fills are unchanged. Shapes have holes only in those cases, there are more paths, and the stacking order changes. Expand by Color starts another group of a color where an area is drawn above the shape around it. The trace's progress counts finer steps across the rounds of enclosed areas, not one step per color layer. A layer's components without its own color are now left out after its mask is traced, instead of in a labeling pass before it. Every route finds its components through the same function. --- crates/engine/src/tests/build.rs | 9 +- crates/trace/src/layers.rs | 989 +++++++++++++++++++++++++++++-- crates/trace/src/lib.rs | 5 +- crates/trace/src/params.rs | 3 +- crates/trace/src/pipeline.rs | 21 +- crates/trace/src/tests.rs | 339 +++++++++++ crates/ui-egui/src/trace_runs.rs | 5 +- docs/image-trace-adjustments.md | 14 +- docs/target-app-parity.md | 1 + 9 files changed, 1342 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 21cbf678eb..82a013758d 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -4134,7 +4134,10 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { sorted.sort(); assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}"); let n = node(&s, g); - assert_eq!(names(&n), order, "{method}"); + // Overlapping fills the ring whole and draws the field inside it above the ring: that path starts a second + // group of the field's color, above the black one. + let want: Vec = if method == "overlapping" { order.iter().chain(order.first()).cloned().collect() } else { order.clone() }; + assert_eq!(names(&n), want, "{method}"); // Each group holds its color's paths, and in turn they are the paths of the trace without // byColor, in their order. let groups = n.children().unwrap(); @@ -4148,8 +4151,8 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { // Release to Layers: a sublayer for each color, in the same order. let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap(); let subs = ids(&r); - assert_eq!(subs.len(), 3, "{method}"); - for (sub, name) in subs.iter().zip(&order) { + assert_eq!(subs.len(), want.len(), "{method}"); + for (sub, name) in subs.iter().zip(&want) { let l = node(&s, *sub); assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}"); assert_eq!(names(&l), std::slice::from_ref(name), "{method}"); diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index e1a0539a9d..dba52ee3cd 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -1,8 +1,11 @@ +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64}; +use std::sync::{Arc, Mutex}; + use super::params::{Method, Mode, TraceParams}; use super::pipeline::TracedPath; #[cfg(test)] use super::pipeline::{bw_tones, left_out}; -use super::raster::Raster; +use super::raster::{Raster, TraceError}; use super::{ AtomicUsize, Crack, Loop, Ordering, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, centerline, contour, fit, logo, shapes, trace_mask, @@ -343,50 +346,255 @@ impl Layers<'_> { pub(crate) fn layer(&self, r: usize, ci: usize) -> Option> { let params = self.params; let color = *self.q.palette.get(ci)?; - let overlapping = params.method == Method::Overlapping; - let (w, h) = (self.w, self.h); // Create Strokes: this colour's lines, left out of its areas. let (thin, lines) = if params.strokes { // The ink the lines are read against: the layer's colour; Black and White's own black. let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) }; - centerline::lines(self.traced, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) + centerline::lines(self.traced, u16::try_from(ci).unwrap_or(TRANSPARENT), self.w, self.h, params.stroke_width, self.img, ink, &self.style) } else { (vec![], vec![]) }; + let comps = self.contours(r, ci, &thin, None, &[])?; + self.fit_layer(ci, &comps, lines) + } + + /// Whether pixel `(x, y)` is one that the layers trace: in the image, and neither transparent nor a color left out. + fn live(&self, x: usize, y: usize) -> bool { + x < self.w && self.traced.get(y * self.w + x).is_some_and(|&l| l != TRANSPARENT && !self.ignored(l)) + } + + /// The highest level of the area `a` whose mask holds every pixel along the inside of its edge (the image's + /// border, or the hole's outline), or 0 when a pixel there is not one the layers trace. That level's shape may + /// run around the whole edge and enclose the rest of the area, so [`Self::stacked`] finds its components (and those + /// of the levels above it) first. + fn rim_level(&self, a: &Area) -> usize { + // The lowest rank along the edge; `None` once a pixel there is not traced. + let mut lowest = Some(usize::MAX); + let mut see = |x: i32, y: i32| { + let rank = usize::try_from(x).ok().zip(usize::try_from(y).ok()).filter(|&(x, y)| self.live(x, y)).and_then(|(x, y)| { + let l = *self.traced.get(y * self.w + x)?; + self.rank.get(l as usize).copied() + }); + lowest = lowest.zip(rank).map(|(a, b)| a.min(b)); + lowest.is_some() + }; + match &a.outline { + None => { + let (w, h) = (i32::try_from(self.w).unwrap_or(0), i32::try_from(self.h).unwrap_or(0)); + // (Rows first, then the columns between them.) + let _ = (0..w).all(|x| see(x, 0) && see(x, h - 1)) && (1..h - 1).all(|y| see(0, y) && see(w - 1, y)); + } + Some(l) => { + // A hole's outline has the shape's pixels on its right and the area's on its left (see `contour`). + let _ = l.pts.iter().zip(l.pts.iter().cycle().skip(1)).all(|(&(x0, y0), &(x1, y1))| { + let (dx, dy) = ((x1 - x0).signum(), (y1 - y0).signum()); + // (Outlines run along pixel edges: never both ways at once.) + if dx != 0 && dy != 0 { + return false; + } + let mut p = (x0, y0); + while p != (x1, y1) { + let left = match (dx, dy) { + (1, _) => (p.0, p.1 - 1), + (_, 1) => (p.0, p.1), + (-1, _) => (p.0 - 1, p.1), + _ => (p.0 - 1, p.1 - 1), + }; + if !see(left.0, left.1) { + return false; + } + p = (p.0 + dx, p.1 + dy); + } + true + }); + } + } + let Some(lowest) = lowest.filter(|&r| r != usize::MAX) else { return 0 }; + a.levels.iter().rposition(|&(r, _)| r <= lowest).unwrap_or(0) + } + + /// The components of layer `r`'s mask (palette entry `ci`), in image pixels: `thin` pixels (Create Strokes' lines) + /// are left out, and `within` an area, only that area's pixels are in it. + /// + /// The pixels inside `covered` (holes that higher levels fill, so that their shapes cover them) are left out too: + /// a component that reaches into one has it as a hole instead, which [`Self::fill_layer`] drops, and its other + /// loops are the same either way. A layer with no pixel of its own color left has nothing to trace. + fn contours(&self, r: usize, ci: usize, thin: &[bool], within: Option<&Area>, covered: &[&Loop]) -> Option> { + let overlapping = self.params.method == Method::Overlapping; + let w = self.w; // The layer is traced within the box its pixels lie in, not the whole image. let part = |l: u16| if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) && !self.ignored(l) } else { l as usize == ci }; - let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); + let (mut x0, mut y0, mut x1, mut y1) = (w, self.h, 0, 0); for (l, b) in self.boxes.iter().enumerate() { if let Some(b) = b.filter(|_| part(l as u16)) { (x0, y0, x1, y1) = (x0.min(b.0), y0.min(b.1), x1.max(b.2), y1.max(b.3)); } } + if let Some(a) = within { + (x0, y0, x1, y1) = (x0.max(a.bx.0), y0.max(a.bx.1), x1.min(a.bx.2), y1.min(a.bx.3)); + } if x0 >= x1 || y0 >= y1 { return Some(vec![]); } let (bw, bh) = (x1 - x0, y1 - y0); - let mut mask = Vec::with_capacity(bw * bh); - for y in y0..y1 { - let row = self.traced.get(y * w + x0..y * w + x1)?; - mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); - } - // Overlapping: a part of the mask with no pixel of this color holds only colors above it, - // whose own layers fill it, and is left out. The layer reaches under the colors above it - // only within the areas that hold its own color. - if overlapping { - let (comp, sizes) = contour::label(&mask, bw, bh); - let mut own = vec![false; sizes.len()]; - for (k, &c) in comp.iter().enumerate() { - if self.q.labels.get((y0 + k / bw) * w + x0 + k % bw).is_some_and(|&l| l as usize == ci) - && let Some(o) = own.get_mut(c as usize) - { - *o = true; + // Each row's runs of pixels that may be in the mask: in the area, outside what is covered. + let mut mask = vec![false; bw * bh]; + // (Whether any pixel of the layer's own color is in it: none, and the layer has nothing to trace.) + let (mut own_color, mut off_image) = (!overlapping, false); + row_runs(x0, x1, y0, bh, within.and_then(|a| a.outline.as_ref()), covered, |k, runs| { + let y = y0 + k; + for &(a, b) in runs { + let (Some(row), Some(out)) = (self.traced.get(y * w + a..y * w + b), mask.get_mut(k * bw + a - x0..k * bw + b - x0)) else { + off_image = true; + return; + }; + for (j, (m, &l)) in out.iter_mut().zip(row).enumerate() { + let i = y * w + a + j; + *m = l != TRANSPARENT && !thin.get(i).copied().unwrap_or(false) && part(l); + own_color |= *m && self.q.labels.get(i).is_some_and(|&l| l as usize == ci); } } - for (m, &c) in mask.iter_mut().zip(&comp) { - *m = *m && own.get(c as usize).copied().unwrap_or(false); + }); + if off_image { + return None; + } + if !own_color { + return Some(vec![]); + } + let mut comps = trace_mask(&mask, bw, bh); + drop(mask); + // Overlapping: a part of the mask with no pixel of this color holds only colors above it, + // whose own layers fill it, and is left out. The layer reaches under the colors above it + // only within the areas that hold its own color. (Leaving a component out changes no other + // component's loops: a walk turns by the pixels of its own component only.) + if overlapping { + let own = |x: usize, y: usize| self.q.labels.get((y0 + y) * w + x0 + x).is_some_and(|&l| l as usize == ci); + comps.retain(|c| !pixels_of(c, |x, y| !own(x, y))); + } + for comp in &mut comps { + for l in std::iter::once(&mut comp.outer).chain(&mut comp.holes) { + for p in &mut l.pts { + *p = (p.0 + x0 as i32, p.1 + y0 as i32); + } } } + Some(comps) + } + + /// The first pass of [`Self::stacked`] at level `level` of the area `a`: marks in `round.marks` the pixels inside + /// each hole of `comps` (that level's components) that its shape fills. A shape fills a hole of at least Noise + /// pixels when every pixel inside it is one the area traces (no transparent pixel, no color left out). Smaller + /// holes are filled with the shape's own color, as they always were. A hole inside an area that this level or a + /// higher one has already marked is passed over: every pixel in it is marked, and the component around it lies + /// inside that area, traced with it. Whether a hole holds a pixel that is not traced is read from `round.dead` a + /// run at a time, not a pixel at a time. Returns the holes marked. + fn mark_filled(&self, round: &Round, a: &Area, level: usize, comps: &[contour::Component]) -> Vec { + let Some(mark) = u16::try_from(level + 1).ok().filter(|_| a.fill) else { return vec![] }; + let min_hole = i64::from(self.params.noise.max(1)); + let all_live = |hole: &Loop| { + let Some(dead) = round.dead else { return true }; + runs_inside([hole], |y, a, b| { + #[cfg(test)] + round.live_reads.fetch_add(1, Ordering::Relaxed); + !dead.get(y).is_some_and(|runs| meets(runs, a, b)) + }) + }; + let mut marked = vec![]; + for hole in comps.iter().flat_map(|c| &c.holes) { + if self.params.noise > 0 && hole.area2.abs() / 2 < min_hole + || first_pixel(hole).and_then(|(x, y)| round.marks.get(y * self.w + x)).is_none_or(|m| m.load(Ordering::Relaxed) >= mark) + || !all_live(hole) + { + continue; + } + interior(hole, |x, y| { + if let Some(m) = round.marks.get(y * self.w + x) { + m.fetch_max(mark, Ordering::Relaxed); + } + true + }); + marked.push(hole.clone()); + } + marked + } + + /// The second pass of [`Self::stacked`] at level `level` (palette entry `ci`) of the area `a`, once every level of + /// the round has marked what it fills: the paths of `comps` (from the first pass), and the holes that the shapes + /// fill (see [`Self::inside`]). + /// + /// An area filled at a higher level is covered by that level's shape, which this one lies under: it is no hole + /// here, and a component whose own color lies only inside such areas is left out. A component inside an area filled + /// at any level is traced with that area. A hole this level fills is no hole. Every other hole stays. + /// + /// `covered`: whether a higher level of the area has filled a hole (without one, every component holds its own + /// color outside such areas, as `contours` keeps only those that hold it). + fn fill_layer( + &self, + round: &Round, + a: &Area, + level: usize, + ci: usize, + mut comps: Vec, + covered: bool, + ) -> Option<(Vec, Vec)> { + let mark = u16::try_from(level + 1).unwrap_or(u16::MAX); + let marked = |(x, y): (usize, usize)| round.marks.get(y * self.w + x).map_or(0, |m| m.load(Ordering::Relaxed)); + comps.retain(|comp| { + first_pixel(&comp.outer).is_some_and(|p| marked(p) == 0) + && !(covered && pixels_of(comp, |x, y| self.q.labels.get(y * self.w + x).is_none_or(|&l| l as usize != ci) || marked((x, y)) > mark)) + }); + let mut filled = vec![]; + for comp in &mut comps { + for hole in std::mem::take(&mut comp.holes) { + match first_pixel(&hole).map(marked) { + Some(m) if m > mark => {} + Some(m) if m == mark && a.fill => filled.push(hole), + _ => comp.holes.push(hole), + } + } + // The pixels the shape fills (the same whichever covered areas `contours` left out). + let area2 = comp.holes.iter().fold(comp.outer.area2, |a, h| a + h.area2); + comp.pixels = usize::try_from(area2 / 2).unwrap_or(0); + } + Some((self.fit_layer(ci, &comps, vec![])?, filled)) + } + + /// What [`Self::stacked`] draws inside the hole `hole` that a shape fills: an area of several colors, traced in the + /// next round, or the paths of an area of one color, traced here (as that round would trace it: its one level's + /// shape is the whole area, without holes). `None` once the anchors made pass the maximum. + fn inside(&self, hole: Loop) -> Option { + let Some(area) = self.area_in(hole) else { return Some(Inside::Done(vec![])) }; + let &[(_, ci)] = area.levels.as_slice() else { return Some(Inside::Next(area)) }; + let comps = area.outline.as_ref().map(shapes_in_hole).unwrap_or_default(); + Some(Inside::Done(self.fit_layer(ci, &comps, vec![])?)) + } + + /// The area inside the hole `hole`, with its box and the colors in it (`None` when it holds no pixel of a color). + fn area_in(&self, hole: Loop) -> Option { + let mut present = vec![false; self.q.palette.len()]; + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0); + runs_inside([&hole], |y, a, b| { + for &l in self.traced.get(y * self.w + a..y * self.w + b).unwrap_or_default() { + if let Some(p) = present.get_mut(l as usize) { + *p = true; + } + } + (x0, y0, x1, y1) = (x0.min(a), y0.min(y), x1.max(b), y1.max(y + 1)); + true + }); + let mut levels: Vec<(usize, usize)> = + present.iter().enumerate().filter(|&(_, &p)| p).filter_map(|(ci, _)| Some((*self.rank.get(ci)?, ci))).collect(); + levels.sort_unstable(); + (x0 < x1 && !levels.is_empty()).then_some(Area { bx: (x0, y0, x1, y1), outline: Some(hole), levels, fill: true }) + } + + /// The paths of the components `comps` of palette entry `ci`'s layer, then of its `lines` (Create Strokes), or + /// `None` once the anchors made pass the maximum. + fn fit_layer(&self, ci: usize, comps: &[contour::Component], lines: Vec) -> Option> { + let params = self.params; + let color = *self.q.palette.get(ci)?; + let overlapping = params.method == Method::Overlapping; + let (w, h) = (self.w, self.h); // Without Fills, areas are outlined with a 1 px stroke. let area_stroke = (params.strokes && !params.fills).then_some(1.0); let min_hole = params.noise.max(1) as i64; @@ -401,15 +609,6 @@ impl Layers<'_> { paths.push(TracedPath { path, color, pixels, stroke, shape, gradient, square_caps }); true }; - let mut comps = trace_mask(&mask, bw, bh); - drop(mask); - for comp in &mut comps { - for l in std::iter::once(&mut comp.outer).chain(&mut comp.holes) { - for p in &mut l.pts { - *p = (p.0 + x0 as i32, p.1 + y0 as i32); - } - } - } // One component's paths: a pure function of its loops and the shared read-only state, so components are fitted in // parallel and then added in order. let fit_comp = |comp: &contour::Component| -> Option<(PathData, Option)> { @@ -484,7 +683,7 @@ impl Layers<'_> { }; // In chunks, so that the anchor budget still stops the work early: a hostile image is refused after fitting at most // the rest of one chunk, not after every component is fitted. - let accepted = fit_in_chunks(&comps, FIT_CHUNK, fit_comp, |comp, fitted| { + let accepted = fit_in_chunks(comps, FIT_CHUNK, fit_comp, |comp, fitted| { let Some((path, shape)) = fitted else { return true }; add(path, comp.pixels, area_stroke, shape, false) }); @@ -498,6 +697,307 @@ impl Layers<'_> { } Some(paths) } + + /// Overlapping with Fills (and without Create Strokes): the stacked layers of `layers` (`(rank, palette entry)`, + /// bottom up), with each shape filled whole. `largest` is the largest of the layers' boxes, in pixels. + /// + /// A layer's shape covers the colors above it in its area, so a part of other colors that it encloses is a hole in + /// it, and every layer whose shape encloses that part would otherwise have its own copy of the same outline. + /// Instead the shape fills the hole, and the area inside the hole is traced again in the next round, as the whole + /// image is, and drawn after everything around it. A hole stays where something inside it must stay unpainted (a + /// transparent pixel, a color left out). + /// + /// Each round takes two passes over its levels, each pass all levels in parallel. The first finds each level's + /// components and marks the pixels inside the holes it fills. The second leaves out what lies inside a marked area + /// (traced in the next round), treats an area filled at a higher level as covered (no hole, and no part of a lower + /// shape whose own color lies only inside it), and fits the rest. Every pixel inside a filled area is then fitted + /// in that area's round only. An area of one color is traced as soon as its hole is known (its one shape has no + /// holes), so only areas of several colors make another round. The levels that hold every pixel along an area's + /// edge go first in the first pass (see [`Self::rim_level`]). + /// + /// The pixels of nested areas are scanned once more in each round that holds them: past a budget of four times the + /// work of the first round (of a 256 × 256 image, at least), an area keeps its holes and ends its branch (an image + /// of thousands of nested rings). An area is charged its box times its levels only when it fits in what is left; an + /// area that does not fit is charged nothing. + /// + /// The marks take two bytes a pixel of the image. In an image with a transparent pixel or a color left out, the + /// runs of such pixels take up to four bytes a pixel more during the first round (eight bytes a run). Both count + /// against the half gigabyte that sets how many levels are traced at a time. + pub(crate) fn stacked( + &self, + layers: &[(usize, usize)], + largest: usize, + steps: &(dyn Fn(u64, u64) -> bool + Sync), + ) -> Result, TraceError> { + let n = self.w * self.h; + let progress = Progress { steps, done: AtomicU64::new(0), total: 3 * layers.len().max(1) as u64 * STEP, stopped: AtomicBool::new(false) }; + if !progress.add(0) { + return Err(TraceError::Cancelled); + } + let too_complex = || TraceError::TooComplex { max: self.max_anchors }; + // Two bytes a pixel for the marks. + let marks: Vec = (0..n).map(|_| AtomicU16::new(0)).collect(); + let untraced = |l: u16| l == TRANSPARENT || self.ignored(l); + // Eight bytes a run of untraced pixels, at most four a pixel (where every other pixel is one), and a list a row. + let mut dead = self.traced.iter().any(|&l| untraced(l)).then(|| dead_runs(self.w, self.traced, untraced)); + let dead_bytes = dead.as_ref().map_or(0, |rows| { + rows.iter().fold(0usize, |s, r| { + s.saturating_add(size_of::>()).saturating_add(r.capacity().saturating_mul(size_of::<(u32, u32)>())) + }) + }); + #[cfg(test)] + let probe = PROBE.with(|p| { + let mut p = p.borrow_mut(); + (p.live_reads, p.areas) = (0, vec![]); + p.clone() + }); + #[cfg(test)] + let (live_reads, mut decided) = (AtomicUsize::new(0), vec![]); + #[cfg(test)] + let mut round_number = 0usize; + let mut areas = vec![Area { bx: (0, 0, self.w, self.h), outline: None, levels: layers.to_vec(), fill: true }]; + // The areas as a tree: each one's paths and the areas inside it. + let mut tree: Vec<(Vec, Vec)> = vec![]; + let mut nodes = vec![0usize]; + tree.push((vec![], vec![])); + let mut budget = 4 * n.max(1 << 16) as u64 * layers.len().max(1) as u64; + #[cfg(test)] + if let Some(b) = probe.budget { + budget = b; + } + // What each round's levels may add to the progress, in units: the first round's levels a step each per pass, + // and every later round half of what is left. + let mut pot = layers.len() as u64 * STEP; + let mut first = true; + while !areas.is_empty() { + // The levels of every area, the highest first: a level's components are found after the levels above it + // have marked what they fill, where they could start first, and leave those areas out. The levels at and + // above an area's rim level (`rim_level`) go first of all, and the others start once they have marked: a + // shape that runs around the whole area (a frame) may enclose nearly all of it. + let rims: Vec = areas.iter().map(|a| self.rim_level(a)).collect(); + let early = |&(k, i): &(usize, usize)| rims.get(k).is_some_and(|&r| r > 0 && i >= r); + let mut jobs: Vec<(usize, usize)> = areas.iter().enumerate().flat_map(|(k, a)| (0..a.levels.len()).map(move |i| (k, i))).collect(); + jobs.sort_by_key(|job| (!early(job), std::cmp::Reverse(job.1), job.0)); + let first_part = jobs.iter().take_while(|job| early(job)).count(); + // The holes each level has marked, once it has. + let published: Mutex> = Mutex::new(vec![]); + let per_job = if first { + STEP + } else { + let share = pot / 2; + pot -= share; + share / 2 / jobs.len().max(1) as u64 + }; + let round = Round { + marks: &marks, + dead: dead.as_deref().filter(|_| first), + #[cfg(test)] + live_reads: &live_reads, + }; + #[cfg(test)] + { + round_number += 1; + } + // A level takes about ten bytes a pixel of its box while its components are found (as `pipeline` counts + // them): as many at a time as fit in half a gigabyte with the marks and the runs of untraced pixels. + let biggest = + jobs.iter().filter_map(|&(k, _)| areas.get(k)).map(|a| (a.bx.2 - a.bx.0) * (a.bx.3 - a.bx.1)).max().unwrap_or(1).min(largest).max(1); + let held = if first { dead_bytes } else { 0 }; + let at_a_time = ((512usize << 20).saturating_sub(2 * n).saturating_sub(held) / (biggest * 10)).max(1); + // Threads cost time to start: one for each `PIXELS_PER_THREAD` pixels of the levels' boxes (the later rounds + // often hold hundreds of small areas). + let work = jobs.iter().filter_map(|&(k, _)| areas.get(k)).fold(0usize, |s, a| s.saturating_add((a.bx.2 - a.bx.0) * (a.bx.3 - a.bx.1))); + let at_a_time = at_a_time.min(work.div_ceil(PIXELS_PER_THREAD).max(1)); + #[cfg(test)] + let at_a_time = if probe.one_level_at_a_time { 1 } else { at_a_time }; + // The components found are kept for the second pass while they take at most `KEPT_BYTES`; past that, a + // level's are dropped and found again in the second pass. + let kept = AtomicUsize::new(0); + let find = |&(k, i): &(usize, usize)| { + let (a, &(r, ci)) = areas.get(k).and_then(|a| Some((a, a.levels.get(i)?)))?; + if progress.stopped.load(Ordering::Relaxed) { + return None; + } + let above: Vec>> = + published.lock().map(|p| p.iter().filter(|e| e.0 == k && e.1 > i).map(|e| Arc::clone(&e.2)).collect()).unwrap_or_default(); + let covered: Vec<&Loop> = above.iter().flat_map(|l| l.iter()).collect(); + #[cfg(test)] + let covered = if probe.keep_covered { vec![] } else { covered }; + let comps = self.contours(r, ci, &[], Some(a), &covered)?; + let marked = self.mark_filled(&round, a, i, &comps); + if !marked.is_empty() + && let Ok(mut p) = published.lock() + { + p.push((k, i, Arc::new(marked))); + } + progress.add(per_job); + let bytes = comps.iter().fold(0usize, |s, c| { + std::iter::once(&c.outer).chain(&c.holes).fold(s.saturating_add(size_of::()), |s, l| { + s.saturating_add(size_of::()).saturating_add(l.pts.len().saturating_mul(size_of::<(i32, i32)>())) + }) + }); + let keep = kept.fetch_add(bytes, Ordering::Relaxed).saturating_add(bytes) <= KEPT_BYTES; + #[cfg(test)] + let keep = keep && !probe.keep_none; + Some(Mutex::new(keep.then_some(comps))) + }; + let (early_jobs, later_jobs) = jobs.split_at(first_part); + let mut found = parallel_map_at_most(at_a_time, early_jobs, find); + found.extend(parallel_map_at_most(at_a_time, later_jobs, find)); + if progress.stopped.load(Ordering::Relaxed) { + return Err(TraceError::Cancelled); + } + // (And one for each `POINTS_PER_THREAD` corners of the outlines to fit; as many as the first pass when a + // level's components are found again.) + let mut again = false; + let mut points = 0usize; + for c in found.iter().flatten() { + match c.lock().ok().as_deref() { + Some(Some(comps)) => { + points = + comps.iter().flat_map(|c| std::iter::once(&c.outer).chain(&c.holes)).fold(points, |s, l| s.saturating_add(l.pts.len())); + } + _ => again = true, + } + } + let threads = points.div_ceil(POINTS_PER_THREAD).max(1); + let threads = if again { threads.min(at_a_time) } else { threads }; + // The highest level of each area that has marked a hole (none: nothing in the area is covered). + let mut highest: Vec> = vec![None; areas.len()]; + if let Ok(p) = published.lock() { + for &(k, i, _) in p.iter() { + if let Some(h) = highest.get_mut(k) { + *h = Some(h.map_or(i, |h| h.max(i))); + } + } + } + let items: Vec<_> = jobs.iter().zip(&found).collect(); + let fitted = parallel_map_at_most(threads, &items, |&(&(k, i), comps)| { + let (a, &(r, ci)) = areas.get(k).and_then(|a| Some((a, a.levels.get(i)?)))?; + if progress.stopped.load(Ordering::Relaxed) { + return None; + } + let comps = match comps.as_ref()?.lock().ok()?.take() { + Some(comps) => comps, + None => { + // (Found again, leaving out every area the levels above have filled.) + let above: Vec>> = published + .lock() + .map(|p| p.iter().filter(|e| e.0 == k && e.1 > i).map(|e| Arc::clone(&e.2)).collect()) + .unwrap_or_default(); + self.contours(r, ci, &[], Some(a), &above.iter().flat_map(|l| l.iter()).collect::>())? + } + }; + let covered = highest.get(k).copied().flatten().is_some_and(|h| h > i); + let out = self.fill_layer(&round, a, i, ci, comps, covered); + progress.add(per_job); + out + }); + drop(found); + if progress.stopped.load(Ordering::Relaxed) { + return Err(TraceError::Cancelled); + } + drop(published); + // Each area's paths, its levels bottom up, and the holes filled in it. + let mut by_level = jobs.iter().zip(fitted).map(|(&job, out)| Some((job, out?))).collect::>>().ok_or_else(too_complex)?; + by_level.sort_by_key(|e| e.0); + // The marks of this round lie inside its areas' boxes. + for a in &areas { + for y in a.bx.1..a.bx.3 { + for m in marks.get(y * self.w + a.bx.0..y * self.w + a.bx.2).unwrap_or_default() { + m.store(0, Ordering::Relaxed); + } + } + } + // What is drawn inside the holes the shapes fill, all levels' holes at once: the paths of an area of one + // color, or an area of the next round. (Threads by the pixels inside, by the corners of the outlines to fit, + // and by the number of holes.) + let mut holes: Vec<(usize, Loop)> = vec![]; + for (n, (_, (_, filled))) in by_level.iter_mut().enumerate() { + holes.extend(std::mem::take(filled).into_iter().map(|l| (n, l))); + } + let pixels = holes.iter().fold(0usize, |s, (_, l)| s.saturating_add(usize::try_from(l.area2.unsigned_abs() / 2).unwrap_or(usize::MAX))); + let corners = holes.iter().fold(0usize, |s, (_, l)| s.saturating_add(l.pts.len())); + let threads = pixels.div_ceil(PIXELS_PER_THREAD).max(corners.div_ceil(POINTS_PER_THREAD)).max(holes.len().div_ceil(64)).max(1); + // (Once one is refused, past the anchors allowed, the rest are not fitted.) + let refused = AtomicBool::new(false); + let insides = parallel_map_at_most(threads, &holes, |(_, l)| { + if progress.stopped.load(Ordering::Relaxed) || refused.load(Ordering::Relaxed) { + return None; + } + let inside = self.inside(l.clone()); + if inside.is_none() { + refused.store(true, Ordering::Relaxed); + } + inside + }); + if progress.stopped.load(Ordering::Relaxed) { + return Err(TraceError::Cancelled); + } + if refused.load(Ordering::Relaxed) { + return Err(too_complex()); + } + let mut by_job: Vec> = (0..by_level.len()).map(|_| vec![]).collect(); + for ((n, _), inside) in holes.into_iter().zip(insides) { + if let Some(v) = by_job.get_mut(n) { + v.push(inside.ok_or_else(too_complex)?); + } + } + // Each area's paths, then what is drawn inside the holes its shapes fill. + let (mut next, mut next_nodes) = (vec![], vec![]); + for (((k, _), (paths, _)), inside) in by_level.into_iter().zip(by_job) { + let up = *nodes.get(k).ok_or_else(too_complex)?; + if let Some(t) = tree.get_mut(up) { + t.0.extend(paths); + } + for item in inside { + let node = tree.len(); + if let Some(t) = tree.get_mut(up) { + t.1.push(node); + } + match item { + Inside::Done(paths) => tree.push((paths, vec![])), + Inside::Next(mut area) => { + tree.push((vec![], vec![])); + let cost = ((area.bx.2 - area.bx.0) * (area.bx.3 - area.bx.1)) as u64 * area.levels.len() as u64; + // (An area past what is left keeps its holes and is charged nothing: a smaller one after + // it may still fit.) + area.fill = cost <= budget; + if area.fill { + budget -= cost; + } + #[cfg(test)] + decided.push((round_number + 1, cost, area.fill)); + next.push(area); + next_nodes.push(node); + } + } + } + } + (areas, nodes) = (next, next_nodes); + // (Only the first round reads the runs of untraced pixels.) + dead = None; + first = false; + } + // Each area's paths, then the areas inside it. + let mut out = vec![]; + let mut stack = vec![0]; + while let Some(k) = stack.pop() { + let Some((p, inner)) = tree.get_mut(k) else { continue }; + out.append(p); + stack.extend(inner.iter().rev()); + } + #[cfg(test)] + PROBE.with(|p| { + let mut p = p.borrow_mut(); + p.live_reads = live_reads.load(Ordering::Relaxed); + p.areas = decided; + }); + if !progress.add(progress.total) { + return Err(TraceError::Cancelled); + } + Ok(out) + } } /// Test probe: every crack between two labels of `img` quantised as `trace` does (no gradients), @@ -542,6 +1042,299 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { out } +/// Overlapping with Fills: a part of the image whose layers are traced together and stacked as the whole image's are +/// (see [`Layers::stacked`]). The whole image is one; the area inside a hole that a shape fills is another, traced in +/// the next round and drawn above the part it lies in. +struct Area { + /// Its box in image pixels, `(x0, y0, x1, y1)`, the far sides excluded. + bx: (usize, usize, usize, usize), + /// The hole whose inside it is (the whole image has none). + outline: Option, + /// The layers with pixels here, bottom up: `(rank, palette entry)`. + levels: Vec<(usize, usize)>, + /// Whether its shapes fill the holes they may fill (past the work budget, they keep them). + fill: bool, +} + +/// What [`Layers::stacked`] draws above a shape inside a hole that the shape fills. +enum Inside { + /// The paths of an area of one color, traced at once: it has no holes, and so nothing more inside it. + Done(Vec), + /// An area of several colors, traced in the next round (its `fill` not set yet). + Next(Area), +} + +/// The holes a level of [`Layers::stacked`] has marked: `(area, level, holes)`. +type Marked = (usize, usize, Arc>); + +/// One round of [`Layers::stacked`]: the areas traced at the same time. Areas of one round never share a pixel. +struct Round<'a> { + /// For each pixel of the image, one more than the highest level of its area at which a shape fills a hole around + /// it; 0 where none does. + marks: &'a [AtomicU16], + /// In the first round, where a hole may hold a pixel that the layers do not trace (transparent, or a color left + /// out), the runs of such pixels in each row, left to right as `(x0, x1)`: an area is filled only where it holds + /// none. `None` in later rounds and in images without such pixels. + dead: Option<&'a [Vec<(u32, u32)>]>, + /// Tests: the reads made to check a hole against `dead`. + #[cfg(test)] + live_reads: &'a AtomicUsize, +} + +/// How far a stacked trace has got, for its `steps` callback: each level of the first round counts one step in each +/// of the two passes, and the rounds after it share as many steps again as the first round has levels, each round +/// half of what the rounds before it left (their number is not known in advance). +struct Progress<'a> { + steps: &'a (dyn Fn(u64, u64) -> bool + Sync), + done: AtomicU64, + total: u64, + stopped: AtomicBool, +} + +impl Progress<'_> { + /// Adds `n` units and says whether to go on. + fn add(&self, n: u64) -> bool { + let done = self.done.fetch_add(n, Ordering::Relaxed) + n; + if !(self.steps)(done.min(self.total), self.total) { + self.stopped.store(true, Ordering::Relaxed); + } + !self.stopped.load(Ordering::Relaxed) + } +} + +/// One step of [`Progress`], in its own units (a level's step can then be split among the levels of later rounds). +const STEP: u64 = 1 << 20; + +/// [`Layers::stacked`]: how many bytes of components found in the first pass of a round are kept for the second. +const KEPT_BYTES: usize = 256 << 20; + +/// [`Layers::stacked`]: how many pixels of the levels' boxes, and how many corners of the outlines to fit, are worth a +/// thread of their own. +const PIXELS_PER_THREAD: usize = 1 << 16; +const POINTS_PER_THREAD: usize = 1 << 6; + +/// Tests: switches and counts of [`Layers::stacked`], one set per test thread ([`PROBE`]). A trace reads the switches +/// when it starts and writes the counts when it ends, so tests that run at the same time do not see each other's. +#[cfg(test)] +#[derive(Clone, Debug, Default)] +pub(crate) struct Probe { + /// Keep the areas that higher levels have filled in a level's components (the result is the same either way). + pub(crate) keep_covered: bool, + /// Find one level's components at a time, so that every level leaves out all the areas the levels above it fill. + pub(crate) one_level_at_a_time: bool, + /// Drop every level's components after the first pass and find them again in the second (as past `KEPT_BYTES`). + pub(crate) keep_none: bool, + /// The work budget of nested areas, in place of the one the image gives. + pub(crate) budget: Option, + /// Counted: the reads the first round made to check that a hole holds only pixels the layers trace. + pub(crate) live_reads: usize, + /// Counted: each area of a later round, as `(round, cost, filled)`, in the order the budget was charged. + pub(crate) areas: Vec<(usize, u64, bool)>, +} + +#[cfg(test)] +thread_local! { + pub(crate) static PROBE: std::cell::RefCell = std::cell::RefCell::new(Probe::default()); +} + +/// The runs of the pixels in each row of a `w` pixels wide image for which `dead` holds, left to right as `(x0, x1)`. +fn dead_runs(w: usize, labels: &[u16], dead: impl Fn(u16) -> bool) -> Vec> { + labels + .chunks(w.max(1)) + .map(|row| { + let mut runs: Vec<(u32, u32)> = vec![]; + for (x, &l) in row.iter().enumerate() { + let x = u32::try_from(x).unwrap_or(u32::MAX); + if !dead(l) { + continue; + } + match runs.last_mut() { + Some(r) if r.1 == x => r.1 = x.saturating_add(1), + _ => runs.push((x, x.saturating_add(1))), + } + } + runs + }) + .collect() +} + +/// Whether any of the runs `runs` (left to right, apart) meets the pixels `a..b`. +fn meets(runs: &[(u32, u32)], a: usize, b: usize) -> bool { + let (Ok(a), Ok(b)) = (u32::try_from(a), u32::try_from(b)) else { return true }; + runs.get(runs.partition_point(|r| r.1 <= a)).is_some_and(|r| r.0 < b) +} + +/// Calls `f(y, x0, x1)` with each run `x0..x1` of the pixels in row `y` inside the loops `loops` (along pixel edges; +/// inside an odd number of them), row by row, until it answers false; whether it never did. +fn runs_inside<'l>(loops: impl IntoIterator, mut f: impl FnMut(usize, usize, usize) -> bool) -> bool { + // The loops' upright edges `(top, bottom, x)`, by their top. Going down the rows, the edges that pass the row are + // kept: the pixels between the first and the second are inside, and so on. + let mut edges: Vec<(i32, i32, i32)> = loops + .into_iter() + .flat_map(|l| l.pts.iter().zip(l.pts.iter().cycle().skip(1))) + .filter(|(a, b)| a.0 == b.0 && a.1 != b.1) + .map(|(a, b)| (a.1.min(b.1), a.1.max(b.1), a.0)) + .collect(); + edges.sort_unstable_by_key(|e| e.0); + let (Some(top), Some(bottom)) = (edges.first().map(|e| e.0), edges.iter().map(|e| e.1).max()) else { return true }; + let (mut next, mut passing, mut xs) = (0, Vec::<(i32, i32)>::new(), Vec::::new()); + for y in top..bottom { + while let Some(&(_, end, x)) = edges.get(next).filter(|e| e.0 <= y) { + passing.push((end, x)); + next += 1; + } + passing.retain(|e| e.0 > y); + xs.clear(); + xs.extend(passing.iter().map(|e| e.1)); + xs.sort_unstable(); + let Ok(row) = usize::try_from(y) else { continue }; + for pair in xs.as_chunks::<2>().0 { + let (Ok(a), Ok(b)) = (usize::try_from(pair[0].max(0)), usize::try_from(pair[1].max(0))) else { continue }; + if a < b && !f(row, a, b) { + return false; + } + } + } + true +} + +/// Calls `f` with each pixel `(x, y)` inside the loops `loops` (see [`runs_inside`]), row by row, until it answers +/// false; whether it never did. A component's outer loop and holes give its own pixels. +fn pixels_inside<'l>(loops: impl IntoIterator, mut f: impl FnMut(usize, usize) -> bool) -> bool { + runs_inside(loops, |y, a, b| (a..b).all(|x| f(x, y))) +} + +/// Calls `f(k, runs)` with the runs of row `y0 + k`, for each `k` below `rows` in order, of the pixels inside `outline` +/// (all of `x0..x1` without one) and inside none of `covered`, within `x0..x1`, left to right. Rows without such a +/// pixel may be passed over. Only the covered loops' runs are held, not a list for every row. +fn row_runs(x0: usize, x1: usize, y0: usize, rows: usize, outline: Option<&Loop>, covered: &[&Loop], mut f: impl FnMut(usize, &[(usize, usize)])) { + // The covered runs `(k, a, b)`, by row and then left to right. + let mut gone: Vec<(usize, usize, usize)> = vec![]; + for l in covered { + runs_inside([*l], |y, a, b| { + if let Some(k) = y.checked_sub(y0).filter(|&k| k < rows) { + gone.push((k, a, b)); + } + true + }); + } + gone.sort_unstable(); + let (mut next, mut left) = (0, vec![]); + // Row `k`'s runs `keep` less its covered runs. + let mut row = |k: usize, keep: &[(usize, usize)]| { + let from = next; + while gone.get(next).is_some_and(|e| e.0 <= k) { + next += 1; + } + let cut = gone.get(from..next).unwrap_or_default(); + if cut.iter().all(|e| e.0 < k) { + f(k, keep); + return; + } + left.clear(); + for &(a, b) in keep { + let mut x = a; + for &(_, ga, gb) in cut.iter().filter(|e| e.0 == k) { + if gb <= x || ga >= b { + continue; + } + if ga > x { + left.push((x, ga)); + } + x = x.max(gb); + } + if x < b { + left.push((x, b)); + } + } + f(k, &left); + }; + match outline { + None => (0..rows).for_each(|k| row(k, &[(x0, x1)])), + Some(l) => { + // (`runs_inside` goes down the rows, each left to right.) + let (mut at, mut runs) = (None, vec![]); + runs_inside([l], |y, a, b| { + let Some(k) = y.checked_sub(y0).filter(|&k| k < rows) else { return true }; + if at != Some(k) { + if let Some(prev) = at { + row(prev, &runs); + } + at = Some(k); + runs.clear(); + } + if a.max(x0) < b.min(x1) { + runs.push((a.max(x0), b.min(x1))); + } + true + }); + if let Some(prev) = at { + row(prev, &runs); + } + } + } +} + +/// [`pixels_inside`] the loop `l`: for a hole, the pixels it encloses, those of what lies in it included. +fn interior(l: &Loop, f: impl FnMut(usize, usize) -> bool) -> bool { + pixels_inside([l], f) +} + +/// [`pixels_inside`] the component `c`: its own pixels. +fn pixels_of(c: &contour::Component, f: impl FnMut(usize, usize) -> bool) -> bool { + pixels_inside(std::iter::once(&c.outer).chain(&c.holes), f) +} + +/// The components that [`trace_mask`] finds in a mask of exactly the pixels inside the hole `hole`, without tracing +/// one: the hole's outline walked the other way round, split where it touches itself (where two pixels inside it meet +/// only at a corner, which are not connected), each part started at its top left corner and in the order of those +/// corners, as `trace_mask` gives them. +fn shapes_in_hole(hole: &Loop) -> Vec { + let Some((&first, rest)) = hole.pts.split_first() else { return vec![] }; + // The corners walked the other way, back to the first; a corner met again closes the part since its first visit. + let mut seen: std::collections::HashMap<(i32, i32), usize> = std::collections::HashMap::new(); + let mut stack: Vec<(i32, i32)> = vec![]; + let mut parts: Vec> = vec![]; + for p in std::iter::once(first).chain(rest.iter().rev().copied()).chain(std::iter::once(first)) { + if let Some(&at) = seen.get(&p) { + let part = stack.split_off(at + 1); + for q in &part { + seen.remove(q); + } + let start = stack.last().copied(); + parts.push(start.into_iter().chain(part).collect()); + } else { + seen.insert(p, stack.len()); + stack.push(p); + } + } + let mut comps: Vec = parts + .into_iter() + .filter(|pts| pts.len() >= 4) + .map(|mut pts| { + // From the top left corner (the walk turns right there). + if let Some(at) = pts.iter().enumerate().min_by_key(|(_, p)| (p.1, p.0)).map(|(i, _)| i) { + pts.rotate_left(at); + } + let area2 = pts + .iter() + .zip(pts.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.0) * i64::from(b.1) - i64::from(b.0) * i64::from(a.1)) + .sum::(); + let pixels = usize::try_from(area2 / 2).unwrap_or(0); + contour::Component { outer: Loop { pts, area2 }, holes: vec![], pixels } + }) + .collect(); + comps.sort_by_key(|c| c.outer.pts.first().map(|p| (p.1, p.0))); + comps +} + +/// The pixel at the top left of the area a loop goes around: its topmost row's leftmost. +fn first_pixel(l: &Loop) -> Option<(usize, usize)> { + let &(x, y) = l.pts.iter().min_by_key(|p| (p.1, p.0))?; + Some((usize::try_from(x).ok()?, usize::try_from(y).ok()?)) +} + /// How many components of a layer are fitted at a time (see `fit_in_chunks`). const FIT_CHUNK: usize = 256; @@ -825,4 +1618,134 @@ mod tests { assert_eq!(out, (1..=1000).collect::>()); assert!(fit_in_chunks(&[] as &[usize], 7, |&i| i, |_, _| false)); } + + /// Seeded 23 × 17 masks of several densities. + fn masks() -> impl Iterator)> { + (0..200u32).map(|seed| { + let mask = (0..23 * 17) + .map(|i| ((i as u32).wrapping_mul(2_654_435_761) ^ seed.wrapping_mul(40_503)).wrapping_mul(2_246_822_519) >> 28 < (seed % 15) + 1) + .collect(); + (seed, mask) + }) + } + + /// `interior` visits each pixel a loop encloses once, outer loops and holes alike, and stops when told to; + /// `pixels_of` visits a component's own pixels. + #[test] + fn interior_visits_each_enclosed_pixel_once() { + let (w, h) = (23, 17); + for (seed, mask) in masks() { + for comp in trace_mask(&mask, w, h) { + for l in std::iter::once(&comp.outer).chain(&comp.holes) { + let mut seen = vec![false; w * h]; + let mut n = 0; + assert!(interior(l, |x, y| { + assert!(!std::mem::replace(&mut seen[y * w + x], true), "seed {seed}: ({x}, {y}) twice"); + n += 1; + true + })); + assert_eq!(n, l.area2.abs() / 2, "seed {seed}"); + } + let mut visits = 0; + assert!(!interior(&comp.outer, |_, _| { + visits += 1; + false + })); + assert_eq!(visits, 1); + let mut own = 0; + assert!(pixels_of(&comp, |x, y| { + assert!(mask[y * w + x], "seed {seed}: ({x}, {y}) is not in the mask"); + own += 1; + true + })); + assert_eq!(own, comp.pixels, "seed {seed}"); + } + } + } + + /// `shapes_in_hole` gives what `trace_mask` finds in a mask of the pixels inside a hole, holes that touch + /// themselves at a corner included. + #[test] + fn shapes_in_a_hole_are_those_traced() { + let (w, h) = (23, 17); + let (mut holes, mut touching) = (0, 0); + for (seed, mask) in masks() { + for hole in trace_mask(&mask, w, h).iter().flat_map(|c| &c.holes) { + let mut inside = vec![false; w * h]; + interior(hole, |x, y| { + inside[y * w + x] = true; + true + }); + assert_eq!(shapes_in_hole(hole), trace_mask(&inside, w, h), "seed {seed}: {hole:?}"); + holes += 1; + let mut corners = hole.pts.clone(); + corners.sort_unstable(); + touching += usize::from(corners.windows(2).any(|p| p[0] == p[1])); + } + } + assert!(holes > 500 && touching > 20, "{holes} holes, {touching} touching themselves"); + } + + /// A component left out of the traced components (as `contours` leaves out those without the layer's own color) + /// gives the same loops as leaving its pixels out of the mask. + #[test] + fn leaving_a_component_out_keeps_the_other_loops() { + let (w, h) = (23, 17); + for (seed, mask) in masks() { + let (labels, _) = contour::label(&mask, w, h); + let rules: [fn(u32) -> bool; 2] = [|c| c % 2 == 0, |c| c % 3 != 1]; + for keep in rules { + let fewer: Vec = mask.iter().zip(&labels).map(|(&m, &c)| m && keep(c)).collect(); + let kept: Vec = + trace_mask(&mask, w, h).into_iter().enumerate().filter(|&(c, _)| keep(c as u32)).map(|(_, comp)| comp).collect(); + assert_eq!(kept, trace_mask(&fewer, w, h), "seed {seed}"); + } + } + } + + /// `row_runs` gives the pixels inside an outline and inside none of the covered loops, clipped to the box. + #[test] + fn row_runs_keep_the_outline_less_what_is_covered() { + let (w, h) = (23, 17); + for (seed, mask) in masks() { + let comps = trace_mask(&mask, w, h); + let Some(outline) = comps.iter().max_by_key(|c| c.outer.area2).map(|c| &c.outer) else { continue }; + let covered: Vec<&Loop> = comps.iter().flat_map(|c| &c.holes).collect(); + let (x0, x1, y0, rows) = (2, 20, 1, 14); + for outline in [Some(outline), None] { + let mut want = vec![outline.is_none(); w * h]; + if let Some(outline) = outline { + interior(outline, |x, y| { + want[y * w + x] = true; + true + }); + } + for l in &covered { + interior(l, |x, y| { + want[y * w + x] = false; + true + }); + } + let (mut got, mut last) = (vec![false; w * h], None); + row_runs(x0, x1, y0, rows, outline, &covered, |k, runs| { + assert!(last.is_none_or(|l| l < k) && k < rows, "seed {seed}: row {k} after {last:?}"); + last = Some(k); + for w2 in runs.windows(2) { + assert!(w2[0].1 <= w2[1].0, "seed {seed}: {runs:?}"); + } + for &(a, b) in runs { + for x in a..b { + got[(y0 + k) * w + x] = true; + } + } + }); + for y in 0..h { + for x in 0..w { + let inside = (x0..x1).contains(&x) && (y0..y0 + rows).contains(&y); + assert_eq!(got[y * w + x], want[y * w + x] && inside, "seed {seed}: ({x}, {y}), outline {}", outline.is_some()); + } + } + } + } + } } diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 48baeccf83..c9e4289119 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -41,7 +41,10 @@ //! //! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or //! *overlapping* (stacked: each layer also covers the layers above it within the areas that hold -//! its own color, so no hairline gaps). +//! its own color, so no hairline gaps). With Fills and without Create Strokes, an overlapping shape +//! is filled whole: the area of other colors that it encloses is traced on its own and drawn above +//! it, so each enclosed outline is fitted once. An area that holds a transparent pixel or a color +//! left out stays a hole. #![forbid(unsafe_code)] mod centerline; diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index a6676cd696..9c430dd19d 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -24,7 +24,8 @@ pub enum Method { #[default] Abutting, /// Stacked: every layer extends under the layers above it, within the areas that hold its own - /// color. + /// color. With Fills (without Create Strokes) each shape is filled whole, and what it encloses + /// is stacked above it. Overlapping, } diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index aa5c7e25b7..2dd54af93b 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -167,7 +167,9 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R /// [`trace_within`], telling `steps` how far it has got: `steps(done, total)` after each stage /// that prepares the colour layers (`total` 0 while they aren't known yet) and after each colour -/// layer, from whichever thread traced it. Once `steps` answers `false` the trace stops at the +/// layer, from whichever thread traced it. Overlapping with Fills (without Create Strokes) counts +/// in finer units instead: a share of `total` after each pass of each layer, in every round of +/// enclosed areas (see `Layers::stacked`). Once `steps` answers `false` the trace stops at the /// next stage or layer with [`TraceError::Cancelled`]. pub fn trace_watched( img: &Raster, @@ -336,10 +338,23 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( // A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels // of its components, the stack that fills them, its edges): as many layers at a time as fit // in half a gigabyte, so that a large image is not traced at the cost of gigabytes. - // (Overlapping labels a layer's mask once more before it is traced, with its labels and stack - // freed before the trace starts.) let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1); let at_a_time = ((512 << 20) / (largest * 10)).max(1); + // Overlapping with Fills: each shape is filled whole, and what it encloses is traced again above it (see + // `Layers::stacked`). Lines (Create Strokes) and outlines (no Fills) are stacked as they always were. + if params.method == Method::Overlapping && params.fills && !params.strokes { + out.paths = job.stacked(&layers, largest, steps)?; + // The colors used, bottom layer first. + for &(_, ci) in &layers { + if let Some(&color) = q.palette.get(ci) + && !out.palette.contains(&color) + && out.paths.iter().any(|p| p.color == color) + { + out.palette.push(color); + } + } + return Ok(out); + } let total = layers.len() as u64; if !steps(0, total) { return Err(TraceError::Cancelled); diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index d0a589b916..934ed55880 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -278,6 +278,345 @@ fn overlapping_layers_leave_out_areas_without_their_color() { } } +/// Concentric discs about the middle of a `size` × `size` image: each `(radius, color)` paints the pixels whose +/// centers lie within its radius, a smaller disc over a larger one; `ground` elsewhere. +fn nested_discs(size: u32, ground: [u8; 4], discs: &[(f64, [u8; 4])]) -> Raster { + let c = f64::from(size) / 2.0; + Raster::from_fn(size, size, |x, y| { + let d = Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5).distance(Point::new(c, c)); + discs.iter().filter(|(r, _)| d <= *r).min_by(|a, b| a.0.total_cmp(&b.0)).map_or(ground, |(_, color)| *color) + }) +} + +/// The paths, drawn bottom first, paint every pixel center of `img` whose 3 × 3 neighborhood is one color with +/// that color (within 3 levels), and leave those of the colors in `left_out` and the transparent ones unpainted. +fn assert_draws(img: &Raster, res: &TraceResult, left_out: &[[u8; 3]]) { + use kurbo::Shape; + let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.path.to_bezpath())).collect(); + let (w, h) = (img.width as usize, img.height as usize); + let px = |x: usize, y: usize| { + let k = (y * w + x) * 4; + [img.rgba[k], img.rgba[k + 1], img.rgba[k + 2], img.rgba[k + 3]] + }; + for y in 1..h - 1 { + for x in 1..w - 1 { + let c = px(x, y); + if (0..9).any(|k| px(x + k % 3 - 1, y + k / 3 - 1) != c) { + continue; + } + let at = kurbo::Point::new(x as f64 + 0.5, y as f64 + 0.5); + let drawn = paths.iter().rev().find(|(_, b)| b.winding(at) != 0).map(|(color, _)| *color); + if c[3] == 0 || left_out.contains(&[c[0], c[1], c[2]]) { + assert_eq!(drawn, None, "at ({x}, {y})"); + } else { + assert!(drawn.is_some_and(|d| (0..3).all(|k| d[k].abs_diff(c[k]) <= 3)), "at ({x}, {y}): drawn {drawn:?}, image {c:?}"); + } + } + } +} + +/// Overlapping fills each shape whole: a black ring around a white disc that holds a navy ring around a disc of the +/// ground's tan, with a red dot in the middle, is traced as shapes with no holes, each drawn above the shape around +/// it. Where something must stay unpainted (Ignore White), the hole stays. +#[test] +fn overlapping_fills_each_shape_whole_and_draws_what_it_encloses_above_it() { + let (tan, white, navy, red) = ([235, 225, 200, 255], [255, 255, 255, 255], [30, 40, 90, 255], [200, 40, 50, 255]); + let img = nested_discs(240, tan, &[(100.0, BLACK), (80.0, white), (55.0, navy), (40.0, tan), (12.0, red)]); + let params = + TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 5, noise: 2, method: Method::Overlapping, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + assert_eq!(res.palette.len(), 5, "{:?}", res.palette); + for t in &res.paths { + assert_eq!(t.path.subpaths.len(), 1, "a {:?} path with holes", t.color); + } + assert_draws(&img, &res, &[]); + assert_eq!(trace(&img, ¶ms), res, "tracing twice differs"); + // Left out, white is not painted over: the shapes around it keep their holes. + let res = trace(&img, &TraceParams { ignore_white: true, ..params.clone() }); + assert_draws(&img, &res, &[[255, 255, 255]]); + assert!(res.paths.iter().any(|t| t.path.subpaths.len() > 1)); +} + +/// How many outlines of [`ring_with_patches`]' white disc (without a frame) the paths of `res` hold, whichever path +/// each is part of. +fn disc_outlines(res: &TraceResult) -> usize { + res.paths + .iter() + .flat_map(|t| &t.path.subpaths) + .filter_map(|s| PathData::single(s.clone()).bounds()) + .filter(|b| [(b.x0, 60.0), (b.y0, 60.0), (b.x1, 180.0), (b.y1, 180.0)].iter().all(|(v, want)| (v - want).abs() <= 1.5)) + .count() +} + +/// Overlapping: the outline of a white disc in a black ring is traced once, though four grays touch the ring and lie +/// between white and black in the stack (each of their layers reaches under the ring and encloses the disc). +#[test] +fn overlapping_traces_an_enclosed_outline_once() { + let res = trace(&ring_with_patches(0), &patch_params()); + assert_eq!(res.palette.len(), 7, "{:?}", res.palette); + assert_eq!(disc_outlines(&res), 1); + assert_draws(&ring_with_patches(0), &res, &[]); +} + +/// Overlapping: a square of a color Keep Colors leaves out, or of transparent pixels, in a corner of the ground keeps +/// its hole in the shapes around it, and the rest is filled whole as without it: the disc's outline is fitted once. +#[test] +fn overlapping_keeps_the_holes_around_colors_left_out_and_transparent_pixels() { + let with_square = |color: [u8; 4]| { + let img = ring_with_patches(0); + Raster::from_fn(240, 240, |x, y| { + if (10..40).contains(&x) && (10..40).contains(&y) { + return color; + } + let k = ((y * 240 + x) * 4) as usize; + [img.rgba[k], img.rgba[k + 1], img.rgba[k + 2], img.rgba[k + 3]] + }) + }; + let magenta = with_square([220, 40, 200, 255]); + let keep: Vec = ["#ebe1c8", "#000000", "#ffffff", "#3c3c3c", "#646464", "#8c8c8c", "#b4b4b4"].map(String::from).to_vec(); + let res = trace(&magenta, &TraceParams { colors: 8, keep_colors: keep, ..patch_params() }); + assert!(res.keep_unmatched.is_empty(), "{:?}", res.keep_unmatched); + assert_eq!(res.palette.len(), 7, "{:?}", res.palette); + assert_draws(&magenta, &res, &[[220, 40, 200]]); + assert!(res.paths.iter().any(|t| t.path.subpaths.len() > 1), "no hole around the square"); + assert_eq!(disc_outlines(&res), 1); + let clear = with_square([0, 0, 0, 0]); + let res = trace(&clear, &patch_params()); + assert_draws(&clear, &res, &[]); + assert!(res.paths.iter().any(|t| t.path.subpaths.len() > 1), "no hole around the square"); + assert_eq!(disc_outlines(&res), 1); +} + +/// A white disc in a black ring on a tan ground, four grays straddling the ring's outer edge, inside a black frame +/// `frame` pixels wide (none at 0): `240 + 2 * frame` pixels square. +fn ring_with_patches(frame: u32) -> Raster { + let tan = [235, 225, 200, 255]; + let grays = [60, 100, 140, 180]; + Raster::from_fn(240 + 2 * frame, 240 + 2 * frame, |x, y| { + if x < frame || y < frame || x >= 240 + frame || y >= 240 + frame { + return BLACK; + } + let (dx, dy) = (f64::from(x - frame) + 0.5 - 120.0, f64::from(y - frame) + 0.5 - 120.0); + let d = dx.hypot(dy); + // A 30 px square straddling the ring's outer edge at each of four angles. + let patch = (0..4usize).find(|&k| { + let a = k as f64 * std::f64::consts::FRAC_PI_2; + (dx - 80.0 * a.cos()).abs() <= 15.0 && (dy - 80.0 * a.sin()).abs() <= 15.0 + }); + if d <= 60.0 { + WHITE + } else if d <= 72.0 { + BLACK + } else if let Some(k) = patch { + let v = grays[k]; + [v, v, v, 255] + } else { + tan + } + }) +} + +fn patch_params() -> TraceParams { + TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 7, noise: 2, method: Method::Overlapping, ..TraceParams::default() } +} + +/// Overlapping: a black frame on top of the stack encloses the whole picture, whose shapes are then traced once, in the +/// area the frame fills: the layers below the frame do not trace them again, under it. The trace is the frame, filled +/// whole, under the picture's own trace moved by the frame's width. +#[test] +fn overlapping_traces_what_a_frame_encloses_once() { + let alone = trace(&ring_with_patches(0), &patch_params()); + let framed = trace(&ring_with_patches(12), &patch_params()); + assert_draws(&ring_with_patches(12), &framed, &[]); + // The frame filled whole, under everything, then the picture. + assert_eq!(framed.paths.len(), alone.paths.len() + 1, "{:?}", framed.paths.iter().map(|t| t.color).collect::>()); + assert_eq!((framed.paths[0].color, framed.paths[0].path.subpaths.len()), ([0, 0, 0], 1)); + let moved: Vec<_> = alone.paths.iter().map(|t| (t.color, t.path.anchor_count(), t.path.bounds().map(|b| (b.x0 + 12.0, b.y0 + 12.0)))).collect(); + let inside: Vec<_> = framed.paths[1..].iter().map(|t| (t.color, t.path.anchor_count(), t.path.bounds().map(|b| (b.x0, b.y0)))).collect(); + for (a, b) in moved.iter().zip(&inside) { + assert_eq!((a.0, a.1), (b.0, b.1)); + let ((ax, ay), (bx, by)) = (a.2.unwrap(), b.2.unwrap()); + assert!((ax - bx).abs() < 0.5 && (ay - by).abs() < 0.5, "{a:?} {b:?}"); + } +} + +/// Overlapping: in 37 nested rings, each area is traced once more for each one around it; past a budget of work the +/// deeper areas keep their holes, and every ring is still drawn in its color. +#[test] +fn overlapping_keeps_the_holes_of_areas_nested_past_its_budget() { + let discs: Vec<(f64, [u8; 4])> = (0..37).map(|k| (148.0 - 4.0 * f64::from(k), if k % 2 == 0 { BLACK } else { WHITE })).collect(); + let img = nested_discs(300, WHITE, &discs); + let params = + TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 2, noise: 0, method: Method::Overlapping, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + assert!(res.paths.iter().any(|t| t.path.subpaths.len() > 1), "no area kept its holes"); + assert!(res.paths.iter().take(4).all(|t| t.path.subpaths.len() == 1), "the outer areas kept their holes"); + assert_draws(&img, &res, &[]); +} + +/// `f` run with the stacked trace's switches `probe` on this thread, and the counts the last trace made. +fn probed(probe: layers::Probe, f: impl FnOnce() -> R) -> (R, layers::Probe) { + /// Puts the switches back, also when an assertion fails. + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + layers::PROBE.with(|p| *p.borrow_mut() = layers::Probe::default()); + } + } + let _restore = Restore; + layers::PROBE.with(|p| *p.borrow_mut() = probe); + let out = f(); + (out, layers::PROBE.with(|p| p.borrow().clone())) +} + +/// Overlapping: in 37 nested rings around a transparent center, no ring's hole may be filled, and each hole is checked +/// for pixels that are not traced by its rows, not by every pixel it encloses again for each ring around it. +#[test] +fn overlapping_checks_nested_holes_around_a_transparent_center_by_their_rows() { + let mut discs: Vec<(f64, [u8; 4])> = (0..37).map(|k| (148.0 - 4.0 * f64::from(k), if k % 2 == 0 { BLACK } else { WHITE })).collect(); + discs.push((3.0, [0, 0, 0, 0])); + let img = nested_discs(300, WHITE, &discs); + let params = + TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 2, noise: 0, method: Method::Overlapping, ..TraceParams::default() }; + let (res, probe) = probed(layers::Probe::default(), || trace(&img, ¶ms)); + assert_draws(&img, &res, &[]); + assert!(res.paths.iter().any(|t| t.path.subpaths.len() > 1), "no shape kept a hole around the transparent center"); + // The rings' rows down to the center: under 37 × 300 reads. Read by the pixel, the holes took 205,241. + assert!(probe.live_reads > 0 && probe.live_reads < 37 * 300, "{} reads", probe.live_reads); +} + +/// Overlapping: an enclosed area past what is left of the work budget keeps its holes and is charged nothing, so a +/// smaller area after it in the same round is still filled. +#[test] +fn overlapping_charges_nothing_for_an_area_past_its_budget() { + let (red, navy) = ([200, 40, 50, 255], [30, 40, 90, 255]); + // A large black ring around a white field with a red and a navy disc, and a small one around a red disc, below it. + let img = Raster::from_fn(240, 240, |x, y| { + let ring = |x0: u32, x1: u32, t: u32| { + (x0..x1).contains(&x) && (x0..x1).contains(&y) && !((x0 + t..x1 - t).contains(&x) && (x0 + t..x1 - t).contains(&y)) + }; + let at = |cx: f64, cy: f64, r: f64| (f64::from(x) + 0.5 - cx).hypot(f64::from(y) + 0.5 - cy) <= r; + if ring(10, 130, 6) || ring(160, 210, 5) { + BLACK + } else if at(50.0, 60.0, 18.0) || at(185.0, 185.0, 9.0) { + red + } else if at(95.0, 90.0, 15.0) { + navy + } else { + WHITE + } + }); + let params = + TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 4, noise: 0, method: Method::Overlapping, ..TraceParams::default() }; + let (whole, probe) = probed(layers::Probe::default(), || trace(&img, ¶ms)); + assert_draws(&img, &whole, &[]); + assert!(probe.areas.iter().all(|a| a.2), "{:?}", probe.areas); + // An area `j` followed in its round by a smaller one `k`: a budget just short of `j` refuses `j`, and leaves `k` room. + let areas = &probe.areas; + let pair = (0..areas.len()).find_map(|j| (j + 1..areas.len()).find(|&k| areas[k].0 == areas[j].0 && areas[k].1 < areas[j].1).map(|k| (j, k))); + let Some((j, k)) = pair else { panic!("no smaller area after a larger one: {areas:?}") }; + let budget = areas[..j].iter().map(|a| a.1).sum::() + areas[j].1 - 1; + let (res, probe) = probed(layers::Probe { budget: Some(budget), ..Default::default() }, || trace(&img, ¶ms)); + assert!(!probe.areas[j].2 && probe.areas[k].2, "{:?} with a budget of {budget}", probe.areas); + assert_draws(&img, &res, &[]); +} + +/// Overlapping with Create Strokes, and without Fills: a layer still leaves out the parts of its mask without its own +/// color (as with Fills), so a black bar on the light ground gets no blue path under it. +#[test] +fn overlapping_lines_and_outlines_leave_out_areas_without_their_color() { + let (light, blue) = ([240, 240, 220, 255], [40, 120, 240, 255]); + let img = Raster::from_fn(400, 400, |x, y| { + if (170..230).contains(&x) && (170..230).contains(&y) { + blue + } else if (50..350).contains(&x) && (300..340).contains(&y) { + BLACK + } else { + light + } + }); + for fills in [false, true] { + let params = TraceParams { + mode: Mode::Color, + palette: Palette::Limited, + colors: 3, + method: Method::Overlapping, + strokes: true, + fills, + ..TraceParams::default() + }; + let res = trace(&img, ¶ms); + let blues: Vec<_> = res.paths.iter().filter(|p| p.color[0] < 100 && p.color[2] > 150).collect(); + assert!(!blues.is_empty(), "fills {fills}: {:?}", res.palette); + for p in blues { + let b = p.path.bounds().unwrap(); + assert!(b.x1 <= 50.0 || b.x0 >= 350.0 || b.y1 <= 300.0 || b.y0 >= 340.0, "fills {fills}: a blue path over the bar: {b:?}"); + } + } +} + +/// Overlapping: a level's components found with the areas that higher levels fill left out give the same trace as +/// those found whole, and as those found again in the second pass (as past the memory kept for them), on a framed +/// image of rings and scattered blobs of eight colors, whatever the Noise. +#[test] +fn overlapping_gives_the_same_trace_with_covered_areas_left_out() { + let colors: [[u8; 4]; 8] = [ + [240, 235, 220, 255], + [200, 60, 50, 255], + [40, 90, 160, 255], + [230, 190, 60, 255], + [70, 140, 80, 255], + [120, 70, 140, 255], + [20, 20, 30, 255], + [250, 250, 250, 255], + ]; + let img = Raster::from_fn(320, 320, |x, y| { + if x < 10 || y < 10 || x >= 310 || y >= 310 { + return colors[7]; + } + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let ring = (fx - 160.0).hypot(fy - 160.0) / 18.0; + let cell = (x / 23 * 31 + y / 19 * 17) as usize; + let blob = (fx - f64::from(x / 23 * 23) - 11.5).hypot(fy - f64::from(y / 19 * 19) - 9.5) < 6.0; + colors[if blob { cell % 6 + 1 } else { ring as usize % 7 }] + }); + for noise in [0, 4] { + let p = TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 8, noise, method: Method::Overlapping, ..TraceParams::default() }; + let left_out = probed(layers::Probe { one_level_at_a_time: true, ..Default::default() }, || trace(&img, &p)).0; + let whole = probed(layers::Probe { one_level_at_a_time: true, keep_covered: true, ..Default::default() }, || trace(&img, &p)).0; + let again = probed(layers::Probe { keep_none: true, ..Default::default() }, || trace(&img, &p)).0; + assert!(left_out.palette.len() >= 6 && left_out.paths.len() > 20, "{} colors, {} paths", left_out.palette.len(), left_out.paths.len()); + assert_eq!(left_out, whole, "noise {noise}"); + assert_eq!(left_out, again, "noise {noise}: found again"); + assert_draws(&img, &left_out, &[]); + } +} + +/// Overlapping with filled shapes: the trace counts its progress through the rounds of enclosed areas with the same +/// total, and stops when told to there. +#[test] +fn overlapping_counts_its_enclosed_rounds_and_stops_in_them() { + let img = ring_with_patches(12); + let seen = std::sync::Mutex::new(vec![]); + let watched = trace_watched(&img, &patch_params(), usize::MAX, &|done, total| { + seen.lock().unwrap().push((done, total)); + true + }); + assert_eq!(watched.unwrap(), trace(&img, &patch_params()), "watching changes nothing"); + let seen = seen.into_inner().unwrap(); + let total = seen.last().unwrap().1; + assert!(seen.iter().filter(|s| s.1 > 0).all(|s| s.1 == total && s.0 <= total), "{seen:?}"); + assert_eq!(seen.last().unwrap().0, total); + // After the first round's two passes (two thirds of the total), a step for each level of the area the frame fills + // (the picture's seven colors), in each pass. + let later = |done: u64| done * 3 > total * 2 && done < total; + assert!(seen.iter().filter(|s| later(s.0)).count() >= 14, "{seen:?}"); + // Told to stop there. + let res = trace_watched(&img, &patch_params(), usize::MAX, &|done, _| !later(done)); + assert!(matches!(res, Err(TraceError::Cancelled)), "{res:?}"); + assert!(matches!(trace_watched(&img, &patch_params(), usize::MAX, &|done, _| done == 0), Err(TraceError::Cancelled))); +} + #[test] fn transparent_pixels_are_not_traced() { let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [0, 0, 0, 0] } else { BLACK }); diff --git a/crates/ui-egui/src/trace_runs.rs b/crates/ui-egui/src/trace_runs.rs index e05448b10c..3d80c1190a 100644 --- a/crates/ui-egui/src/trace_runs.rs +++ b/crates/ui-egui/src/trace_runs.rs @@ -44,7 +44,8 @@ pub struct Running { token: CancelToken, /// A newer trace of the same object took its place: it ends without a word. superseded: bool, - /// Colour layers traced and in all (0 while the image is prepared). + /// How far the trace has got and in all, as the trace counts it (color layers, or finer steps for Overlapping + /// with Fills; 0 in all while the image is prepared). steps: Arc<[AtomicU64; 2]>, /// The Image Trace panel started it, and takes its result ([`crate::panels::image_trace::traced`]). from_panel: bool, @@ -59,7 +60,7 @@ impl Running { !self.hidden && !self.token.is_cancelled() } - /// The share of its colour layers traced, once they are known. + /// The share of the trace done, once its steps are known. fn fraction(&self) -> Option { let [done, total] = &*self.steps; let (done, total) = (done.load(Ordering::Relaxed), total.load(Ordering::Relaxed)); diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 11de4fbda6..f1c0b7d3dd 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -86,7 +86,19 @@ size. Raster exports with Art Optimized anti-aliasing, the default, do not show pixels and 16,384 pixels on a side. A raster export shows the line as the canvas does when it uses Type Optimized anti-aliasing, or when it uses Art Optimized and is larger than either limit. Overlapping stacks the colors instead: a lower color continues under the edge of the color above it, -and no background shows along that edge. +and no background shows along that edge. With Fills and without Create Strokes, Overlapping also +follows a fill rule of VectorCraft's own: each shape is filled whole, and the area of other colors +that a shape encloses is traced again as shapes of its own and stacked above it. Each enclosed +outline is then fitted once. The [Image Trace presets](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-presets.html) +page describes Overlapping as stacking shapes where it can and overlapping their edges elsewhere. A +shape keeps a hole where the enclosed area holds a transparent pixel or a color that Ignore Color or +Keep Colors leaves out. A hole smaller than Noise is filled with the shape's own color, as before. +The pixels of an area inside other areas are scanned once more for each area around it, within a +work budget of VectorCraft's own: four times the image's pixels (counted as at least 65,536) times +its colors, with each area costing its bounding box times the colors in it. An area that costs more +than what is left of the budget keeps its holes and is not charged, so a smaller area after it can +still be filled. +With Create Strokes, or without Fills, shapes keep their holes. **Flat Logo with named colors.** With `logoColors` named, Flat Logo decides whether the image is flat art by the image's own colors. It finds them the same way as when no colors are named, and looks for as diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..be391d7882 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1349,6 +1349,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Create Strokes baseline (2026-10-17, measured):** the ignored centre-lines class covers 23 exact code-drawn scenes in aa and hard variants, including junctions, crossings, fills meeting strokes and varying widths. [Baseline and metric definitions](image-trace-quality.md#centre-lines). Topology is recovered from emitted geometry; width-profile metrics remain unavailable. No behaviour change or score increase. - **Image Trace, collapsed loops keep few segments:** a loop whose fit collapses to one cubic keeps the fewest segments that hold its area and course, so a higher Paths gives no fewer segments; a code-drawn Paths sweep in `crates/trace` (see [quality notes](image-trace-quality.md#collapsed-loops-keep-few-segments)). - **Image Trace, stacked layers only under their own areas:** with Method Overlapping, a layer reaches under the colors above it only within the connected areas that hold its own color, so a shape that sits apart from a color is no longer written again, at the same bounds, in that color, and the edge of that color's layer no longer shows through that shape's anti-aliased rim. *Measured* by `crates/trace/tests/quality` on each preset's own inputs: High Fidelity Photo 121 paths to 69.6 with ΔE 1.74 to 1.72, Low Fidelity Photo 37.1 paths to 26.1. Code-drawn disc, square and line tests in `crates/trace`. +- **Image Trace, Overlapping shapes filled whole:** with Method Overlapping and Fills (without Create Strokes), each shape is filled whole, and the area of other colors it encloses is traced again as shapes of its own and stacked above it, so each enclosed outline is fitted once; this fill rule and its work budget (four times the image's pixels, counted as at least 65,536, times its colors) are VectorCraft's own. A shape keeps a hole where the area holds a transparent pixel or a color left out, and an area past what is left of the budget keeps its holes. Expand by Color starts another group of a color where an area is drawn above the shape around it. Code-drawn nested disc, ring, frame, Keep Colors, transparency and budget tests in `crates/trace`. - **Image Trace, notches read only their own piece:** the notch rule of `subpixel::outline` reads only the shared piece, so both regions of an edge cut a corner pixel alike and leave no gap; a code-drawn noisy multi-colour coverage test in `crates/trace` (see [quality notes](image-trace-quality.md#notches-read-only-their-own-piece)). - **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)). - **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged. From 55820e48b44893dd46e9e55b90ad020b75d5f8c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:43:19 -0800 Subject: [PATCH 2/3] Image Trace: Overlapping's trace is checked across thread orders, and its memory bound names the kept components The overlapping test now traces the same image eight times with several levels found at a time and compares each result with the one traced a level at a time. The doc comment of the stacked rounds says that the components kept for the second pass take up to 256 MiB outside the half gigabyte that sets how many levels are traced at once. --- crates/trace/src/layers.rs | 3 ++- crates/trace/src/tests.rs | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index dba52ee3cd..1a592cd292 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -722,7 +722,8 @@ impl Layers<'_> { /// /// The marks take two bytes a pixel of the image. In an image with a transparent pixel or a color left out, the /// runs of such pixels take up to four bytes a pixel more during the first round (eight bytes a run). Both count - /// against the half gigabyte that sets how many levels are traced at a time. + /// against the half gigabyte that sets how many levels are traced at a time. The components kept for the second pass + /// take up to [`KEPT_BYTES`] more, outside that half gigabyte. pub(crate) fn stacked( &self, layers: &[(usize, usize)], diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 934ed55880..46d3c7604b 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -557,7 +557,8 @@ fn overlapping_lines_and_outlines_leave_out_areas_without_their_color() { /// Overlapping: a level's components found with the areas that higher levels fill left out give the same trace as /// those found whole, and as those found again in the second pass (as past the memory kept for them), on a framed -/// image of rings and scattered blobs of eight colors, whatever the Noise. +/// image of rings and scattered blobs of eight colors, whatever the Noise. Traced with several levels at a time, in +/// whatever order the threads take them, it gives the same trace each time. #[test] fn overlapping_gives_the_same_trace_with_covered_areas_left_out() { let colors: [[u8; 4]; 8] = [ @@ -588,6 +589,9 @@ fn overlapping_gives_the_same_trace_with_covered_areas_left_out() { assert!(left_out.palette.len() >= 6 && left_out.paths.len() > 20, "{} colors, {} paths", left_out.palette.len(), left_out.paths.len()); assert_eq!(left_out, whole, "noise {noise}"); assert_eq!(left_out, again, "noise {noise}: found again"); + for run in 0..8 { + assert_eq!(left_out, trace(&img, &p), "noise {noise}: run {run} with several levels at a time"); + } assert_draws(&img, &left_out, &[]); } } From db10eaf11515625379ca10c239c6aab9c3d22d37 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:58:38 -0800 Subject: [PATCH 3/3] Image Trace: Overlapping holds an image's untraced pixels in one list of runs The runs of transparent and left-out pixels that the first round checks holes against were kept in a list for each row. A tall, narrow image paid a list header for every row: 1.1 MB for a 3 x 20,000 pixel image with two runs. The runs are now one list over the whole image, so they take at most four bytes a pixel, as the half-gigabyte bound counts them. --- crates/trace/src/layers.rs | 57 +++++++++++++++++--------------------- crates/trace/src/tests.rs | 16 +++++++++++ 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index 1a592cd292..04f687b46b 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -496,7 +496,8 @@ impl Layers<'_> { runs_inside([hole], |y, a, b| { #[cfg(test)] round.live_reads.fetch_add(1, Ordering::Relaxed); - !dead.get(y).is_some_and(|runs| meets(runs, a, b)) + let row = y.saturating_mul(self.w); + !meets(dead, row.saturating_add(a), row.saturating_add(b)) }) }; let mut marked = vec![]; @@ -739,17 +740,13 @@ impl Layers<'_> { // Two bytes a pixel for the marks. let marks: Vec = (0..n).map(|_| AtomicU16::new(0)).collect(); let untraced = |l: u16| l == TRANSPARENT || self.ignored(l); - // Eight bytes a run of untraced pixels, at most four a pixel (where every other pixel is one), and a list a row. - let mut dead = self.traced.iter().any(|&l| untraced(l)).then(|| dead_runs(self.w, self.traced, untraced)); - let dead_bytes = dead.as_ref().map_or(0, |rows| { - rows.iter().fold(0usize, |s, r| { - s.saturating_add(size_of::>()).saturating_add(r.capacity().saturating_mul(size_of::<(u32, u32)>())) - }) - }); + // Eight bytes a run of untraced pixels, at most four a pixel (where every other pixel is one). + let mut dead = self.traced.iter().any(|&l| untraced(l)).then(|| dead_runs(self.traced, untraced)); + let dead_bytes = dead.as_ref().map_or(0, |runs| runs.capacity().saturating_mul(size_of::<(u32, u32)>())); #[cfg(test)] let probe = PROBE.with(|p| { let mut p = p.borrow_mut(); - (p.live_reads, p.areas) = (0, vec![]); + (p.live_reads, p.areas, p.dead_bytes) = (0, vec![], dead_bytes); p.clone() }); #[cfg(test)] @@ -1074,9 +1071,9 @@ struct Round<'a> { /// it; 0 where none does. marks: &'a [AtomicU16], /// In the first round, where a hole may hold a pixel that the layers do not trace (transparent, or a color left - /// out), the runs of such pixels in each row, left to right as `(x0, x1)`: an area is filled only where it holds - /// none. `None` in later rounds and in images without such pixels. - dead: Option<&'a [Vec<(u32, u32)>]>, + /// out), the runs of such pixels (see [`dead_runs`]): an area is filled only where it holds none. `None` in later + /// rounds and in images without such pixels. + dead: Option<&'a [(u32, u32)]>, /// Tests: the reads made to check a hole against `dead`. #[cfg(test)] live_reads: &'a AtomicUsize, @@ -1129,6 +1126,8 @@ pub(crate) struct Probe { pub(crate) budget: Option, /// Counted: the reads the first round made to check that a hole holds only pixels the layers trace. pub(crate) live_reads: usize, + /// Counted: the bytes the runs of untraced pixels take. + pub(crate) dead_bytes: usize, /// Counted: each area of a later round, as `(round, cost, filled)`, in the order the budget was charged. pub(crate) areas: Vec<(usize, u64, bool)>, } @@ -1138,25 +1137,21 @@ thread_local! { pub(crate) static PROBE: std::cell::RefCell = std::cell::RefCell::new(Probe::default()); } -/// The runs of the pixels in each row of a `w` pixels wide image for which `dead` holds, left to right as `(x0, x1)`. -fn dead_runs(w: usize, labels: &[u16], dead: impl Fn(u16) -> bool) -> Vec> { - labels - .chunks(w.max(1)) - .map(|row| { - let mut runs: Vec<(u32, u32)> = vec![]; - for (x, &l) in row.iter().enumerate() { - let x = u32::try_from(x).unwrap_or(u32::MAX); - if !dead(l) { - continue; - } - match runs.last_mut() { - Some(r) if r.1 == x => r.1 = x.saturating_add(1), - _ => runs.push((x, x.saturating_add(1))), - } - } - runs - }) - .collect() +/// The runs of the pixels of `labels` for which `dead` holds, in order as `(i0, i1)` (indices into `labels`, so that a +/// run goes on into the next row; one list for the image, whatever its height). +fn dead_runs(labels: &[u16], dead: impl Fn(u16) -> bool) -> Vec<(u32, u32)> { + let mut runs: Vec<(u32, u32)> = vec![]; + for (i, &l) in labels.iter().enumerate() { + let i = u32::try_from(i).unwrap_or(u32::MAX); + if !dead(l) { + continue; + } + match runs.last_mut() { + Some(r) if r.1 == i => r.1 = i.saturating_add(1), + _ => runs.push((i, i.saturating_add(1))), + } + } + runs } /// Whether any of the runs `runs` (left to right, apart) meets the pixels `a..b`. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 46d3c7604b..ce343ceb68 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -485,6 +485,22 @@ fn overlapping_checks_nested_holes_around_a_transparent_center_by_their_rows() { assert!(probe.live_reads > 0 && probe.live_reads < 37 * 300, "{} reads", probe.live_reads); } +/// Overlapping: in a tall, narrow image that is transparent but for a black block with a white center, the runs of +/// untraced pixels take a few bytes for the whole image, not a list for each row. +#[test] +fn overlapping_holds_the_untraced_pixels_of_a_tall_narrow_image_in_a_few_runs() { + let img = Raster::from_fn(3, 20_000, |x, y| match y { + 100..110 if x == 1 && (104..106).contains(&y) => WHITE, + 100..110 => BLACK, + _ => [0, 0, 0, 0], + }); + let params = + TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 2, noise: 0, method: Method::Overlapping, ..TraceParams::default() }; + let (res, probe) = probed(layers::Probe::default(), || trace(&img, ¶ms)); + assert_draws(&img, &res, &[]); + assert!(probe.dead_bytes > 0 && probe.dead_bytes <= 64, "{} bytes", probe.dead_bytes); +} + /// Overlapping: an enclosed area past what is left of the work budget keeps its holes and is charged nothing, so a /// smaller area after it in the same round is still filled. #[test]