mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Image Trace: Shapes, and corners whose pixel is mostly outside the shape
Shapes (params `shapes`, a checkbox in the panel; off unless asked for): a filled outline that is a circle, an ellipse, a rectangle or a regular polygon comes out as that live shape. A shape is only made when every point of the traced outline lies within 0.5 px (0.2% of the shape's size, if more) of it; an ellipse whose radii differ by less than twice that is a circle of its area. The outline before fitting: a corner's own pixel no longer pulls the two edges that meet there, and a one-pixel step between two long edges is the corner where they meet. Sharp corners kept: Default 69% -> 76%, the colour presets 86% -> 91%. The harness gains plain-shape inputs and "shapes %".
This commit is contained in:
+18
-4
@@ -48,6 +48,7 @@ mod fit;
|
||||
mod logo;
|
||||
mod mosaic;
|
||||
mod quantize;
|
||||
mod shapes;
|
||||
mod smooth;
|
||||
mod subpixel;
|
||||
|
||||
@@ -59,6 +60,7 @@ use vectorcraft_geom::PathData;
|
||||
pub use contour::{Component, Loop, trace_mask};
|
||||
pub use mosaic::mosaic;
|
||||
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
|
||||
pub use shapes::TracedShape;
|
||||
use subpixel::Crack;
|
||||
|
||||
/// Errors decoding a raster.
|
||||
@@ -279,6 +281,9 @@ pub struct TraceParams {
|
||||
/// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs
|
||||
/// to true circles.
|
||||
pub snap_curves_to_lines: bool,
|
||||
/// Shapes: a filled outline that is a circle, an ellipse, a rectangle (square or rounded
|
||||
/// corners) or a regular polygon comes out as exactly that shape ([`TracedPath::shape`]).
|
||||
pub shapes: bool,
|
||||
/// Create: trace areas as filled shapes.
|
||||
pub fills: bool,
|
||||
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
|
||||
@@ -309,6 +314,7 @@ impl Default for TraceParams {
|
||||
ignore_white: false,
|
||||
ignore_color: None,
|
||||
snap_curves_to_lines: false,
|
||||
shapes: false,
|
||||
fills: true,
|
||||
strokes: false,
|
||||
stroke_width: 10.0,
|
||||
@@ -426,6 +432,8 @@ pub struct TracedPath {
|
||||
/// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the
|
||||
/// outlines of wider areas without Fills).
|
||||
pub stroke: Option<f64>,
|
||||
/// Shapes: the shape the path is, exactly (`path` is that shape's outline).
|
||||
pub shape: Option<TracedShape>,
|
||||
}
|
||||
|
||||
/// The traced result, bottom-most path first.
|
||||
@@ -777,12 +785,12 @@ impl Layers<'_> {
|
||||
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
|
||||
let min_hole = params.noise.max(1) as i64;
|
||||
let mut paths = vec![];
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>| {
|
||||
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>| {
|
||||
let n = path.anchor_count();
|
||||
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
|
||||
return false;
|
||||
}
|
||||
paths.push(TracedPath { path, color, pixels, stroke });
|
||||
paths.push(TracedPath { path, color, pixels, stroke, shape });
|
||||
true
|
||||
};
|
||||
let mut comps = trace_mask(&mask, bw, bh);
|
||||
@@ -804,12 +812,18 @@ impl Layers<'_> {
|
||||
}
|
||||
subs.extend(fit(hole));
|
||||
}
|
||||
if !add(PathData::new(subs), comp.pixels, area_stroke) {
|
||||
// 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),
|
||||
_ => None,
|
||||
};
|
||||
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
|
||||
if !add(path, comp.pixels, area_stroke, shape) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for line in lines {
|
||||
if !add(line.path, line.pixels, Some(line.width)) {
|
||||
if !add(line.path, line.pixels, Some(line.width), None) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +478,13 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
if !out.palette.contains(&color) {
|
||||
out.palette.push(color);
|
||||
}
|
||||
out.paths.push(TracedPath { path: PathData::new(subs), color, pixels: (s.area * (factor * factor) as f64) as usize, stroke: None });
|
||||
out.paths.push(TracedPath {
|
||||
path: PathData::new(subs),
|
||||
color,
|
||||
pixels: (s.area * (factor * factor) as f64) as usize,
|
||||
stroke: None,
|
||||
shape: None,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Shapes: traced outlines that are a circle, an ellipse, a rectangle (its corners square or
|
||||
//! rounded) or a regular polygon are told so, and come out as exactly that shape.
|
||||
//!
|
||||
//! An outline is a shape when every point of it lies within a small tolerance of the shape that
|
||||
//! its own area, middle and extents describe: nothing is taken for a shape that it merely
|
||||
//! resembles.
|
||||
|
||||
use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, SubPath, shapes};
|
||||
|
||||
/// A shape an outline was found to be (pixel coordinates; angles in degrees, clockwise on screen).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum TracedShape {
|
||||
/// A rectangle `width` × `height` about `center`, turned by `rotation`, its corners rounded
|
||||
/// with `radius` (0: square corners).
|
||||
Rectangle { center: Point, width: f64, height: f64, radius: f64, rotation: f64 },
|
||||
/// An ellipse with radii `rx` and `ry` about `center`, turned by `rotation` (a circle: equal
|
||||
/// radii, no rotation).
|
||||
Ellipse { center: Point, rx: f64, ry: f64, rotation: f64 },
|
||||
/// A regular polygon: its first corner `rotation` from straight up.
|
||||
Polygon { center: Point, radius: f64, sides: u32, rotation: f64 },
|
||||
}
|
||||
|
||||
impl TracedShape {
|
||||
/// The shape's outline.
|
||||
pub fn path(&self) -> PathData {
|
||||
let turned = |p: PathData, c: Point, deg: f64| p.transformed(Affine::rotate_about(deg.to_radians(), c));
|
||||
match *self {
|
||||
TracedShape::Rectangle { center, width, height, radius, rotation } => {
|
||||
let r = Rect::from_center_size(center, (width, height));
|
||||
turned(if radius > 0.0 { shapes::rounded_rectangle(r, radius) } else { shapes::rectangle(r) }, center, rotation)
|
||||
}
|
||||
TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
turned(shapes::ellipse(Rect::from_center_size(center, (2.0 * rx, 2.0 * ry))), center, rotation)
|
||||
}
|
||||
TracedShape::Polygon { center, radius, sides, rotation } => shapes::polygon(center, radius, sides, rotation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if !sp.closed || sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// Points all along the outline.
|
||||
let pts: Vec<Point> = (0..sp.segment_count()).flat_map(|i| (0..24).map(move |k| sp.segment(i).eval(f64::from(k) / 24.0))).collect();
|
||||
let n = pts.len();
|
||||
if n < 8 || !pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Area, middle and second moments of the polygon through them.
|
||||
let (mut area2, mut cx, mut cy) = (0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let cross = a.x * b.y - b.x * a.y;
|
||||
area2 += cross;
|
||||
cx += (a.x + b.x) * cross;
|
||||
cy += (a.y + b.y) * cross;
|
||||
}
|
||||
let area = area2.abs() / 2.0;
|
||||
if area < 20.0 {
|
||||
return None;
|
||||
}
|
||||
let c = Point::new(cx / (3.0 * area2), cy / (3.0 * area2));
|
||||
let (mut ixx, mut iyy, mut ixy) = (0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i] - c, pts[(i + 1) % n] - c);
|
||||
let cross = a.x * b.y - b.x * a.y;
|
||||
ixx += (a.x * a.x + a.x * b.x + b.x * b.x) * cross;
|
||||
iyy += (a.y * a.y + a.y * b.y + b.y * b.y) * cross;
|
||||
ixy += (a.x * b.y + 2.0 * a.x * a.y + 2.0 * b.x * b.y + b.x * a.y) * cross;
|
||||
}
|
||||
// Per unit of area: the variances of x and y and their covariance.
|
||||
let (sxx, syy, sxy) = (ixx / (6.0 * area2), iyy / (6.0 * area2), ixy / (12.0 * area2));
|
||||
let mean = (sxx + syy) / 2.0;
|
||||
let spread = ((sxx - syy) / 2.0).hypot(sxy);
|
||||
let (major, minor) = (mean + spread, (mean - spread).max(0.0));
|
||||
// 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);
|
||||
if size < 6.0 || !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let local = |p: Point, angle: f64| {
|
||||
let d = p - c;
|
||||
(d.x * angle.cos() + d.y * angle.sin(), d.y * angle.cos() - d.x * angle.sin())
|
||||
};
|
||||
|
||||
// An ellipse: its radii are twice the roots of its variances.
|
||||
let (rx, ry) = (2.0 * major.sqrt(), 2.0 * minor.sqrt());
|
||||
if ry > 2.0 {
|
||||
let off = pts.iter().map(|&p| {
|
||||
let (x, y) = local(p, axis);
|
||||
// Scaled to a circle: how far from it, in pixels of the larger radius (no nearer than
|
||||
// that to the ellipse: a long thin bar is no flat ellipse).
|
||||
((x / rx).hypot(y / ry) - 1.0).abs() * rx
|
||||
});
|
||||
if off.fold(0.0, f64::max) <= tol {
|
||||
// Radii this close are a circle's, of the outline's area: a circle without anti-aliasing
|
||||
// is fitted a little wider one way than the other.
|
||||
let circle = (rx - ry).abs() <= 2.0 * tol;
|
||||
let (rx, ry) = if circle { ((rx * ry).sqrt(), (rx * ry).sqrt()) } else { (rx, ry) };
|
||||
return Some(TracedShape::Ellipse { center: c, rx, ry, rotation: if circle { 0.0 } else { axis.to_degrees() } });
|
||||
}
|
||||
}
|
||||
|
||||
// A rectangle, along its longest straight side (a square has no longer axis to go by).
|
||||
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();
|
||||
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.
|
||||
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()
|
||||
});
|
||||
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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is(shape: TracedShape) -> Option<TracedShape> {
|
||||
recognise(shape.path().subpaths.first().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_shape_is_told_from_its_own_outline() {
|
||||
let c = Point::new(60.3, 45.6);
|
||||
match is(TracedShape::Ellipse { center: c, rx: 20.0, ry: 20.0, rotation: 0.0 }) {
|
||||
Some(TracedShape::Ellipse { center, rx, ry, rotation }) => {
|
||||
assert!(center.distance(c) < 0.05 && (rx - 20.0).abs() < 0.05 && rx == ry && rotation == 0.0, "{center:?} {rx} {ry}")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match is(TracedShape::Ellipse { center: c, rx: 30.0, ry: 12.0, rotation: 25.0 }) {
|
||||
Some(TracedShape::Ellipse { rx, ry, rotation, .. }) => {
|
||||
assert!((rx - 30.0).abs() < 0.3 && (ry - 12.0).abs() < 0.3 && (rotation - 25.0).abs() < 0.5, "{rx} {ry} {rotation}")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match is(TracedShape::Rectangle { center: c, width: 50.0, height: 20.0, radius: 0.0, rotation: 10.0 }) {
|
||||
Some(TracedShape::Rectangle { width, height, radius, rotation, .. }) => {
|
||||
assert!((width - 50.0).abs() < 0.1 && (height - 20.0).abs() < 0.1 && radius == 0.0 && (rotation - 10.0).abs() < 0.1)
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match is(TracedShape::Rectangle { center: c, width: 50.0, height: 30.0, radius: 8.0, rotation: 0.0 }) {
|
||||
Some(TracedShape::Rectangle { width, radius, .. }) => {
|
||||
assert!((width - 50.0).abs() < 0.2 && (radius - 8.0).abs() < 0.5, "{width} {radius}")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
for sides in [3, 5, 6, 8] {
|
||||
match is(TracedShape::Polygon { center: c, radius: 25.0, sides, rotation: 7.0 }) {
|
||||
Some(TracedShape::Polygon { radius, sides: n, rotation, .. }) => {
|
||||
assert!(n == sides && (radius - 25.0).abs() < 0.1 && (rotation - 7.0).abs() < 0.1, "{sides}: {radius} {rotation}")
|
||||
}
|
||||
other => panic!("{sides} sides: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,56 @@ pub(crate) fn outline(
|
||||
};
|
||||
let step: Vec<bool> = (0..n).map(|i| edges[i].len == 1 && turn(i) * turn(i + 1) < 0).collect();
|
||||
let outward = |d: (i32, i32)| Vec2::new(f64::from(d.1), f64::from(-d.0));
|
||||
// The pixel at a corner of the shape lies on both of the corner's edges, and what it lacks of
|
||||
// the inside's colour is the doing of both: read as one edge's alone it dents the corner. The
|
||||
// crack of each edge on it takes the shift of the next crack along that edge instead (edges
|
||||
// of three pixels or more: a shorter one has no pixel clear of its corners).
|
||||
let shifts = {
|
||||
let mut shifts = shifts;
|
||||
for i in 0..n {
|
||||
let (b, e) = ((i + n - 1) % n, i);
|
||||
if step[b] || step[e] || turn(i) == 0 {
|
||||
continue;
|
||||
}
|
||||
let (before, edge) = (&edges[b], &edges[e]);
|
||||
if before.len >= 3 {
|
||||
shifts[before.first + before.len - 1] = shifts[before.first + before.len - 2];
|
||||
}
|
||||
if edge.len >= 3 {
|
||||
shifts[edge.first] = shifts[edge.first + 1];
|
||||
}
|
||||
}
|
||||
shifts
|
||||
};
|
||||
|
||||
// A sharp corner whose own pixel is less than half the inside's colour goes to the outside, and
|
||||
// the loop cuts the corner in two steps (the same, turned round, where the corner pixel of a
|
||||
// notch goes to the inside). Between two edges of three pixels or more, those two steps are
|
||||
// that corner: it lies where the two edges, each moved by its shift, meet. `notch[i]` is, for
|
||||
// the first of the two steps, the corner (`Some(Some(..))`), for the second nothing
|
||||
// (`Some(None)`), for any other edge `None`.
|
||||
let mut notch: Vec<Option<Option<Point>>> = vec![None; n];
|
||||
for i in 0..n {
|
||||
let (a, s1, s2, b) = ((i + n - 1) % n, i, (i + 1) % n, (i + 2) % n);
|
||||
let free = |e: usize| junctions[edges[e].first].is_none();
|
||||
if n >= 6
|
||||
&& step[s1]
|
||||
&& step[s2]
|
||||
&& edges[a].len >= 3
|
||||
&& edges[b].len >= 3
|
||||
&& edges[s1].dir == edges[b].dir
|
||||
&& edges[s2].dir == edges[a].dir
|
||||
&& free(s1)
|
||||
&& free(s2)
|
||||
&& free(b)
|
||||
{
|
||||
let (ea, eb) = (&edges[a], &edges[b]);
|
||||
// Edge `a` carried on one pixel further meets edge `b` carried back one.
|
||||
let meet = at(s1) + Vec2::new(f64::from(ea.dir.0), f64::from(ea.dir.1));
|
||||
notch[s1] = Some(Some(meet + outward(ea.dir) * shifts[ea.first + ea.len - 1] + outward(eb.dir) * shifts[eb.first]));
|
||||
notch[s2] = Some(None);
|
||||
}
|
||||
}
|
||||
|
||||
let mut pieces: Vec<Piece> = Vec::new();
|
||||
let start = first.unwrap_or(0);
|
||||
@@ -196,7 +246,10 @@ pub(crate) fn outline(
|
||||
let before = &edges[(i + n - 1) % n];
|
||||
pts.push(corner + outward(before.dir) * shifts[(crack + cracks - 1) % cracks] + outward(e.dir) * shifts[crack]);
|
||||
}
|
||||
if step[i] || !hard {
|
||||
if !hard && let Some(sharp) = notch[i] {
|
||||
// A corner whose own pixel went to the other side: the corner, not the two steps.
|
||||
pts.extend(sharp);
|
||||
} else if step[i] || !hard {
|
||||
pts.push(corner + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * 0.5 + outward(e.dir) * shifts[crack]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,3 +1169,84 @@ fn a_stroke_is_as_wide_as_its_ink_not_a_whole_number_of_pixels() {
|
||||
assert!((width - want).abs() < 0.08, "{width} for a line {want} px wide");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anti_aliased_rectangle_along_the_pixel_grid_is_four_anchors() {
|
||||
// Its sides lie between pixel columns and rows: its corner pixels are part of two sides each.
|
||||
let img = soft(140, 90, |x, y| (20.4..120.7).contains(&x) && (15.3..70.6).contains(&y));
|
||||
let res = trace(&img, &bw());
|
||||
let sp = &res.paths[0].path.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||||
let b = res.paths[0].path.bounds().unwrap();
|
||||
// (To the eighth of a pixel the test picture is drawn to.)
|
||||
assert!((b.x0 - 20.4).abs() < 0.07 && (b.x1 - 120.7).abs() < 0.07 && (b.y0 - 15.3).abs() < 0.07 && (b.y1 - 70.6).abs() < 0.07, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_come_out_as_shapes_when_asked_for() {
|
||||
// A disc, a square along the pixel grid and a rectangle turned 20°, anti-aliased.
|
||||
let turn = 20f64.to_radians();
|
||||
let turned = |x: f64, y: f64| {
|
||||
let (dx, dy) = (x - 180.4, y - 45.3);
|
||||
(dx * turn.cos() + dy * turn.sin()).abs() <= 30.0 && (dy * turn.cos() - dx * turn.sin()).abs() <= 18.0
|
||||
};
|
||||
let img = soft(220, 90, |x, y| {
|
||||
Point::new(x, y).distance(Point::new(40.3, 44.6)) <= 25.0 || ((80.4..130.7).contains(&x) && (20.3..70.6).contains(&y)) || turned(x, y)
|
||||
});
|
||||
let plain = trace(&img, &bw());
|
||||
assert!(plain.paths.iter().all(|p| p.shape.is_none()));
|
||||
let res = trace(&img, &TraceParams { shapes: true, ..bw() });
|
||||
assert_eq!(res.paths.len(), 3);
|
||||
let mut found = (false, false, false);
|
||||
for p in &res.paths {
|
||||
// The path is the shape's own outline.
|
||||
assert_eq!(Some(&p.path), p.shape.map(|s| s.path()).as_ref());
|
||||
match p.shape {
|
||||
Some(TracedShape::Ellipse { center, rx, ry, .. }) => {
|
||||
found.0 = center.distance(Point::new(40.3, 44.6)) < 0.15 && (rx - 25.0).abs() < 0.15 && rx == ry
|
||||
}
|
||||
Some(TracedShape::Rectangle { center, width, height, radius: 0.0, rotation }) if center.x < 150.0 => {
|
||||
found.1 = center.distance(Point::new(105.55, 45.45)) < 0.15
|
||||
&& (width - 50.3).abs() < 0.25
|
||||
&& (height - 50.3).abs() < 0.25
|
||||
&& rotation.abs() < 0.3
|
||||
}
|
||||
Some(TracedShape::Rectangle { center, width, height, radius: 0.0, rotation }) => {
|
||||
found.2 = center.distance(Point::new(180.4, 45.3)) < 0.2
|
||||
&& (width - 60.0).abs() < 0.3
|
||||
&& (height - 36.0).abs() < 0.3
|
||||
&& (rotation - 20.0).abs() < 0.3
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(found, (true, true, true));
|
||||
// A letter is no shape, and a ring is not a disc.
|
||||
let ring = soft(100, 100, |x, y| (20.0..40.0).contains(&Point::new(x, y).distance(Point::new(50.0, 50.0))));
|
||||
assert!(trace(&ring, &TraceParams { shapes: true, ..bw() }).paths.iter().all(|p| p.shape.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corner_whose_pixel_is_mostly_outside_is_still_a_corner() {
|
||||
// Each corner pixel is under half covered (0.6 × 0.7 and less) and traces as outside.
|
||||
let img = soft(140, 90, |x, y| (20.4..120.6).contains(&x) && (15.3..70.7).contains(&y));
|
||||
let res = trace(&img, &bw());
|
||||
let sp = &res.paths[0].path.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||||
let b = res.paths[0].path.bounds().unwrap();
|
||||
assert!((b.x0 - 20.4).abs() < 0.07 && (b.x1 - 120.6).abs() < 0.07 && (b.y0 - 15.3).abs() < 0.07 && (b.y1 - 70.7).abs() < 0.07, "{b:?}");
|
||||
}
|
||||
|
||||
/// A circle without anti-aliasing is still a circle, not an ellipse a little wider than tall.
|
||||
#[test]
|
||||
fn a_hard_edged_circle_is_a_circle() {
|
||||
let img = Raster::from_fn(100, 100, |x, y| {
|
||||
let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0);
|
||||
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
|
||||
});
|
||||
let res = trace(&img, &TraceParams { shapes: true, ignore_white: true, ..Default::default() });
|
||||
let [TracedPath { shape: Some(TracedShape::Ellipse { center, rx, ry, .. }), .. }] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
|
||||
assert!(rx == ry && (rx - 30.0).abs() < 0.3 && center.distance(Point::new(50.0, 50.0)) < 0.1, "{center:?} {rx} {ry}");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! sizes) and two continuous-tone images computed per pixel.
|
||||
|
||||
use kurbo::{Affine, BezPath, Circle, Ellipse, Point, RoundedRect, Shape};
|
||||
use vectorcraft_trace::Raster;
|
||||
use vectorcraft_trace::{Raster, TracedShape};
|
||||
|
||||
use crate::raster::{self, noise};
|
||||
|
||||
@@ -18,13 +18,15 @@ pub enum Class {
|
||||
/// Straight-edged shapes and lines a degree or two off level and upright, as a scan or a
|
||||
/// screenshot taken a little askew has them.
|
||||
Askew,
|
||||
/// Plain shapes apart from each other: a circle, an ellipse, rectangles, a regular polygon.
|
||||
Plain,
|
||||
/// Flat art in several colours, with regions that touch.
|
||||
Flat,
|
||||
/// Continuous tone: a gradient and a photo-like image.
|
||||
Tone,
|
||||
}
|
||||
|
||||
pub const CLASSES: [Class; 6] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Flat, Class::Tone];
|
||||
pub const CLASSES: [Class; 7] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain, Class::Flat, Class::Tone];
|
||||
|
||||
impl Class {
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -33,6 +35,7 @@ impl Class {
|
||||
Class::Poly => "polygons",
|
||||
Class::Lines => "thin lines",
|
||||
Class::Askew => "askew shapes",
|
||||
Class::Plain => "plain shapes",
|
||||
Class::Flat => "flat colour art",
|
||||
Class::Tone => "gradient, photo",
|
||||
}
|
||||
@@ -82,6 +85,8 @@ pub struct Input {
|
||||
pub anchors: usize,
|
||||
/// Its sharp corners.
|
||||
pub corners: Vec<Point>,
|
||||
/// The plain shapes it is made of, where it is made of them.
|
||||
pub plain: Vec<TracedShape>,
|
||||
/// Its lines, where it is lines: each one's two ends and its width.
|
||||
pub strokes: Vec<(Point, Point, f64)>,
|
||||
tone: Option<Tone>,
|
||||
@@ -173,10 +178,20 @@ pub fn inputs() -> Vec<Input> {
|
||||
anchors,
|
||||
corners,
|
||||
strokes: vec![],
|
||||
plain: vec![],
|
||||
tone: None,
|
||||
};
|
||||
let tone =
|
||||
|name, f: Tone| Input { name, class: Class::Tone, shapes: vec![], bg: WHITE, anchors: 0, corners: vec![], strokes: vec![], tone: Some(f) };
|
||||
let tone = |name, f: Tone| Input {
|
||||
name,
|
||||
class: Class::Tone,
|
||||
shapes: vec![],
|
||||
bg: WHITE,
|
||||
anchors: 0,
|
||||
corners: vec![],
|
||||
strokes: vec![],
|
||||
plain: vec![],
|
||||
tone: Some(f),
|
||||
};
|
||||
let ink = |p: BezPath| vec![(p, INK)];
|
||||
|
||||
let mut ellipses = Ellipse::new((90.3, 99.6), (60.0, 30.0), 0.0).to_path(1e-3);
|
||||
@@ -247,6 +262,20 @@ pub fn inputs() -> Vec<Input> {
|
||||
askew.extend(turned(poly(&[(20.0, 150.3), (100.0, 150.3), (100.0, 153.3), (20.0, 153.3)]), 1.0, (60.0, 152.0)));
|
||||
askew.extend(turned(poly(&[(60.4, 170.0), (62.4, 170.0), (62.4, 240.0), (60.4, 240.0)]), 2.0, (61.0, 205.0)));
|
||||
|
||||
// A circle, a rectangle, a square, a rounded rectangle, an ellipse and a hexagon.
|
||||
let plain = vec![
|
||||
TracedShape::Ellipse { center: Point::new(50.3, 50.6), rx: 30.0, ry: 30.0, rotation: 0.0 },
|
||||
TracedShape::Rectangle { center: Point::new(170.4, 50.3), width: 110.0, height: 60.0, radius: 0.0, rotation: 0.0 },
|
||||
TracedShape::Rectangle { center: Point::new(50.6, 140.4), width: 60.0, height: 60.0, radius: 0.0, rotation: 0.0 },
|
||||
TracedShape::Rectangle { center: Point::new(170.3, 140.6), width: 100.0, height: 50.0, radius: 12.0, rotation: 0.0 },
|
||||
TracedShape::Ellipse { center: Point::new(70.4, 220.3), rx: 45.0, ry: 18.0, rotation: 0.0 },
|
||||
TracedShape::Polygon { center: Point::new(190.6, 215.4), radius: 30.0, sides: 6, rotation: 10.0 },
|
||||
];
|
||||
let mut plain_art = BezPath::new();
|
||||
for shape in &plain {
|
||||
plain_art.extend(shape.path().to_bezpath());
|
||||
}
|
||||
|
||||
let centre = Point::new(128.4, 128.3);
|
||||
let touching = vec![(half_disc(centre, 90.0, true), [200, 40, 50]), (half_disc(centre, 90.0, false), [30, 90, 200])];
|
||||
let bar = [(60.3, 180.4), (196.6, 180.4), (196.6, 206.7), (60.3, 206.7)];
|
||||
@@ -267,11 +296,15 @@ pub fn inputs() -> Vec<Input> {
|
||||
art("letter A", Class::Poly, ink(with_holes(poly(&a_outer), &[poly(&a_counter)])), 10, points(&[&a_outer[..], &a_counter].concat())),
|
||||
art("lines", Class::Lines, ink(lines), 20, vec![]),
|
||||
art("askew shapes and lines", Class::Askew, ink(askew), 24, vec![]),
|
||||
art("plain shapes", Class::Plain, ink(plain_art), 30, vec![]),
|
||||
art("two touching halves", Class::Flat, touching, 6, vec![(centre.x, centre.y - 90.0).into(), (centre.x, centre.y + 90.0).into()]),
|
||||
art("four-colour badge", Class::Flat, multicolor, 20, points(&bar)),
|
||||
tone("gradient", gradient),
|
||||
tone("photo-like", photo),
|
||||
];
|
||||
if let Some(input) = all.iter_mut().find(|i| i.name == "plain shapes") {
|
||||
input.plain = plain;
|
||||
}
|
||||
if let Some(input) = all.iter_mut().find(|i| i.name == "lines") {
|
||||
input.strokes = strokes;
|
||||
}
|
||||
@@ -310,6 +343,11 @@ impl Input {
|
||||
let k = f64::from(size) / 256.0;
|
||||
self.strokes.iter().map(|(a, b, w)| ((a.to_vec2() * k).to_point(), (b.to_vec2() * k).to_point(), w * k)).collect()
|
||||
}
|
||||
/// Its plain shapes' outlines in the pixel coordinates of a `size`-pixel drawing.
|
||||
pub fn scaled_plain(&self, size: u32) -> Vec<BezPath> {
|
||||
let k = Affine::scale(f64::from(size) / 256.0);
|
||||
self.plain.iter().map(|s| k * s.path().to_bezpath()).collect()
|
||||
}
|
||||
pub fn scaled_corners(&self, size: u32) -> Vec<Point> {
|
||||
self.corners.iter().map(|p| (p.to_vec2() * (f64::from(size) / 256.0)).to_point()).collect()
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ fn target(preset: &str, input: &Input) -> bool {
|
||||
// Three colours cannot show the badge's five.
|
||||
"3 Colors" => return input.name == "two touching halves",
|
||||
"6 Colors" | "16 Colors" | "Flat Logo" | "Auto Color" => &[Class::Flat],
|
||||
"Default with Shapes" | "6 Colors with Shapes" => &[Class::Plain],
|
||||
"Sketched Art" => &[Class::Shape, Class::Lines],
|
||||
"Silhouettes" => &[Class::Shape],
|
||||
"Line Art" | "Outline" => &[Class::Lines],
|
||||
@@ -134,7 +135,7 @@ fn svg_trace(svg: &str) -> TraceResult {
|
||||
_ => panic!("unread transform {t}"),
|
||||
});
|
||||
}
|
||||
Some(TracedPath { path: vectorcraft_geom::PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: None })
|
||||
Some(TracedPath { path: vectorcraft_geom::PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: None, shape: None })
|
||||
});
|
||||
TraceResult { paths: paths.collect(), palette: vec![[0; 3]] }
|
||||
}
|
||||
@@ -172,12 +173,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 |\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 % |\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(20));
|
||||
return format!("| refused |{}", " |".repeat(21));
|
||||
}
|
||||
let cells = [
|
||||
stat(&m, |m| m.delta, false, 2),
|
||||
@@ -201,6 +202,7 @@ fn row(runs: &[&Run]) -> String {
|
||||
stat(&m, |m| m.centre_off, false, 3),
|
||||
stat(&m, |m| m.width_off, false, 2),
|
||||
stat(&m, |m| m.stroke_anchors, false, 1),
|
||||
stat(&m, |m| m.shapes, false, 0),
|
||||
];
|
||||
format!("| {} |", cells.join(" | "))
|
||||
}
|
||||
@@ -224,10 +226,14 @@ fn cost(img: &Raster, params: &TraceParams) -> String {
|
||||
}
|
||||
|
||||
fn report() -> String {
|
||||
let presets = presets();
|
||||
// The built-in presets, and two of them with the Shapes option on.
|
||||
let mut presets = presets();
|
||||
for (name, base) in [("Default with Shapes", "Default"), ("6 Colors with Shapes", "6 Colors")] {
|
||||
presets.push((name, TraceParams { shapes: true, ..vectorcraft_trace::preset(base).unwrap() }));
|
||||
}
|
||||
let inputs = inputs();
|
||||
let drawn = draw_all(&inputs);
|
||||
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew);
|
||||
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew | Class::Plain);
|
||||
if let Ok(dir) = std::env::var("TRACE_QUALITY_IMAGES") {
|
||||
for (input, size, variant, img) in &drawn {
|
||||
std::fs::write(format!("{dir}/{}.png", stem(input, *size, *variant)), img.encode_png()).unwrap();
|
||||
@@ -274,7 +280,7 @@ fn report() -> String {
|
||||
.collect();
|
||||
let _ = write!(md, "\n## 4. Yardstick\n\n{} traces by another tracer, measured the same way.\n\n| inputs | variant ", theirs.len());
|
||||
md += HEADER.replacen("\n|", "\n|:--|:--|", 1).as_str();
|
||||
for class in [Class::Shape, Class::Poly, Class::Lines, Class::Askew] {
|
||||
for class in [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Plain] {
|
||||
for variant in VARIANTS {
|
||||
let rows: Vec<&Run> = theirs.iter().filter(|r| r.class == class && r.variant == variant).collect();
|
||||
let _ = writeln!(md, "| {} | {} {}", class.name(), variant.name(), row(&rows));
|
||||
|
||||
@@ -192,6 +192,8 @@ pub struct Metrics {
|
||||
pub centre_off: f64,
|
||||
pub width_off: f64,
|
||||
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,
|
||||
/// Paths under 6 px of area.
|
||||
pub specks: f64,
|
||||
/// How far (px) the trace reaches outside the image: a stray curve.
|
||||
@@ -231,7 +233,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
let kept = true_corners.iter().filter(|c| turns.iter().any(|(p, t)| *t >= 20.0 && p.distance(**c) <= 1.5)).count();
|
||||
corners = 100.0 * kept as f64 / true_corners.len() as f64;
|
||||
}
|
||||
if input.class != Class::Lines && input.class != Class::Askew {
|
||||
if !matches!(input.class, Class::Lines | Class::Askew | Class::Plain) {
|
||||
kinks = turns.iter().filter(|(p, t)| *t >= 10.0 && true_corners.iter().all(|c| p.distance(*c) > 2.0)).count() as f64;
|
||||
}
|
||||
if params.mode != Mode::BlackAndWhite {
|
||||
@@ -302,7 +304,20 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
|
||||
}
|
||||
}
|
||||
|
||||
// Plain shapes: each is found when some path that is a shape lies on it.
|
||||
let plain = input.scaled_plain(size);
|
||||
let mut shapes_found = nan;
|
||||
if !plain.is_empty() {
|
||||
let made: Vec<Outline> = res.paths.iter().filter(|tp| tp.shape.is_some()).map(|tp| Outline::new([&tp.path.to_bezpath()], None)).collect();
|
||||
let found = plain.iter().filter(|p| {
|
||||
let truth = Outline::new([*p], None);
|
||||
made.iter().any(|m| m.distance_to(&truth).1 <= 0.6 && truth.distance_to(m).1 <= 0.6)
|
||||
});
|
||||
shapes_found = 100.0 * found.count() as f64 / plain.len() as f64;
|
||||
}
|
||||
|
||||
Metrics {
|
||||
shapes: shapes_found,
|
||||
centre_off,
|
||||
width_off,
|
||||
stroke_anchors,
|
||||
|
||||
Reference in New Issue
Block a user