Files
vectorcraft/crates/trace/src/quantize.rs
T
andeecollardandClaude Opus 5.5 1e06c82800 Image Trace: Auto Color keeps rare but distinct colours
The Automatic palette counts flat art's colours by area (a colour needs
about 1% of the picture, 0.5% at Colors 100) and fits that count with
median cut and area-weighted k-means. A stem, a highlight, or a near
white beside a pale background (within 24 levels, so in one box) was
averaged into a large neighbour; mixed-colour merging and Noise could
then remove what was left.

On the flat route only, after the count is fitted, rare_colours finds
the colours too rare or too close for it and keeps those that are
distinct (at least 2.5 in Oklab x 100 from every palette colour and from
the main colour each stands for), flat (mostly one histogram bin) and
drawn (a 4-connected region of their own unblended pixels of at least 8
pixels, or three of at least 4); a colour lying between two others, as
anti-aliasing mixes them, must also have an inside. At most six, largest
region first. They stay fixed while the other colours are refitted
around them; merge_mixed leaves them alone; denoise_keeping keeps their
regions of 4 pixels or more whatever Noise is (smaller specks still go).
Full Tone, Limited and Automatic's continuous-tone route are unchanged,
and denoise without kept labels is as before.

Methods cited in the code: median cut (Heckbert 1982), k-means (Lloyd
1982), Oklab (Ottosson 2020), salient-colour preservation (Fukuda et al.
2019, doi:10.1117/12.2521607). The rule and its numbers are VectorCraft's
own; Adobe does not document how its palettes are chosen.

Tests (code-drawn): a rare stem or patch over 2-8 flat colours, hard
and anti-aliased at 1x and 2x; near-white highlights on #FFF4E0 kept and
still paths at Noise 16; a 3 px #909090 stem on #808080; scattered
single pixels of the same areas add no colour; denoise_keeping at Noise
0-32.

Measured on 66 synthetic flat logos (22 designs x hard 1x, anti-aliased
1x and 2x; local inputs): every key colour and no spurious one in 66
(was 39); missing key colours 0 (was 43), spurious 0 (was 2), merged
pairs 0 (was 46); rendered dE00 0.348 -> 0.138. Four public-domain scan
crops take the continuous-tone route and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 19:38:43 +07:00

