Image Trace: a filled traced path that straightens to no area is left out

Fitting straightens the pieces of an outline that lie close to a line, and
Snap Curves To Lines straightens more of them. The outline of a single
pixel, or of a region one pixel thick along an edge such as an opaque row
of a mid tone under a light shape, can then have all its pieces on one
line. The trace held a selectable path with no area, for example a path
140 x 0 px with Snap Curves To Lines.

In a Color, Grayscale or Black and White trace with Fills, a fitted
outline or hole whose area is below 1e-6 px^2 is now left out. A path
with no area paints nothing, so the traced result paints the same pixels
as before. Without Fills, the 1 px stroke that outlines each area draws
such a loop, so it is kept. A single closed loop keeps its area as
before.
This commit is contained in:
dotCooCoo
2026-10-24 17:29:32 +07:00
committed by bflatastic
parent 61504c84fb
commit 5e8d41fb8f
3 changed files with 121 additions and 0 deletions
+15
View File
@@ -982,6 +982,21 @@ impl Layers<'_> {
// (Each loop fitted, and whether its own outline is hard.)
let fit = |l: &Loop| {
let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?;
// Every trace straightens the pieces of an outline that lie close to a line (Snap
// Curves To Lines straightens more of them), so the outline of a single pixel or
// of a region one pixel thick can have all its pieces on one chord, and the loop
// then has no area. Filled, such a loop paints nothing, and it is left out.
// Without Fills its 1 px stroke draws it, so it is kept. (`fit::fit_closed` puts
// back the positioned outline of a loop that is one closed piece when its fit has
// no area.) The area is summed about the loop's first anchor.
if area_stroke.is_none() {
use kurbo::ParamCurveArea;
let origin = sp.anchors.first()?.p.to_vec2();
let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum();
if area.abs() < 1e-6 {
return None;
}
}
hard.set(hard.get() || is_hard);
Some((sp, is_hard))
};
+105
View File
@@ -1128,6 +1128,111 @@ fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() {
);
}
#[test]
fn snap_curves_to_lines_leaves_out_a_sliver_it_flattens() {
// A light rectangle on a transparent background, with a blue and a black block on it and a
// one-pixel row of a blue-gray along its lower edge: the row is traced in the blue.
let img = Raster::from_fn(200, 120, |x, y| {
if (60..140).contains(&x) && (30..60).contains(&y) {
[40, 120, 240, 255]
} else if (60..140).contains(&x) && (70..90).contains(&y) {
BLACK
} else if (20..180).contains(&x) && (20..100).contains(&y) {
[240, 240, 220, 255]
} else if y == 100 && (30..170).contains(&x) {
[120, 130, 160, 255]
} else {
[0, 0, 0, 0]
}
});
let params = TraceParams {
mode: Mode::Color,
palette: Palette::Limited,
colors: 3,
paths: 90.0,
corners: 60.0,
noise: 25,
method: Method::Abutting,
..TraceParams::default()
};
let on_the_row = |p: &&TracedPath| p.path.bounds().is_some_and(|b| b.y0 > 99.0 && b.y1 < 102.0);
// Left alone, the row is a path of its own.
let res = trace(&img, &params);
let row: Vec<_> = res.paths.iter().filter(on_the_row).collect();
assert_eq!(row.len(), 1, "{:?}", res.paths.iter().map(|p| p.path.bounds()).collect::<Vec<_>>());
assert!(net_area(&row[0].path) > 100.0, "{:?}", row[0].path.bounds());
// Snapped, both sides of the row straighten onto one line: no path is left there, and every
// path keeps an area.
let res = trace(&img, &TraceParams { snap_curves_to_lines: true, ..params.clone() });
assert_eq!(res.paths.iter().filter(on_the_row).count(), 0, "{:?}", res.paths.iter().map(|p| p.path.bounds()).collect::<Vec<_>>());
for p in &res.paths {
assert!(p.path.subpaths.iter().all(|s| s.area().abs() > 1e-6), "{:?} {:?}", p.color, p.path.bounds());
}
// Without Fills, the areas are outlined with a 1 px stroke, which draws the flattened row: it
// stays. (Lines no wider than 0.5 px are traced as center lines, so the row is an area.)
let res = trace(&img, &TraceParams { snap_curves_to_lines: true, fills: false, strokes: true, stroke_width: 0.5, ..params });
let row: Vec<_> = res.paths.iter().filter(on_the_row).collect();
assert_eq!(row.len(), 1, "{:?}", res.paths.iter().map(|p| p.path.bounds()).collect::<Vec<_>>());
assert_eq!(row[0].stroke, Some(1.0));
assert!(row[0].path.bounds().is_some_and(|b| b.width() > 100.0), "{:?}", row[0].path.bounds());
}
#[test]
fn a_traced_pixel_that_straightens_to_no_area_is_left_out_of_fills_and_kept_as_an_outline() {
// Overlapping anti-aliased ellipses, 4 x 4 samples a pixel. Where the green, the blue and the
// light meet, a single pixel of their blend at (50, 42) is traced as a color of its own, and
// the two pieces of its outline straighten onto one line in every trace, with or without Snap
// Curves To Lines.
let palette: [[f64; 3]; 6] =
[[240.0, 240.0, 220.0], [40.0, 120.0, 240.0], [0.0; 3], [200.0, 40.0, 40.0], [120.0, 130.0, 160.0], [30.0, 160.0, 60.0]];
let ellipses = [
(66.37, 47.61, 32.7, 26.4, 1),
(-1.63, -3.39, 46.2, 30.6, 0),
(43.37, 30.61, 12.0, 17.3, 4),
(32.37, 73.61, 43.5, 6.8, 3),
(34.37, 54.61, 42.6, 13.8, 1),
(27.37, 37.61, 23.7, 22.2, 5),
];
let img = Raster::from_fn(100, 90, |x, y| {
let mut sum = [0.0f64; 3];
for i in 0..16 {
let (px, py) = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0);
let k = ellipses.iter().rev().find(|&&(cx, cy, rx, ry, _)| ((px - cx) / rx).powi(2) + ((py - cy) / ry).powi(2) <= 1.0).map_or(0, |e| e.4);
for (s, c) in sum.iter_mut().zip(palette[k]) {
*s += c / 16.0;
}
}
[sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255]
});
let params = TraceParams {
mode: Mode::Color,
palette: Palette::Limited,
colors: 6,
paths: 90.0,
corners: 60.0,
noise: 1,
method: Method::Abutting,
stroke_width: 1.0,
..TraceParams::default()
};
let at_the_pixel = |p: &&TracedPath| p.path.bounds().is_some_and(|b| b.x0 >= 50.0 && b.x1 <= 51.0 && b.y0 >= 42.0 && b.y1 <= 43.0);
for snap in [false, true] {
// Filled, the pixel's loop would paint nothing: no path is left there, and every path
// keeps an area.
let res = trace(&img, &TraceParams { snap_curves_to_lines: snap, ..params.clone() });
assert_eq!(res.paths.iter().filter(at_the_pixel).count(), 0, "snap {snap}");
for p in &res.paths {
assert!(p.path.subpaths.iter().all(|s| s.area().abs() > 1e-6), "snap {snap}: {:?} {:?}", p.color, p.path.bounds());
}
// Without Fills, its outline is stroked, which draws it: it stays.
let res = trace(&img, &TraceParams { snap_curves_to_lines: snap, fills: false, strokes: true, ..params.clone() });
let pixel: Vec<_> = res.paths.iter().filter(at_the_pixel).collect();
assert_eq!(pixel.len(), 1, "snap {snap}: {:?}", res.paths.iter().map(|p| p.path.bounds()).collect::<Vec<_>>());
assert_eq!(pixel[0].stroke, Some(1.0), "snap {snap}");
assert!(pixel[0].path.subpaths.iter().all(|s| s.area().abs() < 1e-6), "snap {snap}: {:?}", pixel[0].path.bounds());
}
}
#[test]
fn a_straight_anti_aliased_line_is_two_anchors_in_its_middle_as_wide_as_its_ink() {
// 2.6 px wide, from (20.3, 30.7) to (170.6, 52.2): a little off the pixel rows.
+1
View File
@@ -782,6 +782,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
- **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged.
- **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`.
- **Image Trace, Auto Color rare colours:** the Automatic palette keeps colours too rare for its area count when they are distinct (Oklab) and drawn as coherent regions, at most six, protected from mixed-colour merging and Noise; code-drawn stem, highlight, low-contrast and scattered-noise tests (see [quality notes](image-trace-quality.md#auto-color-keeps-rare-colours)).
- **Image Trace, large images:** images over 1500 px on a side trace with a warning in the result and the status bar; the anchor limit is 8,000,000 (2,000,000 on the web), measured at about 90 bytes an anchor (see [quality notes](image-trace-quality.md#large-images)).
- **Image Trace small contours:** closed fits too small to form an editable subpath retain their positioned outline, including holes. Source-generated island, hole/winding and stepped-region regressions cover Corners and Noise; defaults, parity scores and estimates are unchanged.