Enrich binary thresholding: tunable fixed + Bradley–Roth adaptive

BinaryFrontend gains a Threshold enum: Fixed(u8) (now tunable, was
hardcoded to 128) and Adaptive { window, t } — Bradley–Roth adaptive
thresholding computed via visioncortex's SummedAreaTable, O(pixels)
regardless of window size, for images with uneven lighting. Both use a
shared (r+g+b)/3 intensity so they agree on "dark"; the grayscale
checker_bw golden is unaffected.

Exposed through Config and all bindings: CLI (--threshold, --adaptive,
--adaptive-window, --adaptive-t), Python (constructor kwargs + getters/
setters), and the Node package (binaryThreshold, adaptive, adaptiveWindow,
adaptiveT). Adds tests covering fixed tunability and adaptive recovering
locally-dark marks under a brightness gradient that a global cutoff can't.
This commit is contained in:
Chris Tsang
2026-07-25 00:18:57 +01:00
parent 50042f477d
commit a350e2532a
10 changed files with 369 additions and 13 deletions
+7
View File
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
### 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).
* 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
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
+31
View File
@@ -90,6 +90,22 @@ struct Args {
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
optimize: Option<u8>,
/// Binary mode: fixed threshold (0..=255); foreground when intensity is below it.
#[arg(long, value_parser = clap::value_parser!(u8))]
threshold: Option<u8>,
/// Binary mode: use BradleyRoth adaptive thresholding (handles uneven lighting).
#[arg(long)]
adaptive: bool,
/// Adaptive window side length in px (0 = auto). Implies --adaptive.
#[arg(long)]
adaptive_window: Option<u32>,
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
#[arg(long)]
adaptive_t: Option<f64>,
}
fn parse_segment_length(s: &str) -> Result<f64, String> {
@@ -169,6 +185,21 @@ fn build_config(args: &Args) -> Result<Config, String> {
config.max_colors = Some(v);
}
// Binary thresholding: --adaptive (or either adaptive tuning flag) selects
// BradleyRoth; otherwise --threshold tunes the fixed cutoff.
if let Some(v) = args.threshold {
config.binary_threshold = v;
}
if args.adaptive || args.adaptive_window.is_some() || args.adaptive_t.is_some() {
config.binary_adaptive = true;
}
if let Some(v) = args.adaptive_window {
config.binary_adaptive_window = v;
}
if let Some(v) = args.adaptive_t {
config.binary_adaptive_t = v;
}
// Palette: inline flag wins over file; both parse to a color list.
if let Some(text) = &args.palette {
config.palette = parse_palette(text)?;
+48
View File
@@ -141,6 +141,10 @@ impl PyConfig {
palette = None,
max_colors = None,
optimize = 1,
binary_threshold = 128,
adaptive = false,
adaptive_window = 0,
adaptive_t = 15.0,
))]
#[allow(clippy::too_many_arguments)]
fn new(
@@ -158,6 +162,10 @@ impl PyConfig {
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: u8,
binary_threshold: u8,
adaptive: bool,
adaptive_window: u32,
adaptive_t: f64,
) -> PyResult<Self> {
let palette = match palette {
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
@@ -179,6 +187,10 @@ impl PyConfig {
palette,
max_colors,
optimize,
binary_threshold,
binary_adaptive: adaptive,
binary_adaptive_window: adaptive_window,
binary_adaptive_t: adaptive_t,
},
})
}
@@ -333,6 +345,42 @@ impl PyConfig {
self.inner.optimize = v;
}
#[getter]
fn binary_threshold(&self) -> u8 {
self.inner.binary_threshold
}
#[setter]
fn set_binary_threshold(&mut self, v: u8) {
self.inner.binary_threshold = v;
}
#[getter]
fn adaptive(&self) -> bool {
self.inner.binary_adaptive
}
#[setter]
fn set_adaptive(&mut self, v: bool) {
self.inner.binary_adaptive = v;
}
#[getter]
fn adaptive_window(&self) -> u32 {
self.inner.binary_adaptive_window
}
#[setter]
fn set_adaptive_window(&mut self, v: u32) {
self.inner.binary_adaptive_window = v;
}
#[getter]
fn adaptive_t(&self) -> f64 {
self.inner.binary_adaptive_t
}
#[setter]
fn set_adaptive_t(&mut self, v: f64) {
self.inner.binary_adaptive_t = v;
}
// --- conversion ---
/// Trace the image at `input_path` and write the SVG to `output_path`.
+30 -6
View File
@@ -8,7 +8,7 @@ use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAd
use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend};
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend, Threshold};
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
@@ -71,6 +71,16 @@ pub struct Config {
pub max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
pub optimize: u8,
/// Binary-mode fixed threshold (0..=255): foreground when grayscale
/// intensity is below this. Ignored when `binary_adaptive` is set.
pub binary_threshold: u8,
/// Binary mode: use BradleyRoth adaptive thresholding instead of the fixed
/// cutoff (better for uneven lighting).
pub binary_adaptive: bool,
/// Adaptive window side length in pixels; 0 = auto (~1/8 of the shorter side).
pub binary_adaptive_window: u32,
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
pub binary_adaptive_t: f64,
}
impl Default for Config {
@@ -90,6 +100,10 @@ impl Default for Config {
palette: Vec::new(),
max_colors: None,
optimize: 1,
binary_threshold: 128,
binary_adaptive: false,
binary_adaptive_window: 0,
binary_adaptive_t: 15.0,
}
}
}
@@ -134,11 +148,21 @@ impl Config {
color_precision_loss: 8 - self.color_precision,
layer_difference: self.layer_difference,
}),
ColorMode::Binary => Box::new(BinaryFrontend {
filter_speckle_area,
threshold: 128,
diagonal: false,
}),
ColorMode::Binary => {
let threshold = if self.binary_adaptive {
Threshold::Adaptive {
window: self.binary_adaptive_window,
t: self.binary_adaptive_t,
}
} else {
Threshold::Fixed(self.binary_threshold)
};
Box::new(BinaryFrontend {
filter_speckle_area,
threshold,
diagonal: false,
})
}
}
}
+100 -6
View File
@@ -1,18 +1,63 @@
use visioncortex::{Color, ColorImage, PointI32};
use visioncortex::{BinaryImage, Color, ColorImage, PointI32, SummedAreaTable};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::Frontend;
/// Grayscale intensity (0..=255) used by every thresholding method. Matches the
/// metric `SummedAreaTable::from_color_image` sums, so fixed and adaptive
/// thresholds agree on what "dark" means.
#[inline]
fn intensity(c: Color) -> u32 {
(c.r as u32 + c.g as u32 + c.b as u32) / 3
}
/// How the binary frontend separates foreground (dark) from background pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Threshold {
/// Global cutoff: a pixel is foreground when its intensity is below this
/// value (0..=255). Fast and predictable; best for clean, evenly-lit input.
Fixed(u8),
/// BradleyRoth adaptive threshold: a pixel is foreground when its
/// intensity is more than `t` percent below the mean of the surrounding
/// `window`×`window` block. Handles uneven lighting and shadows that defeat
/// a single global cutoff. Computed in one pass with a summed-area table,
/// so it stays O(pixels) regardless of window size.
Adaptive {
/// Window side length in pixels; `0` auto-derives ~1/8 of the shorter
/// image dimension (the value suggested by the paper).
window: u32,
/// Sensitivity, as a percentage below the local mean (paper default 15).
t: f64,
},
}
impl Threshold {
/// BradleyRoth adaptive thresholding with the paper's defaults
/// (auto window, `t = 15`).
pub const fn adaptive() -> Self {
Threshold::Adaptive {
window: 0,
t: 15.0,
}
}
}
impl Default for Threshold {
fn default() -> Self {
Threshold::Fixed(128)
}
}
/// Binary (black/white) frontend: threshold the image then cluster the
/// foreground. Every region is painted black.
#[derive(Debug, Clone)]
pub struct BinaryFrontend {
/// Discard clusters smaller than this many pixels.
pub filter_speckle_area: usize,
/// A pixel is foreground when its red channel is below this threshold.
pub threshold: u8,
/// How foreground pixels are selected.
pub threshold: Threshold,
/// Whether to connect clusters diagonally.
pub diagonal: bool,
}
@@ -21,12 +66,62 @@ impl Default for BinaryFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
threshold: 128,
threshold: Threshold::default(),
diagonal: false,
}
}
}
impl BinaryFrontend {
/// Binarize `img` into a foreground mask according to [`Self::threshold`].
fn binarize(&self, img: &ColorImage) -> BinaryImage {
match self.threshold {
Threshold::Fixed(value) => {
let value = value as u32;
img.to_binary_image(|c| intensity(c) < value)
}
Threshold::Adaptive { window, t } => adaptive_bradley_roth(img, window, t),
}
}
}
/// BradleyRoth adaptive thresholding via a summed-area table.
///
/// For each pixel, compare its intensity to the mean of a surrounding window:
/// it is foreground when `value <= mean * (1 - t/100)`, i.e. more than `t`
/// percent darker than its neighborhood.
fn adaptive_bradley_roth(img: &ColorImage, window: u32, t: f64) -> BinaryImage {
let (w, h) = (img.width, img.height);
let sat = SummedAreaTable::from_color_image(img);
// Window: 0 => auto (~1/8 of the shorter side, per the paper), min 1.
let side = if window == 0 {
(w.min(h) / 8).max(1)
} else {
window as usize
};
let half = side / 2;
let factor = 1.0 - t.clamp(0.0, 100.0) / 100.0;
let mut out = BinaryImage::new_w_h(w, h);
for y in 0..h {
let y0 = y.saturating_sub(half);
let y1 = (y + half).min(h - 1);
for x in 0..w {
let x0 = x.saturating_sub(half);
let x1 = (x + half).min(w - 1);
let count = ((x1 - x0 + 1) * (y1 - y0 + 1)) as f64;
let sum = sat.get_region_sum_x_y_w_h(x0, y0, x1 - x0 + 1, y1 - y0 + 1) as f64;
let value = intensity(img.get_pixel(x, y)) as f64;
// value <= mean * factor ⇔ value * count <= sum * factor
out.set_pixel(x, y, value * count <= sum * factor);
}
}
out
}
impl Frontend for BinaryFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
if img.width == 0 || img.height == 0 {
@@ -35,8 +130,7 @@ impl Frontend for BinaryFrontend {
let width = img.width;
let height = img.height;
let threshold = self.threshold;
let binary = img.to_binary_image(|c| c.r < threshold);
let binary = self.binarize(img);
let clusters = binary.to_clusters(self.diagonal);
let mut seg = Segmentation::new(width as u32, height as u32);
+1 -1
View File
@@ -12,7 +12,7 @@ mod binary;
mod color_cluster;
mod keying;
pub use binary::BinaryFrontend;
pub use binary::{BinaryFrontend, Threshold};
pub use color_cluster::ColorClusterFrontend;
use visioncortex::ColorImage;
+1
View File
@@ -44,6 +44,7 @@ pub mod svg;
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
pub use error::Error;
pub use frontend::Threshold;
pub use pipeline::Pipeline;
pub use progress::{CancelToken, Phase, Progress};
+122
View File
@@ -0,0 +1,122 @@
//! Binary thresholding: tunable fixed cutoff and BradleyRoth adaptive.
use vtracer::frontend::{BinaryFrontend, Frontend};
use vtracer::{ColorImage, Threshold};
fn gray(w: usize, h: usize, f: impl Fn(usize, usize) -> u8) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let v = f(x, y);
pixels.extend_from_slice(&[v, v, v, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// Total foreground pixels selected by a frontend over an image.
fn foreground_area(front: &BinaryFrontend, img: &ColorImage) -> usize {
front
.segment(img)
.unwrap()
.layers
.iter()
.map(|l| l.mask.area())
.sum()
}
/// A uniform gray field: a lower fixed threshold selects strictly fewer pixels.
#[test]
fn fixed_threshold_is_tunable() {
// Left third value 80, middle 130, right 180.
let img = gray(60, 20, |x, _| match x / 20 {
0 => 80,
1 => 130,
_ => 180,
});
let front = |v: u8| BinaryFrontend {
filter_speckle_area: 1,
threshold: Threshold::Fixed(v),
diagonal: false,
};
let low = foreground_area(&front(100), &img); // catches only the 80 band
let mid = foreground_area(&front(150), &img); // 80 + 130 bands
let high = foreground_area(&front(200), &img); // everything
assert!(
low < mid && mid < high,
"higher threshold must select more foreground: {low} < {mid} < {high}"
);
assert_eq!(high, 60 * 20, "threshold above all values selects everything");
}
/// Adaptive thresholding recovers locally-dark marks under a brightness
/// gradient that no single global cutoff can separate.
#[test]
fn adaptive_beats_fixed_under_uneven_lighting() {
let (w, h) = (80, 40);
// Background ramps left(70) → right(210). Two 6x6 marks, each 40 darker
// than their local background: one on the dark side, one on the bright side.
let bg = |x: usize| 70 + (x * 140 / (w - 1)) as u8;
let marks = [(16usize, 17usize), (60, 17)];
let is_mark = |x: usize, y: usize| {
marks
.iter()
.any(|&(mx, my)| x >= mx && x < mx + 6 && y >= my && y < my + 6)
};
let img = gray(w, h, |x, y| {
if is_mark(x, y) {
bg(x).saturating_sub(40)
} else {
bg(x)
}
});
let base = BinaryFrontend {
filter_speckle_area: 4,
threshold: Threshold::Fixed(128),
diagonal: false,
};
// A global cutoff can't isolate both marks: 128 catches the dark-side mark
// but floods the whole dark half of the ramp, and misses the bright-side
// mark (~136) entirely — so fixed has no region on the bright half.
let fixed_seg = base.segment(&img).unwrap();
let fixed_area: usize = fixed_seg.layers.iter().map(|l| l.mask.area()).sum();
let mid = (w as i32) / 2;
let fixed_right = fixed_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
// Adaptive: window comfortably larger than the 6px marks so they fill.
let adaptive = BinaryFrontend {
threshold: Threshold::Adaptive {
window: 21,
t: 15.0,
},
..base.clone()
};
let adaptive_seg = adaptive.segment(&img).unwrap();
let adaptive_area: usize = adaptive_seg.layers.iter().map(|l| l.mask.area()).sum();
let adaptive_left = adaptive_seg.layers.iter().any(|l| l.mask.offset.x < mid);
let adaptive_right = adaptive_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
// The point of adaptive: it finds locally-dark marks on *both* sides of the
// ramp, where the global threshold catches only the dark half.
assert!(!fixed_right, "fixed(128) should miss the bright-side mark");
assert!(
adaptive_left && adaptive_right,
"adaptive should detect marks on both the dark and bright sides"
);
assert!(adaptive_area > 0, "adaptive must select some foreground");
assert!(
adaptive_area * 3 < fixed_area,
"adaptive should select far less than fixed's flooded half: \
adaptive={adaptive_area}, fixed={fixed_area}"
);
}
+8
View File
@@ -19,6 +19,14 @@ export interface Options {
maxColors?: number;
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
optimize?: number;
/** Binary mode (`colorMode: 'bw'`): fixed threshold 0..=255; foreground when intensity is below it. */
binaryThreshold?: number;
/** Binary mode: use BradleyRoth adaptive thresholding (handles uneven lighting). */
adaptive?: boolean;
/** Adaptive window side length in px; 0 = auto (~1/8 of the shorter side). */
adaptiveWindow?: number;
/** Adaptive sensitivity: percent below the local mean (default 15). */
adaptiveT?: number;
}
/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */
+21
View File
@@ -29,6 +29,14 @@ struct Options {
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: Option<u8>,
/// Binary-mode fixed threshold (0..=255).
binary_threshold: Option<u8>,
/// Binary mode: use BradleyRoth adaptive thresholding.
adaptive: Option<bool>,
/// Adaptive window side length in px (0 = auto).
adaptive_window: Option<u32>,
/// Adaptive sensitivity: percent below the local mean (default 15).
adaptive_t: Option<f64>,
/// One of "bw" | "poster" | "photo"; applied before the other fields.
preset: Option<String>,
}
@@ -105,6 +113,19 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
if let Some(v) = opts.optimize {
config.optimize = v;
}
if let Some(v) = opts.binary_threshold {
config.binary_threshold = v;
}
// Any adaptive tuning field (or `adaptive: true`) switches on BradleyRoth.
if opts.adaptive == Some(true) || opts.adaptive_window.is_some() || opts.adaptive_t.is_some() {
config.binary_adaptive = true;
}
if let Some(v) = opts.adaptive_window {
config.binary_adaptive_window = v;
}
if let Some(v) = opts.adaptive_t {
config.binary_adaptive_t = v;
}
Ok(config)
}