45 Commits

Author SHA1 Message Date
Chris Tsang 272866beb3 Linux 2026-09-09 22:32:49 +01:00
VTracer Release Bot 2bc01424cd Publish desktop updater 1.0.0-alpha.4.app.151 2026-09-09 22:32:04 +01:00
VTracer Release Bot f85b8f5813 Publish desktop updater 1.0.0-alpha.4.app.151 2026-09-09 22:10:40 +01:00
Chris Tsang aa0ae083e8 Linux 2026-09-09 17:28:49 +01:00
VTracer Release Bot 4aedd6d808 Publish desktop updater 1.0.0-alpha.4.app.148 2026-09-09 17:25:59 +01:00
VTracer Release Bot 1a4e5314b2 Publish desktop updater 1.0.0-alpha.4.app.148 2026-09-09 15:17:12 +01:00
Chris Tsang 642721e85e Linux 2026-09-09 11:10:39 +01:00
VTracer Release Bot a67cdebac4 Publish desktop updater 1.0.0-alpha.4.app.146 2026-09-09 11:08:32 +01:00
VTracer Release Bot fbbbbbaaae Publish desktop updater 1.0.0-alpha.4.app.146 2026-09-08 23:32:36 +01:00
VTracer Release Bot 5eb956aa2a Publish desktop updater 1.0.0-alpha.4.app.144 2026-09-08 22:42:12 +01:00
Chris Tsang d797c0c934 Linux 2026-09-08 21:57:35 +01:00
VTracer Release Bot a9355f03c0 Publish desktop updater 1.0.0-alpha.4.app.143 2026-09-08 21:56:16 +01:00
VTracer Release Bot e3e28b5170 Publish desktop updater 1.0.0-alpha.4.app.143 2026-09-08 21:55:50 +01:00
Chris Tsang 25fdc6f817 Linux 2026-09-07 12:02:20 +01:00
VTracer Release Bot f8c54afa18 Publish desktop updater 1.0.0-alpha.4.app.135 2026-09-07 11:56:07 +01:00
VTracer Release Bot adb5055f7b Publish desktop updater 1.0.0-alpha.4.app.135 2026-09-07 11:51:56 +01:00
Chris Tsang 0e17e64e51 Linux 2026-09-07 00:50:45 +01:00
VTracer Release Bot 870d5aef49 Publish desktop updater 1.0.0-alpha.4.app.133 2026-09-07 00:44:27 +01:00
VTracer Release Bot 015f7f5baa Publish desktop updater 1.0.0-alpha.4.app.133 2026-09-07 00:42:47 +01:00
Chris Tsang 04ceaff5ed Publish desktop updater 1.0.0-alpha.4.app.118 2026-08-31 22:55:22 +01:00
VTracer Release Bot 4b77efdaae Publish desktop updater 1.0.0-alpha.4.app.118 2026-08-31 22:54:26 +01:00
VTracer Release Bot a05158aedf Publish desktop updater 1.0.0-alpha.4.app.118 2026-08-31 22:40:15 +01:00
Chris Tsang 01833619be Publish desktop updater 1.0.0-alpha.4.app.116 2026-08-29 22:21:14 +01:00
VTracer Release Bot 15d6f3bbda Publish desktop updater 1.0.0-alpha.4.app.116 2026-08-29 22:18:11 +01:00
VTracer Release Bot 037003134d Publish desktop updater 1.0.0-alpha.4.app.116 2026-08-29 22:17:43 +01:00
VTracer Release Bot 142a2987cb Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 17:39:52 +01:00
VTracer Release Bot 034ce42de0 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 17:36:46 +01:00
VTracer Release Bot f78c77be21 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 17:21:56 +01:00
VTracer Release Bot 65218f7f93 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 16:48:51 +01:00
VTracer Release Bot 657177bda1 Publish desktop updater 1.0.0-alpha.3.app.59 2026-08-14 16:45:39 +01:00
VTracer Release Bot cee28b09b8 Publish desktop updater 1.0.0-alpha.3.app.58 2026-08-14 16:09:18 +01:00
VTracer Release Bot 8ac000c213 Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:39:28 +01:00
VTracer Release Bot f17908803d Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:35:46 +01:00
VTracer Release Bot b17f3565c9 Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:06:01 +01:00
VTracer Release Bot 2a82a81a27 Publish desktop updater 1.0.0-alpha.3 2026-08-13 16:05:29 +01:00
VTracer Release Bot dac83cdcf6 Publish desktop updater 1.0.0-alpha.3 2026-08-13 15:52:40 +01:00
VTracer Release Bot 14665d771f Publish desktop updater 1.0.0-alpha.3 2026-08-13 15:12:34 +01:00
VTracer Release Bot 71858aec47 Publish desktop updater 1.0.0-alpha.3 2026-08-13 15:05:55 +01:00
VTracer Release Bot 539354c4b2 Publish desktop updater 1.0.0-alpha.3 2026-08-13 14:51:20 +01:00
VTracer Release Bot 552bc1b8dd Publish desktop updater 1.0.0-alpha.3 2026-08-13 13:28:40 +01:00
VTracer Release Bot e8dce3b972 Publish desktop updater 1.0.0-alpha.3 2026-08-08 21:44:06 +01:00
VTracer Release Bot 8d5dd9bdc1 Publish desktop updater 1.0.0-alpha.3 2026-08-08 20:20:24 +01:00
VTracer Release Bot 71b7210525 Publish desktop updater 1.0.0-alpha.3 2026-08-07 11:33:21 +01:00
VTracer Release Bot 19d4e48cb2 Publish desktop updater 1.0.0-alpha.3 2026-08-02 01:31:23 +01:00
VTracer Release Bot daa866862b Publish desktop updater 1.0.0-alpha.3 2026-08-02 01:15:17 +01:00
16 changed files with 626 additions and 21 deletions
+10
View File
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## 1.0.0-alpha.3 - 2026-08-01
### Added
* `vtracer-bench`: a blind fidelity benchmark for raster-to-vector tracers — it compares an original raster against a rendered reconstruction and reports one 0..1 score built from PSNR, SSIM, and a clustered-diff "missing patch" metric (geometric mean, so a single collapsed axis drags the score down). Blind to how the reconstruction was produced: render any tracer's output to pixels and score it. A new workspace crate, separate from the four shipped packages.
### Fixed
* Watershed no longer leaks a region along a blurred low-contrast crack as a 1-px filament (a slightly soft image could grow a hairline of one region's color running tens of px down a neighbouring boundary). The boundary snap's mixture gate now also admits pixels that blend two *neighbouring* regions — a blend band belongs to its closer flank even when the basin cut misattributed it to a distant region. On the blurred striped synthetic the circle's max boundary error drops from 31.6 px to 1.5 px; crisp images are byte-unaffected.
## 1.0.0-alpha.2 - 2026-07-27
### Added
+2 -1
View File
@@ -3,6 +3,7 @@
members = [
"crates/vtracer",
"crates/vtracer-cli",
"crates/vtracer-bench",
]
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
@@ -18,7 +19,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.0.0-alpha.2"
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
edition = "2024"
license = "MIT OR Apache-2.0"
+7 -7
View File
@@ -8,11 +8,11 @@
</p>
<h3>
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
<a href="https://github.com/visioncortex/vtracer/releases">Releases</a>
<span> | </span>
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
<span> | </span>
<a href="https://github.com/visioncortex/vtracer/releases">Download</a>
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.3/VTracer_1.0.0-alpha.3_x64-setup.exe">Windows App</a>
</h3>
<p>
@@ -46,7 +46,7 @@ VTracer is originally designed for processing high resolution scans of historic
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
## Desktop App (coming soon)
## Desktop App
![screenshot](docs/images/desktop-app.png)
@@ -169,7 +169,7 @@ cargo install vtracer-cli
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
```sh
cargo add vtracer@1.0.0-alpha.2
cargo add vtracer@1.0.0-alpha.3
```
```rust
@@ -199,14 +199,14 @@ let seg = pipeline.segment(&img)?; // the expensive part
let doc = pipeline.finish(&seg)?; // VectorDoc, ready to serialize
```
See [docs.rs/vtracer](https://docs.rs/vtracer/1.0.0-alpha.2/vtracer/) for the full API.
See [docs.rs/vtracer](https://docs.rs/vtracer/1.0.0-alpha.3/vtracer/) for the full API.
### Python Library
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension.
```sh
pip install --pre vtracer
pip install vtracer==1.0.0a3
```
```python
@@ -238,7 +238,7 @@ See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**. Decodes PNG, JPEG, GIF, BMP, and WebP; for other formats, decode yourself and pass raw RGBA to `convertPixels`.
```sh
npm install @visioncortex/vtracer@1.0.0-alpha.2
npm install @visioncortex/vtracer@1.0.0-alpha.3
```
```js
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "vtracer-bench"
description = "Blind fidelity benchmark for raster-to-vector tracers: compare the original raster with a rendered reconstruction and get one 0..1 fidelity score built from PSNR, SSIM and a clustered-diff patch metric."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "development-tools::testing"]
keywords = ["vectorization", "benchmark", "fidelity", "ssim", "psnr"]
[dependencies]
visioncortex.workspace = true
dssim-core = "3"
rgb = "0.8"
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
+104
View File
@@ -0,0 +1,104 @@
# vtracer-bench
Blind fidelity benchmark for raster-to-vector tracers.
It compares an **original raster** with a **rendered reconstruction** and reports one number — a fidelity score in **[0, 1]** — built from three complementary axes. It is *blind* in the sense that it knows nothing about how the reconstruction was produced: any tracer, any format, any renderer. Render your vector output to pixels (same dimensions as the original), then let the benchmark judge.
```console
$ vtracer-bench original.png reconstruction.png
psnr 34.77 dB (rmse 4.66) -> 0.6875
ssim 0.99541 (dssim 0.00461) -> 0.9954
patch 157.8 px rms (14503 bad px, 74 clusters, largest 73) -> 0.9726
fidelity 0.9022
[csv] 0.9022,34.77,0.00461,4.66,157.8,0.6875,0.9954,0.9726
```
## Why another metric?
Every classic metric has a blind spot, and tracers exploit all of them:
- **PSNR** over-values invisible dust and undersells small salient regions — a tracer that drops an eye but nails the background can post a great PSNR.
- **SSIM** tracks perceived quality well, but averages globally: a small, fully-lost region barely moves it.
- Neither can tell **a thousand scattered ±1 pixels** apart from **one coherent missing patch** of the same total mass — and the missing patch is the failure that actually matters.
`vtracer-bench` scores all three axes and combines them so that no single blind spot survives:
| axis | raw metric | subscore in [0, 1] |
| --- | --- | --- |
| `psnr` | sRGB PSNR over RGB | `1 log(1+rmse) / log(256)` |
| `ssim` | multiscale DSSIM (`dssim-core`) | `SSIM = 1 / (1 + DSSIM)` |
| `patch` | clustered-diff "missing patch" detector | `2^(P / 0.005)` |
**fidelity = ( psnr¹ · ssim² · patch¹ ) ^ (1/4)** — a *weighted geometric mean*. Geometric, not arithmetic, so a single collapsed axis drags the composite down: a missing face region cannot hide behind good global PSNR. SSIM carries double weight because it tracks visual accuracy best and is the axis most robust to an imperfect source.
## The three axes
### psnr — parameter-free squash
The squash `1 log(1+rmse)/log(256)` is anchored at the only two natural error scales an 8-bit image has:
- `rmse = 255` (the full range — noise indistinguishable from a random image) → **0**
- `rmse ≤ 1` (the quantization step — errors 8-bit can barely represent) → saturates to **1**
For `rmse ≫ 1` it equals `psnr / 48.13 dB`, i.e. it stays linear in decibels, but with no hand-picked anchor constants.
### ssim — perceptual structure
`dssim-core` computes multiscale structural dissimilarity `d = 1/SSIM 1`; the subscore is simply `SSIM = 1/(1+d)`, already a natural 0..1. Differences the eye can't see score ~1 regardless of how many pixels they touch.
### patch — the missing-patch detector
This is the axis PSNR and SSIM both lack:
1. A pixel is **bad** iff its RGB Euclidean distance to the original exceeds `--thresh` (default 24 — roughly 14 per channel).
2. The bad mask is **opened** (one round of 4-connected erode + dilate). A slightly blurred or recompressed *source* shifts every edge and paints ≤2 px filaments along all boundaries; those vanish under the opening, while genuine missing patches survive. This is what makes the benchmark tolerant of mildly compressed or blurred originals.
3. The surviving mask is clustered (4-connected). With cluster areas `aᵢ`, the **patch mass** is `√(Σ aᵢ²)` — a sum of *squares*, so one coherent blob dominates any amount of scattered dust of equal total area.
4. With `P = patch mass / (w·h)`, the subscore is `2^(P/0.005)`: a single coherent blob at 0.5 % of image mass halves the score; scattered dust barely dents it.
## Calibration
Scored on a 768×1024 flat-shaded illustration, comparing the original against distorted versions of **itself** — this is how much slack the benchmark gives an imperfect source, and what the top of the scale means:
| candidate | psnr | ssim | patch | **fidelity** |
| --- | --- | --- | --- | --- |
| the original itself | 1.000 | 1.000 | 1.000 | **1.0000** |
| JPEG quality 95 | 0.816 | 1.000 | 1.000 | **0.9502** |
| JPEG quality 75 | 0.718 | 0.999 | 0.994 | **0.9186** |
| 0.8 px Gaussian blur | 0.596 | 0.995 | 0.861 | **0.8443** |
Rule of thumb: **≥ 0.95** is visually indistinguishable, **≥ 0.90** is a faithful trace, **≤ 0.80** has visible geometry or color errors, and a score that *collapses* while PSNR/SSIM stay high means the patch axis found a coherent missing region — look at the `--mask` output.
## Usage
### CLI
```console
vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
```
- `original`, `candidate` — rasters of identical dimensions (any format `image` decodes). Rendering an SVG to pixels is deliberately out of scope: use the renderer whose output you actually ship (resvg, Chromium, librsvg, …) so the benchmark judges what users see.
- `--thresh N` — RGB Euclidean bad-pixel gate for the patch axis (default 24).
- `--mask out.png` — write the raw bad-pixel mask (before the opening) for visual inspection.
The last stdout line is machine-readable:
```
[csv] fidelity,psnr,dssim,rmse,patch_mass,s_psnr,s_ssim,s_patch
```
(RMSE is reported for reference but carries no weight — it is the same MSE that PSNR measures, only on a linear curve; scoring both would double-weight one error.)
### Library
```rust
use vtracer_bench::{fidelity, DEFAULT_THRESH};
// orig and cand are interleaved RGB8, both w×h
let (report, bad_mask) = fidelity(&orig, &cand, w, h, DEFAULT_THRESH);
println!("fidelity {:.4} (psnr {:.2} dB, dssim {:.5})",
report.fidelity, report.psnr, report.dssim);
```
`FidelityReport` exposes every raw metric and subscore; the tuning constants (`PATCH_HALF`, `DEFAULT_THRESH`, and the `W_PSNR`/`W_SSIM`/`W_PATCH` weights) are public and documented in `lib.rs`.
The benchmark is fully deterministic: identical inputs produce byte-identical output.
+247
View File
@@ -0,0 +1,247 @@
//! Universal tracer fidelity benchmark — original vs reconstruction, blind to
//! how the reconstruction was made. Three raw metrics, each squashed to [0,1],
//! composed by geometric mean into ONE fidelity score (0 = garbage, 1 = exact):
//!
//! psnr sRGB PSNR over RGB. Squash: 1 log(1+rmse)/log(256) — anchored
//! at the two natural scales of 8-bit imagery and nothing else:
//! rmse = 255 (full range) → 0, rmse ≤ 1 (the quantization step)
//! saturates to 1. Equals psnr/48.13dB for rmse ≫ 1, i.e. still
//! linear in dB, without arbitrary anchor constants.
//! ssim dssim-core multiscale DSSIM d (= 1/SSIM 1) → SSIM = 1/(1+d),
//! already a natural 0..1.
//! patch the "missing patch" / systematic-bias detector: bad ⟺ RGB
//! Euclidean diff > thresh, OPEN the bad mask (1-round 4-conn
//! erode+dilate — a slightly blurred or compressed source shifts
//! every edge and paints ≤2px filaments along all boundaries; those
//! vanish, real patches survive), then cluster it (visioncortex,
//! 4-conn), S = Σ area². Patch mass fraction P = √S / (w·h) — the RMS
//! coherent-blob size as a fraction of the image. Squash: 2^(P/0.005),
//! so ONE coherent blob at 0.5% image mass halves the score while the
//! same pixel count scattered as dust barely dents it. Exactly the
//! failure mode PSNR/SSIM average away.
//!
//! Composite: weighted geometric mean, fidelity = (psnr¹ · ssim² · patch¹)^(1/4).
//! Geometric (not arithmetic) so a single collapsed axis drags the composite
//! down — a missing eye can't hide behind good global PSNR. SSIM carries double
//! weight: it tracks visual accuracy best and is the axis most robust to a
//! mildly compressed or blurred source.
use visioncortex::BinaryImage;
/// Patch mass fraction that halves the patch subscore.
pub const PATCH_HALF: f64 = 0.005;
/// Default RGB Euclidean distance for a pixel to count as "bad".
pub const DEFAULT_THRESH: f64 = 24.0;
/// Composite weights (geometric): fidelity = (psnr^1 · ssim^2 · patch^1)^(1/4).
pub const W_PSNR: f64 = 1.0;
pub const W_SSIM: f64 = 2.0;
pub const W_PATCH: f64 = 1.0;
#[derive(Debug, Clone, Copy)]
pub struct FidelityReport {
// raw
pub psnr: f64,
pub dssim: f64,
/// sRGB RMSE — reported for reference, carries no weight (PSNR is the
/// same MSE on a log curve; scoring both would double-weight it)
pub rmse: f64,
/// bad pixels (‖Δrgb‖ > thresh), before the opening
pub bad_px: usize,
/// 4-conn clusters of bad pixels after the opening
pub clusters: usize,
/// largest cluster area (px)
pub largest: usize,
/// √(Σ area²) — RMS coherent-blob mass, in px
pub patch_mass: f64,
// subscores in [0,1]
pub s_psnr: f64,
pub s_ssim: f64,
pub s_patch: f64,
/// geometric mean of the three subscores
pub fidelity: f64,
}
fn dssim_score(a_rgb: &[u8], b_rgb: &[u8], w: usize, h: usize) -> f64 {
let d = dssim_core::Dssim::new();
let to = |buf: &[u8]| {
let px: Vec<rgb::RGB<u8>> =
(0..w * h).map(|i| rgb::RGB { r: buf[i * 3], g: buf[i * 3 + 1], b: buf[i * 3 + 2] }).collect();
d.create_image_rgb(&px, w, h).expect("dssim image")
};
let (val, _) = d.compare(&to(a_rgb), &to(b_rgb));
val.into()
}
/// Compare an original against a candidate reconstruction, both RGB8, w×h.
/// `thresh` is the RGB Euclidean bad-pixel gate (use [`DEFAULT_THRESH`]).
/// Returns the report plus the bad-pixel mask (255/0, one byte per pixel).
pub fn fidelity(orig_rgb: &[u8], cand_rgb: &[u8], w: usize, h: usize, thresh: f64) -> (FidelityReport, Vec<u8>) {
assert_eq!(orig_rgb.len(), w * h * 3);
assert_eq!(cand_rgb.len(), w * h * 3);
// PSNR + RMSE + bad-pixel binarization in one pass
let mut sse = 0f64;
let mut mask = vec![0u8; w * h];
let mut bad_px = 0usize;
let t2 = thresh * thresh;
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let mut d2 = 0f64;
for c in 0..3 {
let e = orig_rgb[i * 3 + c] as f64 - cand_rgb[i * 3 + c] as f64;
d2 += e * e;
}
sse += d2;
if d2 > t2 {
mask[i] = 255;
bad_px += 1;
}
}
}
let rmse = (sse / (w * h * 3) as f64).sqrt();
let psnr = 20.0 * (255.0 / rmse.max(1e-6)).log10();
let dssim = dssim_score(orig_rgb, cand_rgb, w, h);
// opening: 1-round 4-conn erode + dilate. Edge-shift filaments (≤2px wide,
// the signature of a slightly blurred/compressed source) vanish; genuine
// missing patches survive. The reported mask keeps the raw bad pixels.
let at = |m: &[u8], x: i64, y: i64| {
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && m[y as usize * w + x as usize] != 0
};
let mut eroded = vec![0u8; w * h];
for y in 0..h as i64 {
for x in 0..w as i64 {
if at(&mask, x, y)
&& at(&mask, x - 1, y)
&& at(&mask, x + 1, y)
&& at(&mask, x, y - 1)
&& at(&mask, x, y + 1)
{
eroded[y as usize * w + x as usize] = 255;
}
}
}
let mut bin = BinaryImage::new_w_h(w, h);
for y in 0..h as i64 {
for x in 0..w as i64 {
if at(&eroded, x, y)
|| at(&eroded, x - 1, y)
|| at(&eroded, x + 1, y)
|| at(&eroded, x, y - 1)
|| at(&eroded, x, y + 1)
{
bin.set_pixel(x as usize, y as usize, true);
}
}
}
let sizes: Vec<usize> = bin.to_clusters(false).iter().map(|c| c.size()).collect();
let largest = sizes.iter().copied().max().unwrap_or(0);
let patch_mass = if sizes.is_empty() {
0.0
} else {
sizes.iter().map(|&a| (a as f64) * (a as f64)).sum::<f64>().sqrt()
};
let p_frac = patch_mass / (w * h) as f64;
let s_psnr = 1.0 - (1.0 + rmse).ln() / 256f64.ln();
let s_ssim = 1.0 / (1.0 + dssim);
let s_patch = (-p_frac / PATCH_HALF * std::f64::consts::LN_2).exp();
let fidelity = (s_psnr.powf(W_PSNR) * s_ssim.powf(W_SSIM) * s_patch.powf(W_PATCH))
.powf(1.0 / (W_PSNR + W_SSIM + W_PATCH));
(
FidelityReport {
psnr,
dssim,
rmse,
bad_px,
clusters: sizes.len(),
largest,
patch_mass,
s_psnr,
s_ssim,
s_patch,
fidelity,
},
mask,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(w: usize, h: usize, c: [u8; 3]) -> Vec<u8> {
(0..w * h).flat_map(|_| c).collect()
}
#[test]
fn identical_is_one() {
let a = flat(64, 64, [120, 90, 200]);
let (r, mask) = fidelity(&a, &a, 64, 64, DEFAULT_THRESH);
assert_eq!(r.bad_px, 0);
assert!(mask.iter().all(|&m| m == 0));
assert!((r.fidelity - 1.0).abs() < 1e-9, "fidelity {}", r.fidelity);
}
#[test]
fn coherent_patch_scores_below_scattered_dust() {
// same 256 bad pixels: one 16×16 blob vs isolated singles on a 64×64 grid
let clean = flat(64, 64, [200, 200, 200]);
let mut blob = clean.clone();
for y in 24..40 {
for x in 24..40 {
blob[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
}
let mut dust = clean.clone();
for k in 0..256 {
let (x, y) = ((k % 16) * 4, (k / 16) * 4); // 4px spacing: 256 singleton clusters
dust[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
let (rb, _) = fidelity(&clean, &blob, 64, 64, DEFAULT_THRESH);
let (rd, _) = fidelity(&clean, &dust, 64, 64, DEFAULT_THRESH);
assert_eq!(rb.bad_px, 256);
assert_eq!(rd.bad_px, 256);
// dust vanishes under the opening entirely; the blob survives
assert_eq!(rb.clusters, 1);
assert_eq!(rd.clusters, 0);
assert!((rd.s_patch - 1.0).abs() < 1e-9);
// identical PSNR/RMSE by construction; the patch axis must separate them
assert!((rb.rmse - rd.rmse).abs() < 1e-9);
assert!(rb.s_patch < rd.s_patch * 0.25, "blob {} dust {}", rb.s_patch, rd.s_patch);
assert!(rb.fidelity < rd.fidelity);
}
#[test]
fn edge_shift_filaments_are_tolerated() {
// a slightly blurred/compressed source shifts edges: thin bad-px lines
// along boundaries. A 2px-wide full-width filament (256 px) must open
// away; the same mass as a compact blob must not.
let clean = flat(64, 64, [200, 200, 200]);
let mut fil = clean.clone();
for y in 30..32 {
for x in 0..64 {
fil[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
}
let (rf, _) = fidelity(&clean, &fil, 64, 64, DEFAULT_THRESH);
assert_eq!(rf.bad_px, 128);
assert_eq!(rf.clusters, 0);
assert!((rf.s_patch - 1.0).abs() < 1e-9, "filament must not count as a patch");
}
#[test]
fn worse_is_lower() {
let a = flat(32, 32, [100, 100, 100]);
let mild: Vec<u8> = a.iter().map(|&v| v + 4).collect();
let harsh: Vec<u8> = a.iter().map(|&v| v + 60).collect();
let (rm, _) = fidelity(&a, &mild, 32, 32, DEFAULT_THRESH);
let (rh, _) = fidelity(&a, &harsh, 32, 32, DEFAULT_THRESH);
assert!(rm.fidelity > rh.fidelity);
assert!(rh.fidelity < 0.4, "harsh {}", rh.fidelity);
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Blind fidelity benchmark for raster-to-vector tracers.
//!
//! vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
//!
//! Both arguments are rasters of identical dimensions — rendering a vector
//! reconstruction to pixels is the caller's responsibility. Prints the raw
//! metrics, their [0,1] subscores, the composite fidelity, and a
//! machine-readable csv line.
use vtracer_bench::{fidelity, DEFAULT_THRESH};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]");
std::process::exit(2);
}
let mut thresh = DEFAULT_THRESH;
let mut mask_out: Option<String> = None;
let mut i = 3;
while i < args.len() {
match args[i].as_str() {
"--thresh" => {
i += 1;
thresh = args[i].parse().expect("--thresh N");
}
"--mask" => {
i += 1;
mask_out = Some(args[i].clone());
}
a => {
eprintln!("unknown flag {a}");
std::process::exit(2);
}
}
i += 1;
}
let orig = image::open(&args[1]).expect("open original").to_rgb8();
let (w, h) = (orig.width() as usize, orig.height() as usize);
let img = image::open(&args[2]).expect("open candidate").to_rgb8();
assert_eq!(
(img.width() as usize, img.height() as usize),
(w, h),
"candidate raster must match original dimensions"
);
let cand: Vec<u8> = img.into_raw();
let (r, mask) = fidelity(orig.as_raw(), &cand, w, h, thresh);
if let Some(out) = mask_out {
image::GrayImage::from_raw(w as u32, h as u32, mask).unwrap().save(&out).expect("save mask");
}
println!("psnr {:>8.2} dB (rmse {:.2}) -> {:.4}", r.psnr, r.rmse, r.s_psnr);
println!("ssim {:>8.5} (dssim {:.5}) -> {:.4}", r.s_ssim, r.dssim, r.s_ssim);
println!(
"patch {:>8.1} px rms ({} bad px, {} clusters, largest {}) -> {:.4}",
r.patch_mass, r.bad_px, r.clusters, r.largest, r.s_patch
);
println!("fidelity {:.4}", r.fidelity);
println!(
"[csv] {:.4},{:.2},{:.5},{:.2},{:.1},{:.4},{:.4},{:.4}",
r.fidelity, r.psnr, r.dssim, r.rmse, r.patch_mass, r.s_psnr, r.s_ssim, r.s_patch
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ name = "vtracer"
path = "src/main.rs"
[dependencies]
vtracer = { version = "1.0.0-alpha.2", path = "../vtracer" }
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
visioncortex.workspace = true
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = [
+3 -2
View File
@@ -1,10 +1,11 @@
[package]
name = "vtracer-py"
description = "Python bindings for the vtracer vectorization framework."
version = "1.0.0-alpha.2"
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/"
@@ -18,7 +19,7 @@ name = "vtracer"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.2", path = "../vtracer" }
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = [
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
+1 -1
View File
@@ -8,7 +8,7 @@ this crate adds image decoding and a Pythonic API.
## Install
```sh
pip install vtracer
pip install vtracer==1.0.0a3
```
## Usage
+1
View File
@@ -5,6 +5,7 @@ build-backend = "maturin"
[project]
name = "vtracer"
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT OR Apache-2.0" }
authors = [{ name = "Chris Tsang", email = "tyt2y7@gmail.com" }]
+20 -6
View File
@@ -533,10 +533,16 @@ const SNAP_SLACK: i32 = 16;
/// quantization applies, which is why the color-cluster frontend never shows
/// this.
///
/// Only pixels whose color is a *mixture* of the two 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.
/// 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;
@@ -579,14 +585,22 @@ fn snap_boundaries(
(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]);
let dab: i32 = (0..3).map(|ch| (mean[a][ch] - mean[b][ch]).abs()).sum();
if db < best.0 && da + db <= dab + SNAP_SLACK {
if db >= best.0 {
continue;
}
if mixture(a, b) || nb.iter().any(|&c| c != a && c != b && mixture(c, b)) {
best = (db, b);
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"version": "1.0.0-alpha.4.app.151",
"notes": "VTracer 1.0.0-alpha.4 - Build 151 (b8bcabc6)",
"pub_date": "2026-09-09T21:32:04.887Z",
"platforms": {
"macos-universal": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_universal.app.tar.gz",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTVlYOC9hZGs3V3lpeWxpYnVBOEVNbFZXVWZ1YSthY3pBdXJZdG80MHJMYWZxL24xaVNhc2lRQnlPK3FDN09mbUxrVURsa2FCTGZIZFpuNmw3SlY3emc0PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTg3MDc3CWZpbGU6VlRyYWNlci5hcHAudGFyLmd6CjIyL250TEk3VnlVUmVmWktkT0laU1E1bzNhQ0dJSVkwNWtNOFNNRUU1VmRMY3pPN252cGRiWHJUSGJwRHoyN2lZM01MS0JhcjFwZ2FQdFJESGllWkF3PT0K"
},
"windows-x86_64": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64-setup.exe",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTWQ2WGpRRE9VTjdxcE9uaGhPSHBUUWVtYi82Si9JeHN2ZDN1TkUxRnY3V0ZPL3VIeGJsM1dSV2ZFRDlOZmhSMWh2WEdRZ3JiOXdXNnRLNHFtT1Y5d3dnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTg3MjUxCWZpbGU6VlRyYWNlcl8xLjAuMC1hbHBoYS40LmFwcC4xNTFfeDY0LXNldHVwLmV4ZQpMR0pvNkVGWm42YTlzMU1nSVlYeDlCV0NZb1hHR0dIOTdMRTdkSXpxTTFlcmlZWGVkcVZzdmhmTDBsQTBUUHBCL3ptdk82OExPV0NwYUMwY1RQc21Cdz09Cg=="
},
"linux-x86_64": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64.AppImage",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTVVhRjErZk5oVWQvdWRmMndzalNVYWNBQzN0emRVM1kvMWw5KzZjcUFCS0s2TFI4Ymc1Ynl2cWtNcmJDMFBvbU9IT0VYekQzc3VsQUlLdWdHMWFzK2dNPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4MDIyMjUzCWZpbGU6VlRyYWNlcl8xLjAuMC1hbHBoYS40LmFwcC4xMTFfYW1kNjQuQXBwSW1hZ2UKT0RzUkx4azJqTmJvN3AvVFlIQUZZakVHTVFUalVWU2prajhvakp6eHRzTHVNZU5OVUV3WEZCWDd3QkY4YStqSDFqb0Qyam5XU1ZrdWVzK2JjUTI1RGc9PQo="
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "vtracer-wasm"
description = "WebAssembly core for the vtracer Node.js package."
version = "1.0.0-alpha.2"
version = "1.0.0-alpha.3"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2024"
license = "MIT OR Apache-2.0"
@@ -15,7 +15,7 @@ repository = "https://github.com/visioncortex/vtracer/"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.2", path = "../crates/vtracer" }
vtracer = { version = "1.0.0-alpha.3", path = "../crates/vtracer" }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@visioncortex/vtracer",
"version": "1.0.0-alpha.2",
"version": "1.0.0-alpha.3",
"description": "Raster to vector graphics converter (SVG). WebAssembly build of the vtracer framework — no native dependencies.",
"main": "index.js",
"types": "index.d.ts",
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# Publish the current version of vtracer to every surface. Idempotent: each
# step is skipped if it is already done, so it is safe to re-run after a
# partial or interrupted release.
#
# Surfaces:
# - git push master, push tag (the tag push triggers the PyPI wheels)
# - crates.io vtracer, then vtracer-cli; and vtracer-bench (independent)
# - npm @visioncortex/vtracer
# - GitHub a release from the tag, marked latest (triggers the binaries)
#
# Prerequisites (all in your own shell — this cannot run in a sandbox):
# cargo login • npm login • gh auth login
# version already bumped + committed on master.
#
# Usage: ./scripts/publish.sh # confirm, then publish what's missing
# ./scripts/publish.sh --dry # checks + build/test only, no publish
#
set -euo pipefail
cd "$(dirname "$0")/.."
DRY=0
[ "${1:-}" = "--dry" ] && DRY=1
VERSION=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
TAG="$VERSION"
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
skip() { printf ' \033[2m· %s\033[0m\n' "$*"; }
# --- availability probes (used to skip already-done steps) -------------------
crate_published() { # crate, version
# crates.io rejects requests without a User-Agent (403), and `curl -f` hides
# that as an empty body — so the UA is mandatory or this never matches.
curl -fsS -H "User-Agent: vtracer-publish (github.com/visioncortex/vtracer)" \
"https://crates.io/api/v1/crates/$1/$2" 2>/dev/null | grep -q "\"num\":\"$2\""
}
npm_published() { # pkg@version
npm view "$1" version >/dev/null 2>&1
}
gh_release_exists() { gh release view "$1" >/dev/null 2>&1; }
say "Publishing vtracer $VERSION (dry-run: $DRY)"
# --- preconditions -----------------------------------------------------------
say "Checking preconditions"
[ "$(git rev-parse --abbrev-ref HEAD)" = master ] || { echo "!! not on master"; exit 1; }
[ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "!! tracked files have uncommitted changes — commit first"; exit 1; }
for tool in cargo npm gh curl; do command -v "$tool" >/dev/null || { echo "!! missing: $tool"; exit 1; }; done
# The version we publish comes from Cargo.toml at HEAD; the tag only marks the
# release commit for CI, so it need not be at HEAD (a later commit such as this
# script is fine). Guard only against the genuinely wrong case: a tag whose own
# commit carries a different version than the one we're about to publish.
if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
tag_ver=$(git show "$TAG:Cargo.toml" 2>/dev/null | grep -m1 '^version = ' | sed -E 's/.*"([^"]+)".*/\1/')
[ "$tag_ver" = "$VERSION" ] || { echo "!! tag $TAG marks version $tag_ver, but HEAD is $VERSION"; exit 1; }
skip "tag $TAG already exists (marks $VERSION)"
fi
# --- build + test (always, even on --dry) ------------------------------------
say "Building + testing"
cargo test --workspace
cargo build --release -p vtracer-cli
( cd nodejs && npm run build )
if [ "$DRY" = 1 ]; then say "Dry run complete — checks passed, nothing published."; exit 0; fi
# --- confirm -----------------------------------------------------------------
printf '\nPublish vtracer %s (git, crates.io, npm, GitHub)? Steps already done are skipped. [y/N] ' "$VERSION"
read -r reply
[ "$reply" = y ] || [ "$reply" = Y ] || { echo "aborted."; exit 1; }
# --- 1. git: push master, then the tag (tag push triggers the PyPI wheels) ---
say "git: push master + tag"
git push origin master
git rev-parse "refs/tags/$TAG" >/dev/null 2>&1 || git tag "$TAG"
git push origin "$TAG"
# --- 2. crates.io: core first, then the CLI that depends on it ---------------
if crate_published vtracer "$VERSION"; then
skip "crates.io vtracer $VERSION already published"
else
say "crates.io: publishing vtracer"
cargo publish -p vtracer
printf ' waiting for the index'
until crate_published vtracer "$VERSION"; do printf '.'; sleep 10; done; echo
fi
if crate_published vtracer-cli "$VERSION"; then
skip "crates.io vtracer-cli $VERSION already published"
else
say "crates.io: publishing vtracer-cli"
cargo publish -p vtracer-cli
fi
# vtracer-bench depends only on registry crates (not vtracer), so order is free.
if crate_published vtracer-bench "$VERSION"; then
skip "crates.io vtracer-bench $VERSION already published"
else
say "crates.io: publishing vtracer-bench"
cargo publish -p vtracer-bench
fi
# --- 3. npm ------------------------------------------------------------------
# Published to the default `latest` tag, matching the earlier alpha releases.
# For a prerelease you may prefer: ( cd nodejs && npm publish --tag next )
if npm_published "@visioncortex/vtracer@$VERSION"; then
skip "npm @visioncortex/vtracer@$VERSION already published"
else
say "npm: publishing @visioncortex/vtracer"
( cd nodejs && npm publish )
fi
# --- 4. GitHub release (its creation triggers the binary build workflow) -----
if gh_release_exists "$TAG"; then
skip "GitHub release $TAG already exists"
else
say "GitHub: creating release $TAG"
NOTES=$(awk -v h="## $VERSION" 'index($0,h)==1{f=1;next} /^## /&&f{exit} f' CHANGELOG.md)
# --latest: mark as the latest release (gh would otherwise treat an -alpha
# tag as a prerelease and not promote it).
gh release create "$TAG" --latest --title "$TAG" --notes "${NOTES:-Release $VERSION}"
fi
say "Done. crates.io + npm up to date; PyPI wheels build from the tag; binaries build from the release."