diff --git a/CHANGELOG.md b/CHANGELOG.md
index 988caaf..1ffae34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
* Binary thresholding: a tunable fixed threshold and Bradley–Roth 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
diff --git a/README.md b/README.md
index 9a00af2..b39fe25 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,7 @@ Options:
-i, --input Path to the input raster image
-o, --output Path to the output SVG
--preset Start from a preset: bw, poster, photo
- --colormode Color image `color` (default) or binary image `bw`
+ --clustering Region forming: `color-cluster` (default), `bw`, `watershed`
--hierarchical Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
-m, --mode Curve-fitting mode: `pixel`, `polygon`, `spline`
-f, --filter-speckle Discard patches smaller than X px in size (0..=128)
@@ -98,6 +98,7 @@ Options:
--adaptive Binary mode: Bradley–Roth adaptive threshold (uneven lighting)
--adaptive-window Adaptive window size in px (0 = auto); implies --adaptive
--adaptive-t Adaptive sensitivity: % below local mean (default 15)
+ --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
**Bradley–Roth 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 (Bradley–Roth) 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
diff --git a/crates/vtracer-cli/src/main.rs b/crates/vtracer-cli/src/main.rs
index f2608d7..8eb37b4 100644
--- a/crates/vtracer-cli/src/main.rs
+++ b/crates/vtracer-cli/src/main.rs
@@ -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,
- /// Color image (`color`) or binary image (`bw`).
- #[arg(long = "colormode")]
- colormode: Option,
+ /// Region forming: `color-cluster` (default), `bw`, or `watershed`.
+ #[arg(long)]
+ clustering: Option,
/// 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,
+
+ /// Watershed clustering: hierarchy cut level (0..=255, higher = more regions).
+ #[arg(long, value_parser = clap::value_parser!(u8))]
+ watershed_detail: Option,
}
fn parse_segment_length(s: &str) -> Result {
@@ -148,8 +152,8 @@ fn build_config(args: &Args) -> Result {
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 {
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 {
diff --git a/crates/vtracer-py/README.md b/crates/vtracer-py/README.md
index 1b287a7..29216f6 100644
--- a/crates/vtracer-py/README.md
+++ b/crates/vtracer-py/README.md
@@ -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: Bradley–Roth 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`.
diff --git a/crates/vtracer-py/src/lib.rs b/crates/vtracer-py/src/lib.rs
index 41b00db..a0619c1 100644
--- a/crates/vtracer-py/src/lib.rs
+++ b/crates/vtracer-py/src/lib.rs
@@ -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>(s: &str) -> PyResult {
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 {
let palette = match palette {
Some(list) => list.iter().map(|s| parse_hex(s)).collect::>()?,
@@ -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,
diff --git a/crates/vtracer-py/vtracer.pyi b/crates/vtracer-py/vtracer.pyi
index f438261..5f2b7c6 100644
--- a/crates/vtracer-py/vtracer.pyi
+++ b/crates/vtracer-py/vtracer.pyi
@@ -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: Bradley–Roth 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: ...
diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs
index 2c1055f..ae2ccb5 100644
--- a/crates/vtracer/src/config.rs
+++ b/crates/vtracer/src/config.rs
@@ -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 {
- 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 {
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}")),
}
}
}
diff --git a/crates/vtracer/src/frontend/mod.rs b/crates/vtracer/src/frontend/mod.rs
index 13181e2..19b2990 100644
--- a/crates/vtracer/src/frontend/mod.rs
+++ b/crates/vtracer/src/frontend/mod.rs
@@ -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;
diff --git a/crates/vtracer/src/frontend/watershed.rs b/crates/vtracer/src/frontend/watershed.rs
new file mode 100644
index 0000000..6148212
--- /dev/null
+++ b/crates/vtracer/src/frontend/watershed.rs
@@ -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);
+
+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 {
+ 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::();
+ 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 = (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 = 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 {
+ 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))
+ }
+}
diff --git a/crates/vtracer/src/lib.rs b/crates/vtracer/src/lib.rs
index 279ed7e..2c42b97 100644
--- a/crates/vtracer/src/lib.rs
+++ b/crates/vtracer/src/lib.rs
@@ -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};
diff --git a/crates/vtracer/tests/equivalence.rs b/crates/vtracer/tests/equivalence.rs
index 5255782..89528a3 100644
--- a/crates/vtracer/tests/equivalence.rs
+++ b/crates/vtracer/tests/equivalence.rs
@@ -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 {
@@ -180,12 +193,12 @@ fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec {
/// 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);
+}
diff --git a/crates/vtracer/tests/golden.rs b/crates/vtracer/tests/golden.rs
index a5664cf..ec46466 100644
--- a/crates/vtracer/tests/golden.rs
+++ b/crates/vtracer/tests/golden.rs
@@ -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()
+ },
+ ),
]
}
diff --git a/crates/vtracer/tests/goldens/disc_watershed_spline.svg b/crates/vtracer/tests/goldens/disc_watershed_spline.svg
new file mode 100644
index 0000000..da91a39
--- /dev/null
+++ b/crates/vtracer/tests/goldens/disc_watershed_spline.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/crates/vtracer/tests/goldens/swatches_watershed_mosaic.svg b/crates/vtracer/tests/goldens/swatches_watershed_mosaic.svg
new file mode 100644
index 0000000..76a2714
--- /dev/null
+++ b/crates/vtracer/tests/goldens/swatches_watershed_mosaic.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/vtracer/tests/pipeline.rs b/crates/vtracer/tests/pipeline.rs
index 515cfce..9253fd5 100644
--- a/crates/vtracer/tests/pipeline.rs
+++ b/crates/vtracer/tests/pipeline.rs
@@ -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);
diff --git a/crates/vtracer/tests/session.rs b/crates/vtracer/tests/session.rs
index ae74326..00aa43c 100644
--- a/crates/vtracer/tests/session.rs
+++ b/crates/vtracer/tests/session.rs
@@ -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()
},
] {
diff --git a/crates/vtracer/tests/watershed.rs b/crates/vtracer/tests/watershed.rs
new file mode 100644
index 0000000..017735d
--- /dev/null
+++ b/crates/vtracer/tests/watershed.rs
@@ -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());
+ }
+}
diff --git a/docs/design/architecture.md b/docs/design/architecture.md
index c8214d0..2ae0cef 100644
--- a/docs/design/architecture.md
+++ b/docs/design/architecture.md
@@ -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:
diff --git a/nodejs/README.md b/nodejs/README.md
index 73311ea..73373bd 100644
--- a/nodejs/README.md
+++ b/nodejs/README.md
@@ -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`,
diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts
index 1efa578..e60c178 100644
--- a/nodejs/index.d.ts
+++ b/nodejs/index.d.ts
@@ -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 Bradley–Roth 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. */
diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs
index c7004dd..eccc4c9 100644
--- a/nodejs/src/lib.rs
+++ b/nodejs/src/lib.rs
@@ -15,7 +15,8 @@ use wasm_bindgen::prelude::*;
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Options {
- color_mode: Option,
+ /// Region forming: "color-cluster" | "bw" | "watershed".
+ clustering: Option,
hierarchical: Option,
mode: Option,
filter_speckle: Option,
@@ -37,6 +38,8 @@ struct Options {
adaptive_window: Option,
/// Adaptive sensitivity: percent below the local mean (default 15).
adaptive_t: Option,
+ /// Watershed clustering: hierarchy cut level (0..=255).
+ watershed_detail: Option,
/// One of "bw" | "poster" | "photo"; applied before the other fields.
preset: Option,
}
@@ -71,8 +74,8 @@ fn config_from(options: JsValue) -> Result {
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 {
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)
}