Merge pull request #7187 from dotCooCoo/fix/trace-stair-steps-curves

Image Trace: staircases of longer steps in smooth areas trace as slopes
This commit is contained in:
BFlat
2026-11-14 00:49:57 +07:00
committed by GitHub
3 changed files with 316 additions and 2 deletions
+274 -2
View File
@@ -12,6 +12,8 @@
//! An image with hard edges (a GIF, a 1-bit scan) says nothing more than the cracks do: every crack
//! moves by the same amount. There the staircase is read the classic way: a one-pixel step between
//! two runs is a point of a sloped edge (its middle is on the edge, the corners beside it are not).
//! A staircase of longer steps is read the same way where a smooth area was split into bands (see
//! [`outline`]).
use kurbo::Vec2;
use vectorcraft_geom::Point;
@@ -119,6 +121,10 @@ pub(crate) fn low_contrast(steps: &[[f64; 5]]) -> bool {
/// flat (see [`low_contrast`]).
const FLAT: f64 = 12.0;
/// The longest step (px) of a staircase read as the slope through it in a smooth area (see
/// [`outline`]). An edge longer than this keeps its corners.
const STAIR_MAX: usize = 8;
/// What `across` says of a crack that is an edge whatever the image does there: a crack on the
/// image's border, or one that the image's pixels cannot be read across (see [`low_contrast`]).
pub(crate) const EDGE_STEP: [f64; 5] = [1.0, 1.0, f64::NAN, f64::NAN, f64::NAN];
@@ -233,6 +239,50 @@ pub(crate) fn outline(
if junctions[edges[i % n].first].is_some() { 0 } else { (a.0 * b.1 - a.1 * b.0).signum() }
};
let step: Vec<bool> = (0..n).map(|i| edges[i].len == 1 && turn(i) * turn(i + 1) < 0).collect();
// A staircase of longer steps, where a smooth area was split into bands: a run of three or more
// edges of one to `STAIR_MAX` pixels, each turning one way where it starts and the other where
// it ends, with no junction on it, whose cracks read as the contour of a smooth area (see
// `low_contrast`, applied to the run's own cracks; a run is cut at any step whose own cracks
// show half the step between the colors or more). Its edges are steps: no corner where two of
// them meet, and each gives one point, its middle. Where a run meets an edge that is not a
// step, that corner stays. A sharp edge drawn as a staircase keeps its corners. (The runs are
// the same from either side: they depend only on the edges, their turns and the junctions,
// which every region around a pixel corner shares.)
let stair = {
let candidate: Vec<bool> = (0..n)
.map(|i| {
let e = &edges[i];
(1..=STAIR_MAX).contains(&e.len) && turn(i) * turn(i + 1) < 0 && !(0..=e.len).any(|k| junction((e.first + k) % cracks))
})
.collect();
let own = |i: usize| (0..edges[i].len).filter_map(|j| steps.get(edges[i].first + j).copied()).collect::<Vec<_>>();
// Whether a step's own cracks show under half of the step between the two colors, as a
// contour does (an edge shows nearly all of it): a staircase that runs on from a contour
// into an edge is cut there. (Summed in sorted order: the same from either side.)
let gentle = |i: usize| {
let mut near: Vec<f64> = own(i).iter().map(|s| s[0]).filter(|s| !s.is_nan()).collect();
near.sort_by(f64::total_cmp);
!near.is_empty() && near.iter().sum::<f64>() < 0.5 * near.len() as f64
};
let mut stair = vec![false; n];
// A loop turns once round in all, so some edge is not a candidate: runs start after it.
if let Some(s0) = (0..n).find(|&i| !candidate[i]) {
let mut k = 1;
while k < n {
let run: Vec<usize> = (k..n).map(|j| (s0 + j) % n).take_while(|&i| candidate[i] && gentle(i)).collect();
k += run.len().max(1);
if run.len() < 3 {
continue;
}
if low_contrast(&run.iter().flat_map(|&i| own(i)).collect::<Vec<_>>()) {
for &i in &run {
stair[i] = true;
}
}
}
}
stair
};
let outward = |d: (i32, i32)| Vec2::new(f64::from(d.1), f64::from(-d.0));
// The pixel at a corner of the shape lies on both of the corner's edges, and what it lacks of
// the inside's colour is the doing of both: read as one edge's alone it dents the corner. The
@@ -242,7 +292,7 @@ pub(crate) fn outline(
let mut shifts = shifts;
for i in 0..n {
let (b, e) = ((i + n - 1) % n, i);
if step[b] || step[e] || turn(i) == 0 {
if step[b] || step[e] || (stair[b] && stair[e]) || turn(i) == 0 {
continue;
}
let (before, edge) = (&edges[b], &edges[e]);
@@ -276,6 +326,9 @@ pub(crate) fn outline(
&& edges[b].len >= 3
&& edges[s1].dir == edges[b].dir
&& edges[s2].dir == edges[a].dir
// The edges of a staircase of longer steps (see `stair`) make no corner here.
&& !stair[a]
&& !stair[b]
&& free(s1)
&& free(s2)
&& free(b)
@@ -311,7 +364,7 @@ pub(crate) fn outline(
if k == 0 && first.is_some() {
// The junction itself: the one point every region around it has.
pts.push(junctions[crack].unwrap_or(corner));
} else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] {
} else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] && !(stair[i] && stair[(i + n - 1) % n]) {
// A true corner: where its two edges, each moved by its own shift, meet.
let before = &edges[(i + n - 1) % n];
pts.push(corner + outward(before.dir) * shifts[(crack + cracks - 1) % cracks] + outward(e.dir) * shifts[crack]);
@@ -319,6 +372,13 @@ pub(crate) fn outline(
if !hard && let Some(sharp) = notch[i] {
// A corner whose own pixel went to the other side: the corner, not the two steps.
pts.extend(sharp);
} else if stair[i] {
if along_edge == e.len / 2 {
// The middle of the step, moved by its cracks' mean shift (multiples of 1/1024,
// so the same sum from either side).
let shift = (0..e.len).map(|j| shifts[e.first + j]).sum::<f64>() / e.len as f64;
pts.push(at(i) + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * (e.len as f64 / 2.0) + outward(e.dir) * shift);
}
} else if step[i] || !hard {
pts.push(corner + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * 0.5 + outward(e.dir) * shifts[crack]);
}
@@ -494,6 +554,218 @@ mod tests {
}
}
#[test]
fn a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps() {
// Left and right regions meet along a staircase of steps two pixels across and three down
// (rows 0 to 8), a straight run of ten rows, and the same staircase again (rows 19 to 27).
// The cracks above row 9 read as the contour of a smooth area, the others as an edge, so
// the piece as a whole is no contour.
let (w, h) = (18, 28);
let edge = |y: usize| match y {
0..9 => 2 + 2 * (y / 3),
9..19 => 8,
_ => 10 + 2 * ((y - 19) / 3),
};
let left: Vec<bool> = (0..w * h).map(|i| i % w < edge(i / w)).collect();
let right: Vec<bool> = left.iter().map(|b| !b).collect();
let step = |c: Crack| if c.at(0).unwrap().min(c.at(1).unwrap()) / w < 9 { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] };
let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid);
let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid);
let inner = |pieces: &[Piece]| {
pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone()
};
let (mut ours, theirs) = (inner(&a), inner(&b));
assert!(!ours.contour);
ours.pts.reverse();
assert_eq!(ours, theirs);
let pts = &theirs.pts;
// The upper staircase: the middle of each of its steps, on one line, and none of its corners.
let middles = [(3.0, 3.0), (4.0, 4.5), (5.0, 6.0), (6.0, 7.5), (7.0, 9.0)].map(Point::from);
assert!(middles.iter().all(|m| pts.contains(m)), "{pts:?}");
assert!([(4.0, 3.0), (4.0, 6.0), (6.0, 6.0), (6.0, 9.0)].map(Point::from).iter().all(|c| !pts.contains(c)), "{pts:?}");
// The straight run keeps the corners at both its ends, so it stays straight.
assert!([(8.0, 9.0), (8.0, 19.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
// The lower one is a sharp edge: it keeps its corners.
assert!([(8.0, 19.0), (10.0, 19.0), (10.0, 22.0), (12.0, 22.0), (12.0, 25.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
// Steps longer than STAIR_MAX keep their corners, in a smooth area too.
let (w, h) = (40, 40);
let left: Vec<bool> = (0..w * h).map(|i| i % w < 5 + 10 * (i / w / 10)).collect();
let pieces = outline(&loop_of(&left, w, h), w, h, &|_| (0.0, 1, [0.1, 0.3, 4.0, 4.0, 4.0]), &grid);
let pts = &inner(&pieces).pts;
assert!([(15.0, 10.0), (15.0, 20.0), (25.0, 20.0), (25.0, 30.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
}
#[test]
fn a_staircase_that_runs_on_from_a_smooth_area_into_an_edge_keeps_the_edges_corners() {
// One staircase of steps two pixels across and three down, from the top of the image to its
// bottom: its cracks above row 18 read as the contour of a smooth area, the others as an
// edge. Read as a whole, the run would pass as a contour.
let (w, h) = (20, 27);
let left: Vec<bool> = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect();
let right: Vec<bool> = left.iter().map(|b| !b).collect();
let step = |c: Crack| if c.at(0).unwrap().min(c.at(1).unwrap()) / w < 18 { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] };
let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid);
let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid);
let inner = |pieces: &[Piece]| {
pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone()
};
let (mut ours, theirs) = (inner(&a), inner(&b));
ours.pts.reverse();
assert_eq!(ours, theirs);
let pts = &theirs.pts;
assert!([(3.0, 3.0), (4.0, 4.5), (9.0, 12.0), (11.0, 15.0), (12.0, 16.5)].map(Point::from).iter().all(|m| pts.contains(m)), "{pts:?}");
assert!([(4.0, 3.0), (10.0, 15.0)].map(Point::from).iter().all(|c| !pts.contains(c)), "{pts:?}");
// The corner where the staircase meets the edge's first step stays.
assert!([(14.0, 18.0), (14.0, 21.0), (16.0, 21.0), (16.0, 24.0), (18.0, 24.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
}
#[test]
fn a_staircase_whose_cracks_do_not_read_as_a_contour_keeps_its_corners() {
// Each step's cracks show a tenth of the step near them, but nothing changes within either
// side: two flat colors, not a smooth area.
let (w, h) = (20, 27);
let left: Vec<bool> = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect();
let right: Vec<bool> = left.iter().map(|b| !b).collect();
let flat = [0.1, 0.3, 4.0, 4.0, 0.0];
let a = outline(&loop_of(&left, w, h), w, h, &|_| (0.0, 1, flat), &grid);
let b = outline(&loop_of(&right, w, h), w, h, &|_| (0.0, 2, flat), &grid);
let inner = |pieces: &[Piece]| {
pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone()
};
let (mut ours, theirs) = (inner(&a), inner(&b));
ours.pts.reverse();
assert_eq!(ours, theirs);
let pts = &theirs.pts;
assert!([(4.0, 3.0), (4.0, 6.0), (6.0, 6.0), (10.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
assert!([(4.0, 4.5), (9.0, 12.0)].map(Point::from).iter().all(|m| !pts.contains(m)), "{pts:?}");
}
#[test]
fn a_staircase_step_moves_by_the_mean_shift_of_its_cracks() {
// The staircase of steps two pixels across and three down, in a smooth area. Down a step,
// its cracks move 0, 1/8 and 1/4 px; across one, 0 and 1/8 px (out of the left region; the
// right region sees them negated).
let (w, h) = (20, 27);
let left: Vec<bool> = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect();
let right: Vec<bool> = left.iter().map(|b| !b).collect();
let shift = |c: Crack| {
let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap());
let v = if p / w == q / w { 0.125 * (p / w % 3) as f64 } else { 0.125 * (p % w % 2) as f64 };
if left[p] { v } else { -v }
};
let gentle = [0.1, 0.3, 4.0, 4.0, 4.0];
let a = outline(&loop_of(&left, w, h), w, h, &|c| (shift(c), 1, gentle), &grid);
let b = outline(&loop_of(&right, w, h), w, h, &|c| (shift(c), 2, gentle), &grid);
let inner = |pieces: &[Piece]| {
pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone()
};
let (mut ours, theirs) = (inner(&a), inner(&b));
assert!(!ours.hard);
ours.pts.reverse();
assert_eq!(ours, theirs);
let pts = &theirs.pts;
// A step down at x = 4 moves right by 1/8 px; the step across at y = 6 moves up by 1/16 px.
assert!([(4.125, 4.5), (5.0, 5.9375), (6.125, 7.5), (7.0, 8.9375)].map(Point::from).iter().all(|m| pts.contains(m)), "{pts:?}");
}
#[test]
fn a_staircase_of_mixed_one_pixel_and_longer_steps_is_one_slope_without_a_notch() {
// Per four rows: three down, one across, one down, three across. Each three-pixel step meets
// the next one with no one-pixel step between them, and the one-pixel steps are a notch
// between two longer ones. All of it is the contour of a smooth area, and the cracks move by
// different amounts.
let (w, h) = (26, 24);
let edge = |y: usize| 2 + 4 * (y / 4) + usize::from(y % 4 == 3);
let left: Vec<bool> = (0..w * h).map(|i| i % w < edge(i / w)).collect();
let right: Vec<bool> = left.iter().map(|b| !b).collect();
let shift = |c: Crack| {
let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap());
let v = 0.125 * ((p + q) % 3) as f64 - 0.125;
if left[p] { v } else { -v }
};
let gentle = [0.1, 0.3, 4.0, 4.0, 4.0];
let a = outline(&loop_of(&left, w, h), w, h, &|c| (shift(c), 1, gentle), &grid);
let b = outline(&loop_of(&right, w, h), w, h, &|c| (shift(c), 2, gentle), &grid);
let inner = |pieces: &[Piece]| {
pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone()
};
let (mut ours, theirs) = (inner(&a), inner(&b));
assert!(!ours.hard);
ours.pts.reverse();
assert_eq!(ours, theirs);
let pts = &theirs.pts;
let near = |x: f64, y: f64, d: f64| pts.iter().any(|p| (p.x - x).hypot(p.y - y) < d);
for k in 1..5 {
let (c, y) = ((2 + 4 * k) as f64, (4 * k) as f64);
// The middle of each step, and no point at the corner where the three-pixel steps
// meet or at the corner the notch would make.
assert!(near(c, y + 1.5, 0.2) && near(c + 2.5, y + 4.0, 0.2), "{k}: {pts:?}");
assert!(!near(c, y, 0.4) && !near(c, y + 4.0, 0.4), "{k}: {pts:?}");
}
}
/// Every point of every piece that `mask` and the region around it get in a smooth area, with
/// `junction_at` and the near share of each crack from `near`.
fn both_sides(
mask: &[bool],
w: usize,
h: usize,
near: &dyn Fn(Crack) -> [f64; 5],
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
) -> [Vec<Point>; 2] {
let other: Vec<bool> = mask.iter().map(|b| !b).collect();
[(mask, 1), (&other[..], 2)]
.map(|(m, id)| outline(&loop_of(m, w, h), w, h, &|c| (0.0, id, near(c)), junction_at).into_iter().flat_map(|p| p.pts).collect())
}
#[test]
fn a_run_of_two_steps_keeps_its_corners() {
// Ten rows down, two across, three down, ten across: only two edges are short enough to
// be steps.
let (w, h) = (20, 20);
let edge = |y: usize| match y {
0..10 => 2,
10..13 => 4,
_ => 14,
};
let left: Vec<bool> = (0..w * h).map(|i| i % w < edge(i / w)).collect();
for pts in both_sides(&left, w, h, &|_| [0.1, 0.3, 4.0, 4.0, 4.0], &grid) {
assert!([(2.0, 10.0), (4.0, 10.0), (4.0, 13.0), (14.0, 13.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
assert!(!pts.contains(&Point::new(4.0, 11.5)), "{pts:?}");
}
}
#[test]
fn a_junction_on_a_step_ends_the_staircase_there() {
// The staircase of steps two pixels across and three down, in a smooth area, with a
// junction at (8, 10), on the step down from (8, 9) to (8, 12).
let (w, h) = (20, 27);
let left: Vec<bool> = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect();
let junction_at = |x: i32, y: i32| (x == 8 && y == 10).then(|| Point::new(8.0, 10.0));
for pts in both_sides(&left, w, h, &|_| [0.1, 0.3, 4.0, 4.0, 4.0], &junction_at) {
// The step keeps the corners at both its ends; the steps on either side of it are slopes.
assert!([(8.0, 9.0), (8.0, 10.0), (8.0, 12.0), (5.0, 6.0), (11.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
assert!(!pts.contains(&Point::new(8.0, 10.5)) && !pts.contains(&Point::new(6.0, 6.0)), "{pts:?}");
}
}
#[test]
fn a_step_whose_cracks_read_as_an_edge_ends_the_staircase_there() {
// The same staircase in a smooth area, but the cracks of the step down from (8, 9) to
// (8, 12) cannot be read across.
let (w, h) = (20, 27);
let left: Vec<bool> = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect();
let near = |c: Crack| {
let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap());
if p / w == q / w && (9..12).contains(&(p / w)) && (p % w).max(q % w) == 8 { EDGE_STEP } else { [0.1, 0.3, 4.0, 4.0, 4.0] }
};
for pts in both_sides(&left, w, h, &near, &grid) {
assert!([(8.0, 9.0), (8.0, 12.0), (5.0, 6.0), (11.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}");
assert!(!pts.contains(&Point::new(8.0, 10.5)) && !pts.contains(&Point::new(6.0, 6.0)), "{pts:?}");
}
}
#[test]
fn a_shift_that_is_not_a_number_moves_nothing() {
let mask = [true, true, false, false, true, true, false, false];
+41
View File
@@ -156,6 +156,47 @@ under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, a
four corners); `layers::tests` (a ramp, flat colors in palette entries far apart, close flat colors, a
thin flat stroke, colors off the line between their entries, and Grayscale's grays, each read from both
sides of the crack) and `subpixel::tests` (the rule, and one decision for a whole piece from either side).
## Staircases in smooth areas
Where a band border in a gradient or a shaded area runs at a slant, its pixels form a staircase whose
steps are two or more pixels long. One piece of outline can run along a sharp edge in the image and then
along such a border with no junction between them. That piece as a whole is not a contour (see above),
and the outline kept a corner at every step. `subpixel::outline` now reads a run of three or more edges of
1 to 8 px (`STAIR_MAX`), each turning one way where it starts and the other where it ends, with no
junction on it, as a staircase when the run's own cracks pass `subpixel::low_contrast`. A run is cut at
any step whose own cracks show half of the step between the two colors or more. The 8 px limit and the
one-half cut are VectorCraft's own values. The run's edges are then steps, as one-pixel steps already
were: the outline has no corner where two of them meet, and each gives one point, its middle, moved by the
mean of its cracks' shifts. Where a run meets an edge that is not a step, the corner between them stays,
so a straight edge longer than 8 px next to a staircase stays straight. A straight edge of 8 px or less
between two turns of opposite sign is read as one more step of the run. Inside a run, two one-pixel steps
between longer ones are read as part of the slope and do not make a notch. A staircase of longer steps,
and one whose cracks read as an edge, keep their corners, and Black and White traces do not use the rule.
The runs are the same from either side of the border.
Measured with the harness below, before and after the change: rows of the "flat colour art" inputs do not
change. On gradient and photo inputs, anchors change in 106 rows, 101 of them fewer, by −64% to +6.4%: for
example 390 to 142 (Shades of Gray, linear gradients), 1,860 to 1,537 (High Fidelity Photo, linear
gradients), 6,766 to 6,542 (High Fidelity Photo, gradient and photo) and 47 to 50 (High Fidelity Photo with
Gradients, blurred). On gradient and photo, 6 Colors, 6 Colors with Shapes, with Auto Grouping, without
Gradients and enhanced each leave a gap of 0.9 px (was 1.2) and an overlap of 1.0 px (was 0.8). Edge ΔE
of Shades of Gray on linear gradients goes from 8.36 to 8.40 (8.39 enhanced), and that of 6 Colors with
Gradients on a blurred input from 2.24 to 2.22; other ΔE and edge ΔE values change by 0.01 or less.
Lines % changes by −2 to +3, grouped % by −1 to +4, upright % by +1 in 3 rows (Shades of Gray on a blurred
input), and specks do not change. On synthetic photos of 800 and 4,000 px traced outside this harness,
the corners of rectilinear staircases per 100 px of outline fall from 0.57 to 0.46 and from 1.09 to 0.94
with High Fidelity Photo. On the 800 px photo, the 95th percentile of the pixel difference with High
Fidelity Photo rises from 6.33 to 6.67, and 16 Colors leaves 2 staircases of steps of 2 px or more where
it left none. The tables below are the previous measurement, not a rerun.
Tests on code-drawn outlines in `subpixel::tests`:
`a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps` (a piece with a
staircase of 2 × 3 px steps in a smooth area, a straight run and the same staircase on a sharp edge: the
first gives its steps' middles and none of its corners, the straight run keeps the corners at its ends, the
second staircase keeps its corners, both regions get the same points, and 10 px steps keep their corners
in a smooth area), `a_staircase_that_runs_on_from_a_smooth_area_into_an_edge_keeps_the_edges_corners`,
`a_staircase_whose_cracks_do_not_read_as_a_contour_keeps_its_corners` (two flat colors),
`a_staircase_step_moves_by_the_mean_shift_of_its_cracks`,
`a_staircase_of_mixed_one_pixel_and_longer_steps_is_one_slope_without_a_notch`,
`a_run_of_two_steps_keeps_its_corners`, `a_junction_on_a_step_ends_the_staircase_there` and
`a_step_whose_cracks_read_as_an_edge_ends_the_staircase_there`.
## A simpler contour walk
`contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the
right. It used to build a table of outgoing directions for every pixel corner in a raster pass and
+1
View File
@@ -1355,6 +1355,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Image Trace, notches read only their own piece:** the notch rule of `subpixel::outline` reads only the shared piece, so both regions of an edge cut a corner pixel alike and leave no gap; a code-drawn noisy multi-colour coverage test in `crates/trace` (see [quality notes](image-trace-quality.md#notches-read-only-their-own-piece)).
- **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)).
- **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged.
- **Image Trace, staircases in smooth areas:** in Color and Grayscale traces, a staircase of three or more steps of 1 to 8 px along a border between two colors, where the image changes gradually by the same rule (`subpixel::low_contrast`, read for the staircase alone, which ends at any step that shows half of the step between the two colors or more; the 8 px limit and the one-half cut are VectorCraft's own values), is traced as the slope through the middles of its steps with no corner at each step, also on an outline that runs on along a sharp edge in the image. The corner where a staircase meets a straight edge longer than 8 px stays. Longer steps, staircases along a sharp edge and Black and White traces keep their corners; 6 Colors on gradient and photo leaves a gap of 0.9 px (was 1.2) and an overlap of 1.0 px (was 0.8), and High Fidelity Photo with Gradients on a blurred input takes 50 anchors (was 47); see [quality notes](image-trace-quality.md#staircases-in-smooth-areas) for these and the other rows that got worse. Tests: eight `subpixel::tests` in `crates/trace`, starting with `a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps`. Scores and hours unchanged.
- **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`.
- **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`.
- **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)).