diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9a109..1c77ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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). -* Two-phase conversion for interactive tuning: `Pipeline::segment` caches the expensive clustering result as a reusable `Segmentation`, and `Pipeline::finish` re-runs only the cheap color-fitting / curve-fitting / optimization stages — so tuning those parameters no longer repays the clustering cost. Both have `*_with_progress` variants. +* Two-phase conversion for interactive tuning: `Pipeline::segment` caches the expensive clustering result as a reusable `Segmentation`, and `Pipeline::finish` re-runs only the cheap color-fitting / curve-fitting / optimization stages — so tuning those parameters no longer repays the clustering cost. (Speckle, color precision, and layer difference are clustering parameters and require a fresh `segment`.) Both have `*_with_progress` variants. * 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/README.md b/README.md index d5dd29f..eb8de67 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,7 @@ Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/v ## Desktop App (coming soon) -![screenshot](docs/images/screenshot-01.png) - -![screenshot](docs/images/screenshot-02.png) +![screenshot](docs/images/desktop-app.png) ## Cmd App @@ -90,6 +88,10 @@ Options: --palette-file Fixed palette from a file (hex colors, comma/newline separated) --max-colors Auto-quantize to at most N colors --optimize Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands + --threshold Binary mode: fixed threshold 0..=255 (foreground below it) + --adaptive Binary mode: Bradley–Roth adaptive threshold (uneven lighting) + --adaptive-window Adaptive window size in px (0 = auto); implies --adaptive + --adaptive-t Adaptive sensitivity: % below local mean (default 15) -h, --help Print help -V, --version Print version ``` @@ -102,7 +104,11 @@ Options: - **`--palette` / `--palette-file`** — snap colors to a fixed palette (nearest in OKLab); **`--max-colors`** auto-quantizes the palette. - **`--optimize`** — output size passes (coordinate quantization, redundant- - point removal, relative/shorthand path encoding). + point removal, relative/shorthand path encoding). Note: coordinate + precision is set separately by `--path-precision`, the bigger size lever. +- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or + **Bradley–Roth adaptive** thresholding (`--adaptive`, with `--adaptive-window` + / `--adaptive-t`) for scans with uneven lighting. ## Downloads @@ -125,6 +131,9 @@ cargo install vtracer-cli # black & white line art ./vtracer input.jpg output.svg --preset bw +# scanned/photographed line art with uneven lighting +./vtracer scan.jpg output.svg --colormode bw --adaptive + # seam-free mosaic (gapless tessellation) ./vtracer input.jpg output.svg --hierarchical cutout @@ -160,6 +169,10 @@ cfg = vtracer.Config(mode="polygon", hierarchical="cutout") cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"] svg = cfg.convert_bytes(data) vtracer.Config.poster().convert_file("photo.jpg", "poster.svg") + +# binary with adaptive (Bradley–Roth) thresholding +bw = vtracer.Config(color_mode="bw", adaptive=True) +svg = bw.convert_file("scan.jpg", "scan.svg") ``` See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API. @@ -178,6 +191,9 @@ const vtracer = require('@visioncortex/vtracer'); await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' }); const svg = vtracer.convertBuffer(buffer, { preset: 'poster' }); const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' }); + +// binary with adaptive thresholding +const bw = vtracer.convertBuffer(buffer, { colorMode: 'bw', adaptive: true }); ``` ## Citations diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 6a21591..882fda3 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -141,12 +141,11 @@ impl Config { } fn frontend(&self) -> Box { - let filter_speckle_area = self.filter_speckle * self.filter_speckle; match self.color_mode { ColorMode::Color => Box::new(ColorClusterFrontend { - filter_speckle_area, color_precision_loss: 8 - self.color_precision, layer_difference: self.layer_difference, + good_min_area: self.speckle_area(), }), ColorMode::Binary => { let threshold = if self.binary_adaptive { @@ -158,14 +157,19 @@ impl Config { Threshold::Fixed(self.binary_threshold) }; Box::new(BinaryFrontend { - filter_speckle_area, threshold, diagonal: false, + min_area: self.speckle_area(), }) } } } + /// Speckle filter area (px), applied in the `finish` phase. + fn speckle_area(&self) -> usize { + self.filter_speckle * self.filter_speckle + } + fn color_fitters(&self) -> Vec> { if !self.palette.is_empty() { vec![ diff --git a/crates/vtracer/src/frontend/binary.rs b/crates/vtracer/src/frontend/binary.rs index d8bfe1e..28e148a 100644 --- a/crates/vtracer/src/frontend/binary.rs +++ b/crates/vtracer/src/frontend/binary.rs @@ -52,22 +52,25 @@ impl Default for Threshold { /// Binary (black/white) frontend: threshold the image then cluster the /// foreground. Every region is painted black. +/// +/// Speckle removal drops clusters smaller than `min_area` px as the clusters +/// are collected, matching the pre-1.0 binary path (`cluster.size() >= area`). #[derive(Debug, Clone)] pub struct BinaryFrontend { - /// Discard clusters smaller than this many pixels. - pub filter_speckle_area: usize, /// How foreground pixels are selected. pub threshold: Threshold, /// Whether to connect clusters diagonally. pub diagonal: bool, + /// Discard clusters smaller than this many pixels (0 = keep all). + pub min_area: usize, } impl Default for BinaryFrontend { fn default() -> Self { Self { - filter_speckle_area: 16, threshold: Threshold::default(), diagonal: false, + min_area: 0, } } } @@ -137,19 +140,20 @@ impl Frontend for BinaryFrontend { let black = Color::new(0, 0, 0); for i in 0..clusters.len() { let cluster = clusters.get_cluster(i); - if cluster.size() >= self.filter_speckle_area { - let mask = RegionMask::new( - cluster.to_binary_image(), - PointI32 { - x: cluster.rect.left, - y: cluster.rect.top, - }, - ); - seg.layers.push(Layer { - paint: Paint::Solid(black), - mask, - }); + if cluster.size() < self.min_area { + continue; } + let mask = RegionMask::new( + cluster.to_binary_image(), + PointI32 { + x: cluster.rect.left, + y: cluster.rect.top, + }, + ); + seg.layers.push(Layer { + paint: Paint::Solid(black), + mask, + }); } Ok(seg) diff --git a/crates/vtracer/src/frontend/color_cluster.rs b/crates/vtracer/src/frontend/color_cluster.rs index 3991dcd..7f174a0 100644 --- a/crates/vtracer/src/frontend/color_cluster.rs +++ b/crates/vtracer/src/frontend/color_cluster.rs @@ -14,22 +14,31 @@ use super::keying::{apply_key, find_unused_color, should_key_image}; use super::Frontend; /// Hierarchical color-clustering frontend — the classic VTracer color path. +/// +/// Speckle removal happens *inside* clustering, via `good_min_area`: it is the +/// clusterer's `deepen` gate (visioncortex `patch_good`), so it does far more +/// than drop small regions — it decides whether a small/thin patch is absorbed +/// into its neighbor (its color averaged in) or kept as its own layer. Forcing +/// it to 0 disables the thread-like rejection and changes the whole hierarchy, +/// so speckle must be a clustering parameter, not a downstream filter. #[derive(Debug, Clone)] pub struct ColorClusterFrontend { - /// Discard clusters smaller than this many pixels. - pub filter_speckle_area: usize, /// Bits of color precision dropped when comparing pixels (0 = full 8-bit). pub color_precision_loss: i32, /// Color difference between hierarchical gradient layers. pub layer_difference: i32, + /// Minimum area (px) for a patch to be a `deepen` candidate during + /// clustering; non-zero also enables visioncortex's thread-like rejection. + /// Below it, patches are absorbed into their nearest-color neighbor. + pub good_min_area: usize, } impl Default for ColorClusterFrontend { fn default() -> Self { Self { - filter_speckle_area: 16, color_precision_loss: 2, layer_difference: 16, + good_min_area: 0, } } } @@ -62,7 +71,7 @@ impl ColorClusterFrontend { diagonal: self.layer_difference == 0, hierarchical: HIERARCHICAL_MAX, batch_size: 25600, - good_min_area: self.filter_speckle_area, + good_min_area: self.good_min_area, good_max_area: width * height, is_same_color_a: self.color_precision_loss, is_same_color_b: 1, diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs index bb7e916..0021349 100644 --- a/crates/vtracer/src/pipeline.rs +++ b/crates/vtracer/src/pipeline.rs @@ -55,9 +55,9 @@ impl Pipeline { /// Cache the result and feed it to [`finish`](Pipeline::finish) to /// re-render with different color-fitting, curve-fitting, or optimization /// parameters *without repaying the clustering cost* — the core of an - /// interactive tuning loop. Only re-run `segment` when a parameter that - /// affects clustering itself changes (color precision, layer difference, - /// speckle filter, binary threshold, the frontend choice). + /// interactive tuning loop. Re-run `segment` when a parameter that affects + /// clustering itself changes: filter speckle, color precision, layer + /// difference, binary threshold, or the frontend choice. pub fn segment(&self, img: &ColorImage) -> Result { self.segment_with_progress(img, &CancelToken::new(), &mut |_| {}) } diff --git a/crates/vtracer/tests/binary_threshold.rs b/crates/vtracer/tests/binary_threshold.rs index 20589d5..4b4159e 100644 --- a/crates/vtracer/tests/binary_threshold.rs +++ b/crates/vtracer/tests/binary_threshold.rs @@ -40,9 +40,9 @@ fn fixed_threshold_is_tunable() { }); let front = |v: u8| BinaryFrontend { - filter_speckle_area: 1, threshold: Threshold::Fixed(v), diagonal: false, + min_area: 0, }; let low = foreground_area(&front(100), &img); // catches only the 80 band @@ -80,9 +80,9 @@ fn adaptive_beats_fixed_under_uneven_lighting() { }); let base = BinaryFrontend { - filter_speckle_area: 4, threshold: Threshold::Fixed(128), diagonal: false, + min_area: 4, }; // A global cutoff can't isolate both marks: 128 catches the dark-side mark diff --git a/crates/vtracer/tests/reuse.rs b/crates/vtracer/tests/reuse.rs index 579d155..5741a0b 100644 --- a/crates/vtracer/tests/reuse.rs +++ b/crates/vtracer/tests/reuse.rs @@ -65,7 +65,8 @@ fn cached_segmentation_is_reusable() { /// The tuning workflow: segment once, then feed that segmentation to pipelines /// with different curve-fitting parameters. Same regions, different geometry — -/// and no re-segmentation. +/// and no re-segmentation. (Speckle, color precision, and layer difference are +/// clustering parameters, so changing them requires a fresh `segment`.) #[test] fn tune_curve_fitting_on_cached_segmentation() { let img = blocks();