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:
Chris Tsang
2026-07-25 01:28:57 +01:00
parent eb68057b13
commit 1c3f9fb16e
9 changed files with 119 additions and 2 deletions
+1
View File
@@ -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 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
+7
View File
@@ -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 {
+12
View File
@@ -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`.
+5
View File
@@ -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,
})
}
}
+22 -1
View File
@@ -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());
}
}
+7
View File
@@ -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 {
+58 -1
View File
@@ -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}"
);
}
+2
View File
@@ -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. */
+5
View File
@@ -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)
}