Image Trace: blurred edges no longer trace as rings of in-between colors

A blend pixel took a side of its edge only when the picture's own colors
on both sides lay within 2 px of it. With a limited palette, an edge
blurred over more than about 4 px kept the middle of its ramp in the
palette's in-between tones, which traced as rings of thin paths around
the edge.

With a Limited palette of at most 16 colors (3 Colors, 6 Colors,
16 Colors) and in Grayscale with at most 16 grays (Shades of Gray),
settled_wide looks for the own colors within 8 px, pairs the 8 of them
nearest to the pixel's color, and moves the pixel to the nearer side of
the best pair when that pair explains it twice as well as its own color,
or when it lies between the two and off the line between them by less
than 0.15 of their distance apart. These values are VectorCraft's own.
Of two pairs that explain it equally well it takes the closer one. A
pixel keeps its color when an own pixel of that color lies within 4 px
or when its color is one of the pair. Grayscale compares lightness. The
colors within reach are kept as bitsets for a strip of 7 columns at a
time.

High Fidelity Photo, Low Fidelity Photo, Black and White and the other
palettes keep the 2 px rule unchanged.

On the quality harness, blurred and scanned flat art with 6 and
16 Colors goes from 6 to 9 paths to 3, and from 0.23 to 0.26 px off to
0.07 to 0.11. Askew shapes take up to 11 px more area in colors the art
does not have, thin lines with 16 Colors lose more (largest distance
23.5 to 45.3 px), and Shades of Gray traces thin lines in 7.3 paths
where it took 5.3.

Noisy images settle more of their pixels: the dense grouping test
traces 150 x 150 noise instead of 120 x 120 to keep more than its 500
paths.

Covers the limited-palette part of #1911 (leogrox).

