Files
vectorcraft/crates/trace/src/logo/tests.rs
T
Brandon Thomas 45afd85452 Merge pull request #4066 from dotCooCoo/fix/flat-logo-anchor-limit-counts-kept-outlines
Image Trace: Flat Logo counts only the outlines it keeps toward the anchor limit
2026-10-25 10:38:04 -04:00

688 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end tests: synthetic logos made in code, with known geometry, traced and measured.
use crate::{Method, Mode, Raster, TraceError, TraceParams, TraceResult, trace_within};
use kurbo::{ParamCurve, Shape};
use vectorcraft_geom::Point;
/// A soft, anti-aliased render of `shade` (`Some(colour)` where the shape is), supersampled `ss × ss`
/// and blurred by `blur` pixels, on a transparent background.
fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]>) -> Raster {
let ss = 8usize;
let (wu, hu) = (w as usize, h as usize);
let mut px = vec![[0.0f64; 4]; wu * hu];
for y in 0..hu {
for x in 0..wu {
let mut acc = [0.0f64; 4];
for sy in 0..ss {
for sx in 0..ss {
let (fx, fy) = (x as f64 + (sx as f64 + 0.5) / ss as f64, y as f64 + (sy as f64 + 0.5) / ss as f64);
if let Some(c) = shade(fx, fy) {
acc[0] += f64::from(c[0]) / 255.0;
acc[1] += f64::from(c[1]) / 255.0;
acc[2] += f64::from(c[2]) / 255.0;
acc[3] += 1.0;
}
}
}
let n = (ss * ss) as f64;
px[y * wu + x] = [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n];
}
}
if blur > 0.0 {
let r = (3.0 * blur).ceil() as i64;
let k: Vec<f64> = (-r..=r).map(|i| (-0.5 * (i as f64 / blur).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
let k: Vec<f64> = k.iter().map(|v| v / sum).collect();
for horizontal in [true, false] {
let src = px.clone();
for y in 0..hu as i64 {
for x in 0..wu as i64 {
let mut acc = [0.0f64; 4];
for (j, kv) in k.iter().enumerate() {
let d = j as i64 - r;
let (xx, yy) = if horizontal { ((x + d).clamp(0, wu as i64 - 1), y) } else { (x, (y + d).clamp(0, hu as i64 - 1)) };
let p = src[(yy as usize) * wu + xx as usize];
for c in 0..4 {
acc[c] += kv * p[c];
}
}
px[(y as usize) * wu + x as usize] = acc;
}
}
}
}
let mut rgba = Vec::with_capacity(wu * hu * 4);
for p in px {
let a = p[3];
let un = |v: f64| if a > 1e-9 { ((v / a) * 255.0).round().clamp(0.0, 255.0) as u8 } else { 0 };
rgba.extend_from_slice(&[un(p[0]), un(p[1]), un(p[2]), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
}
Raster::new(w, h, rgba).unwrap()
}
const RED: [u8; 3] = [210, 40, 50];
const BLUE: [u8; 3] = [40, 80, 200];
fn logo_params() -> TraceParams {
TraceParams {
mode: Mode::Logo,
colors: 6,
paths: 60.0,
corners: 50.0,
noise: 6,
snap_curves_to_lines: true,
method: Method::Abutting,
..TraceParams::default()
}
}
fn trace(img: &Raster, p: &TraceParams) -> TraceResult {
trace_within(img, p, 100_000).expect("trace")
}
fn samples(res: &TraceResult) -> Vec<Point> {
let mut out = Vec::new();
for p in &res.paths {
for seg in p.path.to_bezpath().segments() {
out.extend((0..=40).map(|i| seg.eval(f64::from(i) / 40.0)));
}
}
out
}
fn covered(res: &TraceResult, p: Point) -> bool {
res.paths.iter().any(|t| t.path.to_bezpath().winding(p) != 0)
}
fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
(0..3).all(|i| (i32::from(a[i]) - i32::from(b[i])).abs() <= tol)
}
#[test]
fn a_blurred_disc_becomes_a_true_circle_within_a_tenth_of_a_pixel() {
let (cx, cy, r) = (31.7, 32.4, 19.3);
let img = render(64, 64, 0.7, |x, y| ((x - cx).hypot(y - cy) <= r).then_some(RED));
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1, "{} paths", res.paths.len());
assert!(near(res.paths[0].color, RED, 3), "colour {:?}", res.paths[0].color);
let errs: Vec<f64> = samples(&res).iter().map(|p| (p.distance(Point::new(cx, cy)) - r).abs()).collect();
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
let max = errs.iter().copied().fold(0.0, f64::max);
assert!(mean < 0.06 && max < 0.15, "mean {mean:.3} max {max:.3}");
assert!(res.anchor_count() <= 6, "{} anchors for a circle", res.anchor_count());
}
#[test]
fn a_blurred_rectangle_keeps_square_corners_and_four_anchors() {
let (x0, x1, y0, y1) = (10.4, 77.9, 8.7, 38.2);
let img = render(90, 48, 0.8, |x, y| ((x0..=x1).contains(&x) && (y0..=y1).contains(&y)).then_some(BLUE));
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1);
assert_eq!(res.anchor_count(), 4, "a rectangle is four corners");
let b = res.paths[0].path.bounds().expect("bounds");
for (got, want, what) in [(b.x0, x0, "left"), (b.x1, x1, "right"), (b.y0, y0, "top"), (b.y1, y1, "bottom")] {
// Sub-pixel: within 0.15 px of the true edge, from a blurred 90 px image.
assert!((got - want).abs() < 0.15, "{what} edge {got:.3} vs {want}");
}
}
#[test]
fn touching_colours_leave_no_gap_between_them() {
let edge = 40.35;
let img = render(80, 50, 0.7, |x, y| {
if !(10.2..=40.6).contains(&y) {
None
} else if (10.3..=edge).contains(&x) {
Some(RED)
} else if (edge..=70.6).contains(&x) {
Some(BLUE)
} else {
None
}
});
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 2);
assert!(!near(res.paths[0].color, res.paths[1].color, 20));
for i in 0..200 {
let y = 12.0 + 27.0 * f64::from(i) / 199.0;
for dx in [-0.03, 0.0, 0.03] {
assert!(covered(&res, Point::new(edge + dx, y)), "gap at ({}, {y})", edge + dx);
}
}
}
#[test]
fn letters_knocked_out_of_a_badge_become_white_or_holes() {
let ring = |x: f64, y: f64| {
let d = (x - 32.0).hypot(y - 32.0);
(12.0..=25.0).contains(&d).then_some(BLUE)
};
let img = render(64, 64, 0.7, ring);
// Default: the knockout is painted white, on top of the badge.
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 2, "badge + white knockout");
assert!(res.paths.iter().any(|p| p.color == [255, 255, 255]));
assert!(covered(&res, Point::new(32.0, 32.0)));
// Ignore white: the knockout is a real hole in the badge.
let res = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert_eq!(res.paths.len(), 1);
assert_eq!(res.paths[0].path.subpaths.len(), 2, "outer outline and a hole");
assert!(!covered(&res, Point::new(32.0, 32.0)), "the hole must be empty");
assert!(covered(&res, Point::new(32.0, 32.0 + 18.0)), "the ring itself is filled");
}
#[test]
fn a_gradient_is_refused_not_posterised() {
let img = Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]);
match trace_within(&img, &logo_params(), 100_000) {
Err(TraceError::NotFlat { fraction }) => assert!(fraction < 0.93),
other => panic!("expected NotFlat, got {other:?}"),
}
let msg = TraceError::NotFlat { fraction: 0.69 }.to_string();
assert!(msg.contains("69%") && msg.contains("Color mode"), "{msg}");
}
#[test]
fn a_white_background_logo_drops_the_white_when_asked() {
let img = Raster::from_fn(80, 60, |x, y| {
let d = (x as f64 + 0.5 - 40.0).hypot(y as f64 + 0.5 - 30.0);
if (10.0..=24.0).contains(&d) { [20, 20, 20, 255] } else { [255, 255, 255, 255] }
});
let kept = trace(&img, &logo_params());
assert!(kept.paths.iter().any(|p| p.color == [255, 255, 255]), "white background kept by default");
let dropped = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert!(dropped.paths.iter().all(|p| p.color != [255, 255, 255]));
assert!(!dropped.paths.is_empty());
}
#[test]
fn the_named_palette_is_used_exactly_and_a_bad_one_is_an_error() {
let img = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(RED));
let named = TraceParams { logo_colors: vec!["#d22832".into()], ..logo_params() };
let res = trace(&img, &named);
assert_eq!(res.paths[0].color, [0xd2, 0x28, 0x32]);
let bad = TraceParams { logo_colors: vec!["red".into()], ..logo_params() };
assert!(matches!(trace_within(&img, &bad, 1000), Err(TraceError::BadPalette(s)) if s == "red"));
}
#[test]
fn a_large_image_is_traced_at_reduced_size_and_scaled_back() {
let (cx, cy, r) = (1200.0, 600.0, 400.0);
let img = Raster::from_fn(2400, 1200, |x, y| {
if (f64::from(x) + 0.5 - cx).hypot(f64::from(y) + 0.5 - cy) <= r { [RED[0], RED[1], RED[2], 255] } else { [0, 0, 0, 0] }
});
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1);
let b = res.paths[0].path.bounds().expect("bounds");
for (got, want) in [(b.x0, cx - r), (b.x1, cx + r), (b.y0, cy - r), (b.y1, cy + r)] {
assert!((got - want).abs() < 2.0, "{got} vs {want}");
}
}
/// A light square inset 50 px with slanted 2 px black lines across it, on a transparent background.
fn lined_square(n: u32) -> Raster {
Raster::from_fn(n, n, |x, y| {
if !(50..n - 50).contains(&x) || !(50..n - 50).contains(&y) {
[0, 0, 0, 0]
} else if (i64::from(x) - i64::from(y) / 8).rem_euclid(40) < 2 {
[0, 0, 0, 255]
} else {
[240, 240, 220, 255]
}
})
}
#[test]
fn a_trace_averaged_down_says_the_size_it_worked_at() {
let named = TraceParams { logo_colors: vec!["#000000".into(), "#F0F0DC".into()], ..logo_params() };
// 1025 px is over the working size: averaged down by 2, to 513 px.
let res = trace(&lined_square(1025), &named);
assert!(!res.paths.is_empty());
assert_eq!(res.worked_at, Some((513, 513)));
let res = trace(&lined_square(1024), &named);
assert!(!res.paths.is_empty());
assert_eq!(res.worked_at, None);
}
#[test]
fn named_colors_a_few_levels_off_trace_an_image_up_to_1024_px_at_full_size() {
// A light disc in a dark ring with a blue square on it, hard edges, 800 px square, and a dark
// 2 px dot on the disc at odd pixels: averaged down by 2, the dot would be a quarter of each of
// four pixels and leave no shape.
let img = Raster::from_fn(800, 800, |x, y| {
let (fx, fy) = (f64::from(x) / 5.0, f64::from(y) / 5.0);
let d = (fx - 80.0).hypot(fy - 80.0);
if (401..=402).contains(&x) && (201..=202).contains(&y) {
[4, 4, 3, 255]
} else if (70.0..=110.0).contains(&fx) && (70.0..=110.0).contains(&fy) {
[34, 124, 250, 255]
} else if d <= 60.0 {
[252, 248, 227, 255]
} else if d <= 68.0 {
[4, 4, 3, 255]
} else {
[0, 0, 0, 0]
}
});
for (off, names) in [(8, ["#0C0C0B", "#F4F0DB", "#1A74F2"]), (12, ["#10100F", "#F0ECD7", "#1670EE"])] {
let p = TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() };
let res = trace(&img, &p);
let color_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color);
assert_eq!(res.paths.len(), 4, "names {off} levels off");
let (dark, cream, blue) = ([4 + off, 4 + off, 3 + off], [252 - off, 248 - off, 227 - off], [34 - off, 124 - off, 250 - off]);
assert_eq!(color_at(Point::new(402.0, 202.0)), Some(dark), "names {off} levels off");
assert_eq!(color_at(Point::new(450.0, 450.0)), Some(blue), "names {off} levels off");
assert_eq!(color_at(Point::new(200.0, 400.0)), Some(cream), "names {off} levels off");
}
}
/// Every anchor of every path is finite and inside (a small margin around) the image.
fn assert_sane(res: &TraceResult, w: u32, h: u32) {
for t in &res.paths {
for sp in &t.path.subpaths {
for a in &sp.anchors {
assert!(a.p.x.is_finite() && a.p.y.is_finite() && a.h_in.x.is_finite() && a.h_out.y.is_finite(), "non-finite anchor");
assert!(a.p.x > -2.0 && a.p.y > -2.0 && a.p.x < f64::from(w) + 2.0 && a.p.y < f64::from(h) + 2.0, "anchor {:?} outside {w}x{h}", a.p);
}
}
}
}
#[test]
fn degenerate_images_are_fine() {
let p = logo_params();
for (w, h, img) in [
(0, 0, Raster::new(0, 0, vec![]).unwrap()),
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255]).unwrap()),
(3, 3, Raster::new(3, 3, vec![0; 36]).unwrap()),
(2, 2, Raster::new(2, 2, vec![255; 16]).unwrap()),
(5, 1, Raster::new(5, 1, vec![9; 20]).unwrap()),
(1, 7, Raster::new(1, 7, vec![130; 28]).unwrap()),
] {
match trace_within(&img, &p, 1000) {
Ok(res) => assert_sane(&res, w, h),
Err(TraceError::NotFlat { .. }) => {}
Err(e) => panic!("unexpected error for {w}x{h}: {e}"),
}
}
}
#[test]
fn extreme_parameters_never_panic() {
let img = render(48, 48, 0.7, |x, y| ((x - 24.0).hypot(y - 24.0) <= 15.0).then_some(RED));
let weird = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -50.0, 0.0, 100.0, 1e9];
for &paths in &weird {
for &corners in &weird {
let p = TraceParams { paths, corners, ..logo_params() };
let _ = trace_within(&img, &p, 10_000);
}
}
for colors in [0, 1, 2, 300, u32::MAX] {
for noise in [0, 1, 1_000_000, u32::MAX] {
let p = TraceParams { colors, noise, ..logo_params() };
let _ = trace_within(&img, &p, 10_000);
}
}
// A tiny anchor budget is reported as too complex, not exceeded.
let r = trace_within(&img, &logo_params(), 0);
assert!(r.is_err() || r.is_ok_and(|t| t.anchor_count() == 0));
}
#[test]
fn noise_specks_are_dropped() {
let img = render(64, 64, 0.5, |x, y| if (x - 32.0).hypot(y - 32.0) <= 18.0 || (x - 6.0).hypot(y - 6.0) <= 0.6 { Some(RED) } else { None });
let res = trace(&img, &TraceParams { noise: 20, ..logo_params() });
assert_eq!(res.paths.len(), 1, "the speck should be gone");
}
#[test]
fn nested_rings_keep_their_own_holes_under_ignore_white() {
// Red ring, white band, red ring, white centre.
let img = render(80, 80, 0.7, |x, y| {
let d = (x - 40.0).hypot(y - 40.0);
((22.0..=28.0).contains(&d) || (8.0..=16.0).contains(&d)).then_some(RED)
});
let res = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert_eq!(res.paths.len(), 2, "two red rings");
for p in &res.paths {
assert_eq!(p.path.subpaths.len(), 2, "each ring is an outline and its own hole, no more");
}
assert!(!covered(&res, Point::new(40.0, 40.0)), "centre is empty");
assert!(covered(&res, Point::new(52.0, 40.0)), "inner ring is filled");
assert!(!covered(&res, Point::new(59.0, 40.0)), "the band between the rings is empty");
assert!(covered(&res, Point::new(65.0, 40.0)), "outer ring is filled");
}
#[test]
fn too_many_outlines_are_refused_not_silently_truncated() {
let specks =
|x: f64, y: f64| (x.rem_euclid(5.0) >= 1.0 && x.rem_euclid(5.0) < 4.0 && y.rem_euclid(5.0) >= 1.0 && y.rem_euclid(5.0) < 4.0).then_some(RED);
let img = render(200, 200, 0.0, specks);
assert!(matches!(trace_within(&img, &logo_params(), 1_000_000), Err(TraceError::TooComplex { .. })));
// Specks under the noise threshold do not count towards the limit.
let res = trace_within(&img, &TraceParams { noise: 200, ..logo_params() }, 1_000_000).expect("trace");
assert!(res.paths.is_empty());
}
#[test]
fn holes_that_are_not_cut_do_not_count_towards_the_outline_limit() {
// A blue field with 625 holes, each filled with red, white or green: more holes than the limit, fewer of each fill.
const GREEN: [u8; 3] = [40, 170, 60];
let img = render(125, 125, 0.0, |x, y| {
let (cx, cy) = (x.rem_euclid(5.0), y.rem_euclid(5.0));
if (1.0..4.0).contains(&cx) && (1.0..4.0).contains(&cy) {
Some([RED, [255, 255, 255], GREEN][((x / 5.0) as usize + (y / 5.0) as usize) % 3])
} else {
Some(BLUE)
}
});
let res = trace_within(&img, &logo_params(), 1_000_000).expect("trace");
assert!(res.paths.iter().any(|p| near(p.color, BLUE, 3)), "the blue field is traced");
// Under Ignore White the holes are cut, so they count.
assert!(matches!(trace_within(&img, &TraceParams { ignore_white: true, ..logo_params() }, 1_000_000), Err(TraceError::TooComplex { .. })));
}
#[test]
fn a_large_odd_sized_image_never_overshoots_its_edges() {
// 1025 wide: the last working pixel is narrower than the others.
let img = Raster::from_fn(1025, 20, |x, y| if x >= 600 && (2..18).contains(&y) { [RED[0], RED[1], RED[2], 255] } else { [0, 0, 0, 0] });
let res = trace(&img, &logo_params());
assert_eq!(res.paths.len(), 1);
let b = res.paths[0].path.bounds().expect("bounds");
assert!(b.x1 <= 1025.15 && b.x1 >= 1024.7, "right edge {}", b.x1);
assert!((b.x0 - 600.0).abs() < 0.6 && (b.y0 - 2.0).abs() < 0.6 && (b.y1 - 18.0).abs() < 0.6, "{b:?}");
assert_sane(&res, 1025, 20);
}
#[test]
fn a_hostile_palette_is_bounded_and_near_duplicates_merge() {
let img = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(RED));
let many: Vec<String> = std::iter::once("#d22832".to_string()).chain((1..200_000u32).map(|i| format!("#{:06x}", i * 37 % 0xfffff0))).collect();
let started = std::time::Instant::now();
let res = trace_within(&img, &TraceParams { logo_colors: many, ..logo_params() }, 100_000);
assert!(started.elapsed().as_secs() < 20, "palette handling took {:?}", started.elapsed());
if let Ok(r) = res {
assert!(r.palette.len() <= 11);
}
// Ten shades within a couple of units of one red are one colour, so one path, not ten layers.
let shades: Vec<String> = (0..10).map(|i| format!("#{:02x}{:02x}{:02x}", 210 + i % 3, 40 + i % 2, 50 + i % 3)).collect();
let res = trace(&img, &TraceParams { logo_colors: shades, ..logo_params() });
assert_eq!(res.paths.len(), 1, "{} paths", res.paths.len());
// White cannot be named: naming only white leaves nothing to trace.
assert!(trace(&img, &TraceParams { logo_colors: vec!["#ffffff".into()], ..logo_params() }).paths.is_empty());
}
#[test]
fn a_small_accent_colour_is_not_lost() {
let img = render(100, 100, 0.6, |x, y| {
if (x - 50.0).hypot(y - 50.0) <= 40.0 {
Some(BLUE)
} else if (x - 90.0).hypot(y - 90.0) <= 3.5 {
Some(RED)
} else {
None
}
});
let res = trace(&img, &logo_params());
assert!(res.paths.iter().any(|p| near(p.color, RED, 12)), "palette {:?}", res.palette);
assert!(res.paths.iter().any(|p| near(p.color, BLUE, 12)));
}
#[test]
fn a_hard_edged_source_still_gets_smooth_circles() {
// No anti-aliasing at all: every pixel is exactly one colour.
let img = Raster::from_fn(80, 80, |x, y| {
let d = (f64::from(x) + 0.5 - 40.0).hypot(f64::from(y) + 0.5 - 40.0);
if (10.0..=28.0).contains(&d) { [RED[0], RED[1], RED[2], 255] } else { [0, 0, 0, 0] }
});
let res = trace(&img, &TraceParams { ignore_white: true, ..logo_params() });
assert!(res.anchor_count() <= 40, "{} anchors for two circles from a hard-edged source", res.anchor_count());
let errs: Vec<f64> = samples(&res)
.iter()
.map(|p| {
let d = p.distance(Point::new(40.0, 40.0));
(d - 28.0).abs().min((d - 10.0).abs())
})
.collect();
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
assert!(mean < 0.3 && errs.iter().copied().fold(0.0, f64::max) < 0.8, "mean {mean:.3}");
}
#[test]
fn random_small_images_and_settings_never_panic() {
let mut state = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let weird = [f64::NAN, f64::INFINITY, -3.0, 0.0, 35.0, 60.0, 100.0, 1e12];
let colours = [[210u8, 40, 50], [40, 80, 200], [255, 255, 255], [20, 160, 70], [0, 0, 0]];
for _ in 0..250 {
let (w, h) = ((next() % 24 + 1) as u32, (next() % 24 + 1) as u32);
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let r = next();
let c = colours[((u64::from(x) / 3 + u64::from(y) / 4 + r % 2) % 5) as usize];
rgba.extend_from_slice(&match r % 11 {
0 => [0, 0, 0, 0],
1 => [(r >> 8) as u8, (r >> 16) as u8, (r >> 24) as u8, (r >> 32) as u8],
_ => [c[0], c[1], c[2], 255],
});
}
}
let img = Raster::new(w, h, rgba).unwrap();
let p = TraceParams {
paths: weird[(next() % 8) as usize],
corners: weird[(next() % 8) as usize],
noise: [0, 1, 6, 1000, u32::MAX][(next() % 5) as usize],
colors: [0, 2, 6, 40][(next() % 4) as usize],
ignore_white: next() % 2 == 0,
snap_curves_to_lines: next() % 2 == 0,
..logo_params()
};
if let Ok(res) = trace_within(&img, &p, 20_000) {
assert_sane(&res, w, h);
}
}
}
/// A scan's paper is darker in one corner than in another: it is still one colour, not a band of
/// paths for each tone of it.
#[test]
fn uneven_paper_is_one_colour() {
let img = Raster::from_fn(160, 120, |x, y| {
let shade = 1.0 - 0.1 * f64::from(x) / 160.0 - 0.04 * (f64::from(y) / 40.0).sin().abs();
let ink = (40..120).contains(&x) && (30..90).contains(&y);
let c = if ink { [40.0, 60.0, 150.0] } else { [236.0, 230.0, 214.0] };
[(c[0] * shade) as u8, (c[1] * shade) as u8, (c[2] * shade) as u8, 255]
});
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
assert_eq!(res.paths.len(), 2, "{:?}", res.paths.iter().map(|p| (p.color, p.pixels)).collect::<Vec<_>>());
}
/// Line art: thin dark strokes and small red marks (2.4 px wide) on white paper, anti-aliased. No stroke fills a
/// uniform 3 × 3 neighbourhood, so the solid pixels are all paper; the strokes' colours must still
/// be found (from the strokes' cores) and traced, not cut as noise into a blank result.
#[test]
fn thin_line_art_on_paper_is_traced_not_blank() {
const INK: [u8; 3] = [46, 36, 38];
const MARK: [u8; 3] = [196, 38, 66];
// Distance from the point to the segment a–b.
let seg = |x: f64, y: f64, a: (f64, f64), b: (f64, f64)| {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let t = (((x - a.0) * dx + (y - a.1) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
(x - a.0 - t * dx).hypot(y - a.1 - t * dy)
};
let strokes = [((10.0, 12.0), (150.0, 30.0)), ((20.0, 110.0), (140.0, 60.0)), ((80.0, 8.0), (86.0, 112.0)), ((30.0, 40.0), (60.0, 100.0))];
let marks = [((110.0, 95.0), (128.0, 101.0)), ((40.0, 20.0), (52.0, 18.0))];
let img = render(160, 120, 0.0, |x, y| {
Some(if marks.iter().any(|&(a, b)| seg(x, y, a, b) <= 1.2) {
MARK
} else if strokes.iter().any(|&(a, b)| seg(x, y, a, b) <= 1.2) {
INK
} else {
[255, 255, 255]
})
});
let res = trace_within(&img, &crate::preset("Flat Logo").unwrap(), 100_000).expect("line art is traced, not refused");
assert!(res.paths.iter().any(|p| near(p.color, INK, 30)), "no ink path; palette {:?}", res.palette);
assert!(res.paths.iter().any(|p| near(p.color, MARK, 30)), "no red mark; palette {:?}", res.palette);
// The middle of each stroke and mark is painted with its colour (the topmost path there).
let colour_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color);
for (&(a, b), colour) in strokes.iter().map(|s| (s, INK)).chain(marks.iter().map(|m| (m, MARK))) {
for k in 1..8 {
let t = f64::from(k) / 8.0;
let p = Point::new(a.0 + t * (b.0 - a.0), a.1 + t * (b.1 - a.1));
let at = colour_at(p);
assert!(at.is_some_and(|c| near(c, colour, 30)), "{p:?} is {at:?}, not {colour:?}");
}
}
}
/// Blank white paper with a light grain (a scan, a JPEG: each channel 243 to 255): the grain's
/// darkest specks are no ink, and the paper traces to nothing (a blank page stays blank).
#[test]
fn grainy_blank_paper_has_no_ink() {
let grain = |x: u32, y: u32, k: u32| {
let mut v = (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663) ^ k.wrapping_mul(83_492_791)).wrapping_mul(2_654_435_761);
v ^= v >> 15;
(255 - (v % 13)) as u8
};
let img = Raster::from_fn(160, 120, |x, y| [grain(x, y, 1), grain(x, y, 2), grain(x, y, 3), 255]);
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
assert!(res.palette.is_empty() && res.paths.is_empty(), "palette {:?}", res.palette);
}
/// White paper with 24 short grey marks (12 × 2 px) of one shade: light (239, 223) or mid grey
/// (191). They cover under 1% of the page, so it is paper with a few specks on it, not line art:
/// no colour is found and the page traces to nothing, as plain white paper does.
#[test]
fn sparse_marks_on_paper_are_not_promoted_to_colours() {
let marks: Vec<(u32, u32)> = (0..24u32).map(|k| (10 + (k * 37) % 220, 10 + (k * 71) % 220)).collect();
for shade in [239u8, 223, 191] {
let img = Raster::from_fn(256, 256, |x, y| {
let on = marks.iter().any(|&(mx, my)| (mx..mx + 12).contains(&x) && (my..my + 2).contains(&y));
let v = if on { shade } else { 255 };
[v, v, v, 255]
});
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
assert!(res.palette.is_empty(), "shade {shade}: palette {:?}", res.palette);
assert!(res.paths.is_empty(), "shade {shade}: {} paths", res.paths.len());
}
}
/// Bars two pixels wide (whole pixels of one color) on white beside a black disc, with the colors
/// found: no pixel of a bar has a 3 × 3 neighborhood of its own color, and a gray bar is a blend of
/// black and white. Light grays, a red and a pale yellow are found and traced in their own color. A
/// gray bar darker than about `#9B9B9B` stays black: a black stroke 1 to 1.6 px wide on the pixel
/// grid gives the same pixels. A single bar two pixels wide and three pixels long, down or across
/// the image, is found in its color too.
#[test]
fn a_bar_two_pixels_wide_beside_black_art_is_found_in_its_color() {
const INK: [u8; 3] = [4, 4, 3];
let layouts = [
(
vec![(50..52, 8..50), (60..110, 30..32)],
vec![Point::new(51.0, 20.0), Point::new(51.0, 40.0), Point::new(70.0, 31.0), Point::new(100.0, 31.0)],
),
(vec![(50..52, 20..23)], vec![Point::new(51.0, 21.5)]),
(vec![(70..73, 30..32)], vec![Point::new(71.5, 31.0)]),
];
for (bars, points) in &layouts {
for (bar, want) in [
([170u8, 170, 170], None),
([200, 200, 200], None),
([230, 230, 230], None),
([210, 40, 50], None),
([250, 230, 150], None),
([128, 128, 128], Some(INK)),
([51, 51, 51], Some(INK)),
] {
if bars.len() == 1 && bar == [128, 128, 128] {
// Half ink and half paper, a single bar three pixels long is traced as paper.
continue;
}
let img = Raster::from_fn(120, 60, |x, y| {
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
let c = if (fx - 25.0).hypot(fy - 28.0) <= 18.0 {
INK
} else if bars.iter().any(|(xs, ys)| xs.contains(&x) && ys.contains(&y)) {
bar
} else {
[255, 255, 255]
};
[c[0], c[1], c[2], 255]
});
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
let colour_at = |p: Point| res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color);
let paths: Vec<_> = res.paths.iter().map(|t| (t.color, t.pixels)).collect();
let want = want.unwrap_or(bar);
for &at in points {
assert_eq!(colour_at(at), Some(want), "bars {bars:?}, bar {bar:?}: {at:?}; {paths:?}");
}
assert_eq!(colour_at(Point::new(25.0, 28.0)), Some(INK), "bars {bars:?}, bar {bar:?}: the disc; {paths:?}");
}
}
}
/// Three rows of thin anti-aliased "lettering" (rings, slants and bars 1.6 px wide) in red, blue
/// and green on white. The lighter shades along the strokes' anti-aliased edges are blends of an
/// ink with the paper, not colours of their own: the palette is the three inks and white.
#[test]
fn anti_aliased_coloured_lettering_has_only_its_inks() {
const INKS: [[u8; 3]; 3] = [[210, 40, 50], [30, 90, 200], [20, 130, 70]];
let seg = |x: f64, y: f64, a: (f64, f64), b: (f64, f64)| {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let t = (((x - a.0) * dx + (y - a.1) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
(x - a.0 - t * dx).hypot(y - a.1 - t * dy)
};
// One "letter" in the cell at (cx, cy): a ring, a slanted stroke or a bar with a foot.
let glyph = |x: f64, y: f64, k: usize, cx: f64, cy: f64| match k % 3 {
0 => ((x - cx).hypot(y - cy) - 6.0).abs(),
1 => seg(x, y, (cx - 5.0, cy + 8.0), (cx + 4.0, cy - 8.0)).min(seg(x, y, (cx - 1.0, cy), (cx + 6.0, cy + 7.5))),
_ => seg(x, y, (cx - 0.3, cy - 8.0), (cx + 0.4, cy + 8.0)).min(seg(x, y, (cx - 5.0, cy + 8.2), (cx + 5.0, cy + 7.9))),
};
let img = render(256, 256, 0.0, |x, y| {
for (row, ink) in INKS.iter().enumerate() {
let cy = 40.0 + 80.0 * row as f64 + 0.37 * row as f64;
for k in 0..14 {
let cx = 14.0 + 17.3 * k as f64;
if glyph(x, y, k + row, cx, cy) <= 0.8 {
return Some(*ink);
}
}
}
Some([255, 255, 255])
});
let res = trace(&img, &crate::preset("Flat Logo").unwrap());
let colours: Vec<[u8; 3]> = res.palette.iter().copied().filter(|c| !c.iter().all(|&v| v >= 245)).collect();
assert_eq!(colours.len(), 3, "palette {:?}", res.palette);
for ink in INKS {
assert!(colours.iter().any(|&c| near(c, ink, 12)), "{ink:?} missing from {:?}", res.palette);
}
}
/// An outline whose fit has no area is left out. A pixel-sized black and cream noise, with both
/// colors named exactly, fits some specks to two anchors whose segments run along one line and
/// others to one anchor, which has no segment.
#[test]
fn an_outline_fitted_without_area_is_left_out() {
use kurbo::ParamCurveArea;
let noise = render(96, 96, 0.0, |x, y| {
let n = ((x.floor() * 12.9898 + y.floor() * 78.233).sin() * 43758.5453).fract();
Some(if n > 0.5 { [4, 4, 3] } else { [252, 248, 227] })
});
let res = trace(&noise, &TraceParams { logo_colors: vec!["#040403".into(), "#FCF8E3".into()], ..logo_params() });
assert!(res.paths.len() > 1, "{} paths", res.paths.len());
for t in &res.paths {
for sp in &t.path.subpaths {
let origin = sp.anchors[0].p.to_vec2();
let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum();
assert!(area.abs() >= 1e-6, "a {:?} subpath of {} anchors with area {area:e}", t.color, sp.anchors.len());
}
}
}