mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
Merge pull request #2702 from andeecollard/fix/trace-auto-color-rare-colours
Image Trace: Auto Color keeps rare but distinct colours
This commit is contained in:
@@ -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()];
|
||||
|
||||
@@ -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, ¢ers);
|
||||
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(¢ers);
|
||||
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(¢er_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(¢ers);
|
||||
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(¢er_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 ®ions[id].pixels {
|
||||
for j in neighbours(i).into_iter().flatten() {
|
||||
|
||||
@@ -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, ¶ms);
|
||||
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, ¶ms);
|
||||
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), ¶ms);
|
||||
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, ¶ms);
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user