Image Trace: a simpler contour walk with no precomputed direction table

contour::trace_mask built a table of outgoing directions for every
pixel corner in a raster pass and then followed it. It now works out
each turn during the walk from the two pixels ahead of the current
corner: right if the one ahead on the right is outside, straight if the
one ahead on the left is, else left (so at a saddle it still turns
right and diagonal pixels stay apart). Loop starts are found by
scanning the pixels for a top edge on the outline, with one flag per
pixel for a top edge already walked. The walk is bounded by the grid
size, and a mask of the wrong length now gives no loops instead of
failing an assert_eq!.

The output is identical: the_walk_equals_the_stored_direction_walk
compares it with a test-only copy of the previous walk on all 65,536
4x4 masks and 400 seeded 23x17 masks.

Time (best of 7, release, 4 cores): the walk alone on 2000x2000 masks
19.1 vs 26.6 ms (smooth pattern), 81.3 vs 93.2 ms (12% random pixels),
223.1 vs 238.1 ms (50% random); whole 1000x1000 traces 73.1 vs 77.1 ms
(Black and White), 249.6 vs 251.7 ms (Color, 6 colours), 214.1 vs
218.6 ms (Color, 6 colours, Noise 1), same segment counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
This commit is contained in:
andeecollard
2026-10-17 14:46:21 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 2f24495ffe
commit 6bbf30e52a
4 changed files with 230 additions and 86 deletions
+204 -82
View File
@@ -89,99 +89,221 @@ fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
(comp, sizes)
}
/// Trace every component of `mask` (row-major, `w × h`).
/// Trace every component of `mask` (row-major, `w × h`; a mask of another length gives nothing).
///
/// A walk around the mask's pixels with the inside on the right. Each turn is worked out during the
/// walk from the two pixels ahead of the current corner: turn right if the one ahead on the right
/// is outside, go straight if the one ahead on the left is, else turn left (so at a saddle the walk
/// turns right and diagonal pixels stay apart). A loop starts at the top edge of an inside pixel
/// whose upper neighbour is outside, found by scanning the pixels in order; one flag per pixel
/// records whether the walk has been along its top edge, so that each loop is walked once.
pub fn trace_mask(mask: &[bool], w: usize, h: usize) -> Vec<Component> {
assert_eq!(mask.len(), w * h);
let (comp, sizes) = label(mask, w, h);
let vw = w + 1;
let mut dirs = vec![0u8; vw * (h + 1)];
let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask[y as usize * w + x as usize];
for y in 0..h {
for x in 0..w {
if !mask[y * w + x] {
continue;
}
let (xi, yi) = (x as isize, y as isize);
if !inside(xi, yi - 1) {
dirs[y * vw + x] |= R;
}
if !inside(xi + 1, yi) {
dirs[y * vw + x + 1] |= D;
}
if !inside(xi, yi + 1) {
dirs[(y + 1) * vw + x + 1] |= L;
}
if !inside(xi - 1, yi) {
dirs[(y + 1) * vw + x] |= U;
}
}
if w.checked_mul(h) != Some(mask.len()) {
return vec![];
}
let (comp, sizes) = label(mask, w, h);
let inside = |x: i32, y: i32| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask.get(y as usize * w + x as usize) == Some(&true);
// The pixels ahead of corner (x, y) going `d`: on the right of the way and on its left.
let ahead = |x: i32, y: i32, d: u8| {
let (nw, ne, se, sw) = ((x - 1, y - 1), (x, y - 1), (x, y), (x - 1, y));
let (r, l) = match d {
R => (se, ne),
D => (sw, se),
L => (nw, sw),
_ => (ne, nw),
};
(inside(r.0, r.1), inside(l.0, l.1))
};
// A loop has fewer edges than the grid has corners, four times over.
let longest = 4 * (w + 1) * (h + 1);
let mut walked = vec![false; w * h];
let mut outers: Vec<Option<Loop>> = vec![None; sizes.len()];
let mut holes: Vec<Vec<Loop>> = vec![Vec::new(); sizes.len()];
for start in 0..dirs.len() {
// Every loop has an R edge (the top edge of some inside pixel); start there so the loop
// closes exactly (right-turn rule at the start vertex, too).
while dirs[start] & R != 0 {
let (sx, sy) = ((start % vw) as i32, (start / vw) as i32);
let owner = comp[sy as usize * w + sx as usize];
dirs[start] &= !R;
let mut pts = vec![(sx, sy)];
let (mut x, mut y) = (sx + 1, sy);
let mut d = R;
let mut area2: i64 = 0;
let mut prev = (sx, sy);
loop {
area2 += prev.0 as i64 * y as i64 - x as i64 * prev.1 as i64;
prev = (x, y);
let v = y as usize * vw + x as usize;
let at_start = (x, y) == (sx, sy);
let mut cand = dirs[v];
if at_start {
cand |= R;
}
let next = if cand.count_ones() == 1 {
cand
} else if cand & right_of(d) != 0 {
right_of(d)
} else if cand & d != 0 {
d
} else {
// left turn
right_of(right_of(right_of(d)))
};
if at_start && next == R {
break;
}
if next == 0 || dirs[v] & next == 0 {
// Malformed (cannot happen for a valid mask); stop safely.
break;
}
dirs[v] &= !next;
// Keep only turn vertices.
if next != d {
pts.push((x, y));
}
let (dx, dy) = step(next);
x += dx;
y += dy;
d = next;
}
let l = Loop { pts, area2 };
if owner == u32::MAX {
continue;
}
if l.area2 > 0 {
outers[owner as usize] = Some(l);
for (s, (&on, &owner)) in mask.iter().zip(&comp).enumerate() {
// Every loop has a rightward edge (the top edge of some inside pixel); start there so the
// loop closes exactly (right-turn rule at the start corner, too).
if !on || walked.get(s) != Some(&false) || (s >= w && mask.get(s - w) == Some(&true)) {
continue;
}
let (sx, sy) = ((s % w) as i32, (s / w) as i32);
if let Some(v) = walked.get_mut(s) {
*v = true;
}
let mut pts = vec![(sx, sy)];
let (mut x, mut y) = (sx + 1, sy);
let mut d = R;
let mut area2: i64 = 0;
let mut prev = (sx, sy);
for _ in 0..longest {
area2 += i64::from(prev.0) * i64::from(y) - i64::from(x) * i64::from(prev.1);
prev = (x, y);
let (right, left) = ahead(x, y, d);
let next = if !right {
right_of(d)
} else if !left {
d
} else {
holes[owner as usize].push(l);
// left turn
right_of(right_of(right_of(d)))
};
if (x, y) == (sx, sy) && next == R {
break;
}
if next == R
&& let Some(v) = walked.get_mut(y as usize * w + x as usize)
{
*v = true;
}
// Keep only turn vertices.
if next != d {
pts.push((x, y));
}
let (dx, dy) = step(next);
x += dx;
y += dy;
d = next;
}
let l = Loop { pts, area2 };
if l.area2 > 0 {
if let Some(o) = outers.get_mut(owner as usize) {
*o = Some(l);
}
} else if let Some(hs) = holes.get_mut(owner as usize) {
hs.push(l);
}
}
let mut out = Vec::with_capacity(sizes.len());
for (i, o) in outers.into_iter().enumerate() {
for ((o, hs), &pixels) in outers.into_iter().zip(holes).zip(&sizes) {
if let Some(outer) = o {
out.push(Component { outer, holes: std::mem::take(&mut holes[i]), pixels: sizes[i] });
out.push(Component { outer, holes: hs, pixels });
}
}
out
}
#[cfg(test)]
mod walk_tests {
use super::*;
/// The walk this module had before: outgoing-direction bits stored per corner in a raster
/// pass, then followed. Kept to check that [`trace_mask`] gives the same loops.
fn trace_mask_stored(mask: &[bool], w: usize, h: usize) -> Vec<Component> {
assert_eq!(mask.len(), w * h);
let (comp, sizes) = label(mask, w, h);
let vw = w + 1;
let mut dirs = vec![0u8; vw * (h + 1)];
let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask[y as usize * w + x as usize];
for y in 0..h {
for x in 0..w {
if !mask[y * w + x] {
continue;
}
let (xi, yi) = (x as isize, y as isize);
if !inside(xi, yi - 1) {
dirs[y * vw + x] |= R;
}
if !inside(xi + 1, yi) {
dirs[y * vw + x + 1] |= D;
}
if !inside(xi, yi + 1) {
dirs[(y + 1) * vw + x + 1] |= L;
}
if !inside(xi - 1, yi) {
dirs[(y + 1) * vw + x] |= U;
}
}
}
let mut outers: Vec<Option<Loop>> = vec![None; sizes.len()];
let mut holes: Vec<Vec<Loop>> = vec![Vec::new(); sizes.len()];
for start in 0..dirs.len() {
// Every loop has an R edge (the top edge of some inside pixel); start there so the loop
// closes exactly (right-turn rule at the start vertex, too).
while dirs[start] & R != 0 {
let (sx, sy) = ((start % vw) as i32, (start / vw) as i32);
let owner = comp[sy as usize * w + sx as usize];
dirs[start] &= !R;
let mut pts = vec![(sx, sy)];
let (mut x, mut y) = (sx + 1, sy);
let mut d = R;
let mut area2: i64 = 0;
let mut prev = (sx, sy);
loop {
area2 += prev.0 as i64 * y as i64 - x as i64 * prev.1 as i64;
prev = (x, y);
let v = y as usize * vw + x as usize;
let at_start = (x, y) == (sx, sy);
let mut cand = dirs[v];
if at_start {
cand |= R;
}
let next = if cand.count_ones() == 1 {
cand
} else if cand & right_of(d) != 0 {
right_of(d)
} else if cand & d != 0 {
d
} else {
// left turn
right_of(right_of(right_of(d)))
};
if at_start && next == R {
break;
}
if next == 0 || dirs[v] & next == 0 {
// Malformed (cannot happen for a valid mask); stop safely.
break;
}
dirs[v] &= !next;
// Keep only turn vertices.
if next != d {
pts.push((x, y));
}
let (dx, dy) = step(next);
x += dx;
y += dy;
d = next;
}
let l = Loop { pts, area2 };
if owner == u32::MAX {
continue;
}
if l.area2 > 0 {
outers[owner as usize] = Some(l);
} else {
holes[owner as usize].push(l);
}
}
}
let mut out = Vec::with_capacity(sizes.len());
for (i, o) in outers.into_iter().enumerate() {
if let Some(outer) = o {
out.push(Component { outer, holes: std::mem::take(&mut holes[i]), pixels: sizes[i] });
}
}
out
}
/// The walk computing its turns from the pixels gives exactly the stored-direction walk's
/// loops: on every 4 × 4 mask, and on seeded 23 × 17 masks of several densities.
#[test]
fn the_walk_equals_the_stored_direction_walk() {
for bits in 0u32..(1 << 16) {
let mask: Vec<bool> = (0..16).map(|i| bits >> i & 1 == 1).collect();
assert_eq!(trace_mask(&mask, 4, 4), trace_mask_stored(&mask, 4, 4), "{bits:016b}");
}
for seed in 0..400u32 {
let (w, h) = (23, 17);
let mask: Vec<bool> = (0..w * h)
.map(|i| ((i as u32).wrapping_mul(2_654_435_761) ^ seed.wrapping_mul(40_503)).wrapping_mul(2_246_822_519) >> 28 < (seed % 15) + 1)
.collect();
assert_eq!(trace_mask(&mask, w, h), trace_mask_stored(&mask, w, h), "seed {seed}");
}
}
/// A mask of the wrong length gives nothing instead of a panic.
#[test]
fn a_mask_of_the_wrong_length_gives_nothing() {
assert!(trace_mask(&[true; 3], 2, 2).is_empty());
assert!(trace_mask(&[true; 4], usize::MAX, 2).is_empty());
}
}
+19 -1
View File
@@ -1,12 +1,29 @@
# Image Trace quality
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Gradients: grown over noise and soft rims)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace: a simpler contour walk with no precomputed direction table)
What Image Trace (`crates/trace`) makes of images whose right answer is known, measured by a harness and
compared with the numbers before each change. Nothing here is a judgement by eye, and nothing is compared
with any other product except the open tracer named under [Yardstick](#4-yardstick).
## A simpler contour walk
`contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the
right. It used to build a table of outgoing directions for every pixel corner in a raster pass and
then follow it. It now works out each turn during the walk from the two pixels ahead of the current
corner (right if the one ahead on the right is outside, straight if the one ahead on the left is,
else left; so at a saddle it turns right and diagonal pixels stay apart, as before). Loop starts are
found by scanning the pixels for a top edge on the outline, with one flag per pixel for a top edge
already walked. A mask of the wrong length now gives no loops instead of failing an assertion.
The output is identical: `the_walk_equals_the_stored_direction_walk` compares it with a test-only
copy of the previous walk on all 65,536 masks of 4 × 4 pixels and on 400 seeded 23 × 17 masks.
Time (best of 7, release build, 4 cores): the walk alone on 2000 × 2000 masks 19.1 ms vs 26.6 ms
(a smooth pattern), 81.3 vs 93.2 ms (12% random pixels), 223.1 vs 238.1 ms (50% random pixels);
whole traces of 1000 × 1000 images 73.1 vs 77.1 ms (Black and White), 249.6 vs 251.7 ms (Color, 6
colours) and 214.1 vs 218.6 ms (Color, 6 colours, Noise 1), with the same segment counts.
## Noise threshold stability (local development, 2026-10-14)
Noise must merge whole 4-connected regions, including pixels absorbed by an earlier merge.
@@ -1010,6 +1027,7 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
| date | change |
|:--|:--|
| 2026-10-16 | Image Trace: the contour walk works out each turn from the pixels as it goes instead of following a precomputed table of per-corner directions; identical loops on all 65,536 4 × 4 masks and 400 seeded masks, and no slower (whole 1 MP traces 1–5% faster). |
| 2026-10-14 | Closed fitting retains the positioned boundary when straightening collapses a nonzero-area loop; four generated regressions; preset scores unchanged |
| 2026-10-14 | Tiny closed fits retain their positioned outlines instead of losing islands/holes; three generated regression tests; quality-harness baseline unchanged |
| 2026-10-14 | Gradients: a gradient that noise or a soft rim broke into pieces is found in its largest piece and grown over the rest (blurred and noisy linear gradients: 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo) |
+3 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long