Image Trace: Snap Curves To Lines levels lines a degree or two askew

The documented option "replaces slightly curved lines with straight lines and snaps lines near 0 or 90 degrees
to absolute 0 or 90 degrees" (helpx, Image Trace panel options); ours did the first half only.

- With Snap Curves To Lines, a straight piece at least 2 px long and within 2.5 degrees of level or upright is
  made exactly so, by moving its ends across it to their mean (the page gives no angle: 2.5 degrees is our
  choice). The pieces next to a moved end follow it.
- A junction other regions share stays where it is, so abutting regions still have one curve along their edge;
  a line between a junction and a free end is levelled to the junction.
- The harness gains askew inputs (a rectangle, a plus sign and two lines, 1 to 2 degrees off level and upright)
  and an "upright %" column: the share of the outline length that is exactly level or upright.

On the clean askew inputs (docs/image-trace-quality.md): Black and White Logo and Technical Drawing, the presets
with the option on, go from 0% to 91% and 95% of their outline exactly level or upright. Their outlines are then
0.49 and 0.59 px from the art as drawn where they were 0.05 (4.2 px at the far end of a 300 px side turned 1.5
degrees): that distance is what the option is for. Presets without the option are unchanged.
This commit is contained in:
bflatastic
2026-10-13 09:07:13 +07:00
parent 05e321f4e0
commit 612082e26e
8 changed files with 461 additions and 302 deletions
+65
View File
@@ -60,6 +60,65 @@ fn before(a: Point, b: Point) -> std::cmp::Ordering {
a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
}
/// Lines within this of level or upright are made exactly so (the tangent of 2.5°).
const NEARLY_UPRIGHT: f64 = 0.0437;
/// Snap Curves To Lines: the straight pieces of `segs` (a chain; `closed` when its end joins its
/// start) that are within 2.5° of level or upright and at least 2 px long are made exactly level
/// or upright, by moving their ends across them: both to their mean, or, in an open chain, the
/// free end to the end that is fixed (a chain's own ends are junctions other regions share, and
/// stay where they are). The pieces next to a moved end follow it.
fn snap_upright(segs: &mut [Cubic], closed: bool) {
let n = segs.len();
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
let lines: Vec<bool> = segs.iter().map(straight).collect();
// Anchor `i` starts piece `i`; an open chain has one more, at its end.
let fixed = |i: usize| !closed && (i == 0 || i == n);
let at = |segs: &[Cubic], i: usize| if i < n { segs[i][0] } else { segs[n - 1][3] };
let put = |segs: &mut [Cubic], i: usize, p: Point| {
let i = if closed { i % n } else { i };
let d = p - at(segs, i);
if i < n {
segs[i][0] = p;
segs[i][1] += d;
}
if let Some(before) = if i > 0 { Some(i - 1) } else { closed.then_some(n - 1) } {
segs[before][3] = p;
segs[before][2] += d;
}
};
for (i, &line) in lines.iter().enumerate() {
let (ia, ib) = (i, if closed { (i + 1) % n } else { i + 1 });
let (a, b) = (at(segs, ia), at(segs, ib));
let d = b - a;
if !line || d.hypot() < 2.0 || ia == ib {
continue;
}
let level = d.y.abs() <= NEARLY_UPRIGHT * d.x.abs();
if !level && d.x.abs() > NEARLY_UPRIGHT * d.y.abs() {
continue;
}
let pick = |p: Point| if level { p.y } else { p.x };
let to = match (fixed(ia), fixed(ib)) {
(true, true) => continue,
(true, false) => pick(a),
(false, true) => pick(b),
(false, false) => (pick(a) + pick(b)) / 2.0,
};
let moved = |p: Point| if level { Point::new(p.x, to) } else { Point::new(to, p.y) };
if !fixed(ia) {
put(segs, ia, moved(a));
}
if !fixed(ib) {
put(segs, ib, moved(b));
}
}
// A straight piece has its handles on it.
for (c, _) in segs.iter_mut().zip(&lines).filter(|(_, line)| **line) {
*c = [c[0], c[0] + (c[3] - c[0]) / 3.0, c[0] + (c[3] - c[0]) * (2.0 / 3.0), c[3]];
}
}
/// A whole loop fitted. The same loop gives the same curves whichever way it runs and wherever it
/// starts (it is fitted clockwise from its leftmost point): the outline of a shape and the hole it
/// leaves in the shape around it are one curve.
@@ -80,6 +139,9 @@ fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
segs = polygon(&pts, style.tol);
}
if style.upright {
snap_upright(&mut segs, true);
}
Some(if flip { reversed(segs) } else { segs })
}
@@ -107,6 +169,9 @@ fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
segs = kept.windows(2).filter(|p| p[0] != p[1]).map(|p| [p[0], p[0], p[1], p[1]]).collect();
}
if style.upright {
snap_upright(&mut segs, false);
}
if flip { reversed(segs) } else { segs }
}
+3 -2
View File
@@ -329,8 +329,8 @@ impl TraceParams {
}
/// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100,
/// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0).
/// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones
/// and makes parts of circles true arcs.
/// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones,
/// makes parts of circles true arcs, and makes lines within 2.5° of level or upright exactly so.
fn outline_style(&self) -> logo::Style {
// Parameters come from users and agents: a NaN or infinity must not reach the geometry.
let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default };
@@ -345,6 +345,7 @@ impl TraceParams {
circle_tol: if snap { 0.3 } else { 0.15 },
restore: Some(logo::RestoreParams::default()),
check_step: 0.15,
upright: snap,
..logo::Style::default()
}
}
+4
View File
@@ -25,6 +25,9 @@ pub(crate) struct Style {
pub max_dev: f64,
/// Spacing of the points of the fit that this check looks at.
pub check_step: f64,
/// Straight pieces within a few degrees of level or upright are made exactly so (by the
/// tracer's other modes; Flat Logo leaves them).
pub upright: bool,
}
impl Default for Style {
@@ -42,6 +45,7 @@ impl Default for Style {
restore: Some(RestoreParams::default()),
max_dev: 1.4,
check_step: 0.03,
upright: false,
}
}
}
+33
View File
@@ -1093,3 +1093,36 @@ fn ignore_color_leaves_out_the_colour_picked() {
assert_eq!(old.ignore_color, None);
assert_eq!(serde_json::to_value(&params).unwrap()["ignoreColor"], serde_json::Value::Null);
}
#[test]
fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() {
// 120 × 60, turned 1.5° about its centre.
let (c, t) = (Point::new(100.3, 60.6), 1.5f64.to_radians());
let img = soft(200, 120, |x, y| {
let (dx, dy) = (x - c.x, y - c.y);
(dx * t.cos() + dy * t.sin()).abs() <= 60.0 && (dy * t.cos() - dx * t.sin()).abs() <= 30.0
});
let sides = |snap: bool| {
let res = trace(&img, &TraceParams { snap_curves_to_lines: snap, ..bw() });
let sp = res.paths[0].path.subpaths[0].clone();
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
(0..4).map(|i| sp.segment(i)).map(|s| ((s.p3.x - s.p0.x).abs(), (s.p3.y - s.p0.y).abs())).collect::<Vec<_>>()
};
// Snapped, every side is exactly level or upright; left alone, it keeps its 1.5°.
assert!(sides(true).iter().all(|(dx, dy)| *dx == 0.0 || *dy == 0.0), "{:?}", sides(true));
assert!(sides(false).iter().all(|(dx, dy)| dx.min(*dy) > 0.5), "{:?}", sides(false));
// And it is still the rectangle: 120 by 60 about the same centre.
let b = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() }).paths[0].path.bounds().unwrap();
assert!((b.width() - 120.0).abs() < 1.0 && (b.height() - 60.0).abs() < 1.0 && (b.center().x - c.x).abs() < 0.3, "{b:?}");
// A side 5° off is no slip of the scanner: it stays.
let t = 5f64.to_radians();
let img = soft(200, 120, |x, y| {
let (dx, dy) = (x - c.x, y - c.y);
(dx * t.cos() + dy * t.sin()).abs() <= 60.0 && (dy * t.cos() - dx * t.sin()).abs() <= 30.0
});
let sp = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() }).paths[0].path.subpaths[0].clone();
assert!(
(0..sp.segment_count())
.all(|i| (sp.segment(i).p3.y - sp.segment(i).p0.y).abs() > 1.0 && (sp.segment(i).p3.x - sp.segment(i).p0.x).abs() > 1.0)
);
}
+27 -1
View File
@@ -15,13 +15,16 @@ pub enum Class {
Poly,
/// Thin lines, 0.5 to 5 units wide.
Lines,
/// 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,
/// Flat art in several colours, with regions that touch.
Flat,
/// Continuous tone: a gradient and a photo-like image.
Tone,
}
pub const CLASSES: [Class; 5] = [Class::Shape, Class::Poly, Class::Lines, Class::Flat, Class::Tone];
pub const CLASSES: [Class; 6] = [Class::Shape, Class::Poly, Class::Lines, Class::Askew, Class::Flat, Class::Tone];
impl Class {
pub fn name(self) -> &'static str {
@@ -29,6 +32,7 @@ impl Class {
Class::Shape => "curved shapes",
Class::Poly => "polygons",
Class::Lines => "thin lines",
Class::Askew => "askew shapes",
Class::Flat => "flat colour art",
Class::Tone => "gradient, photo",
}
@@ -215,6 +219,27 @@ pub fn inputs() -> Vec<Input> {
lines.extend(strip);
}
// A rectangle 1.5° off level, a plus sign 1° off the other way, and two lines 1° and 2° off.
let turned = |p: BezPath, deg: f64, about: (f64, f64)| Affine::rotate_about(deg.to_radians(), Point::new(about.0, about.1)) * p;
let plus = [
(150.0, 150.0),
(170.0, 150.0),
(170.0, 180.0),
(200.0, 180.0),
(200.0, 200.0),
(170.0, 200.0),
(170.0, 230.0),
(150.0, 230.0),
(150.0, 200.0),
(120.0, 200.0),
(120.0, 180.0),
(150.0, 180.0),
];
let mut askew = turned(poly(&[(40.3, 30.6), (200.7, 30.6), (200.7, 110.4), (40.3, 110.4)]), 1.5, (120.0, 70.0));
askew.extend(turned(poly(&plus), -1.0, (160.0, 190.0)));
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)));
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)];
@@ -234,6 +259,7 @@ pub fn inputs() -> Vec<Input> {
art("star", Class::Poly, ink(poly(&star)), 10, points(&star)),
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("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),
+6 -5
View File
@@ -72,7 +72,7 @@ fn target(preset: &str, input: &Input) -> bool {
"Sketched Art" => &[Class::Shape, Class::Lines],
"Silhouettes" => &[Class::Shape],
"Line Art" | "Outline" => &[Class::Lines],
"Technical Drawing" => &[Class::Lines, Class::Poly],
"Technical Drawing" => &[Class::Lines, Class::Poly, Class::Askew],
_ => &[Class::Shape, Class::Poly],
};
classes.contains(&input.class)
@@ -172,12 +172,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 % | corners % | kinks | odd colours | odd px | gap px | overlap px | specks | outside px |\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 |\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(16));
return format!("| refused |{}", " |".repeat(17));
}
let cells = [
stat(&m, |m| m.delta, false, 2),
@@ -189,6 +189,7 @@ fn row(runs: &[&Run]) -> String {
stat(&m, |m| m.anchors, false, 0),
stat(&m, |m| m.anchor_ratio, false, 2),
stat(&m, |m| m.lines, false, 0),
stat(&m, |m| m.upright, false, 0),
stat(&m, |m| m.corners, false, 0),
stat(&m, |m| m.kinks, false, 1),
stat(&m, |m| m.slivers, false, 1),
@@ -223,7 +224,7 @@ fn report() -> String {
let presets = presets();
let inputs = inputs();
let drawn = draw_all(&inputs);
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines);
let one_colour = |d: &&Drawing| matches!(d.0.class, Class::Shape | Class::Poly | Class::Lines | Class::Askew);
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();
@@ -270,7 +271,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] {
for class in [Class::Shape, Class::Poly, Class::Lines, Class::Askew] {
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));
+8 -2
View File
@@ -175,6 +175,8 @@ pub struct Metrics {
pub anchor_ratio: f64,
/// Share (%) of the outline length made of straight segments.
pub lines: f64,
/// Share (%) of the outline length that is exactly level or upright.
pub upright: f64,
/// Share (%) of the art's sharp corners that have a traced corner within 1.5 px.
pub corners: f64,
/// Anchors where the path turns by 10° or more away from any true corner.
@@ -224,7 +226,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 {
if input.class != Class::Lines && input.class != Class::Askew {
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 {
@@ -234,7 +236,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
}
}
let (mut length, mut straight) = (0.0, 0.0);
let (mut length, mut straight, mut upright) = (0.0, 0.0, 0.0);
for sp in art.iter().flat_map(|(tp, _)| &tp.path.subpaths) {
for i in 0..sp.segment_count() {
let len = sp.segment(i).arclen(0.05);
@@ -243,6 +245,9 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
let c = sp.segment(i);
if seg_dist(c.p1, c.p0, c.p3) < 0.02 && seg_dist(c.p2, c.p0, c.p3) < 0.02 {
straight += len;
if (c.p0.x - c.p3.x).abs() < 1e-9 || (c.p0.y - c.p3.y).abs() < 1e-9 {
upright += len;
}
}
}
}
@@ -275,6 +280,7 @@ pub fn measure(input: &Input, variant: Variant, size: u32, src: &Raster, params:
anchors: anchors as f64,
anchor_ratio: if input.anchors > 0 { anchors as f64 / input.anchors as f64 } else { nan },
lines: if length > 0.0 { 100.0 * straight / length } else { nan },
upright: if length > 0.0 { 100.0 * upright / length } else { nan },
corners,
kinks,
slivers,