mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
989768207f
commit
caabf08f3e
+47
-10
@@ -865,6 +865,35 @@ impl Layers<'_> {
|
||||
first.is_some()
|
||||
}
|
||||
|
||||
/// Shapes, Abutting: the shape the outline `sp` (fitted from the loop `l`, `hard` as that
|
||||
/// loop is) is, where it may be one. The edge is shared by the regions on its two sides, so
|
||||
/// both must become that same shape or neither: only a closed edge between two regions, a
|
||||
/// region whose whole outline is the hole of one other (no junction on it, not on the image's
|
||||
/// border), and only a shape inside the image. The shape is read from the outline run one way
|
||||
/// from one start, so the region and the hole it fills get the very same one.
|
||||
fn shared_shape(&self, l: &Loop, sp: &vectorcraft_geom::SubPath, hard: bool) -> Option<TracedShape> {
|
||||
let (w, h) = (i32::try_from(self.w).ok()?, i32::try_from(self.h).ok()?);
|
||||
if l.pts.iter().any(|&(x, y)| x <= 0 || y <= 0 || x >= w || y >= h || self.junction(x, y).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let mut one_way = sp.clone();
|
||||
if one_way.signed_area() < 0.0 {
|
||||
one_way.reverse();
|
||||
}
|
||||
let first = one_way.anchors.iter().enumerate().min_by(|a, b| a.1.p.x.total_cmp(&b.1.p.x).then(a.1.p.y.total_cmp(&b.1.p.y)))?.0;
|
||||
one_way.anchors.rotate_left(first);
|
||||
let shape = shapes::recognise(&one_way, hard)?;
|
||||
let (wf, hf) = (self.w as f64, self.h as f64);
|
||||
let inside = shape
|
||||
.path()
|
||||
.subpaths
|
||||
.iter()
|
||||
.flat_map(|s| &s.anchors)
|
||||
.flat_map(|a| [a.p, a.h_in, a.h_out])
|
||||
.all(|p| (0.0..=wf).contains(&p.x) && (0.0..=hf).contains(&p.y));
|
||||
inside.then_some(shape)
|
||||
}
|
||||
|
||||
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
|
||||
/// maximum.
|
||||
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
|
||||
@@ -923,26 +952,32 @@ impl Layers<'_> {
|
||||
for comp in comps {
|
||||
// (`hard`: some of the outline has no anti-aliasing, and is known less exactly.)
|
||||
let hard = std::cell::Cell::new(false);
|
||||
// (Each loop fitted, and whether its own outline is hard.)
|
||||
let fit = |l: &Loop| {
|
||||
let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?;
|
||||
hard.set(hard.get() || is_hard);
|
||||
Some(sp)
|
||||
Some((sp, is_hard))
|
||||
};
|
||||
let Some(outer) = fit(&comp.outer) else { continue };
|
||||
let Some((outer, _)) = fit(&comp.outer) else { continue };
|
||||
let mut subs = vec![outer];
|
||||
for hole in &comp.holes {
|
||||
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(mut sp) = fit(hole) else { continue };
|
||||
let Some((mut sp, own_hard)) = fit(hole) else { continue };
|
||||
// Shapes: a hole that is a shape is that shape, as what fills it is (the two are
|
||||
// one outline: they stay one). Only where one colour fills it: the parts of
|
||||
// several are not that shape, and would no longer meet it.
|
||||
if params.shapes
|
||||
&& area_stroke.is_none()
|
||||
&& self.one_colour_within(hole)
|
||||
&& let Some(exact) = shapes::recognise(&sp, hard.get()).and_then(|shape| shape.path().subpaths.into_iter().next())
|
||||
{
|
||||
// several are not that shape, and would no longer meet it. Abutting, only where the
|
||||
// region that fills it is that shape too (see `shared_shape`).
|
||||
let exact = if !params.shapes || area_stroke.is_some() || !self.one_colour_within(hole) {
|
||||
None
|
||||
} else if overlapping {
|
||||
shapes::recognise(&sp, hard.get())
|
||||
} else {
|
||||
// (As hard as this outline is, as the region that fills it reads it.)
|
||||
self.shared_shape(hole, &sp, own_hard)
|
||||
};
|
||||
if let Some(exact) = exact.and_then(|shape| shape.path().subpaths.into_iter().next()) {
|
||||
// (Which way round each goes: by the area of the polygon through points along it.)
|
||||
let turn = |sp: &vectorcraft_geom::SubPath| {
|
||||
use vectorcraft_geom::ParamCurve;
|
||||
@@ -960,7 +995,9 @@ impl Layers<'_> {
|
||||
}
|
||||
// Shapes: a filled outline without holes that is a shape becomes that shape.
|
||||
let shape = match subs.as_slice() {
|
||||
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only, hard.get()),
|
||||
[only] if params.shapes && area_stroke.is_none() && overlapping => shapes::recognise(only, hard.get()),
|
||||
// Abutting: only an island in one other region, whose hole it is (see `shared_shape`).
|
||||
[only] if params.shapes && area_stroke.is_none() && comp.holes.is_empty() => self.shared_shape(&comp.outer, only, hard.get()),
|
||||
_ => None,
|
||||
};
|
||||
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
|
||||
|
||||
@@ -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, ¶ms);
|
||||
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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user