diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs new file mode 100644 index 000000000..bf5bfc402 --- /dev/null +++ b/crates/trace/src/layers.rs @@ -0,0 +1,489 @@ +use super::params::{Method, Mode, TraceParams}; +#[cfg(test)] +use super::pipeline::bw_tones; +use super::pipeline::{TracedPath, is_ignored}; +use super::raster::Raster; +use super::*; + +/// What tracing one colour layer needs. +pub(crate) struct Layers<'a> { + pub(crate) q: &'a Quantized, + /// Each palette entry's layer, bottom (0) up. + pub(crate) rank: &'a [usize], + pub(crate) params: &'a TraceParams, + pub(crate) img: &'a Raster, + /// The colour Ignore Color leaves out, if not white. + pub(crate) ignore: Option<[u8; 3]>, + /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. + pub(crate) boxes: Vec>, + /// Gradients: the gradient each palette entry is, if it is one. + pub(crate) gradients: &'a [Option], + /// Black and White: the luminance of white and of black (see `bw_tones`). + pub(crate) bw_tones: [f64; 2], + /// How outlines and centre lines are fitted. + pub(crate) style: logo::Style, + pub(crate) w: usize, + pub(crate) h: usize, + /// Anchor points made so far, by every layer. + pub(crate) anchors: AtomicUsize, + pub(crate) max_anchors: usize, +} + +impl Layers<'_> { + /// Ignore White: white is no part of any layer (stacked layers reach under the layers above + /// them, and must not reach under white that is left out). + fn ignored(&self, label: u16) -> bool { + self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) + } + /// The region across a crack, for cutting outlines at their junctions: the outside pixel's + /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. + fn neighbour(&self, c: Crack) -> u32 { + if self.params.method == Method::Overlapping { + return 0; + } + c.at(1).and_then(|i| self.q.labels.get(i)).map_or(u32::from(TRANSPARENT), |&l| u32::from(l)) + } + /// The junction at pixel corner `(x, y)`, if there is one: where three or four regions + /// meet, or two parts of a region touch by the corner only, or an edge reaches the image's + /// border. It lies at the corner, moved across by what the edges that run up and down from it + /// say, and up or down by what those to its left and right say (the mean, where there are + /// two). It depends on the corner alone, so every region around it gets the same point; on the + /// image's border it stays on the border. + fn junction(&self, x: i32, y: i32) -> Option { + let label = |px: i32, py: i32| Crack::new((px, py), (1, 0), self.w, self.h).at(0).and_then(|i| self.q.labels.get(i).copied()); + // The four pixels around the corner (`None` off the image), and the edges between them. + let around = [label(x - 1, y - 1), label(x, y - 1), label(x, y), label(x - 1, y)]; + let edges = (0..4).filter(|&i| around[i] != around[(i + 1) % 4]).count(); + let apart = |a: usize, b: usize, c: usize, d: usize| around[a] == around[c] && around[b] != around[a] && around[d] != around[a]; + let touching = apart(0, 1, 2, 3) || apart(1, 2, 3, 0); + // (Stacked layers share no edges: they are cut at the image's border only.) + if edges < 3 && !touching || self.params.method == Method::Overlapping { + return None; + } + // The shift of the crack between `a` and the pixel `step` from it, if both are in the + // image and of different regions. + let shift = |a: (i32, i32), step: (i32, i32)| { + let (la, lb) = (label(a.0, a.1)?, label(a.0 + step.0, a.1 + step.1)?); + (la != lb).then(|| self.shift(Crack::new(a, step, self.w, self.h)).clamp(-0.5, 0.5)) + }; + let mean = |a: Option, b: Option| match (a, b) { + (Some(a), Some(b)) => (a + b) / 2.0, + (Some(v), None) | (None, Some(v)) => v, + (None, None) => 0.0, + }; + let dx = mean(shift((x - 1, y - 1), (1, 0)), shift((x - 1, y), (1, 0))); + let dy = mean(shift((x - 1, y - 1), (0, 1)), shift((x, y - 1), (0, 1))); + Some(vectorcraft_geom::Point::new(f64::from(x) + dx, f64::from(y) + dy)) + } + /// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5). + /// + /// Each pixel of the column across the crack is read as a blend of the tones on its two sides + /// (the two pixels' palette colours; in Black and White the luminance of the image's black and of + /// the white pixels). The edge is where the blends, summed, put it: as far out as there is + /// inside tone in the outside pixels, less the outside tone in the inside ones (two pixels each + /// way, while they are on the same side and no more blended than the pixel before: more would be + /// another edge's doing). + /// + /// Where the inside is at most three pixels deep with the outside's colour behind it (a thin + /// line), its two edges share those pixels and cannot be read apart; the inside tone of the + /// whole column gives the line's middle (its centroid) and width (its sum) instead. A thin gap + /// between two parts of the inside is read the same way, with the tones swapped. + /// + /// Black and White then moves the edge to where the luminance crosses the threshold: by the + /// threshold's distance from the middle tone, over how steep the edge is (nothing for a + /// threshold in the middle, more the softer the edge). + /// + /// The estimate is read from each side of the crack and the two readings averaged, so that + /// the shift is exactly antisymmetric (`a - b == -(b - a)` in floating point) and the two + /// regions of a crack get the same points. A single reading is not: it looks for a thin run on + /// the inside before one on the outside, so where both sides are thin (a one- or two-pixel + /// sliver beside another, as in soft gradients and noisy scans) each region got its own shift, + /// their shared piece different points, and the abutting curves left a sliver uncovered. + fn shift(&self, c: Crack) -> f64 { + (self.shift_one_side(c) - self.shift_one_side(c.flipped())) / 2.0 + } + + /// [`Self::shift`] read from the crack's inside pixel only. + fn shift_one_side(&self, c: Crack) -> f64 { + let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT); + let (Some(la), Some(lb)) = (label(0), label(1)) else { return 0.0 }; + if la == lb { + return 0.0; + } + let bw = self.params.mode == Mode::BlackAndWhite; + // Black and White: black is the image's black; white is the lightest pixel within a few + // pixels of the crack, the paper as it is here (a scan's paper is darker in one corner than + // in another, and read against the image's lightest white it would count as part ink). + let black = self.bw_tones[1]; + let white = if bw { + let light = (-3..=4) + .filter_map(|k| c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3))) + .map(|p| (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0); + light.fold(f64::NEG_INFINITY, f64::max).clamp((black + 32.0).min(self.bw_tones[0]), self.bw_tones[0]) + } else { + self.bw_tones[0] + }; + let (ca, cb) = if bw { + let tone = |l: u16| [if l == 1 { black } else { white }; 3]; + (tone(la), tone(lb)) + } else { + // A side's own colour: its palette entry's, or its gradient's colour here. + let own = |l: u16, k: i32| { + let flat = self.q.palette.get(l as usize)?.map(f64::from); + let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; + let i = c.at(k)?; + Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) + }; + let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 }; + (ca, cb) + }; + let d = [0, 1, 2].map(|k| cb[k] - ca[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + if len2 <= 0.0 { + return 0.0; + } + // How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image. + let inside = |k: i32| { + let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 }; + let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; + let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p }; + 1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::() / len2).clamp(0.0, 1.0) + }; + // The edge from a thin run of `n` pixels starting at pixel `from` and going `dir`, by the + // amounts `tone` of the run's own tone in it and in the two pixels at either end of it. + // Returns the run's middle and width, measured from the crack towards the outside. + let thin = |from: i32, dir: i32, n: i32, tone: &dyn Fn(i32) -> f64| { + let (near, far) = (from - dir, from + n * dir); + let mut cells: Vec<(i32, f64)> = (0..n).map(|j| from + j * dir).chain([near, far]).map(|k| (k, tone(k))).collect(); + for (edge, out) in [(near, -dir), (far, dir)] { + if label(edge + out) == label(edge) { + cells.push((edge + out, tone(edge + out).min(tone(edge)))); + } + } + let width: f64 = cells.iter().map(|c| c.1).sum(); + // Pixel `k`'s centre is `k - 0.5` from the crack. + (width > 1e-6).then(|| (cells.iter().map(|c| c.1 * (f64::from(c.0) - 0.5)).sum::() / width, width)) + }; + // How deep each side is here: up to three pixels of its own label, then the other's. + let depth = |dir: i32, own: u16, other: u16| { + let start = if dir < 0 { 0 } else { 1 }; + let n = (1..=3).take_while(|j| label(start + j * dir) == Some(own)).count() as i32 + 1; + (n <= 3 && label(start + n * dir) == Some(other)).then_some(n) + }; + let mut steep = 1.0; + let mut shift = if let Some((middle, width)) = depth(-1, la, lb).and_then(|n| thin(0, -1, n, &inside)) { + middle + width / 2.0 + } else if let Some((middle, width)) = depth(1, lb, la).and_then(|n| thin(1, 1, n, &|k| 1.0 - inside(k))) { + middle - width / 2.0 + } else { + let (a1, b1) = (1.0 - inside(0), inside(1)); + let a2 = (label(-1) == Some(la)).then(|| (1.0 - inside(-1)).min(a1)); + let b2 = (label(2) == Some(lb)).then(|| inside(2).min(b1)); + // The edge's steepness per pixel: the square of the change over two pixels (1 for an + // anti-aliased edge, less for a blurred one). + let change = [a2.map(|v| 1.0 - v - b1), b2.map(|v| 1.0 - a1 - v)].into_iter().flatten().fold(f64::NAN, f64::max); + if !change.is_nan() { + steep = (change * change).clamp(0.35, 1.0); + } + b1 + b2.unwrap_or(0.0) - a1 - a2.unwrap_or(0.0) + }; + if bw { + // The share of black at which a pixel turns black. + let turn = ((white - f64::from(self.params.threshold)) / (white - black).max(1.0)).clamp(0.02, 0.98); + shift += if la == 1 { 1.0 } else { -1.0 } * (0.5 - turn) / steep; + } + shift + } + + /// Whether the pixels inside `l` (a loop along pixel edges) all have one label. + fn one_colour_within(&self, l: &Loop) -> bool { + let mut first = None; + let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1))); + for y in y0..y1 { + // The loop's upright edges that pass this row of pixels, left to right: the pixels + // between the first and the second are inside, and so on. + let mut xs: Vec = l + .pts + .iter() + .zip(l.pts.iter().cycle().skip(1)) + .filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1)) + .map(|(a, _)| a.0) + .collect(); + xs.sort_unstable(); + for pair in xs.as_chunks::<2>().0 { + for x in pair[0]..pair[1] { + let label = usize::try_from(y) + .ok() + .zip(usize::try_from(x).ok()) + .and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w)); + if label.is_none() || *first.get_or_insert(label) != label { + return false; + } + } + } + } + first.is_some() + } + + /// Shapes, Abutting: the shape the outline `sp` (fitted from the loop `l`, `hard` as that + /// loop is) is, where it may be one. The edge is shared by the regions on its two sides, so + /// both must become that same shape or neither: only a closed edge between two regions, a + /// region whose whole outline is the hole of one other (no junction on it, not on the image's + /// border), and only a shape inside the image. The shape is read from the outline run one way + /// from one start, so the region and the hole it fills get the very same one. + fn shared_shape(&self, l: &Loop, sp: &vectorcraft_geom::SubPath, hard: bool) -> Option { + let (w, h) = (i32::try_from(self.w).ok()?, i32::try_from(self.h).ok()?); + if l.pts.iter().any(|&(x, y)| x <= 0 || y <= 0 || x >= w || y >= h || self.junction(x, y).is_some()) { + return None; + } + let mut one_way = sp.clone(); + if one_way.signed_area() < 0.0 { + one_way.reverse(); + } + let first = one_way.anchors.iter().enumerate().min_by(|a, b| a.1.p.x.total_cmp(&b.1.p.x).then(a.1.p.y.total_cmp(&b.1.p.y)))?.0; + one_way.anchors.rotate_left(first); + let shape = shapes::recognise(&one_way, hard)?; + let (wf, hf) = (self.w as f64, self.h as f64); + let inside = shape + .path() + .subpaths + .iter() + .flat_map(|s| &s.anchors) + .flat_map(|a| [a.p, a.h_in, a.h_out]) + .all(|p| (0.0..=wf).contains(&p.x) && (0.0..=hf).contains(&p.y)); + inside.then_some(shape) + } + + /// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the + /// maximum. + 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.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) + } else { + (vec![], vec![]) + }; + // 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); + 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 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.q.labels.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; + } + } + for (m, &c) in mask.iter_mut().zip(&comp) { + *m = *m && own.get(c as usize).copied().unwrap_or(false); + } + } + // 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; + let mut paths = vec![]; + let mut add = |path: PathData, pixels: usize, stroke: Option, shape: Option, square_caps: bool| { + let n = path.anchor_count(); + if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors { + return false; + } + // (A gradient fills; a stroked line of it is its middle colour.) + let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none()); + 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); + } + } + } + for comp in comps { + // (`hard`: some of the outline has no anti-aliasing, and is known less exactly.) + let hard = std::cell::Cell::new(false); + // (Each loop fitted, and whether its own outline is hard.) + let fit = |l: &Loop| { + let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?; + // Every trace straightens the pieces of an outline that lie close to a line (Snap + // Curves To Lines straightens more of them), so the outline of a single pixel or + // of a region one pixel thick can have all its pieces on one chord, and the loop + // then has no area. Filled, such a loop paints nothing, and it is left out. + // Without Fills its 1 px stroke draws it, so it is kept. (`fit::fit_closed` puts + // back the positioned outline of a loop that is one closed piece when its fit has + // no area.) The area is summed about the loop's first anchor. + if area_stroke.is_none() { + use kurbo::ParamCurveArea; + let origin = sp.anchors.first()?.p.to_vec2(); + let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum(); + if area.abs() < 1e-6 { + return None; + } + } + hard.set(hard.get() || is_hard); + Some((sp, is_hard)) + }; + let Some((outer, _)) = fit(&comp.outer) else { continue }; + let mut subs = vec![outer]; + for hole in &comp.holes { + if hole.area2.abs() / 2 < min_hole && params.noise > 0 { + continue; + } + let Some((mut sp, own_hard)) = fit(hole) else { continue }; + // Shapes: a hole that is a shape is that shape, as what fills it is (the two are + // one outline: they stay one). Only where one colour fills it: the parts of + // several are not that shape, and would no longer meet it. Abutting, only where the + // region that fills it is that shape too (see `shared_shape`). + let exact = if !params.shapes || area_stroke.is_some() || !self.one_colour_within(hole) { + None + } else if overlapping { + shapes::recognise(&sp, hard.get()) + } else { + // (As hard as this outline is, as the region that fills it reads it.) + self.shared_shape(hole, &sp, own_hard) + }; + if let Some(exact) = exact.and_then(|shape| shape.path().subpaths.into_iter().next()) { + // (Which way round each goes: by the area of the polygon through points along it.) + let turn = |sp: &vectorcraft_geom::SubPath| { + use vectorcraft_geom::ParamCurve; + let pts: Vec<_> = + (0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect(); + pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::() + }; + let mut exact = exact; + if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) { + exact.reverse(); + } + sp = exact; + } + subs.push(sp); + } + // Shapes: a filled outline without holes that is a shape becomes that shape. + let shape = match subs.as_slice() { + [only] if params.shapes && area_stroke.is_none() && overlapping => shapes::recognise(only, hard.get()), + // Abutting: only an island in one other region, whose hole it is (see `shared_shape`). + [only] if params.shapes && area_stroke.is_none() && comp.holes.is_empty() => self.shared_shape(&comp.outer, only, hard.get()), + _ => None, + }; + let path = shape.map_or_else(|| PathData::new(subs), |s| s.path()); + if !add(path, comp.pixels, area_stroke, shape, false) { + return None; + } + } + for line in lines { + if !add(line.path, line.pixels, Some(line.width), None, line.square) { + return None; + } + } + Some(paths) + } +} + +/// Test probe: every crack between two labels of `img` quantised as `trace` does (no gradients), +/// as `[shift, shift from the other side, one-sided reading, one-sided reading from the other +/// side]`. +#[cfg(test)] +pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { + let (w, h) = (img.width as usize, img.height as usize); + let mut q = quantize(img, params); + denoise(&mut q.labels, w, h, params.noise as usize); + let n = q.palette.len(); + let rank: Vec = (0..n).collect(); + let gradients = vec![None; n]; + let job = Layers { + q: &q, + rank: &rank, + params, + img, + ignore: None, + boxes: vec![None; n], + gradients: &gradients, + bw_tones: bw_tones(img, &q), + style: params.outline_style(), + w, + h, + anchors: AtomicUsize::new(0), + max_anchors: usize::MAX, + }; + let mut out = vec![]; + for y in 0..h as i32 { + for x in 0..w as i32 { + for step in [(1, 0), (0, 1)] { + let c = Crack::new((x, y), step, w, h); + let (Some(a), Some(b)) = (c.at(0), c.at(1)) else { continue }; + if q.labels[a] != q.labels[b] { + out.push([job.shift(c), job.shift(c.flipped()), job.shift_one_side(c), job.shift_one_side(c.flipped())]); + } + } + } + } + out +} + +/// `f` of each of `items`, in order, spread over the available cores. +pub(crate) fn parallel_map(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + parallel_map_at_most(usize::MAX, items, f) +} + +/// `f` of each of `items`, in order, spread over the available cores, at most `at_a_time` at once. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn parallel_map_at_most(at_a_time: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len()).min(at_a_time); + if threads <= 1 { + return items.iter().map(f).collect(); + } + let next = AtomicUsize::new(0); + let mut done: Vec<(usize, R)> = std::thread::scope(|s| { + let workers: Vec<_> = (0..threads) + .map(|_| { + s.spawn(|| { + let mut mine = vec![]; + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(i) else { break }; + mine.push((i, f(item))); + } + mine + }) + }) + .collect(); + // A worker's panic goes on in this thread, as if it had run here (the engine's guard + // reports it). + workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect() + }); + done.sort_by_key(|(i, _)| *i); + done.into_iter().map(|(_, r)| r).collect() +} + +/// `f` of each of `items`, in order (no threads on the web). +#[cfg(target_arch = "wasm32")] +pub(crate) fn parallel_map_at_most(_: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + items.iter().map(f).collect() +} diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 88e882f98..85e6373c6 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -68,1116 +68,21 @@ pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; pub use shapes::TracedShape; use subpixel::Crack; -/// Errors decoding a raster. -#[derive(Debug, thiserror::Error)] -pub enum TraceError { - #[error("could not decode image: {0}")] - Decode(String), - #[error("image is empty")] - Empty, - #[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")] - TooLarge { width: u32, height: u32, max: u64 }, - #[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())] - NotFlat { fraction: f64 }, - #[error("\"{0}\" is not a colour: write it as #rrggbb")] - BadPalette(String), - #[error( - "this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset" - )] - TooComplex { max: usize }, -} +mod layers; +mod params; +mod pipeline; +mod raster; -/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are -/// refused instead of running out of memory. -pub const MAX_PIXELS: u64 = 64 << 20; - -/// The most pixels an image may have for [`Raster::decode_to_fit`] to decode it and scale it down -/// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused. -pub const MAX_SOURCE_PIXELS: u64 = 192 << 20; - -/// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1). -fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { - let pixels = u64::from(w) * u64::from(h); - if pixels <= max || pixels == 0 { - return (w, h); - } - let k = (max as f64 / pixels as f64).sqrt(); - let side = |v: u32| ((f64::from(v) * k).floor() as u32).clamp(1, v); - let (mut nw, mut nh) = (side(w), side(h)); - // A side held at 1 pixel leaves the other too long: cut it to what fits. - if u64::from(nw) * u64::from(nh) > max { - if nw >= nh { - nw = u32::try_from(max / u64::from(nh)).unwrap_or(nw).clamp(1, nw); - } else { - nh = u32::try_from(max / u64::from(nw)).unwrap_or(nh).clamp(1, nh); - } - } - (nw, nh) -} - -/// An RGBA8 raster (row-major, top row first). -#[derive(Clone, Debug, PartialEq)] -pub struct Raster { - pub width: u32, - pub height: u32, - pub rgba: Vec, -} - -impl Raster { - /// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other - /// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not - /// a crash. (That error's text is already translated; its detail names the sizes.) - pub fn new(width: u32, height: u32, rgba: Vec) -> Result { - let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4)); - if want != Some(rgba.len()) { - return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len()))); - } - Ok(Self { width, height, rgba }) - } - /// A raster whose pixel `(x, y)` is `f(x, y)`. - pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self { - let mut rgba = Vec::with_capacity(width as usize * height as usize * 4); - for y in 0..height { - for x in 0..width { - rgba.extend_from_slice(&f(x, y)); - } - } - Self { width, height, rgba } - } - /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). - pub fn decode(bytes: &[u8]) -> Result { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; - if w == 0 || h == 0 { - return Err(TraceError::Empty); - } - if u64::from(w) * u64::from(h) > MAX_PIXELS { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); - } - let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); - let (w, h) = img.dimensions(); - Ok(Self { width: w, height: h, rgba: img.into_raw() }) - } - /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to - /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within - /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly - /// less than its own resolution. The caller maps the result back with `width` / `height`. - pub fn decode_to_fit(bytes: &[u8]) -> Result { - let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); - let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let (w, h) = reader.into_dimensions().map_err(decode_err)?; - if w == 0 || h == 0 { - return Err(TraceError::Empty); - } - let pixels = u64::from(w) * u64::from(h); - if pixels <= MAX_PIXELS { - return Self::decode(bytes); - } - if pixels > MAX_SOURCE_PIXELS { - return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); - } - // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. - // A deeper image (16 bits a channel) that needs more fails with a decode error. - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; - let mut limits = image::Limits::default(); - limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); - reader.limits(limits); - let img = reader.decode().map_err(decode_err)?.into_rgba8(); - let (w, h) = img.dimensions(); - let (nw, nh) = fit_size(w, h, MAX_PIXELS); - // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. - let small = image::imageops::thumbnail(&img, nw, nh); - Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) - } - /// Encode as PNG. - pub fn encode_png(&self) -> Vec { - let mut out = Vec::new(); - if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { - let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); - } - out - } - /// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose - /// fields were set to a size its bytes don't have). - pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { - if x >= self.width || y >= self.height { - return [0; 4]; - } - let i = (y as usize * self.width as usize + x as usize) * 4; - match self.rgba.get(i..i + 4) { - Some(&[r, g, b, a]) => [r, g, b, a], - _ => [0; 4], - } - } -} - -/// Tracing mode (Illustrator's Mode popup). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Mode { - #[default] - #[serde(alias = "bw", alias = "blackWhite", alias = "Black and White")] - BlackAndWhite, - #[serde(alias = "gray", alias = "Grayscale")] - Grayscale, - #[serde(alias = "Color")] - Color, - /// Flat-colour logos: sub-pixel edges, sharp corners restored, true lines and circles. - #[serde(alias = "Logo", alias = "flat", alias = "flatLogo")] - Logo, -} - -/// How colour layers relate (Advanced → Method). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Method { - /// Each colour traced on its own; neighbouring shapes share their edge. - #[default] - Abutting, - /// Stacked: every layer extends under the layers above it, within the areas that hold its own - /// color. - Overlapping, -} - -/// Where Color mode's colours come from (the Palette popup). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Palette { - /// The `colors` colours that best represent the image. - #[default] - #[serde(alias = "Limited")] - Limited, - /// As many colours as the image's tones need, up to [`FULL_TONE_MAX`] (photos, gradients): - /// `color_detail` sets how close two colours may be and stay apart. - #[serde(alias = "Full Tone")] - FullTone, - /// Flat art (a few colours cover nearly all of it) traces with exactly those colours, anything - /// else as Full Tone. - #[serde(alias = "Automatic")] - Automatic, - /// The most used colours of a swatch library ([`TraceParams::swatches`]), at most `colors` of - /// them, exactly as they are. Each pixel takes the library color nearest in lightness and - /// hue, with hue and chroma counted three times as much as lightness, so that neutral tones - /// stay off saturated colors. - #[serde(alias = "library", alias = "Document Library")] - DocumentLibrary, -} - -/// Image Trace parameters (the Image Trace panel). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct TraceParams { - pub mode: Mode, - /// Black and White: pixels darker than this (luminance 0–255) are black. - pub threshold: u8, - /// Color: where the colours come from. - pub palette: Palette, - /// Palette size for Color (Limited; at most this many for Document Library), number of grays - /// for Grayscale (2–256). - pub colors: u32, - /// Full Tone and Automatic: how many colours, 0 (few) – 100 (many). - pub color_detail: f64, - /// Document Library: the swatch library, `"document"` (the document's swatches) or a library id - /// or name. The caller looks its colours up into `swatches`. - pub library: String, - /// Document Library with the document's swatches: the name of the color group whose colors are - /// used. `None` (or empty) uses every swatch. `imageTrace.make` refuses a name with any other - /// palette, library or mode. - pub color_group: Option, - /// Document Library: the library's colours (sRGB). Not saved: they are looked up from - /// `library` (and `color_group`) at every trace. - #[serde(skip)] - pub swatches: Vec<[u8; 3]>, - /// Paths (fidelity) 0–100: higher follows the pixels more tightly. - pub paths: f64, - /// Corners 0–100: higher keeps more corners. - pub corners: f64, - /// Noise: areas smaller than this many pixels are ignored. (Flat Logo mode: areas smaller than - /// `noise / 10` pixels of the source image.) - pub noise: u32, - pub method: Method, - /// Ignore Color: drop the areas of one colour (no background shape): white, unless - /// `ignore_color` names another. - pub ignore_white: bool, - /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels - /// of it. `None` is white. (Flat Logo mode leaves out white only.) - pub ignore_color: Option, - /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs - /// to true circles. - pub snap_curves_to_lines: bool, - /// Shapes: a filled outline that is a circle, an ellipse, a rectangle (square or rounded - /// corners) or a regular polygon comes out as exactly that shape ([`TracedPath::shape`]). - pub shapes: bool, - /// Auto Grouping: say which path lies inside which ([`TraceResult::parents`]). - pub auto_grouping: bool, - /// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction - /// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour. - /// Grayscale finds them in the image's grays, and their stops are gray. - pub gradients: bool, - /// Create: trace areas as filled shapes. - pub fills: bool, - /// Create: trace lines no wider than `stroke_width` as stroked centre lines. - pub strokes: bool, - /// Stroke: the widest feature (px) traced as a stroke. - pub stroke_width: f64, - /// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is - /// always traced on its own and cannot be named, colours closer than about 12 (RGB distance) - /// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.) - pub logo_colors: Vec, -} - -impl Default for TraceParams { - /// Illustrator's [Default]: black and white, threshold 128. - fn default() -> Self { - Self { - mode: Mode::BlackAndWhite, - threshold: 128, - palette: Palette::Limited, - colors: 6, - color_detail: 50.0, - library: "document".into(), - color_group: None, - swatches: vec![], - paths: 50.0, - corners: 75.0, - noise: 25, - method: Method::Abutting, - ignore_white: false, - ignore_color: None, - snap_curves_to_lines: false, - shapes: false, - auto_grouping: false, - gradients: false, - fills: true, - strokes: false, - stroke_width: 10.0, - logo_colors: Vec::new(), - } - } -} - -impl TraceParams { - /// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100, - /// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0). - /// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones, - /// makes parts of circles true arcs, and makes lines within 2.5° of level or upright exactly so. - fn outline_style(&self) -> logo::Style { - // Parameters come from users and agents: a NaN or infinity must not reach the geometry. - let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default }; - let snap = self.snap_curves_to_lines; - logo::Style { - step: 0.25, - tol: 0.08 + 0.7 * (1.0 - unit(self.paths, 0.5)).powf(1.5), - corner_deg: 90.0 - 55.0 * unit(self.corners, 0.75), - snap: true, - arcs: snap, - line_tol: if snap { 0.5 } else { 0.2 }, - circle_tol: if snap { 0.3 } else { 0.15 }, - restore: Some(logo::RestoreParams::default()), - check_step: 0.15, - upright: snap, - ..logo::Style::default() - } - } -} - -/// Built-in preset names in panel order (our own parameter sets). -pub const PRESET_NAMES: &[&str] = &[ - "Default", - "Auto Color", - "High Fidelity Photo", - "Low Fidelity Photo", - "3 Colors", - "6 Colors", - "16 Colors", - "Shades of Gray", - "Black and White Logo", - "Sketched Art", - "Silhouettes", - "Line Art", - "Technical Drawing", - "Outline", - "Flat Logo", -]; - -/// A built-in preset by name (case-insensitive; `[Default]` is accepted). -pub fn preset(name: &str) -> Option { - let key = name.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); - let d = TraceParams::default(); - let color = |colors: u32, paths: f64, corners: f64, noise: u32, method: Method| TraceParams { - mode: Mode::Color, - colors, - paths, - corners, - noise, - method, - ..TraceParams::default() - }; - let bw = |threshold: u8, paths: f64, corners: f64, noise: u32, snap: bool| TraceParams { - mode: Mode::BlackAndWhite, - threshold, - paths, - corners, - noise, - ignore_white: true, - snap_curves_to_lines: snap, - ..TraceParams::default() - }; - Some(match key.as_str() { - "default" => d, - // A posterised image of a photo or of artwork: its own colours where it is flat art, as - // many as its tones need where it is not. - // (Color detail 85: a colour on a hundredth of the picture is still one of its colours.) - "auto color" => TraceParams { palette: Palette::Automatic, color_detail: 85.0, ..color(6, 65.0, 60.0, 16, Method::Abutting) }, - "high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping), - "low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping), - "3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting), - "6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting), - "16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting), - "shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d }, - "black and white logo" => bw(128, 95.0, 80.0, 8, true), - "sketched art" => bw(150, 50.0, 50.0, 100, false), - "silhouettes" => bw(200, 40.0, 60.0, 30, false), - "line art" => bw(128, 80.0, 70.0, 10, false), - "technical drawing" => bw(128, 95.0, 90.0, 4, true), - // Black outlines: lines as stroked centre lines, wider areas as stroked outlines. - "outline" => TraceParams { fills: false, strokes: true, ..bw(128, 80.0, 70.0, 10, false) }, - // Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel. - "flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d }, - _ => return None, - }) -} - -/// All built-in presets in panel order. -pub fn presets() -> Vec<(&'static str, TraceParams)> { - PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect() -} - -/// The enhanced version of built-in preset `name`: the preset with the options the enhanced -/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo. -/// Gradients stay an option to turn on. Flat Logo, a preset of our own, has none. -pub fn enhanced_preset(name: &str) -> Option { - let p = preset(name).filter(|p| p.mode != Mode::Logo)?; - Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo"), ..p }) -} - -/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`) -/// lines stroked with it. -#[derive(Clone, Debug, PartialEq)] -pub struct TracedPath { - /// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`). - pub path: PathData, - pub color: [u8; 3], - /// Filled pixel count of the component the path came from. - pub pixels: usize, - /// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the - /// outlines of wider areas without Fills). - pub stroke: Option, - /// Shapes: the shape the path is, exactly (`path` is that shape's outline). - pub shape: Option, - /// Gradients: the linear gradient the path is filled with (`color` is its middle colour). - pub gradient: Option, - /// Create Strokes: the stroke's ends are square (a cap that goes half the width past the - /// path's end, cut straight), as the line's ink is; otherwise round. - pub square_caps: bool, -} - -/// The traced result, bottom-most path first. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct TraceResult { - pub paths: Vec, - /// Colours actually used. - pub palette: Vec<[u8; 3]>, - /// Auto Grouping: for each of `paths`, the path it lies inside, if any (empty without the - /// option). Each path drawn together with those inside it, in the order they have here, paints - /// what `paths` paints in its order. - pub parents: Vec>, - /// Flat Logo: the size in pixels the image was traced at when it was averaged down first; - /// `None` when it was traced at its own size. - pub worked_at: Option<(u32, u32)>, -} - -impl TraceResult { - pub fn anchor_count(&self) -> usize { - self.paths.iter().map(|p| p.path.anchor_count()).sum() - } -} - -fn is_white(c: [u8; 3]) -> bool { - c.iter().all(|&v| v >= 245) -} - -/// Whether Ignore Color leaves palette colour `c` out: `target` is the colour picked (`None`: white). -fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { - match target { - None => is_white(c), - Some(t) => (0..3).map(|k| (f64::from(c[k]) - f64::from(t[k])).powi(2)).sum::() <= 30.0 * 30.0 || (is_white(t) && is_white(c)), - } -} - -/// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use -/// [`trace_within`] to see why. -pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { - // No trace has more than `usize::MAX` anchors. - trace_within(img, params, usize::MAX).unwrap_or_default() -} - -/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more -/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The -/// colour layers are traced in parallel where threads are available. -pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { - let (w, h) = (img.width as usize, img.height as usize); - if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { - return Ok(TraceResult::default()); - } - let mut out = trace_paths(img, params, max_anchors)?; - if params.auto_grouping { - // Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can. - let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes; - out.parents = group::parents(&out.paths, img.width as usize, img.height as usize, stacked); - } - Ok(out) -} - -fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { - let (w, h) = (img.width as usize, img.height as usize); - if params.mode == Mode::Logo { - return logo::trace_logo(img, params, max_anchors); - } - // The colour Ignore Color leaves out, if not white. - let ignore = match ¶ms.ignore_color { - Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?), - _ => None, - }; - // A photo is traced from a copy with its noise smoothed away (see `smooth`). - let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img)); - let img = smoothed.as_ref().unwrap_or(img); - // Gradients are found first and kept out of the palette: no colour is spent on bands of them. - // Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray. - let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| { - let mut grays = img.clone(); - for p in grays.rgba.as_chunks_mut::<4>().0 { - let g = quantize::luma(*p); - p[..3].fill(g); - } - grays - }); - let traced_as = grays.as_ref().unwrap_or(img); - let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty()); - let without = found.as_ref().map(|(of, _)| { - let mut rest = img.clone(); - for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) { - if g != u32::MAX { - p[3] = 0; - } - } - rest - }); - let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params); - // The semi-transparent edge of a cut-out image traces as the color beside it. - if matches!(params.mode, Mode::Color | Mode::Grayscale) { - quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale); - } - drop(without); - // Each gradient is a palette entry of its own (its colour: the gradient's middle). - let mut gradients: Vec> = vec![None; q.palette.len()]; - if let Some((mut of, found)) = found { - gradient::settle_edges(traced_as, &mut of, &found, &mut q.labels); - let first = q.palette.len(); - for (l, &g) in q.labels.iter_mut().zip(&of) { - if g != u32::MAX { - *l = u16::try_from(first + g as usize).unwrap_or(TRANSPARENT); - } - } - q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8))); - gradients.extend(found.into_iter().map(Some)); - } - drop(grays); - // Noise clears specks, not the regions of a rare colour Automatic kept on purpose. - let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); - quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); - // Pixel count per palette entry, and the box its pixels lie in. - let mut counts = vec![0usize; q.palette.len()]; - let mut boxes: Vec> = vec![None; q.palette.len()]; - for (i, &l) in q.labels.iter().enumerate() { - if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { - *c += 1; - let (x, y) = (i % w, i / w); - *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); - } - } - // Layers bottom → top: largest area first. - let mut order: Vec = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); - order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); - if params.method == Method::Overlapping { - // Stacked layers go by lightness instead, from the largest colour's end of the scale: each - // layer reaches under those above it in the areas that hold its own color, so where two - // colours meet, every layer between them in the stack that has a pixel in that area ends - // on that same edge and shows through its anti-aliasing. Neighbours in a picture are - // mostly neighbours in tone, and in this order they are neighbours in the stack too (by - // area, an unrelated colour's edge would shine through as a hairline). - let light = |i: usize| q.palette.get(i).map_or(0, |c| u32::from(c[0]) * 299 + u32::from(c[1]) * 587 + u32::from(c[2]) * 114); - let first = order.first().copied(); - order.sort_by(|a, b| light(*a).cmp(&light(*b)).then(a.cmp(b))); - if first.is_some_and(|f| order.iter().position(|&i| i == f).is_some_and(|at| at * 2 >= order.len())) { - order.reverse(); - } - } - let mut rank = vec![usize::MAX; q.palette.len()]; - for (r, &i) in order.iter().enumerate() { - rank[i] = r; - } - let layers: Vec<(usize, usize)> = - order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); - let job = Layers { - q: &q, - rank: &rank, - params, - img, - ignore, - boxes, - gradients: &gradients, - bw_tones: bw_tones(img, &q), - style: params.outline_style(), - w, - h, - anchors: AtomicUsize::new(0), - max_anchors, - }; - let mut out = TraceResult::default(); - // 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); - for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) { - let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?; - // A layer with no paths adds no color to the palette. - if paths.is_empty() { - continue; - } - out.paths.extend(paths); - let color = q.palette[ci]; - if !out.palette.contains(&color) { - out.palette.push(color); - } - } - Ok(out) -} - -/// The luminance of white and of black in a Black and White quantisation: what its lightest -/// white pixels and its darkest black ones have (the 98th and 2nd percentiles; pure white and -/// black where a side has no pixel). Not the means: where the black is thin lines, blurred or -/// scanned, most black pixels are part paper, their mean is a gray, and read against it the middle -/// of every line would count as more than all ink, now more and now less as the line crosses the -/// pixel rows, which put a ripple on its edges. -fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] { - let mut hist = [[0u64; 256]; 2]; - for (p, &l) in img.rgba.as_chunks::<4>().0.iter().zip(&q.labels) { - if let Some(h) = hist.get_mut(l as usize) { - h[((u32::from(p[0]) * 299 + u32::from(p[1]) * 587 + u32::from(p[2]) * 114 + 500) / 1000).min(255) as usize] += 1; - } - } - // The luminance a share `at` of the side's pixels are darker than. - let percentile = |h: &[u64; 256], at: f64, none: f64| { - let total: u64 = h.iter().sum(); - let mut seen = 0; - for (v, n) in h.iter().enumerate() { - seen += n; - if total > 0 && seen as f64 >= at * total as f64 { - return v as f64; - } - } - none - }; - [percentile(&hist[0], 0.98, 255.0), percentile(&hist[1], 0.02, 0.0)] -} - -/// What tracing one colour layer needs. -struct Layers<'a> { - q: &'a Quantized, - /// Each palette entry's layer, bottom (0) up. - rank: &'a [usize], - params: &'a TraceParams, - img: &'a Raster, - /// The colour Ignore Color leaves out, if not white. - ignore: Option<[u8; 3]>, - /// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded. - boxes: Vec>, - /// Gradients: the gradient each palette entry is, if it is one. - gradients: &'a [Option], - /// Black and White: the luminance of white and of black (see `bw_tones`). - bw_tones: [f64; 2], - /// How outlines and centre lines are fitted. - style: logo::Style, - w: usize, - h: usize, - /// Anchor points made so far, by every layer. - anchors: AtomicUsize, - max_anchors: usize, -} - -impl Layers<'_> { - /// Ignore White: white is no part of any layer (stacked layers reach under the layers above - /// them, and must not reach under white that is left out). - fn ignored(&self, label: u16) -> bool { - self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) - } - /// The region across a crack, for cutting outlines at their junctions: the outside pixel's - /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. - fn neighbour(&self, c: Crack) -> u32 { - if self.params.method == Method::Overlapping { - return 0; - } - c.at(1).and_then(|i| self.q.labels.get(i)).map_or(u32::from(TRANSPARENT), |&l| u32::from(l)) - } - /// The junction at pixel corner `(x, y)`, if there is one: where three or four regions - /// meet, or two parts of a region touch by the corner only, or an edge reaches the image's - /// border. It lies at the corner, moved across by what the edges that run up and down from it - /// say, and up or down by what those to its left and right say (the mean, where there are - /// two). It depends on the corner alone, so every region around it gets the same point; on the - /// image's border it stays on the border. - fn junction(&self, x: i32, y: i32) -> Option { - let label = |px: i32, py: i32| Crack::new((px, py), (1, 0), self.w, self.h).at(0).and_then(|i| self.q.labels.get(i).copied()); - // The four pixels around the corner (`None` off the image), and the edges between them. - let around = [label(x - 1, y - 1), label(x, y - 1), label(x, y), label(x - 1, y)]; - let edges = (0..4).filter(|&i| around[i] != around[(i + 1) % 4]).count(); - let apart = |a: usize, b: usize, c: usize, d: usize| around[a] == around[c] && around[b] != around[a] && around[d] != around[a]; - let touching = apart(0, 1, 2, 3) || apart(1, 2, 3, 0); - // (Stacked layers share no edges: they are cut at the image's border only.) - if edges < 3 && !touching || self.params.method == Method::Overlapping { - return None; - } - // The shift of the crack between `a` and the pixel `step` from it, if both are in the - // image and of different regions. - let shift = |a: (i32, i32), step: (i32, i32)| { - let (la, lb) = (label(a.0, a.1)?, label(a.0 + step.0, a.1 + step.1)?); - (la != lb).then(|| self.shift(Crack::new(a, step, self.w, self.h)).clamp(-0.5, 0.5)) - }; - let mean = |a: Option, b: Option| match (a, b) { - (Some(a), Some(b)) => (a + b) / 2.0, - (Some(v), None) | (None, Some(v)) => v, - (None, None) => 0.0, - }; - let dx = mean(shift((x - 1, y - 1), (1, 0)), shift((x - 1, y), (1, 0))); - let dy = mean(shift((x - 1, y - 1), (0, 1)), shift((x, y - 1), (0, 1))); - Some(vectorcraft_geom::Point::new(f64::from(x) + dx, f64::from(y) + dy)) - } - /// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5). - /// - /// Each pixel of the column across the crack is read as a blend of the tones on its two sides - /// (the two pixels' palette colours; in Black and White the luminance of the image's black and of - /// the white pixels). The edge is where the blends, summed, put it: as far out as there is - /// inside tone in the outside pixels, less the outside tone in the inside ones (two pixels each - /// way, while they are on the same side and no more blended than the pixel before: more would be - /// another edge's doing). - /// - /// Where the inside is at most three pixels deep with the outside's colour behind it (a thin - /// line), its two edges share those pixels and cannot be read apart; the inside tone of the - /// whole column gives the line's middle (its centroid) and width (its sum) instead. A thin gap - /// between two parts of the inside is read the same way, with the tones swapped. - /// - /// Black and White then moves the edge to where the luminance crosses the threshold: by the - /// threshold's distance from the middle tone, over how steep the edge is (nothing for a - /// threshold in the middle, more the softer the edge). - /// - /// The estimate is read from each side of the crack and the two readings averaged, so that - /// the shift is exactly antisymmetric (`a - b == -(b - a)` in floating point) and the two - /// regions of a crack get the same points. A single reading is not: it looks for a thin run on - /// the inside before one on the outside, so where both sides are thin (a one- or two-pixel - /// sliver beside another, as in soft gradients and noisy scans) each region got its own shift, - /// their shared piece different points, and the abutting curves left a sliver uncovered. - fn shift(&self, c: Crack) -> f64 { - (self.shift_one_side(c) - self.shift_one_side(c.flipped())) / 2.0 - } - - /// [`Self::shift`] read from the crack's inside pixel only. - fn shift_one_side(&self, c: Crack) -> f64 { - let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT); - let (Some(la), Some(lb)) = (label(0), label(1)) else { return 0.0 }; - if la == lb { - return 0.0; - } - let bw = self.params.mode == Mode::BlackAndWhite; - // Black and White: black is the image's black; white is the lightest pixel within a few - // pixels of the crack, the paper as it is here (a scan's paper is darker in one corner than - // in another, and read against the image's lightest white it would count as part ink). - let black = self.bw_tones[1]; - let white = if bw { - let light = (-3..=4) - .filter_map(|k| c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3))) - .map(|p| (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0); - light.fold(f64::NEG_INFINITY, f64::max).clamp((black + 32.0).min(self.bw_tones[0]), self.bw_tones[0]) - } else { - self.bw_tones[0] - }; - let (ca, cb) = if bw { - let tone = |l: u16| [if l == 1 { black } else { white }; 3]; - (tone(la), tone(lb)) - } else { - // A side's own colour: its palette entry's, or its gradient's colour here. - let own = |l: u16, k: i32| { - let flat = self.q.palette.get(l as usize)?.map(f64::from); - let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; - let i = c.at(k)?; - Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) - }; - let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 }; - (ca, cb) - }; - let d = [0, 1, 2].map(|k| cb[k] - ca[k]); - let len2: f64 = d.iter().map(|v| v * v).sum(); - if len2 <= 0.0 { - return 0.0; - } - // How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image. - let inside = |k: i32| { - let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 }; - let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; - let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p }; - 1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::() / len2).clamp(0.0, 1.0) - }; - // The edge from a thin run of `n` pixels starting at pixel `from` and going `dir`, by the - // amounts `tone` of the run's own tone in it and in the two pixels at either end of it. - // Returns the run's middle and width, measured from the crack towards the outside. - let thin = |from: i32, dir: i32, n: i32, tone: &dyn Fn(i32) -> f64| { - let (near, far) = (from - dir, from + n * dir); - let mut cells: Vec<(i32, f64)> = (0..n).map(|j| from + j * dir).chain([near, far]).map(|k| (k, tone(k))).collect(); - for (edge, out) in [(near, -dir), (far, dir)] { - if label(edge + out) == label(edge) { - cells.push((edge + out, tone(edge + out).min(tone(edge)))); - } - } - let width: f64 = cells.iter().map(|c| c.1).sum(); - // Pixel `k`'s centre is `k - 0.5` from the crack. - (width > 1e-6).then(|| (cells.iter().map(|c| c.1 * (f64::from(c.0) - 0.5)).sum::() / width, width)) - }; - // How deep each side is here: up to three pixels of its own label, then the other's. - let depth = |dir: i32, own: u16, other: u16| { - let start = if dir < 0 { 0 } else { 1 }; - let n = (1..=3).take_while(|j| label(start + j * dir) == Some(own)).count() as i32 + 1; - (n <= 3 && label(start + n * dir) == Some(other)).then_some(n) - }; - let mut steep = 1.0; - let mut shift = if let Some((middle, width)) = depth(-1, la, lb).and_then(|n| thin(0, -1, n, &inside)) { - middle + width / 2.0 - } else if let Some((middle, width)) = depth(1, lb, la).and_then(|n| thin(1, 1, n, &|k| 1.0 - inside(k))) { - middle - width / 2.0 - } else { - let (a1, b1) = (1.0 - inside(0), inside(1)); - let a2 = (label(-1) == Some(la)).then(|| (1.0 - inside(-1)).min(a1)); - let b2 = (label(2) == Some(lb)).then(|| inside(2).min(b1)); - // The edge's steepness per pixel: the square of the change over two pixels (1 for an - // anti-aliased edge, less for a blurred one). - let change = [a2.map(|v| 1.0 - v - b1), b2.map(|v| 1.0 - a1 - v)].into_iter().flatten().fold(f64::NAN, f64::max); - if !change.is_nan() { - steep = (change * change).clamp(0.35, 1.0); - } - b1 + b2.unwrap_or(0.0) - a1 - a2.unwrap_or(0.0) - }; - if bw { - // The share of black at which a pixel turns black. - let turn = ((white - f64::from(self.params.threshold)) / (white - black).max(1.0)).clamp(0.02, 0.98); - shift += if la == 1 { 1.0 } else { -1.0 } * (0.5 - turn) / steep; - } - shift - } - - /// Whether the pixels inside `l` (a loop along pixel edges) all have one label. - fn one_colour_within(&self, l: &Loop) -> bool { - let mut first = None; - let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1))); - for y in y0..y1 { - // The loop's upright edges that pass this row of pixels, left to right: the pixels - // between the first and the second are inside, and so on. - let mut xs: Vec = l - .pts - .iter() - .zip(l.pts.iter().cycle().skip(1)) - .filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1)) - .map(|(a, _)| a.0) - .collect(); - xs.sort_unstable(); - for pair in xs.as_chunks::<2>().0 { - for x in pair[0]..pair[1] { - let label = usize::try_from(y) - .ok() - .zip(usize::try_from(x).ok()) - .and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w)); - if label.is_none() || *first.get_or_insert(label) != label { - return false; - } - } - } - } - first.is_some() - } - - /// Shapes, Abutting: the shape the outline `sp` (fitted from the loop `l`, `hard` as that - /// loop is) is, where it may be one. The edge is shared by the regions on its two sides, so - /// both must become that same shape or neither: only a closed edge between two regions, a - /// region whose whole outline is the hole of one other (no junction on it, not on the image's - /// border), and only a shape inside the image. The shape is read from the outline run one way - /// from one start, so the region and the hole it fills get the very same one. - fn shared_shape(&self, l: &Loop, sp: &vectorcraft_geom::SubPath, hard: bool) -> Option { - let (w, h) = (i32::try_from(self.w).ok()?, i32::try_from(self.h).ok()?); - if l.pts.iter().any(|&(x, y)| x <= 0 || y <= 0 || x >= w || y >= h || self.junction(x, y).is_some()) { - return None; - } - let mut one_way = sp.clone(); - if one_way.signed_area() < 0.0 { - one_way.reverse(); - } - let first = one_way.anchors.iter().enumerate().min_by(|a, b| a.1.p.x.total_cmp(&b.1.p.x).then(a.1.p.y.total_cmp(&b.1.p.y)))?.0; - one_way.anchors.rotate_left(first); - let shape = shapes::recognise(&one_way, hard)?; - let (wf, hf) = (self.w as f64, self.h as f64); - let inside = shape - .path() - .subpaths - .iter() - .flat_map(|s| &s.anchors) - .flat_map(|a| [a.p, a.h_in, a.h_out]) - .all(|p| (0.0..=wf).contains(&p.x) && (0.0..=hf).contains(&p.y)); - inside.then_some(shape) - } - - /// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the - /// maximum. - 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.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) - } else { - (vec![], vec![]) - }; - // 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); - 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 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.q.labels.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; - } - } - for (m, &c) in mask.iter_mut().zip(&comp) { - *m = *m && own.get(c as usize).copied().unwrap_or(false); - } - } - // 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; - let mut paths = vec![]; - let mut add = |path: PathData, pixels: usize, stroke: Option, shape: Option, square_caps: bool| { - let n = path.anchor_count(); - if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors { - return false; - } - // (A gradient fills; a stroked line of it is its middle colour.) - let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none()); - 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); - } - } - } - for comp in comps { - // (`hard`: some of the outline has no anti-aliasing, and is known less exactly.) - let hard = std::cell::Cell::new(false); - // (Each loop fitted, and whether its own outline is hard.) - let fit = |l: &Loop| { - let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?; - // Every trace straightens the pieces of an outline that lie close to a line (Snap - // Curves To Lines straightens more of them), so the outline of a single pixel or - // of a region one pixel thick can have all its pieces on one chord, and the loop - // then has no area. Filled, such a loop paints nothing, and it is left out. - // Without Fills its 1 px stroke draws it, so it is kept. (`fit::fit_closed` puts - // back the positioned outline of a loop that is one closed piece when its fit has - // no area.) The area is summed about the loop's first anchor. - if area_stroke.is_none() { - use kurbo::ParamCurveArea; - let origin = sp.anchors.first()?.p.to_vec2(); - let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum(); - if area.abs() < 1e-6 { - return None; - } - } - hard.set(hard.get() || is_hard); - Some((sp, is_hard)) - }; - let Some((outer, _)) = fit(&comp.outer) else { continue }; - let mut subs = vec![outer]; - for hole in &comp.holes { - if hole.area2.abs() / 2 < min_hole && params.noise > 0 { - continue; - } - let Some((mut sp, own_hard)) = fit(hole) else { continue }; - // Shapes: a hole that is a shape is that shape, as what fills it is (the two are - // one outline: they stay one). Only where one colour fills it: the parts of - // several are not that shape, and would no longer meet it. Abutting, only where the - // region that fills it is that shape too (see `shared_shape`). - let exact = if !params.shapes || area_stroke.is_some() || !self.one_colour_within(hole) { - None - } else if overlapping { - shapes::recognise(&sp, hard.get()) - } else { - // (As hard as this outline is, as the region that fills it reads it.) - self.shared_shape(hole, &sp, own_hard) - }; - if let Some(exact) = exact.and_then(|shape| shape.path().subpaths.into_iter().next()) { - // (Which way round each goes: by the area of the polygon through points along it.) - let turn = |sp: &vectorcraft_geom::SubPath| { - use vectorcraft_geom::ParamCurve; - let pts: Vec<_> = - (0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect(); - pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::() - }; - let mut exact = exact; - if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) { - exact.reverse(); - } - sp = exact; - } - subs.push(sp); - } - // Shapes: a filled outline without holes that is a shape becomes that shape. - let shape = match subs.as_slice() { - [only] if params.shapes && area_stroke.is_none() && overlapping => shapes::recognise(only, hard.get()), - // Abutting: only an island in one other region, whose hole it is (see `shared_shape`). - [only] if params.shapes && area_stroke.is_none() && comp.holes.is_empty() => self.shared_shape(&comp.outer, only, hard.get()), - _ => None, - }; - let path = shape.map_or_else(|| PathData::new(subs), |s| s.path()); - if !add(path, comp.pixels, area_stroke, shape, false) { - return None; - } - } - for line in lines { - if !add(line.path, line.pixels, Some(line.width), None, line.square) { - return None; - } - } - Some(paths) - } -} - -/// Test probe: every crack between two labels of `img` quantised as `trace` does (no gradients), -/// as `[shift, shift from the other side, one-sided reading, one-sided reading from the other -/// side]`. +use layers::parallel_map; #[cfg(test)] -pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { - let (w, h) = (img.width as usize, img.height as usize); - let mut q = quantize(img, params); - denoise(&mut q.labels, w, h, params.noise as usize); - let n = q.palette.len(); - let rank: Vec = (0..n).collect(); - let gradients = vec![None; n]; - let job = Layers { - q: &q, - rank: &rank, - params, - img, - ignore: None, - boxes: vec![None; n], - gradients: &gradients, - bw_tones: bw_tones(img, &q), - style: params.outline_style(), - w, - h, - anchors: AtomicUsize::new(0), - max_anchors: usize::MAX, - }; - let mut out = vec![]; - for y in 0..h as i32 { - for x in 0..w as i32 { - for step in [(1, 0), (0, 1)] { - let c = Crack::new((x, y), step, w, h); - let (Some(a), Some(b)) = (c.at(0), c.at(1)) else { continue }; - if q.labels[a] != q.labels[b] { - out.push([job.shift(c), job.shift(c.flipped()), job.shift_one_side(c), job.shift_one_side(c.flipped())]); - } - } - } - } - out -} - -/// `f` of each of `items`, in order, spread over the available cores. -fn parallel_map(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { - parallel_map_at_most(usize::MAX, items, f) -} - -/// `f` of each of `items`, in order, spread over the available cores, at most `at_a_time` at once. -#[cfg(not(target_arch = "wasm32"))] -fn parallel_map_at_most(at_a_time: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { - let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len()).min(at_a_time); - if threads <= 1 { - return items.iter().map(f).collect(); - } - let next = AtomicUsize::new(0); - let mut done: Vec<(usize, R)> = std::thread::scope(|s| { - let workers: Vec<_> = (0..threads) - .map(|_| { - s.spawn(|| { - let mut mine = vec![]; - loop { - let i = next.fetch_add(1, Ordering::Relaxed); - let Some(item) = items.get(i) else { break }; - mine.push((i, f(item))); - } - mine - }) - }) - .collect(); - // A worker's panic goes on in this thread, as if it had run here (the engine's guard - // reports it). - workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect() - }); - done.sort_by_key(|(i, _)| *i); - done.into_iter().map(|(_, r)| r).collect() -} - -/// `f` of each of `items`, in order (no threads on the web). -#[cfg(target_arch = "wasm32")] -fn parallel_map_at_most(_: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { - items.iter().map(f).collect() -} +use layers::parallel_map_at_most; +#[cfg(test)] +pub(crate) use layers::shift_probe; +pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets}; +pub use pipeline::{TraceResult, TracedPath, trace, trace_within}; +#[cfg(test)] +use raster::fit_size; +pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError}; #[cfg(test)] mod tests; diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs new file mode 100644 index 000000000..5503a84ff --- /dev/null +++ b/crates/trace/src/params.rs @@ -0,0 +1,251 @@ +use super::*; + +/// Tracing mode (Illustrator's Mode popup). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + #[default] + #[serde(alias = "bw", alias = "blackWhite", alias = "Black and White")] + BlackAndWhite, + #[serde(alias = "gray", alias = "Grayscale")] + Grayscale, + #[serde(alias = "Color")] + Color, + /// Flat-colour logos: sub-pixel edges, sharp corners restored, true lines and circles. + #[serde(alias = "Logo", alias = "flat", alias = "flatLogo")] + Logo, +} + +/// How colour layers relate (Advanced → Method). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Method { + /// Each colour traced on its own; neighbouring shapes share their edge. + #[default] + Abutting, + /// Stacked: every layer extends under the layers above it, within the areas that hold its own + /// color. + Overlapping, +} + +/// Where Color mode's colours come from (the Palette popup). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Palette { + /// The `colors` colours that best represent the image. + #[default] + #[serde(alias = "Limited")] + Limited, + /// As many colours as the image's tones need, up to [`FULL_TONE_MAX`] (photos, gradients): + /// `color_detail` sets how close two colours may be and stay apart. + #[serde(alias = "Full Tone")] + FullTone, + /// Flat art (a few colours cover nearly all of it) traces with exactly those colours, anything + /// else as Full Tone. + #[serde(alias = "Automatic")] + Automatic, + /// The most used colours of a swatch library ([`TraceParams::swatches`]), at most `colors` of + /// them, exactly as they are. Each pixel takes the library color nearest in lightness and + /// hue, with hue and chroma counted three times as much as lightness, so that neutral tones + /// stay off saturated colors. + #[serde(alias = "library", alias = "Document Library")] + DocumentLibrary, +} + +/// Image Trace parameters (the Image Trace panel). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TraceParams { + pub mode: Mode, + /// Black and White: pixels darker than this (luminance 0–255) are black. + pub threshold: u8, + /// Color: where the colours come from. + pub palette: Palette, + /// Palette size for Color (Limited; at most this many for Document Library), number of grays + /// for Grayscale (2–256). + pub colors: u32, + /// Full Tone and Automatic: how many colours, 0 (few) – 100 (many). + pub color_detail: f64, + /// Document Library: the swatch library, `"document"` (the document's swatches) or a library id + /// or name. The caller looks its colours up into `swatches`. + pub library: String, + /// Document Library with the document's swatches: the name of the color group whose colors are + /// used. `None` (or empty) uses every swatch. `imageTrace.make` refuses a name with any other + /// palette, library or mode. + pub color_group: Option, + /// Document Library: the library's colours (sRGB). Not saved: they are looked up from + /// `library` (and `color_group`) at every trace. + #[serde(skip)] + pub swatches: Vec<[u8; 3]>, + /// Paths (fidelity) 0–100: higher follows the pixels more tightly. + pub paths: f64, + /// Corners 0–100: higher keeps more corners. + pub corners: f64, + /// Noise: areas smaller than this many pixels are ignored. (Flat Logo mode: areas smaller than + /// `noise / 10` pixels of the source image.) + pub noise: u32, + pub method: Method, + /// Ignore Color: drop the areas of one colour (no background shape): white, unless + /// `ignore_color` names another. + pub ignore_white: bool, + /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels + /// of it. `None` is white. (Flat Logo mode leaves out white only.) + pub ignore_color: Option, + /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs + /// to true circles. + pub snap_curves_to_lines: bool, + /// Shapes: a filled outline that is a circle, an ellipse, a rectangle (square or rounded + /// corners) or a regular polygon comes out as exactly that shape ([`TracedPath::shape`]). + pub shapes: bool, + /// Auto Grouping: say which path lies inside which ([`TraceResult::parents`]). + pub auto_grouping: bool, + /// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction + /// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour. + /// Grayscale finds them in the image's grays, and their stops are gray. + pub gradients: bool, + /// Create: trace areas as filled shapes. + pub fills: bool, + /// Create: trace lines no wider than `stroke_width` as stroked centre lines. + pub strokes: bool, + /// Stroke: the widest feature (px) traced as a stroke. + pub stroke_width: f64, + /// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is + /// always traced on its own and cannot be named, colours closer than about 12 (RGB distance) + /// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.) + pub logo_colors: Vec, +} + +impl Default for TraceParams { + /// Illustrator's [Default]: black and white, threshold 128. + fn default() -> Self { + Self { + mode: Mode::BlackAndWhite, + threshold: 128, + palette: Palette::Limited, + colors: 6, + color_detail: 50.0, + library: "document".into(), + color_group: None, + swatches: vec![], + paths: 50.0, + corners: 75.0, + noise: 25, + method: Method::Abutting, + ignore_white: false, + ignore_color: None, + snap_curves_to_lines: false, + shapes: false, + auto_grouping: false, + gradients: false, + fills: true, + strokes: false, + stroke_width: 10.0, + logo_colors: Vec::new(), + } + } +} + +impl TraceParams { + /// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100, + /// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0). + /// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones, + /// makes parts of circles true arcs, and makes lines within 2.5° of level or upright exactly so. + pub(crate) fn outline_style(&self) -> logo::Style { + // Parameters come from users and agents: a NaN or infinity must not reach the geometry. + let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default }; + let snap = self.snap_curves_to_lines; + logo::Style { + step: 0.25, + tol: 0.08 + 0.7 * (1.0 - unit(self.paths, 0.5)).powf(1.5), + corner_deg: 90.0 - 55.0 * unit(self.corners, 0.75), + snap: true, + arcs: snap, + line_tol: if snap { 0.5 } else { 0.2 }, + circle_tol: if snap { 0.3 } else { 0.15 }, + restore: Some(logo::RestoreParams::default()), + check_step: 0.15, + upright: snap, + ..logo::Style::default() + } + } +} + +/// Built-in preset names in panel order (our own parameter sets). +pub const PRESET_NAMES: &[&str] = &[ + "Default", + "Auto Color", + "High Fidelity Photo", + "Low Fidelity Photo", + "3 Colors", + "6 Colors", + "16 Colors", + "Shades of Gray", + "Black and White Logo", + "Sketched Art", + "Silhouettes", + "Line Art", + "Technical Drawing", + "Outline", + "Flat Logo", +]; + +/// A built-in preset by name (case-insensitive; `[Default]` is accepted). +pub fn preset(name: &str) -> Option { + let key = name.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); + let d = TraceParams::default(); + let color = |colors: u32, paths: f64, corners: f64, noise: u32, method: Method| TraceParams { + mode: Mode::Color, + colors, + paths, + corners, + noise, + method, + ..TraceParams::default() + }; + let bw = |threshold: u8, paths: f64, corners: f64, noise: u32, snap: bool| TraceParams { + mode: Mode::BlackAndWhite, + threshold, + paths, + corners, + noise, + ignore_white: true, + snap_curves_to_lines: snap, + ..TraceParams::default() + }; + Some(match key.as_str() { + "default" => d, + // A posterised image of a photo or of artwork: its own colours where it is flat art, as + // many as its tones need where it is not. + // (Color detail 85: a colour on a hundredth of the picture is still one of its colours.) + "auto color" => TraceParams { palette: Palette::Automatic, color_detail: 85.0, ..color(6, 65.0, 60.0, 16, Method::Abutting) }, + "high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping), + "low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping), + "3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting), + "6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting), + "16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting), + "shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d }, + "black and white logo" => bw(128, 95.0, 80.0, 8, true), + "sketched art" => bw(150, 50.0, 50.0, 100, false), + "silhouettes" => bw(200, 40.0, 60.0, 30, false), + "line art" => bw(128, 80.0, 70.0, 10, false), + "technical drawing" => bw(128, 95.0, 90.0, 4, true), + // Black outlines: lines as stroked centre lines, wider areas as stroked outlines. + "outline" => TraceParams { fills: false, strokes: true, ..bw(128, 80.0, 70.0, 10, false) }, + // Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel. + "flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d }, + _ => return None, + }) +} + +/// All built-in presets in panel order. +pub fn presets() -> Vec<(&'static str, TraceParams)> { + PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect() +} + +/// The enhanced version of built-in preset `name`: the preset with the options the enhanced +/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo. +/// Gradients stay an option to turn on. Flat Logo, a preset of our own, has none. +pub fn enhanced_preset(name: &str) -> Option { + let p = preset(name).filter(|p| p.mode != Mode::Logo)?; + Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo"), ..p }) +} diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs new file mode 100644 index 000000000..6d3ea27f5 --- /dev/null +++ b/crates/trace/src/pipeline.rs @@ -0,0 +1,238 @@ +use super::layers::{Layers, parallel_map_at_most}; +use super::params::{Method, Mode, TraceParams}; +use super::raster::{Raster, TraceError}; +use super::*; + +/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`) +/// lines stroked with it. +#[derive(Clone, Debug, PartialEq)] +pub struct TracedPath { + /// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`). + pub path: PathData, + pub color: [u8; 3], + /// Filled pixel count of the component the path came from. + pub pixels: usize, + /// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the + /// outlines of wider areas without Fills). + pub stroke: Option, + /// Shapes: the shape the path is, exactly (`path` is that shape's outline). + pub shape: Option, + /// Gradients: the linear gradient the path is filled with (`color` is its middle colour). + pub gradient: Option, + /// Create Strokes: the stroke's ends are square (a cap that goes half the width past the + /// path's end, cut straight), as the line's ink is; otherwise round. + pub square_caps: bool, +} + +/// The traced result, bottom-most path first. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TraceResult { + pub paths: Vec, + /// Colours actually used. + pub palette: Vec<[u8; 3]>, + /// Auto Grouping: for each of `paths`, the path it lies inside, if any (empty without the + /// option). Each path drawn together with those inside it, in the order they have here, paints + /// what `paths` paints in its order. + pub parents: Vec>, + /// Flat Logo: the size in pixels the image was traced at when it was averaged down first; + /// `None` when it was traced at its own size. + pub worked_at: Option<(u32, u32)>, +} + +impl TraceResult { + pub fn anchor_count(&self) -> usize { + self.paths.iter().map(|p| p.path.anchor_count()).sum() + } +} + +fn is_white(c: [u8; 3]) -> bool { + c.iter().all(|&v| v >= 245) +} + +/// Whether Ignore Color leaves palette colour `c` out: `target` is the colour picked (`None`: white). +pub(crate) fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { + match target { + None => is_white(c), + Some(t) => (0..3).map(|k| (f64::from(c[k]) - f64::from(t[k])).powi(2)).sum::() <= 30.0 * 30.0 || (is_white(t) && is_white(c)), + } +} + +/// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use +/// [`trace_within`] to see why. +pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { + // No trace has more than `usize::MAX` anchors. + trace_within(img, params, usize::MAX).unwrap_or_default() +} + +/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more +/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The +/// colour layers are traced in parallel where threads are available. +pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { + let (w, h) = (img.width as usize, img.height as usize); + if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { + return Ok(TraceResult::default()); + } + let mut out = trace_paths(img, params, max_anchors)?; + if params.auto_grouping { + // Abutting areas cannot overlap; stacked layers, Logo's shapes and stroked lines can. + let stacked = params.method == Method::Overlapping || params.mode == Mode::Logo || params.strokes; + out.parents = group::parents(&out.paths, img.width as usize, img.height as usize, stacked); + } + Ok(out) +} + +fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { + let (w, h) = (img.width as usize, img.height as usize); + if params.mode == Mode::Logo { + return logo::trace_logo(img, params, max_anchors); + } + // The colour Ignore Color leaves out, if not white. + let ignore = match ¶ms.ignore_color { + Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?), + _ => None, + }; + // A photo is traced from a copy with its noise smoothed away (see `smooth`). + let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img)); + let img = smoothed.as_ref().unwrap_or(img); + // Gradients are found first and kept out of the palette: no colour is spent on bands of them. + // Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray. + let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| { + let mut grays = img.clone(); + for p in grays.rgba.as_chunks_mut::<4>().0 { + let g = quantize::luma(*p); + p[..3].fill(g); + } + grays + }); + let traced_as = grays.as_ref().unwrap_or(img); + let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty()); + let without = found.as_ref().map(|(of, _)| { + let mut rest = img.clone(); + for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) { + if g != u32::MAX { + p[3] = 0; + } + } + rest + }); + let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params); + // The semi-transparent edge of a cut-out image traces as the color beside it. + if matches!(params.mode, Mode::Color | Mode::Grayscale) { + quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale); + } + drop(without); + // Each gradient is a palette entry of its own (its colour: the gradient's middle). + let mut gradients: Vec> = vec![None; q.palette.len()]; + if let Some((mut of, found)) = found { + gradient::settle_edges(traced_as, &mut of, &found, &mut q.labels); + let first = q.palette.len(); + for (l, &g) in q.labels.iter_mut().zip(&of) { + if g != u32::MAX { + *l = u16::try_from(first + g as usize).unwrap_or(TRANSPARENT); + } + } + q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8))); + gradients.extend(found.into_iter().map(Some)); + } + drop(grays); + // Noise clears specks, not the regions of a rare colour Automatic kept on purpose. + let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); + quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); + // Pixel count per palette entry, and the box its pixels lie in. + let mut counts = vec![0usize; q.palette.len()]; + let mut boxes: Vec> = vec![None; q.palette.len()]; + for (i, &l) in q.labels.iter().enumerate() { + if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { + *c += 1; + let (x, y) = (i % w, i / w); + *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + } + } + // Layers bottom → top: largest area first. + let mut order: Vec = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); + order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); + if params.method == Method::Overlapping { + // Stacked layers go by lightness instead, from the largest colour's end of the scale: each + // layer reaches under those above it in the areas that hold its own color, so where two + // colours meet, every layer between them in the stack that has a pixel in that area ends + // on that same edge and shows through its anti-aliasing. Neighbours in a picture are + // mostly neighbours in tone, and in this order they are neighbours in the stack too (by + // area, an unrelated colour's edge would shine through as a hairline). + let light = |i: usize| q.palette.get(i).map_or(0, |c| u32::from(c[0]) * 299 + u32::from(c[1]) * 587 + u32::from(c[2]) * 114); + let first = order.first().copied(); + order.sort_by(|a, b| light(*a).cmp(&light(*b)).then(a.cmp(b))); + if first.is_some_and(|f| order.iter().position(|&i| i == f).is_some_and(|at| at * 2 >= order.len())) { + order.reverse(); + } + } + let mut rank = vec![usize::MAX; q.palette.len()]; + for (r, &i) in order.iter().enumerate() { + rank[i] = r; + } + let layers: Vec<(usize, usize)> = + order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); + let job = Layers { + q: &q, + rank: &rank, + params, + img, + ignore, + boxes, + gradients: &gradients, + bw_tones: bw_tones(img, &q), + style: params.outline_style(), + w, + h, + anchors: AtomicUsize::new(0), + max_anchors, + }; + let mut out = TraceResult::default(); + // 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); + for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) { + let paths = paths.ok_or(TraceError::TooComplex { max: max_anchors })?; + // A layer with no paths adds no color to the palette. + if paths.is_empty() { + continue; + } + out.paths.extend(paths); + let color = q.palette[ci]; + if !out.palette.contains(&color) { + out.palette.push(color); + } + } + Ok(out) +} + +/// The luminance of white and of black in a Black and White quantisation: what its lightest +/// white pixels and its darkest black ones have (the 98th and 2nd percentiles; pure white and +/// black where a side has no pixel). Not the means: where the black is thin lines, blurred or +/// scanned, most black pixels are part paper, their mean is a gray, and read against it the middle +/// of every line would count as more than all ink, now more and now less as the line crosses the +/// pixel rows, which put a ripple on its edges. +pub(crate) fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] { + let mut hist = [[0u64; 256]; 2]; + for (p, &l) in img.rgba.as_chunks::<4>().0.iter().zip(&q.labels) { + if let Some(h) = hist.get_mut(l as usize) { + h[((u32::from(p[0]) * 299 + u32::from(p[1]) * 587 + u32::from(p[2]) * 114 + 500) / 1000).min(255) as usize] += 1; + } + } + // The luminance a share `at` of the side's pixels are darker than. + let percentile = |h: &[u64; 256], at: f64, none: f64| { + let total: u64 = h.iter().sum(); + let mut seen = 0; + for (v, n) in h.iter().enumerate() { + seen += n; + if total > 0 && seen as f64 >= at * total as f64 { + return v as f64; + } + } + none + }; + [percentile(&hist[0], 0.98, 255.0), percentile(&hist[1], 0.02, 0.0)] +} diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs new file mode 100644 index 000000000..f096a97d0 --- /dev/null +++ b/crates/trace/src/raster.rs @@ -0,0 +1,143 @@ +/// Errors decoding a raster. +#[derive(Debug, thiserror::Error)] +pub enum TraceError { + #[error("could not decode image: {0}")] + Decode(String), + #[error("image is empty")] + Empty, + #[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")] + TooLarge { width: u32, height: u32, max: u64 }, + #[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())] + NotFlat { fraction: f64 }, + #[error("\"{0}\" is not a colour: write it as #rrggbb")] + BadPalette(String), + #[error( + "this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset" + )] + TooComplex { max: usize }, +} + +/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are +/// refused instead of running out of memory. +pub const MAX_PIXELS: u64 = 64 << 20; + +/// The most pixels an image may have for [`Raster::decode_to_fit`] to decode it and scale it down +/// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused. +pub const MAX_SOURCE_PIXELS: u64 = 192 << 20; + +/// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1). +pub(crate) fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) { + let pixels = u64::from(w) * u64::from(h); + if pixels <= max || pixels == 0 { + return (w, h); + } + let k = (max as f64 / pixels as f64).sqrt(); + let side = |v: u32| ((f64::from(v) * k).floor() as u32).clamp(1, v); + let (mut nw, mut nh) = (side(w), side(h)); + // A side held at 1 pixel leaves the other too long: cut it to what fits. + if u64::from(nw) * u64::from(nh) > max { + if nw >= nh { + nw = u32::try_from(max / u64::from(nh)).unwrap_or(nw).clamp(1, nw); + } else { + nh = u32::try_from(max / u64::from(nw)).unwrap_or(nh).clamp(1, nh); + } + } + (nw, nh) +} + +/// An RGBA8 raster (row-major, top row first). +#[derive(Clone, Debug, PartialEq)] +pub struct Raster { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl Raster { + /// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other + /// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not + /// a crash. (That error's text is already translated; its detail names the sizes.) + pub fn new(width: u32, height: u32, rgba: Vec) -> Result { + let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4)); + if want != Some(rgba.len()) { + return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len()))); + } + Ok(Self { width, height, rgba }) + } + /// A raster whose pixel `(x, y)` is `f(x, y)`. + pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self { + let mut rgba = Vec::with_capacity(width as usize * height as usize * 4); + for y in 0..height { + for x in 0..width { + rgba.extend_from_slice(&f(x, y)); + } + } + Self { width, height, rgba } + } + /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). + pub fn decode(bytes: &[u8]) -> Result { + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; + if w == 0 || h == 0 { + return Err(TraceError::Empty); + } + if u64::from(w) * u64::from(h) > MAX_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); + let (w, h) = img.dimensions(); + Ok(Self { width: w, height: h, rgba: img.into_raw() }) + } + /// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to + /// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within + /// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly + /// less than its own resolution. The caller maps the result back with `width` / `height`. + pub fn decode_to_fit(bytes: &[u8]) -> Result { + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; + if w == 0 || h == 0 { + return Err(TraceError::Empty); + } + let pixels = u64::from(w) * u64::from(h); + if pixels <= MAX_PIXELS { + return Self::decode(bytes); + } + if pixels > MAX_SOURCE_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 }); + } + // Decoded within the allocation the source limit needs; the web keeps file import's 512 MB. + // A deeper image (16 bits a channel) that needs more fails with a decode error. + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 }); + reader.limits(limits); + let img = reader.decode().map_err(decode_err)?.into_rgba8(); + let (w, h) = img.dimensions(); + let (nw, nh) = fit_size(w, h, MAX_PIXELS); + // Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive. + let small = image::imageops::thumbnail(&img, nw, nh); + Ok(Self { width: nw, height: nh, rgba: small.into_raw() }) + } + /// Encode as PNG. + pub fn encode_png(&self) -> Vec { + let mut out = Vec::new(); + if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) { + let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png); + } + out + } + /// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose + /// fields were set to a size its bytes don't have). + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + if x >= self.width || y >= self.height { + return [0; 4]; + } + let i = (y as usize * self.width as usize + x as usize) * 4; + match self.rgba.get(i..i + 4) { + Some(&[r, g, b, a]) => [r, g, b, a], + _ => [0; 4], + } + } +}