Image Trace: a quality harness and the baseline numbers for every preset

crates/trace/tests/quality draws its inputs from known vector art (no image files), traces them with every
built-in preset and measures fidelity, distance from the true outlines, economy, artefacts, time and memory.
docs/image-trace-quality.md records the numbers on main before any change, beside the same measures of an
open tracer run as a black box. No change to the tracer.
This commit is contained in:
bflatastic 2026-10-12 23:56:19 +07:00
parent 80af380a6a
commit 182a2cda4b
5 changed files with 1389 additions and 0 deletions

View File

@ -0,0 +1,269 @@
//! The harness inputs: ground-truth vector art (drawn in a 256-unit square, rasterised at several
//! sizes) and two continuous-tone images computed per pixel.
use kurbo::{Affine, BezPath, Circle, Ellipse, Point, RoundedRect, Shape};
use vectorcraft_trace::Raster;
use crate::raster::{self, noise};
/// What an input tells about a trace.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Class {
/// Curved one-colour shapes: a circle, ellipses, a rounded badge with a hole, a letter.
Shape,
/// Straight-edged one-colour shapes: polygons, a star, a letter.
Poly,
/// Thin lines, 0.5 to 5 units wide.
Lines,
/// Flat art in several colours, with regions that touch.
Flat,
/// Continuous tone: a gradient and a photo-like image.
Tone,
}
pub const CLASSES: [Class; 5] = [Class::Shape, Class::Poly, Class::Lines, Class::Flat, Class::Tone];
impl Class {
pub fn name(self) -> &'static str {
match self {
Class::Shape => "curved shapes",
Class::Poly => "polygons",
Class::Lines => "thin lines",
Class::Flat => "flat colour art",
Class::Tone => "gradient, photo",
}
}
}
/// How an input is degraded after it is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Variant {
Clean,
Blur,
Noise,
Scan,
}
pub const VARIANTS: [Variant; 4] = [Variant::Clean, Variant::Blur, Variant::Noise, Variant::Scan];
impl Variant {
pub fn name(self) -> &'static str {
match self {
Variant::Clean => "clean",
Variant::Blur => "blurred",
Variant::Noise => "noisy",
Variant::Scan => "scanned",
}
}
pub fn apply(self, img: &Raster) -> Raster {
match self {
Variant::Clean => img.clone(),
Variant::Blur => raster::blur(img),
Variant::Noise => raster::noisy(img),
Variant::Scan => raster::scanned(img),
}
}
/// The colour a flat `c` of the clean image has in this variant (noise and shading aside).
pub fn color(self, c: [u8; 3]) -> [u8; 3] {
if self == Variant::Scan { raster::scan_color(c) } else { c }
}
}
/// A colour of a continuous-tone input at `(u, v)` in the unit square; `x`, `y` are the pixel.
type Tone = fn(f64, f64, u32, u32) -> [f64; 3];
pub struct Input {
pub name: &'static str,
pub class: Class,
/// The art, bottom first, in 256-unit coordinates. Shapes nest or abut, never partly overlap,
/// so every outline is a visible edge.
pub shapes: Vec<(BezPath, [u8; 3])>,
pub bg: [u8; 3],
/// Anchor points the art itself has.
pub anchors: usize,
/// Its sharp corners.
pub corners: Vec<Point>,
tone: Option<Tone>,
}
const WHITE: [u8; 3] = [255, 255, 255];
const INK: [u8; 3] = [0, 0, 0];
fn poly(pts: &[(f64, f64)]) -> BezPath {
let mut p = BezPath::new();
for (i, &pt) in pts.iter().enumerate() {
if i == 0 { p.move_to(pt) } else { p.line_to(pt) }
}
p.close_path();
p
}
fn regular(cx: f64, cy: f64, r: f64, n: usize, rot: f64) -> Vec<(f64, f64)> {
(0..n)
.map(|i| {
let a = rot + std::f64::consts::TAU * i as f64 / n as f64;
(cx + r * a.cos(), cy + r * a.sin())
})
.collect()
}
fn points(pts: &[(f64, f64)]) -> Vec<Point> {
pts.iter().map(|&p| p.into()).collect()
}
/// `outer` with `holes` cut out (wound the other way: the rasteriser fills non-zero).
fn with_holes(outer: BezPath, holes: &[BezPath]) -> BezPath {
let mut p = outer;
for h in holes {
p.extend(h.reverse_subpaths());
}
p
}
/// Half of a disc, left or right of the vertical line through its centre (3 anchors).
fn half_disc(c: Point, r: f64, left: bool) -> BezPath {
let (s, k) = (if left { -r } else { r }, 0.552_284_75 * r);
let ks = if left { -k } else { k };
let mut p = BezPath::new();
p.move_to((c.x, c.y - r));
p.curve_to((c.x + ks, c.y - r), (c.x + s, c.y - k), (c.x + s, c.y));
p.curve_to((c.x + s, c.y + k), (c.x + ks, c.y + r), (c.x, c.y + r));
p.close_path();
p
}
fn gradient(u: f64, v: f64, _: u32, _: u32) -> [f64; 3] {
let glow = (-((u - 0.7).powi(2) + (v - 0.3).powi(2)) / 0.05).exp();
[40.0 + 190.0 * u + 25.0 * glow, 70.0 + 120.0 * v + 60.0 * glow, 200.0 - 150.0 * u * v + 40.0 * glow]
}
/// Photo-like: sky to ground across a soft horizon, soft-edged objects, shading, texture and
/// sensor noise.
fn photo(u: f64, v: f64, x: u32, y: u32) -> [f64; 3] {
let soft = |d: f64, width: f64| 1.0 / (1.0 + (-d / width).exp());
let mix = |a: [f64; 3], b: [f64; 3], t: f64| [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t);
let sky = mix([70.0, 120.0, 200.0], [190.0, 210.0, 235.0], v / 0.55);
let ground = mix([95.0, 120.0, 60.0], [50.0, 70.0, 40.0], (v - 0.55) / 0.45);
let mut c = mix(sky, ground, soft(v - 0.55 - 0.03 * (u * 9.0).sin(), 0.006));
// Objects: (centre, radius, colour, edge softness).
let blobs = [
(0.28, 0.42, 0.17, [200.0, 80.0, 60.0], 0.004),
(0.66, 0.50, 0.22, [230.0, 190.0, 120.0], 0.012),
(0.80, 0.22, 0.08, [250.0, 240.0, 200.0], 0.02),
(0.45, 0.78, 0.12, [60.0, 50.0, 90.0], 0.003),
];
for (bx, by, r, col, edge) in blobs {
let d = ((u - bx).powi(2) + (v - by).powi(2)).sqrt();
// Shaded like a lit ball.
let lit = 1.0 - 0.45 * ((u - bx + 0.4 * r) / r).clamp(-1.0, 1.0).abs();
c = mix(c, col.map(|k| k * lit), soft(r - d, edge));
}
let texture = 6.0 * ((u * 61.0).sin() * (v * 47.0).cos()) + 4.0 * ((u + v) * 173.0).sin();
[0, 1, 2].map(|i| c[i] + texture + 5.0 * noise(x, y, 31 + i as u32))
}
/// Every input, in report order.
pub fn inputs() -> Vec<Input> {
let art = |name, class, shapes: Vec<(BezPath, [u8; 3])>, anchors, corners: Vec<Point>| Input {
name,
class,
shapes,
bg: WHITE,
anchors,
corners,
tone: None,
};
let tone = |name, f: Tone| Input { name, class: Class::Tone, shapes: vec![], bg: WHITE, anchors: 0, corners: vec![], tone: Some(f) };
let ink = |p: BezPath| vec![(p, INK)];
let mut ellipses = Ellipse::new((90.3, 99.6), (60.0, 30.0), 0.0).to_path(1e-3);
ellipses.extend(Ellipse::new((165.4, 190.2), (70.0, 25.0), 0.6).to_path(1e-3));
let badge = with_holes(RoundedRect::new(40.5, 56.5, 215.5, 199.5, 28.0).to_path(1e-3), &[Circle::new((128.2, 127.7), 36.0).to_path(1e-3)]);
// A "D": a straight stem joined smoothly to a bowl, with its counter.
let mut d = BezPath::new();
d.move_to((60.4, 60.3));
d.line_to((110.0, 60.3));
d.curve_to((170.0, 60.3), (200.0, 86.0), (200.0, 128.0));
d.curve_to((200.0, 170.0), (170.0, 195.7), (110.0, 195.7));
d.line_to((60.4, 195.7));
d.close_path();
let mut counter = BezPath::new();
counter.move_to((84.4, 84.3));
counter.line_to((108.0, 84.3));
counter.curve_to((150.0, 84.3), (172.0, 100.0), (172.0, 128.0));
counter.curve_to((172.0, 156.0), (150.0, 171.7), (108.0, 171.7));
counter.line_to((84.4, 171.7));
counter.close_path();
let d_corners = points(&[(60.4, 60.3), (60.4, 195.7), (84.4, 84.3), (84.4, 171.7)]);
let tri = [(40.3, 40.6), (120.7, 60.2), (60.4, 120.8)];
let square = regular(180.3, 80.6, 46.0, 4, 0.35);
let hexagon = regular(128.4, 185.3, 50.0, 6, 0.17);
let mut polygons = poly(&tri);
polygons.extend(poly(&square));
polygons.extend(poly(&hexagon));
let star: Vec<(f64, f64)> = (0..10)
.map(|i| {
let (a, r) = (-1.4 + std::f64::consts::PI * i as f64 / 5.0, if i % 2 == 0 { 100.0 } else { 40.0 });
(128.3 + r * a.cos(), 128.6 + r * a.sin())
})
.collect();
let a_outer = [(128.2, 40.4), (210.3, 216.5), (176.0, 216.5), (160.0, 176.4), (96.0, 176.4), (80.0, 216.5), (46.1, 216.5)];
let a_counter = [(128.2, 96.0), (148.3, 150.4), (108.1, 150.4)];
// Five lines 200 long, 0.5 to 5 wide, at small angles to the pixel grid.
let mut lines = BezPath::new();
for (i, (deg, width)) in [(0.0f64, 0.5), (6.0, 1.0), (-9.0, 2.0), (12.0, 3.0), (-4.0, 5.0)].into_iter().enumerate() {
let (hw, hl) = (width / 2.0, 100.0);
let mut strip = poly(&[(-hl, -hw), (hl, -hw), (hl, hw), (-hl, hw)]);
strip.apply_affine(Affine::translate((128.3, 36.4 + 46.0 * i as f64)) * Affine::rotate(deg.to_radians()));
lines.extend(strip);
}
let centre = Point::new(128.4, 128.3);
let touching = vec![(half_disc(centre, 90.0, true), [200, 40, 50]), (half_disc(centre, 90.0, false), [30, 90, 200])];
let bar = [(60.3, 180.4), (196.6, 180.4), (196.6, 206.7), (60.3, 206.7)];
let multicolor = vec![
(RoundedRect::new(30.4, 30.6, 226.3, 226.5, 30.0).to_path(1e-3), [20, 40, 90]),
(Circle::new((128.3, 104.6), 50.0).to_path(1e-3), [250, 200, 40]),
(Circle::new((128.3, 104.6), 20.0).to_path(1e-3), [240, 110, 30]),
(poly(&bar), [40, 160, 90]),
];
vec![
art("circle", Class::Shape, ink(Circle::new((128.3, 127.6), 80.4).to_path(1e-3)), 4, vec![]),
art("ellipses", Class::Shape, ink(ellipses), 8, vec![]),
art("badge with a hole", Class::Shape, ink(badge), 12, vec![]),
art("letter D", Class::Shape, ink(with_holes(d, &[counter])), 10, d_corners),
art("polygons", Class::Poly, ink(polygons), 13, points(&[&tri[..], &square, &hexagon].concat())),
art("star", Class::Poly, ink(poly(&star)), 10, points(&star)),
art("letter A", Class::Poly, ink(with_holes(poly(&a_outer), &[poly(&a_counter)])), 10, points(&[&a_outer[..], &a_counter].concat())),
art("lines", Class::Lines, ink(lines), 20, vec![]),
art("two touching halves", Class::Flat, touching, 6, vec![(centre.x, centre.y - 90.0).into(), (centre.x, centre.y + 90.0).into()]),
art("four-colour badge", Class::Flat, multicolor, 20, points(&bar)),
tone("gradient", gradient),
tone("photo-like", photo),
]
}
impl Input {
/// The input drawn `size` pixels square, anti-aliased.
pub fn draw(&self, size: u32) -> Raster {
let n = size as usize;
match self.tone {
Some(f) => Raster::from_fn(size, size, |x, y| {
let c = f((f64::from(x) + 0.5) / f64::from(size), (f64::from(y) + 0.5) / f64::from(size), x, y);
[c[0].round().clamp(0.0, 255.0) as u8, c[1].round().clamp(0.0, 255.0) as u8, c[2].round().clamp(0.0, 255.0) as u8, 255]
}),
None => raster::render_truth(&self.scaled(size), self.bg, n, n),
}
}
/// The art in the pixel coordinates of a `size`-pixel drawing.
pub fn scaled(&self, size: u32) -> Vec<(BezPath, [u8; 3])> {
let k = Affine::scale(f64::from(size) / 256.0);
self.shapes.iter().map(|(p, c)| (k * p.clone(), *c)).collect()
}
pub fn scaled_corners(&self, size: u32) -> Vec<Point> {
self.corners.iter().map(|p| (p.to_vec2() * (f64::from(size) / 256.0)).to_point()).collect()
}
}

