From 58767abe0f239e4f779c94bda08384423300e0e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 08:25:19 +0700 Subject: [PATCH] Image Trace: the blended edge of a cut-out image adds no color to the palette Squashed from #4075 onto main. --- crates/trace/src/quantize.rs | 410 ++++++++++++++++++++++++++++++----- crates/trace/src/tests.rs | 354 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 713 insertions(+), 52 deletions(-) diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 176a6f778..4a0225ac2 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -45,11 +45,15 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized (Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }, vec![]) } Mode::Grayscale => { + // The pixels that choose the grays: the opaque ones, except the semi-transparent ones + // along the cut edge (see `choosing_pixels`). + let choose = choosing_pixels(img, true); let own = own_colours(img); + let own_choosing = own_colours_among(img, &choose); let histogram = |only_own: bool| { let mut hist = [0u64; 256]; - for (p, &f) in img.rgba.as_chunks::<4>().0.iter().zip(&own) { - if p[3] >= 128 && (f || !only_own) { + for ((p, &f), &c) in img.rgba.as_chunks::<4>().0.iter().zip(&own_choosing).zip(&choose) { + if p[3] >= 128 && c && (f || !only_own) { hist[luma([p[0], p[1], p[2], p[3]]) as usize] += 1; } } @@ -74,20 +78,28 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized // 15-bit colour histogram with per-bin sums (exact bin means), fitted to the image's // range per channel so that close tones (a dark, low-contrast photo) stay apart. let hk = HistKey::new(img); + let pixels = img.rgba.as_chunks::<4>().0; + // The pixels that choose the colors: the opaque ones, except the semi-transparent ones + // along the cut edge (see `choosing_pixels`). + let choose = choosing_pixels(img, false); let own = own_colours(img); - let (mut every, mut calm) = (vec![Bin::default(); 1 << 15], vec![Bin::default(); 1 << 15]); - for (p, &f) in img.rgba.as_chunks::<4>().0.iter().zip(&own) { + let own_choosing = own_colours_among(img, &choose); + let (mut all, mut every, mut calm) = (vec![Bin::default(); 1 << 15], vec![Bin::default(); 1 << 15], vec![Bin::default(); 1 << 15]); + for ((p, &f), &c) in pixels.iter().zip(&own_choosing).zip(&choose) { if p[3] >= 128 { let k = hk.key(p[0], p[1], p[2]); - every[k].add(p); - if f { - calm[k].add(p); + all[k].add(p); + if c { + every[k].add(p); + if f { + calm[k].add(p); + } } } } let used = |bins: &[Bin]| -> Vec { bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect() }; - let (every, calm) = (used(&every), used(&calm)); + let (all, every, calm) = (used(&all), used(&every), used(&calm)); let count = |u: &[Used]| u.iter().map(|b| b.2).sum::(); let from = if palette_from_own(count(&calm), count(&every)) { &calm } else { &every }; // Automatic on flat art: colours too rare for its count, but distinct and drawn as @@ -99,21 +111,36 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized // when noise made several of one (see `merge_mixed`). let mut reduce = |colors: u32| { let mut centers = color_palette(from, &TraceParams { colors, ..params.clone() }); + // The anti-aliasing beside a rare color along the cut edge, with the indexes of + // that color and of the color on the other side of it (see `rare_colours`). + let mut traced_as: Vec = vec![]; if flat_auto { - let found = rare_colours(img, &own, &hk, ¢ers); + let (found, blends) = rare_colours(img, &own_choosing, &choose, &hk, ¢ers); if !found.is_empty() { let common = centers.len(); + traced_as = blends; centers.extend(found); centers = refine_around(from, centers, common); rare = centers.iter().skip(common).map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect(); } } let mut lut = vec![0u16; 1 << 15]; - let center_labs = labs(¢ers); + let center_labs = labs(¢ers.iter().copied().chain(traced_as.iter().map(|t| t.0)).collect::>()); let library = params.palette == Palette::DocumentLibrary && !params.swatches.is_empty(); let nearest = if library { nearest_library } else { nearest_lab }; - for (i, c, _) in &every { - lut[*i] = nearest(¢er_labs, oklab(*c)) as u16; + // Every opaque pixel takes a color, the cut edge's too. A bin takes the color + // nearest to the mean of the pixels in it that choose the colors, and a bin with + // none of them the color nearest to the mean of all its pixels. A bin nearest to + // the anti-aliasing beside a rare color takes that rare color when it is at least + // halfway from the color on the other side to it, and that color otherwise. + for (i, c, _) in all.iter().chain(&every) { + let n = nearest(¢er_labs, oklab(*c)); + lut[*i] = n.checked_sub(centers.len()).and_then(|t| traced_as.get(t)).map_or(n, |&(_, rare, other)| { + let (Some(r), Some(o)) = (centers.get(rare), centers.get(other)) else { return rare }; + let d2: f64 = (0..3).map(|q| (r[q] - o[q]).powi(2)).sum(); + let t = if d2 > 0.0 { (0..3).map(|q| (c[q] - o[q]) * (r[q] - o[q])).sum::() / d2 } else { 1.0 }; + if t >= 0.5 { rare } else { other } + }) as u16; } let labels = img.rgba.as_chunks::<4>().0.iter().map(|p| if p[3] < 128 { TRANSPARENT } else { lut[hk.key(p[0], p[1], p[2])] }).collect(); @@ -136,11 +163,19 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized /// leave along an edge. A line one pixel wide is no blend (the pixels on its two sides are alike), /// nor is noise. fn own_colours(img: &Raster) -> Vec { + let opaque: Vec = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); + own_colours_among(img, &opaque) +} + +/// [`own_colours`] among the pixels that `among` marks: the others are neither own colours nor +/// the neighbours a pixel is a blend of. +fn own_colours_among(img: &Raster, among: &[bool]) -> Vec { let (w, h) = (img.width as isize, img.height as isize); let px = img.rgba.as_chunks::<4>().0; let at = |x: isize, y: isize| { - let p = (x >= 0 && y >= 0 && x < w && y < h).then(|| px.get((y * w + x) as usize)).flatten()?; - (p[3] >= 128).then(|| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]) + let i = (x >= 0 && y >= 0 && x < w && y < h).then_some((y * w + x) as usize)?; + let p = px.get(i).filter(|_| among.get(i).copied().unwrap_or(false))?; + Some([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]) }; let mut out = Vec::with_capacity(px.len()); for y in 0..h { @@ -413,38 +448,24 @@ pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) { if q.labels.len() != w * h || px.len() != w * h { return; } - let alpha = |j: usize| px.get(j).map_or(0, |p| p[3]); let color = |p: &[u8; 4]| if gray { [luma(*p); 3] } else { [p[0], p[1], p[2]] }; + let edge = cut_edge(img); let mut changed = false; let mut stack: Vec<(usize, usize)> = Vec::new(); for i in 0..w * h { let mine = q.labels[i]; - if mine == TRANSPARENT || !(128..=254).contains(&alpha(i)) { - continue; - } - let (x, y) = (i % w, i / w); - let cut = - (x > 0 && alpha(i - 1) < 128) || (x + 1 < w && alpha(i + 1) < 128) || (y > 0 && alpha(i - w) < 128) || (y + 1 < h && alpha(i + w) < 128); - if !cut { + if mine == TRANSPARENT || !edge.get(i).copied().unwrap_or(false) { continue; } let Some(p) = px.get(i) else { continue }; let own = oklab(color(p).map(f64::from)); // The colors of the fully opaque pixels within two pixels that it reaches and is a blend // of, and how many have each. - let (x0, y0, x1, y1) = (x.saturating_sub(2), y.saturating_sub(2), (x + 2).min(w - 1), (y + 2).min(h - 1)); - let mut reached = [[false; 5]; 5]; let mut near: Vec<(u16, usize)> = Vec::new(); let mut keep = false; - stack.clear(); - stack.push((x, y)); - if let Some(r) = reached.get_mut(y - y0).and_then(|row| row.get_mut(x - x0)) { - *r = true; - } - while let Some((cx, cy)) = stack.pop() { - let j = cy * w + cx; + reached_opaque(img, (i % w, i / w), &mut stack, |j| { let l = q.labels[j]; - if alpha(j) == 255 && l != TRANSPARENT { + if l != TRANSPARENT { keep |= l == mine; if px.get(j).is_some_and(|b| edge_blend_of(own, color(b), p[3])) { match near.iter_mut().find(|n| n.0 == l) { @@ -453,17 +474,7 @@ pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) { } } } - for (nx, ny) in [(cx.wrapping_sub(1), cy), (cx + 1, cy), (cx, cy.wrapping_sub(1)), (cx, cy + 1)] { - if nx < x0 || nx > x1 || ny < y0 || ny > y1 || alpha(ny * w + nx) < 128 { - continue; - } - let Some(r) = reached.get_mut(ny - y0).and_then(|row| row.get_mut(nx - x0)) else { continue }; - if !*r { - *r = true; - stack.push((nx, ny)); - } - } - } + }); if keep || near.is_empty() { continue; } @@ -480,6 +491,114 @@ pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) { } } +/// Calls `visit` with the index of each fully opaque pixel of `img` within two pixels of the pixel +/// at `at` that it reaches through pixels of an alpha of 128 or more, going up, down, left or +/// right. `stack` is scratch space. +fn reached_opaque(img: &Raster, at: (usize, usize), stack: &mut Vec<(usize, usize)>, mut visit: impl FnMut(usize)) { + let (w, h) = (img.width as usize, img.height as usize); + let px = img.rgba.as_chunks::<4>().0; + let alpha = |j: usize| px.get(j).map_or(0, |p| p[3]); + let (x, y) = at; + if x >= w || y >= h { + return; + } + let (x0, y0, x1, y1) = (x.saturating_sub(2), y.saturating_sub(2), (x + 2).min(w - 1), (y + 2).min(h - 1)); + let mut reached = [[false; 5]; 5]; + stack.clear(); + stack.push((x, y)); + if let Some(r) = reached.get_mut(y - y0).and_then(|row| row.get_mut(x - x0)) { + *r = true; + } + while let Some((cx, cy)) = stack.pop() { + let j = cy * w + cx; + if alpha(j) == 255 { + visit(j); + } + for (nx, ny) in [(cx.wrapping_sub(1), cy), (cx + 1, cy), (cx, cy.wrapping_sub(1)), (cx, cy + 1)] { + if nx < x0 || nx > x1 || ny < y0 || ny > y1 || alpha(ny * w + nx) < 128 { + continue; + } + let Some(r) = reached.get_mut(ny - y0).and_then(|row| row.get_mut(nx - x0)) else { continue }; + if !*r { + *r = true; + stack.push((nx, ny)); + } + } + } +} + +/// Which pixels of `img` are on its cut edge: an alpha of 128 to 254, and a pixel above, below, +/// left or right of it with an alpha under 128. Pixels outside the image do not count as +/// transparent. +fn cut_edge(img: &Raster) -> Vec { + let (w, h) = (img.width as usize, img.height as usize); + let px = img.rgba.as_chunks::<4>().0; + let alpha = |j: usize| px.get(j).map_or(0, |p| p[3]); + (0..px.len()) + .map(|i| { + let (x, y) = (i % w.max(1), i / w.max(1)); + (128..=254).contains(&alpha(i)) + && ((x > 0 && alpha(i - 1) < 128) + || (x + 1 < w && alpha(i + 1) < 128) + || (y > 0 && alpha(i - w) < 128) + || (y + 1 < h && alpha(i + w) < 128)) + }) + .collect() +} + +/// How many steps in from the cut edge (see [`cut_edge`]) the semi-transparent pixels that choose +/// no color reach. +const CUT_EDGE_BAND: usize = 2; + +/// Which pixels of `img` choose the palette's colors: the opaque ones (an alpha of 128 or more), +/// except the pixels that [`settle_cut_edges`] gives the color beside them, and the pixels with an +/// alpha of 128 to 254 reached from those in at most [`CUT_EDGE_BAND`] steps up, down, left or +/// right through such pixels whose color is a blend of the same kind. The pixels it gives the color +/// beside them are the ones on the cut edge (see [`cut_edge`]) whose color is the color of a fully +/// opaque pixel that they reach blended with the transparent background (see [`edge_blend_of`]). +/// Counted, these pixels could add a second shade of a color to the palette. A thin +/// semi-transparent outline of another color still chooses. When no pixel is left to choose, every +/// opaque pixel chooses. With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]), as in +/// [`settle_cut_edges`]. +fn choosing_pixels(img: &Raster, gray: bool) -> Vec { + let (w, n) = (img.width as usize, img.rgba.len() / 4); + let px = img.rgba.as_chunks::<4>().0; + let semi = |j: usize| px.get(j).is_some_and(|p| (128..=254).contains(&p[3])); + let color = |p: &[u8; 4]| if gray { [luma(*p); 3] } else { [p[0], p[1], p[2]] }; + let edge = cut_edge(img); + let mut stack: Vec<(usize, usize)> = Vec::new(); + // Whether the color of pixel `i` is the color of a fully opaque pixel it reaches blended with + // the transparent background. + let mut blend_of_art = |i: usize| { + let Some(p) = px.get(i) else { return false }; + let own = oklab(color(p).map(f64::from)); + let mut blend = false; + reached_opaque(img, (i % w.max(1), i / w.max(1)), &mut stack, |j| { + blend |= px.get(j).is_some_and(|b| edge_blend_of(own, color(b), p[3])); + }); + blend + }; + let mut left_out: Vec = (0..n).map(|i| edge.get(i).copied().unwrap_or(false) && blend_of_art(i)).collect(); + let mut front: Vec = (0..left_out.len()).filter(|&i| left_out.get(i).copied().unwrap_or(false)).collect(); + for _ in 0..CUT_EDGE_BAND { + let mut next = Vec::new(); + for i in front { + let (x, y) = (i % w.max(1), i / w.max(1)); + for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (i + w < n).then(|| i + w)].into_iter().flatten() { + if left_out.get(j) == Some(&false) && semi(j) && blend_of_art(j) { + if let Some(out) = left_out.get_mut(j) { + *out = true; + } + next.push(j); + } + } + } + front = next; + } + let choose: Vec = px.iter().zip(&left_out).map(|(p, &out)| p[3] >= 128 && !out).collect(); + if choose.iter().any(|&c| c) { choose } else { px.iter().map(|p| p[3] >= 128).collect() } +} + /// How far (Oklab × 100) the color of a pixel on a cut edge may lie from the color beside it, or /// from a darker shade of that color, and still count as a blend of it with the transparent /// background. @@ -640,6 +759,10 @@ pub const FULL_TONE_MAX: usize = 256; /// A histogram bin in use: its index, mean colour and pixel count. type Used = (usize, [f64; 3], f64); +/// A color that is no palette color, the index of the rare palette color it traces as, and the +/// index of the palette color on the other side of it (see [`rare_colours`]). +type TracedAs = ([f64; 3], usize, usize); + /// Color mode's palette for the histogram bins `used`, as `params.palette` asks. fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3]> { let k = params.colors.clamp(2, 256) as usize; @@ -859,6 +982,14 @@ pub(crate) const RARE_ISLAND: usize = 4; /// How many regions of [`RARE_ISLAND`] pixels make a rare colour coherent. const RARE_ISLANDS: usize = 3; +/// Beside the cut edge, a color is the anti-aliasing between two others only when at least one in +/// this many of its pixels has pixels of both around it (see [`rare_colours`]). +const CUT_EDGE_MIX_TOUCH: usize = 32; + +/// Beside the cut edge, a color is the anti-aliasing of a rare color only when it lies at least +/// this far from the other color toward the rare one, 0 to 1 (see [`rare_colours`]). +const CUT_EDGE_MIX_FROM: f64 = 0.45; + /// Automatic on flat art: the colours too rare to count among its colours (the count keeps colours /// on at least about 1% of the picture), or too close to a large one to get a box of their own, /// that are still colours of the picture: a highlight, a stem, a thin outline. A rare colour is @@ -867,14 +998,24 @@ const RARE_ISLANDS: usize = 3; /// bin; and drawn, some 4-connected region of its own (not blended) pixels of at least /// [`RARE_REGION`] pixels, or [`RARE_ISLANDS`] regions of at least [`RARE_ISLAND`]. At most [`RARE_BUDGET`] are kept, those with the largest regions first. /// +/// `own` marks the pixels that count, and `choose` the pixels that choose the palette (see +/// [`choosing_pixels`]). A color with pixels beside a pixel that `choose` leaves out, and without an +/// inside, is not kept when it lies between two colors, one a palette color or another kept rare +/// color and the other a kept rare color, at least [`CUT_EDGE_MIX_FROM`] of the way to the rare +/// one, and at least one in [`CUT_EDGE_MIX_TOUCH`] of its pixels has pixels of both around it. The +/// pixels around it that `choose` leaves out count when their color is within [`RARE_APART`] of +/// one of the two. It is returned in the second list instead, with the indexes in `centers` +/// followed by the first list of the rare color it traces as and of the color on the other side +/// of it (the rare color again when it traces as the rare color alone). +/// /// The palette is otherwise median cut (Heckbert 1982) refined by k-means (Lloyd 1982); keeping /// salient colours that are rare by area follows the idea of salient-colour preservation in /// palette reduction (Fukuda et al. 2019, doi:10.1117/12.2521607). The rule and its numbers are /// VectorCraft's own. -fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> Vec<[f64; 3]> { +fn rare_colours(img: &Raster, own: &[bool], choose: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> (Vec<[f64; 3]>, Vec) { let px = img.rgba.as_chunks::<4>().0; if centers.is_empty() || own.len() != px.len() { - return vec![]; + return (vec![], vec![]); } // The picture's own (not blended) pixels by histogram bin. let mut bins = vec![Bin::default(); 1 << 15]; @@ -919,7 +1060,7 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) group_of[bin] = g as u16; } if groups.is_empty() { - return vec![]; + return (vec![], vec![]); } // The largest 4-connected region of each group's own pixels. let (w, h) = (img.width as usize, img.height as usize); @@ -932,6 +1073,10 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) // Pixels of a group whose four neighbours are of it too: an edge's blend is a line one pixel // wide and has next to none. let mut inner = vec![0usize; groups.len()]; + // Groups with a pixel beside a pixel that `choose` leaves out (see `choosing_pixels`): the blend + // test of `own` saw no neighbor on that side. + let mut beside_left_out = vec![false; groups.len()]; + let left_out = |j: usize| px.get(j).is_some_and(|p| p[3] >= 128) && !choose.get(j).copied().unwrap_or(true); let mut seen = vec![false; w * h]; let mut stack = vec![]; for start in 0..w * h { @@ -957,6 +1102,10 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) { *n += 1; } + if let Some(b) = beside_left_out.get_mut(g as usize).filter(|b| !**b) { + let (x0, y0, x1, y1) = (x.saturating_sub(1), y.saturating_sub(1), (x + 1).min(w - 1), (y + 1).min(h - 1)); + *b = (y0..=y1).any(|ny| (x0..=x1).any(|nx| left_out(ny * w + nx))); + } } if let Some(l) = largest.get_mut(g as usize) { *l = (*l).max(size); @@ -966,7 +1115,7 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) } } // Coherent and flat groups, largest region first. - let mut chosen: Vec<(usize, f64, [f64; 3])> = vec![]; + let mut chosen: Vec<(usize, f64, [f64; 3], usize)> = vec![]; for (g, members) in groups.iter().enumerate() { let weight: f64 = members.iter().filter_map(|&u| used.get(u)).map(|u| u.2).sum(); let heaviest = members.first().and_then(|&u| used.get(u)).map_or(0.0, |u| u.2); @@ -987,20 +1136,177 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) if is_mix(mean, centers) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND) { continue; } - chosen.push((region, weight, mean)); + chosen.push((region, weight, mean, g)); } chosen.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)).then(a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))); + // Beside the pixels that choose no color, the blend test of `own` does not see the + // anti-aliasing between a palette color and a rare color drawn along the cut edge. There, a + // color without an inside that lies between two colors, one of them another kept rare color, + // and at least CUT_EDGE_MIX_FROM of the way from the other to it, is that anti-aliasing when at + // least one in CUT_EDGE_MIX_TOUCH of its pixels has pixels of both colors around it: it is not + // kept, and it traces as that rare color (the nearer one when there are several). Art that + // reaches a rim drawn along the edge touches it in a few places only, and the rim keeps its + // color. The smallest regions are tested first, so that of two such colors, one stays, and a + // color found to trace as another counts as that color. + let tested = |g: usize| beside_left_out.get(g).copied().unwrap_or(false) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND); + // The pixels of each color tested, gathered in one pass over the image. + let mut pixels_of: Vec> = vec![vec![]; groups.len()]; + let wanted: Vec = (0..groups.len()).map(|g| tested(g) && chosen.iter().any(|c| c.3 == g)).collect(); + if wanted.contains(&true) { + for i in 0..w * h { + if let Some(g) = group_at(i).map(usize::from).filter(|&g| wanted.get(g).copied().unwrap_or(false)) + && let Some(p) = pixels_of.get_mut(g) + { + p.push(i); + } + } + } + let mut onto: Vec> = vec![None; chosen.len()]; + // For each color in `onto`, where the color on the other side of it comes from (see `from` + // below), or `None` when the color traces as the rare color alone. + let mut other_of: Vec> = vec![None; chosen.len()]; + for k in (0..chosen.len()).rev() { + let Some(&(_, _, c, g)) = chosen.get(k) else { continue }; + if !tested(g) { + continue; + } + // The palette colors, then the other rare colors still kept, and where each comes from: a + // palette color's index, or the number of palette colors plus the index in `chosen`. + let from: Vec = (0..centers.len()) + .chain(chosen.iter().zip(&onto).enumerate().filter(|(o, (_, t))| *o != k && t.is_none()).map(|(o, _)| centers.len() + o)) + .collect(); + let with: Vec<[f64; 3]> = centers + .iter() + .copied() + .chain(from.iter().skip(centers.len()).filter_map(|&f| chosen.get(f.checked_sub(centers.len())?)).map(|x| x.2)) + .collect(); + let pairs: Vec<(usize, usize)> = (0..with.len()) + .flat_map(|a| (a + 1..with.len()).map(move |b| (a, b))) + .filter(|&(a, b)| matches!((with.get(a), with.get(b)), (Some(&x), Some(&y)) if is_mix(c, &[x, y]))) + .collect(); + if pairs.is_empty() { + continue; + } + // Which of `with` the pixels in the 3 × 3 around the color's own pixels are: the nearest + // among `with`, the color itself and the colors that trace as one of `with`. A pixel that + // chooses no color counts only within RARE_APART of the one it is nearest to (beyond the + // cut edge, an outline over the edge shows its own color). + let mut near = with.clone(); + near.push(c); + let mut counts_as: Vec> = (0..with.len()).map(Some).chain([None]).collect(); + for (x, t) in chosen.iter().zip(&onto) { + if let Some(t) = t { + near.push(x.2); + counts_as.push(with.iter().position(|w| w == t)); + } + } + let near_labs = labs(&near); + // For each pair, how many of the color's own pixels have pixels of both around them. + let mut both = vec![0usize; pairs.len()]; + let mut pixels = 0usize; + let mut around = vec![false; with.len()]; + for &i in pixels_of.get(g).map_or(&[][..], Vec::as_slice) { + let (x, y) = (i % w, i / w); + pixels += 1; + around.fill(false); + for ny in y.saturating_sub(1)..=(y + 1).min(h - 1) { + for nx in x.saturating_sub(1)..=(x + 1).min(w - 1) { + let j = ny * w + nx; + let Some(p) = px.get(j).filter(|p| p[3] >= 128) else { continue }; + let o = oklab([p[0], p[1], p[2]].map(f64::from)); + let n = nearest_lab(&near_labs, o); + if !choose.get(j).copied().unwrap_or(false) && near_labs.get(n).is_none_or(|l| dist(*l, o) >= RARE_APART) { + continue; + } + if let Some(&Some(a)) = counts_as.get(n) + && let Some(t) = around.get_mut(a) + { + *t = true; + } + } + } + for (n, &(a, b)) in pairs.iter().enumerate() { + if around.get(a).copied().unwrap_or(false) + && around.get(b).copied().unwrap_or(false) + && let Some(v) = both.get_mut(n) + { + *v += 1; + } + } + } + let lab = oklab(c); + // The nearest rare color it traces as, with where the other color of its pair comes from + // when its pixels split between the two (see below). + let mut best: Option<(f64, [f64; 3], Option)> = None; + for (n, &(a, b)) in pairs.iter().enumerate() { + if both.get(n).is_none_or(|&v| v == 0 || v * CUT_EDGE_MIX_TOUCH < pixels) { + continue; + } + for (e, f) in [(a, b), (b, a)].into_iter().filter(|&(e, _)| e >= centers.len()) { + let (Some(&x), Some(&y)) = (with.get(e), with.get(f)) else { continue }; + // How far `c` lies from the other color toward this one. + let d2: f64 = (0..3).map(|q| (x[q] - y[q]).powi(2)).sum(); + let t = if d2 > 0.0 { (0..3).map(|q| (c[q] - y[q]) * (x[q] - y[q])).sum::() / d2 } else { 0.0 }; + if t < CUT_EDGE_MIX_FROM { + continue; + } + let d = dist(oklab(x), lab); + if best.is_none_or(|b| d < b.0) { + // A rare color along the cut edge with a region of RARE_REGION pixels is a line + // of its own there: the anti-aliasing beside it splits between it and the other + // color. Otherwise the anti-aliasing is most of a thin line, or the part of a + // line over the edge that lies inside it, and traces as the rare color alone. + let body = from + .get(e) + .and_then(|&m| chosen.get(m.checked_sub(centers.len())?)) + .is_some_and(|r| r.0 >= RARE_REGION && beside_left_out.get(r.3).copied().unwrap_or(false)); + best = Some((d, x, from.get(f).copied().filter(|_| body))); + } + } + } + if let Some(o) = onto.get_mut(k) { + *o = best.map(|b| b.1); + } + if let Some(o) = other_of.get_mut(k) { + *o = best.and_then(|b| b.2); + } + } let mut out: Vec<[f64; 3]> = vec![]; - for (_, _, c) in chosen { + let mut traced_as = vec![]; + for (((_, _, c, _), o), &from) in chosen.iter().zip(&onto).zip(&other_of) { + if let Some(o) = o { + traced_as.push((*c, *o, from)); + continue; + } if out.len() >= RARE_BUDGET { break; } - let lab = oklab(c); + let lab = oklab(*c); if out.iter().all(|o| dist(oklab(*o), lab) >= RARE_APART) { - out.push(c); + out.push(*c); } } - out + // The index in `centers` followed by `out` of a palette color or a rare color, or of the kept + // rare color one that is not kept traces as. + let index = |from: usize| -> Option { + let Some(m) = from.checked_sub(centers.len()) else { return Some(from) }; + let (_, _, x, _) = chosen.get(m)?; + let x = onto.get(m).copied().flatten().unwrap_or(*x); + Some(centers.len() + out.iter().position(|o| *o == x)?) + }; + // A color whose rare color is not kept (past the budget) is left out. + let traced_as = traced_as + .into_iter() + .filter_map(|(c, o, from)| { + let rare = centers.len() + out.iter().position(|x| *x == o)?; + let other = match from { + Some(f) => index(f)?, + None => rare, + }; + Some((c, rare, other)) + }) + .collect(); + (out, traced_as) } /// Whether `c` (sRGB) lies between two of `centers`, as anti-aliasing mixes them: within 12 levels diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index e3ee1a4af..74bf738a1 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -408,6 +408,64 @@ fn a_thin_semi_transparent_outline_keeps_its_own_color() { } } +#[test] +fn a_cut_out_edge_leaves_the_color_of_the_art_inside_it_alone() { + // A white pixel inside a ring of half-opaque dark gray edge pixels on a transparent + // background: the edge chooses no color, and the white still takes the white swatch. + let img = Raster::from_fn(5, 5, |x, y| match (x, y) { + (2, 2) => [255, 255, 255, 255], + (1..=3, 1..=3) => [64, 64, 64, 128], + _ => [0, 0, 0, 0], + }); + let params = TraceParams { + mode: Mode::Color, + palette: Palette::DocumentLibrary, + swatches: vec![[0, 0, 0], [255, 255, 255]], + colors: 2, + ..TraceParams::default() + }; + let q = quantize(&img, ¶ms); + assert_eq!(q.palette.get(q.labels[12] as usize), Some(&[255, 255, 255]), "{q:?}"); +} + +#[test] +fn a_semi_transparent_stripe_beside_a_cut_out_edge_keeps_its_own_color() { + // Columns: transparent, a half-opaque gray edge (white blended with the background), a + // semi-transparent yellow stripe, then opaque white. The gray chooses no color; the yellow, + // no blend of the white, still does. + let img = Raster::from_fn(8, 10, |x, _| match x { + 0 => [0, 0, 0, 0], + 1 => [128, 128, 128, 128], + 2 => [250, 200, 40, 192], + _ => [255, 255, 255, 255], + }); + let q = quantize(&img, &TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 3, ..TraceParams::default() }); + let yellow = q.palette.get(q.labels[5 * 8 + 2] as usize).copied(); + assert!(yellow.is_some_and(|c| c[2] < 100), "{q:?}"); +} + +#[test] +fn grayscale_takes_no_gray_from_a_cut_out_edge() { + // A white square cut out of a transparent border, with a black and a mid-gray block in it, + // and a two-pixel cut edge of white darkened by the transparent background. The edge + // chooses no gray, so three grays keep the black, the mid-gray and the white apart. + let img = Raster::from_fn(40, 40, |x, y| { + let ring = x.min(y).min(39 - x).min(39 - y); + match (x, y) { + _ if ring < 1 => [0, 0, 0, 0], + _ if ring < 3 => [170, 170, 170, 200], + (6..=17, 6..=33) => [20, 20, 20, 255], + (22..=33, 6..=33) => [110, 110, 110, 255], + _ => [255, 255, 255, 255], + } + }); + let q = quantize(&img, &TraceParams { mode: Mode::Grayscale, colors: 3, ..TraceParams::default() }); + let gray = |x: usize, y: usize| q.palette.get(q.labels[y * 40 + x] as usize).map(|c| c[0]); + for ((x, y), want) in [((10, 20), 20u8), ((28, 20), 110), ((20, 3), 255)] { + assert!(gray(x, y).is_some_and(|g| g.abs_diff(want) <= 4), "({x}, {y}) traces as {:?}, not {want}: {q:?}", gray(x, y)); + } +} + #[test] fn threshold_controls_black_and_white() { let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [100, 100, 100, 255] } else { WHITE }); @@ -2192,6 +2250,302 @@ fn scattered_noise_adds_no_colour() { } } +/// A light disc (radius 190) cut out of a transparent background, with a blue and a black block +/// in it and a tan outline `width` px wide and `alpha` (0–1) opaque drawn over the disc's edge, +/// anti-aliased with 4 × 4 samples a pixel. Inside the edge the outline is an opaque blend of the +/// tan and the light color; outside it, the tan alone, semi-transparent. +fn cut_out_with_outline(width: f64, alpha: f64) -> Raster { + let (light, tan, blue, black) = ([253.0, 247.0, 227.0], [154.0, 132.0, 82.0], [34.0, 125.0, 250.0], [5.0, 5.0, 3.0]); + Raster::from_fn(400, 400, |x, y| { + // Premultiplied color and alpha, summed over the samples. + let (mut sum, mut cover) = ([0.0f64; 3], 0.0f64); + for s in 0..16 { + let (fx, fy) = (f64::from(x) + (f64::from(s % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(s / 4) + 0.5) / 4.0); + let r = (fx - 200.0).hypot(fy - 200.0); + let (mut c, mut a) = if r >= 190.0 { + ([0.0; 3], 0.0) + } else if (125.0..275.0).contains(&fx) && (100.0..200.0).contains(&fy) { + (blue, 1.0) + } else if (125.0..275.0).contains(&fx) && (225.0..300.0).contains(&fy) { + (black, 1.0) + } else { + (light, 1.0) + }; + c = c.map(|v| v * a); + if (r - 190.0).abs() < width / 2.0 { + c = [0, 1, 2].map(|k| tan[k] * alpha + c[k] * (1.0 - alpha)); + a = alpha + a * (1.0 - alpha); + } + for k in 0..3 { + sum[k] += c[k]; + } + cover += a; + } + if cover <= 0.0 { + return [0, 0, 0, 0]; + } + let c = sum.map(|v| (v / cover).round().clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], (cover / 16.0 * 255.0).round() as u8] + }) +} + +#[test] +fn automatic_takes_no_color_from_a_cut_out_edge() { + // The semi-transparent pixels along the disc's cut edge are the tan alone, or a mix of it and + // the outline's opaque blend inside the edge: counted, they make a second tan, or leave the + // outline no color of its own. They trace, but choose no color: one tan. Inside the edge, the + // anti-aliasing between the disc and the outline is no second tan either. + let params = preset("Auto Color").unwrap(); + let mut failed = vec![]; + for width in [1.0, 1.5, 2.0, 2.5, 3.0, 4.0] { + for alpha in [0.5, 0.6, 0.7, 0.75, 0.8, 0.9, 1.0] { + // A 1 px outline under alpha 0.7 traces as the disc's color: the opaque pixels it + // covers vary too much to be a color of their own, and the ones outside the edge are + // darker shades of them. + if width < 1.5 && alpha < 0.7 { + continue; + } + let res = trace(&cut_out_with_outline(width, alpha), ¶ms); + if distinct_colours(&res) != 4 { + failed.push(format!("outline {width} px, alpha {alpha}: {:?}", res.palette)); + } + } + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} + +#[test] +fn automatic_keeps_a_thin_cut_edge_outline_in_few_pieces() { + // A fully opaque outline 1.1 to 1.3 px wide over the cut edge: along the circle its opaque + // pixels are the tan or a mix of the tan and the disc. The mix is the anti-aliasing between + // them and traces as the tan, so the outline stays a ring of few pieces. + let main = [[253, 247, 227], [34, 125, 250], [5, 5, 3]].map(quantize::tests_oklab); + let is_main = |c: [u8; 3]| { + let o = quantize::tests_oklab(c); + main.iter().any(|m| (0..3).map(|k| (o[k] - m[k]).powi(2)).sum::().sqrt() <= 6.0) + }; + let params = preset("Auto Color").unwrap(); + let mut failed = vec![]; + for width in [1.1, 1.25, 1.3] { + let res = trace(&cut_out_with_outline(width, 1.0), ¶ms); + let pieces = res.paths.iter().filter(|p| !is_main(p.color)).count(); + if distinct_colours(&res) != 4 || pieces >= 24 { + failed.push(format!("outline {width} px: {} colors, {pieces} outline paths", distinct_colours(&res))); + } + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} + +/// A light disc (radius 140) cut out of a transparent background, with a blue block and four dark +/// bars 2 px tall in it, and an opaque rim `width` px wide along the inside of its cut edge whose +/// color is `t` (0–1) of the way from the light color to the bars', anti-aliased with 4 × 4 samples +/// a pixel. Outside the edge there is nothing: the edge pixels are the rim's color, semi-transparent. +fn cut_out_with_rim(width: f64, t: f64) -> Raster { + let (light, blue, dark) = ([253.0, 247.0, 227.0], [34.0, 125.0, 250.0], [110.0, 80.0, 40.0]); + let rim: [f64; 3] = [0, 1, 2].map(|k| (light[k] * (1.0 - t) + dark[k] * t).round()); + let bars = [(80.0, 190.0, 120.0, 192.0), (130.0, 190.0, 170.0, 192.0), (180.0, 190.0, 220.0, 192.0), (80.0, 215.0, 220.0, 217.0)]; + Raster::from_fn(300, 300, |x, y| { + let (mut sum, mut cover) = ([0.0f64; 3], 0.0f64); + for s in 0..16 { + let (fx, fy) = (f64::from(x) + (f64::from(s % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(s / 4) + 0.5) / 4.0); + let r = (fx - 150.0).hypot(fy - 150.0); + if r >= 140.0 { + continue; + } + let c = if r >= 140.0 - width { + rim + } else if bars.iter().any(|&(x0, y0, x1, y1)| (x0..x1).contains(&fx) && (y0..y1).contains(&fy)) { + dark + } else if (90.0..210.0).contains(&fx) && (70.0..150.0).contains(&fy) { + blue + } else { + light + }; + for k in 0..3 { + sum[k] += c[k]; + } + cover += 1.0; + } + if cover <= 0.0 { + return [0, 0, 0, 0]; + } + let c = sum.map(|v| (v / cover).round().clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], (cover / 16.0 * 255.0).round() as u8] + }) +} + +#[test] +fn automatic_keeps_an_opaque_rim_along_a_cut_edge() { + // An opaque rim drawn along the inside of the cut edge, its color between the disc's and the + // bars': beside the cut edge, but touching no bar, it is drawn and not anti-aliasing between + // the two, and keeps its color. + let mut failed = vec![]; + for (width, detail) in [(1.5, 85.0), (2.0, 50.0), (2.5, 50.0)] { + for t in [0.35, 0.5, 0.65] { + let rim = [0, 1, 2].map(|k| ([253.0f64, 247.0, 227.0][k] * (1.0 - t) + [110.0, 80.0, 40.0][k] * t).round() as u8); + let params = TraceParams { color_detail: detail, ..preset("Auto Color").unwrap() }; + let res = trace(&cut_out_with_rim(width, t), ¶ms); + if !has_colour(&res, rim, 3.0) { + failed.push(format!("rim {width} px, {t}, color detail {detail}: {:?}", res.palette)); + } + } + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} + +#[test] +fn automatic_splits_the_anti_aliasing_beside_a_cut_edge_outline() { + // A 2.5 px outline at alpha 0.7 and 0.75 over the cut edge has an opaque band of its own + // inside the edge. The anti-aliasing between that band and the disc is no second shade of + // the outline, and each pixel of it traces as the color it is nearer to. Traced so, the + // outline's paths average 2.18 and 2.27 px wide; traced as the outline, the anti-aliasing + // adds 0.075 px. + use kurbo::Shape; + let main = [[253, 247, 227], [34, 125, 250], [5, 5, 3]].map(quantize::tests_oklab); + let is_main = |c: [u8; 3]| { + let o = quantize::tests_oklab(c); + main.iter().any(|m| (0..3).map(|k| (o[k] - m[k]).powi(2)).sum::().sqrt() <= 6.0) + }; + let mut failed = vec![]; + for (alpha, widest) in [(0.7, 2.22), (0.75, 2.31)] { + let res = trace(&cut_out_with_outline(2.5, alpha), &preset("Auto Color").unwrap()); + let area: f64 = res.paths.iter().filter(|p| !is_main(p.color)).map(|p| p.path.to_bezpath().area().abs()).sum(); + let width = area / (2.0 * std::f64::consts::PI * 190.0); + if distinct_colours(&res) != 4 || width > widest { + failed.push(format!("alpha {alpha}: {} colors, outline {width:.2} px wide", distinct_colours(&res))); + } + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} + +/// `layers` (where, color, alpha 0–1) drawn in order over a transparent 300 × 300 image, +/// anti-aliased with 4 × 4 samples a pixel; each pixel's color is unpremultiplied. +fn cut_out_layers(layers: &[(Inside, [f64; 3], f64)]) -> Raster { + Raster::from_fn(300, 300, |x, y| { + // Premultiplied color and alpha, summed over the samples. + let (mut sum, mut cover) = ([0.0f64; 3], 0.0f64); + for s in 0..16 { + let (fx, fy) = (f64::from(x) + (f64::from(s % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(s / 4) + 0.5) / 4.0); + let (mut c, mut a) = ([0.0f64; 3], 0.0f64); + for (inside, color, alpha) in layers { + if inside(fx, fy) { + c = [0, 1, 2].map(|k| color[k] * alpha + c[k] * (1.0 - alpha)); + a = alpha + a * (1.0 - alpha); + } + } + for k in 0..3 { + sum[k] += c[k]; + } + cover += a; + } + if cover <= 0.0 { + return [0, 0, 0, 0]; + } + let c = sum.map(|v| (v / cover).round().clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], (cover / 16.0 * 255.0).round() as u8] + }) +} + +#[test] +fn automatic_traces_an_outline_over_a_cut_edge_in_the_color_of_other_art() { + // A light disc cut out of a transparent background, with a blue block and dark bars, and a + // dark outline at alpha 0.8 over the cut edge in the bars' color. Inside the edge the outline + // is an opaque blend of the dark and the light color; outside it, the dark alone. The blend is + // the outline, not a second shade of it: three colors, and the outline in few pieces. + let (light, blue, dark) = ([253.0, 247.0, 227.0], [34.0, 125.0, 250.0], [110.0, 80.0, 40.0]); + let r = |x: f64, y: f64| (x - 150.0).hypot(y - 150.0); + let disc = |x: f64, y: f64| r(x, y) < 140.0; + let block = |x: f64, y: f64| (90.0..210.0).contains(&x) && (70.0..150.0).contains(&y); + let bars = |x: f64, y: f64| { + [(80.0, 190.0, 120.0, 192.0), (130.0, 190.0, 170.0, 192.0), (180.0, 190.0, 220.0, 192.0), (80.0, 215.0, 220.0, 217.0)] + .iter() + .any(|&(x0, y0, x1, y1)| (x0..x1).contains(&x) && (y0..y1).contains(&y)) + }; + let mut failed = vec![]; + for width in [1.5, 2.0, 2.5] { + let outline = move |x: f64, y: f64| (r(x, y) - 140.0).abs() < width / 2.0; + let img = cut_out_layers(&[(&disc, light, 1.0), (&block, blue, 1.0), (&bars, dark, 1.0), (&outline, dark, 0.8)]); + for detail in [85.0, 50.0] { + let res = trace(&img, &TraceParams { color_detail: detail, ..preset("Auto Color").unwrap() }); + if distinct_colours(&res) != 3 || !has_colour(&res, [110, 80, 40], 3.0) || res.paths.len() > 24 { + failed.push(format!("outline {width} px, color detail {detail}: {} paths, {:?}", res.paths.len(), res.palette)); + } + } + } + // A 1 px outline's blend inside the edge is less than halfway from the light color to the + // dark: it keeps a color of its own, and the outline stays a ring of few pieces. + let outline = move |x: f64, y: f64| (r(x, y) - 140.0).abs() < 0.5; + let img = cut_out_layers(&[(&disc, light, 1.0), (&block, blue, 1.0), (&bars, dark, 1.0), (&outline, dark, 0.8)]); + let res = trace(&img, &preset("Auto Color").unwrap()); + if distinct_colours(&res) != 4 || res.paths.len() > 20 { + failed.push(format!("outline 1 px: {} paths, {:?}", res.paths.len(), res.palette)); + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} + +#[test] +fn automatic_keeps_an_opaque_rim_that_other_art_reaches() { + // An opaque 2 px rim along the inside of the cut edge, its color between the disc's and that of + // dark bars, two of which run out to the rim and touch it: drawn, not anti-aliasing between the + // disc and the bars, so it keeps its color. A light rim with brown bars, and a gray rim with + // black bars on a near-white disc. + let r = |x: f64, y: f64| (x - 150.0).hypot(y - 150.0); + let disc = |x: f64, y: f64| r(x, y) < 140.0; + let rim = |x: f64, y: f64| (138.0..140.0).contains(&r(x, y)); + let block = |x: f64, y: f64| (90.0..210.0).contains(&x) && (70.0..150.0).contains(&y); + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| move |x: f64, y: f64| (x0..x1).contains(&x) && (y0..y1).contains(&y); + let (top, bottom, left, wide) = + (rect(146.0, 12.0, 154.0, 60.0), rect(146.0, 240.0, 154.0, 288.0), rect(12.0, 196.0, 60.0, 199.0), rect(80.0, 215.0, 220.0, 217.0)); + let light: [f64; 3] = [253.0, 247.0, 227.0]; + let tan: [f64; 3] = [0, 1, 2].map(|k| (light[k] * 0.65 + [110.0, 80.0, 40.0][k] * 0.35).round()); + let (blue, dark) = ([34.0, 125.0, 250.0], [110.0, 80.0, 40.0]); + let brown = cut_out_layers(&[ + (&disc, light, 1.0), + (&block, blue, 1.0), + (&top, dark, 1.0), + (&bottom, dark, 1.0), + (&left, dark, 1.0), + (&wide, dark, 1.0), + (&rim, tan, 1.0), + ]); + let tall = rect(80.0, 215.0, 220.0, 218.0); + let (white, red, black, gray) = ([250.0; 3], [200.0, 40.0, 40.0], [5.0, 5.0, 3.0], [128.0; 3]); + let black_bars = cut_out_layers(&[(&disc, white, 1.0), (&block, red, 1.0), (&top, black, 1.0), (&tall, black, 1.0), (&rim, gray, 1.0)]); + let mut failed = vec![]; + for (name, img, color) in [("tan rim", &brown, tan.map(|v| v as u8)), ("gray rim", &black_bars, [128, 128, 128])] { + for detail in [85.0, 50.0] { + let res = trace(img, &TraceParams { color_detail: detail, ..preset("Auto Color").unwrap() }); + if !has_colour(&res, color, 3.0) { + failed.push(format!("{name}, color detail {detail}: {:?}", res.palette)); + } + } + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} + +#[test] +fn automatic_keeps_an_opaque_line_between_two_kept_colors() { + // Two thin opaque lines on a light ground with a blue and a black block: a tan, and a lighter + // tan halfway between it and the ground. Away from a cut edge, the lighter one is drawn, not a + // blend: both stay. + let img = Raster::from_fn(400, 400, |x, y| { + if (100..300).contains(&x) && (40..120).contains(&y) { + [34, 125, 250, 255] + } else if (100..300).contains(&x) && (280..360).contains(&y) { + [5, 5, 3, 255] + } else if (60..340).contains(&x) && (180..182).contains(&y) { + [154, 132, 82, 255] + } else if (60..340).contains(&x) && (220..222).contains(&y) { + [204, 190, 155, 255] + } else { + [253, 247, 227, 255] + } + }); + let res = trace(&img, &preset("Auto Color").unwrap()); + assert!(has_colour(&res, [154, 132, 82], 3.0), "{:?}", res.palette); + assert!(has_colour(&res, [204, 190, 155], 3.0), "{:?}", res.palette); +} + #[test] fn noise_keeps_a_kept_rare_colours_regions_but_not_its_specks() { // Labels: 0 background, 1 a rare colour kept on purpose (four islands of 4, 6, 9 and 2 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8596d85a3..83a049191 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -801,6 +801,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, notches read only their own piece:** the notch rule of `subpixel::outline` reads only the shared piece, so both regions of an edge cut a corner pixel alike and leave no gap; a code-drawn noisy multi-colour coverage test in `crates/trace` (see [quality notes](image-trace-quality.md#notches-read-only-their-own-piece)). - **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)). - **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`. +- **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`. - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged.