Split pipeline into cacheable segment + re-runnable finish

Pipeline::segment runs only the frontend (the expensive clustering) and
returns a reusable Segmentation; Pipeline::finish re-runs just color
fitting, compositing, and optimization over a cached segmentation. This
restores the old stage-reuse workflow: cluster once, then tune curve-fit
or color-fit params without repaying clustering. Both have
*_with_progress variants; run/run_with_progress now compose the two
(one-shot path moves the owned segmentation, so it adds no clone).

finish clones the segmentation internally (color fitting mutates it), so
the cached copy stays pristine across many finish calls. Segmentation and
VectorDoc are re-exported at the crate root.
This commit is contained in:
Chris Tsang
2026-07-25 00:45:33 +01:00
parent a350e2532a
commit 5361a51011
4 changed files with 152 additions and 3 deletions
+1
View File
@@ -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 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`).
## 1.0.0-alpha.1 - 2026-07-24
+1
View File
@@ -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};
+57 -3
View File
@@ -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<VectorDoc, Error> {
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<Segmentation, Error> {
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<Segmentation, Error> {
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<VectorDoc, Error> {
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<VectorDoc, Error> {
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<VectorDoc, Error> {
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() {
+93
View File
@@ -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"
);
}