From a17e3057defdeb040b234c3f9b2a1385f69a8c38 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 00:39:39 +0700 Subject: [PATCH] Image Trace quality harness: an aliased variant, and every drawing can be written out --- crates/trace/tests/quality/inputs.rs | 29 +++++++++++++---------- crates/trace/tests/quality/main.rs | 34 +++++++++++++++++---------- crates/trace/tests/quality/measure.rs | 8 +++++++ crates/trace/tests/quality/raster.rs | 9 ++++--- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/crates/trace/tests/quality/inputs.rs b/crates/trace/tests/quality/inputs.rs index 68ab1e84f..ad21f3ba5 100644 --- a/crates/trace/tests/quality/inputs.rs +++ b/crates/trace/tests/quality/inputs.rs @@ -39,30 +39,25 @@ impl Class { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Variant { Clean, + /// One hard sample per pixel: no anti-aliasing (a GIF, a 1-bit scan). + Hard, Blur, Noise, Scan, } -pub const VARIANTS: [Variant; 4] = [Variant::Clean, Variant::Blur, Variant::Noise, Variant::Scan]; +pub const VARIANTS: [Variant; 5] = [Variant::Clean, Variant::Hard, Variant::Blur, Variant::Noise, Variant::Scan]; impl Variant { pub fn name(self) -> &'static str { match self { Variant::Clean => "clean", + Variant::Hard => "aliased", 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 } @@ -247,15 +242,25 @@ pub fn inputs() -> Vec { } impl Input { - /// The input drawn `size` pixels square, anti-aliased. - pub fn draw(&self, size: u32) -> Raster { + /// The input drawn `size` pixels square as `variant`. + pub fn draw(&self, size: u32, variant: Variant) -> Raster { + // Art is drawn anti-aliased from 6 × 6 samples per pixel; the aliased variant takes one. + let img = self.draw_sampled(size, if variant == Variant::Hard { 1 } else { 6 }); + match variant { + Variant::Clean | Variant::Hard => img, + Variant::Blur => raster::blur(&img), + Variant::Noise => raster::noisy(&img), + Variant::Scan => raster::scanned(&img), + } + } + fn draw_sampled(&self, size: u32, samples: usize) -> 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), + None => raster::render_truth(&self.scaled(size), self.bg, n, n, samples), } } /// The art in the pixel coordinates of a `size`-pixel drawing. diff --git a/crates/trace/tests/quality/main.rs b/crates/trace/tests/quality/main.rs index a4460971d..4a8603d18 100644 --- a/crates/trace/tests/quality/main.rs +++ b/crates/trace/tests/quality/main.rs @@ -7,7 +7,7 @@ //! //! `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 +//! A yardstick: `TRACE_QUALITY_IMAGES=dir` also writes the 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). @@ -88,14 +88,21 @@ struct Run { 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)]; +/// Every (size, variant) an input is traced at: clean at three sizes, the others at the middle one. +const DRAWINGS: [(u32, Variant); 7] = [ + (128, Variant::Clean), + (256, Variant::Clean), + (512, Variant::Clean), + (256, Variant::Hard), + (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() + inputs.iter().flat_map(|input| DRAWINGS.map(|(size, variant)| (input, size, variant, input.draw(size, variant)))).collect() } /// The file name (without extension) of a drawing given to or made by another tracer. @@ -165,12 +172,12 @@ fn stat(runs: &[&Metrics], field: impl Fn(&Metrics) -> f64, max: bool, digits: u 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"; +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 | outside px |\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)); + return format!("| refused |{}", " |".repeat(16)); } let cells = [ stat(&m, |m| m.delta, false, 2), @@ -189,6 +196,7 @@ fn row(runs: &[&Run]) -> String { stat(&m, |m| m.gap, false, 1), stat(&m, |m| m.overlap, false, 1), stat(&m, |m| m.specks, false, 1), + stat(&m, |m| m.outside, true, 1), ]; format!("| {} |", cells.join(" | ")) } @@ -217,7 +225,7 @@ fn report() -> String { 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) { + for (input, size, variant, img) in &drawn { std::fs::write(format!("{dir}/{}.png", stem(input, *size, *variant)), img.encode_png()).unwrap(); } } @@ -225,7 +233,7 @@ fn report() -> String { 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 += "## 1. Each preset on the inputs it is meant for\n\nAll sizes and variants (clean, aliased, 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(); @@ -281,7 +289,7 @@ fn report() -> String { 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))]; + let images = [("photo-like", photo.draw(size, Variant::Clean)), ("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)); @@ -311,12 +319,12 @@ fn harness_measures_a_disc() { let inputs = inputs(); let circle = &inputs[0]; - let src = circle.draw(128); + let src = circle.draw(128, Variant::Clean); 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:?}"); + assert!(m.geo_mean < 0.06 && m.geo_max < 0.3 && m.miss_max < 0.3, "{m:?}"); + assert!(m.delta < 0.2 && m.kinks == 0.0 && m.gap.is_nan(), "{m:?}"); } diff --git a/crates/trace/tests/quality/measure.rs b/crates/trace/tests/quality/measure.rs index f3d49646a..a7c513e7f 100644 --- a/crates/trace/tests/quality/measure.rs +++ b/crates/trace/tests/quality/measure.rs @@ -187,6 +187,8 @@ pub struct Metrics { pub overlap: f64, /// Paths under 6 px of area. pub specks: f64, + /// How far (px) the trace reaches outside the image: a stray curve. + pub outside: f64, } /// Measure `res`, the trace of `src` (`input` drawn `size` px square as `variant`) with `params`. @@ -280,5 +282,11 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params: gap, overlap, specks: art.iter().filter(|(_, p)| p.area().abs() < 6.0).count() as f64, + outside: shapes + .iter() + .map(|(p, _)| p.bounding_box()) + .filter(|b| b.width() > 0.0 || b.height() > 0.0) + .map(|b| (-b.x0).max(-b.y0).max(b.x1 - f64::from(size)).max(b.y1 - f64::from(size)).max(0.0)) + .fold(0.0, f64::max), } } diff --git a/crates/trace/tests/quality/raster.rs b/crates/trace/tests/quality/raster.rs index e0e108592..7945dbf75 100644 --- a/crates/trace/tests/quality/raster.rs +++ b/crates/trace/tests/quality/raster.rs @@ -98,14 +98,13 @@ pub fn render(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize) -> /// 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); +pub fn render_truth(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize, k: usize) -> Raster { + 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)); + 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 { @@ -118,7 +117,7 @@ pub fn render_truth(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usiz 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; + buf[(y / k) * w + x / k][ch] += f32::from(top[y * bw + x][ch]) / (k * k) as f32; } } }