mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
A photo's noise is as large as the step from one palette colour to the next, so noise decided pixel by pixel which of two neighbouring colours a pixel got: tens of thousands of specks, or (since the palette change) neighbouring tones merged into one. - A continuous-tone image (eight colours leave much of it unsaid; flat art, however noisy or unevenly lit, is not one) is smoothed before it is quantised: three rounds of a small bilateral filter, which averages noise away within a tone and keeps the edges between tones where and as sharp as they were. - With Method: Overlapping, layers are stacked by lightness, from the largest colour's end of the scale, instead of by area. Each layer reaches under those above it, so where two colours meet every layer between them in the stack ends on that same edge and shows through its anti-aliasing: by area that was an unrelated colour's hairline around every region; by lightness it is a neighbouring tone. - Ignore White with Overlapping: no layer reaches under the white that is left out. On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after: - High Fidelity Photo: colour error 2.37 -> 1.75 (2.30 at the baseline), edge error 2.59 -> 1.98, 639 -> 121 paths (3,653 at the baseline), 25,604 -> 9,818 anchors, specks 152 -> 10; a 16 megapixel photo-like image is traced in 3.5 s (31,131 paths) where it was refused - Low Fidelity Photo: colour error 2.67 -> 2.45, 55 -> 37 paths - Shades of Gray: 73 -> 17 paths, colour error 9.33 -> 9.26 - The other presets' numbers are unchanged
989 lines
43 KiB
Rust
989 lines
43 KiB
Rust
use super::*;
|
||
use vectorcraft_geom::Point;
|
||
|
||
const BLACK: [u8; 4] = [0, 0, 0, 255];
|
||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||
|
||
fn bw() -> TraceParams {
|
||
TraceParams { ignore_white: true, ..TraceParams::default() }
|
||
}
|
||
|
||
fn disc(size: u32, cx: f64, cy: f64, r: f64) -> Raster {
|
||
Raster::from_fn(size, size, |x, y| {
|
||
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
|
||
if dx * dx + dy * dy <= r * r { BLACK } else { WHITE }
|
||
})
|
||
}
|
||
|
||
/// Net filled area (outer contours positive, holes negative).
|
||
fn net_area(p: &vectorcraft_geom::PathData) -> f64 {
|
||
p.subpaths.iter().map(|s| s.area()).sum()
|
||
}
|
||
|
||
#[test]
|
||
fn black_disc_is_one_closed_path_with_disc_area() {
|
||
let r = 80.0;
|
||
let res = trace(&disc(200, 100.0, 100.0, r), &bw());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let p = &res.paths[0];
|
||
assert_eq!(p.path.subpaths.len(), 1);
|
||
assert!(p.path.subpaths[0].closed);
|
||
assert_eq!(p.color, [0, 0, 0]);
|
||
let want = std::f64::consts::PI * r * r;
|
||
let got = net_area(&p.path);
|
||
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
||
// Curves, not a pixel staircase.
|
||
assert!(p.path.anchor_count() < 40, "anchors {}", p.path.anchor_count());
|
||
}
|
||
|
||
#[test]
|
||
fn small_disc_area_within_tolerance() {
|
||
let r = 20.0;
|
||
let res = trace(&disc(64, 32.0, 32.0, r), &bw());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let want = std::f64::consts::PI * r * r;
|
||
let got = net_area(&res.paths[0].path);
|
||
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
||
}
|
||
|
||
#[test]
|
||
fn ring_has_outer_and_hole() {
|
||
let img = Raster::from_fn(200, 200, |x, y| {
|
||
let d2 = (x as f64 + 0.5 - 100.0).powi(2) + (y as f64 + 0.5 - 100.0).powi(2);
|
||
if (40.0 * 40.0..=80.0 * 80.0).contains(&d2) { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &bw());
|
||
assert_eq!(res.paths.len(), 1);
|
||
assert_eq!(res.paths[0].path.subpaths.len(), 2);
|
||
let want = std::f64::consts::PI * (80.0f64.powi(2) - 40.0f64.powi(2));
|
||
let got = net_area(&res.paths[0].path);
|
||
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
||
// Opposite orientations → fills the same under non-zero.
|
||
let a: Vec<f64> = res.paths[0].path.subpaths.iter().map(|s| s.area()).collect();
|
||
assert!(a[0] * a[1] < 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn stripes_give_one_path_per_stripe() {
|
||
// 5 black stripes, 10 px wide, separated by 10 px of white.
|
||
let img = Raster::from_fn(100, 60, |x, _| if (x / 10) % 2 == 0 { BLACK } else { WHITE });
|
||
let res = trace(&img, &bw());
|
||
assert_eq!(res.paths.len(), 5);
|
||
for p in &res.paths {
|
||
let a = net_area(&p.path);
|
||
assert!((a - 600.0).abs() < 1.0, "stripe area {a}");
|
||
assert_eq!(p.path.anchor_count(), 4, "rectangles keep their 4 corners");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn two_colour_stripes_without_ignore_white_trace_both_colours() {
|
||
let img = Raster::from_fn(100, 60, |x, _| if (x / 10) % 2 == 0 { BLACK } else { WHITE });
|
||
let res = trace(&img, &TraceParams::default());
|
||
assert_eq!(res.paths.len(), 10);
|
||
assert_eq!(res.palette.len(), 2);
|
||
let total: f64 = res.paths.iter().map(|p| net_area(&p.path)).sum();
|
||
assert!((total - 6000.0).abs() < 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn noise_removal_drops_speckles() {
|
||
let img = Raster::from_fn(100, 100, |x, y| {
|
||
let square = (20..60).contains(&x) && (20..60).contains(&y);
|
||
let speck = (x % 13 == 3 && y % 11 == 5) && !(15..65).contains(&x);
|
||
if square || speck { BLACK } else { WHITE }
|
||
});
|
||
let noisy = trace(&img, &TraceParams { noise: 0, ..bw() });
|
||
assert!(noisy.paths.len() > 10, "{} paths", noisy.paths.len());
|
||
let clean = trace(&img, &TraceParams { noise: 10, ..bw() });
|
||
assert_eq!(clean.paths.len(), 1);
|
||
assert!((net_area(&clean.paths[0].path) - 1600.0).abs() < 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn noise_removal_fills_pinholes() {
|
||
let img = Raster::from_fn(60, 60, |x, y| {
|
||
let square = (10..50).contains(&x) && (10..50).contains(&y);
|
||
let hole = x == 30 && y == 30;
|
||
if square && !hole { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &TraceParams { noise: 4, ..bw() });
|
||
assert_eq!(res.paths.len(), 1);
|
||
assert_eq!(res.paths[0].path.subpaths.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn diagonal_pixels_stay_separate() {
|
||
let img = Raster::from_fn(4, 4, |x, y| if (x, y) == (1, 1) || (x, y) == (2, 2) { BLACK } else { WHITE });
|
||
let res = trace(&img, &TraceParams { noise: 0, ..bw() });
|
||
assert_eq!(res.paths.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn contour_loops_are_consistent() {
|
||
let mask = vec![true, true, true, true, false, true, true, true, true];
|
||
let comps = trace_mask(&mask, 3, 3);
|
||
assert_eq!(comps.len(), 1);
|
||
assert_eq!(comps[0].pixels, 8);
|
||
assert_eq!(comps[0].outer.area2, 18);
|
||
assert_eq!(comps[0].holes.len(), 1);
|
||
assert_eq!(comps[0].holes[0].area2, -2);
|
||
}
|
||
|
||
#[test]
|
||
fn color_mode_palette_size_matches_request() {
|
||
let quad = |x: u32, y: u32| match (x < 50, y < 50) {
|
||
(true, true) => [220, 30, 30, 255],
|
||
(false, true) => [30, 200, 40, 255],
|
||
(true, false) => [30, 40, 210, 255],
|
||
(false, false) => [240, 220, 20, 255],
|
||
};
|
||
let img = Raster::from_fn(100, 100, quad);
|
||
let p4 = trace(&img, &TraceParams { mode: Mode::Color, colors: 4, ..TraceParams::default() });
|
||
assert_eq!(p4.palette.len(), 4);
|
||
assert_eq!(p4.paths.len(), 4);
|
||
let p2 = trace(&img, &TraceParams { mode: Mode::Color, colors: 2, ..TraceParams::default() });
|
||
assert_eq!(p2.palette.len(), 2);
|
||
// Palette colours are close to the originals.
|
||
assert!(p4.palette.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60));
|
||
}
|
||
|
||
#[test]
|
||
fn color_mode_gradient_uses_at_most_n_colors() {
|
||
let img = Raster::from_fn(128, 32, |x, _| [(x * 2) as u8, 255 - (x * 2) as u8, 128, 255]);
|
||
for n in [3, 6, 16] {
|
||
let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: n, ..TraceParams::default() });
|
||
assert!(q.palette.len() <= n as usize && q.palette.len() >= 2, "n={n}: {}", q.palette.len());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn color_mode_separates_close_dark_tones() {
|
||
// Eight stripes one level apart (32..=39): a fixed 5-bit-per-channel histogram put them all in
|
||
// one bin, so `colors: 8` gave a single colour and a single path.
|
||
let img = Raster::from_fn(128, 32, |x, _| {
|
||
let v = 32 + (x / 16) as u8;
|
||
[v, v, v, 255]
|
||
});
|
||
let params = TraceParams { mode: Mode::Color, colors: 8, noise: 0, method: Method::Abutting, ..TraceParams::default() };
|
||
let res = trace(&img, ¶ms);
|
||
let mut tones: Vec<u8> = res.palette.iter().map(|c| c[0]).collect();
|
||
tones.sort_unstable();
|
||
assert_eq!(tones, (32..=39).collect::<Vec<u8>>());
|
||
assert_eq!(res.paths.len(), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn color_mode_full_range_and_transparent_inputs() {
|
||
// Full-range input keeps working.
|
||
let img = Raster::from_fn(256, 4, |x, _| [x as u8, 255 - x as u8, 0, 255]);
|
||
let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
assert!((2..=6).contains(&q.palette.len()), "{}", q.palette.len());
|
||
// A fully transparent image has no traced pixel and does not fail.
|
||
let clear = Raster::from_fn(8, 8, |_, _| [10, 20, 30, 0]);
|
||
let q = quantize(&clear, &TraceParams { mode: Mode::Color, colors: 4, ..TraceParams::default() });
|
||
assert!(q.labels.iter().all(|&l| l == TRANSPARENT));
|
||
// Deterministic.
|
||
let a = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
let b = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
#[test]
|
||
fn grayscale_levels() {
|
||
let img = Raster::from_fn(256, 8, |x, _| [x as u8, x as u8, x as u8, 255]);
|
||
let q = quantize(&img, &TraceParams { mode: Mode::Grayscale, colors: 4, ..TraceParams::default() });
|
||
assert_eq!(q.palette.len(), 4);
|
||
assert!(q.palette.iter().all(|c| c[0] == c[1] && c[1] == c[2]));
|
||
let res = trace(&img, &TraceParams { mode: Mode::Grayscale, colors: 4, noise: 0, ..TraceParams::default() });
|
||
assert_eq!(res.paths.len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn overlapping_layers_stack() {
|
||
// White background with a black square: overlapping → bottom layer covers everything.
|
||
let img = Raster::from_fn(50, 50, |x, y| if (10..30).contains(&x) && (10..30).contains(&y) { BLACK } else { WHITE });
|
||
let ab = trace(&img, &TraceParams::default());
|
||
let ov = trace(&img, &TraceParams { method: Method::Overlapping, ..TraceParams::default() });
|
||
assert_eq!(ab.paths.len(), 2);
|
||
assert_eq!(ov.paths.len(), 2);
|
||
// Abutting: white has a hole; overlapping: white is a full rectangle underneath.
|
||
assert_eq!(ab.paths[0].path.subpaths.len(), 2);
|
||
assert_eq!(ov.paths[0].path.subpaths.len(), 1);
|
||
assert!((net_area(&ov.paths[0].path) - 2500.0).abs() < 1.0);
|
||
assert_eq!(ov.paths[1].color, [0, 0, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn transparent_pixels_are_not_traced() {
|
||
let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [0, 0, 0, 0] } else { BLACK });
|
||
let res = trace(&img, &TraceParams::default());
|
||
assert_eq!(res.paths.len(), 1);
|
||
assert!((net_area(&res.paths[0].path) - 800.0).abs() < 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn threshold_controls_black_and_white() {
|
||
let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [100, 100, 100, 255] } else { WHITE });
|
||
assert_eq!(trace(&img, &TraceParams { threshold: 128, ..bw() }).paths.len(), 1);
|
||
assert_eq!(trace(&img, &TraceParams { threshold: 90, ..bw() }).paths.len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn fidelity_changes_anchor_count() {
|
||
let img = Raster::from_fn(200, 200, |x, y| {
|
||
let t = (y as f64 / 12.0).sin() * 20.0 + 100.0;
|
||
if (x as f64) < t { BLACK } else { WHITE }
|
||
});
|
||
let lo = trace(&img, &TraceParams { paths: 0.0, ..bw() }).anchor_count();
|
||
let hi = trace(&img, &TraceParams { paths: 100.0, ..bw() }).anchor_count();
|
||
assert!(hi >= lo, "hi {hi} lo {lo}");
|
||
}
|
||
|
||
#[test]
|
||
fn snap_curves_to_lines_straightens_near_lines() {
|
||
// A slightly jagged, almost straight edge. (Hard pixels place it to half a pixel only, and its
|
||
// ends sit on staircase treads: jags far along it would be more than "slightly" off the line.)
|
||
let img = Raster::from_fn(200, 100, |x, y| {
|
||
if y as f64 > 30.0 + (x as f64 * 0.05) + if (5..7).contains(&(x % 37)) && x < 120 { 1.0 } else { 0.0 } { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() });
|
||
let sp = &res.paths[0].path.subpaths[0];
|
||
let curves = (0..sp.segment_count()).filter(|&i| !sp.segment_is_line(i)).count();
|
||
assert_eq!(curves, 0, "{sp:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn presets_resolve() {
|
||
assert_eq!(presets().len(), 13);
|
||
for n in PRESET_NAMES {
|
||
assert!(preset(n).is_some(), "{n}");
|
||
}
|
||
assert!(preset("[Default]").is_some());
|
||
assert_eq!(preset("16 colors").unwrap().colors, 16);
|
||
assert_eq!(preset("Shades of Gray").unwrap().mode, Mode::Grayscale);
|
||
assert_eq!(preset("flat logo").unwrap().mode, Mode::Logo);
|
||
assert!(preset("nope").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn params_json_roundtrip_and_partial() {
|
||
let p = preset("6 Colors").unwrap();
|
||
let v = serde_json::to_value(&p).unwrap();
|
||
assert_eq!(v["mode"], "color");
|
||
let back: TraceParams = serde_json::from_value(v).unwrap();
|
||
assert_eq!(back, p);
|
||
let partial: TraceParams = serde_json::from_value(serde_json::json!({"threshold": 90, "ignoreWhite": true})).unwrap();
|
||
assert_eq!(partial.threshold, 90);
|
||
assert!(partial.ignore_white);
|
||
assert_eq!(partial.mode, Mode::BlackAndWhite);
|
||
}
|
||
|
||
#[test]
|
||
fn decode_png() {
|
||
let img = disc(32, 16.0, 16.0, 10.0);
|
||
let bytes = img.encode_png();
|
||
let r = Raster::decode(&bytes).unwrap();
|
||
assert_eq!(r, img);
|
||
assert!(Raster::decode(b"not an image").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn all_presets_trace_a_colour_image() {
|
||
let img = Raster::from_fn(64, 64, |x, y| [(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8, 255]);
|
||
for (name, p) in presets() {
|
||
// Flat Logo refuses a gradient on purpose (see logo::tests); every other preset traces it.
|
||
if p.mode == Mode::Logo {
|
||
continue;
|
||
}
|
||
let r = trace(&img, &p);
|
||
assert!(!r.paths.is_empty() || p.ignore_white, "{name}");
|
||
for tp in &r.paths {
|
||
assert!(tp.path.subpaths.iter().all(|s| s.closed && s.anchors.iter().all(|a| a.p.x.is_finite())));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "timing; run with --release --ignored"]
|
||
fn timing_1000x1000_bw_under_300ms() {
|
||
let img = Raster::from_fn(1000, 1000, |x, y| {
|
||
let (fx, fy) = (x as f64, y as f64);
|
||
let v = (fx / 37.0).sin() * (fy / 23.0).cos() + ((fx + fy) / 91.0).sin() * 0.5;
|
||
if v > 0.2 { BLACK } else { WHITE }
|
||
});
|
||
let t = std::time::Instant::now();
|
||
let res = trace(&img, &TraceParams { ignore_white: true, ..TraceParams::default() });
|
||
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||
eprintln!("1000x1000 B&W: {} paths, {} anchors in {ms:.1} ms", res.paths.len(), res.anchor_count());
|
||
assert!(!res.paths.is_empty());
|
||
assert!(ms < 300.0, "{ms} ms");
|
||
}
|
||
|
||
/// Stripes of `n` colours, each with a few dots of the next one: many layers, many small shapes.
|
||
fn stripes(size: u32, n: u32) -> Raster {
|
||
let colour = |i: u32| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 53 % 256) as u8, 255];
|
||
Raster::from_fn(size, size, |x, y| {
|
||
let band = x * n / size;
|
||
if (x % 9 < 3) && (y % 9 < 3) { colour(band + 1) } else { colour(band) }
|
||
})
|
||
}
|
||
|
||
/// #525: past the anchor budget the trace gives up with advice instead of making millions of
|
||
/// anchors; under it, it is the plain trace.
|
||
#[test]
|
||
fn a_trace_past_its_anchor_budget_is_refused() {
|
||
let img = stripes(120, 8);
|
||
let p = TraceParams { mode: Mode::Color, colors: 16, noise: 1, ..TraceParams::default() };
|
||
let full = trace(&img, &p);
|
||
let anchors = full.anchor_count();
|
||
assert!(anchors > 100, "{anchors} anchors");
|
||
assert!(matches!(trace_within(&img, &p, anchors / 2), Err(TraceError::TooComplex { max }) if max == anchors / 2));
|
||
assert_eq!(trace_within(&img, &p, anchors).unwrap(), full);
|
||
}
|
||
|
||
/// Layers traced in parallel come out in the same order every time: bottom (largest) first.
|
||
#[test]
|
||
fn parallel_layers_keep_their_order() {
|
||
let img = stripes(90, 12);
|
||
let p = TraceParams { mode: Mode::Color, colors: 24, noise: 1, method: Method::Overlapping, ..TraceParams::default() };
|
||
let a = trace(&img, &p);
|
||
assert!(a.palette.len() > 8, "{} colours", a.palette.len());
|
||
for _ in 0..4 {
|
||
assert_eq!(trace(&img, &p), a);
|
||
}
|
||
// Overlapping: each layer's shapes cover the layers above, so the pixel counts fall upwards.
|
||
let firsts: Vec<usize> = a.palette.iter().filter_map(|c| a.paths.iter().find(|t| t.color == *c).map(|t| t.pixels)).collect();
|
||
assert!(firsts.windows(2).all(|w| w[0] >= w[1]), "{firsts:?}");
|
||
}
|
||
|
||
/// A PNG whose header says `w` × `h` pixels, with no image data.
|
||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||
fn crc(bytes: &[u8]) -> u32 {
|
||
let mut c = !0u32;
|
||
for &b in bytes {
|
||
c ^= u32::from(b);
|
||
for _ in 0..8 {
|
||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||
}
|
||
}
|
||
!c
|
||
}
|
||
let mut ihdr = b"IHDR".to_vec();
|
||
ihdr.extend(w.to_be_bytes());
|
||
ihdr.extend(h.to_be_bytes());
|
||
ihdr.extend([8, 6, 0, 0, 0]);
|
||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||
for chunk in [ihdr, b"IDAT".to_vec(), b"IEND".to_vec()] {
|
||
png.extend((chunk.len() as u32 - 4).to_be_bytes());
|
||
png.extend(&chunk);
|
||
png.extend(crc(&chunk).to_be_bytes());
|
||
}
|
||
png
|
||
}
|
||
|
||
/// An image too large to trace is refused from its header, before it is decoded.
|
||
#[test]
|
||
fn an_image_too_large_to_trace_is_refused_before_decoding() {
|
||
let err = Raster::decode(&png_header(30_000, 30_000)).unwrap_err();
|
||
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}");
|
||
assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding");
|
||
}
|
||
|
||
/// An image over the tracer's size is scaled down to fit rather than refused; only one too large
|
||
/// to decode at all is refused, from its header.
|
||
#[test]
|
||
fn decode_to_fit_scales_an_oversized_image_down() {
|
||
// Within the limit: decoded as it is.
|
||
let img = disc(32, 16.0, 16.0, 10.0);
|
||
assert_eq!(Raster::decode_to_fit(&img.encode_png()).unwrap(), img);
|
||
// 8958 × 8958 (a 300 ppi scan, 80 megapixels) fits in the limit with its aspect kept.
|
||
let (w, h) = crate::fit_size(8958, 8958, MAX_PIXELS);
|
||
assert_eq!(w, h);
|
||
assert!(u64::from(w) * u64::from(h) <= MAX_PIXELS && w > 8000, "{w}");
|
||
let (w, h) = crate::fit_size(40_000, 3000, MAX_PIXELS);
|
||
assert!(u64::from(w) * u64::from(h) <= MAX_PIXELS && (f64::from(w) / f64::from(h) - 40.0 / 3.0).abs() < 0.01, "{w} × {h}");
|
||
assert_eq!(crate::fit_size(100_000, 1, 10), (10, 1));
|
||
assert_eq!(crate::fit_size(640, 480, MAX_PIXELS), (640, 480));
|
||
// Past the decoding limit: refused before decoding.
|
||
let err = Raster::decode_to_fit(&png_header(30_000, 30_000)).unwrap_err();
|
||
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, max: 192 }), "{err}");
|
||
// Between the two limits the header is accepted and decoding goes on (this one has no pixels).
|
||
assert!(matches!(Raster::decode_to_fit(&png_header(9000, 9000)), Err(TraceError::Decode(_))));
|
||
}
|
||
|
||
/// Four flat colours with anti-aliased (blended) edges between the quadrants.
|
||
fn flat_quads() -> Raster {
|
||
const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]];
|
||
Raster::from_fn(100, 100, |x, y| {
|
||
let at = |x: u32, y: u32| C[usize::from(x >= 50) + 2 * usize::from(y >= 50)];
|
||
let (a, b) = (at(x, y), at(x.saturating_sub(1), y.saturating_sub(1)));
|
||
// Blend the pixels along the quadrant edges, as an anti-aliased picture has them.
|
||
let c: Vec<u8> = a.iter().zip(b).map(|(p, q)| ((u16::from(*p) + u16::from(q)) / 2) as u8).collect();
|
||
[c[0], c[1], c[2], 255]
|
||
})
|
||
}
|
||
|
||
/// A smooth two-dimensional colour gradient (continuous tone).
|
||
fn gradient() -> Raster {
|
||
Raster::from_fn(128, 128, |x, y| [(x * 2) as u8, (y * 2) as u8, (255 - x - y / 2) as u8, 255])
|
||
}
|
||
|
||
fn palette(img: &Raster, palette: Palette, colors: u32, color_detail: f64) -> Vec<[u8; 3]> {
|
||
let p = TraceParams { mode: Mode::Color, palette, colors, color_detail, noise: 4, ..TraceParams::default() };
|
||
trace(img, &p).palette
|
||
}
|
||
|
||
#[test]
|
||
fn palettes_parse_from_their_ids_and_names() {
|
||
for (id, want) in [
|
||
("limited", Palette::Limited),
|
||
("fullTone", Palette::FullTone),
|
||
("Automatic", Palette::Automatic),
|
||
("documentLibrary", Palette::DocumentLibrary),
|
||
("library", Palette::DocumentLibrary),
|
||
] {
|
||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "palette": id })).unwrap();
|
||
assert_eq!(p.palette, want, "{id}");
|
||
}
|
||
assert_eq!(TraceParams::default().palette, Palette::Limited);
|
||
// The library's colours are looked up at every trace, not saved with the settings.
|
||
let v = serde_json::to_value(TraceParams { swatches: vec![[1, 2, 3]], ..TraceParams::default() }).unwrap();
|
||
assert!(v.get("swatches").is_none() && v["library"] == "document" && v["colorDetail"] == 50.0, "{v}");
|
||
}
|
||
|
||
#[test]
|
||
fn full_tone_follows_the_tones_the_image_has() {
|
||
// Flat art keeps its few colours (and the tones of its blended edges); a gradient gets many,
|
||
// more with more detail.
|
||
let flat = palette(&flat_quads(), Palette::FullTone, 6, 50.0);
|
||
assert!((4..=10).contains(&flat.len()), "flat art: {flat:?}");
|
||
let few = palette(&gradient(), Palette::FullTone, 6, 10.0).len();
|
||
let many = palette(&gradient(), Palette::FullTone, 6, 90.0).len();
|
||
assert!(few > 6 && many > few && many <= FULL_TONE_MAX, "{few} then {many} colours");
|
||
}
|
||
|
||
#[test]
|
||
fn automatic_traces_flat_art_with_its_colours_and_photos_in_full_tone() {
|
||
let flat = palette(&flat_quads(), Palette::Automatic, 2, 50.0);
|
||
assert_eq!(flat.len(), 4, "the four flat colours, whatever `colors` says: {flat:?}");
|
||
assert!(flat.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60), "red is one of them: {flat:?}");
|
||
let tone = palette(&gradient(), Palette::Automatic, 2, 50.0);
|
||
assert_eq!(tone.len(), palette(&gradient(), Palette::FullTone, 2, 50.0).len(), "continuous tone: as Full Tone");
|
||
}
|
||
|
||
#[test]
|
||
fn document_library_uses_the_most_used_swatches_exactly() {
|
||
let swatches = vec![[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 0, 0], [255, 255, 255]];
|
||
let p = |colors| TraceParams {
|
||
mode: Mode::Color,
|
||
palette: Palette::DocumentLibrary,
|
||
colors,
|
||
swatches: swatches.clone(),
|
||
noise: 4,
|
||
..TraceParams::default()
|
||
};
|
||
let hard = Raster::from_fn(100, 100, |x, y| match (x < 50, y < 50) {
|
||
(true, true) => [220, 30, 30, 255],
|
||
(false, true) => [30, 200, 40, 255],
|
||
(true, false) => [30, 40, 210, 255],
|
||
(false, false) => [240, 220, 20, 255],
|
||
});
|
||
let res = trace(&hard, &p(6));
|
||
let mut got = res.palette.clone();
|
||
got.sort_unstable();
|
||
assert_eq!(got, [[0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0]], "the nearest swatch of each colour, as it is");
|
||
assert!(res.paths.iter().all(|t| swatches.contains(&t.color)));
|
||
// At most `colors` of them: the ones the most pixels are nearest to.
|
||
let two = trace(&hard, &p(2)).palette;
|
||
assert!(two.len() <= 2 && two.iter().all(|c| swatches.contains(c)), "{two:?}");
|
||
// Without library colours, it traces as Limited.
|
||
let none = TraceParams { swatches: vec![], ..p(4) };
|
||
assert_eq!(trace(&hard, &none).palette.len(), 4);
|
||
}
|
||
|
||
fn strokes(fills: bool) -> TraceParams {
|
||
TraceParams { strokes: true, fills, stroke_width: 6.0, noise: 4, ..bw() }
|
||
}
|
||
|
||
/// Endpoints (first and last anchor) of each open subpath of the stroked paths.
|
||
fn stroked(res: &TraceResult) -> Vec<(&TracedPath, &vectorcraft_geom::SubPath)> {
|
||
res.paths.iter().filter(|p| p.stroke.is_some()).flat_map(|p| p.path.subpaths.iter().map(move |s| (p, s))).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_line_traces_as_one_stroked_centre_line() {
|
||
// A 3 px wide diagonal line from (20, 20) to (180, 100).
|
||
let img = Raster::from_fn(200, 120, |x, y| {
|
||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||
let t = (((px - 20.0) * 160.0 + (py - 20.0) * 80.0) / (160.0f64.powi(2) + 80.0f64.powi(2))).clamp(0.0, 1.0);
|
||
let (cx, cy) = (20.0 + 160.0 * t, 20.0 + 80.0 * t);
|
||
if (px - cx).hypot(py - cy) <= 1.5 { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &strokes(true));
|
||
assert_eq!(res.paths.len(), 1, "{:?}", res.paths.iter().map(|p| p.stroke).collect::<Vec<_>>());
|
||
let p = &res.paths[0];
|
||
let width = p.stroke.expect("stroked");
|
||
assert!((2.0..=4.0).contains(&width), "width {width}");
|
||
assert_eq!(p.path.subpaths.len(), 1);
|
||
let sp = &p.path.subpaths[0];
|
||
assert!(!sp.closed);
|
||
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
|
||
let (a, b) = if a.x < b.x { (a, b) } else { (b, a) };
|
||
assert!(a.distance(Point::new(20.0, 20.0)) < 3.0 && b.distance(Point::new(180.0, 100.0)) < 3.0, "{a:?} … {b:?}");
|
||
// A straight line: a few anchors, all on it.
|
||
assert!(sp.anchors.len() <= 4, "{} anchors", sp.anchors.len());
|
||
// Without Create Strokes, the same line is a filled outline.
|
||
let filled = trace(&img, &bw());
|
||
assert!(filled.paths.iter().all(|p| p.stroke.is_none()) && filled.paths[0].path.subpaths[0].closed);
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_ring_traces_as_a_closed_centre_line() {
|
||
let img = Raster::from_fn(120, 120, |x, y| {
|
||
let d = (x as f64 + 0.5 - 60.0).hypot(y as f64 + 0.5 - 60.0);
|
||
if (d - 40.0).abs() <= 1.5 { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &strokes(true));
|
||
let lines = stroked(&res);
|
||
assert_eq!(lines.len(), 1, "one ring");
|
||
let sp = lines[0].1;
|
||
assert!(sp.closed);
|
||
let len = PathData::single(sp.clone()).length();
|
||
let want = std::f64::consts::TAU * 40.0;
|
||
assert!((len - want).abs() / want < 0.05, "length {len} vs {want}");
|
||
}
|
||
|
||
#[test]
|
||
fn wide_areas_stay_filled_and_lines_cross_at_junctions() {
|
||
// A disc (r = 20) and, apart from it, a plus sign of 3 px wide bars.
|
||
let img = Raster::from_fn(200, 100, |x, y| {
|
||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||
let disc = (px - 50.0).hypot(py - 50.0) <= 20.0;
|
||
let plus = ((px - 150.0).abs() <= 1.5 && (20.0..=80.0).contains(&py)) || ((py - 50.0).abs() <= 1.5 && (120.0..=180.0).contains(&px));
|
||
if disc || plus { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &strokes(true));
|
||
let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect();
|
||
assert_eq!(fills.len(), 1, "the disc");
|
||
let a = net_area(&fills[0].path);
|
||
assert!((a - std::f64::consts::PI * 400.0).abs() < 60.0, "disc area {a}");
|
||
let lines = stroked(&res);
|
||
assert!((2..=4).contains(&lines.len()), "{} centre lines", lines.len());
|
||
// The centre lines stop about half a width short of each end of the bars (their round caps
|
||
// cover it).
|
||
let total: f64 = lines.iter().map(|(_, s)| PathData::single((*s).clone()).length()).sum();
|
||
assert!((106.0..=120.0).contains(&total), "both bars' length: {total}");
|
||
let ends: Vec<Point> = lines.iter().flat_map(|(_, s)| [s.anchors[0].p, s.anchors[s.anchors.len() - 1].p]).collect();
|
||
for want in [Point::new(150.0, 20.0), Point::new(150.0, 80.0), Point::new(120.0, 50.0), Point::new(180.0, 50.0)] {
|
||
assert!(ends.iter().any(|e| e.distance(want) < 4.0), "a line ends near {want:?}: {ends:?}");
|
||
}
|
||
// Strokes only: the disc is outlined.
|
||
let res = trace(&img, &strokes(false));
|
||
assert!(res.paths.iter().all(|p| p.stroke.is_some()));
|
||
assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline");
|
||
}
|
||
|
||
#[test]
|
||
fn create_settings_parse_with_defaults() {
|
||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap();
|
||
assert!(p.fills && p.strokes && p.stroke_width == 4.0);
|
||
let d = TraceParams::default();
|
||
assert!(d.fills && !d.strokes && d.stroke_width == 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn create_strokes_survives_noise_and_tiny_images() {
|
||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||
let mut next = move || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 7;
|
||
seed ^= seed << 17;
|
||
seed
|
||
};
|
||
let noise: Vec<u8> = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect();
|
||
let noise = Raster::new(120, 120, noise);
|
||
for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] {
|
||
for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] {
|
||
for noise in [0, 1, 4] {
|
||
let p = TraceParams { strokes: true, fills, stroke_width: width, noise, ..TraceParams::default() };
|
||
for t in trace(&img, &p).paths {
|
||
assert!(t.stroke.is_none_or(|w| w.is_finite() && w >= 1.0), "{:?}", t.stroke);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The 4-connected same-label components of `labels` (transparent pixels left out): each one's size
|
||
/// and whether it touches a pixel of another opaque label.
|
||
fn components(labels: &[u16], w: usize, h: usize) -> Vec<(usize, bool)> {
|
||
let mut seen = vec![false; w * h];
|
||
let mut out = vec![];
|
||
for start in 0..w * h {
|
||
if seen[start] || labels[start] == TRANSPARENT {
|
||
continue;
|
||
}
|
||
let l = labels[start];
|
||
let (mut size, mut touches, mut stack) = (0, false, vec![start]);
|
||
seen[start] = true;
|
||
while let Some(i) = stack.pop() {
|
||
size += 1;
|
||
let (x, y) = (i % w, i / w);
|
||
let near = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||
for j in near.into_iter().flatten() {
|
||
if labels[j] == l {
|
||
if !seen[j] {
|
||
seen[j] = true;
|
||
stack.push(j);
|
||
}
|
||
} else if labels[j] != TRANSPARENT {
|
||
touches = true;
|
||
}
|
||
}
|
||
}
|
||
out.push((size, touches));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// #819: a speck merged into a small region that is itself merged later was left behind as a new
|
||
/// island of the small region's old label.
|
||
#[test]
|
||
fn denoise_leaves_no_island_behind_a_merged_region() {
|
||
// One row: a 1-pixel speck, a 2-pixel region, then a large one; nothing under 3 pixels may stay.
|
||
let mut labels = vec![0, 1, 1, 2, 2, 2, 2, 2];
|
||
denoise(&mut labels, 8, 1, 3);
|
||
assert_eq!(labels, vec![2; 8]);
|
||
}
|
||
|
||
/// On noise, Remove Noise leaves no region under the minimum that could be merged, and a larger
|
||
/// minimum never leaves more regions (#819: more noise removal gave more paths).
|
||
#[test]
|
||
fn denoise_merges_every_small_region_and_more_noise_never_means_more_regions() {
|
||
let (w, h) = (48, 40);
|
||
let mut seed = 0x2545_f491_u32;
|
||
let mut next = || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
seed
|
||
};
|
||
for colors in [2u16, 3, 5, 8] {
|
||
// Blocky noise: random 1-4 pixel runs, so regions of every size touch each other.
|
||
let mut base = vec![0u16; w * h];
|
||
let mut i = 0;
|
||
while i < w * h {
|
||
let (l, run) = ((next() % u32::from(colors)) as u16, 1 + next() as usize % 4);
|
||
for p in base.iter_mut().skip(i).take(run) {
|
||
*p = l;
|
||
}
|
||
i += run;
|
||
}
|
||
let mut last = usize::MAX;
|
||
for min_area in [1usize, 2, 4, 8, 16, 32, 64, 128] {
|
||
let mut labels = base.clone();
|
||
denoise(&mut labels, w, h, min_area);
|
||
let comps = components(&labels, w, h);
|
||
if min_area > 1 {
|
||
assert!(
|
||
comps.iter().all(|&(size, touches)| size >= min_area || !touches),
|
||
"{colors} colours, min {min_area}: a region under the minimum is left: {comps:?}"
|
||
);
|
||
}
|
||
assert!(comps.len() <= last, "{colors} colours: min {min_area} leaves {} regions, more than the {last} a smaller one left", comps.len());
|
||
last = comps.len();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #819: on noisy scan-like images, raising Noise never gives more paths. Before the fix, these
|
||
/// two went from 70 paths to 126 to 206 as Noise rose from 50 to 200 (and 22 to 25).
|
||
#[test]
|
||
fn more_noise_removal_never_gives_more_paths() {
|
||
for (seed0, colors) in [(15u32, 2usize), (9, 6)] {
|
||
let mut seed = seed0.wrapping_mul(0x9e37_79b9) | 1;
|
||
let mut next = move || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
seed
|
||
};
|
||
let ink: Vec<[u8; 4]> = (0..colors)
|
||
.map(|_| {
|
||
let v = next();
|
||
[(v & 255) as u8, (v >> 8 & 255) as u8, (v >> 16 & 255) as u8, 255]
|
||
})
|
||
.collect();
|
||
// Diagonal bands of ink with a quarter of the pixels speckled at random.
|
||
let (w, h) = (121, 101);
|
||
let px: Vec<[u8; 4]> = (0..w * h)
|
||
.map(|i| {
|
||
let band = ((i % w) / 7 + (i / w) / 5) % colors;
|
||
if next() % 4 == 0 { ink[next() as usize % colors] } else { ink[band] }
|
||
})
|
||
.collect();
|
||
let img = Raster::from_fn(120, 100, |x, y| px[y as usize * w + x as usize]);
|
||
let mut last = (0, usize::MAX);
|
||
for noise in [1, 4, 8, 12, 25, 50, 100, 200] {
|
||
let n = trace(&img, &TraceParams { noise, ..TraceParams::default() }).paths.len();
|
||
assert!(n <= last.1, "image {seed0}: Noise {noise} gives {n} paths, more than the {} of Noise {}", last.1, last.0);
|
||
last = (noise, n);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A shape drawn anti-aliased: each pixel's ink is the share of its 8 × 8 samples that `inside` holds.
|
||
fn soft(w: u32, h: u32, inside: impl Fn(f64, f64) -> bool) -> Raster {
|
||
Raster::from_fn(w, h, |x, y| {
|
||
let n = (0..64).filter(|i| inside(f64::from(x) + (f64::from(i % 8) + 0.5) / 8.0, f64::from(y) + (f64::from(i / 8) + 0.5) / 8.0)).count();
|
||
let v = (255.0 * (1.0 - n as f64 / 64.0)).round() as u8;
|
||
[v, v, v, 255]
|
||
})
|
||
}
|
||
|
||
/// Points along every curve of `p`.
|
||
fn along(p: &vectorcraft_geom::PathData) -> Vec<Point> {
|
||
use vectorcraft_geom::ParamCurve;
|
||
p.subpaths
|
||
.iter()
|
||
.flat_map(|sp| (0..sp.segment_count()).flat_map(move |i| (0..16).map(move |k| sp.segment(i).eval(f64::from(k) / 16.0))))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn an_anti_aliased_disc_is_traced_to_a_fraction_of_a_pixel() {
|
||
let (c, r) = (Point::new(60.3, 59.6), 40.4);
|
||
let res = trace(&soft(120, 120, |x, y| Point::new(x, y).distance(c) <= r), &bw());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let off = along(&res.paths[0].path).iter().map(|p| (p.distance(c) - r).abs()).fold(0.0, f64::max);
|
||
assert!(off < 0.12, "{off} px off the circle");
|
||
// Smooth all the way round, with a handful of anchors.
|
||
assert!(res.paths[0].path.anchor_count() <= 8, "{} anchors", res.paths[0].path.anchor_count());
|
||
assert!(res.paths[0].path.subpaths[0].anchors.iter().all(|a| a.kind == vectorcraft_geom::AnchorKind::Smooth));
|
||
}
|
||
|
||
#[test]
|
||
fn an_anti_aliased_square_has_four_corners_and_straight_sides() {
|
||
// Edges between pixel rows and columns, turned a little so that none follows the grid.
|
||
let (c, half, turn) = (Point::new(50.3, 49.7), 30.2, 0.2f64);
|
||
let inside = |x: f64, y: f64| {
|
||
let (dx, dy) = (x - c.x, y - c.y);
|
||
(dx * turn.cos() + dy * turn.sin()).abs() <= half && (dy * turn.cos() - dx * turn.sin()).abs() <= half
|
||
};
|
||
let res = trace(&soft(100, 100, inside), &bw());
|
||
let sp = &res.paths[0].path.subpaths[0];
|
||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||
for a in &sp.anchors {
|
||
let (dx, dy) = (a.p.x - c.x, a.p.y - c.y);
|
||
let (u, v) = (dx * turn.cos() + dy * turn.sin(), dy * turn.cos() - dx * turn.sin());
|
||
assert!((u.abs() - half).abs() < 0.15 && (v.abs() - half).abs() < 0.15, "corner {:?} is off", a.p);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_anti_aliased_line_keeps_its_place_and_width() {
|
||
// 1.2 px wide, lying across two pixel rows.
|
||
let res = trace(&soft(60, 20, |x, y| (5.0..55.0).contains(&x) && (9.7..10.9).contains(&y)), &bw());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let b = res.paths[0].path.bounds().unwrap();
|
||
assert!((b.y0 - 9.7).abs() < 0.1 && (b.y1 - 10.9).abs() < 0.1, "{b:?}");
|
||
assert!((b.x0 - 5.0).abs() < 0.1 && (b.x1 - 55.0).abs() < 0.1, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn touching_colours_get_the_same_edge() {
|
||
// Red left of a slanted edge, blue right of it: where the paths meet, each lies on the edge.
|
||
let edge = |y: f64| 30.3 + 0.2 * y;
|
||
let img = Raster::from_fn(60, 40, |x, y| {
|
||
let t = (edge(f64::from(y) + 0.5) - f64::from(x)).clamp(0.0, 1.0);
|
||
[(200.0 * t + 30.0 * (1.0 - t)) as u8, (40.0 * t + 90.0 * (1.0 - t)) as u8, (50.0 * t + 200.0 * (1.0 - t)) as u8, 255]
|
||
});
|
||
let res = trace(&img, &TraceParams { mode: Mode::Color, colors: 2, ..TraceParams::default() });
|
||
assert_eq!(res.paths.len(), 2);
|
||
for p in &res.paths {
|
||
let inner: Vec<Point> = along(&p.path).into_iter().filter(|q| q.x > 1.0 && q.x < 59.0 && q.y > 1.0 && q.y < 39.0).collect();
|
||
assert!(!inner.is_empty());
|
||
let off = inner.iter().map(|q| (q.x - edge(q.y)).abs()).fold(0.0, f64::max);
|
||
assert!(off < 0.12, "{off} px off the shared edge");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_hard_edged_disc_is_still_round() {
|
||
let res = trace(&disc(120, 60.0, 60.0, 40.0), &bw());
|
||
let c = Point::new(60.0, 60.0);
|
||
let off = along(&res.paths[0].path).iter().map(|p| (p.distance(c) - 40.0).abs()).fold(0.0, f64::max);
|
||
assert!(off < 0.75, "{off} px off the circle");
|
||
assert!(res.paths[0].path.anchor_count() <= 12, "{} anchors", res.paths[0].path.anchor_count());
|
||
}
|
||
|
||
#[test]
|
||
fn a_higher_threshold_widens_a_blurred_shape_smoothly() {
|
||
// A soft-edged disc: its luminance ramps over six pixels.
|
||
let img = Raster::from_fn(100, 100, |x, y| {
|
||
let d = Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5).distance(Point::new(50.0, 50.0));
|
||
let v = (255.0 * ((d - 27.0) / 6.0).clamp(0.0, 1.0)) as u8;
|
||
[v, v, v, 255]
|
||
});
|
||
let radius = |threshold: u8| {
|
||
let res = trace(&img, &TraceParams { threshold, ..bw() });
|
||
let r: Vec<f64> = along(&res.paths[0].path).iter().map(|p| p.distance(Point::new(50.0, 50.0))).collect();
|
||
(r.iter().copied().fold(f64::MAX, f64::min), r.iter().copied().fold(0.0, f64::max))
|
||
};
|
||
let (low, high) = (radius(64), radius(192));
|
||
// Luminance 64 is 1.5 px into the ramp and 192 4.5 px.
|
||
assert!((low.0 - 28.5).abs() < 0.6 && (low.1 - 28.5).abs() < 0.6, "{low:?}");
|
||
assert!((high.0 - 31.5).abs() < 0.6 && (high.1 - 31.5).abs() < 0.6, "{high:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn no_path_of_a_noisy_picture_leaves_the_picture() {
|
||
// Texture and noise: thousands of small ragged regions, where a fit is most likely to stray.
|
||
let img = Raster::from_fn(160, 120, |x, y| {
|
||
let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77);
|
||
v ^= v >> 15;
|
||
v = v.wrapping_mul(0x2C1B_3C6D);
|
||
v ^= v >> 12;
|
||
let wave = 60.0 * ((f64::from(x) * 0.31).sin() * (f64::from(y) * 0.23).cos());
|
||
let c = |k: u32| (128.0 + wave + f64::from((v >> (8 * k)) & 63) + f64::from(x + 2 * y) * 0.2 * f64::from(k)).clamp(0.0, 255.0) as u8;
|
||
[c(0), c(1), c(2), 255]
|
||
});
|
||
for name in PRESET_NAMES {
|
||
let res = trace(&img, &preset(name).unwrap());
|
||
for p in &res.paths {
|
||
// A fit may bulge past the picture's border by its tolerance, never by more.
|
||
let Some(b) = p.path.bounds() else { continue };
|
||
assert!(b.x0 > -2.0 && b.y0 > -2.0 && b.x1 < 162.0 && b.y1 < 122.0, "{name}: a path reaches {b:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A deterministic value in `-1.0..1.0` for a pixel.
|
||
fn jitter(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;
|
||
f64::from(v & 0xFFFF) / 32768.0 - 1.0
|
||
}
|
||
|
||
/// Three flat colours (a navy field, a yellow disc, a green bar) drawn anti-aliased, with `noise`
|
||
/// levels of noise per channel.
|
||
fn three_colour_art(noise: f64) -> Raster {
|
||
const NAVY: [f64; 3] = [20.0, 40.0, 90.0];
|
||
const YELLOW: [f64; 3] = [250.0, 200.0, 40.0];
|
||
const GREEN: [f64; 3] = [40.0, 160.0, 90.0];
|
||
Raster::from_fn(120, 120, |x, y| {
|
||
let mut sum = [0.0; 3];
|
||
for i in 0..16 {
|
||
let (sx, sy) = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0);
|
||
let c = if Point::new(sx, sy).distance(Point::new(60.3, 45.6)) <= 30.0 {
|
||
YELLOW
|
||
} else if (20.4..100.7).contains(&sx) && (90.3..108.6).contains(&sy) {
|
||
GREEN
|
||
} else {
|
||
NAVY
|
||
};
|
||
for k in 0..3 {
|
||
sum[k] += c[k] / 16.0;
|
||
}
|
||
}
|
||
let c = |k: usize| (sum[k] + noise * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8;
|
||
[c(0), c(1), c(2), 255]
|
||
})
|
||
}
|
||
|
||
fn sixteen_colors() -> TraceParams {
|
||
preset("16 Colors").unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn more_colours_than_flat_art_has_trace_its_own_colours() {
|
||
for noise in [0.0, 8.0] {
|
||
let res = trace(&three_colour_art(noise), &sixteen_colors());
|
||
assert_eq!(res.palette.len(), 3, "noise {noise}: {:?}", res.palette);
|
||
assert_eq!(res.paths.len(), 3, "noise {noise}");
|
||
for want in [[20, 40, 90], [250, 200, 40], [40, 160, 90]] {
|
||
assert!(res.palette.iter().any(|c| (0..3).all(|k| c[k].abs_diff(want[k]) <= 3)), "noise {noise}: no {want:?} in {:?}", res.palette);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_blend_of_two_colours_is_no_sliver_of_a_third() {
|
||
// Halfway from navy to yellow lies an olive nearer to the green of the bar than to either.
|
||
let res = trace(&three_colour_art(0.0), &TraceParams { colors: 3, noise: 0, ..sixteen_colors() });
|
||
let green: Vec<&TracedPath> = res.paths.iter().filter(|p| p.color[1] > p.color[0] && p.color[1] > p.color[2]).collect();
|
||
assert_eq!(green.len(), 1, "the bar, and nothing around the disc");
|
||
let b = green[0].path.bounds().unwrap();
|
||
assert!(b.y0 > 89.0 && b.y1 < 110.0, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn hairlines_keep_their_colour_in_color_mode() {
|
||
// Black lines one pixel wide on white: every black pixel is on an edge, none is a blend.
|
||
let img = Raster::from_fn(80, 80, |x, y| if x % 10 == 4 || y % 10 == 4 { BLACK } else { WHITE });
|
||
let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
let black = q.palette.iter().position(|c| *c == [0, 0, 0]).unwrap() as u16;
|
||
assert_eq!(q.palette.len(), 2);
|
||
assert!(q.palette.contains(&[255, 255, 255]));
|
||
assert_eq!(q.labels.iter().filter(|&&l| l == black).count(), 8 * 80 * 2 - 64);
|
||
}
|
||
|
||
#[test]
|
||
fn the_bands_of_a_gradient_stay_apart() {
|
||
let img = Raster::from_fn(256, 64, |x, y| [x as u8, (x / 2 + y) as u8, 200 - (x / 2) as u8, 255]);
|
||
let q = quantize(&img, &sixteen_colors());
|
||
assert_eq!(q.palette.len(), 16);
|
||
// With noise as strong as the bands are apart, the bands still follow the gradient.
|
||
let noisy = Raster::from_fn(256, 64, |x, y| {
|
||
let c = |v: u32, k: u32| (f64::from(v) + 6.0 * jitter(x, y, k)).round().clamp(0.0, 255.0) as u8;
|
||
[c(x, 0), c(x / 2 + y, 1), c(200 - x / 2, 2), 255]
|
||
});
|
||
assert!(quantize(&noisy, &sixteen_colors()).palette.len() >= 12);
|
||
}
|
||
|
||
#[test]
|
||
fn a_noisy_gradient_keeps_its_tones_without_specks() {
|
||
// Tones 0–255 across with noise larger than High Fidelity Photo's 64 steps are apart.
|
||
let img = Raster::from_fn(256, 96, |x, y| {
|
||
let c = |k: u32| (f64::from(x) + 5.0 * jitter(x, y, k)).round().clamp(0.0, 255.0) as u8;
|
||
[c(0), c(1), c(2), 255]
|
||
});
|
||
let params = preset("High Fidelity Photo").unwrap();
|
||
let res = trace(&img, ¶ms);
|
||
// Smoothed first, the noise mixes fewer neighbouring tones into one (`quantize` alone works
|
||
// on the picture as it is), and leaves no specks.
|
||
let unsmoothed = quantize(&img, ¶ms).palette.len();
|
||
assert!(res.palette.len() > unsmoothed && res.palette.len() >= 24, "{} colours, {unsmoothed} unsmoothed", res.palette.len());
|
||
assert!(res.paths.len() <= 2 * res.palette.len(), "{} paths of {} colours", res.paths.len(), res.palette.len());
|
||
}
|
||
|
||
#[test]
|
||
fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() {
|
||
// Stripes: black, white, mid gray, light gray (the widest).
|
||
let img = Raster::from_fn(100, 20, |x, _| {
|
||
let v = match x {
|
||
0..20 => 0,
|
||
20..40 => 255,
|
||
40..60 => 120,
|
||
_ => 200,
|
||
};
|
||
[v, v, v, 255]
|
||
});
|
||
let params = TraceParams { mode: Mode::Grayscale, colors: 4, noise: 0, method: Method::Overlapping, ..TraceParams::default() };
|
||
let tones = |res: &TraceResult| res.paths.iter().map(|p| p.color[0]).collect::<Vec<u8>>();
|
||
// The largest colour is light, so the stack starts at the light end.
|
||
// (Each layer reaches under those above it: under the black stripe too, a part of its own.)
|
||
assert_eq!(tones(&trace(&img, ¶ms)), [255, 200, 200, 120, 120, 0]);
|
||
// Without white, no layer reaches under where it was.
|
||
let res = trace(&img, &TraceParams { ignore_white: true, ..params });
|
||
assert_eq!(tones(&res), [200, 200, 120, 120, 0]);
|
||
for p in &res.paths {
|
||
let b = p.path.bounds().unwrap();
|
||
assert!(b.x1 <= 20.5 || b.x0 >= 39.5, "a {} layer covers the white stripe: {b:?}", p.color[0]);
|
||
}
|
||
}
|