Three refinements that make the watershed frontend a first-class citizen of both compositing modes and of interactive tuning: Stacked mode now stacks for real. Instead of one full-canvas background plus disjoint regions, the cut emits the merge tree itself: the root (whole canvas, mean color) first, then progressively finer ancestor regions, then the final regions on top — the same principle as the color clustering frontend, just with watershed-born clusters. Sub-pixel gaps between abutting regions therefore show their common ancestor's color rather than an unrelated backdrop, and overdraw stays seam-free. A painted-area budget (3x canvas) keeps pathological persistence chains from ballooning the stack; the root and final regions are always emitted so coverage never depends on it. Cutout is native. The watershed hierarchy already decided every merge, so the flattened partition reaches the mosaic untouched: merge_diff is 0 for watershed (the gradient-step re-merge still applies to the color path). Faces are exactly the cut regions. Re-cuts are cached. WatershedHierarchy is now public and split into build(img) — Kruskal, BPT, volume persistence; depends only on the image — and cut(detail, min_area), which is near-linear: region formation, graph-level small-basin absorption (region adjacencies, not pixel sweeps), then the merge tree. Session builds the hierarchy lazily on the first watershed render and re-cuts it on every watershed_detail or filter_speckle change: ~25 ms per re-cut vs ~40 ms rebuild on a 1400x775 photo, with the one-shot Frontend::segment path unchanged (build + cut), so Session output still equals the one-shot pipeline exactly. Tests: flatten-based stack invariants (solid bottom layer, full coverage, final regions topmost, exact region counts), hierarchy re-cut == one-shot, Session re-cut == one-shot across detail changes, and cutout keeping two regions one gradient step apart that the color path's merge would rejoin. Watershed goldens re-blessed for the new stack structure.
8.3 KiB
Generated
Architecture
Workspace layout
Cargo.toml # workspace
crates/
├── vtracer-core/ # the framework. wasm-safe, no file/image I/O, no clap/pyo3
│ └── src/
│ ├── lib.rs
│ ├── ir/ # Segmentation, LabelMap, VectorDoc, geometry types
│ ├── frontend/ # trait Frontend + ColorClusterFrontend, BinaryFrontend, keying
│ ├── colorfit/ # trait ColorFitter + Identity, FixedPalette, AutoQuantize
│ ├── fitter/ # trait CurveFitter + Pixel, Polygon, Spline
│ ├── compose/ # stacked composition (per-region closed tracing)
│ ├── mosaic/ # boundary-graph extraction + shared-edge fitting (see mosaic.md)
│ ├── optimize/ # trait OptimizerPass + passes over VectorDoc
│ ├── svg/ # writer (absolute/relative, shorthands, precision)
│ └── pipeline.rs # Pipeline driver + Config/presets
├── vtracer/ # publishable bin+lib crate, keeps the crate name.
│ # image I/O (image crate), clap 4 CLI,
│ # pyo3 binding behind `python-binding` feature
└── vtracer-wasm/ # wasm-bindgen bindings over vtracer-core
nodejs/ # npm package: TS wrapper + embedded wasm build + sharp reader
webapp/andcmdapp/are deleted (git history preserves them).vtracerre-exportsvtracer-core, so library users need a single dependency.- During development the workspace carries
[patch.crates-io] visioncortex = { path = "../visioncortex" }; releases pin a published 0.8.x. flo_curves(already in the tree via visioncortex) becomes a direct dependency ofvtracer-corefor configurable-error Bezier fitting.
Core IR
Value types from visioncortex are reused where they fit (ColorImage, Color, PointF64, CompoundPath); the pipeline IR is our own:
/// Frontend output — the general form is ordered layers (painter's algorithm).
pub struct Segmentation {
pub width: u32,
pub height: u32,
pub layers: Vec<Layer>, // bottom-to-top paint order
}
pub struct Layer {
pub paint: Paint, // starts as mean cluster color; ColorFitter may rewrite
pub mask: RegionMask, // the cluster's pixel indices
}
/// Flat partition for mosaic mode, derived by painting layers top-down.
pub struct LabelMap {
pub width: u32,
pub height: u32,
pub labels: Vec<u32>, // one label per pixel; u32::MAX = OUTSIDE (keyed/transparent)
pub paints: Vec<Paint>, // indexed by label
}
/// Output document IR — what the optimizer and the writer operate on.
pub struct VectorDoc { pub width: u32, pub height: u32, pub shapes: Vec<Shape> }
pub struct Shape { pub paint: Paint, pub path: MultiPath } // subpaths: MoveTo + (Line|Cubic)* + Close
pub enum Paint { Solid(Color) } // room for gradients later
Why layers, not a label map, as the frontend output: in stacked mode clusters genuinely overlap (each hierarchical cluster is painted over its parents), which a flat label map cannot represent. The flat LabelMap needed by mosaic mode is derived from the layers by a top-down flatten — cheap and lossless for that purpose.
Stage traits
All object-safe; the driver composes boxed trait objects (ergonomic across CLI/py/wasm boundaries, negligible dispatch cost next to the per-pixel work).
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
}
pub trait ColorFitter {
fn fit(&self, seg: &mut Segmentation);
}
pub trait CurveFitter {
fn fit_closed(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // stacked outlines, rings
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
}
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
pub enum Compositing { Stacked, Mosaic }
pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
}
impl Pipeline {
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> { /* driver */ }
}
Driver flow:
frontend.segment(img)→Segmentation- each
ColorFitterrewrites layer paints (e.g. palette snapping) - compositing:
- Stacked — trace each layer's closed outlines independently (port of today's
to_compound_pathflow) viafitter.fit_closed - Mosaic — flatten to
LabelMap, merge adjacent same-paint regions, extract the boundary graph, fit each shared edge once viafitter.fit_open, assemble faces (see mosaic.md)
- Stacked — trace each layer's closed outlines independently (port of today's
- optimizer passes over the
VectorDoc SvgWriterserializes
Built-in implementations
- Frontends (selected by
Config::clustering)ColorClusterFrontend— wrapsvisioncortex::color_clusters::Runner, including the transparency-keying logic that currently lives inconverter.rs(find unused key color, key fully-transparent pixels,KeyingAction).BinaryFrontend— threshold →BinaryImage::to_clusters.WatershedFrontend— hierarchical watershed by volume on the 4-adjacency pixel graph (Cousty et al. TPAMI 2009; Najman, Cousty & Perret ISMM 2013), cut atwatershed_detail. Split intoWatershedHierarchy::build(expensive, image-only) andcut(near-instant), soSessionre-cuts a cached hierarchy when the detail changes. Emits the merge tree as a stacked hierarchy (root first, refined regions on top — the color-cluster principle), so stacked mode stays seam-free and sub-pixel gaps show ancestor colors; in cutout the partition reaches the mosaic untouched (merge_diff = 0).- Third parties implement
Frontendto feed external label maps or ML segmentation.
- ColorFitters
Identity(today's behavior: mean cluster color)FixedPalette { colors: Vec<Color> }— snaps each layer paint to the nearest palette entry in OKLabAutoQuantize { max_colors }— k-means/median-cut over layer paints- After palette snapping, a built-in merge step unions adjacent regions with identical paint (mosaic path) / merges consecutive identical-paint layers (stacked path).
- CurveFitters
PixelFitter— exact lattice polylinePolygonFitter— staircase-symmetric Douglas-PeuckerSplineFitter— subdivision + corner detection + least-squares cubic fit (port of the visioncortex flow, extended to open polylines with pinned endpoints)
Optimizer and SVG writer
Two levels: geometry passes over VectorDoc, then encoding choices in the writer.
QuantizePass { precision }— round coordinates once, in document space. Replaces today's per-write rounding, and eliminates the per-pathtranslate(x,y)transform by baking offsets into coordinates.SimplifyPass— drop zero-length and collinear-redundant segments after quantization.SvgWriter { relative: bool, shorthands: bool, precision }— per segment picks the shortest encoding:- relative (
l c s h v) vs absolute deltas, whichever serializes shorter h/vfor axis-aligned lines,sfor smooth cubic continuations- number formatting: trim trailing zeros, omit the space before negative numbers, leading-dot decimals
- relative (
- Paint grouping: shapes sharing a fill emitted inside
<g fill="…">when it saves bytes.
Output size is a tracked metric: the test suite asserts a byte-size budget against golden samples (see roadmap.md).
CLI
clap 4 derive, in the vtracer crate. Kept flags (mapping naturally): -i/--input, -o/--output, --preset bw|poster|photo, --clustering color-cluster|bw|watershed (formerly --colormode), --filter_speckle, --color_precision, --gradient_step, --mode pixel|polygon|spline, --corner_threshold, --segment_length, --splice_threshold, --path_precision.
New:
--hierarchical stacked|cutout—cutoutnow runs the true mosaic pipeline--palette '#112233,#445566,…'/--palette-file colors.txt— fixed palette color fitting--optimize 0..2— optimizer level (0 = off, 1 = quantize+simplify, 2 = + full writer shorthands/grouping)- mosaic extras:
--seam-stroke,--mosaic-strict(see mosaic.md)
Range validation moves from panic! to clap value_parser ranges.