Add progress reporting and cancellation to the pipeline

Pipeline::run_with_progress(img, &CancelToken, &mut on_progress)
publishes per-phase Progress and aborts (Error::Cancelled) when the
token trips. The color-cluster frontend now drives visioncortex's
IncrementalBuilder so clustering reports fine-grained progress and
checks cancellation between batches; run() delegates to the new path,
which is output-identical since Runner::run() is that same tick loop.

Intended for native desktop apps (Tauri/egui/iced): run on a worker
thread, hand the UI a CancelToken clone for a cancel button, and
forward progress to a bar. Replaces the old browser-only cooperative
tick() API, which existed only because the main thread couldn't block.
This commit is contained in:
Chris Tsang
2026-07-25 00:00:07 +01:00
parent 1e3895318e
commit 50042f477d
8 changed files with 366 additions and 26 deletions
+45
View File
@@ -5,9 +5,11 @@
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
use crate::error::Error;
use crate::fitter::CurveFitter;
use crate::ir::{Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, SegmentFitter};
use crate::progress::{Ctx, Phase};
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
@@ -25,6 +27,49 @@ impl Compositing {
Compositing::Mosaic(fitter) => compose_mosaic(seg, fitter.as_ref()),
}
}
/// Progress- and cancellation-aware compositing.
///
/// Stacked mode reports per-layer progress and can be cancelled between
/// layers. Mosaic builds its boundary graph in one pass, so it reports
/// coarsely (start/end) and is cancellable only at the boundaries — the
/// dominant cost is upstream in clustering, which cancels finely.
pub fn compose_with(&self, seg: &Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
match self {
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), ctx),
Compositing::Mosaic(fitter) => {
ctx.check()?;
ctx.report(Phase::Compose, 0.0);
let doc = compose_mosaic(seg, fitter.as_ref());
ctx.check()?;
ctx.report(Phase::Compose, 1.0);
Ok(doc)
}
}
}
}
/// Progress-aware [`compose_stacked`]: reports after each layer and checks for
/// cancellation between them.
fn compose_stacked_with(
seg: &Segmentation,
fitter: &dyn CurveFitter,
ctx: &mut Ctx,
) -> Result<VectorDoc, Error> {
let mut doc = VectorDoc::new(seg.width, seg.height);
let total = seg.layers.len().max(1);
for (i, layer) in seg.layers.iter().enumerate() {
ctx.check()?;
let path = fitter.fit_region(&layer.mask);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
ctx.report(Phase::Compose, (i + 1) as f32 / total as f32);
}
Ok(doc)
}
/// Trace every layer's closed outline and stack the shapes in paint order.
+3
View File
@@ -9,6 +9,8 @@ pub enum Error {
NoKeyColor,
/// A requested feature is recognized but not yet implemented.
Unsupported(String),
/// The run was aborted via a [`crate::progress::CancelToken`].
Cancelled,
/// Any other failure, carrying a human-readable message.
Other(String),
}
@@ -21,6 +23,7 @@ impl fmt::Display for Error {
write!(f, "unable to find an unused color in image to use as key")
}
Error::Unsupported(what) => write!(f, "unsupported: {what}"),
Error::Cancelled => write!(f, "conversion cancelled"),
Error::Other(msg) => write!(f, "{msg}"),
}
}
+59 -23
View File
@@ -1,8 +1,14 @@
use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX};
use visioncortex::color_clusters::{
Clusters, KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX,
};
use visioncortex::{Color, ColorImage, PointI32};
// (Runner is constructed inline in each entry point so its generic closure
// types never appear in a return signature.)
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use crate::progress::{Ctx, Phase};
use super::keying::{apply_key, find_unused_color, should_key_image};
use super::Frontend;
@@ -28,8 +34,12 @@ impl Default for ColorClusterFrontend {
}
}
impl Frontend for ColorClusterFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
impl ColorClusterFrontend {
/// Apply transparency keying (if warranted) and build the clustering
/// inputs: the keyed image, the `RunnerConfig`, and the dimensions. The
/// caller constructs `Runner::new(config, image)` inline so the runner's
/// generic closure types never surface in a return signature.
fn prepare(&self, img: &ColorImage) -> Result<(ColorImage, RunnerConfig, usize, usize), Error> {
if img.width == 0 || img.height == 0 {
return Err(Error::EmptyImage);
}
@@ -48,26 +58,26 @@ impl Frontend for ColorClusterFrontend {
Color::default()
};
let runner = Runner::new(
RunnerConfig {
diagonal: self.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: self.filter_speckle_area,
good_max_area: width * height,
is_same_color_a: self.color_precision_loss,
is_same_color_b: 1,
deepen_diff: self.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: KeyingAction::Discard,
},
img,
);
let config = RunnerConfig {
diagonal: self.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: self.filter_speckle_area,
good_max_area: width * height,
is_same_color_a: self.color_precision_loss,
is_same_color_b: 1,
deepen_diff: self.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: KeyingAction::Discard,
};
let clusters = runner.run();
Ok((img, config, width, height))
}
/// Turn finished clusters into the layered [`Segmentation`].
fn segmentation_from_clusters(clusters: &Clusters, width: usize, height: usize) -> Segmentation {
let view = clusters.view();
let mut seg = Segmentation::new(width as u32, height as u32);
// `clusters_output` is top-to-bottom; reverse to get bottom-to-top
// paint order for the layer stack.
@@ -91,7 +101,33 @@ impl Frontend for ColorClusterFrontend {
mask,
});
}
Ok(seg)
seg
}
}
impl Frontend for ColorClusterFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
let (image, config, width, height) = self.prepare(img)?;
let clusters = Runner::new(config, image).run();
Ok(Self::segmentation_from_clusters(&clusters, width, height))
}
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
let (image, config, width, height) = self.prepare(img)?;
// Drive clustering incrementally so we can publish progress and observe
// cancellation between batches. `run()` is exactly this loop, so the
// resulting clusters are identical to the blocking path.
let mut builder = Runner::new(config, image).start();
ctx.report(Phase::Segment, 0.0);
while !builder.tick() {
ctx.check()?;
ctx.report(Phase::Segment, builder.progress() as f32 / 100.0);
}
ctx.check()?;
let clusters = builder.result();
ctx.report(Phase::Segment, 1.0);
Ok(Self::segmentation_from_clusters(&clusters, width, height))
}
}
+15
View File
@@ -19,8 +19,23 @@ use visioncortex::ColorImage;
use crate::error::Error;
use crate::ir::Segmentation;
use crate::progress::Ctx;
/// A frontend segments a raster image into ordered paint layers.
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
/// Progress- and cancellation-aware segmentation.
///
/// The default runs [`segment`](Frontend::segment) and then honors
/// cancellation (coarse: one report at completion, cancel observed after
/// the whole segmentation). Frontends that can step incrementally — like
/// [`ColorClusterFrontend`] — override this to report fine-grained
/// progress and observe cancellation between batches.
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
let seg = self.segment(img)?;
ctx.check()?;
ctx.report(crate::progress::Phase::Segment, 1.0);
Ok(seg)
}
}
+2
View File
@@ -39,11 +39,13 @@ pub mod ir;
pub mod mosaic;
pub mod optimize;
pub mod pipeline;
pub mod progress;
pub mod svg;
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
pub use error::Error;
pub use pipeline::Pipeline;
pub use progress::{CancelToken, Phase, Progress};
// Re-export the visioncortex value types callers need at the boundary.
pub use visioncortex::{Color, ColorImage, PointF64, PointI32};
+31 -3
View File
@@ -8,6 +8,7 @@ use crate::error::Error;
use crate::frontend::Frontend;
use crate::ir::VectorDoc;
use crate::optimize::OptimizerPass;
use crate::progress::{CancelToken, Ctx, Phase, Progress};
use crate::svg::SvgWriter;
/// A fully-assembled vectorization pipeline. Build one with
@@ -22,18 +23,45 @@ pub struct Pipeline {
impl Pipeline {
/// Run the pipeline to the output document IR (before serialization).
///
/// Equivalent to [`run_with_progress`](Pipeline::run_with_progress) with a
/// fresh (never-cancelled) token and a no-op progress callback.
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
let mut seg = self.frontend.segment(img)?;
self.run_with_progress(img, &CancelToken::new(), &mut |_| {})
}
/// Run the pipeline, publishing [`Progress`] updates and honoring the
/// [`CancelToken`].
///
/// Intended to be called on a worker thread: hand a clone of `cancel` to
/// the UI so a button can abort, and forward `on_progress` to a channel
/// that drives a progress bar. Returns [`Error::Cancelled`] if the token is
/// tripped. See [`crate::progress`] for a usage example.
pub fn run_with_progress(
&self,
img: &ColorImage,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<VectorDoc, Error> {
let mut ctx = Ctx::new(cancel, on_progress);
let mut seg = self.frontend.segment_with(img, &mut ctx)?;
for fitter in &self.color_fitters {
fitter.fit(&mut seg);
ctx.check()?;
}
let mut doc = self.compositing.compose(&seg);
let mut doc = self.compositing.compose_with(&seg, &mut ctx)?;
for pass in &self.optimizers {
let total = self.optimizers.len().max(1);
for (i, pass) in self.optimizers.iter().enumerate() {
ctx.check()?;
pass.run(&mut doc);
ctx.report(Phase::Optimize, (i + 1) as f32 / total as f32);
}
// Always emit a terminal 100% so a UI can settle even with no passes.
ctx.report(Phase::Optimize, 1.0);
Ok(doc)
}
+113
View File
@@ -0,0 +1,113 @@
//! Progress reporting and cancellation for long-running conversions.
//!
//! [`crate::Pipeline::run_with_progress`] takes a [`CancelToken`] and a
//! progress callback. On native targets, run it on a worker thread: the
//! callback publishes [`Progress`] to the UI and the token lets the UI abort
//! between work batches (clustering checks once per batch, so cancellation is
//! near-instant). The pipeline returns [`Error::Cancelled`] when the token is
//! tripped.
//!
//! There is deliberately no cooperative `tick()` here: that only existed in the
//! old browser build because the main thread could not block. The same API
//! works unchanged from a Web Worker.
//!
//! ```no_run
//! use vtracer::{Config, ColorImage};
//! use vtracer::progress::{CancelToken, Progress};
//!
//! # fn load() -> ColorImage { todo!() }
//! let pipeline = Config::default().build().unwrap();
//! let cancel = CancelToken::new();
//! # let img: ColorImage = load();
//! // hand `cancel.clone()` to the UI so a button can call `cancel.cancel()`
//! let mut on_progress = |p: Progress| eprintln!("{:?} {:.0}%", p.phase, p.fraction * 100.0);
//! let doc = pipeline.run_with_progress(&img, &cancel, &mut on_progress);
//! ```
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::error::Error;
/// A cheaply-clonable cancellation flag shared between the UI and the worker.
///
/// Clone it, hand one copy to the worker thread running the pipeline and keep
/// the other; call [`cancel`](CancelToken::cancel) from any thread to request
/// an early stop. Clones share the same underlying flag.
#[derive(Clone, Default)]
pub struct CancelToken(Arc<AtomicBool>);
impl CancelToken {
/// A fresh, un-cancelled token.
pub fn new() -> Self {
Self::default()
}
/// Request cancellation. Idempotent; safe to call from any thread.
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
/// Whether cancellation has been requested.
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// Which pipeline phase a [`Progress`] update belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
/// Frontend segmentation (color clustering) — usually the dominant cost.
Segment,
/// Compositing the segmentation into shapes.
Compose,
/// Output optimization passes.
Optimize,
}
/// A progress update: the current [`Phase`] and how far through it we are.
///
/// `fraction` is *within* the phase, in `0.0..=1.0`. Clustering dominates
/// runtime, so a UI can weight the phases or simply show the phase label with
/// its fraction (e.g. "Clustering 45%").
#[derive(Clone, Copy, Debug)]
pub struct Progress {
pub phase: Phase,
pub fraction: f32,
}
/// Bundles the cancel token and progress sink threaded through the stages.
///
/// Stages call [`Ctx::check`] between batches to honor cancellation and
/// [`Ctx::report`] to publish progress.
pub struct Ctx<'a> {
cancel: &'a CancelToken,
on_progress: &'a mut dyn FnMut(Progress),
}
impl<'a> Ctx<'a> {
/// Construct a context from a token and a progress callback.
pub fn new(cancel: &'a CancelToken, on_progress: &'a mut dyn FnMut(Progress)) -> Self {
Self {
cancel,
on_progress,
}
}
/// Return [`Error::Cancelled`] if cancellation has been requested.
pub fn check(&self) -> Result<(), Error> {
if self.cancel.is_cancelled() {
Err(Error::Cancelled)
} else {
Ok(())
}
}
/// Publish a progress update for `phase` at `fraction` (clamped to 0..=1).
pub fn report(&mut self, phase: Phase, fraction: f32) {
(self.on_progress)(Progress {
phase,
fraction: fraction.clamp(0.0, 1.0),
});
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Progress reporting and cancellation for `Pipeline::run_with_progress`.
use std::cell::Cell;
use vtracer::progress::{CancelToken, Phase, Progress};
use vtracer::{ColorImage, Config, Error};
/// A checkerboard of two colors — enough clusters that segmentation runs a few
/// batches, so incremental progress and mid-run cancellation are observable.
fn checker(w: usize, h: usize) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = if (x / 6 + y / 6) % 2 == 0 {
(210u8, 60, 60)
} else {
(60, 90, 200)
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// A token cancelled before the run starts trips promptly and yields no doc.
#[test]
fn precancelled_returns_cancelled() {
let img = checker(64, 64);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
cancel.cancel();
let mut cb = |_p: Progress| {};
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
assert_eq!(result.err(), Some(Error::Cancelled));
}
/// Cancelling from within the progress callback (on the first Segment report)
/// trips at the next batch boundary and returns `Cancelled`.
#[test]
fn cancel_during_progress_trips() {
let img = checker(96, 96);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
let saw_segment = Cell::new(false);
let mut cb = |p: Progress| {
if p.phase == Phase::Segment {
saw_segment.set(true);
cancel.cancel();
}
};
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
assert!(saw_segment.get(), "expected at least one Segment report");
assert_eq!(result.err(), Some(Error::Cancelled));
}
/// A successful run reports monotonically within each phase, ends at
/// Optimize=1.0, and produces the same shapes as the plain `run`.
#[test]
fn progress_completes_and_matches_run() {
let img = checker(64, 64);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
let last = Cell::new(None::<Progress>);
let count = Cell::new(0usize);
let mut cb = |p: Progress| {
assert!(
(0.0..=1.0).contains(&p.fraction),
"fraction out of range: {}",
p.fraction
);
last.set(Some(p));
count.set(count.get() + 1);
};
let doc = pipeline
.run_with_progress(&img, &cancel, &mut cb)
.expect("run should succeed");
assert!(count.get() > 0, "expected progress reports");
let final_p = last.get().expect("a final report");
assert_eq!(final_p.phase, Phase::Optimize);
assert_eq!(final_p.fraction, 1.0);
// Incremental clustering yields the same clusters as the blocking path,
// so both entry points produce identical output.
let plain = pipeline.run(&img).expect("plain run should succeed");
assert_eq!(doc.shapes.len(), plain.shapes.len());
}