M12: build stroke skeleton graphs with junction merging

Place junction clusters at their deepest pixel and prune terminal branches
by significance beyond their maximal discs. Merge overlapping junctions
and split crossings with continuing outer arms, then join degree-two runs.
Bound nodes, branches and simplification work, using checked data access.

Keep whole-blob selection, ink refinement, cap detection and fitting rules.
Test junction degrees, crossings, 24-angle lines, pruning, graph caps and
random-blob determinism. Record improved junction degrees and remaining
spur and extra-junction limitations in the centre-lines measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: OpenAI <noreply@openai.com>
This commit is contained in:
andeecollard
2026-10-17 19:37:31 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5 OpenAI
parent abb7c1351b
commit 0594ffe30f
5 changed files with 722 additions and 231 deletions
+632
View File
@@ -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());
}
}
+14 -171
View File
@@ -8,9 +8,9 @@
//! 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
//! 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.
//! 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
@@ -22,10 +22,9 @@
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
mod distance;
mod graph;
mod thin;
use std::collections::HashSet;
use vectorcraft_geom::{PathData, Point};
use crate::Raster;
@@ -88,11 +87,11 @@ pub(crate) fn lines(
}
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.into_iter().fold(0.0f32, f32::max)).sqrt() - 1.0;
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, comp.len(), max_width, img, ink, style) else { 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;
@@ -189,113 +188,25 @@ impl Grid {
}
}
/// 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(mut g: Grid, pixels: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
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 skeleton: Vec<usize> = g.interior().filter(|&i| g.on.get(i) == Some(&true)).collect();
if skeleton.len() < 2 {
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;
}
// 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.get(i) != Some(&usize::MAX) {
continue;
}
let id = centres.len();
let mut members = vec![i];
*node.get_mut(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.get(q)? == usize::MAX && g.degree(q) > 2 {
*node.get_mut(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.get(i) != Some(&usize::MAX)) {
for q in g.neighbours(p).collect::<Vec<_>>() {
if *node.get(q)? == *node.get(p)? || !used.insert(edge(p, q)) {
continue;
}
let mut pts = vec![*centres.get(*node.get(p)?)?, g.centre(q)];
let (mut prev, mut cur) = (p, q);
while *node.get(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.get(cur)? != usize::MAX).then_some(*node.get(cur)?);
if let (Some(e), Some(last)) = (end, pts.last_mut()) {
*last = *centres.get(e)?;
}
runs.push(Run { pts, ends: [Some(*node.get(p)?), end], closed: false });
}
}
for &s in &skeleton {
if *node.get(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 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 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.get_mut(*e)? += 1;
}
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
for r in &mut runs {
let n = r.pts.len();
@@ -486,71 +397,3 @@ impl Ink<'_> {
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()) {
if let Some(d) = degree.get_mut(*e) {
*d += 1;
}
}
let mut order: Vec<usize> = (0..runs.len()).collect();
order.sort_by(|a, b| runs.get(*a).map_or(0.0, Run::length).total_cmp(&runs.get(*b).map_or(0.0, Run::length)));
let mut keep = vec![true; runs.len()];
for i in order {
let Some(r) = runs.get(i) else { continue };
let [Some(a), Some(b)] = r.ends else { continue };
let (tip, fork) = if degree.get(a).copied().unwrap_or(0) == 1 { (a, b) } else { (b, a) };
if degree.get(tip).copied().unwrap_or(0) == 1 && degree.get(fork).copied().unwrap_or(0) >= 3 && r.length() < width.max(2.0) {
if let Some(k) = keep.get_mut(i) {
*k = false;
}
if let Some(d) = degree.get_mut(tip) {
*d = d.saturating_sub(1);
}
if let Some(d) = degree.get_mut(fork) {
*d = d.saturating_sub(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.get(k) == Some(&Some(n))).map(move |k| (i, k))))
.collect();
let &[(i, ki), (j, kj)] = at.as_slice() else { continue };
if i == j {
if let Some(r) = runs.get_mut(i).and_then(Option::as_mut) {
r.pts.pop();
r.ends = [None, None];
r.closed = r.pts.len() >= 3;
}
continue;
}
let (Some(mut a), Some(mut b)) = (runs.get_mut(i).and_then(Option::take), runs.get_mut(j).and_then(Option::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];
if let Some(r) = runs.get_mut(i) {
*r = Some(a);
}
}
runs.into_iter().flatten().collect()
}
+70 -57
View File
@@ -1,5 +1,6 @@
# 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)
@@ -1074,16 +1075,19 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
## Centre lines
Measured 2026-10-17 with exact crop EDT and Guo–Hall thinning, against base `124b319b`.
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
```
The whole-blob stroke/fill rule remains `2 × maximum distance − 1 ≤ Stroke`; exact distances
replace the chamfer estimate. Guo–Hall preserves diagonal ends; the staircase pass keeps pixels
with fewer than three neighbours. The graph, ink refinement, caps and fitting retain their rules.
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).
@@ -1117,24 +1121,24 @@ Distances use dense exact centre samples (≤0.25 px), emitted curves sampled at
| 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.33 | 0.18 / 0.35 | 100.0 | 0.12 / 0.03 | – | – |
| junctions w2 | hard | 7+0 | 0.39 / 0.87 | 0.43 / 0.91 | 100.0 | 0.15 / 0.12 | – | – |
| junctions w4 | aa | 7+0 | 0.17 / 0.64 | 0.20 / 0.46 | 99.6 | 0.27 / 5.00 | – | – |
| junctions w4 | hard | 7+0 | 0.41 / 1.10 | 0.42 / 0.98 | 99.2 | 0.30 / 5.00 | – | – |
| junctions w8 | aa | 7+0 | 0.29 / 1.52 | 0.31 / 1.44 | 95.3 | 0.30 / 0.04 | – | – |
| junctions w8 | hard | 7+0 | 0.47 / 1.42 | 0.50 / 1.44 | 95.2 | 0.33 / 0.22 | – | – |
| crossings w3 | aa | 5+0 | 0.17 / 0.71 | 0.22 / 0.69 | 100.0 | 0.05 / 0.09 | – | – |
| crossings w3 | hard | 5+0 | 0.42 / 1.07 | 0.44 / 0.94 | 99.8 | 0.20 / 0.32 | – | – |
| crossings w6 | aa | 5+0 | 0.39 / 2.12 | 0.38 / 1.74 | 92.9 | 0.10 / 0.19 | – | – |
| crossings w6 | hard | 5+0 | 0.56 / 2.27 | 0.53 / 1.88 | 91.4 | 0.23 / 0.36 | – | – |
| 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.18 / 0.84 | 0.21 / 0.55 | 98.9 | 0.01 / 0.13 | – | – |
| letters w3 | hard | 7+0 | 0.31 / 0.87 | 0.31 / 0.71 | 99.3 | 0.06 / 0.20 | – | – |
| letters w5 | aa | 7+0 | 0.27 / 1.48 | 0.29 / 1.29 | 96.0 | 0.02 / 0.20 | – | – |
| letters w5 | hard | 7+0 | 0.40 / 1.53 | 0.40 / 1.27 | 96.2 | 0.05 / 0.20 | – | – |
| 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 | – | – |
@@ -1144,61 +1148,70 @@ Distances use dense exact centre samples (≤0.25 px), emitted curves sampled at
| 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 | – | 6.0 |
| lines w1 | hard | 0 / 0 / 0 / 0 | – | 42/48 | 1.50 (-1.42) | 142 | 0.644 / – | -25.8% / – | – | 184 | – | 3.7 |
| 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.3 |
| lines w3 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.26 (-0.17) | 0 | 0.931 / – | -3.0% / – | – | 64 | – | 4.5 |
| 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.7 |
| lines w4 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.40 (-0.15) | 0 | 0.906 / – | -0.1% / – | – | 54 | – | 4.6 |
| 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 | – | 5.0 |
| lines w8 | aa | 0 / 0 / 0 / 0 | – | 48/48 | 0.37 (-0.20) | 0 | 0.967 / – | -1.0% / – | – | 66 | – | 5.5 |
| lines w8 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.46 (-0.18) | 0 | 0.934 / – | -0.3% / – | – | 53 | – | 5.5 |
| 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.5 |
| lines w12 | hard | 0 / 0 / 0 / 0 | – | 48/48 | 0.54 (-0.11) | 0 | 0.949 / – | -0.2% / – | – | 53 | – | 6.5 |
| curves w2 | aa | 0 / 0 / 0 / 0 | – | 8/8 | 0.18 (-0.13) | 0 | 0.921 / – | -0.6% / – | – | 34 | – | 4.6 |
| curves w2 | hard | 0 / 0 / 0 / 0 | – | 8/8 | 0.22 (-0.05) | 0 | 0.821 / – | -2.4% / – | – | 29 | – | 4.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 | – | 6.0 |
| circles | aa | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 32 | 0.964 / – | -0.9% / – | – | 71 | – | 7.5 |
| circles | hard | 0 / 0 / 0 / 0 | – | 0/0 | – (–) | 28 | 0.888 / – | -0.7% / – | – | 52 | – | 8.3 |
| junctions w2 | aa | 6 / 1 / 0 / 1 | 1.26 | 23/23 | 0.32 (-0.16) | 0 | 0.887 / – | 5.9% / – | – | 91 | – | 3.8 |
| junctions w2 | hard | 6 / 1 / 0 / 1 | 1.26 | 23/23 | 0.49 (-0.15) | 0 | 0.772 / – | 6.2% / – | – | 60 | – | 3.9 |
| junctions w4 | aa | 6 / 1 / 0 / 2 | 1.85 | 23/23 | 0.33 (-0.26) | 0 | 0.916 / – | 5.1% / – | – | 101 | – | 4.4 |
| junctions w4 | hard | 6 / 1 / 0 / 2 | 1.85 | 23/23 | 0.65 (-0.32) | 0 | 0.850 / – | 6.9% / – | – | 83 | – | 4.3 |
| junctions w8 | aa | 6 / 1 / 0 / 1 | 3.08 | 23/23 | 0.43 (-0.27) | 0 | 0.937 / – | 2.8% / – | – | 93 | – | 5.0 |
| junctions w8 | hard | 6 / 1 / 0 / 1 | 3.08 | 23/23 | 0.80 (-0.35) | 0 | 0.912 / – | 2.6% / – | – | 79 | – | 4.8 |
| crossings w3 | aa | 1 / 3 / 1 / 5 | 2.15 | 20/20 | 0.27 (-0.18) | 0 | 0.921 / – | -2.1% / – | – | 88 | – | 2.9 |
| crossings w3 | hard | 1 / 3 / 1 / 6 | 2.15 | 20/20 | 0.41 (-0.17) | 0 | 0.766 / – | -8.0% / – | – | 64 | – | 2.9 |
| crossings w6 | aa | 1 / 3 / 1 / 7 | 3.56 | 20/20 | 0.35 (-0.17) | 0 | 0.926 / – | -3.2% / – | – | 119 | – | 3.4 |
| crossings w6 | hard | 1 / 3 / 1 / 8 | 3.56 | 20/20 | 0.51 (-0.21) | 0 | 0.878 / – | -0.7% / – | – | 91 | – | 3.5 |
| dashed w2 | aa | 0 / 0 / 0 / 0 | – | 62/62 | 0.28 (-0.20) | 0 | 0.927 / – | -2.6% / – | – | 64 | – | 2.2 |
| dashed w2 | hard | 0 / 0 / 0 / 0 | – | 62/62 | 0.33 (-0.11) | 0 | 0.926 / – | -3.6% / – | – | 67 | – | 2.1 |
| dashed w4 | aa | 0 / 0 / 0 / 0 | – | 58/58 | 0.30 (-0.20) | 0 | 0.952 / – | -0.1% / – | – | 61 | – | 2.2 |
| 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 | 5 / 2 / 0 / 7 | 1.81 | 17/17 | 0.62 (-0.47) | 1 | 0.932 / – | -0.6% / – | – | 117 | – | 3.4 |
| letters w3 | hard | 5 / 2 / 0 / 4 | 1.81 | 17/17 | 0.38 (-0.21) | 1 | 0.865 / – | -0.1% / – | – | 92 | – | 3.4 |
| letters w5 | aa | 5 / 2 / 0 / 5 | 2.28 | 17/17 | 0.99 (-0.83) | 0 | 0.929 / – | -1.6% / – | – | 129 | – | 3.7 |
| letters w5 | hard | 5 / 2 / 0 / 5 | 2.28 | 17/17 | 1.05 (-0.79) | 0 | 0.889 / – | -1.9% / – | – | 104 | – | 3.8 |
| mix | aa | 0 / 0 / 0 / 0 | – | 6/7 | 0.24 (-0.17) | 0 | 0.986 / – | 0.1% / – | 99.7 | 11 | – | 6.9 |
| mix | hard | 0 / 0 / 0 / 0 | – | 6/7 | 0.57 (-0.46) | 0 | 0.969 / – | -0.0% / – | 99.1 | 11 | – | 6.9 |
| varying | aa | 0 / 0 / 0 / 0 | – | 10/10 | 2.03 (-1.08) | 0 | 0.735 / – | 4.8% / – | – | 22 | – | 5.4 |
| varying | hard | 0 / 0 / 0 / 0 | – | 10/10 | 1.92 (-0.94) | 0 | 0.736 / – | 5.2% / – | – | 22 | – | 5.7 |
| 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 30 correct, 13 wrong degree, 2 missed, 104 extra (of 45); ends 682 of 688; spurs 121.
**aa totals**: junctions 44 correct, 1 wrong degree, 0 missed, 94 extra (of 45); ends 682 of 688; spurs 121.
**hard totals**: junctions 30 correct, 13 wrong degree, 2 missed, 27 extra (of 45); ends 681 of 688; spurs 171.
**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). |
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long