View File

@ -0,0 +1,322 @@
//! Image Trace quality harness: traces images drawn from known vector art with every preset and
//! measures the result (see `docs/image-trace-quality.md`, which this writes).
//!
//! ```text
//! TRACE_QUALITY_REPORT=report.md cargo test --release -p vectorcraft-trace --test quality -- --ignored --nocapture
//! ```
//!
//! `TRACE_QUALITY_MP` (default `1,4,16`) sets the megapixel sizes of the cost table.
//!
//! A yardstick: `TRACE_QUALITY_IMAGES=dir` also writes the one-colour drawings there as PNG files, and
//! `TRACE_QUALITY_YARDSTICK=dir` measures the SVG files of the same names found there (what another
//! tracer made of those PNG files, run by hand as a black box) the same way.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
// The peak-memory measure needs a counting global allocator.
#![allow(unsafe_code)]
mod inputs;
mod measure;
mod raster;
use std::alloc::{GlobalAlloc, Layout, System};
use std::fmt::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use std::time::Instant;
use inputs::{CLASSES, Class, Input, VARIANTS, Variant, inputs};
use measure::{Metrics, measure};
use vectorcraft_trace::{Raster, TraceParams, TraceResult, TracedPath, presets, trace_within};
/// The engine's limit (`MAX_TRACE_ANCHORS`): a trace past it is refused.
const MAX_ANCHORS: usize = 2_000_000;
struct Counting;
static LIVE: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
fn grew(by: usize) {
PEAK.fetch_max(LIVE.fetch_add(by, Relaxed) + by, Relaxed);
}
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
grew(l.size());
unsafe { System.alloc(l) }
}
unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
grew(l.size());
unsafe { System.alloc_zeroed(l) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
LIVE.fetch_sub(l.size(), Relaxed);
unsafe { System.dealloc(p, l) }
}
unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
LIVE.fetch_sub(l.size(), Relaxed);
grew(new);
unsafe { System.realloc(p, l, new) }
}
}
#[global_allocator]
static ALLOC: Counting = Counting;
/// Whether `input` is what `preset` is meant for.
fn target(preset: &str, input: &Input) -> bool {
let classes: &[Class] = match preset {
"High Fidelity Photo" | "Low Fidelity Photo" | "Shades of Gray" => &[Class::Tone],
// Three colours cannot show the badge's five.
"3 Colors" => return input.name == "two touching halves",
"6 Colors" | "16 Colors" | "Flat Logo" => &[Class::Flat],
"Sketched Art" => &[Class::Shape, Class::Lines],
"Silhouettes" => &[Class::Shape],
"Line Art" => &[Class::Lines],
"Technical Drawing" => &[Class::Lines, Class::Poly],
_ => &[Class::Shape, Class::Poly],
};
classes.contains(&input.class)
}
/// One trace: which preset and input, and what was measured (`None`: the trace was refused).
struct Run {
preset: usize,
/// The input is one the preset is meant for.
target: bool,
class: Class,
variant: Variant,
metrics: Option<Metrics>,
}
/// Every (size, variant) an input is traced at: clean at three sizes, degraded at the middle one.
const DRAWINGS: [(u32, Variant); 6] =
[(128, Variant::Clean), (256, Variant::Clean), (512, Variant::Clean), (256, Variant::Blur), (256, Variant::Noise), (256, Variant::Scan)];
type Drawing<'a> = (&'a Input, u32, Variant, Raster);
fn draw_all(inputs: &[Input]) -> Vec<Drawing<'_>> {
inputs.iter().flat_map(|input| DRAWINGS.map(|(size, variant)| (input, size, variant, variant.apply(&input.draw(size))))).collect()
}
/// The file name (without extension) of a drawing given to or made by another tracer.
fn stem(input: &Input, size: u32, variant: Variant) -> String {
format!("{}-{size}-{}", input.name.replace(' ', "-"), variant.name())
}
/// The value of attribute `name` of an SVG element's text.
fn attr<'a>(element: &'a str, name: &str) -> Option<&'a str> {
let key = format!("{name}=\"");
let at = element.match_indices(&key).find(|(i, _)| element[..*i].ends_with(char::is_whitespace))?.0 + key.len();
Some(&element[at..at + element[at..].find('"')?])
}
/// The filled paths of an SVG file as a trace in black. Only what the yardstick's files use is
/// read: `path` elements with a `d`, and `matrix`, `translate` or `scale` transforms on them.
fn svg_trace(svg: &str) -> TraceResult {
let paths = svg.split("<path").skip(1).filter_map(|rest| {
let element = &rest[..rest.find('>')?];
let mut path = kurbo::BezPath::from_svg(attr(element, "d")?).ok()?;
if let Some(t) = attr(element, "transform") {
let (kind, args) = t.trim_end_matches(')').split_once('(').unwrap();
let v: Vec<f64> = args.split([',', ' ']).filter(|a| !a.is_empty()).map(|a| a.parse().unwrap()).collect();
path.apply_affine(match (kind, v.as_slice()) {
("matrix", &[a, b, c, d, e, f]) => kurbo::Affine::new([a, b, c, d, e, f]),
("translate", &[x, y]) => kurbo::Affine::translate((x, y)),
("scale", &[x, y]) => kurbo::Affine::scale_non_uniform(x, y),
("scale", &[k]) => kurbo::Affine::scale(k),
_ => panic!("unread transform {t}"),
});
}
Some(TracedPath { path: vectorcraft_geom::PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: None })
});
TraceResult { paths: paths.collect(), palette: vec![[0; 3]] }
}
fn run_all(presets: &[(&'static str, TraceParams)], drawn: &[Drawing]) -> Vec<Run> {
std::thread::scope(|s| {
let workers: Vec<_> = presets
.iter()
.enumerate()
.map(|(pi, (name, params))| {
s.spawn(move || {
drawn
.iter()
.map(|(input, size, variant, src)| {
let metrics = trace_within(src, params, MAX_ANCHORS).ok().map(|res| measure(input, *variant, *size, src, params, &res));
Run { preset: pi, target: target(name, input), class: input.class, variant: *variant, metrics }
})
.collect::<Vec<_>>()
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
})
}
/// The mean (or, with `max`, the largest) of `field` over `runs`, to `digits` places; `–` when it
/// applies to none of them.
fn stat(runs: &[&Metrics], field: impl Fn(&Metrics) -> f64, max: bool, digits: usize) -> String {
let v: Vec<f64> = runs.iter().map(|m| field(m)).filter(|v| !v.is_nan()).collect();
if v.is_empty() {
return "–".into();
}
let x = if max { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) } else { v.iter().sum::<f64>() / v.len() as f64 };
// `+ 0.0` turns a negative zero into zero.
if x.is_finite() { format!("{:.digits$}", x + 0.0) } else { "all".into() }
}
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
fn row(runs: &[&Run]) -> String {
let m: Vec<&Metrics> = runs.iter().filter_map(|r| r.metrics.as_ref()).collect();
if m.is_empty() {
return format!("| refused |{}", " |".repeat(15));
}
let cells = [
stat(&m, |m| m.delta, false, 2),
stat(&m, |m| m.edge_delta, false, 2),
stat(&m, |m| m.geo_mean, false, 3),
stat(&m, |m| m.geo_max, true, 2),
stat(&m, |m| m.miss_max, true, 2),
stat(&m, |m| m.paths, false, 1),
stat(&m, |m| m.anchors, false, 0),
stat(&m, |m| m.anchor_ratio, false, 2),
stat(&m, |m| m.lines, false, 0),
stat(&m, |m| m.corners, false, 0),
stat(&m, |m| m.kinks, false, 1),
stat(&m, |m| m.slivers, false, 1),
stat(&m, |m| m.sliver_area, false, 0),
stat(&m, |m| m.gap, false, 1),
stat(&m, |m| m.overlap, false, 1),
stat(&m, |m| m.specks, false, 1),
];
format!("| {} |", cells.join(" | "))
}
/// A cost measurement: seconds, paths, anchors and peak megabytes above the image itself.
fn cost(img: &Raster, params: &TraceParams) -> String {
let base = LIVE.load(Relaxed);
PEAK.store(base, Relaxed);
let start = Instant::now();
let res = trace_within(img, params, MAX_ANCHORS);
let secs = start.elapsed().as_secs_f64();
let mb = (PEAK.load(Relaxed) - base) as f64 / f64::from(1 << 20);
match res {
Ok(res) => format!("{secs:.2} s, {} paths, {} anchors, {mb:.0} MB", res.paths.len(), res.anchor_count()),
Err(e) => {
let why = e.to_string();
let why = if why.contains("anchor points") { "over 2,000,000 anchors" } else { "not flat art" };
format!("refused after {secs:.2} s ({why}), {mb:.0} MB")
}
}
}
fn report() -> String {
let presets = presets();
let inputs = inputs();
let drawn = draw_all(&inputs);
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines);
if let Ok(dir) = std::env::var("TRACE_QUALITY_IMAGES") {
for (input, size, variant, img) in drawn.iter().filter(one_colour) {
std::fs::write(format!("{dir}/{}.png", stem(input, *size, *variant)), img.encode_png()).unwrap();
}
}
let runs = run_all(&presets, &drawn);
let pick = |preset: usize, f: &dyn Fn(&Run) -> bool| runs.iter().filter(|r| r.preset == preset && f(r)).collect::<Vec<_>>();
let mut md = String::new();
md += "## 1. Each preset on the inputs it is meant for\n\nAll sizes and variants (clean, blurred, noisy, scanned) of those inputs.\n\n| preset | inputs ";
md += HEADER.replacen("\n|", "\n|:--|:--|", 1).as_str();
for (pi, (name, _)) in presets.iter().enumerate() {
let on: Vec<&str> = inputs.iter().filter(|i| target(name, i)).map(|i| i.name).collect();
let _ = writeln!(md, "| {name} | {} {}", on.join(", "), row(&pick(pi, &|r| r.target)));
}
md += "\n## 2. Each preset on each kind of input (clean, three sizes)\n\n| preset | inputs ";
md += HEADER.replacen("\n|", "\n|:--|:--|", 1).as_str();
for (pi, (name, _)) in presets.iter().enumerate() {
for class in CLASSES {
let _ = writeln!(md, "| {name} | {} {}", class.name(), row(&pick(pi, &|r| r.class == class && r.variant == Variant::Clean)));
}
}
md += "\n## 3. Each preset on its inputs, by variant (256 px)\n\n| preset | variant ";
md += HEADER.replacen("\n|", "\n|:--|:--|", 1).as_str();
for (pi, (name, _)) in presets.iter().enumerate() {
for variant in VARIANTS {
let _ = writeln!(md, "| {name} | {} {}", variant.name(), row(&pick(pi, &|r| r.target && r.variant == variant)));
}
}
if let Ok(dir) = std::env::var("TRACE_QUALITY_YARDSTICK") {
// Measured as a black-and-white trace without a white background.
let params = vectorcraft_trace::preset("Black and White Logo").unwrap();
let theirs: Vec<Run> = drawn
.iter()
.filter(one_colour)
.filter_map(|(input, size, variant, src)| {
let svg = std::fs::read_to_string(format!("{dir}/{}.svg", stem(input, *size, *variant))).ok()?;
let metrics = Some(measure(input, *variant, *size, src, &params, &svg_trace(&svg)));
Some(Run { preset: usize::MAX, target: true, class: input.class, variant: *variant, metrics })
})
.collect();
let _ = write!(md, "\n## 4. Yardstick\n\n{} traces by another tracer, measured the same way.\n\n| inputs | variant ", theirs.len());
md += HEADER.replacen("\n|", "\n|:--|:--|", 1).as_str();
for class in [Class::Shape, Class::Poly, Class::Lines] {
for variant in VARIANTS {
let rows: Vec<&Run> = theirs.iter().filter(|r| r.class == class && r.variant == variant).collect();
let _ = writeln!(md, "| {} | {} {}", class.name(), variant.name(), row(&rows));
}
}
}
// Cost, one trace at a time so that the memory peak is that trace's own.
let sizes: Vec<u32> = std::env::var("TRACE_QUALITY_MP")
.unwrap_or_else(|_| "1,4,16".into())
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.map(|mp| (mp.sqrt() * 1024.0).round() as u32)
.collect();
let (photo, badge) = (inputs.iter().find(|i| i.name == "photo-like").unwrap(), inputs.iter().find(|i| i.name == "four-colour badge").unwrap());
md += "\n## 5. Cost\n\nOne trace at a time on this machine (all cores); memory is the peak allocated above the image itself. A trace of more than 2,000,000 anchors is refused, as in the app.\n\n| preset | image | megapixels | result |\n|:--|:--|--:|:--|\n";
for size in sizes {
let n = size as usize;
let images = [("photo-like", photo.draw(size)), ("four-colour badge", raster::render(&badge.scaled(size), badge.bg, n, n))];
for (name, params) in &presets {
for (image, img) in &images {
let _ = writeln!(md, "| {name} | {image} | {:.0} | {} |", f64::from(size).powi(2) / f64::from(1 << 20), cost(img, params));
}
}
}
md
}
#[test]
#[ignore = "slow: measures every preset and writes the quality report (run with --release)"]
fn quality_report() {
let md = report();
println!("{md}");
if let Ok(path) = std::env::var("TRACE_QUALITY_REPORT") {
std::fs::write(path, md).unwrap();
}
}
/// The harness measures what it says: its rasteriser gives a disc its area, an image is at no
/// distance from itself, and a disc traced in black and white lies on the disc.
#[test]
fn harness_measures_a_disc() {
let disc = kurbo::Shape::to_path(&kurbo::Circle::new((40.3, 39.6), 25.0), 1e-3);
let area: f32 = raster::coverage(&disc, 80, 80).iter().sum();
assert!((f64::from(area) - std::f64::consts::PI * 625.0).abs() < 2.0, "{area}");
let inputs = inputs();
let circle = &inputs[0];
let src = circle.draw(128);
assert_eq!(measure::fidelity(&src, &src), (0.0, 0.0));
let params = vectorcraft_trace::preset("Black and White Logo").unwrap();
let res = trace_within(&src, &params, MAX_ANCHORS).unwrap();
let m = measure(circle, Variant::Clean, 128, &src, &params, &res);
assert_eq!(m.paths, 1.0);
assert!(m.geo_mean < 0.6 && m.geo_max < 2.0 && m.miss_max < 2.0, "{m:?}");
assert!(m.delta < 1.5 && m.gap.is_nan(), "{m:?}");
}

View File

@ -0,0 +1,284 @@
//! What the harness measures of one trace: fidelity, geometric accuracy, economy and artefacts.
use std::collections::HashMap;
use kurbo::{BezPath, ParamCurveArclen, Point, Shape, Stroke, StrokeOpts};
use vectorcraft_geom::SubPath;
use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, TracedPath};
use crate::inputs::{Class, Input, Variant};
use crate::raster::{coverage_into, polylines, render};
/// sRGB to Oklab (Björn Ottosson, "A perceptual color space for image processing", 2020).
pub fn oklab(c: [u8; 3]) -> [f64; 3] {
let lin = c.map(|v| {
let v = f64::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
});
let l = (0.412_221_470_8 * lin[0] + 0.536_332_536_3 * lin[1] + 0.051_445_992_9 * lin[2]).cbrt();
let m = (0.211_903_498_2 * lin[0] + 0.680_699_545_1 * lin[1] + 0.107_396_956_6 * lin[2]).cbrt();
let s = (0.088_302_461_9 * lin[0] + 0.281_718_837_6 * lin[1] + 0.629_978_700_5 * lin[2]).cbrt();
[
0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s,
1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s,
0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s,
]
}
/// Colour difference: Euclidean in Oklab, times 100 (black to white is 100).
pub fn delta(a: [u8; 3], b: [u8; 3]) -> f64 {
let (a, b) = (oklab(a), oklab(b));
100.0 * ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
fn rgb(img: &Raster, x: usize, y: usize) -> [u8; 3] {
let p = img.pixel(x as u32, y as u32);
[p[0], p[1], p[2]]
}
/// The mean colour difference between `src` and `out`, over every pixel and weighted by how
/// strong an edge `src` has at the pixel (its lightness gradient).
pub fn fidelity(src: &Raster, out: &Raster) -> (f64, f64) {
let (w, h) = (src.width as usize, src.height as usize);
let light: Vec<f64> = (0..w * h).map(|i| oklab(rgb(src, i % w, i / w))[0]).collect();
let (mut sum, mut edge_sum, mut edge_weight) = (0.0, 0.0, 0.0);
for y in 0..h {
for x in 0..w {
let d = delta(rgb(src, x, y), rgb(out, x, y));
let at = |x: usize, y: usize| light[y.min(h - 1) * w + x.min(w - 1)];
let g = (at(x + 1, y) - at(x.saturating_sub(1), y)).hypot(at(x, y + 1) - at(x, y.saturating_sub(1)));
sum += d;
edge_sum += d * g;
edge_weight += g;
}
}
(sum / (w * h) as f64, if edge_weight > 0.0 { edge_sum / edge_weight } else { 0.0 })
}
/// A traced path as the shape it paints: stroked paths become their stroke's outline.
fn painted(tp: &TracedPath) -> BezPath {
let bez = tp.path.to_bezpath();
match tp.stroke {
Some(width) => kurbo::stroke(bez, &Stroke::new(width), &StrokeOpts::default(), 0.05),
None => bez,
}
}
/// Outlines as short segments in a grid, for nearest-distance queries.
pub struct Outline {
segs: Vec<(Point, Point)>,
grid: HashMap<(i32, i32), Vec<u32>>,
}
const CELL: f64 = 4.0;
fn seg_dist(p: Point, a: Point, b: Point) -> f64 {
let ab = b - a;
let l2 = ab.hypot2();
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
p.distance(a + ab * t)
}
impl Outline {
/// The outlines of `paths`, cut into pieces at most half a pixel long. With `inside`, pieces
/// within a pixel of the edge of an image that size are left out (the background's frame).
pub fn new<'a>(paths: impl IntoIterator<Item = &'a BezPath>, inside: Option<f64>) -> Self {
let mut out = Self { segs: vec![], grid: HashMap::new() };
for path in paths {
for line in polylines(path, 0.02) {
for i in 0..line.len() {
let (a, b) = (line[i], line[(i + 1) % line.len()]);
let n = (a.distance(b) / 0.5).ceil().max(1.0) as usize;
for k in 0..n {
let (p, q) = (a.lerp(b, k as f64 / n as f64), a.lerp(b, (k + 1) as f64 / n as f64));
let m = p.midpoint(q);
if inside.is_some_and(|s| m.x < 1.0 || m.y < 1.0 || m.x > s - 1.0 || m.y > s - 1.0) || p == q {
continue;
}
out.grid.entry(cell(m)).or_default().push(out.segs.len() as u32);
out.segs.push((p, q));
}
}
}
}
out
}
pub fn is_empty(&self) -> bool {
self.segs.is_empty()
}
/// The distance from `p` to the nearest outline (infinite when there is none).
pub fn dist(&self, p: Point) -> f64 {
let (cx, cy) = cell(p);
let mut best = f64::INFINITY;
for dy in -1..=1 {
for dx in -1..=1 {
for &i in self.grid.get(&(cx + dx, cy + dy)).into_iter().flatten() {
let (a, b) = self.segs[i as usize];
best = best.min(seg_dist(p, a, b));
}
}
}
// Anything nearer than a found piece under 3.5 px away is in the cells looked at.
if best > 3.5 {
best = self.segs.iter().map(|&(a, b)| seg_dist(p, a, b)).fold(f64::INFINITY, f64::min);
}
best
}
/// (mean, max) distance from this outline's pieces to `other`.
pub fn distance_to(&self, other: &Outline) -> (f64, f64) {
if self.is_empty() {
return (f64::NAN, f64::NAN);
}
let (mut sum, mut max) = (0.0, 0.0f64);
for &(a, b) in &self.segs {
let d = other.dist(a.midpoint(b));
sum += d;
max = max.max(d);
}
(sum / self.segs.len() as f64, max)
}
}
fn cell(p: Point) -> (i32, i32) {
((p.x / CELL).floor() as i32, (p.y / CELL).floor() as i32)
}
/// The angle (degrees) the path turns by at anchor `i` of `sp`, or `None` at an open end.
fn turn(sp: &SubPath, i: usize) -> Option<f64> {
let n = sp.anchors.len();
if n < 2 || (!sp.closed && (i == 0 || i + 1 == n)) {
return None;
}
let (before, after) = (sp.segment((i + n - 1) % n), sp.segment(i));
let first = |v: [kurbo::Vec2; 3]| v.into_iter().find(|v| v.hypot() > 1e-9);
let a = first([before.p3 - before.p2, before.p3 - before.p1, before.p3 - before.p0])?;
let b = first([after.p1 - after.p0, after.p2 - after.p0, after.p3 - after.p0])?;
Some(a.cross(b).atan2(a.dot(b)).abs().to_degrees())
}
/// Everything measured of one trace. `NAN` marks what does not apply.
#[derive(Clone, Copy, Debug)]
pub struct Metrics {
/// Mean colour difference of the trace drawn again, against the source.
pub delta: f64,
/// The same, weighted by the source's edge strength.
pub edge_delta: f64,
/// Mean and largest distance (px) from the traced outlines to the true ones.
pub geo_mean: f64,
pub geo_max: f64,
/// Largest distance (px) from a true outline to the nearest traced one: what was missed.
pub miss_max: f64,
/// Paths and anchor points, the background's frame aside.
pub paths: f64,
pub anchors: f64,
/// Anchors per anchor of the art itself.
pub anchor_ratio: f64,
/// Share (%) of the outline length made of straight segments.
pub lines: f64,
/// Share (%) of the art's sharp corners that have a traced corner within 1.5 px.
pub corners: f64,
/// Anchors where the path turns by 10° or more away from any true corner.
pub kinks: f64,
/// Paths (and their area, px) in a colour the art doesn't have.
pub slivers: f64,
pub sliver_area: f64,
/// Area (px) no path covers, and area covered twice.
pub gap: f64,
pub overlap: f64,
/// Paths under 6 px of area.
pub specks: f64,
}
/// Measure `res`, the trace of `src` (`input` drawn `size` px square as `variant`) with `params`.
pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params: &TraceParams, res: &TraceResult) -> Metrics {
let n = size as usize;
let shapes: Vec<(BezPath, [u8; 3])> = res.paths.iter().map(|tp| (painted(tp), tp.color)).collect();
let (delta_mean, edge_delta) = fidelity(src, &render(&shapes, [255; 3], n, n));
// The background's frame: a path as large as the image.
let is_frame = |p: &BezPath| {
let b = p.bounding_box();
b.width() >= 0.98 * f64::from(size) && b.height() >= 0.98 * f64::from(size)
};
let art: Vec<(&TracedPath, &BezPath)> = res.paths.iter().zip(&shapes).filter(|(_, (p, _))| !is_frame(p)).map(|(tp, (p, _))| (tp, p)).collect();
let anchors: usize = art.iter().map(|(tp, _)| tp.path.anchor_count()).sum();
let nan = f64::NAN;
let (mut geo_mean, mut geo_max, mut miss_max, mut corners, mut kinks, mut slivers, mut sliver_area) = (nan, nan, nan, nan, nan, nan, nan);
if input.class != Class::Tone {
let truth_shapes = input.scaled(size);
let truth = Outline::new(truth_shapes.iter().map(|(p, _)| p), None);
let traced = Outline::new(shapes.iter().map(|(p, _)| p), Some(f64::from(size)));
(geo_mean, geo_max) = traced.distance_to(&truth);
miss_max = truth.distance_to(&traced).1;
let true_corners = input.scaled_corners(size);
let turns: Vec<(Point, f64)> = art
.iter()
.flat_map(|(tp, _)| &tp.path.subpaths)
.flat_map(|sp| (0..sp.anchors.len()).filter_map(move |i| Some((sp.anchors[i].p, turn(sp, i)?))))
.collect();
if !true_corners.is_empty() {
let kept = true_corners.iter().filter(|c| turns.iter().any(|(p, t)| *t >= 20.0 && p.distance(**c) <= 1.5)).count();
corners = 100.0 * kept as f64 / true_corners.len() as f64;
}
if input.class != Class::Lines {
kinks = turns.iter().filter(|(p, t)| *t >= 10.0 && true_corners.iter().all(|c| p.distance(*c) > 2.0)).count() as f64;
}
if params.mode != Mode::BlackAndWhite {
let colors: Vec<[u8; 3]> = truth_shapes.iter().map(|(_, c)| *c).chain([input.bg]).map(|c| variant.color(c)).collect();
let odd: Vec<f64> = shapes.iter().filter(|(_, c)| colors.iter().all(|t| delta(*t, *c) > 10.0)).map(|(p, _)| p.area().abs()).collect();
(slivers, sliver_area) = (odd.len() as f64, odd.iter().sum());
}
}
let (mut length, mut straight) = (0.0, 0.0);
for sp in art.iter().flat_map(|(tp, _)| &tp.path.subpaths) {
for i in 0..sp.segment_count() {
let len = sp.segment(i).arclen(0.05);
length += len;
// Straight is judged by the geometry: a line may be stored with handles along it.
let c = sp.segment(i);
if seg_dist(c.p1, c.p0, c.p3) < 0.02 && seg_dist(c.p2, c.p0, c.p3) < 0.02 {
straight += len;
}
}
}
// Coverage summed over the paths: under 1 is a gap, over 1 an overlap.
let (mut gap, mut overlap) = (nan, nan);
if !params.ignore_white {
let (mut sum, mut cov) = (vec![0.0f32; n * n], vec![0.0f32; n * n]);
for (p, _) in &shapes {
let (r0, r1) = coverage_into(p, n, n, &mut cov);
for i in r0 * n..r1 * n {
sum[i] += cov[i].min(1.0);
cov[i] = 0.0;
}
}
gap = sum.iter().map(|&s| f64::from((1.0 - s).max(0.0))).sum();
// Overlapping layers and Flat Logo's shapes are stacked on purpose.
if params.method == Method::Abutting && params.mode != Mode::Logo {
overlap = sum.iter().map(|&s| f64::from((s - 1.0).max(0.0))).sum();
}
}
Metrics {
delta: delta_mean,
edge_delta,
geo_mean,
geo_max,
miss_max,
paths: art.len() as f64,
anchors: anchors as f64,
anchor_ratio: if input.anchors > 0 { anchors as f64 / input.anchors as f64 } else { nan },
lines: if length > 0.0 { 100.0 * straight / length } else { nan },
corners,
kinks,
slivers,
sliver_area,
gap,
overlap,
specks: art.iter().filter(|(_, p)| p.area().abs() < 6.0).count() as f64,
}
}

View File

@ -0,0 +1,204 @@
//! A small scanline rasteriser and image filters for the quality harness: test inputs are drawn
//! from ground-truth vector shapes here, never loaded from files.
use kurbo::{BezPath, PathEl, Point};
use vectorcraft_trace::Raster;
/// The closed polylines of `path` (one per subpath), flattened within `tol`.
pub fn polylines(path: &BezPath, tol: f64) -> Vec<Vec<Point>> {
let (mut out, mut cur) = (vec![], vec![]);
kurbo::flatten(path.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(p);
}
PathEl::LineTo(p) => cur.push(p),
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
out
}
/// Adds the non-zero-winding coverage (0–1 per pixel) of `path` to `cov` (`w × h`); returns the
/// rows touched. Five sub-scanlines per row, exact along x.
pub fn coverage_into(path: &BezPath, w: usize, h: usize, cov: &mut [f32]) -> (usize, usize) {
const SS: usize = 5;
let mut edges = vec![];
for p in polylines(path, 0.02) {
for i in 0..p.len() {
let (a, b) = (p[i], p[(i + 1) % p.len()]);
if a.y < b.y {
edges.push((a, b, 1));
} else if b.y < a.y {
edges.push((b, a, -1));
}
}
}
let (lo, hi) = edges.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), e| (lo.min(e.0.y), hi.max(e.1.y)));
if edges.is_empty() {
return (0, 0);
}
let (r0, r1) = (lo.floor().clamp(0.0, h as f64) as usize, hi.ceil().clamp(0.0, h as f64) as usize);
let mut xs: Vec<(f64, i32)> = vec![];
for row in r0..r1 {
for k in 0..SS {
let y = row as f64 + (k as f64 + 0.5) / SS as f64;
xs.clear();
xs.extend(edges.iter().filter(|(a, b, _)| a.y <= y && y < b.y).map(|(a, b, d)| (a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y), *d)));
xs.sort_by(|p, q| p.0.total_cmp(&q.0));
let (mut wind, mut start) = (0, 0.0);
for &(x, d) in &xs {
let before = wind;
wind += d;
if before == 0 {
start = x;
} else if wind == 0 {
let (x0, x1) = (start.clamp(0.0, w as f64), x.clamp(0.0, w as f64));
for px in x0.floor() as usize..(x1.ceil() as usize).min(w) {
cov[row * w + px] += ((x1.min(px as f64 + 1.0) - x0.max(px as f64)).max(0.0) / SS as f64) as f32;
}
}
}
}
}
(r0, r1)
}
/// The coverage of `path` alone.
pub fn coverage(path: &BezPath, w: usize, h: usize) -> Vec<f32> {
let mut cov = vec![0.0; w * h];
coverage_into(path, w, h, &mut cov);
cov
}
/// Paints `scene` (bottom first) over `bg`, anti-aliased, the way a renderer shows a trace.
pub fn render(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize) -> Raster {
let mut buf = vec![bg.map(f32::from); w * h];
let mut cov = vec![0.0f32; w * h];
for (path, color) in scene {
let (r0, r1) = coverage_into(path, w, h, &mut cov);
for i in r0 * w..r1 * w {
let a = cov[i].min(1.0);
if a > 0.0 {
for ch in 0..3 {
buf[i][ch] += (f32::from(color[ch]) - buf[i][ch]) * a;
}
cov[i] = 0.0;
}
}
}
from_rgb(w, h, &buf)
}
/// Draws ground-truth art: each pixel is the average of `K × K` hard samples of the top-most
/// shape, so abutting shapes blend with each other on their shared edge and never with the
/// background behind them (which painting them one after another would let through).
pub fn render_truth(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize) -> Raster {
const K: usize = 6;
let (bw, bh) = (w * K, h * K);
let mut top = vec![bg; bw * bh];
let mut cov = vec![0.0f32; bw * bh];
for (path, color) in scene {
let mut big = path.clone();
big.apply_affine(kurbo::Affine::scale(K as f64));
let (r0, r1) = coverage_into(&big, bw, bh, &mut cov);
for i in r0 * bw..r1 * bw {
if cov[i] >= 0.5 {
top[i] = *color;
}
cov[i] = 0.0;
}
}
let mut buf = vec![[0.0f32; 3]; w * h];
for y in 0..bh {
for x in 0..bw {
for ch in 0..3 {
buf[(y / K) * w + x / K][ch] += f32::from(top[y * bw + x][ch]) / (K * K) as f32;
}
}
}
from_rgb(w, h, &buf)
}
fn from_rgb(w: usize, h: usize, buf: &[[f32; 3]]) -> Raster {
let mut rgba = Vec::with_capacity(w * h * 4);
for p in buf {
rgba.extend(p.map(|v| v.round().clamp(0.0, 255.0) as u8));
rgba.push(255);
}
Raster::new(w as u32, h as u32, rgba)
}
/// A deterministic hash of a pixel position and a salt, as a value in `-1.0..1.0`.
pub fn noise(x: u32, y: u32, salt: u32) -> f64 {
let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ salt.wrapping_mul(0xC2B2_AE3D);
v ^= v >> 15;
v = v.wrapping_mul(0x2C1B_3C6D);
v ^= v >> 12;
v = v.wrapping_mul(0x297A_2D39);
v ^= v >> 15;
f64::from(v) / f64::from(u32::MAX) * 2.0 - 1.0
}
fn map(img: &Raster, f: impl Fn(u32, u32, [f64; 3]) -> [f64; 3]) -> Raster {
Raster::from_fn(img.width, img.height, |x, y| {
let p = img.pixel(x, y);
let c = f(x, y, [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]);
[c[0].round().clamp(0.0, 255.0) as u8, c[1].round().clamp(0.0, 255.0) as u8, c[2].round().clamp(0.0, 255.0) as u8, 255]
})
}
/// A slight blur (the 1-4-6-4-1 binomial kernel each way, about one pixel of sigma): a resized or
/// re-sampled image.
pub fn blur(img: &Raster) -> Raster {
const K: [f64; 5] = [1.0, 4.0, 6.0, 4.0, 1.0];
let (w, h) = (img.width as i64, img.height as i64);
let pass = |src: &Raster, dx: i64, dy: i64| {
map(src, |x, y, _| {
let mut s = [0.0; 3];
for (k, wt) in K.iter().enumerate() {
let o = k as i64 - 2;
let p = src.pixel((i64::from(x) + o * dx).clamp(0, w - 1) as u32, (i64::from(y) + o * dy).clamp(0, h - 1) as u32);
for ch in 0..3 {
s[ch] += f64::from(p[ch]) * wt / 16.0;
}
}
s
})
};
pass(&pass(img, 1, 0), 0, 1)
}
/// Compression-like noise: an offset per 8 × 8 block plus ringing-sized noise per pixel.
pub fn noisy(img: &Raster) -> Raster {
map(img, |x, y, c| {
let block = 3.0 * noise(x / 8, y / 8, 7);
[0, 1, 2].map(|ch| c[ch] + block + 5.0 * noise(x, y, 11 + ch as u32))
})
}
/// The colour a scan gives a clean colour, before shading, blur and grain: ink on cream paper.
pub fn scan_color(c: [u8; 3]) -> [u8; 3] {
const PAPER: [f64; 3] = [236.0, 230.0, 214.0];
const INK: [f64; 3] = [34.0, 32.0, 44.0];
[0, 1, 2].map(|ch| (INK[ch] + (PAPER[ch] - INK[ch]) * f64::from(c[ch]) / 255.0).round() as u8)
}
/// Scanned ink on paper: the colours of [`scan_color`], uneven lighting, a slight blur and grain.
pub fn scanned(img: &Raster) -> Raster {
let (w, h) = (f64::from(img.width), f64::from(img.height));
let toned = map(img, |x, y, c| {
let s = scan_color(c.map(|v| v as u8));
let shade = 1.0 - 0.07 * (f64::from(x) / w) - 0.05 * ((f64::from(y) / h) * 3.0).sin().abs();
s.map(|v| f64::from(v) * shade)
});
map(&blur(&toned), |x, y, c| {
let g = 4.0 * noise(x, y, 23);
c.map(|v| v + g)
})
}

