Publish desktop updater 1.0.0-alpha.3

This commit is contained in:
VTracer Release Bot
2026-08-02 01:15:17 +01:00
commit daa866862b
152 changed files with 74300 additions and 0 deletions
+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
);
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "vtracer-cli"
description = "Command-line front-end for the vtracer vectorization framework."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "command-line-utilities"]
keywords = ["svg", "vectorization", "computer-graphics"]
[[bin]]
name = "vtracer"
path = "src/main.rs"
[dependencies]
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 = [
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
] }
clap = { version = "4", features = ["derive"] }
+291
View File
@@ -0,0 +1,291 @@
//! Thin command-line front-end over the `vtracer` framework.
//!
//! Handles the two things the framework deliberately leaves out: image file
//! I/O and argument parsing. Everything else is delegated to
//! [`vtracer::Config`] / [`vtracer::Pipeline`].
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use visioncortex::{Color, ColorImage};
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
/// Convert an image into vector graphics.
#[derive(Parser, Debug)]
#[command(name = "vtracer", version, about, rename_all = "kebab-case")]
struct Args {
/// Input raster image (positional; or use --input).
#[arg(value_name = "INPUT")]
input_pos: Option<PathBuf>,
/// Output SVG (positional; or use --output).
#[arg(value_name = "OUTPUT")]
output_pos: Option<PathBuf>,
/// Path to the input raster image.
#[arg(short = 'i', long = "input", value_name = "INPUT")]
input: Option<PathBuf>,
/// Path to the output SVG.
#[arg(short = 'o', long = "output", value_name = "OUTPUT")]
output: Option<PathBuf>,
/// Start from a preset: bw, poster, photo.
#[arg(long)]
preset: Option<Preset>,
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
#[arg(long)]
clustering: Option<Clustering>,
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
#[arg(long)]
hierarchical: Option<Hierarchical>,
/// Curve-fitting mode: pixel, polygon, spline.
#[arg(short, long)]
mode: Option<FitMode>,
/// Discard patches smaller than X px in size (0..=128).
#[arg(short = 'f', long, value_parser = clap::value_parser!(i64).range(0..=128))]
filter_speckle: Option<i64>,
/// Significant bits per RGB channel (1..=8).
#[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(1..=8))]
color_precision: Option<i64>,
/// Color difference between gradient layers (0..=255).
#[arg(short = 'g', long, value_parser = clap::value_parser!(i64).range(0..=255))]
gradient_step: Option<i64>,
/// Minimum momentary angle (degrees) to be a corner (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).
///
/// 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).
///
/// 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>,
/// Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'.
#[arg(long)]
palette: Option<String>,
/// Fixed palette from a file (one hex color per line or comma-separated).
#[arg(long)]
palette_file: Option<PathBuf>,
/// Auto-quantize to at most N colors.
#[arg(long)]
max_colors: Option<usize>,
/// 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 Bradley–Roth 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"))?;
if !(3.5..=10.0).contains(&v) {
return Err(format!("segment length {v} is out of range [3.5, 10]"));
}
Ok(v)
}
/// Parse a comma/whitespace/newline separated list of `#rrggbb` colors.
fn parse_palette(text: &str) -> Result<Vec<Color>, String> {
let mut colors = Vec::new();
for token in text.split(|c: char| c == ',' || c.is_whitespace()) {
let token = token.trim();
if token.is_empty() {
continue;
}
colors.push(parse_hex_color(token)?);
}
Ok(colors)
}
fn parse_hex_color(token: &str) -> Result<Color, String> {
let hex = token.strip_prefix('#').unwrap_or(token);
if hex.len() != 6 {
return Err(format!("`{token}` is not a #rrggbb color"));
}
let parse = |range: std::ops::Range<usize>| {
u8::from_str_radix(&hex[range], 16).map_err(|_| format!("`{token}` is not a #rrggbb color"))
};
Ok(Color::new(parse(0..2)?, parse(2..4)?, parse(4..6)?))
}
fn build_config(args: &Args) -> Result<Config, String> {
let mut config = match args.preset {
Some(preset) => Config::from_preset(preset),
None => Config::default(),
};
if let Some(v) = args.clustering {
config.clustering = v;
}
if let Some(v) = args.hierarchical {
config.hierarchical = v;
}
if let Some(v) = args.mode {
config.mode = v;
}
if let Some(v) = args.filter_speckle {
config.filter_speckle = v as usize;
}
if let Some(v) = args.color_precision {
config.color_precision = v as i32;
}
if let Some(v) = args.gradient_step {
config.layer_difference = v as i32;
}
if let Some(v) = args.corner_threshold {
config.corner_threshold = v as i32;
}
if let Some(v) = args.segment_length {
config.length_threshold = v;
}
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;
}
if let Some(v) = args.optimize {
config.optimize = v;
}
if let Some(v) = args.max_colors {
config.max_colors = Some(v);
}
// Binary thresholding: --adaptive (or either adaptive tuning flag) selects
// Bradley–Roth; 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}"))?;
config.palette = parse_palette(&text)?;
}
Ok(config)
}
fn read_image(path: &std::path::Path) -> Result<ColorImage, String> {
let img = image::open(path)
.map_err(|_| "no image file found at specified input path".to_string())?
.to_rgba8();
let (width, height) = (img.width() as usize, img.height() as usize);
Ok(ColorImage {
pixels: img.into_raw(),
width,
height,
})
}
fn run() -> Result<(), String> {
let args = Args::parse();
// Accept input/output as positionals (`vtracer in.png out.svg`) or as
// named flags; an explicit flag takes precedence over the positional.
let input = args
.input
.as_ref()
.or(args.input_pos.as_ref())
.ok_or("no input path given (positional or --input)")?;
let output = args
.output
.as_ref()
.or(args.output_pos.as_ref())
.ok_or("no output path given (positional or --output)")?;
let config = build_config(&args)?;
let pipeline = config.build().map_err(|e| e.to_string())?;
let img = read_image(input)?;
let svg = pipeline.to_svg(&img).map_err(|e| e.to_string())?;
std::fs::write(output, svg).map_err(|e| format!("cannot write output file: {e}"))?;
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => {
println!("Conversion successful.");
ExitCode::SUCCESS
}
Err(msg) => {
eprintln!("Conversion failed: {msg}");
ExitCode::FAILURE
}
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "vtracer-py"
description = "Python bindings for the vtracer vectorization framework."
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2024"
license = "MIT OR Apache-2.0"
readme = "README.md"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
# Excluded from the workspace: pyo3 `extension-module` cdylibs don't link
# libpython, which breaks `cargo test` at the workspace root. Built with
# maturin. Deps are declared explicitly (no workspace inheritance).
[lib]
# Python imports this as `vtracer`.
name = "vtracer"
crate-type = ["cdylib"]
[dependencies]
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",
] }
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py38"] }
+73
View File
@@ -0,0 +1,73 @@
# vtracer (Python)
Python bindings for the [`vtracer`](https://github.com/visioncortex/vtracer)
raster-to-vector framework. Built with [pyo3](https://pyo3.rs) +
[maturin](https://www.maturin.rs); the core Rust crate stays pure (no I/O), and
this crate adds image decoding and a Pythonic API.
## Install
```sh
pip install vtracer==1.0.0a3
```
## Usage
```python
import vtracer
# one-liners
vtracer.convert_file("in.png", "out.svg")
svg = vtracer.convert_bytes(open("in.png", "rb").read()) # -> str
svg = vtracer.convert_pixels(rgba_bytes, width, height) # raw RGBA8
# a rich, reusable configuration object
cfg = vtracer.Config(mode="polygon", filter_speckle=8)
cfg.hierarchical = "cutout" # seam-free mosaic
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"] # snap to a fixed palette
cfg.max_colors = 8 # or auto-quantize
cfg.optimize = 2
svg = cfg.convert_bytes(data)
# presets
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
vtracer.Config.bw().convert_file("scan.png", "lineart.svg")
```
### `Config`
Constructor keyword arguments (all optional) — also exposed as mutable
properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| arg | default | notes |
|---|---|---|
| `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 |
| `color_precision` | `6` | significant bits per channel |
| `layer_difference` | `16` | color diff between gradient layers |
| `corner_threshold` | `60` | degrees |
| `length_threshold` | `4.0` | px |
| `max_iterations` | `10` | |
| `splice_threshold` | `45` | degrees |
| `simplify` | `None` | curve simplification tolerance in px (try 1–2.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+cleanup, `2` + shorthands |
| `binary_threshold` | `128` | bw: fixed cutoff, foreground below it |
| `adaptive` | `False` | bw: Bradley–Roth adaptive thresholding |
| `adaptive_window` | `0` | bw adaptive: window px (`0` = auto) |
| `adaptive_t` | `15.0` | bw adaptive: % below local mean |
| `watershed_detail` | `128` | watershed: hierarchy cut level 0..=255 |
Each `Config` has `convert_file(input, output)`, `convert_bytes(data, format=None) -> str`,
and `convert_pixels(rgba, width, height) -> str`.
## Build from source
```sh
maturin develop # into the active virtualenv
maturin build --release # produce a wheel
```
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["maturin>=1.5,<2.0"]
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" }]
keywords = ["svg", "vectorization", "raster", "computer-graphics"]
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: 3",
"Topic :: Multimedia :: Graphics",
]
dynamic = ["version"]
[project.urls]
Homepage = "http://www.visioncortex.org/vtracer"
Repository = "https://github.com/visioncortex/vtracer/"
[tool.maturin]
# Pure-Rust extension module; the compiled library is imported as `vtracer`.
module-name = "vtracer"
features = ["pyo3/extension-module"]
+539
View File
@@ -0,0 +1,539 @@
//! Python bindings for the `vtracer` vectorization framework.
//!
//! The API centers on a mutable [`Config`] object with named properties and
//! preset constructors, plus three input paths — a file, encoded image bytes,
//! or a raw RGBA buffer — each returning the SVG (or writing it to disk):
//!
//! ```python
//! import vtracer
//!
//! # one-liners
//! vtracer.convert_file("in.png", "out.svg")
//! svg = vtracer.convert_bytes(open("in.png", "rb").read())
//!
//! # rich, reusable config
//! cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
//! cfg.max_colors = 8
//! cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
//! svg = cfg.convert_bytes(data)
//!
//! # presets
//! vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
//! ```
use std::io::Cursor;
use std::path::PathBuf;
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use ::vtracer::{
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
};
// --- string <-> enum helpers -------------------------------------------------
fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
s.parse().map_err(PyValueError::new_err)
}
fn clustering_str(c: Clustering) -> &'static str {
match c {
Clustering::ColorCluster => "color-cluster",
Clustering::Binary => "bw",
Clustering::Watershed => "watershed",
}
}
fn hierarchical_str(h: Hierarchical) -> &'static str {
match h {
Hierarchical::Stacked => "stacked",
Hierarchical::Cutout => "cutout",
}
}
fn mode_str(m: FitMode) -> &'static str {
match m {
FitMode::Pixel => "pixel",
FitMode::Polygon => "polygon",
FitMode::Spline => "spline",
}
}
fn parse_hex(token: &str) -> PyResult<Color> {
let hex = token.strip_prefix('#').unwrap_or(token);
if hex.len() != 6 {
return Err(PyValueError::new_err(format!(
"`{token}` is not a #rrggbb color"
)));
}
let byte = |r: std::ops::Range<usize>| {
u8::from_str_radix(&hex[r], 16)
.map_err(|_| PyValueError::new_err(format!("`{token}` is not a #rrggbb color")))
};
Ok(Color::new(byte(0..2)?, byte(2..4)?, byte(4..6)?))
}
// --- image helpers -----------------------------------------------------------
fn dynimg_to_color(img: image::DynamicImage) -> ColorImage {
let img = img.to_rgba8();
let (w, h) = (img.width() as usize, img.height() as usize);
ColorImage {
pixels: img.into_raw(),
width: w,
height: h,
}
}
fn decode_bytes(bytes: &[u8], format: Option<&str>) -> PyResult<ColorImage> {
let mut reader = image::ImageReader::new(Cursor::new(bytes));
match format {
Some(ext) => {
let fmt = image::ImageFormat::from_extension(ext)
.ok_or_else(|| PyValueError::new_err(format!("unknown image format `{ext}`")))?;
reader.set_format(fmt);
}
None => {
reader = reader
.with_guessed_format()
.map_err(|e| PyValueError::new_err(e.to_string()))?;
}
}
let img = reader
.decode()
.map_err(|e| PyValueError::new_err(format!("failed to decode image: {e}")))?;
Ok(dynimg_to_color(img))
}
// --- Config ------------------------------------------------------------------
/// Conversion configuration. Construct with keyword arguments or a preset,
/// mutate via properties, then call one of the `convert_*` methods.
#[pyclass(name = "Config")]
#[derive(Clone)]
struct PyConfig {
inner: CoreConfig,
}
impl PyConfig {
fn to_svg(&self, img: &ColorImage) -> PyResult<String> {
self.inner
.build()
.map_err(|e| PyValueError::new_err(e.to_string()))?
.to_svg(img)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
}
#[pymethods]
impl PyConfig {
#[new]
#[pyo3(signature = (
clustering = "color-cluster",
hierarchical = "stacked",
mode = "spline",
filter_speckle = 4,
color_precision = 6,
layer_difference = 16,
corner_threshold = 60,
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(
clustering: &str,
hierarchical: &str,
mode: &str,
filter_speckle: usize,
color_precision: i32,
layer_difference: i32,
corner_threshold: i32,
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<_>>()?,
None => Vec::new(),
};
Ok(Self {
inner: CoreConfig {
clustering: parse(clustering)?,
hierarchical: parse(hierarchical)?,
mode: parse(mode)?,
filter_speckle,
color_precision,
layer_difference,
corner_threshold,
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,
},
})
}
/// Preset for black & white line art.
#[staticmethod]
fn bw() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Bw),
}
}
/// Preset for posterized color art.
#[staticmethod]
fn poster() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Poster),
}
}
/// Preset tuned for photographs.
#[staticmethod]
fn photo() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Photo),
}
}
// --- properties ---
#[getter]
fn clustering(&self) -> &'static str {
clustering_str(self.inner.clustering)
}
#[setter]
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)
}
#[setter]
fn set_hierarchical(&mut self, v: &str) -> PyResult<()> {
self.inner.hierarchical = parse(v)?;
Ok(())
}
#[getter]
fn mode(&self) -> &'static str {
mode_str(self.inner.mode)
}
#[setter]
fn set_mode(&mut self, v: &str) -> PyResult<()> {
self.inner.mode = parse(v)?;
Ok(())
}
#[getter]
fn filter_speckle(&self) -> usize {
self.inner.filter_speckle
}
#[setter]
fn set_filter_speckle(&mut self, v: usize) {
self.inner.filter_speckle = v;
}
#[getter]
fn color_precision(&self) -> i32 {
self.inner.color_precision
}
#[setter]
fn set_color_precision(&mut self, v: i32) {
self.inner.color_precision = v;
}
#[getter]
fn layer_difference(&self) -> i32 {
self.inner.layer_difference
}
#[setter]
fn set_layer_difference(&mut self, v: i32) {
self.inner.layer_difference = v;
}
#[getter]
fn corner_threshold(&self) -> i32 {
self.inner.corner_threshold
}
#[setter]
fn set_corner_threshold(&mut self, v: i32) {
self.inner.corner_threshold = v;
}
#[getter]
fn length_threshold(&self) -> f64 {
self.inner.length_threshold
}
#[setter]
fn set_length_threshold(&mut self, v: f64) {
self.inner.length_threshold = v;
}
#[getter]
fn max_iterations(&self) -> usize {
self.inner.max_iterations
}
#[setter]
fn set_max_iterations(&mut self, v: usize) {
self.inner.max_iterations = v;
}
#[getter]
fn splice_threshold(&self) -> i32 {
self.inner.splice_threshold
}
#[setter]
fn set_splice_threshold(&mut self, v: i32) {
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
}
#[setter]
fn set_path_precision(&mut self, v: Option<u32>) {
self.inner.path_precision = v;
}
#[getter]
fn palette(&self) -> Vec<String> {
self.inner
.palette
.iter()
.map(Color::to_hex_string)
.collect()
}
#[setter]
fn set_palette(&mut self, v: Vec<String>) -> PyResult<()> {
self.inner.palette = v.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?;
Ok(())
}
#[getter]
fn max_colors(&self) -> Option<usize> {
self.inner.max_colors
}
#[setter]
fn set_max_colors(&mut self, v: Option<usize>) {
self.inner.max_colors = v;
}
#[getter]
fn optimize(&self) -> u8 {
self.inner.optimize
}
#[setter]
fn set_optimize(&mut self, v: u8) {
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 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()))
})
}
/// Trace encoded image `data` (png/jpg/...) and return the SVG string.
/// `format` (e.g. "png") overrides content-based format detection.
#[pyo3(signature = (data, format = None))]
fn convert_bytes(&self, data: Vec<u8>, format: Option<&str>) -> PyResult<String> {
self.to_svg(&decode_bytes(&data, format)?)
}
/// Trace a raw RGBA8 buffer (`width * height * 4` bytes) and return the SVG.
fn convert_pixels(&self, rgba: Vec<u8>, width: usize, height: usize) -> PyResult<String> {
if rgba.len() != width * height * 4 {
return Err(PyValueError::new_err(format!(
"rgba length {} != width*height*4 ({})",
rgba.len(),
width * height * 4
)));
}
self.to_svg(&ColorImage {
pixels: rgba,
width,
height,
})
}
fn __repr__(&self) -> String {
let c = &self.inner;
format!(
"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={})",
clustering_str(c.clustering),
hierarchical_str(c.hierarchical),
mode_str(c.mode),
c.filter_speckle,
c.color_precision,
c.layer_difference,
c.corner_threshold,
c.length_threshold,
c.max_iterations,
c.splice_threshold,
c.path_precision,
c.palette.len(),
c.max_colors,
c.optimize,
)
}
}
// --- module-level convenience ------------------------------------------------
/// Convert a file to SVG on disk, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (input_path, output_path, config = None))]
fn convert_file(
input_path: PathBuf,
output_path: PathBuf,
config: Option<PyConfig>,
) -> PyResult<()> {
config
.unwrap_or_else(default_config)
.convert_file(input_path, output_path)
}
/// Convert encoded image bytes to an SVG string, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (data, config = None, format = None))]
fn convert_bytes(
data: Vec<u8>,
config: Option<PyConfig>,
format: Option<&str>,
) -> PyResult<String> {
config
.unwrap_or_else(default_config)
.convert_bytes(data, format)
}
/// Convert a raw RGBA8 buffer to an SVG string, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (rgba, width, height, config = None))]
fn convert_pixels(
rgba: Vec<u8>,
width: usize,
height: usize,
config: Option<PyConfig>,
) -> PyResult<String> {
config
.unwrap_or_else(default_config)
.convert_pixels(rgba, width, height)
}
fn default_config() -> PyConfig {
PyConfig {
inner: CoreConfig::default(),
}
}
#[pymodule]
#[pyo3(name = "vtracer")]
fn vtracer_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyConfig>()?;
m.add_function(wrap_pyfunction!(convert_file, m)?)?;
m.add_function(wrap_pyfunction!(convert_bytes, m)?)?;
m.add_function(wrap_pyfunction!(convert_pixels, m)?)?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}
+67
View File
@@ -0,0 +1,67 @@
from typing import Optional
__version__: str
class Config:
"""Conversion configuration. Construct with keyword arguments or a preset,
mutate via properties, then call one of the ``convert_*`` methods."""
def __init__(
self,
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
mode: str = "spline", # "pixel" | "polygon" | "spline"
filter_speckle: int = 4,
color_precision: int = 6,
layer_difference: int = 16,
corner_threshold: int = 60,
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: Bradley–Roth adaptive
adaptive_window: int = 0, # bw adaptive: window px (0 = auto)
adaptive_t: float = 15.0, # bw adaptive: % below local mean
watershed_detail: int = 128, # watershed: cut level 0..=255
) -> None: ...
@staticmethod
def bw() -> "Config": ...
@staticmethod
def poster() -> "Config": ...
@staticmethod
def photo() -> "Config": ...
clustering: str
hierarchical: str
mode: str
filter_speckle: int
color_precision: int
layer_difference: int
corner_threshold: int
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: ...
def convert_pixels(self, rgba: bytes, width: int, height: int) -> str: ...
def convert_file(input_path: str, output_path: str, config: Optional[Config] = None) -> None: ...
def convert_bytes(data: bytes, config: Optional[Config] = None, format: Optional[str] = None) -> str: ...
def convert_pixels(rgba: bytes, width: int, height: int, config: Optional[Config] = None) -> str: ...
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "vtracer"
description = "A vectorization framework that converts raster images into vector graphics: pluggable frontends, curve fitters, color fitting, and output optimization."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "computer-vision"]
keywords = ["svg", "vectorization", "computer-graphics"]
readme = "../../README.md"
[lib]
name = "vtracer"
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
+75
View File
@@ -0,0 +1,75 @@
//! Color fitters: rewrite layer paints before compositing.
//!
//! * [`Identity`] — keep the frontend's mean colors (0.6.x behavior).
//! * [`FixedPalette`] — snap each paint to the nearest entry of a fixed
//! palette, measured in OKLab.
//! * [`AutoQuantize`] — reduce the palette to at most `max_colors` via
//! area-weighted median cut.
//! * [`MergeAdjacent`] — union consecutive layers that share a paint, cutting
//! shape count for free.
mod merge;
mod oklab;
mod palette;
mod quantize;
pub use merge::MergeAdjacent;
pub use palette::FixedPalette;
pub use quantize::AutoQuantize;
use crate::ir::Segmentation;
/// A color fitter rewrites the paints of a segmentation in place.
pub trait ColorFitter {
fn fit(&self, seg: &mut Segmentation);
}
/// No-op fitter: paints keep the frontend's mean cluster colors.
#[derive(Debug, Clone, Default)]
pub struct Identity;
impl ColorFitter for Identity {
fn fit(&self, _seg: &mut Segmentation) {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{Layer, Paint, RegionMask};
use visioncortex::{BinaryImage, Color, PointI32};
fn layer(color: Color) -> Layer {
let mut image = BinaryImage::new_w_h(1, 1);
image.set_pixel(0, 0, true);
Layer {
paint: Paint::Solid(color),
mask: RegionMask::new(image, PointI32 { x: 0, y: 0 }),
}
}
#[test]
fn fixed_palette_snaps_to_nearest_oklab() {
let mut seg = Segmentation::new(1, 1);
seg.layers.push(layer(Color::new(250, 10, 10))); // near red
seg.layers.push(layer(Color::new(10, 10, 250))); // near blue
let palette = FixedPalette::new(vec![Color::new(255, 0, 0), Color::new(0, 0, 255)]);
palette.fit(&mut seg);
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(255, 0, 0)));
assert_eq!(seg.layers[1].paint, Paint::Solid(Color::new(0, 0, 255)));
}
#[test]
fn merge_adjacent_unions_same_paint_runs() {
let mut seg = Segmentation::new(2, 1);
seg.layers.push(layer(Color::new(0, 0, 0)));
seg.layers.push(layer(Color::new(0, 0, 0)));
seg.layers.push(layer(Color::new(255, 255, 255)));
MergeAdjacent.fit(&mut seg);
assert_eq!(seg.layers.len(), 2);
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(0, 0, 0)));
}
}
+46
View File
@@ -0,0 +1,46 @@
use crate::ir::{Layer, RegionMask, Segmentation};
use super::ColorFitter;
/// Union consecutive layers that share a paint into a single layer. Run this
/// after palette snapping (which is what creates runs of identical paints) to
/// cut the shape count without changing appearance.
#[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 run.first().is_some_and(|first| first.paint != layer.paint) {
flush(&mut run, &mut merged);
}
run.push(layer);
}
flush(&mut run, &mut merged);
seg.layers = merged;
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Minimal sRGB → OKLab conversion for perceptual color distance.
//!
//! OKLab (Björn Ottosson, 2020) gives a Euclidean space where distance
//! approximates perceived color difference far better than raw RGB.
use visioncortex::Color;
/// A color in the OKLab space.
#[derive(Debug, Clone, Copy)]
pub struct Oklab {
pub l: f64,
pub a: f64,
pub b: f64,
}
fn srgb_to_linear(c: u8) -> f64 {
let c = c as f64 / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
impl Oklab {
pub fn from_color(color: &Color) -> Self {
let r = srgb_to_linear(color.r);
let g = srgb_to_linear(color.g);
let b = srgb_to_linear(color.b);
let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b;
let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b;
let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b;
let l_ = l.cbrt();
let m_ = m.cbrt();
let s_ = s.cbrt();
Oklab {
l: 0.210_454_255_3 * l_ + 0.793_617_785_0 * m_ - 0.004_072_046_8 * s_,
a: 1.977_998_495_1 * l_ - 2.428_592_205_0 * m_ + 0.450_593_709_9 * s_,
b: 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766_0 * s_,
}
}
/// Squared Euclidean distance (monotonic with distance; avoids the sqrt).
pub fn distance_squared(&self, other: &Oklab) -> f64 {
let dl = self.l - other.l;
let da = self.a - other.a;
let db = self.b - other.b;
dl * dl + da * da + db * db
}
}
+47
View File
@@ -0,0 +1,47 @@
use visioncortex::Color;
use crate::ir::{Paint, Segmentation};
use super::oklab::Oklab;
use super::ColorFitter;
/// Snap every layer paint to the nearest color in a fixed palette, measured in
/// OKLab. An empty palette leaves paints untouched.
#[derive(Debug, Clone, Default)]
pub struct FixedPalette {
pub colors: Vec<Color>,
}
impl FixedPalette {
pub fn new(colors: Vec<Color>) -> Self {
Self { colors }
}
/// The palette entry closest to `color` in OKLab.
fn nearest(&self, color: &Color, lab: &[Oklab]) -> Color {
let target = Oklab::from_color(color);
let mut best = self.colors[0];
let mut best_dist = f64::INFINITY;
for (i, entry) in self.colors.iter().enumerate() {
let dist = target.distance_squared(&lab[i]);
if dist < best_dist {
best_dist = dist;
best = *entry;
}
}
best
}
}
impl ColorFitter for FixedPalette {
fn fit(&self, seg: &mut Segmentation) {
if self.colors.is_empty() {
return;
}
let lab: Vec<Oklab> = self.colors.iter().map(Oklab::from_color).collect();
for layer in &mut seg.layers {
let snapped = self.nearest(&layer.paint.color(), &lab);
layer.paint = Paint::Solid(snapped);
}
}
}
+148
View File
@@ -0,0 +1,148 @@
use visioncortex::Color;
use crate::ir::{Paint, Segmentation};
use super::oklab::Oklab;
use super::ColorFitter;
/// Reduce the layer palette to at most `max_colors` representative colors via
/// area-weighted median cut, then snap each layer to the nearest representative
/// (in OKLab).
#[derive(Debug, Clone)]
pub struct AutoQuantize {
pub max_colors: usize,
}
impl Default for AutoQuantize {
fn default() -> Self {
Self { max_colors: 16 }
}
}
#[derive(Clone, Copy)]
struct Sample {
color: Color,
weight: u64,
}
struct Bucket {
samples: Vec<Sample>,
}
impl Bucket {
/// Extent (max - min) of the given channel across the bucket.
fn channel_range(&self, channel: usize) -> u8 {
let mut lo = u8::MAX;
let mut hi = u8::MIN;
for s in &self.samples {
let v = s.color.rgb_u8()[channel];
lo = lo.min(v);
hi = hi.max(v);
}
hi.saturating_sub(lo)
}
fn widest_channel(&self) -> usize {
let mut best = 0;
let mut best_range = 0u8;
for c in 0..3 {
let r = self.channel_range(c);
if r > best_range {
best_range = r;
best = c;
}
}
best
}
fn total_weight(&self) -> u64 {
self.samples.iter().map(|s| s.weight).sum()
}
/// Weighted-average representative color.
fn representative(&self) -> Color {
let mut r = 0u64;
let mut g = 0u64;
let mut b = 0u64;
let mut w = 0u64;
for s in &self.samples {
let rgb = s.color.rgb_u8();
r += rgb[0] as u64 * s.weight;
g += rgb[1] as u64 * s.weight;
b += rgb[2] as u64 * s.weight;
w += s.weight;
}
if w == 0 {
return Color::new(0, 0, 0);
}
Color::new((r / w) as u8, (g / w) as u8, (b / w) as u8)
}
/// Split at the weighted median of the widest channel.
fn split(mut self) -> (Bucket, Bucket) {
let channel = self.widest_channel();
self.samples
.sort_by_key(|s| s.color.rgb_u8()[channel]);
let half = self.total_weight() / 2;
let mut acc = 0u64;
let mut cut = 1;
for (i, s) in self.samples.iter().enumerate() {
acc += s.weight;
if acc >= half {
cut = (i + 1).clamp(1, self.samples.len().saturating_sub(1).max(1));
break;
}
}
let right = self.samples.split_off(cut);
(Bucket { samples: self.samples }, Bucket { samples: right })
}
}
impl ColorFitter for AutoQuantize {
fn fit(&self, seg: &mut Segmentation) {
if self.max_colors == 0 || seg.layers.is_empty() {
return;
}
let samples: Vec<Sample> = seg
.layers
.iter()
.map(|l| Sample {
color: l.paint.color(),
weight: l.mask.area() as u64 + 1,
})
.collect();
let mut buckets = vec![Bucket { samples }];
while buckets.len() < self.max_colors {
// Split the bucket with the widest single-channel range.
let target = buckets
.iter()
.enumerate()
.filter(|(_, b)| b.samples.len() > 1)
.max_by_key(|(_, b)| b.channel_range(b.widest_channel()));
let Some((idx, _)) = target else { break };
let bucket = buckets.swap_remove(idx);
let (a, b) = bucket.split();
buckets.push(a);
buckets.push(b);
}
let palette: Vec<Color> = buckets.iter().map(Bucket::representative).collect();
let lab: Vec<Oklab> = palette.iter().map(Oklab::from_color).collect();
for layer in &mut seg.layers {
let target = Oklab::from_color(&layer.paint.color());
let mut best = palette[0];
let mut best_dist = f64::INFINITY;
for (i, entry) in palette.iter().enumerate() {
let d = target.distance_squared(&lab[i]);
if d < best_dist {
best_dist = d;
best = *entry;
}
}
layer.paint = Paint::Solid(best);
}
}
}
+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
}
+390
View File
@@ -0,0 +1,390 @@
//! High-level configuration and presets that assemble a [`Pipeline`].
use std::str::FromStr;
use visioncortex::Color;
use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAdjacent};
use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
use crate::frontend::{
BinaryFrontend, ColorClusterFrontend, Frontend, Threshold, WatershedFrontend,
};
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
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 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)]
pub enum Hierarchical {
Stacked,
/// True mosaic cutout — not yet implemented (separate milestone).
Cutout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FitMode {
Pixel,
Polygon,
Spline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preset {
Bw,
Poster,
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 {
/// 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,
/// Significant bits per RGB channel (1..=8).
pub color_precision: i32,
/// Color difference between gradient layers.
pub layer_difference: i32,
pub mode: FitMode,
/// Corner threshold in degrees.
pub corner_threshold: i32,
/// Segment length threshold in pixels.
pub length_threshold: f64,
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+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 Bradley–Roth 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 {
clustering: Clustering::ColorCluster,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 6,
layer_difference: 16,
mode: FitMode::Spline,
corner_threshold: 60,
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,
}
}
}
impl Config {
pub fn from_preset(preset: Preset) -> Self {
match preset {
Preset::Bw => Self {
clustering: Clustering::Binary,
..Self::default()
},
Preset::Poster => Self {
color_precision: 8,
..Self::default()
},
Preset::Photo => Self {
filter_speckle: 10,
color_precision: 8,
layer_difference: 48,
corner_threshold: 180,
..Self::default()
},
}
}
fn fit_params(&self) -> FitParams {
FitParams {
corner_threshold: deg2rad(self.corner_threshold),
length_threshold: self.length_threshold,
max_iterations: self.max_iterations,
splice_threshold: deg2rad(self.splice_threshold),
}
}
fn frontend(&self) -> Box<dyn Frontend> {
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(),
}),
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![
Box::new(FixedPalette::new(self.palette.clone())),
Box::new(MergeAdjacent),
]
} else if let Some(max_colors) = self.max_colors {
vec![Box::new(AutoQuantize { max_colors }), Box::new(MergeAdjacent)]
} else {
vec![Box::new(Identity)]
}
}
fn fitter(&self) -> Box<dyn CurveFitter> {
match self.mode {
FitMode::Pixel => Box::new(PixelFitter),
FitMode::Polygon => Box::new(PolygonFitter),
FitMode::Spline => Box::new(SplineFitter::new(self.fit_params())),
}
}
fn segment_fitter(&self) -> Box<dyn SegmentFitter> {
match self.mode {
FitMode::Pixel => Box::new(PixelSegmentFitter),
FitMode::Polygon => Box::new(PolygonSegmentFitter::default()),
FitMode::Spline => Box::new(SplineSegmentFitter {
corner_threshold: deg2rad(self.corner_threshold),
length_threshold: self.length_threshold,
max_iterations: self.max_iterations,
splice_threshold: deg2rad(self.splice_threshold),
..SplineSegmentFitter::default()
}),
}
}
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();
}
let precision = self.path_precision.unwrap_or(2);
vec![
Box::new(QuantizePass::new(precision)),
Box::new(CleanupPass),
]
}
fn writer(&self) -> SvgWriter {
match self.optimize {
0 => SvgWriter {
relative: false,
shorthands: false,
precision: self.path_precision,
},
1 => SvgWriter {
relative: true,
shorthands: false,
precision: self.path_precision,
},
_ => SvgWriter {
relative: true,
shorthands: true,
precision: self.path_precision,
},
}
}
/// 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 {
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(),
})
}
}
fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
impl FromStr for Clustering {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
"binary" | "bw" | "BW" => Ok(Self::Binary),
"watershed" => Ok(Self::Watershed),
_ => Err(format!("unknown clustering {s}")),
}
}
}
impl FromStr for Hierarchical {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"stacked" => Ok(Self::Stacked),
"cutout" => Ok(Self::Cutout),
_ => Err(format!("unknown hierarchical mode {s}")),
}
}
}
impl FromStr for FitMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pixel" | "none" => Ok(Self::Pixel),
"polygon" => Ok(Self::Polygon),
"spline" => Ok(Self::Spline),
_ => Err(format!("unknown fit mode {s}")),
}
}
}
impl FromStr for Preset {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"bw" => Ok(Self::Bw),
"poster" => Ok(Self::Poster),
"photo" => Ok(Self::Photo),
_ => Err(format!("unknown preset {s}")),
}
}
}
+44
View File
@@ -0,0 +1,44 @@
use std::fmt;
/// Errors produced by the framework stages and the pipeline driver.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// The input image had zero width or height.
EmptyImage,
/// Transparency keying was requested but no unused key color could be found.
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),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::EmptyImage => write!(f, "input image is empty"),
Error::NoKeyColor => {
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}"),
}
}
}
impl std::error::Error for Error {}
impl From<String> for Error {
fn from(msg: String) -> Self {
Error::Other(msg)
}
}
impl From<&str> for Error {
fn from(msg: &str) -> Self {
Error::Other(msg.to_string())
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Curve fitters: turn a region's pixel mask into vector outlines.
//!
//! The three built-ins wrap the corresponding visioncortex tracing modes and
//! emit [`FittedGeom`] contours in absolute (document) coordinates:
//!
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
//! * [`PolygonFitter`] — staircase-symmetric Douglas–Peucker polygon.
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
//!
//! 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::{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.
#[derive(Debug, Clone, Copy)]
pub struct FitParams {
/// Minimum momentary angle (radians) to be considered a corner.
pub corner_threshold: f64,
/// Subdivide until all segments are shorter than this length (px).
pub length_threshold: f64,
/// Maximum smoothing iterations.
pub max_iterations: usize,
/// Minimum angle displacement (radians) to splice a spline.
pub splice_threshold: f64,
}
impl Default for FitParams {
fn default() -> Self {
Self {
corner_threshold: std::f64::consts::PI / 3.0, // 60°
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: std::f64::consts::PI / 4.0, // 45°
}
}
}
/// 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) -> Vec<FittedGeom>;
}
/// Exact lattice polyline; every pixel-boundary step is preserved.
#[derive(Debug, Clone, Default)]
pub struct PixelFitter;
impl CurveFitter for PixelFitter {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::None, FitParams::default())
}
}
/// Douglas–Peucker polygon with staircase removal.
#[derive(Debug, Clone, Default)]
pub struct PolygonFitter;
impl CurveFitter for PolygonFitter {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::Polygon, FitParams::default())
}
}
/// Smoothed spline (cubic Bézier) fitter.
#[derive(Debug, Clone, Default)]
pub struct SplineFitter {
pub params: FitParams,
}
impl SplineFitter {
pub fn new(params: FitParams) -> Self {
Self { params }
}
}
impl CurveFitter for SplineFitter {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
trace_region(mask, PathSimplifyMode::Spline, self.params)
}
}
/// 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) -> 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,
y: mask.offset.y + sub.rect.top,
};
let compound = BinaryCluster::image_to_compound_path(
&offset,
&sub.to_binary_image(),
mode,
params.corner_threshold,
params.length_threshold,
params.max_iterations,
params.splice_threshold,
);
append_compound(&mut geoms, &compound);
}
geoms
}
fn append_compound(geoms: &mut Vec<FittedGeom>, compound: &CompoundPath) {
for element in compound.iter() {
match element {
CompoundPathElement::PathI32(p) => {
let pts: Vec<PointF64> = p
.path
.iter()
.map(|q| PointF64 {
x: q.x as f64,
y: q.y as f64,
})
.collect();
geoms.push(FittedGeom::Polyline(pts));
}
CompoundPathElement::PathF64(p) => {
geoms.push(FittedGeom::Polyline(p.path.clone()));
}
CompoundPathElement::Spline(s) => {
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 {
let mut sub = SubPath::new();
if points.len() < 2 {
return sub;
}
// The tracer emits closed paths whose final point duplicates the first.
let closed = points.first() == points.last();
let body_end = if closed { points.len() - 1 } else { points.len() };
sub.commands.push(PathCmd::MoveTo(points[0]));
for p in &points[1..body_end] {
sub.commands.push(PathCmd::LineTo(*p));
}
sub.commands.push(PathCmd::Close);
sub
}
/// A cubic chain becomes `MoveTo · CubicTo* · Close`.
fn beziers_subpath(chain: &[[PointF64; 4]]) -> SubPath {
let mut sub = SubPath::new();
if chain.is_empty() {
return sub;
}
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)
}
}
+161
View File
@@ -0,0 +1,161 @@
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),
/// Bradley–Roth 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 {
/// Bradley–Roth 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 {
/// 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 {
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),
}
}
}
/// Bradley–Roth 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 {
return Err(Error::EmptyImage);
}
let width = img.width;
let height = img.height;
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.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)
}
}
@@ -0,0 +1,142 @@
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 {
/// 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 {
color_precision_loss: 2,
layer_difference: 16,
good_min_area: 0,
}
}
}
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);
}
let width = img.width;
let height = img.height;
let mut img = img.clone();
// Transparency keying (stacked mode discards the keyed background).
let key_color = if should_key_image(&img) {
let key = find_unused_color(&img)?;
apply_key(&mut img, key);
key
} else {
// All-zero is the sentinel understood by visioncortex as "no keying".
Color::default()
};
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,
};
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.
for &cluster_index in view.clusters_output.iter().rev() {
let cluster = view.get_cluster(cluster_index);
// Solid cluster masks (no holes punched): stacked mode relies on
// paint-order overdraw for occlusion, matching 0.6.x. Punching
// holes here would leave the layer below exposed as hairline seams.
// The mosaic flatten is unaffected — a higher layer still wins per
// pixel — so a solid parent gives the same partition.
let image = cluster.to_image_with_hole(view.width, false);
let mask = RegionMask::new(
image,
PointI32 {
x: cluster.rect.left,
y: cluster.rect.top,
},
);
seg.layers.push(Layer {
paint: Paint::Solid(cluster.residue_color()),
mask,
});
}
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))
}
}
+105
View File
@@ -0,0 +1,105 @@
//! Transparency keying, ported from the 0.6.x `converter.rs`.
//!
//! When an image has substantial transparency, fully-transparent pixels are
//! recolored to an unused "key" color so the clustering runner can treat them
//! as a discardable background. The random key search of 0.6.x is replaced by a
//! deterministic sweep so results are reproducible and `no_std`/wasm-friendly.
use visioncortex::{Color, ColorImage};
use crate::error::Error;
/// Fraction of pixels in the sampled rows that must be transparent before the
/// whole image is keyed.
const KEYING_THRESHOLD: f32 = 0.2;
/// Whether the image carries enough transparency to warrant keying.
pub fn should_key_image(img: &ColorImage) -> bool {
if img.width == 0 || img.height == 0 {
return false;
}
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
let mut transparent = 0usize;
let rows = [
0,
img.height / 4,
img.height / 2,
3 * img.height / 4,
img.height - 1,
];
for y in rows {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
transparent += 1;
}
if transparent >= threshold {
return true;
}
}
}
false
}
fn color_exists(img: &ColorImage, color: Color) -> bool {
for y in 0..img.height {
for x in 0..img.width {
let p = img.get_pixel(x, y);
if p.r == color.r && p.g == color.g && p.b == color.b {
return true;
}
}
}
false
}
/// Find a color not present in the image, to be used as the key. Tries the
/// primary/secondary colors first, then does a deterministic sweep of the RGB
/// cube. Returns [`Error::NoKeyColor`] only if every probed color is used.
pub fn find_unused_color(img: &ColorImage) -> Result<Color, Error> {
let specials = [
Color::new(255, 0, 0),
Color::new(0, 255, 0),
Color::new(0, 0, 255),
Color::new(255, 255, 0),
Color::new(0, 255, 255),
Color::new(255, 0, 255),
];
for &c in specials.iter() {
if !color_exists(img, c) {
return Ok(c);
}
}
// Deterministic sweep: step by a value coprime-ish with 256 to spread out.
const STEP: u16 = 37;
let mut r = 0u16;
while r < 256 {
let mut g = 0u16;
while g < 256 {
let mut b = 0u16;
while b < 256 {
let c = Color::new(r as u8, g as u8, b as u8);
if !color_exists(img, c) {
return Ok(c);
}
b += STEP;
}
g += STEP;
}
r += STEP;
}
Err(Error::NoKeyColor)
}
/// Recolor every fully-transparent pixel to `key`, in place.
pub fn apply_key(img: &mut ColorImage, key: Color) {
for y in 0..img.height {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
img.set_pixel(x, y, &key);
}
}
}
}
+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 1–2 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 ±1–2 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))
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Core intermediate representation shared by the pipeline stages.
//!
//! Two IRs flow through the pipeline:
//!
//! * [`Segmentation`] — the frontend output: ordered paint layers over a
//! raster canvas (painter's algorithm, bottom to top). This is what the
//! [`crate::colorfit`] stages rewrite.
//! * [`VectorDoc`] — the output document: resolved shapes with fitted paths.
//! This is what the [`crate::optimize`] passes and the [`crate::svg`] writer
//! operate on.
mod region;
mod vector;
pub use region::{Layer, RegionMask, Segmentation};
pub use vector::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use visioncortex::Color;
/// The final appearance of a region. Only solid colors are supported today;
/// the enum leaves room for gradients and patterns later.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Paint {
Solid(Color),
}
impl Paint {
/// The representative solid color of this paint.
pub fn color(&self) -> Color {
match self {
Paint::Solid(c) => *c,
}
}
}
+123
View File
@@ -0,0 +1,123 @@
use visioncortex::{BinaryImage, PointI32};
use super::Paint;
/// A region's pixel coverage: a local binary mask positioned on the canvas.
///
/// Foreground pixels are `true`. Holes (interior background) are already
/// punched out of the mask, so a mask is self-describing for tracing.
#[derive(Debug, Clone)]
pub struct RegionMask {
/// Local coverage; `true` = inside the region.
pub image: BinaryImage,
/// Position of the mask's top-left corner in full-canvas coordinates.
pub offset: PointI32,
}
impl RegionMask {
pub fn new(image: BinaryImage, offset: PointI32) -> Self {
Self { image, offset }
}
pub fn width(&self) -> usize {
self.image.width
}
pub fn height(&self) -> usize {
self.image.height
}
/// Number of foreground pixels.
pub fn area(&self) -> usize {
let mut count = 0;
for y in 0..self.image.height {
for x in 0..self.image.width {
if self.image.get_pixel(x, y) {
count += 1;
}
}
}
count
}
/// 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 {
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 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) {
image.set_pixel(x + dx, y + dy, true);
}
}
}
}
RegionMask::new(image, PointI32 { x: left, y: top })
}
}
/// A single paint layer. Layers are painted bottom-to-top.
#[derive(Debug, Clone)]
pub struct Layer {
/// Fill applied to the region. Starts as the cluster's mean color; a
/// [`crate::colorfit::ColorFitter`] may rewrite it.
pub paint: Paint,
/// Pixel coverage of the region.
pub mask: RegionMask,
}
/// Frontend output: ordered layers over a canvas, in paint order.
#[derive(Debug, Clone)]
pub struct Segmentation {
pub width: u32,
pub height: u32,
/// Bottom-to-top paint order.
pub layers: Vec<Layer>,
}
impl Segmentation {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
layers: Vec::new(),
}
}
}
+90
View File
@@ -0,0 +1,90 @@
use visioncortex::PointF64;
use super::Paint;
/// A single drawing command in a subpath. Coordinates are absolute, in
/// full-canvas (document) space — the writer bakes any offset into them.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PathCmd {
/// Start a new subpath at the given point.
MoveTo(PointF64),
/// Straight line to the given point.
LineTo(PointF64),
/// Cubic Bézier: two control points then the endpoint.
CubicTo(PointF64, PointF64, PointF64),
/// Close the current subpath back to its start.
Close,
}
/// One connected outline: a `MoveTo` followed by line/cubic segments, usually
/// terminated by `Close`.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SubPath {
pub commands: Vec<PathCmd>,
}
impl SubPath {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
/// The starting point of the subpath, if any.
pub fn start(&self) -> Option<PointF64> {
match self.commands.first() {
Some(PathCmd::MoveTo(p)) => Some(*p),
_ => None,
}
}
}
/// A shape may consist of several subpaths (outer ring plus holes).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MultiPath {
pub subpaths: Vec<SubPath>,
}
impl MultiPath {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.subpaths.iter().all(SubPath::is_empty)
}
pub fn push(&mut self, subpath: SubPath) {
if !subpath.is_empty() {
self.subpaths.push(subpath);
}
}
}
/// A filled shape in the output document.
#[derive(Debug, Clone)]
pub struct Shape {
pub paint: Paint,
pub path: MultiPath,
}
/// The output document IR: what the optimizer passes and the writer consume.
#[derive(Debug, Clone)]
pub struct VectorDoc {
pub width: u32,
pub height: u32,
/// Shapes in paint order (first drawn is bottom).
pub shapes: Vec<Shape>,
}
impl VectorDoc {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
shapes: Vec::new(),
}
}
}
+56
View File
@@ -0,0 +1,56 @@
//! # vtracer
//!
//! A vectorization *framework*: raster images become vector graphics through a
//! pipeline of pluggable stages.
//!
//! ```text
//! 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
//! `vtracer-cli` wrapper). Everything here compiles to
//! `wasm32-unknown-unknown`.
//!
//! ## Quick start
//!
//! ```no_run
//! use vtracer::{Config, ColorImage};
//!
//! # fn load() -> ColorImage { todo!() }
//! let img: ColorImage = load();
//! let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
//! ```
//!
//! For finer control, assemble a [`Pipeline`] directly from the stage traits
//! in [`frontend`], [`colorfit`], [`fitter`], [`simplify`], [`compose`],
//! [`optimize`], and [`svg`].
pub mod colorfit;
pub mod compose;
pub mod config;
pub mod error;
pub mod fitter;
pub mod frontend;
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::{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 1–2 (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));
}
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Stage 4: compose per-region SVG paths from shared fitted segments.
//!
//! Each region becomes one shape whose `d` concatenates its contours as
//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each
//! oriented segment is emitted skipping its first point (identical to the
//! previous segment's last point), so shared boundaries are byte-identical on
//! both sides.
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use crate::simplify::CurvePass;
use visioncortex::PointF64;
use super::face::{assemble, Contour, Face};
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
use super::graph::BoundaryGraph;
use super::{LabelMap, Segmentation};
/// 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);
// Fit every segment exactly once; both adjacent faces share the result.
let fitted: Vec<FittedSegment> = graph
.segments
.iter()
.map(|s| {
let ring = s.is_ring();
let mut geom = if ring {
fitter.fit_ring(s).geom
} else {
fitter.fit_open(s).geom
};
for pass in passes {
geom = if ring { pass.ring(geom) } else { pass.open(geom) };
}
FittedSegment { geom }
})
.collect();
let mut doc = VectorDoc::new(seg.width, seg.height);
for face in &faces {
let path = build_path(face, &fitted, &graph);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: map.paints[face.region as usize],
path,
});
}
}
doc
}
fn build_path(face: &Face, fitted: &[FittedSegment], _graph: &BoundaryGraph) -> MultiPath {
let mut mp = MultiPath::new();
for contour in &face.contours {
let mut sub = SubPath::new();
emit_contour(contour, fitted, &mut sub);
if !sub.is_empty() {
sub.commands.push(PathCmd::Close);
mp.subpaths.push(sub);
}
}
mp
}
fn emit_contour(contour: &Contour, fitted: &[FittedSegment], sub: &mut SubPath) {
for (i, sref) in contour.0.iter().enumerate() {
let geom = &fitted[sref.seg as usize].geom;
emit_segment(geom, sref.forward, i == 0, sub);
}
}
/// Append one oriented segment's commands. When `first`, opens with a `MoveTo`;
/// otherwise the leading point (shared with the previous segment) is skipped.
fn emit_segment(geom: &FittedGeom, forward: bool, first: bool, sub: &mut SubPath) {
match geom {
FittedGeom::Polyline(pts) => {
if pts.len() < 2 {
return;
}
let ordered: Vec<PointF64> = if forward {
pts.clone()
} else {
pts.iter().rev().copied().collect()
};
if first {
sub.commands.push(PathCmd::MoveTo(ordered[0]));
}
for p in &ordered[1..] {
sub.commands.push(PathCmd::LineTo(*p));
}
}
FittedGeom::Beziers(curves) => {
if curves.is_empty() {
return;
}
// Reversing a cubic is exact: [p0,p1,p2,p3] -> [p3,p2,p1,p0], and
// the whole chain reverses in order too.
let ordered: Vec<[PointF64; 4]> = if forward {
curves.clone()
} else {
curves
.iter()
.rev()
.map(|c| [c[3], c[2], c[1], c[0]])
.collect()
};
if first {
sub.commands.push(PathCmd::MoveTo(ordered[0][0]));
}
for c in &ordered {
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
}
}
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Stage 2: face assembly.
//!
//! Lift the "region kept on the left" successor rule from unit edges to whole
//! segments. Following it around each region yields its contours; because the
//! interior is always on the left, outer contours and hole contours come out
//! 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::{
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 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];
if r.forward {
seg.left
} else {
seg.right
}
}
/// Pick the next unit direction leaving `corner`, keeping region `r` on the
/// left: sharpest right turn first (this pinches checkerboard nodes and keeps
/// contours simple).
fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
for &d in &[turn_right(d_in), straight(d_in), turn_left(d_in)] {
if edge_present(map, x, y, d) && left_pixel_at(map, x, y, d) == r {
return d;
}
}
unreachable!("no successor edge keeps the region on the left");
}
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
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()];
for seg_id in 0..graph.segments.len() {
if graph.segments[seg_id].is_ring() {
continue;
}
for &forward in &[true, false] {
let start = SegRef {
seg: seg_id as u32,
forward,
};
let region = left_region(graph, start);
if region == OUTSIDE || used[seg_id][forward as usize] {
continue;
}
let mut contour = Vec::new();
let mut cur = start;
loop {
used[cur.seg as usize][cur.forward as usize] = true;
contour.push(cur);
let seg = &graph.segments[cur.seg as usize];
let (node_id, d_in) = if cur.forward {
(seg.end.unwrap(), seg.last_dir)
} else {
(seg.start.unwrap(), reverse(seg.first_dir))
};
let corner = graph.nodes[node_id as usize].corner;
let d_next = successor(map, corner.x, corner.y, d_in, region);
cur = graph.nodes[node_id as usize].out[d_next as usize]
.expect("successor direction must have an outgoing segment");
if cur == start {
break;
}
}
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));
}
}
}
// Rings: the left side uses it forward, the right side reversed.
for seg_id in 0..graph.segments.len() {
let seg = &graph.segments[seg_id];
if !seg.is_ring() {
continue;
}
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,
};
let island = island_of(graph, map, &ids, r);
by_island
.entry((region, island))
.or_default()
.push(Contour(vec![r]));
}
}
by_island
.into_iter()
.map(|((region, _island), contours)| Face { region, contours })
.collect()
}
+258
View File
@@ -0,0 +1,258 @@
//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes.
//!
//! A segment is fitted a single time and cached; both adjacent faces reference
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
//! the shared geometry is bitwise identical and no seam can appear.
use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideSmooth};
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;
/// A fitted segment, cached and indexed by segment id.
#[derive(Clone, Debug)]
pub struct FittedSegment {
pub geom: FittedGeom,
}
/// Fits a single boundary segment. `fit_open` pins both endpoints (junction
/// nodes must not move); `fit_ring` fits a closed loop with no pinned point.
pub trait SegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment;
fn fit_ring(&self, seg: &Segment) -> FittedSegment;
}
fn to_f64(points: &[PointI32]) -> Vec<PointF64> {
points
.iter()
.map(|p| PointF64 {
x: p.x as f64,
y: p.y as f64,
})
.collect()
}
/// Identity fitter: lattice points as f64. Produces an exact tessellation and
/// is the reference backend for tests.
#[derive(Debug, Clone, Default)]
pub struct PixelSegmentFitter;
impl SegmentFitter for PixelSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
}
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
}
}
}
/// Straight-segment fitter. Uses visioncortex's symmetric `limit_penalties`
/// simplification, which collapses 1px staircases toward the crack midline
/// (centered, no directional outset) so the boundary stays gapless. Endpoints
/// are preserved, pinning junction nodes.
#[derive(Debug, Clone, Default)]
pub struct PolygonSegmentFitter;
impl PolygonSegmentFitter {
fn fit(&self, seg: &Segment) -> FittedSegment {
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
FittedSegment {
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
}
}
}
impl SegmentFitter for PolygonSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
self.fit(seg)
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
self.fit(seg)
}
}
/// Smooth (cubic-Bézier) open-path fitter — the mosaic analogue of the stacked
/// [`crate::fitter::SplineFitter`], but for open segments with pinned
/// endpoints.
///
/// Staircase removal reuses visioncortex's symmetric `limit_penalties`
/// simplification (the same de-noising stacked mode applies), which collapses
/// staircases toward the crack midline. Unlike `remove_staircase`, it has no
/// directional outset, so the boundary stays centered (≤√2/2 px from its
/// crack) and cannot cross a non-adjacent segment — the tessellation stays
/// 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_beziers`),
/// so the curve character matches stacked.
#[derive(Debug, Clone)]
pub struct SplineSegmentFitter {
/// Corner angle threshold, radians.
pub corner_threshold: f64,
/// Subdivide until segments are shorter than this (px).
pub length_threshold: f64,
pub max_iterations: usize,
/// Splice angle threshold, radians.
pub splice_threshold: f64,
}
impl Default for SplineSegmentFitter {
fn default() -> Self {
Self {
corner_threshold: std::f64::consts::PI / 3.0,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: std::f64::consts::PI / 4.0,
}
}
}
fn pt(p: PointI32) -> PointF64 {
PointF64 {
x: p.x as f64,
y: p.y as f64,
}
}
/// A degenerate cubic tracing the straight line `a`→`b`.
fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] {
let c1 = PointF64 {
x: a.x + (b.x - a.x) / 3.0,
y: a.y + (b.y - a.y) / 3.0,
};
let c2 = PointF64 {
x: a.x + 2.0 * (b.x - a.x) / 3.0,
y: a.y + 2.0 * (b.y - a.y) / 3.0,
};
[a, c1, c2, b]
}
/// Error bound for the per-slice cubic fit. Matches the value stacked mode
/// uses in `Spline::from_path_f64`, so mosaic curves have the same character.
const FIT_ERROR: f64 = 10.0;
/// 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.extend(SubdivideSmooth::fit_points_with_beziers(slice, FIT_ERROR)),
}
}
fn spline_to_beziers(spline: &Spline) -> Vec<[PointF64; 4]> {
spline
.get_control_points()
.into_iter()
.filter(|w| w.len() == 4)
.map(|w| [w[0], w[1], w[2], w[3]])
.collect()
}
impl SegmentFitter for SplineSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
if seg.points.len() <= 2 {
return FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
};
}
// 1. Staircase removal via visioncortex's `limit_penalties` — the
// symmetric (area-based, no directional outset) simplifier stacked
// mode runs after remove_staircase. Used alone here it collapses
// staircases toward the crack midline, so the boundary stays
// centered and cannot cross a non-adjacent segment (which would
// open a gap in the tessellation). Endpoints are preserved.
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
if simplified.len() <= 2 {
return FittedSegment {
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
};
}
// 2. Corner detection (open, endpoints forced as corners).
let mut corners = SubdivideSmooth::find_corners(&simplified, self.corner_threshold, false);
// 3. Open 4-point subdivision.
let mut path = simplified.to_path_f64();
for _ in 0..self.max_iterations {
let (np, nc, done) = SubdivideSmooth::subdivide_keep_corners(
&path,
&corners,
OUTSET_RATIO,
self.length_threshold,
false,
);
path = np;
corners = nc;
if done {
break;
}
}
// 4. Splice points (open, endpoints forced).
let splice = SubdivideSmooth::find_splice_points(&path, self.splice_threshold, false);
let cuts: Vec<usize> = splice
.iter()
.enumerate()
.filter_map(|(i, &s)| if s { Some(i) } else { None })
.collect();
// 5. Per-slice cubic fit.
let mut beziers = Vec::new();
for w in cuts.windows(2) {
fit_slice(&path.path[w[0]..=w[1]], &mut beziers);
}
if beziers.is_empty() {
return FittedSegment {
geom: FittedGeom::Polyline(path.path.clone()),
};
}
// Pin the segment's endpoints exactly to the lattice nodes so that
// segments meeting at a junction share identical coordinates.
beziers.first_mut().unwrap()[0] = pt(seg.points[0]);
beziers.last_mut().unwrap()[3] = pt(seg.points[seg.points.len() - 1]);
FittedSegment {
geom: FittedGeom::Beziers(beziers),
}
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
// Rings are closed loops — this is exactly the stacked closed-spline
// pipeline (simplify → smooth → fit).
if seg.points.len() <= 4 {
return FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
};
}
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
let smoothed = simplified.smooth(
self.corner_threshold,
OUTSET_RATIO,
self.length_threshold,
self.max_iterations,
);
let spline = Spline::from_path_f64(&smoothed, self.splice_threshold);
let beziers = spline_to_beziers(&spline);
if beziers.is_empty() {
return FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
};
}
FittedSegment {
geom: FittedGeom::Beziers(beziers),
}
}
}
+370
View File
@@ -0,0 +1,370 @@
//! Stage 1: boundary-graph extraction from a [`LabelMap`].
//!
//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`.
//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run
//! along the "cracks" between differing labels.
use visioncortex::PointI32;
use super::{LabelMap, RegionId, OUTSIDE};
pub type NodeId = u32;
pub type SegId = u32;
// Unit directions, arranged clockwise in y-down screen space so that
// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal.
const N: u8 = 0;
const E: u8 = 1;
const S: u8 = 2;
const W: u8 = 3;
/// (dx, dy) per direction.
const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
#[inline]
pub(super) fn turn_right(d: u8) -> u8 {
(d + 1) % 4
}
#[inline]
pub(super) fn straight(d: u8) -> u8 {
d
}
#[inline]
pub(super) fn turn_left(d: u8) -> u8 {
(d + 3) % 4
}
#[inline]
pub(super) fn reverse(d: u8) -> u8 {
(d + 2) % 4
}
/// A directed reference to a segment: either traversed forward or reversed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SegRef {
pub seg: SegId,
pub forward: bool,
}
/// A junction corner (degree ≥ 3) with the segment leaving it in each unit
/// direction (if any).
#[derive(Clone, Debug)]
pub struct Node {
pub corner: PointI32,
pub out: [Option<SegRef>; 4],
}
/// A maximal boundary chain between two nodes, or a nodeless ring.
#[derive(Clone, Debug)]
pub struct Segment {
/// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`.
pub points: Vec<PointI32>,
pub start: Option<NodeId>,
pub end: Option<NodeId>,
/// Region on the left when traversing forward (y-down convention).
pub left: RegionId,
pub right: RegionId,
/// Direction of the first edge (leaving `start`); unused for rings.
pub first_dir: u8,
/// Direction of the last edge (arriving at `end`); unused for rings.
pub last_dir: u8,
}
impl Segment {
pub fn is_ring(&self) -> bool {
self.start.is_none()
}
}
/// The extracted boundary graph. Faces are assembled separately (see `face`).
pub struct BoundaryGraph {
pub nodes: Vec<Node>,
pub segments: Vec<Segment>,
}
struct Extractor<'a> {
map: &'a LabelMap,
w: i32,
h: i32,
/// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1).
node_at: Vec<NodeId>,
/// Visited flags for undirected unit edges.
visited_v: Vec<bool>, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x
visited_h: Vec<bool>, // horizontal edge (x in 0..W, y in 0..=H): y*W + x
nodes: Vec<Node>,
segments: Vec<Segment>,
}
impl<'a> Extractor<'a> {
fn new(map: &'a LabelMap) -> Self {
let w = map.width as i32;
let h = map.height as i32;
let cw = (map.width + 1) as usize;
let ch = (map.height + 1) as usize;
Extractor {
map,
w,
h,
node_at: vec![u32::MAX; cw * ch],
visited_v: vec![false; (map.width as usize + 1) * map.height as usize],
visited_h: vec![false; map.width as usize * (map.height as usize + 1)],
nodes: Vec::new(),
segments: Vec::new(),
}
}
#[inline]
fn corner_index(&self, x: i32, y: i32) -> usize {
y as usize * (self.w as usize + 1) + x as usize
}
/// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`.
fn edge_mask(&self, x: i32, y: i32) -> u8 {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
let mut m = 0u8;
if nw != ne {
m |= 1 << N;
}
if ne != se {
m |= 1 << E;
}
if sw != se {
m |= 1 << S;
}
if nw != sw {
m |= 1 << W;
}
m
}
/// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`.
fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
match d {
N => (nw, ne),
E => (ne, se),
S => (se, sw),
W => (sw, nw),
_ => unreachable!(),
}
}
/// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`.
/// Returns the canonical (is_vertical, index).
fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) {
match d {
N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize),
S => (true, y as usize * (self.w as usize + 1) + x as usize),
E => (false, y as usize * self.w as usize + x as usize),
W => (false, y as usize * self.w as usize + (x - 1) as usize),
_ => unreachable!(),
}
}
fn is_visited(&self, x: i32, y: i32, d: u8) -> bool {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i]
} else {
self.visited_h[i]
}
}
fn mark_visited(&mut self, x: i32, y: i32, d: u8) {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i] = true;
} else {
self.visited_h[i] = true;
}
}
/// Pass A — classify corners and allocate node ids for degree ≥ 3.
fn classify(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let deg = self.edge_mask(x, y).count_ones();
if deg >= 3 {
let id = self.nodes.len() as NodeId;
self.nodes.push(Node {
corner: PointI32 { x, y },
out: [None; 4],
});
let ci = self.corner_index(x, y);
self.node_at[ci] = id;
}
}
}
}
fn node_id(&self, x: i32, y: i32) -> Option<NodeId> {
let id = self.node_at[self.corner_index(x, y)];
if id == u32::MAX {
None
} else {
Some(id)
}
}
/// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to
/// the start). Returns the polyline, the final heading, and the corner
/// walked to. Marks every traversed edge visited.
fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec<PointI32>, u8, i32, i32) {
let mut points = vec![PointI32 { x: x0, y: y0 }];
let (mut cx, mut cy, mut d) = (x0, y0, d0);
loop {
self.mark_visited(cx, cy, d);
let (dx, dy) = DVEC[d as usize];
let (nx, ny) = (cx + dx, cy + dy);
points.push(PointI32 { x: nx, y: ny });
let mask = self.edge_mask(nx, ny);
if mask.count_ones() >= 3 {
return (points, d, nx, ny); // reached a node
}
if nx == x0 && ny == y0 {
return (points, d, nx, ny); // closed ring
}
// Degree-2: continue via the unique present edge that is not the
// reverse of how we arrived.
let rev = reverse(d);
let mut nd = d;
for cand in 0..4u8 {
if cand != rev && (mask & (1 << cand)) != 0 {
nd = cand;
break;
}
}
d = nd;
cx = nx;
cy = ny;
}
}
/// Pass B — trace node-to-node segments.
fn trace_segments(&mut self) {
let node_corners: Vec<PointI32> = self.nodes.iter().map(|n| n.corner).collect();
for (nid, corner) in node_corners.iter().enumerate() {
let nid = nid as NodeId;
let (x, y) = (corner.x, corner.y);
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, last_dir, ex, ey) = self.walk(x, y, d);
let end = self
.node_id(ex, ey)
.expect("segment must end at a node");
let seg_id = self.segments.len() as SegId;
self.segments.push(Segment {
points,
start: Some(nid),
end: Some(end),
left,
right,
first_dir: d,
last_dir,
});
self.nodes[nid as usize].out[d as usize] = Some(SegRef {
seg: seg_id,
forward: true,
});
// Leaving the end node backward along this segment.
let back = reverse(last_dir);
self.nodes[end as usize].out[back as usize] = Some(SegRef {
seg: seg_id,
forward: false,
});
}
}
}
/// Pass C — closed rings from any remaining unvisited boundary edges.
fn trace_rings(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, _last, _ex, _ey) = self.walk(x, y, d);
self.segments.push(Segment {
points,
start: None,
end: None,
left,
right,
first_dir: d,
last_dir: 0,
});
}
}
}
}
}
impl BoundaryGraph {
pub fn extract(map: &LabelMap) -> BoundaryGraph {
let mut ex = Extractor::new(map);
ex.classify();
ex.trace_segments();
ex.trace_rings();
BoundaryGraph {
nodes: ex.nodes,
segments: ex.segments,
}
}
}
/// 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 {
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.
pub(super) fn edge_present(map: &LabelMap, x: i32, y: i32, d: u8) -> bool {
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 != ne,
E => ne != se,
S => sw != se,
W => nw != sw,
_ => false,
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Optimizer passes over the [`VectorDoc`] before serialization.
//!
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
//! 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`.
//! * [`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;
use crate::ir::{MultiPath, PathCmd, SubPath, VectorDoc};
/// An optimizer pass rewrites the document in place.
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
/// Round all coordinates to `precision` decimal places.
#[derive(Debug, Clone, Copy)]
pub struct QuantizePass {
pub precision: u32,
}
impl QuantizePass {
pub fn new(precision: u32) -> Self {
Self { precision }
}
fn round(&self, v: f64) -> f64 {
let factor = 10f64.powi(self.precision as i32);
(v * factor).round() / factor
}
fn round_pt(&self, p: PointF64) -> PointF64 {
PointF64 {
x: self.round(p.x),
y: self.round(p.y),
}
}
}
impl OptimizerPass for QuantizePass {
fn run(&self, doc: &mut VectorDoc) {
for shape in &mut doc.shapes {
for sub in &mut shape.path.subpaths {
for cmd in &mut sub.commands {
*cmd = match *cmd {
PathCmd::MoveTo(p) => PathCmd::MoveTo(self.round_pt(p)),
PathCmd::LineTo(p) => PathCmd::LineTo(self.round_pt(p)),
PathCmd::CubicTo(c1, c2, e) => PathCmd::CubicTo(
self.round_pt(c1),
self.round_pt(c2),
self.round_pt(e),
),
PathCmd::Close => PathCmd::Close,
};
}
}
}
}
}
/// Remove zero-length segments and collinear-redundant line vertices.
#[derive(Debug, Clone, Copy, Default)]
pub struct CleanupPass;
/// Tolerance for treating two points as coincident.
const COINCIDENT_EPS: f64 = 1e-6;
/// Perpendicular-distance tolerance for treating three points as collinear.
const COLLINEAR_EPS: f64 = 1e-4;
fn approx_eq(a: PointF64, b: PointF64) -> bool {
(a.x - b.x).abs() < COINCIDENT_EPS && (a.y - b.y).abs() < COINCIDENT_EPS
}
/// Perpendicular distance of `b` from the line through `a` and `c`.
fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool {
let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
let base = ((c.x - a.x).powi(2) + (c.y - a.y).powi(2)).sqrt();
if base < COINCIDENT_EPS {
return true;
}
(cross.abs() / base) < COLLINEAR_EPS
}
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.
let mut prev = PointF64::default();
let mut last = PointF64::default();
for cmd in &sub.commands {
match *cmd {
PathCmd::MoveTo(p) => {
out.commands.push(PathCmd::MoveTo(p));
prev = p;
last = p;
}
PathCmd::LineTo(p) => {
if approx_eq(last, p) {
continue; // zero-length
}
if let Some(PathCmd::LineTo(_)) = out.commands.last() {
if collinear(prev, last, p) {
*out.commands.last_mut().unwrap() = PathCmd::LineTo(p);
last = p; // anchor `prev` unchanged
continue;
}
}
out.commands.push(PathCmd::LineTo(p));
prev = last;
last = p;
}
PathCmd::CubicTo(c1, c2, e) => {
out.commands.push(PathCmd::CubicTo(c1, c2, e));
prev = last;
last = e;
}
PathCmd::Close => {
out.commands.push(PathCmd::Close);
}
}
}
out
}
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 = cleanup_subpath(sub);
// Keep only subpaths with real geometry (a MoveTo plus at least
// one drawing command beyond Close).
let draws = simplified
.commands
.iter()
.filter(|c| matches!(c, PathCmd::LineTo(_) | PathCmd::CubicTo(..)))
.count();
if draws > 0 {
subpaths.push(simplified);
}
}
shape.path = MultiPath { subpaths };
}
doc.shapes.retain(|s| !s.path.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{MultiPath, Paint, Shape};
use visioncortex::Color;
fn pt(x: f64, y: f64) -> PointF64 {
PointF64 { x, y }
}
fn doc_with(commands: Vec<PathCmd>) -> VectorDoc {
let mut doc = VectorDoc::new(100, 100);
doc.shapes.push(Shape {
paint: Paint::Solid(Color::new(0, 0, 0)),
path: MultiPath {
subpaths: vec![SubPath { commands }],
},
});
doc
}
#[test]
fn quantize_rounds_coordinates() {
let mut doc = doc_with(vec![
PathCmd::MoveTo(pt(1.234, 5.678)),
PathCmd::LineTo(pt(9.876, 0.001)),
PathCmd::Close,
]);
QuantizePass::new(1).run(&mut doc);
let cmds = &doc.shapes[0].path.subpaths[0].commands;
assert_eq!(cmds[0], PathCmd::MoveTo(pt(1.2, 5.7)));
assert_eq!(cmds[1], PathCmd::LineTo(pt(9.9, 0.0)));
}
#[test]
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)),
PathCmd::LineTo(pt(1.0, 0.0)),
PathCmd::LineTo(pt(2.0, 0.0)), // collinear with previous run
PathCmd::LineTo(pt(2.0, 0.0)), // zero-length
PathCmd::LineTo(pt(2.0, 5.0)),
PathCmd::Close,
]);
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);
assert_eq!(cmds[0], PathCmd::MoveTo(pt(0.0, 0.0)));
assert_eq!(cmds[1], PathCmd::LineTo(pt(2.0, 0.0)));
assert_eq!(cmds[2], PathCmd::LineTo(pt(2.0, 5.0)));
assert_eq!(cmds[3], PathCmd::Close);
}
}
+132
View File
@@ -0,0 +1,132 @@
//! The pipeline driver: composes the stages and runs an image through them.
use visioncortex::ColorImage;
use crate::colorfit::ColorFitter;
use crate::compose::Compositing;
use crate::error::Error;
use crate::frontend::Frontend;
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
/// [`crate::Config::build`], or construct it directly for full control.
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> {
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_with(&seg, &self.curve_passes, ctx)?;
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)
}
/// Run the pipeline and serialize the result to an SVG string.
pub fn to_svg(&self, img: &ColorImage) -> Result<String, Error> {
Ok(self.writer.write(&self.run(img)?))
}
}
+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"),
}
}
}
+582
View File
@@ -0,0 +1,582 @@
//! Serialize a [`VectorDoc`] to an SVG string.
//!
//! The writer makes the encoding choices that shrink output without changing
//! geometry:
//!
//! * per segment, the shorter of absolute vs. relative deltas (`L`/`l`, `C`/`c`);
//! * `H`/`V` (`h`/`v`) for axis-aligned lines and `S`/`s` for smooth cubic
//! continuations;
//! * compact number formatting (trimmed zeros, leading-dot decimals, no
//! separator before a negative);
//! * optional `<g fill>` grouping of consecutive same-fill shapes.
//!
//! Coordinates are assumed to already be in absolute document space (the
//! [`crate::optimize::QuantizePass`] bakes in any offset), so no per-path
//! `transform` is emitted.
use std::fmt::Write as _;
use visioncortex::PointF64;
use crate::ir::{Paint, PathCmd, Shape, SubPath, VectorDoc};
/// SVG serializer configuration.
#[derive(Debug, Clone, Copy)]
pub struct SvgWriter {
/// Allow relative commands where they serialize shorter.
pub relative: bool,
/// Allow `H`/`V`/`S` shorthands and `<g fill>` grouping.
pub shorthands: bool,
/// Decimal places for coordinates (`None` = full precision).
pub precision: Option<u32>,
}
impl Default for SvgWriter {
fn default() -> Self {
Self {
relative: true,
shorthands: true,
precision: Some(2),
}
}
}
impl SvgWriter {
pub fn write(&self, doc: &VectorDoc) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(
out,
"<!-- Generator: visioncortex VTracer {} -->",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">",
doc.width, doc.height
);
if self.shorthands {
self.write_grouped(&mut out, &doc.shapes);
} else {
for shape in &doc.shapes {
self.write_path(&mut out, shape, true);
}
}
out.push_str("</svg>\n");
out
}
/// Emit shapes, grouping maximal runs of consecutive same-fill shapes into
/// a single `<g fill>` (preserving paint order).
fn write_grouped(&self, out: &mut String, shapes: &[Shape]) {
let mut i = 0;
while i < shapes.len() {
let fill = shape_fill(&shapes[i]);
let mut j = i + 1;
while j < shapes.len() && shape_fill(&shapes[j]) == fill {
j += 1;
}
let run = &shapes[i..j];
if run.len() > 1 {
let _ = writeln!(out, "<g fill=\"{}\">", fill);
for shape in run {
self.write_path(out, shape, false);
}
out.push_str("</g>\n");
} else {
self.write_path(out, &run[0], true);
}
i = j;
}
}
fn write_path(&self, out: &mut String, shape: &Shape, with_fill: bool) {
let d = self.encode_path(shape);
if d.is_empty() {
return;
}
if with_fill {
let _ = writeln!(
out,
"<path d=\"{}\" fill=\"{}\"/>",
d,
shape_fill(shape)
);
} else {
let _ = writeln!(out, "<path d=\"{}\"/>", d);
}
}
fn encode_path(&self, shape: &Shape) -> String {
let mut emitter = Emitter::new(self.relative, self.shorthands, self.precision);
for sub in &shape.path.subpaths {
emitter.subpath(sub);
}
emitter.finish()
}
}
fn shape_fill(shape: &Shape) -> String {
match shape.paint {
Paint::Solid(c) => c.to_hex_string(),
}
}
/// Streaming SVG-path encoder that tracks the current point.
struct Emitter {
relative: bool,
shorthands: bool,
precision: Option<u32>,
out: String,
cur: PointF64,
/// Start of the current subpath; `cur` returns here after `Z`.
subpath_start: PointF64,
started: bool,
/// Absolute second control point of the previous cubic, for `S` detection.
prev_cubic_c2: Option<PointF64>,
}
impl Emitter {
fn new(relative: bool, shorthands: bool, precision: Option<u32>) -> Self {
Self {
relative,
shorthands,
precision,
out: String::new(),
cur: PointF64::default(),
subpath_start: PointF64::default(),
started: false,
prev_cubic_c2: None,
}
}
fn finish(self) -> String {
self.out
}
fn subpath(&mut self, sub: &SubPath) {
for cmd in &sub.commands {
match *cmd {
PathCmd::MoveTo(p) => self.move_to(p),
PathCmd::LineTo(p) => self.line_to(p),
PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e),
PathCmd::Close => {
self.out.push('Z');
// SVG resets the current point to the subpath's start after
// Z; a following relative `m`/`l` is measured from there.
self.cur = self.subpath_start;
self.prev_cubic_c2 = None;
}
}
}
}
fn move_to(&mut self, p: PointF64) {
if !self.started {
// First move is always absolute.
let token = format!("M{}", self.coord(p));
self.out.push_str(&token);
self.started = true;
} else {
let abs = format!("M{}", self.coord(p));
let token = if self.relative {
let rel = format!("m{}", self.coord_delta(p));
shorter(abs, rel)
} else {
abs
};
self.out.push_str(&token);
}
self.cur = p;
self.subpath_start = p;
self.prev_cubic_c2 = None;
}
fn line_to(&mut self, p: PointF64) {
let mut candidates: Vec<String> = Vec::new();
// Axis-aligned shorthands.
if self.shorthands {
if p.y == self.cur.y {
candidates.push(format!("H{}", self.num(p.x)));
if self.relative {
candidates.push(format!("h{}", self.num(p.x - self.cur.x)));
}
}
if p.x == self.cur.x {
candidates.push(format!("V{}", self.num(p.y)));
if self.relative {
candidates.push(format!("v{}", self.num(p.y - self.cur.y)));
}
}
}
candidates.push(format!("L{}", self.coord(p)));
if self.relative {
candidates.push(format!("l{}", self.coord_delta(p)));
}
self.out.push_str(&shortest(candidates));
self.cur = p;
self.prev_cubic_c2 = None;
}
fn cubic_to(&mut self, c1: PointF64, c2: PointF64, e: PointF64) {
let mut candidates: Vec<String> = Vec::new();
// Smooth continuation: c1 is the reflection of the previous cubic's c2.
if self.shorthands {
if let Some(prev_c2) = self.prev_cubic_c2 {
let reflection = PointF64 {
x: 2.0 * self.cur.x - prev_c2.x,
y: 2.0 * self.cur.y - prev_c2.y,
};
if approx(reflection, c1) {
candidates.push(format!(
"S{}",
self.coord_list(&[c2, e])
));
if self.relative {
candidates.push(format!(
"s{}",
self.delta_list(&[c2, e])
));
}
}
}
}
candidates.push(format!("C{}", self.coord_list(&[c1, c2, e])));
if self.relative {
candidates.push(format!("c{}", self.delta_list(&[c1, c2, e])));
}
self.out.push_str(&shortest(candidates));
self.cur = e;
self.prev_cubic_c2 = Some(c2);
}
// --- number/coordinate formatting -------------------------------------
fn num(&self, v: f64) -> String {
fmt_num(v, self.precision)
}
/// Absolute coordinate pair.
fn coord(&self, p: PointF64) -> String {
join_nums(&[self.num(p.x), self.num(p.y)])
}
/// Delta coordinate pair relative to the current point.
fn coord_delta(&self, p: PointF64) -> String {
join_nums(&[self.num(p.x - self.cur.x), self.num(p.y - self.cur.y)])
}
/// Absolute list of points, flattened.
fn coord_list(&self, pts: &[PointF64]) -> String {
let mut nums = Vec::with_capacity(pts.len() * 2);
for p in pts {
nums.push(self.num(p.x));
nums.push(self.num(p.y));
}
join_nums(&nums)
}
/// Delta list of points relative to the current point (all deltas are from
/// `cur`, matching SVG's relative-command semantics for multi-point ops).
fn delta_list(&self, pts: &[PointF64]) -> String {
let mut nums = Vec::with_capacity(pts.len() * 2);
for p in pts {
nums.push(self.num(p.x - self.cur.x));
nums.push(self.num(p.y - self.cur.y));
}
join_nums(&nums)
}
}
fn approx(a: PointF64, b: PointF64) -> bool {
(a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6
}
fn shorter(a: String, b: String) -> String {
if b.len() < a.len() {
b
} else {
a
}
}
fn shortest(candidates: Vec<String>) -> String {
candidates
.into_iter()
.min_by_key(|s| s.len())
.unwrap_or_default()
}
/// Join formatted numbers with the minimal separators SVG allows: a comma,
/// except that a leading `-` is self-separating.
fn join_nums(nums: &[String]) -> String {
let mut s = String::new();
for (i, n) in nums.iter().enumerate() {
if i > 0 && !n.starts_with('-') {
s.push(',');
}
s.push_str(n);
}
s
}
/// Compact number formatting: round to precision, trim trailing zeros, use a
/// leading-dot for magnitudes below 1.
fn fmt_num(v: f64, precision: Option<u32>) -> String {
let v = match precision {
Some(p) => {
let factor = 10f64.powi(p as i32);
(v * factor).round() / factor
}
None => v,
};
// Normalize -0.0 to 0.
if v == 0.0 {
return "0".to_string();
}
let mut s = match precision {
Some(p) => format!("{:.*}", p as usize, v),
None => format!("{v}"),
};
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if let Some(rest) = s.strip_prefix("0.") {
s = format!(".{rest}");
} else if let Some(rest) = s.strip_prefix("-0.") {
s = format!("-.{rest}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{MultiPath, Paint, PathCmd, Shape, SubPath};
use visioncortex::Color;
#[test]
fn number_formatting() {
assert_eq!(fmt_num(0.0, Some(2)), "0");
assert_eq!(fmt_num(-0.0, Some(2)), "0");
assert_eq!(fmt_num(1.50, Some(2)), "1.5");
assert_eq!(fmt_num(0.5, Some(2)), ".5");
assert_eq!(fmt_num(-0.5, Some(2)), "-.5");
assert_eq!(fmt_num(2.0, Some(2)), "2");
assert_eq!(fmt_num(3.14159, Some(2)), "3.14");
}
#[test]
fn join_omits_separator_before_negative() {
let nums = vec!["1".to_string(), "-2".to_string(), "3".to_string()];
assert_eq!(join_nums(&nums), "1-2,3");
}
fn square_shape() -> Shape {
use visioncortex::PointF64;
let p = |x, y| PointF64 { x, y };
let mut sub = SubPath::new();
sub.commands = vec![
PathCmd::MoveTo(p(0.0, 0.0)),
PathCmd::LineTo(p(10.0, 0.0)),
PathCmd::LineTo(p(10.0, 10.0)),
PathCmd::LineTo(p(0.0, 10.0)),
PathCmd::Close,
];
Shape {
paint: Paint::Solid(Color::new(255, 0, 0)),
path: MultiPath { subpaths: vec![sub] },
}
}
#[test]
fn encodes_axis_aligned_shorthands() {
let writer = SvgWriter {
relative: true,
shorthands: true,
precision: Some(2),
};
let d = writer.encode_path(&square_shape());
// Horizontal/vertical lines collapse to H/V/h/v; first move is absolute.
assert!(d.starts_with("M0,0"));
assert!(d.contains('H') || d.contains('h'));
assert!(d.contains('V') || d.contains('v'));
assert!(d.ends_with('Z'));
}
#[test]
fn absolute_mode_uses_no_relative_commands() {
let writer = SvgWriter {
relative: false,
shorthands: false,
precision: Some(2),
};
let d = writer.encode_path(&square_shape());
assert!(!d.contains('l'));
assert!(!d.contains('c'));
assert!(d.contains('L'));
}
/// A shape with a hole (second subpath). Encoded absolute vs relative must
/// describe the *same* geometry — regression for the bug where the current
/// point was not reset to the subpath start after `Z`, so the relative `m`
/// of the hole was measured from the wrong origin.
fn holed_shape() -> Shape {
use visioncortex::PointF64;
let p = |x, y| PointF64 { x, y };
let outer = SubPath {
commands: vec![
PathCmd::MoveTo(p(0.0, 0.0)),
PathCmd::LineTo(p(30.0, 0.0)),
PathCmd::LineTo(p(30.0, 30.0)),
PathCmd::LineTo(p(0.0, 30.0)),
PathCmd::Close,
],
};
let hole = SubPath {
commands: vec![
PathCmd::MoveTo(p(10.0, 10.0)),
PathCmd::LineTo(p(20.0, 10.0)),
PathCmd::LineTo(p(20.0, 20.0)),
PathCmd::LineTo(p(10.0, 20.0)),
PathCmd::Close,
],
};
Shape {
paint: Paint::Solid(Color::new(0, 0, 0)),
path: MultiPath {
subpaths: vec![outer, hole],
},
}
}
/// Parse an SVG `d` (M/m/L/l/H/h/V/v/Z only) into absolute points.
fn parse_abs(d: &str) -> Vec<(f64, f64)> {
let mut toks = Vec::new();
let mut i = 0;
let b = d.as_bytes();
while i < b.len() {
let c = b[i] as char;
if c.is_ascii_alphabetic() {
toks.push(c.to_string());
i += 1;
} else if c == '-' || c == '.' || c.is_ascii_digit() {
let start = i;
i += 1;
while i < b.len() && {
let d = b[i] as char;
d.is_ascii_digit() || d == '.'
} {
i += 1;
}
toks.push(d[start..i].to_string());
} else {
i += 1;
}
}
let mut out = Vec::new();
let (mut cx, mut cy, mut sx, mut sy) = (0.0, 0.0, 0.0, 0.0);
let mut j = 0;
let mut cmd = ' ';
let num = |j: &mut usize| -> f64 {
let v = toks[*j].parse().unwrap();
*j += 1;
v
};
while j < toks.len() {
if toks[j].chars().next().unwrap().is_ascii_alphabetic() {
cmd = toks[j].chars().next().unwrap();
j += 1;
}
let rel = cmd.is_ascii_lowercase();
match cmd.to_ascii_uppercase() {
'M' => {
let (mut x, mut y) = (num(&mut j), num(&mut j));
if rel {
x += cx;
y += cy;
}
cx = x;
cy = y;
sx = x;
sy = y;
out.push((cx, cy));
cmd = if rel { 'l' } else { 'L' };
}
'L' => {
let (mut x, mut y) = (num(&mut j), num(&mut j));
if rel {
x += cx;
y += cy;
}
cx = x;
cy = y;
out.push((cx, cy));
}
'H' => {
let mut x = num(&mut j);
if rel {
x += cx;
}
cx = x;
out.push((cx, cy));
}
'V' => {
let mut y = num(&mut j);
if rel {
y += cy;
}
cy = y;
out.push((cx, cy));
}
'Z' => {
cx = sx;
cy = sy;
}
_ => unreachable!(),
}
}
out
}
#[test]
fn relative_and_absolute_encode_same_geometry() {
let shape = holed_shape();
let abs = SvgWriter {
relative: false,
shorthands: false,
precision: Some(2),
}
.encode_path(&shape);
for shorthands in [false, true] {
let rel = SvgWriter {
relative: true,
shorthands,
precision: Some(2),
}
.encode_path(&shape);
assert_eq!(
parse_abs(&abs),
parse_abs(&rel),
"relative (shorthands={shorthands}) geometry diverges from absolute:\n abs={abs}\n rel={rel}"
);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Binary thresholding: tunable fixed cutoff and Bradley–Roth 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}"
);
}
+240
View File
@@ -0,0 +1,240 @@
//! Rasterize-and-diff equivalence between stacked and mosaic (cutout) modes.
//!
//! Both modes render the *same* flattened partition of the image — stacked by
//! painting layers top-down, mosaic as a gapless tessellation. So their
//! rasterizations must agree in every region interior; they may differ only
//! within a thin band along region boundaries, where the two fitting paths
//! legitimately place the edge a fraction of a pixel apart. This test asserts
//! exactly that: any pixel that differs must lie within ~1–2px of a boundary.
//!
//! `resvg` is a dev-dependency, so this never enters a wasm build.
use resvg::{tiny_skia, usvg};
use vtracer::{ColorImage, Config, FitMode, Hierarchical};
/// A few smooth colored discs on a background — curved boundaries, limited
/// boundary length, no thin (1px) features.
fn blobs(w: usize, h: usize) -> ColorImage {
let discs = [
(28.0f64, 30.0, 18.0, (210u8, 60, 60)),
(64.0, 40.0, 20.0, (60, 160, 90)),
(44.0, 68.0, 16.0, (70, 90, 200)),
];
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let mut col = (235u8, 230, 225); // background
for &(cx, cy, r, c) in &discs {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
if dx * dx + dy * dy <= r * r {
col = c;
}
}
pixels.extend_from_slice(&[col.0, col.1, col.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
fn rasterize(svg: &str, w: u32, h: u32) -> Vec<u8> {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
pixmap.data().to_vec()
}
/// Max per-channel difference between two RGBA pixels at index `i`.
fn pixel_diff(a: &[u8], b: &[u8], i: usize) -> u8 {
(0..4)
.map(|c| a[i + c].abs_diff(b[i + c]))
.max()
.unwrap_or(0)
}
/// Mark pixels within Chebyshev radius `r` of a color edge in either image.
fn boundary_band(a: &[u8], b: &[u8], w: usize, h: usize, r: i32) -> Vec<bool> {
const EDGE: u8 = 24;
let idx = |x: usize, y: usize| (y * w + x) * 4;
let mut edge = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let i = idx(x, y);
// An edge is where either rendering changes color vs its right/down
// neighbor.
let mut is_edge = false;
for img in [a, b] {
if x + 1 < w && neighbor_diff(img, i, idx(x + 1, y)) > EDGE {
is_edge = true;
}
if y + 1 < h && neighbor_diff(img, i, idx(x, y + 1)) > EDGE {
is_edge = true;
}
}
if is_edge {
edge[y * w + x] = true;
}
}
}
// Dilate the edge set by r.
let mut band = vec![false; w * h];
for y in 0..h as i32 {
for x in 0..w as i32 {
let mut near = false;
'outer: for dy in -r..=r {
for dx in -r..=r {
let (nx, ny) = (x + dx, y + dy);
if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && edge[ny as usize * w + nx as usize] {
near = true;
break 'outer;
}
}
}
band[y as usize * w + x as usize] = near;
}
}
band
}
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_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()
}
.build()
.unwrap()
.to_svg(&img)
.unwrap();
let cutout = Config {
mode,
clustering,
hierarchical: Hierarchical::Cutout,
..Config::default()
}
.build()
.unwrap()
.to_svg(&img)
.unwrap();
let a = rasterize(&stacked, w as u32, h as u32);
let b = rasterize(&cutout, w as u32, h as u32);
assert_eq!(a.len(), b.len());
let band = boundary_band(&a, &b, w, h, 2);
const DIFF: u8 = 40;
let mut interior_mismatches = 0;
for p in 0..(w * h) {
let i = p * 4;
if pixel_diff(&a, &b, i) > DIFF && !band[p] {
interior_mismatches += 1;
}
}
// Every real difference must live in the boundary band; interiors match.
assert_eq!(
interior_mismatches, 0,
"{mode:?}: {interior_mismatches} interior pixels differ between stacked and cutout \
(differences must be confined to the boundary band)"
);
}
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);
}
#[test]
fn stacked_and_cutout_agree_in_interiors_polygon() {
assert_equivalent(FitMode::Polygon);
}
#[test]
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> {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
pixmap.fill(tiny_skia::Color::from_rgba8(bg[0], bg[1], bg[2], 255));
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
pixmap.data().to_vec()
}
/// A full-canvas-coverage image rendered in stacked mode must be fully opaque:
/// 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.
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()
}
.build()
.unwrap()
.to_svg(&img)
.unwrap();
let white = rasterize_on(&svg, w as u32, h as u32, [255, 255, 255, 255]);
let black = rasterize_on(&svg, w as u32, h as u32, [0, 0, 0, 255]);
// Count backdrop-dependent pixels, ignoring the 1px canvas border (the only
// legitimate outer-silhouette antialiasing for a full-coverage image).
let mut show_through = 0;
for y in 1..h - 1 {
for x in 1..w - 1 {
let i = (y * w + x) * 4;
if (0..3).any(|c| white[i + c].abs_diff(black[i + c]) > 8) {
show_through += 1;
}
}
}
assert_eq!(
show_through, 0,
"{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);
}
+317
View File
@@ -0,0 +1,317 @@
//! Golden-snapshot tests over synthetic images, exercising every stage —
//! hierarchical clustering, all three fitters, color fitting, the optimizer
//! passes, and the writer.
//!
//! Goldens are compared by **rendering** both the stored SVG and the freshly
//! produced SVG and diffing pixels, not by byte-equality. The spline fitter's
//! cubic fit is floating-point, and f64 results differ by a few ULPs across
//! architectures (arm64 vs x86_64); after rounding, a coordinate can flip and
//! change the SVG bytes without any real geometry change. A visual diff is
//! encoding-agnostic and tolerant of that sub-pixel noise while still catching
//! genuine regressions.
//!
//! Regenerate goldens after an intentional behavior change with:
//!
//! ```sh
//! VTRACER_BLESS=1 cargo test -p vtracer --test golden
//! ```
use std::path::PathBuf;
use resvg::{tiny_skia, usvg};
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
// --- synthetic image builders ------------------------------------------------
fn mk<F: Fn(usize, usize) -> (u8, u8, u8, u8)>(w: usize, h: usize, f: F) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let (r, g, b, a) = f(x, y);
pixels.extend_from_slice(&[r, g, b, a]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// Four vertical color bands.
fn bands() -> ColorImage {
let cols = [
(220, 40, 40),
(40, 200, 60),
(50, 60, 220),
(230, 210, 40),
];
mk(48, 40, |x, _| {
let (r, g, b) = cols[(x * cols.len()) / 48];
(r, g, b, 255)
})
}
/// Checkerboard of 8x8 cells — exercises region adjacency and holes.
fn checker() -> ColorImage {
mk(48, 48, |x, y| {
if ((x / 8) + (y / 8)) % 2 == 0 {
(20, 20, 20, 255)
} else {
(235, 235, 235, 255)
}
})
}
/// A filled disc on a contrasting background — exercises curve fitting.
fn disc() -> ColorImage {
let (cx, cy, r2) = (24.0f64, 24.0f64, 16.0f64 * 16.0);
mk(48, 48, |x, y| {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
if dx * dx + dy * dy <= r2 {
(200, 60, 60, 255)
} else {
(240, 240, 240, 255)
}
})
}
/// An annulus (disc with a hole) — exercises hole tracing.
fn ring() -> ColorImage {
let (cx, cy) = (24.0f64, 24.0f64);
mk(48, 48, |x, y| {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let d2 = dx * dx + dy * dy;
if d2 <= 20.0 * 20.0 && d2 >= 9.0 * 9.0 {
(40, 90, 200, 255)
} else {
(245, 245, 245, 255)
}
})
}
/// A 4x4 grid of 16 distinct saturated colors — produces many hierarchical
/// layers, and gives auto-quantize something real to reduce.
fn swatches() -> ColorImage {
let step = [0u8, 85, 170, 255];
mk(48, 48, |x, y| {
let col = (x / 12).min(3);
let row = (y / 12).min(3);
(step[col], step[row], 128, 255)
})
}
// --- fixture matrix ----------------------------------------------------------
fn base() -> Config {
Config::default()
}
fn cases() -> Vec<(&'static str, ColorImage, Config)> {
vec![
// Fit modes on the same content.
("bands_spline", bands(), base()),
(
"bands_polygon",
bands(),
Config {
mode: FitMode::Polygon,
..base()
},
),
(
"bands_pixel",
bands(),
Config {
mode: FitMode::Pixel,
optimize: 0,
..base()
},
),
// Curves and holes.
("disc_spline", disc(), base()),
("ring_spline", ring(), base()),
("checker_spline", checker(), base()),
// Hierarchical layering.
("swatches_color", swatches(), base()),
// Binary mode.
(
"checker_bw",
checker(),
Config {
clustering: Clustering::Binary,
..base()
},
),
// Color fitting: fixed palette (+ merge) and auto-quantize (+ merge).
(
"bands_palette",
bands(),
Config {
palette: vec![Color::new(0, 0, 0), Color::new(255, 255, 255)],
optimize: 2,
..base()
},
),
(
"swatches_quant4",
swatches(),
Config {
max_colors: Some(4),
optimize: 2,
..base()
},
),
// Optimizer / writer encoding levels on identical geometry.
(
"disc_opt0",
disc(),
Config {
optimize: 0,
..base()
},
),
(
"disc_opt2",
disc(),
Config {
optimize: 2,
..base()
},
),
// Mosaic (seam-free tessellation): exact pixel and polygon fitters.
(
"disc_mosaic_pixel",
disc(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Pixel,
..base()
},
),
(
"checker_mosaic_polygon",
checker(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Polygon,
optimize: 2,
..base()
},
),
(
"disc_mosaic_spline",
disc(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Spline,
..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()
},
),
]
}
fn goldens_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("goldens")
}
#[test]
fn golden_snapshots() {
let bless = std::env::var_os("VTRACER_BLESS").is_some();
let dir = goldens_dir();
if bless {
std::fs::create_dir_all(&dir).unwrap();
}
let mut mismatches = Vec::new();
for (name, img, config) in cases() {
let svg = config
.build()
.unwrap_or_else(|e| panic!("case {name}: build failed: {e}"))
.to_svg(&img)
.unwrap_or_else(|e| panic!("case {name}: convert failed: {e}"));
let path = dir.join(format!("{name}.svg"));
if bless {
std::fs::write(&path, &svg).unwrap();
continue;
}
match std::fs::read_to_string(&path) {
Ok(expected) => {
if let Some(diff) = render_diff(&expected, &svg) {
mismatches.push(format!("{name}: {diff}"));
}
}
Err(_) => mismatches.push(format!(
"{name}: missing golden ({}); run with VTRACER_BLESS=1",
path.display()
)),
}
}
assert!(
mismatches.is_empty(),
"golden mismatches:\n{}",
mismatches.join("\n")
);
}
/// Render an SVG string to an RGBA pixmap at its intrinsic size.
fn render(svg: &str) -> (u32, u32, Vec<u8>) {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse golden svg");
let size = tree.size();
let (w, h) = (size.width().ceil() as u32, size.height().ceil() as u32);
let mut pixmap = tiny_skia::Pixmap::new(w.max(1), h.max(1)).expect("alloc pixmap");
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
(w, h, pixmap.data().to_vec())
}
/// Compare two SVGs by rendering. Returns `Some(reason)` if they differ beyond
/// a small tolerance (which absorbs cross-architecture sub-pixel float noise),
/// or `None` if visually equivalent.
fn render_diff(expected: &str, actual: &str) -> Option<String> {
let (ew, eh, a) = render(expected);
let (aw, ah, b) = render(actual);
if (ew, eh) != (aw, ah) {
return Some(format!("size {ew}x{eh} vs {aw}x{ah}"));
}
// A pixel "differs" only on a clear color change, not antialiasing wobble.
const CHANNEL: u8 = 40;
let total = (ew * eh) as usize;
let differing = (0..total)
.filter(|&p| (0..3).any(|c| a[p * 4 + c].abs_diff(b[p * 4 + c]) > CHANNEL))
.count();
// Allow a tiny fraction for boundary pixels that flip under sub-pixel shifts.
let allowed = (total / 200).max(8); // 0.5%, min 8px
if differing > allowed {
Some(format!(
"{differing}/{total} pixels differ (> {allowed} allowed) — real change, re-bless if intended"
))
} else {
None
}
}
@@ -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="40">
<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>

