mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-26 05:51:21 -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.
|
* 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.
|
* 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
|
## 1.0.0-alpha.1 - 2026-07-24
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ Options:
|
|||||||
-i, --input <INPUT> Path to the input raster image
|
-i, --input <INPUT> Path to the input raster image
|
||||||
-o, --output <OUTPUT> Path to the output SVG
|
-o, --output <OUTPUT> Path to the output SVG
|
||||||
--preset <PRESET> Start from a preset: bw, poster, photo
|
--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)
|
--hierarchical <HIERARCHICAL> Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
|
||||||
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
|
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
|
||||||
-f, --filter-speckle <FILTER_SPECKLE> Discard patches smaller than X px in size (0..=128)
|
-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 Binary mode: Bradley–Roth adaptive threshold (uneven lighting)
|
||||||
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
|
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
|
||||||
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
|
--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
|
-h, --help Print help
|
||||||
-V, --version Print version
|
-V, --version Print version
|
||||||
```
|
```
|
||||||
@@ -112,6 +113,10 @@ Options:
|
|||||||
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
|
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
|
||||||
**Bradley–Roth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
|
**Bradley–Roth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
|
||||||
/ `--adaptive-t`) for scans with uneven lighting.
|
/ `--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
|
## Downloads
|
||||||
|
|
||||||
@@ -135,11 +140,14 @@ cargo install vtracer-cli
|
|||||||
./vtracer input.jpg output.svg --preset bw
|
./vtracer input.jpg output.svg --preset bw
|
||||||
|
|
||||||
# scanned/photographed line art with uneven lighting
|
# 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)
|
# seam-free mosaic (gapless tessellation)
|
||||||
./vtracer input.jpg output.svg --hierarchical cutout
|
./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
|
# constrain to a fixed palette
|
||||||
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
|
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
|
||||||
```
|
```
|
||||||
@@ -202,8 +210,12 @@ cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
|
|||||||
svg = cfg.convert_bytes(data)
|
svg = cfg.convert_bytes(data)
|
||||||
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
|
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
|
# 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")
|
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' });
|
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
|
||||||
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
|
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
|
// binary with adaptive thresholding
|
||||||
const bw = vtracer.convertBuffer(buffer, { colorMode: 'bw', adaptive: true });
|
const bw = vtracer.convertBuffer(buffer, { clustering: 'bw', adaptive: true });
|
||||||
```
|
```
|
||||||
|
|
||||||
## Citations
|
## Citations
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::process::ExitCode;
|
|||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use visioncortex::{Color, ColorImage};
|
use visioncortex::{Color, ColorImage};
|
||||||
use vtracer::{ColorMode, Config, FitMode, Hierarchical, Preset};
|
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
|
||||||
|
|
||||||
/// Convert an image into vector graphics.
|
/// Convert an image into vector graphics.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -35,9 +35,9 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
preset: Option<Preset>,
|
preset: Option<Preset>,
|
||||||
|
|
||||||
/// Color image (`color`) or binary image (`bw`).
|
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
|
||||||
#[arg(long = "colormode")]
|
#[arg(long)]
|
||||||
colormode: Option<ColorMode>,
|
clustering: Option<Clustering>,
|
||||||
|
|
||||||
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
|
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -106,6 +106,10 @@ struct Args {
|
|||||||
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
|
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
adaptive_t: Option<f64>,
|
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> {
|
fn parse_segment_length(s: &str) -> Result<f64, String> {
|
||||||
@@ -148,8 +152,8 @@ fn build_config(args: &Args) -> Result<Config, String> {
|
|||||||
None => Config::default(),
|
None => Config::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(v) = args.colormode {
|
if let Some(v) = args.clustering {
|
||||||
config.color_mode = v;
|
config.clustering = v;
|
||||||
}
|
}
|
||||||
if let Some(v) = args.hierarchical {
|
if let Some(v) = args.hierarchical {
|
||||||
config.hierarchical = v;
|
config.hierarchical = v;
|
||||||
@@ -199,6 +203,9 @@ fn build_config(args: &Args) -> Result<Config, String> {
|
|||||||
if let Some(v) = args.adaptive_t {
|
if let Some(v) = args.adaptive_t {
|
||||||
config.binary_adaptive_t = v;
|
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.
|
// Palette: inline flag wins over file; both parse to a color list.
|
||||||
if let Some(text) = &args.palette {
|
if let Some(text) = &args.palette {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
|
|||||||
|
|
||||||
| arg | default | notes |
|
| arg | default | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `color_mode` | `"color"` | `"color"` or `"bw"` |
|
| `clustering` | `"color-cluster"` | `"color-cluster"`, `"bw"`, or `"watershed"` |
|
||||||
| `hierarchical` | `"stacked"` | `"stacked"` or `"cutout"` (mosaic) |
|
| `hierarchical` | `"stacked"` | `"stacked"` or `"cutout"` (mosaic) |
|
||||||
| `mode` | `"spline"` | `"pixel"`, `"polygon"`, `"spline"` |
|
| `mode` | `"spline"` | `"pixel"`, `"polygon"`, `"spline"` |
|
||||||
| `filter_speckle` | `4` | discard patches smaller than X px |
|
| `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 |
|
| `palette` | `None` | list of `#rrggbb` strings |
|
||||||
| `max_colors` | `None` | auto-quantize target |
|
| `max_colors` | `None` | auto-quantize target |
|
||||||
| `optimize` | `1` | `0` off, `1` quantize+simplify, `2` + shorthands |
|
| `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`,
|
Each `Config` has `convert_file(input, output)`, `convert_bytes(data, format=None) -> str`,
|
||||||
and `convert_pixels(rgba, width, height) -> str`.
|
and `convert_pixels(rgba, width, height) -> str`.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use pyo3::exceptions::{PyIOError, PyValueError};
|
|||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
use ::vtracer::{
|
use ::vtracer::{
|
||||||
Color, ColorImage, ColorMode, Config as CoreConfig, FitMode, Hierarchical, Preset,
|
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- string <-> enum helpers -------------------------------------------------
|
// --- 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)
|
s.parse().map_err(PyValueError::new_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn color_mode_str(m: ColorMode) -> &'static str {
|
fn clustering_str(c: Clustering) -> &'static str {
|
||||||
match m {
|
match c {
|
||||||
ColorMode::Color => "color",
|
Clustering::ColorCluster => "color-cluster",
|
||||||
ColorMode::Binary => "bw",
|
Clustering::Binary => "bw",
|
||||||
|
Clustering::Watershed => "watershed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ impl PyConfig {
|
|||||||
impl PyConfig {
|
impl PyConfig {
|
||||||
#[new]
|
#[new]
|
||||||
#[pyo3(signature = (
|
#[pyo3(signature = (
|
||||||
color_mode = "color",
|
clustering = "color-cluster",
|
||||||
hierarchical = "stacked",
|
hierarchical = "stacked",
|
||||||
mode = "spline",
|
mode = "spline",
|
||||||
filter_speckle = 4,
|
filter_speckle = 4,
|
||||||
@@ -147,10 +148,11 @@ impl PyConfig {
|
|||||||
adaptive = false,
|
adaptive = false,
|
||||||
adaptive_window = 0,
|
adaptive_window = 0,
|
||||||
adaptive_t = 15.0,
|
adaptive_t = 15.0,
|
||||||
|
watershed_detail = 128,
|
||||||
))]
|
))]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn new(
|
fn new(
|
||||||
color_mode: &str,
|
clustering: &str,
|
||||||
hierarchical: &str,
|
hierarchical: &str,
|
||||||
mode: &str,
|
mode: &str,
|
||||||
filter_speckle: usize,
|
filter_speckle: usize,
|
||||||
@@ -168,6 +170,7 @@ impl PyConfig {
|
|||||||
adaptive: bool,
|
adaptive: bool,
|
||||||
adaptive_window: u32,
|
adaptive_window: u32,
|
||||||
adaptive_t: f64,
|
adaptive_t: f64,
|
||||||
|
watershed_detail: u8,
|
||||||
) -> PyResult<Self> {
|
) -> PyResult<Self> {
|
||||||
let palette = match palette {
|
let palette = match palette {
|
||||||
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
||||||
@@ -175,7 +178,7 @@ impl PyConfig {
|
|||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: CoreConfig {
|
inner: CoreConfig {
|
||||||
color_mode: parse(color_mode)?,
|
clustering: parse(clustering)?,
|
||||||
hierarchical: parse(hierarchical)?,
|
hierarchical: parse(hierarchical)?,
|
||||||
mode: parse(mode)?,
|
mode: parse(mode)?,
|
||||||
filter_speckle,
|
filter_speckle,
|
||||||
@@ -193,6 +196,7 @@ impl PyConfig {
|
|||||||
binary_adaptive: adaptive,
|
binary_adaptive: adaptive,
|
||||||
binary_adaptive_window: adaptive_window,
|
binary_adaptive_window: adaptive_window,
|
||||||
binary_adaptive_t: adaptive_t,
|
binary_adaptive_t: adaptive_t,
|
||||||
|
watershed_detail,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -224,15 +228,24 @@ impl PyConfig {
|
|||||||
// --- properties ---
|
// --- properties ---
|
||||||
|
|
||||||
#[getter]
|
#[getter]
|
||||||
fn color_mode(&self) -> &'static str {
|
fn clustering(&self) -> &'static str {
|
||||||
color_mode_str(self.inner.color_mode)
|
clustering_str(self.inner.clustering)
|
||||||
}
|
}
|
||||||
#[setter]
|
#[setter]
|
||||||
fn set_color_mode(&mut self, v: &str) -> PyResult<()> {
|
fn set_clustering(&mut self, v: &str) -> PyResult<()> {
|
||||||
self.inner.color_mode = parse(v)?;
|
self.inner.clustering = parse(v)?;
|
||||||
Ok(())
|
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]
|
#[getter]
|
||||||
fn hierarchical(&self) -> &'static str {
|
fn hierarchical(&self) -> &'static str {
|
||||||
hierarchical_str(self.inner.hierarchical)
|
hierarchical_str(self.inner.hierarchical)
|
||||||
@@ -432,11 +445,11 @@ impl PyConfig {
|
|||||||
fn __repr__(&self) -> String {
|
fn __repr__(&self) -> String {
|
||||||
let c = &self.inner;
|
let c = &self.inner;
|
||||||
format!(
|
format!(
|
||||||
"Config(color_mode='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
|
"Config(clustering='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
|
||||||
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
|
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
|
||||||
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
|
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
|
||||||
max_colors={:?}, optimize={})",
|
max_colors={:?}, optimize={})",
|
||||||
color_mode_str(c.color_mode),
|
clustering_str(c.clustering),
|
||||||
hierarchical_str(c.hierarchical),
|
hierarchical_str(c.hierarchical),
|
||||||
mode_str(c.mode),
|
mode_str(c.mode),
|
||||||
c.filter_speckle,
|
c.filter_speckle,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class Config:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
color_mode: str = "color", # "color" | "bw"
|
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
|
||||||
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
|
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
|
||||||
mode: str = "spline", # "pixel" | "polygon" | "spline"
|
mode: str = "spline", # "pixel" | "polygon" | "spline"
|
||||||
filter_speckle: int = 4,
|
filter_speckle: int = 4,
|
||||||
@@ -22,6 +22,11 @@ class Config:
|
|||||||
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
|
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
|
||||||
max_colors: Optional[int] = None, # auto-quantize target
|
max_colors: Optional[int] = None, # auto-quantize target
|
||||||
optimize: int = 1, # 0 | 1 | 2
|
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: ...
|
) -> None: ...
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -31,7 +36,7 @@ class Config:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def photo() -> "Config": ...
|
def photo() -> "Config": ...
|
||||||
|
|
||||||
color_mode: str
|
clustering: str
|
||||||
hierarchical: str
|
hierarchical: str
|
||||||
mode: str
|
mode: str
|
||||||
filter_speckle: int
|
filter_speckle: int
|
||||||
@@ -45,6 +50,11 @@ class Config:
|
|||||||
palette: list[str]
|
palette: list[str]
|
||||||
max_colors: Optional[int]
|
max_colors: Optional[int]
|
||||||
optimize: 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_file(self, input_path: str, output_path: str) -> None: ...
|
||||||
def convert_bytes(self, data: bytes, format: Optional[str] = None) -> str: ...
|
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::compose::Compositing;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
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::{
|
use crate::mosaic::{
|
||||||
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
|
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
|
||||||
};
|
};
|
||||||
@@ -16,10 +18,15 @@ use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
|
|||||||
use crate::pipeline::Pipeline;
|
use crate::pipeline::Pipeline;
|
||||||
use crate::svg::SvgWriter;
|
use crate::svg::SvgWriter;
|
||||||
|
|
||||||
|
/// Which region-forming algorithm segments the image.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ColorMode {
|
pub enum Clustering {
|
||||||
Color,
|
/// Hierarchical color clustering — the classic VTracer path.
|
||||||
|
ColorCluster,
|
||||||
|
/// Threshold to black/white, then cluster the foreground.
|
||||||
Binary,
|
Binary,
|
||||||
|
/// Hierarchical watershed on the pixel graph, cut at `watershed_detail`.
|
||||||
|
Watershed,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[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.
|
/// whether to re-segment. Kept in sync with [`Config::frontend`] in one place.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct SegmentKey {
|
pub struct SegmentKey {
|
||||||
color_mode: ColorMode,
|
clustering: Clustering,
|
||||||
color_precision: i32,
|
color_precision: i32,
|
||||||
layer_difference: i32,
|
layer_difference: i32,
|
||||||
filter_speckle: usize,
|
filter_speckle: usize,
|
||||||
@@ -57,13 +64,15 @@ pub struct SegmentKey {
|
|||||||
binary_adaptive: bool,
|
binary_adaptive: bool,
|
||||||
binary_adaptive_window: u32,
|
binary_adaptive_window: u32,
|
||||||
binary_adaptive_t: f64,
|
binary_adaptive_t: f64,
|
||||||
|
watershed_detail: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-level converter configuration. [`Config::build`] turns this into a
|
/// High-level converter configuration. [`Config::build`] turns this into a
|
||||||
/// concrete [`Pipeline`].
|
/// concrete [`Pipeline`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub color_mode: ColorMode,
|
/// Region-forming algorithm (see [`Clustering`]).
|
||||||
|
pub clustering: Clustering,
|
||||||
pub hierarchical: Hierarchical,
|
pub hierarchical: Hierarchical,
|
||||||
/// Speckle filter given as a side length; the area threshold is its square.
|
/// Speckle filter given as a side length; the area threshold is its square.
|
||||||
pub filter_speckle: usize,
|
pub filter_speckle: usize,
|
||||||
@@ -97,12 +106,15 @@ pub struct Config {
|
|||||||
pub binary_adaptive_window: u32,
|
pub binary_adaptive_window: u32,
|
||||||
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
|
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
|
||||||
pub binary_adaptive_t: f64,
|
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 {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
color_mode: ColorMode::Color,
|
clustering: Clustering::ColorCluster,
|
||||||
hierarchical: Hierarchical::Stacked,
|
hierarchical: Hierarchical::Stacked,
|
||||||
filter_speckle: 4,
|
filter_speckle: 4,
|
||||||
color_precision: 6,
|
color_precision: 6,
|
||||||
@@ -120,6 +132,7 @@ impl Default for Config {
|
|||||||
binary_adaptive: false,
|
binary_adaptive: false,
|
||||||
binary_adaptive_window: 0,
|
binary_adaptive_window: 0,
|
||||||
binary_adaptive_t: 15.0,
|
binary_adaptive_t: 15.0,
|
||||||
|
watershed_detail: 128,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,16 +141,14 @@ impl Config {
|
|||||||
pub fn from_preset(preset: Preset) -> Self {
|
pub fn from_preset(preset: Preset) -> Self {
|
||||||
match preset {
|
match preset {
|
||||||
Preset::Bw => Self {
|
Preset::Bw => Self {
|
||||||
color_mode: ColorMode::Binary,
|
clustering: Clustering::Binary,
|
||||||
..Self::default()
|
..Self::default()
|
||||||
},
|
},
|
||||||
Preset::Poster => Self {
|
Preset::Poster => Self {
|
||||||
color_mode: ColorMode::Color,
|
|
||||||
color_precision: 8,
|
color_precision: 8,
|
||||||
..Self::default()
|
..Self::default()
|
||||||
},
|
},
|
||||||
Preset::Photo => Self {
|
Preset::Photo => Self {
|
||||||
color_mode: ColorMode::Color,
|
|
||||||
filter_speckle: 10,
|
filter_speckle: 10,
|
||||||
color_precision: 8,
|
color_precision: 8,
|
||||||
layer_difference: 48,
|
layer_difference: 48,
|
||||||
@@ -157,13 +168,13 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn frontend(&self) -> Box<dyn Frontend> {
|
fn frontend(&self) -> Box<dyn Frontend> {
|
||||||
match self.color_mode {
|
match self.clustering {
|
||||||
ColorMode::Color => Box::new(ColorClusterFrontend {
|
Clustering::ColorCluster => Box::new(ColorClusterFrontend {
|
||||||
color_precision_loss: 8 - self.color_precision,
|
color_precision_loss: 8 - self.color_precision,
|
||||||
layer_difference: self.layer_difference,
|
layer_difference: self.layer_difference,
|
||||||
good_min_area: self.speckle_area(),
|
good_min_area: self.speckle_area(),
|
||||||
}),
|
}),
|
||||||
ColorMode::Binary => {
|
Clustering::Binary => {
|
||||||
let threshold = if self.binary_adaptive {
|
let threshold = if self.binary_adaptive {
|
||||||
Threshold::Adaptive {
|
Threshold::Adaptive {
|
||||||
window: self.binary_adaptive_window,
|
window: self.binary_adaptive_window,
|
||||||
@@ -178,6 +189,10 @@ impl Config {
|
|||||||
min_area: self.speckle_area(),
|
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
|
/// The clustering-relevant subset of this config. Changing any field it
|
||||||
/// captures (color mode, color precision, layer difference, speckle, or the
|
/// captures (clustering algorithm, color precision, layer difference,
|
||||||
/// binary threshold settings) requires re-segmenting; changing anything else
|
/// speckle, binary threshold settings, or watershed detail) requires
|
||||||
/// — fit mode, curve params, compositing, palette, optimization — reuses a
|
/// re-segmenting; changing anything else — fit mode, curve params,
|
||||||
/// cached segmentation. See [`Session`](crate::Session).
|
/// compositing, palette, optimization — reuses a cached segmentation. See
|
||||||
|
/// [`Session`](crate::Session).
|
||||||
pub fn segment_key(&self) -> SegmentKey {
|
pub fn segment_key(&self) -> SegmentKey {
|
||||||
SegmentKey {
|
SegmentKey {
|
||||||
color_mode: self.color_mode,
|
clustering: self.clustering,
|
||||||
color_precision: self.color_precision,
|
color_precision: self.color_precision,
|
||||||
layer_difference: self.layer_difference,
|
layer_difference: self.layer_difference,
|
||||||
filter_speckle: self.filter_speckle,
|
filter_speckle: self.filter_speckle,
|
||||||
@@ -267,6 +283,7 @@ impl Config {
|
|||||||
binary_adaptive: self.binary_adaptive,
|
binary_adaptive: self.binary_adaptive,
|
||||||
binary_adaptive_window: self.binary_adaptive_window,
|
binary_adaptive_window: self.binary_adaptive_window,
|
||||||
binary_adaptive_t: self.binary_adaptive_t,
|
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
|
deg as f64 / 180.0 * std::f64::consts::PI
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for ColorMode {
|
impl FromStr for Clustering {
|
||||||
type Err = String;
|
type Err = String;
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s {
|
match s {
|
||||||
"color" => Ok(Self::Color),
|
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
|
||||||
"binary" | "bw" | "BW" => Ok(Self::Binary),
|
"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
|
//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic
|
||||||
//! VTracer color path), including transparency keying.
|
//! VTracer color path), including transparency keying.
|
||||||
//! * [`BinaryFrontend`] — threshold to black/white then cluster.
|
//! * [`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
|
//! Third parties can implement [`Frontend`] to feed external label maps or ML
|
||||||
//! segmentation into the pipeline.
|
//! segmentation into the pipeline.
|
||||||
@@ -11,9 +12,11 @@
|
|||||||
mod binary;
|
mod binary;
|
||||||
mod color_cluster;
|
mod color_cluster;
|
||||||
mod keying;
|
mod keying;
|
||||||
|
mod watershed;
|
||||||
|
|
||||||
pub use binary::{BinaryFrontend, Threshold};
|
pub use binary::{BinaryFrontend, Threshold};
|
||||||
pub use color_cluster::ColorClusterFrontend;
|
pub use color_cluster::ColorClusterFrontend;
|
||||||
|
pub use watershed::WatershedFrontend;
|
||||||
|
|
||||||
use visioncortex::ColorImage;
|
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 session;
|
||||||
pub mod svg;
|
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 error::Error;
|
||||||
pub use frontend::Threshold;
|
pub use frontend::Threshold;
|
||||||
pub use ir::{Segmentation, VectorDoc};
|
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)
|
(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 (w, h) = (96usize, 96usize);
|
||||||
let img = blobs(w, h);
|
let img = blobs(w, h);
|
||||||
|
|
||||||
let stacked = Config {
|
let stacked = Config {
|
||||||
mode,
|
mode,
|
||||||
|
clustering,
|
||||||
hierarchical: Hierarchical::Stacked,
|
hierarchical: Hierarchical::Stacked,
|
||||||
..Config::default()
|
..Config::default()
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,7 @@ fn assert_equivalent(mode: FitMode) {
|
|||||||
|
|
||||||
let cutout = Config {
|
let cutout = Config {
|
||||||
mode,
|
mode,
|
||||||
|
clustering,
|
||||||
hierarchical: Hierarchical::Cutout,
|
hierarchical: Hierarchical::Cutout,
|
||||||
..Config::default()
|
..Config::default()
|
||||||
}
|
}
|
||||||
@@ -151,6 +153,10 @@ fn assert_equivalent(mode: FitMode) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_equivalent(mode: FitMode) {
|
||||||
|
assert_equivalent_with(mode, vtracer::Clustering::ColorCluster);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stacked_and_cutout_agree_in_interiors_spline() {
|
fn stacked_and_cutout_agree_in_interiors_spline() {
|
||||||
assert_equivalent(FitMode::Spline);
|
assert_equivalent(FitMode::Spline);
|
||||||
@@ -166,6 +172,13 @@ fn stacked_and_cutout_agree_in_interiors_pixel() {
|
|||||||
assert_equivalent(FitMode::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 -------------------------------------------------
|
// --- seam / show-through test -------------------------------------------------
|
||||||
|
|
||||||
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
|
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
|
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
|
||||||
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
|
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
|
||||||
/// exactly the hole-punching bug this guards against.
|
/// exactly the hole-punching bug this guards against.
|
||||||
#[test]
|
fn assert_no_seams(clustering: vtracer::Clustering) {
|
||||||
fn stacked_has_no_seams() {
|
|
||||||
let (w, h) = (96usize, 96usize);
|
let (w, h) = (96usize, 96usize);
|
||||||
let img = blobs(w, h); // background fills the whole canvas
|
let img = blobs(w, h); // background fills the whole canvas
|
||||||
let svg = Config {
|
let svg = Config {
|
||||||
mode: FitMode::Spline,
|
mode: FitMode::Spline,
|
||||||
|
clustering,
|
||||||
hierarchical: Hierarchical::Stacked,
|
hierarchical: Hierarchical::Stacked,
|
||||||
..Config::default()
|
..Config::default()
|
||||||
}
|
}
|
||||||
@@ -210,6 +223,18 @@ fn stacked_has_no_seams() {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
show_through, 0,
|
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 std::path::PathBuf;
|
||||||
|
|
||||||
use resvg::{tiny_skia, usvg};
|
use resvg::{tiny_skia, usvg};
|
||||||
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
|
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
|
||||||
|
|
||||||
// --- synthetic image builders ------------------------------------------------
|
// --- synthetic image builders ------------------------------------------------
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
|||||||
"checker_bw",
|
"checker_bw",
|
||||||
checker(),
|
checker(),
|
||||||
Config {
|
Config {
|
||||||
color_mode: ColorMode::Binary,
|
clustering: Clustering::Binary,
|
||||||
..base()
|
..base()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -210,6 +210,25 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
|||||||
..base()
|
..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.
|
//! 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.
|
/// Build a `size × size` image split into two vertical color bands.
|
||||||
fn two_band_image(size: usize) -> ColorImage {
|
fn two_band_image(size: usize) -> ColorImage {
|
||||||
@@ -52,13 +52,27 @@ fn all_fit_modes_produce_svg() {
|
|||||||
fn binary_pipeline_produces_svg() {
|
fn binary_pipeline_produces_svg() {
|
||||||
let img = two_band_image(32);
|
let img = two_band_image(32);
|
||||||
let config = Config {
|
let config = Config {
|
||||||
color_mode: ColorMode::Binary,
|
clustering: Clustering::Binary,
|
||||||
..Config::default()
|
..Config::default()
|
||||||
};
|
};
|
||||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||||
assert_valid_svg(&svg);
|
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]
|
#[test]
|
||||||
fn optimize_levels_shrink_or_match() {
|
fn optimize_levels_shrink_or_match() {
|
||||||
let img = two_band_image(48);
|
let img = two_band_image(48);
|
||||||
|
|||||||
@@ -72,7 +72,15 @@ fn segment_key_tracks_only_clustering_params() {
|
|||||||
..base.clone()
|
..base.clone()
|
||||||
},
|
},
|
||||||
Config {
|
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()
|
..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
|
## 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`).
|
- `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`.
|
- `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.
|
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
|
||||||
- **ColorFitters**
|
- **ColorFitters**
|
||||||
- `Identity` (today's behavior: mean cluster color)
|
- `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
|
## 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:
|
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' });
|
const svg = vtracer.convertBuffer(fs.readFileSync('in.png'), { preset: 'poster' });
|
||||||
|
|
||||||
// raw RGBA8 pixels
|
// raw RGBA8 pixels
|
||||||
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
|
const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -36,7 +36,7 @@ const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
|
|||||||
|
|
||||||
### `Options` (all optional, camelCase)
|
### `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
|
(`"color" | "bw"`), `hierarchical` (`"stacked" | "cutout"` for the seam-free
|
||||||
mosaic), `mode` (`"pixel" | "polygon" | "spline"`), `filterSpeckle`,
|
mosaic), `mode` (`"pixel" | "polygon" | "spline"`), `filterSpeckle`,
|
||||||
`colorPrecision`, `layerDifference`, `cornerThreshold`, `lengthThreshold`,
|
`colorPrecision`, `layerDifference`, `cornerThreshold`, `lengthThreshold`,
|
||||||
|
|||||||
Vendored
+5
-2
@@ -2,7 +2,8 @@
|
|||||||
export interface Options {
|
export interface Options {
|
||||||
/** Applied before other fields: "bw" | "poster" | "photo". */
|
/** Applied before other fields: "bw" | "poster" | "photo". */
|
||||||
preset?: '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';
|
hierarchical?: 'stacked' | 'cutout';
|
||||||
mode?: 'pixel' | 'polygon' | 'spline';
|
mode?: 'pixel' | 'polygon' | 'spline';
|
||||||
filterSpeckle?: number;
|
filterSpeckle?: number;
|
||||||
@@ -19,7 +20,7 @@ export interface Options {
|
|||||||
maxColors?: number;
|
maxColors?: number;
|
||||||
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
|
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
|
||||||
optimize?: number;
|
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;
|
binaryThreshold?: number;
|
||||||
/** Binary mode: use Bradley–Roth adaptive thresholding (handles uneven lighting). */
|
/** Binary mode: use Bradley–Roth adaptive thresholding (handles uneven lighting). */
|
||||||
adaptive?: boolean;
|
adaptive?: boolean;
|
||||||
@@ -27,6 +28,8 @@ export interface Options {
|
|||||||
adaptiveWindow?: number;
|
adaptiveWindow?: number;
|
||||||
/** Adaptive sensitivity: percent below the local mean (default 15). */
|
/** Adaptive sensitivity: percent below the local mean (default 15). */
|
||||||
adaptiveT?: number;
|
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. */
|
/** 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)]
|
#[derive(Default, Deserialize)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
struct Options {
|
struct Options {
|
||||||
color_mode: Option<String>,
|
/// Region forming: "color-cluster" | "bw" | "watershed".
|
||||||
|
clustering: Option<String>,
|
||||||
hierarchical: Option<String>,
|
hierarchical: Option<String>,
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
filter_speckle: Option<usize>,
|
filter_speckle: Option<usize>,
|
||||||
@@ -37,6 +38,8 @@ struct Options {
|
|||||||
adaptive_window: Option<u32>,
|
adaptive_window: Option<u32>,
|
||||||
/// Adaptive sensitivity: percent below the local mean (default 15).
|
/// Adaptive sensitivity: percent below the local mean (default 15).
|
||||||
adaptive_t: Option<f64>,
|
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.
|
/// One of "bw" | "poster" | "photo"; applied before the other fields.
|
||||||
preset: Option<String>,
|
preset: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -71,8 +74,8 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
|
|||||||
None => Config::default(),
|
None => Config::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(v) = opts.color_mode {
|
if let Some(v) = opts.clustering {
|
||||||
config.color_mode = v.parse().map_err(err)?;
|
config.clustering = v.parse().map_err(err)?;
|
||||||
}
|
}
|
||||||
if let Some(v) = opts.hierarchical {
|
if let Some(v) = opts.hierarchical {
|
||||||
config.hierarchical = v.parse().map_err(err)?;
|
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 {
|
if let Some(v) = opts.adaptive_t {
|
||||||
config.binary_adaptive_t = v;
|
config.binary_adaptive_t = v;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = opts.watershed_detail {
|
||||||
|
config.watershed_detail = v;
|
||||||
|
}
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user