Add Session: transparent segmentation caching for interactive tuning

A stateful, image-owning converter for the desktop tuning loop. The consumer
calls render / render_svg / render_with_progress with a fresh Config each
frame and never reasons about cachability: Session compares the Config's
SegmentKey (its clustering-relevant projection — color mode, color precision,
layer difference, speckle, binary threshold settings) to what it last
clustered and re-segments only when that changes. Everything else — fit mode,
curve params, compositing, palette, optimization — reuses the cached
Segmentation.

Config::segment_key is public too, so callers that hold their own state (e.g.
the wasm/JS side) can compare keys with the same source of truth.

Tests cover the key partition (finish-phase params share a key; clustering
params change it) and that Session output matches the one-shot pipeline for
both a reused-segmentation render and a re-segmented one.

Add desktop app screenshot referenced by the README

Condense the unreleased changelog notes
This commit is contained in:
Chris Tsang
2026-07-25 12:44:45 +01:00
parent abe21658dc
commit df94675494
6 changed files with 282 additions and 4 deletions
+3 -3
View File
@@ -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 BradleyRoth 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 BradleyRoth 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
+34
View File
@@ -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<Pipeline, Error> {
let compositing = match self.hierarchical {
+3 -1
View File
@@ -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};
+118
View File
@@ -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<VectorDoc, Error> {
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<String, Error> {
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<VectorDoc, Error> {
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
}
}
+124
View File
@@ -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"
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB