diff --git a/crates/trace/src/group.rs b/crates/trace/src/group.rs index b51ae57907..a0e3b85020 100644 --- a/crates/trace/src/group.rs +++ b/crates/trace/src/group.rs @@ -420,13 +420,13 @@ mod tests { #[test] fn a_dense_trace_is_grouped_with_a_few_box_tests_per_path() { - let img = noise(120, 120); + let img = noise(150, 150); for (method, stacked) in [(crate::Method::Abutting, false), (crate::Method::Overlapping, true)] { let paths = traced(&img, method); let n = paths.len(); let counted = |by_pairs: bool| { BOX_TESTS.with(|n| n.set(0)); - let found = if by_pairs { parents_by_pairs(&paths, 120, 120, stacked) } else { parents(&paths, 120, 120, stacked) }; + let found = if by_pairs { parents_by_pairs(&paths, 150, 150, stacked) } else { parents(&paths, 150, 150, stacked) }; (found, BOX_TESTS.with(std::cell::Cell::get)) }; let (found, tests) = counted(false); diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 9452671def..f5a2d9fa47 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -8,7 +8,7 @@ mod denoise; mod palette; mod rare; -use cut_edge::{choosing_pixels, compact, settled}; +use cut_edge::{choosing_pixels, compact, settled, settled_wide, settles_wide}; pub(crate) use cut_edge::{continuous_tone, settle_cut_edges}; pub use denoise::denoise; pub(crate) use denoise::denoise_keeping; @@ -91,7 +91,8 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized let g = l.round().clamp(0.0, 255.0) as u8; [g, g, g] }); - (settled(merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]), img, &own), vec![]) + let q = merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]); + (if settles_wide(params) { settled_wide(q, img, &own, true) } else { settled(q, img, &own) }, vec![]) } // Logo mode traces without quantising; asked directly, it quantises like Color. Mode::Color | Mode::Logo => { @@ -170,7 +171,7 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized if library { q } else { merge_mixed(q, img.width as usize, img.height as usize, &rare) } }; let q = reduce(params.colors); - let q = settled(q, img, &own); + let q = if settles_wide(params) { settled_wide(q, img, &own, false) } else { settled(q, img, &own) }; rare.retain(|c| q.palette.contains(c)); (q, rare) } diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 22e5c02af2..99b3c42845 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,5 +1,5 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; -use super::{Quantized, Raster, TRANSPARENT, luma, oklab}; +use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab}; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// @@ -65,6 +65,214 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized if changed { compact(q.labels, q.palette) } else { q } } +/// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels +/// settled by [`settled_wide`] rather than [`settled`]. The limited-color presets ask for 3 to 16 +/// colors. +const LIMITED_COLORS: u32 = 16; + +/// Whether a trace with `params` settles its blend pixels with [`settled_wide`]: Grayscale, and +/// Color with a Limited palette, of at most [`LIMITED_COLORS`] colors. +pub(super) fn settles_wide(params: &TraceParams) -> bool { + match params.mode { + Mode::Grayscale => params.colors <= LIMITED_COLORS, + Mode::Color | Mode::Logo => params.palette == Palette::Limited && params.colors <= LIMITED_COLORS, + Mode::BlackAndWhite => false, + } +} + +/// How far (px, across, along or diagonally) [`settled_wide`] looks for the picture's own pixels +/// around a blend pixel: blur spreads an edge over several pixels, and its two sides can lie this +/// far apart. +const BLEND_REACH: usize = 8; + +/// How far (px, across, along or diagonally) an own pixel of its color keeps a blend pixel's color +/// in [`settled_wide`]: where three colors meet, the blend is of its color too. +const OWN_NEAR: usize = 4; + +/// How many of the colors within reach of a blend pixel, nearest to its color first, +/// [`settled_wide`] pairs up. +const NEAR_COLORS: usize = 8; + +/// How many columns at a time [`settled_wide`] keeps the colors within reach for, so that its +/// memory does not grow with the width of the picture. +const BLEND_STRIP: usize = 7; + +/// How far apart (squared levels) the distances of a blend pixel off the lines between two pairs +/// of colors may be for [`settled_wide`] to count them as equal: rounding leaves a gray that lies +/// between two grays about 1e-28 off the line between them. +const SAME_FIT: f64 = 1e-6; + +/// How much nearer (squared) to the line between a pair of colors than to its own color a blend +/// pixel must lie for [`settled_wide`] to settle it: half as far, as [`settled`] asks. +const BETTER_FIT: f64 = 0.25; + +/// How far off the line between a pair of colors (as a share of their distance apart) a blend pixel +/// that lies between the two may be for [`settled_wide`] to settle it whatever its own color. +const ON_THE_LINE: f64 = 0.15; + +/// [`settled`] for a limited palette (see [`settles_wide`]), which also settles the in-between +/// tones of an edge blurred over several pixels: without it they trace as a ring of a third color +/// around the edge. +/// +/// A blend pixel (see [`own_colours`]) whose color is none of those of the picture's own pixels +/// within [`OWN_NEAR`] pixels of it takes the side it is nearer to of the pair of the colors of own +/// pixels within [`BLEND_REACH`] pixels that it is best a blend of (of two pairs that explain it +/// equally well, the closer one), when that explains it twice as well as its own color does, or +/// when it lies between the two and off the line between them by less than [`ON_THE_LINE`] of +/// their distance apart. Only the [`NEAR_COLORS`] colors nearest to its color are paired. It keeps +/// its own color when that color is one of the pair, or when one color is all there is around it. +/// With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]). +pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized { + let (w, h) = (img.width as usize, img.height as usize); + if q.labels.len() != w * h || own.len() != w * h { + return q; + } + let before = q.labels.clone(); + let colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect(); + let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::(); + // The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette + // colors), a strip of columns at a time: along its row for the rows within reach (a window + // sliding along each row, kept for the last 2 × reach + 1 rows), then across those rows. + let words = q.palette.len().div_ceil(64).max(1); + let span = 2 * BLEND_REACH + 1; + let strip = BLEND_STRIP.min(w); + let mut rows = vec![0u64; span * strip * words]; + let mut counts = vec![0u32; q.palette.len()]; + let mut window = vec![0u64; words]; + let mut bits = vec![0u64; words]; + // The bits of row `y` for the columns `x0..x1` (at most `strip` of them). + let mut row_colors = |y: usize, x0: usize, x1: usize, rows: &mut [u64]| { + let Some(out) = rows.get_mut((y % span) * strip * words..(y % span + 1) * strip * words) else { return }; + counts.fill(0); + window.fill(0); + // The own pixel at `x` enters the window (`add`) or leaves it. + let step = |counts: &mut [u32], window: &mut [u64], x: usize, add: bool| { + let i = y * w + x; + let l = before[i] as usize; + if !own[i] || l >= counts.len() { + return; + } + if add { + counts[l] += 1; + window[l / 64] |= 1 << (l % 64); + } else { + counts[l] -= 1; + if counts[l] == 0 { + window[l / 64] &= !(1 << (l % 64)); + } + } + }; + let first = x0.saturating_sub(BLEND_REACH); + for x in first..(x0 + BLEND_REACH).min(w) { + step(&mut counts, &mut window, x, true); + } + for x in x0..x1 { + if x + BLEND_REACH < w { + step(&mut counts, &mut window, x + BLEND_REACH, true); + } + if let Some(gone) = x.checked_sub(BLEND_REACH + 1).filter(|&g| g >= first) { + step(&mut counts, &mut window, gone, false); + } + if let Some(o) = out.get_mut((x - x0) * words..(x - x0 + 1) * words) { + o.copy_from_slice(&window); + } + } + }; + let mut changed = false; + let mut near: Vec = Vec::new(); + let mut by_distance: Vec<(f64, u16)> = Vec::new(); + let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] }; + for (x0, y) in (0..w).step_by(strip.max(1)).flat_map(|x0| (0..h).map(move |y| (x0, y))) { + let x1 = (x0 + strip).min(w); + if y == 0 { + for yy in 0..BLEND_REACH.min(h) { + row_colors(yy, x0, x1, &mut rows); + } + } + if y + BLEND_REACH < h { + row_colors(y + BLEND_REACH, x0, x1, &mut rows); + } + 'pixels: for x in x0..x1 { + let i = y * w + x; + let mine_l = before[i]; + if own[i] || mine_l == TRANSPARENT { + continue; + } + // Its color is the picture's own here. + for yy in y.saturating_sub(OWN_NEAR)..=(y + OWN_NEAR).min(h - 1) { + for xx in x.saturating_sub(OWN_NEAR)..=(x + OWN_NEAR).min(w - 1) { + if own[yy * w + xx] && before[yy * w + xx] == mine_l { + continue 'pixels; + } + } + } + bits.fill(0); + for yy in y.saturating_sub(BLEND_REACH)..=(y + BLEND_REACH).min(h - 1) { + let at = (yy % span) * strip * words + (x - x0) * words; + for (b, r) in bits.iter_mut().zip(rows.get(at..at + words).unwrap_or_default()) { + *b |= r; + } + } + near.clear(); + for (k, &b) in bits.iter().enumerate() { + let mut b = b; + while b != 0 { + let l = (k * 64 + b.trailing_zeros() as usize) as u16; + b &= b - 1; + near.push(l); + } + } + let (Some(p), Some(&mine)) = (img.rgba.get(i * 4..i * 4 + 4).map(pixel), colors.get(mine_l as usize)) else { + continue; + }; + if near.iter().filter(|&&l| l != mine_l).count() < 2 { + continue; + } + // In busy art many colors lie within reach: only the ones nearest to its color are paired. + // Each distance is worked out once, and only the nearest few are sorted (nearest first, + // then by index). + if near.len() > NEAR_COLORS { + by_distance.clear(); + by_distance.extend(near.iter().map(|&l| (colors.get(l as usize).map_or(f64::INFINITY, |&c| dist2(p, c)), l))); + let order = |a: &(f64, u16), b: &(f64, u16)| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)); + by_distance.select_nth_unstable_by(NEAR_COLORS, order); + by_distance.truncate(NEAR_COLORS); + by_distance.sort_unstable_by(order); + near.clear(); + near.extend(by_distance.iter().map(|&(_, l)| l)); + } + // The pair it is best a blend of, by the square of how far it lies off the line between them, + // and of two pairs it lies equally far off, the closer pair (in grays it lies on the line + // between every pair of grays darker and lighter than it). + let mut best: Option<(f64, [u16; 2], u16, f64, bool)> = None; + for (n, &a) in near.iter().enumerate() { + let Some(&ca) = colors.get(a as usize) else { continue }; + let pa = [p[0] - ca[0], p[1] - ca[1], p[2] - ca[2]]; + for &b in &near[n + 1..] { + let Some(&cb) = colors.get(b as usize) else { continue }; + let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]]; + let len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + let along = if len2 > 0.0 { (pa[0] * d[0] + pa[1] * d[1] + pa[2] * d[2]) / len2 } else { 0.0 }; + let t = along.clamp(0.0, 1.0); + let off2 = (pa[0] - t * d[0]).powi(2) + (pa[1] - t * d[1]).powi(2) + (pa[2] - t * d[2]).powi(2); + if best.is_none_or(|x| off2 < x.0 - SAME_FIT || (off2 <= x.0 + SAME_FIT && len2 < x.3)) { + best = Some((off2, [a, b], if t < 0.5 { a } else { b }, len2, along > 0.0 && along < 1.0)); + } + } + } + // A pixel already of one of the two colors it is best a blend of stays as it is. + if let Some((off2, pair, to, apart2, between)) = best + && !pair.contains(&mine_l) + && (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2)) + { + q.labels[i] = to; + changed = true; + } + } + } + if changed { compact(q.labels, q.palette) } else { q } +} + /// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy /// or unevenly lit (a scan): whether eight colours leave much of it unsaid. Flat art is a few /// colours and what noise and lighting make of them, and eight colours come within a few levels @@ -443,3 +651,66 @@ fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool { let off2: f64 = (0..3).map(|c| (own[c] - from[c] - t * d[c]).powi(2)).sum(); off2 <= EDGE_BLEND * EDGE_BLEND } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() { + // Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black + // along the top row, and a palette gray of 120 of its own. It lies between 0 and 200 exactly + // and between 50 and 200 to within rounding: it takes 50, the nearer of the closer pair. + let gray = |x: u32, y: u32| match (x, y) { + (8, 8) => 105, + (_, 0) => 0, + (0..=8, _) => 50, + _ => 200, + }; + let img = Raster::from_fn(17, 17, |x, y| { + let v = gray(x, y); + [v, v, v, 255] + }); + let palette = vec![[0, 0, 0], [50, 50, 50], [200, 200, 200], [120, 120, 120]]; + let labels: Vec = (0..17 * 17) + .map(|i| match gray(i % 17, i / 17) { + 0 => 0, + 50 => 1, + 200 => 2, + _ => 3, + }) + .collect(); + let own: Vec = labels.iter().map(|&l| l != 3).collect(); + let q = settled_wide(Quantized { labels, palette }, &img, &own, true); + let middle = q.labels[8 * 17 + 8]; + assert_eq!(q.palette[usize::from(middle)], [50, 50, 50]); + } + + #[test] + fn a_blend_pixel_beyond_the_end_of_a_pair_keeps_its_nearest_color() { + // A column of [92, 92, 92] between own pixels of [100, 100, 100] to its left and + // [200, 200, 200] to its right, with a palette color of [90, 90, 90]: it lies beyond the + // darker end of the pair, not between the two, and its own color is the nearer, so it + // keeps it. + let value = |x: u32| match x { + 0..=7 => 100, + 8 => 92, + _ => 200, + }; + let img = Raster::from_fn(17, 17, |x, _| { + let v = value(x); + [v, v, v, 255] + }); + let palette = vec![[100, 100, 100], [200, 200, 200], [90, 90, 90]]; + let labels: Vec = (0..17 * 17u32) + .map(|i| match value(i % 17) { + 100 => 0, + 200 => 1, + _ => 2, + }) + .collect(); + let own: Vec = labels.iter().map(|&l| l != 2).collect(); + let q = settled_wide(Quantized { labels, palette }, &img, &own, false); + assert_eq!(q.palette[usize::from(q.labels[8 * 17 + 8])], [90, 90, 90]); + } +} diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index d0a589b916..b244c3b79b 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1330,6 +1330,115 @@ fn a_blend_of_two_colours_is_no_sliver_of_a_third() { assert!(b.y0 > 89.0 && b.y1 < 110.0, "{b:?}"); } +/// A light disc of radius 32 at (55, 60) on a dark ground, its edge blurred into a ramp 10 px +/// wide, and elsewhere a patch of the tone halfway between the two, so that the palette holds +/// that tone. Returns the picture and whether a color is that tone. +fn blurred_disc_and_halfway_patch() -> (Raster, impl Fn([u8; 3]) -> bool) { + const DARK: [f64; 3] = [20.0, 25.0, 35.0]; + const LIGHT: [f64; 3] = [225.0, 195.0, 160.0]; + let mid = [0, 1, 2].map(|k| (DARK[k] + LIGHT[k]) / 2.0); + let img = Raster::from_fn(160, 120, |x, y| { + let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let c = if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) { + mid + } else { + let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0); + [0, 1, 2].map(|k| DARK[k] + t * (LIGHT[k] - DARK[k])) + }; + let c = |k: usize| c[k].round() as u8; + [c(0), c(1), c(2), 255] + }); + (img, move |c: [u8; 3]| (0..3).all(|k| (f64::from(c[k]) - mid[k]).abs() <= 12.0)) +} + +#[test] +fn a_blurred_edge_is_no_ring_of_a_tone_between_its_sides() { + let (img, is_mid) = blurred_disc_and_halfway_patch(); + let res = trace(&img, &sixteen_colors()); + let mids: Vec<&TracedPath> = res.paths.iter().filter(|p| is_mid(p.color)).collect(); + assert_eq!(mids.len(), 1, "the patch, and no ring around the disc: {:?}", res.paths.iter().map(|p| p.color).collect::>()); + let b = mids[0].path.bounds().unwrap(); + assert!(b.x0 > 110.0 && b.y0 > 38.0 && b.y1 < 82.0, "{b:?}"); + // The disc's edge lies halfway along the ramp. + let light = res.paths.iter().find(|p| p.color[0] > 180).unwrap(); + let off = along(&light.path).iter().map(|p| (p.distance(Point::new(55.0, 60.0)) - 32.0).abs()).fold(0.0, f64::max); + assert!(off < 1.0, "{off} px off the middle of the ramp"); +} + +#[test] +fn grayscale_settles_a_tinted_blurred_edge_by_its_lightness() { + // The same blurred disc and halfway patch in grays, and tinted green at the same lightness + // (every pixel moved by the same color, which keeps its luma): Grayscale reads lightness + // alone, so both make the same grays in the same places. + let lightness = |x: u32, y: u32| { + let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) { + 130 + } else { + let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0); + (40.0 + 180.0 * t).round() as u8 + } + }; + let gray = Raster::from_fn(160, 120, |x, y| { + let v = lightness(x, y); + [v, v, v, 255] + }); + let tinted = Raster::from_fn(160, 120, |x, y| { + let v = lightness(x, y); + [v - 40, v + 28, v - 40, 255] + }); + let lumas = |img: &Raster| img.rgba.as_chunks::<4>().0.iter().map(|&p| crate::quantize::luma(p)).collect::>(); + assert_eq!(lumas(&tinted), lumas(&gray)); + let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() }; + assert_eq!(quantize(&tinted, ¶ms), quantize(&gray, ¶ms)); +} + +#[test] +fn grayscale_settles_a_blurred_edge_between_its_own_two_grays() { + // A dark gray and a light gray meet in a ramp 12 px wide, and a black band runs across the top. + // In grays every blend lies between black and the light gray as well as between the two grays + // (to within rounding): the ramp's pixels just below the band take the nearer of the two grays, + // the grays on either side of them. + for (dark, light) in [(40u8, 200u8), (50, 200), (30, 170), (60, 230), (45, 215)] { + let img = Raster::from_fn(160, 60, |x, y| { + let v = if y < 8 { + 0 + } else { + let t = ((f64::from(x) + 0.5 - 74.0) / 12.0).clamp(0.0, 1.0); + (f64::from(dark) + f64::from(light - dark) * t).round() as u8 + }; + [v, v, v, 255] + }); + let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() }; + let q = quantize(&img, ¶ms); + let middle = u16::from(dark) + u16::from(light); + for y in 10..16 { + for x in 74..86 { + let v = img.rgba[(y * 160 + x) * 4]; + let g = q.palette[usize::from(q.labels[y * 160 + x])][0]; + assert_eq!(2 * u16::from(g) < middle, 2 * u16::from(v) < middle, "{dark} to {light}, ({x}, {y}): {v} took gray {g}"); + assert!(g >= dark, "{dark} to {light}, ({x}, {y}): {v} took gray {g}"); + } + } + } +} + +#[test] +fn the_photo_presets_keep_the_tones_of_a_blurred_edge() { + // The photo presets ask for more colors than the limited-color presets and keep the middle of + // the ramp in the halfway tone: only a blend pixel with the colors on both sides within 2 px of + // it takes a side. + let (img, is_mid) = blurred_disc_and_halfway_patch(); + for name in ["High Fidelity Photo", "Low Fidelity Photo"] { + let q = quantize(&img, &preset(name).unwrap()); + let middle = (0..160 * 120) + .filter(|&i| (Point::new((i % 160) as f64 + 0.5, (i / 160) as f64 + 0.5).distance(Point::new(55.0, 60.0)) - 32.0).abs() < 1.0) + .filter(|&i| q.palette.get(usize::from(q.labels[i])).is_some_and(|&c| is_mid(c))) + .count(); + assert!(middle > 100, "{name}: {middle} pixels of the halfway tone in the middle of the ramp"); + } +} + #[test] fn hairlines_keep_their_colour_in_color_mode() { // Black lines one pixel wide on white: every black pixel is on an edge, none is a blend. diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 034e009f17..364dc2fcd8 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -156,6 +156,34 @@ under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, a four corners); `layers::tests` (a ramp, flat colors in palette entries far apart, close flat colors, a thin flat stroke, colors off the line between their entries, and Grayscale's grays, each read from both sides of the crack) and `subpixel::tests` (the rule, and one decision for a whole piece from either side). +## Blurred edges +`settled` moved a blend pixel to a side of its edge only when the picture's own colors on both sides lay +within 2 px of it, so the middle of an edge blurred over more than about 4 px kept the palette's +in-between tones and traced as rings of thin paths around the edge. In Color with a Limited palette of +at most 16 colors and in Grayscale with at most 16 grays, `cut_edge::settled_wide` now does the work, by +VectorCraft's own rule (RGB distances, 0 to 255 a channel; Grayscale reads each pixel as its gray). A +blend pixel with no own pixel of its color within 4 px pairs the 8 own colors within 8 px that are nearest +to its color. It takes the nearer side of the pair it is best a blend of when that pair explains it twice +as well as its own color, or when it lies between the two and off the line between them by less than 0.15 +of their distance apart. Of two pairs that fit equally well it takes the closer pair. Other palettes, +High Fidelity Photo, Low Fidelity Photo and Black and White keep `settled`. +Measured with the harness below on `main` at `77970c6924`, before and after the change (256 px variants +unless stated): 16 Colors on blurred flat art 9.0 paths to 3.0, 158 anchors to 34, 0.239 px off to 0.069; +6 Colors 6.0 paths to 3.0, 118 anchors to 33, 0.226 px off to 0.071; scanned, 16 Colors 8.0 paths to 3.0 +and 0.259 px off to 0.110, 6 Colors 7.5 paths to 3.0 and 0.235 px off to 0.093. On each preset's own +inputs (section 1), 6 Colors went from 4.1 paths to 3.0 and 0.143 px off to 0.101, and 16 Colors from 4.6 +paths to 3.0 and 0.145 px off to 0.100. Auto Color does not change. Worse by more than 5% (25 of 211 rows +that moved, most of them variants of the same inputs): askew shapes, odd px 11 to 22 with 6 Colors (paths +4.0 to 4.3, odd colours 0.3 to 0.7), 23 to 26 with 3 Colors, 17 to 22 with 16 Colors and 27 to 30 with +Shades of Gray; thin lines, missed max 23.5 to 45.3 px with 16 Colors, and with Shades of Gray paths 5.3 to +7.3, anchors 36 to 41 and odd colours 3.0 to 5.0; linear gradients with 3 Colors, paths 3.0 to 3.3, and +with 6 Colors with Gradients, paths 1.3 to 1.5 and anchors 13 to 16; two- and three-stop gradients with 6 +Colors with Gradients, paths 1.2 to 1.3; gradient and photo with 16 Colors, overlap px 0.0 to 0.2 and +specks 0.8 to 1.0. The tables below are the previous measurement, not a rerun. +Tests on code-drawn images: `tests::a_blurred_edge_is_no_ring_of_a_tone_between_its_sides` (a disc +blurred into a 10 px ramp, 16 Colors), `tests::grayscale_settles_a_blurred_edge_between_its_own_two_grays`, +`tests::grayscale_settles_a_tinted_blurred_edge_by_its_lightness`, +`tests::the_photo_presets_keep_the_tones_of_a_blurred_edge` and `quantize::cut_edge::tests`. ## A simpler contour walk `contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the right. It used to build a table of outgoing directions for every pixel corner in a raster pass and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9783b0216..81a6ac6639 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1330,6 +1330,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged. - **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, blurred edges:** in Color with a Limited palette of at most 16 colors (3 Colors, 6 Colors, 16 Colors) and in Grayscale with at most 16 grays (Shades of Gray), by VectorCraft's own rule (RGB distances, 0 to 255 a channel), a blend pixel with no unblended pixel of its own color within 4 pixels takes the nearer side of the pair of colors within 8 pixels that it is best a blend of, of the 8 colors within 8 pixels nearest to its color, when that pair explains it twice as well as its own color, or when it lies between the two and off the line between them by less than 0.15 of their distance apart; it keeps its color when it is one of the pair. Grayscale compares lightness. The in-between tones of an edge blurred over several pixels no longer trace as rings around it. High Fidelity Photo, Low Fidelity Photo and Black and White trace as before, and so do Automatic, Full Tone and Document Library palettes. Noisy and textured images settle more of their pixels. By `crates/trace/tests/quality` (256 px variants), 16 Colors on blurred flat art went from 9.0 paths to 3.0, 158 anchors to 34 and 0.239 px off to 0.069, and 6 Colors from 6.0 paths to 3.0 and 0.226 px off to 0.071; askew shapes take up to 11 px more area in colors the art does not have, and with 16 Colors the largest distance from a thin line's outline to the trace goes from 23.5 px to 45.3 (see [quality notes](image-trace-quality.md#blurred-edges)). Code-drawn blurred-disc and grayscale ramp 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.