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.
This commit is contained in:
Chris Tsang
2026-07-25 01:06:25 +01:00
parent 5361a51011
commit eb68057b13
8 changed files with 117 additions and 32 deletions
+4
View File
@@ -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 BradleyRoth 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
+6 -3
View File
@@ -141,10 +141,8 @@ impl Config {
}
fn frontend(&self) -> Box<dyn Frontend> {
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<Box<dyn ColorFitter>> {
if !self.palette.is_empty() {
vec![
@@ -245,6 +247,7 @@ impl Config {
compositing,
optimizers: self.optimizers(),
writer: self.writer(),
speckle_area: self.speckle_area(),
})
}
}
+15 -16
View File
@@ -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)
+7 -4
View File
@@ -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,
+13
View File
@@ -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);
}
}
+14 -5
View File
@@ -19,6 +19,10 @@ pub struct Pipeline {
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
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<Segmentation, Error> {
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<VectorDoc, Error> {
// 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()?;
+5 -4
View File
@@ -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);
+53
View File
@@ -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}"
);
}