mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Merge pull request #2705 from andeecollard/trace/centreline-graph
Create Strokes: skeleton graph with junction merging
This commit is contained in:
@@ -1,582 +0,0 @@
|
||||
//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked
|
||||
//! centre lines instead of filled outlines.
|
||||
//!
|
||||
//! 1. **Distance**: a chamfer (3-4) distance transform gives each pixel its distance to the nearest
|
||||
//! pixel outside the layer.
|
||||
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
|
||||
//! one) is within the stroke width is a line; wider ones stay filled areas.
|
||||
//! 3. **Skeleton**: the line is thinned to one pixel (Zhang–Suen, applied pixel by pixel so it
|
||||
//! keeps its topology and two-pixel-wide lines don't vanish), then staircase corners that
|
||||
//! don't connect anything are dropped.
|
||||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||||
//! merged into one point) become polylines through the pixel centres; rings without ends
|
||||
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
|
||||
//! on either side of a junction left with two of them are joined.
|
||||
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
|
||||
//! of the line's ink, read from the image: across the line (along the pixel column or row most
|
||||
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
|
||||
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
|
||||
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
|
||||
//! median of those widths, and each end is put where the ink ends, less the half width a round
|
||||
//! cap adds.
|
||||
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
|
||||
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::Raster;
|
||||
use crate::fit;
|
||||
use crate::logo::Style;
|
||||
|
||||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||||
pub(crate) struct Line {
|
||||
pub path: PathData,
|
||||
/// Stroke width in pixels.
|
||||
pub width: f64,
|
||||
pub pixels: usize,
|
||||
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
|
||||
pub square: bool,
|
||||
}
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||||
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
|
||||
/// image's own black), which the lines' middles and widths are read against.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn lines(
|
||||
labels: &[u16],
|
||||
label: u16,
|
||||
w: usize,
|
||||
h: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
) -> (Vec<bool>, Vec<Line>) {
|
||||
let mut thin = vec![false; labels.len()];
|
||||
let mut out = vec![];
|
||||
if w == 0 || h == 0 || labels.len() != w * h {
|
||||
return (thin, out);
|
||||
}
|
||||
let mask = |i: usize| labels.get(i) == Some(&label);
|
||||
let dt = distance(&mask, w, h);
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![];
|
||||
for start in 0..w * h {
|
||||
if !mask(start) || seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut comp = vec![];
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
comp.push(i);
|
||||
for j in neighbours(i, w, h) {
|
||||
if mask(j) && !seen[j] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
let widest = 2.0 * f64::from(comp.iter().map(|&i| dt[i]).max().unwrap_or(0)) / 3.0 - 1.0;
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(&comp, w, max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
thin[i] = true;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
(thin, out)
|
||||
}
|
||||
|
||||
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
|
||||
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
|
||||
AROUND.into_iter().filter_map(move |(dx, dy)| {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
(nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize)
|
||||
})
|
||||
}
|
||||
|
||||
/// Chamfer 3-4 distance (3 per pixel step, saturating) of each pixel in `mask` to the nearest
|
||||
/// pixel outside it; outside the image counts as outside.
|
||||
fn distance(mask: &impl Fn(usize) -> bool, w: usize, h: usize) -> Vec<u16> {
|
||||
let mut d = vec![0u16; w * h];
|
||||
let at = |d: &[u16], x: i64, y: i64| {
|
||||
if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0 } else { d.get(y as usize * w + x as usize).copied().unwrap_or(0) }
|
||||
};
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if mask(i) {
|
||||
let (x, y) = (x as i64, y as i64);
|
||||
let steps = [(x - 1, y, 3), (x - 1, y - 1, 4), (x, y - 1, 3), (x + 1, y - 1, 4)];
|
||||
d[i] = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in (0..h).rev() {
|
||||
for x in (0..w).rev() {
|
||||
let i = y * w + x;
|
||||
if mask(i) {
|
||||
let (xi, yi) = (x as i64, y as i64);
|
||||
let steps = [(xi + 1, yi, 3), (xi + 1, yi + 1, 4), (xi, yi + 1, 3), (xi - 1, yi + 1, 4)];
|
||||
let v = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0);
|
||||
d[i] = d[i].min(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A grid around one line: its pixels with a one-pixel empty border.
|
||||
struct Grid {
|
||||
on: Vec<bool>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Image position of grid pixel (0, 0).
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// The 8 neighbours (N, NE, E, SE, S, SW, W, NW) of interior pixel `i`.
|
||||
fn ring(&self, i: usize) -> [bool; 8] {
|
||||
let w = self.w;
|
||||
let g = |j: usize| self.on.get(j).copied().unwrap_or(false);
|
||||
[g(i - w), g(i - w + 1), g(i + 1), g(i + w + 1), g(i + w), g(i + w - 1), g(i - 1), g(i - w - 1)]
|
||||
}
|
||||
fn interior(&self) -> impl Iterator<Item = usize> + use<> {
|
||||
let (w, h) = (self.w, self.h);
|
||||
(1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x))
|
||||
}
|
||||
fn neighbours(&self, i: usize) -> impl Iterator<Item = usize> + '_ {
|
||||
let w = self.w;
|
||||
[i - w, i - w + 1, i + 1, i + w + 1, i + w, i + w - 1, i - 1, i - w - 1].into_iter().filter(|&j| self.on.get(j).copied().unwrap_or(false))
|
||||
}
|
||||
fn degree(&self, i: usize) -> usize {
|
||||
self.neighbours(i).count()
|
||||
}
|
||||
/// The centre of pixel `i` in image coordinates.
|
||||
fn centre(&self, i: usize) -> Point {
|
||||
Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zhang–Suen thinning. Each sub-iteration picks the pixels to remove from the same state, so the
|
||||
/// skeleton stays centred; they are then removed one by one, each only if it still doesn't
|
||||
/// disconnect anything, so a two-pixel-wide line thins to one pixel instead of vanishing.
|
||||
fn thin(g: &mut Grid) {
|
||||
let removable = |p: &[bool; 8]| {
|
||||
let b = p.iter().filter(|v| **v).count();
|
||||
let a = (0..8).filter(|&k| !p[k] && p[(k + 1) % 8]).count();
|
||||
(2..=6).contains(&b) && a == 1
|
||||
};
|
||||
let mut picked = vec![];
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for step in 0..2 {
|
||||
picked.clear();
|
||||
for i in g.interior() {
|
||||
if !g.on[i] {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
let (n, e, s, w) = (p[0], p[2], p[4], p[6]);
|
||||
let side = if step == 0 { !(e && s && (n || w)) } else { !(n && w && (e || s)) };
|
||||
if side && removable(&p) {
|
||||
picked.push(i);
|
||||
}
|
||||
}
|
||||
for &i in &picked {
|
||||
if removable(&g.ring(i)) {
|
||||
g.on[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Staircase corners: a pixel whose neighbours stay connected without it (Yokoi's
|
||||
// 8-connectivity number 1) and that doesn't end the line.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for i in g.interior() {
|
||||
if !g.on[i] || g.degree(i) < 2 {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
// x1 = E, x2 = NE, x3 = N, x4 = NW, x5 = W, x6 = SW, x7 = S, x8 = SE.
|
||||
let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]];
|
||||
let off = |k: usize| !x[k % 8];
|
||||
let c8: usize = [0, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum();
|
||||
if c8 == 1 {
|
||||
g.on[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of skeleton pixels between two nodes (`None`: none, a ring).
|
||||
#[derive(Clone)]
|
||||
struct Run {
|
||||
pts: Vec<Point>,
|
||||
ends: [Option<usize>; 2],
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn length(&self) -> f64 {
|
||||
self.pts.windows(2).map(|p| p[0].distance(p[1])).sum::<f64>()
|
||||
+ if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
let xs = comp.iter().map(|&i| i % w);
|
||||
let ys = comp.iter().map(|&i| i / w);
|
||||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||||
let (gw, gh) = (x1 - x0 + 3, y1 - y0 + 3);
|
||||
let mut g = Grid { on: vec![false; gw * gh], w: gw, h: gh, x0: x0 as i64 - 1, y0: y0 as i64 - 1 };
|
||||
for &i in comp {
|
||||
g.on[(i / w - y0 + 1) * gw + (i % w - x0 + 1)] = true;
|
||||
}
|
||||
thin(&mut g);
|
||||
let skeleton: Vec<usize> = g.interior().filter(|&i| g.on[i]).collect();
|
||||
if skeleton.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// Nodes: line ends, and junctions with the junction pixels touching them.
|
||||
let mut node = vec![usize::MAX; g.on.len()];
|
||||
let mut centres: Vec<Point> = vec![];
|
||||
for &i in &skeleton {
|
||||
let d = g.degree(i);
|
||||
if d == 2 || node[i] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let id = centres.len();
|
||||
let mut members = vec![i];
|
||||
node[i] = id;
|
||||
if d > 2 {
|
||||
let mut k = 0;
|
||||
while let Some(&p) = members.get(k) {
|
||||
k += 1;
|
||||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||||
if node[q] == usize::MAX && g.degree(q) > 2 {
|
||||
node[q] = id;
|
||||
members.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sum = members.iter().fold(Point::ZERO, |s, &m| s + g.centre(m).to_vec2());
|
||||
centres.push(Point::new(sum.x / members.len() as f64, sum.y / members.len() as f64));
|
||||
}
|
||||
// Runs from each node, then the rings left.
|
||||
let mut used: HashSet<(usize, usize)> = HashSet::new();
|
||||
let edge = |a: usize, b: usize| (a.min(b), a.max(b));
|
||||
let mut runs: Vec<Run> = vec![];
|
||||
for &p in skeleton.iter().filter(|&&i| node[i] != usize::MAX) {
|
||||
for q in g.neighbours(p).collect::<Vec<_>>() {
|
||||
if node[q] == node[p] || !used.insert(edge(p, q)) {
|
||||
continue;
|
||||
}
|
||||
let mut pts = vec![centres[node[p]], g.centre(q)];
|
||||
let (mut prev, mut cur) = (p, q);
|
||||
while node[cur] == usize::MAX {
|
||||
let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) else { break };
|
||||
used.insert(edge(cur, next));
|
||||
pts.push(g.centre(next));
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
let end = (node[cur] != usize::MAX).then_some(node[cur]);
|
||||
if let (Some(e), Some(last)) = (end, pts.last_mut()) {
|
||||
*last = centres[e];
|
||||
}
|
||||
runs.push(Run { pts, ends: [Some(node[p]), end], closed: false });
|
||||
}
|
||||
}
|
||||
for &s in &skeleton {
|
||||
if node[s] != usize::MAX || g.neighbours(s).any(|n| used.contains(&edge(s, n))) {
|
||||
continue;
|
||||
}
|
||||
let mut pts = vec![g.centre(s)];
|
||||
let (mut prev, mut cur) = (usize::MAX, s);
|
||||
let mut closed = false;
|
||||
while let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) {
|
||||
used.insert(edge(cur, next));
|
||||
if next == s {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
pts.push(g.centre(next));
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
runs.push(Run { pts, ends: [None, None], closed });
|
||||
}
|
||||
// The line's mean width: its pixels over its length (the skeleton stops about half a width
|
||||
// short of each end).
|
||||
let width = |runs: &[Run]| {
|
||||
let length: f64 = runs.iter().map(Run::length).sum();
|
||||
let px = comp.len() as f64;
|
||||
((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0))
|
||||
};
|
||||
let spur = width(&runs);
|
||||
let runs = join(prune(runs, centres.len(), spur), centres.len());
|
||||
let mut runs = runs;
|
||||
let coarse = width(&runs);
|
||||
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
|
||||
// How many runs end at each node: one is an end of the line.
|
||||
let mut meet = vec![0usize; centres.len()];
|
||||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||||
meet[*e] += 1;
|
||||
}
|
||||
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
|
||||
for r in &mut runs {
|
||||
let n = r.pts.len();
|
||||
let old = r.pts.clone();
|
||||
for i in 0..n {
|
||||
// A junction stays where its pixels put it.
|
||||
let end = if i == 0 {
|
||||
r.ends[0]
|
||||
} else if i + 1 == n {
|
||||
r.ends[1]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if end.is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = if r.closed { (old[(i + n - 2 % n) % n], old[(i + 2) % n]) } else { (old[i.saturating_sub(2)], old[(i + 2).min(n - 1)]) };
|
||||
let d = b - a;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let upright = d.x.abs() >= d.y.abs();
|
||||
let Some(across) = ink.across(old[i], upright) else { continue };
|
||||
r.pts[i] = old[i] + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
|
||||
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
|
||||
read += 1;
|
||||
soft += usize::from(across.soft);
|
||||
}
|
||||
}
|
||||
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
|
||||
// wide as its pixels make it).
|
||||
widths.sort_by(f64::total_cmp);
|
||||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||||
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
|
||||
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
|
||||
// of the line's middle as along it, where a round end stops short by a seventh of the width.
|
||||
let (mut short_by, mut told) = (0.0, 0);
|
||||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||||
for k in 0..2 {
|
||||
if r.ends[k].is_some_and(|e| meet[e] > 1) {
|
||||
continue;
|
||||
}
|
||||
let n = r.pts.len();
|
||||
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
|
||||
// The last points of a wide line's skeleton turn towards a corner of its end: the end
|
||||
// lies where the line was going a width back, and they are put on that line.
|
||||
let back = (width.round() as usize + 2).max(3);
|
||||
let (tip, inner) = (r.pts[at(0)], r.pts[at(back)]);
|
||||
// (Only an end that has left that line: over so few pixels the line's direction is known
|
||||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||||
// better left where it is.)
|
||||
let before = inner - r.pts[at(2 * back)];
|
||||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||||
let d = if wide { before } else { tip - inner };
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let along = d / d.hypot();
|
||||
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
|
||||
if wide {
|
||||
for i in 0..back {
|
||||
r.pts[at(i)] = on_line(r.pts[at(i)]);
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
|
||||
short_by += short / width;
|
||||
told += 1;
|
||||
}
|
||||
let more = reach - width / 2.0;
|
||||
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
|
||||
r.pts[at(0)] = tip + along * more;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut style = *style;
|
||||
if read > 0 && soft * 4 < read {
|
||||
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 2.5;
|
||||
style.sigma = 1.5;
|
||||
}
|
||||
let subs: Vec<_> = runs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
|
||||
})
|
||||
.collect();
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels: comp.len(), square: told > 0 && short_by / f64::from(told) < 0.07 })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
struct Ink<'a> {
|
||||
img: &'a Raster,
|
||||
/// The line's colour.
|
||||
color: [f64; 3],
|
||||
/// How far (pixels) from the skeleton the ink is looked for.
|
||||
reach: i64,
|
||||
}
|
||||
|
||||
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
|
||||
/// whether its edges are soft (some pixel is part ink, part paper).
|
||||
struct Across {
|
||||
middle: f64,
|
||||
width: f64,
|
||||
soft: bool,
|
||||
}
|
||||
|
||||
impl Ink<'_> {
|
||||
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
|
||||
fn far(&self, x: i64, y: i64) -> Option<f64> {
|
||||
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
|
||||
if x < 0 || y < 0 || x >= w || y >= h {
|
||||
return None;
|
||||
}
|
||||
let at = (y * w + x) as usize * 4;
|
||||
let p = self.img.rgba.get(at..at + 3)?;
|
||||
Some((0..3).map(|k| (f64::from(p[k]) - self.color[k]).powi(2)).sum::<f64>().sqrt())
|
||||
}
|
||||
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
|
||||
/// within reach, along its row and its column.
|
||||
fn paper(&self, x: i64, y: i64) -> f64 {
|
||||
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
|
||||
}
|
||||
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
|
||||
fn across(&self, p: Point, upright: bool) -> Option<Across> {
|
||||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||||
let paper = self.paper(x, y);
|
||||
// Paper too like the ink says nothing.
|
||||
if paper < 24.0 {
|
||||
return None;
|
||||
}
|
||||
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
|
||||
// off the line's darkest row) out to where the ink ends, or rises again (another line).
|
||||
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
|
||||
let here = tone(peak);
|
||||
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
|
||||
for side in [-1, 1] {
|
||||
let mut last = here;
|
||||
for k in 1..=self.reach {
|
||||
let t = tone(peak + side * k);
|
||||
if t < 0.04 || t > last + 0.1 {
|
||||
break;
|
||||
}
|
||||
sum += t;
|
||||
moment += t * (peak + side * k) as f64;
|
||||
soft |= t > 0.1 && t < 0.9;
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
|
||||
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
|
||||
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
|
||||
}
|
||||
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
|
||||
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
|
||||
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
|
||||
if paper < 24.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for step in 1..=4 * (self.reach + 2) {
|
||||
let q = tip + dir * (step as f64 * 0.25);
|
||||
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
if t < 0.04 {
|
||||
break;
|
||||
}
|
||||
sum += 0.25 * t;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
|
||||
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
|
||||
/// to a junction that keeps at least two other runs.
|
||||
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
|
||||
let mut degree = vec![0usize; nodes];
|
||||
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
|
||||
degree[*e] += 1;
|
||||
}
|
||||
let mut order: Vec<usize> = (0..runs.len()).collect();
|
||||
order.sort_by(|a, b| runs[*a].length().total_cmp(&runs[*b].length()));
|
||||
let mut keep = vec![true; runs.len()];
|
||||
for i in order {
|
||||
let r = &runs[i];
|
||||
let [Some(a), Some(b)] = r.ends else { continue };
|
||||
let (tip, fork) = if degree[a] == 1 { (a, b) } else { (b, a) };
|
||||
if degree[tip] == 1 && degree[fork] >= 3 && r.length() < width.max(2.0) {
|
||||
keep[i] = false;
|
||||
degree[tip] -= 1;
|
||||
degree[fork] -= 1;
|
||||
}
|
||||
}
|
||||
runs.into_iter().zip(keep).filter_map(|(r, k)| k.then_some(r)).collect()
|
||||
}
|
||||
|
||||
/// The runs joined through every node exactly two of them meet at (a run meeting itself closes).
|
||||
fn join(runs: Vec<Run>, nodes: usize) -> Vec<Run> {
|
||||
let mut runs: Vec<Option<Run>> = runs.into_iter().map(Some).collect();
|
||||
for n in 0..nodes {
|
||||
let at: Vec<(usize, usize)> = runs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, r)| r.iter().flat_map(move |r| (0..2).filter(move |&k| r.ends[k] == Some(n)).map(move |k| (i, k))))
|
||||
.collect();
|
||||
let [(i, ki), (j, kj)] = at[..] else { continue };
|
||||
if i == j {
|
||||
if let Some(r) = runs[i].as_mut() {
|
||||
r.pts.pop();
|
||||
r.ends = [None, None];
|
||||
r.closed = r.pts.len() >= 3;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (Some(mut a), Some(mut b)) = (runs[i].take(), runs[j].take()) else { continue };
|
||||
// a ends at n, b starts at n.
|
||||
if ki == 0 {
|
||||
a.pts.reverse();
|
||||
a.ends.reverse();
|
||||
}
|
||||
if kj == 1 {
|
||||
b.pts.reverse();
|
||||
b.ends.reverse();
|
||||
}
|
||||
a.pts.extend(b.pts.into_iter().skip(1));
|
||||
a.ends[1] = b.ends[1];
|
||||
runs[i] = Some(a);
|
||||
}
|
||||
runs.into_iter().flatten().collect()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Exact squared Euclidean distance, Felzenszwalb & Huttenlocher, "Distance Transforms of
|
||||
//! Sampled Functions", §3. Separable lower envelopes of parabolas, linear time and crop storage.
|
||||
|
||||
const FAR: f64 = 1e20;
|
||||
|
||||
/// Transform one row or column. Each envelope entry is (sample, left intersection).
|
||||
fn transform(f: &[f64], out: &mut [f64], envelope: &mut Vec<(usize, f64)>) -> Option<()> {
|
||||
envelope.clear();
|
||||
for (q, &fq) in f.iter().enumerate() {
|
||||
let mut left = -FAR;
|
||||
while let Some(&(v, z)) = envelope.last() {
|
||||
let (qf, vf) = (q as f64, v as f64);
|
||||
left = ((fq + qf * qf) - (*f.get(v)? + vf * vf)) / (2.0 * (qf - vf));
|
||||
if left > z {
|
||||
break;
|
||||
}
|
||||
envelope.pop();
|
||||
}
|
||||
if envelope.is_empty() {
|
||||
left = -FAR;
|
||||
}
|
||||
envelope.push((q, left));
|
||||
}
|
||||
let mut k = 0;
|
||||
for (q, d) in out.iter_mut().enumerate() {
|
||||
while envelope.get(k + 1).is_some_and(|&(_, z)| z < q as f64) {
|
||||
k += 1;
|
||||
}
|
||||
let &(v, _) = envelope.get(k)?;
|
||||
*d = (q as f64 - v as f64).powi(2) + *f.get(v)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Squared centre-to-paper distances for a validated, paper-bordered component crop.
|
||||
/// Border paper also represents the space outside the raster. Returns None for invalid sizes
|
||||
/// or a non-paper border, rather than accepting an unbounded allocation or an absent seed.
|
||||
pub(super) fn squared(mask: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
let size = super::crop_size(w, h)?;
|
||||
if mask.len() != size || mask.iter().enumerate().any(|(i, &on)| on && (i < w || i >= size - w || i % w == 0 || i % w + 1 == w)) {
|
||||
return None;
|
||||
}
|
||||
let mut grid: Vec<f64> = mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect();
|
||||
let n = w.max(h);
|
||||
let (mut f, mut d, mut envelope) = (vec![0.0; n], vec![0.0; n], Vec::with_capacity(n));
|
||||
for x in 0..w {
|
||||
for (y, v) in f.get_mut(..h)?.iter_mut().enumerate() {
|
||||
*v = *grid.get(y * w + x)?;
|
||||
}
|
||||
transform(f.get(..h)?, d.get_mut(..h)?, &mut envelope)?;
|
||||
for (y, &v) in d.get(..h)?.iter().enumerate() {
|
||||
*grid.get_mut(y * w + x)? = v;
|
||||
}
|
||||
}
|
||||
for row in grid.chunks_exact_mut(w) {
|
||||
for (v, &g) in f.get_mut(..w)?.iter_mut().zip(row.iter()) {
|
||||
*v = g;
|
||||
}
|
||||
transform(f.get(..w)?, d.get_mut(..w)?, &mut envelope)?;
|
||||
for (g, &v) in row.iter_mut().zip(d.get(..w)?) {
|
||||
*g = v;
|
||||
}
|
||||
}
|
||||
Some(grid.into_iter().map(|v| v as f32).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edt_equals_brute_force_on_random_masks() {
|
||||
let mut seed = 19u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..200 {
|
||||
let (w, h) = (3 + rnd() % 24, 3 + rnd() % 24);
|
||||
let mask: Vec<bool> =
|
||||
(0..w * h).map(|i| i >= w && i < w * (h - 1) && i % w != 0 && i % w + 1 < w && (case % 5 == 0 || rnd() % 4 != 0)).collect();
|
||||
let d = squared(&mask, w, h).unwrap();
|
||||
for (i, &actual) in d.iter().enumerate() {
|
||||
let expected = mask
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(_, &on)| !on)
|
||||
.map(|(j, _)| {
|
||||
let dx = (i % w).abs_diff(j % w);
|
||||
let dy = (i / w).abs_diff(j / w);
|
||||
(dx * dx + dy * dy) as f32
|
||||
})
|
||||
.min_by(f32::total_cmp)
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected, "case {case}, pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_crops_are_refused() {
|
||||
assert!(squared(&[], 0, 0).is_none());
|
||||
assert!(squared(&[], usize::MAX, 3).is_none());
|
||||
assert!(squared(&[false; 9], 4, 3).is_none());
|
||||
assert!(squared(&[true; 9], 3, 3).is_none());
|
||||
assert!(squared(&[], crate::MAX_PIXELS as usize + 3, 3).is_none());
|
||||
assert_eq!(squared(&[false; 9], 3, 3), Some(vec![0.0; 9]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
//! The skeleton as a graph: nodes (line ends, junctions) and branches (pixel runs between them),
|
||||
//! with spur pruning and junction merging.
|
||||
//!
|
||||
//! Pruning follows the usual significance test for medial-axis branches (Blum 1967; see also
|
||||
//! Shaked & Bruckstein 1998, "Pruning medial axes", CVIU 69(2)): a terminal branch that reaches
|
||||
//! less than a threshold beyond the maximal disc of the junction it hangs from adds almost
|
||||
//! nothing to the shape the skeleton reconstructs, so it is a boundary artefact (a spur). The
|
||||
//! threshold is tied to the line width: `max(spur_min, spur_k · min(2 r, stroke_width))`.
|
||||
//! Two junctions whose maximal discs overlap are one junction that thinning split in two (the
|
||||
//! crossing of two thick lines thins to two forks joined by a short bridge).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use kurbo::Vec2;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::Grid;
|
||||
|
||||
const MAX_BRANCHES: usize = 200_000;
|
||||
const MAX_NODES: usize = 200_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Node {
|
||||
/// Position (pixel centre of its deepest pixel, or a merged junction's middle).
|
||||
pub pos: Point,
|
||||
/// Radius of the node's maximal disc (distance to the ink's edge), px.
|
||||
pub r: f64,
|
||||
/// A junction (thinning met three or more runs here) rather than a line end.
|
||||
pub junction: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Branch {
|
||||
/// Points from one node to the other (end points are the nodes' positions).
|
||||
pub pts: Vec<Point>,
|
||||
/// Distance transform at each point (px, centre to nearest paper centre).
|
||||
pub d: Vec<f64>,
|
||||
/// End nodes; both `None` for a ring.
|
||||
pub ends: [Option<usize>; 2],
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl Branch {
|
||||
pub(super) fn length(&self) -> f64 {
|
||||
self.pts.windows(2).map(|w| w[0].distance(w[1])).sum::<f64>()
|
||||
+ if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct Graph {
|
||||
pub nodes: Vec<Node>,
|
||||
pub branches: Vec<Branch>,
|
||||
}
|
||||
|
||||
/// Build one paper-bordered crop. Distances use this same crop and are squared.
|
||||
/// Refuse an over-budget graph in full so the caller can retain the blob as a fill.
|
||||
pub(super) fn build(g: &Grid, dist: &[f32]) -> Option<Graph> {
|
||||
build_with_limits(g, dist, MAX_NODES, MAX_BRANCHES)
|
||||
}
|
||||
|
||||
fn build_with_limits(g: &Grid, dist: &[f32], max_nodes: usize, max_branches: usize) -> Option<Graph> {
|
||||
let n = super::crop_size(g.w, g.h)?;
|
||||
if g.on.len() != n
|
||||
|| dist.len() != n
|
||||
|| dist.iter().any(|d| !d.is_finite() || *d < 0.0)
|
||||
|| g.on.iter().enumerate().any(|(i, &on)| on && !g.is_interior(i))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let dval = |i: usize| f64::from(dist.get(i).copied().unwrap_or(0.0)).sqrt();
|
||||
let deg: Vec<u8> = (0..n).map(|i| if g.on.get(i) == Some(&true) { g.degree(i) as u8 } else { 0 }).collect();
|
||||
let mut node_of = vec![usize::MAX; n];
|
||||
let mut graph = Graph::default();
|
||||
// Ends on their own; touching junction pixels together, deepest pixel first on ties.
|
||||
for i in g.interior() {
|
||||
let degree = *deg.get(i)?;
|
||||
if g.on.get(i) != Some(&true) || degree == 2 || *node_of.get(i)? != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
// Isolated pixels cannot form a stroke; leave them to the existing fill path.
|
||||
if degree == 0 {
|
||||
continue;
|
||||
}
|
||||
if graph.nodes.len() >= max_nodes.min(MAX_NODES) {
|
||||
return None;
|
||||
}
|
||||
let id = graph.nodes.len();
|
||||
*node_of.get_mut(i)? = id;
|
||||
let mut members = vec![i];
|
||||
if degree > 2 {
|
||||
let mut k = 0;
|
||||
while let Some(&p) = members.get(k) {
|
||||
k += 1;
|
||||
for q in g.neighbours(p) {
|
||||
if *node_of.get(q)? == usize::MAX && *deg.get(q)? > 2 {
|
||||
*node_of.get_mut(q)? = id;
|
||||
members.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = members.into_iter().max_by(|&a, &b| dval(a).total_cmp(&dval(b)).then(b.cmp(&a)))?;
|
||||
graph.nodes.push(Node { pos: g.centre(best), r: (dval(best) - 0.5).max(0.5), junction: degree > 2 });
|
||||
}
|
||||
let mut seen_edge = BTreeSet::new();
|
||||
let mut visited = vec![false; n];
|
||||
for p in g.interior() {
|
||||
let a = *node_of.get(p)?;
|
||||
if a == usize::MAX {
|
||||
continue;
|
||||
}
|
||||
for q in g.neighbours(p) {
|
||||
let b = *node_of.get(q)?;
|
||||
if b == a {
|
||||
continue;
|
||||
}
|
||||
if b != usize::MAX {
|
||||
if seen_edge.insert((p.min(q), p.max(q))) {
|
||||
graph.push_branch(
|
||||
Branch {
|
||||
pts: vec![graph.nodes.get(a)?.pos, graph.nodes.get(b)?.pos],
|
||||
d: vec![dval(p), dval(q)],
|
||||
ends: [Some(a), Some(b)],
|
||||
closed: false,
|
||||
},
|
||||
max_branches,
|
||||
)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *visited.get(q)? {
|
||||
continue;
|
||||
}
|
||||
let mut px = vec![p];
|
||||
let (mut prev, mut cur) = (p, q);
|
||||
let mut guard = n;
|
||||
while *node_of.get(cur)? == usize::MAX && guard > 0 {
|
||||
guard -= 1;
|
||||
*visited.get_mut(cur)? = true;
|
||||
px.push(cur);
|
||||
let Some(next) = g.neighbours(cur).find(|&m| m != prev && (visited.get(m) == Some(&false) || node_of.get(m) != Some(&usize::MAX)))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
if guard == 0 {
|
||||
return None;
|
||||
}
|
||||
let end = (*node_of.get(cur)? != usize::MAX).then_some(*node_of.get(cur)?);
|
||||
if end.is_some() {
|
||||
px.push(cur);
|
||||
}
|
||||
let mut pts: Vec<Point> = px.iter().map(|&i| g.centre(i)).collect();
|
||||
let d = px.into_iter().map(dval).collect();
|
||||
*pts.first_mut()? = graph.nodes.get(a)?.pos;
|
||||
if let Some(e) = end {
|
||||
*pts.last_mut()? = graph.nodes.get(e)?.pos;
|
||||
}
|
||||
graph.push_branch(Branch { pts, d, ends: [Some(a), end], closed: false }, max_branches)?;
|
||||
}
|
||||
}
|
||||
// Rings never reached from a node.
|
||||
for s in g.interior() {
|
||||
if g.on.get(s) != Some(&true) || deg.get(s) != Some(&2) || *visited.get(s)? || *node_of.get(s)? != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let mut px = vec![];
|
||||
let (mut prev, mut cur) = (usize::MAX, s);
|
||||
let mut closed = false;
|
||||
let mut guard = n;
|
||||
while guard > 0 {
|
||||
guard -= 1;
|
||||
*visited.get_mut(cur)? = true;
|
||||
px.push(cur);
|
||||
let Some(next) = g.neighbours(cur).find(|&m| m != prev && visited.get(m) == Some(&false)) else {
|
||||
closed = g.neighbours(cur).any(|m| m == s) && px.len() > 2;
|
||||
break;
|
||||
};
|
||||
(prev, cur) = (cur, next);
|
||||
}
|
||||
if guard == 0 {
|
||||
return None;
|
||||
}
|
||||
graph.push_branch(
|
||||
Branch { pts: px.iter().map(|&i| g.centre(i)).collect(), d: px.into_iter().map(dval).collect(), ends: [None, None], closed },
|
||||
max_branches,
|
||||
)?;
|
||||
}
|
||||
Some(graph)
|
||||
}
|
||||
|
||||
impl Graph {
|
||||
fn push_branch(&mut self, branch: Branch, limit: usize) -> Option<()> {
|
||||
if self.branches.len() >= limit.min(MAX_BRANCHES) {
|
||||
return None;
|
||||
}
|
||||
self.branches.push(branch);
|
||||
Some(())
|
||||
}
|
||||
pub(super) fn degrees(&self) -> Option<Vec<usize>> {
|
||||
let mut deg = vec![0usize; self.nodes.len()];
|
||||
for b in &self.branches {
|
||||
for e in b.ends.iter().flatten() {
|
||||
*deg.get_mut(*e)? += 1;
|
||||
}
|
||||
}
|
||||
Some(deg)
|
||||
}
|
||||
|
||||
/// Removes spurs, drops tiny self-loops, merges junctions whose discs overlap and joins runs
|
||||
/// through nodes left with two of them; repeated until nothing changes (at most 32 rounds).
|
||||
pub(super) fn simplify(&mut self, stroke_width: f64) -> Option<()> {
|
||||
if !stroke_width.is_finite() || stroke_width <= 0.0 || self.nodes.len() > MAX_NODES || self.branches.len() > MAX_BRANCHES {
|
||||
return None;
|
||||
}
|
||||
let (spur_min, spur_k) = (2.0, 0.5);
|
||||
for _ in 0..32 {
|
||||
let mut changed = self.prune(spur_min, spur_k, stroke_width)?;
|
||||
changed |= self.merge_junctions()?;
|
||||
changed |= self.join_through()?;
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Nodes with a single branch end are line ends now.
|
||||
let deg = self.degrees()?;
|
||||
for (node, d) in self.nodes.iter_mut().zip(deg) {
|
||||
if d <= 1 {
|
||||
node.junction = false;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn prune(&mut self, spur_min: f64, spur_k: f64, stroke_width: f64) -> Option<bool> {
|
||||
let mut deg = self.degrees()?;
|
||||
let mut order: Vec<usize> = (0..self.branches.len()).collect();
|
||||
let lens: Vec<f64> = self.branches.iter().map(Branch::length).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
lens.get(a).copied().unwrap_or(f64::INFINITY).total_cmp(&lens.get(b).copied().unwrap_or(f64::INFINITY)).then(a.cmp(&b))
|
||||
});
|
||||
let mut keep = vec![true; self.branches.len()];
|
||||
for i in order {
|
||||
let b = self.branches.get(i)?;
|
||||
match b.ends {
|
||||
[Some(a), Some(c)] if a == c => {
|
||||
// A loop on one node: a real hole unless it lies inside the node's disc.
|
||||
let r = self.nodes.get(a).map_or(0.0, |n| n.r);
|
||||
if *lens.get(i)? < 2.0 * r + 2.0 {
|
||||
*keep.get_mut(i)? = false;
|
||||
let degree = deg.get_mut(a)?;
|
||||
*degree = degree.saturating_sub(2);
|
||||
}
|
||||
}
|
||||
[Some(a), Some(c)] => {
|
||||
let (tip, fork) = if *deg.get(a)? == 1 { (a, c) } else { (c, a) };
|
||||
if *deg.get(tip)? != 1 || *deg.get(fork)? < 3 {
|
||||
continue;
|
||||
}
|
||||
let r = self.nodes.get(fork).map_or(0.0, |n| n.r);
|
||||
let tau = spur_min.max(spur_k * (2.0 * r).min(stroke_width));
|
||||
if *lens.get(i)? < r + tau {
|
||||
*keep.get_mut(i)? = false;
|
||||
*deg.get_mut(tip)? = deg.get(tip)?.saturating_sub(1);
|
||||
*deg.get_mut(fork)? = deg.get(fork)?.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let before = self.branches.len();
|
||||
let mut k = keep.into_iter();
|
||||
self.branches.retain(|_| k.next().unwrap_or(true));
|
||||
Some(self.branches.len() != before)
|
||||
}
|
||||
|
||||
/// Direction leaving node `node` along branch `b` (unit), from the point `reach` px along it.
|
||||
fn leaving(&self, b: &Branch, node: usize, reach: f64) -> Option<Vec2> {
|
||||
let pts: Vec<Point> = if b.ends[0] == Some(node) { b.pts.clone() } else { b.pts.iter().rev().copied().collect() };
|
||||
let first = *pts.first()?;
|
||||
let mut acc = 0.0;
|
||||
let mut far = *pts.last()?;
|
||||
for w in pts.windows(2) {
|
||||
acc += w[0].distance(w[1]);
|
||||
if acc >= reach {
|
||||
far = w[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
{
|
||||
let v = far - first;
|
||||
let len = v.hypot();
|
||||
(len > 1e-12 && len.is_finite()).then(|| v / len)
|
||||
}
|
||||
}
|
||||
|
||||
/// For each node, the (branch, end) pairs that end there.
|
||||
fn ends_index(&self) -> Option<Vec<Vec<(usize, usize)>>> {
|
||||
let mut idx = vec![vec![]; self.nodes.len()];
|
||||
for (i, b) in self.branches.iter().enumerate() {
|
||||
for k in 0..2 {
|
||||
if let Some(n) = b.ends.get(k).copied().flatten() {
|
||||
idx.get_mut(n)?.push((i, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(idx)
|
||||
}
|
||||
|
||||
/// Two forks to merge into one junction: joined by a bridge shorter than their discs'
|
||||
/// radii plus 2 px (pixel quantisation), or the crossing of two lines that thinning split in
|
||||
/// two (degree 3 each, the bridge no longer than `8 r + 6`, and the other two runs at one fork
|
||||
/// continuing the other two at the other, each pair within 25° of straight on).
|
||||
/// All non-conflicting pairs are merged in one pass, shortest bridges first.
|
||||
fn merge_junctions(&mut self) -> Option<bool> {
|
||||
let idx = self.ends_index()?;
|
||||
let deg: Vec<usize> = idx.iter().map(Vec::len).collect();
|
||||
let mut cand: Vec<(f64, usize)> = vec![];
|
||||
for (i, b) in self.branches.iter().enumerate() {
|
||||
let [Some(a), Some(c)] = b.ends else { continue };
|
||||
if a != c && *deg.get(a)? >= 3 && *deg.get(c)? >= 3 {
|
||||
cand.push((b.length(), i));
|
||||
}
|
||||
}
|
||||
cand.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1)));
|
||||
let mut used = vec![false; self.nodes.len()];
|
||||
let mut merges = vec![];
|
||||
for (l, i) in cand {
|
||||
let b = self.branches.get(i)?;
|
||||
let [Some(a), Some(c)] = b.ends else { continue };
|
||||
if *used.get(a)? || *used.get(c)? {
|
||||
continue;
|
||||
}
|
||||
let (ra, rc) = (self.nodes.get(a)?.r, self.nodes.get(c)?.r);
|
||||
let mut merge = l < ra + rc + 2.0;
|
||||
if !merge && *deg.get(a)? == 3 && *deg.get(c)? == 3 && l <= 8.0 * ra.max(rc) + 6.0 {
|
||||
let reach = 3.0 * ra.max(rc) + 4.0;
|
||||
let arms = |n: usize| -> Vec<Vec2> {
|
||||
idx.get(n)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(j, _)| *j != i)
|
||||
.filter_map(|&(j, _)| self.branches.get(j))
|
||||
.filter(|o| o.ends[0] != o.ends[1])
|
||||
.filter_map(|o| self.leaving(o, n, reach))
|
||||
.collect()
|
||||
};
|
||||
let (aa, cc) = (arms(a), arms(c));
|
||||
if let ([a1, a2], [c1, c2]) = (aa.as_slice(), cc.as_slice()) {
|
||||
let straight = |u: &Vec2, v: &Vec2| u.dot(*v) < -(25f64.to_radians().cos());
|
||||
merge = (straight(a1, c1) && straight(a2, c2)) || (straight(a1, c2) && straight(a2, c1));
|
||||
}
|
||||
}
|
||||
if merge {
|
||||
*used.get_mut(a)? = true;
|
||||
*used.get_mut(c)? = true;
|
||||
merges.push((i, a, c));
|
||||
}
|
||||
}
|
||||
if merges.is_empty() {
|
||||
return Some(false);
|
||||
}
|
||||
let mut remove = vec![false; self.branches.len()];
|
||||
for &(i, a, c) in &merges {
|
||||
*remove.get_mut(i)? = true;
|
||||
let mid = self.branches.get(i)?.pts.get(self.branches.get(i)?.pts.len() / 2).copied().unwrap_or(self.nodes.get(a)?.pos);
|
||||
let r = self.nodes.get(a)?.r.max(self.nodes.get(c)?.r);
|
||||
self.nodes.get_mut(a)?.pos = mid;
|
||||
self.nodes.get_mut(a)?.r = r;
|
||||
for &(j, k) in idx.get(a)?.iter().chain(idx.get(c)?) {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
let Some(br) = self.branches.get_mut(j) else {
|
||||
continue;
|
||||
};
|
||||
*br.ends.get_mut(k)? = Some(a);
|
||||
let last = br.pts.len().saturating_sub(1);
|
||||
if let Some(p) = br.pts.get_mut(if k == 0 { 0 } else { last }) {
|
||||
*p = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = remove.into_iter();
|
||||
self.branches.retain(|_| !k.next().unwrap_or(false));
|
||||
Some(true)
|
||||
}
|
||||
|
||||
fn join_through(&mut self) -> Option<bool> {
|
||||
let mut idx = self.ends_index()?;
|
||||
let mut slots: Vec<Option<Branch>> = std::mem::take(&mut self.branches).into_iter().map(Some).collect();
|
||||
let mut changed = false;
|
||||
for node in 0..self.nodes.len() {
|
||||
let at: Vec<(usize, usize)> = idx
|
||||
.get(node)?
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(i, k)| slots.get(i).and_then(Option::as_ref).is_some_and(|b| b.ends.get(k) == Some(&Some(node))))
|
||||
.collect();
|
||||
let &[(i, ki), (j, kj)] = at.as_slice() else { continue };
|
||||
changed = true;
|
||||
if i == j {
|
||||
if let Some(b) = slots.get_mut(i).and_then(Option::as_mut) {
|
||||
b.pts.pop();
|
||||
b.d.pop();
|
||||
b.ends = [None, None];
|
||||
b.closed = b.pts.len() >= 3;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (Some(mut a), Some(mut other)) = (slots.get_mut(i)?.take(), slots.get_mut(j)?.take()) else {
|
||||
continue;
|
||||
};
|
||||
// Orient: a ends at the node, other starts at it.
|
||||
if ki == 0 {
|
||||
a.pts.reverse();
|
||||
a.d.reverse();
|
||||
a.ends.reverse();
|
||||
}
|
||||
if kj == 1 {
|
||||
other.pts.reverse();
|
||||
other.d.reverse();
|
||||
other.ends.reverse();
|
||||
}
|
||||
a.pts.extend(other.pts.into_iter().skip(1));
|
||||
a.d.extend(other.d.into_iter().skip(1));
|
||||
a.ends[1] = other.ends[1];
|
||||
// The joined branch is slot i: re-point both far ends' index entries.
|
||||
for e in a.ends {
|
||||
if let Some(v) = e.and_then(|n| idx.get_mut(n)) {
|
||||
v.retain(|&(b, _)| b != i && b != j);
|
||||
}
|
||||
}
|
||||
for (k, e) in a.ends.iter().enumerate() {
|
||||
if let Some(v) = e.and_then(|n| idx.get_mut(n)) {
|
||||
v.push((i, k));
|
||||
}
|
||||
}
|
||||
*slots.get_mut(i)? = Some(a);
|
||||
}
|
||||
self.branches = slots.into_iter().flatten().collect();
|
||||
Some(changed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::centerline::{distance, thin};
|
||||
|
||||
fn raw_graph(arms: &[(Point, Point)], width: f64) -> (Grid, Vec<f32>) {
|
||||
let (w, h) = (180, 180);
|
||||
let mask: Vec<_> = (0..w * h)
|
||||
.map(|i| {
|
||||
let p = Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
arms.iter().any(|&(a, b)| {
|
||||
let v = b - a;
|
||||
let t = ((p - a).dot(v) / v.hypot2()).clamp(0.0, 1.0);
|
||||
p.distance(a + v * t) <= width / 2.0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut grid = Grid::from_mask(&mask, w, h).unwrap();
|
||||
let dist = distance::squared(&grid.on, grid.w, grid.h).unwrap();
|
||||
thin::thin(&mut grid);
|
||||
(grid, dist)
|
||||
}
|
||||
|
||||
fn graph(arms: &[(Point, Point)], width: f64) -> Graph {
|
||||
let (g, dist) = raw_graph(arms, width);
|
||||
let mut graph = build(&g, &dist).unwrap();
|
||||
graph.simplify(13.0).unwrap();
|
||||
graph
|
||||
}
|
||||
|
||||
fn arms(degrees: &[f64]) -> Vec<(Point, Point)> {
|
||||
let c = Point::new(90.27, 90.41);
|
||||
degrees
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let a = a.to_radians();
|
||||
(c, c + Vec2::new(a.cos(), a.sin()) * 65.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_degrees(g: &Graph, expected: &[usize]) {
|
||||
let mut degrees: Vec<_> = g.degrees().unwrap().into_iter().filter(|&d| d > 0).collect();
|
||||
degrees.sort_unstable();
|
||||
assert_eq!(degrees, expected, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_y_x_oblique_t_and_narrow_y_have_their_junction_degrees() {
|
||||
for width in [2.0, 4.0, 8.0] {
|
||||
for (name, angles) in [
|
||||
("T", vec![0.0, 90.0, 180.0]),
|
||||
("Y", vec![90.0, 210.0, 330.0]),
|
||||
("X", vec![45.0, 135.0, 225.0, 315.0]),
|
||||
("oblique T", vec![17.0, 107.0, 197.0]),
|
||||
("narrow Y", vec![90.0, 255.0, 285.0]),
|
||||
] {
|
||||
let g = graph(&arms(&angles), width);
|
||||
let mut want = vec![1; angles.len()];
|
||||
want.push(angles.len());
|
||||
eprintln!("{name}, width {width}");
|
||||
assert_degrees(&g, &want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_twenty_to_ninety_degrees_merge_into_one_four_way_junction() {
|
||||
for width in [2.0, 4.0, 8.0] {
|
||||
for angle in (20..=90).step_by(10) {
|
||||
let g = graph(&arms(&[0.0, 180.0, angle as f64, angle as f64 + 180.0]), width);
|
||||
eprintln!("crossing {angle}, width {width}");
|
||||
assert_degrees(&g, &[1, 1, 1, 1, 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_at_twenty_four_angles_have_no_spurs() {
|
||||
let c = Point::new(90.27, 90.41);
|
||||
for width in [1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0] {
|
||||
for angle in 0..24 {
|
||||
let a = (angle as f64 * 15.0).to_radians();
|
||||
let v = Vec2::new(a.cos(), a.sin()) * 65.0;
|
||||
let g = graph(&[(c - v, c + v)], width);
|
||||
assert_eq!(g.branches.len(), 1, "width {width}, angle {angle}");
|
||||
assert_degrees(&g, &[1, 1]);
|
||||
assert!(g.branches[0].length() > 110.0);
|
||||
assert!(!g.branches[0].closed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_blobs_never_panic_and_build_the_same_graph_twice() {
|
||||
let mut seed = 73u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..200 {
|
||||
let (w, h) = (3 + rnd() % 45, 3 + rnd() % 45);
|
||||
let blobs: Vec<_> = (0..1 + rnd() % 8).map(|_| (rnd() % w, rnd() % h, 1 + rnd() % 12)).collect();
|
||||
let mask: Vec<_> =
|
||||
(0..w * h).map(|i| blobs.iter().any(|&(x, y, r)| (i % w).abs_diff(x).pow(2) + (i / w).abs_diff(y).pow(2) <= r * r)).collect();
|
||||
let mut g = Grid::from_mask(&mask, w, h).unwrap();
|
||||
let dist = distance::squared(&g.on, g.w, g.h).unwrap();
|
||||
thin::thin(&mut g);
|
||||
let mut a = build(&g, &dist).unwrap();
|
||||
let mut b = build(&g, &dist).unwrap();
|
||||
assert_eq!(a, b, "raw graph, case {case}");
|
||||
a.simplify(13.0).unwrap();
|
||||
b.simplify(13.0).unwrap();
|
||||
assert_eq!(a, b, "simplified graph, case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_crossings_merge_only_when_outer_arms_continue_in_pairs() {
|
||||
let a = Point::new(20.0, 30.0);
|
||||
let b = Point::new(34.0, 30.0);
|
||||
let points = [a, b, a + Vec2::new(-20.0, -6.0), a + Vec2::new(-20.0, 6.0), b + Vec2::new(20.0, -6.0), b + Vec2::new(20.0, 6.0)];
|
||||
let mut g = Graph {
|
||||
nodes: points.iter().enumerate().map(|(i, &pos)| Node { pos, r: 1.0, junction: i < 2 }).collect(),
|
||||
branches: [(0, 1), (0, 2), (0, 3), (1, 4), (1, 5)]
|
||||
.into_iter()
|
||||
.map(|(a, b)| Branch {
|
||||
pts: vec![points[a], points[a].lerp(points[b], 0.5), points[b]],
|
||||
d: vec![1.5; 3],
|
||||
ends: [Some(a), Some(b)],
|
||||
closed: false,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// The bridge is 14 px: too long for the 4 px disc-overlap threshold.
|
||||
let mut bent = g.clone();
|
||||
bent.branches[4].pts = vec![b, b + Vec2::new(-20.0, 20.0)];
|
||||
assert_eq!(bent.merge_junctions(), Some(false));
|
||||
assert_eq!(g.merge_junctions(), Some(true));
|
||||
assert_degrees(&g, &[1, 1, 1, 1, 4]);
|
||||
assert_eq!(g.nodes[0].pos, Point::new(27.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_keeps_significant_arms_and_real_holes() {
|
||||
let centre = Point::new(20.0, 20.0);
|
||||
let mut g = Graph { nodes: vec![Node { pos: centre, r: 3.0, junction: true }], branches: vec![] };
|
||||
for (n, length) in [2.0, 8.0, 9.0].into_iter().enumerate() {
|
||||
let end = centre + Vec2::new(length, 0.0);
|
||||
g.nodes.push(Node { pos: end, r: 1.0, junction: false });
|
||||
g.branches.push(Branch { pts: vec![centre, end], d: vec![3.5, 1.5], ends: [Some(0), Some(n + 1)], closed: false });
|
||||
}
|
||||
// r + max(2, 0.5 * min(6, 13)) = 6: only the 2 px arm is a spur.
|
||||
assert_eq!(g.prune(2.0, 0.5, 13.0), Some(true));
|
||||
assert_eq!(g.branches.len(), 2);
|
||||
let mut loop_graph = Graph { nodes: vec![g.nodes[0].clone()], branches: vec![] };
|
||||
for side in [1.0, 5.0] {
|
||||
loop_graph.branches.push(Branch {
|
||||
pts: vec![centre, centre + Vec2::new(side, 0.0), centre + Vec2::new(side, side), centre],
|
||||
d: vec![3.5; 4],
|
||||
ends: [Some(0), Some(0)],
|
||||
closed: false,
|
||||
});
|
||||
}
|
||||
assert_eq!(loop_graph.prune(2.0, 0.5, 13.0), Some(true));
|
||||
assert_eq!(loop_graph.branches.len(), 1);
|
||||
assert!(loop_graph.branches[0].length() > 8.0);
|
||||
loop_graph.join_through().unwrap();
|
||||
assert!(loop_graph.branches[0].closed);
|
||||
assert_eq!(loop_graph.branches[0].ends, [None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_and_branch_limits_refuse_the_whole_graph() {
|
||||
let (g, dist) = raw_graph(&arms(&[0.0, 90.0, 180.0]), 4.0);
|
||||
assert!(build_with_limits(&g, &dist, 1, MAX_BRANCHES).is_none());
|
||||
assert!(build_with_limits(&g, &dist, MAX_NODES, 1).is_none());
|
||||
assert!(build(&g, &[]).is_none());
|
||||
let mut invalid = dist.clone();
|
||||
invalid[0] = f32::NAN;
|
||||
assert!(build(&g, &invalid).is_none());
|
||||
invalid[0] = -1.0;
|
||||
assert!(build(&g, &invalid).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked
|
||||
//! centre lines instead of filled outlines.
|
||||
//!
|
||||
//! 1. **Distance**: an exact Euclidean distance transform in each blob crop gives each pixel
|
||||
//! its distance to the nearest pixel outside the blob.
|
||||
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
|
||||
//! one) is within the stroke width is a line; wider ones stay filled areas.
|
||||
//! 3. **Skeleton**: Guo–Hall thins the crop to one pixel, preserving diagonal ends, then
|
||||
//! staircase corners that don't connect anything are dropped.
|
||||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||||
//! placed at their deepest pixel) become polylines; rings become closed ones. Spurs are
|
||||
//! measured beyond each junction's maximal disc. Overlapping or split-crossing junctions
|
||||
//! merge, and runs join through nodes left with two ends.
|
||||
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
|
||||
//! of the line's ink, read from the image: across the line (along the pixel column or row most
|
||||
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
|
||||
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
|
||||
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
|
||||
//! median of those widths, and each end is put where the ink ends, less the half width a round
|
||||
//! cap adds.
|
||||
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
|
||||
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
|
||||
|
||||
mod distance;
|
||||
mod graph;
|
||||
mod thin;
|
||||
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::Raster;
|
||||
use crate::fit;
|
||||
use crate::logo::Style;
|
||||
|
||||
/// One line of a colour layer: its centre lines and the width to stroke them with.
|
||||
pub(crate) struct Line {
|
||||
pub path: PathData,
|
||||
/// Stroke width in pixels.
|
||||
pub width: f64,
|
||||
pub pixels: usize,
|
||||
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
|
||||
pub square: bool,
|
||||
}
|
||||
|
||||
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
|
||||
/// pixels, and which pixels they cover (to leave out of the filled areas).
|
||||
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
|
||||
/// image's own black), which the lines' middles and widths are read against.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn lines(
|
||||
labels: &[u16],
|
||||
label: u16,
|
||||
w: usize,
|
||||
h: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
) -> (Vec<bool>, Vec<Line>) {
|
||||
let mut out = vec![];
|
||||
let Some(n) = w.checked_mul(h).filter(|&n| n > 0 && n as u64 <= crate::MAX_PIXELS && n == labels.len()) else { return (vec![], out) };
|
||||
let mut thin = vec![false; n];
|
||||
if w == 0 || h == 0 || !max_width.is_finite() || max_width <= 0.0 || !ink.iter().all(|v| v.is_finite()) {
|
||||
return (thin, out);
|
||||
}
|
||||
let mask = |i: usize| labels.get(i) == Some(&label);
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![];
|
||||
for start in 0..w * h {
|
||||
if !mask(start) || seen.get(start).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let mut comp = vec![];
|
||||
if let Some(v) = seen.get_mut(start) {
|
||||
*v = true;
|
||||
}
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
comp.push(i);
|
||||
for j in neighbours(i, w, h) {
|
||||
if mask(j) && !seen.get(j).copied().unwrap_or(false) {
|
||||
if let Some(v) = seen.get_mut(j) {
|
||||
*v = true;
|
||||
}
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(g) = Grid::from_component(&comp, w) else { continue };
|
||||
let Some(dt) = distance::squared(&g.on, g.w, g.h) else { continue };
|
||||
let widest = 2.0 * f64::from(dt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0;
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(g, &dt, comp.len(), max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
if let Some(v) = thin.get_mut(i) {
|
||||
*v = true;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
(thin, out)
|
||||
}
|
||||
|
||||
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
|
||||
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
|
||||
AROUND.into_iter().filter_map(move |(dx, dy)| {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
(nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize)
|
||||
})
|
||||
}
|
||||
|
||||
/// A valid crop is at most the raster limit plus a one-pixel paper border.
|
||||
fn crop_size(w: usize, h: usize) -> Option<usize> {
|
||||
if w < 3 || h < 3 {
|
||||
return None;
|
||||
}
|
||||
let inner = (w - 2).checked_mul(h - 2)?;
|
||||
if inner as u64 > crate::MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
w.checked_mul(h).filter(|&n| n <= isize::MAX as usize)
|
||||
}
|
||||
|
||||
/// A grid around one line: its pixels with a one-pixel empty border.
|
||||
struct Grid {
|
||||
on: Vec<bool>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Image position of grid pixel (0, 0).
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn from_component(comp: &[usize], image_width: usize) -> Option<Self> {
|
||||
if image_width == 0 {
|
||||
return None;
|
||||
}
|
||||
let xs = comp.iter().map(|&i| i % image_width);
|
||||
let ys = comp.iter().map(|&i| i / image_width);
|
||||
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
|
||||
let (w, h) = (x1.checked_sub(x0)?.checked_add(3)?, y1.checked_sub(y0)?.checked_add(3)?);
|
||||
let n = crop_size(w, h)?;
|
||||
let mut g = Self { on: vec![false; n], w, h, x0: x0 as i64 - 1, y0: y0 as i64 - 1 };
|
||||
for &i in comp {
|
||||
*g.on.get_mut((i / image_width - y0 + 1) * w + i % image_width - x0 + 1)? = true;
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn from_mask(mask: &[bool], w: usize, h: usize) -> Option<Self> {
|
||||
if w.checked_mul(h)? != mask.len() {
|
||||
return None;
|
||||
}
|
||||
let comp: Vec<_> = mask.iter().enumerate().filter_map(|(i, &on)| on.then_some(i)).collect();
|
||||
Self::from_component(&comp, w)
|
||||
}
|
||||
fn offsets(&self) -> [isize; 8] {
|
||||
let w = self.w as isize;
|
||||
[-w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1]
|
||||
}
|
||||
fn is_interior(&self, i: usize) -> bool {
|
||||
self.w > 0 && i < self.on.len() && !i.is_multiple_of(self.w) && i % self.w + 1 < self.w && i / self.w > 0 && i / self.w + 1 < self.h
|
||||
}
|
||||
/// N, NE, E, SE, S, SW, W, NW, with paper outside the interior.
|
||||
fn ring(&self, i: usize) -> [bool; 8] {
|
||||
if !self.is_interior(i) {
|
||||
return [false; 8];
|
||||
}
|
||||
self.offsets().map(|o| self.on.get(i.wrapping_add_signed(o)).copied().unwrap_or(false))
|
||||
}
|
||||
fn interior(&self) -> impl Iterator<Item = usize> + use<> {
|
||||
let (w, h) = (self.w, self.h);
|
||||
(1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x))
|
||||
}
|
||||
fn neighbours(&self, i: usize) -> impl Iterator<Item = usize> + '_ {
|
||||
self.offsets().into_iter().map(move |o| i.wrapping_add_signed(o)).filter(move |&j| self.is_interior(i) && self.on.get(j) == Some(&true))
|
||||
}
|
||||
fn degree(&self, i: usize) -> usize {
|
||||
self.neighbours(i).count()
|
||||
}
|
||||
/// The centre of pixel `i` in image coordinates.
|
||||
fn centre(&self, i: usize) -> Point {
|
||||
Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
thin::thin(&mut g);
|
||||
let mut graph = graph::build(&g, dist)?;
|
||||
graph.simplify(max_width)?;
|
||||
let meet = graph.degrees()?;
|
||||
let mut runs = graph.branches;
|
||||
if runs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The line's mean width: its pixels over its length (the skeleton stops about half a width
|
||||
// short of each end).
|
||||
let width = |runs: &[graph::Branch]| {
|
||||
let length: f64 = runs.iter().map(graph::Branch::length).sum();
|
||||
let px = pixels as f64;
|
||||
((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0))
|
||||
};
|
||||
let coarse = width(&runs);
|
||||
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
|
||||
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
|
||||
for r in &mut runs {
|
||||
let n = r.pts.len();
|
||||
let old = r.pts.clone();
|
||||
for i in 0..n {
|
||||
// A junction stays where its pixels put it.
|
||||
let end = if i == 0 {
|
||||
r.ends[0]
|
||||
} else if i + 1 == n {
|
||||
r.ends[1]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if end.is_some_and(|e| meet.get(e).is_some_and(|&n| n > 1)) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = if r.closed {
|
||||
(*old.get((i + n - 2 % n) % n)?, *old.get((i + 2) % n)?)
|
||||
} else {
|
||||
(*old.get(i.saturating_sub(2))?, *old.get((i + 2).min(n - 1))?)
|
||||
};
|
||||
let d = b - a;
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let upright = d.x.abs() >= d.y.abs();
|
||||
let Some(across) = ink.across(*old.get(i)?, upright) else { continue };
|
||||
*r.pts.get_mut(i)? = *old.get(i)? + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
|
||||
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
|
||||
read += 1;
|
||||
soft += usize::from(across.soft);
|
||||
}
|
||||
}
|
||||
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
|
||||
// wide as its pixels make it).
|
||||
widths.sort_by(f64::total_cmp);
|
||||
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
|
||||
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
|
||||
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
|
||||
// of the line's middle as along it, where a round end stops short by a seventh of the width.
|
||||
let (mut short_by, mut told) = (0.0, 0);
|
||||
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
|
||||
for k in 0..2 {
|
||||
if r.ends.get(k).copied().flatten().is_some_and(|e| meet.get(e).is_some_and(|&n| n > 1)) {
|
||||
continue;
|
||||
}
|
||||
let n = r.pts.len();
|
||||
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
|
||||
// The last points of a wide line's skeleton turn towards a corner of its end: the end
|
||||
// lies where the line was going a width back, and they are put on that line.
|
||||
let back = (width.round().clamp(0.0, n as f64) as usize + 2).max(3);
|
||||
let (tip, inner) = (*r.pts.get(at(0))?, *r.pts.get(at(back))?);
|
||||
// (Only an end that has left that line: over so few pixels the line's direction is known
|
||||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||||
// better left where it is.)
|
||||
let before = inner - *r.pts.get(at(2 * back))?;
|
||||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||||
let d = if wide { before } else { tip - inner };
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let along = d / d.hypot();
|
||||
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
|
||||
if wide {
|
||||
for i in 0..back {
|
||||
let point = r.pts.get_mut(at(i))?;
|
||||
*point = on_line(*point);
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
|
||||
short_by += short / width;
|
||||
told += 1;
|
||||
}
|
||||
let more = reach - width / 2.0;
|
||||
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
|
||||
*r.pts.get_mut(at(0))? = tip + along * more;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut style = *style;
|
||||
if read > 0 && soft * 4 < read {
|
||||
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
|
||||
style.tol *= 2.5;
|
||||
style.line_tol *= 2.5;
|
||||
style.sigma = 1.5;
|
||||
}
|
||||
let subs: Vec<_> = runs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
|
||||
})
|
||||
.collect();
|
||||
if subs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line { path: PathData::new(subs), width, pixels, square: told > 0 && short_by / f64::from(told) < 0.07 })
|
||||
}
|
||||
|
||||
/// What the image says of a line's ink.
|
||||
struct Ink<'a> {
|
||||
img: &'a Raster,
|
||||
/// The line's colour.
|
||||
color: [f64; 3],
|
||||
/// How far (pixels) from the skeleton the ink is looked for.
|
||||
reach: i64,
|
||||
}
|
||||
|
||||
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
|
||||
/// whether its edges are soft (some pixel is part ink, part paper).
|
||||
struct Across {
|
||||
middle: f64,
|
||||
width: f64,
|
||||
soft: bool,
|
||||
}
|
||||
|
||||
impl Ink<'_> {
|
||||
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
|
||||
fn far(&self, x: i64, y: i64) -> Option<f64> {
|
||||
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
|
||||
if x < 0 || y < 0 || x >= w || y >= h {
|
||||
return None;
|
||||
}
|
||||
let at = (y as usize).checked_mul(w as usize)?.checked_add(x as usize)?.checked_mul(4)?;
|
||||
let p = self.img.rgba.get(at..at.checked_add(3)?)?;
|
||||
Some(p.iter().zip(self.color).map(|(&v, c)| (f64::from(v) - c).powi(2)).sum::<f64>().sqrt())
|
||||
}
|
||||
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
|
||||
/// within reach, along its row and its column.
|
||||
fn paper(&self, x: i64, y: i64) -> f64 {
|
||||
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
|
||||
}
|
||||
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
|
||||
fn across(&self, p: Point, upright: bool) -> Option<Across> {
|
||||
if !p.x.is_finite() || !p.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||||
let paper = self.paper(x, y);
|
||||
// Paper too like the ink says nothing.
|
||||
if paper < 24.0 {
|
||||
return None;
|
||||
}
|
||||
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
|
||||
// off the line's darkest row) out to where the ink ends, or rises again (another line).
|
||||
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
|
||||
let here = tone(peak);
|
||||
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
|
||||
for side in [-1, 1] {
|
||||
let mut last = here;
|
||||
for k in 1..=self.reach {
|
||||
let t = tone(peak + side * k);
|
||||
if t < 0.04 || t > last + 0.1 {
|
||||
break;
|
||||
}
|
||||
sum += t;
|
||||
moment += t * (peak + side * k) as f64;
|
||||
soft |= t > 0.1 && t < 0.9;
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
|
||||
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
|
||||
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
|
||||
}
|
||||
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
|
||||
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
|
||||
if !tip.x.is_finite() || !tip.y.is_finite() || !dir.x.is_finite() || !dir.y.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
|
||||
if paper < 24.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for step in 1..=4 * (self.reach + 2) {
|
||||
let q = tip + dir * (step as f64 * 0.25);
|
||||
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
|
||||
if t < 0.04 {
|
||||
break;
|
||||
}
|
||||
sum += 0.25 * t;
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Guo–Hall A1 thinning (Guo & Hall 1989, doi:10.1145/62065.62074).
|
||||
//! Neighbourhoods are judged together in each sub-iteration; only changed ones are revisited.
|
||||
|
||||
use super::Grid;
|
||||
|
||||
/// Guo–Hall A1's test for pixel with neighbours `p` (P2..P9) in sub-iteration `step`.
|
||||
fn guo_hall_removable(p: &[bool; 8], step: usize) -> bool {
|
||||
let [p2, p3, p4, p5, p6, p7, p8, p9] = *p;
|
||||
let c = usize::from(!p2 && (p3 || p4)) + usize::from(!p4 && (p5 || p6)) + usize::from(!p6 && (p7 || p8)) + usize::from(!p8 && (p9 || p2));
|
||||
let n1 = usize::from(p9 || p2) + usize::from(p3 || p4) + usize::from(p5 || p6) + usize::from(p7 || p8);
|
||||
let n2 = usize::from(p2 || p3) + usize::from(p4 || p5) + usize::from(p6 || p7) + usize::from(p8 || p9);
|
||||
let m = if step == 0 { (p6 || p7 || !p9) && p8 } else { (p2 || p3 || !p5) && p4 };
|
||||
c == 1 && (2..=3).contains(&n1.min(n2)) && !m
|
||||
}
|
||||
|
||||
/// The Guo–Hall rounds alone (no staircase removal). Bounded: at most `w + h` rounds.
|
||||
///
|
||||
/// Only pixels whose neighbourhood changed since they were last judged in a sub-iteration are
|
||||
/// judged again (a pixel's removability depends on its 8 neighbours only): in sub-iteration 0,
|
||||
/// neighbours of every pixel removed in the last round; in sub-iteration 1, neighbours of those
|
||||
/// removed in the last round's sub-iteration 1 and this round's sub-iteration 0. The result is
|
||||
/// the plain algorithm's (tested against it below).
|
||||
fn guo_hall_rounds(g: &mut Grid) {
|
||||
let n = g.on.len();
|
||||
let ring = g.offsets();
|
||||
let mut mark = vec![0u32; n];
|
||||
let mut stamp = 0u32;
|
||||
let mut around = |g: &Grid, removed: &[&[usize]]| -> Vec<usize> {
|
||||
// Two stamps per round; the crop cap keeps this well below u32::MAX.
|
||||
stamp += 1;
|
||||
let mut out = vec![];
|
||||
for &t in removed.iter().flat_map(|r| r.iter()) {
|
||||
for o in ring {
|
||||
let i = t.wrapping_add_signed(o);
|
||||
if i < n && g.on.get(i) == Some(&true) && g.is_interior(i) && mark.get(i) != Some(&stamp) {
|
||||
if let Some(m) = mark.get_mut(i) {
|
||||
*m = stamp;
|
||||
}
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
};
|
||||
let (mut prev0, mut prev1): (Vec<usize>, Vec<usize>) = (vec![], vec![]);
|
||||
for round in 0..g.w + g.h {
|
||||
let cand0: Vec<usize> =
|
||||
if round == 0 { (0..n).filter(|&i| g.on.get(i) == Some(&true) && g.is_interior(i)).collect() } else { around(g, &[&prev0, &prev1]) };
|
||||
let rem0: Vec<usize> = cand0.into_iter().filter(|&i| guo_hall_removable(&g.ring(i), 0)).collect();
|
||||
for &i in &rem0 {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
}
|
||||
let cand1: Vec<usize> =
|
||||
if round == 0 { (0..n).filter(|&i| g.on.get(i) == Some(&true) && g.is_interior(i)).collect() } else { around(g, &[&prev1, &rem0]) };
|
||||
let rem1: Vec<usize> = cand1.into_iter().filter(|&i| guo_hall_removable(&g.ring(i), 1)).collect();
|
||||
for &i in &rem1 {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
}
|
||||
if rem0.is_empty() && rem1.is_empty() {
|
||||
break;
|
||||
}
|
||||
(prev0, prev1) = (rem0, rem1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thins `g` in place (Guo–Hall, then staircase removal); see the module docs.
|
||||
pub(super) fn thin(g: &mut Grid) {
|
||||
guo_hall_rounds(g);
|
||||
staircase(g);
|
||||
}
|
||||
|
||||
/// Removes staircase pixels (see the module docs). Sequential, in scan order, until stable
|
||||
/// (at most 4 passes).
|
||||
fn staircase(g: &mut Grid) {
|
||||
for _ in 0..4 {
|
||||
let mut changed = false;
|
||||
for i in 0..g.on.len() {
|
||||
// Three or more neighbours: a corner of a staircase always has them, while a pixel
|
||||
// with two touching neighbours may be the end of a line two pixels thick (removing
|
||||
// it would eat that line from its end, pixel by pixel).
|
||||
if g.on.get(i) != Some(&true) || g.degree(i) < 3 {
|
||||
continue;
|
||||
}
|
||||
let p = g.ring(i);
|
||||
// Yokoi's x1..x8 = E, NE, N, NW, W, SW, S, SE.
|
||||
let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]];
|
||||
let off = |k: usize| !x.get(k % 8).copied().unwrap_or(false);
|
||||
let c8: usize = [0usize, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum();
|
||||
if c8 == 1 {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn skel(w: usize, h: usize, f: impl Fn(usize, usize) -> bool) -> Grid {
|
||||
let m: Vec<bool> = (0..w * h).map(|i| f(i % w, i / w)).collect();
|
||||
let mut g = Grid::from_mask(&m, w, h).unwrap();
|
||||
thin(&mut g);
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_thin_to_lines() {
|
||||
for thick in 1..7 {
|
||||
let g = skel(40, 12, |x, y| (3..37).contains(&x) && (3..3 + thick).contains(&y));
|
||||
let on: Vec<usize> = (0..g.on.len()).filter(|&i| g.on[i]).collect();
|
||||
assert!(on.len() >= 20, "thick {thick}: {} pixels", on.len());
|
||||
// Corner spurs may remain at the square ends (the graph prunes them).
|
||||
assert!(on.iter().filter(|&&i| g.degree(i) == 1).count() >= 2, "thick {thick}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonal_lines_two_to_five_pixels_thick_keep_their_length() {
|
||||
for thick in 2..6usize {
|
||||
for mirror in [false, true] {
|
||||
let g = skel(60, 60, |x, y| {
|
||||
let x = if mirror { 59 - x } else { x };
|
||||
(5..55).contains(&y) && x + 1 >= y && x < y + thick
|
||||
});
|
||||
let on: Vec<usize> = (0..g.on.len()).filter(|&i| g.on[i]).collect();
|
||||
let (lo, hi) = (on.iter().map(|&i| i / g.w).min().unwrap(), on.iter().map(|&i| i / g.w).max().unwrap());
|
||||
assert!(hi - lo >= 46, "thick {thick}, mirror {mirror}: shortened to {} rows", hi - lo + 1);
|
||||
assert!(on.iter().all(|&i| g.degree(i) <= 2), "thick {thick}: not one pixel wide");
|
||||
assert_eq!(on.iter().filter(|&&i| g.degree(i) == 1).count(), 2);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut stack = vec![on[0]];
|
||||
while let Some(i) = stack.pop() {
|
||||
if seen.insert(i) {
|
||||
stack.extend(g.neighbours(i));
|
||||
}
|
||||
}
|
||||
assert_eq!(seen.len(), on.len(), "disconnected diagonal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain Guo–Hall: every pixel judged every sub-iteration.
|
||||
fn reference(g: &mut Grid) {
|
||||
loop {
|
||||
let mut any = false;
|
||||
for step in 0..2 {
|
||||
let picked: Vec<usize> =
|
||||
(0..g.on.len()).filter(|&i| g.on.get(i) == Some(&true) && g.is_interior(i) && guo_hall_removable(&g.ring(i), step)).collect();
|
||||
any |= !picked.is_empty();
|
||||
for i in picked {
|
||||
if let Some(on) = g.on.get_mut(i) {
|
||||
*on = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_set_gives_the_plain_algorithms_result() {
|
||||
let mut seed = 11u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..200 {
|
||||
let (w, h) = (3 + rnd() % 40, 3 + rnd() % 40);
|
||||
let blobs: Vec<(usize, usize, usize)> = (0..1 + rnd() % 6).map(|_| (rnd() % w, rnd() % h, 1 + rnd() % 9)).collect();
|
||||
let m: Vec<bool> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
blobs.iter().any(|&(bx, by, r)| (x - bx as i64).pow(2) + (y - by as i64).pow(2) <= (r * r) as i64)
|
||||
})
|
||||
.collect();
|
||||
let (mut a, mut b) = (Grid::from_mask(&m, w, h).unwrap(), Grid::from_mask(&m, w, h).unwrap());
|
||||
guo_hall_rounds(&mut a);
|
||||
reference(&mut b);
|
||||
assert!(a.on == b.on, "case {case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@
|
||||
# Image Trace quality
|
||||
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Create Strokes: maximal-disc pruning and junction merging; centre-lines rerun)
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Create Strokes: exact crop distances and Guo–Hall thinning; centre-lines rerun)
|
||||
> **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 +1073,147 @@ 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
|
||||
|
||||
Measured 2026-10-17 with maximal-disc pruning and junction merging, against base `3e44478f`.
|
||||
Earlier preset and cost tables retain their previous measurements. 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
|
||||
```
|
||||
|
||||
Junction clusters use their deepest pixel and a disc radius of `distance − 0.5` px. Terminal
|
||||
branches shorter than `r + max(2, 0.5 × min(2r, Stroke))` are pruned, shortest first. Tiny
|
||||
self-loops inside the junction disc are removed; overlapping junctions and geometrically split
|
||||
crossings merge, and runs join through degree-two nodes. Simplification is bounded to 32 rounds;
|
||||
nodes and branches are each capped at 200,000. An over-budget blob stays with the fill path.
|
||||
Whole-blob selection, ink refinement, cap detection and curve fitting retain their current rules.
|
||||
|
||||
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.09 / 0.16 | 2.64 / 30.00 | 90.9 | 0.05 / 0.13 | – | – |
|
||||
| lines w1 | hard | 92+0 | 0.18 / 0.38 | 1.93 / 5.01 | 82.3 | 0.14 / 1.00 | – | – |
|
||||
| lines w2 | aa | 24+0 | 0.11 / 0.27 | 0.16 / 0.29 | 100.0 | 0.03 / 0.06 | – | – |
|
||||
| lines w2 | hard | 24+0 | 0.22 / 0.38 | 0.25 / 0.42 | 100.0 | 0.18 / 0.59 | – | – |
|
||||
| lines w3 | aa | 24+0 | 0.09 / 0.21 | 0.15 / 0.27 | 100.0 | 0.04 / 0.09 | – | – |
|
||||
| lines w3 | hard | 24+0 | 0.20 / 0.42 | 0.24 / 0.46 | 100.0 | 0.22 / 0.54 | – | – |
|
||||
| lines w4 | aa | 24+0 | 0.09 / 0.20 | 0.15 / 0.26 | 100.0 | 0.06 / 0.12 | – | – |
|
||||
| lines w4 | hard | 24+0 | 0.20 / 0.42 | 0.24 / 0.46 | 100.0 | 0.12 / 0.46 | – | – |
|
||||
| lines w6 | aa | 24+0 | 0.10 / 0.22 | 0.15 / 0.26 | 100.0 | 0.09 / 0.19 | – | – |
|
||||
| lines w6 | hard | 24+0 | 0.20 / 0.40 | 0.24 / 0.43 | 100.0 | 0.24 / 0.40 | – | – |
|
||||
| lines w8 | aa | 24+0 | 0.11 / 0.26 | 0.16 / 0.29 | 100.0 | 0.13 / 0.26 | – | – |
|
||||
| lines w8 | hard | 24+0 | 0.23 / 0.56 | 0.26 / 0.59 | 100.0 | 0.13 / 0.49 | – | – |
|
||||
| lines w10 | aa | 24+0 | 0.11 / 0.24 | 0.16 / 0.29 | 100.0 | 0.16 / 0.32 | – | – |
|
||||
| lines w10 | hard | 24+0 | 0.24 / 0.64 | 0.27 / 0.67 | 99.9 | 0.24 / 0.61 | – | – |
|
||||
| lines w12 | aa | 24+0 | 0.12 / 0.30 | 0.17 / 0.32 | 100.0 | 0.20 / 0.39 | – | – |
|
||||
| lines w12 | hard | 24+0 | 0.24 / 0.60 | 0.27 / 0.61 | 99.9 | 0.25 / 0.52 | – | – |
|
||||
| curves w2 | aa | 4+0 | 0.11 / 0.22 | 0.15 / 0.26 | 100.0 | 0.01 / 0.01 | – | – |
|
||||
| curves w2 | hard | 4+0 | 0.22 / 0.55 | 0.25 / 0.57 | 100.0 | 0.05 / 0.06 | – | – |
|
||||
| curves w4 | aa | 4+0 | 0.12 / 0.25 | 0.16 / 0.28 | 99.9 | 0.01 / 0.03 | – | – |
|
||||
| curves w4 | hard | 4+0 | 0.24 / 0.57 | 0.26 / 0.56 | 99.9 | 0.00 / 0.00 | – | – |
|
||||
| curves w8 | aa | 4+0 | 0.16 / 0.30 | 0.22 / 0.34 | 98.7 | 0.02 / 0.06 | – | – |
|
||||
| curves w8 | hard | 4+0 | 0.26 / 0.61 | 0.29 / 0.63 | 98.8 | 0.00 / 0.00 | – | – |
|
||||
| circles | aa | 25+0 | 0.09 / 0.23 | 0.24 / 0.30 | 97.6 | 0.00 / 0.02 | – | – |
|
||||
| circles | hard | 23+0 | 0.27 / 0.64 | 0.45 / 0.75 | 96.5 | 0.02 / 0.10 | – | – |
|
||||
| junctions w2 | aa | 7+0 | 0.14 / 0.34 | 0.18 / 0.35 | 100.0 | 0.12 / 0.03 | – | – |
|
||||
| junctions w2 | hard | 7+0 | 0.36 / 0.82 | 0.40 / 0.85 | 100.0 | 0.14 / 0.12 | – | – |
|
||||
| junctions w4 | aa | 7+0 | 0.17 / 0.72 | 0.20 / 0.49 | 99.5 | 0.27 / 5.00 | – | – |
|
||||
| junctions w4 | hard | 7+0 | 0.42 / 1.11 | 0.42 / 0.98 | 99.2 | 0.30 / 5.00 | – | – |
|
||||
| junctions w8 | aa | 7+0 | 0.28 / 1.49 | 0.31 / 1.45 | 95.1 | 0.30 / 0.04 | – | – |
|
||||
| junctions w8 | hard | 7+0 | 0.47 / 1.36 | 0.50 / 1.47 | 95.1 | 0.33 / 0.22 | – | – |
|
||||
| crossings w3 | aa | 5+0 | 0.14 / 0.34 | 0.18 / 0.31 | 100.0 | 0.05 / 0.09 | – | – |
|
||||
| crossings w3 | hard | 5+0 | 0.39 / 1.14 | 0.40 / 0.93 | 99.9 | 0.20 / 0.32 | – | – |
|
||||
| crossings w6 | aa | 5+0 | 0.42 / 2.16 | 0.38 / 1.79 | 92.3 | 0.10 / 0.19 | – | – |
|
||||
| crossings w6 | hard | 5+0 | 0.57 / 2.27 | 0.54 / 1.95 | 90.9 | 0.25 / 0.36 | – | – |
|
||||
| dashed w2 | aa | 31+0 | 0.10 / 0.22 | 0.15 / 0.25 | 100.0 | 0.02 / 0.05 | – | – |
|
||||
| dashed w2 | hard | 31+0 | 0.14 / 0.36 | 0.18 / 0.38 | 100.0 | 0.09 / 0.34 | – | – |
|
||||
| dashed w4 | aa | 29+0 | 0.11 / 0.28 | 0.15 / 0.31 | 100.0 | 0.01 / 0.03 | – | – |
|
||||
| dashed w4 | hard | 29+0 | 0.15 / 0.42 | 0.19 / 0.41 | 100.0 | 0.11 / 0.46 | – | – |
|
||||
| letters w3 | aa | 7+0 | 0.19 / 0.86 | 0.22 / 0.62 | 98.7 | 0.01 / 0.13 | – | – |
|
||||
| letters w3 | hard | 7+0 | 0.33 / 0.92 | 0.34 / 0.71 | 99.0 | 0.06 / 0.20 | – | – |
|
||||
| letters w5 | aa | 7+0 | 0.27 / 1.49 | 0.29 / 1.31 | 95.9 | 0.02 / 0.20 | – | – |
|
||||
| letters w5 | hard | 7+0 | 0.40 / 1.48 | 0.41 / 1.29 | 96.2 | 0.05 / 0.20 | – | – |
|
||||
| mix | aa | 3+1 | 0.10 / 0.21 | 12.89 / 30.00 | 57.3 | 0.03 / 0.14 | – | – |
|
||||
| mix | hard | 3+1 | 0.22 / 0.43 | 12.95 / 30.00 | 57.3 | 0.08 / 0.15 | – | – |
|
||||
| varying | aa | 5+0 | 0.13 / 0.28 | 0.19 / 0.31 | 99.5 | 1.89 / 4.13 | – | – |
|
||||
| varying | hard | 5+0 | 0.20 / 0.51 | 0.24 / 0.52 | 99.7 | 1.90 / 4.22 | – | – |
|
||||
|
||||
### 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 / 76 | – | 43/48 | 0.71 (-0.69) | 88 | 0.790 / – | -6.2% / – | – | 186 | – | 4.6 |
|
||||
| lines w1 | hard | 0 / 0 / 0 / 0 | – | 42/48 | 1.50 (-1.42) | 142 | 0.644 / – | -25.8% / – | – | 184 | – | 3.6 |
|
||||
| lines w2 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.32 (-0.20) | 0 | 0.914 / – | -0.3% / – | – | 71 | – | 4.3 |
|
||||
| lines w2 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.38 (-0.10) | 0 | 0.808 / – | -2.0% / – | – | 58 | – | 4.2 |
|
||||
| lines w3 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.26 (-0.17) | 0 | 0.931 / – | -3.0% / – | – | 64 | – | 4.4 |
|
||||
| lines w3 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.46 (-0.24) | 0 | 0.881 / – | -2.5% / – | – | 50 | – | 4.3 |
|
||||
| lines w4 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.25 (-0.18) | 0 | 0.964 / – | -0.6% / – | – | 70 | – | 4.6 |
|
||||
| lines w4 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.40 (-0.15) | 0 | 0.906 / – | -0.1% / – | – | 54 | – | 4.5 |
|
||||
| lines w6 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.30 (-0.18) | 0 | 0.965 / – | -0.3% / – | – | 65 | – | 5.1 |
|
||||
| lines w6 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.45 (-0.12) | 0 | 0.923 / – | 1.4% / – | – | 55 | – | 4.9 |
|
||||
| lines w8 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.37 (-0.20) | 0 | 0.967 / – | -1.0% / – | – | 66 | – | 5.4 |
|
||||
| lines w8 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.46 (-0.18) | 0 | 0.934 / – | -0.3% / – | – | 53 | – | 5.4 |
|
||||
| lines w10 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.35 (-0.16) | 0 | 0.969 / – | -0.7% / – | – | 66 | – | 5.9 |
|
||||
| lines w10 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.50 (-0.12) | 0 | 0.942 / – | -0.1% / – | – | 52 | – | 5.9 |
|
||||
| lines w12 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.40 (-0.16) | 0 | 0.969 / – | -0.6% / – | – | 70 | – | 6.4 |
|
||||
| lines w12 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.54 (-0.11) | 0 | 0.949 / – | -0.2% / – | – | 53 | – | 6.3 |
|
||||
| curves w2 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.18 (-0.13) | 0 | 0.921 / – | -0.6% / – | – | 34 | – | 4.7 |
|
||||
| curves w2 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.22 (-0.05) | 0 | 0.821 / – | -2.4% / – | – | 29 | – | 4.7 |
|
||||
| curves w4 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.56 (-0.42) | 0 | 0.949 / – | -0.8% / – | – | 35 | – | 5.1 |
|
||||
| curves w4 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.66 (-0.38) | 0 | 0.904 / – | -0.5% / – | – | 30 | – | 5.2 |
|
||||
| curves w8 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 2.24 (-2.01) | 0 | 0.956 / – | -0.9% / – | – | 42 | – | 5.9 |
|
||||
| curves w8 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 1.97 (-1.67) | 0 | 0.936 / – | -0.5% / – | – | 31 | – | 5.9 |
|
||||
| circles | aa | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 32 | 0.964 / – | -0.9% / – | – | 71 | – | 7.4 |
|
||||
| circles | hard | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 28 | 0.888 / – | -0.7% / – | – | 52 | – | 8.0 |
|
||||
| junctions w2 | aa | 7 / 0 / 0 / 0 | 1.17 | 23/23 | 0.32 (-0.16) | 0 | 0.889 / – | 6.1% / – | – | 84 | – | 3.7 |
|
||||
| junctions w2 | hard | 7 / 0 / 0 / 0 | 1.17 | 23/23 | 0.49 (-0.15) | 0 | 0.771 / – | 4.9% / – | – | 59 | – | 3.6 |
|
||||
| junctions w4 | aa | 7 / 0 / 0 / 1 | 1.62 | 23/23 | 0.33 (-0.26) | 0 | 0.919 / – | 5.0% / – | – | 100 | – | 4.1 |
|
||||
| junctions w4 | hard | 7 / 0 / 0 / 1 | 1.62 | 23/23 | 0.65 (-0.32) | 0 | 0.850 / – | 7.0% / – | – | 81 | – | 4.0 |
|
||||
| junctions w8 | aa | 7 / 0 / 0 / 2 | 2.76 | 23/23 | 0.43 (-0.27) | 0 | 0.937 / – | 2.7% / – | – | 93 | – | 4.7 |
|
||||
| junctions w8 | hard | 7 / 0 / 0 / 1 | 2.76 | 23/23 | 0.80 (-0.35) | 0 | 0.913 / – | 2.5% / – | – | 77 | – | 4.7 |
|
||||
| crossings w3 | aa | 5 / 0 / 0 / 2 | 0.25 | 20/20 | 0.27 (-0.18) | 0 | 0.935 / – | -1.1% / – | – | 74 | – | 2.8 |
|
||||
| crossings w3 | hard | 5 / 0 / 0 / 5 | 0.25 | 20/20 | 0.41 (-0.17) | 0 | 0.791 / – | -7.6% / – | – | 69 | – | 2.8 |
|
||||
| crossings w6 | aa | 5 / 0 / 0 / 1 | 0.25 | 20/20 | 0.35 (-0.17) | 0 | 0.925 / – | -3.6% / – | – | 112 | – | 3.3 |
|
||||
| crossings w6 | hard | 5 / 0 / 0 / 4 | 0.25 | 20/20 | 0.51 (-0.20) | 0 | 0.872 / – | -2.0% / – | – | 81 | – | 3.4 |
|
||||
| dashed w2 | aa | 0 / 0 / 0 / 0 | – | 62/62 | 0.28 (-0.20) | 0 | 0.927 / – | -2.6% / – | – | 64 | – | 2.0 |
|
||||
| dashed w2 | hard | 0 / 0 / 0 / 0 | – | 62/62 | 0.33 (-0.11) | 0 | 0.926 / – | -3.6% / – | – | 67 | – | 1.9 |
|
||||
| dashed w4 | aa | 0 / 0 / 0 / 0 | – | 58/58 | 0.30 (-0.20) | 0 | 0.952 / – | -0.1% / – | – | 61 | – | 2.1 |
|
||||
| dashed w4 | hard | 0 / 0 / 0 / 0 | – | 58/58 | 0.37 (-0.08) | 0 | 0.944 / – | -1.5% / – | – | 60 | – | 2.1 |
|
||||
| letters w3 | aa | 7 / 0 / 0 / 6 | 1.47 | 17/17 | 0.62 (-0.47) | 1 | 0.923 / – | -0.9% / – | – | 114 | – | 3.3 |
|
||||
| letters w3 | hard | 7 / 0 / 0 / 3 | 1.47 | 17/17 | 0.38 (-0.21) | 1 | 0.859 / – | -0.6% / – | – | 81 | – | 3.3 |
|
||||
| letters w5 | aa | 6 / 1 / 0 / 6 | 2.10 | 17/17 | 0.99 (-0.83) | 0 | 0.929 / – | -1.7% / – | – | 124 | – | 3.7 |
|
||||
| letters w5 | hard | 6 / 1 / 0 / 8 | 2.10 | 17/17 | 1.05 (-0.79) | 0 | 0.887 / – | -2.0% / – | – | 100 | – | 3.7 |
|
||||
| mix | aa | 0 / 0 / 0 / 0 | – | 6/7 | 0.24 (-0.17) | 0 | 0.986 / – | 0.1% / – | 99.7 | 11 | – | 6.5 |
|
||||
| mix | hard | 0 / 0 / 0 / 0 | – | 6/7 | 0.57 (-0.46) | 0 | 0.969 / – | -0.0% / – | 99.1 | 11 | – | 6.4 |
|
||||
| varying | aa | 0 / 0 / 0 / 0 | – | 10/10 | 2.03 (-1.08) | 0 | 0.735 / – | 4.8% / – | – | 22 | – | 5.2 |
|
||||
| varying | hard | 0 / 0 / 0 / 0 | – | 10/10 | 1.92 (-0.94) | 0 | 0.736 / – | 5.2% / – | – | 22 | – | 5.3 |
|
||||
|
||||
**aa totals**: junctions 44 correct, 1 wrong degree, 0 missed, 94 extra (of 45); ends 682 of 688; spurs 121.
|
||||
|
||||
**hard totals**: junctions 44 correct, 1 wrong degree, 0 missed, 22 extra (of 45); ends 681 of 688; spurs 171.
|
||||
|
||||
**Scope of the improvement:** correct junction degrees rise from 30/45 to 44/45 in each variant;
|
||||
missed junctions fall from two to zero. Extra junctions fall from 104 to 94 aa and 27 to 22 hard.
|
||||
Spur totals remain 121 aa / 171 hard: the one-pixel-line and circle scenes contribute 120 / 170
|
||||
of those, with unchanged path counts (one-pixel lines: 93 aa / 92 hard; circles: 25 aa / 23 hard).
|
||||
The graph stage does not reconnect separate blobs. Raw graph tests cover straight lines at 24
|
||||
angles, junctions and 20–90° crossings; these do not replace the emitted-path measurements above.
|
||||
The remaining spur/extra-junction target is not met by this graph-only change.
|
||||
|
||||
## Revision history
|
||||
|
||||
| date | change |
|
||||
|:--|:--|
|
||||
| 2026-10-17 | M12: maximal-disc graph pruning, split-crossing merges and degree-two joins; correct junction degrees 30→44/45 aa and hard, none missed; remaining spurs unchanged. Centre-lines rerun, scores and earlier tables unchanged. |
|
||||
| 2026-10-17 | M12: exact per-blob Euclidean distances and Guo–Hall thinning; centre-lines rerun on 46 inputs. Ends found: aa 638→682/688, hard 631→681/688. Whole-blob stroke/fill choice retained; preset/cost tables unchanged. |
|
||||
| 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). |
|
||||
|
||||
+5
-2
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