mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
M12: add centre-line scenes and baseline quality measurements
Measure Create Strokes on exact code-drawn scenes in aa and hard variants, including centre/width error, topology, ends, spurs and painted coverage. Keep the existing allocator report in one ignored test and document the current baseline without changing tracing behaviour or dependencies. Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Codex
Claude Opus 5.5
parent
8c10185ef9
commit
c9f0ed14f0
@@ -0,0 +1,69 @@
|
||||
//! Point arithmetic for the code-drawn centre-line scenes.
|
||||
|
||||
use std::ops::{Add, Mul, Neg, Sub};
|
||||
|
||||
/// A point or vector in pixel coordinates (pixel `(x, y)` covers `[x, x+1] × [y, y+1]`, y down).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct P {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl P {
|
||||
pub const fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
pub fn dot(self, o: P) -> f64 {
|
||||
self.x * o.x + self.y * o.y
|
||||
}
|
||||
pub fn len(self) -> f64 {
|
||||
self.x.hypot(self.y)
|
||||
}
|
||||
pub fn dist(self, o: P) -> f64 {
|
||||
(self - o).len()
|
||||
}
|
||||
/// Unit vector, or `None` for a (near) zero vector.
|
||||
pub fn unit(self) -> Option<P> {
|
||||
let l = self.len();
|
||||
(l > 1e-12 && l.is_finite()).then(|| P::new(self.x / l, self.y / l))
|
||||
}
|
||||
pub fn lerp(self, o: P, t: f64) -> P {
|
||||
self + (o - self) * t
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for P {
|
||||
type Output = P;
|
||||
fn add(self, o: P) -> P {
|
||||
P::new(self.x + o.x, self.y + o.y)
|
||||
}
|
||||
}
|
||||
impl Sub for P {
|
||||
type Output = P;
|
||||
fn sub(self, o: P) -> P {
|
||||
P::new(self.x - o.x, self.y - o.y)
|
||||
}
|
||||
}
|
||||
impl Mul<f64> for P {
|
||||
type Output = P;
|
||||
fn mul(self, k: f64) -> P {
|
||||
P::new(self.x * k, self.y * k)
|
||||
}
|
||||
}
|
||||
impl Neg for P {
|
||||
type Output = P;
|
||||
fn neg(self) -> P {
|
||||
P::new(-self.x, -self.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from `q` to segment `ab`, and the parameter of the nearest point.
|
||||
pub fn seg_dist(q: P, a: P, b: P) -> (f64, f64) {
|
||||
let ab = b - a;
|
||||
let l2 = ab.dot(ab);
|
||||
if l2 < 1e-18 {
|
||||
return (q.dist(a), 0.0);
|
||||
}
|
||||
let t = ((q - a).dot(ab) / l2).clamp(0.0, 1.0);
|
||||
(q.dist(a + ab * t), t)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! Centre-line measurements on code-drawn scenes. No external images or tracer required.
|
||||
use super::centre_geom::{P, seg_dist};
|
||||
use super::centre_scenes::{self as scenes, Scene};
|
||||
use kurbo::{BezPath, ParamCurve, PathSeg};
|
||||
use std::fmt::Write as _;
|
||||
use std::time::Instant;
|
||||
use vectorcraft_trace::{Raster, TraceParams, TraceResult, trace_within};
|
||||
|
||||
/// Nearest-point queries over a point set (uniform grid buckets).
|
||||
struct Near {
|
||||
cell: f64,
|
||||
gw: usize,
|
||||
gh: usize,
|
||||
buckets: Vec<Vec<usize>>,
|
||||
pts: Vec<P>,
|
||||
}
|
||||
|
||||
impl Near {
|
||||
fn new(pts: Vec<P>, w: usize, h: usize, cell: f64) -> Self {
|
||||
let gw = (w as f64 / cell).ceil() as usize + 2;
|
||||
let gh = (h as f64 / cell).ceil() as usize + 2;
|
||||
let mut buckets = vec![vec![]; gw * gh];
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let (cx, cy) = (((p.x / cell).floor().max(0.0) as usize).min(gw - 1), ((p.y / cell).floor().max(0.0) as usize).min(gh - 1));
|
||||
buckets[cy * gw + cx].push(i);
|
||||
}
|
||||
Self { cell, gw, gh, buckets, pts }
|
||||
}
|
||||
/// Nearest point within `max` (index, distance).
|
||||
fn nearest(&self, q: P, max: f64) -> Option<(usize, f64)> {
|
||||
let r = (max / self.cell).ceil() as i64;
|
||||
let (cx, cy) = ((q.x / self.cell).floor() as i64, (q.y / self.cell).floor() as i64);
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for y in cy - r..=cy + r {
|
||||
for x in cx - r..=cx + r {
|
||||
if x < 0 || y < 0 || x >= self.gw as i64 || y >= self.gh as i64 {
|
||||
continue;
|
||||
}
|
||||
for &i in &self.buckets[y as usize * self.gw + x as usize] {
|
||||
let d = self.pts[i].dist(q);
|
||||
if d <= max && best.is_none_or(|b| d < b.1) {
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
fn mean(v: &[f64]) -> f64 {
|
||||
if v.is_empty() { f64::NAN } else { v.iter().sum::<f64>() / v.len() as f64 }
|
||||
}
|
||||
fn p95(v: &[f64]) -> f64 {
|
||||
if v.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(f64::total_cmp);
|
||||
s[((s.len() - 1) as f64 * 0.95).round() as usize]
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
struct Metrics {
|
||||
name: String,
|
||||
input: &'static str,
|
||||
strokes: usize,
|
||||
fills: usize,
|
||||
anchors: usize,
|
||||
ms: f64,
|
||||
c_mean: f64,
|
||||
c_p95: f64,
|
||||
g_mean: f64,
|
||||
g_p95: f64,
|
||||
complete: f64,
|
||||
w_uni: f64,
|
||||
w_uni_p95: f64,
|
||||
junc_total: usize,
|
||||
junc_ok: usize,
|
||||
junc_wrong: usize,
|
||||
junc_extra: usize,
|
||||
junc_pos: f64,
|
||||
ends_gt: usize,
|
||||
ends_found: usize,
|
||||
end_err: f64,
|
||||
end_signed: f64,
|
||||
spurs: usize,
|
||||
iou_uni: f64,
|
||||
area_uni: f64,
|
||||
fill_as_fill: f64,
|
||||
}
|
||||
|
||||
fn iou(a: &[bool], m: &[bool]) -> (f64, f64) {
|
||||
let (mut i, mut u, mut ra, mut rm) = (0usize, 0usize, 0usize, 0usize);
|
||||
for (x, y) in a.iter().zip(m) {
|
||||
i += usize::from(*x && *y);
|
||||
u += usize::from(*x || *y);
|
||||
ra += usize::from(*x);
|
||||
rm += usize::from(*y);
|
||||
}
|
||||
(i as f64 / u.max(1) as f64, (ra as f64 - rm as f64) / rm.max(1) as f64)
|
||||
}
|
||||
|
||||
fn evaluate(sc: &Scene, input: &'static str, cov: &[f32]) -> Metrics {
|
||||
let rgba = cov
|
||||
.iter()
|
||||
.flat_map(|&c| {
|
||||
let v = ((1.0 - c) * 255.0).round() as u8;
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let img = Raster::new(sc.w as u32, sc.h as u32, rgba);
|
||||
let params = TraceParams { strokes: true, fills: true, stroke_width: sc.wmax, ignore_white: true, noise: 4, ..TraceParams::default() };
|
||||
let t0 = Instant::now();
|
||||
let res = trace_within(&img, ¶ms, super::MAX_ANCHORS).unwrap();
|
||||
let ms = t0.elapsed().as_secs_f64() * 1e3;
|
||||
measure(sc, input, cov, &res, ms)
|
||||
}
|
||||
|
||||
fn measure(sc: &Scene, input: &'static str, cov: &[f32], res: &TraceResult, ms: f64) -> Metrics {
|
||||
let t = geometry(res);
|
||||
let in_fill = |q: P, grow: f64| sc.fills.iter().any(|f| f.dist(q) <= grow);
|
||||
// Ground-truth centre samples (outside fills) and output samples.
|
||||
let mut gt_pts = vec![];
|
||||
let mut gt_w = vec![];
|
||||
for s in &sc.strokes {
|
||||
for (p, w) in s.pts.iter().zip(&s.w) {
|
||||
if !in_fill(*p, 0.0) {
|
||||
gt_pts.push(*p);
|
||||
gt_w.push(*w);
|
||||
}
|
||||
}
|
||||
}
|
||||
let gt = Near::new(gt_pts.clone(), sc.w, sc.h, 4.0);
|
||||
let mut out_pts = vec![];
|
||||
let (mut cd, mut wu) = (vec![], vec![]);
|
||||
for s in &t.strokes {
|
||||
for (p, _) in &s.samples {
|
||||
if in_fill(*p, 0.0) {
|
||||
continue;
|
||||
}
|
||||
out_pts.push(*p);
|
||||
if let Some((i, d)) = gt.nearest(*p, 30.0) {
|
||||
cd.push(d);
|
||||
// Width: only where the sample is on a stroke (not a stray piece).
|
||||
if d < 2.0 {
|
||||
wu.push((s.width - gt_w[i]).abs());
|
||||
}
|
||||
} else {
|
||||
cd.push(30.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = Near::new(out_pts, sc.w, sc.h, 4.0);
|
||||
let gd: Vec<f64> = gt_pts.iter().map(|&p| out.nearest(p, 30.0).map_or(30.0, |x| x.1)).collect();
|
||||
let complete = gd.iter().filter(|&&d| d <= 1.5).count() as f64 / gd.len().max(1) as f64;
|
||||
// Junctions (away from fills).
|
||||
let outj: Vec<_> = t.junctions.iter().filter(|j| !in_fill(j.pos, 2.0)).collect();
|
||||
let mut used = vec![false; outj.len()];
|
||||
let (mut ok, mut wrong, mut pos) = (0, 0, vec![]);
|
||||
for &(p, deg) in &sc.junctions {
|
||||
let wl = gt.nearest(p, 5.0).map_or(4.0, |(i, _)| gt_w[i]);
|
||||
let tol = wl + 3.0;
|
||||
let best =
|
||||
outj.iter().enumerate().filter(|(k, j)| !used[*k] && j.pos.dist(p) <= tol).min_by(|a, b| a.1.pos.dist(p).total_cmp(&b.1.pos.dist(p)));
|
||||
if let Some((k, j)) = best {
|
||||
used[k] = true;
|
||||
pos.push(j.pos.dist(p));
|
||||
if j.degree == deg {
|
||||
ok += 1;
|
||||
} else {
|
||||
wrong += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra = used.iter().filter(|u| !**u).count();
|
||||
// Free ends: ground truth = open stroke ends not at a junction, corner or fill.
|
||||
let mut gt_ends = vec![];
|
||||
for s in sc.strokes.iter().filter(|s| !s.closed) {
|
||||
for (p, w) in [(s.pts[0], s.w[0]), (*s.pts.last().unwrap_or(&s.pts[0]), *s.w.last().unwrap_or(&s.w[0]))] {
|
||||
let near_j = sc.junctions.iter().any(|(q, _)| q.dist(p) < 1.0) || sc.corners.iter().any(|q| q.dist(p) < 1.0);
|
||||
let on_other = sc.strokes.iter().filter(|o| !std::ptr::eq(*o, s)).any(|o| o.pts.iter().any(|q| q.dist(p) < 0.5));
|
||||
if !near_j && !on_other && !in_fill(p, w) {
|
||||
// Its direction (outwards), for the signed error.
|
||||
let k =
|
||||
if p == s.pts[0] { (s.pts[0] - s.pts[s.pts.len().min(9) - 1]).unit() } else { (p - s.pts[s.pts.len().saturating_sub(9)]).unit() };
|
||||
gt_ends.push((p, w, k.unwrap_or(P::new(1.0, 0.0))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let out_ends: Vec<P> = t.ends.iter().copied().filter(|p| !in_fill(*p, 2.0)).collect();
|
||||
let mut used_e = vec![false; out_ends.len()];
|
||||
let (mut found, mut err, mut signed) = (0, vec![], vec![]);
|
||||
for &(p, w, dir) in >_ends {
|
||||
let tol = 0.5 * w + 4.0;
|
||||
let best = out_ends.iter().enumerate().filter(|(k, q)| !used_e[*k] && q.dist(p) <= tol).min_by(|a, b| a.1.dist(p).total_cmp(&b.1.dist(p)));
|
||||
if let Some((k, q)) = best {
|
||||
used_e[k] = true;
|
||||
found += 1;
|
||||
err.push(q.dist(p));
|
||||
signed.push((*q - p).dot(dir));
|
||||
}
|
||||
}
|
||||
let spurs = used_e.iter().filter(|u| !**u).count();
|
||||
let r_uni = render(res, sc.w, sc.h, false);
|
||||
// Coverage against the exact shape (pixel centres inside it); the thresholded mask
|
||||
// over-counts pixels exactly half covered.
|
||||
let exact = sc.exact();
|
||||
let (iou_uni, area_uni) = iou(&r_uni, &exact);
|
||||
// Fill pixels traced as fills.
|
||||
let rf = render(res, sc.w, sc.h, true);
|
||||
let (mut fp, mut ff) = (0usize, 0usize);
|
||||
for y in 0..sc.h {
|
||||
for x in 0..sc.w {
|
||||
let q = P::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
if cov[y * sc.w + x] >= 0.5 && sc.fills.iter().any(|f| f.inside(q)) {
|
||||
fp += 1;
|
||||
ff += usize::from(rf[y * sc.w + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Metrics {
|
||||
name: sc.name.clone(),
|
||||
input,
|
||||
strokes: res.paths.iter().filter(|p| p.stroke.is_some()).count(),
|
||||
fills: t.fills.len(),
|
||||
anchors: res.paths.iter().filter(|p| p.stroke.is_some()).map(|p| p.path.anchor_count()).sum(),
|
||||
ms,
|
||||
c_mean: mean(&cd),
|
||||
c_p95: p95(&cd),
|
||||
g_mean: mean(&gd),
|
||||
g_p95: p95(&gd),
|
||||
complete,
|
||||
w_uni: mean(&wu),
|
||||
w_uni_p95: p95(&wu),
|
||||
junc_total: sc.junctions.len(),
|
||||
junc_ok: ok,
|
||||
junc_wrong: wrong,
|
||||
junc_extra: extra,
|
||||
junc_pos: mean(&pos),
|
||||
ends_gt: gt_ends.len(),
|
||||
ends_found: found,
|
||||
end_err: mean(&err),
|
||||
end_signed: mean(&signed),
|
||||
spurs,
|
||||
iou_uni,
|
||||
area_uni,
|
||||
fill_as_fill: if fp > 0 { ff as f64 / fp as f64 } else { f64::NAN },
|
||||
}
|
||||
}
|
||||
|
||||
struct StrokeSamples {
|
||||
samples: Vec<(P, f64)>,
|
||||
closed: bool,
|
||||
width: f64,
|
||||
}
|
||||
struct Junction {
|
||||
pos: P,
|
||||
degree: usize,
|
||||
}
|
||||
struct Geometry {
|
||||
strokes: Vec<StrokeSamples>,
|
||||
fills: Vec<()>,
|
||||
junctions: Vec<Junction>,
|
||||
ends: Vec<P>,
|
||||
}
|
||||
|
||||
fn point(p: kurbo::Point) -> P {
|
||||
P::new(p.x, p.y)
|
||||
}
|
||||
|
||||
/// Evaluate the emitted curves at <=0.5 px control-polygon steps (as for the scene
|
||||
/// yardstick). Keep subpaths separate: today's compound strokes share one width.
|
||||
fn samples(path: &BezPath) -> Vec<P> {
|
||||
let mut pts = vec![];
|
||||
for seg in path.segments() {
|
||||
let poly = match seg {
|
||||
PathSeg::Line(l) => l.p0.distance(l.p1),
|
||||
PathSeg::Quad(q) => q.p0.distance(q.p1) + q.p1.distance(q.p2),
|
||||
PathSeg::Cubic(c) => c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3),
|
||||
};
|
||||
let n = (poly / 0.5).ceil().max(1.0) as usize;
|
||||
if pts.is_empty() {
|
||||
pts.push(point(seg.eval(0.0)));
|
||||
}
|
||||
for i in 1..=n {
|
||||
pts.push(point(seg.eval(i as f64 / n as f64)));
|
||||
}
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Recover topology from emitted geometry, without consulting scene truth. Candidates
|
||||
/// are open endpoints and intersections. Coincident points merge within 0.05 px;
|
||||
/// each touching endpoint contributes one arm, each interior contributes two.
|
||||
/// This also measures a bar chained through a T, or two chained strokes crossing.
|
||||
fn geometry(res: &TraceResult) -> Geometry {
|
||||
let mut strokes = vec![];
|
||||
for path in &res.paths {
|
||||
if let Some(width) = path.stroke {
|
||||
for sp in &path.path.subpaths {
|
||||
let mut bez = BezPath::new();
|
||||
sp.to_bezpath_into(&mut bez);
|
||||
strokes.push(StrokeSamples { samples: samples(&bez).into_iter().map(|p| (p, width)).collect(), closed: sp.closed, width });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut candidates = vec![];
|
||||
for s in &strokes {
|
||||
if !s.closed && !s.samples.is_empty() {
|
||||
candidates.extend([s.samples[0].0, s.samples.last().unwrap().0]);
|
||||
}
|
||||
}
|
||||
for (i, a) in strokes.iter().enumerate() {
|
||||
for b in &strokes[i + 1..] {
|
||||
for aa in a.samples.windows(2) {
|
||||
let (p, r) = (aa[0].0, aa[1].0 - aa[0].0);
|
||||
for bb in b.samples.windows(2) {
|
||||
let (q, s) = (bb[0].0, bb[1].0 - bb[0].0);
|
||||
if aa[0].0.x.max(aa[1].0.x) < bb[0].0.x.min(bb[1].0.x)
|
||||
|| bb[0].0.x.max(bb[1].0.x) < aa[0].0.x.min(aa[1].0.x)
|
||||
|| aa[0].0.y.max(aa[1].0.y) < bb[0].0.y.min(bb[1].0.y)
|
||||
|| bb[0].0.y.max(bb[1].0.y) < aa[0].0.y.min(aa[1].0.y)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cross = |v: P, w: P| v.x * w.y - v.y * w.x;
|
||||
let d = cross(r, s);
|
||||
if d.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let t = cross(q - p, s) / d;
|
||||
let u = cross(q - p, r) / d;
|
||||
if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
|
||||
candidates.push(p + r * t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut nodes: Vec<P> = vec![];
|
||||
for p in candidates {
|
||||
if !nodes.iter().any(|q| q.dist(p) <= 0.05) {
|
||||
nodes.push(p);
|
||||
}
|
||||
}
|
||||
let (mut junctions, mut ends) = (vec![], vec![]);
|
||||
for pos in nodes {
|
||||
let degree = strokes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if s.samples.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if !s.closed {
|
||||
// A run returning to its junction contributes both endpoint arms.
|
||||
let arms = usize::from(pos.dist(s.samples[0].0) <= 0.05) + usize::from(pos.dist(s.samples.last().unwrap().0) <= 0.05);
|
||||
if arms > 0 {
|
||||
return arms;
|
||||
}
|
||||
}
|
||||
if s.samples.windows(2).any(|p| seg_dist(pos, p[0].0, p[1].0).0 <= 0.05) { 2 } else { 0 }
|
||||
})
|
||||
.sum();
|
||||
if degree >= 3 {
|
||||
junctions.push(Junction { pos, degree });
|
||||
}
|
||||
if degree == 1 {
|
||||
ends.push(pos);
|
||||
}
|
||||
}
|
||||
Geometry { strokes, fills: vec![(); res.paths.iter().filter(|p| p.stroke.is_none()).count()], junctions, ends }
|
||||
}
|
||||
|
||||
/// Pixel-centre nonzero-winding fill of the actual painted paths. Strokes retain
|
||||
/// their emitted square/round caps. Overlapping paths are unioned, never cancelled.
|
||||
fn render(res: &TraceResult, w: usize, h: usize, fills_only: bool) -> Vec<bool> {
|
||||
let mut cov = vec![false; w * h];
|
||||
for path in &res.paths {
|
||||
if fills_only && path.stroke.is_some() {
|
||||
continue;
|
||||
}
|
||||
let bez = super::measure::painted(path);
|
||||
let polys = super::raster::polylines(&bez, 0.02);
|
||||
let mut xs = vec![];
|
||||
for y in 0..h {
|
||||
let yc = y as f64 + 0.5;
|
||||
xs.clear();
|
||||
for poly in &polys {
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||||
if (a.y <= yc) != (b.y <= yc) {
|
||||
xs.push((a.x + (b.x - a.x) * (yc - a.y) / (b.y - a.y), if b.y > a.y { 1 } else { -1 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut wind = 0;
|
||||
for pair in xs.windows(2) {
|
||||
wind += pair[0].1;
|
||||
if wind != 0 {
|
||||
let xa = (pair[0].0 - 0.5).ceil().max(0.0) as usize;
|
||||
let xb = ((pair[1].0 - 0.5).floor() + 1.0).max(0.0) as usize;
|
||||
for x in xa.min(w)..xb.min(w) {
|
||||
cov[y * w + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
fn f(v: f64, d: usize) -> String {
|
||||
if v.is_nan() { "–".into() } else { format!("{v:.d$}") }
|
||||
}
|
||||
|
||||
pub fn report() -> String {
|
||||
let mut rows = vec![];
|
||||
for sc in scenes::all() {
|
||||
let cov = sc.rasterise();
|
||||
let hard: Vec<f32> = cov.iter().map(|&c| if c >= 0.5 { 1.0 } else { 0.0 }).collect();
|
||||
for (input, c) in [("aa", &cov), ("hard", &hard)] {
|
||||
rows.push(evaluate(&sc, input, c));
|
||||
}
|
||||
}
|
||||
let mut md = String::from(
|
||||
"## Centre lines\n\nCode-drawn scenes only: 23 scenes, each anti-aliased (8×8 coverage samples) and hard (that coverage thresholded at 0.5). Coordinates, widths and scene Stroke values are fixed; junction/letter/crossing coordinates include the 0.27/0.41 px offset. Create Fills and Strokes are on, Stroke is the scene's limit (13 px, mix 6 px, varying 12 px), Black and White threshold 128, Noise 4, Ignore White on, other settings at their defaults. Today's tracer has no minimum-length setting (the dashed scenes specify 0 for future use).\n\nDistances use dense exact centre samples (≤0.25 px), emitted curves sampled at ≤0.5 px control-polygon steps, and nearest-point searches capped at 30 px. Found is the fraction of true samples within 1.5 px; width errors use output samples within 2 px of a true stroke. Samples inside exact fills are excluded. Topology is recovered from emitted subpath endpoints and intersections, merging coincident positions within 0.05 px; an endpoint contributes one arm, an interior two. Junction matching radius is local true width + 3 px; ends use half-width + 4 px. Each match is used once. Free ends exclude true junctions, corners, other strokes and fills; unmatched emitted ends are spurs. Junctions/ends within 2 px of fills are excluded. End error is distance, with signed error along the outward tangent in parentheses. Coverage is a union of painted output at pixel centres (0.02 px flattening tolerance, actual emitted caps), compared with the exact swept shape at pixel centres, for both input variants. Area error is signed relative to exact area. Anchors counts stroke anchors, including all compound subpaths; paths counts stroked + filled paths. Time covers tracing only, one scene at a time. Profile-dependent columns are unavailable (–).\n\n### Centre line and width (px)\n\n| scene | input | paths | out→true mean / p95 | true→out mean / p95 | found % | width err uniform mean / p95 | variable mean / p95 | profile mean |\n|---|---|---|---|---|---|---|---|---|\n",
|
||||
);
|
||||
for m in &rows {
|
||||
writeln!(
|
||||
md,
|
||||
"| {} | {} | {}+{} | {} / {} | {} / {} | {} | {} / {} | – | – |",
|
||||
m.name,
|
||||
m.input,
|
||||
m.strokes,
|
||||
m.fills,
|
||||
f(m.c_mean, 2),
|
||||
f(m.c_p95, 2),
|
||||
f(m.g_mean, 2),
|
||||
f(m.g_p95, 2),
|
||||
f(100.0 * m.complete, 1),
|
||||
f(m.w_uni, 2),
|
||||
f(m.w_uni_p95, 2)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
md += "\n### Topology, ends, coverage\n\n| scene | input | junctions ok / wrong deg / missed / extra | junction pos err | ends found | end err (signed) | spurs | IoU uniform / profile | area err uniform / profile | fill→fill % | anchors | width pts | ms |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|\n";
|
||||
for m in &rows {
|
||||
writeln!(
|
||||
md,
|
||||
"| {} | {} | {} / {} / {} / {} | {} | {}/{} | {} ({}) | {} | {} / – | {}% / – | {} | {} | – | {} |",
|
||||
m.name,
|
||||
m.input,
|
||||
m.junc_ok,
|
||||
m.junc_wrong,
|
||||
m.junc_total - m.junc_ok - m.junc_wrong,
|
||||
m.junc_extra,
|
||||
f(m.junc_pos, 2),
|
||||
m.ends_found,
|
||||
m.ends_gt,
|
||||
f(m.end_err, 2),
|
||||
f(m.end_signed, 2),
|
||||
m.spurs,
|
||||
f(m.iou_uni, 3),
|
||||
f(100.0 * m.area_uni, 1),
|
||||
f(100.0 * m.fill_as_fill, 1),
|
||||
m.anchors,
|
||||
f(m.ms, 1)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let sum = |g: &dyn Fn(&Metrics) -> usize, inp: &str| rows.iter().filter(|m| m.input == inp).map(g).sum::<usize>();
|
||||
for inp in ["aa", "hard"] {
|
||||
writeln!(
|
||||
md,
|
||||
"\n**{inp} totals**: junctions {} correct, {} wrong degree, {} missed, {} extra (of {}); ends {} of {}; spurs {}.",
|
||||
sum(&|m| m.junc_ok, inp),
|
||||
sum(&|m| m.junc_wrong, inp),
|
||||
sum(&|m| m.junc_total - m.junc_ok - m.junc_wrong, inp),
|
||||
sum(&|m| m.junc_extra, inp),
|
||||
sum(&|m| m.junc_total, inp),
|
||||
sum(&|m| m.ends_found, inp),
|
||||
sum(&|m| m.ends_gt, inp),
|
||||
sum(&|m| m.spurs, inp)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_geometry_measures_junctions_ends_missing_ink_and_caps() {
|
||||
use vectorcraft_geom::PathData;
|
||||
use vectorcraft_trace::TracedPath;
|
||||
let make = |points: &[(f64, f64)], square_caps: bool| {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(points[0]);
|
||||
for &p in &points[1..] {
|
||||
path.line_to(p);
|
||||
}
|
||||
TracedPath { path: PathData::from_bezpath(&path), color: [0; 3], pixels: 0, stroke: Some(2.0), shape: None, gradient: None, square_caps }
|
||||
};
|
||||
// An interior meeting must count as two arms, irrespective of path chaining.
|
||||
let bar = make(&[(10.27, 20.41), (50.27, 20.41)], false);
|
||||
for (stem, degree, ends) in [(make(&[(30.27, 20.41), (30.27, 40.41)], false), 3, 3), (make(&[(30.27, 10.41), (30.27, 40.41)], false), 4, 4)] {
|
||||
let res = TraceResult { paths: vec![bar.clone(), stem], ..Default::default() };
|
||||
let g = geometry(&res);
|
||||
assert_eq!(g.junctions.len(), 1);
|
||||
assert_eq!(g.junctions[0].degree, degree);
|
||||
assert!(g.junctions[0].pos.dist(P::new(30.27, 20.41)) < 1e-9);
|
||||
assert_eq!(g.ends.len(), ends);
|
||||
}
|
||||
let returning = make(&[(30.27, 20.41), (45.27, 35.41), (15.27, 35.41), (30.27, 20.41)], false);
|
||||
let stem = make(&[(30.27, 20.41), (30.27, 10.41)], false);
|
||||
let g = geometry(&TraceResult { paths: vec![returning, stem], ..Default::default() });
|
||||
assert_eq!(g.junctions.len(), 1);
|
||||
assert_eq!(g.junctions[0].degree, 3);
|
||||
assert_eq!(g.ends.len(), 1);
|
||||
// Closed subpaths have no free ends, even if their first sample is repeated.
|
||||
let mut ring = BezPath::new();
|
||||
ring.move_to((10.0, 10.0));
|
||||
ring.line_to((20.0, 10.0));
|
||||
ring.line_to((20.0, 20.0));
|
||||
ring.close_path();
|
||||
let res = TraceResult { paths: vec![TracedPath { path: PathData::from_bezpath(&ring), ..bar.clone() }], ..Default::default() };
|
||||
let g = geometry(&res);
|
||||
assert!(g.ends.is_empty() && g.junctions.is_empty());
|
||||
|
||||
let mut sc = scenes::lines(2.0);
|
||||
sc.strokes.truncate(1);
|
||||
let st = &sc.strokes[0];
|
||||
let (a, b) = (st.pts[0], *st.pts.last().unwrap());
|
||||
let res = TraceResult { paths: vec![make(&[(a.x, a.y), (b.x, b.y)], false)], ..Default::default() };
|
||||
let cov = sc.rasterise();
|
||||
let m = measure(&sc, "aa", &cov, &res, 0.0);
|
||||
assert!(m.c_mean < 0.01 && m.g_p95 <= 0.251 && m.w_uni < 1e-9, "{m:?}");
|
||||
assert_eq!((m.ends_found, m.ends_gt, m.spurs), (2, 2, 0));
|
||||
assert!(m.end_err < 1e-9 && m.iou_uni > 0.999 && m.area_uni.abs() < 1e-9, "{m:?}");
|
||||
let missing = measure(&sc, "aa", &cov, &TraceResult::default(), 0.0);
|
||||
assert_eq!((missing.g_mean, missing.complete, missing.ends_found, missing.iou_uni, missing.area_uni), (30.0, 0.0, 0, 0.0, -1.0));
|
||||
let diagonal = make(&[(10.27, 10.41), (30.27, 30.41)], false);
|
||||
let round = render(&TraceResult { paths: vec![diagonal.clone()], ..Default::default() }, 50, 50, false);
|
||||
let square = render(&TraceResult { paths: vec![TracedPath { square_caps: true, ..diagonal }], ..Default::default() }, 50, 50, false);
|
||||
assert!(square.iter().filter(|p| **p).count() > round.iter().filter(|p| **p).count());
|
||||
// Preserve the exact catalogue and special scene limits.
|
||||
assert_eq!(scenes::all().len(), 23);
|
||||
assert_eq!((scenes::mix().wmax, scenes::varying().wmax, scenes::dashed(2.0).min_len), (6.0, 12.0, 0.0));
|
||||
assert_eq!(scenes::junctions(4.0).junctions[0].0, P::new(60.27, 40.41));
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Code-drawn test scenes with exact answers: centre lines with widths (round caps), filled
|
||||
//! areas, junctions with their degrees, and corners (stroke ends that meet but are no junction).
|
||||
|
||||
use super::centre_geom::{P, seg_dist};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GtStroke {
|
||||
/// Dense centre line (≤ 0.25 px apart).
|
||||
pub pts: Vec<P>,
|
||||
pub w: Vec<f64>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GtFill {
|
||||
Disc(P, f64),
|
||||
Poly(Vec<P>),
|
||||
}
|
||||
|
||||
impl GtFill {
|
||||
pub fn inside(&self, q: P) -> bool {
|
||||
match self {
|
||||
GtFill::Disc(c, r) => q.dist(*c) <= *r,
|
||||
GtFill::Poly(p) => {
|
||||
let n = p.len();
|
||||
let mut inside = false;
|
||||
for i in 0..n {
|
||||
let (a, b) = (p[i], p[(i + 1) % n]);
|
||||
if (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Distance outside (0 inside), approximate for polygons (to the edges).
|
||||
pub fn dist(&self, q: P) -> f64 {
|
||||
if self.inside(q) {
|
||||
return 0.0;
|
||||
}
|
||||
match self {
|
||||
GtFill::Disc(c, r) => q.dist(*c) - r,
|
||||
GtFill::Poly(p) => {
|
||||
let n = p.len();
|
||||
(0..n).map(|i| seg_dist(q, p[i], p[(i + 1) % n]).0).fold(f64::MAX, f64::min)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Scene {
|
||||
pub name: String,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub strokes: Vec<GtStroke>,
|
||||
pub fills: Vec<GtFill>,
|
||||
pub junctions: Vec<(P, usize)>,
|
||||
pub corners: Vec<P>,
|
||||
pub wmax: f64,
|
||||
pub min_len: f64,
|
||||
/// Added to every coordinate (odd widths at integer positions put pixel centres exactly on
|
||||
/// the edge: ties that make coverage numbers meaningless).
|
||||
pub offset: P,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
fn new(name: &str, w: usize, h: usize, wmax: f64) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
w,
|
||||
h,
|
||||
strokes: vec![],
|
||||
fills: vec![],
|
||||
junctions: vec![],
|
||||
corners: vec![],
|
||||
wmax,
|
||||
min_len: 20.0,
|
||||
offset: P::new(0.0, 0.0),
|
||||
}
|
||||
}
|
||||
/// A polyline stroke of constant width.
|
||||
fn line(&mut self, pts: &[P], w: f64) {
|
||||
self.var(pts, &vec![w; pts.len()], false);
|
||||
}
|
||||
/// A polyline stroke with a width at each vertex (linear between), densified.
|
||||
fn var(&mut self, pts: &[P], ws: &[f64], closed: bool) {
|
||||
let pts: Vec<P> = pts.iter().map(|&p| p + self.offset).collect();
|
||||
let pts = &pts[..];
|
||||
let mut d = vec![];
|
||||
let mut dw = vec![];
|
||||
let n = pts.len();
|
||||
let segs = if closed { n } else { n.saturating_sub(1) };
|
||||
for i in 0..segs {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let (wa, wb) = (ws[i], ws[(i + 1) % n]);
|
||||
let k = ((a.dist(b) / 0.25).ceil() as usize).max(1);
|
||||
for j in 0..k {
|
||||
let t = j as f64 / k as f64;
|
||||
d.push(a.lerp(b, t));
|
||||
dw.push(wa + (wb - wa) * t);
|
||||
}
|
||||
}
|
||||
if !closed && let (Some(&l), Some(&lw)) = (pts.last(), ws.last()) {
|
||||
d.push(l);
|
||||
dw.push(lw);
|
||||
}
|
||||
self.strokes.push(GtStroke { pts: d, w: dw, closed });
|
||||
}
|
||||
fn curve(&mut self, f: impl Fn(f64) -> P, wf: impl Fn(f64) -> f64, n: usize, closed: bool) {
|
||||
let m = if closed { n } else { n + 1 };
|
||||
let pts: Vec<P> = (0..m).map(|k| f(k as f64 / n as f64)).collect();
|
||||
let ws: Vec<f64> = (0..m).map(|k| wf(k as f64 / n as f64)).collect();
|
||||
self.var(&pts, &ws, closed);
|
||||
}
|
||||
fn arc(&mut self, c: P, r: f64, a0: f64, a1: f64, w: f64) {
|
||||
let n = ((r * (a1 - a0).abs() / 0.5).ceil() as usize).max(8);
|
||||
self.curve(
|
||||
|t| {
|
||||
let a = a0 + (a1 - a0) * t;
|
||||
c + P::new(r * a.cos(), r * a.sin())
|
||||
},
|
||||
|_| w,
|
||||
n,
|
||||
false,
|
||||
);
|
||||
}
|
||||
fn ring(&mut self, c: P, r: f64, w: f64) {
|
||||
let n = ((r * std::f64::consts::TAU / 0.5).ceil() as usize).max(16);
|
||||
self.curve(
|
||||
|t| {
|
||||
let a = std::f64::consts::TAU * t;
|
||||
c + P::new(r * a.cos(), r * a.sin())
|
||||
},
|
||||
|_| w,
|
||||
n,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ink coverage (8 × 8 samples per pixel).
|
||||
pub fn rasterise(&self) -> Vec<f32> {
|
||||
self.rasterise_at(8)
|
||||
}
|
||||
/// Exact shape point-sampled at pixel centres (the reference for coverage).
|
||||
pub fn exact(&self) -> Vec<bool> {
|
||||
self.rasterise_at(1).into_iter().map(|c| c >= 1.0).collect()
|
||||
}
|
||||
fn rasterise_at(&self, ss: usize) -> Vec<f32> {
|
||||
let (sw, sh) = (self.w * ss, self.h * ss);
|
||||
let mut sub = vec![false; sw * sh];
|
||||
let at = |x: usize, y: usize| P::new((x as f64 + 0.5) / ss as f64, (y as f64 + 0.5) / ss as f64);
|
||||
for s in &self.strokes {
|
||||
let n = s.pts.len();
|
||||
let segs = if s.closed { n } else { n.saturating_sub(1) };
|
||||
let mut stamp = |a: P, b: P, ra: f64, rb: f64| {
|
||||
let r = ra.max(rb);
|
||||
let x0 = (((a.x.min(b.x) - r) * ss as f64).floor().max(0.0)) as usize;
|
||||
let y0 = (((a.y.min(b.y) - r) * ss as f64).floor().max(0.0)) as usize;
|
||||
let x1 = ((((a.x.max(b.x) + r) * ss as f64).ceil().max(0.0)) as usize).min(sw);
|
||||
let y1 = ((((a.y.max(b.y) + r) * ss as f64).ceil().max(0.0)) as usize).min(sh);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let q = at(x, y);
|
||||
let (d, t) = seg_dist(q, a, b);
|
||||
if d <= ra + (rb - ra) * t {
|
||||
sub[y * sw + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if n == 1 {
|
||||
stamp(s.pts[0], s.pts[0], s.w[0] / 2.0, s.w[0] / 2.0);
|
||||
}
|
||||
for i in 0..segs {
|
||||
let j = (i + 1) % n;
|
||||
stamp(s.pts[i], s.pts[j], s.w[i] / 2.0, s.w[j] / 2.0);
|
||||
}
|
||||
}
|
||||
for f in &self.fills {
|
||||
for y in 0..sh {
|
||||
for x in 0..sw {
|
||||
if !sub[y * sw + x] && f.inside(at(x, y)) {
|
||||
sub[y * sw + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; self.w * self.h];
|
||||
for y in 0..self.h {
|
||||
for x in 0..self.w {
|
||||
let mut c = 0;
|
||||
for yy in 0..ss {
|
||||
for xx in 0..ss {
|
||||
c += usize::from(sub[(y * ss + yy) * sw + x * ss + xx]);
|
||||
}
|
||||
}
|
||||
out[y * self.w + x] = c as f32 / (ss * ss) as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn pol(c: P, r: f64, deg: f64) -> P {
|
||||
let a = deg.to_radians();
|
||||
c + P::new(r * a.cos(), r * a.sin())
|
||||
}
|
||||
|
||||
/// 24 straight lines of width `w`, at 0°–172.5° in 7.5° steps, centres at varied sub-pixel phases.
|
||||
pub fn lines(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("lines w{w}"), 480, 320, 13.0);
|
||||
for k in 0..24 {
|
||||
let (cx, cy) = ((k % 6) as f64 * 80.0 + 40.0 + 0.13 * k as f64 % 1.0, (k / 6) as f64 * 80.0 + 40.0 + 0.29 * k as f64 % 1.0);
|
||||
let c = P::new(cx, cy);
|
||||
let a = 7.5 * k as f64;
|
||||
s.line(&[pol(c, 25.0, a + 180.0), pol(c, 25.0, a)], w);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn curves(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("curves w{w}"), 400, 300, 13.0);
|
||||
// Sine waves.
|
||||
s.curve(|t| P::new(30.0 + 340.0 * t, 50.0 + 20.0 * (t * 12.0).sin()), |_| w, 400, false);
|
||||
s.curve(|t| P::new(30.0 + 340.0 * t, 110.0 + 10.0 * (t * 25.0).sin()), |_| w, 600, false);
|
||||
// Cubic S-curve.
|
||||
let cubic = |t: f64| {
|
||||
let (p0, p1, p2, p3) = (P::new(40.0, 280.0), P::new(60.0, 140.0), P::new(200.0, 300.0), P::new(190.0, 160.0));
|
||||
let u = 1.0 - t;
|
||||
p0 * (u * u * u) + p1 * (3.0 * u * u * t) + p2 * (3.0 * u * t * t) + p3 * (t * t * t)
|
||||
};
|
||||
s.curve(cubic, |_| w, 300, false);
|
||||
// Spiral.
|
||||
s.curve(
|
||||
|t| {
|
||||
let a = t * 4.0 * std::f64::consts::PI;
|
||||
P::new(300.0, 225.0) + P::new(a.cos(), a.sin()) * (12.0 + 50.0 * t)
|
||||
},
|
||||
|_| w,
|
||||
800,
|
||||
false,
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn circles() -> Scene {
|
||||
let mut s = Scene::new("circles", 520, 260, 13.0);
|
||||
let mut x = 50.0;
|
||||
for (r, w) in [(8.0, 1.0), (12.0, 2.0), (18.0, 3.0), (25.0, 4.0), (35.0, 6.0), (45.0, 8.0)] {
|
||||
s.ring(P::new(x, 70.0 + 0.3 * r % 1.0), r, w);
|
||||
s.ring(P::new(x + 10.0, 190.0), r * 1.2, (w * 0.5_f64).max(1.0));
|
||||
x += 2.0 * r + 35.0;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn junctions(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("junctions w{w}"), 520, 220, 13.0);
|
||||
s.offset = P::new(0.27, 0.41);
|
||||
// T.
|
||||
s.line(&[P::new(20.0, 40.0), P::new(100.0, 40.0)], w);
|
||||
s.line(&[P::new(60.0, 40.0), P::new(60.0, 100.0)], w);
|
||||
s.junctions.push((P::new(60.0, 40.0), 3));
|
||||
// Y (120°).
|
||||
let c = P::new(180.0, 70.0);
|
||||
for a in [-90.0, 30.0, 150.0] {
|
||||
s.line(&[c, pol(c, 40.0, a)], w);
|
||||
}
|
||||
s.junctions.push((c, 3));
|
||||
// X (90°).
|
||||
let c = P::new(300.0, 70.0);
|
||||
s.line(&[pol(c, 40.0, 45.0), pol(c, 40.0, 225.0)], w);
|
||||
s.line(&[pol(c, 40.0, 135.0), pol(c, 40.0, 315.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
// Oblique T (stem at 50°).
|
||||
s.line(&[P::new(380.0, 40.0), P::new(500.0, 40.0)], w);
|
||||
s.line(&[P::new(430.0, 40.0), pol(P::new(430.0, 40.0), 70.0, 50.0)], w);
|
||||
s.junctions.push((P::new(430.0, 40.0), 3));
|
||||
// Thin stem on a thick bar.
|
||||
let bar = (w * 2.0).min(12.0);
|
||||
s.line(&[P::new(20.0, 160.0), P::new(140.0, 160.0)], bar);
|
||||
s.line(&[P::new(80.0, 160.0), P::new(80.0, 215.0)], (w * 0.75).max(1.0));
|
||||
s.junctions.push((P::new(80.0, 160.0), 3));
|
||||
// Narrow Y (arms 40° apart).
|
||||
let c = P::new(230.0, 150.0);
|
||||
s.line(&[c, pol(c, 60.0, 180.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, -20.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, 20.0)], w);
|
||||
s.junctions.push((c, 3));
|
||||
// Asterisk-free "K": arms meeting the stem at one point (degree 4).
|
||||
let c = P::new(400.0, 165.0);
|
||||
s.line(&[P::new(400.0, 120.0), P::new(400.0, 210.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, -40.0)], w);
|
||||
s.line(&[c, pol(c, 50.0, 40.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
shifted(s)
|
||||
}
|
||||
|
||||
pub fn crossings(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("crossings w{w}"), 560, 140, 13.0);
|
||||
s.offset = P::new(0.27, 0.41);
|
||||
for (k, ang) in [20.0, 30.0, 45.0, 60.0, 90.0].into_iter().enumerate() {
|
||||
let c = P::new(60.0 + 110.0 * k as f64, 70.0);
|
||||
s.line(&[pol(c, 50.0, 180.0 - ang / 2.0), pol(c, 50.0, -ang / 2.0)], w);
|
||||
s.line(&[pol(c, 50.0, 180.0 + ang / 2.0), pol(c, 50.0, ang / 2.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
}
|
||||
shifted(s)
|
||||
}
|
||||
|
||||
pub fn dashed(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("dashed w{w}"), 400, 260, 13.0);
|
||||
s.min_len = 0.0;
|
||||
for (k, (dash, gap, ang)) in [(12.0, 4.0, 0.0), (6.0, 3.0, 15.0), (10.0, 4.0, 37.0), (16.0, 6.0, 90.0), (8.0, 5.0, 135.0)].into_iter().enumerate()
|
||||
{
|
||||
let c = P::new(80.0 + 120.0 * (k % 3) as f64, 70.0 + 120.0 * (k / 3) as f64);
|
||||
let a: f64 = f64::to_radians(ang);
|
||||
let dir = P::new(a.cos(), a.sin());
|
||||
let mut t = -50.0;
|
||||
while t + dash <= 50.0 {
|
||||
s.line(&[c + dir * t, c + dir * (t + dash)], w);
|
||||
// The visible gap is `gap`: the round caps reach w/2 past each dash's ends.
|
||||
t += dash + gap + w;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Letter-like strokes drawn from lines and arcs (no fonts).
|
||||
pub fn letters(w: f64) -> Scene {
|
||||
let mut s = Scene::new(&format!("letters w{w}"), 520, 120, 13.0);
|
||||
s.offset = P::new(0.27, 0.41);
|
||||
// A: two legs meeting at the apex (a corner), crossbar (two T's).
|
||||
let (l, apex, r) = (P::new(20.0, 100.0), P::new(50.0, 20.0), P::new(80.0, 100.0));
|
||||
s.line(&[l, apex, r], w);
|
||||
let (bl, br) = (l.lerp(apex, 0.4), r.lerp(apex, 0.4));
|
||||
s.line(&[bl, br], w);
|
||||
s.junctions.push((bl, 3));
|
||||
s.junctions.push((br, 3));
|
||||
s.corners.push(apex);
|
||||
// H.
|
||||
s.line(&[P::new(110.0, 20.0), P::new(110.0, 100.0)], w);
|
||||
s.line(&[P::new(160.0, 20.0), P::new(160.0, 100.0)], w);
|
||||
s.line(&[P::new(110.0, 60.0), P::new(160.0, 60.0)], w);
|
||||
s.junctions.push((P::new(110.0, 60.0), 3));
|
||||
s.junctions.push((P::new(160.0, 60.0), 3));
|
||||
// e: bar across a circle that opens at the lower right.
|
||||
let c = P::new(215.0, 65.0);
|
||||
s.arc(c, 25.0, 0.0, -std::f64::consts::PI * 1.75, w);
|
||||
s.line(&[pol(c, 25.0, 180.0), pol(c, 25.0, 0.0)], w);
|
||||
s.junctions.push((pol(c, 25.0, 180.0), 3));
|
||||
s.corners.push(pol(c, 25.0, 0.0));
|
||||
// k.
|
||||
let c = P::new(275.0, 75.0);
|
||||
s.line(&[P::new(275.0, 15.0), P::new(275.0, 105.0)], w);
|
||||
s.line(&[c, P::new(310.0, 45.0)], w);
|
||||
s.line(&[c, P::new(310.0, 105.0)], w);
|
||||
s.junctions.push((c, 4));
|
||||
// x.
|
||||
s.line(&[P::new(330.0, 40.0), P::new(370.0, 100.0)], w);
|
||||
s.line(&[P::new(370.0, 40.0), P::new(330.0, 100.0)], w);
|
||||
s.junctions.push((P::new(350.0, 70.0), 4));
|
||||
// s: two arcs.
|
||||
s.arc(P::new(405.0, 50.0), 15.0, -0.2, -std::f64::consts::PI * 1.5, w);
|
||||
s.arc(P::new(405.0, 80.0), 15.0, -std::f64::consts::PI * 0.5, std::f64::consts::PI * 0.8, w);
|
||||
s.corners.push(P::new(405.0, 65.0));
|
||||
// o.
|
||||
s.ring(P::new(475.0, 65.0), 25.0, w);
|
||||
shifted(s)
|
||||
}
|
||||
|
||||
/// Thin strokes and wide fills, with strokes running into fills; Stroke width 6.
|
||||
pub fn mix() -> Scene {
|
||||
let mut s = Scene::new("mix", 480, 300, 6.0);
|
||||
s.fills.push(GtFill::Disc(P::new(100.0, 100.0), 30.0));
|
||||
s.fills.push(GtFill::Poly(vec![P::new(260.0, 60.0), P::new(340.0, 60.0), P::new(340.0, 110.0), P::new(260.0, 110.0)]));
|
||||
s.fills.push(GtFill::Disc(P::new(400.0, 220.0), 22.0));
|
||||
// Lines into the disc and the rectangle.
|
||||
s.line(&[P::new(20.0, 200.0), P::new(80.0, 120.0)], 3.0);
|
||||
s.line(&[P::new(130.0, 100.0), P::new(260.0, 85.0)], 2.0);
|
||||
s.line(&[P::new(300.0, 110.0), P::new(300.0, 200.0), P::new(380.0, 210.0)], 4.0);
|
||||
// Free strokes.
|
||||
s.line(&[P::new(40.0, 260.0), P::new(250.0, 280.0)], 3.0);
|
||||
s.curve(|t| P::new(150.0 + 120.0 * t, 200.0 + 25.0 * (t * 7.0).sin()), |_| 2.0, 200, false);
|
||||
s.line(&[P::new(420.0, 30.0), P::new(460.0, 150.0)], 5.0);
|
||||
s
|
||||
}
|
||||
|
||||
/// Strokes whose width varies: tapers, a lens, a swelling brush curve; Stroke width 12.
|
||||
pub fn varying() -> Scene {
|
||||
let mut s = Scene::new("varying", 480, 300, 12.0);
|
||||
s.curve(|t| P::new(30.0 + 200.0 * t, 40.0), |t| 2.0 + 8.0 * t, 200, false);
|
||||
s.curve(|t| P::new(260.0 + 200.0 * t, 40.0 + 30.0 * t), |t| 2.0 + 8.0 * (std::f64::consts::PI * t).sin(), 200, false);
|
||||
s.curve(|t| P::new(30.0 + 420.0 * t, 150.0 + 40.0 * (t * 6.0).sin()), |t| 5.5 + 3.0 * (t * 9.0).sin(), 600, false);
|
||||
s.curve(
|
||||
|t| {
|
||||
let a = t * 1.6 * std::f64::consts::PI;
|
||||
P::new(120.0, 250.0) + P::new(a.cos(), a.sin()) * 35.0
|
||||
},
|
||||
|t| 2.0 + 9.0 * t,
|
||||
300,
|
||||
false,
|
||||
);
|
||||
s.curve(|t| P::new(250.0 + 200.0 * t, 260.0 - 50.0 * t * t), |t| 9.0 - 6.0 * t, 200, false);
|
||||
s
|
||||
}
|
||||
|
||||
/// Shifted copies of the integer-placed scenes' junctions and corners (strokes shift in `var`).
|
||||
fn shifted(mut s: Scene) -> Scene {
|
||||
let o = s.offset;
|
||||
for j in &mut s.junctions {
|
||||
j.0 = j.0 + o;
|
||||
}
|
||||
for c in &mut s.corners {
|
||||
*c = *c + o;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn all() -> Vec<Scene> {
|
||||
let mut v = vec![];
|
||||
for w in [1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0] {
|
||||
v.push(lines(w));
|
||||
}
|
||||
for w in [2.0, 4.0, 8.0] {
|
||||
v.push(curves(w));
|
||||
}
|
||||
v.push(circles());
|
||||
for w in [2.0, 4.0, 8.0] {
|
||||
v.push(junctions(w));
|
||||
}
|
||||
for w in [3.0, 6.0] {
|
||||
v.push(crossings(w));
|
||||
}
|
||||
for w in [2.0, 4.0] {
|
||||
v.push(dashed(w));
|
||||
}
|
||||
for w in [3.0, 5.0] {
|
||||
v.push(letters(w));
|
||||
}
|
||||
v.push(mix());
|
||||
v.push(varying());
|
||||
v
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! ```
|
||||
//!
|
||||
//! `TRACE_QUALITY_MP` (default `1,4,16`) sets the megapixel sizes of the cost table.
|
||||
//! `TRACE_QUALITY_CLASS=centre-lines` selects only the code-drawn centre-line report.
|
||||
//!
|
||||
//! A yardstick: `TRACE_QUALITY_IMAGES=dir` also writes the drawings there as PNG files, and
|
||||
//! `TRACE_QUALITY_YARDSTICK=dir` measures the SVG files of the same names found there (what another
|
||||
@@ -15,6 +16,9 @@
|
||||
// The peak-memory measure needs a counting global allocator.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
mod centre_geom;
|
||||
mod centre_lines;
|
||||
mod centre_scenes;
|
||||
mod inputs;
|
||||
mod measure;
|
||||
mod raster;
|
||||
@@ -347,12 +351,23 @@ fn report() -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
md += ¢re_lines::report();
|
||||
md
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "slow: measures every preset and writes the quality report (run with --release)"]
|
||||
fn quality_report() {
|
||||
// One ignored test owns the allocator's cost measurements. Selecting this class
|
||||
// here avoids a second ignored test allocating concurrently with those measurements.
|
||||
if std::env::var("TRACE_QUALITY_CLASS").as_deref() == Ok("centre-lines") {
|
||||
let md = centre_lines::report();
|
||||
println!("{md}");
|
||||
if let Ok(path) = std::env::var("TRACE_QUALITY_REPORT") {
|
||||
std::fs::write(path, md).unwrap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let md = report();
|
||||
println!("{md}");
|
||||
// Gradients finds none in art that is flat colour, however it is degraded.
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn fidelity(src: &Raster, out: &Raster) -> (f64, f64) {
|
||||
}
|
||||
|
||||
/// A traced path as the shape it paints: stroked paths become their stroke's outline.
|
||||
fn painted(tp: &TracedPath) -> BezPath {
|
||||
pub(super) fn painted(tp: &TracedPath) -> BezPath {
|
||||
let bez = tp.path.to_bezpath();
|
||||
match tp.stroke {
|
||||
Some(width) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Image Trace quality
|
||||
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (centre-lines baseline; no tracing behaviour change)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace: a higher Paths never gives fewer segments on collapsed loops)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace: the notch rule reads only its own piece)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace: an edge's sub-pixel shift is the same from either side)
|
||||
@@ -1070,10 +1071,129 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
|
||||
| Outline, enhanced | photo-like | 16 | 1.64 s, 632 paths, 34884 anchors, 256 MB |
|
||||
| Outline, enhanced | four-colour badge | 16 | 0.73 s, 1 paths, 49 anchors, 176 MB |
|
||||
|
||||
## Centre lines
|
||||
|
||||
Baseline measured on upstream `8bc65329` (2026-10-17). Earlier preset and cost tables retain their previous measurement. To regenerate only this class:
|
||||
|
||||
```text
|
||||
TRACE_QUALITY_CLASS=centre-lines TRACE_QUALITY_REPORT=centre-lines.md cargo test --release -p vectorcraft-trace --test quality quality_report -- --ignored --nocapture
|
||||
```
|
||||
|
||||
Code-drawn scenes only: 23 scenes, each anti-aliased (8×8 coverage samples) and hard (that coverage thresholded at 0.5). Coordinates, widths and scene Stroke values are fixed; junction/letter/crossing coordinates include the 0.27/0.41 px offset. Create Fills and Strokes are on, Stroke is the scene's limit (13 px, mix 6 px, varying 12 px), Black and White threshold 128, Noise 4, Ignore White on, other settings at their defaults. Today's tracer has no minimum-length setting (the dashed scenes specify 0 for future use).
|
||||
|
||||
Distances use dense exact centre samples (≤0.25 px), emitted curves sampled at ≤0.5 px control-polygon steps, and nearest-point searches capped at 30 px. Found is the fraction of true samples within 1.5 px; width errors use output samples within 2 px of a true stroke. Samples inside exact fills are excluded. Topology is recovered from emitted subpath endpoints and intersections, merging coincident positions within 0.05 px; an endpoint contributes one arm, an interior two. Junction matching radius is local true width + 3 px; ends use half-width + 4 px. Each match is used once. Free ends exclude true junctions, corners, other strokes and fills; unmatched emitted ends are spurs. Junctions/ends within 2 px of fills are excluded. End error is distance, with signed error along the outward tangent in parentheses. Coverage is a union of painted output at pixel centres (0.02 px flattening tolerance, actual emitted caps), compared with the exact swept shape at pixel centres, for both input variants. Area error is signed relative to exact area. Anchors counts stroke anchors, including all compound subpaths; paths counts stroked + filled paths. Time covers tracing only, one scene at a time. Profile-dependent columns are unavailable (–).
|
||||
|
||||
### Centre line and width (px)
|
||||
|
||||
| scene | input | paths | out→true mean / p95 | true→out mean / p95 | found % | width err uniform mean / p95 | variable mean / p95 | profile mean |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| lines w1 | aa | 93+0 | 0.08 / 0.13 | 2.89 / 30.00 | 88.0 | 0.41 / 1.00 | – | – |
|
||||
| lines w1 | hard | 92+0 | 0.17 / 0.36 | 2.19 / 9.26 | 75.9 | 0.30 / 1.00 | – | – |
|
||||
| lines w2 | aa | 24+0 | 0.08 / 0.17 | 0.50 / 0.38 | 95.6 | 0.05 / 0.06 | – | – |
|
||||
| lines w2 | hard | 24+0 | 0.19 / 0.41 | 0.58 / 0.66 | 95.6 | 0.14 / 1.00 | – | – |
|
||||
| lines w3 | aa | 24+0 | 0.08 / 0.15 | 0.53 / 0.48 | 95.3 | 0.08 / 0.09 | – | – |
|
||||
| lines w3 | hard | 24+0 | 0.18 / 0.41 | 0.62 / 0.85 | 95.3 | 0.19 / 1.00 | – | – |
|
||||
| lines w4 | aa | 24+0 | 0.08 / 0.16 | 0.57 / 1.18 | 95.1 | 0.11 / 0.12 | – | – |
|
||||
| lines w4 | hard | 24+0 | 0.17 / 0.44 | 0.63 / 1.06 | 95.1 | 0.14 / 1.00 | – | – |
|
||||
| lines w6 | aa | 24+0 | 0.09 / 0.18 | 1.14 / 6.75 | 93.6 | 0.13 / 0.19 | – | – |
|
||||
| lines w6 | hard | 24+0 | 0.18 / 0.51 | 1.18 / 6.20 | 93.7 | 0.26 / 1.00 | – | – |
|
||||
| lines w8 | aa | 24+0 | 0.10 / 0.22 | 1.17 / 7.37 | 93.4 | 0.16 / 0.26 | – | – |
|
||||
| lines w8 | hard | 24+0 | 0.23 / 0.64 | 1.23 / 6.95 | 93.4 | 0.17 / 1.00 | – | – |
|
||||
| lines w10 | aa | 24+0 | 0.11 / 0.26 | 1.20 / 7.96 | 93.3 | 0.18 / 0.32 | – | – |
|
||||
| lines w10 | hard | 24+0 | 0.27 / 0.68 | 1.29 / 7.79 | 93.4 | 0.35 / 1.00 | – | – |
|
||||
| lines w12 | aa | 24+0 | 0.15 / 0.42 | 1.59 / 13.81 | 92.6 | 0.21 / 0.39 | – | – |
|
||||
| lines w12 | hard | 24+0 | 0.31 / 0.78 | 1.31 / 8.06 | 93.3 | 0.32 / 1.00 | – | – |
|
||||
| curves w2 | aa | 4+0 | 0.11 / 0.22 | 0.15 / 0.26 | 100.0 | 0.00 / 0.01 | – | – |
|
||||
| curves w2 | hard | 4+0 | 0.24 / 0.57 | 0.26 / 0.57 | 100.0 | 0.05 / 0.06 | – | – |
|
||||
| curves w4 | aa | 4+0 | 0.13 / 0.27 | 0.18 / 0.30 | 99.6 | 0.01 / 0.03 | – | – |
|
||||
| curves w4 | hard | 4+0 | 0.27 / 0.64 | 0.28 / 0.63 | 99.9 | 0.00 / 0.00 | – | – |
|
||||
| curves w8 | aa | 4+0 | 0.16 / 0.31 | 0.24 / 0.36 | 98.1 | 0.01 / 0.02 | – | – |
|
||||
| curves w8 | hard | 4+0 | 0.28 / 0.63 | 0.35 / 0.65 | 98.1 | 0.00 / 0.00 | – | – |
|
||||
| circles | aa | 25+0 | 0.09 / 0.25 | 0.26 / 0.37 | 97.2 | 0.01 / 0.03 | – | – |
|
||||
| circles | hard | 23+0 | 0.28 / 0.66 | 0.46 / 0.78 | 96.1 | 0.02 / 0.09 | – | – |
|
||||
| junctions w2 | aa | 7+0 | 0.12 / 0.31 | 3.81 / 30.00 | 86.6 | 0.15 / 2.50 | – | – |
|
||||
| junctions w2 | hard | 7+0 | 0.38 / 0.87 | 4.09 / 30.00 | 86.3 | 0.16 / 2.50 | – | – |
|
||||
| junctions w4 | aa | 7+0 | 0.18 / 0.86 | 1.87 / 17.27 | 90.6 | 0.29 / 5.00 | – | – |
|
||||
| junctions w4 | hard | 7+0 | 0.42 / 1.15 | 2.10 / 17.87 | 90.3 | 0.31 / 5.00 | – | – |
|
||||
| junctions w8 | aa | 7+0 | 0.28 / 1.63 | 2.11 / 19.60 | 86.9 | 0.34 / 6.00 | – | – |
|
||||
| junctions w8 | hard | 7+0 | 0.43 / 1.64 | 2.24 / 19.46 | 86.5 | 0.33 / 6.00 | – | – |
|
||||
| crossings w3 | aa | 5+0 | 0.18 / 0.81 | 4.35 / 30.00 | 80.6 | 0.06 / 0.09 | – | – |
|
||||
| crossings w3 | hard | 5+0 | 0.39 / 1.13 | 4.48 / 30.00 | 80.4 | 0.16 / 0.32 | – | – |
|
||||
| crossings w6 | aa | 5+0 | 0.43 / 2.30 | 5.75 / 30.00 | 73.9 | 0.12 / 0.19 | – | – |
|
||||
| crossings w6 | hard | 5+0 | 0.58 / 2.40 | 5.89 / 30.00 | 72.4 | 0.19 / 0.26 | – | – |
|
||||
| dashed w2 | aa | 31+0 | 0.08 / 0.15 | 0.14 / 0.27 | 100.0 | 0.18 / 0.87 | – | – |
|
||||
| dashed w2 | hard | 31+0 | 0.09 / 0.25 | 0.16 / 0.35 | 100.0 | 0.22 / 1.00 | – | – |
|
||||
| dashed w4 | aa | 29+0 | 0.15 / 0.76 | 0.34 / 1.78 | 94.0 | 0.19 / 1.00 | – | – |
|
||||
| dashed w4 | hard | 29+0 | 0.19 / 0.70 | 0.40 / 1.93 | 93.7 | 0.15 / 1.00 | – | – |
|
||||
| letters w3 | aa | 7+0 | 0.16 / 0.61 | 0.19 / 0.40 | 99.4 | 0.02 / 0.13 | – | – |
|
||||
| letters w3 | hard | 7+0 | 0.32 / 0.79 | 0.33 / 0.73 | 99.6 | 0.06 / 0.20 | – | – |
|
||||
| letters w5 | aa | 7+0 | 0.29 / 1.46 | 0.30 / 1.14 | 96.8 | 0.02 / 0.20 | – | – |
|
||||
| letters w5 | hard | 7+0 | 0.40 / 1.57 | 0.40 / 1.18 | 96.8 | 0.08 / 0.20 | – | – |
|
||||
| mix | aa | 3+1 | 0.10 / 0.26 | 12.89 / 30.00 | 57.3 | 0.03 / 0.12 | – | – |
|
||||
| mix | hard | 3+1 | 0.21 / 0.47 | 12.94 / 30.00 | 57.3 | 0.08 / 0.15 | – | – |
|
||||
| varying | aa | 5+0 | 0.13 / 0.29 | 0.19 / 0.33 | 99.6 | 1.90 / 4.21 | – | – |
|
||||
| varying | hard | 5+0 | 0.24 / 0.61 | 0.28 / 0.63 | 99.4 | 1.91 / 4.36 | – | – |
|
||||
|
||||
### Topology, ends, coverage
|
||||
|
||||
| scene | input | junctions ok / wrong deg / missed / extra | junction pos err | ends found | end err (signed) | spurs | IoU uniform / profile | area err uniform / profile | fill→fill % | anchors | width pts | ms |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| lines w1 | aa | 0 / 0 / 0 / 81 | – | 41/48 | 0.96 (-0.95) | 90 | 0.633 / – | 10.9% / – | – | 186 | – | 5.0 |
|
||||
| lines w1 | hard | 0 / 0 / 0 / 0 | – | 35/48 | 1.24 (-1.19) | 149 | 0.632 / – | -25.8% / – | – | 184 | – | 3.7 |
|
||||
| lines w2 | aa | 0 / 0 / 0 / 0 | – | 44/48 | 0.22 (-0.18) | 4 | 0.894 / – | -3.1% / – | – | 52 | – | 4.1 |
|
||||
| lines w2 | hard | 0 / 0 / 0 / 0 | – | 44/48 | 0.41 (-0.24) | 4 | 0.786 / – | -3.4% / – | – | 52 | – | 4.2 |
|
||||
| lines w3 | aa | 0 / 0 / 0 / 0 | – | 44/48 | 0.20 (-0.17) | 4 | 0.889 / – | -6.6% / – | – | 52 | – | 4.1 |
|
||||
| lines w3 | hard | 0 / 0 / 0 / 0 | – | 44/48 | 0.48 (-0.31) | 4 | 0.835 / – | -4.5% / – | – | 49 | – | 4.2 |
|
||||
| lines w4 | aa | 0 / 0 / 0 / 0 | – | 44/48 | 0.22 (-0.18) | 4 | 0.916 / – | -3.5% / – | – | 58 | – | 4.3 |
|
||||
| lines w4 | hard | 0 / 0 / 0 / 0 | – | 44/48 | 0.37 (-0.15) | 4 | 0.868 / – | -3.6% / – | – | 56 | – | 4.3 |
|
||||
| lines w6 | aa | 0 / 0 / 0 / 0 | – | 45/48 | 0.23 (-0.15) | 3 | 0.911 / – | -5.3% / – | – | 64 | – | 4.7 |
|
||||
| lines w6 | hard | 0 / 0 / 0 / 0 | – | 45/48 | 0.39 (-0.10) | 3 | 0.874 / – | -2.7% / – | – | 57 | – | 4.6 |
|
||||
| lines w8 | aa | 0 / 0 / 0 / 0 | – | 45/48 | 0.27 (-0.20) | 3 | 0.910 / – | -5.1% / – | – | 62 | – | 5.0 |
|
||||
| lines w8 | hard | 0 / 0 / 0 / 0 | – | 45/48 | 0.48 (-0.18) | 3 | 0.878 / – | -4.8% / – | – | 62 | – | 5.0 |
|
||||
| lines w10 | aa | 0 / 0 / 0 / 0 | – | 45/48 | 0.33 (-0.19) | 3 | 0.908 / – | -4.6% / – | – | 73 | – | 5.3 |
|
||||
| lines w10 | hard | 0 / 0 / 0 / 0 | – | 45/48 | 0.50 (-0.18) | 3 | 0.876 / – | -2.7% / – | – | 67 | – | 5.4 |
|
||||
| lines w12 | aa | 0 / 0 / 0 / 0 | – | 46/48 | 0.46 (-0.21) | 2 | 0.900 / – | -4.9% / – | – | 72 | – | 5.7 |
|
||||
| lines w12 | hard | 0 / 0 / 0 / 0 | – | 45/48 | 0.55 (-0.15) | 3 | 0.880 / – | -3.6% / – | – | 64 | – | 6.0 |
|
||||
| curves w2 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.19 (-0.15) | 0 | 0.922 / – | -0.1% / – | – | 35 | – | 4.2 |
|
||||
| curves w2 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.21 (-0.10) | 0 | 0.814 / – | -1.8% / – | – | 27 | – | 4.3 |
|
||||
| curves w4 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.99 (-0.89) | 0 | 0.939 / – | -1.0% / – | – | 34 | – | 4.6 |
|
||||
| curves w4 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.63 (-0.47) | 0 | 0.881 / – | -0.1% / – | – | 25 | – | 4.6 |
|
||||
| curves w8 | aa | 0 / 0 / 0 / 0 | – | 6/8 | 1.46 (-1.34) | 2 | 0.953 / – | -1.1% / – | – | 41 | – | 5.2 |
|
||||
| curves w8 | hard | 0 / 0 / 0 / 0 | – | 6/8 | 1.21 (-0.91) | 2 | 0.924 / – | -1.1% / – | – | 30 | – | 5.2 |
|
||||
| circles | aa | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 32 | 0.962 / – | -1.2% / – | – | 70 | – | 7.0 |
|
||||
| circles | hard | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 28 | 0.887 / – | -1.2% / – | – | 52 | – | 7.6 |
|
||||
| junctions w2 | aa | 6 / 0 / 1 / 1 | 1.22 | 20/23 | 0.28 (-0.19) | 1 | 0.777 / – | -6.6% / – | – | 67 | – | 3.7 |
|
||||
| junctions w2 | hard | 6 / 0 / 1 / 0 | 1.31 | 20/23 | 0.51 (-0.20) | 1 | 0.698 / – | -8.4% / – | – | 48 | – | 3.7 |
|
||||
| junctions w4 | aa | 5 / 1 / 1 / 2 | 1.67 | 20/23 | 0.30 (-0.24) | 1 | 0.841 / – | -3.3% / – | – | 91 | – | 3.9 |
|
||||
| junctions w4 | hard | 5 / 1 / 1 / 2 | 1.67 | 20/23 | 0.54 (-0.24) | 1 | 0.788 / – | -2.5% / – | – | 74 | – | 3.9 |
|
||||
| junctions w8 | aa | 4 / 2 / 1 / 4 | 3.00 | 20/23 | 0.45 (-0.31) | 1 | 0.857 / – | -6.2% / – | – | 104 | – | 4.6 |
|
||||
| junctions w8 | hard | 5 / 1 / 1 / 2 | 3.02 | 20/23 | 0.73 (-0.34) | 1 | 0.836 / – | -6.0% / – | – | 89 | – | 4.4 |
|
||||
| crossings w3 | aa | 0 / 3 / 2 / 5 | 2.91 | 16/20 | 0.39 (-0.31) | 2 | 0.757 / – | -20.4% / – | – | 68 | – | 2.8 |
|
||||
| crossings w3 | hard | 0 / 3 / 2 / 6 | 2.81 | 16/20 | 0.58 (-0.31) | 2 | 0.658 / – | -23.9% / – | – | 61 | – | 2.8 |
|
||||
| crossings w6 | aa | 0 / 3 / 2 / 8 | 5.00 | 17/20 | 0.20 (-0.15) | 1 | 0.754 / – | -21.3% / – | – | 103 | – | 3.5 |
|
||||
| crossings w6 | hard | 0 / 3 / 2 / 8 | 5.00 | 17/20 | 0.43 (-0.27) | 1 | 0.718 / – | -21.8% / – | – | 78 | – | 3.4 |
|
||||
| dashed w2 | aa | 0 / 0 / 0 / 0 | – | 62/62 | 0.34 (-0.31) | 0 | 0.861 / – | 8.4% / – | – | 62 | – | 2.0 |
|
||||
| dashed w2 | hard | 0 / 0 / 0 / 0 | – | 62/62 | 0.36 (-0.26) | 0 | 0.843 / – | 9.9% / – | – | 62 | – | 2.3 |
|
||||
| dashed w4 | aa | 0 / 0 / 0 / 0 | – | 58/58 | 0.78 (-0.52) | 0 | 0.849 / – | 3.5% / – | – | 61 | – | 2.2 |
|
||||
| dashed w4 | hard | 0 / 0 / 0 / 0 | – | 58/58 | 0.88 (-0.53) | 0 | 0.850 / – | -0.3% / – | – | 60 | – | 2.0 |
|
||||
| letters w3 | aa | 5 / 2 / 0 / 3 | 1.42 | 17/17 | 0.30 (-0.24) | 0 | 0.941 / – | -0.6% / – | – | 106 | – | 3.2 |
|
||||
| letters w3 | hard | 5 / 2 / 0 / 3 | 1.42 | 17/17 | 0.44 (-0.27) | 0 | 0.860 / – | -0.5% / – | – | 82 | – | 3.2 |
|
||||
| letters w5 | aa | 5 / 2 / 0 / 8 | 1.81 | 16/17 | 0.80 (-0.65) | 1 | 0.925 / – | -1.1% / – | – | 134 | – | 3.5 |
|
||||
| letters w5 | hard | 5 / 2 / 0 / 8 | 1.81 | 16/17 | 0.89 (-0.67) | 1 | 0.889 / – | -2.0% / – | – | 117 | – | 3.4 |
|
||||
| mix | aa | 0 / 0 / 0 / 0 | – | 6/7 | 0.21 (-0.19) | 0 | 0.987 / – | 0.1% / – | 99.7 | 11 | – | 5.9 |
|
||||
| mix | hard | 0 / 0 / 0 / 0 | – | 6/7 | 0.61 (-0.52) | 0 | 0.971 / – | -0.1% / – | 99.1 | 10 | – | 5.9 |
|
||||
| varying | aa | 0 / 0 / 0 / 0 | – | 10/10 | 2.11 (-1.12) | 0 | 0.738 / – | 6.0% / – | – | 22 | – | 4.7 |
|
||||
| varying | hard | 0 / 0 / 0 / 0 | – | 10/10 | 2.17 (-1.17) | 0 | 0.732 / – | 6.0% / – | – | 21 | – | 4.8 |
|
||||
|
||||
**aa totals**: junctions 25 correct, 13 wrong degree, 7 missed, 112 extra (of 45); ends 638 of 688; spurs 154.
|
||||
|
||||
**hard totals**: junctions 26 correct, 12 wrong degree, 7 missed, 29 extra (of 45); ends 631 of 688; spurs 210.
|
||||
|
||||
## Revision history
|
||||
|
||||
| date | change |
|
||||
|:--|:--|
|
||||
| 2026-10-17 | M12: add the ignored centre-lines class and measure the existing Create Strokes output on 23 code-drawn scenes, aa and hard; centre/width/topology/end/coverage metrics, no new images, dependencies or behaviour |
|
||||
| 2026-10-16 | Image Trace: a loop or junction-returning piece whose fit collapsed to one cubic keeps the fewest segments that hold its area and course instead of every positioned point, so a higher Paths no longer gives fewer segments on code-drawn noisy images (six falling runs to none; one other cause remains). |
|
||||
| 2026-10-16 | Image Trace: a notch corner is cut only when the straight edges beside it lie in the shared piece, so both regions of the piece get the same points (code-drawn noisy colour images: 93 uncovered sample points to 0). |
|
||||
| 2026-10-16 | Image Trace: a crack's sub-pixel shift is the mean of the readings from its two sides, exactly antisymmetric, so abutting regions get the same points on thin slivers (a code-drawn noisy ripple went from 482–1,228 uncovered sample points per trace to 0). |
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user