diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c77ace..df41d33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ 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. (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`). +* Progress reporting and cancellation for driving a UI from a worker thread: `Pipeline::run_with_progress` with a `CancelToken` and a per-phase callback. +* `Session`: interactive tuning that clusters an image once and re-renders per `Config` change, re-clustering only when a clustering parameter actually changes. Built on `Pipeline::segment`/`finish` (cache the segmentation, re-run just color/curve/optimize); `Config::segment_key` exposes what it compares. +* Binary thresholding: a tunable fixed threshold and Bradley–Roth adaptive thresholding for uneven lighting — CLI `--threshold` / `--adaptive` (`--adaptive-window`, `--adaptive-t`), also on `Config`, Python, and Node. ## 1.0.0-alpha.1 - 2026-07-24 diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 882fda3..889921d 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -43,6 +43,22 @@ pub enum Preset { Photo, } +/// The clustering-relevant projection of a [`Config`]. Two configs with equal +/// keys produce the same [`Segmentation`](crate::Segmentation), so a cached one +/// stays valid — this is what [`Session`](crate::Session) compares to decide +/// whether to re-segment. Kept in sync with [`Config::frontend`] in one place. +#[derive(Debug, Clone, PartialEq)] +pub struct SegmentKey { + color_mode: ColorMode, + color_precision: i32, + layer_difference: i32, + filter_speckle: usize, + binary_threshold: u8, + binary_adaptive: bool, + binary_adaptive_window: u32, + binary_adaptive_t: f64, +} + /// High-level converter configuration. [`Config::build`] turns this into a /// concrete [`Pipeline`]. #[derive(Debug, Clone)] @@ -236,6 +252,24 @@ impl Config { } } + /// The clustering-relevant subset of this config. Changing any field it + /// captures (color mode, color precision, layer difference, speckle, or the + /// binary threshold settings) requires re-segmenting; changing anything else + /// — fit mode, curve params, compositing, palette, optimization — reuses a + /// cached segmentation. See [`Session`](crate::Session). + pub fn segment_key(&self) -> SegmentKey { + SegmentKey { + color_mode: self.color_mode, + color_precision: self.color_precision, + layer_difference: self.layer_difference, + filter_speckle: self.filter_speckle, + binary_threshold: self.binary_threshold, + binary_adaptive: self.binary_adaptive, + binary_adaptive_window: self.binary_adaptive_window, + binary_adaptive_t: self.binary_adaptive_t, + } + } + /// Assemble a concrete pipeline from this configuration. pub fn build(&self) -> Result { let compositing = match self.hierarchical { diff --git a/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs index fd40d77..279ed7e 100644 --- a/crates/vtracer/src/lib.rs +++ b/crates/vtracer/src/lib.rs @@ -40,14 +40,16 @@ pub mod mosaic; pub mod optimize; pub mod pipeline; pub mod progress; +pub mod session; pub mod svg; -pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset}; +pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset, SegmentKey}; pub use error::Error; pub use frontend::Threshold; pub use ir::{Segmentation, VectorDoc}; pub use pipeline::Pipeline; pub use progress::{CancelToken, Phase, Progress}; +pub use session::Session; // Re-export the visioncortex value types callers need at the boundary. pub use visioncortex::{Color, ColorImage, PointF64, PointI32}; diff --git a/crates/vtracer/src/session.rs b/crates/vtracer/src/session.rs new file mode 100644 index 0000000..98be553 --- /dev/null +++ b/crates/vtracer/src/session.rs @@ -0,0 +1,118 @@ +//! Interactive tuning session: cache the expensive clustering, re-render on the +//! cheap stages, and re-segment automatically only when it's actually needed. +//! +//! A desktop app loads an image once, then calls [`Session::render`] on every +//! slider change with a fresh [`Config`]. The session compares the config's +//! [`SegmentKey`](crate::config::SegmentKey) to what it last clustered and +//! re-segments only if a clustering parameter changed — the caller never has to +//! know which parameters those are. +//! +//! ```no_run +//! use vtracer::{Config, Session, ColorImage}; +//! # fn load() -> ColorImage { todo!() } +//! let mut session = Session::new(load()); +//! +//! // First render clusters the image. +//! let mut cfg = Config::default(); +//! let _svg = session.render_svg(&cfg).unwrap(); +//! +//! // Tuning a curve parameter reuses the cached segmentation (no re-cluster). +//! cfg.corner_threshold = 90; +//! let _svg = session.render_svg(&cfg).unwrap(); +//! +//! // Changing a clustering parameter re-segments automatically. +//! cfg.filter_speckle = 8; +//! let _svg = session.render_svg(&cfg).unwrap(); +//! ``` + +use visioncortex::ColorImage; + +use crate::config::{Config, SegmentKey}; +use crate::error::Error; +use crate::ir::{Segmentation, VectorDoc}; +use crate::progress::{CancelToken, Progress}; + +/// A reusable converter for one image: clusters once, re-renders many times. +/// +/// Build it with the source [`ColorImage`] and drive it with a [`Config`] per +/// render. The cached [`Segmentation`] is refreshed transparently whenever the +/// config's clustering parameters change. +pub struct Session { + img: ColorImage, + /// The segmentation and the key it was produced with (`None` until the + /// first render). + cache: Option<(SegmentKey, Segmentation)>, +} + +impl Session { + /// Start a session over `img`. Nothing is clustered until the first render. + pub fn new(img: ColorImage) -> Self { + Self { img, cache: None } + } + + /// Whether the cached segmentation is missing or was clustered with + /// different parameters than `key`. + fn stale(&self, key: &SegmentKey) -> bool { + self.cache.as_ref().map_or(true, |(k, _)| k != key) + } + + /// The cached segmentation. Only call after ensuring the cache is fresh. + fn segmentation(&self) -> &Segmentation { + &self.cache.as_ref().expect("cache populated by caller").1 + } + + /// Render to the document IR, re-segmenting only if `cfg`'s clustering + /// parameters differ from the cached segmentation's. + pub fn render(&mut self, cfg: &Config) -> Result { + let pipeline = cfg.build()?; + let key = cfg.segment_key(); + if self.stale(&key) { + self.cache = Some((key, pipeline.segment(&self.img)?)); + } + pipeline.finish(self.segmentation()) + } + + /// [`render`](Session::render), serialized to an SVG string. + pub fn render_svg(&mut self, cfg: &Config) -> Result { + let pipeline = cfg.build()?; + let key = cfg.segment_key(); + if self.stale(&key) { + self.cache = Some((key, pipeline.segment(&self.img)?)); + } + Ok(pipeline.writer.write(&pipeline.finish(self.segmentation())?)) + } + + /// [`render`](Session::render) with progress reporting and cancellation. + /// + /// When a re-segmentation is needed, progress covers the [`Phase::Segment`] + /// stage first, then the finish stages; on a cache hit only the finish + /// stages report. Hand a clone of `cancel` to the UI to abort a long + /// clustering pass. + /// + /// [`Phase::Segment`]: crate::Phase::Segment + pub fn render_with_progress( + &mut self, + cfg: &Config, + cancel: &CancelToken, + on_progress: &mut dyn FnMut(Progress), + ) -> Result { + let pipeline = cfg.build()?; + let key = cfg.segment_key(); + if self.stale(&key) { + let seg = pipeline.segment_with_progress(&self.img, cancel, on_progress)?; + self.cache = Some((key, seg)); + } + pipeline.finish_with_progress(self.segmentation(), cancel, on_progress) + } + + /// Drop the cached segmentation, forcing the next render to re-cluster. + /// Use after replacing the source image out of band; normally unnecessary. + pub fn invalidate(&mut self) { + self.cache = None; + } + + /// The source image this session renders. + pub fn image(&self) -> &ColorImage { + &self.img + } +} diff --git a/crates/vtracer/tests/session.rs b/crates/vtracer/tests/session.rs new file mode 100644 index 0000000..ae74326 --- /dev/null +++ b/crates/vtracer/tests/session.rs @@ -0,0 +1,124 @@ +//! `Session` caches the segmentation and re-segments only when a clustering +//! parameter changes — verified both at the key level and end-to-end. + +use vtracer::{ColorImage, Config, Session}; + +/// A few colored blocks — several clusters. +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, + } +} + +/// The key partition: finish-phase params share a segment key; clustering +/// params change it. This is the contract `Session` relies on. +#[test] +fn segment_key_tracks_only_clustering_params() { + let base = Config::default(); + + // Finish-phase changes → same key (segmentation is reusable). + for tweaked in [ + Config { + corner_threshold: 90, + ..base.clone() + }, + Config { + optimize: 0, + ..base.clone() + }, + Config { + hierarchical: vtracer::Hierarchical::Cutout, + ..base.clone() + }, + Config { + max_colors: Some(4), + ..base.clone() + }, + ] { + assert_eq!( + base.segment_key(), + tweaked.segment_key(), + "finish-phase param must not change the segment key" + ); + } + + // Clustering changes → different key (must re-segment). + for tweaked in [ + Config { + filter_speckle: base.filter_speckle + 4, + ..base.clone() + }, + Config { + color_precision: 4, + ..base.clone() + }, + Config { + layer_difference: 32, + ..base.clone() + }, + Config { + color_mode: vtracer::ColorMode::Binary, + ..base.clone() + }, + ] { + assert_ne!( + base.segment_key(), + tweaked.segment_key(), + "clustering param must change the segment key" + ); + } +} + +/// A `Session` render equals the one-shot pipeline — for a finish-only change +/// (reuses the cache) and for a clustering change (re-segments). Correctness is +/// identical either way; the cache is a transparent optimization. +#[test] +fn session_matches_one_shot() { + let img = blocks(); + let mut session = Session::new(img.clone()); + + let base = Config::default(); + let svg0 = session.render_svg(&base).unwrap(); + assert_eq!( + svg0, + base.build().unwrap().to_svg(&img).unwrap(), + "first render must match the one-shot pipeline" + ); + + // Finish-only change: reuses the cached segmentation. + let tuned = Config { + corner_threshold: 90, + ..base.clone() + }; + assert_eq!( + session.render_svg(&tuned).unwrap(), + tuned.build().unwrap().to_svg(&img).unwrap(), + "reused-segmentation render must match the one-shot pipeline" + ); + + // Clustering change: re-segments, still matches the one-shot. + let respeckled = Config { + filter_speckle: base.filter_speckle + 4, + ..base.clone() + }; + assert_eq!( + session.render_svg(&respeckled).unwrap(), + respeckled.build().unwrap().to_svg(&img).unwrap(), + "re-segmented render must match the one-shot pipeline" + ); +} diff --git a/docs/images/desktop-app.png b/docs/images/desktop-app.png new file mode 100644 index 0000000..bb4d608 Binary files /dev/null and b/docs/images/desktop-app.png differ