Image Trace: the notch rule reads only its own piece

Where the pixel at a sharp corner falls to the other colour, the outline
cuts the corner in two one-pixel steps, and between two straight edges
of three cracks or more subpixel::outline makes those steps one point
(a notch). Whether the edges were long enough was measured on the whole
loop. An edge can run on past a junction in one region's loop while
the region across it, whose loop turns at that junction, sees a shorter
edge: one side made the notch and the other did not, so the shared
piece got different points on each side and the abutting curves left a
gap.

The notch now also requires the three cracks of each edge next to it to
lie in the piece (no junction among them), as the corner-pixel shift
correction above it already does.

Test (code-drawn): noisy_colour_images_leave_no_sample_uncovered traces
20 seeded 36 px images of soft overlapping blobs in four colours plus
pixel noise (Color, 5 colours, Noise 1 and 2, Paths 0/50/100) and needs
every one of five sample points per pixel to lie in some path: 93
uncovered before (seeds 9, 11, 13), 0 after.

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:47:03 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent f8af51a341
commit 1d72a6f895
5 changed files with 83 additions and 7 deletions
+5
View File
@@ -222,6 +222,11 @@ pub(crate) fn outline(
&& free(s1)
&& free(s2)
&& free(b)
// The three cracks of `a` and of `b` next to the notch must lie in this piece. An edge
// that runs on past a junction is shorter in the loop of the region across it, which
// turns there: that region makes no notch, and the two would get different points.
&& !(1..=2).any(|k| junction(edges[a].first + edges[a].len - k))
&& !(1..=2).any(|k| junction(edges[b].first + k))
{
let (ea, eb) = (&edges[a], &edges[b]);
// Edge `a` carried on one pixel further meets edge `b` carried back one.
+52
View File
@@ -1550,3 +1550,55 @@ fn a_crack_between_thin_slivers_has_the_same_shift_from_either_side() {
assert!(probe.iter().all(|s| s[0] == -s[1]), "threshold {threshold}");
}
}
/// Noisy multi-colour images (soft blobs, slivers, specks) at several Paths and Noise values: no
/// sample point is left uncovered by the abutting paths. A notch (a corner pixel cut in two steps)
/// is made only where the straight edges beside it lie in the piece itself: measured over the whole
/// loop, an edge that runs on past a junction made one region cut the corner and the region across
/// the edge, whose loop turns at that junction, not (seeds 9, 11 and 13 here).
#[test]
fn noisy_colour_images_leave_no_sample_uncovered() {
use kurbo::Shape;
let hash =
|x: u32, y: u32, s: u32| (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663) ^ s.wrapping_mul(83_492_791)).wrapping_mul(2_654_435_761);
let mut uncovered = vec![];
for seed in 0..20u32 {
let (w, h) = (36u32, 36u32);
// Overlapping soft blobs of four colours plus pixel noise.
let img = Raster::from_fn(w, h, |x, y| {
let mut c = [235.0f64, 230.0, 220.0];
for b in 0..5u32 {
let cx = f64::from(hash(b, 1, seed) % w);
let cy = f64::from(hash(b, 2, seed) % h);
let r = 3.0 + f64::from(hash(b, 3, seed) % 9);
let d = ((f64::from(x) + 0.5 - cx).powi(2) + (f64::from(y) + 0.5 - cy).powi(2)).sqrt();
let a = (r + 0.7 - d).clamp(0.0, 1.4) / 1.4;
let col = [[20.0, 20.0, 30.0], [200.0, 40.0, 50.0], [40.0, 90.0, 200.0], [120.0, 120.0, 120.0]][(b % 4) as usize];
for k in 0..3 {
c[k] = c[k] * (1.0 - a) + col[k] * a;
}
}
let n = f64::from(hash(x, y, seed) % 41) - 20.0;
let p = c.map(|v| (v + n).clamp(0.0, 255.0) as u8);
[p[0], p[1], p[2], 255]
});
for noise in [1, 2] {
for paths in [0.0, 50.0, 100.0] {
let params = TraceParams { mode: Mode::Color, colors: 5, noise, paths, ..TraceParams::default() };
let res = trace(&img, &params);
let shapes: Vec<_> = res.paths.iter().map(|p| p.path.to_bezpath()).collect();
for y in 1..h - 1 {
for x in 1..w - 1 {
for (dx, dy) in [(0.5, 0.5), (0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let pt = kurbo::Point::new(f64::from(x) + dx, f64::from(y) + dy);
if !shapes.iter().any(|b| b.winding(pt) != 0) {
uncovered.push((seed, noise, paths, pt));
}
}
}
}
}
}
}
assert!(uncovered.is_empty(), "{} uncovered (seed, noise, paths, point), e.g. {:?}", uncovered.len(), &uncovered[..uncovered.len().min(5)]);
}
+19 -3
View File
@@ -1,5 +1,6 @@
# Image Trace quality
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace: the notch rule reads only its own piece)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace: an edge's sub-pixel shift is the same from either side)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace: a simpler contour walk with no precomputed direction table)
@@ -8,8 +9,24 @@ compared with the numbers before each change. Nothing here is a judgement by eye
with any other product except the open tracer named under [Yardstick](#4-yardstick).
## Same sub-pixel shift from either side of an edge
## Notches read only their own piece
Where the pixel at a sharp corner falls to the other colour, the outline cuts the corner in two
one-pixel steps, and between two straight edges of three cracks or more those two steps become one
point, where the two edges meet (a notch). Whether the edges were long enough was measured on the
whole loop. An edge can run on past a junction in one region's loop while the region across it,
whose loop turns at that junction, sees a shorter edge: one side made the notch and the other did
not, the shared piece got different points on each side, and the abutting curves left a gap. The
rule in `subpixel::outline` now also requires the three cracks of each edge next to the notch to lie
in the piece (no junction among them), as the corner-pixel correction above it already did.
Test on code-drawn images: `noisy_colour_images_leave_no_sample_uncovered` (20 seeded 36 px images
of soft overlapping blobs in four colours plus pixel noise; Color, 5 colours, Noise 1 and 2, Paths
0, 50 and 100; five sample points per pixel must each lie in some path). Without the condition 93
sample points were uncovered (seeds 9, 11 and 13); with it none are. The crate's other tests pass
unchanged.
## Same sub-pixel shift from either side of an edge
Each crack between two regions is moved by a sub-pixel shift read from the image. The reading
looked for a thin run (one to three pixels) on the crack's inside before one on its outside, so where
both sides were thin (a one- or two-pixel sliver beside another, as in soft gradients and noisy
@@ -17,7 +34,6 @@ scans) the two regions of the crack got different shifts, their shared piece dif
the abutting curves left slivers that no path covers. `Layers::shift` now averages the reading from
either side, `(s(c) - s(c flipped)) / 2`, which is exactly antisymmetric in floating point, so both
regions get the same points.
Tests on code-drawn images: `a_crack_between_thin_slivers_has_the_same_shift_from_either_side` (a
noisy ripple of 1–2 px columns at three thresholds: every crack's shift is the exact negative of its
flipped crack's, and the image does have cracks the one-sided reading gets wrong) and, in
@@ -25,7 +41,6 @@ flipped crack's, and the image does have cracks the one-sided reading gets wrong
48 px noisy ripple in Black and White at thresholds 140, 157, 170 and Paths 0, 50, 100, sampled on
the 4 × 4 grid per pixel the shared-boundary check uses). With the one-sided reading 482–1,228
sample points per trace were uncovered; now none are. The crate's other tests pass unchanged.
## 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
@@ -1043,6 +1058,7 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
| date | change |
|:--|:--|
| 2026-10-16 | Image Trace: a notch corner is cut only when the straight edges beside it lie in the shared piece, so both regions of the piece get the same points (code-drawn noisy colour images: 93 uncovered sample points to 0). |
| 2026-10-16 | Image Trace: a crack's sub-pixel shift is the mean of the readings from its two sides, exactly antisymmetric, so abutting regions get the same points on thin slivers (a code-drawn noisy ripple went from 482–1,228 uncovered sample points per trace to 0). |
| 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 |
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long