Files
effectcraft/crates/path/src/trace.rs
T
Dylan M. Taylor 2c95408777 Auto-trace: bound the nesting, simplification and fitting work (#2945)
* Auto-trace: bound the nesting, simplification and fitting work

Many separate regions made the nesting depth test every pair of contours,
Douglas-Peucker could rescan a span once per point (and recurse as deep as
the loop is long), and the curve fit searched the raw contour from its start
for every simplified vertex.

- Depth: each loop's bounding box goes into a coarse grid; a loop's first
  point is tested only against loops whose box holds it. Same depths as
  testing every pair.
- Douglas-Peucker: spans wait on an explicit stack, with a budget of 64
  distance evaluations per point; spans still waiting when it runs out keep
  all their points, so the outline stays exact.
- Fitting: uses the raw indices the simplifier kept instead of searching.

* cargo fmt; clippy manual_is_multiple_of

* address review: keep wide contour boxes out of the nesting grid
2026-11-10 17:59:07 -05:00

556 lines
23 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.
//! Auto-trace: outlines of a scalar field (a layer's alpha, a colour channel or luminance) as
//! Bezier paths (Layer ▸ Auto-trace).
//!
//! 1. **Marching squares** over the samples at pixel centres, with linear interpolation of the
//! threshold crossing on each cell edge (saddles resolved by the cell's mean), on a field padded
//! with "outside" so every contour closes.
//! 2. **Douglas–Peucker** simplification of each closed loop to the Tolerance (pixels).
//! 3. **Bezier fitting**: each simplified span is fitted with one cubic (least squares with fixed
//! end tangents, Newton reparameterisation; `crate::fit`). The tangent at each vertex is the
//! chord direction at Corner Roundness 0 (straight segments, sharp corners) and the smooth
//! (Catmull–Rom) direction at 100 %, blended between.
//!
//! Loops smaller than Minimum Area are dropped. Each loop gets its nesting depth (0 = outermost),
//! so callers can add outer outlines and subtract holes.
use std::collections::HashMap;
use effectcraft_keyframe::ShapePath;
use kurbo::{Point, Vec2};
use crate::fit::{fit_single, unit};
/// Auto-trace options.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TraceOpts {
/// Inside where the field is at least this (0…1).
pub threshold: f32,
/// Douglas–Peucker tolerance in pixels.
pub tolerance: f64,
/// Loops with a smaller area (square pixels) are dropped.
pub min_area: f64,
/// 0 = polygons, 1 = fully smooth.
pub corner_roundness: f64,
}
impl Default for TraceOpts {
fn default() -> Self {
TraceOpts { threshold: 0.5, tolerance: 1.0, min_area: 10.0, corner_roundness: 0.5 }
}
}
/// A traced outline.
#[derive(Clone, Debug, PartialEq)]
pub struct Traced {
pub path: ShapePath,
/// Number of other outlines that contain this one (even = filled, odd = hole).
pub depth: usize,
/// Signed area of the simplified polygon (pixels²).
pub area: f64,
}
/// Trace `field` (`w × h`, row-major, sample (x, y) at pixel centre (x + 0.5, y + 0.5)).
pub fn trace(field: &[f32], w: usize, h: usize, o: &TraceOpts) -> Vec<Traced> {
if w == 0 || h == 0 || field.len() < w * h {
return vec![];
}
let loops = contours(field, w, h, o.threshold);
// (raw loop, kept raw indices, simplified loop, area)
let mut polys: Vec<(Vec<[f64; 2]>, Vec<usize>, Vec<[f64; 2]>, f64)> = loops
.into_iter()
.filter_map(|raw| {
let a = signed_area(&raw);
if a.abs() < o.min_area.max(0.0) || raw.len() < 3 {
return None;
}
let idx = simplify_idx(&raw, o.tolerance.max(0.01));
if idx.len() < 3 {
return None;
}
let simp = idx.iter().map(|&i| raw[i]).collect();
Some((raw, idx, simp, a))
})
.collect();
let depths = nesting_depths(&polys.iter().map(|p| (p.2.as_slice(), p.3.abs())).collect::<Vec<_>>(), w, h);
let mut out: Vec<Traced> = polys
.drain(..)
.zip(depths)
.map(|((raw, idx, simp, a), depth)| Traced { path: fit_loop(&raw, &idx, &simp, o.corner_roundness.clamp(0.0, 1.0)), depth, area: a })
.collect();
// Outer outlines first, then holes, islands…; larger first within a depth.
out.sort_by(|a, b| a.depth.cmp(&b.depth).then(b.area.abs().total_cmp(&a.area.abs())));
out
}
/// Grid cell (pixels) of the index [`nesting_depths`] finds candidate containers with.
const DEPTH_CELL: f64 = 32.0;
/// Most grid cells one loop's box is entered into; a box covering more is kept in a short list
/// every point is tested against, so nested full-image contours can't fill the grid.
const MAX_BOX_CELLS: usize = 64;
/// Per loop (simplified points, |area|): how many other, larger loops contain its first point.
/// Only loops whose bounding box holds that point can contain it, so each loop's box is entered
/// into a coarse grid over the `w × h` field and a point is tested against the loops of its
/// cell alone: many separate regions no longer test every pair.
fn nesting_depths(polys: &[(&[[f64; 2]], f64)], w: usize, h: usize) -> Vec<usize> {
// Loops lie within the field padded by half a pixel each side.
let (gw, gh) = (((w as f64 + 1.0) / DEPTH_CELL).ceil() as usize + 1, ((h as f64 + 1.0) / DEPTH_CELL).ceil() as usize + 1);
let cell = |v: f64, n: usize| (((v + 0.5) / DEPTH_CELL).floor().max(0.0) as usize).min(n - 1);
let boxes: Vec<[f64; 4]> = polys
.iter()
.map(|(p, _)| {
p.iter().fold([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY], |b, q| {
[b[0].min(q[0]), b[1].min(q[1]), b[2].max(q[0]), b[3].max(q[1])]
})
})
.collect();
let mut grid: Vec<Vec<usize>> = vec![Vec::new(); gw * gh];
let mut wide: Vec<usize> = Vec::new();
for (j, b) in boxes.iter().enumerate() {
let (x0, x1, y0, y1) = (cell(b[0], gw), cell(b[2], gw), cell(b[1], gh), cell(b[3], gh));
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > MAX_BOX_CELLS {
wide.push(j);
continue;
}
for cy in y0..=y1 {
for cx in x0..=x1 {
grid[cy * gw + cx].push(j);
}
}
}
(0..polys.len())
.map(|i| {
let p = polys[i].0[0];
grid[cell(p[1], gh) * gw + cell(p[0], gw)]
.iter()
.chain(&wide)
.filter(|&&j| {
let b = boxes[j];
j != i && polys[j].1 > polys[i].1 && p[0] >= b[0] && p[0] <= b[2] && p[1] >= b[1] && p[1] <= b[3] && point_in_poly(p, polys[j].0)
})
.count()
})
.collect()
}
/// Marching squares: closed polylines (pixel coordinates).
fn contours(field: &[f32], w: usize, h: usize, thr: f32) -> Vec<Vec<[f64; 2]>> {
// Padded grid: sample (i, j) of the padded grid is pixel (i - 1, j - 1), outside = -inf.
let (gw, gh) = (w + 2, h + 2);
let val = |i: usize, j: usize| -> f32 { if i == 0 || j == 0 || i > w || j > h { f32::NEG_INFINITY } else { field[(j - 1) * w + (i - 1)] } };
let inside = |i: usize, j: usize| val(i, j) >= thr;
// Edge ids: horizontal edge (i, j)-(i+1, j) = 2 * (j * gw + i); vertical (i, j)-(i, j+1) = +1.
let pos = |i: usize, j: usize| [i as f64 - 0.5, j as f64 - 0.5];
let cross = |a: (usize, usize), b: (usize, usize)| -> [f64; 2] {
let (va, vb) = (val(a.0, a.1), val(b.0, b.1));
let t = if va.is_finite() && vb.is_finite() && (vb - va).abs() > 1e-12 {
((thr - va) / (vb - va)).clamp(0.0, 1.0) as f64
} else if va.is_finite() {
// b is padding: the crossing sits half a pixel out.
if va >= thr { 0.5 } else { 0.0 }
} else if vb >= thr {
0.5
} else {
1.0
};
let (pa, pb) = (pos(a.0, a.1), pos(b.0, b.1));
[pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t]
};
let hid = |i: usize, j: usize| 2 * (j * gw + i);
let vid = |i: usize, j: usize| 2 * (j * gw + i) + 1;
// Directed segments (from edge, to edge) keeping the inside on the left.
let mut next: HashMap<usize, usize> = HashMap::new();
let mut point: HashMap<usize, [f64; 2]> = HashMap::new();
for j in 0..gh - 1 {
for i in 0..gw - 1 {
// Corners: tl (i, j), tr (i+1, j), br (i+1, j+1), bl (i, j+1).
let (tl, tr, br, bl) = (inside(i, j), inside(i + 1, j), inside(i + 1, j + 1), inside(i, j + 1));
let code = (tl as u8) << 3 | (tr as u8) << 2 | (br as u8) << 1 | bl as u8;
if code == 0 || code == 15 {
continue;
}
let top = hid(i, j);
let bottom = hid(i, j + 1);
let left = vid(i, j);
let right = vid(i + 1, j);
let mut seg = |a: usize, b: usize| {
next.insert(a, b);
};
// Walk with the inside on the right in image coordinates (y down), which is
// counter-clockwise on screen for outer loops… orientation is fixed afterwards.
match code {
1 => seg(left, bottom),
2 => seg(bottom, right),
3 => seg(left, right),
4 => seg(right, top),
5 => {
// tr + bl inside (saddle).
let centre = (val(i, j) + val(i + 1, j) + val(i + 1, j + 1) + val(i, j + 1)) / 4.0;
if centre >= thr {
seg(left, top);
seg(right, bottom);
} else {
seg(left, bottom);
seg(right, top);
}
}
6 => seg(bottom, top),
7 => seg(left, top),
8 => seg(top, left),
9 => seg(top, bottom),
10 => {
// tl + br inside (saddle).
let centre = (val(i, j) + val(i + 1, j) + val(i + 1, j + 1) + val(i, j + 1)) / 4.0;
if centre >= thr {
seg(top, right);
seg(bottom, left);
} else {
seg(top, left);
seg(bottom, right);
}
}
11 => seg(top, right),
12 => seg(right, left),
13 => seg(right, bottom),
14 => seg(bottom, left),
_ => {}
}
for (e, a, b) in [(top, (i, j), (i + 1, j)), (bottom, (i, j + 1), (i + 1, j + 1)), (left, (i, j), (i, j + 1)), (right, (i + 1, j), (i + 1, j + 1))]
{
if inside(a.0, a.1) != inside(b.0, b.1) {
point.entry(e).or_insert_with(|| cross(a, b));
}
}
}
}
let mut out = vec![];
let mut keys: Vec<usize> = next.keys().copied().collect();
keys.sort_unstable();
for start in keys {
if !next.contains_key(&start) {
continue;
}
let mut poly = vec![];
let mut e = start;
while let Some(n) = next.remove(&e) {
if let Some(p) = point.get(&e) {
poly.push(*p);
}
e = n;
if e == start {
break;
}
}
if poly.len() >= 3 {
out.push(poly);
}
}
out
}
/// Shoelace area (positive = clockwise on screen with y down).
pub fn signed_area(p: &[[f64; 2]]) -> f64 {
let n = p.len();
let mut a = 0.0;
for i in 0..n {
let (q, r) = (p[i], p[(i + 1) % n]);
a += q[0] * r[1] - r[0] * q[1];
}
a * 0.5
}
fn point_in_poly(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
let mut inside = false;
let n = poly.len();
let mut j = n - 1;
for i in 0..n {
let (a, b) = (poly[i], poly[j]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
inside = !inside;
}
j = i;
}
inside
}
fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
((p[0] - a[0] - t * dx).powi(2) + (p[1] - a[1] - t * dy).powi(2)).sqrt()
}
/// Distance evaluations Douglas–Peucker may spend per point of a loop. Balanced splits need
/// about log2(n); splits that keep landing next to an end would need up to n.
const DP_WORK_PER_POINT: usize = 64;
/// Douglas–Peucker on an open polyline: kept indices (unordered). Spans wait on an explicit
/// stack (no recursion depth to overflow), and once `budget` distance evaluations are spent the
/// spans still waiting keep all their points: the outline stays exact, just less simplified.
fn dp(p: &[[f64; 2]], lo: usize, hi: usize, tol: f64, budget: &mut usize, keep: &mut Vec<usize>) {
let mut spans = vec![(lo, hi)];
while let Some((lo, hi)) = spans.pop() {
if hi <= lo + 1 {
continue;
}
if *budget < hi - lo - 1 {
keep.extend(lo + 1..hi);
continue;
}
*budget -= hi - lo - 1;
let (mut best, mut bi) = (0.0, lo);
for (k, q) in p.iter().enumerate().take(hi).skip(lo + 1) {
let d = seg_dist(*q, p[lo], p[hi]);
if d > best {
best = d;
bi = k;
}
}
if best > tol {
keep.push(bi);
spans.push((lo, bi));
spans.push((bi, hi));
}
}
}
/// Douglas–Peucker on a closed loop: indices into `p` of the kept vertices, in order.
fn simplify_idx(p: &[[f64; 2]], tol: f64) -> Vec<usize> {
let n = p.len();
// Split at the two mutually farthest-ish points (first point and the point farthest from it).
let far = (0..n).max_by(|&a, &b| {
let d = |i: usize| (p[i][0] - p[0][0]).powi(2) + (p[i][1] - p[0][1]).powi(2);
d(a).total_cmp(&d(b))
});
let far = far.unwrap_or(0);
let mut ext: Vec<[f64; 2]> = p.to_vec();
ext.push(p[0]);
let mut keep = vec![0];
let mut budget = n.saturating_mul(DP_WORK_PER_POINT);
dp(&ext, 0, far, tol, &mut budget, &mut keep);
keep.push(far);
dp(&ext, far, n, tol, &mut budget, &mut keep);
keep.sort_unstable();
keep.dedup();
keep.retain(|&i| i < n);
keep
}
/// Fit cubics through the simplified vertices of a loop (`raw` holds the full contour, `idx`
/// the raw index of each simplified vertex, as [`simplify_idx`] kept them).
fn fit_loop(raw: &[[f64; 2]], idx: &[usize], simp: &[[f64; 2]], round: f64) -> ShapePath {
let n = simp.len();
if round <= 1e-6 {
return ShapePath::polygon(simp, true);
}
let pt = |a: [f64; 2]| Point::new(a[0], a[1]);
let v = |a: [f64; 2], b: [f64; 2]| Vec2::new(b[0] - a[0], b[1] - a[1]);
// Per vertex: (direction leaving, direction arriving), blended between chord and smooth.
let dirs: Vec<(Vec2, Vec2)> = (0..n)
.map(|i| {
let (prev, cur, nxt) = (simp[(i + n - 1) % n], simp[i], simp[(i + 1) % n]);
let smooth = unit(v(prev, nxt)).unwrap_or(Vec2::new(1.0, 0.0));
let out_chord = unit(v(cur, nxt)).unwrap_or(smooth);
let in_chord = unit(v(prev, cur)).unwrap_or(smooth);
let leave = unit(out_chord * (1.0 - round) + smooth * round).unwrap_or(out_chord);
let arrive = unit(in_chord * (1.0 - round) + smooth * round).unwrap_or(in_chord);
(leave, arrive)
})
.collect();
let mut path = ShapePath::polygon(simp, true);
for i in 0..n {
let j = (i + 1) % n;
let (a, b) = (idx[i], idx[j]);
let mut pts: Vec<Point> =
if b > a { raw[a..=b].iter().map(|q| pt(*q)).collect() } else { raw[a..].iter().chain(raw[..=b].iter()).map(|q| pt(*q)).collect() };
if pts.len() < 2 {
pts = vec![pt(simp[i]), pt(simp[j])];
}
let chord = pts[0].distance(pts[pts.len() - 1]);
let (t0, t1) = (dirs[i].0, dirs[j].1);
let (c, _, _) = if pts.len() >= 3 { fit_single(&pts, t0, t1) } else { (kurbo::CubicBez::new(pts[0], pts[0], pts[1], pts[1]), 0.0, 0) };
// Keep handles pointing along the tangents and within the chord.
let max = chord * 0.5 * round.max(0.34);
let l0 = (c.p1 - c.p0).dot(t0).clamp(0.0, max);
let l1 = (c.p3 - c.p2).dot(t1).clamp(0.0, max);
let (l0, l1) = if pts.len() < 3 { (chord / 3.0 * round, chord / 3.0 * round) } else { (l0, l1) };
path.out_tangents[i] = [t0.x * l0, t0.y * l0];
path.in_tangents[j] = [-t1.x * l1, -t1.y * l1];
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FillRule, fill_coverage, to_kurbo};
use effectcraft_geom::Mat3;
fn iou(a: &[bool], b: &[f32]) -> f64 {
let (mut i, mut u) = (0.0, 0.0);
for (x, y) in a.iter().zip(b) {
let y = *y >= 0.5;
if *x && y {
i += 1.0;
}
if *x || y {
u += 1.0;
}
}
i / u
}
/// Rasterise traced outlines with even-odd fill (as the Add / Subtract mask stack does).
fn raster(t: &[Traced], w: usize, h: usize) -> Vec<f32> {
let paths: Vec<_> = t.iter().map(|t| to_kurbo(&t.path)).collect();
fill_coverage(&paths, &Mat3::IDENTITY, w as u32, h as u32, FillRule::EvenOdd).data
}
#[test]
fn nesting_depths_match_every_pair() {
// Rings within rings beside many separate dots: the grid lookup gives the same depths
// as testing every pair.
let (w, h) = (200, 120);
let mut field = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f64 + 0.5 - 60.0, y as f64 + 0.5 - 60.0);
let r = (fx * fx + fy * fy).sqrt();
let ring = r < 56.0 && ((r / 8.0) as usize).is_multiple_of(2);
let dot = x >= 130 && x % 6 < 3 && y % 6 < 3;
field[y * w + x] = if ring || dot { 1.0 } else { 0.0 };
}
}
let opts = TraceOpts { min_area: 1.0, ..Default::default() };
let t = trace(&field, w, h, &opts);
assert!(t.len() > 200, "{}", t.len());
let polys: Vec<(Vec<[f64; 2]>, f64)> = t.iter().map(|t| (t.path.vertices.clone(), t.area.abs())).collect();
let refs: Vec<(&[[f64; 2]], f64)> = polys.iter().map(|(p, a)| (p.as_slice(), *a)).collect();
let fast = nesting_depths(&refs, w, h);
let pairs: Vec<usize> =
(0..refs.len()).map(|i| (0..refs.len()).filter(|&j| j != i && refs[j].1 > refs[i].1 && point_in_poly(refs[i].0[0], refs[j].0)).count()).collect();
assert_eq!(fast, pairs);
assert_eq!(fast, t.iter().map(|t| t.depth).collect::<Vec<_>>());
assert!(fast.iter().max() >= Some(&5));
}
#[test]
fn nesting_depths_count_wide_boxes_kept_out_of_the_grid() {
// Concentric squares whose boxes each cover far more than MAX_BOX_CELLS cells.
let (w, h) = (4000, 4000);
let squares: Vec<Vec<[f64; 2]>> = (0..40)
.map(|k| {
let (a, b) = (10.0 + 40.0 * k as f64, 3990.0 - 40.0 * k as f64);
vec![[a, a], [b, a], [b, b], [a, b]]
})
.collect();
let refs: Vec<(&[[f64; 2]], f64)> = squares.iter().map(|p| (p.as_slice(), (p[1][0] - p[0][0]).powi(2))).collect();
assert_eq!(nesting_depths(&refs, w, h), (0..40).collect::<Vec<_>>());
}
#[test]
fn simplification_work_is_bounded_and_keeps_the_outline() {
// Out of budget, the spans still waiting keep every point: nothing is lost, the outline
// is just less simplified.
let p: Vec<[f64; 2]> = (0..12).map(|i| [i as f64, if i % 2 == 0 { 0.0 } else { 5.0 }]).collect();
let (mut budget, mut keep) = (0, vec![]);
dp(&p, 0, 11, 0.01, &mut budget, &mut keep);
keep.sort_unstable();
assert_eq!(keep, (1..11).collect::<Vec<_>>());
// With a budget, the result matches the plain recursion's on an ordinary loop.
let (mut budget, mut keep) = (usize::MAX, vec![]);
dp(&p, 0, 11, 0.01, &mut budget, &mut keep);
keep.sort_unstable();
assert_eq!(keep, (1..11).collect::<Vec<_>>());
// A long, finely zig-zagging loop: simplified within a budget linear in its length.
let n = 200_000;
let ring: Vec<[f64; 2]> = (0..n)
.map(|i| {
let a = i as f64 / n as f64 * std::f64::consts::TAU;
let r = 1000.0 + if i % 2 == 0 { 0.0 } else { 3.0 };
[r * a.cos(), r * a.sin()]
})
.collect();
let idx = simplify_idx(&ring, 0.5);
assert!(idx.windows(2).all(|w| w[0] < w[1]) && idx.len() >= 3 && *idx.last().unwrap() < n);
}
#[test]
fn fit_uses_the_kept_indices() {
// A contour that passes the same point twice: the fit follows the simplifier's index,
// not the first raw point with those coordinates.
let raw = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 0.0], [-10.0, 10.0], [-10.0, 0.0]];
let idx = vec![0, 2, 3, 4];
let simp: Vec<[f64; 2]> = idx.iter().map(|&i| raw[i]).collect();
let path = fit_loop(&raw, &idx, &simp, 0.5);
assert_eq!(path.vertices, simp);
}
#[test]
fn traces_a_disc_and_a_square_with_a_hole() {
let (w, h) = (120, 80);
let mut field = vec![0.0f32; w * h];
let mut truth = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let disc = (fx - 30.0).powi(2) + (fy - 40.0).powi(2) <= 20.0f64.powi(2);
let square = (60.0..110.0).contains(&fx) && (15.0..65.0).contains(&fy);
let hole = (75.0..95.0).contains(&fx) && (30.0..50.0).contains(&fy);
let on = disc || (square && !hole);
truth[y * w + x] = on;
field[y * w + x] = if on { 1.0 } else { 0.0 };
}
}
for round in [0.0, 0.5, 1.0] {
let t = trace(&field, w, h, &TraceOpts { corner_roundness: round, ..Default::default() });
assert_eq!(t.len(), 3, "disc, square, hole");
assert_eq!(t.iter().filter(|t| t.depth == 1).count(), 1, "one hole");
let r = raster(&t, w, h);
let score = iou(&truth, &r);
// Full roundness rounds the square's corners off as well.
let min = if round >= 1.0 { 0.88 } else { 0.96 };
assert!(score > min, "IoU {score} at roundness {round}");
}
// A disc stays a disc when smooth.
let disc: Vec<f32> =
(0..w * h).map(|i| if ((i % w) as f64 + 0.5 - 30.0).powi(2) + ((i / w) as f64 + 0.5 - 40.0).powi(2) <= 400.0 { 1.0 } else { 0.0 }).collect();
let t = trace(&disc, w, h, &TraceOpts { corner_roundness: 1.0, tolerance: 1.5, ..Default::default() });
let truth_disc: Vec<bool> = disc.iter().map(|v| *v > 0.5).collect();
let score = iou(&truth_disc, &raster(&t, w, h));
assert!(score > 0.97, "disc IoU {score}");
assert!(t[0].path.vertices.len() < 40, "simplified to {}", t[0].path.vertices.len());
// The square traces to 4 corners at zero roundness.
let t = trace(&field, w, h, &TraceOpts { corner_roundness: 0.0, tolerance: 1.0, ..Default::default() });
let sq = t.iter().find(|t| t.depth == 0 && t.path.vertices.iter().all(|v| v[0] > 55.0)).expect("square");
assert!(sq.path.vertices.len() <= 8, "{:?}", sq.path.vertices);
}
#[test]
fn min_area_and_threshold() {
let (w, h) = (40, 40);
let mut field = vec![0.0f32; w * h];
// A 2×2 speck and a 20×20 block at 0.6.
for y in 2..4 {
for x in 2..4 {
field[y * w + x] = 1.0;
}
}
for y in 10..30 {
for x in 10..30 {
field[y * w + x] = 0.6;
}
}
assert_eq!(trace(&field, w, h, &TraceOpts { min_area: 10.0, ..Default::default() }).len(), 1);
assert_eq!(trace(&field, w, h, &TraceOpts { min_area: 1.0, ..Default::default() }).len(), 2);
// Above 0.6 only the speck remains (and it is too small).
assert!(trace(&field, w, h, &TraceOpts { threshold: 0.7, ..Default::default() }).is_empty());
// Edge-touching shapes close along the border.
let full = vec![1.0f32; 100];
let t = trace(&full, 10, 10, &TraceOpts::default());
assert_eq!(t.len(), 1);
assert!((t[0].area.abs() - 100.0).abs() < 2.0, "{}", t[0].area);
}
}