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 <[email protected]>
This commit is contained in:
japbcoelhoandClaude Opus 5.5 authored and bflatastic committed 2026-10-12 06:59:50 +07:00
1 parent 44f3141c3d
commit 4268b8edd5
2 files changed
+76 -5

No files matched your search

+44 -5
View File
@@ -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<Bin> = 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(&centers, *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 {
+32
View File
@@ -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, &params);
let mut tones: Vec<u8> = res.palette.iter().map(|c| c[0]).collect();
tones.sort_unstable();
assert_eq!(tones, (32..=39).collect::<Vec<u8>>());
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]);