mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-26 05:51:21 -07:00
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:
@@ -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/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
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
|
## 1.0.0-alpha.1 - 2026-07-24
|
||||||
|
|
||||||
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
|
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ struct Args {
|
|||||||
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
|
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
|
||||||
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
|
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
|
||||||
optimize: Option<u8>,
|
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 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<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> {
|
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);
|
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.
|
// Palette: inline flag wins over file; both parse to a color list.
|
||||||
if let Some(text) = &args.palette {
|
if let Some(text) = &args.palette {
|
||||||
config.palette = parse_palette(text)?;
|
config.palette = parse_palette(text)?;
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ impl PyConfig {
|
|||||||
palette = None,
|
palette = None,
|
||||||
max_colors = None,
|
max_colors = None,
|
||||||
optimize = 1,
|
optimize = 1,
|
||||||
|
binary_threshold = 128,
|
||||||
|
adaptive = false,
|
||||||
|
adaptive_window = 0,
|
||||||
|
adaptive_t = 15.0,
|
||||||
))]
|
))]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn new(
|
fn new(
|
||||||
@@ -158,6 +162,10 @@ impl PyConfig {
|
|||||||
palette: Option<Vec<String>>,
|
palette: Option<Vec<String>>,
|
||||||
max_colors: Option<usize>,
|
max_colors: Option<usize>,
|
||||||
optimize: u8,
|
optimize: u8,
|
||||||
|
binary_threshold: u8,
|
||||||
|
adaptive: bool,
|
||||||
|
adaptive_window: u32,
|
||||||
|
adaptive_t: f64,
|
||||||
) -> PyResult<Self> {
|
) -> PyResult<Self> {
|
||||||
let palette = match palette {
|
let palette = match palette {
|
||||||
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
||||||
@@ -179,6 +187,10 @@ impl PyConfig {
|
|||||||
palette,
|
palette,
|
||||||
max_colors,
|
max_colors,
|
||||||
optimize,
|
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;
|
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 ---
|
// --- conversion ---
|
||||||
|
|
||||||
/// Trace the image at `input_path` and write the SVG to `output_path`.
|
/// Trace the image at `input_path` and write the SVG to `output_path`.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAd
|
|||||||
use crate::compose::Compositing;
|
use crate::compose::Compositing;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
||||||
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend};
|
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend, Threshold};
|
||||||
use crate::mosaic::{
|
use crate::mosaic::{
|
||||||
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
|
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
|
||||||
};
|
};
|
||||||
@@ -71,6 +71,16 @@ pub struct Config {
|
|||||||
pub max_colors: Option<usize>,
|
pub max_colors: Option<usize>,
|
||||||
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
|
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
|
||||||
pub optimize: u8,
|
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 {
|
impl Default for Config {
|
||||||
@@ -90,6 +100,10 @@ impl Default for Config {
|
|||||||
palette: Vec::new(),
|
palette: Vec::new(),
|
||||||
max_colors: None,
|
max_colors: None,
|
||||||
optimize: 1,
|
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,
|
color_precision_loss: 8 - self.color_precision,
|
||||||
layer_difference: self.layer_difference,
|
layer_difference: self.layer_difference,
|
||||||
}),
|
}),
|
||||||
ColorMode::Binary => Box::new(BinaryFrontend {
|
ColorMode::Binary => {
|
||||||
filter_speckle_area,
|
let threshold = if self.binary_adaptive {
|
||||||
threshold: 128,
|
Threshold::Adaptive {
|
||||||
diagonal: false,
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,63 @@
|
|||||||
use visioncortex::{Color, ColorImage, PointI32};
|
use visioncortex::{BinaryImage, Color, ColorImage, PointI32, SummedAreaTable};
|
||||||
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
||||||
|
|
||||||
use super::Frontend;
|
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
|
/// Binary (black/white) frontend: threshold the image then cluster the
|
||||||
/// foreground. Every region is painted black.
|
/// foreground. Every region is painted black.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BinaryFrontend {
|
pub struct BinaryFrontend {
|
||||||
/// Discard clusters smaller than this many pixels.
|
/// Discard clusters smaller than this many pixels.
|
||||||
pub filter_speckle_area: usize,
|
pub filter_speckle_area: usize,
|
||||||
/// A pixel is foreground when its red channel is below this threshold.
|
/// How foreground pixels are selected.
|
||||||
pub threshold: u8,
|
pub threshold: Threshold,
|
||||||
/// Whether to connect clusters diagonally.
|
/// Whether to connect clusters diagonally.
|
||||||
pub diagonal: bool,
|
pub diagonal: bool,
|
||||||
}
|
}
|
||||||
@@ -21,12 +66,62 @@ impl Default for BinaryFrontend {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
filter_speckle_area: 16,
|
filter_speckle_area: 16,
|
||||||
threshold: 128,
|
threshold: Threshold::default(),
|
||||||
diagonal: false,
|
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 {
|
impl Frontend for BinaryFrontend {
|
||||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||||
if img.width == 0 || img.height == 0 {
|
if img.width == 0 || img.height == 0 {
|
||||||
@@ -35,8 +130,7 @@ impl Frontend for BinaryFrontend {
|
|||||||
|
|
||||||
let width = img.width;
|
let width = img.width;
|
||||||
let height = img.height;
|
let height = img.height;
|
||||||
let threshold = self.threshold;
|
let binary = self.binarize(img);
|
||||||
let binary = img.to_binary_image(|c| c.r < threshold);
|
|
||||||
let clusters = binary.to_clusters(self.diagonal);
|
let clusters = binary.to_clusters(self.diagonal);
|
||||||
|
|
||||||
let mut seg = Segmentation::new(width as u32, height as u32);
|
let mut seg = Segmentation::new(width as u32, height as u32);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ mod binary;
|
|||||||
mod color_cluster;
|
mod color_cluster;
|
||||||
mod keying;
|
mod keying;
|
||||||
|
|
||||||
pub use binary::BinaryFrontend;
|
pub use binary::{BinaryFrontend, Threshold};
|
||||||
pub use color_cluster::ColorClusterFrontend;
|
pub use color_cluster::ColorClusterFrontend;
|
||||||
|
|
||||||
use visioncortex::ColorImage;
|
use visioncortex::ColorImage;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub mod svg;
|
|||||||
|
|
||||||
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
|
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
pub use frontend::Threshold;
|
||||||
pub use pipeline::Pipeline;
|
pub use pipeline::Pipeline;
|
||||||
pub use progress::{CancelToken, Phase, Progress};
|
pub use progress::{CancelToken, Phase, Progress};
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+8
@@ -19,6 +19,14 @@ export interface Options {
|
|||||||
maxColors?: number;
|
maxColors?: number;
|
||||||
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
|
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
|
||||||
optimize?: number;
|
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. */
|
/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ struct Options {
|
|||||||
palette: Option<Vec<String>>,
|
palette: Option<Vec<String>>,
|
||||||
max_colors: Option<usize>,
|
max_colors: Option<usize>,
|
||||||
optimize: Option<u8>,
|
optimize: Option<u8>,
|
||||||
|
/// Binary-mode fixed threshold (0..=255).
|
||||||
|
binary_threshold: Option<u8>,
|
||||||
|
/// Binary mode: use Bradley–Roth 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.
|
/// One of "bw" | "poster" | "photo"; applied before the other fields.
|
||||||
preset: Option<String>,
|
preset: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -105,6 +113,19 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
|
|||||||
if let Some(v) = opts.optimize {
|
if let Some(v) = opts.optimize {
|
||||||
config.optimize = v;
|
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)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user