mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-17 17:45:55 -07:00
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:
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Generated
BIN
Binary file not shown.
|
After Width: | Height: | Size: 806 KiB |
Reference in New Issue
Block a user