After

Width:  |  Height:  |  Size: 488 B

@@ -0,0 +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,0L48,0L48,40L0,40Z" fill="#28C83C"/>
<path d="M36,0L48,0L48,40L36,40Z" fill="#E6D228"/>
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
<path d="M0,0L12,0L12,40L0,40Z" fill="#DC2828"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

@@ -0,0 +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,0L48,0l0,40L0,40Z" fill="#28C83C"/>
<path d="M36,0L48,0l0,40L36,40Z" fill="#E6D228"/>
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
<path d="M0,0L12,0l0,40L0,40Z" fill="#DC2828"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +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,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>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,22 @@
<?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,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#000000"/>
<path d="M16,0c2.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="#000000"/>
<path d="M32,0c2.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="#000000"/>
<path d="M8,8c2.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="#000000"/>
<path d="M24,8c2.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="#000000"/>
<path d="M40,8c2.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="#000000"/>
<path d="M0,16c2.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="#000000"/>
<path d="M16,16c2.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="#000000"/>
<path d="M32,16c2.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="#000000"/>
<path d="M8,24c2.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="#000000"/>
<path d="M24,24c2.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="#000000"/>
<path d="M40,24c2.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="#000000"/>
<path d="M0,32c2.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="#000000"/>
<path d="M16,32c2.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="#000000"/>
<path d="M32,32c2.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="#000000"/>
<path d="M8,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="#000000"/>
<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="#000000"/>
<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="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +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="M24,0H16V8h8V0Z" fill="#141414"/>
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
<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>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,40 @@
<?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="#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"/>
<path d="M16,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="M8,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="M0,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="M40,32c2.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="M32,32c2.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="M24,32c2.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="M16,32c2.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="M8,32c2.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="M0,32c2.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="M40,24c2.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,24c2.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,24c2.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="M16,24c2.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="M8,24c2.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="M0,24c2.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="M40,16c2.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="M32,16c2.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="M24,16c2.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="M16,16c2.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="M8,16c2.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="M0,16c2.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="M40,8c2.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,8c2.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,8c2.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="M16,8c2.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="M8,8c2.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="M0,8C2.64,8,5.28,8,8,8c0,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="M40,0c2.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="M32,0c2.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="M24,0c2.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="M16,0c2.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="M0,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#141414"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0L0,48l48,0L48,0L0,0ZM24,8l1,0l0,1l5,0l0,1l2,0l0,1l2,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,2l1,0l0,2l1,0l0,5l1,0l0,1l-1,0l0,5l-1,0l0,2l-1,0l0,2l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-2,0l0,1l-2,0l0,1l-5,0l0,1l-1,0l0-1l-5,0l0-1l-2,0l0-1l-2,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-2l-1,0l0-2L9,30l0-5L8,25l0-1l1,0l0-5l1,0l0-2l1,0l0-2l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l2,0l0-1l2,0l0-1l5,0l0-1Z" fill="#F0F0F0"/>
<path d="M24,8l0,1L19,9l0,1l-2,0l0,1l-2,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,2l-1,0l0,2L9,19l0,5L8,24l0,1l1,0l0,5l1,0l0,2l1,0l0,2l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l2,0l0,1l2,0l0,1l5,0l0,1l1,0l0-1l5,0l0-1l2,0l0-1l2,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-2l1,0l0-2l1,0l0-5l1,0l0-1l-1,0l0-5l-1,0l0-2l-1,0l0-2l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-2,0l0-1l-2,0l0-1L25,9l0-1L24,8Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 985 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

