Image Trace: same sub-pixel shift from either side of an edge

Layers::shift read a crack's sub-pixel shift from its inside pixel,
looking for a thin run on the inside before one on the outside. Where
both sides are thin (a one- or two-pixel sliver beside another, as in
soft gradients and noisy scans) the two regions of the crack got
different shifts, so their shared piece got different points and the
abutting curves left slivers that no path covers.

The shift is now the mean of the readings from both sides,
(s(c) - s(c flipped)) / 2, which is exactly antisymmetric in floating
point (Crack::flipped gives the crack seen from its other side).

Tests (code-drawn images):
- a_crack_between_thin_slivers_has_the_same_shift_from_either_side:
  every crack's shift is the exact negative of its flipped crack's, on
  a noisy ripple of 1-2 px columns at three thresholds.
- shared_boundaries::thin_slivers_on_both_sides_of_an_edge_leave_nothing_uncovered:
  a 48 px noisy ripple in Black and White at thresholds 140/157/170 and
  Paths 0/50/100, sampled on the shared-boundary check's 4x4 grid per
  pixel: 482-1,228 uncovered points per trace before, 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 caabf08f3e
commit f8af51a341
7 changed files with 145 additions and 10 deletions
+53
View File
@@ -744,7 +744,19 @@ impl Layers<'_> {
/// Black and White then moves the edge to where the luminance crosses the threshold: by the
/// threshold's distance from the middle tone, over how steep the edge is (nothing for a
/// threshold in the middle, more the softer the edge).
///
/// The estimate is read from each side of the crack and the two readings averaged, so that
/// the shift is exactly antisymmetric (`a - b == -(b - a)` in floating point) and the two
/// regions of a crack get the same points. A single reading is not: it looks for a thin run on
/// the inside before one on the outside, so where both sides are thin (a one- or two-pixel
/// sliver beside another, as in soft gradients and noisy scans) each region got its own shift,
/// their shared piece different points, and the abutting curves left a sliver uncovered.
fn shift(&self, c: Crack) -> f64 {
(self.shift_one_side(c) - self.shift_one_side(c.flipped())) / 2.0
}
/// [`Self::shift`] read from the crack's inside pixel only.
fn shift_one_side(&self, c: Crack) -> f64 {
let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT);
let (Some(la), Some(lb)) = (label(0), label(1)) else { return 0.0 };
if la == lb {
@@ -1014,6 +1026,47 @@ impl Layers<'_> {
}
}
/// Test probe: every crack between two labels of `img` quantised as `trace` does (no gradients),
/// as `[shift, shift from the other side, one-sided reading, one-sided reading from the other
/// side]`.
#[cfg(test)]
pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> {
let (w, h) = (img.width as usize, img.height as usize);
let mut q = quantize(img, params);
denoise(&mut q.labels, w, h, params.noise as usize);
let n = q.palette.len();
let rank: Vec<usize> = (0..n).collect();
let gradients = vec![None; n];
let job = Layers {
q: &q,
rank: &rank,
params,
img,
ignore: None,
boxes: vec![None; n],
gradients: &gradients,
bw_tones: bw_tones(img, &q),
style: params.outline_style(),
w,
h,
anchors: AtomicUsize::new(0),
max_anchors: usize::MAX,
};
let mut out = vec![];
for y in 0..h as i32 {
for x in 0..w as i32 {
for step in [(1, 0), (0, 1)] {
let c = Crack::new((x, y), step, w, h);
let (Some(a), Some(b)) = (c.at(0), c.at(1)) else { continue };
if q.labels[a] != q.labels[b] {
out.push([job.shift(c), job.shift(c.flipped()), job.shift_one_side(c), job.shift_one_side(c.flipped())]);
}
}
}
}
out
}
/// `f` of each of `items`, in order, spread over the available cores.
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
parallel_map_at_most(usize::MAX, items, f)
+4
View File
@@ -44,6 +44,10 @@ impl Crack {
pub fn new(inside: (i32, i32), step: (i32, i32), w: usize, h: usize) -> Self {
Self { inside, step, w, h }
}
/// The same crack seen from its other side: the outside pixel becomes the inside one.
pub fn flipped(&self) -> Self {
Self { inside: (self.inside.0 + self.step.0, self.inside.1 + self.step.1), step: (-self.step.0, -self.step.1), w: self.w, h: self.h }
}
/// The index of the pixel `k` steps out from the inside pixel (0: the inside pixel, 1: the
/// outside one, −1: the one behind the inside pixel), if the image has it.
pub fn at(&self, k: i32) -> Option<usize> {
+24
View File
@@ -1526,3 +1526,27 @@ fn shapes_keep_abutting_neighbours_watertight() {
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
/// One- and two-pixel slivers on both sides of an edge (a soft, noisy ripple read at a threshold):
/// read from one side only, such a crack had a different shift from each side, so the two regions'
/// shared piece got different points. The shift is now the same from either side, exactly.
#[test]
fn a_crack_between_thin_slivers_has_the_same_shift_from_either_side() {
// Columns a pixel or two wide, alternately darker and lighter, with a deterministic ripple.
let rnd = |x: u32, y: u32| (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761) >> 27;
let img = Raster::from_fn(40, 40, |x, y| {
let base = [120u32, 170, 175, 125, 165, 118, 172][(x as usize + y as usize / 7) % 7];
let v = (base + rnd(x, y) * (y % 3)).min(255) as u8;
[v, v, v, 255]
});
for threshold in [140u8, 157, 170] {
let params = TraceParams { threshold, noise: 0, ..TraceParams::default() };
let probe = shift_probe(&img, &params);
assert!(!probe.is_empty());
// The image does have cracks a one-sided reading gets wrong ...
let asymmetric = probe.iter().filter(|s| (s[2] + s[3]).abs() > 1e-9).count();
assert!(asymmetric > 0, "threshold {threshold}: no crack with thin runs on both sides");
// ... and the shift is exactly antisymmetric on every crack.
assert!(probe.iter().all(|s| s[0] == -s[1]), "threshold {threshold}");
}
}
+37 -3
View File
@@ -195,20 +195,26 @@ fn check(res: &TraceResult, w: u32, h: u32, colours: &[[u8; 3]]) -> Seams {
}
}
let area = res.paths.iter().map(|p| p.path.subpaths.iter().map(|s| s.area()).sum::<f64>()).sum::<f64>() - wf * hf;
let beziers: Vec<BezPath> = res.paths.iter().map(|p| p.path.to_bezpath()).collect();
let (uncovered, doubled) = coverage(res, w, h);
Seams { faces, gap, at, area, uncovered, doubled }
}
/// Points of a 4 × 4 grid per pixel that no path covers, and that more than one path covers.
fn coverage(res: &TraceResult, w: u32, h: u32) -> (usize, usize) {
let beziers: Vec<(BezPath, Rect)> = res.paths.iter().map(|p| p.path.to_bezpath()).map(|b| (b.clone(), b.bounding_box())).collect();
let (mut uncovered, mut doubled) = (0, 0);
for yi in 0..h * 4 {
for xi in 0..w * 4 {
// (Off the half and quarter pixels, where hard edges lie.)
let p = Point::new(0.1093 + f64::from(xi) * 0.25, 0.1371 + f64::from(yi) * 0.25);
match beziers.iter().filter(|b| b.winding(p) != 0).count() {
match beziers.iter().filter(|(b, r)| r.contains(p) && b.winding(p) != 0).count() {
0 => uncovered += 1,
1 => {}
_ => doubled += 1,
}
}
}
Seams { faces, gap, at, area, uncovered, doubled }
(uncovered, doubled)
}
/// One trace of `art` checked: a line for the record, and what is wrong (empty when nothing is).
@@ -535,3 +541,31 @@ fn the_check_finds_a_hairline_gap_and_an_overlap() {
let s = check(&trace(&art.hard(colours), &stacked), art.w, art.h, colours);
assert!(s.doubled > 0 && s.area > 100.0, "{s:?}");
}
/// A noisy ripple of one- and two-pixel slivers, light and dark, read in Black and White at a
/// threshold: both sides of many edges are thin. Read from one side only, such an edge had a
/// different sub-pixel shift for each of its two regions, so their curves left slivers between
/// them that no path covers. (Coverage only: on noise this fine, curves fitted to neighbouring
/// pieces may still cross, which the full check counts as overlaps.)
#[test]
fn thin_slivers_on_both_sides_of_an_edge_leave_nothing_uncovered() {
let (w, h) = (48, 48);
let rnd = |x: u32, y: u32| (x.wrapping_mul(83_492_791) ^ y.wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761) >> 26;
let img = Raster::from_fn(w, h, |x, y| {
let base = [118u32, 168, 176, 122, 160, 116, 174, 171][(x as usize + y as usize / 5) % 8];
let v = (base + rnd(x, y) * ((x + y) % 2)).min(255) as u8;
[v, v, v, 255]
});
let mut wrong = vec![];
for threshold in [140u8, 157, 170] {
for paths in [0.0, 50.0, 100.0] {
let params = TraceParams { threshold, paths, ..black_and_white(0) };
let (uncovered, _) = coverage(&trace(&img, &params), w, h);
println!("threshold {threshold} paths {paths}: uncovered {uncovered}");
if uncovered > 0 {
wrong.push(format!("threshold {threshold} paths {paths}: {uncovered} points uncovered"));
}
}
}
assert_none(wrong);
}
+20 -3
View File
@@ -1,5 +1,6 @@
# Image Trace quality
> **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)
What Image Trace (`crates/trace`) makes of images whose right answer is known, measured by a harness and
@@ -7,8 +8,25 @@ 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).
## A simpler contour walk
## 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
scans) the two regions of the crack got different shifts, their shared piece different points, and
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
`tests/shared_boundaries.rs`, `thin_slivers_on_both_sides_of_an_edge_leave_nothing_uncovered` (a
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
then follow it. It now works out each turn during the walk from the two pixels ahead of the current
@@ -16,14 +34,12 @@ corner (right if the one ahead on the right is outside, straight if the one ahea
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.
@@ -1027,6 +1043,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 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 |
| 2026-10-14 | Tiny closed fits retain their positioned outlines instead of losing islands/holes; three generated regression tests; quality-harness baseline 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