Docs: docs/target-app-parity.md, docs/image-trace-quality.md.
This commit is contained in:
dotCooCoo
2026-11-13 08:54:47 -08:00
parent 77970c6924
commit 9bf05dece4
6 changed files with 416 additions and 6 deletions
+2 -2
View File
@@ -420,13 +420,13 @@ mod tests {
#[test]
fn a_dense_trace_is_grouped_with_a_few_box_tests_per_path() {
let img = noise(120, 120);
let img = noise(150, 150);
for (method, stacked) in [(crate::Method::Abutting, false), (crate::Method::Overlapping, true)] {
let paths = traced(&img, method);
let n = paths.len();
let counted = |by_pairs: bool| {
BOX_TESTS.with(|n| n.set(0));
let found = if by_pairs { parents_by_pairs(&paths, 120, 120, stacked) } else { parents(&paths, 120, 120, stacked) };
let found = if by_pairs { parents_by_pairs(&paths, 150, 150, stacked) } else { parents(&paths, 150, 150, stacked) };
(found, BOX_TESTS.with(std::cell::Cell::get))
};
let (found, tests) = counted(false);
+4 -3
View File
@@ -8,7 +8,7 @@ mod denoise;
mod palette;
mod rare;
use cut_edge::{choosing_pixels, compact, settled};
use cut_edge::{choosing_pixels, compact, settled, settled_wide, settles_wide};
pub(crate) use cut_edge::{continuous_tone, settle_cut_edges};
pub use denoise::denoise;
pub(crate) use denoise::denoise_keeping;
@@ -91,7 +91,8 @@ pub(crate) fn quantize_keeping(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), vec![])
let q = merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]);
(if settles_wide(params) { settled_wide(q, img, &own, true) } else { settled(q, img, &own) }, vec![])
}
// Logo mode traces without quantising; asked directly, it quantises like Color.
Mode::Color | Mode::Logo => {
@@ -170,7 +171,7 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
if library { 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);
let q = if settles_wide(params) { settled_wide(q, img, &own, false) } else { settled(q, img, &own) };
rare.retain(|c| q.palette.contains(c));
(q, rare)
}
+272 -1
View File
@@ -1,5 +1,5 @@
use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab};
use super::{Quantized, Raster, TRANSPARENT, luma, oklab};
use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab};
/// `q` with its blend pixels settled, and the colours that leaves unused dropped.
///
@@ -65,6 +65,214 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized
if changed { compact(q.labels, q.palette) } else { q }
}
/// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels
/// settled by [`settled_wide`] rather than [`settled`]. The limited-color presets ask for 3 to 16
/// colors.
const LIMITED_COLORS: u32 = 16;
/// Whether a trace with `params` settles its blend pixels with [`settled_wide`]: Grayscale, and
/// Color with a Limited palette, of at most [`LIMITED_COLORS`] colors.
pub(super) fn settles_wide(params: &TraceParams) -> bool {
match params.mode {
Mode::Grayscale => params.colors <= LIMITED_COLORS,
Mode::Color | Mode::Logo => params.palette == Palette::Limited && params.colors <= LIMITED_COLORS,
Mode::BlackAndWhite => false,
}
}
/// How far (px, across, along or diagonally) [`settled_wide`] looks for the picture's own pixels
/// around a blend pixel: blur spreads an edge over several pixels, and its two sides can lie this
/// far apart.
const BLEND_REACH: usize = 8;
/// How far (px, across, along or diagonally) an own pixel of its color keeps a blend pixel's color
/// in [`settled_wide`]: where three colors meet, the blend is of its color too.
const OWN_NEAR: usize = 4;
/// How many of the colors within reach of a blend pixel, nearest to its color first,
/// [`settled_wide`] pairs up.
const NEAR_COLORS: usize = 8;
/// How many columns at a time [`settled_wide`] keeps the colors within reach for, so that its
/// memory does not grow with the width of the picture.
const BLEND_STRIP: usize = 7;
/// How far apart (squared levels) the distances of a blend pixel off the lines between two pairs
/// of colors may be for [`settled_wide`] to count them as equal: rounding leaves a gray that lies
/// between two grays about 1e-28 off the line between them.
const SAME_FIT: f64 = 1e-6;
/// How much nearer (squared) to the line between a pair of colors than to its own color a blend
/// pixel must lie for [`settled_wide`] to settle it: half as far, as [`settled`] asks.
const BETTER_FIT: f64 = 0.25;
/// How far off the line between a pair of colors (as a share of their distance apart) a blend pixel
/// that lies between the two may be for [`settled_wide`] to settle it whatever its own color.
const ON_THE_LINE: f64 = 0.15;
/// [`settled`] for a limited palette (see [`settles_wide`]), which also settles the in-between
/// tones of an edge blurred over several pixels: without it they trace as a ring of a third color
/// around the edge.
///
/// A blend pixel (see [`own_colours`]) whose color is none of those of the picture's own pixels
/// within [`OWN_NEAR`] pixels of it takes the side it is nearer to of the pair of the colors of own
/// pixels within [`BLEND_REACH`] pixels that it is best a blend of (of two pairs that explain it
/// equally well, the closer one), when that explains it twice as well as its own color does, or
/// when it lies between the two and off the line between them by less than [`ON_THE_LINE`] of
/// their distance apart. Only the [`NEAR_COLORS`] colors nearest to its color are paired. It keeps
/// its own color when that color is one of the pair, or when one color is all there is around it.
/// With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]).
pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: 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 colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect();
let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>();
// The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette
// colors), a strip of columns at a time: along its row for the rows within reach (a window
// sliding along each row, kept for the last 2 × reach + 1 rows), then across those rows.
let words = q.palette.len().div_ceil(64).max(1);
let span = 2 * BLEND_REACH + 1;
let strip = BLEND_STRIP.min(w);
let mut rows = vec![0u64; span * strip * words];
let mut counts = vec![0u32; q.palette.len()];
let mut window = vec![0u64; words];
let mut bits = vec![0u64; words];
// The bits of row `y` for the columns `x0..x1` (at most `strip` of them).
let mut row_colors = |y: usize, x0: usize, x1: usize, rows: &mut [u64]| {
let Some(out) = rows.get_mut((y % span) * strip * words..(y % span + 1) * strip * words) else { return };
counts.fill(0);
window.fill(0);
// The own pixel at `x` enters the window (`add`) or leaves it.
let step = |counts: &mut [u32], window: &mut [u64], x: usize, add: bool| {
let i = y * w + x;
let l = before[i] as usize;
if !own[i] || l >= counts.len() {
return;
}
if add {
counts[l] += 1;
window[l / 64] |= 1 << (l % 64);
} else {
counts[l] -= 1;
if counts[l] == 0 {
window[l / 64] &= !(1 << (l % 64));
}
}
};
let first = x0.saturating_sub(BLEND_REACH);
for x in first..(x0 + BLEND_REACH).min(w) {
step(&mut counts, &mut window, x, true);
}
for x in x0..x1 {
if x + BLEND_REACH < w {
step(&mut counts, &mut window, x + BLEND_REACH, true);
}
if let Some(gone) = x.checked_sub(BLEND_REACH + 1).filter(|&g| g >= first) {
step(&mut counts, &mut window, gone, false);
}
if let Some(o) = out.get_mut((x - x0) * words..(x - x0 + 1) * words) {
o.copy_from_slice(&window);
}
}
};
let mut changed = false;
let mut near: Vec<u16> = Vec::new();
let mut by_distance: Vec<(f64, u16)> = Vec::new();
let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] };
for (x0, y) in (0..w).step_by(strip.max(1)).flat_map(|x0| (0..h).map(move |y| (x0, y))) {
let x1 = (x0 + strip).min(w);
if y == 0 {
for yy in 0..BLEND_REACH.min(h) {
row_colors(yy, x0, x1, &mut rows);
}
}
if y + BLEND_REACH < h {
row_colors(y + BLEND_REACH, x0, x1, &mut rows);
}
'pixels: for x in x0..x1 {
let i = y * w + x;
let mine_l = before[i];
if own[i] || mine_l == TRANSPARENT {
continue;
}
// Its color is the picture's own here.
for yy in y.saturating_sub(OWN_NEAR)..=(y + OWN_NEAR).min(h - 1) {
for xx in x.saturating_sub(OWN_NEAR)..=(x + OWN_NEAR).min(w - 1) {
if own[yy * w + xx] && before[yy * w + xx] == mine_l {
continue 'pixels;
}
}
}
bits.fill(0);
for yy in y.saturating_sub(BLEND_REACH)..=(y + BLEND_REACH).min(h - 1) {
let at = (yy % span) * strip * words + (x - x0) * words;
for (b, r) in bits.iter_mut().zip(rows.get(at..at + words).unwrap_or_default()) {
*b |= r;
}
}
near.clear();
for (k, &b) in bits.iter().enumerate() {
let mut b = b;
while b != 0 {
let l = (k * 64 + b.trailing_zeros() as usize) as u16;
b &= b - 1;
near.push(l);
}
}
let (Some(p), Some(&mine)) = (img.rgba.get(i * 4..i * 4 + 4).map(pixel), colors.get(mine_l as usize)) else {
continue;
};
if near.iter().filter(|&&l| l != mine_l).count() < 2 {
continue;
}
// In busy art many colors lie within reach: only the ones nearest to its color are paired.
// Each distance is worked out once, and only the nearest few are sorted (nearest first,
// then by index).
if near.len() > NEAR_COLORS {
by_distance.clear();
by_distance.extend(near.iter().map(|&l| (colors.get(l as usize).map_or(f64::INFINITY, |&c| dist2(p, c)), l)));
let order = |a: &(f64, u16), b: &(f64, u16)| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1));
by_distance.select_nth_unstable_by(NEAR_COLORS, order);
by_distance.truncate(NEAR_COLORS);
by_distance.sort_unstable_by(order);
near.clear();
near.extend(by_distance.iter().map(|&(_, l)| l));
}
// The pair it is best a blend of, by the square of how far it lies off the line between them,
// and of two pairs it lies equally far off, the closer pair (in grays it lies on the line
// between every pair of grays darker and lighter than it).
let mut best: Option<(f64, [u16; 2], u16, f64, bool)> = None;
for (n, &a) in near.iter().enumerate() {
let Some(&ca) = colors.get(a as usize) else { continue };
let pa = [p[0] - ca[0], p[1] - ca[1], p[2] - ca[2]];
for &b in &near[n + 1..] {
let Some(&cb) = colors.get(b as usize) else { continue };
let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]];
let len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let along = if len2 > 0.0 { (pa[0] * d[0] + pa[1] * d[1] + pa[2] * d[2]) / len2 } else { 0.0 };
let t = along.clamp(0.0, 1.0);
let off2 = (pa[0] - t * d[0]).powi(2) + (pa[1] - t * d[1]).powi(2) + (pa[2] - t * d[2]).powi(2);
if best.is_none_or(|x| off2 < x.0 - SAME_FIT || (off2 <= x.0 + SAME_FIT && len2 < x.3)) {
best = Some((off2, [a, b], if t < 0.5 { a } else { b }, len2, along > 0.0 && along < 1.0));
}
}
}
// A pixel already of one of the two colors it is best a blend of stays as it is.
if let Some((off2, pair, to, apart2, between)) = best
&& !pair.contains(&mine_l)
&& (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2))
{
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
@@ -443,3 +651,66 @@ fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool {
let off2: f64 = (0..3).map(|c| (own[c] - from[c] - t * d[c]).powi(2)).sum();
off2 <= EDGE_BLEND * EDGE_BLEND
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() {
// Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black
// along the top row, and a palette gray of 120 of its own. It lies between 0 and 200 exactly
// and between 50 and 200 to within rounding: it takes 50, the nearer of the closer pair.
let gray = |x: u32, y: u32| match (x, y) {
(8, 8) => 105,
(_, 0) => 0,
(0..=8, _) => 50,
_ => 200,
};
let img = Raster::from_fn(17, 17, |x, y| {
let v = gray(x, y);
[v, v, v, 255]
});
let palette = vec![[0, 0, 0], [50, 50, 50], [200, 200, 200], [120, 120, 120]];
let labels: Vec<u16> = (0..17 * 17)
.map(|i| match gray(i % 17, i / 17) {
0 => 0,
50 => 1,
200 => 2,
_ => 3,
})
.collect();
let own: Vec<bool> = labels.iter().map(|&l| l != 3).collect();
let q = settled_wide(Quantized { labels, palette }, &img, &own, true);
let middle = q.labels[8 * 17 + 8];
assert_eq!(q.palette[usize::from(middle)], [50, 50, 50]);
}
#[test]
fn a_blend_pixel_beyond_the_end_of_a_pair_keeps_its_nearest_color() {
// A column of [92, 92, 92] between own pixels of [100, 100, 100] to its left and
// [200, 200, 200] to its right, with a palette color of [90, 90, 90]: it lies beyond the
// darker end of the pair, not between the two, and its own color is the nearer, so it
// keeps it.
let value = |x: u32| match x {
0..=7 => 100,
8 => 92,
_ => 200,
};
let img = Raster::from_fn(17, 17, |x, _| {
let v = value(x);
[v, v, v, 255]
});
let palette = vec![[100, 100, 100], [200, 200, 200], [90, 90, 90]];
let labels: Vec<u16> = (0..17 * 17u32)
.map(|i| match value(i % 17) {
100 => 0,
200 => 1,
_ => 2,
})
.collect();
let own: Vec<bool> = labels.iter().map(|&l| l != 2).collect();
let q = settled_wide(Quantized { labels, palette }, &img, &own, false);
assert_eq!(q.palette[usize::from(q.labels[8 * 17 + 8])], [90, 90, 90]);
}
}
+109
View File
@@ -1330,6 +1330,115 @@ fn a_blend_of_two_colours_is_no_sliver_of_a_third() {
assert!(b.y0 > 89.0 && b.y1 < 110.0, "{b:?}");
}
/// A light disc of radius 32 at (55, 60) on a dark ground, its edge blurred into a ramp 10 px
/// wide, and elsewhere a patch of the tone halfway between the two, so that the palette holds
/// that tone. Returns the picture and whether a color is that tone.
fn blurred_disc_and_halfway_patch() -> (Raster, impl Fn([u8; 3]) -> bool) {
const DARK: [f64; 3] = [20.0, 25.0, 35.0];
const LIGHT: [f64; 3] = [225.0, 195.0, 160.0];
let mid = [0, 1, 2].map(|k| (DARK[k] + LIGHT[k]) / 2.0);
let img = Raster::from_fn(160, 120, |x, y| {
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
let c = if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) {
mid
} else {
let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0);
[0, 1, 2].map(|k| DARK[k] + t * (LIGHT[k] - DARK[k]))
};
let c = |k: usize| c[k].round() as u8;
[c(0), c(1), c(2), 255]
});
(img, move |c: [u8; 3]| (0..3).all(|k| (f64::from(c[k]) - mid[k]).abs() <= 12.0))
}
#[test]
fn a_blurred_edge_is_no_ring_of_a_tone_between_its_sides() {
let (img, is_mid) = blurred_disc_and_halfway_patch();
let res = trace(&img, &sixteen_colors());
let mids: Vec<&TracedPath> = res.paths.iter().filter(|p| is_mid(p.color)).collect();
assert_eq!(mids.len(), 1, "the patch, and no ring around the disc: {:?}", res.paths.iter().map(|p| p.color).collect::<Vec<_>>());
let b = mids[0].path.bounds().unwrap();
assert!(b.x0 > 110.0 && b.y0 > 38.0 && b.y1 < 82.0, "{b:?}");
// The disc's edge lies halfway along the ramp.
let light = res.paths.iter().find(|p| p.color[0] > 180).unwrap();
let off = along(&light.path).iter().map(|p| (p.distance(Point::new(55.0, 60.0)) - 32.0).abs()).fold(0.0, f64::max);
assert!(off < 1.0, "{off} px off the middle of the ramp");
}
#[test]
fn grayscale_settles_a_tinted_blurred_edge_by_its_lightness() {
// The same blurred disc and halfway patch in grays, and tinted green at the same lightness
// (every pixel moved by the same color, which keeps its luma): Grayscale reads lightness
// alone, so both make the same grays in the same places.
let lightness = |x: u32, y: u32| {
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) {
130
} else {
let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0);
(40.0 + 180.0 * t).round() as u8
}
};
let gray = Raster::from_fn(160, 120, |x, y| {
let v = lightness(x, y);
[v, v, v, 255]
});
let tinted = Raster::from_fn(160, 120, |x, y| {
let v = lightness(x, y);
[v - 40, v + 28, v - 40, 255]
});
let lumas = |img: &Raster| img.rgba.as_chunks::<4>().0.iter().map(|&p| crate::quantize::luma(p)).collect::<Vec<_>>();
assert_eq!(lumas(&tinted), lumas(&gray));
let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() };
assert_eq!(quantize(&tinted, &params), quantize(&gray, &params));
}
#[test]
fn grayscale_settles_a_blurred_edge_between_its_own_two_grays() {
// A dark gray and a light gray meet in a ramp 12 px wide, and a black band runs across the top.
// In grays every blend lies between black and the light gray as well as between the two grays
// (to within rounding): the ramp's pixels just below the band take the nearer of the two grays,
// the grays on either side of them.
for (dark, light) in [(40u8, 200u8), (50, 200), (30, 170), (60, 230), (45, 215)] {
let img = Raster::from_fn(160, 60, |x, y| {
let v = if y < 8 {
0
} else {
let t = ((f64::from(x) + 0.5 - 74.0) / 12.0).clamp(0.0, 1.0);
(f64::from(dark) + f64::from(light - dark) * t).round() as u8
};
[v, v, v, 255]
});
let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() };
let q = quantize(&img, &params);
let middle = u16::from(dark) + u16::from(light);
for y in 10..16 {
for x in 74..86 {
let v = img.rgba[(y * 160 + x) * 4];
let g = q.palette[usize::from(q.labels[y * 160 + x])][0];
assert_eq!(2 * u16::from(g) < middle, 2 * u16::from(v) < middle, "{dark} to {light}, ({x}, {y}): {v} took gray {g}");
assert!(g >= dark, "{dark} to {light}, ({x}, {y}): {v} took gray {g}");
}
}
}
}
#[test]
fn the_photo_presets_keep_the_tones_of_a_blurred_edge() {
// The photo presets ask for more colors than the limited-color presets and keep the middle of
// the ramp in the halfway tone: only a blend pixel with the colors on both sides within 2 px of
// it takes a side.
let (img, is_mid) = blurred_disc_and_halfway_patch();
for name in ["High Fidelity Photo", "Low Fidelity Photo"] {
let q = quantize(&img, &preset(name).unwrap());
let middle = (0..160 * 120)
.filter(|&i| (Point::new((i % 160) as f64 + 0.5, (i / 160) as f64 + 0.5).distance(Point::new(55.0, 60.0)) - 32.0).abs() < 1.0)
.filter(|&i| q.palette.get(usize::from(q.labels[i])).is_some_and(|&c| is_mid(c)))
.count();
assert!(middle > 100, "{name}: {middle} pixels of the halfway tone in the middle of the ramp");
}
}
#[test]
fn hairlines_keep_their_colour_in_color_mode() {
// Black lines one pixel wide on white: every black pixel is on an edge, none is a blend.
+28
View File
@@ -156,6 +156,34 @@ under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, a
four corners); `layers::tests` (a ramp, flat colors in palette entries far apart, close flat colors, a
thin flat stroke, colors off the line between their entries, and Grayscale's grays, each read from both
sides of the crack) and `subpixel::tests` (the rule, and one decision for a whole piece from either side).
## Blurred edges
`settled` moved a blend pixel to a side of its edge only when the picture's own colors on both sides lay
within 2 px of it, so the middle of an edge blurred over more than about 4 px kept the palette's
in-between tones and traced as rings of thin paths around the edge. In Color with a Limited palette of
at most 16 colors and in Grayscale with at most 16 grays, `cut_edge::settled_wide` now does the work, by
VectorCraft's own rule (RGB distances, 0 to 255 a channel; Grayscale reads each pixel as its gray). A
blend pixel with no own pixel of its color within 4 px pairs the 8 own colors within 8 px that are nearest
to its color. It takes the nearer side of the pair it is best a blend of when that pair explains it twice
as well as its own color, or when it lies between the two and off the line between them by less than 0.15
of their distance apart. Of two pairs that fit equally well it takes the closer pair. Other palettes,
High Fidelity Photo, Low Fidelity Photo and Black and White keep `settled`.
Measured with the harness below on `main` at `77970c6924`, before and after the change (256 px variants
unless stated): 16 Colors on blurred flat art 9.0 paths to 3.0, 158 anchors to 34, 0.239 px off to 0.069;
6 Colors 6.0 paths to 3.0, 118 anchors to 33, 0.226 px off to 0.071; scanned, 16 Colors 8.0 paths to 3.0
and 0.259 px off to 0.110, 6 Colors 7.5 paths to 3.0 and 0.235 px off to 0.093. On each preset's own
inputs (section 1), 6 Colors went from 4.1 paths to 3.0 and 0.143 px off to 0.101, and 16 Colors from 4.6
paths to 3.0 and 0.145 px off to 0.100. Auto Color does not change. Worse by more than 5% (25 of 211 rows
that moved, most of them variants of the same inputs): askew shapes, odd px 11 to 22 with 6 Colors (paths
4.0 to 4.3, odd colours 0.3 to 0.7), 23 to 26 with 3 Colors, 17 to 22 with 16 Colors and 27 to 30 with
Shades of Gray; thin lines, missed max 23.5 to 45.3 px with 16 Colors, and with Shades of Gray paths 5.3 to
7.3, anchors 36 to 41 and odd colours 3.0 to 5.0; linear gradients with 3 Colors, paths 3.0 to 3.3, and
with 6 Colors with Gradients, paths 1.3 to 1.5 and anchors 13 to 16; two- and three-stop gradients with 6
Colors with Gradients, paths 1.2 to 1.3; gradient and photo with 16 Colors, overlap px 0.0 to 0.2 and
specks 0.8 to 1.0. The tables below are the previous measurement, not a rerun.
Tests on code-drawn images: `tests::a_blurred_edge_is_no_ring_of_a_tone_between_its_sides` (a disc
blurred into a 10 px ramp, 16 Colors), `tests::grayscale_settles_a_blurred_edge_between_its_own_two_grays`,
`tests::grayscale_settles_a_tinted_blurred_edge_by_its_lightness`,
`tests::the_photo_presets_keep_the_tones_of_a_blurred_edge` and `quantize::cut_edge::tests`.
## A simpler contour walk
`contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the
right. It used to build a table of outgoing directions for every pixel corner in a raster pass and
File diff suppressed because one or more lines are too long