diff --git a/CHANGELOG.md b/CHANGELOG.md index a0cfb53..bd4a1e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/vtracer-cli/src/main.rs b/crates/vtracer-cli/src/main.rs index f2608d7..e18e467 100644 --- a/crates/vtracer-cli/src/main.rs +++ b/crates/vtracer-cli/src/main.rs @@ -106,6 +106,10 @@ struct Args { /// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive. #[arg(long)] adaptive_t: Option, + + /// Keep thread-like (sub-~2px-thick) regions instead of filtering them out. + #[arg(long)] + keep_thin: bool, } fn parse_segment_length(s: &str) -> Result { @@ -199,6 +203,9 @@ fn build_config(args: &Args) -> Result { 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 { diff --git a/crates/vtracer-py/src/lib.rs b/crates/vtracer-py/src/lib.rs index 7819c77..f9a5896 100644 --- a/crates/vtracer-py/src/lib.rs +++ b/crates/vtracer-py/src/lib.rs @@ -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 { let palette = match palette { Some(list) => list.iter().map(|s| parse_hex(s)).collect::>()?, @@ -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`. diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 73ef1c5..a7d68ff 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -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, }) } } diff --git a/crates/vtracer/src/ir/region.rs b/crates/vtracer/src/ir/region.rs index 8636579..f1619e6 100644 --- a/crates/vtracer/src/ir/region.rs +++ b/crates/vtracer/src/ir/region.rs @@ -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()); + } } diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs index 5abdc34..dde1843 100644 --- a/crates/vtracer/src/pipeline.rs +++ b/crates/vtracer/src/pipeline.rs @@ -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 { // 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 { diff --git a/crates/vtracer/tests/reuse.rs b/crates/vtracer/tests/reuse.rs index de1076f..4fa51a5 100644 --- a/crates/vtracer/tests/reuse.rs +++ b/crates/vtracer/tests/reuse.rs @@ -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}" + ); +} diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 1efa578..53b677c 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -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. */ diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index c7004dd..00c2962 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -37,6 +37,8 @@ struct Options { adaptive_window: Option, /// Adaptive sensitivity: percent below the local mean (default 15). adaptive_t: Option, + /// Drop thread-like (sub-~2px-thick) regions (default true). + filter_thin: Option, /// One of "bw" | "poster" | "photo"; applied before the other fields. preset: Option, } @@ -126,6 +128,9 @@ fn config_from(options: JsValue) -> Result { 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) }