From 4268b8edd5efdb8258c0e9f422d8fef681a2b5d0 Mon Sep 17 00:00:00 2001 From: japbcoelho Date: Thu, 8 Oct 2026 19:36:01 -0300 Subject: [PATCH] Trace: colour mode keeps close dark tones apart The colour histogram used the top 5 bits of each channel, so tones less than 8 values apart always shared a bin and could never get their own palette entry, however many colours were asked for. A dark, low-contrast photo (a book-cover texture with RGB 33..66) traced to 5 colours when 12 were requested, and eight grey stripes 32..39 traced to one: vectorcraft-cli run --in stripes-32-39.png --cmd select.all \ --cmd imageTrace.makeAndExpand \ --params '{"params":{"mode":"color","colors":8,"noise":0,"method":"overlapping"}}' -> {"anchors":4,"colors":1,"id":4,"paths":1} The histogram keeps its 32x32x32 bins, now fitted to each channel's range among the opaque pixels: a channel spanning at most 32 values keeps every value apart, a full-range channel keeps the old 5-bit bins. Co-Authored-By: Claude Opus 5.5 --- crates/trace/src/quantize.rs | 49 ++++++++++++++++++++++++++++++++---- crates/trace/src/tests.rs | 32 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 01c6d2e6..e6a15174 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -57,11 +57,13 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized { } // Logo mode traces without quantising; asked directly, it quantises like Color. Mode::Color | Mode::Logo => { - // 15-bit colour histogram with per-bin sums (exact bin means). + // 15-bit colour histogram with per-bin sums (exact bin means), fitted to the image's + // range per channel so that close tones (a dark, low-contrast photo) stay apart. + let hk = HistKey::new(img); let mut bins: Vec = vec![Bin::default(); 1 << 15]; for p in img.rgba.as_chunks::<4>().0 { if p[3] >= 128 { - let b = &mut bins[key(p[0], p[1], p[2])]; + let b = &mut bins[hk.key(p[0], p[1], p[2])]; b.n += 1; b.sum[0] += p[0] as u64; b.sum[1] += p[1] as u64; @@ -74,7 +76,7 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized { for (i, c, _) in &used { lut[*i] = nearest_rgb(¢ers, *c) as u16; } - let labels = px.map(|p| if p[3] < 128 { TRANSPARENT } else { lut[key(p[0], p[1], p[2])] }).collect(); + let labels = px.map(|p| if p[3] < 128 { TRANSPARENT } else { lut[hk.key(p[0], p[1], p[2])] }).collect(); let palette = centers.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect(); compact(labels, palette) } @@ -124,8 +126,45 @@ impl Bin { } } -fn key(r: u8, g: u8, b: u8) -> usize { - ((r as usize >> 3) << 10) | ((g as usize >> 3) << 5) | (b as usize >> 3) +/// The colour histogram's bin index: 5 bits per channel over that channel's range in the image. +/// A channel spanning at most 32 values keeps every value apart; a full-range one keeps the +/// plain top 5 bits. +struct HistKey { + lo: [u8; 3], + shift: [u8; 3], +} + +impl HistKey { + /// Fit the bins to the opaque pixels (alpha >= 128) of `img`. + fn new(img: &Raster) -> Self { + let (mut lo, mut hi) = ([u8::MAX; 3], [0u8; 3]); + for p in img.rgba.as_chunks::<4>().0 { + if p[3] >= 128 { + for ch in 0..3 { + lo[ch] = lo[ch].min(p[ch]); + hi[ch] = hi[ch].max(p[ch]); + } + } + } + let mut shift = [0u8; 3]; + for ch in 0..3 { + if lo[ch] > hi[ch] { + // No opaque pixel: nothing is looked up. + lo[ch] = 0; + continue; + } + let range = hi[ch] - lo[ch]; + while shift[ch] < 3 && (range >> shift[ch]) > 31 { + shift[ch] += 1; + } + } + Self { lo, shift } + } + + fn key(&self, r: u8, g: u8, b: u8) -> usize { + let c = |v: u8, ch: usize| (v.saturating_sub(self.lo[ch]) >> self.shift[ch]).min(31) as usize; + (c(r, 0) << 10) | (c(g, 1) << 5) | c(b, 2) + } } fn nearest_1d(levels: &[f64], v: f64) -> usize { diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index c921f49b..501a9dec 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -157,6 +157,38 @@ fn color_mode_gradient_uses_at_most_n_colors() { } } +#[test] +fn color_mode_separates_close_dark_tones() { + // Eight stripes one level apart (32..=39): a fixed 5-bit-per-channel histogram put them all in + // one bin, so `colors: 8` gave a single colour and a single path. + let img = Raster::from_fn(128, 32, |x, _| { + let v = 32 + (x / 16) as u8; + [v, v, v, 255] + }); + let params = TraceParams { mode: Mode::Color, colors: 8, noise: 0, method: Method::Abutting, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + let mut tones: Vec = res.palette.iter().map(|c| c[0]).collect(); + tones.sort_unstable(); + assert_eq!(tones, (32..=39).collect::>()); + assert_eq!(res.paths.len(), 8); +} + +#[test] +fn color_mode_full_range_and_transparent_inputs() { + // Full-range input keeps working. + let img = Raster::from_fn(256, 4, |x, _| [x as u8, 255 - x as u8, 0, 255]); + let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() }); + assert!((2..=6).contains(&q.palette.len()), "{}", q.palette.len()); + // A fully transparent image has no traced pixel and does not fail. + let clear = Raster::from_fn(8, 8, |_, _| [10, 20, 30, 0]); + let q = quantize(&clear, &TraceParams { mode: Mode::Color, colors: 4, ..TraceParams::default() }); + assert!(q.labels.iter().all(|&l| l == TRANSPARENT)); + // Deterministic. + let a = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() }); + let b = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() }); + assert_eq!(a, b); +} + #[test] fn grayscale_levels() { let img = Raster::from_fn(256, 8, |x, _| [x as u8, x as u8, x as u8, 255]);