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:
Chris Tsang
2026-07-25 01:06:25 +01:00
parent 5361a51011
commit abe21658dc
8 changed files with 67 additions and 33 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
* 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. (Speckle, color precision, and layer difference are clustering parameters and require a fresh `segment`.) Both have `*_with_progress` variants.
* 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
+20 -4
View File
@@ -49,9 +49,7 @@ Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/v
## Desktop App (coming soon)
![screenshot](docs/images/screenshot-01.png)
![screenshot](docs/images/screenshot-02.png)
![screenshot](docs/images/desktop-app.png)
## Cmd App
@@ -90,6 +88,10 @@ Options:
--palette-file <PALETTE_FILE> Fixed palette from a file (hex colors, comma/newline separated)
--max-colors <MAX_COLORS> Auto-quantize to at most N colors
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands
--threshold <THRESHOLD> Binary mode: fixed threshold 0..=255 (foreground below it)
--adaptive Binary mode: BradleyRoth adaptive threshold (uneven lighting)
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
-h, --help Print help
-V, --version Print version
```
@@ -102,7 +104,11 @@ Options:
- **`--palette` / `--palette-file`** — snap colors to a fixed palette
(nearest in OKLab); **`--max-colors`** auto-quantizes the palette.
- **`--optimize`** — output size passes (coordinate quantization, redundant-
point removal, relative/shorthand path encoding).
point removal, relative/shorthand path encoding). Note: coordinate
precision is set separately by `--path-precision`, the bigger size lever.
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
**BradleyRoth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
/ `--adaptive-t`) for scans with uneven lighting.
## Downloads
@@ -125,6 +131,9 @@ cargo install vtracer-cli
# black & white line art
./vtracer input.jpg output.svg --preset bw
# scanned/photographed line art with uneven lighting
./vtracer scan.jpg output.svg --colormode bw --adaptive
# seam-free mosaic (gapless tessellation)
./vtracer input.jpg output.svg --hierarchical cutout
@@ -160,6 +169,10 @@ cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
svg = cfg.convert_bytes(data)
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
# binary with adaptive (BradleyRoth) thresholding
bw = vtracer.Config(color_mode="bw", adaptive=True)
svg = bw.convert_file("scan.jpg", "scan.svg")
```
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
@@ -178,6 +191,9 @@ const vtracer = require('@visioncortex/vtracer');
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
// binary with adaptive thresholding
const bw = vtracer.convertBuffer(buffer, { colorMode: 'bw', adaptive: true });
```
## Citations
+7 -3
View File
@@ -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![
+19 -15
View File
@@ -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)
+13 -4
View File
@@ -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,
+3 -3
View File
@@ -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 |_| {})
}
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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();