mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
44f3141c3d
commit
4268b8edd5
2 files changed
+76
-5
No files matched your search
@@ -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(¢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 {
|
||||
|
||||
@@ -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<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]);
|
||||
|
||||
Reference in new issue
Block a user