After

Width:  |  Height:  |  Size: 864 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

After

Width:  |  Height:  |  Size: 549 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

After

Width:  |  Height:  |  Size: 520 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

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

@@ -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="#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>

After

Width:  |  Height:  |  Size: 781 B

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="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>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +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,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>

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

+103
View File
@@ -0,0 +1,103 @@
//! End-to-end pipeline smoke tests over synthetic images.
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 {
let mut pixels = Vec::with_capacity(size * size * 4);
for _y in 0..size {
for x in 0..size {
let (r, g, b) = if x < size / 2 {
(220, 40, 40)
} else {
(40, 40, 220)
};
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
ColorImage {
pixels,
width: size,
height: size,
}
}
fn assert_valid_svg(svg: &str) {
assert!(svg.contains("<svg"), "missing <svg> element:\n{svg}");
assert!(svg.trim_end().ends_with("</svg>"), "missing </svg> close");
assert!(svg.contains("<path"), "expected at least one path:\n{svg}");
}
#[test]
fn default_color_pipeline_produces_svg() {
let img = two_band_image(32);
let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn all_fit_modes_produce_svg() {
let img = two_band_image(32);
for mode in [FitMode::Pixel, FitMode::Polygon, FitMode::Spline] {
let config = Config {
mode,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
}
#[test]
fn binary_pipeline_produces_svg() {
let img = two_band_image(32);
let config = Config {
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);
let mut sizes = Vec::new();
for level in [0u8, 1, 2] {
let config = Config {
optimize: level,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
sizes.push(svg.len());
}
// Higher optimization should never produce larger output than level 0.
assert!(sizes[1] <= sizes[0], "opt1 {} > opt0 {}", sizes[1], sizes[0]);
assert!(sizes[2] <= sizes[0], "opt2 {} > opt0 {}", sizes[2], sizes[0]);
}
#[test]
fn mosaic_cutout_produces_svg() {
let img = two_band_image(32);
let config = Config {
hierarchical: Hierarchical::Cutout,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
+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");
}