1032 lines
44 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 and noise removal. Colours are compared in Oklab, where equal distances look
//! equally different.
use crate::{Mode, Palette, Raster, TraceParams};
/// Label of a transparent (untraced) pixel.
pub const TRANSPARENT: u16 = u16::MAX;
/// A palette-indexed image.
#[derive(Clone, Debug, PartialEq)]
pub struct Quantized {
/// One palette index per pixel (row-major), or [`TRANSPARENT`].
pub labels: Vec<u16>,
pub palette: Vec<[u8; 3]>,
}
/// 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
}
/// Reduce `img` to a palette according to `params.mode`.
pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
quantize_keeping(img, params).0
}
/// [`quantize`], and the palette colours that are rare but kept on purpose (see [`rare_colours`]):
/// noise removal must not erase their regions.
pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized, Vec<[u8; 3]>) {
let px = img.rgba.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]);
match params.mode {
Mode::BlackAndWhite => {
let t = params.threshold;
let labels = px
.map(|p| {
if p[3] < 128 {
TRANSPARENT
} else if luma(p) < t {
1
} else {
0
}
})
.collect();
(Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }, vec![])
}
Mode::Grayscale => {
let own = own_colours(img);
let histogram = |only_own: bool| {
let mut hist = [0u64; 256];
for (p, &f) in img.rgba.as_chunks::<4>().0.iter().zip(&own) {
if p[3] >= 128 && (f || !only_own) {
hist[luma([p[0], p[1], p[2], p[3]]) as usize] += 1;
}
}
hist
};
let (every, calm) = (histogram(false), histogram(true));
let hist = if palette_from_own(calm.iter().sum::<u64>() as f64, every.iter().sum::<u64>() as f64) { calm } else { every };
let levels = kmeans_1d(&hist, params.colors.clamp(2, 256) as usize);
let mut lut = [0u16; 256];
for (v, slot) in lut.iter_mut().enumerate() {
*slot = nearest_1d(&levels, v as f64) as u16;
}
let labels = px.map(|p| if p[3] < 128 { TRANSPARENT } else { lut[luma(p) as usize] }).collect();
let palette = levels.iter().map(|&l| {
let g = l.round().clamp(0.0, 255.0) as u8;
[g, g, g]
});
(settled(merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]), img, &own), vec![])
}
// 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), 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 own = own_colours(img);
let (mut every, mut calm) = (vec![Bin::default(); 1 << 15], vec![Bin::default(); 1 << 15]);
for (p, &f) in img.rgba.as_chunks::<4>().0.iter().zip(&own) {
if p[3] >= 128 {
let k = hk.key(p[0], p[1], p[2]);
every[k].add(p);
if f {
calm[k].add(p);
}
}
}
let used =
|bins: &[Bin]| -> Vec<Used> { bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect() };
let (every, calm) = (used(&every), used(&calm));
let count = |u: &[Used]| u.iter().map(|b| b.2).sum::<f64>();
let from = if palette_from_own(count(&calm), count(&every)) { &calm } else { &every };
// Automatic on flat art: colours too rare for its count, but distinct and drawn as
// regions of their own, are added (see `rare_colours`).
let detail = (params.color_detail / 100.0).clamp(0.0, 1.0);
let flat_auto = params.palette == Palette::Automatic && flat_colors(from, detail).is_some();
let mut rare: Vec<[u8; 3]> = vec![];
// The picture with `colors` colours asked for; fewer come back when it has fewer, or
// when noise made several of one (see `merge_mixed`).
let mut reduce = |colors: u32| {
let mut centers = color_palette(from, &TraceParams { colors, ..params.clone() });
if flat_auto {
let found = rare_colours(img, &own, &hk, &centers);
if !found.is_empty() {
let common = centers.len();
centers.extend(found);
centers = refine_around(from, centers, common);
rare = centers.iter().skip(common).map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect();
}
}
let mut lut = vec![0u16; 1 << 15];
let center_labs = labs(&centers);
for (i, c, _) in &every {
lut[*i] = nearest_lab(&center_labs, oklab(*c)) as u16;
}
let labels =
img.rgba.as_chunks::<4>().0.iter().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();
let q = compact(labels, palette);
// A swatch library's colours are used as they are.
if params.palette == Palette::DocumentLibrary && !params.swatches.is_empty() {
q
} else {
merge_mixed(q, img.width as usize, img.height as usize, &rare)
}
};
let q = reduce(params.colors);
let q = settled(q, img, &own);
rare.retain(|c| q.palette.contains(c));
(q, rare)
}
}
}
/// The opaque pixels whose colour is the picture's own, not a blend: a pixel is a blend when it
/// lies between the two pixels on opposite sides of it (left and right, above and below, or
/// across a diagonal) that differ by more than 20 levels, which is what anti-aliasing and blur
/// leave along an edge. A line one pixel wide is no blend (the pixels on its two sides are alike),
/// nor is noise.
fn own_colours(img: &Raster) -> Vec<bool> {
let (w, h) = (img.width as isize, img.height as isize);
let px = img.rgba.as_chunks::<4>().0;
let at = |x: isize, y: isize| {
let p = (x >= 0 && y >= 0 && x < w && y < h).then(|| px.get((y * w + x) as usize)).flatten()?;
(p[3] >= 128).then(|| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])])
};
let mut out = Vec::with_capacity(px.len());
for y in 0..h {
for x in 0..w {
let Some(p) = at(x, y) else {
out.push(false);
continue;
};
let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| {
let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false };
let d = [0, 1, 2].map(|k| b[k] - a[k]);
let len2: f64 = d.iter().map(|v| v * v).sum();
if len2 <= 400.0 {
return false;
}
let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::<f64>() / len2;
let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum();
t > 0.06 && t < 0.94 && off2 < len2 / 16.0
});
out.push(!blend);
}
}
out
}
/// Whether the palette is made from the picture's own colours alone (`own` of `all` opaque
/// pixels), so that the blends along edges take none of its colours. A picture with next to none
/// is all soft edges: there every pixel counts.
fn palette_from_own(own: f64, all: f64) -> bool {
own >= 0.05 * all && own >= 16.0
}
/// sRGB (0–255) to Oklab × 100 (Björn Ottosson, "A perceptual color space for image processing",
/// 2020): equal distances look equally different, and 2 or 3 is about the least one sees.
fn oklab(c: [f64; 3]) -> [f64; 3] {
let lin = c.map(|v| {
let v = (v / 255.0).clamp(0.0, 1.0);
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
});
let l = (0.412_221_470_8 * lin[0] + 0.536_332_536_3 * lin[1] + 0.051_445_992_9 * lin[2]).cbrt();
let m = (0.211_903_498_2 * lin[0] + 0.680_699_545_1 * lin[1] + 0.107_396_956_6 * lin[2]).cbrt();
let s = (0.088_302_461_9 * lin[0] + 0.281_718_837_6 * lin[1] + 0.629_978_700_5 * lin[2]).cbrt();
[
100.0 * (0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s),
100.0 * (1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s),
100.0 * (0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s),
]
}
/// [`oklab`] of an sRGB colour, for the tests.
#[cfg(test)]
pub(crate) fn tests_oklab(c: [u8; 3]) -> [f64; 3] {
oklab(c.map(f64::from))
}
/// `q` with the colours that noise made of one colour merged, largest mix first.
///
/// Asked for more colours than a picture has, k-means splits a flat colour along its noise, and
/// the two halves would trace as specks of one all over the other. Such halves are close (under 30
/// levels apart: noise is about as strong in a dark colour as in a light one, so the distance is
/// taken in RGB) and thoroughly mixed: the pixels of the lesser one have, on average, more than
/// half a neighbour of the other each; or they are closer still (under 12 levels) and mixed in
/// patches (compression blocks). The bands of a gradient are close too, but each keeps to itself
/// and they stay apart. The colours in `keep` merge with none.
fn merge_mixed(q: Quantized, w: usize, h: usize, keep: &[[u8; 3]]) -> Quantized {
let k = q.palette.len();
// A rare colour kept on purpose (see `rare_colours`) is distinct by choice: it merges with none.
let kept: Vec<bool> = q.palette.iter().map(|c| keep.contains(c)).collect();
if k < 2 || q.labels.len() != w * h {
return q;
}
// How many pixels each colour has, and how many neighbouring pairs each pair of colours.
let mut count = vec![0.0f64; k];
let mut touch = vec![0.0f64; k * k];
let label = |i: usize| q.labels.get(i).map(|&l| l as usize).filter(|&l| l < k);
for i in 0..w * h {
let Some(a) = label(i) else { continue };
count[a] += 1.0;
for j in [(i % w + 1 < w).then_some(i + 1), (i + w < w * h).then_some(i + w)].into_iter().flatten() {
if let Some(b) = label(j).filter(|&b| b != a) {
touch[a * k + b] += 1.0;
touch[b * k + a] += 1.0;
}
}
}
let mut color: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect();
// Which colour each has merged into (itself when none).
let mut into: Vec<usize> = (0..k).collect();
loop {
let mut best: Option<(f64, usize, usize)> = None;
for a in 0..k {
for b in a + 1..k {
if into[a] != a || into[b] != b || count[a] <= 0.0 || count[b] <= 0.0 || kept[a] || kept[b] {
continue;
}
let mixed = touch[a * k + b] / count[a].min(count[b]);
let apart = (0..3).map(|c| (color[a][c] - color[b][c]).powi(2)).sum::<f64>().sqrt();
if (mixed > 0.5 && apart < 30.0 || mixed > 0.15 && apart < 12.0) && best.is_none_or(|x| mixed > x.0) {
best = Some((mixed, a, b));
}
}
}
let Some((_, a, b)) = best else { break };
color[a] = [0, 1, 2].map(|c| (color[a][c] * count[a] + color[b][c] * count[b]) / (count[a] + count[b]));
count[a] += count[b];
count[b] = 0.0;
into[b] = a;
for c in 0..k {
if c != a && c != b {
touch[a * k + c] += touch[b * k + c];
touch[c * k + a] = touch[a * k + c];
}
}
}
if into.iter().enumerate().all(|(i, &t)| i == t) {
return q;
}
// A colour may have merged into one that merged again: follow each to where it ended up.
for i in 0..k {
let mut to = into[i];
while into[to] != to {
to = into[to];
}
into[i] = to;
}
let labels = q.labels.iter().map(|&l| into.get(l as usize).map_or(l, |&t| t as u16)).collect();
compact(labels, color.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect())
}
/// `q` with its blend pixels settled, and the colours that leaves unused dropped.
///
/// A pixel on an edge is a blend of the colours on its two sides, and the palette colour nearest
/// to a blend can be a third colour altogether (halfway from navy to yellow lies an olive that is
/// nearer to a green elsewhere in the picture), which would trace as a sliver of that colour along
/// the edge. A blend pixel (see [`own_colours`]) whose colour is none of those of the picture's
/// own pixels within two pixels of it takes the side it is nearer to of the pair of those colours
/// it is best a blend of, when that explains it twice as well as its own colour does. With one
/// colour around it, it keeps its own.
fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized {
let (w, h) = (img.width as usize, img.height as usize);
if q.labels.len() != w * h || own.len() != w * h {
return q;
}
let before = q.labels.clone();
let color = |l: u16| q.palette.get(l as usize).map(|c| c.map(f64::from));
let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt();
let mut changed = false;
for i in 0..w * h {
let mine_l = before[i];
if own[i] || mine_l == TRANSPARENT {
continue;
}
let (x, y) = (i % w, i / w);
// The colours of the picture's own pixels within two pixels (seldom more than three).
let mut near: Vec<u16> = Vec::new();
for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) {
for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) {
let j = yy * w + xx;
if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) {
near.push(before[j]);
}
}
}
let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l))
else {
continue;
};
if near.len() < 2 || near.contains(&mine_l) {
continue;
}
let mut best: Option<(f64, u16)> = None;
for (n, &a) in near.iter().enumerate() {
for &b in &near[n + 1..] {
let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue };
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
let len2: f64 = d.iter().map(|v| v * v).sum();
let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0) } else { 0.0 };
let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k]));
if best.is_none_or(|x| off < x.0) {
best = Some((off, if t < 0.5 { a } else { b }));
}
}
}
if let Some((off, to)) = best
&& off < 0.5 * dist(p, mine)
{
q.labels[i] = to;
changed = true;
}
}
if changed { compact(q.labels, q.palette) } else { q }
}
/// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy
/// or unevenly lit (a scan): whether eight colours leave much of it unsaid. Flat art is a few
/// colours and what noise and lighting make of them, and eight colours come within a few levels
/// of every pixel; a photo's tones spread all through its range, and eight colours stay far from
/// most of them (more than 6 levels, and more than a tenth of how far a single colour stays).
pub(crate) fn continuous_tone(img: &Raster) -> bool {
let hk = HistKey::new(img);
let mut bins = vec![Bin::default(); 1 << 15];
for p in img.rgba.as_chunks::<4>().0 {
if p[3] >= 128 {
bins[hk.key(p[0], p[1], p[2])].add(p);
}
}
let used: Vec<Used> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
// The root-mean-square distance (levels) from the pixels to the nearest of `k` colours.
let unsaid = |k: usize| {
let colors = centers(&median_cut(&used, k, 0.0));
let (mut sum, mut n) = (0.0, 0.0);
for (_, c, weight) in &used {
let d2 = colors.iter().map(|m| (0..3).map(|ch| (m[ch] - c[ch]).powi(2)).sum::<f64>()).fold(f64::INFINITY, f64::min);
sum += d2 * weight;
n += weight;
}
if n > 0.0 { (sum / n).sqrt() } else { 0.0 }
};
let eight = unsaid(8);
eight > 6.0 && eight > 0.1 * unsaid(1)
}
/// Drop unused palette entries and merge duplicates.
fn compact(mut labels: Vec<u16>, palette: Vec<[u8; 3]>) -> Quantized {
let mut used = vec![false; palette.len()];
for &l in &labels {
if l != TRANSPARENT {
used[l as usize] = true;
}
}
let mut remap = vec![0u16; palette.len()];
let mut out: Vec<[u8; 3]> = vec![];
for (i, c) in palette.iter().enumerate() {
if !used[i] {
continue;
}
remap[i] = match out.iter().position(|o| o == c) {
Some(j) => j as u16,
None => {
out.push(*c);
(out.len() - 1) as u16
}
};
}
for l in &mut labels {
if *l != TRANSPARENT {
*l = remap[*l as usize];
}
}
Quantized { labels, palette: out }
}
#[derive(Clone, Copy, Default)]
struct Bin {
n: u64,
sum: [u64; 3],
}
impl Bin {
fn add(&mut self, p: &[u8; 4]) {
self.n += 1;
for (sum, v) in self.sum.iter_mut().zip(p) {
*sum += u64::from(*v);
}
}
fn mean(&self) -> [f64; 3] {
let n = self.n.max(1) as f64;
[self.sum[0] as f64 / n, self.sum[1] as f64 / n, self.sum[2] as f64 / n]
}
}
/// 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 {
let mut best = (f64::INFINITY, 0);
for (i, &l) in levels.iter().enumerate() {
let d = (l - v).abs();
if d < best.0 {
best = (d, i);
}
}
best.1
}
/// 1-D k-means over a 256-bin histogram (Lloyd iterations, evenly spaced start).
fn kmeans_1d(hist: &[u64; 256], k: usize) -> Vec<f64> {
let lo = hist.iter().position(|&n| n > 0).unwrap_or(0) as f64;
let hi = hist.iter().rposition(|&n| n > 0).unwrap_or(255) as f64;
let mut levels: Vec<f64> = (0..k).map(|i| if k == 1 { lo } else { lo + (hi - lo) * i as f64 / (k - 1) as f64 }).collect();
for _ in 0..20 {
let mut sum = vec![0.0; k];
let mut cnt = vec![0.0; k];
for (v, &n) in hist.iter().enumerate() {
if n > 0 {
let j = nearest_1d(&levels, v as f64);
sum[j] += v as f64 * n as f64;
cnt[j] += n as f64;
}
}
let mut moved = false;
for j in 0..k {
if cnt[j] > 0.0 {
let m = sum[j] / cnt[j];
moved |= (m - levels[j]).abs() > 0.01;
levels[j] = m;
}
}
if !moved {
break;
}
}
levels
}
/// The index of the colour of `labs` (Oklab) nearest to `c` (Oklab): the one that looks most like it.
fn nearest_lab(labs: &[[f64; 3]], c: [f64; 3]) -> usize {
let mut best = (f64::INFINITY, 0);
for (i, m) in labs.iter().enumerate() {
let d = (m[0] - c[0]).powi(2) + (m[1] - c[1]).powi(2) + (m[2] - c[2]).powi(2);
if d < best.0 {
best = (d, i);
}
}
best.1
}
fn labs(colors: &[[f64; 3]]) -> Vec<[f64; 3]> {
colors.iter().map(|c| oklab(*c)).collect()
}
/// The most colours Full Tone (and Automatic on continuous-tone images) makes.
pub const FULL_TONE_MAX: usize = 256;
/// A histogram bin in use: its index, mean colour and pixel count.
type Used = (usize, [f64; 3], f64);
/// Color mode's palette for the histogram bins `used`, as `params.palette` asks.
fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3]> {
let k = params.colors.clamp(2, 256) as usize;
let detail = (params.color_detail / 100.0).clamp(0.0, 1.0);
// Full Tone: split until no colour box is wider than 96 (few colours) … 8 (many) levels per
// channel, which is one histogram bin.
let full_tone = || refine(used, centers(&median_cut(used, FULL_TONE_MAX, 96.0 - 88.0 * detail)));
match params.palette {
Palette::Limited => kmeans_rgb(used, k),
Palette::FullTone => full_tone(),
Palette::Automatic => match flat_colors(used, detail) {
Some(n) => kmeans_rgb(used, n),
None => full_tone(),
},
Palette::DocumentLibrary if !params.swatches.is_empty() => library_colors(used, &params.swatches, k),
// No library colours to use: as Limited.
Palette::DocumentLibrary => kmeans_rgb(used, k),
}
}
/// Automatic: how many colours flat art is made of, or `None` for continuous tone (photos,
/// gradients). Flat art has most of its pixels in a few tight colours (anti-aliased edges and
/// noise aside): the bins holding 90% of the pixels are few, and each colour's pixels sit mostly
/// in one bin. `detail` (0–1) lets smaller colour areas count as colours of their own.
fn flat_colors(used: &[Used], detail: f64) -> Option<usize> {
let total: f64 = used.iter().map(|u| u.2).sum();
if total <= 0.0 {
return Some(2);
}
let mut by_count: Vec<&Used> = used.iter().collect();
by_count.sort_by(|a, b| b.2.total_cmp(&a.2));
let mut covered = 0.0;
let main = by_count
.iter()
.take_while(|u| {
let before = covered;
covered += u.2;
before < 0.9 * total
})
.count();
if main > FLAT_BINS {
return None;
}
let boxes = median_cut(used, 64, 24.0);
let min_share = (0.04 - 0.035 * detail) * total;
let colours: Vec<&ColorBox> = boxes.iter().filter(|b| b.weight >= min_share).collect();
let weight: f64 = colours.iter().map(|b| b.weight).sum();
let peaks: f64 = colours.iter().map(|b| b.peak).sum();
(weight >= 0.9 * total && peaks >= 0.5 * weight && colours.len() <= 32).then_some(colours.len().max(2))
}
/// The most histogram bins flat art's main colours spread over (a few bins per colour).
const FLAT_BINS: usize = 96;
/// Document Library: the `k` swatches (at most) that the most pixels are nearest to.
fn library_colors(used: &[Used], swatches: &[[u8; 3]], k: usize) -> Vec<[f64; 3]> {
let mut all: Vec<[f64; 3]> = vec![];
for s in swatches {
let c = s.map(f64::from);
if !all.contains(&c) {
all.push(c);
}
}
let mut weight = vec![0.0; all.len()];
let all_labs = labs(&all);
for (_, c, n) in used {
if let Some(w) = weight.get_mut(nearest_lab(&all_labs, oklab(*c))) {
*w += n;
}
}
let mut order: Vec<usize> = (0..all.len()).collect();
order.sort_by(|a, b| weight[*b].total_cmp(&weight[*a]).then(a.cmp(b)));
order.into_iter().take(k.max(1)).filter_map(|i| all.get(i).copied()).collect()
}
/// A median-cut box: its weighted mean colour, pixel count and the pixel count of its heaviest bin.
struct ColorBox {
mean: [f64; 3],
weight: f64,
peak: f64,
}
fn centers(boxes: &[ColorBox]) -> Vec<[f64; 3]> {
boxes.iter().map(|b| b.mean).collect()
}
/// Median cut to `k` colours, then weighted k-means refinement over histogram bins.
fn kmeans_rgb(used: &[Used], k: usize) -> Vec<[f64; 3]> {
refine(used, centers(&median_cut(used, k, 0.0)))
}
/// Divisive clustering in the manner of median cut, but each box is cut where that leaves its two
/// sides most alike within themselves (the cut, along one channel, with the largest between-side
/// variance: Otsu's criterion), and the box cut next is the one whose cut gains most. A cut at the
/// median, as in classic median cut, splits the largest colour of flat art in two along its noise
/// and leaves two smaller colours in one box. Boxes are cut while there are fewer than `k` and
/// some box spans more than `spread` levels of a channel.
fn median_cut(used: &[Used], k: usize, spread: f64) -> Vec<ColorBox> {
if used.is_empty() {
return vec![ColorBox { mean: [255.0; 3], weight: 0.0, peak: 0.0 }];
}
/// A box's bins and its best cut: (gain, bins sorted along the cut's channel, bins on the first side).
struct Cut {
bins: Vec<usize>,
best: Option<(f64, Vec<usize>, usize)>,
}
let plan = |bins: Vec<usize>| {
let mut best: Option<(f64, Vec<usize>, usize)> = None;
let widest = (0..3)
.map(|ch| {
let v = bins.iter().map(|&i| used[i].1[ch]);
v.clone().fold(f64::NEG_INFINITY, f64::max) - v.fold(f64::INFINITY, f64::min)
})
.fold(0.0, f64::max);
if bins.len() >= 2 && widest > spread.max(0.0) {
for ch in 0..3 {
let mut sorted = bins.clone();
sorted.sort_by(|x, y| used[*x].1[ch].total_cmp(&used[*y].1[ch]));
let (total, sum): (f64, f64) = sorted.iter().fold((0.0, 0.0), |a, &i| (a.0 + used[i].2, a.1 + used[i].1[ch] * used[i].2));
let (mut w, mut s) = (0.0, 0.0);
let mut cut: Option<(f64, usize)> = None;
for (j, &i) in sorted.iter().enumerate().take(sorted.len() - 1) {
w += used[i].2;
s += used[i].1[ch] * used[i].2;
let rest = total - w;
if w > 0.0 && rest > 0.0 {
let gain = w * rest / total * (s / w - (sum - s) / rest).powi(2);
if cut.is_none_or(|c| gain > c.0) {
cut = Some((gain, j + 1));
}
}
}
if let Some((gain, at)) = cut
&& best.as_ref().is_none_or(|b| gain > b.0)
{
best = Some((gain, sorted, at));
}
}
}
Cut { bins, best }
};
let mut boxes = vec![plan((0..used.len()).collect())];
while boxes.len() < k {
let gain = |c: &Cut| c.best.as_ref().map_or(f64::NEG_INFINITY, |b| b.0);
let Some(bi) = (0..boxes.len()).filter(|&i| boxes[i].best.is_some()).max_by(|&a, &b| gain(&boxes[a]).total_cmp(&gain(&boxes[b]))) else {
break;
};
let Some((_, mut sorted, at)) = boxes.swap_remove(bi).best else { break };
let tail = sorted.split_off(at);
boxes.push(plan(sorted));
boxes.push(plan(tail));
}
boxes
.iter()
.map(|b| {
let mut s = [0.0; 3];
let (mut w, mut peak) = (0.0, 0.0f64);
for &i in &b.bins {
for (acc, v) in s.iter_mut().zip(used[i].1) {
*acc += v * used[i].2;
}
w += used[i].2;
peak = peak.max(used[i].2);
}
ColorBox { mean: [s[0] / w, s[1] / w, s[2] / w], weight: w, peak }
})
.collect()
}
/// Lloyd refinement of `centers` over the bins `used`: each bin goes to the centre that looks most
/// like it (nearest in Oklab), and each centre becomes the mean colour of its bins.
fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
let bins = labs(&used.iter().map(|u| u.1).collect::<Vec<_>>());
for _ in 0..12 {
let center_labs = labs(&centers);
let mut sum = vec![[0.0; 3]; centers.len()];
let mut cnt = vec![0.0; centers.len()];
for ((_, c, n), lab) in used.iter().zip(&bins) {
let j = nearest_lab(&center_labs, *lab);
for ch in 0..3 {
sum[j][ch] += c[ch] * n;
}
cnt[j] += n;
}
let mut moved = 0.0f64;
for j in 0..centers.len() {
if cnt[j] > 0.0 {
let m = [sum[j][0] / cnt[j], sum[j][1] / cnt[j], sum[j][2] / cnt[j]];
moved = moved.max((0..3).map(|ch| (m[ch] - centers[j][ch]).powi(2)).sum());
centers[j] = m;
}
}
if moved < 0.25 {
break;
}
}
centers
}
/// Automatic: the most rare colours added to a flat picture's count.
const RARE_BUDGET: usize = 6;
/// How far apart (Oklab × 100) a rare colour must be from every palette colour and from the main
/// colour each stands for: a little more than the least difference one sees (about 2).
const RARE_APART: f64 = 2.5;
/// The fewest pixels of a 4-connected region that make a rare colour a colour of the picture (a
/// stem one pixel wide and eight long): scattered noise makes none so large.
const RARE_REGION: usize = 8;
/// A rare colour drawn as several small regions (highlights) is a colour of the picture too when
/// at least [`RARE_ISLANDS`] of its regions have this many pixels; noise kept by Noise removal is
/// no smaller than this either.
pub(crate) const RARE_ISLAND: usize = 4;
/// How many regions of [`RARE_ISLAND`] pixels make a rare colour coherent.
const RARE_ISLANDS: usize = 3;
/// Automatic on flat art: the colours too rare to count among its colours (the count keeps colours
/// on at least about 1% of the picture), or too close to a large one to get a box of their own,
/// that are still colours of the picture: a highlight, a stem, a thin outline. A rare colour is
/// kept when it is distinct, at least [`RARE_APART`] in Oklab (Ottosson 2020) from every colour of
/// `centers` and from the main colour each stands for; flat, its pixels mostly of one histogram
/// bin; and drawn, some 4-connected region of its own (not blended) pixels of at least
/// [`RARE_REGION`] pixels, or [`RARE_ISLANDS`] regions of at least [`RARE_ISLAND`]. At most [`RARE_BUDGET`] are kept, those with the largest regions first.
///
/// The palette is otherwise median cut (Heckbert 1982) refined by k-means (Lloyd 1982); keeping
/// salient colours that are rare by area follows the idea of salient-colour preservation in
/// palette reduction (Fukuda et al. 2019, doi:10.1117/12.2521607). The rule and its numbers are
/// VectorCraft's own.
fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> Vec<[f64; 3]> {
let px = img.rgba.as_chunks::<4>().0;
if centers.is_empty() || own.len() != px.len() {
return vec![];
}
// The picture's own (not blended) pixels by histogram bin.
let mut bins = vec![Bin::default(); 1 << 15];
for (p, _) in px.iter().zip(own).filter(|(p, f)| **f && p[3] >= 128) {
bins[hk.key(p[0], p[1], p[2])].add(p);
}
let used: Vec<(usize, [f64; 3], f64)> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
let used_labs = labs(&used.iter().map(|u| u.1).collect::<Vec<_>>());
let center_labs = labs(centers);
// Each palette colour's main colour: its heaviest bin.
let mut peak: Vec<Option<(f64, [f64; 3])>> = vec![None; centers.len()];
for ((_, _, n), lab) in used.iter().zip(&used_labs) {
let j = nearest_lab(&center_labs, *lab);
if let Some(slot) = peak.get_mut(j)
&& slot.is_none_or(|(m, _)| *n > m)
{
*slot = Some((*n, *lab));
}
}
let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt();
let apart = |lab: [f64; 3]| center_labs.iter().chain(peak.iter().flatten().map(|(_, l)| l)).all(|c| dist(lab, *c) >= RARE_APART);
// The bins far from every colour, heaviest first, grouped: a bin joins the first group whose
// heaviest bin is nearer than RARE_APART.
let mut far: Vec<usize> = (0..used.len()).filter(|&u| used_labs.get(u).is_some_and(|l| apart(*l))).collect();
far.sort_by(|&a, &b| used[b].2.total_cmp(&used[a].2).then(a.cmp(&b)));
let mut group_of = vec![u16::MAX; 1 << 15];
let mut groups: Vec<Vec<usize>> = vec![];
for u in far {
let (Some(lab), Some(&(bin, ..))) = (used_labs.get(u), used.get(u)) else { continue };
let at = groups.iter().position(|g| g.first().and_then(|&f| used_labs.get(f)).is_some_and(|l| dist(*l, *lab) < RARE_APART));
let g = match at {
Some(g) => g,
None if groups.len() < 64 => {
groups.push(vec![]);
groups.len() - 1
}
None => continue,
};
if let Some(members) = groups.get_mut(g) {
members.push(u);
}
group_of[bin] = g as u16;
}
if groups.is_empty() {
return vec![];
}
// The largest 4-connected region of each group's own pixels.
let (w, h) = (img.width as usize, img.height as usize);
let group_at = |i: usize| -> Option<u16> {
let p = px.get(i)?;
(own.get(i).copied().unwrap_or(false) && p[3] >= 128).then(|| group_of[hk.key(p[0], p[1], p[2])]).filter(|&g| g != u16::MAX)
};
let mut largest = vec![0usize; groups.len()];
let mut islands = vec![0usize; groups.len()];
// Pixels of a group whose four neighbours are of it too: an edge's blend is a line one pixel
// wide and has next to none.
let mut inner = vec![0usize; groups.len()];
let mut seen = vec![false; w * h];
let mut stack = vec![];
for start in 0..w * h {
let Some(g) = group_at(start).filter(|_| !seen[start]) else { continue };
seen[start] = true;
stack.push(start);
let mut size = 0;
while let Some(i) = stack.pop() {
size += 1;
let (x, y) = (i % w, i / w);
let mut alike = 0;
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
if group_at(j) == Some(g) {
alike += 1;
if !seen[j] {
seen[j] = true;
stack.push(j);
}
}
}
if alike == 4
&& let Some(n) = inner.get_mut(g as usize)
{
*n += 1;
}
}
if let Some(l) = largest.get_mut(g as usize) {
*l = (*l).max(size);
}
if let Some(n) = islands.get_mut(g as usize).filter(|_| size >= RARE_ISLAND) {
*n += 1;
}
}
// Coherent and flat groups, largest region first.
let mut chosen: Vec<(usize, f64, [f64; 3])> = vec![];
for (g, members) in groups.iter().enumerate() {
let weight: f64 = members.iter().filter_map(|&u| used.get(u)).map(|u| u.2).sum();
let heaviest = members.first().and_then(|&u| used.get(u)).map_or(0.0, |u| u.2);
let region = largest.get(g).copied().unwrap_or(0);
let coherent = region >= RARE_REGION || islands.get(g).is_some_and(|&n| n >= RARE_ISLANDS);
if !coherent || heaviest < 0.5 * weight || weight <= 0.0 {
continue;
}
let mut mean = [0.0; 3];
for u in members.iter().filter_map(|&u| used.get(u)) {
for (m, c) in mean.iter_mut().zip(u.1) {
*m += c * u.2 / weight;
}
}
// Not an edge's blend of two colours that the blend test let through (a thin ring of
// anti-aliasing between two bands is coherent too): a colour that lies between two others
// is kept only with an inside, pixels whose neighbours are all of it.
if is_mix(mean, centers) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND) {
continue;
}
chosen.push((region, weight, mean));
}
chosen.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)).then(a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)));
let mut out: Vec<[f64; 3]> = vec![];
for (_, _, c) in chosen {
if out.len() >= RARE_BUDGET {
break;
}
let lab = oklab(c);
if out.iter().all(|o| dist(oklab(*o), lab) >= RARE_APART) {
out.push(c);
}
}
out
}
/// Whether `c` (sRGB) lies between two of `centers`, as anti-aliasing mixes them: within 12 levels
/// of the line between them, and at least an eighth of the way from each.
fn is_mix(c: [f64; 3], centers: &[[f64; 3]]) -> bool {
centers.iter().enumerate().any(|(i, a)| {
centers.iter().skip(i + 1).any(|b| {
let d = [0, 1, 2].map(|k| b[k] - a[k]);
let len2: f64 = d.iter().map(|v| v * v).sum();
if len2 <= 0.0 {
return false;
}
let t = (0..3).map(|k| (c[k] - a[k]) * d[k]).sum::<f64>() / len2;
let off2: f64 = (0..3).map(|k| (c[k] - a[k] - t * d[k]).powi(2)).sum();
(0.12..=0.88).contains(&t) && off2 < 144.0
})
})
}
/// `centers` refined over the bins `used` as k-means does, but the colours from `fixed` on (rare
/// colours, see [`rare_colours`]) stay where they are: they take their own bins from the colours
/// they were averaged into, and those move back to their own.
fn refine_around(used: &[Used], mut centers: Vec<[f64; 3]>, fixed: usize) -> Vec<[f64; 3]> {
let bins = labs(&used.iter().map(|u| u.1).collect::<Vec<_>>());
for _ in 0..4 {
let center_labs = labs(&centers);
let mut sum = vec![[0.0; 3]; centers.len()];
let mut cnt = vec![0.0; centers.len()];
for ((_, c, n), lab) in used.iter().zip(&bins) {
let j = nearest_lab(&center_labs, *lab);
if let (Some(s), Some(k)) = (sum.get_mut(j), cnt.get_mut(j)) {
for ch in 0..3 {
s[ch] += c[ch] * n;
}
*k += n;
}
}
for (j, center) in centers.iter_mut().enumerate().take(fixed) {
if let (Some(s), Some(&k)) = (sum.get(j), cnt.get(j))
&& k > 0.0
{
*center = [s[0] / k, s[1] / k, s[2] / k];
}
}
}
centers
}
/// Merge whole 4-connected regions, smallest first, into their most common neighbouring label.
/// The merge order does not depend on `min_area`: a higher threshold continues the same sequence
/// instead of splitting a region already merged at a lower threshold. Transparency is a barrier.
pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
denoise_keeping(labels, w, h, min_area, &[]);
}
/// [`denoise`], except that a region of a label `keep` marks (a rare colour kept on purpose, see
/// [`rare_colours`]) that is at least [`RARE_ISLAND`] pixels stays whatever `min_area` is: Noise
/// clears specks, not a colour's only regions. Smaller regions of it go as any other.
pub(crate) fn denoise_keeping(labels: &mut [u16], w: usize, h: usize, min_area: usize, keep: &[bool]) {
use std::cmp::Reverse;
use std::collections::{BTreeMap, BinaryHeap};
if min_area <= 1 || w == 0 || h == 0 || w.checked_mul(h) != Some(labels.len()) {
return;
}
struct Region {
label: u16,
pixels: Vec<usize>,
}
// Region membership is kept current after every merge. In particular, no stale `seen`
// flags can exclude previously recoloured pixels from a subsequent connected component.
let mut at = vec![usize::MAX; labels.len()];
let mut regions: Vec<Region> = Vec::new();
let neighbours = |i: usize| {
let (x, y) = (i % w, i / w);
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)]
};
for start in 0..labels.len() {
if at[start] != usize::MAX || labels[start] == TRANSPARENT {
continue;
}
let id = regions.len();
let label = labels[start];
let mut pixels = vec![start];
at[start] = id;
let mut cursor = 0;
while let Some(&i) = pixels.get(cursor) {
for j in neighbours(i).into_iter().flatten() {
if at[j] == usize::MAX && labels[j] == label {
at[j] = id;
pixels.push(j);
}
}
cursor += 1;
}
regions.push(Region { label, pixels });
}
let mut pending: BinaryHeap<_> = regions.iter().enumerate().map(|(id, r)| Reverse((r.pixels.len(), id))).collect();
while let Some(Reverse((area, id))) = pending.pop() {
if area >= min_area {
break;
}
if regions[id].pixels.len() != area {
continue; // absorbed region or an old heap entry for a region that grew
}
if area >= RARE_ISLAND && keep.get(regions[id].label as usize).copied().unwrap_or(false) {
continue;
}
let mut borders: BTreeMap<usize, usize> = BTreeMap::new();
for &i in &regions[id].pixels {
for j in neighbours(i).into_iter().flatten() {
let other = at[j];
if other != id && other != usize::MAX {
*borders.entry(other).or_default() += 1;
}
}
}
let mut colours: BTreeMap<u16, usize> = BTreeMap::new();
for (&other, &border) in &borders {
*colours.entry(regions[other].label).or_default() += border;
}
let Some((&colour, _)) = colours.iter().max_by_key(|(label, border)| (**border, Reverse(**label))) else {
continue; // an opaque island surrounded only by transparency stays intact
};
// All adjacent regions of the chosen colour become connected by this merge. Absorb
// them together, keeping the largest one's storage to avoid repeatedly moving it.
let Some(target) = borders
.keys()
.copied()
.filter(|&other| regions[other].label == colour)
.max_by_key(|&other| (regions[other].pixels.len(), Reverse(other)))
else {
continue;
};
let sources: Vec<_> =
std::iter::once(id).chain(borders.keys().copied().filter(|&other| other != target && regions[other].label == colour)).collect();
for source in sources {
let pixels = std::mem::take(&mut regions[source].pixels);
for &i in &pixels {
at[i] = target;
}
regions[target].pixels.extend(pixels);
}
pending.push(Reverse((regions[target].pixels.len(), target)));
}
for region in regions {
for i in region.pixels {
labels[i] = region.label;
}
}
}