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) }