Add watershed clustering: hierarchical watershed frontend

An alternative region-forming frontend selected by --clustering watershed
(the color_mode field is replaced by clustering: color-cluster | bw |
watershed across CLI, Rust, Python, and Node — it selects the algorithm,
not a color space).

The algorithm is the watershed hierarchy by volume on the 4-adjacency
pixel graph, implemented from the papers:

  Cousty, Bertrand, Najman, Couprie, "Watershed Cuts: Minimum Spanning
  Forests and the Drop of Water Principle", IEEE TPAMI 31(8), 2009.
  Najman, Cousty, Perret, "Playing with Kruskal", ISMM 2013.

Edge weights are the max per-channel color difference between adjacent
pixels (no gradient image); a counting-sorted Kruskal pass builds the
binary partition tree as a flat parents array; a leaves-to-root pass
computes subtree area and volume; each merge's persistence (the volume of
the smaller side, plateau-corrected) becomes its MST edge's saliency; and
cutting the hierarchy is single-linkage over MST edges below the cut
level. Watershed cuts label every pixel — no watershed-line pixel class —
so the output is a strict, gapless partition that drops straight into
both stacked and cutout modes. Integer arithmetic and flat u32 arrays
throughout; deterministic across platforms; ~66 ms on a 1400x775 photo.

The one dial, --watershed-detail (0..=255), maps exponentially to a
target region count (each +25.5 doubles it) since the persistence
distribution is far too skewed for a linear threshold. filter_speckle
absorbs undersized basins into their most color-similar neighbour rather
than dropping them, preserving the partition. The largest region is
emitted first as a solid full-canvas background layer so stacked mode
keeps its seam-free overdraw; the mosaic flatten is unaffected.

