mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,3 +1093,36 @@ fn ignore_color_leaves_out_the_colour_picked() {
|
||||
assert_eq!(old.ignore_color, None);
|
||||
assert_eq!(serde_json::to_value(¶ms).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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user