mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-13 07:36:02 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user