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
This commit is contained in:
andeecollard
2026-10-17 19:38:43 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 17a602d1dc
commit 1e06c82800
7 changed files with 505 additions and 21 deletions
+4 -2
View File
@@ -562,7 +562,7 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
}
rest
});
let mut q = quantize(without.as_ref().unwrap_or(img), params);
let (mut q, rare) = quantize::quantize_keeping(without.as_ref().unwrap_or(img), params);
drop(without);
// Each gradient is a palette entry of its own (its colour: the gradient's middle).
let mut gradients: Vec<Option<TracedGradient>> = vec![None; q.palette.len()];
@@ -578,7 +578,9 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result
gradients.extend(found.into_iter().map(Some));
}
drop(grays);
denoise(&mut q.labels, w, h, params.noise as usize);
// Noise clears specks, not the regions of a rare colour Automatic kept on purpose.
let keep: Vec<bool> = q.palette.iter().map(|c| rare.contains(c)).collect();
quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep);
// Pixel count per palette entry, and the box its pixels lie in.
let mut counts = vec![0usize; q.palette.len()];
let mut boxes: Vec<Option<(usize, usize, usize, usize)>> = vec![None; q.palette.len()];
+258 -9
View File
@@ -21,6 +21,12 @@ pub(crate) fn luma(p: [u8; 4]) -> 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 => {
@@ -36,7 +42,7 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
}
})
.collect();
Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }
(Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }, vec![])
}
Mode::Grayscale => {
let own = own_colours(img);
@@ -61,7 +67,7 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
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)
(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 => {
@@ -84,10 +90,24 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
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 reduce = |colors: u32| {
let centers = color_palette(from, &TraceParams { colors, ..params.clone() });
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 {
@@ -101,11 +121,13 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
if params.palette == Palette::DocumentLibrary && !params.swatches.is_empty() {
q
} else {
merge_mixed(q, img.width as usize, img.height as usize)
merge_mixed(q, img.width as usize, img.height as usize, &rare)
}
};
let q = reduce(params.colors);
settled(q, img, &own)
let q = settled(q, img, &own);
rare.retain(|c| q.palette.contains(c));
(q, rare)
}
}
}
@@ -170,6 +192,12 @@ fn oklab(c: [f64; 3]) -> [f64; 3] {
]
}
/// [`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
@@ -178,9 +206,11 @@ fn oklab(c: [f64; 3]) -> [f64; 3] {
/// 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.
fn merge_mixed(q: Quantized, w: usize, h: usize) -> Quantized {
/// 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;
}
@@ -205,7 +235,7 @@ fn merge_mixed(q: Quantized, w: usize, h: usize) -> Quantized {
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 {
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]);
@@ -687,10 +717,226 @@ fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
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};
@@ -737,6 +983,9 @@ pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
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() {
+188
View File
@@ -1674,3 +1674,191 @@ fn a_raster_of_the_wrong_length_is_an_error_not_a_crash() {
assert!(trace(&broken, &TraceParams::default()).paths.is_empty());
assert_eq!(mosaic(&broken, 2, 2).len(), 4);
}
// ---- Automatic keeps rare but distinct colours (code-drawn flat art) ----
/// Where a layer of [`flat_art`] is.
type Inside<'a> = &'a dyn Fn(f64, f64) -> bool;
/// A layer of [`flat_art`], owning where it is.
type Layer = ([u8; 3], Box<dyn Fn(f64, f64) -> bool>);
/// Flat art drawn in code at `scale` (1 or 2) with 4 × 4 samples a pixel (anti-aliased) or one
/// (hard): `layers` painted in order, each a colour and where it is (in 1× units).
fn flat_art(w: u32, h: u32, scale: u32, aa: bool, layers: &[([u8; 3], Inside)]) -> Raster {
let n = if aa { 4 } else { 1 };
Raster::from_fn(w * scale, h * scale, |x, y| {
let mut acc = [0.0f64; 3];
for i in 0..n {
for j in 0..n {
let (fx, fy) = (
(f64::from(x) + (f64::from(i) + 0.5) / f64::from(n)) / f64::from(scale),
(f64::from(y) + (f64::from(j) + 0.5) / f64::from(n)) / f64::from(scale),
);
let c = layers.iter().rev().find(|(_, inside)| inside(fx, fy)).map_or([255, 255, 255], |l| l.0);
for k in 0..3 {
acc[k] += f64::from(c[k]);
}
}
}
let c = acc.map(|v| (v / f64::from(n * n)).round() as u8);
[c[0], c[1], c[2], 255]
})
}
/// Whether the trace has a colour within `de` (Oklab × 100) of `c`.
fn has_colour(res: &TraceResult, c: [u8; 3], de: f64) -> bool {
let target = quantize::tests_oklab(c);
res.paths.iter().any(|p| {
let o = quantize::tests_oklab(p.color);
(0..3).map(|k| (o[k] - target[k]).powi(2)).sum::<f64>().sqrt() <= de
})
}
fn distinct_colours(res: &TraceResult) -> usize {
let mut v: Vec<[u8; 3]> = res.paths.iter().map(|p| p.color).collect();
v.sort_unstable();
v.dedup();
v.len()
}
const LARGE: [[u8; 3]; 8] =
[[245, 240, 228], [30, 60, 140], [200, 40, 50], [40, 140, 70], [240, 190, 40], [90, 50, 30], [120, 120, 130], [20, 20, 25]];
#[test]
fn automatic_keeps_a_rare_distinct_stem_or_patch() {
// 2–8 large flat colours (bands) and a contrasting stem 1–4 px wide, or a square patch, from
// about 0.05% to 2% of the picture, hard and anti-aliased at 1× and 2×.
let rare = [[230, 30, 200], [0, 200, 220], [255, 255, 255]];
let params = preset("Auto Color").unwrap();
let mut failed = vec![];
for k in [2usize, 3, 5, 8] {
for (ri, &stem) in rare.iter().enumerate() {
for (width, length) in [(1.0, 20.0), (2.0, 30.0), (4.0, 50.0), (6.0, 6.0), (14.0, 14.0)] {
for (scale, aa) in [(1, false), (1, true), (2, true)] {
let bands = move |x: f64, _: f64| ((x / 200.0 * k as f64) as usize).min(k - 1);
let band_colour = |b: usize| LARGE[(b + ri) % 8];
// The stem goes where it contrasts with the band under it.
let (x0, y0) = (12.0, 40.0);
if band_colour(bands(x0, y0)) == stem {
continue;
}
let layers: Vec<Layer> = (0..k)
.map(|b| (band_colour(b), Box::new(move |x: f64, y: f64| bands(x, y) == b) as Box<dyn Fn(f64, f64) -> bool>))
.chain(std::iter::once((
stem,
Box::new(move |x: f64, y: f64| (x0..x0 + width).contains(&x) && (y0..y0 + length).contains(&y))
as Box<dyn Fn(f64, f64) -> bool>,
)))
.collect();
let refs: Vec<([u8; 3], Inside)> = layers.iter().map(|(c, f)| (*c, f.as_ref())).collect();
let img = flat_art(200, 200, scale, aa, &refs);
let res = trace(&img, &params);
let share = width * length / 40000.0 * 100.0;
if !has_colour(&res, stem, 3.0) {
failed.push(format!(
"{k} colours, stem {stem:?} {width}×{length} ({share:.2}%), ×{scale} aa {aa}: lost, {} colours",
distinct_colours(&res)
));
} else if distinct_colours(&res) > k + 1 {
failed.push(format!("{k} colours, stem {stem:?} {width}×{length}, ×{scale} aa {aa}: {} colours", distinct_colours(&res)));
}
}
}
}
}
assert!(failed.is_empty(), "{}", failed.join("\n"));
}
#[test]
fn automatic_keeps_near_white_highlights_on_a_pale_background() {
// White highlights (ten islands of about 10–20 px, 0.2–0.4% in all) on #FFF4E0, with brown and
// dark shapes: the white is 4 (Oklab × 100) from the background, the islands under Noise.
let bg = [255, 244, 224];
let params = preset("Auto Color").unwrap();
for (scale, aa) in [(1, false), (1, true), (2, true)] {
let face = |x: f64, y: f64| (x - 100.0).hypot(y - 100.0) < 60.0;
let eyes = |x: f64, y: f64| (x - 80.0).hypot(y - 90.0) < 9.0 || (x - 120.0).hypot(y - 90.0) < 9.0;
let shine = |x: f64, y: f64| (0..10).any(|i| (x - (40.0 + 13.0 * f64::from(i))).hypot(y - 30.0) < 2.0);
let layers: [([u8; 3], Inside); 4] = [(bg, &|_, _| true), ([139, 90, 43], &face), ([46, 31, 20], &eyes), ([255, 255, 255], &shine)];
let img = flat_art(200, 200, scale, aa, &layers);
let res = trace(&img, &params);
assert!(has_colour(&res, [255, 255, 255], 1.5), "×{scale} aa {aa}: white lost: {:?}", res.palette);
assert!(has_colour(&res, bg, 1.5), "×{scale} aa {aa}: background lost: {:?}", res.palette);
assert_eq!(distinct_colours(&res), 4, "×{scale} aa {aa}: {:?}", res.palette);
// The highlights survive Noise (16 in Auto Color): each is a region of its own.
let white = res.paths.iter().filter(|p| p.color == [255, 255, 255]).count();
assert!(white >= 10, "×{scale} aa {aa}: {white} white paths");
}
}
#[test]
fn automatic_keeps_a_thin_low_contrast_stem() {
// A coherent 3 px stem #909090 on #808080 beside a dark area: close in RGB (28 levels), so
// merge_mixed would take it for noise, but it is one region, not intermixed.
let params = preset("Auto Color").unwrap();
for (scale, aa) in [(1, false), (1, true), (2, true)] {
let stem = |x: f64, y: f64| (60.0..63.0).contains(&x) && (20.0..180.0).contains(&y);
let dark = |x: f64, _: f64| x > 130.0;
let layers: [([u8; 3], Inside); 3] = [([128, 128, 128], &|_, _| true), ([32, 32, 32], &dark), ([144, 144, 144], &stem)];
let res = trace(&flat_art(200, 200, scale, aa, &layers), &params);
assert!(has_colour(&res, [144, 144, 144], 1.5), "×{scale} aa {aa}: stem lost: {:?}", res.palette);
assert_eq!(distinct_colours(&res), 3, "×{scale} aa {aa}: {:?}", res.palette);
}
}
#[test]
fn scattered_noise_adds_no_colour() {
// The negative control: the same areas as a stem (0.05% to 2%) as scattered single pixels of a
// distinct colour, seeded, over 2–5 flat colours: no palette entry of their own.
let params = preset("Auto Color").unwrap();
let hash =
|x: u32, y: u32, s: u32| (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663) ^ s.wrapping_mul(83_492_791)).wrapping_mul(2_654_435_761);
for k in [2usize, 3, 5] {
for (seed, per_mille) in [(1u32, 1u32), (2, 5), (3, 10), (4, 20)] {
let img = Raster::from_fn(200, 200, |x, y| {
let b = ((f64::from(x) / 200.0 * k as f64) as usize).min(k - 1);
let c = if hash(x, y, seed) % 1000 < per_mille { [230, 30, 200] } else { LARGE[b] };
[c[0], c[1], c[2], 255]
});
let res = trace(&img, &params);
assert!(!has_colour(&res, [230, 30, 200], 8.0), "{k} colours, {per_mille}‰ noise: {:?}", res.palette);
assert!(distinct_colours(&res) <= k, "{k} colours, {per_mille}‰ noise: {:?}", res.palette);
}
}
}
#[test]
fn noise_keeps_a_kept_rare_colours_regions_but_not_its_specks() {
// Labels: 0 background, 1 a rare colour kept on purpose (four islands of 4, 6, 9 and 2
// pixels), 2 an ordinary colour with an island of 6.
let (w, h) = (40usize, 10usize);
let mut labels = vec![0u16; w * h];
let mut put = |x0: usize, y0: usize, cw: usize, ch: usize, l: u16| {
for y in y0..y0 + ch {
for x in x0..x0 + cw {
labels[y * w + x] = l;
}
}
};
put(2, 2, 2, 2, 1);
put(8, 2, 3, 2, 1);
put(14, 2, 3, 3, 1);
put(20, 2, 2, 1, 1);
put(28, 2, 3, 2, 2);
for noise in [0usize, 4, 10, 16, 32] {
let mut l = labels.clone();
quantize::denoise_keeping(&mut l, w, h, noise, &[false, true, false]);
let count = |label: u16| l.iter().filter(|&&v| v == label).count();
// The rare colour's regions of at least four pixels stay whatever Noise is; its speck of
// two goes from Noise 4 on, as the ordinary colour's island of six goes from Noise 10.
assert_eq!(count(1), if noise >= 4 { 4 + 6 + 9 } else { 4 + 6 + 9 + 2 }, "Noise {noise}");
assert_eq!(count(2), if noise >= 10 { 0 } else { 6 }, "Noise {noise}");
}
// Without a kept label, denoise is as before.
let mut a = labels.clone();
let mut b = labels.clone();
denoise(&mut a, w, h, 16);
quantize::denoise_keeping(&mut b, w, h, 16, &[]);
assert_eq!(a, b);
assert!(a.iter().all(|&v| v == 0));
}
+6 -1
View File
@@ -26,6 +26,12 @@ so Undo also restores the panel values before its next frame. View can show the
Use **Expand** when finished to turn the result into ordinary editable vector objects;
after expanding, the tracing settings are no longer available. **Release** restores the source.
**Auto Color** (the Automatic palette) counts flat art's colours by area, and also keeps colours too
rare for that count when they are distinct and drawn as regions of their own (a stem, a highlight, a
white beside a cream), at most six; scattered specks of a colour add none. Adobe does not document how
its own palettes are chosen; this is VectorCraft's own method (see
[image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)).
**Large images.** An image of more than 1500 pixels on a side is traced as any other, only more
slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar
shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html)
@@ -34,7 +40,6 @@ page describes the same warning for images over 1500 px). An image of more than
make more than 8,000,000 anchor points (2,000,000 on the web) is refused to protect memory, with advice:
raise Noise, lower Paths or Colors, or pick a lower-fidelity preset. A trace keeps
about 90 bytes an anchor; a 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million.
These controls use VectorCraft's existing tracing algorithm. Parameter effects follow its
implementation; they do not claim pixel-identical results with another application.
The terminology and general control behavior are described in the
+42 -5
View File
@@ -1,5 +1,6 @@
# Image Trace quality
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-17 · **Change:** minor (Image Trace: Auto Color keeps rare but distinct colours; earlier: Gradients: grown over noise and soft rims)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-17 · **Change:** minor (Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; earlier: Gradients: grown over noise and soft rims)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-17 · **Change:** minor (Image Trace: Raster::new returns an error for a buffer of the wrong length; earlier: Gradients: grown over noise and soft rims)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Create Strokes: maximal-disc pruning and junction merging; centre-lines rerun)
@@ -15,15 +16,53 @@ compared with the numbers before each change. Nothing here is a judgement by eye
with any other product except the open tracer named under [Yardstick](#4-yardstick).
## Large images
## Auto Color keeps rare colours
The Automatic palette (Auto Color) counts flat art's colours by area: a colour needs about 1% of the
picture (0.5% at Colors 100) to be counted, and the count is then fitted by median cut (Heckbert 1982)
and k-means (Lloyd 1982), weighted by area. A stem, a highlight or a cream close to a pale background
was averaged into a large neighbour, and Noise could remove what was left of it. Now, on the flat route
only, after the count is fitted, the colours too rare or too close for it are found and kept when they
are:
- distinct: at least 2.5 (Oklab × 100; Ottosson 2020) from every palette colour and from the main
colour each one stands for;
- flat: mostly one histogram bin;
- drawn: a 4-connected region of their own (not blended) pixels of at least 8 pixels, or three of at
least 4 (repeated highlights); a colour lying between two others, as an edge's anti-aliasing mixes
them, must also have an inside (pixels whose four neighbours are all of it).
At most six are kept, largest region first. They are fixed while the other colours are refitted around
them, mixed-colour merging leaves them alone, and Noise removal keeps their regions of 4 pixels or more
(smaller specks of them still go). This 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.
Full Tone, Limited and the continuous-tone route of Automatic are unchanged.
Tests (code-drawn): `automatic_keeps_a_rare_distinct_stem_or_patch` (2, 3, 5 and 8 flat colours and a
contrasting stem 1–4 px wide or a patch, about 0.05% to 0.5% of the picture, hard and anti-aliased at
1× and 2×: the stem's colour is kept and no other is added), `automatic_keeps_near_white_highlights_on_a_pale_background`
(ten white islands of about 12 px on #FFF4E0: kept, each still a path at Noise 16),
`automatic_keeps_a_thin_low_contrast_stem` (#909090 on #808080, 3 px), `scattered_noise_adds_no_colour`
(the negative control: the same areas as single scattered pixels add no colour) and
`noise_keeps_a_kept_rare_colours_regions_but_not_its_specks` (Noise 0 to 32).
Measured on 22 synthetic flat-colour logos drawn from known palettes, each hard 1×, anti-aliased 1×
and 2× (66 inputs, used locally and not committed; a key colour is missing when no traced colour is
within ΔE00 5): every key colour and no spurious one in **66 of 66** (was 39); missing key colours 0 (was 43),
spurious 0 (was 2), key colours sharing one traced colour 0 pairs (was 46); mean rendered ΔE00 against
the input 0.138 (was 0.348); paths 2547 → 2586, anchors 27,204 → 24,896, trace time 2.85 → 3.02 s in
all. On four public-domain scan crops (1024 × 1024) Auto Color takes the continuous-tone route and
nothing changes: 14, 72, 199 and 98 colours, the same paths, anchors and rendered error (2.11, 5.66,
9.15 and 1.00 ΔE00), time within run-to-run spread.
## Large images
Images over 1500 px on a side are traced with a warning that they trace more slowly (see
[image-trace-adjustments.md](image-trace-adjustments.md)), and the anchor limit that guards memory is
8,000,000 (2,000,000 on the web) instead of 2,000,000. Measured through `imageTrace.make` (release
build, 4 cores, one trace at a time; memory is the peak allocated above the placed image, and what the
document keeps after the trace) on code-drawn photo-like pictures (40 soft-edged shaded discs on a
gradient, texture, sensor noise):
| size | preset | time | anchors | paths | peak | kept |
|:--|:--|--:|--:|--:|--:|--:|
| 2000 × 2000 | High Fidelity Photo | 4.1 s | 737,478 | 16,064 | 152 MB | 52 MB |
@@ -39,12 +78,9 @@ gradient, texture, sensor noise):
| 4000 × 3000 | Full Tone, Colors 100, Paths 100, Noise 1 | 37.2 s | 7,885,709 | 338,561 | 876 MB | 685 MB |
| 6000 × 4000 | Full Tone, Colors 85, Paths 90, Noise 4 | 106.1 s | 10,445,409 | 398,772 | 1169 MB | 872 MB |
| 6000 × 4000 | Full Tone, Colors 100, Paths 100, Noise 1 | 116.3 s | 13,508,871 | 658,133 | 1456 MB | 1230 MB |
(The last two rows were measured without a limit; they are refused now.) The 4000 × 3000 High Fidelity
Photo trace was 86,000 anchors under the old limit; a noisier scan of the same size would have been
refused. At 8,000,000 anchors a trace keeps about 0.7 GB, with a peak of about 0.9 GB while tracing.
## Collapsed loops keep few segments
A small closed loop (or a piece that returns to its own junction) whose fit collapsed to a single
cubic cannot stay as one, so it kept every positioned point as a straight segment. A looser Paths
@@ -1240,6 +1276,7 @@ The remaining spur/extra-junction target is not met by this graph-only change.
| date | change |
|:--|:--|
| 2026-10-17 | Auto Color keeps rare but distinct colours (stems, highlights, near-white on pale backgrounds): 66 of 66 flat test logos keep every key colour (was 39); scans unchanged |
| 2026-10-17 | Images over 1500 px on a side trace with a warning; the anchor limit is 8,000,000 (2,000,000 on the web); time and memory of 2000 × 2000 to 6000 × 4000 code-drawn photos |
| 2026-10-17 | `Raster::new` returns an error for RGBA bytes of the wrong length (it failed an assertion) and `Raster::pixel` reads nothing past the raster; traces of valid images unchanged |
| 2026-10-17 | M12: maximal-disc graph pruning, split-crossing merges and degree-two joins; correct junction degrees 30→44/45 aa and hard, none missed; remaining spurs unchanged. Centre-lines rerun, scores and earlier tables unchanged. |
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long