vectorcraft/crates/render/src/encode/quantize.rs

686 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Colour reduction for palette formats (PNG-8, GIF): a palette of at most 256 colours chosen by
//! median cut over a 15-bit histogram (refined by a few k-means passes) or a fixed palette, then
//! each pixel mapped to its nearest entry with optional dithering (Floyd–Steinberg diffusion, an
//! 8×8 ordered pattern, or noise). Pixels under half opacity can become one transparent entry;
//! the others are blended over the matte colour.
use std::collections::HashMap;
/// How the palette is chosen.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Reduction {
/// Median cut weighted towards the colours the eye tells apart best.
Perceptual,
/// Like perceptual, keeping rarer distinct colours too, and snapping near web colours to them.
#[default]
Selective,
/// Median cut by how often colours occur.
Adaptive,
/// The 216 web-safe colours (the most used of them when fewer are allowed).
Web,
/// Black and white.
BlackWhite,
/// Evenly spaced greys.
Gray,
}
impl Reduction {
pub const ALL: [Reduction; 6] =
[Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive, Reduction::Web, Reduction::BlackWhite, Reduction::Gray];
pub fn id(self) -> &'static str {
match self {
Reduction::Perceptual => "perceptual",
Reduction::Selective => "selective",
Reduction::Adaptive => "adaptive",
Reduction::Web => "web",
Reduction::BlackWhite => "blackWhite",
Reduction::Gray => "gray",
}
}
pub fn label(self) -> &'static str {
match self {
Reduction::Perceptual => "Perceptual",
Reduction::Selective => "Selective",
Reduction::Adaptive => "Adaptive",
Reduction::Web => "Web",
Reduction::BlackWhite => "Black & White",
Reduction::Gray => "Grayscale",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.id().eq_ignore_ascii_case(s) || r.label().eq_ignore_ascii_case(s))
}
/// Channel weights of colour distances.
fn weights(self) -> [f32; 3] {
match self {
Reduction::Perceptual | Reduction::Selective => [0.55, 0.77, 0.34],
_ => [1.0; 3],
}
}
}
/// How colours between palette entries are approximated.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Dither {
None,
/// Floyd–Steinberg error diffusion.
#[default]
Diffusion,
/// An 8×8 ordered (Bayer) pattern.
Pattern,
/// Random noise.
Noise,
}
impl Dither {
pub const ALL: [Dither; 4] = [Dither::None, Dither::Diffusion, Dither::Pattern, Dither::Noise];
pub fn id(self) -> &'static str {
match self {
Dither::None => "none",
Dither::Diffusion => "diffusion",
Dither::Pattern => "pattern",
Dither::Noise => "noise",
}
}
pub fn label(self) -> &'static str {
match self {
Dither::None => "No Dither",
Dither::Diffusion => "Diffusion",
Dither::Pattern => "Pattern",
Dither::Noise => "Noise",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s) || d.label().eq_ignore_ascii_case(s))
}
}
/// The palette options of a raster export (PNG-8, GIF).
#[derive(Clone, Debug, PartialEq)]
pub struct PaletteOptions {
/// Most palette entries, the transparent one included: 2–256.
pub colors: u16,
pub reduction: Reduction,
pub dither: Dither,
/// Dither strength 0–100.
pub dither_amount: u8,
/// Pixels under half opacity become a transparent entry; otherwise every pixel is opaque.
pub transparency: bool,
/// The colour partly transparent pixels are blended over. `None`: with transparency, they
/// keep their own colour (hard edges); without, white.
pub matte: Option<[u8; 3]>,
}
impl Default for PaletteOptions {
fn default() -> Self {
Self {
colors: 256,
reduction: Reduction::Selective,
dither: Dither::Diffusion,
dither_amount: 100,
transparency: true,
matte: Some([255; 3]),
}
}
}
/// An image as palette indices.
#[derive(Clone, Debug, PartialEq)]
pub struct Indexed {
pub width: u32,
pub height: u32,
pub palette: Vec<[u8; 3]>,
/// The transparent entry (always 0 when there is one).
pub transparent: Option<u8>,
/// One index per pixel, row-major.
pub indices: Vec<u8>,
}
/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette.
pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed {
quantize_locked(rgba, width, height, o, &[])
}
/// [`quantize`], keeping the `locked` colours in a reduced palette (the colour table's locked
/// colours: reducing the colours further never drops them). Art with few enough colours keeps
/// them all anyway; locked colours no pixel ends up using are left out.
pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> Indexed {
let px = rgba.as_chunks::<4>().0;
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite);
let matte = o.matte.unwrap_or([255; 3]);
let blend = o.matte.is_some() || !o.transparency;
// The opaque colours: blended over the matte, greys for the grey reductions.
let colors: Vec<[u8; 3]> = px
.iter()
.map(|p| {
let a = if blend { p[3] as u32 } else { 255 };
let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8);
if gray { [luma(c); 3] } else { c }
})
.collect();
let has_clear = px.iter().any(clear);
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c);
// Locked colours take their slots first (at least one is left for the reduction).
let mut lock: Vec<[u8; 3]> = Vec::with_capacity(locked.len().min(k));
for c in locked {
if lock.len() + 1 < k && !lock.contains(c) {
lock.push(*c);
}
}
let free = k - lock.len();
let (mut palette, exact) = match o.reduction {
Reduction::BlackWhite => (vec![[0; 3], [255; 3]], false),
Reduction::Gray => ((0..free).map(|i| [(i * 255 / (free - 1).max(1)) as u8; 3]).collect(), false),
Reduction::Web => (web(opaque(), free), false),
r => match distinct(opaque(), k) {
Some(exact) => (exact, true),
None => (median_cut(opaque(), free, r), false),
},
};
if !exact && !lock.is_empty() {
palette.retain(|c| !lock.contains(c));
lock.append(&mut palette);
lock.truncate(k);
palette = lock;
}
if palette.is_empty() {
palette.push(matte);
}
let weights = o.reduction.weights();
let dither = if exact { Dither::None } else { o.dither };
let mut indices = map(&colors, width as usize, &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i]));
// Drop the entries no pixel uses (fixed palettes), keeping the order.
let mut used = [false; 256];
for (i, &ix) in indices.iter().enumerate() {
if !clear(&px[i]) {
used[ix as usize] = true;
}
}
let mut remap = [0u8; 256];
let mut kept = Vec::with_capacity(palette.len() + 1);
if has_clear {
kept.push(matte);
}
for (i, c) in palette.iter().enumerate() {
if used[i] {
remap[i] = kept.len() as u8;
kept.push(*c);
}
}
for (i, ix) in indices.iter_mut().enumerate() {
*ix = if clear(&px[i]) { 0 } else { remap[*ix as usize] };
}
Indexed { width, height, palette: kept, transparent: has_clear.then_some(0), indices }
}
/// Lightness as a grey value (Rec. 601 luma).
pub(super) fn luma([r, g, b]: [u8; 3]) -> u8 {
((r as u32 * 299 + g as u32 * 587 + b as u32 * 114 + 500) / 1000) as u8
}
/// The colours themselves, most used first, when there are at most `k`.
fn distinct(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Option<Vec<[u8; 3]>> {
let mut seen: HashMap<[u8; 3], u32> = HashMap::with_capacity(k + 1);
for c in colors {
*seen.entry(c).or_insert(0) += 1;
if seen.len() > k {
return None;
}
}
let mut v: Vec<([u8; 3], u32)> = seen.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
Some(v.into_iter().map(|(c, _)| c).collect())
}
/// A web-safe colour: each channel a multiple of 51.
pub fn web_safe(c: [u8; 3]) -> [u8; 3] {
c.map(|v| ((v as u32 + 25) / 51 * 51) as u8)
}
/// The `k` most used web-safe colours.
fn web(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Vec<[u8; 3]> {
let mut count: HashMap<[u8; 3], u32> = HashMap::new();
for c in colors {
*count.entry(web_safe(c)).or_insert(0) += 1;
}
let mut v: Vec<([u8; 3], u32)> = count.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v.into_iter().take(k).map(|(c, _)| c).collect()
}
/// One histogram cell: its mean colour and pixel count.
#[derive(Clone, Copy)]
struct Cell {
mean: [f32; 3],
count: u32,
}
fn dist(a: [f32; 3], b: [f32; 3], w: [f32; 3]) -> f32 {
(0..3).map(|i| (w[i] * (a[i] - b[i])).powi(2)).sum()
}
/// `k` colours by median cut over a 5-bit-per-channel histogram, then k-means passes.
fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) -> Vec<[u8; 3]> {
let mut hist = vec![(0u32, [0u64; 3]); 1 << 15];
for c in colors {
let h = &mut hist[(c[0] as usize >> 3) << 10 | (c[1] as usize >> 3) << 5 | c[2] as usize >> 3];
h.0 += 1;
for (sum, v) in h.1.iter_mut().zip(c) {
*sum += v as u64;
}
}
let mut cells: Vec<Cell> = hist.iter().filter(|h| h.0 > 0).map(|&(n, s)| Cell { mean: s.map(|v| v as f32 / n as f32), count: n }).collect();
let w = r.weights();
// How much a box gains from a split: its widest weighted range, times its pixel count (how
// often its colours occur) or a root of it (keeping rarer colours apart too).
let score = |b: &[Cell]| -> (f32, usize) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
let mut n = 0u64;
for c in b {
for i in 0..3 {
lo[i] = lo[i].min(c.mean[i]);
hi[i] = hi[i].max(c.mean[i]);
}
n += c.count as u64;
}
let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0);
let range = w[axis] * (hi[axis] - lo[axis]);
let weight = match r {
Reduction::Adaptive => n as f32,
Reduction::Perceptual => (n as f32).sqrt(),
_ => (n as f32).sqrt().sqrt(),
};
(if b.len() < 2 { 0.0 } else { range * weight }, axis)
};
// (start, end, score, axis) over `cells`.
let mut boxes = vec![{
let (s, axis) = score(&cells);
(0, cells.len(), s, axis)
}];
while boxes.len() < k {
let Some(at) = boxes.iter().enumerate().max_by(|x, y| x.1.2.total_cmp(&y.1.2)).map(|(i, _)| i) else { break };
let (a, b, s, axis) = boxes[at];
if s <= 0.0 {
break;
}
let part = &mut cells[a..b];
part.sort_by(|x, y| x.mean[axis].total_cmp(&y.mean[axis]));
// Split at the median pixel, leaving at least one cell on each side.
let total: u64 = part.iter().map(|c| c.count as u64).sum();
let mut acc = 0u64;
let mut mid = 1;
for (i, c) in part.iter().enumerate() {
acc += c.count as u64;
if acc * 2 >= total {
mid = (i + 1).clamp(1, part.len() - 1);
break;
}
}
for (x, y) in [(a, a + mid), (a + mid, b)] {
let (s, axis) = score(&cells[x..y]);
if x == a {
boxes[at] = (x, y, s, axis);
} else {
boxes.push((x, y, s, axis));
}
}
}
let mean = |b: &[Cell]| {
let n: f64 = b.iter().map(|c| c.count as f64).sum();
[0, 1, 2].map(|i| (b.iter().map(|c| c.mean[i] as f64 * c.count as f64).sum::<f64>() / n.max(1.0)) as f32)
};
let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect();
// k-means: move each entry to the mean of the cells nearest to it.
for _ in 0..3 {
// Centres move between passes, not within one: build the spatial index once per pass.
let index = PaletteIndex::new(&palette, w);
let mut sums = vec![([0f64; 3], 0f64); palette.len()];
for c in &cells {
let i = index.nearest(c.mean);
for ch in 0..3 {
sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64;
}
sums[i].1 += c.count as f64;
}
for (p, (s, n)) in palette.iter_mut().zip(sums) {
if n > 0.0 {
*p = s.map(|v| (v / n) as f32);
}
}
}
let mut out: Vec<[u8; 3]> = palette.iter().map(|p| p.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect();
if r == Reduction::Selective {
// Colours close to a web-safe one become it (they display without dithering in a browser
// limited to those).
for c in &mut out {
let s = web_safe(*c);
if (0..3).all(|i| c[i].abs_diff(s[i]) <= 6) {
*c = s;
}
}
}
out.sort_unstable();
out.dedup();
out
}
/// An exact 3-D nearest-neighbour index for the weighted RGB distance used by palette export.
///
/// Balanced at the weighted widest axis. Every subtree carries tight channel bounds, so a
/// branch-and-bound search skips it when even its nearest *possible* colour is farther than the
/// best one found. Equal distances still choose the earliest palette index (as a linear scan did).
/// For very small palettes a scan is cheaper than traversing the tree.
struct PaletteNode {
index: usize,
lo: [f32; 3],
hi: [f32; 3],
left: Option<usize>,
right: Option<usize>,
}
struct PaletteIndex<'a> {
colors: &'a [[f32; 3]],
weights: [f32; 3],
nodes: Vec<PaletteNode>,
root: Option<usize>,
}
impl<'a> PaletteIndex<'a> {
const SCAN_CUTOFF: usize = 12;
fn new(colors: &'a [[f32; 3]], weights: [f32; 3]) -> Self {
let mut nodes = if colors.len() <= Self::SCAN_CUTOFF { Vec::new() } else { Vec::with_capacity(colors.len()) };
let root = if colors.len() <= Self::SCAN_CUTOFF {
None
} else {
let mut indices: Vec<usize> = (0..colors.len()).collect();
Self::build(colors, weights, &mut indices, &mut nodes)
};
Self { colors, weights, nodes, root }
}
// Median partitioning takes O(k log k) time and space for k palette entries.
fn build(colors: &[[f32; 3]], w: [f32; 3], ids: &mut [usize], nodes: &mut Vec<PaletteNode>) -> Option<usize> {
if ids.is_empty() {
return None;
}
let (mut lo, mut hi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
for &id in ids.iter() {
let Some(c) = colors.get(id) else { continue };
for axis in 0..3 {
lo[axis] = lo[axis].min(c[axis]);
hi[axis] = hi[axis].max(c[axis]);
}
}
let axis = (0..3)
.max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b]))))
.unwrap_or(0);
let mid = ids.len() / 2;
ids.select_nth_unstable_by(mid, |&a, &b| {
let ac = colors.get(a).map_or(0.0, |c| c[axis]);
let bc = colors.get(b).map_or(0.0, |c| c[axis]);
ac.total_cmp(&bc).then(a.cmp(&b))
});
let (before, rest) = ids.split_at_mut(mid);
let (pivot, after) = rest.split_first_mut()?;
let at = nodes.len();
nodes.push(PaletteNode { index: *pivot, lo, hi, left: None, right: None });
let left = Self::build(colors, w, before, nodes);
let right = Self::build(colors, w, after, nodes);
if let Some(n) = nodes.get_mut(at) {
n.left = left;
n.right = right;
}
Some(at)
}
// Lower bound on *the same f32 weighted squared distance* as `dist`: avoid transforming
// coordinates, which would change rounding and potentially break identical-palette ties.
fn lower_bound(&self, id: usize, c: [f32; 3]) -> f32 {
let Some(n) = self.nodes.get(id) else { return f32::INFINITY };
(0..3)
.map(|i| {
let gap = if c[i] < n.lo[i] {
n.lo[i] - c[i]
} else if c[i] > n.hi[i] {
c[i] - n.hi[i]
} else {
0.0
};
(self.weights[i] * gap).powi(2)
})
.sum()
}
fn search(&self, at: usize, c: [f32; 3], best: &mut (f32, usize)) {
let Some(n) = self.nodes.get(at) else { return };
let Some(&p) = self.colors.get(n.index) else { return };
let d = dist(p, c, self.weights);
if d < best.0 || (d == best.0 && n.index < best.1) {
*best = (d, n.index);
}
// Search the more promising box first, tightening the bound for the second box.
let left = n.left.map(|id| (id, self.lower_bound(id, c)));
let right = n.right.map(|id| (id, self.lower_bound(id, c)));
match (left, right) {
(Some(a), Some(b)) => {
let (first, second) = if a.1 <= b.1 { (a, b) } else { (b, a) };
if first.1 <= best.0 {
self.search(first.0, c, best);
}
// `<=` is essential: an equal-distance colour may have an earlier index.
if second.1 <= best.0 {
self.search(second.0, c, best);
}
}
(Some(a), None) | (None, Some(a)) => {
if a.1 <= best.0 {
self.search(a.0, c, best);
}
}
(None, None) => {}
}
}
fn nearest(&self, c: [f32; 3]) -> usize {
if self.colors.len() <= Self::SCAN_CUTOFF {
return self
.colors
.iter()
.enumerate()
.min_by(|a, b| dist(*a.1, c, self.weights).total_cmp(&dist(*b.1, c, self.weights)))
.map_or(0, |(i, _)| i);
}
let mut best = (f32::INFINITY, 0);
if let Some(root) = self.root {
self.search(root, c, &mut best);
}
best.1
}
}
/// The 8×8 ordered-dither threshold at (x, y), in 0..1.
fn bayer(x: usize, y: usize) -> f32 {
let mut v = 0;
for i in 0..3 {
let (xb, yb) = ((x >> i) & 1, (y >> i) & 1);
v |= ((xb ^ yb) << 1 | yb) << (2 * (2 - i));
}
(v as f32 + 0.5) / 64.0
}
/// A noise threshold at (x, y), in 0..1 (a hash, so exports are reproducible).
fn noise(x: usize, y: usize) -> f32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
h ^= h >> 15;
h = h.wrapping_mul(0x2C1B_3C6D);
h ^= h >> 12;
(h >> 8) as f32 / (1 << 24) as f32
}
/// Map each colour to its nearest palette entry, dithering by `amount` (0–1). Pixels `skip`
/// names (transparent) get index 0 and take no diffused error.
fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec<u8> {
let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect();
let index = PaletteIndex::new(&pal, w);
// Nearest entries by colour: exact colours without dithering, a 6-bit grid with it.
let mut exact: HashMap<[u8; 3], u8> = HashMap::new();
let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }];
let mut lookup = |c: [f32; 3]| -> u8 {
let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8);
if grid.is_empty() {
return *exact.entry(q).or_insert_with(|| index.nearest(c) as u8);
}
let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2;
match grid.get(key) {
Some(&v) if v != u16::MAX => v as u8,
_ => {
let i = index.nearest(c) as u8;
if let Some(g) = grid.get_mut(key) {
*g = i as u16;
}
i
}
}
};
// Ordered and noise dithering spread a pixel by about one palette step.
let step = 255.0 / (pal.len() as f32).cbrt().max(1.0) * amount;
let width = width.max(1);
let mut out = vec![0u8; colors.len()];
let mut err = vec![[0f32; 3]; 2 * (width + 2)];
for (y, row) in colors.chunks(width).enumerate() {
let (cur, next) = err.split_at_mut(width + 2);
if dither == Dither::Diffusion {
next.fill([0.0; 3]);
}
for (x, c) in row.iter().enumerate() {
let i = y * width + x;
if skip(i) {
continue;
}
let mut v = c.map(f32::from);
match dither {
Dither::None => {}
Dither::Diffusion => {
for ch in 0..3 {
v[ch] = (v[ch] + cur[x + 1][ch]).clamp(0.0, 255.0);
}
}
Dither::Pattern | Dither::Noise => {
let t = if dither == Dither::Pattern { bayer(x % 8, y % 8) } else { noise(x, y) };
v = v.map(|ch| (ch + (t - 0.5) * step).clamp(0.0, 255.0));
}
}
let ix = lookup(v);
out[i] = ix;
if dither == Dither::Diffusion {
let p = pal.get(ix as usize).copied().unwrap_or_default();
let e = [0, 1, 2].map(|ch| (v[ch] - p[ch]) * amount);
// Floyd–Steinberg: 7/16 right, 3/16 below left, 5/16 below, 1/16 below right.
for ch in 0..3 {
cur[x + 2][ch] += e[ch] * 7.0 / 16.0;
next[x][ch] += e[ch] * 3.0 / 16.0;
next[x + 1][ch] += e[ch] * 5.0 / 16.0;
next[x + 2][ch] += e[ch] / 16.0;
}
}
}
if dither == Dither::Diffusion {
cur.copy_from_slice(next);
}
}
out
}
#[cfg(test)]
mod nearest_tests {
use super::*;
// The original definition, retained only as a parity oracle for the spatial index.
fn linear(colors: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
colors.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
}
fn next(seed: &mut u32) -> u32 {
*seed ^= *seed << 13;
*seed ^= *seed >> 17;
*seed ^= *seed << 5;
*seed
}
#[test]
fn exact_nearest_matches_linear_for_palette_sizes_and_weights() {
let mut seed = 0x2ab4_1017;
for w in [[1.0; 3], [0.55, 0.77, 0.34], [0.1, 1.0, 0.01]] {
for size in [0, 1, 2, 8, 12, 13, 16, 17, 32, 64, 128, 256] {
// Include duplicated colours, close k-means-style floating-point centres,
// and a wide colour gamut. Query points are fractional, as with dithering.
let colors: Vec<[f32; 3]> = (0..size)
.map(|i| {
if i % 11 == 0 {
[127.0, 127.0, 127.0]
} else {
[0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))
}
})
.collect();
let index = PaletteIndex::new(&colors, w);
for _ in 0..500 {
let c = [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0));
assert_eq!(index.nearest(c), linear(&colors, c, w), "size {size}, weights {w:?}, query {c:?}");
}
}
}
}
#[test]
fn equal_distance_always_chooses_first_palette_entry() {
// Enough entries to exercise the tree, even when they share one bounding box.
let mut colors = vec![[128.0, 128.0, 128.0]; 256];
colors[0] = [0.0, 128.0, 128.0];
colors[255] = [2.0, 128.0, 128.0];
let index = PaletteIndex::new(&colors, [1.0; 3]);
assert_eq!(index.nearest([1.0, 128.0, 128.0]), 0, "equal distance to indices 0 and 255");
assert_eq!(index.nearest([128.0; 3]), 1, "equal among 254 identical colours");
let colors = vec![[10.0, 10.0, 10.0]; 256];
assert_eq!(PaletteIndex::new(&colors, [0.55, 0.77, 0.34]).nearest([10.0; 3]), 0);
}
/// Opt-in microbenchmark: compares the same nearest-neighbour queries, without image IO.
#[test]
#[ignore = "run with cargo test -p vectorcraft-render nearest_palette_benchmark --release -- --ignored --nocapture"]
fn nearest_palette_benchmark() {
use std::time::Instant;
let mut seed = 0x87bb_7641;
let colors: Vec<[f32; 3]> = (0..256)
.map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 256) as f32))
.collect();
let queries: Vec<[f32; 3]> = (0..100_000)
.map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0)))
.collect();
let w = [0.55, 0.77, 0.34];
let start = Instant::now();
let baseline: Vec<_> = queries.iter().map(|&c| linear(&colors, c, w)).collect();
let old = start.elapsed();
let start = Instant::now();
let index = PaletteIndex::new(&colors, w);
let improved: Vec<_> = queries.iter().map(|&c| index.nearest(c)).collect();
let new = start.elapsed();
assert_eq!(baseline, improved);
eprintln!(
"100k nearest-colour queries, 256 entries: linear {old:?}, spatial index {new:?}, speedup {:.2}x",
old.as_secs_f64() / new.as_secs_f64()
);
}
}