Image Trace: Shapes keep abutting neighbours watertight

With Method Abutting and Shapes on, a region recognised as a shape had its
outline replaced by the exact shape, but the region across that edge kept
its fitted curve unless it was a hole filled by one colour, and even then
the hole was recognised on its own, with its region's combined hardness.
A shape touching a junction (a third colour, or its own colour at a
corner) or the image's border was replaced on one side only, so abutting
colours parted or overlapped along the shared edge, and a square on the
image's corner could reach past the frame.

Cause: Layers::layer recognised an island with no holes
(`[only] => shapes::recognise`) regardless of what lay around it, and a
hole only by `one_colour_within`, each from its own outline and hardness.

Fix: under Abutting a shape is applied only where both faces take it
(`Layers::shared_shape`): the loop has no junction on it and does not
touch the border, the island has no holes and the hole holds one colour,
and the shape stays inside the image. The shape is recognised from the
outline turned one way and started at its leftmost anchor, with that
loop's own hardness, so the island and the hole it fills get the very same
shape. Overlapping is unchanged.

Test: shapes_keep_abutting_neighbours_watertight traces three code-drawn
anti-aliased colour images (a disc in a square plus a disc across its
edge, shapes on the border, tilted tiles) and checks every outline lies
within 0.001 px of a neighbour and a 4 x 4 per-pixel grid is covered
exactly once. Before: gaps of 0.50, 15.1 and 0.73 px, 183 and 276 points
uncovered or doubled; after: 0 px, 0 points. Isolated shapes are still
recognised.

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 989768207f
commit caabf08f3e
2 changed files with 163 additions and 10 deletions
+116
View File
@@ -1410,3 +1410,119 @@ fn grayscale_gradients_are_grey() {
}
assert!(res.palette.iter().all(|&c| grey(c)), "{:?}", res.palette);
}
/// `w × h` art of `colours`, anti-aliased: each pixel the mean of the 8 × 8 samples `at` colours.
fn soft_colours(w: u32, h: u32, colours: &[[u8; 3]], at: impl Fn(f64, f64) -> usize) -> Raster {
Raster::from_fn(w, h, |x, y| {
let mut sum = [0.0f64; 3];
for i in 0..64 {
let c = colours[at(f64::from(x) + (f64::from(i % 8) + 0.5) / 8.0, f64::from(y) + (f64::from(i / 8) + 0.5) / 8.0)];
for k in 0..3 {
sum[k] += f64::from(c[k]) / 64.0;
}
}
[sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255]
})
}
/// Abutting paths: how far (px), at worst, a point along one path's outline lies from every other
/// path's outline (the image's frame aside), and how many points of a 4 × 4 grid per pixel no path
/// or more than one path covers.
fn seams(res: &TraceResult, w: u32, h: u32) -> (f64, usize) {
use kurbo::{ParamCurve, ParamCurveNearest, Shape};
let (wf, hf) = (f64::from(w), f64::from(h));
let segs: Vec<Vec<kurbo::CubicBez>> =
res.paths.iter().map(|p| p.path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))).collect()).collect();
let mut gap = 0.0f64;
for (i, own) in segs.iter().enumerate() {
for c in own {
for k in 0..16 {
let p = c.eval(f64::from(k) / 16.0);
if p.x.abs() < 1e-9 || p.y.abs() < 1e-9 || (p.x - wf).abs() < 1e-9 || (p.y - hf).abs() < 1e-9 {
continue;
}
let near = segs.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, o)| o).map(|o| o.nearest(p, 1e-12).distance_sq);
gap = gap.max(near.fold(f64::INFINITY, f64::min).sqrt());
}
}
}
let beziers: Vec<kurbo::BezPath> = res.paths.iter().map(|p| p.path.to_bezpath()).collect();
let mut wrong = 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 = kurbo::Point::new(0.1093 + f64::from(xi) * 0.25, 0.1371 + f64::from(yi) * 0.25);
wrong += usize::from(beziers.iter().filter(|b| b.winding(p) != 0).count() != 1);
}
}
(gap, wrong)
}
/// Shapes, Abutting: a region that is a shape is that shape on both of its sides, or on neither.
#[test]
fn shapes_keep_abutting_neighbours_watertight() {
let colours = [[255, 255, 255], [200, 40, 50], [30, 90, 200], [40, 160, 70], [240, 200, 40]];
let disc = |x: f64, y: f64, cx: f64, cy: f64, r: f64| (x - cx).hypot(y - cy) <= r;
let tile = |x: f64, y: f64, cx: f64, cy: f64, half: f64, deg: f64| {
let (s, c) = deg.to_radians().sin_cos();
let (dx, dy) = (x - cx, y - cy);
(dx * c + dy * s).abs() <= half && (dy * c - dx * s).abs() <= half
};
let arts: Vec<(&str, Raster)> = vec![
// A disc in a square in the background, and a disc across the square's edge.
(
"nested",
soft_colours(120, 100, &colours, |x, y| {
if disc(x, y, 50.3, 50.6, 14.2) {
2
} else if disc(x, y, 90.4, 30.2, 13.1) {
3
} else if (20.4..80.6).contains(&x) && (20.3..80.7).contains(&y) {
1
} else {
0
}
}),
),
// A square on the frame, a disc on the frame, and a square away from it.
(
"on the frame",
soft_colours(100, 80, &colours, |x, y| {
if (0.0..30.6).contains(&x) && (20.3..50.4).contains(&y) {
1
} else if disc(x, y, 70.3, 12.0, 18.4) {
2
} else if (55.3..85.6).contains(&x) && (40.4..70.7).contains(&y) {
3
} else {
0
}
}),
),
// Tilted tiles, some touching, some apart.
(
"tiles",
soft_colours(120, 120, &colours, |x, y| {
for (k, (cx, cy)) in [(30.3, 30.4), (52.0, 30.4), (90.2, 32.1), (30.6, 88.7), (60.4, 62.3), (92.1, 90.6)].into_iter().enumerate() {
if tile(x, y, cx, cy, 11.2, 15.0 + 7.0 * k as f64) {
return 1 + k % 4;
}
}
0
}),
),
];
let mut wrong = vec![];
for (name, img) in &arts {
let params = TraceParams { mode: Mode::Color, colors: 5, noise: 4, ignore_white: false, shapes: true, ..TraceParams::default() };
let res = trace(img, &params);
let (gap, bad) = seams(&res, img.width, img.height);
let shapes = res.paths.iter().filter(|p| p.shape.is_some()).count();
let line = format!("{name}: {} paths, {shapes} shapes, gap {gap:.6} px, {bad} points covered other than once", res.paths.len());
eprintln!("{line}");
if gap > 1e-3 || bad > 0 {
wrong.push(line);
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}