Fix Image Trace Noise splitting previously merged regions

Squashed from #1368 onto main.
This commit is contained in:
bflatastic
2026-10-16 15:27:54 +07:00
parent 84bd59de36
commit 05b160b32e
5 changed files with 193 additions and 72 deletions
+3 -2
View File
@@ -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
+78 -65
View File
@@ -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 &regions[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
}
+84 -2
View File
@@ -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| {
+27 -3
View File
@@ -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.
+1
View File
@@ -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. |