mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-26 14:01:18 -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. Add finish-phase thin-strand filter (restores thread-like rejection) good_min_area = 0 disabled visioncortex's thread-like rejection (which was gated on good_min_area > 0). Reintroduce it in our repo as a finish-phase step: Segmentation::filter_thin drops regions whose perimeter >= area (average thickness under ~2px), using the same Shape::image_boundary_list metric so the heuristic matches. It's toggleable on a cached segmentation (Config::filter_thin, on by default), unlike the clustering-time version. Exposed via CLI --keep-thin, Python filter_thin, and Node filterThin. Adds RegionMask::perimeter/is_thin and a reuse test toggling it on one cached segmentation. Clean-image goldens are unaffected (large regions aren't thin). README: document binary thresholding, --keep-thin, and finish-phase filters Add the new CLI flags (--threshold, --adaptive, --adaptive-window, --adaptive-t, --keep-thin) to the options block and "New in 1.0"; note that --optimize is encoding-only (precision is --path-precision) and that speckle/ thin filtering run after clustering. Add adaptive-threshold examples for CLI, Python, and Node. Return speckle/thin filtering to clustering (fix gum-tree regression) good_min_area is visioncortex's clustering `deepen` gate, not a speckle post-filter: it decides whether a small or thread-like patch is absorbed into its nearest-color neighbour or kept as its own layer, and it enables the thread-like rejection (perimeter < area). An earlier change set it to 0 to make filter_speckle "retunable downstream", which disabled the thin check and reshaped the whole hierarchy — dissolving gradient-boundary structure that clustering is meant to absorb. The Gum Tree preset's central trunk vanished at gradient-step ~26 where the pre-1.0 path held it to 128. Clean-logo goldens couldn't exercise it, so it shipped green. Follow the proven webapp model instead: speckle lives inside clustering (good_min_area = filter_speckle^2 for the colour frontend; a post-cluster size gate for the binary frontend). The segment/finish split stays — it is the progressive model (cluster once, re-run colour/curve/optimize cheaply); clustering params (speckle, colour precision, layer difference, binary threshold) re-segment. Remove the downstream band-aids: Segmentation::filter_speckle/filter_thin, RegionMask::perimeter/is_thin, the pipeline speckle_area/filter_thin fields, Config::filter_thin, CLI --keep-thin, Python filter_thin, Node filterThin. Update the two reuse tests that encoded the wrong contract and the binary threshold test to use BinaryFrontend::min_area. All goldens, equivalence, progress, and reuse tests pass.
This commit is contained in:
@@ -141,12 +141,11 @@ 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,
|
||||
good_min_area: self.speckle_area(),
|
||||
}),
|
||||
ColorMode::Binary => {
|
||||
let threshold = if self.binary_adaptive {
|
||||
@@ -158,14 +157,19 @@ impl Config {
|
||||
Threshold::Fixed(self.binary_threshold)
|
||||
};
|
||||
Box::new(BinaryFrontend {
|
||||
filter_speckle_area,
|
||||
threshold,
|
||||
diagonal: false,
|
||||
min_area: self.speckle_area(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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![
|
||||
|
||||
@@ -52,22 +52,25 @@ impl Default for Threshold {
|
||||
|
||||
/// Binary (black/white) frontend: threshold the image then cluster the
|
||||
/// foreground. Every region is painted black.
|
||||
///
|
||||
/// Speckle removal drops clusters smaller than `min_area` px as the clusters
|
||||
/// are collected, matching the pre-1.0 binary path (`cluster.size() >= area`).
|
||||
#[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.
|
||||
pub diagonal: bool,
|
||||
/// Discard clusters smaller than this many pixels (0 = keep all).
|
||||
pub min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for BinaryFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter_speckle_area: 16,
|
||||
threshold: Threshold::default(),
|
||||
diagonal: false,
|
||||
min_area: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,19 +140,20 @@ 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,
|
||||
});
|
||||
if cluster.size() < self.min_area {
|
||||
continue;
|
||||
}
|
||||
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,22 +14,31 @@ use super::keying::{apply_key, find_unused_color, should_key_image};
|
||||
use super::Frontend;
|
||||
|
||||
/// Hierarchical color-clustering frontend — the classic VTracer color path.
|
||||
///
|
||||
/// Speckle removal happens *inside* clustering, via `good_min_area`: it is the
|
||||
/// clusterer's `deepen` gate (visioncortex `patch_good`), so it does far more
|
||||
/// than drop small regions — it decides whether a small/thin patch is absorbed
|
||||
/// into its neighbor (its color averaged in) or kept as its own layer. Forcing
|
||||
/// it to 0 disables the thread-like rejection and changes the whole hierarchy,
|
||||
/// so speckle must be a clustering parameter, not a downstream filter.
|
||||
#[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.
|
||||
pub layer_difference: i32,
|
||||
/// Minimum area (px) for a patch to be a `deepen` candidate during
|
||||
/// clustering; non-zero also enables visioncortex's thread-like rejection.
|
||||
/// Below it, patches are absorbed into their nearest-color neighbor.
|
||||
pub good_min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for ColorClusterFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter_speckle_area: 16,
|
||||
color_precision_loss: 2,
|
||||
layer_difference: 16,
|
||||
good_min_area: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +71,7 @@ impl ColorClusterFrontend {
|
||||
diagonal: self.layer_difference == 0,
|
||||
hierarchical: HIERARCHICAL_MAX,
|
||||
batch_size: 25600,
|
||||
good_min_area: self.filter_speckle_area,
|
||||
good_min_area: self.good_min_area,
|
||||
good_max_area: width * height,
|
||||
is_same_color_a: self.color_precision_loss,
|
||||
is_same_color_b: 1,
|
||||
|
||||
@@ -55,9 +55,9 @@ impl Pipeline {
|
||||
/// 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).
|
||||
/// interactive tuning loop. Re-run `segment` when a parameter that affects
|
||||
/// clustering itself changes: filter speckle, 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 |_| {})
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ fn fixed_threshold_is_tunable() {
|
||||
});
|
||||
|
||||
let front = |v: u8| BinaryFrontend {
|
||||
filter_speckle_area: 1,
|
||||
threshold: Threshold::Fixed(v),
|
||||
diagonal: false,
|
||||
min_area: 0,
|
||||
};
|
||||
|
||||
let low = foreground_area(&front(100), &img); // catches only the 80 band
|
||||
@@ -80,9 +80,9 @@ fn adaptive_beats_fixed_under_uneven_lighting() {
|
||||
});
|
||||
|
||||
let base = BinaryFrontend {
|
||||
filter_speckle_area: 4,
|
||||
threshold: Threshold::Fixed(128),
|
||||
diagonal: false,
|
||||
min_area: 4,
|
||||
};
|
||||
|
||||
// A global cutoff can't isolate both marks: 128 catches the dark-side mark
|
||||
|
||||
@@ -65,7 +65,8 @@ fn cached_segmentation_is_reusable() {
|
||||
|
||||
/// The tuning workflow: segment once, then feed that segmentation to pipelines
|
||||
/// with different curve-fitting parameters. Same regions, different geometry —
|
||||
/// and no re-segmentation.
|
||||
/// and no re-segmentation. (Speckle, color precision, and layer difference are
|
||||
/// clustering parameters, so changing them requires a fresh `segment`.)
|
||||
#[test]
|
||||
fn tune_curve_fitting_on_cached_segmentation() {
|
||||
let img = blocks();
|
||||
|
||||
Reference in New Issue
Block a user