Tests: partition invariant (disjoint masks tiling the canvas), detail
monotonicity, min-area absorption, determinism, watershed cases in the
pipeline/golden suites, stacked-vs-cutout interior equivalence, a
watershed seam test, and SegmentKey coverage for the new params.
This commit is contained in:
Chris Tsang
2026-07-27 14:45:25 +01:00
parent f4872fcfe3
commit 46a1b90ccd
21 changed files with 854 additions and 67 deletions
+5
View File
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
* Binary thresholding: a tunable fixed threshold and BradleyRoth adaptive thresholding for uneven lighting — CLI `--threshold` / `--adaptive` (`--adaptive-window`, `--adaptive-t`), also on `Config`, Python, and Node.
* Cutout mode merges neighbouring mosaic regions whose colors are within one gradient step — the flattened tessellation no longer keeps the near-identical faces that stacked gradient layering splits a smooth area into.
* Watershed clustering (`--clustering watershed`): an alternative region-forming frontend — a hierarchical watershed by volume on the pixel graph (Cousty et al., TPAMI 2009; Najman, Cousty & Perret, ISMM 2013), cut at a single `--watershed-detail` dial (0..=255, each +25.5 roughly doubles the region count). Content-adaptive regions with no watershed-line pixels; the partition drops straight into both stacked and cutout modes.
### Changed
* `color_mode` is replaced by `clustering` (`color-cluster` | `bw` | `watershed`) across the CLI (`--clustering`), Rust (`Config::clustering`, enum `Clustering`), Python, and Node — the field selects the region-forming algorithm, not a color space.
## 1.0.0-alpha.1 - 2026-07-24
+17 -5
View File
@@ -80,7 +80,7 @@ Options:
-i, --input <INPUT> Path to the input raster image
-o, --output <OUTPUT> Path to the output SVG
--preset <PRESET> Start from a preset: bw, poster, photo
--colormode <COLORMODE> Color image `color` (default) or binary image `bw`
--clustering <CLUSTERING> Region forming: `color-cluster` (default), `bw`, `watershed`
--hierarchical <HIERARCHICAL> Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
-f, --filter-speckle <FILTER_SPECKLE> Discard patches smaller than X px in size (0..=128)
@@ -98,6 +98,7 @@ Options:
--adaptive Binary mode: BradleyRoth adaptive threshold (uneven lighting)
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
--watershed-detail <WATERSHED_DETAIL> Watershed: hierarchy cut level 0..=255 (higher = more regions)
-h, --help Print help
-V, --version Print version
```
@@ -112,6 +113,10 @@ Options:
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
**BradleyRoth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
/ `--adaptive-t`) for scans with uneven lighting.
- **`--clustering watershed`** — an alternative region-forming algorithm: a
hierarchical watershed on the pixel graph (Cousty et al., TPAMI 2009; Najman,
Cousty & Perret, ISMM 2013), cut at `--watershed-detail`. Content-adaptive
regions that follow object shape — pairs beautifully with `cutout`.
## Downloads
@@ -135,11 +140,14 @@ cargo install vtracer-cli
./vtracer input.jpg output.svg --preset bw
# scanned/photographed line art with uneven lighting
./vtracer scan.jpg output.svg --colormode bw --adaptive
./vtracer scan.jpg output.svg --clustering bw --adaptive
# seam-free mosaic (gapless tessellation)
./vtracer input.jpg output.svg --hierarchical cutout
# watershed region forming, cut to taste
./vtracer photo.jpg output.svg --clustering watershed --watershed-detail 192
# constrain to a fixed palette
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
```
@@ -202,8 +210,12 @@ cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
svg = cfg.convert_bytes(data)
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
# watershed region forming
ws = vtracer.Config(clustering="watershed", watershed_detail=192)
svg = ws.convert_file("photo.jpg", "photo.svg")
# binary with adaptive (BradleyRoth) thresholding
bw = vtracer.Config(color_mode="bw", adaptive=True)
bw = vtracer.Config(clustering="bw", adaptive=True)
svg = bw.convert_file("scan.jpg", "scan.svg")
```
@@ -222,10 +234,10 @@ const vtracer = require('@visioncortex/vtracer');
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
// binary with adaptive thresholding
const bw = vtracer.convertBuffer(buffer, { colorMode: 'bw', adaptive: true });
const bw = vtracer.convertBuffer(buffer, { clustering: 'bw', adaptive: true });
```
## Citations
+13 -6
View File
@@ -9,7 +9,7 @@ use std::process::ExitCode;
use clap::Parser;
use visioncortex::{Color, ColorImage};
use vtracer::{ColorMode, Config, FitMode, Hierarchical, Preset};
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
/// Convert an image into vector graphics.
#[derive(Parser, Debug)]
@@ -35,9 +35,9 @@ struct Args {
#[arg(long)]
preset: Option<Preset>,
/// Color image (`color`) or binary image (`bw`).
#[arg(long = "colormode")]
colormode: Option<ColorMode>,
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
#[arg(long)]
clustering: Option<Clustering>,
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
#[arg(long)]
@@ -106,6 +106,10 @@ struct Args {
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
#[arg(long)]
adaptive_t: Option<f64>,
/// Watershed clustering: hierarchy cut level (0..=255, higher = more regions).
#[arg(long, value_parser = clap::value_parser!(u8))]
watershed_detail: Option<u8>,
}
fn parse_segment_length(s: &str) -> Result<f64, String> {
@@ -148,8 +152,8 @@ fn build_config(args: &Args) -> Result<Config, String> {
None => Config::default(),
};
if let Some(v) = args.colormode {
config.color_mode = v;
if let Some(v) = args.clustering {
config.clustering = v;
}
if let Some(v) = args.hierarchical {
config.hierarchical = v;
@@ -199,6 +203,9 @@ fn build_config(args: &Args) -> Result<Config, String> {
if let Some(v) = args.adaptive_t {
config.binary_adaptive_t = v;
}
if let Some(v) = args.watershed_detail {
config.watershed_detail = v;
}
// Palette: inline flag wins over file; both parse to a color list.
if let Some(text) = &args.palette {
+6 -1
View File
@@ -41,7 +41,7 @@ properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| arg | default | notes |
|---|---|---|
| `color_mode` | `"color"` | `"color"` or `"bw"` |
| `clustering` | `"color-cluster"` | `"color-cluster"`, `"bw"`, or `"watershed"` |
| `hierarchical` | `"stacked"` | `"stacked"` or `"cutout"` (mosaic) |
| `mode` | `"spline"` | `"pixel"`, `"polygon"`, `"spline"` |
| `filter_speckle` | `4` | discard patches smaller than X px |
@@ -55,6 +55,11 @@ properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| `palette` | `None` | list of `#rrggbb` strings |
| `max_colors` | `None` | auto-quantize target |
| `optimize` | `1` | `0` off, `1` quantize+simplify, `2` + shorthands |
| `binary_threshold` | `128` | bw: fixed cutoff, foreground below it |
| `adaptive` | `False` | bw: BradleyRoth adaptive thresholding |
| `adaptive_window` | `0` | bw adaptive: window px (`0` = auto) |
| `adaptive_t` | `15.0` | bw adaptive: % below local mean |
| `watershed_detail` | `128` | watershed: hierarchy cut level 0..=255 |
Each `Config` has `convert_file(input, output)`, `convert_bytes(data, format=None) -> str`,
and `convert_pixels(rgba, width, height) -> str`.
+27 -14
View File
@@ -28,7 +28,7 @@ use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use ::vtracer::{
Color, ColorImage, ColorMode, Config as CoreConfig, FitMode, Hierarchical, Preset,
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
};
// --- string <-> enum helpers -------------------------------------------------
@@ -37,10 +37,11 @@ fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
s.parse().map_err(PyValueError::new_err)
}
fn color_mode_str(m: ColorMode) -> &'static str {
match m {
ColorMode::Color => "color",
ColorMode::Binary => "bw",
fn clustering_str(c: Clustering) -> &'static str {
match c {
Clustering::ColorCluster => "color-cluster",
Clustering::Binary => "bw",
Clustering::Watershed => "watershed",
}
}
@@ -129,7 +130,7 @@ impl PyConfig {
impl PyConfig {
#[new]
#[pyo3(signature = (
color_mode = "color",
clustering = "color-cluster",
hierarchical = "stacked",
mode = "spline",
filter_speckle = 4,
@@ -147,10 +148,11 @@ impl PyConfig {
adaptive = false,
adaptive_window = 0,
adaptive_t = 15.0,
watershed_detail = 128,
))]
#[allow(clippy::too_many_arguments)]
fn new(
color_mode: &str,
clustering: &str,
hierarchical: &str,
mode: &str,
filter_speckle: usize,
@@ -168,6 +170,7 @@ impl PyConfig {
adaptive: bool,
adaptive_window: u32,
adaptive_t: f64,
watershed_detail: u8,
) -> PyResult<Self> {
let palette = match palette {
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
@@ -175,7 +178,7 @@ impl PyConfig {
};
Ok(Self {
inner: CoreConfig {
color_mode: parse(color_mode)?,
clustering: parse(clustering)?,
hierarchical: parse(hierarchical)?,
mode: parse(mode)?,
filter_speckle,
@@ -193,6 +196,7 @@ impl PyConfig {
binary_adaptive: adaptive,
binary_adaptive_window: adaptive_window,
binary_adaptive_t: adaptive_t,
watershed_detail,
},
})
}
@@ -224,15 +228,24 @@ impl PyConfig {
// --- properties ---
#[getter]
fn color_mode(&self) -> &'static str {
color_mode_str(self.inner.color_mode)
fn clustering(&self) -> &'static str {
clustering_str(self.inner.clustering)
}
#[setter]
fn set_color_mode(&mut self, v: &str) -> PyResult<()> {
self.inner.color_mode = parse(v)?;
fn set_clustering(&mut self, v: &str) -> PyResult<()> {
self.inner.clustering = parse(v)?;
Ok(())
}
#[getter]
fn watershed_detail(&self) -> u8 {
self.inner.watershed_detail
}
#[setter]
fn set_watershed_detail(&mut self, v: u8) {
self.inner.watershed_detail = v;
}
#[getter]
fn hierarchical(&self) -> &'static str {
hierarchical_str(self.inner.hierarchical)
@@ -432,11 +445,11 @@ impl PyConfig {
fn __repr__(&self) -> String {
let c = &self.inner;
format!(
"Config(color_mode='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
"Config(clustering='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
max_colors={:?}, optimize={})",
color_mode_str(c.color_mode),
clustering_str(c.clustering),
hierarchical_str(c.hierarchical),
mode_str(c.mode),
c.filter_speckle,
+12 -2
View File
@@ -8,7 +8,7 @@ class Config:
def __init__(
self,
color_mode: str = "color", # "color" | "bw"
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
mode: str = "spline", # "pixel" | "polygon" | "spline"
filter_speckle: int = 4,
@@ -22,6 +22,11 @@ class Config:
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
max_colors: Optional[int] = None, # auto-quantize target
optimize: int = 1, # 0 | 1 | 2
binary_threshold: int = 128, # bw: fixed cutoff 0..=255
adaptive: bool = False, # bw: BradleyRoth adaptive
adaptive_window: int = 0, # bw adaptive: window px (0 = auto)
adaptive_t: float = 15.0, # bw adaptive: % below local mean
watershed_detail: int = 128, # watershed: cut level 0..=255
) -> None: ...
@staticmethod
@@ -31,7 +36,7 @@ class Config:
@staticmethod
def photo() -> "Config": ...
color_mode: str
clustering: str
hierarchical: str
mode: str
filter_speckle: int
@@ -45,6 +50,11 @@ class Config:
palette: list[str]
max_colors: Optional[int]
optimize: int
binary_threshold: int
adaptive: bool
adaptive_window: int
adaptive_t: float
watershed_detail: int
def convert_file(self, input_path: str, output_path: str) -> None: ...
def convert_bytes(self, data: bytes, format: Optional[str] = None) -> str: ...
+38 -20
View File
@@ -8,7 +8,9 @@ use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAd
use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend, Threshold};
use crate::frontend::{
BinaryFrontend, ColorClusterFrontend, Frontend, Threshold, WatershedFrontend,
};
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
@@ -16,10 +18,15 @@ use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
use crate::pipeline::Pipeline;
use crate::svg::SvgWriter;
/// Which region-forming algorithm segments the image.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Color,
pub enum Clustering {
/// Hierarchical color clustering — the classic VTracer path.
ColorCluster,
/// Threshold to black/white, then cluster the foreground.
Binary,
/// Hierarchical watershed on the pixel graph, cut at `watershed_detail`.
Watershed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -49,7 +56,7 @@ pub enum Preset {
/// whether to re-segment. Kept in sync with [`Config::frontend`] in one place.
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentKey {
color_mode: ColorMode,
clustering: Clustering,
color_precision: i32,
layer_difference: i32,
filter_speckle: usize,
@@ -57,13 +64,15 @@ pub struct SegmentKey {
binary_adaptive: bool,
binary_adaptive_window: u32,
binary_adaptive_t: f64,
watershed_detail: u8,
}
/// High-level converter configuration. [`Config::build`] turns this into a
/// concrete [`Pipeline`].
#[derive(Debug, Clone)]
pub struct Config {
pub color_mode: ColorMode,
/// Region-forming algorithm (see [`Clustering`]).
pub clustering: Clustering,
pub hierarchical: Hierarchical,
/// Speckle filter given as a side length; the area threshold is its square.
pub filter_speckle: usize,
@@ -97,12 +106,15 @@ pub struct Config {
pub binary_adaptive_window: u32,
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
pub binary_adaptive_t: f64,
/// Watershed clustering: where to cut the hierarchy (0..=255). Higher
/// keeps more regions; 0 collapses the image to a single region.
pub watershed_detail: u8,
}
impl Default for Config {
fn default() -> Self {
Self {
color_mode: ColorMode::Color,
clustering: Clustering::ColorCluster,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 6,
@@ -120,6 +132,7 @@ impl Default for Config {
binary_adaptive: false,
binary_adaptive_window: 0,
binary_adaptive_t: 15.0,
watershed_detail: 128,
}
}
}
@@ -128,16 +141,14 @@ impl Config {
pub fn from_preset(preset: Preset) -> Self {
match preset {
Preset::Bw => Self {
color_mode: ColorMode::Binary,
clustering: Clustering::Binary,
..Self::default()
},
Preset::Poster => Self {
color_mode: ColorMode::Color,
color_precision: 8,
..Self::default()
},
Preset::Photo => Self {
color_mode: ColorMode::Color,
filter_speckle: 10,
color_precision: 8,
layer_difference: 48,
@@ -157,13 +168,13 @@ impl Config {
}
fn frontend(&self) -> Box<dyn Frontend> {
match self.color_mode {
ColorMode::Color => Box::new(ColorClusterFrontend {
match self.clustering {
Clustering::ColorCluster => Box::new(ColorClusterFrontend {
color_precision_loss: 8 - self.color_precision,
layer_difference: self.layer_difference,
good_min_area: self.speckle_area(),
}),
ColorMode::Binary => {
Clustering::Binary => {
let threshold = if self.binary_adaptive {
Threshold::Adaptive {
window: self.binary_adaptive_window,
@@ -178,6 +189,10 @@ impl Config {
min_area: self.speckle_area(),
})
}
Clustering::Watershed => Box::new(WatershedFrontend {
detail: self.watershed_detail,
min_area: self.speckle_area(),
}),
}
}
@@ -253,13 +268,14 @@ 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).
/// captures (clustering algorithm, color precision, layer difference,
/// speckle, binary threshold settings, or watershed detail) 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,
clustering: self.clustering,
color_precision: self.color_precision,
layer_difference: self.layer_difference,
filter_speckle: self.filter_speckle,
@@ -267,6 +283,7 @@ impl Config {
binary_adaptive: self.binary_adaptive,
binary_adaptive_window: self.binary_adaptive_window,
binary_adaptive_t: self.binary_adaptive_t,
watershed_detail: self.watershed_detail,
}
}
@@ -297,13 +314,14 @@ fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
impl FromStr for ColorMode {
impl FromStr for Clustering {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color" => Ok(Self::Color),
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
"binary" | "bw" | "BW" => Ok(Self::Binary),
_ => Err(format!("unknown color mode {s}")),
"watershed" => Ok(Self::Watershed),
_ => Err(format!("unknown clustering {s}")),
}
}
}
+3
View File
@@ -4,6 +4,7 @@
//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic
//! VTracer color path), including transparency keying.
//! * [`BinaryFrontend`] — threshold to black/white then cluster.
//! * [`WatershedFrontend`] — hierarchical watershed on the pixel graph.
//!
//! Third parties can implement [`Frontend`] to feed external label maps or ML
//! segmentation into the pipeline.
@@ -11,9 +12,11 @@
mod binary;
mod color_cluster;
mod keying;
mod watershed;
pub use binary::{BinaryFrontend, Threshold};
pub use color_cluster::ColorClusterFrontend;
pub use watershed::WatershedFrontend;
use visioncortex::ColorImage;
+440
View File
@@ -0,0 +1,440 @@
//! Hierarchical watershed frontend — region forming on the pixel graph.
//!
//! The image is treated as a 4-adjacency edge-weighted graph (edge weight =
//! color difference between the two pixels; no gradient image is built). On it
//! we compute the watershed hierarchy by **volume extinction** and cut it at a
//! detail level, following:
//!
//! * Cousty, Bertrand, Najman, Couprie, *Watershed Cuts: Minimum Spanning
//! Forests and the Drop of Water Principle*, IEEE TPAMI 31(8), 2009.
//! * Najman, Cousty, Perret, *Playing with Kruskal: Algorithms for
//! Morphological Trees in Edge-Weighted Graphs*, ISMM 2013.
//!
//! Pipeline: Kruskal over counting-sorted edges builds the binary partition
//! tree (a flat `parents` array, leaves `0..n`, internal nodes created in
//! altitude order). A leaves-to-root pass computes each subtree's area and
//! volume; each internal node's *persistence* (the volume of the smaller of
//! the two merged regions) becomes the saliency of its MST edge. Cutting the
//! hierarchy at level λ is then single-linkage over MST edges with
//! persistence ≤ λ — every pixel gets a label, no watershed-line pixels.
//!
//! Everything is integer and allocation-flat: counting sort over 256 weight
//! buckets, path-halving union-find, `u32` node ids. Deterministic across
//! platforms.
use visioncortex::{BinaryImage, Color, ColorImage, PointI32};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::Frontend;
/// Watershed frontend: hierarchical watershed by volume, cut at `detail`.
#[derive(Debug, Clone)]
pub struct WatershedFrontend {
/// Detail level (0..=255): where to cut the hierarchy. 255 keeps every
/// basin that survives a zero-persistence merge (finest useful partition);
/// 0 merges everything into a single region.
pub detail: u8,
/// Absorb regions smaller than this many pixels into their most
/// color-similar neighbour after the cut (0 = keep all).
pub min_area: usize,
}
impl Default for WatershedFrontend {
fn default() -> Self {
Self {
detail: 128,
min_area: 16,
}
}
}
/// Flat union-find over `u32` ids with path halving.
struct Uf(Vec<u32>);
impl Uf {
fn new(n: usize) -> Self {
Uf((0..n as u32).collect())
}
fn find(&mut self, mut x: u32) -> u32 {
while self.0[x as usize] != x {
self.0[x as usize] = self.0[self.0[x as usize] as usize];
x = self.0[x as usize];
}
x
}
/// Union by attaching `b`'s root under `a`'s. Caller passes roots.
fn link(&mut self, a: u32, b: u32) {
self.0[b as usize] = a;
}
}
/// Edge weight: max per-channel absolute difference (L∞), the same family of
/// channel-difference metric the rest of vtracer uses. 0..=255.
#[inline]
fn edge_weight(a: Color, b: Color) -> u8 {
let dr = a.r.abs_diff(b.r);
let dg = a.g.abs_diff(b.g);
let db = a.b.abs_diff(b.b);
dr.max(dg).max(db)
}
impl WatershedFrontend {
fn label_map(&self, img: &ColorImage) -> Vec<u32> {
let w = img.width;
let h = img.height;
let n = w * h;
// --- 4-adjacency edges, counting-sorted by weight -------------------
// Edge id encodes (pixel, direction): 2*p = right, 2*p+1 = down.
// The per-bucket fill preserves edge-id order, so the sort is stable
// and the whole construction is deterministic.
let px = |i: usize| {
let c = img.get_pixel(i % w, i / w);
c
};
let mut counts = [0u32; 256];
let mut weight_of = vec![0u8; 2 * n];
for i in 0..n {
let c = px(i);
if i % w + 1 < w {
let wgt = edge_weight(c, px(i + 1));
weight_of[2 * i] = wgt;
counts[wgt as usize] += 1;
}
if i / w + 1 < h {
let wgt = edge_weight(c, px(i + w));
weight_of[2 * i + 1] = wgt;
counts[wgt as usize] += 1;
}
}
let n_edges = counts.iter().map(|&c| c as usize).sum::<usize>();
let mut start = [0usize; 256];
let mut acc = 0usize;
for b in 0..256 {
start[b] = acc;
acc += counts[b] as usize;
}
let mut order = vec![0u32; n_edges];
let mut fill = start;
for i in 0..n {
if i % w + 1 < w {
let e = 2 * i;
let b = weight_of[e] as usize;
order[fill[b]] = e as u32;
fill[b] += 1;
}
if i / w + 1 < h {
let e = 2 * i + 1;
let b = weight_of[e] as usize;
order[fill[b]] = e as u32;
fill[b] += 1;
}
}
// --- Kruskal → binary partition tree by altitude --------------------
// Leaves 0..n are pixels; each accepted MST edge creates internal node
// n+k whose two children are the merged components' current roots.
// The grid is connected, so exactly n-1 internal nodes are created and
// parent indices are always greater than child indices.
let n_nodes = 2 * n - 1;
let mut parent = vec![u32::MAX; n_nodes];
let mut alt = vec![0u8; n_nodes]; // altitude; leaves at 0
let mut child = vec![[0u32; 2]; n - 1]; // children of internal node k
let mut mst_edge = vec![(0u32, 0u32); n - 1]; // pixel pair of edge k
let mut uf = Uf::new(n);
// Current tree node representing each union-find root's component.
let mut comp_node: Vec<u32> = (0..n as u32).collect();
let mut next = n as u32;
for &e in &order {
let p = (e / 2) as usize;
let q = if e % 2 == 0 { p + 1 } else { p + w };
let (rp, rq) = (uf.find(p as u32), uf.find(q as u32));
if rp == rq {
continue;
}
let k = (next - n as u32) as usize;
alt[next as usize] = weight_of[e as usize];
child[k] = [comp_node[rp as usize], comp_node[rq as usize]];
mst_edge[k] = (p as u32, q as u32);
parent[comp_node[rp as usize] as usize] = next;
parent[comp_node[rq as usize] as usize] = next;
uf.link(rp, rq);
comp_node[rp as usize] = next;
next += 1;
}
debug_assert_eq!(next as usize, n_nodes);
// --- Volume attribute, leaves → root --------------------------------
// area = pixels in the subtree; volume = ∫ area over altitude, i.e.
// each node contributes area × (parent altitude own altitude).
// Ascending index order visits all children before their parent.
let root = n_nodes - 1;
let mut area = vec![0u64; n_nodes];
for a in area.iter_mut().take(n) {
*a = 1;
}
let mut volume = vec![0u64; n_nodes];
for i in 0..root {
let pa = parent[i] as usize;
area[pa] += area[i];
let rise = (alt[pa] - alt[i]) as u64; // parent is never lower
volume[i] += area[i] * rise;
volume[pa] += volume[i];
}
// --- Persistence per MST edge ----------------------------------------
// Plateau fix first (Playing with Kruskal): equal-weight edge chains
// create internal nodes at the same altitude as their parent; their
// volume is not a real basin measure, so replace it with the max over
// children while the altitude is unchanged.
let mut corrected = volume;
for i in n..n_nodes {
let k = i - n;
if i != root && alt[i] == alt[parent[i] as usize] {
let [c0, c1] = child[k];
corrected[i] = corrected[c0 as usize].max(corrected[c1 as usize]);
}
}
// Persistence of a merge = the volume of the smaller side: the level
// at which that basin stops existing on its own.
let mut pers = vec![0u64; n - 1];
for k in 0..n - 1 {
let [c0, c1] = child[k];
pers[k] = corrected[c0 as usize].min(corrected[c1 as usize]);
}
// --- Cut level from the detail slider --------------------------------
// Merging every MST edge with persistence ≤ λ leaves exactly
// 1 + #{edges above λ} regions, so choosing λ as the k-th largest
// persistence targets k regions directly (ties merge a little more).
// The persistence distribution is extremely skewed — most merges are
// trivia with persistence ≈ 0 — so the slider maps to a region
// *count*, exponentially: every +25.5 of detail doubles the target,
// from 1 region at 0 up to 1024 at 255.
let target = (2f64).powf(self.detail as f64 / 25.5).round() as usize;
let target = target.clamp(1, pers.len());
let lambda = {
let mut sorted = pers.clone();
sorted.sort_unstable_by(|a, b| b.cmp(a));
sorted[target - 1]
};
// --- Single-linkage cut over MST edges -------------------------------
let mut cut = Uf::new(n);
for k in 0..n - 1 {
if pers[k] <= lambda {
let (p, q) = mst_edge[k];
let (rp, rq) = (cut.find(p), cut.find(q));
if rp != rq {
cut.link(rp, rq);
}
}
}
let mut labels = vec![0u32; n];
for (i, l) in labels.iter_mut().enumerate() {
*l = cut.find(i as u32);
}
labels
}
/// Absorb regions smaller than `min_area` into their most color-similar
/// 4-neighbour. Works on root-labels in place; areas and color sums are
/// maintained through the merges so chains stay well-behaved.
fn absorb_small(&self, img: &ColorImage, labels: &mut [u32]) {
if self.min_area <= 1 {
return;
}
let w = img.width;
let n = labels.len();
let mut uf = Uf::new(n);
// Rebuild region stats keyed by current label (a pixel index).
let mut area = vec![0u64; n];
let mut sum = vec![[0u64; 3]; n];
for i in 0..n {
let l = labels[i] as usize;
let c = img.get_pixel(i % w, i / w);
area[l] += 1;
sum[l][0] += c.r as u64;
sum[l][1] += c.g as u64;
sum[l][2] += c.b as u64;
}
let mean_diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> u64 {
let mut d = 0i64;
for ch in 0..3 {
d += ((sa[ch] / aa) as i64 - (sb[ch] / ab) as i64).abs();
}
d as u64
};
// Sweep until no undersized region can be absorbed. Each sweep scans
// the boundary edges once and merges each small region into its best
// neighbour seen so far; region count strictly decreases, so this
// terminates quickly in practice.
loop {
// best[l] = (diff, neighbour_root) for undersized root l
let mut best: Vec<(u64, u32)> = vec![(u64::MAX, u32::MAX); n];
let mut any_small = false;
for i in 0..n {
let a = uf.find(labels[i]);
for j in [
if i % w + 1 < w { i + 1 } else { i },
if i / w + 1 < labels.len() / w { i + w } else { i },
] {
if j == i {
continue;
}
let b = uf.find(labels[j]);
if a == b {
continue;
}
for (s, t) in [(a, b), (b, a)] {
let (su, tu) = (s as usize, t as usize);
if area[su] < self.min_area as u64 {
any_small = true;
let d = mean_diff(&sum[su], area[su], &sum[tu], area[tu]);
if d < best[su].0 || (d == best[su].0 && t < best[su].1) {
best[su] = (d, t);
}
}
}
}
}
if !any_small {
break;
}
let mut merged = false;
for l in 0..n {
let (_, tgt) = best[l];
if tgt == u32::MAX {
continue;
}
let rl = uf.find(l as u32);
if rl as usize != l {
continue; // already absorbed this sweep
}
let rt = uf.find(tgt);
if rt == rl {
continue;
}
uf.link(rt, rl);
area[rt as usize] += area[l];
for ch in 0..3 {
sum[rt as usize][ch] += sum[l][ch];
}
merged = true;
}
if !merged {
break; // isolated undersized region (e.g. whole-canvas)
}
}
for l in labels.iter_mut() {
*l = uf.find(*l);
}
}
/// Turn a root-label map into the layered [`Segmentation`]: one layer per
/// region with its mean color, the largest region first as a solid
/// full-canvas background so stacked mode stays seam-free by overdraw.
fn segmentation(img: &ColorImage, labels: &[u32]) -> Segmentation {
let w = img.width;
let h = img.height;
let n = labels.len();
// Compact labels in raster order of first appearance (deterministic).
let mut compact = vec![u32::MAX; n];
let mut regions: Vec<u32> = Vec::new(); // compact id -> root label
let mut ids = vec![0u32; n];
for i in 0..n {
let l = labels[i] as usize;
if compact[l] == u32::MAX {
compact[l] = regions.len() as u32;
regions.push(labels[i]);
}
ids[i] = compact[l];
}
let m = regions.len();
let mut area = vec![0u64; m];
let mut sum = vec![[0u64; 3]; m];
let mut bbox = vec![(i32::MAX, i32::MAX, i32::MIN, i32::MIN); m];
for i in 0..n {
let id = ids[i] as usize;
let (x, y) = ((i % w) as i32, (i / w) as i32);
let c = img.get_pixel(i % w, i / w);
area[id] += 1;
sum[id][0] += c.r as u64;
sum[id][1] += c.g as u64;
sum[id][2] += c.b as u64;
let b = &mut bbox[id];
b.0 = b.0.min(x);
b.1 = b.1.min(y);
b.2 = b.2.max(x);
b.3 = b.3.max(y);
}
let mean = |id: usize| {
Color::new(
(sum[id][0] / area[id]) as u8,
(sum[id][1] / area[id]) as u8,
(sum[id][2] / area[id]) as u8,
)
};
let background = (0..m).max_by_key(|&id| area[id]).unwrap_or(0);
let mut seg = Segmentation::new(w as u32, h as u32);
// Background: solid full canvas, painted first; the regions stacked on
// top stamp out everything that isn't actually background, so the
// flattened partition is exact while stacked mode keeps overdraw.
let mut bg = BinaryImage::new_w_h(w, h);
for y in 0..h {
for x in 0..w {
bg.set_pixel(x, y, true);
}
}
seg.layers.push(Layer {
paint: Paint::Solid(mean(background)),
mask: RegionMask::new(bg, PointI32 { x: 0, y: 0 }),
});
for id in 0..m {
if id == background {
continue;
}
let (x0, y0, x1, y1) = bbox[id];
let (bw, bh) = ((x1 - x0 + 1) as usize, (y1 - y0 + 1) as usize);
let mut image = BinaryImage::new_w_h(bw, bh);
for y in 0..bh {
for x in 0..bw {
let i = (y0 as usize + y) * w + (x0 as usize + x);
if ids[i] as usize == id {
image.set_pixel(x, y, true);
}
}
}
seg.layers.push(Layer {
paint: Paint::Solid(mean(id)),
mask: RegionMask::new(image, PointI32 { x: x0, y: y0 }),
});
}
seg
}
}
impl Frontend for WatershedFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
if img.width == 0 || img.height == 0 {
return Err(Error::EmptyImage);
}
if img.width * img.height == 1 {
// Degenerate single pixel: no edges, one region.
let labels = [0u32];
return Ok(Self::segmentation(img, &labels));
}
let mut labels = self.label_map(img);
self.absorb_small(img, &mut labels);
Ok(Self::segmentation(img, &labels))
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub mod progress;
pub mod session;
pub mod svg;
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset, SegmentKey};
pub use config::{Clustering, Config, FitMode, Hierarchical, Preset, SegmentKey};
pub use error::Error;
pub use frontend::Threshold;
pub use ir::{Segmentation, VectorDoc};
+29 -4
View File
@@ -104,12 +104,13 @@ fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
}
fn assert_equivalent(mode: FitMode) {
fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h);
let stacked = Config {
mode,
clustering,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
@@ -120,6 +121,7 @@ fn assert_equivalent(mode: FitMode) {
let cutout = Config {
mode,
clustering,
hierarchical: Hierarchical::Cutout,
..Config::default()
}
@@ -151,6 +153,10 @@ fn assert_equivalent(mode: FitMode) {
);
}
fn assert_equivalent(mode: FitMode) {
assert_equivalent_with(mode, vtracer::Clustering::ColorCluster);
}
#[test]
fn stacked_and_cutout_agree_in_interiors_spline() {
assert_equivalent(FitMode::Spline);
@@ -166,6 +172,13 @@ fn stacked_and_cutout_agree_in_interiors_pixel() {
assert_equivalent(FitMode::Pixel);
}
#[test]
fn watershed_stacked_and_cutout_agree_in_interiors() {
for mode in [FitMode::Pixel, FitMode::Spline] {
assert_equivalent_with(mode, vtracer::Clustering::Watershed);
}
}
// --- seam / show-through test -------------------------------------------------
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
@@ -180,12 +193,12 @@ fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
/// exactly the hole-punching bug this guards against.
#[test]
fn stacked_has_no_seams() {
fn assert_no_seams(clustering: vtracer::Clustering) {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h); // background fills the whole canvas
let svg = Config {
mode: FitMode::Spline,
clustering,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
@@ -210,6 +223,18 @@ fn stacked_has_no_seams() {
}
assert_eq!(
show_through, 0,
"stacked mode leaked {show_through} backdrop pixels — seams/holes in solid overdraw"
"{clustering:?} stacked leaked {show_through} backdrop pixels — seams/holes in overdraw"
);
}
#[test]
fn stacked_has_no_seams() {
assert_no_seams(vtracer::Clustering::ColorCluster);
}
/// The watershed frontend emits disjoint region masks; its full-canvas solid
/// background layer is what restores overdraw. This guards that construction.
#[test]
fn watershed_stacked_has_no_seams() {
assert_no_seams(vtracer::Clustering::Watershed);
}
+21 -2
View File
@@ -19,7 +19,7 @@
use std::path::PathBuf;
use resvg::{tiny_skia, usvg};
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
// --- synthetic image builders ------------------------------------------------
@@ -141,7 +141,7 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
"checker_bw",
checker(),
Config {
color_mode: ColorMode::Binary,
clustering: Clustering::Binary,
..base()
},
),
@@ -210,6 +210,25 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
..base()
},
),
// Watershed clustering: stacked and mosaic.
(
"disc_watershed_spline",
disc(),
Config {
clustering: Clustering::Watershed,
..base()
},
),
(
"swatches_watershed_mosaic",
swatches(),
Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
mode: FitMode::Polygon,
..base()
},
),
]
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M12,12L12,0L0,0L0,12l12,0Z" fill="#000080"/>
<path d="M24,0L12,0l0,12l12,0L24,0Z" fill="#550080"/>
<path d="M36,0L24,0l0,12l12,0L36,0Z" fill="#AA0080"/>
<path d="M48,12L48,0L36,0l0,12l12,0Z" fill="#FF0080"/>
<path d="M12,12L0,12L0,24l12,0l0-12Z" fill="#005580"/>
<path d="M24,12L12,12l0,12l12,0l0-12Z" fill="#555580"/>
<path d="M36,12L24,12l0,12l12,0l0-12Z" fill="#AA5580"/>
<path d="M48,12L36,12l0,12l12,0l0-12Z" fill="#FF5580"/>
<path d="M12,24L0,24L0,36l12,0l0-12Z" fill="#00AA80"/>
<path d="M24,24L12,24l0,12l12,0l0-12Z" fill="#55AA80"/>
<path d="M36,24L24,24l0,12l12,0l0-12Z" fill="#AAAA80"/>
<path d="M48,24L36,24l0,12l12,0l0-12Z" fill="#FFAA80"/>
<path d="M12,36L0,36L0,48l12,0l0-12Z" fill="#00FF80"/>
<path d="M24,36L12,36l0,12l12,0l0-12Z" fill="#55FF80"/>
<path d="M36,36L24,36l0,12l12,0l0-12Z" fill="#AAFF80"/>
<path d="M48,36L36,36l0,12l12,0l0-12Z" fill="#FFFF80"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+16 -2
View File
@@ -1,6 +1,6 @@
//! End-to-end pipeline smoke tests over synthetic images.
use vtracer::{ColorImage, ColorMode, Config, FitMode, Hierarchical};
use vtracer::{ColorImage, Clustering, Config, FitMode, Hierarchical};
/// Build a `size × size` image split into two vertical color bands.
fn two_band_image(size: usize) -> ColorImage {
@@ -52,13 +52,27 @@ fn all_fit_modes_produce_svg() {
fn binary_pipeline_produces_svg() {
let img = two_band_image(32);
let config = Config {
color_mode: ColorMode::Binary,
clustering: Clustering::Binary,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn watershed_pipeline_produces_svg() {
let img = two_band_image(32);
for hierarchical in [Hierarchical::Stacked, Hierarchical::Cutout] {
let config = Config {
clustering: Clustering::Watershed,
hierarchical,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
}
#[test]
fn optimize_levels_shrink_or_match() {
let img = two_band_image(48);
+9 -1
View File
@@ -72,7 +72,15 @@ fn segment_key_tracks_only_clustering_params() {
..base.clone()
},
Config {
color_mode: vtracer::ColorMode::Binary,
clustering: vtracer::Clustering::Binary,
..base.clone()
},
Config {
clustering: vtracer::Clustering::Watershed,
..base.clone()
},
Config {
watershed_detail: 200,
..base.clone()
},
] {
+172
View File
@@ -0,0 +1,172 @@
//! Watershed frontend: partition invariants, the detail dial, and small-basin
//! absorption.
use vtracer::frontend::{Frontend, WatershedFrontend};
use vtracer::ColorImage;
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> (u8, u8, u8)) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let (r, g, b) = f(x, y);
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// The core partition invariant behind the seam-free mosaic: painting the
/// layers bottom-to-top covers every canvas pixel exactly once per region —
/// i.e. the non-background masks are pairwise disjoint, and together with the
/// full-canvas background they tile the image.
fn assert_partition(seg: &vtracer::Segmentation) {
let (w, h) = (seg.width as usize, seg.height as usize);
// Background layer must be first and cover the full canvas.
let bg = &seg.layers[0].mask;
assert_eq!((bg.width(), bg.height()), (w, h), "background is full-canvas");
assert_eq!(bg.area(), w * h, "background mask is solid");
// Later layers are pairwise disjoint.
let mut covered = vec![false; w * h];
for layer in &seg.layers[1..] {
let m = &layer.mask;
for y in 0..m.image.height {
for x in 0..m.image.width {
if !m.image.get_pixel(x, y) {
continue;
}
let gx = (m.offset.x + x as i32) as usize;
let gy = (m.offset.y + y as i32) as usize;
assert!(gx < w && gy < h, "mask pixel out of canvas");
assert!(!covered[gy * w + gx], "overlapping region masks");
covered[gy * w + gx] = true;
}
}
}
}
/// A flat single-color image is one region no matter the detail level.
#[test]
fn flat_image_is_one_region() {
let img = image(24, 16, |_, _| (90, 120, 150));
for detail in [0u8, 128, 255] {
let seg = WatershedFrontend {
detail,
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(seg.layers.len(), 1, "detail={detail}");
assert_partition(&seg);
}
}
/// Two clearly separated halves form two regions, with the boundary exactly on
/// the color edge (no watershed-line pixels — the partition is gapless).
#[test]
fn two_tone_image_is_two_regions() {
let img = image(32, 20, |x, _| {
if x < 16 {
(220, 40, 40)
} else {
(40, 60, 220)
}
});
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(seg.layers.len(), 2);
assert_partition(&seg);
// The non-background region is exactly one half of the canvas.
assert_eq!(seg.layers[1].mask.area(), 16 * 20);
}
/// Raising detail never decreases the region count (the hierarchy cut is
/// monotone in the target).
#[test]
fn detail_is_monotone() {
// A blobby gradient image with structure at several scales.
let img = image(64, 48, |x, y| {
let v = ((x * 4) as f64).sin() * 40.0 + ((y * 3) as f64).cos() * 40.0;
let base = 128i32 + v as i32;
let r = (base + ((x / 16) as i32) * 20).clamp(0, 255) as u8;
let g = (base + ((y / 12) as i32) * 25).clamp(0, 255) as u8;
(r, g, 128)
});
let mut prev = 0usize;
for detail in [0u8, 64, 128, 192, 255] {
let seg = WatershedFrontend {
detail,
min_area: 0,
}
.segment(&img)
.unwrap();
assert!(
seg.layers.len() >= prev,
"detail={detail}: {} < {prev}",
seg.layers.len()
);
assert_partition(&seg);
prev = seg.layers.len();
}
assert!(prev > 1, "highest detail should find several regions");
}
/// Small basins are absorbed into a neighbour rather than dropped: the region
/// disappears but its pixels stay covered (the partition invariant holds).
#[test]
fn min_area_absorbs_small_basins() {
// Background plus a 3x3 fleck and a 12x12 block, all far apart in color.
let img = image(40, 30, |x, y| {
if (4..7).contains(&x) && (4..7).contains(&y) {
(10, 200, 10) // 9 px fleck
} else if (20..32).contains(&x) && (10..22).contains(&y) {
(200, 30, 30) // 144 px block
} else {
(240, 240, 240)
}
});
let keep = WatershedFrontend {
detail: 255,
min_area: 0,
}
.segment(&img)
.unwrap();
let absorb = WatershedFrontend {
detail: 255,
min_area: 16, // fleck (9 px) absorbed, block (144 px) kept
}
.segment(&img)
.unwrap();
assert!(keep.layers.len() > absorb.layers.len(), "fleck absorbed");
assert_eq!(absorb.layers.len(), 2, "background + block survive");
assert_partition(&absorb);
}
/// Output is deterministic: two runs produce identical layer geometry.
#[test]
fn deterministic() {
let img = image(48, 32, |x, y| {
(((x * 7 + y * 13) % 256) as u8, ((x * 3) % 256) as u8, ((y * 5) % 256) as u8)
});
let front = WatershedFrontend {
detail: 160,
min_area: 4,
};
let a = front.segment(&img).unwrap();
let b = front.segment(&img).unwrap();
assert_eq!(a.layers.len(), b.layers.len());
for (la, lb) in a.layers.iter().zip(&b.layers) {
assert_eq!(la.paint, lb.paint);
assert_eq!(la.mask.offset, lb.mask.offset);
assert_eq!(la.mask.area(), lb.mask.area());
}
}
+3 -2
View File
@@ -111,9 +111,10 @@ Driver flow:
## Built-in implementations
- **Frontends**
- **Frontends** (selected by `Config::clustering`)
- `ColorClusterFrontend` — wraps `visioncortex::color_clusters::Runner`, including the transparency-keying logic that currently lives in `converter.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 at `watershed_detail`. Emits a flat partition with a solid full-canvas background layer so stacked overdraw stays seam-free.
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
- **ColorFitters**
- `Identity` (today's behavior: mean cluster color)
@@ -141,7 +142,7 @@ Output size is a tracked metric: the test suite asserts a byte-size budget again
## CLI
clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--colormode color|bw`, `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`.
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:
+2 -2
View File
@@ -24,7 +24,7 @@ await vtracer.convertFile('in.jpg', 'out.svg', { mode: 'polygon', hierarchical:
const svg = vtracer.convertBuffer(fs.readFileSync('in.png'), { preset: 'poster' });
// raw RGBA8 pixels
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
```
## API
@@ -36,7 +36,7 @@ const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
### `Options` (all optional, camelCase)
`preset` (`"bw" | "poster" | "photo"`, applied first), `colorMode`
`preset` (`"bw" | "poster" | "photo"`, applied first), `clustering`
(`"color" | "bw"`), `hierarchical` (`"stacked" | "cutout"` for the seam-free
mosaic), `mode` (`"pixel" | "polygon" | "spline"`), `filterSpeckle`,
`colorPrecision`, `layerDifference`, `cornerThreshold`, `lengthThreshold`,
+5 -2
View File
@@ -2,7 +2,8 @@
export interface Options {
/** Applied before other fields: "bw" | "poster" | "photo". */
preset?: 'bw' | 'poster' | 'photo';
colorMode?: 'color' | 'bw';
/** Region forming: hierarchical color clustering (default), binary threshold, or watershed. */
clustering?: 'color-cluster' | 'bw' | 'watershed';
hierarchical?: 'stacked' | 'cutout';
mode?: 'pixel' | 'polygon' | 'spline';
filterSpeckle?: number;
@@ -19,7 +20,7 @@ export interface Options {
maxColors?: number;
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
optimize?: number;
/** Binary mode (`colorMode: 'bw'`): fixed threshold 0..=255; foreground when intensity is below it. */
/** Binary mode (`clustering: 'bw'`): fixed threshold 0..=255; foreground when intensity is below it. */
binaryThreshold?: number;
/** Binary mode: use BradleyRoth adaptive thresholding (handles uneven lighting). */
adaptive?: boolean;
@@ -27,6 +28,8 @@ export interface Options {
adaptiveWindow?: number;
/** Adaptive sensitivity: percent below the local mean (default 15). */
adaptiveT?: number;
/** Watershed clustering: hierarchy cut level 0..=255 (higher = more regions, default 128). */
watershedDetail?: number;
}
/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */
+9 -3
View File
@@ -15,7 +15,8 @@ use wasm_bindgen::prelude::*;
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Options {
color_mode: Option<String>,
/// Region forming: "color-cluster" | "bw" | "watershed".
clustering: Option<String>,
hierarchical: Option<String>,
mode: Option<String>,
filter_speckle: Option<usize>,
@@ -37,6 +38,8 @@ struct Options {
adaptive_window: Option<u32>,
/// Adaptive sensitivity: percent below the local mean (default 15).
adaptive_t: Option<f64>,
/// Watershed clustering: hierarchy cut level (0..=255).
watershed_detail: Option<u8>,
/// One of "bw" | "poster" | "photo"; applied before the other fields.
preset: Option<String>,
}
@@ -71,8 +74,8 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
None => Config::default(),
};
if let Some(v) = opts.color_mode {
config.color_mode = v.parse().map_err(err)?;
if let Some(v) = opts.clustering {
config.clustering = v.parse().map_err(err)?;
}
if let Some(v) = opts.hierarchical {
config.hierarchical = v.parse().map_err(err)?;
@@ -126,6 +129,9 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
if let Some(v) = opts.adaptive_t {
config.binary_adaptive_t = v;
}
if let Some(v) = opts.watershed_detail {
config.watershed_detail = v;
}
Ok(config)
}