Merge pull request #1845 from storytold/u/trace-shapes2

Image Trace: Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art
This commit is contained in:
BFlat authored and GitHub committed 2026-10-14 07:07:15 +07:00
commit 156fbe9912
6 files changed
+658 -603

No files matched your search

+2 -2
View File
@@ -188,7 +188,7 @@ pub(crate) fn fit_loop(
across: &dyn Fn(Crack) -> (f64, u32),
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
style: &Style,
) -> Option<SubPath> {
) -> Option<(SubPath, bool)> {
let pieces = subpixel::outline(l, w, h, across, junction_at);
let mut segs: Vec<Cubic> = Vec::new();
for piece in &pieces {
@@ -206,7 +206,7 @@ pub(crate) fn fit_loop(
segs.extend(fit_piece(&piece.pts, &style, true));
}
}
subpath(&segs, true)
Some((subpath(&segs, true)?, pieces.iter().any(|p| p.hard)))
}
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
+8 -2
View File
@@ -866,7 +866,13 @@ impl Layers<'_> {
}
}
for comp in comps {
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style);
// (`hard`: some of the outline has no anti-aliasing, and is known less exactly.)
let hard = std::cell::Cell::new(false);
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)
};
let Some(outer) = fit(&comp.outer) else { continue };
let mut subs = vec![outer];
for hole in &comp.holes {
@@ -877,7 +883,7 @@ 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),
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only, hard.get()),
_ => None,
};
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
+94 -56
View File
@@ -37,13 +37,18 @@ impl TracedShape {
}
}
/// How near (px) a sharp corner the outline may fall short of it, and by how much more than
/// elsewhere: anti-aliasing and blur take that much off a corner.
const CORNER: f64 = 3.0;
const CORNER_SLACK: f64 = 0.75;
/// How far a point may lie from the shape: half a pixel, and for large shapes 0.2% of their size.
fn tolerance(size: f64) -> f64 {
(0.002 * size).max(0.5)
}
/// The shape the closed outline `sp` is, if it is one (and at least 6 px across).
pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
pub(crate) fn recognise(sp: &SubPath, hard: bool) -> Option<TracedShape> {
if !sp.closed || sp.anchors.len() < 2 {
return None;
}
@@ -83,7 +88,8 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
// The direction of the longer axis (none when the shape is as wide every way).
let axis = if spread < 0.01 * mean { 0.0 } else { 0.5 * (2.0 * sxy).atan2(sxx - syy) };
let size = 4.0 * major.sqrt();
let tol = tolerance(size);
// (An outline without anti-aliasing is only known to about half a pixel.)
let tol = tolerance(size) * if hard { 1.5 } else { 1.0 };
if size < 6.0 || !size.is_finite() {
return None;
}
@@ -110,64 +116,96 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
}
}
// A rectangle, along its longest straight side (a square has no longer axis to go by).
// A rectangle. Its sides run along its longest straight side if it has one, else along the
// longer axis of its area (a blurred one may have no side that is straight), or the pixels.
let quarter = std::f64::consts::FRAC_PI_2;
let folded = |a: f64| a - quarter * (a / quarter).round();
let side = (0..sp.segment_count())
.filter(|&i| sp.segment_is_line(i))
.map(|i| sp.segment(i))
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)));
if let Some(side) = side {
let d = side.p3 - side.p0;
// Turned by under 45° either way: a rectangle is the same a quarter turn on.
let quarter = std::f64::consts::FRAC_PI_2;
let angle = d.y.atan2(d.x);
let angle = angle - quarter * (angle / quarter).round();
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)))
.map(|s| folded((s.p3 - s.p0).y.atan2((s.p3 - s.p0).x)));
for angle in side.into_iter().chain([folded(axis), 0.0]) {
let xy: Vec<(f64, f64)> = pts.iter().map(|&p| local(p, angle)).collect();
let (hw, hh) = xy.iter().fold((0.0f64, 0.0f64), |m, p| (m.0.max(p.0.abs()), m.1.max(p.1.abs())));
// Square corners first; else rounded ones, which take (4 − π) r² off the area.
let rounded = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt().min(hw.min(hh));
for radius in [0.0, rounded] {
let off = xy.iter().map(|&(x, y)| {
// Distance to the rounded rectangle's edge.
if hw <= 2.0 || hh <= 2.0 {
continue;
}
// How far the outline is from the rectangle with corners of `radius`, at worst; the points
// by its corners `slack` nearer than they are.
let off = |radius: f64, slack: f64| {
let each = xy.iter().map(|&(x, y)| {
let (qx, qy) = (x.abs() - hw + radius, y.abs() - hh + radius);
(qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs()
let d = (qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs();
if x.abs() > hw - CORNER && y.abs() > hh - CORNER { (d - slack).max(0.0) } else { d }
});
if hw > 2.0 && hh > 2.0 && (radius == 0.0 || radius >= 0.75) && off.fold(0.0, f64::max) <= tol {
return Some(TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() });
}
each.fold(0.0, f64::max)
};
let rectangle = |radius: f64| TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() };
// Square corners (anti-aliasing and blur take a little off a corner: that is no radius).
// (Not of a thin bar: it is all corners, and would pass for a rectangle whatever it is.)
if off(0.0, if hw.min(hh) >= 2.0 * CORNER { CORNER_SLACK } else { 0.0 }) <= tol {
return Some(rectangle(0.0));
}
// Rounded ones: the radius that fits best, near the one the area says ((4 − π) r² is missing).
let by_area = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt();
let radii = (0..=40).map(|k| by_area + (f64::from(k) - 20.0) * 0.15).filter(|r| (CORNER..=hw.min(hh)).contains(r));
if let Some((_, radius)) = radii.map(|r| (off(r, 0.0), r)).min_by(|a, b| a.0.total_cmp(&b.0)).filter(|best| best.0 <= tol) {
return Some(rectangle(radius));
}
}
// A regular polygon: its corners evenly round the middle, its sides straight between them.
let sides = sp.anchors.len();
if (3..=12).contains(&sides) {
let radius = sp.anchors.iter().map(|a| a.p.distance(c)).sum::<f64>() / sides as f64;
let first = sp.anchors.first()?.p - c;
let rotation = (first.y.atan2(first.x).to_degrees() + 90.0).rem_euclid(360.0 / sides as f64);
let shape = TracedShape::Polygon { center: c, radius, sides: sides as u32, rotation };
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
// Every corner of the polygon has one of the outline's on it, and every point of the
// outline lies on one of the polygon's sides.
let cornered = ideal.iter().all(|q| sp.anchors.iter().any(|a| q.distance(a.p) <= 1.5 * tol));
let on_a_side = |p: Point| {
(0..ideal.len()).any(|i| {
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
p.distance(a + (b - a) * t) <= tol
})
// A regular polygon: going round it, its outline is nearest its middle once a side and farthest
// once a corner. The sides are as many as that happens most strongly for, the corners where
// it is farthest, the size what the area says.
let polar: Vec<(f64, f64)> = pts.iter().map(|&p| ((p - c).hypot(), (p - c).y.atan2((p - c).x))).collect();
let weights: Vec<f64> = (0..n).map(|i| (polar[(i + 1) % n].1 - polar[i].1).sin().abs()).collect();
let mean = polar.iter().zip(&weights).map(|(p, w)| p.0 * w).sum::<f64>() / weights.iter().sum::<f64>().max(1e-9);
let wave = |sides: u32| {
polar.iter().zip(&weights).fold((0.0, 0.0), |m, (p, w)| {
(m.0 + (p.0 - mean) * w * (f64::from(sides) * p.1).cos(), m.1 + (p.0 - mean) * w * (f64::from(sides) * p.1).sin())
})
};
let sides = (3..=12u32).max_by(|&a, &b| wave(a).0.hypot(wave(a).1).total_cmp(&wave(b).0.hypot(wave(b).1)))?;
let (re, im) = wave(sides);
let corner = im.atan2(re) / f64::from(sides);
// (More exactly: by the outline's own corners, where it has them there.)
let step = 2.0 * std::f64::consts::PI / f64::from(sides);
let turns: Vec<f64> = sp
.anchors
.iter()
.map(|a| ((a.p - c).y.atan2((a.p - c).x) - corner, (a.p - c).hypot()))
.map(|(d, far)| (d - step * (d / step).round(), far))
.filter(|&(d, far)| (d * far).abs() <= CORNER && far > 0.9 * mean)
.map(|(d, _)| d)
.collect();
let corner = corner + turns.iter().sum::<f64>() / turns.len().max(1) as f64;
let radius = (2.0 * area / (f64::from(sides) * (2.0 * std::f64::consts::PI / f64::from(sides)).sin())).sqrt();
let rotation = (corner.to_degrees() + 90.0).rem_euclid(360.0 / f64::from(sides));
let shape = TracedShape::Polygon { center: c, radius, sides, rotation };
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
// Every point of the outline lies on one of the polygon's sides (by its corners, nearly).
let side_length = 2.0 * radius * (std::f64::consts::PI / f64::from(sides)).sin();
let on_a_side = |p: Point| {
let slack = if side_length >= 4.0 * CORNER && ideal.iter().any(|q| q.distance(p) <= CORNER) { CORNER_SLACK } else { 0.0 };
(0..ideal.len()).any(|i| {
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
p.distance(a + (b - a) * t) <= tol + slack
})
};
if radius > 3.0 && pts.iter().all(|&p| on_a_side(p)) {
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
let side = radius * std::f64::consts::SQRT_2;
let turned = (rotation + 45.0).rem_euclid(90.0);
let square = TracedShape::Rectangle {
center: c,
width: side,
height: side,
radius: 0.0,
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
};
if cornered && pts.iter().all(|&p| on_a_side(p)) {
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
let side = radius * std::f64::consts::SQRT_2;
let turned = (rotation + 45.0).rem_euclid(90.0);
let square = TracedShape::Rectangle {
center: c,
width: side,
height: side,
radius: 0.0,
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
};
return Some(if sides == 4 { square } else { shape });
}
return Some(if sides == 4 { square } else { shape });
}
None
}
@@ -177,7 +215,7 @@ mod tests {
use super::*;
fn is(shape: TracedShape) -> Option<TracedShape> {
recognise(shape.path().subpaths.first().unwrap())
recognise(shape.path().subpaths.first().unwrap(), false)
}
#[test]
@@ -221,20 +259,20 @@ mod tests {
fn what_only_resembles_a_shape_is_none() {
// A star, an L, a rectangle with one corner cut, an egg.
let star = shapes::star(Point::new(50.0, 50.0), 30.0, 12.0, 5, 0.0);
assert_eq!(recognise(&star.subpaths[0]), None);
assert_eq!(recognise(&star.subpaths[0], false), None);
let poly = |pts: &[(f64, f64)]| SubPath::polyline(&pts.iter().map(|&p| Point::from(p)).collect::<Vec<_>>(), true);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)])), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)])), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)]), false), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)]), false), None);
let mut egg = shapes::ellipse(Rect::new(10.0, 10.0, 70.0, 50.0));
egg.subpaths[0].anchors[2].p.x += 4.0;
assert_eq!(recognise(&egg.subpaths[0]), None);
assert_eq!(recognise(&egg.subpaths[0], false), None);
// Too small to say, open, or not a number.
assert_eq!(is(TracedShape::Ellipse { center: Point::new(5.0, 5.0), rx: 2.0, ry: 2.0, rotation: 0.0 }), None);
let mut open = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
open.closed = false;
assert_eq!(recognise(&open), None);
assert_eq!(recognise(&open, false), None);
let mut bad = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
bad.anchors[1].p.x = f64::NAN;
assert_eq!(recognise(&bad), None);
assert_eq!(recognise(&bad, false), None);
}
}
+3 -2
View File
@@ -185,12 +185,12 @@ fn stat(runs: &[&Metrics], field: impl Fn(&Metrics) -> f64, max: bool, digits: u
if x.is_finite() { format!("{:.digits$}", x + 0.0) } else { "all".into() }
}
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | grouped % | gradient paths | found % | angle off | ramp off |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
const HEADER: &str = "| ΔE | edge ΔE | off mean | off max | missed max | paths | anchors | × art | lines % | upright % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px | centre off | width off | anchors a stroke | shapes % | false shapes | grouped % | gradient paths | found % | angle off | ramp off |\n|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|\n";
fn row(runs: &[&Run]) -> String {
let m: Vec<&Metrics> = runs.iter().filter_map(|r| r.metrics.as_ref()).collect();
if m.is_empty() {
return format!("| refused |{}", " |".repeat(26));
return format!("| refused |{}", " |".repeat(27));
}
let cells = [
stat(&m, |m| m.delta, false, 2),
@@ -215,6 +215,7 @@ fn row(runs: &[&Run]) -> String {
stat(&m, |m| m.width_off, false, 2),
stat(&m, |m| m.stroke_anchors, false, 1),
stat(&m, |m| m.shapes, false, 0),
stat(&m, |m| m.false_shapes, false, 2),
stat(&m, |m| m.grouped, false, 0),
stat(&m, |m| m.gradients, false, 1),
stat(&m, |m| m.ramp_found, false, 0),
+8
View File
@@ -194,6 +194,8 @@ pub struct Metrics {
pub stroke_anchors: f64,
/// Share (%) of the art's plain shapes that came out as a shape lying within 0.6 px of it.
pub shapes: f64,
/// Shapes: paths made a shape that lie more than 0.6 px off the art.
pub false_shapes: f64,
/// Auto Grouping: share (%) of the paths that lie inside another.
pub grouped: f64,
/// Gradients: paths filled with one; and where the input is a known gradient, whether one was
@@ -271,11 +273,16 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
let nan = f64::NAN;
let (mut geo_mean, mut geo_max, mut miss_max, mut corners, mut kinks, mut slivers, mut sliver_area) = (nan, nan, nan, nan, nan, nan, nan);
let mut false_shapes = if params.shapes { 0.0 } else { nan };
if !matches!(input.class, Class::Tone | Class::Ramp) {
let truth_shapes = input.scaled(size);
let truth = Outline::new(truth_shapes.iter().map(|(p, _)| p), None);
let traced = Outline::new(shapes.iter().map(|(p, _)| p), Some(f64::from(size)));
(geo_mean, geo_max) = traced.distance_to(&truth);
// Shapes that are not the art's: a path made a shape that strays more than 0.6 px from it.
false_shapes =
res.paths.iter().filter(|tp| tp.shape.is_some() && Outline::new([&tp.path.to_bezpath()], None).distance_to(&truth).1 > 0.6).count()
as f64;
miss_max = truth.distance_to(&traced).1;
let true_corners = input.scaled_corners(size);
@@ -378,6 +385,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
ramp_angle,
ramp_off,
shapes: shapes_found,
false_shapes,
centre_off,
width_off,
stroke_anchors,
File diff suppressed because it is too large. Load diff