mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-18 10:06:16 -07:00
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:
@@ -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.
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user