mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-25 21:41:21 -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).
|
* 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.
|
* 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`).
|
* 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
|
## 1.0.0-alpha.1 - 2026-07-24
|
||||||
|
|||||||
@@ -141,10 +141,8 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn frontend(&self) -> Box<dyn Frontend> {
|
fn frontend(&self) -> Box<dyn Frontend> {
|
||||||
let filter_speckle_area = self.filter_speckle * self.filter_speckle;
|
|
||||||
match self.color_mode {
|
match self.color_mode {
|
||||||
ColorMode::Color => Box::new(ColorClusterFrontend {
|
ColorMode::Color => Box::new(ColorClusterFrontend {
|
||||||
filter_speckle_area,
|
|
||||||
color_precision_loss: 8 - self.color_precision,
|
color_precision_loss: 8 - self.color_precision,
|
||||||
layer_difference: self.layer_difference,
|
layer_difference: self.layer_difference,
|
||||||
}),
|
}),
|
||||||
@@ -158,7 +156,6 @@ impl Config {
|
|||||||
Threshold::Fixed(self.binary_threshold)
|
Threshold::Fixed(self.binary_threshold)
|
||||||
};
|
};
|
||||||
Box::new(BinaryFrontend {
|
Box::new(BinaryFrontend {
|
||||||
filter_speckle_area,
|
|
||||||
threshold,
|
threshold,
|
||||||
diagonal: false,
|
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>> {
|
fn color_fitters(&self) -> Vec<Box<dyn ColorFitter>> {
|
||||||
if !self.palette.is_empty() {
|
if !self.palette.is_empty() {
|
||||||
vec![
|
vec![
|
||||||
@@ -245,6 +247,7 @@ impl Config {
|
|||||||
compositing,
|
compositing,
|
||||||
optimizers: self.optimizers(),
|
optimizers: self.optimizers(),
|
||||||
writer: self.writer(),
|
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
|
/// Binary (black/white) frontend: threshold the image then cluster the
|
||||||
/// foreground. Every region is painted black.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BinaryFrontend {
|
pub struct BinaryFrontend {
|
||||||
/// Discard clusters smaller than this many pixels.
|
|
||||||
pub filter_speckle_area: usize,
|
|
||||||
/// How foreground pixels are selected.
|
/// How foreground pixels are selected.
|
||||||
pub threshold: Threshold,
|
pub threshold: Threshold,
|
||||||
/// Whether to connect clusters diagonally.
|
/// Whether to connect clusters diagonally.
|
||||||
@@ -65,7 +67,6 @@ pub struct BinaryFrontend {
|
|||||||
impl Default for BinaryFrontend {
|
impl Default for BinaryFrontend {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
filter_speckle_area: 16,
|
|
||||||
threshold: Threshold::default(),
|
threshold: Threshold::default(),
|
||||||
diagonal: false,
|
diagonal: false,
|
||||||
}
|
}
|
||||||
@@ -137,19 +138,17 @@ impl Frontend for BinaryFrontend {
|
|||||||
let black = Color::new(0, 0, 0);
|
let black = Color::new(0, 0, 0);
|
||||||
for i in 0..clusters.len() {
|
for i in 0..clusters.len() {
|
||||||
let cluster = clusters.get_cluster(i);
|
let cluster = clusters.get_cluster(i);
|
||||||
if cluster.size() >= self.filter_speckle_area {
|
let mask = RegionMask::new(
|
||||||
let mask = RegionMask::new(
|
cluster.to_binary_image(),
|
||||||
cluster.to_binary_image(),
|
PointI32 {
|
||||||
PointI32 {
|
x: cluster.rect.left,
|
||||||
x: cluster.rect.left,
|
y: cluster.rect.top,
|
||||||
y: cluster.rect.top,
|
},
|
||||||
},
|
);
|
||||||
);
|
seg.layers.push(Layer {
|
||||||
seg.layers.push(Layer {
|
paint: Paint::Solid(black),
|
||||||
paint: Paint::Solid(black),
|
mask,
|
||||||
mask,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(seg)
|
Ok(seg)
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ use super::keying::{apply_key, find_unused_color, should_key_image};
|
|||||||
use super::Frontend;
|
use super::Frontend;
|
||||||
|
|
||||||
/// Hierarchical color-clustering frontend — the classic VTracer color path.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ColorClusterFrontend {
|
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).
|
/// Bits of color precision dropped when comparing pixels (0 = full 8-bit).
|
||||||
pub color_precision_loss: i32,
|
pub color_precision_loss: i32,
|
||||||
/// Color difference between hierarchical gradient layers.
|
/// Color difference between hierarchical gradient layers.
|
||||||
@@ -27,7 +30,6 @@ pub struct ColorClusterFrontend {
|
|||||||
impl Default for ColorClusterFrontend {
|
impl Default for ColorClusterFrontend {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
filter_speckle_area: 16,
|
|
||||||
color_precision_loss: 2,
|
color_precision_loss: 2,
|
||||||
layer_difference: 16,
|
layer_difference: 16,
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,8 @@ impl ColorClusterFrontend {
|
|||||||
diagonal: self.layer_difference == 0,
|
diagonal: self.layer_difference == 0,
|
||||||
hierarchical: HIERARCHICAL_MAX,
|
hierarchical: HIERARCHICAL_MAX,
|
||||||
batch_size: 25600,
|
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,
|
good_max_area: width * height,
|
||||||
is_same_color_a: self.color_precision_loss,
|
is_same_color_a: self.color_precision_loss,
|
||||||
is_same_color_b: 1,
|
is_same_color_b: 1,
|
||||||
|
|||||||
@@ -97,4 +97,17 @@ impl Segmentation {
|
|||||||
layers: Vec::new(),
|
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 compositing: Compositing,
|
||||||
pub optimizers: Vec<Box<dyn OptimizerPass>>,
|
pub optimizers: Vec<Box<dyn OptimizerPass>>,
|
||||||
pub writer: SvgWriter,
|
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 {
|
impl Pipeline {
|
||||||
@@ -53,11 +57,12 @@ impl Pipeline {
|
|||||||
/// and return a reusable [`Segmentation`].
|
/// and return a reusable [`Segmentation`].
|
||||||
///
|
///
|
||||||
/// Cache the result and feed it to [`finish`](Pipeline::finish) to
|
/// Cache the result and feed it to [`finish`](Pipeline::finish) to
|
||||||
/// re-render with different color-fitting, curve-fitting, or optimization
|
/// re-render with different speckle, color-fitting, curve-fitting, or
|
||||||
/// parameters *without repaying the clustering cost* — the core of an
|
/// optimization parameters *without repaying the clustering cost* — the
|
||||||
/// interactive tuning loop. Only re-run `segment` when a parameter that
|
/// core of an interactive tuning loop. (`filter_speckle` is a finish-phase
|
||||||
/// affects clustering itself changes (color precision, layer difference,
|
/// area filter, so it too is tunable on a cached segmentation.) Only re-run
|
||||||
/// speckle filter, binary threshold, the frontend choice).
|
/// `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> {
|
pub fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||||
self.segment_with_progress(img, &CancelToken::new(), &mut |_| {})
|
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
|
/// segmentation. Shared by the one-shot and two-phase entry points; takes
|
||||||
/// ownership so the one-shot path avoids a clone.
|
/// ownership so the one-shot path avoids a clone.
|
||||||
fn finish_ctx(&self, mut seg: Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
|
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 {
|
for fitter in &self.color_fitters {
|
||||||
fitter.fit(&mut seg);
|
fitter.fit(&mut seg);
|
||||||
ctx.check()?;
|
ctx.check()?;
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ fn fixed_threshold_is_tunable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let front = |v: u8| BinaryFrontend {
|
let front = |v: u8| BinaryFrontend {
|
||||||
filter_speckle_area: 1,
|
|
||||||
threshold: Threshold::Fixed(v),
|
threshold: Threshold::Fixed(v),
|
||||||
diagonal: false,
|
diagonal: false,
|
||||||
};
|
};
|
||||||
@@ -80,7 +79,6 @@ fn adaptive_beats_fixed_under_uneven_lighting() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let base = BinaryFrontend {
|
let base = BinaryFrontend {
|
||||||
filter_speckle_area: 4,
|
|
||||||
threshold: Threshold::Fixed(128),
|
threshold: Threshold::Fixed(128),
|
||||||
diagonal: false,
|
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
|
// 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
|
// 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.
|
// 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 fixed_area: usize = fixed_seg.layers.iter().map(|l| l.mask.area()).sum();
|
||||||
let mid = (w as i32) / 2;
|
let mid = (w as i32) / 2;
|
||||||
let fixed_right = fixed_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
|
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()
|
..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_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_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);
|
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"
|
"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