Merge pull request #7080 from dotCooCoo/feat/trace-create-strokes

Image Trace: Create Strokes makes a wide shape's thin features strokes and keeps its body a fill
This commit is contained in:
BFlat
2026-11-13 17:03:37 +07:00
committed by GitHub
5 changed files with 252 additions and 14 deletions
+142 -12
View File
@@ -4,7 +4,10 @@
//! 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.
//! one) is within the stroke width is a line. A wider one keeps as its area every disc wider
//! than the stroke width that fits in it; each piece outside those discs that reaches out of
//! them at least one and a half times its own width (and 3 px) is a thin feature of it, and a
//! line, whose end at the area goes to the cut, its cap reaching into the area.
//! 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
@@ -93,20 +96,111 @@ pub(crate) fn lines(
cells_left = left;
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;
let found = if widest > max_width {
// A wider shape keeps its wide part as an area; its thin features are lines.
arm_lines(&g, &dt, w, max_width, img, ink, style, &mut cells_left)
} else {
trace_line(g, &dt, comp.len(), max_width, img, ink, style, None).map(|line| (comp, line)).into_iter().collect()
};
for (pixels, line) in found {
for &i in &pixels {
if let Some(v) = thin.get_mut(i) {
*v = true;
}
}
out.push(line);
}
out.push(line);
}
(thin, out)
}
/// The thin features of component `g` (wider than `max_width` somewhere; `dt` its distances), traced
/// as lines, each with its pixels (indices of the `w`-wide image). A feature that cannot be traced, or
/// past the work left in `cells_left`, stays part of the area.
#[allow(clippy::too_many_arguments)]
fn arm_lines(
g: &Grid,
dt: &[f32],
w: usize,
max_width: f64,
img: &Raster,
ink: [f64; 3],
style: &Style,
cells_left: &mut usize,
) -> Vec<(Vec<usize>, Line)> {
let mut out = vec![];
for arm in arms(g, dt, w, max_width).unwrap_or_default() {
let pixels: Vec<usize> = arm.iter().map(|&(i, _)| i).collect();
let Some(ag) = Grid::from_component(&pixels, w) else { continue };
let Some(left) = cells_left.checked_sub(ag.w.saturating_mul(ag.h)) else { break };
*cells_left = left;
let mut cut = vec![false; ag.on.len()];
for &(i, at_cut) in &arm {
if let Some(c) = ag.index(i, w).and_then(|k| cut.get_mut(k)) {
*c = at_cut;
}
}
let Some(adt) = distance::squared(&ag.on, ag.w, ag.h) else { continue };
if 2.0 * f64::from(adt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0 > max_width {
continue;
}
if let Some(line) = trace_line(ag, &adt, pixels.len(), max_width, img, ink, style, Some(&cut)) {
out.push((pixels, line));
}
}
out
}
/// The thin features of component `g`, which is wider than `max_width` somewhere (`dt` its squared
/// distances to the paper): the pieces of it that lie outside every disc wider than `max_width`
/// that fits in it (the component opened by such a disc) and reach out of those discs at least one
/// and a half times their own width (and 3 px). The rest, with the slivers and corners the discs
/// leave, is the area. Each piece is a list of its pixels (indices of the `w`-wide image), each
/// marked when it touches the area (where the line is cut from it).
fn arms(g: &Grid, dt: &[f32], w: usize, max_width: f64) -> Option<Vec<Vec<(usize, bool)>>> {
let r = (max_width + 1.0) / 2.0;
let seeds: Vec<bool> = dt.iter().map(|&d| f64::from(d) > r * r).collect();
let near = distance::to_seeds(&seeds, g.w, g.h)?;
drop(seeds);
// (Half a pixel more than the discs' radius, so pixels the discs cut through are area.)
let cover = r + 0.5;
let rest = |i: usize| g.on.get(i) == Some(&true) && near.get(i).is_some_and(|&d| f64::from(d) > cover * cover);
let mut seen = vec![false; g.on.len()];
let (mut out, mut stack) = (vec![], vec![]);
for start in g.interior() {
if !rest(start) || seen.get(start).copied().unwrap_or(true) {
continue;
}
let mut piece = vec![];
*seen.get_mut(start)? = true;
stack.push(start);
while let Some(i) = stack.pop() {
let mut at_cut = false;
for j in g.neighbours(i) {
if !rest(j) {
at_cut = true;
} else if !seen.get(j).copied().unwrap_or(true) {
*seen.get_mut(j)? = true;
stack.push(j);
}
}
piece.push((i, at_cut));
}
let deepest = piece.iter().filter_map(|&(i, _)| dt.get(i)).copied().fold(0.0f32, f32::max);
let width = (2.0 * f64::from(deepest).sqrt() - 1.0).max(1.0);
let farthest = piece.iter().filter_map(|&(i, _)| near.get(i)).copied().fold(0.0f32, f32::max);
if f64::from(farthest).sqrt() - cover < (1.5 * width).max(3.0) {
continue;
}
let image = |i: usize| {
let (x, y) = (usize::try_from(g.x0 + (i % g.w) as i64).ok()?, usize::try_from(g.y0 + (i / g.w) as i64).ok()?);
y.checked_mul(w)?.checked_add(x)
};
out.push(piece.into_iter().map(|(i, at_cut)| Some((image(i)?, at_cut))).collect::<Option<Vec<_>>>()?);
}
Some(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);
@@ -163,6 +257,23 @@ impl Grid {
let comp: Vec<_> = mask.iter().enumerate().filter_map(|(i, &on)| on.then_some(i)).collect();
Self::from_component(&comp, w)
}
/// The grid index of pixel `i` of the `image_width`-wide image, when the grid holds it.
fn index(&self, i: usize, image_width: usize) -> Option<usize> {
let (x, y) = (i64::try_from(i % image_width.max(1)).ok()? - self.x0, i64::try_from(i / image_width.max(1)).ok()? - self.y0);
let (x, y) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?);
(x < self.w && y < self.h).then(|| y * self.w + x)
}
/// Whether a pixel `cut` marks (`cut` is laid out as the grid) has its center within `radius` of
/// `p` (image coordinates).
fn near_cut(&self, cut: &[bool], p: Point, radius: f64) -> bool {
if !p.x.is_finite() || !p.y.is_finite() || !radius.is_finite() {
return false;
}
let (cx, cy) = (p.x - self.x0 as f64 - 0.5, p.y - self.y0 as f64 - 0.5);
let span = |c: f64, n: usize| ((c - radius).floor().max(0.0) as usize, ((c + radius).ceil().max(0.0) as usize).min(n.saturating_sub(1)));
let ((x0, x1), (y0, y1)) = (span(cx, self.w), span(cy, self.h));
(y0..=y1).any(|y| (x0..=x1).any(|x| (x as f64 - cx).hypot(y as f64 - cy) <= radius && cut.get(y * self.w + x).copied().unwrap_or(false)))
}
fn offsets(&self) -> [isize; 8] {
let w = self.w as isize;
[-w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1]
@@ -193,8 +304,20 @@ impl Grid {
}
}
/// 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> {
/// The center lines of one line component (pixel indices of the `w`-wide image). `cut`, for a thin
/// feature of a wider shape, marks (in `g`) its pixels that touch the shape's area: an end there is
/// not an end of the ink, and goes to the cut, its cap reaching into the area.
#[allow(clippy::too_many_arguments)]
fn trace_line(
mut g: Grid,
dist: &[f32],
pixels: usize,
max_width: f64,
img: &Raster,
ink: [f64; 3],
style: &Style,
cut: Option<&[bool]>,
) -> Option<Line> {
thin::thin(&mut g);
let mut graph = graph::build(&g, dist)?;
graph.simplify(max_width)?;
@@ -268,7 +391,9 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra
// 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();
// (At a cut from a wider shape, where the skeleton turns along the cut.)
let joined = cut.is_some_and(|cut| g.near_cut(cut, tip, width / 2.0 + 1.5));
let wide = width >= 3.0 && n > 2 * back && (joined || 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;
@@ -282,6 +407,11 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra
}
}
let tip = on_line(tip);
if joined {
// The skeleton stops about half a width short of the cut.
*r.pts.get_mut(at(0))? = tip + along * (width / 2.0 + 0.5);
continue;
}
let reach = ink.beyond(tip, along);
if width >= 3.0 {
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
+47 -1
View File
@@ -40,7 +40,21 @@ pub(super) fn squared(mask: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
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();
two_passes(mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect(), w, h)
}
/// Squared distances from each pixel of a validated crop to the nearest `seeds` pixel (a very large
/// value everywhere when there is none).
pub(super) fn to_seeds(seeds: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
let size = super::crop_size(w, h)?;
if seeds.len() != size {
return None;
}
two_passes(seeds.iter().map(|&seed| if seed { 0.0 } else { FAR }).collect(), w, h)
}
/// The column pass and then the row pass over `grid` (0 at a seed, `FAR` elsewhere).
fn two_passes(mut grid: Vec<f64>, w: usize, h: usize) -> Option<Vec<f32>> {
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 {
@@ -97,6 +111,38 @@ mod tests {
}
}
#[test]
fn distances_to_seeds_equal_brute_force() {
let mut seed = 7u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
(seed >> 33) as usize
};
for case in 0..100 {
let (w, h) = (3 + rnd() % 20, 3 + rnd() % 20);
let seeds: Vec<bool> = (0..w * h).map(|_| rnd() % 9 == 0).collect();
let d = to_seeds(&seeds, w, h).unwrap();
for (i, &actual) in d.iter().enumerate() {
let expected = seeds
.iter()
.enumerate()
.filter(|&(_, &s)| s)
.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);
match expected {
Some(e) => assert_eq!(actual, e, "case {case}, pixel {i}"),
None => assert!(actual > 1e18, "case {case}: no seed, pixel {i} at {actual}"),
}
}
}
assert!(to_seeds(&[true; 8], 3, 3).is_none());
assert!(to_seeds(&[], 2, 2).is_none());
}
#[test]
fn invalid_crops_are_refused() {
assert!(squared(&[], 0, 0).is_none());
+2 -1
View File
@@ -114,7 +114,8 @@ pub struct TraceParams {
pub gradients: bool,
/// Create: trace areas as filled shapes.
pub fills: bool,
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
/// Create: trace lines no wider than `stroke_width`, and such thin features of wider shapes, as
/// stroked center lines.
pub strokes: bool,
/// Stroke: the widest feature (px) traced as a stroke.
pub stroke_width: f64,
+60
View File
@@ -932,6 +932,66 @@ fn wide_areas_stay_filled_and_lines_cross_at_junctions() {
assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline");
}
#[test]
fn thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill() {
// A disc (r = 20) with a 3 px wide arm from it out to x = 140 and a 5 px wide one up to y = 5.
let img = Raster::from_fn(160, 100, |x, y| {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let disc = (px - 50.0).hypot(py - 60.0) <= 20.0;
let across = (py - 60.0).abs() <= 1.5 && (50.0..=140.0).contains(&px);
let up = (px - 50.0).abs() <= 2.5 && (5.0..=60.0).contains(&py);
if disc || across || up { BLACK } else { WHITE }
});
let res = trace(&img, &strokes(true));
let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect();
assert_eq!(fills.len(), 1, "the disc");
let a = net_area(&fills[0].path);
assert!((a - std::f64::consts::PI * 400.0).abs() < 90.0, "disc area {a}");
let b = fills[0].path.bounds().unwrap();
assert!(b.x1 < 76.0 && b.y0 > 34.0, "the fill stays the disc: {b:?}");
let lines = stroked(&res);
assert_eq!(lines.len(), 2, "{:?}", lines.iter().map(|(p, s)| (p.stroke, s.anchors.len())).collect::<Vec<_>>());
let ends = |s: &vectorcraft_geom::SubPath| (s.anchors[0].p, s.anchors[s.anchors.len() - 1].p);
let (across, up) = if ends(lines[0].1).0.y.min(ends(lines[0].1).1.y) > 50.0 { (lines[0], lines[1]) } else { (lines[1], lines[0]) };
// The arm across: its free end where the bar ends (less its cap), its other end at the disc, the
// cap closing the join.
let w = across.0.stroke.unwrap();
assert!((2.0..=4.0).contains(&w), "width {w}");
let (p, q) = ends(across.1);
let (near, far) = if p.x < q.x { (p, q) } else { (q, p) };
assert!(far.distance(Point::new(140.0, 60.0)) < 4.0, "free end {far:?}");
assert!((60.0..=72.5).contains(&near.x) && (near.y - 60.0).abs() < 1.5 && near.x - w / 2.0 <= 70.5, "joined end {near:?}");
// The arm up, 5 px wide.
let w = up.0.stroke.unwrap();
assert!((4.0..=6.0).contains(&w), "width {w}");
let (p, q) = ends(up.1);
let (top, low) = if p.y < q.y { (p, q) } else { (q, p) };
assert!(top.distance(Point::new(50.0, 5.0)) < 4.0, "free end {top:?}");
assert!((38.0..=48.0).contains(&low.y) && (low.x - 50.0).abs() < 1.5 && low.y + w / 2.0 >= 40.0 - 0.5, "joined end {low:?}");
// Strokes only: the disc is outlined and both arms stay strokes.
let res = trace(&img, &strokes(false));
assert!(res.paths.iter().all(|p| p.stroke.is_some()));
assert_eq!(res.paths.iter().filter(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed).count(), 1, "the disc's outline");
assert_eq!(stroked(&res).iter().filter(|(p, _)| p.stroke.is_some_and(|w| w > 1.5)).count(), 2, "the two arms");
}
#[test]
fn wide_shapes_without_thin_features_stay_one_fill() {
// A square, a diamond and a disc: their corners and edges are not thin features.
let img = Raster::from_fn(240, 90, |x, y| {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let square = (10.0..=70.0).contains(&px) && (15.0..=75.0).contains(&py);
let diamond = (px - 120.0).abs() + (py - 45.0).abs() <= 35.0;
let disc = (px - 200.0).hypot(py - 45.0) <= 30.0;
if square || diamond || disc { BLACK } else { WHITE }
});
for width in [3.0, 6.0, 12.0] {
let res = trace(&img, &TraceParams { stroke_width: width, ..strokes(true) });
assert_eq!(res.paths.len(), 3, "width {width}: {:?}", res.paths.iter().map(|p| p.stroke).collect::<Vec<_>>());
assert!(res.paths.iter().all(|p| p.stroke.is_none()), "width {width}");
}
}
#[test]
fn create_settings_parse_with_defaults() {
let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap();