mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-13 07:36:02 -07:00
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).
This commit is contained in:
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### 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.
|
||||
* Thread-like (sub-~2px-thick) region filtering is now a toggleable finish-phase step (`Segmentation::filter_thin`, `Config::filter_thin`, on by default). This restores the thin-strand rejection that visioncortex applied during clustering — which `good_min_area = 0` had disabled — but as a control that can be tuned on a cached segmentation. Exposed via the CLI (`--keep-thin`), Python (`filter_thin`), and Node (`filterThin`).
|
||||
* 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
|
||||
|
||||
@@ -106,6 +106,10 @@ struct Args {
|
||||
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
|
||||
#[arg(long)]
|
||||
adaptive_t: Option<f64>,
|
||||
|
||||
/// Keep thread-like (sub-~2px-thick) regions instead of filtering them out.
|
||||
#[arg(long)]
|
||||
keep_thin: bool,
|
||||
}
|
||||
|
||||
fn parse_segment_length(s: &str) -> Result<f64, String> {
|
||||
@@ -199,6 +203,9 @@ fn build_config(args: &Args) -> Result<Config, String> {
|
||||
if let Some(v) = args.adaptive_t {
|
||||
config.binary_adaptive_t = v;
|
||||
}
|
||||
if args.keep_thin {
|
||||
config.filter_thin = false;
|
||||
}
|
||||
|
||||
// Palette: inline flag wins over file; both parse to a color list.
|
||||
if let Some(text) = &args.palette {
|
||||
|
||||
@@ -145,6 +145,7 @@ impl PyConfig {
|
||||
adaptive = false,
|
||||
adaptive_window = 0,
|
||||
adaptive_t = 15.0,
|
||||
filter_thin = true,
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
@@ -166,6 +167,7 @@ impl PyConfig {
|
||||
adaptive: bool,
|
||||
adaptive_window: u32,
|
||||
adaptive_t: f64,
|
||||
filter_thin: bool,
|
||||
) -> PyResult<Self> {
|
||||
let palette = match palette {
|
||||
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
||||
@@ -191,6 +193,7 @@ impl PyConfig {
|
||||
binary_adaptive: adaptive,
|
||||
binary_adaptive_window: adaptive_window,
|
||||
binary_adaptive_t: adaptive_t,
|
||||
filter_thin,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -381,6 +384,15 @@ impl PyConfig {
|
||||
self.inner.binary_adaptive_t = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn filter_thin(&self) -> bool {
|
||||
self.inner.filter_thin
|
||||
}
|
||||
#[setter]
|
||||
fn set_filter_thin(&mut self, v: bool) {
|
||||
self.inner.filter_thin = v;
|
||||
}
|
||||
|
||||
// --- conversion ---
|
||||
|
||||
/// Trace the image at `input_path` and write the SVG to `output_path`.
|
||||
|
||||
@@ -81,6 +81,9 @@ pub struct Config {
|
||||
pub binary_adaptive_window: u32,
|
||||
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
|
||||
pub binary_adaptive_t: f64,
|
||||
/// Drop thread-like (sub-~2px-thick) regions. On by default, matching the
|
||||
/// pre-1.0 clustering-time behavior; applied in the finish phase.
|
||||
pub filter_thin: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -104,6 +107,7 @@ impl Default for Config {
|
||||
binary_adaptive: false,
|
||||
binary_adaptive_window: 0,
|
||||
binary_adaptive_t: 15.0,
|
||||
filter_thin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +252,7 @@ impl Config {
|
||||
optimizers: self.optimizers(),
|
||||
writer: self.writer(),
|
||||
speckle_area: self.speckle_area(),
|
||||
filter_thin: self.filter_thin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use visioncortex::{BinaryImage, PointI32};
|
||||
use visioncortex::{BinaryImage, PointI32, Shape};
|
||||
|
||||
use super::Paint;
|
||||
|
||||
@@ -40,6 +40,19 @@ impl RegionMask {
|
||||
count
|
||||
}
|
||||
|
||||
/// Boundary length, using the same metric as visioncortex's clustering
|
||||
/// (`Shape::image_boundary_list`) so the thread-like test matches.
|
||||
pub fn perimeter(&self) -> usize {
|
||||
Shape::image_boundary_list(&self.image).len()
|
||||
}
|
||||
|
||||
/// Whether the region is "thread-like" (average thickness under ~2px), by
|
||||
/// the visioncortex heuristic `perimeter >= area`. Small compact regions
|
||||
/// also qualify, matching the original clustering-time filter.
|
||||
pub fn is_thin(&self) -> bool {
|
||||
self.perimeter() >= self.area()
|
||||
}
|
||||
|
||||
/// Combine two masks into one covering the union of their bounding boxes.
|
||||
/// Foreground is the OR of both; this is used by the layer-merge step.
|
||||
pub fn union(&self, other: &RegionMask) -> RegionMask {
|
||||
@@ -110,4 +123,12 @@ impl Segmentation {
|
||||
}
|
||||
self.layers.retain(|layer| layer.mask.area() >= min_area);
|
||||
}
|
||||
|
||||
/// Drop "thread-like" layers — regions thinner than ~2px on average (see
|
||||
/// [`RegionMask::is_thin`]). This reproduces the thin-strand rejection that
|
||||
/// visioncortex clustering applied when `good_min_area > 0`; it lives here,
|
||||
/// in the finish phase, so it can be toggled on a cached segmentation.
|
||||
pub fn filter_thin(&mut self) {
|
||||
self.layers.retain(|layer| !layer.mask.is_thin());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ pub struct Pipeline {
|
||||
/// phase — not the frontend — so it can be retuned on a cached
|
||||
/// [`Segmentation`] without re-clustering.
|
||||
pub speckle_area: usize,
|
||||
/// Drop thread-like (thinner than ~2px) regions in the `finish` phase.
|
||||
/// Reproduces visioncortex's clustering-time thin-strand rejection, but
|
||||
/// toggleable on a cached [`Segmentation`].
|
||||
pub filter_thin: bool,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
@@ -108,6 +112,9 @@ impl Pipeline {
|
||||
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);
|
||||
if self.filter_thin {
|
||||
seg.filter_thin();
|
||||
}
|
||||
ctx.check()?;
|
||||
|
||||
for fitter in &self.color_fitters {
|
||||
|
||||
@@ -120,15 +120,18 @@ fn tune_speckle_on_cached_segmentation() {
|
||||
};
|
||||
|
||||
// filter_speckle no longer affects the frontend, so these share a
|
||||
// segmentation: cluster once, filter differently in finish.
|
||||
// segmentation: cluster once, filter differently in finish. Disable the
|
||||
// thin filter so speckle area is the only thing varying.
|
||||
let keep = Config {
|
||||
filter_speckle: 1, // area 1 → keep the 4px dots
|
||||
filter_thin: false,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap();
|
||||
let drop = Config {
|
||||
filter_speckle: 3, // area 9 → drop the 4px dots
|
||||
filter_thin: false,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
@@ -144,3 +147,57 @@ fn tune_speckle_on_cached_segmentation() {
|
||||
keep={n_keep}, drop={n_drop}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The thin-strand filter is also a finish-phase toggle on a cached
|
||||
/// segmentation: a 2px-wide strand survives with `filter_thin = false` and is
|
||||
/// dropped with `filter_thin = true`, while the compact block always survives.
|
||||
#[test]
|
||||
fn tune_thin_filter_on_cached_segmentation() {
|
||||
let (w, h) = (40usize, 40usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = if (4..24).contains(&x) && (4..24).contains(&y) {
|
||||
(40u8, 120, 220) // 20x20 compact block (not thin)
|
||||
} else if (30..32).contains(&x) && (8..28).contains(&y) {
|
||||
(220, 40, 40) // 2x20 thread-like strand
|
||||
} else {
|
||||
(245, 245, 245) // background
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
let img = ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
|
||||
// Small speckle area so the 40px strand isn't removed by the area filter;
|
||||
// only filter_thin varies between the two.
|
||||
let base = Config {
|
||||
filter_speckle: 1,
|
||||
..Config::default()
|
||||
};
|
||||
let keep_thin = Config {
|
||||
filter_thin: false,
|
||||
..base.clone()
|
||||
}
|
||||
.build()
|
||||
.unwrap();
|
||||
let drop_thin = Config {
|
||||
filter_thin: true,
|
||||
..base
|
||||
}
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let seg = keep_thin.segment(&img).unwrap(); // cluster once
|
||||
let n_keep = keep_thin.finish(&seg).unwrap().shapes.len();
|
||||
let n_drop = drop_thin.finish(&seg).unwrap().shapes.len(); // same cached seg
|
||||
|
||||
assert!(
|
||||
n_keep > n_drop,
|
||||
"filter_thin should drop the thread-like strand: keep={n_keep}, drop={n_drop}"
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -27,6 +27,8 @@ export interface Options {
|
||||
adaptiveWindow?: number;
|
||||
/** Adaptive sensitivity: percent below the local mean (default 15). */
|
||||
adaptiveT?: number;
|
||||
/** Drop thread-like (sub-~2px-thick) regions. Default true. */
|
||||
filterThin?: boolean;
|
||||
}
|
||||
|
||||
/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */
|
||||
|
||||
@@ -37,6 +37,8 @@ struct Options {
|
||||
adaptive_window: Option<u32>,
|
||||
/// Adaptive sensitivity: percent below the local mean (default 15).
|
||||
adaptive_t: Option<f64>,
|
||||
/// Drop thread-like (sub-~2px-thick) regions (default true).
|
||||
filter_thin: Option<bool>,
|
||||
/// One of "bw" | "poster" | "photo"; applied before the other fields.
|
||||
preset: Option<String>,
|
||||
}
|
||||
@@ -126,6 +128,9 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
|
||||
if let Some(v) = opts.adaptive_t {
|
||||
config.binary_adaptive_t = v;
|
||||
}
|
||||
if let Some(v) = opts.filter_thin {
|
||||
config.filter_thin = v;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user