mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Fix Image Trace Noise splitting previously merged regions
Squashed from #1368 onto main.
This commit is contained in:
@@ -15,8 +15,9 @@
|
||||
//! A continuous-tone image (a photo) is first smoothed within its tones, edges kept (see
|
||||
//! `smooth`): otherwise its noise decides, pixel by pixel, which of two neighbouring tones a
|
||||
//! pixel gets, and every such pixel is a speck.
|
||||
//! 2. **Remove noise**: 4-connected same-colour components smaller than `noise` pixels are merged
|
||||
//! into their most common neighbouring colour.
|
||||
//! 2. **Remove noise**: whole 4-connected same-colour regions are merged, smallest first, into
|
||||
//! their most common neighbouring colour until no mergeable region is smaller than `noise`.
|
||||
//! Raising Noise continues the same merge sequence without splitting earlier regions.
|
||||
//! 3. **Follow boundaries**: for each colour layer, the pixel-crack edges between inside and
|
||||
//! outside are linked into closed loops (inside on the right, right turns at saddles, so
|
||||
//! diagonal pixels stay separate, matching 4-connectivity). Outer loops run clockwise and
|
||||
|
||||
@@ -687,83 +687,96 @@ fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
|
||||
centers
|
||||
}
|
||||
|
||||
/// Merge 4-connected same-label components smaller than `min_area` pixels into their most common
|
||||
/// neighbouring label. Transparent pixels are never relabelled.
|
||||
///
|
||||
/// A pass can merge a speck into a small region that it merges away later, leaving the speck behind
|
||||
/// as a new island of that region's old label (#819: more noise removal gave more paths). Passes
|
||||
/// repeat until one changes nothing: each merge joins two components, so they always end.
|
||||
/// Merge whole 4-connected regions, smallest first, into their most common neighbouring label.
|
||||
/// The merge order does not depend on `min_area`: a higher threshold continues the same sequence
|
||||
/// instead of splitting a region already merged at a lower threshold. Transparency is a barrier.
|
||||
pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) {
|
||||
if min_area <= 1 || w == 0 || h == 0 || labels.len() < w.saturating_mul(h) {
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BinaryHeap};
|
||||
|
||||
if min_area <= 1 || w == 0 || h == 0 || w.checked_mul(h) != Some(labels.len()) {
|
||||
return;
|
||||
}
|
||||
// A backstop only: real images settle in a few passes.
|
||||
const MAX_PASSES: usize = 64;
|
||||
for _ in 0..MAX_PASSES {
|
||||
if !denoise_pass(labels, w, h, min_area) {
|
||||
break;
|
||||
}
|
||||
struct Region {
|
||||
label: u16,
|
||||
pixels: Vec<usize>,
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`denoise`] pass over every component; true when it relabelled any.
|
||||
fn denoise_pass(labels: &mut [u16], w: usize, h: usize, min_area: usize) -> bool {
|
||||
let mut changed = false;
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut comp: Vec<usize> = Vec::new();
|
||||
let mut neigh: Vec<(u16, u32)> = Vec::new();
|
||||
for start in 0..w * h {
|
||||
if seen[start] || labels[start] == TRANSPARENT {
|
||||
// Region membership is kept current after every merge. In particular, no stale `seen`
|
||||
// flags can exclude previously recoloured pixels from a subsequent connected component.
|
||||
let mut at = vec![usize::MAX; labels.len()];
|
||||
let mut regions: Vec<Region> = Vec::new();
|
||||
let neighbours = |i: usize| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)]
|
||||
};
|
||||
for start in 0..labels.len() {
|
||||
if at[start] != usize::MAX || labels[start] == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let l = labels[start];
|
||||
comp.clear();
|
||||
neigh.clear();
|
||||
stack.push(start);
|
||||
seen[start] = true;
|
||||
let mut small = true;
|
||||
while let Some(i) = stack.pop() {
|
||||
if small {
|
||||
comp.push(i);
|
||||
if comp.len() >= min_area {
|
||||
small = false;
|
||||
let id = regions.len();
|
||||
let label = labels[start];
|
||||
let mut pixels = vec![start];
|
||||
at[start] = id;
|
||||
let mut cursor = 0;
|
||||
while let Some(&i) = pixels.get(cursor) {
|
||||
for j in neighbours(i).into_iter().flatten() {
|
||||
if at[j] == usize::MAX && labels[j] == label {
|
||||
at[j] = id;
|
||||
pixels.push(j);
|
||||
}
|
||||
}
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut visit = |j: usize| {
|
||||
let lj = labels[j];
|
||||
if lj == l {
|
||||
if !seen[j] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
} else if lj != TRANSPARENT {
|
||||
match neigh.iter_mut().find(|(n, _)| *n == lj) {
|
||||
Some(e) => e.1 += 1,
|
||||
None => neigh.push((lj, 1)),
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
regions.push(Region { label, pixels });
|
||||
}
|
||||
let mut pending: BinaryHeap<_> = regions.iter().enumerate().map(|(id, r)| Reverse((r.pixels.len(), id))).collect();
|
||||
while let Some(Reverse((area, id))) = pending.pop() {
|
||||
if area >= min_area {
|
||||
break;
|
||||
}
|
||||
if regions[id].pixels.len() != area {
|
||||
continue; // absorbed region or an old heap entry for a region that grew
|
||||
}
|
||||
let mut borders: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
for &i in ®ions[id].pixels {
|
||||
for j in neighbours(i).into_iter().flatten() {
|
||||
let other = at[j];
|
||||
if other != id && other != usize::MAX {
|
||||
*borders.entry(other).or_default() += 1;
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
visit(i - 1);
|
||||
}
|
||||
if x + 1 < w {
|
||||
visit(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
visit(i - w);
|
||||
}
|
||||
if y + 1 < h {
|
||||
visit(i + w);
|
||||
}
|
||||
}
|
||||
if small && let Some(&(to, _)) = neigh.iter().max_by_key(|(n, c)| (*c, u16::MAX - *n)) {
|
||||
for &i in &comp {
|
||||
labels[i] = to;
|
||||
let mut colours: BTreeMap<u16, usize> = BTreeMap::new();
|
||||
for (&other, &border) in &borders {
|
||||
*colours.entry(regions[other].label).or_default() += border;
|
||||
}
|
||||
let Some((&colour, _)) = colours.iter().max_by_key(|(label, border)| (**border, Reverse(**label))) else {
|
||||
continue; // an opaque island surrounded only by transparency stays intact
|
||||
};
|
||||
// All adjacent regions of the chosen colour become connected by this merge. Absorb
|
||||
// them together, keeping the largest one's storage to avoid repeatedly moving it.
|
||||
let Some(target) = borders
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&other| regions[other].label == colour)
|
||||
.max_by_key(|&other| (regions[other].pixels.len(), Reverse(other)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let sources: Vec<_> =
|
||||
std::iter::once(id).chain(borders.keys().copied().filter(|&other| other != target && regions[other].label == colour)).collect();
|
||||
for source in sources {
|
||||
let pixels = std::mem::take(&mut regions[source].pixels);
|
||||
for &i in &pixels {
|
||||
at[i] = target;
|
||||
}
|
||||
changed = true;
|
||||
regions[target].pixels.extend(pixels);
|
||||
}
|
||||
pending.push(Reverse((regions[target].pixels.len(), target)));
|
||||
}
|
||||
for region in regions {
|
||||
for i in region.pixels {
|
||||
labels[i] = region.label;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -651,10 +651,11 @@ fn components(labels: &[u16], w: usize, h: usize) -> Vec<(usize, bool)> {
|
||||
/// island of the small region's old label.
|
||||
#[test]
|
||||
fn denoise_leaves_no_island_behind_a_merged_region() {
|
||||
// One row: a 1-pixel speck, a 2-pixel region, then a large one; nothing under 3 pixels may stay.
|
||||
// The speck joins the 2-pixel region, which now meets the strict 3-pixel threshold.
|
||||
// Its pixels must stay together rather than forcing a merge based on the old area.
|
||||
let mut labels = vec![0, 1, 1, 2, 2, 2, 2, 2];
|
||||
denoise(&mut labels, 8, 1, 3);
|
||||
assert_eq!(labels, vec![2; 8]);
|
||||
assert_eq!(labels, vec![1, 1, 1, 2, 2, 2, 2, 2]);
|
||||
}
|
||||
|
||||
/// On noise, Remove Noise leaves no region under the minimum that could be merged, and a larger
|
||||
@@ -1296,6 +1297,87 @@ fn stroke_caps_are_square_or_round_as_the_ink_is() {
|
||||
assert!(!line(false));
|
||||
}
|
||||
|
||||
// Return the actual 4-connected regions, independent of the denoiser's bookkeeping.
|
||||
fn noise_regions(labels: &[u16], w: usize, h: usize) -> Vec<Vec<usize>> {
|
||||
let mut seen = vec![false; labels.len()];
|
||||
let mut result = Vec::new();
|
||||
for start in 0..labels.len() {
|
||||
if seen[start] || labels[start] == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let mut region = vec![start];
|
||||
seen[start] = true;
|
||||
let mut cursor = 0;
|
||||
while cursor < region.len() {
|
||||
let i = region[cursor];
|
||||
let (x, y) = (i % w, i / w);
|
||||
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
|
||||
if !seen[j] && labels[j] == labels[start] {
|
||||
seen[j] = true;
|
||||
region.push(j);
|
||||
}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
result.push(region);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raising_noise_only_merges_whole_regions() {
|
||||
// Multicolour islands and transparent barriers exercise the stale-visited-pixel bug.
|
||||
// Check the partition, not only the count: no region at a lower Noise may be split.
|
||||
let mut seed = 819u64;
|
||||
for case in 0..64 {
|
||||
let original: Vec<u16> = (0..49)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
match (seed >> 32) % 5 {
|
||||
4 => TRANSPARENT,
|
||||
colour => colour as u16,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut previous = noise_regions(&original, 7, 7);
|
||||
for noise in 0..=24 {
|
||||
let mut labels = original.clone();
|
||||
denoise(&mut labels, 7, 7, noise);
|
||||
for region in &previous {
|
||||
assert!(region.iter().all(|&i| labels[i] == labels[region[0]]), "case {case}, noise {noise}: split {region:?}");
|
||||
}
|
||||
for (before, after) in original.iter().zip(&labels) {
|
||||
assert_eq!(*before == TRANSPARENT, *after == TRANSPARENT);
|
||||
}
|
||||
let current = noise_regions(&labels, 7, 7);
|
||||
assert!(current.len() <= previous.len(), "case {case}, noise {noise}: {} -> {}", previous.len(), current.len());
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_threshold_is_strict_and_transparent_islands_survive() {
|
||||
let original = vec![0, 0, 1, 1, 1, TRANSPARENT, 2];
|
||||
let mut labels = original.clone();
|
||||
denoise(&mut labels, 7, 1, 2);
|
||||
assert_eq!(labels, original, "regions equal to Noise are retained");
|
||||
denoise(&mut labels, 7, 1, 3);
|
||||
assert_eq!(labels, [1, 1, 1, 1, 1, TRANSPARENT, 2]);
|
||||
denoise(&mut labels, 7, 1, 100);
|
||||
assert_eq!(labels, [1, 1, 1, 1, 1, TRANSPARENT, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denoise_invalid_grid_is_left_unchanged() {
|
||||
let original = vec![0, 1, 1];
|
||||
for (w, h) in [(0, 3), (3, 0), (2, 2), (usize::MAX, 2), (1, 2)] {
|
||||
let mut labels = original.clone();
|
||||
denoise(&mut labels, w, h, 10);
|
||||
assert_eq!(labels, original);
|
||||
}
|
||||
}
|
||||
|
||||
/// A blue-to-orange sky over flat colours, and a green-to-magenta ramp in a rectangle on them.
|
||||
fn sky_and_ramp() -> Raster {
|
||||
Raster::from_fn(240, 160, |x, y| {
|
||||
|
||||
@@ -6,21 +6,45 @@ What Image Trace (`crates/trace`) makes of images whose right answer is known, m
|
||||
compared with the numbers before each change. Nothing here is a judgement by eye, and nothing is compared
|
||||
with any other product except the open tracer named under [Yardstick](#4-yardstick).
|
||||
|
||||
## Pieces returning to a junction
|
||||
|
||||
## Noise threshold stability (local development, 2026-10-14)
|
||||
|
||||
Noise must merge whole 4-connected regions, including pixels absorbed by an earlier merge.
|
||||
A count of regions alone cannot establish this: one old region may split while others disappear.
|
||||
The repeated-pass implementation at `a0671777` fails a partition regression at case 0, Noise 3.
|
||||
|
||||
The replacement discovers the initial regions, processes them in deterministic increasing-area
|
||||
order, and keeps their membership current. All adjacent regions of the chosen colour join the
|
||||
merge, with ties resolved by label and region id. Increasing Noise continues the same merge
|
||||
sequence: every lower-threshold region remains wholly inside one higher-threshold region.
|
||||
Regions exactly at the threshold stay; an opaque island surrounded by transparency stays;
|
||||
transparent pixels are never recoloured. A merged region is tested at its current area, so a
|
||||
1-pixel speck joining a 2-pixel region meets Noise 3 and is retained as one 3-pixel region.
|
||||
|
||||
Tests generate 64 multicolour 7 × 7 grids with transparent barriers and check every threshold
|
||||
from 0 through 24 with an independent component traversal. They assert nested partitions,
|
||||
non-increasing region counts and unchanged transparency. Separate cases check the strict
|
||||
threshold, isolated opaque islands and invalid or overflowing grid dimensions. The existing
|
||||
no-small-region and traced-path regressions remain active.
|
||||
|
||||
This uses region and heap bookkeeping instead of repeated full-image passes, so memory and
|
||||
photo quality must also be checked. Local photo/scan validation uses fixed hashed inputs and
|
||||
identical settings on unchanged current source and the candidate, including separate portrait
|
||||
crops and white-composited PNG comparisons. These local fixtures are not repository assets.
|
||||
The parity score is unchanged.
|
||||
|
||||
## Pieces returning to a junction
|
||||
An outline piece can return to its starting junction while being fitted as an open chain. At loose
|
||||
Corners/Paths settings, a nonzero positioned boundary could collapse to one cubic and lose a small
|
||||
corner-touching mark. Fixed-end pieces with identical endpoints and fewer than two fitted segments
|
||||
now retain their positioned boundary when its area is finite and nonzero. Fitting the shared piece
|
||||
this way also gives an abutting fill exactly the reversed boundary, including within a compound path.
|
||||
|
||||
Six source-generated regressions check 1- and 2-pixel regions through four rotations, both windings
|
||||
and large translations; fixed junctions; zero positioned area; free ends; Noise controls; and shared
|
||||
edges through four rotations and black/white inversion. Noise 2 still removes the 1-pixel mark.
|
||||
The white-hole Noise check is a control, not a reproduced returning-hole case. Defaults are unchanged;
|
||||
these tests establish survival and edge consistency, not a new artistic-quality or parity score.
|
||||
This is separate from the closed-contour recovery in #2012 and straightening guard in #2025.
|
||||
|
||||
## Thin closed outlines with Snap Curves To Lines
|
||||
|
||||
A four-pixel stair-step mark can fit as two straight cubics along the same diagonal, leaving a closed path with zero area. The outline-distance checks accept this because both sides are close to the chord. Closed fitting now checks analytic curve area after straightening and upright levelling; if a nondegenerate source loop has lost its interior, it retains the positioned boundary as an unsimplified polygon. The area integral is translated to a local origin to reduce cancellation. This numerical degeneracy check is not a new Noise or minimum-feature-area setting.
|
||||
|
||||
@@ -694,6 +694,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
| 2026-10-14 | minor | Quick Image Trace presets reveal the adjustment panel with Advanced open; Properties reopens it. Sliders keep their preset row until release so the re-trace is committed. UI regressions added; scores and remaining estimates unchanged. |
|
||||
| 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding |
|
||||
| 2026-10-14 | minor | Evidence for the later merges of 2026-10-14 that did not write their own: anchor X/Y/W/H (#1839), Snap to Grid over Snap to Pixel (#1840), guides in a marquee (#1836), the toolbar and All Tools drawer (#1751), the Actions panel selection (#1853), redo during a drag (#1856), preferences written on OK (#1857), character styles from a selected range (#1634), type in symbols after reopening (#1833), swatches in opacity masks (#1830), macOS save sheets (#1855, the Format menu withdrawn), shared stroke outlines (#1667), SVG and DXF fixes (#1799, #1834, #1870, #1789), untrusted files (#1852, #1862, #1863, #1871–#1873), failed saves (#1865), effects and Dim Images (#1864), krilla (#1874) and the wasm gate (#1875). #1630, #1631, #1632, #1633, #1682, #1687–#1690, #1744, #1750, #1755, #1767, #1782, #1784, #1792, #1809, #1814 and #1850 wrote their own evidence and are not repeated. A note above the 2026-10-05 table says its duplicated "Selection, transform & align tools" and "Drawing tools" rows need the owner's judgement. Scores and hours unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Local Image Trace Noise change: increasing-area whole-region merges preserve nested region partitions across thresholds, checked independently on 64 synthetic grids. See `image-trace-quality.md`; score and hours unchanged. |
|
||||
| 2026-10-14 | minor | Image Trace: closed outlines keep their interior when straightening would collapse them; generated mark/hole/winding/Noise regressions; score and hours unchanged |
|
||||
| 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: Shapes rework (#1845), square stroke caps (#1846), enhanced presets (#1847), gradients grown over noise and soft rims (#1848). Score and hours unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Existing-guide Escape cancellation: eight app-input regressions, the Direct Selection Escape control and current full CI passed; scores and hours unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user