From eb68057b13cf855a3cbc3c2e99254f4e501a623a Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sat, 25 Jul 2026 01:06:25 +0100 Subject: [PATCH] Make filter_speckle a finish-phase filter, tunable without re-clustering Speckle removal moves out of the frontends into Segmentation::filter_speckle, applied in the finish phase. The color frontend now clusters with good_min_area = 0 and the binary frontend emits every cluster, so the cached segmentation retains all regions and the speckle threshold can be retuned via finish() with no re-clustering. Pipeline gains a speckle_area field (Config sets it from filter_speckle^2). Frontend structs drop their filter_speckle_area field. Output on clean images is unchanged (golden/equivalence pass unblessed); noisy images are filtered downstream instead of during clustering. Adds a test tuning filter_speckle on one cached segmentation. --- CHANGELOG.md | 4 ++ crates/vtracer/src/config.rs | 9 ++-- crates/vtracer/src/frontend/binary.rs | 31 ++++++------ crates/vtracer/src/frontend/color_cluster.rs | 11 ++-- crates/vtracer/src/ir/region.rs | 13 +++++ crates/vtracer/src/pipeline.rs | 19 +++++-- crates/vtracer/tests/binary_threshold.rs | 9 ++-- crates/vtracer/tests/reuse.rs | 53 ++++++++++++++++++++ 8 files changed, 117 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9a109..a0cfb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Progress reporting and cancellation: `Pipeline::run_with_progress` with a `CancelToken` and a per-phase progress callback (for driving desktop UIs from a worker thread). * Two-phase conversion for interactive tuning: `Pipeline::segment` caches the expensive clustering result as a reusable `Segmentation`, and `Pipeline::finish` re-runs only the cheap color-fitting / curve-fitting / optimization stages — so tuning those parameters no longer repays the clustering cost. Both have `*_with_progress` variants. + +### Changed + +* `filter_speckle` is now a finish-phase area filter (`Segmentation::filter_speckle`) rather than a clustering parameter: frontends keep every region (`good_min_area = 0`), so the speckle threshold can be retuned on a cached segmentation without re-clustering. Output on clean images is unchanged; images with sub-threshold noise are filtered downstream instead of during clustering. * Binary thresholding methods: a tunable fixed threshold and Bradley–Roth adaptive thresholding (via visioncortex's summed-area table) for images with uneven lighting. Exposed on `Config` (`binary_threshold`, `binary_adaptive`, `binary_adaptive_window`, `binary_adaptive_t`), the CLI (`--threshold`, `--adaptive`, `--adaptive-window`, `--adaptive-t`), Python, and the Node package (`binaryThreshold`, `adaptive`, `adaptiveWindow`, `adaptiveT`). ## 1.0.0-alpha.1 - 2026-07-24 diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 6a21591..73ef1c5 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -141,10 +141,8 @@ impl Config { } fn frontend(&self) -> Box { - let filter_speckle_area = self.filter_speckle * self.filter_speckle; match self.color_mode { ColorMode::Color => Box::new(ColorClusterFrontend { - filter_speckle_area, color_precision_loss: 8 - self.color_precision, layer_difference: self.layer_difference, }), @@ -158,7 +156,6 @@ impl Config { Threshold::Fixed(self.binary_threshold) }; Box::new(BinaryFrontend { - filter_speckle_area, threshold, diagonal: false, }) @@ -166,6 +163,11 @@ impl Config { } } + /// Speckle filter area (px), applied in the `finish` phase. + fn speckle_area(&self) -> usize { + self.filter_speckle * self.filter_speckle + } + fn color_fitters(&self) -> Vec> { if !self.palette.is_empty() { vec![ @@ -245,6 +247,7 @@ impl Config { compositing, optimizers: self.optimizers(), writer: self.writer(), + speckle_area: self.speckle_area(), }) } } diff --git a/crates/vtracer/src/frontend/binary.rs b/crates/vtracer/src/frontend/binary.rs index d8bfe1e..5f72e95 100644 --- a/crates/vtracer/src/frontend/binary.rs +++ b/crates/vtracer/src/frontend/binary.rs @@ -52,10 +52,12 @@ impl Default for Threshold { /// Binary (black/white) frontend: threshold the image then cluster the /// foreground. Every region is painted black. +/// +/// Like the color frontend, this keeps every cluster; speckle filtering is a +/// downstream, by-area step (see [`Segmentation::filter_speckle`]) so it can be +/// retuned without re-thresholding. #[derive(Debug, Clone)] pub struct BinaryFrontend { - /// Discard clusters smaller than this many pixels. - pub filter_speckle_area: usize, /// How foreground pixels are selected. pub threshold: Threshold, /// Whether to connect clusters diagonally. @@ -65,7 +67,6 @@ pub struct BinaryFrontend { impl Default for BinaryFrontend { fn default() -> Self { Self { - filter_speckle_area: 16, threshold: Threshold::default(), diagonal: false, } @@ -137,19 +138,17 @@ impl Frontend for BinaryFrontend { let black = Color::new(0, 0, 0); for i in 0..clusters.len() { let cluster = clusters.get_cluster(i); - if cluster.size() >= self.filter_speckle_area { - let mask = RegionMask::new( - cluster.to_binary_image(), - PointI32 { - x: cluster.rect.left, - y: cluster.rect.top, - }, - ); - seg.layers.push(Layer { - paint: Paint::Solid(black), - mask, - }); - } + let mask = RegionMask::new( + cluster.to_binary_image(), + PointI32 { + x: cluster.rect.left, + y: cluster.rect.top, + }, + ); + seg.layers.push(Layer { + paint: Paint::Solid(black), + mask, + }); } Ok(seg) diff --git a/crates/vtracer/src/frontend/color_cluster.rs b/crates/vtracer/src/frontend/color_cluster.rs index 3991dcd..f142ff0 100644 --- a/crates/vtracer/src/frontend/color_cluster.rs +++ b/crates/vtracer/src/frontend/color_cluster.rs @@ -14,10 +14,13 @@ use super::keying::{apply_key, find_unused_color, should_key_image}; use super::Frontend; /// Hierarchical color-clustering frontend — the classic VTracer color path. +/// +/// Speckle filtering is intentionally *not* done here: the runner keeps every +/// region (`good_min_area = 0`) so the resulting [`Segmentation`] can be cached +/// and re-filtered downstream by area (see [`Segmentation::filter_speckle`]), +/// letting the speckle threshold be tuned without re-clustering. #[derive(Debug, Clone)] pub struct ColorClusterFrontend { - /// Discard clusters smaller than this many pixels. - pub filter_speckle_area: usize, /// Bits of color precision dropped when comparing pixels (0 = full 8-bit). pub color_precision_loss: i32, /// Color difference between hierarchical gradient layers. @@ -27,7 +30,6 @@ pub struct ColorClusterFrontend { impl Default for ColorClusterFrontend { fn default() -> Self { Self { - filter_speckle_area: 16, color_precision_loss: 2, layer_difference: 16, } @@ -62,7 +64,8 @@ impl ColorClusterFrontend { diagonal: self.layer_difference == 0, hierarchical: HIERARCHICAL_MAX, batch_size: 25600, - good_min_area: self.filter_speckle_area, + // Keep every region; speckle filtering happens downstream by area. + good_min_area: 0, good_max_area: width * height, is_same_color_a: self.color_precision_loss, is_same_color_b: 1, diff --git a/crates/vtracer/src/ir/region.rs b/crates/vtracer/src/ir/region.rs index 1373c95..8636579 100644 --- a/crates/vtracer/src/ir/region.rs +++ b/crates/vtracer/src/ir/region.rs @@ -97,4 +97,17 @@ impl Segmentation { layers: Vec::new(), } } + + /// Drop layers whose covered area is below `min_area` pixels (speckle + /// removal). A no-op when `min_area == 0`. + /// + /// This runs downstream of the frontend, so the speckle threshold can be + /// retuned on a cached segmentation without re-clustering. Dropping a layer + /// exposes whatever is painted beneath it in the stack. + pub fn filter_speckle(&mut self, min_area: usize) { + if min_area == 0 { + return; + } + self.layers.retain(|layer| layer.mask.area() >= min_area); + } } diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs index bb7e916..5abdc34 100644 --- a/crates/vtracer/src/pipeline.rs +++ b/crates/vtracer/src/pipeline.rs @@ -19,6 +19,10 @@ pub struct Pipeline { pub compositing: Compositing, pub optimizers: Vec>, pub writer: SvgWriter, + /// Speckle filter, in pixels of area (0 = off). Applied in the `finish` + /// phase — not the frontend — so it can be retuned on a cached + /// [`Segmentation`] without re-clustering. + pub speckle_area: usize, } impl Pipeline { @@ -53,11 +57,12 @@ impl Pipeline { /// and return a reusable [`Segmentation`]. /// /// Cache the result and feed it to [`finish`](Pipeline::finish) to - /// re-render with different color-fitting, curve-fitting, or optimization - /// parameters *without repaying the clustering cost* — the core of an - /// interactive tuning loop. Only re-run `segment` when a parameter that - /// affects clustering itself changes (color precision, layer difference, - /// speckle filter, binary threshold, the frontend choice). + /// re-render with different speckle, color-fitting, curve-fitting, or + /// optimization parameters *without repaying the clustering cost* — the + /// core of an interactive tuning loop. (`filter_speckle` is a finish-phase + /// area filter, so it too is tunable on a cached segmentation.) Only re-run + /// `segment` when a parameter that affects clustering itself changes: color + /// precision, layer difference, binary threshold, or the frontend choice. pub fn segment(&self, img: &ColorImage) -> Result { self.segment_with_progress(img, &CancelToken::new(), &mut |_| {}) } @@ -101,6 +106,10 @@ impl Pipeline { /// segmentation. Shared by the one-shot and two-phase entry points; takes /// ownership so the one-shot path avoids a clone. fn finish_ctx(&self, mut seg: Segmentation, ctx: &mut Ctx) -> Result { + // Speckle removal first, so noise doesn't feed color fitting/merging. + seg.filter_speckle(self.speckle_area); + ctx.check()?; + for fitter in &self.color_fitters { fitter.fit(&mut seg); ctx.check()?; diff --git a/crates/vtracer/tests/binary_threshold.rs b/crates/vtracer/tests/binary_threshold.rs index 20589d5..2eafc7b 100644 --- a/crates/vtracer/tests/binary_threshold.rs +++ b/crates/vtracer/tests/binary_threshold.rs @@ -40,7 +40,6 @@ fn fixed_threshold_is_tunable() { }); let front = |v: u8| BinaryFrontend { - filter_speckle_area: 1, threshold: Threshold::Fixed(v), diagonal: false, }; @@ -80,7 +79,6 @@ fn adaptive_beats_fixed_under_uneven_lighting() { }); let base = BinaryFrontend { - filter_speckle_area: 4, threshold: Threshold::Fixed(128), diagonal: false, }; @@ -88,7 +86,9 @@ fn adaptive_beats_fixed_under_uneven_lighting() { // A global cutoff can't isolate both marks: 128 catches the dark-side mark // but floods the whole dark half of the ramp, and misses the bright-side // mark (~136) entirely — so fixed has no region on the bright half. - let fixed_seg = base.segment(&img).unwrap(); + // (Speckle filtering is now a downstream step; apply it explicitly.) + let mut fixed_seg = base.segment(&img).unwrap(); + fixed_seg.filter_speckle(4); let fixed_area: usize = fixed_seg.layers.iter().map(|l| l.mask.area()).sum(); let mid = (w as i32) / 2; let fixed_right = fixed_seg.layers.iter().any(|l| l.mask.offset.x >= mid); @@ -101,7 +101,8 @@ fn adaptive_beats_fixed_under_uneven_lighting() { }, ..base.clone() }; - let adaptive_seg = adaptive.segment(&img).unwrap(); + let mut adaptive_seg = adaptive.segment(&img).unwrap(); + adaptive_seg.filter_speckle(4); let adaptive_area: usize = adaptive_seg.layers.iter().map(|l| l.mask.area()).sum(); let adaptive_left = adaptive_seg.layers.iter().any(|l| l.mask.offset.x < mid); let adaptive_right = adaptive_seg.layers.iter().any(|l| l.mask.offset.x >= mid); diff --git a/crates/vtracer/tests/reuse.rs b/crates/vtracer/tests/reuse.rs index 579d155..de1076f 100644 --- a/crates/vtracer/tests/reuse.rs +++ b/crates/vtracer/tests/reuse.rs @@ -91,3 +91,56 @@ fn tune_curve_fitting_on_cached_segmentation() { "pixel and spline fitting should produce different paths" ); } + +/// `filter_speckle` is now a finish-phase area filter, so tuning it reuses the +/// cached segmentation — no re-clustering. A higher threshold drops the tiny +/// dots; the low threshold keeps them. +#[test] +fn tune_speckle_on_cached_segmentation() { + // White background, a 10x10 block, and four 2x2 dots (4px each). + let (w, h) = (32usize, 32usize); + let dots = [(4usize, 4usize), (4, 26), (26, 4), (26, 26)]; + let mut pixels = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let c = if (12..22).contains(&x) && (12..22).contains(&y) { + (220u8, 40, 40) // center block + } else if dots.iter().any(|&(dx, dy)| x >= dx && x < dx + 2 && y >= dy && y < dy + 2) { + (10, 10, 10) // dots + } else { + (245, 245, 245) // background + }; + pixels.extend_from_slice(&[c.0, c.1, c.2, 255]); + } + } + let img = ColorImage { + pixels, + width: w, + height: h, + }; + + // filter_speckle no longer affects the frontend, so these share a + // segmentation: cluster once, filter differently in finish. + let keep = Config { + filter_speckle: 1, // area 1 → keep the 4px dots + ..Config::default() + } + .build() + .unwrap(); + let drop = Config { + filter_speckle: 3, // area 9 → drop the 4px dots + ..Config::default() + } + .build() + .unwrap(); + + let seg = keep.segment(&img).unwrap(); // the expensive step, done once + let n_keep = keep.finish(&seg).unwrap().shapes.len(); + let n_drop = drop.finish(&seg).unwrap().shapes.len(); // same cached seg + + assert!( + n_keep > n_drop, + "raising filter_speckle should drop the tiny dots without re-clustering: \ + keep={n_keep}, drop={n_drop}" + ); +}