mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Image Trace: Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art
- A sharp corner may fall up to 0.75 px short within 3 px of where it should be: anti-aliasing and blur take that much off a corner (not on a thin bar, which is all corners). - A rounded rectangle gets the radius that fits its outline best, searched around the one its area says, instead of that one alone. - A rectangle's direction is tried along its longest straight side, the longer axis of its area and the pixel grid: a blurred one may have no straight side. - A regular polygon's sides, corners and size are read from the outline's shape (how its distance from the middle goes round, and its area), not from how many anchors the fitter gave it. - An outline without anti-aliasing is known to half a pixel: it gets half as much room again. Plain shapes found with Shapes on, all sizes and variants: Default 57% -> 98%, 6 Colors 62% -> 100%. The harness counts false shapes: none on any input but one thin line (a rectangle 2 px wide) whose trace is 0.70 px off with or without Shapes.
This commit is contained in:
1 parent
fe83800ed5
commit
c6d9822f26
6 files changed
+658
-603
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+543
-541
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user