diff --git a/CHANGELOG.md b/CHANGELOG.md index 9838a3b..396aab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages. diff --git a/crates/vtracer-cli/src/main.rs b/crates/vtracer-cli/src/main.rs index 28148ab..f2608d7 100644 --- a/crates/vtracer-cli/src/main.rs +++ b/crates/vtracer-cli/src/main.rs @@ -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, + + /// Binary mode: fixed threshold (0..=255); foreground when intensity is below it. + #[arg(long, value_parser = clap::value_parser!(u8))] + threshold: Option, + + /// Binary mode: use Bradley–Roth adaptive thresholding (handles uneven lighting). + #[arg(long)] + adaptive: bool, + + /// Adaptive window side length in px (0 = auto). Implies --adaptive. + #[arg(long)] + adaptive_window: Option, + + /// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive. + #[arg(long)] + adaptive_t: Option, } fn parse_segment_length(s: &str) -> Result { @@ -169,6 +185,21 @@ fn build_config(args: &Args) -> Result { config.max_colors = Some(v); } + // Binary thresholding: --adaptive (or either adaptive tuning flag) selects + // Bradley–Roth; 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)?; diff --git a/crates/vtracer-py/src/lib.rs b/crates/vtracer-py/src/lib.rs index bb3458b..7819c77 100644 --- a/crates/vtracer-py/src/lib.rs +++ b/crates/vtracer-py/src/lib.rs @@ -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>, max_colors: Option, optimize: u8, + binary_threshold: u8, + adaptive: bool, + adaptive_window: u32, + adaptive_t: f64, ) -> PyResult { let palette = match palette { Some(list) => list.iter().map(|s| parse_hex(s)).collect::>()?, @@ -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`. diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index bfa2b66..6a21591 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -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, /// 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 Bradley–Roth 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, + }) + } } } diff --git a/crates/vtracer/src/frontend/binary.rs b/crates/vtracer/src/frontend/binary.rs index cb0fc4f..d8bfe1e 100644 --- a/crates/vtracer/src/frontend/binary.rs +++ b/crates/vtracer/src/frontend/binary.rs @@ -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), + /// Bradley–Roth 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 { + /// Bradley–Roth 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), + } + } +} + +/// Bradley–Roth 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 { 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); diff --git a/crates/vtracer/src/frontend/mod.rs b/crates/vtracer/src/frontend/mod.rs index 2bbc8d5..13181e2 100644 --- a/crates/vtracer/src/frontend/mod.rs +++ b/crates/vtracer/src/frontend/mod.rs @@ -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; diff --git a/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs index f53354b..8065b89 100644 --- a/crates/vtracer/src/lib.rs +++ b/crates/vtracer/src/lib.rs @@ -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}; diff --git a/crates/vtracer/tests/binary_threshold.rs b/crates/vtracer/tests/binary_threshold.rs new file mode 100644 index 0000000..20589d5 --- /dev/null +++ b/crates/vtracer/tests/binary_threshold.rs @@ -0,0 +1,122 @@ +//! Binary thresholding: tunable fixed cutoff and Bradley–Roth 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}" + ); +} diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index c782b56..1efa578 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -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 Bradley–Roth 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. */ diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index 4b063e2..c7004dd 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -29,6 +29,14 @@ struct Options { palette: Option>, max_colors: Option, optimize: Option, + /// Binary-mode fixed threshold (0..=255). + binary_threshold: Option, + /// Binary mode: use Bradley–Roth adaptive thresholding. + adaptive: Option, + /// Adaptive window side length in px (0 = auto). + adaptive_window: Option, + /// Adaptive sensitivity: percent below the local mean (default 15). + adaptive_t: Option, /// One of "bw" | "poster" | "photo"; applied before the other fields. preset: Option, } @@ -105,6 +113,19 @@ fn config_from(options: JsValue) -> Result { 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 Bradley–Roth. + 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) }