45 Commits

Author SHA1 Message Date
Chris Tsang 272866beb3 Linux 2026-09-09 22:32:49 +01:00
VTracer Release Bot 2bc01424cd Publish desktop updater 1.0.0-alpha.4.app.151 2026-09-09 22:32:04 +01:00
VTracer Release Bot f85b8f5813 Publish desktop updater 1.0.0-alpha.4.app.151 2026-09-09 22:10:40 +01:00
Chris Tsang aa0ae083e8 Linux 2026-09-09 17:28:49 +01:00
VTracer Release Bot 4aedd6d808 Publish desktop updater 1.0.0-alpha.4.app.148 2026-09-09 17:25:59 +01:00
VTracer Release Bot 1a4e5314b2 Publish desktop updater 1.0.0-alpha.4.app.148 2026-09-09 15:17:12 +01:00
Chris Tsang 642721e85e Linux 2026-09-09 11:10:39 +01:00
VTracer Release Bot a67cdebac4 Publish desktop updater 1.0.0-alpha.4.app.146 2026-09-09 11:08:32 +01:00
VTracer Release Bot fbbbbbaaae Publish desktop updater 1.0.0-alpha.4.app.146 2026-09-08 23:32:36 +01:00
VTracer Release Bot 5eb956aa2a Publish desktop updater 1.0.0-alpha.4.app.144 2026-09-08 22:42:12 +01:00
Chris Tsang d797c0c934 Linux 2026-09-08 21:57:35 +01:00
VTracer Release Bot a9355f03c0 Publish desktop updater 1.0.0-alpha.4.app.143 2026-09-08 21:56:16 +01:00
VTracer Release Bot e3e28b5170 Publish desktop updater 1.0.0-alpha.4.app.143 2026-09-08 21:55:50 +01:00
Chris Tsang 25fdc6f817 Linux 2026-09-07 12:02:20 +01:00
VTracer Release Bot f8c54afa18 Publish desktop updater 1.0.0-alpha.4.app.135 2026-09-07 11:56:07 +01:00
VTracer Release Bot adb5055f7b Publish desktop updater 1.0.0-alpha.4.app.135 2026-09-07 11:51:56 +01:00
Chris Tsang 0e17e64e51 Linux 2026-09-07 00:50:45 +01:00
VTracer Release Bot 870d5aef49 Publish desktop updater 1.0.0-alpha.4.app.133 2026-09-07 00:44:27 +01:00
VTracer Release Bot 015f7f5baa Publish desktop updater 1.0.0-alpha.4.app.133 2026-09-07 00:42:47 +01:00
Chris Tsang 04ceaff5ed Publish desktop updater 1.0.0-alpha.4.app.118 2026-08-31 22:55:22 +01:00
VTracer Release Bot 4b77efdaae Publish desktop updater 1.0.0-alpha.4.app.118 2026-08-31 22:54:26 +01:00
VTracer Release Bot a05158aedf Publish desktop updater 1.0.0-alpha.4.app.118 2026-08-31 22:40:15 +01:00
Chris Tsang 01833619be Publish desktop updater 1.0.0-alpha.4.app.116 2026-08-29 22:21:14 +01:00
VTracer Release Bot 15d6f3bbda Publish desktop updater 1.0.0-alpha.4.app.116 2026-08-29 22:18:11 +01:00
VTracer Release Bot 037003134d Publish desktop updater 1.0.0-alpha.4.app.116 2026-08-29 22:17:43 +01:00
VTracer Release Bot 142a2987cb Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 17:39:52 +01:00
VTracer Release Bot 034ce42de0 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 17:36:46 +01:00
VTracer Release Bot f78c77be21 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 17:21:56 +01:00
VTracer Release Bot 65218f7f93 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 16:48:51 +01:00
VTracer Release Bot 657177bda1 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 16:45:39 +01:00
VTracer Release Bot cee28b09b8 Publish desktop updater 1.0.0-alpha.3.app.58 2026-08-14 16:09:18 +01:00
VTracer Release Bot 8ac000c213 Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:39:28 +01:00
VTracer Release Bot f17908803d Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:35:46 +01:00
VTracer Release Bot b17f3565c9 Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:06:01 +01:00
VTracer Release Bot 2a82a81a27 Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:05:29 +01:00
VTracer Release Bot dac83cdcf6 Publish desktop updater 1.0.0-alpha.3 2026-08-13 15:52:40 +01:00
VTracer Release Bot 14665d771f Publish desktop updater 1.0.0-alpha.3 2026-08-13 15:12:34 +01:00
VTracer Release Bot 71858aec47 Publish desktop updater 1.0.0-alpha.3 2026-08-13 15:05:55 +01:00
VTracer Release Bot 539354c4b2 Publish desktop updater 1.0.0-alpha.3 2026-08-13 14:51:20 +01:00
VTracer Release Bot 552bc1b8dd Publish desktop updater 1.0.0-alpha.3 2026-08-13 13:28:40 +01:00
VTracer Release Bot e8dce3b972 Publish desktop updater 1.0.0-alpha.3 2026-08-08 21:44:06 +01:00
VTracer Release Bot 8d5dd9bdc1 Publish desktop updater 1.0.0-alpha.3 2026-08-08 20:20:24 +01:00
VTracer Release Bot 71b7210525 Publish desktop updater 1.0.0-alpha.3 2026-08-07 11:33:21 +01:00
VTracer Release Bot 19d4e48cb2 Publish desktop updater 1.0.0-alpha.3 2026-08-02 01:31:23 +01:00
VTracer Release Bot daa866862b Publish desktop updater 1.0.0-alpha.3 2026-08-02 01:15:17 +01:00
84 changed files with 9818 additions and 5662 deletions
+32
View File
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## 1.0.0-alpha.3 - 2026-08-01
### Added
* `vtracer-bench`: a blind fidelity benchmark for raster-to-vector tracers — it compares an original raster against a rendered reconstruction and reports one 0..1 score built from PSNR, SSIM, and a clustered-diff "missing patch" metric (geometric mean, so a single collapsed axis drags the score down). Blind to how the reconstruction was produced: render any tracer's output to pixels and score it. A new workspace crate, separate from the four shipped packages.
### Fixed
* Watershed no longer leaks a region along a blurred low-contrast crack as a 1-px filament (a slightly soft image could grow a hairline of one region's color running tens of px down a neighbouring boundary). The boundary snap's mixture gate now also admits pixels that blend two *neighbouring* regions — a blend band belongs to its closer flank even when the basin cut misattributed it to a distant region. On the blurred striped synthetic the circle's max boundary error drops from 31.6 px to 1.5 px; crisp images are byte-unaffected.
## 1.0.0-alpha.2 - 2026-07-27
### Added
* Watershed clustering (`--clustering watershed`): a new region-forming frontend — a hierarchical watershed on the pixel graph (Cousty et al. 2009; Najman et al. 2013), controlled by one dial, `--watershed-detail` (0..=255; each +25.5 roughly doubles the region count). Regions follow image content, with no watershed-line pixels.
* Boundaries come out calm: antialiased pixels snap to the color-midpoint iso-line instead of meandering with the noise inside the ramp.
* `stacked` stacks the merge tree itself (coarse ancestors below, refined regions on top), so overdraw stays seam-free.
* `cutout` gets the partition natively; neighbouring faces closer than `max(2, (255 detail) / 8)` merge, so faces a human cannot tell apart never survive as separate patches.
* `WatershedHierarchy` is public, split into `build` (expensive, image-only) and `cut` (near-instant); `Session` re-cuts a cached hierarchy on detail changes, making the slider fully interactive (~25 ms vs ~40 ms on a 1400×775 photo).
* Curve simplification (`--simplify <tolerance>`, `Config::simplify`, `simplify` in Python and Node; off by default): a paper.js-style Schneider re-fit — each smooth run between corners is redrawn with the fewest cubics that stay within the tolerance (px). Roughly halves file size (sample photo at tolerance 1: 229 → 138 KB stacked, 103 → 36 KB watershed cutout). Runs on fitted geometry before composition, so cutout simplifies each shared boundary once and stays seam-free; corners and junction endpoints stay pinned.
* Binary thresholding: a tunable fixed threshold (`--threshold`) and BradleyRoth adaptive thresholding for uneven lighting (`--adaptive`, `--adaptive-window`, `--adaptive-t`) — also on `Config`, Python, and Node.
* Cutout mode merges neighbouring faces whose colors are within one gradient step, rejoining the near-identical faces that stacked gradient layering splits a smooth area into.
### Changed
* `color_mode` is replaced by `clustering` (`color-cluster` | `bw` | `watershed`) across the CLI, Rust, Python, and Node — the field selects the region-forming algorithm, not a color space.
* The spline fine-tuning flags (`--corner-threshold`, `--segment-length`, `--splice-threshold`) are hidden from CLI help — still accepted, but without their `-c`/`-l`/`-s` short forms. The defaults serve virtually every conversion; `--simplify` supersedes them.
### Fixed
* Spline fitting no longer swings far away from the outline around thin strands (a long-standing defect, fixed via visioncortex 0.9.1): a sparse splice slice could be fitted by a single cubic that passed through every sample yet ballooned up to ~30 px sideways between them. Slices are now densified before fitting and multi-cubic fits kept in full, in both stacked mode and the mosaic fitter.
## 1.0.0-alpha.1 - 2026-07-24
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
+8 -6
View File
@@ -3,6 +3,7 @@
members = [
"crates/vtracer",
"crates/vtracer-cli",
"crates/vtracer-bench",
]
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
@@ -18,15 +19,16 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.0.0-alpha.1"
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
edition = "2021"
edition = "2024"
license = "MIT OR Apache-2.0"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
[workspace.dependencies]
visioncortex = "0.9"
# For local development against an unreleased visioncortex, add a patch:
# [patch.crates-io]
# visioncortex = { path = "../visioncortex" }
visioncortex = "0.9.1"
# Schneider curve fitting for the simplify pass. visioncortex pins an old
# flo_curves internally for legacy reasons; we depend on the current one
# directly and convert at the call boundary.
flo_curves = "0.8"
+109 -19
View File
@@ -8,15 +8,32 @@
</p>
<h3>
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
<a href="https://github.com/visioncortex/vtracer/releases">Releases</a>
<span> | </span>
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
<span> | </span>
<a href="https://github.com/visioncortex/vtracer/releases">Download</a>
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.3/VTracer_1.0.0-alpha.3_x64-setup.exe">Windows App</a>
</h3>
<p>
<a href="https://crates.io/crates/vtracer"><img src="https://img.shields.io/crates/v/vtracer.svg?label=crates.io" alt="Rust library on crates.io"></a>
<a href="https://pypi.org/project/vtracer/"><img src="https://img.shields.io/pypi/v/vtracer.svg?label=PyPI" alt="Python package on PyPI"></a>
<a href="https://www.npmjs.com/package/@visioncortex/vtracer"><img src="https://img.shields.io/npm/v/@visioncortex/vtracer.svg?label=npm" alt="Node package on npm"></a>
</p>
</div>
## Packages
VTracer 1.0 is a vectorization **framework** (pluggable frontends, curve fitters, color fitting, and output optimization) shipped across four surfaces from this repository:
| Package | Registry | Source | Use |
| --- | --- | --- | --- |
| `vtracer-cli` | [crates.io](https://crates.io/crates/vtracer-cli) | [`crates/vtracer-cli`](crates/vtracer-cli) | Command-line tool (`vtracer` binary) |
| `vtracer` | [crates.io](https://crates.io/crates/vtracer) | [`crates/vtracer`](crates/vtracer) | Rust library / the framework core |
| `vtracer` | [PyPI](https://pypi.org/project/vtracer/) | [`crates/vtracer-py`](crates/vtracer-py) | Python native extension (pyo3 + maturin) |
| `@visioncortex/vtracer` | [npm](https://www.npmjs.com/package/@visioncortex/vtracer) | [`nodejs`](nodejs) | Node.js WebAssembly build, no native dependency |
## Introduction
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
@@ -29,11 +46,20 @@ VTracer is originally designed for processing high resolution scans of historic
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
## Desktop App (coming soon)
## Desktop App
![screenshot](docs/images/screenshot-01.png)
![screenshot](docs/images/desktop-app.png)
![screenshot](docs/images/screenshot-02.png)
The familiar web-app workflow, now on the native 1.0 engine:
+ **Native speed** — the engine runs natively instead of in-browser wasm, so conversions are quicker
+ **A/B comparator** — a sliding split view to check the trace against the original
+ **Curve inspector** — examine the fitted curves up close
+ **Seam-free cutout** — a true gapless tessellation with shared boundaries, no cracks between shapes
+ **Watershed clustering** — content-adaptive regions that follow object shape, with one interactive detail slider
+ **Curve simplification** — the fewest curves within a pixel tolerance, typically halving file size
+ **Adaptive B/W thresholding** — handles scans and photos with uneven lighting
+ **Fixed color palettes** — snap the output to your own colors
## Cmd App
@@ -58,24 +84,33 @@ 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)
-p, --color-precision <COLOR_PRECISION> Significant bits per RGB channel (1..=8)
-g, --gradient-step <GRADIENT_STEP> Color difference between gradient layers (0..=255)
-c, --corner-threshold <CORNER_THRESHOLD> Minimum momentary angle (degrees) to be a corner (0..=180)
-l, --segment-length <SEGMENT_LENGTH> Subdivide until all segments are shorter than this (3.5..=10)
-s, --splice-threshold <SPLICE_THRESHOLD> Minimum angle displacement (degrees) to splice a spline (0..=180)
--simplify <TOLERANCE> Simplify curves: fewest cubics within this tolerance in px (try 12.5)
--path-precision <PATH_PRECISION> Decimal places to use in path coordinates
--palette <PALETTE> Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'
--palette-file <PALETTE_FILE> Fixed palette from a file (hex colors, comma/newline separated)
--max-colors <MAX_COLORS> Auto-quantize to at most N colors
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+cleanup, 2 = + shorthands
--threshold <THRESHOLD> Binary mode: fixed threshold 0..=255 (foreground below it)
--adaptive Binary mode: BradleyRoth adaptive threshold (uneven lighting)
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
--watershed-detail <WATERSHED_DETAIL> Watershed: hierarchy cut level 0..=255 (higher = more regions)
-h, --help Print help
-V, --version Print version
```
The spline fine-tuning flags `--corner-threshold <0..=180>`,
`--segment-length <3.5..=10>`, and `--splice-threshold <0..=180>` are still
accepted but hidden from `--help`: their defaults (60 / 4 / 45) serve
virtually every conversion, and `--simplify` is the knob that actually moves
output size and smoothness.
### New in 1.0
- **Positional arguments** — `vtracer in.png out.svg`.
@@ -83,14 +118,23 @@ Options:
tessellation with shared boundaries), replacing the old re-clustered cutout.
- **`--palette` / `--palette-file`** — snap colors to a fixed palette
(nearest in OKLab); **`--max-colors`** auto-quantizes the palette.
- **`--optimize`** — output size passes (coordinate quantization, redundant-
point removal, relative/shorthand path encoding).
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
**BradleyRoth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
/ `--adaptive-t`) for scans with uneven lighting.
- **`--simplify <tolerance>`** — paper.js-style curve simplification: re-fits
smooth runs with the fewest cubics that stay within the tolerance (px),
typically halving file size; seam-free in cutout mode because shared
boundaries are simplified once for both faces.
- **`--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
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
You can also install the program from source from [crates.io/vtracer](https://crates.io/crates/vtracer):
You can also install the program from source:
```sh
cargo install vtracer-cli
@@ -107,9 +151,15 @@ cargo install vtracer-cli
# black & white line art
./vtracer input.jpg output.svg --preset bw
# scanned/photographed line art with uneven lighting
./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'
```
@@ -119,15 +169,44 @@ cargo install vtracer-cli
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
```sh
cargo add vtracer
cargo add vtracer@1.0.0-alpha.3
```
```rust
use vtracer::{ColorImage, Config, FitMode, Hierarchical, Preset, Session};
// Decode with whatever you like, then hand over pixels.
let raw = image::open("in.png")?.to_rgba8();
let (width, height) = (raw.width() as usize, raw.height() as usize);
let img = ColorImage { pixels: raw.into_raw(), width, height };
// one-liner
let svg = Config::default().build()?.to_svg(&img)?;
// presets + per-field config
let mut cfg = Config::from_preset(Preset::Poster);
cfg.mode = FitMode::Polygon;
cfg.hierarchical = Hierarchical::Cutout; // seam-free mosaic
cfg.max_colors = Some(8);
let svg = cfg.build()?.to_svg(&img)?;
```
Split the pipeline when you want the stages separately — `segment` caches, `finish` re-runs:
```rust
let pipeline = cfg.build()?;
let seg = pipeline.segment(&img)?; // the expensive part
let doc = pipeline.finish(&seg)?; // VectorDoc, ready to serialize
```
See [docs.rs/vtracer](https://docs.rs/vtracer/1.0.0-alpha.3/vtracer/) for the full API.
### Python Library
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension (built with [pyo3](https://github.com/PyO3/pyo3) + [maturin](https://www.maturin.rs), from the `crates/vtracer-py` crate).
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension.
```sh
pip install vtracer
pip install vtracer==1.0.0a3
```
```python
@@ -142,16 +221,24 @@ cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
svg = cfg.convert_bytes(data)
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
# watershed region forming
ws = vtracer.Config(clustering="watershed", watershed_detail=192)
svg = ws.convert_file("photo.jpg", "photo.svg")
# binary with adaptive (BradleyRoth) thresholding
bw = vtracer.Config(clustering="bw", adaptive=True)
svg = bw.convert_file("scan.jpg", "scan.svg")
```
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
### Node.js Library
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**.
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**. Decodes PNG, JPEG, GIF, BMP, and WebP; for other formats, decode yourself and pass raw RGBA to `convertPixels`.
```sh
npm install @visioncortex/vtracer
npm install @visioncortex/vtracer@1.0.0-alpha.3
```
```js
@@ -159,7 +246,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, { clustering: 'bw', adaptive: true });
```
## Citations
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "vtracer-bench"
description = "Blind fidelity benchmark for raster-to-vector tracers: compare the original raster with a rendered reconstruction and get one 0..1 fidelity score built from PSNR, SSIM and a clustered-diff patch metric."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "development-tools::testing"]
keywords = ["vectorization", "benchmark", "fidelity", "ssim", "psnr"]
[dependencies]
visioncortex.workspace = true
dssim-core = "3"
rgb = "0.8"
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
+104
View File
@@ -0,0 +1,104 @@
# vtracer-bench
Blind fidelity benchmark for raster-to-vector tracers.
It compares an **original raster** with a **rendered reconstruction** and reports one number — a fidelity score in **[0, 1]** — built from three complementary axes. It is *blind* in the sense that it knows nothing about how the reconstruction was produced: any tracer, any format, any renderer. Render your vector output to pixels (same dimensions as the original), then let the benchmark judge.
```console
$ vtracer-bench original.png reconstruction.png
psnr 34.77 dB (rmse 4.66) -> 0.6875
ssim 0.99541 (dssim 0.00461) -> 0.9954
patch 157.8 px rms (14503 bad px, 74 clusters, largest 73) -> 0.9726
fidelity 0.9022
[csv] 0.9022,34.77,0.00461,4.66,157.8,0.6875,0.9954,0.9726
```
## Why another metric?
Every classic metric has a blind spot, and tracers exploit all of them:
- **PSNR** over-values invisible dust and undersells small salient regions — a tracer that drops an eye but nails the background can post a great PSNR.
- **SSIM** tracks perceived quality well, but averages globally: a small, fully-lost region barely moves it.
- Neither can tell **a thousand scattered ±1 pixels** apart from **one coherent missing patch** of the same total mass — and the missing patch is the failure that actually matters.
`vtracer-bench` scores all three axes and combines them so that no single blind spot survives:
| axis | raw metric | subscore in [0, 1] |
| --- | --- | --- |
| `psnr` | sRGB PSNR over RGB | `1 log(1+rmse) / log(256)` |
| `ssim` | multiscale DSSIM (`dssim-core`) | `SSIM = 1 / (1 + DSSIM)` |
| `patch` | clustered-diff "missing patch" detector | `2^(P / 0.005)` |
**fidelity = ( psnr¹ · ssim² · patch¹ ) ^ (1/4)** — a *weighted geometric mean*. Geometric, not arithmetic, so a single collapsed axis drags the composite down: a missing face region cannot hide behind good global PSNR. SSIM carries double weight because it tracks visual accuracy best and is the axis most robust to an imperfect source.
## The three axes
### psnr — parameter-free squash
The squash `1 log(1+rmse)/log(256)` is anchored at the only two natural error scales an 8-bit image has:
- `rmse = 255` (the full range — noise indistinguishable from a random image) → **0**
- `rmse ≤ 1` (the quantization step — errors 8-bit can barely represent) → saturates to **1**
For `rmse ≫ 1` it equals `psnr / 48.13 dB`, i.e. it stays linear in decibels, but with no hand-picked anchor constants.
### ssim — perceptual structure
`dssim-core` computes multiscale structural dissimilarity `d = 1/SSIM 1`; the subscore is simply `SSIM = 1/(1+d)`, already a natural 0..1. Differences the eye can't see score ~1 regardless of how many pixels they touch.
### patch — the missing-patch detector
This is the axis PSNR and SSIM both lack:
1. A pixel is **bad** iff its RGB Euclidean distance to the original exceeds `--thresh` (default 24 — roughly 14 per channel).
2. The bad mask is **opened** (one round of 4-connected erode + dilate). A slightly blurred or recompressed *source* shifts every edge and paints ≤2 px filaments along all boundaries; those vanish under the opening, while genuine missing patches survive. This is what makes the benchmark tolerant of mildly compressed or blurred originals.
3. The surviving mask is clustered (4-connected). With cluster areas `aᵢ`, the **patch mass** is `√(Σ aᵢ²)` — a sum of *squares*, so one coherent blob dominates any amount of scattered dust of equal total area.
4. With `P = patch mass / (w·h)`, the subscore is `2^(P/0.005)`: a single coherent blob at 0.5 % of image mass halves the score; scattered dust barely dents it.
## Calibration
Scored on a 768×1024 flat-shaded illustration, comparing the original against distorted versions of **itself** — this is how much slack the benchmark gives an imperfect source, and what the top of the scale means:
| candidate | psnr | ssim | patch | **fidelity** |
| --- | --- | --- | --- | --- |
| the original itself | 1.000 | 1.000 | 1.000 | **1.0000** |
| JPEG quality 95 | 0.816 | 1.000 | 1.000 | **0.9502** |
| JPEG quality 75 | 0.718 | 0.999 | 0.994 | **0.9186** |
| 0.8 px Gaussian blur | 0.596 | 0.995 | 0.861 | **0.8443** |
Rule of thumb: **≥ 0.95** is visually indistinguishable, **≥ 0.90** is a faithful trace, **≤ 0.80** has visible geometry or color errors, and a score that *collapses* while PSNR/SSIM stay high means the patch axis found a coherent missing region — look at the `--mask` output.
## Usage
### CLI
```console
vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
```
- `original`, `candidate` — rasters of identical dimensions (any format `image` decodes). Rendering an SVG to pixels is deliberately out of scope: use the renderer whose output you actually ship (resvg, Chromium, librsvg, …) so the benchmark judges what users see.
- `--thresh N` — RGB Euclidean bad-pixel gate for the patch axis (default 24).
- `--mask out.png` — write the raw bad-pixel mask (before the opening) for visual inspection.
The last stdout line is machine-readable:
```
[csv] fidelity,psnr,dssim,rmse,patch_mass,s_psnr,s_ssim,s_patch
```
(RMSE is reported for reference but carries no weight — it is the same MSE that PSNR measures, only on a linear curve; scoring both would double-weight one error.)
### Library
```rust
use vtracer_bench::{fidelity, DEFAULT_THRESH};
// orig and cand are interleaved RGB8, both w×h
let (report, bad_mask) = fidelity(&orig, &cand, w, h, DEFAULT_THRESH);
println!("fidelity {:.4} (psnr {:.2} dB, dssim {:.5})",
report.fidelity, report.psnr, report.dssim);
```
`FidelityReport` exposes every raw metric and subscore; the tuning constants (`PATCH_HALF`, `DEFAULT_THRESH`, and the `W_PSNR`/`W_SSIM`/`W_PATCH` weights) are public and documented in `lib.rs`.
The benchmark is fully deterministic: identical inputs produce byte-identical output.
+247
View File
@@ -0,0 +1,247 @@
//! Universal tracer fidelity benchmark — original vs reconstruction, blind to
//! how the reconstruction was made. Three raw metrics, each squashed to [0,1],
//! composed by geometric mean into ONE fidelity score (0 = garbage, 1 = exact):
//!
//! psnr sRGB PSNR over RGB. Squash: 1 log(1+rmse)/log(256) — anchored
//! at the two natural scales of 8-bit imagery and nothing else:
//! rmse = 255 (full range) → 0, rmse ≤ 1 (the quantization step)
//! saturates to 1. Equals psnr/48.13dB for rmse ≫ 1, i.e. still
//! linear in dB, without arbitrary anchor constants.
//! ssim dssim-core multiscale DSSIM d (= 1/SSIM 1) → SSIM = 1/(1+d),
//! already a natural 0..1.
//! patch the "missing patch" / systematic-bias detector: bad ⟺ RGB
//! Euclidean diff > thresh, OPEN the bad mask (1-round 4-conn
//! erode+dilate — a slightly blurred or compressed source shifts
//! every edge and paints ≤2px filaments along all boundaries; those
//! vanish, real patches survive), then cluster it (visioncortex,
//! 4-conn), S = Σ area². Patch mass fraction P = √S / (w·h) — the RMS
//! coherent-blob size as a fraction of the image. Squash: 2^(P/0.005),
//! so ONE coherent blob at 0.5% image mass halves the score while the
//! same pixel count scattered as dust barely dents it. Exactly the
//! failure mode PSNR/SSIM average away.
//!
//! Composite: weighted geometric mean, fidelity = (psnr¹ · ssim² · patch¹)^(1/4).
//! Geometric (not arithmetic) so a single collapsed axis drags the composite
//! down — a missing eye can't hide behind good global PSNR. SSIM carries double
//! weight: it tracks visual accuracy best and is the axis most robust to a
//! mildly compressed or blurred source.
use visioncortex::BinaryImage;
/// Patch mass fraction that halves the patch subscore.
pub const PATCH_HALF: f64 = 0.005;
/// Default RGB Euclidean distance for a pixel to count as "bad".
pub const DEFAULT_THRESH: f64 = 24.0;
/// Composite weights (geometric): fidelity = (psnr^1 · ssim^2 · patch^1)^(1/4).
pub const W_PSNR: f64 = 1.0;
pub const W_SSIM: f64 = 2.0;
pub const W_PATCH: f64 = 1.0;
#[derive(Debug, Clone, Copy)]
pub struct FidelityReport {
// raw
pub psnr: f64,
pub dssim: f64,
/// sRGB RMSE — reported for reference, carries no weight (PSNR is the
/// same MSE on a log curve; scoring both would double-weight it)
pub rmse: f64,
/// bad pixels (‖Δrgb‖ > thresh), before the opening
pub bad_px: usize,
/// 4-conn clusters of bad pixels after the opening
pub clusters: usize,
/// largest cluster area (px)
pub largest: usize,
/// √(Σ area²) — RMS coherent-blob mass, in px
pub patch_mass: f64,
// subscores in [0,1]
pub s_psnr: f64,
pub s_ssim: f64,
pub s_patch: f64,
/// geometric mean of the three subscores
pub fidelity: f64,
}
fn dssim_score(a_rgb: &[u8], b_rgb: &[u8], w: usize, h: usize) -> f64 {
let d = dssim_core::Dssim::new();
let to = |buf: &[u8]| {
let px: Vec<rgb::RGB<u8>> =
(0..w * h).map(|i| rgb::RGB { r: buf[i * 3], g: buf[i * 3 + 1], b: buf[i * 3 + 2] }).collect();
d.create_image_rgb(&px, w, h).expect("dssim image")
};
let (val, _) = d.compare(&to(a_rgb), &to(b_rgb));
val.into()
}
/// Compare an original against a candidate reconstruction, both RGB8, w×h.
/// `thresh` is the RGB Euclidean bad-pixel gate (use [`DEFAULT_THRESH`]).
/// Returns the report plus the bad-pixel mask (255/0, one byte per pixel).
pub fn fidelity(orig_rgb: &[u8], cand_rgb: &[u8], w: usize, h: usize, thresh: f64) -> (FidelityReport, Vec<u8>) {
assert_eq!(orig_rgb.len(), w * h * 3);
assert_eq!(cand_rgb.len(), w * h * 3);
// PSNR + RMSE + bad-pixel binarization in one pass
let mut sse = 0f64;
let mut mask = vec![0u8; w * h];
let mut bad_px = 0usize;
let t2 = thresh * thresh;
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let mut d2 = 0f64;
for c in 0..3 {
let e = orig_rgb[i * 3 + c] as f64 - cand_rgb[i * 3 + c] as f64;
d2 += e * e;
}
sse += d2;
if d2 > t2 {
mask[i] = 255;
bad_px += 1;
}
}
}
let rmse = (sse / (w * h * 3) as f64).sqrt();
let psnr = 20.0 * (255.0 / rmse.max(1e-6)).log10();
let dssim = dssim_score(orig_rgb, cand_rgb, w, h);
// opening: 1-round 4-conn erode + dilate. Edge-shift filaments (≤2px wide,
// the signature of a slightly blurred/compressed source) vanish; genuine
// missing patches survive. The reported mask keeps the raw bad pixels.
let at = |m: &[u8], x: i64, y: i64| {
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && m[y as usize * w + x as usize] != 0
};
let mut eroded = vec![0u8; w * h];
for y in 0..h as i64 {
for x in 0..w as i64 {
if at(&mask, x, y)
&& at(&mask, x - 1, y)
&& at(&mask, x + 1, y)
&& at(&mask, x, y - 1)
&& at(&mask, x, y + 1)
{
eroded[y as usize * w + x as usize] = 255;
}
}
}
let mut bin = BinaryImage::new_w_h(w, h);
for y in 0..h as i64 {
for x in 0..w as i64 {
if at(&eroded, x, y)
|| at(&eroded, x - 1, y)
|| at(&eroded, x + 1, y)
|| at(&eroded, x, y - 1)
|| at(&eroded, x, y + 1)
{
bin.set_pixel(x as usize, y as usize, true);
}
}
}
let sizes: Vec<usize> = bin.to_clusters(false).iter().map(|c| c.size()).collect();
let largest = sizes.iter().copied().max().unwrap_or(0);
let patch_mass = if sizes.is_empty() {
0.0
} else {
sizes.iter().map(|&a| (a as f64) * (a as f64)).sum::<f64>().sqrt()
};
let p_frac = patch_mass / (w * h) as f64;
let s_psnr = 1.0 - (1.0 + rmse).ln() / 256f64.ln();
let s_ssim = 1.0 / (1.0 + dssim);
let s_patch = (-p_frac / PATCH_HALF * std::f64::consts::LN_2).exp();
let fidelity = (s_psnr.powf(W_PSNR) * s_ssim.powf(W_SSIM) * s_patch.powf(W_PATCH))
.powf(1.0 / (W_PSNR + W_SSIM + W_PATCH));
(
FidelityReport {
psnr,
dssim,
rmse,
bad_px,
clusters: sizes.len(),
largest,
patch_mass,
s_psnr,
s_ssim,
s_patch,
fidelity,
},
mask,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(w: usize, h: usize, c: [u8; 3]) -> Vec<u8> {
(0..w * h).flat_map(|_| c).collect()
}
#[test]
fn identical_is_one() {
let a = flat(64, 64, [120, 90, 200]);
let (r, mask) = fidelity(&a, &a, 64, 64, DEFAULT_THRESH);
assert_eq!(r.bad_px, 0);
assert!(mask.iter().all(|&m| m == 0));
assert!((r.fidelity - 1.0).abs() < 1e-9, "fidelity {}", r.fidelity);
}
#[test]
fn coherent_patch_scores_below_scattered_dust() {
// same 256 bad pixels: one 16×16 blob vs isolated singles on a 64×64 grid
let clean = flat(64, 64, [200, 200, 200]);
let mut blob = clean.clone();
for y in 24..40 {
for x in 24..40 {
blob[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
}
let mut dust = clean.clone();
for k in 0..256 {
let (x, y) = ((k % 16) * 4, (k / 16) * 4); // 4px spacing: 256 singleton clusters
dust[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
let (rb, _) = fidelity(&clean, &blob, 64, 64, DEFAULT_THRESH);
let (rd, _) = fidelity(&clean, &dust, 64, 64, DEFAULT_THRESH);
assert_eq!(rb.bad_px, 256);
assert_eq!(rd.bad_px, 256);
// dust vanishes under the opening entirely; the blob survives
assert_eq!(rb.clusters, 1);
assert_eq!(rd.clusters, 0);
assert!((rd.s_patch - 1.0).abs() < 1e-9);
// identical PSNR/RMSE by construction; the patch axis must separate them
assert!((rb.rmse - rd.rmse).abs() < 1e-9);
assert!(rb.s_patch < rd.s_patch * 0.25, "blob {} dust {}", rb.s_patch, rd.s_patch);
assert!(rb.fidelity < rd.fidelity);
}
#[test]
fn edge_shift_filaments_are_tolerated() {
// a slightly blurred/compressed source shifts edges: thin bad-px lines
// along boundaries. A 2px-wide full-width filament (256 px) must open
// away; the same mass as a compact blob must not.
let clean = flat(64, 64, [200, 200, 200]);
let mut fil = clean.clone();
for y in 30..32 {
for x in 0..64 {
fil[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
}
let (rf, _) = fidelity(&clean, &fil, 64, 64, DEFAULT_THRESH);
assert_eq!(rf.bad_px, 128);
assert_eq!(rf.clusters, 0);
assert!((rf.s_patch - 1.0).abs() < 1e-9, "filament must not count as a patch");
}
#[test]
fn worse_is_lower() {
let a = flat(32, 32, [100, 100, 100]);
let mild: Vec<u8> = a.iter().map(|&v| v + 4).collect();
let harsh: Vec<u8> = a.iter().map(|&v| v + 60).collect();
let (rm, _) = fidelity(&a, &mild, 32, 32, DEFAULT_THRESH);
let (rh, _) = fidelity(&a, &harsh, 32, 32, DEFAULT_THRESH);
assert!(rm.fidelity > rh.fidelity);
assert!(rh.fidelity < 0.4, "harsh {}", rh.fidelity);
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Blind fidelity benchmark for raster-to-vector tracers.
//!
//! vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
//!
//! Both arguments are rasters of identical dimensions — rendering a vector
//! reconstruction to pixels is the caller's responsibility. Prints the raw
//! metrics, their [0,1] subscores, the composite fidelity, and a
//! machine-readable csv line.
use vtracer_bench::{fidelity, DEFAULT_THRESH};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]");
std::process::exit(2);
}
let mut thresh = DEFAULT_THRESH;
let mut mask_out: Option<String> = None;
let mut i = 3;
while i < args.len() {
match args[i].as_str() {
"--thresh" => {
i += 1;
thresh = args[i].parse().expect("--thresh N");
}
"--mask" => {
i += 1;
mask_out = Some(args[i].clone());
}
a => {
eprintln!("unknown flag {a}");
std::process::exit(2);
}
}
i += 1;
}
let orig = image::open(&args[1]).expect("open original").to_rgb8();
let (w, h) = (orig.width() as usize, orig.height() as usize);
let img = image::open(&args[2]).expect("open candidate").to_rgb8();
assert_eq!(
(img.width() as usize, img.height() as usize),
(w, h),
"candidate raster must match original dimensions"
);
let cand: Vec<u8> = img.into_raw();
let (r, mask) = fidelity(orig.as_raw(), &cand, w, h, thresh);
if let Some(out) = mask_out {
image::GrayImage::from_raw(w as u32, h as u32, mask).unwrap().save(&out).expect("save mask");
}
println!("psnr {:>8.2} dB (rmse {:.2}) -> {:.4}", r.psnr, r.rmse, r.s_psnr);
println!("ssim {:>8.5} (dssim {:.5}) -> {:.4}", r.s_ssim, r.dssim, r.s_ssim);
println!(
"patch {:>8.1} px rms ({} bad px, {} clusters, largest {}) -> {:.4}",
r.patch_mass, r.bad_px, r.clusters, r.largest, r.s_patch
);
println!("fidelity {:.4}", r.fidelity);
println!(
"[csv] {:.4},{:.2},{:.5},{:.2},{:.1},{:.4},{:.4},{:.4}",
r.fidelity, r.psnr, r.dssim, r.rmse, r.patch_mass, r.s_psnr, r.s_ssim, r.s_patch
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ name = "vtracer"
path = "src/main.rs"
[dependencies]
vtracer = { version = "1.0.0-alpha.1", path = "../vtracer" }
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
visioncortex.workspace = true
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = [
+75 -15
View File
@@ -9,7 +9,7 @@ use std::process::ExitCode;
use clap::Parser;
use visioncortex::{Color, ColorImage};
use vtracer::{ColorMode, Config, FitMode, Hierarchical, Preset};
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
/// Convert an image into vector graphics.
#[derive(Parser, Debug)]
@@ -35,9 +35,9 @@ struct Args {
#[arg(long)]
preset: Option<Preset>,
/// Color image (`color`) or binary image (`bw`).
#[arg(long = "colormode")]
colormode: Option<ColorMode>,
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
#[arg(long)]
clustering: Option<Clustering>,
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
#[arg(long)]
@@ -60,17 +60,30 @@ struct Args {
gradient_step: Option<i64>,
/// Minimum momentary angle (degrees) to be a corner (0..=180).
#[arg(short = 'c', long, value_parser = clap::value_parser!(i64).range(0..=180))]
///
/// Hidden from help: a fine-tuning knob few conversions need — the
/// default (60) serves; `--simplify` is the knob worth reaching for.
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
corner_threshold: Option<i64>,
/// Subdivide until all segments are shorter than this length (3.5..=10).
#[arg(short = 'l', long, value_parser = parse_segment_length)]
///
/// Hidden from help: with `--simplify` reducing anchors by an explicit
/// error tolerance, this legacy knob's effect on output is negligible.
#[arg(long, hide = true, value_parser = parse_segment_length)]
segment_length: Option<f64>,
/// Minimum angle displacement (degrees) to splice a spline (0..=180).
#[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))]
///
/// Hidden from help: a fine-tuning knob few conversions need — the
/// default (45) serves; `--simplify` is the knob worth reaching for.
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
splice_threshold: Option<i64>,
/// Simplify curves: fewest cubics within this tolerance in px (try 1-2.5).
#[arg(long, value_name = "TOLERANCE", value_parser = parse_simplify_tolerance)]
simplify: Option<f64>,
/// Decimal places to use in path coordinates.
#[arg(long)]
path_precision: Option<u32>,
@@ -87,15 +100,41 @@ struct Args {
#[arg(long)]
max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
optimize: Option<u8>,
/// Binary mode: fixed threshold (0..=255); foreground when intensity is below it.
#[arg(long, value_parser = clap::value_parser!(u8))]
threshold: Option<u8>,
/// Binary mode: use BradleyRoth adaptive thresholding (handles uneven lighting).
#[arg(long)]
adaptive: bool,
/// Adaptive window side length in px (0 = auto). Implies --adaptive.
#[arg(long)]
adaptive_window: Option<u32>,
/// 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_simplify_tolerance(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
if !v.is_finite() || v <= 0.0 {
return Err(format!("simplify tolerance {v} must be positive"));
}
Ok(v)
}
fn parse_segment_length(s: &str) -> Result<f64, String> {
let v: f64 = s
.parse()
.map_err(|_| format!("`{s}` is not a number"))?;
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
if !(3.5..=10.0).contains(&v) {
return Err(format!("segment length {v} is out of range [3.5, 10]"));
}
@@ -132,8 +171,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;
@@ -159,6 +198,9 @@ fn build_config(args: &Args) -> Result<Config, String> {
if let Some(v) = args.splice_threshold {
config.splice_threshold = v as i32;
}
if args.simplify.is_some() {
config.simplify = args.simplify;
}
if args.path_precision.is_some() {
config.path_precision = args.path_precision;
}
@@ -169,12 +211,30 @@ fn build_config(args: &Args) -> Result<Config, String> {
config.max_colors = Some(v);
}
// Binary thresholding: --adaptive (or either adaptive tuning flag) selects
// BradleyRoth; otherwise --threshold tunes the fixed cutoff.
if let Some(v) = args.threshold {
config.binary_threshold = v;
}
if args.adaptive || args.adaptive_window.is_some() || args.adaptive_t.is_some() {
config.binary_adaptive = true;
}
if let Some(v) = args.adaptive_window {
config.binary_adaptive_window = v;
}
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 {
config.palette = parse_palette(text)?;
} else if let Some(path) = &args.palette_file {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read palette file: {e}"))?;
let text =
std::fs::read_to_string(path).map_err(|e| format!("cannot read palette file: {e}"))?;
config.palette = parse_palette(&text)?;
}
+4 -3
View File
@@ -1,10 +1,11 @@
[package]
name = "vtracer-py"
description = "Python bindings for the vtracer vectorization framework."
version = "1.0.0-alpha.1"
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2021"
edition = "2024"
license = "MIT OR Apache-2.0"
readme = "README.md"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
@@ -18,7 +19,7 @@ name = "vtracer"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.1", path = "../vtracer" }
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = [
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
+9 -3
View File
@@ -8,7 +8,7 @@ this crate adds image decoding and a Pythonic API.
## Install
```sh
pip install vtracer
pip install vtracer==1.0.0a3
```
## Usage
@@ -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 |
@@ -51,10 +51,16 @@ properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| `length_threshold` | `4.0` | px |
| `max_iterations` | `10` | |
| `splice_threshold` | `45` | degrees |
| `simplify` | `None` | curve simplification tolerance in px (try 12.5) |
| `path_precision` | `2` | output decimal places |
| `palette` | `None` | list of `#rrggbb` strings |
| `max_colors` | `None` | auto-quantize target |
| `optimize` | `1` | `0` off, `1` quantize+simplify, `2` + shorthands |
| `optimize` | `1` | `0` off, `1` quantize+cleanup, `2` + shorthands |
| `binary_threshold` | `128` | bw: fixed cutoff, foreground below it |
| `adaptive` | `False` | bw: BradleyRoth adaptive thresholding |
| `adaptive_window` | `0` | bw adaptive: window px (`0` = auto) |
| `adaptive_t` | `15.0` | bw adaptive: % below local mean |
| `watershed_detail` | `128` | watershed: hierarchy cut level 0..=255 |
Each `Config` has `convert_file(input, output)`, `convert_bytes(data, format=None) -> str`,
and `convert_pixels(rgba, width, height) -> str`.
+1
View File
@@ -5,6 +5,7 @@ build-backend = "maturin"
[project]
name = "vtracer"
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT OR Apache-2.0" }
authors = [{ name = "Chris Tsang", email = "tyt2y7@gmail.com" }]
+118 -25
View File
@@ -27,7 +27,9 @@ use std::path::PathBuf;
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use ::vtracer::{Color, ColorImage, ColorMode, Config as CoreConfig, FitMode, Hierarchical, Preset};
use ::vtracer::{
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
};
// --- string <-> enum helpers -------------------------------------------------
@@ -35,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",
}
}
@@ -127,7 +130,7 @@ impl PyConfig {
impl PyConfig {
#[new]
#[pyo3(signature = (
color_mode = "color",
clustering = "color-cluster",
hierarchical = "stacked",
mode = "spline",
filter_speckle = 4,
@@ -137,14 +140,20 @@ impl PyConfig {
length_threshold = 4.0,
max_iterations = 10,
splice_threshold = 45,
simplify = None,
path_precision = 2,
palette = None,
max_colors = None,
optimize = 1,
binary_threshold = 128,
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,
@@ -154,10 +163,16 @@ impl PyConfig {
length_threshold: f64,
max_iterations: usize,
splice_threshold: i32,
simplify: Option<f64>,
path_precision: u32,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: u8,
binary_threshold: u8,
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<_>>()?,
@@ -165,7 +180,7 @@ impl PyConfig {
};
Ok(Self {
inner: CoreConfig {
color_mode: parse(color_mode)?,
clustering: parse(clustering)?,
hierarchical: parse(hierarchical)?,
mode: parse(mode)?,
filter_speckle,
@@ -175,10 +190,16 @@ impl PyConfig {
length_threshold,
max_iterations,
splice_threshold,
simplify,
path_precision: Some(path_precision),
palette,
max_colors,
optimize,
binary_threshold,
binary_adaptive: adaptive,
binary_adaptive_window: adaptive_window,
binary_adaptive_t: adaptive_t,
watershed_detail,
},
})
}
@@ -186,33 +207,48 @@ impl PyConfig {
/// Preset for black & white line art.
#[staticmethod]
fn bw() -> Self {
Self { inner: CoreConfig::from_preset(Preset::Bw) }
Self {
inner: CoreConfig::from_preset(Preset::Bw),
}
}
/// Preset for posterized color art.
#[staticmethod]
fn poster() -> Self {
Self { inner: CoreConfig::from_preset(Preset::Poster) }
Self {
inner: CoreConfig::from_preset(Preset::Poster),
}
}
/// Preset tuned for photographs.
#[staticmethod]
fn photo() -> Self {
Self { inner: CoreConfig::from_preset(Preset::Photo) }
Self {
inner: CoreConfig::from_preset(Preset::Photo),
}
}
// --- 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)
@@ -296,6 +332,15 @@ impl PyConfig {
self.inner.splice_threshold = v;
}
#[getter]
fn simplify(&self) -> Option<f64> {
self.inner.simplify
}
#[setter]
fn set_simplify(&mut self, v: Option<f64>) {
self.inner.simplify = v;
}
#[getter]
fn path_precision(&self) -> Option<u32> {
self.inner.path_precision
@@ -307,7 +352,11 @@ impl PyConfig {
#[getter]
fn palette(&self) -> Vec<String> {
self.inner.palette.iter().map(Color::to_hex_string).collect()
self.inner
.palette
.iter()
.map(Color::to_hex_string)
.collect()
}
#[setter]
fn set_palette(&mut self, v: Vec<String>) -> PyResult<()> {
@@ -333,15 +382,53 @@ impl PyConfig {
self.inner.optimize = v;
}
#[getter]
fn binary_threshold(&self) -> u8 {
self.inner.binary_threshold
}
#[setter]
fn set_binary_threshold(&mut self, v: u8) {
self.inner.binary_threshold = v;
}
#[getter]
fn adaptive(&self) -> bool {
self.inner.binary_adaptive
}
#[setter]
fn set_adaptive(&mut self, v: bool) {
self.inner.binary_adaptive = v;
}
#[getter]
fn adaptive_window(&self) -> u32 {
self.inner.binary_adaptive_window
}
#[setter]
fn set_adaptive_window(&mut self, v: u32) {
self.inner.binary_adaptive_window = v;
}
#[getter]
fn adaptive_t(&self) -> f64 {
self.inner.binary_adaptive_t
}
#[setter]
fn set_adaptive_t(&mut self, v: f64) {
self.inner.binary_adaptive_t = v;
}
// --- conversion ---
/// Trace the image at `input_path` and write the SVG to `output_path`.
fn convert_file(&self, input_path: PathBuf, output_path: PathBuf) -> PyResult<()> {
let img = image::open(&input_path)
.map_err(|e| PyIOError::new_err(format!("cannot open `{}`: {e}", input_path.display())))?;
let img = image::open(&input_path).map_err(|e| {
PyIOError::new_err(format!("cannot open `{}`: {e}", input_path.display()))
})?;
let svg = self.to_svg(&dynimg_to_color(img))?;
std::fs::write(&output_path, svg)
.map_err(|e| PyIOError::new_err(format!("cannot write `{}`: {e}", output_path.display())))
std::fs::write(&output_path, svg).map_err(|e| {
PyIOError::new_err(format!("cannot write `{}`: {e}", output_path.display()))
})
}
/// Trace encoded image `data` (png/jpg/...) and return the SVG string.
@@ -370,11 +457,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,
@@ -402,7 +489,9 @@ fn convert_file(
output_path: PathBuf,
config: Option<PyConfig>,
) -> PyResult<()> {
config.unwrap_or_else(default_config).convert_file(input_path, output_path)
config
.unwrap_or_else(default_config)
.convert_file(input_path, output_path)
}
/// Convert encoded image bytes to an SVG string, using `config` (or defaults).
@@ -413,7 +502,9 @@ fn convert_bytes(
config: Option<PyConfig>,
format: Option<&str>,
) -> PyResult<String> {
config.unwrap_or_else(default_config).convert_bytes(data, format)
config
.unwrap_or_else(default_config)
.convert_bytes(data, format)
}
/// Convert a raw RGBA8 buffer to an SVG string, using `config` (or defaults).
@@ -425,7 +516,9 @@ fn convert_pixels(
height: usize,
config: Option<PyConfig>,
) -> PyResult<String> {
config.unwrap_or_else(default_config).convert_pixels(rgba, width, height)
config
.unwrap_or_else(default_config)
.convert_pixels(rgba, width, height)
}
fn default_config() -> PyConfig {
+14 -2
View File
@@ -8,7 +8,7 @@ class Config:
def __init__(
self,
color_mode: str = "color", # "color" | "bw"
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
mode: str = "spline", # "pixel" | "polygon" | "spline"
filter_speckle: int = 4,
@@ -18,10 +18,16 @@ class Config:
length_threshold: float = 4.0,
max_iterations: int = 10,
splice_threshold: int = 45,
simplify: Optional[float] = None, # curve simplification tolerance in px (None = off)
path_precision: int = 2,
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
max_colors: Optional[int] = None, # auto-quantize target
optimize: int = 1, # 0 | 1 | 2
binary_threshold: int = 128, # bw: fixed cutoff 0..=255
adaptive: bool = False, # bw: BradleyRoth adaptive
adaptive_window: int = 0, # bw adaptive: window px (0 = auto)
adaptive_t: float = 15.0, # bw adaptive: % below local mean
watershed_detail: int = 128, # watershed: cut level 0..=255
) -> None: ...
@staticmethod
@@ -31,7 +37,7 @@ class Config:
@staticmethod
def photo() -> "Config": ...
color_mode: str
clustering: str
hierarchical: str
mode: str
filter_speckle: int
@@ -41,10 +47,16 @@ class Config:
length_threshold: float
max_iterations: int
splice_threshold: int
simplify: Optional[float]
path_precision: Optional[int]
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: ...
+4
View File
@@ -9,6 +9,7 @@ homepage.workspace = true
repository.workspace = true
categories = ["graphics", "computer-vision"]
keywords = ["svg", "vectorization", "computer-graphics"]
readme = "../../README.md"
[lib]
name = "vtracer"
@@ -16,8 +17,11 @@ path = "src/lib.rs"
[dependencies]
visioncortex.workspace = true
flo_curves.workspace = true
[dev-dependencies]
# Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not
# compiled for wasm targets, so the library stays wasm-safe.
resvg = "0.45"
# Decode the sample photo for the spline-fitting regression test. Test-only.
image = { version = "0.25", default-features = false, features = ["jpeg"] }
+2
View File
@@ -0,0 +1,2 @@
# This crate is hand-formatted; a stray `cargo fmt` must not rewrite it.
disable_all_formatting = true
+25 -7
View File
@@ -1,4 +1,4 @@
use crate::ir::{Layer, Segmentation};
use crate::ir::{Layer, RegionMask, Segmentation};
use super::ColorFitter;
@@ -8,21 +8,39 @@ use super::ColorFitter;
#[derive(Debug, Clone, Default)]
pub struct MergeAdjacent;
/// Collapse one run of same-paint layers and push the result.
///
/// The whole run is unioned in a single pass — folding pairwise would reallocate
/// and rewrite a canvas-sized accumulator once per layer. See
/// [`RegionMask::union_all`].
fn flush(run: &mut Vec<Layer>, out: &mut Vec<Layer>) {
match run.len() {
0 => {}
1 => out.push(run.pop().expect("run is non-empty")),
_ => {
let paint = run[0].paint;
let masks: Vec<&RegionMask> = run.iter().map(|l| &l.mask).collect();
let mask = RegionMask::union_all(&masks);
out.push(Layer { paint, mask });
run.clear();
}
}
}
impl ColorFitter for MergeAdjacent {
fn fit(&self, seg: &mut Segmentation) {
if seg.layers.len() < 2 {
return;
}
let mut merged: Vec<Layer> = Vec::with_capacity(seg.layers.len());
let mut run: Vec<Layer> = Vec::new();
for layer in seg.layers.drain(..) {
if let Some(last) = merged.last_mut() {
if last.paint == layer.paint {
last.mask = last.mask.union(&layer.mask);
continue;
}
if run.first().is_some_and(|first| first.paint != layer.paint) {
flush(&mut run, &mut merged);
}
merged.push(layer);
run.push(layer);
}
flush(&mut run, &mut merged);
seg.layers = merged;
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
//!
//! * **Stacked** — each layer is traced independently into closed outlines and
//! stacked in paint order (painter's algorithm).
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
//!
//! Both compositors run the pipeline's [`CurvePass`]es over every fitted
//! contour before assembling paths — geometry passes have to happen here, on
//! the fitted geometry, so that in mosaic mode each shared boundary segment
//! is transformed exactly once for both of its faces.
use crate::error::Error;
use crate::fitter::CurveFitter;
use crate::ir::{MultiPath, RegionMask, Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, SegmentFitter};
use crate::progress::{Ctx, Phase};
use crate::simplify::CurvePass;
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
/// Independent per-region closed outlines, stacked bottom-to-top.
Stacked(Box<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic {
fitter: Box<dyn SegmentFitter>,
/// Merge flattened neighbours whose colors are within this diff —
/// rejoins regions the stacked gradient layering had split. Usually
/// the clustering gradient step; `0` still merges identical-color
/// neighbours, negative disables merging entirely.
merge_diff: i32,
},
}
impl Compositing {
/// Run the selected compositor over a segmentation, applying `passes` to
/// every fitted contour before paths are assembled.
pub fn compose(&self, seg: &Segmentation, passes: &[Box<dyn CurvePass>]) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref(), passes),
Compositing::Mosaic { fitter, merge_diff } => {
compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes)
}
}
}
/// Progress- and cancellation-aware compositing.
///
/// Stacked mode reports per-layer progress and can be cancelled between
/// layers. Mosaic builds its boundary graph in one pass, so it reports
/// coarsely (start/end) and is cancellable only at the boundaries — the
/// dominant cost is upstream in clustering, which cancels finely.
pub fn compose_with(
&self,
seg: &Segmentation,
passes: &[Box<dyn CurvePass>],
ctx: &mut Ctx,
) -> Result<VectorDoc, Error> {
match self {
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), passes, ctx),
Compositing::Mosaic { fitter, merge_diff } => {
ctx.check()?;
ctx.report(Phase::Compose, 0.0);
let doc = compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes);
ctx.check()?;
ctx.report(Phase::Compose, 1.0);
Ok(doc)
}
}
}
}
/// Fit one region's outlines and run the curve passes over each contour.
/// Stacked contours are closed rings, so the ring form of each pass applies.
fn fit_region(
fitter: &dyn CurveFitter,
mask: &RegionMask,
passes: &[Box<dyn CurvePass>],
) -> MultiPath {
let mut path = MultiPath::new();
for mut geom in fitter.fit_region(mask) {
for pass in passes {
geom = pass.ring(geom);
}
path.push(geom.into_closed_subpath());
}
path
}
/// Progress-aware [`compose_stacked`]: reports after each layer and checks for
/// cancellation between them.
fn compose_stacked_with(
seg: &Segmentation,
fitter: &dyn CurveFitter,
passes: &[Box<dyn CurvePass>],
ctx: &mut Ctx,
) -> Result<VectorDoc, Error> {
let mut doc = VectorDoc::new(seg.width, seg.height);
let total = seg.layers.len().max(1);
for (i, layer) in seg.layers.iter().enumerate() {
ctx.check()?;
let path = fit_region(fitter, &layer.mask, passes);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
ctx.report(Phase::Compose, (i + 1) as f32 / total as f32);
}
Ok(doc)
}
/// Trace every layer's closed outline and stack the shapes in paint order.
pub fn compose_stacked(
seg: &Segmentation,
fitter: &dyn CurveFitter,
passes: &[Box<dyn CurvePass>],
) -> VectorDoc {
let mut doc = VectorDoc::new(seg.width, seg.height);
for layer in &seg.layers {
let path = fit_region(fitter, &layer.mask, passes);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
}
doc
}
-43
View File
@@ -1,43 +0,0 @@
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
//!
//! * **Stacked** — each layer is traced independently into closed outlines and
//! stacked in paint order (painter's algorithm).
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
use crate::fitter::CurveFitter;
use crate::ir::{Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, SegmentFitter};
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
/// Independent per-region closed outlines, stacked bottom-to-top.
Stacked(Box<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic(Box<dyn SegmentFitter>),
}
impl Compositing {
/// Run the selected compositor over a segmentation.
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
Compositing::Mosaic(fitter) => compose_mosaic(seg, fitter.as_ref()),
}
}
}
/// Trace every layer's closed outline and stack the shapes in paint order.
pub fn compose_stacked(seg: &Segmentation, fitter: &dyn CurveFitter) -> VectorDoc {
let mut doc = VectorDoc::new(seg.width, seg.height);
for layer in &seg.layers {
let path = fitter.fit_region(&layer.mask);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
}
doc
}
+137 -23
View File
@@ -8,18 +8,26 @@ 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};
use crate::frontend::{
BinaryFrontend, ColorClusterFrontend, Frontend, Threshold, WatershedFrontend,
};
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
use crate::optimize::{CleanupPass, OptimizerPass, QuantizePass};
use crate::pipeline::Pipeline;
use crate::simplify::{CurvePass, SimplifyCurves};
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)]
@@ -43,11 +51,29 @@ pub enum Preset {
Photo,
}
/// The clustering-relevant projection of a [`Config`]. Two configs with equal
/// keys produce the same [`Segmentation`](crate::Segmentation), so a cached one
/// stays valid — this is what [`Session`](crate::Session) compares to decide
/// whether to re-segment. Kept in sync with [`Config::frontend`] in one place.
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentKey {
clustering: Clustering,
color_precision: i32,
layer_difference: i32,
filter_speckle: usize,
binary_threshold: u8,
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,
@@ -63,20 +89,38 @@ pub struct Config {
pub max_iterations: usize,
/// Splice threshold in degrees.
pub splice_threshold: i32,
/// Curve simplification tolerance in px (paper.js-style `simplify`):
/// re-fit smooth runs of fitted cubics with the fewest curves that stay
/// within this distance, keeping corners in place. `None` = off. Only
/// affects spline mode; pixel/polygon polylines pass through untouched.
pub simplify: Option<f64>,
/// Coordinate precision (decimal places) for output.
pub path_precision: Option<u32>,
/// Fixed palette (empty = none). Takes priority over `max_colors`.
pub palette: Vec<Color>,
/// Auto-quantize target color count (None = off).
pub max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
pub optimize: u8,
/// Binary-mode fixed threshold (0..=255): foreground when grayscale
/// intensity is below this. Ignored when `binary_adaptive` is set.
pub binary_threshold: u8,
/// Binary mode: use BradleyRoth adaptive thresholding instead of the fixed
/// cutoff (better for uneven lighting).
pub binary_adaptive: bool,
/// Adaptive window side length in pixels; 0 = auto (~1/8 of the shorter side).
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,
@@ -86,10 +130,16 @@ impl Default for Config {
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
simplify: None,
path_precision: Some(2),
palette: Vec::new(),
max_colors: None,
optimize: 1,
binary_threshold: 128,
binary_adaptive: false,
binary_adaptive_window: 0,
binary_adaptive_t: 15.0,
watershed_detail: 128,
}
}
}
@@ -98,16 +148,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,
@@ -127,21 +175,39 @@ impl Config {
}
fn frontend(&self) -> Box<dyn Frontend> {
let filter_speckle_area = self.filter_speckle * self.filter_speckle;
match self.color_mode {
ColorMode::Color => Box::new(ColorClusterFrontend {
filter_speckle_area,
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 => Box::new(BinaryFrontend {
filter_speckle_area,
threshold: 128,
diagonal: false,
Clustering::Binary => {
let threshold = if self.binary_adaptive {
Threshold::Adaptive {
window: self.binary_adaptive_window,
t: self.binary_adaptive_t,
}
} else {
Threshold::Fixed(self.binary_threshold)
};
Box::new(BinaryFrontend {
threshold,
diagonal: false,
min_area: self.speckle_area(),
})
}
Clustering::Watershed => Box::new(WatershedFrontend {
detail: self.watershed_detail,
min_area: self.speckle_area(),
}),
}
}
/// Speckle filter area (px), fed to the frontend.
pub(crate) fn speckle_area(&self) -> usize {
self.filter_speckle * self.filter_speckle
}
fn color_fitters(&self) -> Vec<Box<dyn ColorFitter>> {
if !self.palette.is_empty() {
vec![
@@ -177,6 +243,16 @@ impl Config {
}
}
fn curve_passes(&self) -> Vec<Box<dyn CurvePass>> {
match self.simplify {
Some(tolerance) if tolerance > 0.0 => vec![Box::new(SimplifyCurves {
tolerance,
corner_threshold: deg2rad(self.corner_threshold),
})],
_ => Vec::new(),
}
}
fn optimizers(&self) -> Vec<Box<dyn OptimizerPass>> {
if self.optimize == 0 {
return Vec::new();
@@ -184,7 +260,7 @@ impl Config {
let precision = self.path_precision.unwrap_or(2);
vec![
Box::new(QuantizePass::new(precision)),
Box::new(SimplifyPass),
Box::new(CleanupPass),
]
}
@@ -208,17 +284,54 @@ impl Config {
}
}
/// The clustering-relevant subset of this config. Changing any field it
/// 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 {
clustering: self.clustering,
color_precision: self.color_precision,
layer_difference: self.layer_difference,
filter_speckle: self.filter_speckle,
binary_threshold: self.binary_threshold,
binary_adaptive: self.binary_adaptive,
binary_adaptive_window: self.binary_adaptive_window,
binary_adaptive_t: self.binary_adaptive_t,
watershed_detail: self.watershed_detail,
}
}
/// Assemble a concrete pipeline from this configuration.
pub fn build(&self) -> Result<Pipeline, Error> {
let compositing = match self.hierarchical {
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
Hierarchical::Cutout => Compositing::Mosaic(self.segment_fitter()),
Hierarchical::Cutout => Compositing::Mosaic {
fitter: self.segment_fitter(),
// Rejoin flattened neighbours the clustering split too finely.
// Color clustering considers colors within one gradient step
// to be the same region (`deepen_diff`), so that is its
// tolerance. The watershed dial has no color units (it
// targets a region *count*), so its tolerance is anchored
// instead: at the default detail (128) it matches the
// color-cluster default gradient step (16) and grows linearly
// as detail drops; the floor keeps faces a human cannot tell
// apart (within a just-noticeable difference) from surviving
// as separate patches even at maximum detail.
merge_diff: match self.clustering {
Clustering::Watershed => ((255 - self.watershed_detail as i32) / 8).max(2),
_ => self.layer_difference,
},
},
};
Ok(Pipeline {
frontend: self.frontend(),
color_fitters: self.color_fitters(),
compositing,
curve_passes: self.curve_passes(),
optimizers: self.optimizers(),
writer: self.writer(),
})
@@ -229,13 +342,14 @@ fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
impl FromStr for ColorMode {
impl FromStr for Clustering {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color" => Ok(Self::Color),
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
"binary" | "bw" | "BW" => Ok(Self::Binary),
_ => Err(format!("unknown color mode {s}")),
"watershed" => Ok(Self::Watershed),
_ => Err(format!("unknown clustering {s}")),
}
}
}
+3
View File
@@ -9,6 +9,8 @@ pub enum Error {
NoKeyColor,
/// A requested feature is recognized but not yet implemented.
Unsupported(String),
/// The run was aborted via a [`crate::progress::CancelToken`].
Cancelled,
/// Any other failure, carrying a human-readable message.
Other(String),
}
@@ -21,6 +23,7 @@ impl fmt::Display for Error {
write!(f, "unable to find an unused color in image to use as key")
}
Error::Unsupported(what) => write!(f, "unsupported: {what}"),
Error::Cancelled => write!(f, "conversion cancelled"),
Error::Other(msg) => write!(f, "{msg}"),
}
}
@@ -1,22 +1,44 @@
//! Curve fitters: turn a region's pixel mask into vector outlines.
//!
//! The three built-ins wrap the corresponding visioncortex tracing modes and
//! emit our [`MultiPath`] IR in absolute (document) coordinates:
//! emit [`FittedGeom`] contours in absolute (document) coordinates:
//!
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
//! * [`PolygonFitter`] — staircase-symmetric DouglasPeucker polygon.
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
//!
//! All three trace *closed* region outlines (outer ring plus holes). Open
//! polyline fitting (needed for the mosaic compositor) will arrive with that
//! milestone.
//! All three trace *closed* region outlines (outer ring plus holes). The
//! mosaic compositor fits open boundary segments instead; see
//! [`crate::mosaic::SegmentFitter`].
use visioncortex::clusters::Cluster as BinaryCluster;
use visioncortex::{
CompoundPath, CompoundPathElement, PathSimplifyMode, PointF64, PointI32,
};
use crate::ir::{MultiPath, PathCmd, RegionMask, SubPath};
use crate::ir::{PathCmd, RegionMask, SubPath};
/// Fitted geometry for one contour — the common currency between the curve
/// fitters, the [`CurvePass`](crate::simplify::CurvePass) stage, and
/// composition. The stacked fitters produce one per closed outline; the
/// mosaic fitters produce one per shared boundary segment.
#[derive(Clone, Debug)]
pub enum FittedGeom {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
impl FittedGeom {
/// Convert one closed contour into a `MoveTo … Close` subpath.
pub fn into_closed_subpath(self) -> SubPath {
match self {
FittedGeom::Polyline(points) => polyline_subpath(&points),
FittedGeom::Beziers(chain) => beziers_subpath(&chain),
}
}
}
/// Fitting parameters shared by the built-in fitters. Only the spline fitter
/// consults the smoothing/splice fields.
@@ -43,9 +65,10 @@ impl Default for FitParams {
}
}
/// A curve fitter traces a region mask into closed vector outlines.
/// A curve fitter traces a region mask into closed vector outlines, one
/// [`FittedGeom`] per contour (outer ring or hole).
pub trait CurveFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath;
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom>;
}
/// Exact lattice polyline; every pixel-boundary step is preserved.
@@ -53,7 +76,7 @@ pub trait CurveFitter {
pub struct PixelFitter;
impl CurveFitter for PixelFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::None, FitParams::default())
}
}
@@ -63,7 +86,7 @@ impl CurveFitter for PixelFitter {
pub struct PolygonFitter;
impl CurveFitter for PolygonFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::Polygon, FitParams::default())
}
}
@@ -81,19 +104,19 @@ impl SplineFitter {
}
impl CurveFitter for SplineFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::Spline, self.params)
}
}
/// Trace every connected component of a masked region and merge the resulting
/// outlines into a single [`MultiPath`] in absolute coordinates.
/// Trace every connected component of a masked region and collect the
/// resulting outlines, one [`FittedGeom`] per contour, in absolute coordinates.
///
/// This mirrors visioncortex's `Cluster::to_compound_path`: the mask (with
/// holes already punched) is split into connected sub-clusters, each traced
/// independently, then offset into document space.
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> MultiPath {
let mut multi = MultiPath::new();
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> Vec<FittedGeom> {
let mut geoms = Vec::new();
for sub in mask.image.to_clusters(false).iter() {
let offset = PointI32 {
x: mask.offset.x + sub.rect.left,
@@ -108,12 +131,12 @@ fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) ->
params.max_iterations,
params.splice_threshold,
);
append_compound(&mut multi, &compound);
append_compound(&mut geoms, &compound);
}
multi
geoms
}
fn append_compound(multi: &mut MultiPath, compound: &CompoundPath) {
fn append_compound(geoms: &mut Vec<FittedGeom>, compound: &CompoundPath) {
for element in compound.iter() {
match element {
CompoundPathElement::PathI32(p) => {
@@ -125,18 +148,34 @@ fn append_compound(multi: &mut MultiPath, compound: &CompoundPath) {
y: q.y as f64,
})
.collect();
multi.push(polyline_subpath(&pts));
geoms.push(FittedGeom::Polyline(pts));
}
CompoundPathElement::PathF64(p) => {
multi.push(polyline_subpath(&p.path));
geoms.push(FittedGeom::Polyline(p.path.clone()));
}
CompoundPathElement::Spline(s) => {
multi.push(spline_subpath(&s.points));
geoms.push(FittedGeom::Beziers(spline_chain(&s.points)));
}
}
}
}
/// A spline of `1 + 3n` points becomes a chain of `n` cubics sharing endpoints.
fn spline_chain(points: &[PointF64]) -> Vec<[PointF64; 4]> {
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
return Vec::new();
}
let mut chain = Vec::with_capacity((points.len() - 1) / 3);
let mut start = points[0];
let mut i = 1;
while i + 2 < points.len() {
chain.push([start, points[i], points[i + 1], points[i + 2]]);
start = points[i + 2];
i += 3;
}
chain
}
/// A closed polyline whose last point repeats the first becomes
/// `MoveTo · LineTo* · Close`.
fn polyline_subpath(points: &[PointF64]) -> SubPath {
@@ -155,18 +194,15 @@ fn polyline_subpath(points: &[PointF64]) -> SubPath {
sub
}
/// A spline of `1 + 3n` points becomes `MoveTo · CubicTo* · Close`.
fn spline_subpath(points: &[PointF64]) -> SubPath {
/// A cubic chain becomes `MoveTo · CubicTo* · Close`.
fn beziers_subpath(chain: &[[PointF64; 4]]) -> SubPath {
let mut sub = SubPath::new();
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
if chain.is_empty() {
return sub;
}
sub.commands.push(PathCmd::MoveTo(points[0]));
let mut i = 1;
while i + 2 < points.len() {
sub.commands
.push(PathCmd::CubicTo(points[i], points[i + 1], points[i + 2]));
i += 3;
sub.commands.push(PathCmd::MoveTo(chain[0][0]));
for c in chain {
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
}
sub.commands.push(PathCmd::Close);
sub
+44
View File
@@ -0,0 +1,44 @@
//! Frontends: algorithms that turn a raster image into a [`Segmentation`].
//!
//! Built-ins:
//! * [`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.
mod binary;
mod color_cluster;
mod keying;
mod watershed;
pub use binary::{BinaryFrontend, Threshold};
pub use color_cluster::ColorClusterFrontend;
pub use watershed::{WatershedFrontend, WatershedHierarchy};
use visioncortex::ColorImage;
use crate::error::Error;
use crate::ir::Segmentation;
use crate::progress::Ctx;
/// A frontend segments a raster image into ordered paint layers.
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
/// Progress- and cancellation-aware segmentation.
///
/// The default runs [`segment`](Frontend::segment) and then honors
/// cancellation (coarse: one report at completion, cancel observed after
/// the whole segmentation). Frontends that can step incrementally — like
/// [`ColorClusterFrontend`] — override this to report fine-grained
/// progress and observe cancellation between batches.
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
let seg = self.segment(img)?;
ctx.check()?;
ctx.report(crate::progress::Phase::Segment, 1.0);
Ok(seg)
}
}
+119 -21
View File
@@ -1,32 +1,130 @@
use visioncortex::{Color, ColorImage, PointI32};
use visioncortex::{BinaryImage, Color, ColorImage, PointI32, SummedAreaTable};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::Frontend;
/// Grayscale intensity (0..=255) used by every thresholding method. Matches the
/// metric `SummedAreaTable::from_color_image` sums, so fixed and adaptive
/// thresholds agree on what "dark" means.
#[inline]
fn intensity(c: Color) -> u32 {
(c.r as u32 + c.g as u32 + c.b as u32) / 3
}
/// How the binary frontend separates foreground (dark) from background pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Threshold {
/// Global cutoff: a pixel is foreground when its intensity is below this
/// value (0..=255). Fast and predictable; best for clean, evenly-lit input.
Fixed(u8),
/// BradleyRoth adaptive threshold: a pixel is foreground when its
/// intensity is more than `t` percent below the mean of the surrounding
/// `window`×`window` block. Handles uneven lighting and shadows that defeat
/// a single global cutoff. Computed in one pass with a summed-area table,
/// so it stays O(pixels) regardless of window size.
Adaptive {
/// Window side length in pixels; `0` auto-derives ~1/8 of the shorter
/// image dimension (the value suggested by the paper).
window: u32,
/// Sensitivity, as a percentage below the local mean (paper default 15).
t: f64,
},
}
impl Threshold {
/// BradleyRoth adaptive thresholding with the paper's defaults
/// (auto window, `t = 15`).
pub const fn adaptive() -> Self {
Threshold::Adaptive {
window: 0,
t: 15.0,
}
}
}
impl Default for Threshold {
fn default() -> Self {
Threshold::Fixed(128)
}
}
/// Binary (black/white) frontend: threshold the image then cluster the
/// foreground. Every region is painted black.
///
/// Speckle removal drops clusters smaller than `min_area` px as the clusters
/// are collected, matching the pre-1.0 binary path (`cluster.size() >= area`).
#[derive(Debug, Clone)]
pub struct BinaryFrontend {
/// Discard clusters smaller than this many pixels.
pub filter_speckle_area: usize,
/// A pixel is foreground when its red channel is below this threshold.
pub threshold: u8,
/// How foreground pixels are selected.
pub threshold: Threshold,
/// Whether to connect clusters diagonally.
pub diagonal: bool,
/// Discard clusters smaller than this many pixels (0 = keep all).
pub min_area: usize,
}
impl Default for BinaryFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
threshold: 128,
threshold: Threshold::default(),
diagonal: false,
min_area: 0,
}
}
}
impl BinaryFrontend {
/// Binarize `img` into a foreground mask according to [`Self::threshold`].
fn binarize(&self, img: &ColorImage) -> BinaryImage {
match self.threshold {
Threshold::Fixed(value) => {
let value = value as u32;
img.to_binary_image(|c| intensity(c) < value)
}
Threshold::Adaptive { window, t } => adaptive_bradley_roth(img, window, t),
}
}
}
/// BradleyRoth adaptive thresholding via a summed-area table.
///
/// For each pixel, compare its intensity to the mean of a surrounding window:
/// it is foreground when `value <= mean * (1 - t/100)`, i.e. more than `t`
/// percent darker than its neighborhood.
fn adaptive_bradley_roth(img: &ColorImage, window: u32, t: f64) -> BinaryImage {
let (w, h) = (img.width, img.height);
let sat = SummedAreaTable::from_color_image(img);
// Window: 0 => auto (~1/8 of the shorter side, per the paper), min 1.
let side = if window == 0 {
(w.min(h) / 8).max(1)
} else {
window as usize
};
let half = side / 2;
let factor = 1.0 - t.clamp(0.0, 100.0) / 100.0;
let mut out = BinaryImage::new_w_h(w, h);
for y in 0..h {
let y0 = y.saturating_sub(half);
let y1 = (y + half).min(h - 1);
for x in 0..w {
let x0 = x.saturating_sub(half);
let x1 = (x + half).min(w - 1);
let count = ((x1 - x0 + 1) * (y1 - y0 + 1)) as f64;
let sum = sat.get_region_sum_x_y_w_h(x0, y0, x1 - x0 + 1, y1 - y0 + 1) as f64;
let value = intensity(img.get_pixel(x, y)) as f64;
// value <= mean * factor ⇔ value * count <= sum * factor
out.set_pixel(x, y, value * count <= sum * factor);
}
}
out
}
impl Frontend for BinaryFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
if img.width == 0 || img.height == 0 {
@@ -35,27 +133,27 @@ impl Frontend for BinaryFrontend {
let width = img.width;
let height = img.height;
let threshold = self.threshold;
let binary = img.to_binary_image(|c| c.r < threshold);
let binary = self.binarize(img);
let clusters = binary.to_clusters(self.diagonal);
let mut seg = Segmentation::new(width as u32, height as u32);
let black = Color::new(0, 0, 0);
for i in 0..clusters.len() {
let cluster = clusters.get_cluster(i);
if cluster.size() >= self.filter_speckle_area {
let mask = RegionMask::new(
cluster.to_binary_image(),
PointI32 {
x: cluster.rect.left,
y: cluster.rect.top,
},
);
seg.layers.push(Layer {
paint: Paint::Solid(black),
mask,
});
if cluster.size() < self.min_area {
continue;
}
let mask = RegionMask::new(
cluster.to_binary_image(),
PointI32 {
x: cluster.rect.left,
y: cluster.rect.top,
},
);
seg.layers.push(Layer {
paint: Paint::Solid(black),
mask,
});
}
Ok(seg)
+71 -26
View File
@@ -1,35 +1,54 @@
use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX};
use visioncortex::color_clusters::{
Clusters, KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX,
};
use visioncortex::{Color, ColorImage, PointI32};
// (Runner is constructed inline in each entry point so its generic closure
// types never appear in a return signature.)
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use crate::progress::{Ctx, Phase};
use super::keying::{apply_key, find_unused_color, should_key_image};
use super::Frontend;
/// Hierarchical color-clustering frontend — the classic VTracer color path.
///
/// Speckle removal happens *inside* clustering, via `good_min_area`: it is the
/// clusterer's `deepen` gate (visioncortex `patch_good`), so it does far more
/// than drop small regions — it decides whether a small/thin patch is absorbed
/// into its neighbor (its color averaged in) or kept as its own layer. Forcing
/// it to 0 disables the thread-like rejection and changes the whole hierarchy,
/// so speckle must be a clustering parameter, not a downstream filter.
#[derive(Debug, Clone)]
pub struct ColorClusterFrontend {
/// Discard clusters smaller than this many pixels.
pub filter_speckle_area: usize,
/// Bits of color precision dropped when comparing pixels (0 = full 8-bit).
pub color_precision_loss: i32,
/// Color difference between hierarchical gradient layers.
pub layer_difference: i32,
/// Minimum area (px) for a patch to be a `deepen` candidate during
/// clustering; non-zero also enables visioncortex's thread-like rejection.
/// Below it, patches are absorbed into their nearest-color neighbor.
pub good_min_area: usize,
}
impl Default for ColorClusterFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
color_precision_loss: 2,
layer_difference: 16,
good_min_area: 0,
}
}
}
impl Frontend for ColorClusterFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
impl ColorClusterFrontend {
/// Apply transparency keying (if warranted) and build the clustering
/// inputs: the keyed image, the `RunnerConfig`, and the dimensions. The
/// caller constructs `Runner::new(config, image)` inline so the runner's
/// generic closure types never surface in a return signature.
fn prepare(&self, img: &ColorImage) -> Result<(ColorImage, RunnerConfig, usize, usize), Error> {
if img.width == 0 || img.height == 0 {
return Err(Error::EmptyImage);
}
@@ -48,26 +67,26 @@ impl Frontend for ColorClusterFrontend {
Color::default()
};
let runner = Runner::new(
RunnerConfig {
diagonal: self.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: self.filter_speckle_area,
good_max_area: width * height,
is_same_color_a: self.color_precision_loss,
is_same_color_b: 1,
deepen_diff: self.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: KeyingAction::Discard,
},
img,
);
let config = RunnerConfig {
diagonal: self.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: self.good_min_area,
good_max_area: width * height,
is_same_color_a: self.color_precision_loss,
is_same_color_b: 1,
deepen_diff: self.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: KeyingAction::Discard,
};
let clusters = runner.run();
Ok((img, config, width, height))
}
/// Turn finished clusters into the layered [`Segmentation`].
fn segmentation_from_clusters(clusters: &Clusters, width: usize, height: usize) -> Segmentation {
let view = clusters.view();
let mut seg = Segmentation::new(width as u32, height as u32);
// `clusters_output` is top-to-bottom; reverse to get bottom-to-top
// paint order for the layer stack.
@@ -91,7 +110,33 @@ impl Frontend for ColorClusterFrontend {
mask,
});
}
Ok(seg)
seg
}
}
impl Frontend for ColorClusterFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
let (image, config, width, height) = self.prepare(img)?;
let clusters = Runner::new(config, image).run();
Ok(Self::segmentation_from_clusters(&clusters, width, height))
}
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
let (image, config, width, height) = self.prepare(img)?;
// Drive clustering incrementally so we can publish progress and observe
// cancellation between batches. `run()` is exactly this loop, so the
// resulting clusters are identical to the blocking path.
let mut builder = Runner::new(config, image).start();
ctx.report(Phase::Segment, 0.0);
while !builder.tick() {
ctx.check()?;
ctx.report(Phase::Segment, builder.progress() as f32 / 100.0);
}
ctx.check()?;
let clusters = builder.result();
ctx.report(Phase::Segment, 1.0);
Ok(Self::segmentation_from_clusters(&clusters, width, height))
}
}
-26
View File
@@ -1,26 +0,0 @@
//! Frontends: algorithms that turn a raster image into a [`Segmentation`].
//!
//! Built-ins:
//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic
//! VTracer color path), including transparency keying.
//! * [`BinaryFrontend`] — threshold to black/white then cluster.
//!
//! Third parties can implement [`Frontend`] to feed external label maps or ML
//! segmentation into the pipeline.
mod binary;
mod color_cluster;
mod keying;
pub use binary::BinaryFrontend;
pub use color_cluster::ColorClusterFrontend;
use visioncortex::ColorImage;
use crate::error::Error;
use crate::ir::Segmentation;
/// A frontend segments a raster image into ordered paint layers.
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
}
+905
View File
@@ -0,0 +1,905 @@
//! 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**, 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.
//!
//! The work is split in two so the expensive part can be cached (see
//! [`crate::Session`]):
//!
//! * [`WatershedHierarchy::build`] — 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 basins) becomes the saliency of
//! its MST edge. This depends only on the image — no tuning parameters.
//! * [`WatershedHierarchy::cut`] — cutting at level λ is single-linkage over
//! MST edges with persistence ≤ λ (every pixel gets a label, no
//! watershed-line pixels), antialiased boundary pixels are snapped to the
//! color-midpoint iso-line (see [`snap_boundaries`]), small basins are
//! absorbed, and the surviving merge tree above λ becomes the output layer
//! stack.
//!
//! The cut emits a **stacked hierarchy**, the same principle as the color
//! clustering frontend: the root (whole canvas, mean color) is painted first,
//! then progressively finer ancestor regions, then the final regions on top.
//! Sub-pixel gaps between abutting regions therefore show their common
//! ancestor's color instead of an unrelated backdrop, and stacked mode stays
//! seam-free by overdraw. Flattening top-down (what cutout does) recovers the
//! exact partition, because the final regions are painted last.
//!
//! 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;
/// Cap on the total painted area of ancestor layers, as a multiple of the
/// canvas: keeps a pathological hierarchy (long chains of near-equal
/// persistence) from ballooning the stacked output. The root and the final
/// regions are always emitted, so coverage never depends on this.
const ANCESTOR_AREA_BUDGET: usize = 3;
/// Watershed frontend: hierarchical watershed by volume, cut at `detail`.
#[derive(Debug, Clone)]
pub struct WatershedFrontend {
/// Detail level (0..=255): where to cut the hierarchy. Each +25.5 roughly
/// doubles the region count; 0 collapses the image to 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)
}
/// The image's watershed hierarchy: the minimum spanning tree of the pixel
/// graph with a persistence (volume extinction) per edge. Building it is the
/// expensive step and depends only on the image; [`cut`](Self::cut) derives a
/// [`Segmentation`] for any detail level in near-linear time, so interactive
/// re-tuning never repays the build (see [`crate::Session`]).
pub struct WatershedHierarchy {
width: usize,
height: usize,
/// MST edges as pixel pairs, in Kruskal creation order.
mst: Vec<(u32, u32)>,
/// Persistence (volume of the smaller merged basin) per MST edge.
pers: Vec<u64>,
/// MST edge indices by ascending (persistence, index) — the cut order.
order: Vec<u32>,
}
impl WatershedHierarchy {
/// Build the hierarchy: counting-sorted Kruskal → binary partition tree →
/// volume persistence per MST edge. O(n α(n)).
pub fn build(img: &ColorImage) -> Result<Self, Error> {
let w = img.width;
let h = img.height;
if w == 0 || h == 0 {
return Err(Error::EmptyImage);
}
let n = w * h;
if n == 1 {
return Ok(Self {
width: w,
height: h,
mst: Vec::new(),
pers: Vec::new(),
order: Vec::new(),
});
}
// --- 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| img.get_pixel(i % w, i / w);
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 sorted = 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;
sorted[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;
sorted[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 = 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 &sorted {
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[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]);
}
let mut order: Vec<u32> = (0..(n - 1) as u32).collect();
order.sort_by_key(|&k| (pers[k as usize], k));
Ok(Self {
width: w,
height: h,
mst,
pers,
order,
})
}
/// Cut the hierarchy at `detail` and emit the stacked [`Segmentation`].
/// Near-linear; safe to call repeatedly with different parameters.
pub fn cut(&self, img: &ColorImage, detail: u8, min_area: usize) -> Segmentation {
let (w, h) = (self.width, self.height);
let n = w * h;
let m = self.mst.len();
// --- Region formation: merge every MST edge with persistence ≤ λ ----
// Merging 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 at ≈ 0 — so the dial 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 mut uf = Uf::new(n);
if m > 0 {
let target = (2f64).powf(detail as f64 / 25.5).round() as usize;
let target = target.clamp(1, m);
let lambda = self.pers[self.order[m - target] as usize];
for &k in &self.order {
if self.pers[k as usize] > lambda {
break;
}
let (p, q) = self.mst[k as usize];
let (rp, rq) = (uf.find(p), uf.find(q));
if rp != rq {
uf.link(rp, rq);
}
}
}
// --- Compact to region ids and region stats --------------------------
// One find per pixel; everything after this works on the (small)
// region graph so re-cuts stay cheap.
let mut pre_of_root = vec![u32::MAX; n];
let mut pre = vec![0u32; n];
let mut kp = 0usize;
for i in 0..n {
let r = uf.find(i as u32) as usize;
if pre_of_root[r] == u32::MAX {
pre_of_root[r] = kp as u32;
kp += 1;
}
pre[i] = pre_of_root[r];
}
let mut area = vec![0u64; kp];
let mut sum = vec![[0u64; 3]; kp];
for i in 0..n {
let a = pre[i] as usize;
let c = img.get_pixel(i % w, i / w);
area[a] += 1;
sum[a][0] += c.r as u64;
sum[a][1] += c.g as u64;
sum[a][2] += c.b as u64;
}
// --- Boundary snap, then boundary adjacency ---------------------------
snap_boundaries(img, w, h, &mut pre, &mut area, &mut sum);
let mut pairs: Vec<(u32, u32)> = Vec::new();
for i in 0..n {
let a = pre[i];
if i % w + 1 < w && pre[i + 1] != a {
pairs.push((a, pre[i + 1]));
}
if i / w + 1 < h && pre[i + w] != a {
pairs.push((a, pre[i + w]));
}
}
// --- Small-basin absorption on the region graph ----------------------
let mut uf_r = Uf::new(kp);
absorb_small(min_area, &pairs, &mut uf_r, &mut area, &mut sum);
// --- Final leaf ids in raster order of first appearance --------------
let mut leaf_of = vec![u32::MAX; kp];
let mut leaf_root: Vec<u32> = Vec::new(); // leaf id -> absorb root
let mut ids = vec![0u32; n];
for i in 0..n {
let r = uf_r.find(pre[i]) as usize;
if leaf_of[r] == u32::MAX {
leaf_of[r] = leaf_root.len() as u32;
leaf_root.push(r as u32);
}
ids[i] = leaf_of[r];
}
let k = leaf_root.len();
let mean = |s: &[u64; 3], a: u64| {
Color::new((s[0] / a) as u8, (s[1] / a) as u8, (s[2] / a) as u8)
};
let mut seg = Segmentation::new(w as u32, h as u32);
if k == 1 {
// Single region: one solid full-canvas layer.
let r = leaf_root[0] as usize;
seg.layers.push(Layer {
paint: Paint::Solid(mean(&sum[r], area[r])),
mask: full_canvas(w, h),
});
return seg;
}
// --- Merge tree above the cut ----------------------------------------
// Re-run all merges (ascending persistence) over the final regions:
// each one that still joins two components is a kept split. Nodes 0..k
// are the final regions; internal nodes are created in ascending
// persistence order, so the reverse is a root-first order in which
// every ancestor precedes its descendants. Below-cut edges are almost
// all no-ops (their endpoints share a region), but not quite: boundary
// snapping can leave a region's only adjacency running through a
// below-cut edge, and skipping those would leave the tree unconnected.
let n_tree = 2 * k - 1;
let mut tree_child: Vec<[u32; 2]> = Vec::with_capacity(k - 1);
let mut tree_area = vec![0u64; n_tree];
let mut tree_sum = vec![[0u64; 3]; n_tree];
for (t, &r) in leaf_root.iter().enumerate() {
tree_area[t] = area[r as usize];
tree_sum[t] = sum[r as usize];
}
let mut uf2 = Uf::new(k);
let mut node_rep: Vec<u32> = (0..k as u32).collect();
let mut next = k as u32;
for &e in &self.order {
let (p, q) = self.mst[e as usize];
let (lp, lq) = (ids[p as usize], ids[q as usize]);
if lp == lq {
continue; // same region — the bulk of the below-cut edges
}
let (a, b) = (uf2.find(lp), uf2.find(lq));
if a == b {
continue; // already merged, or rejoined by absorption
}
let node = next as usize;
tree_child.push([node_rep[a as usize], node_rep[b as usize]]);
for ch in [node_rep[a as usize], node_rep[b as usize]] {
tree_area[node] += tree_area[ch as usize];
for c in 0..3 {
tree_sum[node][c] += tree_sum[ch as usize][c];
}
}
uf2.link(a, b);
node_rep[a as usize] = next;
next += 1;
}
debug_assert_eq!(next as usize, n_tree);
// Per-leaf pixel lists, for painting ancestor masks.
let mut leaf_len = vec![0u32; k];
for &id in &ids {
leaf_len[id as usize] += 1;
}
let mut leaf_start = vec![0usize; k + 1];
for t in 0..k {
leaf_start[t + 1] = leaf_start[t] + leaf_len[t] as usize;
}
let mut leaf_px = vec![0u32; n];
let mut fill = leaf_start.clone();
for (i, &id) in ids.iter().enumerate() {
leaf_px[fill[id as usize]] = i as u32;
fill[id as usize] += 1;
}
// --- Emit: root, ancestors (budgeted), then the final regions --------
let root = n_tree - 1;
seg.layers.push(Layer {
paint: Paint::Solid(mean(&tree_sum[root], tree_area[root])),
mask: full_canvas(w, h),
});
let mut budget = ANCESTOR_AREA_BUDGET * n;
for node in (k..root).rev() {
let node_area = tree_area[node] as usize;
if node_area > budget {
continue;
}
budget -= node_area;
seg.layers.push(Layer {
paint: Paint::Solid(mean(&tree_sum[node], tree_area[node])),
mask: node_mask(node, k, &tree_child, &leaf_start, &leaf_px, w),
});
}
for t in 0..k {
seg.layers.push(Layer {
paint: Paint::Solid(mean(&tree_sum[t], tree_area[t])),
mask: node_mask(t, k, &tree_child, &leaf_start, &leaf_px, w),
});
}
seg
}
}
fn full_canvas(w: usize, h: usize) -> RegionMask {
let mut image = BinaryImage::new_w_h(w, h);
for y in 0..h {
for x in 0..w {
image.set_pixel(x, y, true);
}
}
RegionMask::new(image, PointI32 { x: 0, y: 0 })
}
/// Paint a tree node's region (the union of the final regions beneath it)
/// into a bbox-cropped mask.
fn node_mask(
node: usize,
k: usize,
tree_child: &[[u32; 2]],
leaf_start: &[usize],
leaf_px: &[u32],
w: usize,
) -> RegionMask {
// Collect the node's leaves.
let mut leaves: Vec<usize> = Vec::new();
let mut stack = vec![node];
while let Some(t) = stack.pop() {
if t < k {
leaves.push(t);
} else {
let [a, b] = tree_child[t - k];
stack.push(a as usize);
stack.push(b as usize);
}
}
// Bounding box over all member pixels.
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for &t in &leaves {
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
let (x, y) = ((p as usize % w) as i32, (p as usize / w) as i32);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
let (bw, bh) = ((x1 - x0 + 1) as usize, (y1 - y0 + 1) as usize);
let mut image = BinaryImage::new_w_h(bw, bh);
for &t in &leaves {
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
let (x, y) = (p as usize % w, p as usize / w);
image.set_pixel(x - x0 as usize, y - y0 as usize, true);
}
}
RegionMask::new(image, PointI32 { x: x0, y: y0 })
}
/// How many 1-px boundary-snap sweeps to run: bounds the boundary movement to
/// the width of an antialiasing ramp / JPEG halo (compression ringing spreads
/// a hard edge over up to ~3 px; a plain AA ramp over 12 px).
const SNAP_SWEEPS: usize = 4;
/// Tolerance for the mixture test below: an antialiased blend of two region
/// colors satisfies `d(p,A) + d(p,B) = d(A,B)` exactly (L1, per-channel
/// between-ness); this slack admits sensor/JPEG noise of a few units per
/// channel without admitting genuine third colors.
const SNAP_SLACK: i32 = 16;
/// Re-assign boundary pixels to whichever adjacent region's mean color is
/// closest (strictly closer than their own region's mean, L1).
///
/// The minimum-spanning-forest cut routes the boundary through whichever
/// crack of an antialiasing ramp has the minutely-largest weight, so along a
/// smooth edge it meanders ±12 px with the pixel noise and the fitted curves
/// visibly wave (crisp synthetic edges are unaffected: their boundary pixels
/// sit exactly at a region's mean). Snapping by color lands the boundary on
/// the color-midpoint iso-line of the ramp instead — the same rule color
/// quantization applies, which is why the color-cluster frontend never shows
/// this.
///
/// Only pixels whose color is a *mixture* of two adjacent region means may
/// flip (`d(p,A) + d(p,B) ≤ d(A,B) + slack`): a pixel of a genuine third
/// color — say a dark outline stroke absorbed into a lighter region — must
/// stay with its basin even when some other neighbour's mean happens to sit
/// closer. The mixture pair is usually the pixel's own region and the flip
/// candidate (the classic AA ramp), but a pair of *neighbouring* regions
/// also qualifies: on a blurred low-contrast crack the basin cut can leak a
/// distant region along the crack's blend band as a 1-px filament — those
/// pixels blend the two flanking regions and are unrelated to their own
/// region's color, and they belong to the closer flank.
/// Sweeps are double-buffered (flips apply after scanning) and each moves the
/// boundary at most 1 px, so total movement stays within the ambiguity band;
/// regions are never emptied. Only the first sweep scans the whole canvas;
/// later sweeps revisit the moving front (last sweep's flips and their
/// neighbours), so the cost past sweep one is proportional to the boundary
/// that is actually moving.
fn snap_boundaries(
img: &ColorImage,
w: usize,
h: usize,
labels: &mut [u32],
area: &mut [u64],
sum: &mut [[u64; 3]],
) {
let n = w * h;
let k = area.len();
if k < 2 {
return;
}
// Where a boundary pixel should move, if anywhere: strict improvement
// only, gated on the mixture test; the first of the fixed neighbour
// order wins ties, keeping the sweep deterministic.
let snap_target = |i: usize, labels: &[u32], mean: &[[i32; 3]]| -> Option<u32> {
let a = labels[i] as usize;
// Neighbour labels, replicated at the canvas border (a no-op
// candidate) so the hot path below stays branch-light.
let (x, y) = (i % w, i / w);
let nb = [
labels[if x > 0 { i - 1 } else { i }] as usize,
labels[if x + 1 < w { i + 1 } else { i }] as usize,
labels[if y > 0 { i - w } else { i }] as usize,
labels[if y + 1 < h { i + w } else { i }] as usize,
];
if nb == [a; 4] {
return None; // interior pixel — the overwhelmingly common case
}
let c = img.get_pixel(x, y);
let cv = [c.r as i32, c.g as i32, c.b as i32];
let dist = |m: &[i32; 3]| {
(cv[0] - m[0]).abs() + (cv[1] - m[1]).abs() + (cv[2] - m[2]).abs()
};
let da = dist(&mean[a]);
// The pixel qualifies as a blend of regions `p` and `q` when its
// color sits between their means (L1 between-ness plus noise slack).
let mixture = |p: usize, q: usize| -> bool {
let dpq: i32 = (0..3).map(|ch| (mean[p][ch] - mean[q][ch]).abs()).sum();
dist(&mean[p]) + dist(&mean[q]) <= dpq + SNAP_SLACK
};
let mut best = (da, a);
for b in nb {
if b == a {
continue;
}
let db = dist(&mean[b]);
if db >= best.0 {
continue;
}
if mixture(a, b) || nb.iter().any(|&c| c != a && c != b && mixture(c, b)) {
best = (db, b);
}
}
(best.1 != a).then_some(best.1 as u32)
};
let mut mean = vec![[0i32; 3]; k];
let mut flips: Vec<(u32, u32)> = Vec::new(); // (pixel, new label)
let mut front: Vec<u32> = Vec::new(); // pixels to rescan; sweep 0 scans all
let mut touched: Vec<u32> = Vec::new(); // every front, for the fragment check
for sweep in 0..SNAP_SWEEPS {
for r in 0..k {
for ch in 0..3 {
mean[r][ch] = (sum[r][ch] / area[r]) as i32;
}
}
flips.clear();
if sweep == 0 {
// Interior first with a branch-free neighbour check (the div/mod
// and border branches in snap_target would dominate a whole-canvas
// scan), then the border rim.
for y in 1..h.saturating_sub(1) {
for i in y * w + 1..y * w + w.saturating_sub(1) {
let a = labels[i];
if labels[i - 1] == a
&& labels[i + 1] == a
&& labels[i - w] == a
&& labels[i + w] == a
{
continue;
}
if let Some(b) = snap_target(i, labels, &mean) {
flips.push((i as u32, b));
}
}
}
let h1 = h.saturating_sub(1);
let rim = (0..w)
.chain((1..h1).map(|y| y * w))
.chain((1..h1).map(|y| y * w + w - 1).filter(|_| w > 1))
.chain(if h > 1 { h1 * w..n } else { 0..0 });
for i in rim {
if let Some(b) = snap_target(i, labels, &mean) {
flips.push((i as u32, b));
}
}
} else {
for &i in &front {
if let Some(b) = snap_target(i as usize, labels, &mean) {
flips.push((i, b));
}
}
}
if flips.is_empty() {
break;
}
for &(i, b) in &flips {
let (i, b) = (i as usize, b as usize);
let a = labels[i] as usize;
if area[a] <= 1 {
continue; // never empty a region
}
let c = img.get_pixel(i % w, i / w);
labels[i] = b as u32;
area[a] -= 1;
area[b] += 1;
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
sum[a][ch] -= v as u64;
sum[b][ch] += v as u64;
}
}
// Next sweep revisits each flipped pixel and its 4-neighbourhood,
// in raster order for determinism; the same set seeds the fragment
// check below (a severed strand is always adjacent to the flipped
// bridge pixel that cut it off).
front.clear();
for &(i, _) in &flips {
let i = i as usize;
let (x, y) = (i % w, i / w);
front.push(i as u32);
if x > 0 {
front.push((i - 1) as u32);
}
if x + 1 < w {
front.push((i + 1) as u32);
}
if y > 0 {
front.push((i - w) as u32);
}
if y + 1 < h {
front.push((i + w) as u32);
}
}
front.sort_unstable();
front.dedup();
touched.extend_from_slice(&front);
}
touched.sort_unstable();
touched.dedup();
absorb_fragments(img, w, h, labels, area, sum, &touched);
}
/// Fragments a snap flip may pinch off: a pixel can flip toward a neighbour
/// whose own flip then strands it, and a flipped bridge pixel can sever a
/// thin strand of its source region. Watershed basins are connected by
/// construction and everything downstream relies on regions staying coherent
/// (the mosaic gives every disjoint patch its own face), so the snap must not
/// leave debris: a connected component that is disconnected from the rest of
/// its region and fits under this floor is re-assigned to the most
/// color-similar adjacent region. (A *substantial* patch severed at a thin
/// antialiased neck stays — it makes a coherent face of its own; recoloring
/// it would be visible.)
const SNAP_FRAGMENT_MAX: usize = SNAP_SWEEPS * SNAP_SWEEPS;
fn absorb_fragments(
img: &ColorImage,
w: usize,
h: usize,
labels: &mut [u32],
area: &mut [u64],
sum: &mut [[u64; 3]],
seeds: &[u32],
) {
let n = w * h;
let mut visited = vec![false; n];
let mut comp: Vec<usize> = Vec::new();
let mut rim: Vec<u32> = Vec::new(); // adjacent region labels
for &s in seeds {
let s = s as usize;
if visited[s] {
continue;
}
// Flood s's same-label component, capped: hitting the cap — or a
// pixel already visited by an earlier over-cap flood of the same
// component — proves it is no fragment.
let l = labels[s];
visited[s] = true;
comp.clear();
comp.push(s);
rim.clear();
let mut over = false;
let mut qi = 0;
'flood: while qi < comp.len() {
let i = comp[qi];
qi += 1;
let (x, y) = (i % w, i / w);
for j in [
(x > 0).then(|| i - 1),
(x + 1 < w).then(|| i + 1),
(y > 0).then(|| i - w),
(y + 1 < h).then(|| i + w),
]
.into_iter()
.flatten()
{
if labels[j] != l {
rim.push(labels[j]);
continue;
}
if visited[j] {
if !comp.contains(&j) {
over = true; // joined an earlier over-cap flood
break 'flood;
}
continue;
}
if comp.len() > SNAP_FRAGMENT_MAX {
over = true;
break 'flood;
}
visited[j] = true;
comp.push(j);
}
}
// A component as large as its whole region is the region itself, not
// a fragment of one. (The flood can end at cap + 1 without tripping
// `over`, so re-check the size.)
if over
|| comp.len() > SNAP_FRAGMENT_MAX
|| comp.len() as u64 >= area[l as usize]
|| rim.is_empty()
{
continue;
}
// The whole fragment moves to the adjacent region whose mean is
// closest to the fragment's own mean.
let mut fsum = [0i64; 3];
for &i in &comp {
let c = img.get_pixel(i % w, i / w);
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
fsum[ch] += v as i64;
}
}
let fl = comp.len() as i64;
rim.sort_unstable();
rim.dedup();
let target = rim
.iter()
.map(|&b| {
let d: i64 = (0..3)
.map(|ch| {
(fsum[ch] / fl - (sum[b as usize][ch] / area[b as usize]) as i64).abs()
})
.sum();
(d, b)
})
.min()
.unwrap()
.1 as usize;
let l = l as usize;
for &i in &comp {
let c = img.get_pixel(i % w, i / w);
labels[i] = target as u32;
area[l] -= 1;
area[target] += 1;
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
sum[l][ch] -= v as u64;
sum[target][ch] += v as u64;
}
}
}
}
/// Absorb regions smaller than `min_area` into their most color-similar
/// neighbour, working entirely on the region graph: `pairs` are the boundary
/// adjacencies (duplicates fine), `uf` is a region-level union-find, and the
/// stats are merged along so downstream consumers see the final regions.
/// Sweeps until nothing undersized remains (or an undersized region has no
/// neighbour at all).
fn absorb_small(
min_area: usize,
pairs: &[(u32, u32)],
uf: &mut Uf,
area: &mut [u64],
sum: &mut [[u64; 3]],
) {
if min_area <= 1 {
return;
}
let k = area.len();
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
};
loop {
// best[r] = (diff, neighbour_root) for undersized root r
let mut best: Vec<(u64, u32)> = vec![(u64::MAX, u32::MAX); k];
let mut any_small = false;
for &(p, q) in pairs {
let (a, b) = (uf.find(p), uf.find(q));
if a == b {
continue;
}
for (s, t) in [(a, b), (b, a)] {
let (su, tu) = (s as usize, t as usize);
if area[su] < 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 r in 0..k {
let (_, tgt) = best[r];
if tgt == u32::MAX {
continue;
}
let rr = uf.find(r as u32);
if rr as usize != r {
continue; // already absorbed this sweep
}
let rt = uf.find(tgt);
if rt == rr {
continue;
}
uf.link(rt, rr);
area[rt as usize] += area[r];
for ch in 0..3 {
sum[rt as usize][ch] += sum[r][ch];
}
merged = true;
}
if !merged {
break; // isolated undersized region (e.g. whole-canvas)
}
}
}
impl Frontend for WatershedFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
Ok(WatershedHierarchy::build(img)?.cut(img, self.detail, self.min_area))
}
}
+33 -10
View File
@@ -43,24 +43,47 @@ impl RegionMask {
/// Combine two masks into one covering the union of their bounding boxes.
/// Foreground is the OR of both; this is used by the layer-merge step.
pub fn union(&self, other: &RegionMask) -> RegionMask {
let left = self.offset.x.min(other.offset.x);
let top = self.offset.y.min(other.offset.y);
let right = (self.offset.x + self.image.width as i32)
.max(other.offset.x + other.image.width as i32);
let bottom = (self.offset.y + self.image.height as i32)
.max(other.offset.y + other.image.height as i32);
Self::union_all(&[self, other])
}
/// Union any number of masks in one pass: size the destination from the
/// combined bounding box, then blit each source into it exactly once.
///
/// Folding [`union`](Self::union) instead costs one full-size allocation and
/// rewrite of the accumulator *per input*. That is quadratic in the canvas
/// area, and it bites precisely when a palette snap leaves a long run of
/// same-paint layers for [`MergeAdjacent`](crate::colorfit::MergeAdjacent):
/// the accumulator grows to the full canvas after the first few merges, so
/// every remaining layer copies the entire canvas again.
///
/// An empty input yields an empty mask at the origin.
pub fn union_all(masks: &[&RegionMask]) -> RegionMask {
let Some((first, rest)) = masks.split_first() else {
return RegionMask::new(BinaryImage::new_w_h(0, 0), PointI32 { x: 0, y: 0 });
};
let mut left = first.offset.x;
let mut top = first.offset.y;
let mut right = first.offset.x + first.image.width as i32;
let mut bottom = first.offset.y + first.image.height as i32;
for m in rest {
left = left.min(m.offset.x);
top = top.min(m.offset.y);
right = right.max(m.offset.x + m.image.width as i32);
bottom = bottom.max(m.offset.y + m.image.height as i32);
}
let width = (right - left) as usize;
let height = (bottom - top) as usize;
let mut image = BinaryImage::new_w_h(width, height);
for src in [self, other] {
for src in masks {
let dx = (src.offset.x - left) as usize;
let dy = (src.offset.y - top) as usize;
for y in 0..src.image.height {
for x in 0..src.image.width {
if src.image.get_pixel(x, y) {
let gx = (src.offset.x + x as i32 - left) as usize;
let gy = (src.offset.y + y as i32 - top) as usize;
image.set_pixel(gx, gy, true);
image.set_pixel(x + dx, y + dy, true);
}
}
}
+15 -8
View File
@@ -4,11 +4,11 @@
//! pipeline of pluggable stages.
//!
//! ```text
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ VectorDoc
//! │
//! OptimizerPass* ─────┤
//! ▼
//! SvgWriter ─▶ SVG
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ CurvePass* ─▶ VectorDoc
//!
//! OptimizerPass* ─────┤
//!
//! SvgWriter ─▶ SVG
//! ```
//!
//! The crate is wasm-safe: it performs no file or image I/O (that lives in the
@@ -26,8 +26,8 @@
//! ```
//!
//! For finer control, assemble a [`Pipeline`] directly from the stage traits
//! in [`frontend`], [`colorfit`], [`fitter`], [`compose`], [`optimize`], and
//! [`svg`].
//! in [`frontend`], [`colorfit`], [`fitter`], [`simplify`], [`compose`],
//! [`optimize`], and [`svg`].
pub mod colorfit;
pub mod compose;
@@ -39,11 +39,18 @@ pub mod ir;
pub mod mosaic;
pub mod optimize;
pub mod pipeline;
pub mod progress;
pub mod session;
pub mod simplify;
pub mod svg;
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
pub use config::{Clustering, Config, FitMode, Hierarchical, Preset, SegmentKey};
pub use error::Error;
pub use frontend::Threshold;
pub use ir::{Segmentation, VectorDoc};
pub use pipeline::Pipeline;
pub use progress::{CancelToken, Phase, Progress};
pub use session::Session;
// Re-export the visioncortex value types callers need at the boundary.
pub use visioncortex::{Color, ColorImage, PointF64, PointI32};
+664
View File
@@ -0,0 +1,664 @@
//! Mosaic mode: a seam-free, gapless tessellation.
//!
//! Instead of tracing every region independently (which lets neighboring
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
//!
//! ```text
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
//! ```
//!
//! Every boundary curve exists exactly once; the two adjacent regions
//! reference the same fitted geometry, one traversed reversed. Reversal is
//! exact, so the serialized coordinates match on both sides — no seams.
//!
//! Stages 12 (graph + faces) are pure integer arithmetic on the lattice of
//! pixel corners. Only fitting (stage 3) is floating point.
mod compose;
mod face;
mod fit;
mod graph;
pub use compose::compose_mosaic;
pub use fit::{
FittedGeom, FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
SplineSegmentFitter,
};
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
use crate::ir::{Paint, Segmentation};
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
pub type RegionId = u32;
/// Sentinel label for pixels outside any region.
pub const OUTSIDE: RegionId = u32::MAX;
/// A flat partition of the canvas: one region id per pixel, plus the paint for
/// each region. This is the sole input to the boundary-graph extractor.
#[derive(Debug, Clone)]
pub struct LabelMap {
pub width: u32,
pub height: u32,
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
pub labels: Vec<RegionId>,
/// Paint per region, indexed by label.
pub paints: Vec<Paint>,
}
impl LabelMap {
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
/// pixel takes the paint of the topmost layer covering it. Layers are
/// bottom-to-top, so painting them in order lets higher layers win.
pub fn from_segmentation(seg: &Segmentation) -> Self {
let w = seg.width as usize;
let h = seg.height as usize;
let mut labels = vec![OUTSIDE; w * h];
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
for (i, layer) in seg.layers.iter().enumerate() {
let mask = &layer.mask;
for ly in 0..mask.image.height {
for lx in 0..mask.image.width {
if mask.image.get_pixel(lx, ly) {
let gx = mask.offset.x + lx as i32;
let gy = mask.offset.y + ly as i32;
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
labels[gy as usize * w + gx as usize] = i as RegionId;
}
}
}
}
}
LabelMap {
width: seg.width,
height: seg.height,
labels,
paints,
}
}
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
/// Treating outside as a real label removes all image-border special cases.
#[inline]
pub fn label(&self, x: i32, y: i32) -> RegionId {
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
return OUTSIDE;
}
self.labels[y as usize * self.width as usize + x as usize]
}
/// Merge neighbouring regions whose colors are within `max_diff` of each
/// other (the metric is the clustering one: sum of per-channel absolute
/// differences, and clustering keeps neighbours together when
/// `diff <= deepen_diff`).
///
/// The stacked hierarchy deliberately splits a gradient into layers one
/// `deepen_diff` apart — that's what makes stacking smooth. Flattened into
/// a mosaic, that layering degenerates into abutting faces with barely
/// distinguishable fills. This pass undoes it: agglomerative union-find
/// over the adjacency graph, most-similar pairs first, with each merged
/// region's color re-derived as the area-weighted mean so chains only
/// combine while they genuinely stay within `max_diff`.
///
/// `max_diff == 0` still merges *identical*-color neighbours — a boundary
/// between two same-colored faces is never useful. Pass a negative value
/// to disable merging entirely.
pub fn merge_similar(&mut self, max_diff: i32) {
let n = self.paints.len();
if max_diff < 0 || n < 2 {
return;
}
// Area and summed color per region, for weighted mean colors.
let mut area = vec![0u64; n];
for &l in &self.labels {
if l != OUTSIDE {
area[l as usize] += 1;
}
}
let mut sum: Vec<[u64; 3]> = (0..n)
.map(|i| {
let c = self.paints[i].color();
[
c.r as u64 * area[i],
c.g as u64 * area[i],
c.b as u64 * area[i],
]
})
.collect();
// Adjacency pairs (right/down scan covers 4-connectivity once).
let (w, h) = (self.width as i32, self.height as i32);
let mut pairs: Vec<(RegionId, RegionId)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for y in 0..h {
for x in 0..w {
let a = self.label(x, y);
if a == OUTSIDE {
continue;
}
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
let b = self.label(nx, ny);
if b == OUTSIDE || b == a {
continue;
}
let key = (a.min(b), a.max(b));
if seen.insert(key) {
pairs.push(key);
}
}
}
}
let diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> i32 {
let mut d = 0i64;
for k in 0..3 {
d += ((sa[k] / aa.max(1)) as i64 - (sb[k] / ab.max(1)) as i64).abs();
}
d as i32
};
// Most-similar pairs first, so gradient chains coalesce around their
// closest links; ties break on ids for determinism.
pairs.sort_by_key(|&(a, b)| {
(
diff(&sum[a as usize], area[a as usize], &sum[b as usize], area[b as usize]),
a,
b,
)
});
let mut parent: Vec<RegionId> = (0..n as RegionId).collect();
fn find(parent: &mut [RegionId], mut i: RegionId) -> RegionId {
while parent[i as usize] != i {
parent[i as usize] = parent[parent[i as usize] as usize];
i = parent[i as usize];
}
i
}
// Colors move as regions absorb one another, so re-sweep the candidate
// pairs until nothing merges. Each union is O(α); the sweep count is
// tiny in practice (colors only ever move toward each other's mean).
loop {
let mut changed = false;
for &(a, b) in &pairs {
let ra = find(&mut parent, a);
let rb = find(&mut parent, b);
if ra == rb {
continue;
}
let (ia, ib) = (ra as usize, rb as usize);
if diff(&sum[ia], area[ia], &sum[ib], area[ib]) <= max_diff {
parent[ib] = ra;
for k in 0..3 {
sum[ia][k] += sum[ib][k];
}
area[ia] += area[ib];
changed = true;
}
}
if !changed {
break;
}
}
// Compact surviving roots into dense ids and rewrite labels + paints.
let mut remap: Vec<RegionId> = vec![OUTSIDE; n];
let mut paints: Vec<Paint> = Vec::new();
for l in &mut self.labels {
if *l == OUTSIDE {
continue;
}
let root = find(&mut parent, *l);
if remap[root as usize] == OUTSIDE {
remap[root as usize] = paints.len() as RegionId;
let (s, a) = (&sum[root as usize], area[root as usize].max(1));
paints.push(Paint::Solid(visioncortex::Color::new(
(s[0] / a) as u8,
(s[1] / a) as u8,
(s[2] / a) as u8,
)));
}
*l = remap[root as usize];
}
self.paints = paints;
}
}
#[cfg(test)]
mod tests {
use super::face::{assemble, Face};
use super::graph::BoundaryGraph;
use super::*;
use crate::ir::Paint;
use visioncortex::{Color, PointF64};
/// Build a label map from a row-major grid (for tests).
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
let n = max.map(|m| m as usize + 1).unwrap_or(0);
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
LabelMap {
width,
height,
labels,
paints,
}
}
/// Reconstruct a face's contour polygons in exact lattice coordinates.
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
face.contours
.iter()
.map(|contour| {
let mut ring: Vec<PointF64> = Vec::new();
for (i, sref) in contour.0.iter().enumerate() {
let pts = &graph.segments[sref.seg as usize].points;
let ordered: Vec<PointF64> = if sref.forward {
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
} else {
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
};
if i == 0 {
ring.extend(ordered);
} else {
ring.extend(ordered[1..].iter().copied());
}
}
ring
})
.collect()
}
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Winding number of point `p` w.r.t. a closed ring (last == first).
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
let mut wn = 0;
for w in ring.windows(2) {
let (a, b) = (w[0], w[1]);
if a.y <= p.y {
if b.y > p.y && is_left(a, b, p) > 0.0 {
wn += 1;
}
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
wn -= 1;
}
}
wn
}
/// The strongest guarantee: rasterize the composed faces at pixel centers
/// and assert the result is byte-identical to the input label map.
fn assert_pixel_roundtrip(map: &LabelMap) {
let graph = BoundaryGraph::extract(map);
let faces = assemble(&graph, map);
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
.iter()
.map(|f| (f.region, face_polygons(&graph, f)))
.collect();
for y in 0..map.height as i32 {
for x in 0..map.width as i32 {
let center = PointF64 {
x: x as f64 + 0.5,
y: y as f64 + 0.5,
};
let mut hits: Vec<RegionId> = Vec::new();
for (region, rings) in &polys {
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
if wn != 0 {
hits.push(*region);
}
}
let expected = map.label(x, y);
if expected == OUTSIDE {
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
} else {
assert_eq!(
hits,
vec![expected],
"({x},{y}) expected region {expected}, got {hits:?}"
);
}
}
}
}
#[test]
fn single_region_is_one_ring() {
let map = grid(3, 2, vec![0; 6]);
let graph = BoundaryGraph::extract(&map);
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
assert_eq!(graph.segments.len(), 1, "one border ring");
assert!(graph.segments[0].is_ring());
assert_pixel_roundtrip(&map);
}
#[test]
fn vertical_split() {
// 4x2, left half 0, right half 1.
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
let graph = BoundaryGraph::extract(&map);
// Two border junctions where the split meets the top and bottom edges.
assert_eq!(graph.nodes.len(), 2);
assert_pixel_roundtrip(&map);
}
#[test]
fn t_junction() {
// top row one region, bottom row split — a degree-3 interior node.
let map = grid(2, 2, vec![0, 0, 1, 2]);
assert_pixel_roundtrip(&map);
}
#[test]
fn checkerboard_pinch() {
// A B / B A — the center corner is a degree-4 pinch; each region is two
// lobes touching there. (The four boundary/border corners are degree-3
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
// the real check that the pinch produces exact, simple contours.
let map = grid(2, 2, vec![0, 1, 1, 0]);
let graph = BoundaryGraph::extract(&map);
let has_degree4 = graph.nodes.iter().any(|n| {
let c = n.corner;
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
});
assert!(has_degree4, "expected a degree-4 pinch node at the center");
assert_pixel_roundtrip(&map);
}
#[test]
fn disjoint_patches_of_one_region_get_separate_faces() {
// Region 0 appears as two islands, separated by a column of region 1.
// Each island must get its own face, so they cannot share a path.
#[rustfmt::skip]
let map = grid(3, 2, vec![
0, 1, 0,
0, 1, 0,
]);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
assert_eq!(
faces.iter().filter(|f| f.region == 0).count(),
2,
"each island of region 0 gets its own face"
);
assert_eq!(faces.len(), 3, "two islands of region 0, plus region 1");
assert_pixel_roundtrip(&map);
}
#[test]
fn diagonal_lobes_share_one_face() {
// A B / B A — region 0's lobes meet only at the center corner, which the
// successor rule pinches into a single contour. They must stay in one
// face: splitting them could separate a hole contour from the ring that
// encloses it, and a lone hole ring fills solid under `nonzero`.
#[rustfmt::skip]
let map = grid(2, 2, vec![
0, 1,
1, 0,
]);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
assert_eq!(
faces.iter().filter(|f| f.region == 0).count(),
1,
"diagonally touching lobes stay in one face"
);
assert_pixel_roundtrip(&map);
}
#[test]
fn nested_rings() {
// Concentric squares: 0 outer, 1 middle, 2 center.
let l = |x: i32, y: i32| -> RegionId {
let d = x.min(y).min(5 - x).min(5 - y);
match d {
0 => 0,
1 => 1,
_ => 2,
}
};
let mut labels = Vec::new();
for y in 0..6 {
for x in 0..6 {
labels.push(l(x, y));
}
}
assert_pixel_roundtrip(&grid(6, 6, labels));
}
#[test]
fn outside_region_border_touching() {
// A region that does not fill the canvas; the rest is OUTSIDE.
let mut labels = vec![OUTSIDE; 16];
for y in 1..3 {
for x in 1..3 {
labels[y * 4 + x] = 0;
}
}
assert_pixel_roundtrip(&grid(4, 4, labels));
}
#[test]
fn spline_segments_pin_endpoints_to_lattice() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
// A shape with junctions so there are open (non-ring) segments.
let map = grid(4, 4, vec![
0, 0, 1, 1,
0, 0, 1, 1,
2, 2, 1, 1,
2, 2, 2, 2,
]);
let graph = BoundaryGraph::extract(&map);
let fitter = SplineSegmentFitter::default();
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
let fitted = fitter.fit_open(seg);
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
let end = {
let p = seg.points[seg.points.len() - 1];
PointF64 { x: p.x as f64, y: p.y as f64 }
};
match fitted.geom {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned to node");
assert_eq!(b.last().unwrap()[3], end, "end pinned to node");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
#[test]
fn curve_passes_keep_segment_endpoints_pinned() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
use crate::simplify::{CurvePass, SimplifyCurves};
// Simplification runs per shared segment; junction nodes must not
// move or the faces meeting there would disagree.
let map = grid(4, 4, vec![
0, 0, 1, 1,
0, 0, 1, 1,
2, 2, 1, 1,
2, 2, 2, 2,
]);
let graph = BoundaryGraph::extract(&map);
let fitter = SplineSegmentFitter::default();
let pass = SimplifyCurves {
tolerance: 2.0,
corner_threshold: std::f64::consts::PI / 3.0,
};
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
let end = {
let p = seg.points[seg.points.len() - 1];
PointF64 { x: p.x as f64, y: p.y as f64 }
};
match pass.open(fitter.fit_open(seg).geom) {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned through pass");
assert_eq!(b.last().unwrap()[3], end, "end pinned through pass");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
/// Build a label map with explicit per-region gray levels.
fn gray_grid(width: u32, height: u32, labels: Vec<RegionId>, grays: &[u8]) -> LabelMap {
LabelMap {
width,
height,
labels,
paints: grays
.iter()
.map(|&g| Paint::Solid(Color::new(g, g, g)))
.collect(),
}
}
#[test]
fn merge_similar_rejoins_close_neighbours() {
// Three vertical strips: 100 | 106 | 220. Diff(0,1) = 18 ≤ 20 → merge;
// the merged mean (103) vs 220 stays far apart.
#[rustfmt::skip]
let mut map = gray_grid(3, 2, vec![
0, 1, 2,
0, 1, 2,
], &[100, 106, 220]);
map.merge_similar(20);
assert_eq!(map.paints.len(), 2, "strips 0 and 1 merge; 2 survives");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(0, 0), map.label(2, 0));
// Area-weighted mean of two equal strips of 100 and 106.
assert_eq!(map.paints[map.label(0, 0) as usize].color().r, 103);
assert_pixel_roundtrip(&map);
}
#[test]
fn merge_similar_uses_running_means_not_original_colors() {
// Gradient chain 100 | 103 | 106 with threshold 9 (grays g apart diff
// by 3g across the three channels). The closest pair merges first
// (ties broken by id → strips 0,1 → mean 101); the merged region vs
// 106 is then 15 apart, over threshold — the chain must NOT collapse
// transitively into one region on the strength of the original colors.
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 103, 106]);
map.merge_similar(9);
assert_eq!(map.paints.len(), 2, "running mean stops the chain");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(1, 0), map.label(2, 0));
}
#[test]
fn merge_similar_ignores_outside_and_non_neighbours() {
// Two same-colored regions separated by OUTSIDE: not adjacent, so they
// must stay distinct faces (merging them would create a disjoint
// region, which face assembly handles, but the ids must stay honest to
// the partition).
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, OUTSIDE, 1], &[100, 100]);
map.merge_similar(20);
assert_eq!(map.paints.len(), 2, "non-adjacent regions never merge");
assert_eq!(map.label(1, 0), OUTSIDE, "outside pixels are untouched");
assert_pixel_roundtrip(&map);
}
#[test]
fn merge_similar_zero_threshold_merges_only_identical_colors() {
// Regions 0 and 1 share a color; region 2 differs by one level. At
// threshold 0 the identical pair merges, the near-identical one stays.
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 100, 101]);
map.merge_similar(0);
assert_eq!(map.paints.len(), 2, "identical neighbours merge at 0");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(1, 0), map.label(2, 0));
// A negative threshold disables merging entirely.
let labels = vec![0, 1, 0, 1];
let mut map = gray_grid(2, 2, labels.clone(), &[100, 100]);
map.merge_similar(-1);
assert_eq!(map.labels, labels);
assert_eq!(map.paints.len(), 2);
}
#[test]
fn compose_mosaic_merges_gradient_faces() {
use super::compose_mosaic;
use super::fit::PixelSegmentFitter;
use crate::ir::{Layer, RegionMask, Segmentation};
use visioncortex::BinaryImage;
// A 6x2 canvas of three 2px strips, one gradient step apart (diff 6),
// as bottom-to-top layers — exactly what a stacked gradient flattens
// into. With merging they are one face; without, three.
let mut seg = Segmentation::new(6, 2);
for (i, g) in [(0, 100u8), (1, 102), (2, 104)] {
let mut image = BinaryImage::new_w_h(2, 2);
for y in 0..2 {
for x in 0..2 {
image.set_pixel(x, y, true);
}
}
seg.layers.push(Layer {
paint: Paint::Solid(Color::new(g, g, g)),
mask: RegionMask::new(
image,
visioncortex::PointI32 { x: i * 2, y: 0 },
),
});
}
let unmerged = compose_mosaic(&seg, &PixelSegmentFitter, 0, &[]);
let merged = compose_mosaic(&seg, &PixelSegmentFitter, 16, &[]);
assert_eq!(unmerged.shapes.len(), 3);
assert_eq!(merged.shapes.len(), 1, "gradient strips coalesce into one face");
assert_eq!(merged.shapes[0].paint.color().r, 102, "area-weighted mean");
}
#[test]
fn random_maps_roundtrip() {
// Deterministic LCG; connectivity not required.
let mut state: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as u32
};
for _ in 0..40 {
let w = 2 + next() % 10;
let h = 2 + next() % 10;
let nlabels = 1 + next() % 5;
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
assert_pixel_roundtrip(&grid(w, h, labels));
}
}
}
+29 -6
View File
@@ -7,6 +7,7 @@
//! both sides.
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use crate::simplify::CurvePass;
use visioncortex::PointF64;
use super::face::{assemble, Contour, Face};
@@ -14,9 +15,26 @@ use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
use super::graph::BoundaryGraph;
use super::{LabelMap, Segmentation};
/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose.
pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter) -> VectorDoc {
let map = LabelMap::from_segmentation(seg);
/// Run the full mosaic pipeline: flatten → merge similar neighbours →
/// boundary graph → faces → fit → curve passes → compose.
///
/// `merge_diff` is the color-difference threshold for
/// [`LabelMap::merge_similar`]; pass the clustering `deepen_diff`
/// (gradient step) so the flattened mosaic rejoins what only the stacked
/// gradient layering had split. `0` still merges identical-color
/// neighbours; negative disables merging entirely.
///
/// `passes` run on each fitted segment before composition — once per shared
/// boundary, so both adjacent faces reference the transformed geometry and
/// the tessellation stays seam-free.
pub fn compose_mosaic(
seg: &Segmentation,
fitter: &dyn SegmentFitter,
merge_diff: i32,
passes: &[Box<dyn CurvePass>],
) -> VectorDoc {
let mut map = LabelMap::from_segmentation(seg);
map.merge_similar(merge_diff);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
@@ -25,11 +43,16 @@ pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter) -> VectorD
.segments
.iter()
.map(|s| {
if s.is_ring() {
fitter.fit_ring(s)
let ring = s.is_ring();
let mut geom = if ring {
fitter.fit_ring(s).geom
} else {
fitter.fit_open(s)
fitter.fit_open(s).geom
};
for pass in passes {
geom = if ring { pass.ring(geom) } else { pass.open(geom) };
}
FittedSegment { geom }
})
.collect();
+114 -22
View File
@@ -6,22 +6,108 @@
//! with opposite winding automatically — no containment/nesting computation is
//! needed, and the region can be filled with a single `nonzero` path.
use std::collections::BTreeMap;
use super::graph::{
edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef,
dir_from_delta, edge_present, left_pixel_at, left_pixel_coord, reverse, straight, turn_left,
turn_right, BoundaryGraph, SegRef,
};
use super::{LabelMap, RegionId, OUTSIDE};
/// Island id for pixels that belong to no region.
const NO_ISLAND: u32 = u32::MAX;
/// A closed cycle of directed segments bounding (part of) a region.
#[derive(Clone, Debug)]
pub struct Contour(pub Vec<SegRef>);
/// One region and all of its contours (outer + holes).
/// One connected patch of a region and all of its contours (outer + holes).
///
/// A region can appear as several disjoint patches; each gets its own face, so
/// isolated islands never share a path.
#[derive(Clone, Debug)]
pub struct Face {
pub region: RegionId,
pub contours: Vec<Contour>,
}
/// Connected-component ("island") id per pixel, grouping equal labels with
/// 8-connectivity. [`OUTSIDE`] pixels get [`NO_ISLAND`].
///
/// 8-connectivity is what matches [`successor`]: it pinches a checkerboard
/// corner into one contour, so two lobes meeting only at a diagonal are walked
/// as a single contour and must land in a single face. Splitting them
/// (4-connectivity) could put a hole contour in a different face than the ring
/// enclosing it, and a lone hole ring fills solid under `nonzero`.
fn islands(map: &LabelMap) -> Vec<u32> {
let (w, h) = (map.width as usize, map.height as usize);
let mut ids = vec![NO_ISLAND; w * h];
let mut next = 0u32;
let mut stack: Vec<(usize, usize)> = Vec::new();
for start in 0..w * h {
if ids[start] != NO_ISLAND || map.labels[start] == OUTSIDE {
continue;
}
let label = map.labels[start];
let id = next;
next += 1;
ids[start] = id;
stack.push((start % w, start / w));
while let Some((x, y)) = stack.pop() {
for dy in -1i32..=1 {
for dx in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let n = ny as usize * w + nx as usize;
if ids[n] == NO_ISLAND && map.labels[n] == label {
ids[n] = id;
stack.push((nx as usize, ny as usize));
}
}
}
}
}
ids
}
/// Which island a contour bounds, taken from the region-side pixel flanking its
/// first directed edge. Every contour is walked with its region on the left, so
/// that pixel is always interior to the patch the contour belongs to — an outer
/// ring and the holes inside it therefore agree.
fn island_of(graph: &BoundaryGraph, map: &LabelMap, ids: &[u32], r: SegRef) -> u32 {
let seg = &graph.segments[r.seg as usize];
let (corner, dir) = if seg.is_ring() {
let n = seg.points.len();
// A ring is used forward by the region on its left, reversed by the one
// on its right; take the first step of the chosen direction.
let (from, to) = if r.forward {
(seg.points[0], seg.points[1])
} else {
(seg.points[n - 1], seg.points[n - 2])
};
(from, dir_from_delta(to.x - from.x, to.y - from.y))
} else if r.forward {
let node = seg.start.expect("non-ring segment has a start node");
(graph.nodes[node as usize].corner, seg.first_dir)
} else {
let node = seg.end.expect("non-ring segment has an end node");
(graph.nodes[node as usize].corner, reverse(seg.last_dir))
};
let (px, py) = left_pixel_coord(corner.x, corner.y, dir);
if px < 0 || py < 0 || px as u32 >= map.width || py as u32 >= map.height {
return NO_ISLAND;
}
ids[py as usize * map.width as usize + px as usize]
}
/// Left region of a directed segment view.
fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId {
let seg = &graph.segments[r.seg as usize];
@@ -45,7 +131,12 @@ fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
}
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
let mut by_region: Vec<Vec<Contour>> = vec![Vec::new(); map.paints.len()];
let ids = islands(map);
// Keyed by (region, island) rather than by region alone, so disjoint patches
// of one region become separate faces — and separate paths downstream. The
// BTreeMap keeps face order deterministic: region ascending, then island in
// raster-scan order.
let mut by_island: BTreeMap<(RegionId, u32), Vec<Contour>> = BTreeMap::new();
// usage[seg][0] = forward view used, [1] = backward view used.
let mut used = vec![[false; 2]; graph.segments.len()];
@@ -84,8 +175,12 @@ pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
break;
}
}
if (region as usize) < by_region.len() {
by_region[region as usize].push(Contour(contour));
if (region as usize) < map.paints.len() {
let island = island_of(graph, map, &ids, start);
by_island
.entry((region, island))
.or_default()
.push(Contour(contour));
}
}
}
@@ -96,27 +191,24 @@ pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
if !seg.is_ring() {
continue;
}
if seg.left != OUTSIDE && (seg.left as usize) < by_region.len() {
by_region[seg.left as usize].push(Contour(vec![SegRef {
for (region, forward) in [(seg.left, true), (seg.right, false)] {
if region == OUTSIDE || (region as usize) >= map.paints.len() {
continue;
}
let r = SegRef {
seg: seg_id as u32,
forward: true,
}]));
}
if seg.right != OUTSIDE && (seg.right as usize) < by_region.len() {
by_region[seg.right as usize].push(Contour(vec![SegRef {
seg: seg_id as u32,
forward: false,
}]));
forward,
};
let island = island_of(graph, map, &ids, r);
by_island
.entry((region, island))
.or_default()
.push(Contour(vec![r]));
}
}
by_region
by_island
.into_iter()
.enumerate()
.filter(|(_, c)| !c.is_empty())
.map(|(region, contours)| Face {
region: region as RegionId,
contours,
})
.map(|((region, _island), contours)| Face { region, contours })
.collect()
}
+8 -14
View File
@@ -8,18 +8,11 @@ use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideS
use super::graph::Segment;
pub use crate::fitter::FittedGeom;
/// Outset ratio for the 4-point subdivision scheme (matches visioncortex).
const OUTSET_RATIO: f64 = 8.0;
/// Fitted geometry for one boundary segment.
#[derive(Clone, Debug)]
pub enum FittedGeom {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
/// A fitted segment, cached and indexed by segment id.
#[derive(Clone, Debug)]
pub struct FittedSegment {
@@ -98,7 +91,7 @@ impl SegmentFitter for PolygonSegmentFitter {
/// gapless. A distance-based DP can't do this: near the √2/2 threshold it
/// can't separate staircase noise from real curvature. Smoothing and per-slice
/// cubic fitting then reuse the same visioncortex machinery stacked mode uses
/// (open-path variants of the smoothing primitives + `fit_points_with_bezier`),
/// (open-path variants of the smoothing primitives + `fit_points_with_beziers`),
/// so the curve character matches stacked.
#[derive(Debug, Clone)]
pub struct SplineSegmentFitter {
@@ -146,14 +139,15 @@ fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] {
/// uses in `Spline::from_path_f64`, so mosaic curves have the same character.
const FIT_ERROR: f64 = 10.0;
/// Fit one splice slice into a single cubic, exactly as stacked mode does
/// (`fit_points_with_bezier`: one retract-handled cubic per slice, endpoints
/// pinned to the slice ends).
/// Fit one splice slice, exactly as stacked mode does
/// (`fit_points_with_beziers`: the full retract-handled cubic chain per slice,
/// outer endpoints pinned to the slice ends — a sparse or multi-curve slice is
/// kept faithful instead of being collapsed onto one ballooning cubic).
fn fit_slice(slice: &[PointF64], out: &mut Vec<[PointF64; 4]>) {
match slice.len() {
0 | 1 => {}
2 => out.push(straight_cubic(slice[0], slice[1])),
_ => out.push(SubdivideSmooth::fit_points_with_bezier(slice, FIT_ERROR)),
_ => out.extend(SubdivideSmooth::fit_points_with_beziers(slice, FIT_ERROR)),
}
}
+23 -10
View File
@@ -325,20 +325,33 @@ impl BoundaryGraph {
}
}
/// Pixel flanking the left of the directed edge leaving `(x,y)` in `d`. May be
/// out of bounds, in which case it is [`OUTSIDE`] as far as the map is concerned.
pub(super) fn left_pixel_coord(x: i32, y: i32, d: u8) -> (i32, i32) {
match d {
N => (x - 1, y - 1),
E => (x, y - 1),
S => (x, y),
W => (x - 1, y),
_ => (x, y),
}
}
/// Unit direction of a single lattice step.
pub(super) fn dir_from_delta(dx: i32, dy: i32) -> u8 {
DVEC.iter()
.position(|&v| v == (dx, dy))
.expect("consecutive lattice points differ by one unit step") as u8
}
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
/// face-assembly successor rule against a [`LabelMap`].
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
let nw = map.label(x - 1, y - 1);
let ne = map.label(x, y - 1);
let sw = map.label(x - 1, y);
let se = map.label(x, y);
match d {
N => nw,
E => ne,
S => se,
W => sw,
_ => OUTSIDE,
if !matches!(d, N | E | S | W) {
return OUTSIDE;
}
let (px, py) = left_pixel_coord(x, y, d);
map.label(px, py)
}
// Direction constants and edge-present test needed by face assembly.
-323
View File
@@ -1,323 +0,0 @@
//! Mosaic mode: a seam-free, gapless tessellation.
//!
//! Instead of tracing every region independently (which lets neighboring
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
//!
//! ```text
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
//! ```
//!
//! Every boundary curve exists exactly once; the two adjacent regions
//! reference the same fitted geometry, one traversed reversed. Reversal is
//! exact, so the serialized coordinates match on both sides — no seams.
//!
//! Stages 12 (graph + faces) are pure integer arithmetic on the lattice of
//! pixel corners. Only fitting (stage 3) is floating point.
mod compose;
mod face;
mod fit;
mod graph;
pub use compose::compose_mosaic;
pub use fit::{
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
use crate::ir::{Paint, Segmentation};
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
pub type RegionId = u32;
/// Sentinel label for pixels outside any region.
pub const OUTSIDE: RegionId = u32::MAX;
/// A flat partition of the canvas: one region id per pixel, plus the paint for
/// each region. This is the sole input to the boundary-graph extractor.
#[derive(Debug, Clone)]
pub struct LabelMap {
pub width: u32,
pub height: u32,
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
pub labels: Vec<RegionId>,
/// Paint per region, indexed by label.
pub paints: Vec<Paint>,
}
impl LabelMap {
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
/// pixel takes the paint of the topmost layer covering it. Layers are
/// bottom-to-top, so painting them in order lets higher layers win.
pub fn from_segmentation(seg: &Segmentation) -> Self {
let w = seg.width as usize;
let h = seg.height as usize;
let mut labels = vec![OUTSIDE; w * h];
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
for (i, layer) in seg.layers.iter().enumerate() {
let mask = &layer.mask;
for ly in 0..mask.image.height {
for lx in 0..mask.image.width {
if mask.image.get_pixel(lx, ly) {
let gx = mask.offset.x + lx as i32;
let gy = mask.offset.y + ly as i32;
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
labels[gy as usize * w + gx as usize] = i as RegionId;
}
}
}
}
}
LabelMap {
width: seg.width,
height: seg.height,
labels,
paints,
}
}
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
/// Treating outside as a real label removes all image-border special cases.
#[inline]
pub fn label(&self, x: i32, y: i32) -> RegionId {
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
return OUTSIDE;
}
self.labels[y as usize * self.width as usize + x as usize]
}
}
#[cfg(test)]
mod tests {
use super::face::{assemble, Face};
use super::graph::BoundaryGraph;
use super::*;
use crate::ir::Paint;
use visioncortex::{Color, PointF64};
/// Build a label map from a row-major grid (for tests).
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
let n = max.map(|m| m as usize + 1).unwrap_or(0);
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
LabelMap {
width,
height,
labels,
paints,
}
}
/// Reconstruct a face's contour polygons in exact lattice coordinates.
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
face.contours
.iter()
.map(|contour| {
let mut ring: Vec<PointF64> = Vec::new();
for (i, sref) in contour.0.iter().enumerate() {
let pts = &graph.segments[sref.seg as usize].points;
let ordered: Vec<PointF64> = if sref.forward {
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
} else {
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
};
if i == 0 {
ring.extend(ordered);
} else {
ring.extend(ordered[1..].iter().copied());
}
}
ring
})
.collect()
}
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Winding number of point `p` w.r.t. a closed ring (last == first).
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
let mut wn = 0;
for w in ring.windows(2) {
let (a, b) = (w[0], w[1]);
if a.y <= p.y {
if b.y > p.y && is_left(a, b, p) > 0.0 {
wn += 1;
}
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
wn -= 1;
}
}
wn
}
/// The strongest guarantee: rasterize the composed faces at pixel centers
/// and assert the result is byte-identical to the input label map.
fn assert_pixel_roundtrip(map: &LabelMap) {
let graph = BoundaryGraph::extract(map);
let faces = assemble(&graph, map);
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
.iter()
.map(|f| (f.region, face_polygons(&graph, f)))
.collect();
for y in 0..map.height as i32 {
for x in 0..map.width as i32 {
let center = PointF64 {
x: x as f64 + 0.5,
y: y as f64 + 0.5,
};
let mut hits: Vec<RegionId> = Vec::new();
for (region, rings) in &polys {
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
if wn != 0 {
hits.push(*region);
}
}
let expected = map.label(x, y);
if expected == OUTSIDE {
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
} else {
assert_eq!(
hits,
vec![expected],
"({x},{y}) expected region {expected}, got {hits:?}"
);
}
}
}
}
#[test]
fn single_region_is_one_ring() {
let map = grid(3, 2, vec![0; 6]);
let graph = BoundaryGraph::extract(&map);
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
assert_eq!(graph.segments.len(), 1, "one border ring");
assert!(graph.segments[0].is_ring());
assert_pixel_roundtrip(&map);
}
#[test]
fn vertical_split() {
// 4x2, left half 0, right half 1.
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
let graph = BoundaryGraph::extract(&map);
// Two border junctions where the split meets the top and bottom edges.
assert_eq!(graph.nodes.len(), 2);
assert_pixel_roundtrip(&map);
}
#[test]
fn t_junction() {
// top row one region, bottom row split — a degree-3 interior node.
let map = grid(2, 2, vec![0, 0, 1, 2]);
assert_pixel_roundtrip(&map);
}
#[test]
fn checkerboard_pinch() {
// A B / B A — the center corner is a degree-4 pinch; each region is two
// lobes touching there. (The four boundary/border corners are degree-3
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
// the real check that the pinch produces exact, simple contours.
let map = grid(2, 2, vec![0, 1, 1, 0]);
let graph = BoundaryGraph::extract(&map);
let has_degree4 = graph.nodes.iter().any(|n| {
let c = n.corner;
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
});
assert!(has_degree4, "expected a degree-4 pinch node at the center");
assert_pixel_roundtrip(&map);
}
#[test]
fn nested_rings() {
// Concentric squares: 0 outer, 1 middle, 2 center.
let l = |x: i32, y: i32| -> RegionId {
let d = x.min(y).min(5 - x).min(5 - y);
match d {
0 => 0,
1 => 1,
_ => 2,
}
};
let mut labels = Vec::new();
for y in 0..6 {
for x in 0..6 {
labels.push(l(x, y));
}
}
assert_pixel_roundtrip(&grid(6, 6, labels));
}
#[test]
fn outside_region_border_touching() {
// A region that does not fill the canvas; the rest is OUTSIDE.
let mut labels = vec![OUTSIDE; 16];
for y in 1..3 {
for x in 1..3 {
labels[y * 4 + x] = 0;
}
}
assert_pixel_roundtrip(&grid(4, 4, labels));
}
#[test]
fn spline_segments_pin_endpoints_to_lattice() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
// A shape with junctions so there are open (non-ring) segments.
let map = grid(4, 4, vec![
0, 0, 1, 1,
0, 0, 1, 1,
2, 2, 1, 1,
2, 2, 2, 2,
]);
let graph = BoundaryGraph::extract(&map);
let fitter = SplineSegmentFitter::default();
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
let fitted = fitter.fit_open(seg);
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
let end = {
let p = seg.points[seg.points.len() - 1];
PointF64 { x: p.x as f64, y: p.y as f64 }
};
match fitted.geom {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned to node");
assert_eq!(b.last().unwrap()[3], end, "end pinned to node");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
#[test]
fn random_maps_roundtrip() {
// Deterministic LCG; connectivity not required.
let mut state: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as u32
};
for _ in 0..40 {
let w = 2 + next() % 10;
let h = 2 + next() % 10;
let nlabels = 1 + next() % 5;
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
assert_pixel_roundtrip(&grid(w, h, labels));
}
}
}
@@ -1,11 +1,13 @@
//! Optimizer passes over the [`VectorDoc`] before serialization.
//!
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
//! it here (rather than at write time) lets [`SimplifyPass`] act on the
//! it here (rather than at write time) lets [`CleanupPass`] act on the
//! rounded geometry, and it bakes offsets into coordinates so the writer
//! never needs a per-path `translate`.
//! * [`SimplifyPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created.
//! * [`CleanupPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created. (Curve *simplification* is not an
//! optimizer pass: it must run on shared fitted geometry before composition
//! — see [`crate::simplify`].)
use visioncortex::PointF64;
@@ -63,7 +65,7 @@ impl OptimizerPass for QuantizePass {
/// Remove zero-length segments and collinear-redundant line vertices.
#[derive(Debug, Clone, Copy, Default)]
pub struct SimplifyPass;
pub struct CleanupPass;
/// Tolerance for treating two points as coincident.
const COINCIDENT_EPS: f64 = 1e-6;
@@ -84,7 +86,7 @@ fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool {
(cross.abs() / base) < COLLINEAR_EPS
}
fn simplify_subpath(sub: &SubPath) -> SubPath {
fn cleanup_subpath(sub: &SubPath) -> SubPath {
let mut out = SubPath::new();
// `prev` is the point active before the last emitted command; `last` is the
// current point after it. Both are needed to test collinearity of a run.
@@ -127,12 +129,12 @@ fn simplify_subpath(sub: &SubPath) -> SubPath {
out
}
impl OptimizerPass for SimplifyPass {
impl OptimizerPass for CleanupPass {
fn run(&self, doc: &mut VectorDoc) {
for shape in &mut doc.shapes {
let mut subpaths = Vec::with_capacity(shape.path.subpaths.len());
for sub in &shape.path.subpaths {
let simplified = simplify_subpath(sub);
let simplified = cleanup_subpath(sub);
// Keep only subpaths with real geometry (a MoveTo plus at least
// one drawing command beyond Close).
let draws = simplified
@@ -185,7 +187,7 @@ mod tests {
}
#[test]
fn simplify_drops_collinear_and_zero_length() {
fn cleanup_drops_collinear_and_zero_length() {
// A straight run of colinear points plus a duplicate should collapse.
let mut doc = doc_with(vec![
PathCmd::MoveTo(pt(0.0, 0.0)),
@@ -195,7 +197,7 @@ mod tests {
PathCmd::LineTo(pt(2.0, 5.0)),
PathCmd::Close,
]);
SimplifyPass.run(&mut doc);
CleanupPass.run(&mut doc);
let cmds = &doc.shapes[0].path.subpaths[0].commands;
// MoveTo, one merged horizontal LineTo, one vertical LineTo, Close.
assert_eq!(cmds.len(), 4);
+91 -4
View File
@@ -6,8 +6,10 @@ use crate::colorfit::ColorFitter;
use crate::compose::Compositing;
use crate::error::Error;
use crate::frontend::Frontend;
use crate::ir::VectorDoc;
use crate::ir::{Segmentation, VectorDoc};
use crate::optimize::OptimizerPass;
use crate::progress::{CancelToken, Ctx, Phase, Progress};
use crate::simplify::CurvePass;
use crate::svg::SvgWriter;
/// A fully-assembled vectorization pipeline. Build one with
@@ -16,24 +18,109 @@ pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub compositing: Compositing,
/// Geometry passes over fitted contours (e.g. curve simplification), run
/// inside compositing — after curve fitting, before paths are assembled —
/// so mosaic mode applies them once per shared boundary segment.
pub curve_passes: Vec<Box<dyn CurvePass>>,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
pub writer: SvgWriter,
}
impl Pipeline {
/// Run the pipeline to the output document IR (before serialization).
///
/// Equivalent to [`run_with_progress`](Pipeline::run_with_progress) with a
/// fresh (never-cancelled) token and a no-op progress callback.
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
let mut seg = self.frontend.segment(img)?;
self.run_with_progress(img, &CancelToken::new(), &mut |_| {})
}
/// Run the pipeline, publishing [`Progress`] updates and honoring the
/// [`CancelToken`].
///
/// Intended to be called on a worker thread: hand a clone of `cancel` to
/// the UI so a button can abort, and forward `on_progress` to a channel
/// that drives a progress bar. Returns [`Error::Cancelled`] if the token is
/// tripped. See [`crate::progress`] for a usage example.
pub fn run_with_progress(
&self,
img: &ColorImage,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<VectorDoc, Error> {
let mut ctx = Ctx::new(cancel, on_progress);
let seg = self.frontend.segment_with(img, &mut ctx)?;
// `seg` is owned and about to be consumed, so no clone is needed here.
self.finish_ctx(seg, &mut ctx)
}
/// Phase 1 of 2 — run **only** the frontend (the expensive clustering step)
/// and return a reusable [`Segmentation`].
///
/// Cache the result and feed it to [`finish`](Pipeline::finish) to
/// re-render with different color-fitting, curve-fitting, or optimization
/// parameters *without repaying the clustering cost* — the core of an
/// interactive tuning loop. Re-run `segment` when a parameter that affects
/// clustering itself changes: filter speckle, color precision, layer
/// difference, binary threshold, or the frontend choice.
pub fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
self.segment_with_progress(img, &CancelToken::new(), &mut |_| {})
}
/// [`segment`](Pipeline::segment) with progress reporting and cancellation.
pub fn segment_with_progress(
&self,
img: &ColorImage,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<Segmentation, Error> {
let mut ctx = Ctx::new(cancel, on_progress);
self.frontend.segment_with(img, &mut ctx)
}
/// Phase 2 of 2 — color fitting → compositing → optimization, reusing a
/// [`Segmentation`] produced by [`segment`](Pipeline::segment).
///
/// The segmentation is cloned internally (color fitting mutates it), so the
/// cached copy stays pristine and can be reused across many `finish` calls
/// with different pipelines. The frontend of `self` is not used here; build
/// the tuning pipeline with the color/curve/optimize parameters you want
/// and the *same* clustering parameters that produced `seg`.
pub fn finish(&self, seg: &Segmentation) -> Result<VectorDoc, Error> {
self.finish_with_progress(seg, &CancelToken::new(), &mut |_| {})
}
/// [`finish`](Pipeline::finish) with progress reporting and cancellation.
/// Progress starts at the [`Phase::Compose`] stage (segmentation is skipped).
pub fn finish_with_progress(
&self,
seg: &Segmentation,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<VectorDoc, Error> {
let mut ctx = Ctx::new(cancel, on_progress);
self.finish_ctx(seg.clone(), &mut ctx)
}
/// Downstream stages (color fit → compose → optimize) over an owned
/// segmentation. Shared by the one-shot and two-phase entry points; takes
/// ownership so the one-shot path avoids a clone.
fn finish_ctx(&self, mut seg: Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
for fitter in &self.color_fitters {
fitter.fit(&mut seg);
ctx.check()?;
}
let mut doc = self.compositing.compose(&seg);
let mut doc = self.compositing.compose_with(&seg, &self.curve_passes, ctx)?;
for pass in &self.optimizers {
let total = self.optimizers.len().max(1);
for (i, pass) in self.optimizers.iter().enumerate() {
ctx.check()?;
pass.run(&mut doc);
ctx.report(Phase::Optimize, (i + 1) as f32 / total as f32);
}
// Always emit a terminal 100% so a UI can settle even with no passes.
ctx.report(Phase::Optimize, 1.0);
Ok(doc)
}
+113
View File
@@ -0,0 +1,113 @@
//! Progress reporting and cancellation for long-running conversions.
//!
//! [`crate::Pipeline::run_with_progress`] takes a [`CancelToken`] and a
//! progress callback. On native targets, run it on a worker thread: the
//! callback publishes [`Progress`] to the UI and the token lets the UI abort
//! between work batches (clustering checks once per batch, so cancellation is
//! near-instant). The pipeline returns [`Error::Cancelled`] when the token is
//! tripped.
//!
//! There is deliberately no cooperative `tick()` here: that only existed in the
//! old browser build because the main thread could not block. The same API
//! works unchanged from a Web Worker.
//!
//! ```no_run
//! use vtracer::{Config, ColorImage};
//! use vtracer::progress::{CancelToken, Progress};
//!
//! # fn load() -> ColorImage { todo!() }
//! let pipeline = Config::default().build().unwrap();
//! let cancel = CancelToken::new();
//! # let img: ColorImage = load();
//! // hand `cancel.clone()` to the UI so a button can call `cancel.cancel()`
//! let mut on_progress = |p: Progress| eprintln!("{:?} {:.0}%", p.phase, p.fraction * 100.0);
//! let doc = pipeline.run_with_progress(&img, &cancel, &mut on_progress);
//! ```
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::error::Error;
/// A cheaply-clonable cancellation flag shared between the UI and the worker.
///
/// Clone it, hand one copy to the worker thread running the pipeline and keep
/// the other; call [`cancel`](CancelToken::cancel) from any thread to request
/// an early stop. Clones share the same underlying flag.
#[derive(Clone, Default)]
pub struct CancelToken(Arc<AtomicBool>);
impl CancelToken {
/// A fresh, un-cancelled token.
pub fn new() -> Self {
Self::default()
}
/// Request cancellation. Idempotent; safe to call from any thread.
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
/// Whether cancellation has been requested.
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// Which pipeline phase a [`Progress`] update belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
/// Frontend segmentation (color clustering) — usually the dominant cost.
Segment,
/// Compositing the segmentation into shapes.
Compose,
/// Output optimization passes.
Optimize,
}
/// A progress update: the current [`Phase`] and how far through it we are.
///
/// `fraction` is *within* the phase, in `0.0..=1.0`. Clustering dominates
/// runtime, so a UI can weight the phases or simply show the phase label with
/// its fraction (e.g. "Clustering 45%").
#[derive(Clone, Copy, Debug)]
pub struct Progress {
pub phase: Phase,
pub fraction: f32,
}
/// Bundles the cancel token and progress sink threaded through the stages.
///
/// Stages call [`Ctx::check`] between batches to honor cancellation and
/// [`Ctx::report`] to publish progress.
pub struct Ctx<'a> {
cancel: &'a CancelToken,
on_progress: &'a mut dyn FnMut(Progress),
}
impl<'a> Ctx<'a> {
/// Construct a context from a token and a progress callback.
pub fn new(cancel: &'a CancelToken, on_progress: &'a mut dyn FnMut(Progress)) -> Self {
Self {
cancel,
on_progress,
}
}
/// Return [`Error::Cancelled`] if cancellation has been requested.
pub fn check(&self) -> Result<(), Error> {
if self.cancel.is_cancelled() {
Err(Error::Cancelled)
} else {
Ok(())
}
}
/// Publish a progress update for `phase` at `fraction` (clamped to 0..=1).
pub fn report(&mut self, phase: Phase, fraction: f32) {
(self.on_progress)(Progress {
phase,
fraction: fraction.clamp(0.0, 1.0),
});
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Interactive tuning session: cache the expensive clustering, re-render on the
//! cheap stages, and re-segment automatically only when it's actually needed.
//!
//! A desktop app loads an image once, then calls [`Session::render`] on every
//! slider change with a fresh [`Config`]. The session compares the config's
//! [`SegmentKey`](crate::config::SegmentKey) to what it last clustered and
//! re-segments only if a clustering parameter changed — the caller never has to
//! know which parameters those are.
//!
//! For watershed clustering there is a second cache level: the
//! [`WatershedHierarchy`] depends only on the image, so it is built once and
//! every re-segmentation (a detail or speckle change) is a near-instant re-cut
//! of the cached hierarchy rather than a rebuild.
//!
//! ```no_run
//! use vtracer::{Config, Session, ColorImage};
//! # fn load() -> ColorImage { todo!() }
//! let mut session = Session::new(load());
//!
//! // First render clusters the image.
//! let mut cfg = Config::default();
//! let _svg = session.render_svg(&cfg).unwrap();
//!
//! // Tuning a curve parameter reuses the cached segmentation (no re-cluster).
//! cfg.corner_threshold = 90;
//! let _svg = session.render_svg(&cfg).unwrap();
//!
//! // Changing a clustering parameter re-segments automatically.
//! cfg.filter_speckle = 8;
//! let _svg = session.render_svg(&cfg).unwrap();
//! ```
use visioncortex::ColorImage;
use crate::config::{Clustering, Config, SegmentKey};
use crate::error::Error;
use crate::frontend::WatershedHierarchy;
use crate::ir::{Segmentation, VectorDoc};
use crate::pipeline::Pipeline;
use crate::progress::{CancelToken, Ctx, Phase, Progress};
/// A reusable converter for one image: clusters once, re-renders many times.
///
/// Build it with the source [`ColorImage`] and drive it with a [`Config`] per
/// render. The cached [`Segmentation`] is refreshed transparently whenever the
/// config's clustering parameters change.
pub struct Session {
img: ColorImage,
/// The segmentation and the key it was produced with (`None` until the
/// first render).
cache: Option<(SegmentKey, Segmentation)>,
/// The image's watershed hierarchy, built lazily on the first watershed
/// render. Parameter-free, so it never goes stale while the image lives.
hierarchy: Option<WatershedHierarchy>,
}
impl Session {
/// Start a session over `img`. Nothing is clustered until the first render.
pub fn new(img: ColorImage) -> Self {
Self {
img,
cache: None,
hierarchy: None,
}
}
/// Whether the cached segmentation is missing or was clustered with
/// different parameters than `key`.
fn stale(&self, key: &SegmentKey) -> bool {
self.cache.as_ref().map_or(true, |(k, _)| k != key)
}
/// The cached segmentation. Only call after ensuring the cache is fresh.
fn segmentation(&self) -> &Segmentation {
&self.cache.as_ref().expect("cache populated by caller").1
}
/// Produce a fresh segmentation for `cfg`. Watershed goes through the
/// hierarchy cache (build once, cut cheaply); everything else runs the
/// pipeline's frontend.
fn segment(&mut self, cfg: &Config, pipeline: &Pipeline) -> Result<Segmentation, Error> {
if cfg.clustering == Clustering::Watershed {
if self.hierarchy.is_none() {
self.hierarchy = Some(WatershedHierarchy::build(&self.img)?);
}
let hierarchy = self.hierarchy.as_ref().expect("just built");
Ok(hierarchy.cut(&self.img, cfg.watershed_detail, cfg.speckle_area()))
} else {
pipeline.segment(&self.img)
}
}
/// Render to the document IR, re-segmenting only if `cfg`'s clustering
/// parameters differ from the cached segmentation's.
pub fn render(&mut self, cfg: &Config) -> Result<VectorDoc, Error> {
let pipeline = cfg.build()?;
let key = cfg.segment_key();
if self.stale(&key) {
let seg = self.segment(cfg, &pipeline)?;
self.cache = Some((key, seg));
}
pipeline.finish(self.segmentation())
}
/// [`render`](Session::render), serialized to an SVG string.
pub fn render_svg(&mut self, cfg: &Config) -> Result<String, Error> {
let pipeline = cfg.build()?;
let key = cfg.segment_key();
if self.stale(&key) {
let seg = self.segment(cfg, &pipeline)?;
self.cache = Some((key, seg));
}
Ok(pipeline.writer.write(&pipeline.finish(self.segmentation())?))
}
/// [`render`](Session::render) with progress reporting and cancellation.
///
/// When a re-segmentation is needed, progress covers the [`Phase::Segment`]
/// stage first, then the finish stages; on a cache hit only the finish
/// stages report. Hand a clone of `cancel` to the UI to abort a long
/// clustering pass. (A watershed re-cut over a cached hierarchy is fast
/// enough that it reports coarsely.)
pub fn render_with_progress(
&mut self,
cfg: &Config,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<VectorDoc, Error> {
let pipeline = cfg.build()?;
let key = cfg.segment_key();
if self.stale(&key) {
let seg = if cfg.clustering == Clustering::Watershed {
let mut ctx = Ctx::new(cancel, on_progress);
ctx.check()?;
let seg = self.segment(cfg, &pipeline)?;
ctx.check()?;
ctx.report(Phase::Segment, 1.0);
seg
} else {
pipeline.segment_with_progress(&self.img, cancel, on_progress)?
};
self.cache = Some((key, seg));
}
pipeline.finish_with_progress(self.segmentation(), cancel, on_progress)
}
/// Drop the cached segmentation and hierarchy, forcing the next render to
/// re-cluster. Use after replacing the source image out of band; normally
/// unnecessary.
pub fn invalidate(&mut self) {
self.cache = None;
self.hierarchy = None;
}
/// The source image this session renders.
pub fn image(&self) -> &ColorImage {
&self.img
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Curve passes: geometry transforms between curve fitting and composition.
//!
//! A [`CurvePass`] rewrites one fitted contour at a time. Passes run *before*
//! composition on the fitted geometry itself — in mosaic mode each shared
//! boundary segment is transformed exactly once and both adjacent faces
//! reference the result, so the tessellation stays seam-free by construction.
//! Running instead on the composed [`VectorDoc`](crate::ir::VectorDoc) would
//! re-fit the two copies of every shared boundary independently and reopen
//! the seams the mosaic exists to prevent.
//!
//! The built-in pass is [`SimplifyCurves`], the paper.js `simplify` analogue.
use flo_curves::bezier::{fit_curve_cubic, Curve};
use flo_curves::Coord2;
use visioncortex::PointF64;
use crate::fitter::FittedGeom;
/// A geometry pass over one fitted contour, run between curve fitting and
/// composition. Implementations must keep an open chain's endpoints exactly
/// (mosaic junction nodes must not move) and keep a ring closed.
pub trait CurvePass {
/// Transform an open chain; both endpoints are pinned.
fn open(&self, geom: FittedGeom) -> FittedGeom;
/// Transform a closed ring.
fn ring(&self, geom: FittedGeom) -> FittedGeom;
}
/// paper.js-style curve simplification (Schneider's fit): re-fit each smooth
/// run of cubics between corners with the fewest curves that stay within
/// `tolerance` of the fitted geometry.
///
/// The spline fitters cut an outline at every splice point and fit each short
/// slice separately, so a lazily curving edge carries an anchor per splice.
/// This pass samples the fitted curve (~1 px spacing) and re-fits whole
/// corner-to-corner runs with `flo_curves`' Schneider implementation
/// (`fit_curve_cubic`, tangents taken from the chain's own ends), merging
/// those slices down to what the tolerance genuinely requires.
///
/// A run is replaced only when the re-fit uses strictly fewer cubics and is
/// kept verbatim otherwise, so the pass never increases the curve count and
/// never moves the geometry more than `tolerance` (measured at the samples).
/// Polylines (pixel / polygon modes) pass through untouched.
#[derive(Debug, Clone, Copy)]
pub struct SimplifyCurves {
/// Maximum distance (px) the simplified curve may stray from the fitted
/// one. paper.js defaults to 2.5.
pub tolerance: f64,
/// Tangent-break angle (radians) above which an anchor is a corner and
/// must survive in place; runs are re-fitted between corners.
pub corner_threshold: f64,
}
impl CurvePass for SimplifyCurves {
fn open(&self, geom: FittedGeom) -> FittedGeom {
match geom {
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, false)),
other => other,
}
}
fn ring(&self, geom: FittedGeom) -> FittedGeom {
match geom {
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, true)),
other => other,
}
}
}
impl SimplifyCurves {
fn simplify_chain(&self, mut chain: Vec<[PointF64; 4]>, closed: bool) -> Vec<[PointF64; 4]> {
if self.tolerance <= 0.0 || chain.len() < 2 {
return chain;
}
if closed {
// The re-fit pins run endpoints, so a ring needs a seam. Put it at
// the sharpest junction (wraparound included): a corner the fit
// would keep anyway, or the least-smooth anchor when the ring has
// none, so any residual tangent break lands where it hides best.
let angles: Vec<f64> = (0..chain.len())
.map(|k| {
let prev = if k == 0 { chain.len() - 1 } else { k - 1 };
break_angle(&chain[prev], &chain[k])
})
.collect();
let seam = angles
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
chain.rotate_left(seam);
}
// Cut into smooth runs at corner anchors (chain ends are always cuts).
let mut cuts: Vec<usize> = vec![0];
for i in 1..chain.len() {
if break_angle(&chain[i - 1], &chain[i]) >= self.corner_threshold {
cuts.push(i);
}
}
cuts.push(chain.len());
let mut out: Vec<[PointF64; 4]> = Vec::with_capacity(chain.len());
for w in cuts.windows(2) {
let run = &chain[w[0]..w[1]];
if run.len() < 2 {
out.extend_from_slice(run);
continue;
}
match refit_run(run, self.tolerance) {
Some(refit) if refit.len() < run.len() => out.extend(refit),
_ => out.extend_from_slice(run),
}
}
out
}
}
/// Schneider-fit one smooth run: sample it, then `fit_curve_cubic` with the
/// run's own end tangents (`end_tangent` points backward, per its contract).
/// The recursion splits at sample points, so consecutive fitted cubics share
/// endpoints exactly; the outer endpoints are pinned to the run's, bit for
/// bit. Returns `None` for degenerate (point-like) runs.
fn refit_run(run: &[[PointF64; 4]], tolerance: f64) -> Option<Vec<[PointF64; 4]>> {
let start_tan = tangent_out(run.first()?)?;
let end_tan = tangent_in(run.last()?)?;
let samples: Vec<Coord2> = sample_run(run, tolerance)
.into_iter()
.map(|p| Coord2(p.x, p.y))
.collect();
let fitted: Vec<Curve<Coord2>> = fit_curve_cubic(
&samples,
&Coord2(start_tan.0, start_tan.1),
&Coord2(-end_tan.0, -end_tan.1),
tolerance,
);
if fitted.is_empty() {
return None;
}
let pt = |c: Coord2| PointF64 { x: c.0, y: c.1 };
let mut out: Vec<[PointF64; 4]> = fitted
.into_iter()
.map(|c| [pt(c.start_point), pt(c.control_points.0), pt(c.control_points.1), pt(c.end_point)])
.collect();
out.first_mut()?[0] = run[0][0];
out.last_mut()?[3] = run[run.len() - 1][3];
Some(out)
}
fn dist(a: PointF64, b: PointF64) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
/// Unit direction a→b, or `None` when the points (nearly) coincide.
fn dir(a: PointF64, b: PointF64) -> Option<(f64, f64)> {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
None
} else {
Some((dx / len, dy / len))
}
}
/// Tangent arriving at a cubic's end: the last distinct control point wins.
fn tangent_in(c: &[PointF64; 4]) -> Option<(f64, f64)> {
dir(c[2], c[3]).or_else(|| dir(c[1], c[3])).or_else(|| dir(c[0], c[3]))
}
/// Tangent leaving a cubic's start: the first distinct control point wins.
fn tangent_out(c: &[PointF64; 4]) -> Option<(f64, f64)> {
dir(c[0], c[1]).or_else(|| dir(c[0], c[2])).or_else(|| dir(c[0], c[3]))
}
/// Turn angle at the junction of two consecutive cubics. A fully degenerate
/// (point-like) neighbour counts as a corner so it is never smoothed across.
fn break_angle(prev: &[PointF64; 4], next: &[PointF64; 4]) -> f64 {
match (tangent_in(prev), tangent_out(next)) {
(Some(a), Some(b)) => (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0).acos(),
_ => std::f64::consts::PI,
}
}
fn cubic_at(c: &[PointF64; 4], t: f64) -> PointF64 {
let u = 1.0 - t;
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
PointF64 {
x: b0 * c[0].x + b1 * c[1].x + b2 * c[2].x + b3 * c[3].x,
y: b0 * c[0].y + b1 * c[1].y + b2 * c[2].y + b3 * c[3].y,
}
}
/// Sample a run of cubics at roughly 1 px spacing (by control-polygon length),
/// tighter when the tolerance is sub-pixel — the fit measures its error at
/// the samples, so their spacing is the fidelity guard. The first and last
/// samples are the run's endpoints, exactly: `cubic_at` with `t = 1` returns
/// `c[3]` bit for bit.
fn sample_run(run: &[[PointF64; 4]], tolerance: f64) -> Vec<PointF64> {
let spacing = tolerance.clamp(0.25, 1.0);
let mut samples = vec![run[0][0]];
for c in run {
let len = dist(c[0], c[1]) + dist(c[1], c[2]) + dist(c[2], c[3]);
let n = ((len / spacing).ceil() as usize).clamp(1, 512);
for k in 1..=n {
samples.push(cubic_at(c, k as f64 / n as f64));
}
}
samples
}
#[cfg(test)]
mod tests {
use super::*;
fn pt(x: f64, y: f64) -> PointF64 {
PointF64 { x, y }
}
/// A degenerate cubic tracing the straight line `a`→`b`.
fn straight(a: PointF64, b: PointF64) -> [PointF64; 4] {
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
[a, lerp(1.0 / 3.0), lerp(2.0 / 3.0), b]
}
/// `n` straight cubics subdividing the segment `a`→`b`.
fn straight_chain(a: PointF64, b: PointF64, n: usize) -> Vec<[PointF64; 4]> {
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
(0..n)
.map(|i| straight(lerp(i as f64 / n as f64), lerp((i + 1) as f64 / n as f64)))
.collect()
}
/// One cubic approximating the circular arc `a0..a1` on a circle of
/// radius `r` about the origin (the classic 4/3·tan(Δ/4) handle length).
fn arc_cubic(r: f64, a0: f64, a1: f64) -> [PointF64; 4] {
let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan();
let (p0, p3) = (pt(r * a0.cos(), r * a0.sin()), pt(r * a1.cos(), r * a1.sin()));
[
p0,
pt(p0.x - k * r * a0.sin(), p0.y + k * r * a0.cos()),
pt(p3.x + k * r * a1.sin(), p3.y - k * r * a1.cos()),
p3,
]
}
fn pass() -> SimplifyCurves {
SimplifyCurves {
tolerance: 1.0,
corner_threshold: std::f64::consts::PI / 3.0,
}
}
fn anchors(chain: &[[PointF64; 4]]) -> Vec<PointF64> {
let mut a: Vec<PointF64> = chain.iter().map(|c| c[0]).collect();
a.push(chain.last().unwrap()[3]);
a
}
#[test]
fn collinear_run_collapses_to_one_cubic() {
let chain = straight_chain(pt(0.0, 0.0), pt(100.0, 0.0), 10);
let out = match pass().open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 1, "ten collinear cubics become one");
assert_eq!(out[0][0], pt(0.0, 0.0), "start pinned");
assert_eq!(out[0][3], pt(100.0, 0.0), "end pinned");
}
#[test]
fn corner_survives_in_place() {
// An L: two straight runs meeting at a right angle.
let mut chain = straight_chain(pt(0.0, 0.0), pt(50.0, 0.0), 5);
chain.extend(straight_chain(pt(50.0, 0.0), pt(50.0, 50.0), 5));
let out = match pass().open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 2, "one cubic per leg");
assert_eq!(out[0][3], pt(50.0, 0.0), "corner anchor exact");
assert_eq!(out[1][0], pt(50.0, 0.0), "chain continuous through corner");
assert_eq!(out[0][0], pt(0.0, 0.0));
assert_eq!(out[1][3], pt(50.0, 50.0));
}
#[test]
fn ring_stays_closed_and_keeps_square_corners() {
// A closed square, three cubics per side, seam mid-side (anchor 0 is
// smooth) — the pass must rotate the seam onto a corner.
let corners = [pt(0.0, 0.0), pt(60.0, 0.0), pt(60.0, 60.0), pt(0.0, 60.0)];
let mut chain = Vec::new();
for i in 0..4 {
chain.extend(straight_chain(corners[i], corners[(i + 1) % 4], 3));
}
chain.rotate_left(1); // seam mid-side
let out = match pass().ring(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 4, "one cubic per side");
assert_eq!(out[0][0], out.last().unwrap()[3], "ring closed");
let mut got = anchors(&out);
got.pop(); // last repeats first
for c in corners {
assert!(got.contains(&c), "corner {c:?} kept, got {got:?}");
}
}
#[test]
fn arc_merges_within_tolerance() {
// A quarter circle as 8 short arcs collapses to far fewer cubics, and
// the result stays within tolerance of the true circle.
let r = 50.0;
let n = 8;
let chain: Vec<[PointF64; 4]> = (0..n)
.map(|i| {
let step = std::f64::consts::FRAC_PI_2 / n as f64;
arc_cubic(r, i as f64 * step, (i + 1) as f64 * step)
})
.collect();
let tol = 0.5;
let p = SimplifyCurves {
tolerance: tol,
corner_threshold: std::f64::consts::PI / 3.0,
};
let out = match p.open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert!(out.len() < 8, "arcs merge, got {}", out.len());
for c in &out {
for k in 0..=32 {
let q = cubic_at(c, k as f64 / 32.0);
let radial = ((q.x * q.x + q.y * q.y).sqrt() - r).abs();
assert!(radial <= tol + 0.1, "deviation {radial} beyond tolerance");
}
}
}
#[test]
fn refit_never_grows_the_chain() {
// A single cubic is untouchable; a sharp S of two cubics that cannot
// merge within a tiny tolerance is kept verbatim.
let lone = vec![arc_cubic(50.0, 0.0, 1.0)];
match pass().open(FittedGeom::Beziers(lone.clone())) {
FittedGeom::Beziers(c) => assert_eq!(c, lone),
_ => panic!("geometry kind changed"),
}
let s_curve = vec![
[pt(0.0, 0.0), pt(20.0, 40.0), pt(30.0, 40.0), pt(50.0, 0.0)],
[pt(50.0, 0.0), pt(70.0, -40.0), pt(80.0, -40.0), pt(100.0, 0.0)],
];
let tight = SimplifyCurves {
tolerance: 0.01,
corner_threshold: std::f64::consts::PI / 3.0,
};
match tight.open(FittedGeom::Beziers(s_curve.clone())) {
FittedGeom::Beziers(c) => {
assert!(c.len() <= s_curve.len(), "never more cubics than input")
}
_ => panic!("geometry kind changed"),
}
}
#[test]
fn polylines_pass_through_untouched() {
let poly = vec![pt(0.0, 0.0), pt(1.0, 0.0), pt(2.0, 0.0), pt(3.0, 0.0)];
match pass().open(FittedGeom::Polyline(poly.clone())) {
FittedGeom::Polyline(p) => assert_eq!(p, poly),
_ => panic!("polyline must stay a polyline"),
}
match pass().ring(FittedGeom::Polyline(poly.clone())) {
FittedGeom::Polyline(p) => assert_eq!(p, poly),
_ => panic!("polyline must stay a polyline"),
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Binary thresholding: tunable fixed cutoff and BradleyRoth adaptive.
use vtracer::frontend::{BinaryFrontend, Frontend};
use vtracer::{ColorImage, Threshold};
fn gray(w: usize, h: usize, f: impl Fn(usize, usize) -> u8) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let v = f(x, y);
pixels.extend_from_slice(&[v, v, v, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// Total foreground pixels selected by a frontend over an image.
fn foreground_area(front: &BinaryFrontend, img: &ColorImage) -> usize {
front
.segment(img)
.unwrap()
.layers
.iter()
.map(|l| l.mask.area())
.sum()
}
/// A uniform gray field: a lower fixed threshold selects strictly fewer pixels.
#[test]
fn fixed_threshold_is_tunable() {
// Left third value 80, middle 130, right 180.
let img = gray(60, 20, |x, _| match x / 20 {
0 => 80,
1 => 130,
_ => 180,
});
let front = |v: u8| BinaryFrontend {
threshold: Threshold::Fixed(v),
diagonal: false,
min_area: 0,
};
let low = foreground_area(&front(100), &img); // catches only the 80 band
let mid = foreground_area(&front(150), &img); // 80 + 130 bands
let high = foreground_area(&front(200), &img); // everything
assert!(
low < mid && mid < high,
"higher threshold must select more foreground: {low} < {mid} < {high}"
);
assert_eq!(high, 60 * 20, "threshold above all values selects everything");
}
/// Adaptive thresholding recovers locally-dark marks under a brightness
/// gradient that no single global cutoff can separate.
#[test]
fn adaptive_beats_fixed_under_uneven_lighting() {
let (w, h) = (80, 40);
// Background ramps left(70) → right(210). Two 6x6 marks, each 40 darker
// than their local background: one on the dark side, one on the bright side.
let bg = |x: usize| 70 + (x * 140 / (w - 1)) as u8;
let marks = [(16usize, 17usize), (60, 17)];
let is_mark = |x: usize, y: usize| {
marks
.iter()
.any(|&(mx, my)| x >= mx && x < mx + 6 && y >= my && y < my + 6)
};
let img = gray(w, h, |x, y| {
if is_mark(x, y) {
bg(x).saturating_sub(40)
} else {
bg(x)
}
});
let base = BinaryFrontend {
threshold: Threshold::Fixed(128),
diagonal: false,
min_area: 4,
};
// A global cutoff can't isolate both marks: 128 catches the dark-side mark
// but floods the whole dark half of the ramp, and misses the bright-side
// mark (~136) entirely — so fixed has no region on the bright half.
let fixed_seg = base.segment(&img).unwrap();
let fixed_area: usize = fixed_seg.layers.iter().map(|l| l.mask.area()).sum();
let mid = (w as i32) / 2;
let fixed_right = fixed_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
// Adaptive: window comfortably larger than the 6px marks so they fill.
let adaptive = BinaryFrontend {
threshold: Threshold::Adaptive {
window: 21,
t: 15.0,
},
..base.clone()
};
let adaptive_seg = adaptive.segment(&img).unwrap();
let adaptive_area: usize = adaptive_seg.layers.iter().map(|l| l.mask.area()).sum();
let adaptive_left = adaptive_seg.layers.iter().any(|l| l.mask.offset.x < mid);
let adaptive_right = adaptive_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
// The point of adaptive: it finds locally-dark marks on *both* sides of the
// ramp, where the global threshold catches only the dark half.
assert!(!fixed_right, "fixed(128) should miss the bright-side mark");
assert!(
adaptive_left && adaptive_right,
"adaptive should detect marks on both the dark and bright sides"
);
assert!(adaptive_area > 0, "adaptive must select some foreground");
assert!(
adaptive_area * 3 < fixed_area,
"adaptive should select far less than fixed's flooded half: \
adaptive={adaptive_area}, fixed={fixed_area}"
);
}
+29 -4
View File
@@ -104,12 +104,13 @@ fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
}
fn assert_equivalent(mode: FitMode) {
fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h);
let stacked = Config {
mode,
clustering,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
@@ -120,6 +121,7 @@ fn assert_equivalent(mode: FitMode) {
let cutout = Config {
mode,
clustering,
hierarchical: Hierarchical::Cutout,
..Config::default()
}
@@ -151,6 +153,10 @@ fn assert_equivalent(mode: FitMode) {
);
}
fn assert_equivalent(mode: FitMode) {
assert_equivalent_with(mode, vtracer::Clustering::ColorCluster);
}
#[test]
fn stacked_and_cutout_agree_in_interiors_spline() {
assert_equivalent(FitMode::Spline);
@@ -166,6 +172,13 @@ fn stacked_and_cutout_agree_in_interiors_pixel() {
assert_equivalent(FitMode::Pixel);
}
#[test]
fn watershed_stacked_and_cutout_agree_in_interiors() {
for mode in [FitMode::Pixel, FitMode::Spline] {
assert_equivalent_with(mode, vtracer::Clustering::Watershed);
}
}
// --- seam / show-through test -------------------------------------------------
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
@@ -180,12 +193,12 @@ fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
/// exactly the hole-punching bug this guards against.
#[test]
fn stacked_has_no_seams() {
fn assert_no_seams(clustering: vtracer::Clustering) {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h); // background fills the whole canvas
let svg = Config {
mode: FitMode::Spline,
clustering,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
@@ -210,6 +223,18 @@ fn stacked_has_no_seams() {
}
assert_eq!(
show_through, 0,
"stacked mode leaked {show_through} backdrop pixels — seams/holes in solid overdraw"
"{clustering:?} stacked leaked {show_through} backdrop pixels — seams/holes in overdraw"
);
}
#[test]
fn stacked_has_no_seams() {
assert_no_seams(vtracer::Clustering::ColorCluster);
}
/// The watershed frontend emits disjoint region masks; its full-canvas solid
/// background layer is what restores overdraw. This guards that construction.
#[test]
fn watershed_stacked_has_no_seams() {
assert_no_seams(vtracer::Clustering::Watershed);
}
+21 -2
View File
@@ -19,7 +19,7 @@
use std::path::PathBuf;
use resvg::{tiny_skia, usvg};
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
// --- synthetic image builders ------------------------------------------------
@@ -141,7 +141,7 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
"checker_bw",
checker(),
Config {
color_mode: ColorMode::Binary,
clustering: Clustering::Binary,
..base()
},
),
@@ -210,6 +210,25 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
..base()
},
),
// Watershed clustering: stacked and mosaic.
(
"disc_watershed_spline",
disc(),
Config {
clustering: Clustering::Watershed,
..base()
},
),
(
"swatches_watershed_mosaic",
swatches(),
Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
mode: FitMode::Polygon,
..base()
},
),
]
}
@@ -1,7 +1,7 @@
<?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="40">
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#000000"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#000000"/>
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 512 B

After

Width:  |  Height:  |  Size: 488 B

@@ -1,8 +1,8 @@
<?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="40">
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#28C83C"/>
<path d="M36,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#E6D228"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#323CDC"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#DC2828"/>
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#28C83C"/>
<path d="M36,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#E6D228"/>
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#323CDC"/>
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#DC2828"/>
</svg>

Before

Width:  |  Height:  |  Size: 623 B

After

Width:  |  Height:  |  Size: 591 B

@@ -1,52 +1,50 @@
<?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="M8,8V0H0V8H8Z" fill="#141414"/>
<path d="M16,0H8V8h8V0Z" fill="#EBEBEB"/>
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,40H0v8H8V40Z"/>
<path d="M48,32H40v8h8V32Z"/>
</g>
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M8,32H0v8H8V32Z"/>
<path d="M48,24H40v8h8V24Z"/>
</g>
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,24H0v8H8V24Z"/>
<path d="M48,16H40v8h8V16Z"/>
</g>
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M8,16H0v8H8V16Z"/>
<path d="M48,8H40v8h8V8Z"/>
</g>
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,8H0v8H8V8Z"/>
<path d="M48,8V0H40V8h8Z"/>
</g>
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
<path d="M24,0H16V8h8V0Z" fill="#141414"/>
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M24,0H16V8h8V0Z"/>
<path d="M8,8V0H0V8H8Z"/>
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M48,8V0H40V8h8Z"/>
<path d="M8,8H0v8H8V8Z"/>
</g>
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M48,8H40v8h8V8Z"/>
<path d="M8,16H0v8H8V16Z"/>
</g>
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M48,16H40v8h8V16Z"/>
<path d="M8,24H0v8H8V24Z"/>
</g>
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M48,24H40v8h8V24Z"/>
<path d="M8,32H0v8H8V32Z"/>
</g>
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M48,32H40v8h8V32Z"/>
<path d="M8,40H0v8H8V40Z"/>
</g>
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,7 +1,7 @@
<?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="#EBEBEB"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#EBEBEB"/>
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
<path d="M32,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,6 +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,0C0,16.32,0,32.16,0,48c16.32,0,32.16,0,48,0c0-16.32,0-32.16,0-48C31.68,0,15.84,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
<path d="M0,0C0,16,0,32,0,48c16,0,32,0,48,0c0-16,0-32,0-48C32,0,16,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
<path d="M31.56,9.75C23.31,7.33,14,8.99,9.75,17.5c-.26,.85-.5,1.68-.75,2.5c-1.33,4-1.33,4,.81,10.44c1.84,3.95,3.73,6.83,7.69,8.81c.85,.26,1.68,.5,2.5,.75c4,1.33,4,1.33,10.44-.81c3.95-1.84,6.83-3.73,8.81-7.69c.26-.85,.5-1.67,.75-2.5c.75-2.24,1.06-3.6,.69-5.94c-1.85-5.53-3.65-10.5-9.13-13.31Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

After

Width:  |  Height:  |  Size: 864 B

+1 -1
View File
@@ -1,6 +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,0C48,15.84,48,31.68,48,48C32.16,48,16.32,48,0,48C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M0,0C16,0,32,0,48,0C48,16,48,32,48,48C32,48,16,48,0,48C0,32,0,16,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06C39.1,16.2,41.16,20.44,40.91,26.15C39.91,31.12,37.77,34.55,34,38C29.69,40.33,25.67,41.47,20.81,40.5C16.02,38.91,12.35,36.5,9.89,31.95C8.06,27.32,7.57,23.62,9.15,18.85C11.43,13.88,14.28,10.99,19.31,9C25.64,7.23,29.86,8.66,35.31,12.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 549 B

+1 -1
View File
@@ -1,6 +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="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,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>

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 520 B

+1 -1
View File
@@ -1,6 +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="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,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>

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 520 B

@@ -0,0 +1,7 @@
<?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,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#E2B1B1"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM12.06,13.69c-2.83,4.54-4.51,8.24-3.72,13.65C9.88,32.82,12.06,35.92,17,39c4.43,2.03,7.98,2.31,12.69,1c5.03-1.99,7.88-4.88,10.16-9.85c1.58-4.77,1.09-8.47-.74-13.1c-2.46-4.55-6.13-6.96-10.92-8.55c-6.36-1.27-11.46,.92-16.13,5.19Z" 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: 839 B

+2 -2
View File
@@ -1,7 +1,7 @@
<?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="#285AC8"/>
<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,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#285AC8"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
<path d="M29,16c2.56,1.44,2.56,1.44,4,4c.75,4.29,.71,7.73-1.44,11.56C27.73,33.71,24.29,33.75,20,33c-2.56-1.44-2.56-1.44-4-4c-.75-4.29-.71-7.73,1.44-11.56C21.27,15.29,24.71,15.25,29,16Z" fill="#F5F5F5"/>
</svg>

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 781 B

+16 -16
View File
@@ -1,20 +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="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="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#55FF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#555580"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FFAA80"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#55AA80"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF0080"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#550080"/>
<path d="M24,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AAFF80"/>
<path d="M0,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#00FF80"/>
<path d="M24,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AAAA80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#00AA80"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA5580"/>
<path d="M0,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#005580"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA0080"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0C0,8.04,0,4.08,0,0Z" fill="#000080"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#55FF80"/>
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#555580"/>
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FFAA80"/>
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#55AA80"/>
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF0080"/>
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#550080"/>
<path d="M24,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAFF80"/>
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00FF80"/>
<path d="M24,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAAA80"/>
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00AA80"/>
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA5580"/>
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#005580"/>
<path d="M24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA0080"/>
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0Z" fill="#000080"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,17 +1,17 @@
<?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="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#FFFF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#AA2A80"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#AA2A80"/>
<path d="M0,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#FFFF80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
<path d="M0,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0C0,8.04,0,4.08,0,0ZM24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#FFFF80"/>
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#AA2A80"/>
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#4B9280"/>
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#AA2A80"/>
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#FFFF80"/>
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0ZM24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 1.0 KiB

+16 -2
View File
@@ -1,6 +1,6 @@
//! End-to-end pipeline smoke tests over synthetic images.
use vtracer::{ColorImage, ColorMode, Config, FitMode, Hierarchical};
use vtracer::{ColorImage, Clustering, Config, FitMode, Hierarchical};
/// Build a `size × size` image split into two vertical color bands.
fn two_band_image(size: usize) -> ColorImage {
@@ -52,13 +52,27 @@ fn all_fit_modes_produce_svg() {
fn binary_pipeline_produces_svg() {
let img = two_band_image(32);
let config = Config {
color_mode: ColorMode::Binary,
clustering: Clustering::Binary,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn watershed_pipeline_produces_svg() {
let img = two_band_image(32);
for hierarchical in [Hierarchical::Stacked, Hierarchical::Cutout] {
let config = Config {
clustering: Clustering::Watershed,
hierarchical,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
}
#[test]
fn optimize_levels_shrink_or_match() {
let img = two_band_image(48);
+98
View File
@@ -0,0 +1,98 @@
//! Progress reporting and cancellation for `Pipeline::run_with_progress`.
use std::cell::Cell;
use vtracer::progress::{CancelToken, Phase, Progress};
use vtracer::{ColorImage, Config, Error};
/// A checkerboard of two colors — enough clusters that segmentation runs a few
/// batches, so incremental progress and mid-run cancellation are observable.
fn checker(w: usize, h: usize) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = if (x / 6 + y / 6) % 2 == 0 {
(210u8, 60, 60)
} else {
(60, 90, 200)
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// A token cancelled before the run starts trips promptly and yields no doc.
#[test]
fn precancelled_returns_cancelled() {
let img = checker(64, 64);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
cancel.cancel();
let mut cb = |_p: Progress| {};
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
assert_eq!(result.err(), Some(Error::Cancelled));
}
/// Cancelling from within the progress callback (on the first Segment report)
/// trips at the next batch boundary and returns `Cancelled`.
#[test]
fn cancel_during_progress_trips() {
let img = checker(96, 96);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
let saw_segment = Cell::new(false);
let mut cb = |p: Progress| {
if p.phase == Phase::Segment {
saw_segment.set(true);
cancel.cancel();
}
};
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
assert!(saw_segment.get(), "expected at least one Segment report");
assert_eq!(result.err(), Some(Error::Cancelled));
}
/// A successful run reports monotonically within each phase, ends at
/// Optimize=1.0, and produces the same shapes as the plain `run`.
#[test]
fn progress_completes_and_matches_run() {
let img = checker(64, 64);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
let last = Cell::new(None::<Progress>);
let count = Cell::new(0usize);
let mut cb = |p: Progress| {
assert!(
(0.0..=1.0).contains(&p.fraction),
"fraction out of range: {}",
p.fraction
);
last.set(Some(p));
count.set(count.get() + 1);
};
let doc = pipeline
.run_with_progress(&img, &cancel, &mut cb)
.expect("run should succeed");
assert!(count.get() > 0, "expected progress reports");
let final_p = last.get().expect("a final report");
assert_eq!(final_p.phase, Phase::Optimize);
assert_eq!(final_p.fraction, 1.0);
// Incremental clustering yields the same clusters as the blocking path,
// so both entry points produce identical output.
let plain = pipeline.run(&img).expect("plain run should succeed");
assert_eq!(doc.shapes.len(), plain.shapes.len());
}
+94
View File
@@ -0,0 +1,94 @@
//! Two-phase pipeline: cache the expensive segmentation, re-run the cheap
//! downstream stages with different parameters (the interactive tuning loop).
use vtracer::{ColorImage, Config, FitMode};
/// A few colored blocks — several clusters, a few holes.
fn blocks() -> ColorImage {
let (w, h) = (48usize, 48usize);
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = match (x / 16, y / 16) {
(0, _) => (220u8, 40, 40),
(1, 0) => (40, 200, 60),
(1, _) => (50, 60, 220),
_ => (230, 210, 40),
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
fn cfg(mode: FitMode) -> Config {
Config {
mode,
..Config::default()
}
}
/// `finish(segment(img))` equals the one-shot `run(img)`.
#[test]
fn two_phase_matches_one_shot() {
let img = blocks();
let pipeline = cfg(FitMode::Spline).build().unwrap();
let one_shot = pipeline.run(&img).unwrap();
let seg = pipeline.segment(&img).unwrap();
let two_phase = pipeline.finish(&seg).unwrap();
assert_eq!(
pipeline.writer.write(&one_shot),
pipeline.writer.write(&two_phase),
"splitting segment/finish must not change the output"
);
}
/// A cached segmentation stays pristine — `finish` can be called repeatedly and
/// deterministically (color fitting mutates only an internal clone).
#[test]
fn cached_segmentation_is_reusable() {
let img = blocks();
let pipeline = cfg(FitMode::Polygon).build().unwrap();
let seg = pipeline.segment(&img).unwrap();
let first = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
let second = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
assert_eq!(first, second, "reusing a cached segmentation must be stable");
}
/// The tuning workflow: segment once, then feed that segmentation to pipelines
/// with different curve-fitting parameters. Same regions, different geometry —
/// and no re-segmentation. (Speckle, color precision, and layer difference are
/// clustering parameters, so changing them requires a fresh `segment`.)
#[test]
fn tune_curve_fitting_on_cached_segmentation() {
let img = blocks();
// Same clustering parameters (defaults), different fit modes → the
// segmentation from one is valid input to the other's `finish`.
let pixel = cfg(FitMode::Pixel).build().unwrap();
let spline = cfg(FitMode::Spline).build().unwrap();
let seg = pixel.segment(&img).unwrap();
let doc_pixel = pixel.finish(&seg).unwrap();
let doc_spline = spline.finish(&seg).unwrap();
// Same partition → same number of shapes.
assert_eq!(doc_pixel.shapes.len(), doc_spline.shapes.len());
assert!(!doc_pixel.shapes.is_empty());
// But the fitted geometry differs (straight edges vs cubic curves).
assert_ne!(
pixel.writer.write(&doc_pixel),
spline.writer.write(&doc_spline),
"pixel and spline fitting should produce different paths"
);
}
+282
View File
@@ -0,0 +1,282 @@
//! `Session` caches the segmentation and re-segments only when a clustering
//! parameter changes — verified both at the key level and end-to-end.
use visioncortex::Color;
use vtracer::{
CancelToken, Clustering, ColorImage, Config, FitMode, Hierarchical, Session,
};
/// A few colored blocks — several clusters.
fn blocks() -> ColorImage {
let (w, h) = (48usize, 48usize);
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = match (x / 16, y / 16) {
(0, _) => (220u8, 40, 40),
(1, 0) => (40, 200, 60),
(1, _) => (50, 60, 220),
_ => (230, 210, 40),
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// The key partition: finish-phase params share a segment key; clustering
/// params change it. This is the contract `Session` relies on.
#[test]
fn segment_key_tracks_only_clustering_params() {
let base = Config::default();
// Finish-phase changes → same key (segmentation is reusable).
for tweaked in [
Config {
corner_threshold: 90,
..base.clone()
},
Config {
optimize: 0,
..base.clone()
},
Config {
hierarchical: vtracer::Hierarchical::Cutout,
..base.clone()
},
Config {
max_colors: Some(4),
..base.clone()
},
] {
assert_eq!(
base.segment_key(),
tweaked.segment_key(),
"finish-phase param must not change the segment key"
);
}
// Clustering changes → different key (must re-segment).
for tweaked in [
Config {
filter_speckle: base.filter_speckle + 4,
..base.clone()
},
Config {
color_precision: 4,
..base.clone()
},
Config {
layer_difference: 32,
..base.clone()
},
Config {
clustering: vtracer::Clustering::Binary,
..base.clone()
},
Config {
clustering: vtracer::Clustering::Watershed,
..base.clone()
},
Config {
watershed_detail: 200,
..base.clone()
},
] {
assert_ne!(
base.segment_key(),
tweaked.segment_key(),
"clustering param must change the segment key"
);
}
}
/// A `Session` render equals the one-shot pipeline — for a finish-only change
/// (reuses the cache) and for a clustering change (re-segments). Correctness is
/// identical either way; the cache is a transparent optimization.
#[test]
fn session_matches_one_shot() {
let img = blocks();
let mut session = Session::new(img.clone());
let base = Config::default();
let svg0 = session.render_svg(&base).unwrap();
assert_eq!(
svg0,
base.build().unwrap().to_svg(&img).unwrap(),
"first render must match the one-shot pipeline"
);
// Finish-only change: reuses the cached segmentation.
let tuned = Config {
corner_threshold: 90,
..base.clone()
};
assert_eq!(
session.render_svg(&tuned).unwrap(),
tuned.build().unwrap().to_svg(&img).unwrap(),
"reused-segmentation render must match the one-shot pipeline"
);
// Clustering change: re-segments, still matches the one-shot.
let respeckled = Config {
filter_speckle: base.filter_speckle + 4,
..base.clone()
};
assert_eq!(
session.render_svg(&respeckled).unwrap(),
respeckled.build().unwrap().to_svg(&img).unwrap(),
"re-segmented render must match the one-shot pipeline"
);
}
/// Blocks plus a gradient band and a small fleck — structure that makes every
/// clustering parameter (speckle, precision, gradient step, watershed detail,
/// thresholds) actually change the output.
fn textured() -> ColorImage {
let (w, h) = (48usize, 48usize);
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = if y >= 32 {
let g = 60 + (x * 3) as u8; // gradient band
(g, g, 200)
} else if (4..7).contains(&x) && (4..7).contains(&y) {
(10, 200, 10) // 9 px fleck
} else {
match (x / 16, y / 16) {
(0, _) => (220u8, 40, 40),
(1, _) => (40, 200, 60),
_ => (230, 210, 40),
}
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// The exhaustive contract: walk a cumulative sequence of config changes that
/// touches every parameter category — finish-phase dials, clustering dials,
/// frontend switches (including leaving watershed and coming back to its
/// cached hierarchy), compositing, palettes — and after each step the cached
/// session render must be byte-identical to a from-scratch one-shot pipeline.
#[test]
fn session_equals_one_shot_across_param_walk() {
let img = textured();
let mut session = Session::new(img.clone());
let mut cfg = Config::default();
let steps: Vec<(&str, fn(&mut Config))> = vec![
("initial", |_| {}),
// Finish-phase changes (cache hits).
("corner_threshold", |c| c.corner_threshold = 90),
("mode polygon", |c| c.mode = FitMode::Polygon),
("optimize 2", |c| c.optimize = 2),
("cutout", |c| c.hierarchical = Hierarchical::Cutout),
("path_precision", |c| c.path_precision = Some(1)),
// Clustering changes (re-segment).
("filter_speckle", |c| c.filter_speckle = 6),
("layer_difference", |c| c.layer_difference = 32),
("color_precision", |c| c.color_precision = 5),
// Watershed, incl. cheap re-cuts of the cached hierarchy.
("watershed", |c| c.clustering = Clustering::Watershed),
("detail 200", |c| c.watershed_detail = 200),
("detail 64", |c| c.watershed_detail = 64),
("stacked", |c| c.hierarchical = Hierarchical::Stacked),
("mode spline", |c| c.mode = FitMode::Spline),
// Binary, with both thresholding methods.
("binary", |c| c.clustering = Clustering::Binary),
("threshold 100", |c| c.binary_threshold = 100),
("adaptive", |c| c.binary_adaptive = true),
// Back to watershed: the hierarchy cache must still be valid.
("watershed again", |c| c.clustering = Clustering::Watershed),
("quantize", |c| c.max_colors = Some(4)),
// And back to the color path with a palette.
("color-cluster", |c| {
c.clustering = Clustering::ColorCluster;
c.max_colors = None;
c.palette = vec![
Color::new(0, 0, 0),
Color::new(255, 255, 255),
Color::new(200, 40, 40),
];
}),
("speckle again", |c| c.filter_speckle = 2),
];
for (name, step) in steps {
step(&mut cfg);
assert_eq!(
session.render_svg(&cfg).unwrap(),
cfg.build().unwrap().to_svg(&img).unwrap(),
"step `{name}`: cached session render must equal a full rebuild"
);
}
}
/// The progress-reporting render path (which segments through a different
/// branch, including the watershed hierarchy shortcut) produces the same
/// document as the plain path and the one-shot pipeline.
#[test]
fn render_with_progress_matches_plain_render() {
let img = textured();
for clustering in [
Clustering::ColorCluster,
Clustering::Watershed,
Clustering::Binary,
] {
let cfg = Config {
clustering,
..Config::default()
};
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
// Fresh session per variant so the progress path does the segmenting.
let mut session = Session::new(img.clone());
let doc = session
.render_with_progress(&cfg, &CancelToken::new(), &mut |_| {})
.unwrap();
let progress_svg = cfg.build().unwrap().writer.write(&doc);
assert_eq!(
progress_svg, one_shot,
"{clustering:?}: progress path must equal the one-shot pipeline"
);
// And the now-warm cache serves the plain path identically.
assert_eq!(
session.render_svg(&cfg).unwrap(),
one_shot,
"{clustering:?}: cache warmed by the progress path must match too"
);
}
}
/// `invalidate` drops all cached state; the next render rebuilds from scratch
/// and still matches.
#[test]
fn invalidate_then_render_matches() {
let img = textured();
let cfg = Config {
clustering: Clustering::Watershed,
..Config::default()
};
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
let mut session = Session::new(img);
assert_eq!(session.render_svg(&cfg).unwrap(), one_shot);
session.invalidate();
assert_eq!(
session.render_svg(&cfg).unwrap(),
one_shot,
"render after invalidate must rebuild identically"
);
}
+93
View File
@@ -0,0 +1,93 @@
//! The curve-simplification stage, end to end: `Config::simplify` must cut
//! anchor counts in both compositing modes without changing geometry kind,
//! and leave output untouched when off (the goldens enforce the byte-level
//! version of that).
use vtracer::ir::PathCmd;
use vtracer::{ColorImage, Config, FitMode, Hierarchical, VectorDoc};
/// A filled disc — one long smooth boundary, the best case for merging the
/// per-splice cubics the spline fitter emits.
fn disc_image(size: usize) -> ColorImage {
let mut pixels = Vec::with_capacity(size * size * 4);
let (c, r) = (size as f64 / 2.0, size as f64 * 0.4);
for y in 0..size {
for x in 0..size {
let (dx, dy) = (x as f64 + 0.5 - c, y as f64 + 0.5 - c);
let (rr, gg, bb) = if (dx * dx + dy * dy).sqrt() < r {
(200, 60, 60)
} else {
(240, 240, 240)
};
pixels.extend_from_slice(&[rr, gg, bb, 255]);
}
}
ColorImage {
pixels,
width: size,
height: size,
}
}
fn cubic_count(doc: &VectorDoc) -> usize {
doc.shapes
.iter()
.flat_map(|s| &s.path.subpaths)
.flat_map(|sub| &sub.commands)
.filter(|c| matches!(c, PathCmd::CubicTo(..)))
.count()
}
fn run(config: &Config) -> VectorDoc {
config.build().unwrap().run(&disc_image(128)).unwrap()
}
#[test]
fn simplify_reduces_cubics_in_stacked_mode() {
let base = Config::default();
let simplified = Config {
simplify: Some(2.0),
..Config::default()
};
let (before, after) = (cubic_count(&run(&base)), cubic_count(&run(&simplified)));
assert!(before > 0, "the disc must be traced with cubics");
assert!(
after < before,
"simplify must reduce anchors: {before} -> {after}"
);
}
#[test]
fn simplify_reduces_cubics_in_cutout_mode() {
let cutout = |simplify| Config {
hierarchical: Hierarchical::Cutout,
simplify,
..Config::default()
};
let (before, after) = (
cubic_count(&run(&cutout(None))),
cubic_count(&run(&cutout(Some(2.0)))),
);
assert!(before > 0, "the disc must be traced with cubics");
assert!(
after < before,
"simplify must reduce anchors: {before} -> {after}"
);
}
#[test]
fn simplify_leaves_polyline_modes_untouched() {
for mode in [FitMode::Pixel, FitMode::Polygon] {
let base = Config {
mode,
..Config::default()
};
let simplified = Config {
simplify: Some(2.0),
..base.clone()
};
let a = base.build().unwrap().to_svg(&disc_image(64)).unwrap();
let b = simplified.build().unwrap().to_svg(&disc_image(64)).unwrap();
assert_eq!(a, b, "{mode:?} output must not change");
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Spline fitting stays anchored to the geometry it approximates.
//!
//! Regression for the sparse-slice ballooning bug: a splice slice with very
//! uneven point spacing (a few-pixel jog then a long straight leg, produced by
//! the walker around thin strands) used to be fitted by a single cubic that
//! interpolated the samples exactly while swinging ~30 px sideways between
//! them — its control points landing far outside the shape itself. The
//! Cityscape sample at color precision 8 / gradient step 28 is the real
//! reproduction (a 1 px, 330 px-tall strand in the maroon region).
use std::path::PathBuf;
use vtracer::ir::PathCmd;
use vtracer::{ColorImage, Config, Hierarchical, VectorDoc};
fn cityscape() -> ColorImage {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg");
let img = image::open(&p).expect("sample image").to_rgba8();
let (w, h) = (img.width() as usize, img.height() as usize);
ColorImage {
pixels: img.into_raw(),
width: w,
height: h,
}
}
/// Every cubic's control points must stay within its shape's on-curve bounding
/// box plus a small overshoot allowance. The ballooning bug put handles ~25 px
/// outside the whole shape; a healthy fit stays within the fit error (10).
fn assert_handles_anchored(doc: &VectorDoc, margin: f64) {
for (si, shape) in doc.shapes.iter().enumerate() {
// Bounding box over on-curve points only.
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut on_curve = |p: &visioncortex::PointF64| {
x0 = x0.min(p.x);
y0 = y0.min(p.y);
x1 = x1.max(p.x);
y1 = y1.max(p.y);
};
for sub in &shape.path.subpaths {
for cmd in &sub.commands {
match cmd {
PathCmd::MoveTo(p) | PathCmd::LineTo(p) => on_curve(p),
PathCmd::CubicTo(_, _, p) => on_curve(p),
PathCmd::Close => {}
}
}
}
for sub in &shape.path.subpaths {
for cmd in &sub.commands {
if let PathCmd::CubicTo(c1, c2, _) = cmd {
for q in [c1, c2] {
assert!(
q.x >= x0 - margin
&& q.x <= x1 + margin
&& q.y >= y0 - margin
&& q.y <= y1 + margin,
"shape {si}: control point ({},{}) strays outside \
bbox ({x0},{y0})..({x1},{y1}) + {margin}",
q.x,
q.y
);
}
}
}
}
}
}
#[test]
fn spline_handles_stay_anchored_on_photo() {
let img = cityscape();
let base = Config {
color_precision: 8,
layer_difference: 28,
..Config::default()
};
// Stacked: per-region closed outlines through Spline::from_path_f64.
let doc = base.build().unwrap().run(&img).unwrap();
assert!(doc.shapes.len() > 500, "sanity: the trace produced real output");
assert_handles_anchored(&doc, 15.0);
// Cutout: open boundary segments through the mosaic's segment fitter.
let cutout = Config {
hierarchical: Hierarchical::Cutout,
..base
};
let doc = cutout.build().unwrap().run(&img).unwrap();
assert_handles_anchored(&doc, 15.0);
}
+682
View File
@@ -0,0 +1,682 @@
//! Watershed frontend: partition invariants, the detail dial, small-basin
//! absorption, and the hierarchy stack / cached re-cut behavior.
use vtracer::frontend::{Frontend, WatershedFrontend, WatershedHierarchy};
use vtracer::{Color, ColorImage, Clustering, Config, Hierarchical, Segmentation, Session};
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,
}
}
/// Flatten the stacked layers top-down (later layers win), returning one layer
/// index per pixel — the partition both compositors ultimately consume.
fn flatten(seg: &Segmentation) -> Vec<usize> {
let (w, h) = (seg.width as usize, seg.height as usize);
let mut labels = vec![usize::MAX; w * h];
for (li, layer) in seg.layers.iter().enumerate() {
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) {
let gx = (m.offset.x + x as i32) as usize;
let gy = (m.offset.y + y as i32) as usize;
labels[gy * w + gx] = li;
}
}
}
}
labels
}
/// The stacked-hierarchy invariants: the bottom layer is a solid full canvas
/// (so overdraw is seam-free), every pixel is covered, the flattened
/// partition has exactly `regions` distinct labels, and the stack size is
/// bounded by the merge tree (at most 2·regions 1 layers).
fn assert_stack(seg: &Segmentation, regions: usize) {
let (w, h) = (seg.width as usize, seg.height as usize);
let bottom = &seg.layers[0].mask;
assert_eq!((bottom.width(), bottom.height()), (w, h), "bottom layer is full-canvas");
assert_eq!(bottom.area(), w * h, "bottom layer is solid");
assert!(seg.layers.len() <= 2 * regions.max(1) - 1, "stack bounded by the merge tree");
let labels = flatten(seg);
assert!(labels.iter().all(|&l| l != usize::MAX), "every pixel covered");
let mut distinct: Vec<usize> = labels.clone();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(distinct.len(), regions, "flattened region count");
// The final regions must be the topmost layers (painted after every
// ancestor), or the flatten would not recover the partition.
let first_final = seg.layers.len() - regions;
assert!(
distinct.iter().all(|&l| l >= first_final),
"final regions are the topmost layers"
);
}
/// Region count of a segmentation's flattened partition.
fn regions(seg: &Segmentation) -> usize {
let mut labels = flatten(seg);
labels.sort_unstable();
labels.dedup();
labels.len()
}
/// 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_stack(&seg, 1);
}
}
/// Two clearly separated halves form two regions plus their common ancestor:
/// the stack is [root, half, half] and the flatten recovers the exact split.
#[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(), 3, "root + two final regions");
assert_stack(&seg, 2);
// Each final region is exactly one half of the canvas.
assert_eq!(seg.layers[1].mask.area(), 16 * 20);
assert_eq!(seg.layers[2].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();
let k = regions(&seg);
assert!(k >= prev, "detail={detail}: {k} < {prev}");
assert_stack(&seg, k);
prev = k;
}
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.
#[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!(regions(&keep) > regions(&absorb), "fleck absorbed");
assert_eq!(regions(&absorb), 2, "background + block survive");
assert_stack(&absorb, 2);
}
/// 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());
}
}
/// A cut of a prebuilt hierarchy equals the one-shot frontend — the contract
/// behind `Session`'s cached re-cut.
#[test]
fn hierarchy_recut_matches_one_shot() {
let img = image(48, 32, |x, y| {
(((x * 5 + y * 3) % 200) as u8, ((x / 8) * 30) as u8, ((y / 8) * 40) as u8)
});
let hierarchy = WatershedHierarchy::build(&img).unwrap();
for detail in [64u8, 128, 200] {
let recut = hierarchy.cut(&img, detail, 16);
let one_shot = WatershedFrontend {
detail,
min_area: 16,
}
.segment(&img)
.unwrap();
assert_eq!(recut.layers.len(), one_shot.layers.len(), "detail={detail}");
for (a, b) in recut.layers.iter().zip(&one_shot.layers) {
assert_eq!(a.paint, b.paint);
assert_eq!(a.mask.offset, b.mask.offset);
assert_eq!(a.mask.area(), b.mask.area());
}
}
}
/// End-to-end through `Session`: retuning watershed detail re-cuts the cached
/// hierarchy, and the output still equals the one-shot pipeline.
#[test]
fn session_recut_matches_one_shot() {
let img = image(48, 32, |x, y| {
(((x * 5 + y * 3) % 200) as u8, ((x / 8) * 30) as u8, ((y / 8) * 40) as u8)
});
let mut session = Session::new(img.clone());
let base = Config {
clustering: Clustering::Watershed,
..Config::default()
};
for detail in [128u8, 200, 64] {
let cfg = Config {
watershed_detail: detail,
..base.clone()
};
assert_eq!(
session.render_svg(&cfg).unwrap(),
cfg.build().unwrap().to_svg(&img).unwrap(),
"detail={detail}: session re-cut must match the one-shot pipeline"
);
}
}
/// Watershed + cutout is native: at max detail the partition reaches the
/// mosaic essentially untouched, so two *distinguishable* regions within one
/// gradient step stay separate faces (the color path's `merge_similar` would
/// have rejoined them). Only the just-noticeable-difference floor applies —
/// see `cutout_merge_tolerance_follows_detail`.
#[test]
fn cutout_keeps_watershed_partition() {
// Two halves 4 gray-levels apart (12 L1): close enough that the flatten
// merge (threshold = layer_difference = 16 >= 3*4) would union them, yet
// clearly above the JND floor (2).
let img = image(32, 20, |x, _| {
if x < 16 {
(100, 100, 100)
} else {
(104, 104, 104)
}
});
let cfg = Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: 255,
filter_speckle: 0,
..Config::default()
};
let doc = cfg.build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
2,
"watershed partition must pass to the mosaic unmerged"
);
}
/// The cutout merge tolerance is derived from the detail dial —
/// `max(2, (255 detail) / 8)` — because detail has no color units of its
/// own. The same two halves 12 L1 apart that max detail keeps separate (see
/// above) merge into one face at the default detail, whose tolerance (15)
/// matches the color-cluster default gradient step; and a pair a human
/// cannot tell apart (within the just-noticeable-difference floor) merges
/// even at max detail.
#[test]
fn cutout_merge_tolerance_follows_detail() {
let halves = |a: (u8, u8, u8), b: (u8, u8, u8)| {
image(32, 20, |x, _| if x < 16 { a } else { b })
};
let cfg = |detail| Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: detail,
filter_speckle: 0,
..Config::default()
};
let img = halves((100, 100, 100), (104, 104, 104));
let doc = cfg(128).build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
1,
"near-identical neighbours merge at the default detail"
);
// #863339 next to #863238 (2 L1 apart): indistinguishable by eye, so it
// must never survive as two patches, not even at maximum detail.
let img = halves((0x86, 0x33, 0x39), (0x86, 0x32, 0x38));
let doc = cfg(255).build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
1,
"sub-JND neighbours merge even at max detail"
);
}
/// Regions are 4-connected: two same-colored squares touching only at a
/// corner are separate basins (and so are the two squares of the other color).
#[test]
fn diagonal_touch_does_not_connect() {
let img = image(16, 16, |x, y| {
if (x / 8 + y / 8) % 2 == 0 {
(30, 30, 30)
} else {
(220, 220, 220)
}
});
let seg = WatershedFrontend {
detail: 255,
min_area: 0,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
assert_eq!(regions(&seg), 4, "four quadrants, none diagonally joined");
assert_ne!(labels[2 * 16 + 2], labels[10 * 16 + 10], "dark squares separate");
assert_ne!(labels[2 * 16 + 10], labels[10 * 16 + 2], "light squares separate");
assert_stack(&seg, 4);
}
/// Nested flat zones — a frame around a ring around a core — come out as
/// three exact regions, and the ring face (which has a hole) survives both
/// compositors.
#[test]
fn nested_regions() {
// Background frame 230, square ring 40 (4..28 minus 10..22), core 130.
let img = image(32, 32, |x, y| {
let ring = (4..28).contains(&x) && (4..28).contains(&y);
let core = (10..22).contains(&x) && (10..22).contains(&y);
if core {
(130, 130, 130)
} else if ring {
(40, 40, 40)
} else {
(230, 230, 230)
}
});
let seg = WatershedFrontend {
detail: 255,
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(regions(&seg), 3, "frame + ring + core");
let labels = flatten(&seg);
let at = |x: usize, y: usize| labels[y * 32 + x];
assert_ne!(at(1, 1), at(6, 6), "frame vs ring");
assert_ne!(at(6, 6), at(16, 16), "ring vs core");
assert_ne!(at(1, 1), at(16, 16), "frame vs core");
assert_stack(&seg, 3);
// The same nesting through the mosaic: three faces, ring with a hole.
let cfg = Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: 255,
filter_speckle: 0,
..Config::default()
};
let doc = cfg.build().unwrap().run(&img).unwrap();
assert_eq!(doc.shapes.len(), 3, "nested faces survive the mosaic");
}
/// Volume extinction, the hierarchy's ranking attribute: a small but vivid
/// basin (large color rise) outlives a bigger but faint one. Cutting to two
/// regions must keep the black dot, not the barely-different patch.
#[test]
fn volume_extinction_prefers_vivid_over_large() {
let img = image(48, 32, |x, y| {
if (4..7).contains(&x) && (4..7).contains(&y) {
(0, 0, 0) // 9 px, rise ~128: volume ≈ 1150
} else if (20..30).contains(&x) && (10..20).contains(&y) {
(132, 132, 132) // 100 px, rise 4: volume ≈ 400
} else {
(128, 128, 128)
}
});
let seg = WatershedFrontend {
detail: 26, // target = 2 regions
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(regions(&seg), 2);
let labels = flatten(&seg);
// The surviving split isolates the dot: its 9 pixels share a label that
// appears nowhere else.
let dot = labels[5 * 48 + 5];
let dot_area = labels.iter().filter(|&&l| l == dot).count();
assert_eq!(dot_area, 9, "the vivid dot is the kept region");
assert_eq!(
labels[15 * 48 + 25],
labels[0],
"the faint patch merged into the background"
);
}
/// Plateaus joined by short ramps — the antialiased-boundary shape. Cutting to
/// three regions recovers the plateaus, with each region's mean close to its
/// plateau value (ramp pixels split between the sides they descend from).
#[test]
fn plateaus_with_ramps() {
// Columns: 40 ×20 | ramp ×2 | 128 ×20 | ramp ×2 | 216 ×20.
let level = |x: usize| -> u8 {
match x {
0..=19 => 40,
20 => 69,
21 => 99,
22..=41 => 128,
42 => 157,
43 => 187,
_ => 216,
}
};
let img = image(64, 16, |x, _| {
let v = level(x);
(v, v, v)
});
let seg = WatershedFrontend {
detail: 40, // target = 3 regions
min_area: 4,
}
.segment(&img)
.unwrap();
assert_eq!(regions(&seg), 3);
// Means sit near the plateau values — the ramps don't form regions of
// their own or drag a mean far off.
let mut means: Vec<u8> = seg
.layers
.iter()
.rev()
.take(3)
.map(|l| l.paint.color().r)
.collect();
means.sort_unstable();
for (mean, plateau) in means.iter().zip([40u8, 128, 216]) {
assert!(
mean.abs_diff(plateau) <= 20,
"region mean {mean} strays from plateau {plateau}"
);
}
}
/// Degenerate geometries: single pixel, single row, single column.
#[test]
fn degenerate_geometries() {
let one = image(1, 1, |_, _| (7, 8, 9));
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&one)
.unwrap();
assert_eq!(seg.layers.len(), 1);
assert_stack(&seg, 1);
let row = image(16, 1, |x, _| if x < 8 { (0, 0, 0) } else { (255, 255, 255) });
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&row)
.unwrap();
assert_eq!(regions(&seg), 2, "single row splits");
assert_stack(&seg, 2);
let col = image(1, 16, |_, y| if y < 8 { (0, 0, 0) } else { (255, 255, 255) });
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&col)
.unwrap();
assert_eq!(regions(&seg), 2, "single column splits");
assert_stack(&seg, 2);
}
/// …but identical-color neighbours still collapse into one face: regions that
/// snap to the same palette entry and share a boundary must not keep a useless
/// edge between them. (The dark region sits between them in stack order, so
/// the layer-level `MergeAdjacent` cannot be the one doing the merging — only
/// the mosaic's same-color merge can.)
#[test]
fn cutout_merges_identical_palette_faces() {
let img = image(32, 32, |x, y| {
if y < 16 {
if x < 16 {
(200, 200, 200) // A: top-left
} else {
(20, 20, 20) // C: top-right
}
} else {
(180, 180, 180) // B: bottom, touches A
}
});
let cfg = Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: 255,
filter_speckle: 0,
palette: vec![Color::new(255, 255, 255), Color::new(0, 0, 0)],
..Config::default()
};
let doc = cfg.build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
2,
"A and B snap to the same palette color and share a boundary — one face"
);
}
/// An antialiased edge with pixel noise must come out straight: inside the
/// ramp the per-pixel differences are near-equal, so the raw
/// minimum-spanning-forest boundary meanders with the noise; the boundary
/// snap re-assigns ramp pixels by color proximity, landing the cut on the
/// color-midpoint iso-line (within a pixel).
#[test]
fn antialiased_edge_snaps_to_midline() {
let (w, h) = (32usize, 16usize);
let edge = |x: usize| 6.0 + 0.2 * x as f64; // nearly horizontal
let img = image(w, h, |x, y| {
// A 4-px linear ramp: adjacent in-ramp differences are near-equal,
// so without the snap the cut meanders on the noise.
let t = ((y as f64 + 0.5 - edge(x)) / 4.0 + 0.5).clamp(0.0, 1.0);
let mut v = (t * 200.0).round() as i32;
if t > 0.0 && t < 1.0 {
v += ((x * 7 + y * 13) % 5) as i32 - 2; // deterministic "sensor" noise
}
let v = v.clamp(0, 255) as u8;
(v, v, v)
});
let seg = WatershedFrontend {
detail: 26, // target 2 regions
min_area: 1,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
assert_eq!(regions(&seg), 2);
for x in 0..w {
let col: Vec<usize> = (0..h).map(|y| labels[y * w + x]).collect();
let cross: Vec<usize> = (1..h).filter(|&y| col[y] != col[y - 1]).collect();
assert_eq!(
cross.len(),
1,
"column {x} crosses the boundary exactly once, got {col:?}"
);
let dev = cross[0] as f64 - edge(x);
assert!(
dev.abs() <= 1.5,
"column {x}: boundary at row {} strays from the edge at {:.1}",
cross[0],
edge(x)
);
}
}
/// Sizes of the 4-connected components of a label map.
fn component_sizes(labels: &[usize], w: usize, h: usize) -> Vec<usize> {
let mut seen = vec![false; labels.len()];
let mut sizes = Vec::new();
let mut stack = Vec::new();
for start in 0..labels.len() {
if seen[start] {
continue;
}
let mut size = 0;
seen[start] = true;
stack.push(start);
while let Some(i) = stack.pop() {
size += 1;
let (x, y) = (i % w, i / w);
for j in [
(x > 0).then(|| i - 1),
(x + 1 < w).then(|| i + 1),
(y > 0).then(|| i - w),
(y + 1 < h).then(|| i + w),
]
.into_iter()
.flatten()
{
if !seen[j] && labels[j] == labels[i] {
seen[j] = true;
stack.push(j);
}
}
}
sizes.push(size);
}
sizes
}
/// The boundary snap must not leave debris: a pixel can flip toward a
/// neighbour whose own flip then strands it, leaving 1-px chips that the
/// mosaic turns into micro-faces wedged between the real ones (faces that
/// visually abut but no longer share a fitted boundary). Every connected
/// patch of the partition must clear the speckle floor — a *substantial*
/// patch severed at a thin antialiased neck is fine (it becomes its own
/// tight face), sub-speckle debris is not. The real photo is the
/// reproduction: its JPEG noise produced 62 such chips before the snap
/// absorbed fragments.
#[test]
fn snap_leaves_no_debris() {
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg");
let decoded = image::open(&p).expect("sample image").to_rgba8();
let (w, h) = (decoded.width() as usize, decoded.height() as usize);
let img = ColorImage {
pixels: decoded.into_raw(),
width: w,
height: h,
};
let min_area = 16;
let seg = WatershedFrontend {
detail: 128,
min_area,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
let sizes = component_sizes(&labels, w, h);
assert!(
sizes.iter().all(|&s| s >= min_area),
"smallest patch {} px is under the speckle floor ({} patches total)",
sizes.iter().min().unwrap(),
sizes.len()
);
}
/// The snap must not bulldoze genuine detail: a pixel of the *other side's*
/// color sitting across the boundary (here a bright pixel notching into the
/// dark half) is not a mixture of the two region means, so the mixture gate
/// keeps it with its color-correct basin — where a geometric smoothing
/// filter would have erased the notch.
#[test]
fn snap_keeps_genuine_color_detail() {
let (w, h) = (16usize, 16usize);
let img = image(w, h, |x, y| {
if (x, y) == (7, 7) {
(190, 190, 190) // bright pixel on the dark side of the edge
} else if x < 8 {
(0, 0, 0)
} else {
(200, 200, 200)
}
});
let seg = WatershedFrontend {
detail: 26,
min_area: 1,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
assert_eq!(regions(&seg), 2);
assert_eq!(
labels[7 * w + 7],
labels[7 * w + 8],
"the bright pixel stays with the bright region"
);
assert_ne!(labels[7 * w + 7], labels[7 * w + 6], "the notch survives");
}
+13 -3
View File
@@ -80,6 +80,11 @@ pub trait CurveFitter {
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
}
pub trait CurvePass {
fn open(&self, geom: FittedGeom) -> FittedGeom; // endpoints pinned
fn ring(&self, geom: FittedGeom) -> FittedGeom; // stays closed
}
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
@@ -91,6 +96,7 @@ pub struct Pipeline {
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub curve_passes: Vec<Box<dyn CurvePass>>,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
}
@@ -106,14 +112,16 @@ Driver flow:
3. compositing:
- **Stacked** — trace each layer's closed outlines independently (port of today's `to_compound_path` flow) via `fitter.fit_closed`
- **Mosaic** — flatten to `LabelMap`, merge adjacent same-paint regions, extract the boundary graph, fit each shared edge once via `fitter.fit_open`, assemble faces (see [mosaic.md](mosaic.md))
- either way, `CurvePass`es run on each fitted contour *before* paths are assembled — in mosaic mode that means once per shared boundary segment, so both faces reference the transformed geometry and the tessellation stays seam-free. Running them any later (on the `VectorDoc`) would re-fit the two copies of every shared boundary independently and reopen the seams.
4. optimizer passes over the `VectorDoc`
5. `SvgWriter` serializes
## 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`. Split into `WatershedHierarchy::build` (expensive, image-only) and `cut` (near-instant), so `Session` re-cuts a cached hierarchy when the detail changes. Emits the merge tree as a stacked hierarchy (root first, refined regions on top — the color-cluster principle), so stacked mode stays seam-free and sub-pixel gaps show ancestor colors; in cutout the partition reaches the mosaic untouched (`merge_diff = 0`).
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
- **ColorFitters**
- `Identity` (today's behavior: mean cluster color)
@@ -124,13 +132,15 @@ Driver flow:
- `PixelFitter` — exact lattice polyline
- `PolygonFitter` — staircase-symmetric Douglas-Peucker
- `SplineFitter` — subdivision + corner detection + least-squares cubic fit (port of the visioncortex flow, extended to open polylines with pinned endpoints)
- **CurvePasses** (selected by `Config::simplify`)
- `SimplifyCurves { tolerance, corner_threshold }` — the paper.js `simplify` analogue: samples each smooth run of fitted cubics between corners and re-fits it with the fewest curves that stay within `tolerance` px (Schneider's algorithm via a current `flo_curves`, with tangents taken from the chain's own ends; visioncortex's internal copy is pinned to an old flo_curves and block-splits at 200 points, so it is not used here). A run is only replaced when the re-fit is strictly smaller, corners stay in place, open-segment endpoints are pinned bit-for-bit, and rings are seamed at their sharpest junction. Polylines pass through untouched.
## Optimizer and SVG writer
Two levels: geometry passes over `VectorDoc`, then encoding choices in the writer.
- `QuantizePass { precision }` — round coordinates once, in document space. Replaces today's per-write rounding, and eliminates the per-path `translate(x,y)` transform by baking offsets into coordinates.
- `SimplifyPass` — drop zero-length and collinear-redundant segments *after* quantization.
- `CleanupPass` — drop zero-length and collinear-redundant segments *after* quantization. (Curve *simplification* is deliberately not an optimizer pass — see `CurvePass` above.)
- `SvgWriter { relative: bool, shorthands: bool, precision }` — per segment picks the shortest encoding:
- relative (`l c s h v`) vs absolute deltas, whichever serializes shorter
- `h`/`v` for axis-aligned lines, `s` for smooth cubic continuations
@@ -141,7 +151,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:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"version": "1.0.0-alpha.4.app.151",
"notes": "VTracer 1.0.0-alpha.4 - Build 151 (b8bcabc6)",
"pub_date": "2026-09-09T21:32:04.887Z",
"platforms": {
"macos-universal": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_universal.app.tar.gz",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTVlYOC9hZGs3V3lpeWxpYnVBOEVNbFZXVWZ1YSthY3pBdXJZdG80MHJMYWZxL24xaVNhc2lRQnlPK3FDN09mbUxrVURsa2FCTGZIZFpuNmw3SlY3emc0PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTg3MDc3CWZpbGU6VlRyYWNlci5hcHAudGFyLmd6CjIyL250TEk3VnlVUmVmWktkT0laU1E1bzNhQ0dJSVkwNWtNOFNNRUU1VmRMY3pPN252cGRiWHJUSGJwRHoyN2lZM01MS0JhcjFwZ2FQdFJESGllWkF3PT0K"
},
"windows-x86_64": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64-setup.exe",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTWQ2WGpRRE9VTjdxcE9uaGhPSHBUUWVtYi82Si9JeHN2ZDN1TkUxRnY3V0ZPL3VIeGJsM1dSV2ZFRDlOZmhSMWh2WEdRZ3JiOXdXNnRLNHFtT1Y5d3dnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTg3MjUxCWZpbGU6VlRyYWNlcl8xLjAuMC1hbHBoYS40LmFwcC4xNTFfeDY0LXNldHVwLmV4ZQpMR0pvNkVGWm42YTlzMU1nSVlYeDlCV0NZb1hHR0dIOTdMRTdkSXpxTTFlcmlZWGVkcVZzdmhmTDBsQTBUUHBCL3ptdk82OExPV0NwYUMwY1RQc21Cdz09Cg=="
},
"linux-x86_64": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64.AppImage",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTVVhRjErZk5oVWQvdWRmMndzalNVYWNBQzN0emRVM1kvMWw5KzZjcUFCS0s2TFI4Ymc1Ynl2cWtNcmJDMFBvbU9IT0VYekQzc3VsQUlLdWdHMWFzK2dNPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4MDIyMjUzCWZpbGU6VlRyYWNlcl8xLjAuMC1hbHBoYS40LmFwcC4xMTFfYW1kNjQuQXBwSW1hZ2UKT0RzUkx4azJqTmJvN3AvVFlIQUZZakVHTVFUalVWU2prajhvakp6eHRzTHVNZU5OVUV3WEZCWDd3QkY4YStqSDFqb0Qyam5XU1ZrdWVzK2JjUTI1RGc9PQo="
}
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
[package]
name = "vtracer-wasm"
description = "WebAssembly core for the vtracer Node.js package."
version = "1.0.0-alpha.1"
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2021"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/visioncortex/vtracer/"
@@ -15,7 +15,7 @@ repository = "https://github.com/visioncortex/vtracer/"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.1", path = "../crates/vtracer" }
vtracer = { version = "1.0.0-alpha.3", path = "../crates/vtracer" }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
+10 -7
View File
@@ -24,7 +24,7 @@ await vtracer.convertFile('in.jpg', 'out.svg', { mode: 'polygon', hierarchical:
const svg = vtracer.convertBuffer(fs.readFileSync('in.png'), { preset: 'poster' });
// raw RGBA8 pixels
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
```
## API
@@ -36,12 +36,15 @@ const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
### `Options` (all optional, camelCase)
`preset` (`"bw" | "poster" | "photo"`, applied first), `colorMode`
(`"color" | "bw"`), `hierarchical` (`"stacked" | "cutout"` for the seam-free
mosaic), `mode` (`"pixel" | "polygon" | "spline"`), `filterSpeckle`,
`colorPrecision`, `layerDifference`, `cornerThreshold`, `lengthThreshold`,
`maxIterations`, `spliceThreshold`, `pathPrecision`, `palette` (list of
`#rrggbb`), `maxColors`, `optimize` (`0 | 1 | 2`).
`preset` (`"bw" | "poster" | "photo"`, applied first), `clustering`
(`"color-cluster" | "bw" | "watershed"`), `hierarchical` (`"stacked" | "cutout"`
for the seam-free mosaic), `mode` (`"pixel" | "polygon" | "spline"`),
`filterSpeckle`, `colorPrecision`, `layerDifference`, `cornerThreshold`,
`lengthThreshold`, `maxIterations`, `spliceThreshold`, `simplify` (curve
simplification tolerance in px, try 12.5), `pathPrecision`, `palette` (list of
`#rrggbb`), `maxColors`, `optimize` (`0 | 1 | 2`), `binaryThreshold` /
`adaptive` / `adaptiveWindow` / `adaptiveT` (binary mode), `watershedDetail`
(0..=255).
## Build from source
+14 -1
View File
@@ -2,7 +2,8 @@
export interface Options {
/** Applied before other fields: "bw" | "poster" | "photo". */
preset?: 'bw' | 'poster' | 'photo';
colorMode?: 'color' | 'bw';
/** Region forming: hierarchical color clustering (default), binary threshold, or watershed. */
clustering?: 'color-cluster' | 'bw' | 'watershed';
hierarchical?: 'stacked' | 'cutout';
mode?: 'pixel' | 'polygon' | 'spline';
filterSpeckle?: number;
@@ -12,6 +13,8 @@ export interface Options {
lengthThreshold?: number;
maxIterations?: number;
spliceThreshold?: number;
/** Curve simplification tolerance in px (omit = off; try 1-2.5). */
simplify?: number;
pathPrecision?: number;
/** Fixed palette: `#rrggbb` strings. */
palette?: string[];
@@ -19,6 +22,16 @@ export interface Options {
maxColors?: number;
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
optimize?: number;
/** Binary mode (`clustering: 'bw'`): fixed threshold 0..=255; foreground when intensity is below it. */
binaryThreshold?: number;
/** Binary mode: use BradleyRoth adaptive thresholding (handles uneven lighting). */
adaptive?: boolean;
/** Adaptive window side length in px; 0 = auto (~1/8 of the shorter side). */
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. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@visioncortex/vtracer",
"version": "1.0.0-alpha.1",
"version": "1.0.0-alpha.3",
"description": "Raster to vector graphics converter (SVG). WebAssembly build of the vtracer framework — no native dependencies.",
"main": "index.js",
"types": "index.d.ts",
+35 -3
View File
@@ -15,7 +15,8 @@ use wasm_bindgen::prelude::*;
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Options {
color_mode: Option<String>,
/// Region forming: "color-cluster" | "bw" | "watershed".
clustering: Option<String>,
hierarchical: Option<String>,
mode: Option<String>,
filter_speckle: Option<usize>,
@@ -25,10 +26,22 @@ struct Options {
length_threshold: Option<f64>,
max_iterations: Option<usize>,
splice_threshold: Option<i32>,
/// Curve simplification tolerance in px (omit = off).
simplify: Option<f64>,
path_precision: Option<u32>,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: Option<u8>,
/// Binary-mode fixed threshold (0..=255).
binary_threshold: Option<u8>,
/// Binary mode: use BradleyRoth adaptive thresholding.
adaptive: Option<bool>,
/// Adaptive window side length in px (0 = auto).
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>,
}
@@ -63,8 +76,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)?;
@@ -93,6 +106,9 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
if let Some(v) = opts.splice_threshold {
config.splice_threshold = v;
}
if let Some(v) = opts.simplify {
config.simplify = Some(v);
}
if let Some(v) = opts.path_precision {
config.path_precision = Some(v);
}
@@ -105,6 +121,22 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
if let Some(v) = opts.optimize {
config.optimize = v;
}
if let Some(v) = opts.binary_threshold {
config.binary_threshold = v;
}
// Any adaptive tuning field (or `adaptive: true`) switches on BradleyRoth.
if opts.adaptive == Some(true) || opts.adaptive_window.is_some() || opts.adaptive_t.is_some() {
config.binary_adaptive = true;
}
if let Some(v) = opts.adaptive_window {
config.binary_adaptive_window = v;
}
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)
}
+10 -2
View File
@@ -12,11 +12,19 @@ let svg = vtracer.convertBuffer(data);
assert(svg.includes('<svg') && svg.includes('<path'), 'default convertBuffer');
console.log('convertBuffer default:', (svg.match(/<path/g) || []).length, 'paths');
// options: bw preset -> all black
svg = vtracer.convertBuffer(data, { colorMode: 'bw' });
// options: bw clustering -> all black
svg = vtracer.convertBuffer(data, { clustering: 'bw' });
assert(svg.includes('fill="#000000"'), 'bw produces black');
console.log('convertBuffer bw:', (svg.match(/<path/g) || []).length, 'paths');
// curve simplification shrinks the output
{
const plain = vtracer.convertBuffer(data);
const simplified = vtracer.convertBuffer(data, { simplify: 2 });
assert(simplified.length < plain.length, 'simplify shrinks output');
console.log('convertBuffer simplify:', plain.length, '->', simplified.length, 'bytes');
}
// options: mosaic + polygon + palette
svg = vtracer.convertBuffer(data, { hierarchical: 'cutout', mode: 'polygon', palette: ['#000000', '#ffffff'], optimize: 2 });
assert(svg.includes('<svg'), 'mosaic+palette');
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# Publish the current version of vtracer to every surface. Idempotent: each
# step is skipped if it is already done, so it is safe to re-run after a
# partial or interrupted release.
#
# Surfaces:
# - git push master, push tag (the tag push triggers the PyPI wheels)
# - crates.io vtracer, then vtracer-cli; and vtracer-bench (independent)
# - npm @visioncortex/vtracer
# - GitHub a release from the tag, marked latest (triggers the binaries)
#
# Prerequisites (all in your own shell — this cannot run in a sandbox):
# cargo login • npm login • gh auth login
# version already bumped + committed on master.
#
# Usage: ./scripts/publish.sh # confirm, then publish what's missing
# ./scripts/publish.sh --dry # checks + build/test only, no publish
#
set -euo pipefail
cd "$(dirname "$0")/.."
DRY=0
[ "${1:-}" = "--dry" ] && DRY=1
VERSION=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
TAG="$VERSION"
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
skip() { printf ' \033[2m· %s\033[0m\n' "$*"; }
# --- availability probes (used to skip already-done steps) -------------------
crate_published() { # crate, version
# crates.io rejects requests without a User-Agent (403), and `curl -f` hides
# that as an empty body — so the UA is mandatory or this never matches.
curl -fsS -H "User-Agent: vtracer-publish (github.com/visioncortex/vtracer)" \
"https://crates.io/api/v1/crates/$1/$2" 2>/dev/null | grep -q "\"num\":\"$2\""
}
npm_published() { # pkg@version
npm view "$1" version >/dev/null 2>&1
}
gh_release_exists() { gh release view "$1" >/dev/null 2>&1; }
say "Publishing vtracer $VERSION (dry-run: $DRY)"
# --- preconditions -----------------------------------------------------------
say "Checking preconditions"
[ "$(git rev-parse --abbrev-ref HEAD)" = master ] || { echo "!! not on master"; exit 1; }
[ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "!! tracked files have uncommitted changes — commit first"; exit 1; }
for tool in cargo npm gh curl; do command -v "$tool" >/dev/null || { echo "!! missing: $tool"; exit 1; }; done
# The version we publish comes from Cargo.toml at HEAD; the tag only marks the
# release commit for CI, so it need not be at HEAD (a later commit such as this
# script is fine). Guard only against the genuinely wrong case: a tag whose own
# commit carries a different version than the one we're about to publish.
if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
tag_ver=$(git show "$TAG:Cargo.toml" 2>/dev/null | grep -m1 '^version = ' | sed -E 's/.*"([^"]+)".*/\1/')
[ "$tag_ver" = "$VERSION" ] || { echo "!! tag $TAG marks version $tag_ver, but HEAD is $VERSION"; exit 1; }
skip "tag $TAG already exists (marks $VERSION)"
fi
# --- build + test (always, even on --dry) ------------------------------------
say "Building + testing"
cargo test --workspace
cargo build --release -p vtracer-cli
( cd nodejs && npm run build )
if [ "$DRY" = 1 ]; then say "Dry run complete — checks passed, nothing published."; exit 0; fi
# --- confirm -----------------------------------------------------------------
printf '\nPublish vtracer %s (git, crates.io, npm, GitHub)? Steps already done are skipped. [y/N] ' "$VERSION"
read -r reply
[ "$reply" = y ] || [ "$reply" = Y ] || { echo "aborted."; exit 1; }
# --- 1. git: push master, then the tag (tag push triggers the PyPI wheels) ---
say "git: push master + tag"
git push origin master
git rev-parse "refs/tags/$TAG" >/dev/null 2>&1 || git tag "$TAG"
git push origin "$TAG"
# --- 2. crates.io: core first, then the CLI that depends on it ---------------
if crate_published vtracer "$VERSION"; then
skip "crates.io vtracer $VERSION already published"
else
say "crates.io: publishing vtracer"
cargo publish -p vtracer
printf ' waiting for the index'
until crate_published vtracer "$VERSION"; do printf '.'; sleep 10; done; echo
fi
if crate_published vtracer-cli "$VERSION"; then
skip "crates.io vtracer-cli $VERSION already published"
else
say "crates.io: publishing vtracer-cli"
cargo publish -p vtracer-cli
fi
# vtracer-bench depends only on registry crates (not vtracer), so order is free.
if crate_published vtracer-bench "$VERSION"; then
skip "crates.io vtracer-bench $VERSION already published"
else
say "crates.io: publishing vtracer-bench"
cargo publish -p vtracer-bench
fi
# --- 3. npm ------------------------------------------------------------------
# Published to the default `latest` tag, matching the earlier alpha releases.
# For a prerelease you may prefer: ( cd nodejs && npm publish --tag next )
if npm_published "@visioncortex/vtracer@$VERSION"; then
skip "npm @visioncortex/vtracer@$VERSION already published"
else
say "npm: publishing @visioncortex/vtracer"
( cd nodejs && npm publish )
fi
# --- 4. GitHub release (its creation triggers the binary build workflow) -----
if gh_release_exists "$TAG"; then
skip "GitHub release $TAG already exists"
else
say "GitHub: creating release $TAG"
NOTES=$(awk -v h="## $VERSION" 'index($0,h)==1{f=1;next} /^## /&&f{exit} f' CHANGELOG.md)
# --latest: mark as the latest release (gh would otherwise treat an -alpha
# tag as a prerelease and not promote it).
gh release create "$TAG" --latest --title "$TAG" --notes "${NOTES:-Release $VERSION}"
fi
say "Done. crates.io + npm up to date; PyPI wheels build from the tag; binaries build from the release."
+6 -4
View File
@@ -17,12 +17,14 @@ crate-type = ["cdylib"]
default = ["console_error_panic_hook"]
[dependencies]
cfg-if = "0.1"
console_log = { version = "0.2", features = ["color"] }
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
cfg-if = "1.0"
console_log = { version = "1.0", features = ["color"] }
# `serde-serialize` was removed upstream in 0.2.84; params arrive as a JSON
# string and are parsed with serde_json, so the feature was never needed.
wasm-bindgen = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
visioncortex = "0.8.1"
visioncortex = "0.9"
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
+1
View File
@@ -0,0 +1 @@
../../docs/assets
+3933 -4877
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -7,7 +7,7 @@
"private": true,
"main": "index.js",
"scripts": {
"start": "webpack-dev-server",
"start": "webpack serve",
"build": "webpack"
},
"keywords": [
@@ -16,10 +16,10 @@
],
"dependencies": {
"vtracer": "file:../pkg",
"webpack": "^4.41.5"
"webpack": "^5.101.0"
},
"devDependencies": {
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.1"
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2"
}
}
+10 -1
View File
@@ -5,10 +5,19 @@ module.exports = {
output: {
path: path.resolve(__dirname, "dist"),
filename: "bootstrap.js",
clean: true,
},
mode: "development",
// wasm-pack's `bundler` target emits ESM imports of the .wasm module; webpack
// 5 handles those natively once this experiment is on.
experiments: {
asyncWebAssembly: true,
},
devServer: {
//host: "0.0.0.0",
//host: "0.0.0.0",
port: 8080,
// dev-server 5 defaults its static root to ./public, which would leave
// index.html unserved. `assets` symlinks to ../../docs/assets for samples.
static: { directory: __dirname },
}
};
+38 -5
View File
@@ -1,6 +1,9 @@
use wasm_bindgen::prelude::*;
use visioncortex::{Color, ColorImage, PathSimplifyMode};
use visioncortex::color_clusters::{Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX, IncrementalBuilder, KeyingAction};
use visioncortex::color_clusters::{
Cluster, Clusters, ClustersView, IncrementalBuilder, KeyingAction, NeighbourInfo, Runner,
RunnerConfig, HIERARCHICAL_MAX,
};
use crate::canvas::*;
use crate::svg::*;
@@ -38,11 +41,41 @@ pub struct ColorImageConverter {
pub enum Stage {
New,
Clustering(IncrementalBuilder),
Reclustering(IncrementalBuilder),
Clustering(Box<dyn ClusterBuilder>),
Reclustering(Box<dyn ClusterBuilder>),
Vectorize(Clusters),
}
/// visioncortex 0.9 parameterises `IncrementalBuilder` over its four closures,
/// and `Runner::start()` returns them as anonymous `impl Fn` types, so the
/// builder can no longer be named in a field. The stage machine only ever steps
/// it, so erase the closures behind a trait object.
pub trait ClusterBuilder {
fn tick(&mut self) -> bool;
fn progress(&self) -> u32;
fn result(&mut self) -> Clusters;
}
impl<C, D, P, H> ClusterBuilder for IncrementalBuilder<C, D, P, H>
where
C: Fn(Color, Color) -> bool,
D: Fn(Color, Color) -> i32,
P: Fn(&ClustersView, &Cluster, &[NeighbourInfo]) -> bool,
H: Fn(&ClustersView, &Cluster, &[NeighbourInfo]) -> bool,
{
fn tick(&mut self) -> bool {
IncrementalBuilder::tick(self)
}
fn progress(&self) -> u32 {
IncrementalBuilder::progress(self)
}
fn result(&mut self) -> Clusters {
IncrementalBuilder::result(self)
}
}
impl ColorImageConverter {
pub fn new(params: ColorImageConverterParams) -> Self {
let canvas = Canvas::new_from_id(&params.canvas_id);
@@ -106,7 +139,7 @@ impl ColorImageConverter {
KeyingAction::Discard
},
}, image);
self.stage = Stage::Clustering(runner.start());
self.stage = Stage::Clustering(Box::new(runner.start()));
}
pub fn tick(&mut self) -> bool {
@@ -138,7 +171,7 @@ impl ColorImageConverter {
key_color: Default::default(),
keying_action: KeyingAction::Discard,
}, image);
self.stage = Stage::Reclustering(runner.start());
self.stage = Stage::Reclustering(Box::new(runner.start()));
},
_ => panic!("unknown hierarchical `{}`", self.params.hierarchical)
}