310
docs/image-trace-quality.md Normal file
View File

@ -0,0 +1,310 @@
# Image Trace quality
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** new (the harness and the baseline numbers)
What Image Trace (`crates/trace`) makes of images whose right answer is known, measured by a harness and
compared with the numbers before each change. Nothing here is a judgement by eye, and nothing is compared
with any other product except the open tracer named under [Yardstick](#4-yardstick).
## How it is measured
`crates/trace/tests/quality` draws its own inputs from vector art (no image files), traces each with every
built-in preset, and measures the result:
```text
TRACE_QUALITY_REPORT=report.md cargo test --release -p vectorcraft-trace --test quality -- --ignored
```
**Inputs** (drawn anti-aliased at 128, 256 and 512 px, and at 256 px also blurred, with compression-like
noise, and as a scan of ink on cream paper with uneven light and grain):
| kind | inputs |
|:--|:--|
| curved shapes | a circle; two ellipses; a rounded badge with a round hole; a letter D with its counter |
| polygons | a triangle, a turned square and a hexagon; a five-pointed star; a letter A with its counter |
| thin lines | five lines 0.5, 1, 2, 3 and 5 units wide (of a 256-unit square), a few degrees off the pixel grid |
| flat colour art | a disc of two halves in different colours that touch; a badge of four colours on white |
| gradient, photo | a smooth two-way gradient with a glow; a photo-like image (soft-edged shaded objects, texture, sensor noise) |
The letters are drawn by hand from lines and curves: the repository has no font to set them in.
**Columns**
| column | meaning |
|:--|:--|
| ΔE | the trace drawn again against the source: mean colour difference in Oklab × 100 (black to white is 100) |
| edge ΔE | the same, weighted by how strong an edge the source has at each pixel |
| off mean, off max | distance (px) from the traced outlines to the art's true outlines: wobble and staircase |
| missed max | largest distance (px) from a true outline to the nearest traced one: what was lost |
| paths, anchors | counts, the background's frame aside |
| × art | anchors per anchor point of the art itself (1.00 is as economical as the original) |
| lines % | share of the outline length made of straight segments (100 is right for polygons, 0 for a circle) |
| corners % | share of the art's sharp corners with a traced corner within 1.5 px |
| kinks | anchors where the outline turns by 10° or more away from any true corner |
| odd colours, odd px | paths (and their area) in a colour the art doesn't have: slivers along anti-aliased edges |
| gap px, overlap px | area no path covers, and area covered twice (abutting traces with a white background only) |
| specks | paths under 6 px of area |
Black-and-white presets trace black: on the scanned variant their ΔE includes the paper's and ink's own
colours. `–` means the measure does not apply; `all` that nothing was traced.
**These are the baseline numbers:** measured on main at `baf9693f`, before any change to the tracer.
## 1. Each preset on the inputs it is meant for
All sizes and variants (clean, blurred, noisy, scanned) of those inputs.
| preset | inputs | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks |
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
| Default | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 3.18 | 16.26 | 0.317 | 1.51 | 2.52 | 1.9 | 14 | 1.47 | 33 | 72 | 1.5 | – | – | 65.2 | 68.3 | 0.0 |
| High Fidelity Photo | gradient, photo-like | 2.29 | 2.61 | – | – | – | 3810.7 | 48993 | – | 83 | – | – | – | – | 0.0 | – | 1245.8 |
| Low Fidelity Photo | gradient, photo-like | 2.61 | 2.93 | – | – | – | 128.8 | 4013 | – | 42 | – | – | – | – | 0.0 | – | 0.0 |
| 3 Colors | two touching halves | 5.72 | 11.69 | 0.517 | 2.35 | 89.09 | 2.2 | 14 | 2.42 | 7 | 17 | 2.5 | 1.8 | 17987 | 118.3 | 93.3 | 0.0 |
| 6 Colors | two touching halves, four-colour badge | 0.86 | 8.60 | 5.062 | 83.10 | 30.11 | 24.2 | 310 | 34.87 | 53 | 98 | 249.0 | 2.8 | 318 | 213.4 | 206.7 | 0.0 |
| 16 Colors | two touching halves, four-colour badge | 0.81 | 8.70 | 5.914 | 86.21 | 4.32 | 81.1 | 1071 | 119.54 | 61 | 100 | 893.2 | 6.9 | 782 | 324.1 | 365.3 | 0.0 |
| Shades of Gray | gradient, photo-like | 9.22 | 9.08 | – | – | – | 92.6 | 1990 | – | 41 | – | – | – | – | 0.0 | – | 0.2 |
| Black and White Logo | circle, ellipses, badge with a hole, letter D, polygons, star, letter A | 3.15 | 14.98 | 0.276 | 1.76 | 2.53 | 1.4 | 32 | 3.80 | 90 | 79 | 15.2 | – | – | – | – | 0.0 |
| Sketched Art | circle, ellipses, badge with a hole, letter D, lines | 3.23 | 16.14 | 0.306 | 1.63 | 105.61 | 1.8 | 14 | 1.34 | 28 | 21 | 1.4 | – | – | – | – | 0.0 |
| Silhouettes | circle, ellipses, badge with a hole, letter D | 3.46 | 20.78 | 0.611 | 2.88 | 2.89 | 1.2 | 10 | 1.18 | 9 | 83 | 1.4 | – | – | – | – | 0.0 |
| Line Art | lines | 3.12 | 12.24 | 0.161 | 1.13 | 105.76 | 4.0 | 20 | 0.98 | 89 | – | – | – | – | – | – | 0.0 |
| Technical Drawing | polygons, star, letter A, lines | 3.04 | 12.82 | 0.203 | 1.37 | 105.76 | 4.5 | 31 | 2.19 | 100 | 83 | 6.4 | – | – | – | – | 0.2 |
| Flat Logo | two touching halves, four-colour badge | 0.48 | 3.14 | 3.588 | 88.50 | 0.78 | 81.0 | 405 | 45.08 | 37 | 85 | 276.5 | 0.0 | 0 | 15.2 | – | 69.0 |
## 2. Each preset on each kind of input (clean, three sizes)
| preset | inputs | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks |
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
| Default | curved shapes | 0.38 | 16.26 | 0.310 | 1.51 | 1.08 | 1.8 | 13 | 1.54 | 11 | 100 | 1.6 | – | – | 91.2 | 104.1 | 0.0 |
| Default | polygons | 0.37 | 14.70 | 0.281 | 1.34 | 1.68 | 2.0 | 14 | 1.26 | 79 | 91 | 0.3 | – | – | 14.1 | 19.4 | 0.0 |
| Default | thin lines | 0.80 | 10.48 | 0.180 | 1.09 | 105.61 | 3.3 | 15 | 0.73 | 90 | – | – | – | – | 89.4 | 26.0 | 0.0 |
| Default | flat colour art | 20.03 | 22.55 | 0.396 | 1.23 | 178.71 | 1.5 | 15 | 1.08 | 7 | 0 | 2.0 | – | – | 130.9 | 133.5 | 0.0 |
| Default | gradient, photo | 39.23 | 38.90 | – | – | – | 3.2 | 350 | – | 65 | – | – | – | – | 83.6 | 29.9 | 0.0 |
| High Fidelity Photo | curved shapes | 0.30 | 13.87 | 0.387 | 1.34 | 1.34 | 9.9 | 65 | 8.17 | 41 | 100 | 21.9 | 8.7 | 296 | 0.0 | – | 3.6 |
| High Fidelity Photo | polygons | 0.42 | 15.98 | 0.364 | 1.34 | 3.10 | 3.3 | 26 | 2.44 | 96 | 61 | 7.2 | 1.7 | 78 | 0.0 | – | 0.0 |
| High Fidelity Photo | thin lines | 1.11 | 17.32 | 0.341 | 0.97 | 45.35 | 13.0 | 49 | 2.43 | 89 | – | – | 6.7 | 532 | 0.0 | – | 3.0 |
| High Fidelity Photo | flat colour art | 0.21 | 6.75 | 0.420 | 1.33 | 1.33 | 48.2 | 219 | 17.76 | 66 | 100 | 108.7 | 23.2 | 1111 | 0.0 | – | 15.0 |
| High Fidelity Photo | gradient, photo | 2.38 | 2.72 | – | – | – | 2936.7 | 47288 | – | 87 | – | – | – | – | 0.0 | – | 898.7 |
| Low Fidelity Photo | curved shapes | 0.39 | 16.85 | 0.440 | 1.77 | 1.77 | 5.0 | 31 | 3.38 | 30 | 100 | 8.1 | 3.8 | 268 | 0.0 | – | 0.0 |
| Low Fidelity Photo | polygons | 0.51 | 18.23 | 0.411 | 1.54 | 3.10 | 3.3 | 23 | 2.13 | 71 | 55 | 4.8 | 1.7 | 79 | 0.0 | – | 0.0 |
| Low Fidelity Photo | thin lines | 1.13 | 17.25 | 0.363 | 1.03 | 45.35 | 5.0 | 24 | 1.22 | 100 | – | – | 1.3 | 498 | 0.0 | – | 0.0 |
| Low Fidelity Photo | flat colour art | 0.27 | 8.33 | 0.462 | 1.87 | 1.87 | 20.0 | 97 | 6.23 | 64 | 100 | 48.8 | 7.0 | 1033 | 0.0 | – | 0.0 |
| Low Fidelity Photo | gradient, photo | 2.70 | 3.06 | – | – | – | 96.2 | 4529 | – | 47 | – | – | – | – | 0.0 | – | 0.0 |
| 3 Colors | curved shapes | 0.80 | 18.57 | 0.400 | 1.65 | 1.54 | 2.8 | 21 | 2.27 | 21 | 100 | 4.8 | 1.0 | 107 | 103.0 | 93.3 | 0.0 |
| 3 Colors | polygons | 0.93 | 16.07 | 0.315 | 1.75 | 2.43 | 2.4 | 17 | 1.59 | 71 | 60 | 2.0 | 0.4 | 23 | 38.3 | 35.3 | 0.0 |
| 3 Colors | thin lines | 0.96 | 12.66 | 0.238 | 0.95 | 45.30 | 5.0 | 22 | 1.08 | 96 | – | – | 3.7 | 1109 | 54.9 | 65.7 | 0.0 |
| 3 Colors | flat colour art | 3.98 | 12.80 | 0.371 | 1.11 | 89.09 | 2.3 | 24 | 1.70 | 30 | 67 | 2.2 | 1.5 | 15185 | 126.7 | 107.6 | 0.0 |
| 3 Colors | gradient, photo | 7.87 | 7.94 | – | – | – | 4.3 | 222 | – | 51 | – | – | – | – | 101.7 | 66.6 | 0.0 |
| 6 Colors | curved shapes | 0.46 | 17.68 | 0.428 | 1.78 | 1.39 | 3.6 | 28 | 3.07 | 26 | 100 | 6.2 | 1.8 | 169 | 101.5 | 165.6 | 0.0 |
| 6 Colors | polygons | 0.45 | 16.94 | 0.369 | 1.56 | 2.44 | 3.1 | 22 | 2.08 | 61 | 48 | 3.4 | 1.1 | 51 | 82.6 | 36.0 | 0.0 |
| 6 Colors | thin lines | 1.18 | 16.03 | 0.314 | 0.95 | 45.35 | 4.0 | 18 | 0.88 | 78 | – | – | 1.7 | 458 | 68.8 | 20.0 | 0.0 |
| 6 Colors | flat colour art | 0.44 | 10.13 | 0.421 | 1.61 | 1.12 | 5.0 | 35 | 2.66 | 56 | 100 | 8.3 | 1.5 | 198 | 165.2 | 178.1 | 0.0 |
| 6 Colors | gradient, photo | 5.55 | 5.80 | – | – | – | 15.5 | 649 | – | 52 | – | – | – | – | 137.7 | 92.2 | 0.0 |
| 16 Colors | curved shapes | 0.48 | 17.72 | 0.431 | 1.40 | 1.39 | 4.2 | 32 | 3.68 | 30 | 100 | 8.4 | 2.5 | 170 | 101.1 | 166.8 | 0.0 |
| 16 Colors | polygons | 0.47 | 17.47 | 0.379 | 1.46 | 3.10 | 3.1 | 24 | 2.18 | 80 | 56 | 5.9 | 1.1 | 51 | 58.9 | 34.0 | 0.0 |
| 16 Colors | thin lines | 1.13 | 17.11 | 0.340 | 1.13 | 45.35 | 4.0 | 20 | 1.00 | 90 | – | – | 1.3 | 365 | 34.1 | 25.2 | 0.0 |
| 16 Colors | flat colour art | 0.37 | 9.54 | 0.453 | 1.35 | 1.26 | 7.5 | 57 | 4.29 | 61 | 100 | 13.0 | 3.0 | 382 | 206.1 | 288.5 | 0.0 |
| 16 Colors | gradient, photo | 3.30 | 3.71 | – | – | – | 72.0 | 2665 | – | 49 | – | – | – | – | 385.4 | 245.8 | 0.0 |
| Shades of Gray | curved shapes | 0.32 | 14.15 | 0.381 | 1.78 | 1.78 | 5.1 | 33 | 3.66 | 31 | 100 | 9.8 | 3.8 | 275 | 0.0 | – | 0.0 |
| Shades of Gray | polygons | 0.44 | 16.95 | 0.387 | 1.44 | 2.33 | 3.3 | 27 | 2.46 | 82 | 57 | 7.7 | 1.7 | 79 | 0.0 | – | 0.0 |
| Shades of Gray | thin lines | 1.11 | 16.92 | 0.354 | 1.03 | 45.35 | 5.0 | 25 | 1.23 | 100 | – | – | 1.3 | 499 | 0.0 | – | 0.0 |
| Shades of Gray | flat colour art | 7.36 | 13.20 | 0.394 | 1.60 | 178.73 | 13.3 | 66 | 4.35 | 41 | 50 | 29.3 | 12.7 | 38774 | 0.0 | – | 0.0 |
| Shades of Gray | gradient, photo | 9.74 | 9.66 | – | – | – | 82.2 | 1950 | – | 46 | – | – | – | – | 0.0 | – | 0.0 |
| Black and White Logo | curved shapes | 0.43 | 16.76 | 0.339 | 1.76 | 1.76 | 1.2 | 39 | 5.15 | 84 | 100 | 21.1 | – | – | – | – | 0.0 |
| Black and White Logo | polygons | 0.27 | 10.59 | 0.202 | 1.34 | 1.68 | 1.7 | 19 | 1.70 | 100 | 95 | 3.0 | – | – | – | – | 0.0 |
| Black and White Logo | thin lines | 0.70 | 8.44 | 0.160 | 0.82 | 77.30 | 10.3 | 45 | 2.23 | 100 | – | – | – | – | – | – | 0.0 |
| Black and White Logo | flat colour art | 19.99 | 20.78 | 0.428 | 1.66 | 178.38 | 1.0 | 40 | 3.92 | 88 | 0 | 24.7 | – | – | – | – | 0.0 |
| Black and White Logo | gradient, photo | 39.20 | 38.88 | – | – | – | 9.0 | 304 | – | 95 | – | – | – | – | – | – | 0.8 |
| Sketched Art | curved shapes | 0.36 | 15.95 | 0.305 | 1.32 | 1.32 | 1.2 | 12 | 1.42 | 11 | 33 | 1.3 | – | – | – | – | 0.0 |
| Sketched Art | polygons | 0.33 | 13.09 | 0.249 | 1.09 | 1.28 | 1.7 | 13 | 1.19 | 83 | 88 | 1.1 | – | – | – | – | 0.0 |
| Sketched Art | thin lines | 0.73 | 9.90 | 0.206 | 0.62 | 52.42 | 4.3 | 19 | 0.95 | 100 | – | – | – | – | – | – | 0.0 |
| Sketched Art | flat colour art | 20.35 | 23.39 | 0.392 | 1.51 | 179.03 | 1.5 | 13 | 0.94 | 7 | 0 | 2.0 | – | – | – | – | 0.0 |
| Sketched Art | gradient, photo | 45.68 | 45.57 | – | – | – | 1.0 | 115 | – | 35 | – | – | – | – | – | – | 0.0 |
| Silhouettes | curved shapes | 0.42 | 17.79 | 0.345 | 1.34 | 1.34 | 1.2 | 10 | 1.17 | 10 | 100 | 1.2 | – | – | – | – | 0.0 |
| Silhouettes | polygons | 0.47 | 17.79 | 0.353 | 1.62 | 2.53 | 1.7 | 12 | 1.10 | 51 | 66 | 0.6 | – | – | – | – | 0.0 |
| Silhouettes | thin lines | 1.20 | 16.32 | 0.316 | 0.89 | 18.29 | 5.0 | 29 | 1.47 | 87 | – | – | – | – | – | – | 0.0 |
| Silhouettes | flat colour art | 23.77 | 31.89 | 0.378 | 1.22 | 187.69 | 1.0 | 8 | 0.65 | 9 | 0 | 1.0 | – | – | – | – | 0.0 |
| Silhouettes | gradient, photo | 61.64 | 61.45 | – | – | – | 0.2 | 1 | – | 18 | – | – | – | – | – | – | 0.0 |
| Line Art | curved shapes | 0.33 | 13.45 | 0.259 | 1.05 | 1.05 | 1.2 | 21 | 2.77 | 22 | 100 | 2.1 | – | – | – | – | 0.0 |
| Line Art | polygons | 0.33 | 12.57 | 0.239 | 1.34 | 1.68 | 1.7 | 16 | 1.44 | 74 | 83 | 0.6 | – | – | – | – | 0.0 |
| Line Art | thin lines | 0.78 | 9.82 | 0.175 | 0.82 | 105.63 | 3.3 | 16 | 0.80 | 89 | – | – | – | – | – | – | 0.0 |
| Line Art | flat colour art | 19.97 | 20.23 | 0.352 | 1.06 | 178.81 | 1.0 | 23 | 1.94 | 21 | 0 | 4.2 | – | – | – | – | 0.0 |
| Line Art | gradient, photo | 39.20 | 38.81 | – | – | – | 7.7 | 260 | – | 65 | – | – | – | – | – | – | 0.0 |
| Technical Drawing | curved shapes | 0.43 | 16.76 | 0.339 | 1.76 | 1.76 | 1.2 | 39 | 5.15 | 84 | 100 | 21.1 | – | – | – | – | 0.0 |
| Technical Drawing | polygons | 0.28 | 10.79 | 0.206 | 1.34 | 1.68 | 1.7 | 19 | 1.69 | 100 | 95 | 3.0 | – | – | – | – | 0.0 |
| Technical Drawing | thin lines | 0.64 | 8.26 | 0.171 | 0.82 | 55.59 | 15.3 | 65 | 3.23 | 100 | – | – | – | – | – | – | 0.0 |
| Technical Drawing | flat colour art | 19.99 | 20.78 | 0.428 | 1.66 | 178.38 | 1.0 | 40 | 3.92 | 88 | 0 | 24.7 | – | – | – | – | 0.0 |
| Technical Drawing | gradient, photo | 39.19 | 38.87 | – | – | – | 20.0 | 360 | – | 96 | – | – | – | – | – | – | 9.0 |
| Flat Logo | curved shapes | 0.06 | 2.14 | 0.036 | 0.25 | 0.25 | 1.8 | 31 | 3.38 | 19 | 100 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 |
| Flat Logo | polygons | 0.09 | 3.44 | 0.060 | 0.35 | 1.03 | 2.0 | 35 | 3.17 | 67 | 94 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 |
| Flat Logo | thin lines | 1.17 | 11.78 | 0.050 | 0.49 | all | 3.0 | 189 | 9.45 | 28 | – | – | 0.0 | 0 | 5467.1 | – | 0.0 |
| Flat Logo | flat colour art | 0.07 | 1.53 | 0.065 | 0.31 | 0.27 | 3.0 | 36 | 3.51 | 47 | 100 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 |
| Flat Logo | gradient, photo | refused | | | | | | | | | | | | | | | |
## 3. Each preset on its inputs, by variant (256 px)
| preset | variant | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks |
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
| Default | clean | 0.37 | 15.59 | 0.297 | 1.51 | 1.68 | 1.9 | 14 | 1.42 | 40 | 93 | 1.0 | – | – | 58.1 | 67.8 | 0.0 |
| Default | blurred | 0.90 | 18.86 | 0.363 | 1.41 | 2.23 | 1.9 | 16 | 1.56 | 25 | 38 | 3.0 | – | – | 72.1 | 85.2 | 0.0 |
| Default | noisy | 1.97 | 10.79 | 0.293 | 1.48 | 1.68 | 1.9 | 13 | 1.35 | 33 | 83 | 0.7 | – | – | 67.2 | 61.8 | 0.0 |
| Default | scanned | 15.12 | 21.14 | 0.354 | 1.41 | 2.52 | 1.9 | 16 | 1.68 | 19 | 32 | 2.4 | – | – | 77.2 | 59.3 | 0.0 |
| High Fidelity Photo | clean | 2.38 | 2.72 | – | – | – | 2936.7 | 47288 | – | 87 | – | – | – | – | 0.0 | – | 898.7 |
| High Fidelity Photo | blurred | 1.98 | 2.47 | – | – | – | 1486.5 | 19972 | – | 88 | – | – | – | – | 0.0 | – | 274.5 |
| High Fidelity Photo | noisy | 2.78 | 3.04 | – | – | – | 7340.0 | 70154 | – | 74 | – | – | – | – | 0.0 | – | 2494.5 |
| High Fidelity Photo | scanned | 1.84 | 2.01 | – | – | – | 5227.5 | 61967 | – | 76 | – | – | – | – | 0.0 | – | 2009.5 |
| Low Fidelity Photo | clean | 2.70 | 3.06 | – | – | – | 96.2 | 4529 | – | 47 | – | – | – | – | 0.0 | – | 0.0 |
| Low Fidelity Photo | blurred | 2.68 | 3.17 | – | – | – | 151.5 | 2258 | – | 47 | – | – | – | – | 0.0 | – | 0.0 |
| Low Fidelity Photo | noisy | 2.78 | 2.99 | – | – | – | 106.5 | 3818 | – | 30 | – | – | – | – | 0.0 | – | 0.0 |
| Low Fidelity Photo | scanned | 2.08 | 2.21 | – | – | – | 226.0 | 4414 | – | 31 | – | – | – | – | 0.0 | – | 0.0 |
| 3 Colors | clean | 4.71 | 12.75 | 0.362 | 1.11 | 89.09 | 1.3 | 9 | 1.50 | 13 | 33 | 0.0 | 0.7 | 10502 | 112.5 | 95.5 | 0.0 |
| 3 Colors | blurred | 6.97 | 11.97 | 0.858 | 2.35 | 87.85 | 4.0 | 22 | 3.67 | 1 | 0 | 8.0 | 4.0 | 25530 | 134.6 | 100.1 | 0.0 |
| 3 Colors | noisy | 7.30 | 12.66 | 0.395 | 0.95 | 89.02 | 1.0 | 8 | 1.33 | 0 | 0 | 0.0 | 1.0 | 25239 | 71.4 | 60.4 | 0.0 |
| 3 Colors | scanned | 5.92 | 7.24 | 0.761 | 1.75 | 88.20 | 4.0 | 30 | 5.00 | 1 | 0 | 7.0 | 4.0 | 25648 | 166.3 | 112.5 | 0.0 |
| 6 Colors | clean | 0.44 | 10.13 | 0.421 | 1.61 | 1.12 | 5.0 | 35 | 2.66 | 56 | 100 | 8.3 | 1.5 | 198 | 165.2 | 178.1 | 0.0 |
| 6 Colors | blurred | 0.89 | 9.39 | 0.695 | 2.33 | 1.98 | 10.0 | 108 | 9.68 | 41 | 100 | 26.5 | 6.0 | 800 | 125.1 | 197.9 | 0.0 |
| 6 Colors | noisy | 1.39 | 6.91 | 19.919 | 79.80 | 30.11 | 99.0 | 1218 | 146.12 | 55 | 100 | 1124.5 | 3.0 | 363 | 445.5 | 296.0 | 0.0 |
| 6 Colors | scanned | 1.53 | 4.87 | 8.493 | 83.10 | 1.39 | 21.5 | 428 | 45.40 | 53 | 88 | 318.0 | 3.0 | 148 | 214.3 | 212.0 | 0.0 |
| 16 Colors | clean | 0.37 | 9.54 | 0.453 | 1.35 | 1.26 | 7.5 | 57 | 4.29 | 61 | 100 | 13.0 | 3.0 | 382 | 206.1 | 288.5 | 0.0 |
| 16 Colors | blurred | 0.66 | 10.88 | 1.030 | 2.74 | 2.36 | 26.5 | 182 | 15.72 | 86 | 100 | 117.0 | 10.0 | 1168 | 177.5 | 323.7 | 0.0 |
| 16 Colors | noisy | 1.50 | 8.23 | 16.918 | 81.85 | 4.32 | 238.5 | 2540 | 312.41 | 45 | 100 | 2187.0 | 4.5 | 770 | 596.1 | 426.6 | 0.0 |
| 16 Colors | scanned | 1.59 | 4.44 | 16.175 | 86.21 | 3.61 | 199.0 | 3534 | 376.23 | 51 | 100 | 3016.0 | 18.0 | 1609 | 552.9 | 575.6 | 0.0 |
| Shades of Gray | clean | 9.74 | 9.66 | – | – | – | 82.2 | 1950 | – | 46 | – | – | – | – | 0.0 | – | 0.0 |
| Shades of Gray | blurred | 9.69 | 9.42 | – | – | – | 62.0 | 1002 | – | 46 | – | – | – | – | 0.0 | – | 0.0 |
| Shades of Gray | noisy | 9.79 | 9.54 | – | – | – | 132.0 | 2312 | – | 34 | – | – | – | – | 0.0 | – | 1.5 |
| Shades of Gray | scanned | 6.63 | 6.56 | – | – | – | 115.0 | 2778 | – | 29 | – | – | – | – | 0.0 | – | 0.0 |
| Black and White Logo | clean | 0.36 | 14.11 | 0.280 | 1.76 | 1.76 | 1.4 | 30 | 3.67 | 91 | 97 | 13.3 | – | – | – | – | 0.0 |
| Black and White Logo | blurred | 0.87 | 17.99 | 0.317 | 1.43 | 2.23 | 1.4 | 32 | 3.85 | 86 | 40 | 17.0 | – | – | – | – | 0.0 |
| Black and White Logo | noisy | 1.92 | 9.17 | 0.233 | 1.55 | 1.68 | 1.4 | 32 | 3.87 | 92 | 96 | 14.3 | – | – | – | – | 0.0 |
| Black and White Logo | scanned | 15.07 | 20.40 | 0.265 | 1.65 | 2.53 | 1.4 | 36 | 4.09 | 89 | 47 | 20.0 | – | – | – | – | 0.0 |
| Sketched Art | clean | 0.43 | 14.74 | 0.285 | 1.32 | 52.42 | 1.9 | 14 | 1.33 | 29 | 33 | 1.3 | – | – | – | – | 0.0 |
| Sketched Art | blurred | 1.06 | 19.54 | 0.312 | 1.29 | 105.61 | 1.6 | 14 | 1.30 | 26 | 0 | 1.5 | – | – | – | – | 0.0 |
| Sketched Art | noisy | 2.00 | 11.50 | 0.309 | 1.06 | 1.06 | 2.0 | 13 | 1.26 | 27 | 25 | 1.5 | – | – | – | – | 0.0 |
| Sketched Art | scanned | 15.05 | 21.60 | 0.361 | 1.63 | 105.46 | 1.6 | 15 | 1.53 | 27 | 0 | 1.2 | – | – | – | – | 0.0 |
| Silhouettes | clean | 0.42 | 17.79 | 0.345 | 1.34 | 1.34 | 1.2 | 10 | 1.17 | 10 | 100 | 1.2 | – | – | – | – | 0.0 |
| Silhouettes | blurred | 1.23 | 28.03 | 0.754 | 1.87 | 1.90 | 1.2 | 8 | 0.97 | 7 | 50 | 1.5 | – | – | – | – | 0.0 |
| Silhouettes | noisy | 2.23 | 13.42 | 0.384 | 1.17 | 1.17 | 1.2 | 9 | 1.09 | 7 | 100 | 1.5 | – | – | – | – | 0.0 |
| Silhouettes | scanned | 16.06 | 29.87 | 1.494 | 2.88 | 2.89 | 1.2 | 12 | 1.52 | 12 | 50 | 2.0 | – | – | – | – | 0.0 |
| Line Art | clean | 0.78 | 9.82 | 0.175 | 0.82 | 105.63 | 3.3 | 16 | 0.80 | 89 | – | – | – | – | – | – | 0.0 |
| Line Art | blurred | 1.59 | 16.64 | 0.128 | 1.13 | 105.76 | 3.0 | 17 | 0.85 | 94 | – | – | – | – | – | – | 0.0 |
| Line Art | noisy | 1.22 | 7.62 | 0.137 | 0.91 | 56.14 | 8.0 | 36 | 1.80 | 97 | – | – | – | – | – | – | 0.0 |
| Line Art | scanned | 13.55 | 19.72 | 0.173 | 1.13 | 105.76 | 3.0 | 17 | 0.85 | 78 | – | – | – | – | – | – | 0.0 |
| Technical Drawing | clean | 0.37 | 10.16 | 0.197 | 1.34 | 55.59 | 5.1 | 30 | 2.07 | 100 | 95 | 3.0 | – | – | – | – | 0.0 |
| Technical Drawing | blurred | 1.07 | 17.47 | 0.202 | 1.37 | 105.76 | 2.0 | 24 | 1.98 | 100 | 54 | 13.7 | – | – | – | – | 0.0 |
| Technical Drawing | noisy | 1.58 | 8.47 | 0.195 | 1.23 | 31.30 | 7.8 | 43 | 2.79 | 100 | 94 | 2.7 | – | – | – | – | 1.0 |
| Technical Drawing | scanned | 14.47 | 20.51 | 0.228 | 1.36 | 105.76 | 2.0 | 26 | 2.17 | 100 | 62 | 13.3 | – | – | – | – | 0.0 |
| Flat Logo | clean | 0.07 | 1.53 | 0.065 | 0.31 | 0.27 | 3.0 | 36 | 3.51 | 47 | 100 | 0.0 | 0.0 | 0 | 6.8 | – | 0.0 |
| Flat Logo | blurred | 0.54 | 8.33 | 0.052 | 0.62 | 0.78 | 3.0 | 28 | 1.78 | 43 | 50 | 0.0 | 0.0 | 0 | 5.8 | – | 0.0 |
| Flat Logo | noisy | 0.81 | 1.77 | 0.076 | 0.39 | 0.32 | 3.5 | 44 | 2.63 | 31 | 100 | 1.0 | 0.0 | 0 | 6.9 | – | 0.5 |
| Flat Logo | scanned | 1.31 | 4.15 | 21.204 | 88.50 | 0.66 | 470.5 | 2251 | 255.53 | 11 | 62 | 1658.0 | 0.0 | 0 | 58.0 | – | 413.5 |
## 4. Yardstick
Inkscape 1.4.2's Trace Bitmap, run as a black box through its `object-trace` action (two scans, background
removed, its other settings as they come) on the same one-colour drawings, and its SVG output measured by
the same code. Compare with the black-and-white presets on the same kinds of input in section 2 and 3.
| inputs | variant | ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks |
|:--|:--|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
| curved shapes | clean | 0.25 | 11.05 | 0.198 | 0.97 | 0.97 | 1.0 | 15 | 1.98 | 11 | 83 | 1.8 | – | – | – | – | 0.0 |
| curved shapes | blurred | 0.79 | 16.57 | 0.182 | 0.86 | 1.14 | 1.0 | 15 | 1.84 | 6 | 0 | 1.8 | – | – | – | – | 0.0 |
| curved shapes | noisy | 2.01 | 8.14 | 0.191 | 0.93 | 0.93 | 1.0 | 17 | 2.06 | 10 | 75 | 2.8 | – | – | – | – | 0.0 |
| curved shapes | scanned | 15.31 | 20.22 | 0.191 | 0.97 | 1.14 | 1.0 | 16 | 1.98 | 7 | 0 | 2.0 | – | – | – | – | 0.0 |
| polygons | clean | 0.16 | 6.45 | 0.124 | 1.26 | 2.36 | 1.0 | 21 | 1.93 | 27 | 34 | 0.0 | – | – | – | – | 0.0 |
| polygons | blurred | 0.90 | 17.54 | 0.190 | 1.20 | 2.56 | 1.0 | 19 | 1.75 | 0 | 0 | 0.0 | – | – | – | – | 0.0 |
| polygons | noisy | 1.62 | 6.05 | 0.119 | 1.22 | 2.32 | 1.0 | 22 | 2.01 | 29 | 19 | 0.0 | – | – | – | – | 0.0 |
| polygons | scanned | 14.75 | 20.30 | 0.170 | 1.49 | 2.55 | 1.0 | 21 | 1.90 | 4 | 3 | 0.0 | – | – | – | – | 0.0 |
| thin lines | clean | 0.44 | 6.05 | 0.109 | 0.98 | 76.81 | 1.0 | 34 | 1.72 | 2 | – | – | – | – | – | – | 0.0 |
| thin lines | blurred | 1.72 | 18.80 | 0.254 | 0.81 | 106.00 | 1.0 | 18 | 0.90 | 0 | – | – | – | – | – | – | 0.0 |
| thin lines | noisy | 1.02 | 6.25 | 0.112 | 1.00 | 37.89 | 1.0 | 25 | 1.25 | 0 | – | – | – | – | – | – | 0.0 |
| thin lines | scanned | 28.94 | 46.83 | 12.147 | 43.11 | 20.79 | 1.0 | 2763 | 138.15 | 2 | – | – | – | – | – | – | 0.0 |
## 5. Cost
One trace at a time on this machine (all cores); memory is the peak allocated above the image itself. A trace of more than 2,000,000 anchors is refused, as in the app.
| preset | image | megapixels | result |
|:--|:--|--:|:--|
| Default | photo-like | 1 | 0.05 s, 29 paths, 3831 anchors, 15 MB |
| Default | four-colour badge | 1 | 0.03 s, 3 paths, 70 anchors, 15 MB |
| High Fidelity Photo | photo-like | 1 | 0.48 s, 51389 paths, 745118 anchors, 195 MB |
| High Fidelity Photo | four-colour badge | 1 | 0.05 s, 147 paths, 657 anchors, 81 MB |
| Low Fidelity Photo | photo-like | 1 | 0.08 s, 612 paths, 47559 anchors, 135 MB |
| Low Fidelity Photo | four-colour badge | 1 | 0.06 s, 85 paths, 354 anchors, 93 MB |
| 3 Colors | photo-like | 1 | 0.05 s, 9 paths, 2481 anchors, 22 MB |
| 3 Colors | four-colour badge | 1 | 0.04 s, 6 paths, 87 anchors, 21 MB |
| 6 Colors | photo-like | 1 | 0.05 s, 53 paths, 7197 anchors, 38 MB |
| 6 Colors | four-colour badge | 1 | 0.04 s, 7 paths, 112 anchors, 39 MB |
| 16 Colors | photo-like | 1 | 0.07 s, 398 paths, 28412 anchors, 99 MB |
| 16 Colors | four-colour badge | 1 | 0.04 s, 16 paths, 170 anchors, 90 MB |
| Shades of Gray | photo-like | 1 | 0.06 s, 449 paths, 18285 anchors, 55 MB |
| Shades of Gray | four-colour badge | 1 | 0.04 s, 36 paths, 168 anchors, 50 MB |
| Black and White Logo | photo-like | 1 | 0.03 s, 71 paths, 3032 anchors, 9 MB |
| Black and White Logo | four-colour badge | 1 | 0.02 s, 1 paths, 83 anchors, 8 MB |
| Sketched Art | photo-like | 1 | 0.03 s, 5 paths, 1411 anchors, 9 MB |
| Sketched Art | four-colour badge | 1 | 0.02 s, 2 paths, 40 anchors, 8 MB |
| Silhouettes | photo-like | 1 | 0.03 s, 2 paths, 391 anchors, 11 MB |
| Silhouettes | four-colour badge | 1 | 0.02 s, 1 paths, 23 anchors, 9 MB |
| Line Art | photo-like | 1 | 0.03 s, 54 paths, 2545 anchors, 9 MB |
| Line Art | four-colour badge | 1 | 0.02 s, 1 paths, 56 anchors, 8 MB |
| Technical Drawing | photo-like | 1 | 0.03 s, 221 paths, 4083 anchors, 9 MB |
| Technical Drawing | four-colour badge | 1 | 0.02 s, 1 paths, 83 anchors, 8 MB |
| Flat Logo | photo-like | 1 | refused after 0.10 s (not flat art), 40 MB |
| Flat Logo | four-colour badge | 1 | 0.66 s, 5 paths, 78 anchors, 146 MB |
| Default | photo-like | 4 | 0.17 s, 48 paths, 10545 anchors, 60 MB |
| Default | four-colour badge | 4 | 0.08 s, 3 paths, 77 anchors, 64 MB |
| High Fidelity Photo | photo-like | 4 | refused after 1.24 s (over 2,000,000 anchors), 859 MB |
| High Fidelity Photo | four-colour badge | 4 | 0.21 s, 225 paths, 991 anchors, 529 MB |
| Low Fidelity Photo | photo-like | 4 | 0.25 s, 1499 paths, 100595 anchors, 485 MB |
| Low Fidelity Photo | four-colour badge | 4 | 0.21 s, 88 paths, 408 anchors, 361 MB |
| 3 Colors | photo-like | 4 | 0.18 s, 22 paths, 7209 anchors, 88 MB |
| 3 Colors | four-colour badge | 4 | 0.12 s, 5 paths, 101 anchors, 84 MB |
| 6 Colors | photo-like | 4 | 0.15 s, 100 paths, 18024 anchors, 152 MB |
| 6 Colors | four-colour badge | 4 | 0.12 s, 7 paths, 127 anchors, 152 MB |
| 16 Colors | photo-like | 4 | 0.18 s, 1236 paths, 75609 anchors, 390 MB |
| 16 Colors | four-colour badge | 4 | 0.15 s, 17 paths, 220 anchors, 315 MB |
| Shades of Gray | photo-like | 4 | 0.19 s, 1289 paths, 48985 anchors, 208 MB |
| Shades of Gray | four-colour badge | 4 | 0.18 s, 41 paths, 211 anchors, 200 MB |
| Black and White Logo | photo-like | 4 | 0.18 s, 208 paths, 9008 anchors, 36 MB |
| Black and White Logo | four-colour badge | 4 | 0.09 s, 1 paths, 142 anchors, 36 MB |
| Sketched Art | photo-like | 4 | 0.17 s, 9 paths, 3366 anchors, 36 MB |
| Sketched Art | four-colour badge | 4 | 0.08 s, 2 paths, 45 anchors, 36 MB |
| Silhouettes | photo-like | 4 | 0.20 s, 2 paths, 1041 anchors, 44 MB |
| Silhouettes | four-colour badge | 4 | 0.08 s, 1 paths, 28 anchors, 36 MB |
| Line Art | photo-like | 4 | 0.16 s, 127 paths, 7334 anchors, 36 MB |
| Line Art | four-colour badge | 4 | 0.08 s, 1 paths, 73 anchors, 36 MB |
| Technical Drawing | photo-like | 4 | 0.15 s, 931 paths, 14515 anchors, 36 MB |
| Technical Drawing | four-colour badge | 4 | 0.08 s, 1 paths, 142 anchors, 36 MB |
| Flat Logo | photo-like | 4 | refused after 0.10 s (not flat art), 40 MB |
| Flat Logo | four-colour badge | 4 | 0.66 s, 5 paths, 69 anchors, 146 MB |
| Default | photo-like | 16 | 1.00 s, 127 paths, 26017 anchors, 256 MB |
| Default | four-colour badge | 16 | 0.44 s, 3 paths, 88 anchors, 240 MB |
| High Fidelity Photo | photo-like | 16 | refused after 3.22 s (over 2,000,000 anchors), 3326 MB |
| High Fidelity Photo | four-colour badge | 16 | 1.10 s, 438 paths, 1733 anchors, 2392 MB |
| Low Fidelity Photo | photo-like | 16 | 1.18 s, 8558 paths, 345957 anchors, 1873 MB |
| Low Fidelity Photo | four-colour badge | 16 | 1.07 s, 105 paths, 449 anchors, 1540 MB |
| 3 Colors | photo-like | 16 | 1.05 s, 87 paths, 18248 anchors, 352 MB |
| 3 Colors | four-colour badge | 16 | 0.65 s, 6 paths, 110 anchors, 336 MB |
| 6 Colors | photo-like | 16 | 0.90 s, 476 paths, 47937 anchors, 600 MB |
| 6 Colors | four-colour badge | 16 | 0.63 s, 11 paths, 160 anchors, 588 MB |
| 16 Colors | photo-like | 16 | 0.86 s, 7432 paths, 236585 anchors, 1574 MB |
| 16 Colors | four-colour badge | 16 | 0.83 s, 23 paths, 231 anchors, 1348 MB |
| Shades of Gray | photo-like | 16 | 0.93 s, 6007 paths, 147606 anchors, 805 MB |
| Shades of Gray | four-colour badge | 16 | 0.97 s, 41 paths, 224 anchors, 676 MB |
| Black and White Logo | photo-like | 16 | 0.99 s, 1128 paths, 30289 anchors, 144 MB |
| Black and White Logo | four-colour badge | 16 | 0.43 s, 1 paths, 193 anchors, 128 MB |
| Sketched Art | photo-like | 16 | 1.02 s, 15 paths, 8027 anchors, 144 MB |
| Sketched Art | four-colour badge | 16 | 0.42 s, 2 paths, 52 anchors, 128 MB |
| Silhouettes | photo-like | 16 | 1.29 s, 3 paths, 2442 anchors, 176 MB |
| Silhouettes | four-colour badge | 16 | 0.50 s, 1 paths, 29 anchors, 144 MB |
| Line Art | photo-like | 16 | 0.99 s, 713 paths, 23577 anchors, 144 MB |
| Line Art | four-colour badge | 16 | 0.41 s, 1 paths, 81 anchors, 128 MB |
| Technical Drawing | photo-like | 16 | 1.02 s, 4578 paths, 55463 anchors, 144 MB |
| Technical Drawing | four-colour badge | 16 | 0.43 s, 1 paths, 193 anchors, 128 MB |
| Flat Logo | photo-like | 16 | refused after 0.12 s (not flat art), 40 MB |
| Flat Logo | four-colour badge | 16 | 0.68 s, 5 paths, 67 anchors, 146 MB |
## Revision history
| date | change |
|:--|:--|
| 2026-10-12 | The harness and the baseline: every preset measured before any change to the tracer |