mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
f8af51a341
commit
1d72a6f895
@@ -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.
|
||||
|
||||
@@ -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, ¶ms);
|
||||
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)]);
|
||||
}
|
||||
|
||||
@@ -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
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