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:
bflatastic
2026-10-13 10:25:05 +07:00
parent f0f3cb4eb1
commit 9ca74ac011
12 changed files with 900 additions and 322 deletions
+42 -4
View File
@@ -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()
}
+12 -6
View File
@@ -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));
+16 -1
View File
@@ -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,