diff --git a/CHANGELOG.md b/CHANGELOG.md index 396aab0..0b9a109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +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. * 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/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs index 8065b89..fd40d77 100644 --- a/crates/vtracer/src/lib.rs +++ b/crates/vtracer/src/lib.rs @@ -45,6 +45,7 @@ pub mod svg; pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset}; pub use error::Error; pub use frontend::Threshold; +pub use ir::{Segmentation, VectorDoc}; pub use pipeline::Pipeline; pub use progress::{CancelToken, Phase, Progress}; diff --git a/crates/vtracer/src/pipeline.rs b/crates/vtracer/src/pipeline.rs index c1c2cc1..bb7e916 100644 --- a/crates/vtracer/src/pipeline.rs +++ b/crates/vtracer/src/pipeline.rs @@ -6,7 +6,7 @@ use crate::colorfit::ColorFitter; use crate::compose::Compositing; use crate::error::Error; use crate::frontend::Frontend; -use crate::ir::VectorDoc; +use crate::ir::{Segmentation, VectorDoc}; use crate::optimize::OptimizerPass; use crate::progress::{CancelToken, Ctx, Phase, Progress}; use crate::svg::SvgWriter; @@ -44,15 +44,69 @@ impl Pipeline { on_progress: &mut dyn FnMut(Progress), ) -> Result { let mut ctx = Ctx::new(cancel, on_progress); + let seg = self.frontend.segment_with(img, &mut ctx)?; + // `seg` is owned and about to be consumed, so no clone is needed here. + self.finish_ctx(seg, &mut ctx) + } - let mut seg = self.frontend.segment_with(img, &mut ctx)?; + /// Phase 1 of 2 — run **only** the frontend (the expensive clustering step) + /// and return a reusable [`Segmentation`]. + /// + /// 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). + pub fn segment(&self, img: &ColorImage) -> Result { + self.segment_with_progress(img, &CancelToken::new(), &mut |_| {}) + } + /// [`segment`](Pipeline::segment) with progress reporting and cancellation. + pub fn segment_with_progress( + &self, + img: &ColorImage, + cancel: &CancelToken, + on_progress: &mut dyn FnMut(Progress), + ) -> Result { + let mut ctx = Ctx::new(cancel, on_progress); + self.frontend.segment_with(img, &mut ctx) + } + + /// Phase 2 of 2 — color fitting → compositing → optimization, reusing a + /// [`Segmentation`] produced by [`segment`](Pipeline::segment). + /// + /// The segmentation is cloned internally (color fitting mutates it), so the + /// cached copy stays pristine and can be reused across many `finish` calls + /// with different pipelines. The frontend of `self` is not used here; build + /// the tuning pipeline with the color/curve/optimize parameters you want + /// and the *same* clustering parameters that produced `seg`. + pub fn finish(&self, seg: &Segmentation) -> Result { + self.finish_with_progress(seg, &CancelToken::new(), &mut |_| {}) + } + + /// [`finish`](Pipeline::finish) with progress reporting and cancellation. + /// Progress starts at the [`Phase::Compose`] stage (segmentation is skipped). + pub fn finish_with_progress( + &self, + seg: &Segmentation, + cancel: &CancelToken, + on_progress: &mut dyn FnMut(Progress), + ) -> Result { + let mut ctx = Ctx::new(cancel, on_progress); + self.finish_ctx(seg.clone(), &mut ctx) + } + + /// Downstream stages (color fit → compose → optimize) over an owned + /// segmentation. Shared by the one-shot and two-phase entry points; takes + /// ownership so the one-shot path avoids a clone. + fn finish_ctx(&self, mut seg: Segmentation, ctx: &mut Ctx) -> Result { for fitter in &self.color_fitters { fitter.fit(&mut seg); ctx.check()?; } - let mut doc = self.compositing.compose_with(&seg, &mut ctx)?; + let mut doc = self.compositing.compose_with(&seg, ctx)?; let total = self.optimizers.len().max(1); for (i, pass) in self.optimizers.iter().enumerate() { diff --git a/crates/vtracer/tests/reuse.rs b/crates/vtracer/tests/reuse.rs new file mode 100644 index 0000000..579d155 --- /dev/null +++ b/crates/vtracer/tests/reuse.rs @@ -0,0 +1,93 @@ +//! Two-phase pipeline: cache the expensive segmentation, re-run the cheap +//! downstream stages with different parameters (the interactive tuning loop). + +use vtracer::{ColorImage, Config, FitMode}; + +/// A few colored blocks — several clusters, a few holes. +fn blocks() -> ColorImage { + let (w, h) = (48usize, 48usize); + let mut pixels = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let c = match (x / 16, y / 16) { + (0, _) => (220u8, 40, 40), + (1, 0) => (40, 200, 60), + (1, _) => (50, 60, 220), + _ => (230, 210, 40), + }; + pixels.extend_from_slice(&[c.0, c.1, c.2, 255]); + } + } + ColorImage { + pixels, + width: w, + height: h, + } +} + +fn cfg(mode: FitMode) -> Config { + Config { + mode, + ..Config::default() + } +} + +/// `finish(segment(img))` equals the one-shot `run(img)`. +#[test] +fn two_phase_matches_one_shot() { + let img = blocks(); + let pipeline = cfg(FitMode::Spline).build().unwrap(); + + let one_shot = pipeline.run(&img).unwrap(); + let seg = pipeline.segment(&img).unwrap(); + let two_phase = pipeline.finish(&seg).unwrap(); + + assert_eq!( + pipeline.writer.write(&one_shot), + pipeline.writer.write(&two_phase), + "splitting segment/finish must not change the output" + ); +} + +/// A cached segmentation stays pristine — `finish` can be called repeatedly and +/// deterministically (color fitting mutates only an internal clone). +#[test] +fn cached_segmentation_is_reusable() { + let img = blocks(); + let pipeline = cfg(FitMode::Polygon).build().unwrap(); + + let seg = pipeline.segment(&img).unwrap(); + let first = pipeline.writer.write(&pipeline.finish(&seg).unwrap()); + let second = pipeline.writer.write(&pipeline.finish(&seg).unwrap()); + + assert_eq!(first, second, "reusing a cached segmentation must be stable"); +} + +/// The tuning workflow: segment once, then feed that segmentation to pipelines +/// with different curve-fitting parameters. Same regions, different geometry — +/// and no re-segmentation. +#[test] +fn tune_curve_fitting_on_cached_segmentation() { + let img = blocks(); + + // Same clustering parameters (defaults), different fit modes → the + // segmentation from one is valid input to the other's `finish`. + let pixel = cfg(FitMode::Pixel).build().unwrap(); + let spline = cfg(FitMode::Spline).build().unwrap(); + + let seg = pixel.segment(&img).unwrap(); + + let doc_pixel = pixel.finish(&seg).unwrap(); + let doc_spline = spline.finish(&seg).unwrap(); + + // Same partition → same number of shapes. + assert_eq!(doc_pixel.shapes.len(), doc_spline.shapes.len()); + assert!(!doc_pixel.shapes.is_empty()); + + // But the fitted geometry differs (straight edges vs cubic curves). + assert_ne!( + pixel.writer.write(&doc_pixel), + spline.writer.write(&doc_spline), + "pixel and spline fitting should produce different paths" + ); +}