From 389bb4d79099148facbaba8294846471244f5b1b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 17:28:37 -0700 Subject: [PATCH] Image Trace: the semi-transparent edge of a cut-out image traces as the color beside it A pixel on the edge of a cut-out image with an alpha of 128 or more was traced with its own color. When that color was a darkened edge tone, it took whichever palette color lay nearest, and the edge traced as 1 px slivers of a color that appears nowhere near it. In Color and Grayscale, a pixel with an alpha of 128 to 254 next to a pixel under half opaque now takes 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, counting only pixels it reaches without crossing a transparent gap; of several such colors, it takes the one the most of those pixels have. It keeps its own color when one of those pixels has it, or when its color is not a shade of any of theirs, so a thin semi-transparent outline of another color stays as it is. Ties go to the color nearest its own, by its gray in Grayscale. Opaque images trace as before. --- crates/trace/src/lib.rs | 4 ++ crates/trace/src/quantize.rs | 106 ++++++++++++++++++++++++++++++ crates/trace/src/tests.rs | 122 +++++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 233 insertions(+) diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 8572810c9..fca2d9e95 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -563,6 +563,10 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result rest }); let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params); + // The semi-transparent edge of a cut-out image traces as the color beside it. + if matches!(params.mode, Mode::Color | Mode::Grayscale) { + quantize::settle_cut_edges(&mut q, img, params.mode == Mode::Grayscale); + } drop(without); // Each gradient is a palette entry of its own (its colour: the gradient's middle). let mut gradients: Vec> = vec![None; q.palette.len()]; diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 05613f0d6..96049c00e 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -395,6 +395,112 @@ fn compact(mut labels: Vec, palette: Vec<[u8; 3]>) -> Quantized { Quantized { labels, palette: out } } +/// `q` with the semi-transparent pixels along the cut edge of `img` given the color beside them, +/// and the colors that leaves unused dropped. +/// +/// A traced pixel with an alpha of 128 to 254 and a pixel above, below, left or right of it with an +/// alpha under 128 is on the cut edge. When its color is the color of a fully opaque pixel within +/// two pixels of it, or a darker shade of that color (see [`edge_blend_of`]), it is that color +/// blended with the transparent background, and its own color can lie nearest to an unrelated +/// palette color, which would trace as a sliver of that color along the edge. Such a pixel takes +/// the color that most of those opaque pixels have, and of colors that tie, the one nearest to its +/// own color. Only the pixels it reaches through pixels of an alpha of 128 or more, going up, down, +/// left or right, count, so that art across a transparent gap is left out. It keeps its own color +/// when one of the fully opaque pixels it reaches has it, or when its color is a blend of none of +/// them, as with a thin semi-transparent outline of another color. Pixels outside the image do not +/// count as transparent. With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]). +pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) { + let (w, h) = (img.width as usize, img.height as usize); + let px = img.rgba.as_chunks::<4>().0; + 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 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 { + 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; + let l = q.labels[j]; + if alpha(j) == 255 && 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) { + Some(n) => n.1 += 1, + None => near.push((l, 1)), + } + } + } + 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; + } + let most = near.iter().map(|n| n.1).max().unwrap_or(0); + let tied: Vec<(u16, [f64; 3])> = + near.iter().filter(|n| n.1 == most).filter_map(|n| Some((n.0, oklab(q.palette.get(n.0 as usize)?.map(f64::from))))).collect(); + let tied_labs: Vec<[f64; 3]> = tied.iter().map(|t| t.1).collect(); + let Some(&(to, _)) = tied.get(nearest_lab(&tied_labs, own)) else { continue }; + q.labels[i] = to; + changed = true; + } + if changed { + *q = compact(std::mem::take(&mut q.labels), std::mem::take(&mut q.palette)); + } +} + +/// 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. +const EDGE_BLEND: f64 = 8.0; + +/// Whether `own` (Oklab), the color of a pixel with an alpha of `alpha`, is `beside` (sRGB) blended +/// with the transparent background: whether it lies within [`EDGE_BLEND`] of the line in Oklab from +/// `beside` to `beside` with each channel scaled by `alpha` / 510. +fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool { + let to = oklab(beside.map(f64::from)); + let k = f64::from(alpha) / 510.0; + let from = oklab(beside.map(|v| f64::from(v) * k)); + let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + let t = if len2 > 0.0 { ((0..3).map(|c| (own[c] - from[c]) * d[c]).sum::() / len2).clamp(0.0, 1.0) } else { 0.0 }; + let off2: f64 = (0..3).map(|c| (own[c] - from[c] - t * d[c]).powi(2)).sum(); + off2 <= EDGE_BLEND * EDGE_BLEND +} + #[derive(Clone, Copy, Default)] struct Bin { n: u64, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 1717069da..fd727d266 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -222,6 +222,128 @@ fn transparent_pixels_are_not_traced() { assert!((net_area(&res.paths[0].path) - 800.0).abs() < 1.0); } +/// A light square cut out of a transparent background, with a blue and a black block on it and a +/// semi-transparent tan pixel row and column along two of its cut edges. `alpha` is the tan pixels' +/// alpha. +fn cut_out_with_edge(alpha: u8) -> Raster { + Raster::from_fn(200, 200, |x, y| { + if (60..140).contains(&x) && (40..90).contains(&y) { + [40, 120, 240, 255] + } else if (60..140).contains(&x) && (110..150).contains(&y) { + BLACK + } else if (20..180).contains(&x) && (20..180).contains(&y) { + [240, 240, 220, 255] + } else if (y == 180 && (30..170).contains(&x)) || (x == 180 && (30..110).contains(&y)) { + [160, 120, 80, alpha] + } else { + [0, 0, 0, 0] + } + }) +} + +#[test] +fn a_cut_out_edge_traces_as_the_color_beside_it() { + // The tan of the edge pixels is nearer to the blue than to the light color beside them, but + // it is a darker shade of the light color, blended with the transparent background. + for mode in [Mode::Color, Mode::Grayscale] { + let params = TraceParams { + mode, + palette: Palette::Limited, + colors: 3, + paths: 90.0, + corners: 60.0, + noise: 25, + method: Method::Abutting, + ..TraceParams::default() + }; + let res = trace(&cut_out_with_edge(192), ¶ms); + // The blue block's color: that of the smallest path around the block's center. + let around = |p: &&TracedPath| p.path.bounds().is_some_and(|b| b.x0 < 100.0 && b.x1 > 100.0 && b.y0 < 65.0 && b.y1 > 65.0); + let size = |p: &TracedPath| p.path.bounds().map_or(f64::INFINITY, |b| b.width() * b.height()); + let block = res.paths.iter().filter(around).min_by(|a, b| size(a).total_cmp(&size(b))).unwrap().color; + for p in res.paths.iter().filter(|p| p.color == block) { + let b = p.path.bounds().unwrap(); + assert!(b.x0 > 59.0 && b.y0 > 39.0 && b.x1 < 141.0 && b.y1 < 91.0, "{mode:?}: a path of the blue block's color reaches {b:?}"); + } + } +} + +#[test] +fn an_opaque_edge_keeps_its_own_color() { + // The same art with the edge pixels opaque: they are a tan line of their own, and stay so. + let img = cut_out_with_edge(255); + for mode in [Mode::Color, Mode::Grayscale] { + let q = quantize(&img, &TraceParams { mode, colors: 3, ..TraceParams::default() }); + let mut settled = q.clone(); + crate::quantize::settle_cut_edges(&mut settled, &img, mode == Mode::Grayscale); + assert_eq!(settled, q, "{mode:?}"); + } +} + +#[test] +fn a_cut_out_edge_in_grayscale_ties_by_its_gray() { + // A red edge pixel between two opaque red pixels, one in a dark and one in a light gray of the + // palette: its gray (76) is nearer to the dark one, though its color looks nearer to the light + // one. + let img = Raster::from_fn(3, 2, |x, y| match (x, y) { + (0, 0) | (2, 0) => [255, 0, 0, 255], + (1, 0) => [255, 0, 0, 192], + _ => [0, 0, 0, 0], + }); + let t = TRANSPARENT; + let q = Quantized { labels: vec![0, 2, 1, t, t, t], palette: vec![[50, 50, 50], [150, 150, 150], [90, 90, 90]] }; + let mut gray = q.clone(); + crate::quantize::settle_cut_edges(&mut gray, &img, true); + assert_eq!(gray.palette.get(gray.labels[1] as usize), Some(&[50, 50, 50])); + let mut color = q; + crate::quantize::settle_cut_edges(&mut color, &img, false); + assert_eq!(color.palette.get(color.labels[1] as usize), Some(&[150, 150, 150])); +} + +#[test] +fn a_semi_transparent_line_across_a_gap_keeps_its_own_color() { + // A light block, a transparent column, then a semi-transparent tan line: the block lies within + // two pixels of the line, but across the gap, so the line is no edge of it. + let img = Raster::from_fn(20, 10, |x, _| match x { + 0..=9 => [240, 240, 220, 255], + 11 => [160, 120, 80, 192], + _ => [0, 0, 0, 0], + }); + let labels = (0..200).map(|i| match i % 20 { + 0..=9 => 0, + 11 => 1, + _ => TRANSPARENT, + }); + let q = Quantized { labels: labels.collect(), palette: vec![[240, 240, 220], [160, 120, 80]] }; + let mut settled = q.clone(); + crate::quantize::settle_cut_edges(&mut settled, &img, false); + assert_eq!(settled, q); +} + +#[test] +fn a_thin_semi_transparent_outline_keeps_its_own_color() { + // A blue square on a transparent background with a one-pixel, partly transparent yellow + // outline around it: the yellow is no shade of the blue, so the outline stays yellow. + for alpha in [128, 192, 254] { + let img = Raster::from_fn(60, 60, |x, y| { + if (10..50).contains(&x) && (10..50).contains(&y) { + [40, 120, 240, 255] + } else if (9..51).contains(&x) && (9..51).contains(&y) { + [250, 200, 40, alpha] + } else { + [0, 0, 0, 0] + } + }); + for mode in [Mode::Color, Mode::Grayscale] { + let q = quantize(&img, &TraceParams { mode, colors: 3, ..TraceParams::default() }); + assert_ne!(q.labels[9 * 60 + 30], q.labels[30 * 60 + 30], "{mode:?}, alpha {alpha}: the outline has a color of its own"); + let mut settled = q.clone(); + crate::quantize::settle_cut_edges(&mut settled, &img, mode == Mode::Grayscale); + assert_eq!(settled, q, "{mode:?}, alpha {alpha}"); + } + } +} + #[test] fn threshold_controls_black_and_white() { let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [100, 100, 100, 255] } else { WHITE }); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 676ee972e..77ec3230b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -790,6 +790,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, collapsed loops keep few segments:** a loop whose fit collapses to one cubic keeps the fewest segments that hold its area and course, so a higher Paths gives no fewer segments; a code-drawn Paths sweep in `crates/trace` (see [quality notes](image-trace-quality.md#collapsed-loops-keep-few-segments)). - **Image Trace, 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, 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.