mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
2f24495ffe
commit
6bbf30e52a
+204
-82
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user