mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-15 08:35:56 -07:00
Add watershed clustering: hierarchical watershed frontend
An alternative region-forming frontend selected by --clustering watershed (the color_mode field is replaced by clustering: color-cluster | bw | watershed across CLI, Rust, Python, and Node — it selects the algorithm, not a color space). The algorithm is the watershed hierarchy by volume on the 4-adjacency pixel graph, implemented from the papers: Cousty, Bertrand, Najman, Couprie, "Watershed Cuts: Minimum Spanning Forests and the Drop of Water Principle", IEEE TPAMI 31(8), 2009. Najman, Cousty, Perret, "Playing with Kruskal", ISMM 2013. Edge weights are the max per-channel color difference between adjacent pixels (no gradient image); a counting-sorted Kruskal pass builds the binary partition tree as a flat parents array; a leaves-to-root pass computes subtree area and volume; each merge's persistence (the volume of the smaller side, plateau-corrected) becomes its MST edge's saliency; and cutting the hierarchy is single-linkage over MST edges below the cut level. Watershed cuts label every pixel — no watershed-line pixel class — so the output is a strict, gapless partition that drops straight into both stacked and cutout modes. Integer arithmetic and flat u32 arrays throughout; deterministic across platforms; ~66 ms on a 1400x775 photo. The one dial, --watershed-detail (0..=255), maps exponentially to a target region count (each +25.5 doubles it) since the persistence distribution is far too skewed for a linear threshold. filter_speckle absorbs undersized basins into their most color-similar neighbour rather than dropping them, preserving the partition. The largest region is emitted first as a solid full-canvas background layer so stacked mode keeps its seam-free overdraw; the mosaic flatten is unaffected. Tests: partition invariant (disjoint masks tiling the canvas), detail monotonicity, min-area absorption, determinism, watershed cases in the pipeline/golden suites, stacked-vs-cutout interior equivalence, a watershed seam test, and SegmentKey coverage for the new params.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Options:
|
||||
-i, --input <INPUT> Path to the input raster image
|
||||
-o, --output <OUTPUT> Path to the output SVG
|
||||
--preset <PRESET> Start from a preset: bw, poster, photo
|
||||
--colormode <COLORMODE> Color image `color` (default) or binary image `bw`
|
||||
--clustering <CLUSTERING> Region forming: `color-cluster` (default), `bw`, `watershed`
|
||||
--hierarchical <HIERARCHICAL> Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
|
||||
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
|
||||
-f, --filter-speckle <FILTER_SPECKLE> Discard patches smaller than X px in size (0..=128)
|
||||
@@ -98,6 +98,7 @@ Options:
|
||||
--adaptive Binary mode: Bradley–Roth adaptive threshold (uneven lighting)
|
||||
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
|
||||
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
|
||||
--watershed-detail <WATERSHED_DETAIL> Watershed: hierarchy cut level 0..=255 (higher = more regions)
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
@@ -112,6 +113,10 @@ Options:
|
||||
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
|
||||
**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
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use visioncortex::{Color, ColorImage};
|
||||
use vtracer::{ColorMode, Config, FitMode, Hierarchical, Preset};
|
||||
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
|
||||
|
||||
/// Convert an image into vector graphics.
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -35,9 +35,9 @@ struct Args {
|
||||
#[arg(long)]
|
||||
preset: Option<Preset>,
|
||||
|
||||
/// Color image (`color`) or binary image (`bw`).
|
||||
#[arg(long = "colormode")]
|
||||
colormode: Option<ColorMode>,
|
||||
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
|
||||
#[arg(long)]
|
||||
clustering: Option<Clustering>,
|
||||
|
||||
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
|
||||
#[arg(long)]
|
||||
@@ -106,6 +106,10 @@ struct Args {
|
||||
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
|
||||
#[arg(long)]
|
||||
adaptive_t: Option<f64>,
|
||||
|
||||
/// Watershed clustering: hierarchy cut level (0..=255, higher = more regions).
|
||||
#[arg(long, value_parser = clap::value_parser!(u8))]
|
||||
watershed_detail: Option<u8>,
|
||||
}
|
||||
|
||||
fn parse_segment_length(s: &str) -> Result<f64, String> {
|
||||
@@ -148,8 +152,8 @@ fn build_config(args: &Args) -> Result<Config, String> {
|
||||
None => Config::default(),
|
||||
};
|
||||
|
||||
if let Some(v) = args.colormode {
|
||||
config.color_mode = v;
|
||||
if let Some(v) = args.clustering {
|
||||
config.clustering = v;
|
||||
}
|
||||
if let Some(v) = args.hierarchical {
|
||||
config.hierarchical = v;
|
||||
@@ -199,6 +203,9 @@ fn build_config(args: &Args) -> Result<Config, String> {
|
||||
if let Some(v) = args.adaptive_t {
|
||||
config.binary_adaptive_t = v;
|
||||
}
|
||||
if let Some(v) = args.watershed_detail {
|
||||
config.watershed_detail = v;
|
||||
}
|
||||
|
||||
// Palette: inline flag wins over file; both parse to a color list.
|
||||
if let Some(text) = &args.palette {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -28,7 +28,7 @@ use pyo3::exceptions::{PyIOError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use ::vtracer::{
|
||||
Color, ColorImage, ColorMode, Config as CoreConfig, FitMode, Hierarchical, Preset,
|
||||
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
|
||||
};
|
||||
|
||||
// --- string <-> enum helpers -------------------------------------------------
|
||||
@@ -37,10 +37,11 @@ fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
|
||||
s.parse().map_err(PyValueError::new_err)
|
||||
}
|
||||
|
||||
fn color_mode_str(m: ColorMode) -> &'static str {
|
||||
match m {
|
||||
ColorMode::Color => "color",
|
||||
ColorMode::Binary => "bw",
|
||||
fn clustering_str(c: Clustering) -> &'static str {
|
||||
match c {
|
||||
Clustering::ColorCluster => "color-cluster",
|
||||
Clustering::Binary => "bw",
|
||||
Clustering::Watershed => "watershed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ impl PyConfig {
|
||||
impl PyConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
color_mode = "color",
|
||||
clustering = "color-cluster",
|
||||
hierarchical = "stacked",
|
||||
mode = "spline",
|
||||
filter_speckle = 4,
|
||||
@@ -147,10 +148,11 @@ impl PyConfig {
|
||||
adaptive = false,
|
||||
adaptive_window = 0,
|
||||
adaptive_t = 15.0,
|
||||
watershed_detail = 128,
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
color_mode: &str,
|
||||
clustering: &str,
|
||||
hierarchical: &str,
|
||||
mode: &str,
|
||||
filter_speckle: usize,
|
||||
@@ -168,6 +170,7 @@ impl PyConfig {
|
||||
adaptive: bool,
|
||||
adaptive_window: u32,
|
||||
adaptive_t: f64,
|
||||
watershed_detail: u8,
|
||||
) -> PyResult<Self> {
|
||||
let palette = match palette {
|
||||
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
||||
@@ -175,7 +178,7 @@ impl PyConfig {
|
||||
};
|
||||
Ok(Self {
|
||||
inner: CoreConfig {
|
||||
color_mode: parse(color_mode)?,
|
||||
clustering: parse(clustering)?,
|
||||
hierarchical: parse(hierarchical)?,
|
||||
mode: parse(mode)?,
|
||||
filter_speckle,
|
||||
@@ -193,6 +196,7 @@ impl PyConfig {
|
||||
binary_adaptive: adaptive,
|
||||
binary_adaptive_window: adaptive_window,
|
||||
binary_adaptive_t: adaptive_t,
|
||||
watershed_detail,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -224,15 +228,24 @@ impl PyConfig {
|
||||
// --- properties ---
|
||||
|
||||
#[getter]
|
||||
fn color_mode(&self) -> &'static str {
|
||||
color_mode_str(self.inner.color_mode)
|
||||
fn clustering(&self) -> &'static str {
|
||||
clustering_str(self.inner.clustering)
|
||||
}
|
||||
#[setter]
|
||||
fn set_color_mode(&mut self, v: &str) -> PyResult<()> {
|
||||
self.inner.color_mode = parse(v)?;
|
||||
fn set_clustering(&mut self, v: &str) -> PyResult<()> {
|
||||
self.inner.clustering = parse(v)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn watershed_detail(&self) -> u8 {
|
||||
self.inner.watershed_detail
|
||||
}
|
||||
#[setter]
|
||||
fn set_watershed_detail(&mut self, v: u8) {
|
||||
self.inner.watershed_detail = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn hierarchical(&self) -> &'static str {
|
||||
hierarchical_str(self.inner.hierarchical)
|
||||
@@ -432,11 +445,11 @@ impl PyConfig {
|
||||
fn __repr__(&self) -> String {
|
||||
let c = &self.inner;
|
||||
format!(
|
||||
"Config(color_mode='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
|
||||
"Config(clustering='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
|
||||
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
|
||||
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
|
||||
max_colors={:?}, optimize={})",
|
||||
color_mode_str(c.color_mode),
|
||||
clustering_str(c.clustering),
|
||||
hierarchical_str(c.hierarchical),
|
||||
mode_str(c.mode),
|
||||
c.filter_speckle,
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -8,7 +8,9 @@ use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAd
|
||||
use crate::compose::Compositing;
|
||||
use crate::error::Error;
|
||||
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
||||
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend, Threshold};
|
||||
use crate::frontend::{
|
||||
BinaryFrontend, ColorClusterFrontend, Frontend, Threshold, WatershedFrontend,
|
||||
};
|
||||
use crate::mosaic::{
|
||||
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
|
||||
};
|
||||
@@ -16,10 +18,15 @@ use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
|
||||
use crate::pipeline::Pipeline;
|
||||
use crate::svg::SvgWriter;
|
||||
|
||||
/// Which region-forming algorithm segments the image.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorMode {
|
||||
Color,
|
||||
pub enum Clustering {
|
||||
/// Hierarchical color clustering — the classic VTracer path.
|
||||
ColorCluster,
|
||||
/// Threshold to black/white, then cluster the foreground.
|
||||
Binary,
|
||||
/// Hierarchical watershed on the pixel graph, cut at `watershed_detail`.
|
||||
Watershed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -49,7 +56,7 @@ pub enum Preset {
|
||||
/// whether to re-segment. Kept in sync with [`Config::frontend`] in one place.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SegmentKey {
|
||||
color_mode: ColorMode,
|
||||
clustering: Clustering,
|
||||
color_precision: i32,
|
||||
layer_difference: i32,
|
||||
filter_speckle: usize,
|
||||
@@ -57,13 +64,15 @@ pub struct SegmentKey {
|
||||
binary_adaptive: bool,
|
||||
binary_adaptive_window: u32,
|
||||
binary_adaptive_t: f64,
|
||||
watershed_detail: u8,
|
||||
}
|
||||
|
||||
/// High-level converter configuration. [`Config::build`] turns this into a
|
||||
/// concrete [`Pipeline`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub color_mode: ColorMode,
|
||||
/// Region-forming algorithm (see [`Clustering`]).
|
||||
pub clustering: Clustering,
|
||||
pub hierarchical: Hierarchical,
|
||||
/// Speckle filter given as a side length; the area threshold is its square.
|
||||
pub filter_speckle: usize,
|
||||
@@ -97,12 +106,15 @@ pub struct Config {
|
||||
pub binary_adaptive_window: u32,
|
||||
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
|
||||
pub binary_adaptive_t: f64,
|
||||
/// Watershed clustering: where to cut the hierarchy (0..=255). Higher
|
||||
/// keeps more regions; 0 collapses the image to a single region.
|
||||
pub watershed_detail: u8,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color_mode: ColorMode::Color,
|
||||
clustering: Clustering::ColorCluster,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 4,
|
||||
color_precision: 6,
|
||||
@@ -120,6 +132,7 @@ impl Default for Config {
|
||||
binary_adaptive: false,
|
||||
binary_adaptive_window: 0,
|
||||
binary_adaptive_t: 15.0,
|
||||
watershed_detail: 128,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,16 +141,14 @@ impl Config {
|
||||
pub fn from_preset(preset: Preset) -> Self {
|
||||
match preset {
|
||||
Preset::Bw => Self {
|
||||
color_mode: ColorMode::Binary,
|
||||
clustering: Clustering::Binary,
|
||||
..Self::default()
|
||||
},
|
||||
Preset::Poster => Self {
|
||||
color_mode: ColorMode::Color,
|
||||
color_precision: 8,
|
||||
..Self::default()
|
||||
},
|
||||
Preset::Photo => Self {
|
||||
color_mode: ColorMode::Color,
|
||||
filter_speckle: 10,
|
||||
color_precision: 8,
|
||||
layer_difference: 48,
|
||||
@@ -157,13 +168,13 @@ impl Config {
|
||||
}
|
||||
|
||||
fn frontend(&self) -> Box<dyn Frontend> {
|
||||
match self.color_mode {
|
||||
ColorMode::Color => Box::new(ColorClusterFrontend {
|
||||
match self.clustering {
|
||||
Clustering::ColorCluster => Box::new(ColorClusterFrontend {
|
||||
color_precision_loss: 8 - self.color_precision,
|
||||
layer_difference: self.layer_difference,
|
||||
good_min_area: self.speckle_area(),
|
||||
}),
|
||||
ColorMode::Binary => {
|
||||
Clustering::Binary => {
|
||||
let threshold = if self.binary_adaptive {
|
||||
Threshold::Adaptive {
|
||||
window: self.binary_adaptive_window,
|
||||
@@ -178,6 +189,10 @@ impl Config {
|
||||
min_area: self.speckle_area(),
|
||||
})
|
||||
}
|
||||
Clustering::Watershed => Box::new(WatershedFrontend {
|
||||
detail: self.watershed_detail,
|
||||
min_area: self.speckle_area(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,13 +268,14 @@ impl Config {
|
||||
}
|
||||
|
||||
/// The clustering-relevant subset of this config. Changing any field it
|
||||
/// captures (color mode, color precision, layer difference, speckle, or the
|
||||
/// binary threshold settings) requires re-segmenting; changing anything else
|
||||
/// — fit mode, curve params, compositing, palette, optimization — reuses a
|
||||
/// cached segmentation. See [`Session`](crate::Session).
|
||||
/// captures (clustering algorithm, color precision, layer difference,
|
||||
/// speckle, binary threshold settings, or watershed detail) requires
|
||||
/// re-segmenting; changing anything else — fit mode, curve params,
|
||||
/// compositing, palette, optimization — reuses a cached segmentation. See
|
||||
/// [`Session`](crate::Session).
|
||||
pub fn segment_key(&self) -> SegmentKey {
|
||||
SegmentKey {
|
||||
color_mode: self.color_mode,
|
||||
clustering: self.clustering,
|
||||
color_precision: self.color_precision,
|
||||
layer_difference: self.layer_difference,
|
||||
filter_speckle: self.filter_speckle,
|
||||
@@ -267,6 +283,7 @@ impl Config {
|
||||
binary_adaptive: self.binary_adaptive,
|
||||
binary_adaptive_window: self.binary_adaptive_window,
|
||||
binary_adaptive_t: self.binary_adaptive_t,
|
||||
watershed_detail: self.watershed_detail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,13 +314,14 @@ fn deg2rad(deg: i32) -> f64 {
|
||||
deg as f64 / 180.0 * std::f64::consts::PI
|
||||
}
|
||||
|
||||
impl FromStr for ColorMode {
|
||||
impl FromStr for Clustering {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"color" => Ok(Self::Color),
|
||||
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
|
||||
"binary" | "bw" | "BW" => Ok(Self::Binary),
|
||||
_ => Err(format!("unknown color mode {s}")),
|
||||
"watershed" => Ok(Self::Watershed),
|
||||
_ => Err(format!("unknown clustering {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Hierarchical watershed frontend — region forming on the pixel graph.
|
||||
//!
|
||||
//! The image is treated as a 4-adjacency edge-weighted graph (edge weight =
|
||||
//! color difference between the two pixels; no gradient image is built). On it
|
||||
//! we compute the watershed hierarchy by **volume extinction** and cut it at a
|
||||
//! detail level, following:
|
||||
//!
|
||||
//! * Cousty, Bertrand, Najman, Couprie, *Watershed Cuts: Minimum Spanning
|
||||
//! Forests and the Drop of Water Principle*, IEEE TPAMI 31(8), 2009.
|
||||
//! * Najman, Cousty, Perret, *Playing with Kruskal: Algorithms for
|
||||
//! Morphological Trees in Edge-Weighted Graphs*, ISMM 2013.
|
||||
//!
|
||||
//! Pipeline: Kruskal over counting-sorted edges builds the binary partition
|
||||
//! tree (a flat `parents` array, leaves `0..n`, internal nodes created in
|
||||
//! altitude order). A leaves-to-root pass computes each subtree's area and
|
||||
//! volume; each internal node's *persistence* (the volume of the smaller of
|
||||
//! the two merged regions) becomes the saliency of its MST edge. Cutting the
|
||||
//! hierarchy at level λ is then single-linkage over MST edges with
|
||||
//! persistence ≤ λ — every pixel gets a label, no watershed-line pixels.
|
||||
//!
|
||||
//! Everything is integer and allocation-flat: counting sort over 256 weight
|
||||
//! buckets, path-halving union-find, `u32` node ids. Deterministic across
|
||||
//! platforms.
|
||||
|
||||
use visioncortex::{BinaryImage, Color, ColorImage, PointI32};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
||||
|
||||
use super::Frontend;
|
||||
|
||||
/// Watershed frontend: hierarchical watershed by volume, cut at `detail`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WatershedFrontend {
|
||||
/// Detail level (0..=255): where to cut the hierarchy. 255 keeps every
|
||||
/// basin that survives a zero-persistence merge (finest useful partition);
|
||||
/// 0 merges everything into a single region.
|
||||
pub detail: u8,
|
||||
/// Absorb regions smaller than this many pixels into their most
|
||||
/// color-similar neighbour after the cut (0 = keep all).
|
||||
pub min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for WatershedFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
detail: 128,
|
||||
min_area: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat union-find over `u32` ids with path halving.
|
||||
struct Uf(Vec<u32>);
|
||||
|
||||
impl Uf {
|
||||
fn new(n: usize) -> Self {
|
||||
Uf((0..n as u32).collect())
|
||||
}
|
||||
|
||||
fn find(&mut self, mut x: u32) -> u32 {
|
||||
while self.0[x as usize] != x {
|
||||
self.0[x as usize] = self.0[self.0[x as usize] as usize];
|
||||
x = self.0[x as usize];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Union by attaching `b`'s root under `a`'s. Caller passes roots.
|
||||
fn link(&mut self, a: u32, b: u32) {
|
||||
self.0[b as usize] = a;
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge weight: max per-channel absolute difference (L∞), the same family of
|
||||
/// channel-difference metric the rest of vtracer uses. 0..=255.
|
||||
#[inline]
|
||||
fn edge_weight(a: Color, b: Color) -> u8 {
|
||||
let dr = a.r.abs_diff(b.r);
|
||||
let dg = a.g.abs_diff(b.g);
|
||||
let db = a.b.abs_diff(b.b);
|
||||
dr.max(dg).max(db)
|
||||
}
|
||||
|
||||
impl WatershedFrontend {
|
||||
fn label_map(&self, img: &ColorImage) -> Vec<u32> {
|
||||
let w = img.width;
|
||||
let h = img.height;
|
||||
let n = w * h;
|
||||
|
||||
// --- 4-adjacency edges, counting-sorted by weight -------------------
|
||||
// Edge id encodes (pixel, direction): 2*p = right, 2*p+1 = down.
|
||||
// The per-bucket fill preserves edge-id order, so the sort is stable
|
||||
// and the whole construction is deterministic.
|
||||
let px = |i: usize| {
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
c
|
||||
};
|
||||
let mut counts = [0u32; 256];
|
||||
let mut weight_of = vec![0u8; 2 * n];
|
||||
for i in 0..n {
|
||||
let c = px(i);
|
||||
if i % w + 1 < w {
|
||||
let wgt = edge_weight(c, px(i + 1));
|
||||
weight_of[2 * i] = wgt;
|
||||
counts[wgt as usize] += 1;
|
||||
}
|
||||
if i / w + 1 < h {
|
||||
let wgt = edge_weight(c, px(i + w));
|
||||
weight_of[2 * i + 1] = wgt;
|
||||
counts[wgt as usize] += 1;
|
||||
}
|
||||
}
|
||||
let n_edges = counts.iter().map(|&c| c as usize).sum::<usize>();
|
||||
let mut start = [0usize; 256];
|
||||
let mut acc = 0usize;
|
||||
for b in 0..256 {
|
||||
start[b] = acc;
|
||||
acc += counts[b] as usize;
|
||||
}
|
||||
let mut order = vec![0u32; n_edges];
|
||||
let mut fill = start;
|
||||
for i in 0..n {
|
||||
if i % w + 1 < w {
|
||||
let e = 2 * i;
|
||||
let b = weight_of[e] as usize;
|
||||
order[fill[b]] = e as u32;
|
||||
fill[b] += 1;
|
||||
}
|
||||
if i / w + 1 < h {
|
||||
let e = 2 * i + 1;
|
||||
let b = weight_of[e] as usize;
|
||||
order[fill[b]] = e as u32;
|
||||
fill[b] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kruskal → binary partition tree by altitude --------------------
|
||||
// Leaves 0..n are pixels; each accepted MST edge creates internal node
|
||||
// n+k whose two children are the merged components' current roots.
|
||||
// The grid is connected, so exactly n-1 internal nodes are created and
|
||||
// parent indices are always greater than child indices.
|
||||
let n_nodes = 2 * n - 1;
|
||||
let mut parent = vec![u32::MAX; n_nodes];
|
||||
let mut alt = vec![0u8; n_nodes]; // altitude; leaves at 0
|
||||
let mut child = vec![[0u32; 2]; n - 1]; // children of internal node k
|
||||
let mut mst_edge = vec![(0u32, 0u32); n - 1]; // pixel pair of edge k
|
||||
let mut uf = Uf::new(n);
|
||||
// Current tree node representing each union-find root's component.
|
||||
let mut comp_node: Vec<u32> = (0..n as u32).collect();
|
||||
let mut next = n as u32;
|
||||
for &e in &order {
|
||||
let p = (e / 2) as usize;
|
||||
let q = if e % 2 == 0 { p + 1 } else { p + w };
|
||||
let (rp, rq) = (uf.find(p as u32), uf.find(q as u32));
|
||||
if rp == rq {
|
||||
continue;
|
||||
}
|
||||
let k = (next - n as u32) as usize;
|
||||
alt[next as usize] = weight_of[e as usize];
|
||||
child[k] = [comp_node[rp as usize], comp_node[rq as usize]];
|
||||
mst_edge[k] = (p as u32, q as u32);
|
||||
parent[comp_node[rp as usize] as usize] = next;
|
||||
parent[comp_node[rq as usize] as usize] = next;
|
||||
uf.link(rp, rq);
|
||||
comp_node[rp as usize] = next;
|
||||
next += 1;
|
||||
}
|
||||
debug_assert_eq!(next as usize, n_nodes);
|
||||
|
||||
// --- Volume attribute, leaves → root --------------------------------
|
||||
// area = pixels in the subtree; volume = ∫ area over altitude, i.e.
|
||||
// each node contributes area × (parent altitude − own altitude).
|
||||
// Ascending index order visits all children before their parent.
|
||||
let root = n_nodes - 1;
|
||||
let mut area = vec![0u64; n_nodes];
|
||||
for a in area.iter_mut().take(n) {
|
||||
*a = 1;
|
||||
}
|
||||
let mut volume = vec![0u64; n_nodes];
|
||||
for i in 0..root {
|
||||
let pa = parent[i] as usize;
|
||||
area[pa] += area[i];
|
||||
let rise = (alt[pa] - alt[i]) as u64; // parent is never lower
|
||||
volume[i] += area[i] * rise;
|
||||
volume[pa] += volume[i];
|
||||
}
|
||||
|
||||
// --- Persistence per MST edge ----------------------------------------
|
||||
// Plateau fix first (Playing with Kruskal): equal-weight edge chains
|
||||
// create internal nodes at the same altitude as their parent; their
|
||||
// volume is not a real basin measure, so replace it with the max over
|
||||
// children while the altitude is unchanged.
|
||||
let mut corrected = volume;
|
||||
for i in n..n_nodes {
|
||||
let k = i - n;
|
||||
if i != root && alt[i] == alt[parent[i] as usize] {
|
||||
let [c0, c1] = child[k];
|
||||
corrected[i] = corrected[c0 as usize].max(corrected[c1 as usize]);
|
||||
}
|
||||
}
|
||||
// Persistence of a merge = the volume of the smaller side: the level
|
||||
// at which that basin stops existing on its own.
|
||||
let mut pers = vec![0u64; n - 1];
|
||||
for k in 0..n - 1 {
|
||||
let [c0, c1] = child[k];
|
||||
pers[k] = corrected[c0 as usize].min(corrected[c1 as usize]);
|
||||
}
|
||||
|
||||
// --- Cut level from the detail slider --------------------------------
|
||||
// Merging every MST edge with persistence ≤ λ leaves exactly
|
||||
// 1 + #{edges above λ} regions, so choosing λ as the k-th largest
|
||||
// persistence targets k regions directly (ties merge a little more).
|
||||
// The persistence distribution is extremely skewed — most merges are
|
||||
// trivia with persistence ≈ 0 — so the slider maps to a region
|
||||
// *count*, exponentially: every +25.5 of detail doubles the target,
|
||||
// from 1 region at 0 up to 1024 at 255.
|
||||
let target = (2f64).powf(self.detail as f64 / 25.5).round() as usize;
|
||||
let target = target.clamp(1, pers.len());
|
||||
let lambda = {
|
||||
let mut sorted = pers.clone();
|
||||
sorted.sort_unstable_by(|a, b| b.cmp(a));
|
||||
sorted[target - 1]
|
||||
};
|
||||
|
||||
// --- Single-linkage cut over MST edges -------------------------------
|
||||
let mut cut = Uf::new(n);
|
||||
for k in 0..n - 1 {
|
||||
if pers[k] <= lambda {
|
||||
let (p, q) = mst_edge[k];
|
||||
let (rp, rq) = (cut.find(p), cut.find(q));
|
||||
if rp != rq {
|
||||
cut.link(rp, rq);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut labels = vec![0u32; n];
|
||||
for (i, l) in labels.iter_mut().enumerate() {
|
||||
*l = cut.find(i as u32);
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
/// Absorb regions smaller than `min_area` into their most color-similar
|
||||
/// 4-neighbour. Works on root-labels in place; areas and color sums are
|
||||
/// maintained through the merges so chains stay well-behaved.
|
||||
fn absorb_small(&self, img: &ColorImage, labels: &mut [u32]) {
|
||||
if self.min_area <= 1 {
|
||||
return;
|
||||
}
|
||||
let w = img.width;
|
||||
let n = labels.len();
|
||||
let mut uf = Uf::new(n);
|
||||
// Rebuild region stats keyed by current label (a pixel index).
|
||||
let mut area = vec![0u64; n];
|
||||
let mut sum = vec![[0u64; 3]; n];
|
||||
for i in 0..n {
|
||||
let l = labels[i] as usize;
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
area[l] += 1;
|
||||
sum[l][0] += c.r as u64;
|
||||
sum[l][1] += c.g as u64;
|
||||
sum[l][2] += c.b as u64;
|
||||
}
|
||||
let mean_diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> u64 {
|
||||
let mut d = 0i64;
|
||||
for ch in 0..3 {
|
||||
d += ((sa[ch] / aa) as i64 - (sb[ch] / ab) as i64).abs();
|
||||
}
|
||||
d as u64
|
||||
};
|
||||
// Sweep until no undersized region can be absorbed. Each sweep scans
|
||||
// the boundary edges once and merges each small region into its best
|
||||
// neighbour seen so far; region count strictly decreases, so this
|
||||
// terminates quickly in practice.
|
||||
loop {
|
||||
// best[l] = (diff, neighbour_root) for undersized root l
|
||||
let mut best: Vec<(u64, u32)> = vec![(u64::MAX, u32::MAX); n];
|
||||
let mut any_small = false;
|
||||
for i in 0..n {
|
||||
let a = uf.find(labels[i]);
|
||||
for j in [
|
||||
if i % w + 1 < w { i + 1 } else { i },
|
||||
if i / w + 1 < labels.len() / w { i + w } else { i },
|
||||
] {
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
let b = uf.find(labels[j]);
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
for (s, t) in [(a, b), (b, a)] {
|
||||
let (su, tu) = (s as usize, t as usize);
|
||||
if area[su] < self.min_area as u64 {
|
||||
any_small = true;
|
||||
let d = mean_diff(&sum[su], area[su], &sum[tu], area[tu]);
|
||||
if d < best[su].0 || (d == best[su].0 && t < best[su].1) {
|
||||
best[su] = (d, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any_small {
|
||||
break;
|
||||
}
|
||||
let mut merged = false;
|
||||
for l in 0..n {
|
||||
let (_, tgt) = best[l];
|
||||
if tgt == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let rl = uf.find(l as u32);
|
||||
if rl as usize != l {
|
||||
continue; // already absorbed this sweep
|
||||
}
|
||||
let rt = uf.find(tgt);
|
||||
if rt == rl {
|
||||
continue;
|
||||
}
|
||||
uf.link(rt, rl);
|
||||
area[rt as usize] += area[l];
|
||||
for ch in 0..3 {
|
||||
sum[rt as usize][ch] += sum[l][ch];
|
||||
}
|
||||
merged = true;
|
||||
}
|
||||
if !merged {
|
||||
break; // isolated undersized region (e.g. whole-canvas)
|
||||
}
|
||||
}
|
||||
for l in labels.iter_mut() {
|
||||
*l = uf.find(*l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a root-label map into the layered [`Segmentation`]: one layer per
|
||||
/// region with its mean color, the largest region first as a solid
|
||||
/// full-canvas background so stacked mode stays seam-free by overdraw.
|
||||
fn segmentation(img: &ColorImage, labels: &[u32]) -> Segmentation {
|
||||
let w = img.width;
|
||||
let h = img.height;
|
||||
let n = labels.len();
|
||||
|
||||
// Compact labels in raster order of first appearance (deterministic).
|
||||
let mut compact = vec![u32::MAX; n];
|
||||
let mut regions: Vec<u32> = Vec::new(); // compact id -> root label
|
||||
let mut ids = vec![0u32; n];
|
||||
for i in 0..n {
|
||||
let l = labels[i] as usize;
|
||||
if compact[l] == u32::MAX {
|
||||
compact[l] = regions.len() as u32;
|
||||
regions.push(labels[i]);
|
||||
}
|
||||
ids[i] = compact[l];
|
||||
}
|
||||
let m = regions.len();
|
||||
|
||||
let mut area = vec![0u64; m];
|
||||
let mut sum = vec![[0u64; 3]; m];
|
||||
let mut bbox = vec![(i32::MAX, i32::MAX, i32::MIN, i32::MIN); m];
|
||||
for i in 0..n {
|
||||
let id = ids[i] as usize;
|
||||
let (x, y) = ((i % w) as i32, (i / w) as i32);
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
area[id] += 1;
|
||||
sum[id][0] += c.r as u64;
|
||||
sum[id][1] += c.g as u64;
|
||||
sum[id][2] += c.b as u64;
|
||||
let b = &mut bbox[id];
|
||||
b.0 = b.0.min(x);
|
||||
b.1 = b.1.min(y);
|
||||
b.2 = b.2.max(x);
|
||||
b.3 = b.3.max(y);
|
||||
}
|
||||
let mean = |id: usize| {
|
||||
Color::new(
|
||||
(sum[id][0] / area[id]) as u8,
|
||||
(sum[id][1] / area[id]) as u8,
|
||||
(sum[id][2] / area[id]) as u8,
|
||||
)
|
||||
};
|
||||
|
||||
let background = (0..m).max_by_key(|&id| area[id]).unwrap_or(0);
|
||||
|
||||
let mut seg = Segmentation::new(w as u32, h as u32);
|
||||
// Background: solid full canvas, painted first; the regions stacked on
|
||||
// top stamp out everything that isn't actually background, so the
|
||||
// flattened partition is exact while stacked mode keeps overdraw.
|
||||
let mut bg = BinaryImage::new_w_h(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
bg.set_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(background)),
|
||||
mask: RegionMask::new(bg, PointI32 { x: 0, y: 0 }),
|
||||
});
|
||||
for id in 0..m {
|
||||
if id == background {
|
||||
continue;
|
||||
}
|
||||
let (x0, y0, x1, y1) = bbox[id];
|
||||
let (bw, bh) = ((x1 - x0 + 1) as usize, (y1 - y0 + 1) as usize);
|
||||
let mut image = BinaryImage::new_w_h(bw, bh);
|
||||
for y in 0..bh {
|
||||
for x in 0..bw {
|
||||
let i = (y0 as usize + y) * w + (x0 as usize + x);
|
||||
if ids[i] as usize == id {
|
||||
image.set_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(id)),
|
||||
mask: RegionMask::new(image, PointI32 { x: x0, y: y0 }),
|
||||
});
|
||||
}
|
||||
seg
|
||||
}
|
||||
}
|
||||
|
||||
impl Frontend for WatershedFrontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
if img.width * img.height == 1 {
|
||||
// Degenerate single pixel: no edges, one region.
|
||||
let labels = [0u32];
|
||||
return Ok(Self::segmentation(img, &labels));
|
||||
}
|
||||
|
||||
let mut labels = self.label_map(img);
|
||||
self.absorb_small(img, &mut labels);
|
||||
Ok(Self::segmentation(img, &labels))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -104,12 +104,13 @@ fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
|
||||
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn assert_equivalent(mode: FitMode) {
|
||||
fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let img = blobs(w, h);
|
||||
|
||||
let stacked = Config {
|
||||
mode,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
..Config::default()
|
||||
}
|
||||
@@ -120,6 +121,7 @@ fn assert_equivalent(mode: FitMode) {
|
||||
|
||||
let cutout = Config {
|
||||
mode,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..Config::default()
|
||||
}
|
||||
@@ -151,6 +153,10 @@ fn assert_equivalent(mode: FitMode) {
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_equivalent(mode: FitMode) {
|
||||
assert_equivalent_with(mode, vtracer::Clustering::ColorCluster);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_and_cutout_agree_in_interiors_spline() {
|
||||
assert_equivalent(FitMode::Spline);
|
||||
@@ -166,6 +172,13 @@ fn stacked_and_cutout_agree_in_interiors_pixel() {
|
||||
assert_equivalent(FitMode::Pixel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watershed_stacked_and_cutout_agree_in_interiors() {
|
||||
for mode in [FitMode::Pixel, FitMode::Spline] {
|
||||
assert_equivalent_with(mode, vtracer::Clustering::Watershed);
|
||||
}
|
||||
}
|
||||
|
||||
// --- seam / show-through test -------------------------------------------------
|
||||
|
||||
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
|
||||
@@ -180,12 +193,12 @@ fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
|
||||
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
|
||||
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
|
||||
/// exactly the hole-punching bug this guards against.
|
||||
#[test]
|
||||
fn stacked_has_no_seams() {
|
||||
fn assert_no_seams(clustering: vtracer::Clustering) {
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let img = blobs(w, h); // background fills the whole canvas
|
||||
let svg = Config {
|
||||
mode: FitMode::Spline,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
..Config::default()
|
||||
}
|
||||
@@ -210,6 +223,18 @@ fn stacked_has_no_seams() {
|
||||
}
|
||||
assert_eq!(
|
||||
show_through, 0,
|
||||
"stacked mode leaked {show_through} backdrop pixels — seams/holes in solid overdraw"
|
||||
"{clustering:?} stacked leaked {show_through} backdrop pixels — seams/holes in overdraw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_has_no_seams() {
|
||||
assert_no_seams(vtracer::Clustering::ColorCluster);
|
||||
}
|
||||
|
||||
/// The watershed frontend emits disjoint region masks; its full-canvas solid
|
||||
/// background layer is what restores overdraw. This guards that construction.
|
||||
#[test]
|
||||
fn watershed_stacked_has_no_seams() {
|
||||
assert_no_seams(vtracer::Clustering::Watershed);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use resvg::{tiny_skia, usvg};
|
||||
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
|
||||
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
|
||||
|
||||
// --- synthetic image builders ------------------------------------------------
|
||||
|
||||
@@ -141,7 +141,7 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
||||
"checker_bw",
|
||||
checker(),
|
||||
Config {
|
||||
color_mode: ColorMode::Binary,
|
||||
clustering: Clustering::Binary,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
@@ -210,6 +210,25 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Watershed clustering: stacked and mosaic.
|
||||
(
|
||||
"disc_watershed_spline",
|
||||
disc(),
|
||||
Config {
|
||||
clustering: Clustering::Watershed,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"swatches_watershed_mosaic",
|
||||
swatches(),
|
||||
Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Polygon,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M12,12L12,0L0,0L0,12l12,0Z" fill="#000080"/>
|
||||
<path d="M24,0L12,0l0,12l12,0L24,0Z" fill="#550080"/>
|
||||
<path d="M36,0L24,0l0,12l12,0L36,0Z" fill="#AA0080"/>
|
||||
<path d="M48,12L48,0L36,0l0,12l12,0Z" fill="#FF0080"/>
|
||||
<path d="M12,12L0,12L0,24l12,0l0-12Z" fill="#005580"/>
|
||||
<path d="M24,12L12,12l0,12l12,0l0-12Z" fill="#555580"/>
|
||||
<path d="M36,12L24,12l0,12l12,0l0-12Z" fill="#AA5580"/>
|
||||
<path d="M48,12L36,12l0,12l12,0l0-12Z" fill="#FF5580"/>
|
||||
<path d="M12,24L0,24L0,36l12,0l0-12Z" fill="#00AA80"/>
|
||||
<path d="M24,24L12,24l0,12l12,0l0-12Z" fill="#55AA80"/>
|
||||
<path d="M36,24L24,24l0,12l12,0l0-12Z" fill="#AAAA80"/>
|
||||
<path d="M48,24L36,24l0,12l12,0l0-12Z" fill="#FFAA80"/>
|
||||
<path d="M12,36L0,36L0,48l12,0l0-12Z" fill="#00FF80"/>
|
||||
<path d="M24,36L12,36l0,12l12,0l0-12Z" fill="#55FF80"/>
|
||||
<path d="M36,36L24,36l0,12l12,0l0-12Z" fill="#AAFF80"/>
|
||||
<path d="M48,36L36,36l0,12l12,0l0-12Z" fill="#FFFF80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
] {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Generated
+3
-2
@@ -111,9 +111,10 @@ Driver flow:
|
||||
|
||||
## Built-in implementations
|
||||
|
||||
- **Frontends**
|
||||
- **Frontends** (selected by `Config::clustering`)
|
||||
- `ColorClusterFrontend` — wraps `visioncortex::color_clusters::Runner`, including the transparency-keying logic that currently lives in `converter.rs` (find unused key color, key fully-transparent pixels, `KeyingAction`).
|
||||
- `BinaryFrontend` — threshold → `BinaryImage::to_clusters`.
|
||||
- `WatershedFrontend` — hierarchical watershed by volume on the 4-adjacency pixel graph (Cousty et al. TPAMI 2009; Najman, Cousty & Perret ISMM 2013), cut at `watershed_detail`. Emits a flat partition with a solid full-canvas background layer so stacked overdraw stays seam-free.
|
||||
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
|
||||
- **ColorFitters**
|
||||
- `Identity` (today's behavior: mean cluster color)
|
||||
@@ -141,7 +142,7 @@ Output size is a tracked metric: the test suite asserts a byte-size budget again
|
||||
|
||||
## CLI
|
||||
|
||||
clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--colormode color|bw`, `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`.
|
||||
clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--clustering color-cluster|bw|watershed` (formerly `--colormode`), `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`.
|
||||
|
||||
New:
|
||||
|
||||
|
||||
+2
-2
@@ -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`,
|
||||
|
||||
Vendored
+5
-2
@@ -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. */
|
||||
|
||||
+9
-3
@@ -15,7 +15,8 @@ use wasm_bindgen::prelude::*;
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct Options {
|
||||
color_mode: Option<String>,
|
||||
/// Region forming: "color-cluster" | "bw" | "watershed".
|
||||
clustering: Option<String>,
|
||||
hierarchical: Option<String>,
|
||||
mode: Option<String>,
|
||||
filter_speckle: Option<usize>,
|
||||
@@ -37,6 +38,8 @@ struct Options {
|
||||
adaptive_window: Option<u32>,
|
||||
/// Adaptive sensitivity: percent below the local mean (default 15).
|
||||
adaptive_t: Option<f64>,
|
||||
/// Watershed clustering: hierarchy cut level (0..=255).
|
||||
watershed_detail: Option<u8>,
|
||||
/// One of "bw" | "poster" | "photo"; applied before the other fields.
|
||||
preset: Option<String>,
|
||||
}
|
||||
@@ -71,8 +74,8 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
|
||||
None => Config::default(),
|
||||
};
|
||||
|
||||
if let Some(v) = opts.color_mode {
|
||||
config.color_mode = v.parse().map_err(err)?;
|
||||
if let Some(v) = opts.clustering {
|
||||
config.clustering = v.parse().map_err(err)?;
|
||||
}
|
||||
if let Some(v) = opts.hierarchical {
|
||||
config.hierarchical = v.parse().map_err(err)?;
|
||||
@@ -126,6 +129,9 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
|
||||
if let Some(v) = opts.adaptive_t {
|
||||
config.binary_adaptive_t = v;
|
||||
}
|
||||
if let Some(v) = opts.watershed_detail {
|
||||
config.watershed_detail = v;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user