mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 13:09:16 +00:00
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:
+142
-12
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user