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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
This commit is contained in:
andeecollard
2026-10-16 04:07:55 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 5d8b5ddc6c
commit dbcd32d21a
3 changed files with 49 additions and 3 deletions
+14 -2
View File
@@ -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<Option<TracedGradient>> = 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()];
+2 -1
View File
@@ -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
}
+33
View File
@@ -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, &params);
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);
}