diff --git a/crates/trace/src/fit.rs b/crates/trace/src/fit.rs index 39fe3c974c..1ae81b14e8 100644 --- a/crates/trace/src/fit.rs +++ b/crates/trace/src/fit.rs @@ -4,7 +4,10 @@ use kurbo::ParamCurveArea; use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath}; use crate::contour::Loop; -use crate::logo::{Cubic, Stats, Style, all_within, fit_cubics, fit_open, fit_outline, resample_closed, resample_open, sample_cubics, tangent}; +use crate::logo::{ + Cubic, Stats, Style, all_within, fit_cubics, fit_open, fit_outline, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_pinned, + tangent, +}; use crate::subpixel::{self, Crack}; #[cfg(test)] @@ -140,7 +143,9 @@ pub(crate) fn fit_closed(pts: &[Point], style: &Style) -> Option> { let mut segs = fit_outline(&pts, style, &mut stats)?; // A fit that strayed was replaced by a plain one that nothing has checked: one that strays too // (a wild handle on a tiny or ragged loop) gives way to the outline's own points. - if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) { + let followed = + resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, step)| if style.contour { smooth_closed(&p, style.sigma / step) } else { p }); + if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &followed, 1.5) { segs = polygon(&pts, style.tol); } // A coarse fit of a tiny loop can be a single cubic. A closed editable @@ -250,8 +255,11 @@ pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec Vec (f64, u32), + across: &dyn Fn(Crack) -> (f64, u32, [f64; 5]), junction_at: &dyn Fn(i32, i32) -> Option, style: &Style, ) -> Option<(SubPath, bool)> { @@ -312,6 +330,21 @@ pub(crate) fn fit_loop( style.circle_tol *= 1.8; style.sigma = 0.9; } + if piece.contour { + style.sigma = CONTOUR_SIGMA; + if piece.closed { + // (The box is the same whichever way the loop runs.) + let (x0, x1, y0, y1) = + piece.pts.iter().fold((f64::MAX, f64::MIN, f64::MAX, f64::MIN), |b, p| (b.0.min(p.x), b.1.max(p.x), b.2.min(p.y), b.3.max(p.y))); + // Smoothing a loop of radius r by s draws it in by about s²/2r: an eighth of its + // width keeps that to about 3%. + style.sigma = style.sigma.min((x1 - x0).min(y1 - y0) / 8.0); + } + style.contour = true; + style.corner_deg = f64::INFINITY; + style.restore = None; + style.tol = style.tol.max(CONTOUR_TOL); + } if piece.closed { segs = fit_closed(&piece.pts, &style)?; } else { diff --git a/crates/trace/src/fit/junction_return_tests.rs b/crates/trace/src/fit/junction_return_tests.rs index fea2ea7dcc..0618a0969b 100644 --- a/crates/trace/src/fit/junction_return_tests.rs +++ b/crates/trace/src/fit/junction_return_tests.rs @@ -35,7 +35,8 @@ fn returning_piece_keeps_its_junction_and_interior() { let raw = Loop { pts: pts.clone(), area2: 2 * i64::from(width) }; let w = offset.0 as usize + 20; let h = offset.1 as usize + 20; - let (path, _) = fit_loop(&raw, w, h, &|_| (0., 0), &at, &style).expect("a positive-area returning loop must be editable"); + let (path, _) = + fit_loop(&raw, w, h, &|_| (0., 0, subpixel::EDGE_STEP), &at, &style).expect("a positive-area returning loop must be editable"); assert_eq!(path.anchors[0].p, Point::new(junction.0 as f64, junction.1 as f64)); let mut local = path.clone(); let shift = Point::new(offset.0 as f64, offset.1 as f64).to_vec2(); @@ -46,7 +47,7 @@ fn returning_piece_keeps_its_junction_and_interior() { } assert!(local.area().abs() > 0.9 * width as f64, "{width}, {turns}, {offset:?}"); let reverse = Loop { pts: pts.into_iter().rev().collect(), area2: -raw.area2 }; - let (back, _) = fit_loop(&reverse, w, h, &|_| (0., 0), &at, &style).unwrap(); + let (back, _) = fit_loop(&reverse, w, h, &|_| (0., 0, subpixel::EDGE_STEP), &at, &style).unwrap(); let mut expected = path; expected.reverse(); let start = expected.anchors.iter().position(|a| a.p == back.anchors[0].p).unwrap(); @@ -64,7 +65,7 @@ fn returning_piece_does_not_promote_a_zero_area_chain() { let style = crate::TraceParams { corners: 0., paths: 0., ..crate::TraceParams::default() }.outline_style(); // All four subpixel corners coincide; the raw pixel has area but its // positioned boundary does not. The fallback must not invent ink. - assert!(fit_loop(&raw, 4, 4, &|_| (-0.5, 0), &junction, &style).is_none()); + assert!(fit_loop(&raw, 4, 4, &|_| (-0.5, 0, subpixel::EDGE_STEP), &junction, &style).is_none()); } #[test] diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index 5050f4a2dd..e1a0539a9d 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -137,14 +137,7 @@ impl Layers<'_> { 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 }; + let (Some(ca), Some(cb)) = (self.own_color(la, c, 0), self.own_color(lb, c, 1)) else { return 0.0 }; (ca, cb) }; let d = [0, 1, 2].map(|k| cb[k] - ca[k]); @@ -152,10 +145,12 @@ impl Layers<'_> { if len2 <= 0.0 { return 0.0; } + let gray = self.params.mode == Mode::Grayscale; // How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image. + // Grayscale reads each pixel as the gray it traces it as. 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 Some(&[r, g, b, a]) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 4)) else { return 0.0 }; + let p = if gray { [f64::from(super::quantize::luma([r, g, b, a])); 3] } else { [f64::from(r), f64::from(g), f64::from(b)] }; 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) }; @@ -205,6 +200,85 @@ impl Layers<'_> { shift } + /// The color of label `l` at the pixel `k` steps out from the inside pixel of crack `c`: its + /// palette entry's, or its gradient's color there. + fn own_color(&self, l: u16, c: Crack, k: i32) -> Option<[f64; 3]> { + 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))) + } + + /// What the image does across a crack, for `subpixel::low_contrast`. Distances between colors + /// are RGB distances (0 to 255 a channel), and Grayscale reads each pixel as the gray it traces + /// it as. In order: + /// + /// - the share of the step from the inside color to the outside one that the image makes near + /// the crack, measured along the line between the two colors: from the mean of the two pixels + /// behind the inside pixel to the mean of the two beyond the outside one, each side only as + /// far as its own label goes; + /// - the share further out, from the next two pixels on one side to the next two on the other, + /// whatever their labels (not a number where a side has none); + /// - the near change along that line, as a distance; + /// - the whole near change, in any direction, as a distance; + /// - how much the image changes within each side: the distance between the two pixels behind + /// the inside pixel and between the two beyond the outside one, where both have the side's + /// label, averaged (not a number where neither side has two such pixels). + /// + /// Where a side has no pixel near the crack, and in Black and White, whose two tones are not the + /// colors of its areas, it is [`subpixel::EDGE_STEP`]. Exactly the same read from either side. + fn step(&self, c: Crack) -> [f64; 5] { + use super::subpixel::EDGE_STEP; + 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 EDGE_STEP }; + if la == lb || self.params.mode == Mode::BlackAndWhite { + return EDGE_STEP; + } + let (Some(ca), Some(cb)) = (self.own_color(la, c, 0), self.own_color(lb, c, 1)) else { return EDGE_STEP }; + 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 EDGE_STEP; + } + let gray = self.params.mode == Mode::Grayscale; + // The pixel `k`, while it is in the image and not transparent (and has label `l`, if one is + // given). + let pixel = |k: i32, l: Option| { + label(k).filter(|&x| l.is_none_or(|l| x == l))?; + let &[r, g, b, a] = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 4))? else { return None }; + Some(if gray { [f64::from(super::quantize::luma([r, g, b, a])); 3] } else { [f64::from(r), f64::from(g), f64::from(b)] }) + }; + // The mean color of the pixels `ks` up to the first one `pixel` refuses. + let mean = |ks: [i32; 2], l: Option| { + let (mut sum, mut n) = ([0.0; 3], 0.0); + for p in ks.iter().map_while(|&k| pixel(k, l)) { + for (s, v) in sum.iter_mut().zip(p) { + *s += v; + } + n += 1.0; + } + (n > 0.0).then(|| sum.map(|s| s / n)) + }; + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (b[k] - a[k]).powi(2)).sum::().sqrt(); + // From `a` to `b` along the line from the inside color to the outside one, in its lengths. + let along = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (b[k] - a[k]) * d[k]).sum::() / len2; + let (Some(a), Some(b)) = (mean([-1, -2], Some(la)), mean([2, 3], Some(lb))) else { return EDGE_STEP }; + let far = match (mean([-3, -4], None), mean([4, 5], None)) { + (Some(a), Some(b)) => along(a, b), + _ => f64::NAN, + }; + let (mut within, mut sides) = (0.0, 0.0); + for ([j, k], l) in [([-1, -2], la), ([2, 3], lb)] { + if let (Some(p), Some(q)) = (pixel(j, Some(l)), pixel(k, Some(l))) { + within += distance(p, q); + sides += 1.0; + } + } + let within = if sides > 0.0 { within / sides } else { f64::NAN }; + let near = along(a, b); + [near, far, near * len2.sqrt(), distance(a, b), within] + } + /// 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; @@ -343,7 +417,8 @@ impl Layers<'_> { 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)?; + let (sp, is_hard) = + fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c), self.step(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 @@ -530,6 +605,185 @@ pub(crate) fn parallel_map_at_most(_: usize, items: &[T], f: i mod tests { use super::*; + /// `f` of the layers of a `w × 8` image whose pixels in column `x` are the color `color(x)` and + /// have the label `label(x)`, with the palette `palette`, and of the cracks between columns + /// `split - 1` and `split`. + fn across( + w: u32, + split: u32, + color: impl Fn(u32) -> [u8; 3], + label: impl Fn(u32) -> u16, + palette: &[[u8; 3]], + mode: Mode, + f: impl FnOnce(&Layers, Vec) -> R, + ) -> R { + let img = Raster::from_fn(w, 8, |x, _| { + let [r, g, b] = color(x); + [r, g, b, 255] + }); + let (w, h) = (w as usize, 8); + let q = Quantized { labels: (0..w * h).map(|i| label((i % w) as u32)).collect(), palette: palette.to_vec() }; + let params = TraceParams { mode, ..TraceParams::default() }; + let n = palette.len(); + let (rank, gradients): (Vec, Vec>) = ((0..n).collect(), vec![None; n]); + let job = Layers { + q: &q, + traced: &q.labels, + rank: &rank, + params: ¶ms, + img: &img, + left_out: vec![false; n], + boxes: vec![None; n], + gradients: &gradients, + bw_tones: [255.0, 0.0], + style: params.outline_style(), + w, + h, + anchors: AtomicUsize::new(0), + max_anchors: usize::MAX, + }; + let cracks = (0..h as i32).map(|y| Crack::new((split as i32 - 1, y), (1, 0), w, h)).collect(); + f(&job, cracks) + } + + /// [`steps_labeled`] with the label 0 left of column `split` and 1 from it. + fn steps(w: u32, split: u32, color: impl Fn(u32) -> [u8; 3], palette: &[[u8; 3]], mode: Mode) -> (Vec<[f64; 5]>, bool) { + steps_labeled(w, split, color, |x| u16::from(x >= split), palette, mode) + } + + /// [`Layers::step`] of every crack between columns `split - 1` and `split` of [`across`]'s image, + /// and whether `subpixel::low_contrast` calls them a contour. Each is read from both sides, which + /// must agree exactly. + fn steps_labeled( + w: u32, + split: u32, + color: impl Fn(u32) -> [u8; 3], + label: impl Fn(u32) -> u16, + palette: &[[u8; 3]], + mode: Mode, + ) -> (Vec<[f64; 5]>, bool) { + across(w, split, color, label, palette, mode, |job, cracks| { + let shares: Vec<[f64; 5]> = cracks + .into_iter() + .map(|c| { + assert_eq!(job.step(c).map(f64::to_bits), job.step(c.flipped()).map(f64::to_bits)); + job.step(c) + }) + .collect(); + let contour = crate::subpixel::low_contrast(&shares); + (shares, contour) + }) + } + + #[test] + fn a_crack_in_a_smooth_area_shows_a_share_of_the_step_that_grows_further_out() { + // Two levels a pixel between colors 40 apart: 135 and 143 two pixels either side (8 levels + // in each channel), 131 and 147 four pixels either side. + let ramp = |x: u32| [(100 + 2 * x) as u8; 3]; + for mode in [Mode::Color, Mode::Grayscale] { + let (shares, contour) = steps(40, 20, ramp, &[[120; 3], [160; 3]], mode); + // (The image changes along the line between the two colors, so its whole change is + // the change along that line; it changes by 2 levels a channel from pixel to pixel.) + let fits = |s: &[f64; 5]| { + (s[0] - 0.2).abs() < 1e-9 + && (s[1] - 0.4).abs() < 1e-9 + && [s[2], s[3]].iter().all(|v| (v - 8.0 * 3f64.sqrt()).abs() < 1e-9) + && (s[4] - 2.0 * 3f64.sqrt()).abs() < 1e-9 + }; + assert!(shares.iter().all(fits), "{mode:?}: {shares:?}"); + assert!(contour); + } + // Black and White's tones are no area's colors. + let (shares, contour) = steps(40, 20, ramp, &[[120; 3], [160; 3]], Mode::BlackAndWhite); + assert!(shares.iter().all(|s| s[0] == 1.0 && s[1] == 1.0 && s[2..].iter().all(|v| v.is_nan())) && !contour, "{shares:?}"); + // By the image's border there is nothing further out. + let (shares, contour) = steps(6, 3, ramp, &[[90; 3], [130; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[1].is_nan()) && contour, "{shares:?}"); + } + + #[test] + fn an_edge_between_flat_colors_is_an_edge_however_far_their_palette_entries_are() { + // A flat gray beside white, the gray in an entry with black: 115 of 245 levels, less than + // half the step between the two entries, but the same further out. + let flat = |x: u32| if x < 10 { [140; 3] } else { [255; 3] }; + let (shares, contour) = steps(20, 10, flat, &[[10; 3], [255; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] < 0.5 && s[0] == s[1] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // Two flat grays 6 levels apart, in entries 25 apart: under a half of the step and a + // change of under `FLAT`, but nothing changes within either side. + let close = |split: u32| move |x: u32| if x < split { [100; 3] } else { [106; 3] }; + let (shares, contour) = steps(20, 10, close(10), &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| (s[0] - 0.24).abs() < 1e-9 && s[0] == s[1] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // The same by the image's border, where there is nothing further out. + let (shares, contour) = steps(6, 3, close(3), &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[1].is_nan() && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // Two flat colors whose difference is square to the line between their entries: none of + // the step near the crack or further out. + let square = |x: u32| if x < 10 { [100, 100, 100] } else { [100, 106, 94] }; + let (shares, contour) = steps(20, 10, square, &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] == 0.0 && s[1] == 0.0 && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn an_edge_beside_a_thin_flat_stroke_is_an_edge() { + // White, then a gray stroke two pixels wide in an entry far darker than it (under a half + // of the step from white), then black: four pixels out from the crack the image reaches + // black, more than the whole step. + let color = |x: u32| match x { + 0..10 => [255; 3], + 10..12 => [200; 3], + _ => [0; 3], + }; + let label = |x: u32| match x { + 0..10 => 0, + 10..12 => 1, + _ => 2, + }; + let (shares, contour) = steps_labeled(20, 10, color, label, &[[255; 3], [140; 3], [0; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] < 0.5 && s[1] > 1.25 * s[0] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn grayscale_reads_an_edge_between_two_colors_by_their_grays() { + // Red beside green: grays 60 and 117 (the palette's entries), whose channels add up to the + // same 200, so read as colors the image would make none of the step between them. + let flat = |x: u32| if x < 10 { [200, 0, 0] } else { [0, 200, 0] }; + let (shares, contour) = steps(20, 10, flat, &[[60; 3], [117; 3]], Mode::Grayscale); + assert!(shares.iter().all(|s| s[0] == 1.0 && s[1] == 1.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn grayscale_places_an_edge_between_two_colors_by_their_grays() { + // The same red and green: by their grays, each is all of its own entry, so the edge lies on + // the crack. Read as colors, each would be mostly the other's. + let flat = |x: u32| if x < 10 { [200, 0, 0] } else { [0, 200, 0] }; + let shifts: Vec = across( + 20, + 10, + flat, + |x| u16::from(x >= 10), + &[[60; 3], [117; 3]], + Mode::Grayscale, + |job, cracks| cracks.into_iter().map(|c| job.shift(c)).collect(), + ); + assert!(shifts.iter().all(|&s| s == 0.0), "{shifts:?}"); + } + + #[test] + fn an_edge_between_colors_off_the_line_between_the_palettes_two_is_an_edge() { + // Red beside green traced with black and white entries: a change of 226 levels, none of it + // from black towards white. + let flat = |x: u32| if x < 10 { [160, 0, 0] } else { [0, 160, 0] }; + let (shares, contour) = steps(20, 10, flat, &[[0; 3], [255; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] == 0.0 && s[1] == 0.0 && (s[3] - 160.0 * 2f64.sqrt()).abs() < 1e-9), "{shares:?}"); + assert!(!contour); + } + #[test] fn fit_in_chunks_keeps_the_order_and_stops_at_the_first_refusal() { let items: Vec = (0..1000).collect(); diff --git a/crates/trace/src/logo.rs b/crates/trace/src/logo.rs index f3b5dd03d5..4f6d258b92 100644 --- a/crates/trace/src/logo.rs +++ b/crates/trace/src/logo.rs @@ -25,7 +25,7 @@ mod testutil; mod unmix; pub(crate) use bezier::{fit_cubics, tangent}; -pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics}; +pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_pinned}; pub(crate) use outline::{Stats, Style, fit_open, fit_outline}; pub(crate) use palette::parse_hex; pub(crate) use pipeline::trace_logo; diff --git a/crates/trace/src/logo/geom.rs b/crates/trace/src/logo/geom.rs index b33b358856..d81528e713 100644 --- a/crates/trace/src/logo/geom.rs +++ b/crates/trace/src/logo/geom.rs @@ -119,13 +119,18 @@ pub(crate) fn smooth_closed(p: &[Point], sigma: f64) -> Vec { } /// Gaussian smoothing of an open run with both ends pinned (the run is extended by odd reflection -/// so tangents at the ends survive). +/// so tangents at the ends survive), by at most an eighth of the run's samples. pub(crate) fn smooth_open(p: &[Point], sigma: f64) -> Vec { + smooth_pinned(p, sigma.max(0.4).min(p.len() as f64 / 8.0)) +} + +/// [`smooth_open`] by `sigma` (in samples, at least 0.4) whatever the run's length. +pub(crate) fn smooth_pinned(p: &[Point], sigma: f64) -> Vec { let n = p.len(); if n < 5 { return p.to_vec(); } - let s = sigma.max(0.4).min(n as f64 / 8.0).max(0.4); + let s = sigma.max(0.4); let k = kernel(s); let r = (k.len() - 1) / 2; let r_ext = r.min(n - 2); diff --git a/crates/trace/src/logo/outline.rs b/crates/trace/src/logo/outline.rs index 35f655b37f..9eaad508c9 100644 --- a/crates/trace/src/logo/outline.rs +++ b/crates/trace/src/logo/outline.rs @@ -3,6 +3,7 @@ use super::bezier::{fit_cubics, tangent}; use super::geom::{ Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_open, + smooth_pinned, }; use super::restore::{RestoreParams, restore_corners}; use vectorcraft_geom::Point; @@ -28,6 +29,10 @@ pub(crate) struct Style { /// Straight pieces within a few degrees of level or upright are made exactly so (by the /// tracer's other modes; Flat Logo leaves them). pub upright: bool, + /// The outline is the contour of a smooth area: a run between corners is smoothed by `sigma` + /// whatever its length (not by at most an eighth of it), and the fit is checked against the + /// outline smoothed by `sigma` (not against the outline itself). + pub contour: bool, } impl Default for Style { @@ -46,6 +51,7 @@ impl Default for Style { max_dev: 1.4, check_step: 0.03, upright: false, + contour: false, } } } @@ -111,7 +117,7 @@ fn fit_run(run: &[Point], sg: f64, st: &Style, stats: &mut Stats, segs: &mut Vec } return; } - let sm = smooth_open(run, sg); + let sm = if st.contour { smooth_pinned(run, sg) } else { smooth_open(run, sg) }; let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { return }; let len = a.distance(b); if st.snap && len > 1.5 { @@ -267,8 +273,10 @@ pub(crate) fn fit_outline(p: &[Point], st: &Style, stats: &mut Stats) -> Option< return None; } let mut local = Stats::default(); - // The reference is the raw outline, densified so the check never depends on how sparse it is. - let reference = resample_closed(p, st.check_step / 0.3).map_or_else(|| p.to_vec(), |(d, _)| d); + // The reference is the raw outline, densified so the check never depends on how sparse it is + // (and smoothed, for the contour of a smooth area). + let reference = resample_closed(p, st.check_step / 0.3) + .map_or_else(|| p.to_vec(), |(d, step)| if st.contour { smooth_closed(&d, st.sigma / step) } else { d }); let checked = |st: &Style, local: &mut Stats| { styled_fit(p, st, local).filter(|s| sane(s)).filter(|s| { let samples = sample_cubics(s, st.check_step); diff --git a/crates/trace/src/subpixel.rs b/crates/trace/src/subpixel.rs index 91a24a1da4..550fb87d2f 100644 --- a/crates/trace/src/subpixel.rs +++ b/crates/trace/src/subpixel.rs @@ -67,8 +67,62 @@ pub(crate) struct Piece { pub hard: bool, /// The piece is a whole loop without a junction. pub closed: bool, + /// The piece is a contour of a smooth area, not an edge (see [`low_contrast`]). + pub contour: bool, } +/// Whether a stretch of outline is the contour of a smooth area (where a gradient or a shaded +/// surface was split into bands of two colors) rather than an edge of the image, from what +/// `across` says of each of its cracks (see `Layers::step`): the share of the step from one color to +/// the other that the image makes from the second and third pixels on one side of the crack to those +/// on the other (near), from the fourth and fifth (far), the near change along the line between the +/// two colors, the whole near change in any direction, and the change within each side, each change +/// an RGB distance (0 to 255 a channel). An edge makes all of the step near the crack, +/// anti-aliased or not, and none further out; a smooth area makes a fraction of it near the crack +/// and a larger fraction further out, and changes within each side too. +/// +/// Here, over the stretch: +/// +/// - the near share is under one half; +/// - the far share is at least one and a quarter times the near one, or the near change along the +/// line is under [`FLAT`], or no crack has a far share; +/// - the sides change within themselves, by at least a thirty-second of the near change along the +/// line; +/// - and the whole near change is under twice the change along the line, or under twice [`FLAT`]. +/// +/// Between two flat colors that the palette put in entries further apart than they are, the near +/// share can be under one half, but nothing changes within either side. Between two colors off the +/// line between their entries, such as a red and a green traced with black and white, the shares can +/// be nothing at all, but the whole change is large. Values that are not a number are left out. The +/// means are summed in sorted order, so the stretch is decided the same from either side. +pub(crate) fn low_contrast(steps: &[[f64; 5]]) -> bool { + let mean = |k: usize| { + let mut v: Vec = steps.iter().map(|s| s[k]).filter(|s| !s.is_nan()).collect(); + v.sort_by(f64::total_cmp); + (!v.is_empty()).then(|| v.iter().sum::() / v.len() as f64) + }; + let along = mean(2); + if mean(3).is_some_and(|change| change >= 2.0 * along.unwrap_or(0.0).max(FLAT)) { + return false; + } + if !mean(4).is_some_and(|within| within > 0.0 && within >= along.unwrap_or(0.0) / 32.0) { + return false; + } + match (mean(0), mean(1)) { + (Some(near), Some(far)) => near < 0.5 && (far >= 1.25 * near || along.is_some_and(|l| l < FLAT)), + (Some(near), None) => near < 0.5, + _ => false, + } +} + +/// A change between colors (an RGB distance, 0 to 255 a channel) under which an area is nearly +/// flat (see [`low_contrast`]). +const FLAT: f64 = 12.0; + +/// What `across` says of a crack that is an edge whatever the image does there: a crack on the +/// image's border, or one that the image's pixels cannot be read across (see [`low_contrast`]). +pub(crate) const EDGE_STEP: [f64; 5] = [1.0, 1.0, f64::NAN, f64::NAN, f64::NAN]; + /// What lies across a crack on the image's border: one neighbour per side, so that the image's /// corners are junctions and stay exact. fn border(dir: (i32, i32)) -> u32 { @@ -84,7 +138,8 @@ fn border(dir: (i32, i32)) -> u32 { /// The outline of `l` (a loop of a `w × h` image) as points. /// /// `across` says, for a crack, how far (in pixels, −0.5 to 0.5, positive towards the outside -/// pixel) the edge lies from it, and which region is on its other side. +/// pixel) the edge lies from it, which region is on its other side, and what the image does across +/// it between the two regions' colors (see [`low_contrast`]; the same read from either side). /// /// The outline comes in pieces, cut at its junctions: the pixel corners where the region on the /// other side changes (three or more regions meet there, or the image's border begins), and @@ -100,18 +155,19 @@ pub(crate) fn outline( l: &Loop, w: usize, h: usize, - across: &dyn Fn(Crack) -> (f64, u32), + across: &dyn Fn(Crack) -> (f64, u32, [f64; 5]), junction_at: &dyn Fn(i32, i32) -> Option, ) -> Vec { let v = &l.pts; let n = v.len(); let at = |i: usize| Point::new(f64::from(v[i % n].0), f64::from(v[i % n].1)); if n < 4 { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } let mut edges = Vec::with_capacity(n); - // Per crack: its shift, the region across it, and the edge it is on. - let (mut shifts, mut others, mut edge_of): (Vec, Vec, Vec) = (vec![], vec![], vec![]); + // Per crack: its shift, the region across it, the edge it is on, and what the image does + // across it between the two colors. + let (mut shifts, mut others, mut edge_of, mut steps): (Vec, Vec, Vec, Vec<[f64; 5]>) = (vec![], vec![], vec![], vec![]); for i in 0..n { let (a, b) = (v[i], v[(i + 1) % n]); let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -128,19 +184,20 @@ pub(crate) fn outline( _ => ((sx, sy - 1), (sx - 1, sy - 1)), }; let crack = Crack { inside, step: (outside.0 - inside.0, outside.1 - inside.1), w, h }; - let (s, other) = match crack.at(1) { + let (s, other, step) = match crack.at(1) { Some(_) => across(crack), // The edge of the image is where it is. - _ => (0.0, border(dir)), + _ => (0.0, border(dir), EDGE_STEP), }; shifts.push(if s.is_finite() { (s.clamp(-0.5, 0.5) * 1024.0).round() / 1024.0 } else { 0.0 }); others.push(other); edge_of.push(i); + steps.push(step); } } let cracks = shifts.len(); if cracks == 0 { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } // The pixel corner each crack starts at. let corner_of = |c: usize| { @@ -244,6 +301,7 @@ pub(crate) fn outline( let len = (1..cracks - c).find(|&k| junction((start + c + k) % cracks)).unwrap_or(cracks - c); let own: Vec = (0..len).map(|k| shifts[(start + c + k) % cracks]).collect(); let hard = is_hard(&own); + let contour = low_contrast(&(0..len).map(|k| steps[(start + c + k) % cracks]).collect::>()); let mut pts = Vec::with_capacity(len + 2); for k in 0..len { let crack = (start + c + k) % cracks; @@ -271,11 +329,11 @@ pub(crate) fn outline( let (x, y) = corner_of(next); pts.push(junctions[next].unwrap_or(Point::new(f64::from(x), f64::from(y)))); } - pieces.push(Piece { pts, hard, closed: first.is_none() }); + pieces.push(Piece { pts, hard, closed: first.is_none(), contour }); c += len; } if first.is_none() && pieces.first().is_some_and(|p| p.pts.len() < 3) { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } pieces } @@ -297,7 +355,7 @@ mod tests { /// The outline with one neighbour all round (the image's border too): its points, and whether /// it is hard. fn whole(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec, bool) { - let mut pieces = outline(l, w, h, &|c| (shift(c), 0), &grid); + let mut pieces = outline(l, w, h, &|c| (shift(c), 0, EDGE_STEP), &grid); let hard = pieces.iter().all(|p| p.hard); // Only the image's border makes junctions here: drop each piece's closing point. let split = pieces.len() > 1; @@ -354,7 +412,7 @@ mod tests { let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap()); assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1))); assert_eq!((c.at(-3), c.at(4)), (None, None)); - (0.0, 0) + (0.0, 0, EDGE_STEP) }, &grid, ); @@ -366,6 +424,76 @@ mod tests { assert!(pts.contains(&Point::new(3.0, 0.0)) && pts.contains(&Point::new(3.0, 4.0))); } + #[test] + fn a_stretch_is_a_contour_when_it_shows_a_small_share_of_the_step_that_grows_further_out() { + // A smooth area: about a fifth of the step near the cracks, more further out, with noise. + assert!(low_contrast(&[[0.1, 0.5, 4.0, 9.0, 3.0], [0.3, 0.3, 12.0, 15.0, 4.0], [0.2, 0.4, 8.0, 11.0, 2.0]])); + assert!(low_contrast(&[[0.2, 0.26, 20.0, 24.0, 5.0], [0.2, 0.26, 20.0, 24.0, 5.0]])); + assert!(!low_contrast(&[[0.2, 0.24, 20.0, 24.0, 5.0], [0.2, 0.24, 20.0, 24.0, 5.0]])); + // Flat color beside flat color: the same share near the crack and further out. + assert!(!low_contrast(&[[0.45, 0.45, 40.0, 40.0, 1.0], [0.47, 0.5, 42.0, 42.0, 2.0], [0.46, 0.44, 41.0, 41.0, 1.0]])); + // A near change of a few levels, the same further out: a nearly flat area whose bands + // noise divides (and turns in any direction, also within each side). + assert!(low_contrast(&[[0.22, 0.22, 3.0, 20.0, 15.0], [0.24, 0.2, 3.5, 18.0, 12.0]])); + assert!(!low_contrast(&[[0.22, 0.22, 12.0, 14.0, 15.0], [0.24, 0.2, 12.0, 14.0, 12.0]])); + // Two close flat colors, the same near the crack and further out, and nothing changes + // within either side: no contour, however small their change. + assert!(!low_contrast(&[[0.24, 0.24, 10.4, 10.4, 0.0], [0.24, 0.24, 10.4, 10.4, 0.0]])); + // The same for two flat colors whose difference is square to the line between their + // entries: none of the step, and no change within the sides either. + assert!(!low_contrast(&[[0.0, 0.0, 0.0, 14.0, 0.0]])); + // The change within the sides must be at least a thirty-second of the change along the line. + assert!(low_contrast(&[[0.2, 0.4, 16.0, 16.0, 0.5]])); + assert!(!low_contrast(&[[0.2, 0.4, 16.0, 16.0, 0.49]])); + // An edge: all of the step. + assert!(!low_contrast(&[[1.0, 1.0, 40.0, 40.0, 0.0], [0.9, 1.0, 36.0, 37.0, 1.0]])); + // An edge between colors off the line between the two entries: none of the step, but a + // large change. The whole change must stay under twice the change along the line. + assert!(!low_contrast(&[[0.0, 0.0, 0.0, 226.0, 5.0], [0.0, 0.0, 0.0, 226.0, 5.0]])); + assert!(low_contrast(&[[0.2, 0.4, 14.0, 27.0, 4.0]])); + assert!(!low_contrast(&[[0.2, 0.4, 14.0, 28.0, 4.0]])); + // The whole stretch counts: one half and more near the crack is no contour. + assert!(!low_contrast(&[[0.2, 1.0, 8.0, 10.0, 2.0], [0.9, 1.0, 36.0, 36.0, 0.0]])); + // Without far shares (by the image's border), the near share decides, if the sides change. + assert!(low_contrast(&[[0.3, f64::NAN, 30.0, 32.0, 6.0], [0.2, f64::NAN, 20.0, 22.0, 5.0]])); + assert!(!low_contrast(&[[0.3, f64::NAN, 30.0, 32.0, 0.0], [0.2, f64::NAN, 20.0, 22.0, 0.0]])); + // Values that are not a number say nothing; no share at all, or no change within the + // sides at all, no contour. + assert!(low_contrast(&[[f64::NAN; 5], [0.2, 0.4, 20.0, 22.0, 4.0]])); + assert!(!low_contrast(&[])); + assert!(!low_contrast(&[[f64::NAN, 0.2, 3.0, 5.0, 1.0]])); + assert!(!low_contrast(&[[0.2, 0.4, 20.0, 22.0, f64::NAN]])); + } + + #[test] + fn a_contour_is_decided_for_the_whole_piece_and_the_same_from_either_side() { + // Left and right halves meet along a staircase; the cracks in the upper rows show a tenth of + // the step near them (4 levels) and three tenths further out, and change within each side, + // those in the lower ones all of it. + let (w, h) = (12, 10); + let left: Vec = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + for (low_rows, contour) in [(9, true), (3, false)] { + // (By the upper of the crack's two pixels: the same from either side.) + let step = |c: Crack| { + if c.at(0).unwrap().min(c.at(1).unwrap()) / w < low_rows { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] } + }; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + assert_eq!((inner(&a).contour, inner(&b).contour), (contour, contour), "{low_rows} low rows"); + // The image's border is no contour: its cracks have nothing across them. + assert!( + a.iter() + .chain(&b) + .filter(|p| p.pts.iter().all(|q| q.x == 0.0 || q.y == 0.0 || q.x == w as f64 || q.y == h as f64)) + .all(|p| !p.contour) + ); + } + } + #[test] fn a_shift_that_is_not_a_number_moves_nothing() { let mask = [true, true, false, false, true, true, false, false]; @@ -400,7 +528,7 @@ mod tests { let across = |c: Crack| { let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap()); let wave = 0.4 * ((a + b) as f64).sin(); - (if labels[a] < labels[b] { wave } else { -wave }, labels[b]) + (if labels[a] < labels[b] { wave } else { -wave }, labels[b], EDGE_STEP) }; let pieces = |label| { let mask: Vec = labels.iter().map(|&l| l == label).collect(); @@ -446,8 +574,8 @@ mod tests { let left: Vec = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect(); let right: Vec = left.iter().map(|b| !b).collect(); let wave = |c: Crack| 0.4 * ((c.at(0).unwrap() + c.at(1).unwrap()) as f64).sin(); - let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1), &grid); - let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2), &grid); + let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1, EDGE_STEP), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2, EDGE_STEP), &grid); // The piece that is not on the image's border, from either side. let inner = |pieces: &[Piece]| { pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 4e138dfd5b..0d2b725efb 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1281,6 +1281,92 @@ fn hairlines_keep_their_colour_in_color_mode() { assert_eq!(q.labels.iter().filter(|&&l| l == black).count(), 8 * 80 * 2 - 64); } +/// Where a point lies across [`wavy_bands`]' gradient: the same all along each of its contours. +fn wavy_u(x: f64, y: f64) -> f64 { + x + 8.0 * (y / 23.0).sin() +} + +/// The dark square of [`wavy_bands`]: its left, top, right and bottom sides. +const WAVY_SQUARE: [f64; 4] = [100.3, 40.2, 132.6, 80.7]; + +/// A 256 × 128 gradient from blue to orange whose contours wave from top to bottom, with 6 levels +/// of noise per channel, and a dark square with crisp sides on it, drawn anti-aliased. +fn wavy_bands() -> Raster { + const LOW: [f64; 3] = [30.0, 60.0, 120.0]; + const HIGH: [f64; 3] = [230.0, 170.0, 60.0]; + Raster::from_fn(256, 128, |x, y| { + let mut sum = [0.0; 3]; + for i in 0..16 { + let (sx, sy) = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0); + let [l, t, r, b] = WAVY_SQUARE; + let c = if (l..r).contains(&sx) && (t..b).contains(&sy) { + [20.0; 3] + } else { + let along = ((wavy_u(sx, sy) + 8.0) / 272.0).clamp(0.0, 1.0); + [0, 1, 2].map(|k| LOW[k] + (HIGH[k] - LOW[k]) * along) + }; + for k in 0..3 { + sum[k] += c[k] / 16.0; + } + } + let c = |k: usize| (sum[k] + 6.0 * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8; + [c(0), c(1), c(2), 255] + }) +} + +/// How far the borders between the bands of a [`wavy_bands`] trace stray from contours of the +/// gradient: the RMS, over points along every curve more than 6 px from the image's border and from +/// the square, of the point's [`wavy_u`] less the mean of its stretch (the points in a row along +/// one curve), over stretches that run at least 30 px down the image. Also returns their number. +fn contour_spread(res: &TraceResult) -> (f64, usize) { + use vectorcraft_geom::ParamCurve; + let [l, t, r, b] = WAVY_SQUARE; + let away = |p: Point| p.x > 6.0 && p.x < 250.0 && p.y > 6.0 && p.y < 122.0 && !(p.x > l - 6.0 && p.x < r + 6.0 && p.y > t - 6.0 && p.y < b + 6.0); + let mut stretches: Vec> = vec![]; + for sp in res.paths.iter().flat_map(|p| &p.path.subpaths) { + let mut stretch = vec![]; + for p in (0..sp.segment_count()).flat_map(|i| (0..64).map(move |k| sp.segment(i).eval(f64::from(k) / 64.0))) { + if away(p) { + stretch.push(p); + } else if !stretch.is_empty() { + stretches.push(std::mem::take(&mut stretch)); + } + } + stretches.push(stretch); + } + stretches.retain(|s| s.iter().map(|p| p.y).fold(f64::MIN, f64::max) - s.iter().map(|p| p.y).fold(f64::MAX, f64::min) >= 30.0); + let mut sum = 0.0; + let mut n = 0; + for s in &stretches { + let mean = s.iter().map(|p| wavy_u(p.x, p.y)).sum::() / s.len() as f64; + sum += s.iter().map(|p| (wavy_u(p.x, p.y) - mean).powi(2)).sum::(); + n += s.len(); + } + ((sum / n.max(1) as f64).sqrt(), stretches.len()) +} + +#[test] +fn the_borders_of_a_gradients_bands_follow_its_contours() { + let res = trace(&wavy_bands(), &sixteen_colors()); + // The square's sides are edges: it keeps its four corners. + let [l, t, r, b] = WAVY_SQUARE; + let square = res.paths.iter().find(|p| p.color.iter().all(|&c| c < 40)).expect("the dark square"); + let nearest = [Point::new(l, t), Point::new(r, t), Point::new(r, b), Point::new(l, b)].map(|corner| { + square.path.subpaths[0] + .anchors + .iter() + .filter(|a| a.kind == vectorcraft_geom::AnchorKind::Corner) + .map(|a| a.p.distance(corner)) + .fold(f64::MAX, f64::min) + }); + assert!(nearest.iter().all(|&d| d < 0.3), "the nearest corner anchors to the square's corners: {nearest:?} px"); + let (spread, stretches) = contour_spread(&res); + // Each of the borders between the 15 or so bands, once for the band on either side of it. + assert!(stretches >= 20, "{stretches} stretches"); + // (Smoothed as edges are, 1.13 px.) + assert!(spread < 0.9, "the borders stray {spread} px from the gradient's contours"); +} + #[test] fn the_bands_of_a_gradient_stay_apart() { let img = Raster::from_fn(256, 64, |x, y| [x as u8, (x / 2 + y) as u8, 200 - (x / 2) as u8, 255]); diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 417aab0192..034e009f17 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -126,6 +126,36 @@ flipped crack's, and the image does have cracks the one-sided reading gets wrong 48 px noisy ripple in Black and White at thresholds 140, 157, 170 and Paths 0, 50, 100, sampled on the 4 × 4 grid per pixel the shared-boundary check uses). With the one-sided reading 482–1,228 sample points per trace were uncovered; now none are. The crate's other tests pass unchanged. +## Band borders in smooth areas +In a gradient or a shaded area, the palette colors on either side of a band border differ by more than +the image changes across it. Pixel noise placed the border, and the fitter smoothed it over 0.5 px as it +smooths an edge, so the border wobbled, kept stair steps and took many anchors. For each crack between +two colors, `Layers::step` now reads the share of the step between the two colors that the image makes +next to the crack and a few pixels further out, how far the image changes next to the crack along the +line between the two colors and in any direction, and how much it changes within each side, as RGB +distances (0 to 255 a channel). `subpixel::low_contrast` makes a piece of outline the contour of a smooth +area when, over its cracks, the near share is under one half; the far share is at least 1.25 times the +near one, the change along the line is under 12 (about 7 levels in each channel of a gray), or no crack +has a far share; the sides change within themselves, by at least 1/32 of the change along the line; and the +whole change is under twice the change along the line, or under 24. Black and White traces do not use +the rule. A contour piece is smoothed over 4 px (a closed one over at most an eighth of its smaller width +or height), keeps no corners, is fitted to at least 0.5 px and is checked against its smoothed points. +Grayscale now reads each pixel as its gray for this rule and for every edge's sub-pixel shift +(`Layers::shift`), which read the pixels' colors against gray palette entries before. +Measured with the harness below, before and after the change: Color traces of the "flat colour art" +inputs, and corners % in every row, do not change. Shades of Gray on those inputs: off mean 0.263 to 0.165 px, +off max 1.47 to 1.04 px, anchors 196 to 122. Gradient and photo inputs: anchors 2,138 to 798 (16 Colors, +gradient and photo), 1,372 to 200 (16 Colors, linear gradients), 10,063 to 6,766 (High Fidelity Photo, +gradient and photo); ΔE and edge ΔE change by −0.25 to +0.02. Specks rise by up to 0.8 per image (16 +Colors on gradient and photo 0.0 to 0.8, Auto Color 0.0 to 0.5). Auto Color, enhanced (Shapes on) on +gradient and photo leaves a gap of 0.3 px (was 0.0) and an overlap of 0.4 px (was 0.1). The tables below +are the previous measurement, not a rerun. +Tests on code-drawn images: `tests::the_borders_of_a_gradients_bands_follow_its_contours` (a 256 × 128 +gradient whose contours wave, with noise, and a dark square with crisp sides: the band borders stray +under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, and the square keeps its +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). ## 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 21fc933c8a..28abb6a4e7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1268,6 +1268,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, stacked layers only under their own areas:** with Method Overlapping, a layer reaches under the colors above it only within the connected areas that hold its own color, so a shape that sits apart from a color is no longer written again, at the same bounds, in that color, and the edge of that color's layer no longer shows through that shape's anti-aliased rim. *Measured* by `crates/trace/tests/quality` on each preset's own inputs: High Fidelity Photo 121 paths to 69.6 with ΔE 1.74 to 1.72, Low Fidelity Photo 37.1 paths to 26.1. Code-drawn disc, square and line tests in `crates/trace`. - **Image Trace, notches read only their own piece:** the notch rule of `subpixel::outline` reads only the shared piece, so both regions of an edge cut a corner pixel alike and leave no gap; a code-drawn noisy multi-colour coverage test in `crates/trace` (see [quality notes](image-trace-quality.md#notches-read-only-their-own-piece)). - **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)). +- **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged. - **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)).