From cbe793e059f01233c9cf67d2fd62979c99b9d98b Mon Sep 17 00:00:00 2001 From: andeecollard Date: Thu, 15 Oct 2026 18:14:17 +0000 Subject: [PATCH] Image Trace: grayscale gradients use grey stops In Grayscale mode with Gradients on, the trace could fill paths with coloured linear gradients (for example a blue-to-orange ramp) beside grey flat fills. Gradient detection ran on the RGB image, so the stops and the gradient's palette colour (its middle) were the source colours, while Grayscale's flat fills come from each pixel's luma. Grayscale now finds gradients, and settles their edges, in a copy of the image reduced to the same Rec. 601 luma the flat grays are quantised from. Every channel of that copy is equal, so the fitted stops, the ends carried to the area's edges and the palette entry are all grey. Color mode is unchanged: it still detects on the RGB image (trace output for gradient fixtures is byte-identical before and after). Adds grayscale_gradients_are_grey: a code-drawn sky ramp over flat shapes with a ramp rectangle, traced in Grayscale with 4 grays and Gradients on, must still produce a gradient, and every path colour, gradient stop and palette entry must be grey. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB --- crates/trace/src/lib.rs | 16 ++++++++++++++-- crates/trace/src/quantize.rs | 3 ++- crates/trace/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 6ea7b689e..9e8157083 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -291,6 +291,7 @@ pub struct TraceParams { pub auto_grouping: bool, /// Gradients (Grayscale and Color): an area whose colour changes evenly along one direction /// is one path filled with a linear gradient ([`TracedPath::gradient`]), not bands of colour. + /// Grayscale finds them in the image's grays, and their stops are gray. pub gradients: bool, /// Create: trace areas as filled shapes. pub fills: bool, @@ -527,7 +528,17 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img)); let img = smoothed.as_ref().unwrap_or(img); // Gradients are found first and kept out of the palette: no colour is spent on bands of them. - let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(img)).filter(|f| !f.1.is_empty()); + // Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray. + let grays = (params.gradients && params.mode == Mode::Grayscale).then(|| { + let mut grays = img.clone(); + for p in grays.rgba.as_chunks_mut::<4>().0 { + let g = quantize::luma(*p); + p[..3].fill(g); + } + grays + }); + let traced_as = grays.as_ref().unwrap_or(img); + let found = (params.gradients && params.mode != Mode::BlackAndWhite).then(|| gradient::detect(traced_as)).filter(|f| !f.1.is_empty()); let without = found.as_ref().map(|(of, _)| { let mut rest = img.clone(); for (p, &g) in rest.rgba.as_chunks_mut::<4>().0.iter_mut().zip(of) { @@ -542,7 +553,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result // Each gradient is a palette entry of its own (its colour: the gradient's middle). let mut gradients: Vec> = vec![None; q.palette.len()]; if let Some((mut of, found)) = found { - gradient::settle_edges(img, &mut of, &found, &mut q.labels); + gradient::settle_edges(traced_as, &mut of, &found, &mut q.labels); let first = q.palette.len(); for (l, &g) in q.labels.iter_mut().zip(&of) { if g != u32::MAX { @@ -552,6 +563,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result q.palette.extend(found.iter().map(|g| g.color_at(g.start.midpoint(g.end)).map(|v| v.round().clamp(0.0, 255.0) as u8))); gradients.extend(found.into_iter().map(Some)); } + drop(grays); denoise(&mut q.labels, w, h, params.noise as usize); // Pixel count per palette entry, and the box its pixels lie in. let mut counts = vec![0usize; q.palette.len()]; diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 476a212eb..7c14e3dde 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -14,7 +14,8 @@ pub struct Quantized { pub palette: Vec<[u8; 3]>, } -fn luma(p: [u8; 4]) -> u8 { +/// A pixel's gray (Rec. 601 luma): what Grayscale traces it as. +pub(crate) fn luma(p: [u8; 4]) -> u8 { ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114 + 500) / 1000) as u8 } diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 3f8fb0f5c..59e49bd8c 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1295,3 +1295,36 @@ fn stroke_caps_are_square_or_round_as_the_ink_is() { assert!(line(true)); assert!(!line(false)); } + +/// A blue-to-orange sky over flat colours, and a green-to-magenta ramp in a rectangle on them. +fn sky_and_ramp() -> Raster { + Raster::from_fn(240, 160, |x, y| { + let (fx, fy) = (f64::from(x), f64::from(y)); + let mix = |a: [f64; 3], b: [f64; 3], t: f64| [0, 1, 2].map(|k| (a[k] + (b[k] - a[k]) * t).round() as u8); + if (20..100).contains(&x) && (100..150).contains(&y) { + let [r, g, b] = mix([40.0, 200.0, 60.0], [220.0, 40.0, 200.0], (fx - 20.0) / 80.0); + [r, g, b, 255] + } else if y >= 90 { + if x >= 140 { [200, 30, 40, 255] } else { [30, 120, 50, 255] } + } else { + let [r, g, b] = mix([30.0, 60.0, 200.0], [250.0, 140.0, 30.0], fy / 90.0); + [r, g, b, 255] + } + }) +} + +#[test] +fn grayscale_gradients_are_grey() { + let img = sky_and_ramp(); + let params = TraceParams { mode: Mode::Grayscale, colors: 4, gradients: true, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + let grey = |c: [u8; 3]| c[0] == c[1] && c[1] == c[2]; + assert!(res.paths.iter().any(|p| p.gradient.is_some()), "a gradient is still found"); + for p in &res.paths { + assert!(grey(p.color), "{:?}", p.color); + for stop in p.gradient.iter().flat_map(|g| &g.stops) { + assert!(grey(stop.1), "{:?}", p.gradient); + } + } + assert!(res.palette.iter().all(|&c| grey(c)), "{:?}", res.palette); +}