From 182a2cda4bc3a3dcd5402f7796b4c6787196a0b6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 23:56:19 +0700 Subject: [PATCH] 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. --- crates/trace/tests/quality/inputs.rs | 269 +++++++++++++++++++++ crates/trace/tests/quality/main.rs | 322 ++++++++++++++++++++++++++ crates/trace/tests/quality/measure.rs | 284 +++++++++++++++++++++++ crates/trace/tests/quality/raster.rs | 204 ++++++++++++++++ docs/image-trace-quality.md | 310 +++++++++++++++++++++++++ 5 files changed, 1389 insertions(+) create mode 100644 crates/trace/tests/quality/inputs.rs create mode 100644 crates/trace/tests/quality/main.rs create mode 100644 crates/trace/tests/quality/measure.rs create mode 100644 crates/trace/tests/quality/raster.rs create mode 100644 docs/image-trace-quality.md diff --git a/crates/trace/tests/quality/inputs.rs b/crates/trace/tests/quality/inputs.rs new file mode 100644 index 00000000..68ab1e84 --- /dev/null +++ b/crates/trace/tests/quality/inputs.rs @@ -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, + tone: Option, +} + +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 { + 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 { + let art = |name, class, shapes: Vec<(BezPath, [u8; 3])>, anchors, corners: Vec| 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 { + self.corners.iter().map(|p| (p.to_vec2() * (f64::from(size) / 256.0)).to_point()).collect() + } +} diff --git a/crates/trace/tests/quality/main.rs b/crates/trace/tests/quality/main.rs new file mode 100644 index 00000000..a4460971 --- /dev/null +++ b/crates/trace/tests/quality/main.rs @@ -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, +} + +/// 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> { + 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("')?]; + 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 = 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 { + 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::>() + }) + }) + .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 = 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::() / 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::>(); + 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 = 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, ¶ms, &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 = std::env::var("TRACE_QUALITY_MP") + .unwrap_or_else(|_| "1,4,16".into()) + .split(',') + .filter_map(|s| s.trim().parse::().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, ¶ms, MAX_ANCHORS).unwrap(); + let m = measure(circle, Variant::Clean, 128, &src, ¶ms, &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:?}"); +} diff --git a/crates/trace/tests/quality/measure.rs b/crates/trace/tests/quality/measure.rs new file mode 100644 index 00000000..f3d49646 --- /dev/null +++ b/crates/trace/tests/quality/measure.rs @@ -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 = (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>, +} + +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, inside: Option) -> 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 { + 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 = 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, + } +} diff --git a/crates/trace/tests/quality/raster.rs b/crates/trace/tests/quality/raster.rs new file mode 100644 index 00000000..e0e10859 --- /dev/null +++ b/crates/trace/tests/quality/raster.rs @@ -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> { + 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 { + 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) + }) +} diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md new file mode 100644 index 00000000..fb10a8ca --- /dev/null +++ b/docs/image-trace-quality.md @@ -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 |