5 Commits

Author SHA1 Message Date
Chris Tsang 66f6bd98f4 Feature guard 2023-09-08 13:20:28 +01:00
Chris Tsang 59448884cb Edit RELEASES.md 2023-09-08 13:10:19 +01:00
Chris Tsang edf1311425 Edit Readme 2023-09-08 13:05:15 +01:00
Chris Tsang 02c844dc7a Move code around 2023-09-08 13:03:43 +01:00
Evan Jones c3090ac50b Python bindings sep 2023 (#52)
* Added maturin-based Python binding, to be deployed to https://pypi.org/project/vtracer/

* Removed poetry mentions from pyproject.toml, added README_PY.md for use on PYPI

* ->   v0.6.1
-> moved Python bindings to bottom of converter.rs

* - README_PY.md needed to be inside the cmdapp directory to display on PyPi.irg
->  v0.6.3
2023-09-08 19:43:13 +08:00
96 changed files with 1454 additions and 6828 deletions
+117
View File
@@ -0,0 +1,117 @@
# This file is autogenerated by maturin v1.2.3
# To update, run
#
# maturin generate-ci github
#
name: CI
on:
push:
tags:
- '*'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
linux:
runs-on: ubuntu-latest
strategy:
matrix:
target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
manylinux: auto
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
windows:
runs-on: windows-latest
strategy:
matrix:
target: [x64, x86]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
architecture: ${{ matrix.target }}
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
macos:
runs-on: macos-latest
strategy:
matrix:
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
sdist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
release:
name: Release
runs-on: ubuntu-latest
if: "startsWith(github.ref, 'refs/tags/')"
needs: [linux, windows, macos, sdist]
steps:
- uses: actions/download-artifact@v3
with:
name: wheels
- name: Publish to PyPI
uses: PyO3/maturin-action@v1
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
with:
command: upload
args: --non-interactive --skip-existing *
-182
View File
@@ -1,182 +0,0 @@
# Python wheels for crates/vtracer-py (maturin). Regenerate the skeleton with:
# maturin generate-ci github -m crates/vtracer-py/Cargo.toml
name: Python
# Wheel builds are heavy (full platform matrix), so they run only on release
# tags and on-demand — not on every push/PR. Rust/wasm/Node CI (rust.yml) still
# gates ordinary commits.
on:
push:
tags:
- '*'
workflow_dispatch:
permissions:
contents: read
jobs:
linux:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: ubuntu-22.04
target: x86_64
- runner: ubuntu-22.04
target: x86
- runner: ubuntu-22.04
target: aarch64
- runner: ubuntu-22.04
target: armv7
- runner: ubuntu-22.04
target: s390x
- runner: ubuntu-22.04
target: ppc64le
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
manylinux: auto
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-linux-${{ matrix.platform.target }}
path: dist
musllinux:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: ubuntu-22.04
target: x86_64
- runner: ubuntu-22.04
target: x86
- runner: ubuntu-22.04
target: aarch64
- runner: ubuntu-22.04
target: armv7
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
manylinux: musllinux_1_2
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-musllinux-${{ matrix.platform.target }}
path: dist
windows:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: windows-latest
target: x64
python_arch: x64
- runner: windows-latest
target: x86
python_arch: x86
- runner: windows-11-arm
target: aarch64
python_arch: arm64
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.13
architecture: ${{ matrix.platform.python_arch }}
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-windows-${{ matrix.platform.target }}
path: dist
macos:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: macos-15-intel
target: x86_64
- runner: macos-latest
target: aarch64
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-macos-${{ matrix.platform.target }}
path: dist
sdist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist --manifest-path crates/vtracer-py/Cargo.toml
- name: Upload sdist
uses: actions/upload-artifact@v5
with:
name: wheels-sdist
path: dist
release:
name: Release
runs-on: ubuntu-latest
# Specifying a GitHub environment is optional, but strongly encouraged
environment: python
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
needs: [linux, musllinux, windows, macos, sdist]
permissions:
# Use to sign the release artifacts
id-token: write
# Used to upload release artifacts
contents: write
# Used to generate artifact attestation
attestations: write
steps:
- uses: actions/download-artifact@v6
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-path: 'wheels-*/*'
- name: Install uv
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: astral-sh/setup-uv@v7
- name: Publish to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
run: uv publish 'wheels-*/*'
-32
View File
@@ -1,32 +0,0 @@
name: Release
# Builds the `vtracer` CLI binary (crates/vtracer-cli) for each target.
on:
release:
types: [published]
jobs:
release:
strategy:
matrix:
include:
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: taiki-e/upload-rust-binary-action@v1
with:
bin: vtracer
target: ${{ matrix.target }}
# (required) GitHub token for uploading assets to GitHub Releases.
token: ${{ secrets.GITHUB_TOKEN }}
+9 -44
View File
@@ -1,57 +1,22 @@
name: Rust
on:
pull_request:
paths-ignore:
- '**.md'
- '.github/ISSUE_TEMPLATE/**'
push:
paths-ignore:
- '**.md'
- '.github/ISSUE_TEMPLATE/**'
branches:
- master
- 0.*.x
- ci-*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }}
cancel-in-progress: true
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --workspace --verbose
- name: Test
run: cargo test --workspace --verbose
build:
wasm:
name: wasm-safety (core)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: rustup target add wasm32-unknown-unknown
- name: Build core for wasm32
run: cargo build --target wasm32-unknown-unknown -p vtracer
nodejs:
name: Node package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Build & test
working-directory: nodejs
run: |
wasm-pack build --target nodejs --out-dir pkg
node test.js
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
+1 -2
View File
@@ -1,5 +1,4 @@
target
Cargo.lock
*.sublime*
.vscode
.DS_Store
.vscode
+2 -53
View File
@@ -5,60 +5,13 @@ 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.1 - 2026-07-24
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
### Added
* Pluggable pipeline: swappable frontend (segmentation), color fitting (incl. custom palettes), curve-fitting backend, and an optimizer pass phase.
* **Mosaic mode**: true seam-free, gapless tessellation via shared boundary-graph tracing (pixel, polygon, and spline fitters), replacing the old "cutout" that produced seams.
* SVG optimizer: relative path syntax, shorthand commands, and coordinate-precision reduction for smaller files.
* `@visioncortex/vtracer` Node.js package (npm): wasm core with a native image reader.
* Rewritten Python bindings (`vtracer-py`) with a richer API; pyo3 bumped to 0.26 (fixes CPython 3.14 segfaults, #124).
* CLI accepts positional `input`/`output` arguments (#114).
### Changed
* Workspace restructured into `crates/vtracer` (core lib), `crates/vtracer-cli`, `crates/vtracer-py`, and `nodejs/`.
* CLI upgraded from clap 2.x to 4.x (#118).
* `filter_speckle` CLI cap raised from 16 to 128, matching the web app (#115).
* Depends on `visioncortex` 0.9.
* Python wheel CI now runs only on release tags and manual dispatch, not on every commit.
### Removed
* The pre-1.0 `cmdapp` crate and the demo webapp GUI.
## 0.6.12 - 2026-02-04
## 0.6.0 - 2023-09-08
* Python Binding
## 0.6.5 - 2025-10-17
* Update `fastrand` to `2.3`
## 0.6.4 - 2024-03-29
* Update `visioncortex` version to `0.8.8`
## 0.6.3 - 2023-11-21
* New converter API https://github.com/visioncortex/vtracer/pull/59
## 0.6.1 - 2023-09-23
* Fixed "The two lines are parallel!"
### Python Binding
Thanks to the contribution of [@etjones](https://github.com/etjones), we now have an official Python binding! https://github.com/visioncortex/vtracer/pull/55
https://pypi.org/project/vtracer/0.6.10/
## 0.5.0 - 2022-10-09
* Handle transparent png images (cli) https://github.com/visioncortex/vtracer/pull/23
* Handle transparent png images (cli) (#23)
## 0.4.0 - 2021-07-23
@@ -72,10 +25,6 @@ https://pypi.org/project/vtracer/0.6.10/
* Use relative & closed paths
## 0.1.1 - 2020-11-01
* SVG namespace
## 0.1.0 - 2020-10-31
* Initial release
+2 -27
View File
@@ -1,32 +1,7 @@
[workspace]
members = [
"crates/vtracer",
"crates/vtracer-cli",
]
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
# build. It is superseded by the crates/ workspace above.
exclude = [
"cmdapp",
"webapp",
# pyo3 extension-module cdylib; built with maturin, not the core workspace.
"crates/vtracer-py",
# wasm-bindgen cdylib; built with wasm-pack as the Node package's core.
"nodejs",
]
resolver = "2"
[workspace.package]
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
[workspace.dependencies]
visioncortex = "0.9"
# For local development against an unreleased visioncortex, add a patch:
# [patch.crates-io]
# visioncortex = { path = "../visioncortex" }
resolver = "2"
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2024 TSANG, Hao Fung
Copyright (c) 2022 Tsang Hao Fung
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
+64 -129
View File
@@ -4,32 +4,35 @@
<h1>VTracer</h1>
<p>
<strong>Raster to Vector Graphics Converter</strong>
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
</p>
<h3>
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
<a href="//www.visioncortex.org/vtracer-docs">Article</a>
<span> | </span>
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
<a href="//www.visioncortex.org/vtracer/">Demo</a>
<span> | </span>
<a href="https://github.com/visioncortex/vtracer/releases">Download</a>
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
</h3>
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
</div>
## Introduction
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans. tl;dr: Potrace uses a `O(n^2)` fitting algorithm, whereas `vtracer` is entirely `O(n)`.
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrator/using/image-trace.html), VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
A technical description of the algorithm is on [visioncortex.org/vtracer-docs](//www.visioncortex.org/vtracer-docs).
## Desktop App (coming soon)
## Web App
VTracer and its [core library](//github.com/visioncortex/visioncortex) is implemented in [Rust](//www.rust-lang.org/). It provides us a solid foundation to develop robust and efficient algorithms and easily bring it to interactive applications. The webapp is a perfect showcase of the capability of the Rust + wasm platform.
![screenshot](docs/images/screenshot-01.png)
@@ -37,137 +40,69 @@ Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/v
## Cmd App
Input and output can be given as positional arguments or as named flags:
```sh
vtracer input.jpg output.svg
# equivalent to:
vtracer --input input.jpg --output output.svg
visioncortex VTracer 0.4.0
A cmd app to convert images into vector graphics.
USAGE:
vtracer [OPTIONS] --input <input> --output <output>
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
--colormode <color_mode> True color image `color` (default) or Binary image `bw`
-p, --color_precision <color_precision> Number of significant bits to use in an RGB channel
-c, --corner_threshold <corner_threshold> Minimum momentary angle (degree) to be considered a corner
-f, --filter_speckle <filter_speckle> Discard patches smaller than X px in size
-g, --gradient_step <gradient_step> Color difference between gradient layers
--hierarchical <hierarchical>
Hierarchical clustering `stacked` (default) or non-stacked `cutout`. Only applies to color mode.
-i, --input <input> Path to input raster image
-m, --mode <mode> Curver fitting mode `pixel`, `polygon`, `spline`
-o, --output <output> Path to output vector graphics
--path_precision <path_precision> Number of decimal places to use in path string
--preset <preset> Use one of the preset configs `bw`, `poster`, `photo`
-l, --segment_length <segment_length>
Perform iterative subdivide smooth until all segments are shorter than this length
-s, --splice_threshold <splice_threshold> Minimum angle displacement (degree) to splice a spline
```
Full options (flag names are kebab-case, e.g. `--filter-speckle`):
```sh
Usage: vtracer [OPTIONS] [INPUT] [OUTPUT]
Arguments:
[INPUT] Input raster image (positional; or use --input)
[OUTPUT] Output SVG (positional; or use --output)
Options:
-i, --input <INPUT> Path to the input raster image
-o, --output <OUTPUT> Path to the output SVG
--preset <PRESET> Start from a preset: bw, poster, photo
--colormode <COLORMODE> Color image `color` (default) or binary image `bw`
--hierarchical <HIERARCHICAL> Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
-f, --filter-speckle <FILTER_SPECKLE> Discard patches smaller than X px in size (0..=128)
-p, --color-precision <COLOR_PRECISION> Significant bits per RGB channel (1..=8)
-g, --gradient-step <GRADIENT_STEP> Color difference between gradient layers (0..=255)
-c, --corner-threshold <CORNER_THRESHOLD> Minimum momentary angle (degrees) to be a corner (0..=180)
-l, --segment-length <SEGMENT_LENGTH> Subdivide until all segments are shorter than this (3.5..=10)
-s, --splice-threshold <SPLICE_THRESHOLD> Minimum angle displacement (degrees) to splice a spline (0..=180)
--path-precision <PATH_PRECISION> Decimal places to use in path coordinates
--palette <PALETTE> Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'
--palette-file <PALETTE_FILE> Fixed palette from a file (hex colors, comma/newline separated)
--max-colors <MAX_COLORS> Auto-quantize to at most N colors
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands
-h, --help Print help
-V, --version Print version
```
### New in 1.0
- **Positional arguments** — `vtracer in.png out.svg`.
- **`--hierarchical cutout`** is now a true seam-free mosaic (a gapless
tessellation with shared boundaries), replacing the old re-clustered cutout.
- **`--palette` / `--palette-file`** — snap colors to a fixed palette
(nearest in OKLab); **`--max-colors`** auto-quantizes the palette.
- **`--optimize`** — output size passes (coordinate quantization, redundant-
point removal, relative/shorthand path encoding).
## Downloads
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
You can also install the program from source from [crates.io/vtracer](https://crates.io/crates/vtracer):
```sh
cargo install vtracer-cli
```
> You are strongly advised to not download from any other third-party sources
### Usage
```sh
# simplest form
./vtracer input.jpg output.svg
# black & white line art
./vtracer input.jpg output.svg --preset bw
# seam-free mosaic (gapless tessellation)
./vtracer input.jpg output.svg --hierarchical cutout
# constrain to a fixed palette
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
```
./vtracer --input input.jpg --output output.svg
```
### Rust Library
## Library
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
The library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
```sh
cargo add vtracer
## Install
Download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
or
Install from source (Rust toolchain needed):
```
cargo install vtracer
```
### Python Library
## In the wild
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension (built with [pyo3](https://github.com/PyO3/pyo3) + [maturin](https://www.maturin.rs), from the `crates/vtracer-py` crate).
VTracer is used by the following products (feel free to add yours to the list):
```sh
pip install vtracer
```
```python
import vtracer
# one-liners
vtracer.convert_file("in.png", "out.svg")
svg = vtracer.convert_bytes(open("in.png", "rb").read())
# rich, reusable config + presets
cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
svg = cfg.convert_bytes(data)
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
```
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
### Node.js Library
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**.
```sh
npm install @visioncortex/vtracer
```
```js
const vtracer = require('@visioncortex/vtracer');
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
```
## Citations
VTracer has since been cited by a few academic papers in computer graphics / vision research. Please kindly let us know if you have cited our work:
+ SKILL 2023 [Framework to Vectorize Digital Artworks for Physical Fabrication based on Geometric Stylization Techniques](https://www.researchgate.net/publication/374448489_Framework_to_Vectorize_Digital_Artworks_for_Physical_Fabrication_based_on_Geometric_Stylization_Techniques)
+ arXiv 2023 [Image Vectorization: a Review](https://arxiv.org/abs/2306.06441)
+ arXiv 2023 [StarVector: Generating Scalable Vector Graphics Code from Images](https://arxiv.org/abs/2312.11556)
+ arXiv 2024 [Text-Based Reasoning About Vector Graphics](https://arxiv.org/abs/2404.06479)
+ arXiv 2024 [Delving into LLMs' visual understanding ability using SVG to bridge image and text](https://openreview.net/pdf?id=pwlm6Po61I)
<table>
<tbody>
<tr>
<td><a href="https://logo.aliyun.com/logo#/name"><img src="docs/images/aliyun-logo.png" width="250"/></a>
<br>Smart logo design
</td>
<td></td>
</tr>
</tbody>
</table>
+27
View File
@@ -0,0 +1,27 @@
# Version 0.6.0 (2023-09-08)
- Python bindings
# Version 0.5.0 (2022-10-09)
- Handle transparent png images
# Version 0.4.0 (2021-07-23)
- SVG path string numeric precision
# Version 0.3.0 (2021-01-24)
- Added cutout mode
# Version 0.2.0 (2020-11-15)
- Use relative & closed paths
# Version 0.1.1 (2020-11-01)
- SVG namespace
# Version 0.1.0 (2020-10-31)
- Initial release
+3
View File
@@ -0,0 +1,3 @@
*.svg
*.png
*.jpg
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "vtracer"
version = "0.6.3"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
edition = "2021"
description = "A cmd app to convert images into vector graphics."
license = "MIT OR Apache-2.0"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
categories = ["graphics"]
keywords = ["svg", "computer-graphics"]
[dependencies]
clap = "2.33.3"
image = "0.23.10"
visioncortex = { version = "0.8.0" }
fastrand = "1.8"
pyo3 = { version = "0.19.0", optional = true }
[features]
python-binding = ["pyo3"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+25
View File
@@ -0,0 +1,25 @@
Copyright (c) 2022 Tsang Hao Fung
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+28
View File
@@ -0,0 +1,28 @@
[project]
name = "vtracer"
version = "0.6.3"
description = "Python bindings for the Rust Vtracer raster-to-vector library"
authors = [ { name = "Chris Tsang", email = "chris.2y3@outlook.com" } ]
readme = "vtracer/README.md"
requires-python = ">=3.7"
license = "MIT"
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
[dependencies]
python = "^3.7"
[dev-dependencies]
maturin = "^1.2"
[build-system]
requires = ["maturin>=1.2,<2.0"]
build-backend = "maturin"
[tool.maturin]
features = ["pyo3/extension-module"]
compatibility = "linux"
sdist-include = ["../LICENSE", "../README.md"]
+397
View File
@@ -0,0 +1,397 @@
use std::str::FromStr;
use std::path::PathBuf;
use clap::{Arg, App};
use visioncortex::PathSimplifyMode;
pub enum Preset {
Bw,
Poster,
Photo
}
pub enum ColorMode {
Color,
Binary,
}
pub enum Hierarchical {
Stacked,
Cutout,
}
/// Converter config
pub struct Config {
pub input_path: PathBuf,
pub output_path: PathBuf,
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
pub filter_speckle: usize,
pub color_precision: i32,
pub layer_difference: i32,
pub mode: PathSimplifyMode,
pub corner_threshold: i32,
pub length_threshold: f64,
pub max_iterations: usize,
pub splice_threshold: i32,
pub path_precision: Option<u32>,
}
pub(crate) struct ConverterConfig {
pub input_path: PathBuf,
pub output_path: PathBuf,
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
pub filter_speckle_area: usize,
pub color_precision_loss: i32,
pub layer_difference: i32,
pub mode: PathSimplifyMode,
pub corner_threshold: f64,
pub length_threshold: f64,
pub max_iterations: usize,
pub splice_threshold: f64,
pub path_precision: Option<u32>,
}
impl Default for Config {
fn default() -> Self {
Self {
input_path: PathBuf::default(),
output_path: PathBuf::default(),
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
mode: PathSimplifyMode::Spline,
filter_speckle: 4,
color_precision: 6,
layer_difference: 16,
corner_threshold: 60,
length_threshold: 4.0,
splice_threshold: 45,
max_iterations: 10,
path_precision: Some(8),
}
}
}
impl FromStr for ColorMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color" => Ok(Self::Color),
"binary" => Ok(Self::Binary),
_ => Err(format!("unknown ColorMode {}", 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 {}", 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)),
}
}
}
fn path_simplify_mode_from_str(s: &str) -> PathSimplifyMode {
match s {
"polygon" => PathSimplifyMode::Polygon,
"spline" => PathSimplifyMode::Spline,
"none" => PathSimplifyMode::None,
_ => panic!("unknown PathSimplifyMode {}", s),
}
}
impl Config {
pub fn from_args() -> Self {
let app = App::new("visioncortex VTracer ".to_owned() + env!("CARGO_PKG_VERSION"))
.about("A cmd app to convert images into vector graphics.");
let app = app.arg(Arg::with_name("input")
.long("input")
.short("i")
.takes_value(true)
.help("Path to input raster image")
.required(true));
let app = app.arg(Arg::with_name("output")
.long("output")
.short("o")
.takes_value(true)
.help("Path to output vector graphics")
.required(true));
let app = app.arg(Arg::with_name("color_mode")
.long("colormode")
.takes_value(true)
.help("True color image `color` (default) or Binary image `bw`"));
let app = app.arg(Arg::with_name("hierarchical")
.long("hierarchical")
.takes_value(true)
.help(
"Hierarchical clustering `stacked` (default) or non-stacked `cutout`. \
Only applies to color mode. "
));
let app = app.arg(Arg::with_name("preset")
.long("preset")
.takes_value(true)
.help("Use one of the preset configs `bw`, `poster`, `photo`"));
let app = app.arg(Arg::with_name("filter_speckle")
.long("filter_speckle")
.short("f")
.takes_value(true)
.help("Discard patches smaller than X px in size"));
let app = app.arg(Arg::with_name("color_precision")
.long("color_precision")
.short("p")
.takes_value(true)
.help("Number of significant bits to use in an RGB channel"));
let app = app.arg(Arg::with_name("gradient_step")
.long("gradient_step")
.short("g")
.takes_value(true)
.help("Color difference between gradient layers"));
let app = app.arg(Arg::with_name("corner_threshold")
.long("corner_threshold")
.short("c")
.takes_value(true)
.help("Minimum momentary angle (degree) to be considered a corner"));
let app = app.arg(Arg::with_name("segment_length")
.long("segment_length")
.short("l")
.takes_value(true)
.help("Perform iterative subdivide smooth until all segments are shorter than this length"));
let app = app.arg(Arg::with_name("splice_threshold")
.long("splice_threshold")
.short("s")
.takes_value(true)
.help("Minimum angle displacement (degree) to splice a spline"));
let app = app.arg(Arg::with_name("mode")
.long("mode")
.short("m")
.takes_value(true)
.help("Curver fitting mode `pixel`, `polygon`, `spline`"));
let app = app.arg(Arg::with_name("path_precision")
.long("path_precision")
.takes_value(true)
.help("Number of decimal places to use in path string"));
// Extract matches
let matches = app.get_matches();
let mut config = Config::default();
let input_path = matches.value_of("input").expect("Input path is required, please specify it by --input or -i.");
let output_path = matches.value_of("output").expect("Output path is required, please specify it by --output or -o.");
if let Some(value) = matches.value_of("preset") {
config = Self::from_preset(Preset::from_str(value).unwrap(), input_path, output_path);
}
config.input_path = PathBuf::from(input_path);
config.output_path = PathBuf::from(output_path);
if let Some(value) = matches.value_of("color_mode") {
config.color_mode = ColorMode::from_str(if value.trim() == "bw" || value.trim() == "BW" {"binary"} else {"color"}).unwrap()
}
if let Some(value) = matches.value_of("hierarchical") {
config.hierarchical = Hierarchical::from_str(value).unwrap()
}
if let Some(value) = matches.value_of("mode") {
let value = value.trim();
config.mode = path_simplify_mode_from_str(if value == "pixel" {
"none"
} else if value == "polygon" {
"polygon"
} else if value == "spline" {
"spline"
} else {
panic!("Parser Error: Curve fitting mode is invalid: {}", value);
});
}
if let Some(value) = matches.value_of("filter_speckle") {
if value.trim().parse::<usize>().is_ok() { // is numeric
let value = value.trim().parse::<usize>().unwrap();
if value > 16 {
panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [0,16].", value);
}
config.filter_speckle = value;
} else {
panic!("Parser Error: Filter speckle is not a positive integer: {}.", value);
}
}
if let Some(value) = matches.value_of("color_precision") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 1 || value > 8 {
panic!("Out of Range Error: Color precision is invalid at {}. It must be within [1,8].", value);
}
config.color_precision = value;
} else {
panic!("Parser Error: Color precision is not an integer: {}.", value);
}
}
if let Some(value) = matches.value_of("gradient_step") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 0 || value > 255 {
panic!("Out of Range Error: Gradient step is invalid at {}. It must be within [0,255].", value);
}
config.layer_difference = value;
} else {
panic!("Parser Error: Gradient step is not an integer: {}.", value);
}
}
if let Some(value) = matches.value_of("corner_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 0 || value > 180 {
panic!("Out of Range Error: Corner threshold is invalid at {}. It must be within [0,180].", value);
}
config.corner_threshold = value
} else {
panic!("Parser Error: Corner threshold is not numeric: {}.", value);
}
}
if let Some(value) = matches.value_of("segment_length") {
if value.trim().parse::<f64>().is_ok() { // is numeric
let value = value.trim().parse::<f64>().unwrap();
if value < 3.5 || value > 10.0 {
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [3.5,10].", value);
}
config.length_threshold = value;
} else {
panic!("Parser Error: Segment length is not numeric: {}.", value);
}
}
if let Some(value) = matches.value_of("splice_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 0 || value > 180 {
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [0,180].", value);
}
config.splice_threshold = value;
} else {
panic!("Parser Error: Segment length is not numeric: {}.", value);
}
}
if let Some(value) = matches.value_of("path_precision") {
if value.trim().parse::<u32>().is_ok() { // is numeric
let value = value.trim().parse::<u32>().ok();
config.path_precision = value;
} else {
panic!("Parser Error: Path precision is not an unsigned integer: {}.", value);
}
}
config
}
pub fn from_preset(preset: Preset, input_path: &str, output_path: &str) -> Self {
let input_path = PathBuf::from(input_path);
let output_path = PathBuf::from(output_path);
match preset {
Preset::Bw => Self {
input_path,
output_path,
color_mode: ColorMode::Binary,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 6,
layer_difference: 16,
mode: PathSimplifyMode::Spline,
corner_threshold: 60,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
path_precision: Some(8),
},
Preset::Poster => Self {
input_path,
output_path,
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 8,
layer_difference: 16,
mode: PathSimplifyMode::Spline,
corner_threshold: 60,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
path_precision: Some(8),
},
Preset::Photo => Self {
input_path,
output_path,
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 10,
color_precision: 8,
layer_difference: 48,
mode: PathSimplifyMode::Spline,
corner_threshold: 180,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
path_precision: Some(8),
}
}
}
pub(crate) fn into_converter_config(self) -> ConverterConfig {
ConverterConfig {
input_path: self.input_path,
output_path: self.output_path,
color_mode: self.color_mode,
hierarchical: self.hierarchical,
filter_speckle_area: self.filter_speckle * self.filter_speckle,
color_precision_loss: 8 - self.color_precision,
layer_difference: self.layer_difference,
mode: self.mode,
corner_threshold: deg2rad(self.corner_threshold),
length_threshold: self.length_threshold,
max_iterations: self.max_iterations,
splice_threshold: deg2rad(self.splice_threshold),
path_precision: self.path_precision,
}
}
}
fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
+228
View File
@@ -0,0 +1,228 @@
use std::path::PathBuf;
use std::{fs::File, io::Write};
use fastrand::Rng;
use visioncortex::{Color, ColorImage, ColorName};
use visioncortex::color_clusters::{Runner, RunnerConfig, KeyingAction, HIERARCHICAL_MAX};
use super::config::{Config, ColorMode, Hierarchical, ConverterConfig};
use super::svg::SvgFile;
const NUM_UNUSED_COLOR_ITERATIONS: usize = 6;
/// The fraction of pixels in the top/bottom rows of the image that need to be transparent before
/// the entire image will be keyed.
const KEYING_THRESHOLD: f32 = 0.2;
/// Convert an image file into svg file
pub fn convert_image_to_svg(config: Config) -> Result<(), String> {
let config = config.into_converter_config();
match config.color_mode {
ColorMode::Color => color_image_to_svg(config),
ColorMode::Binary => binary_image_to_svg(config),
}
}
fn color_exists_in_image(img: &ColorImage, color: Color) -> bool {
for y in 0..img.height {
for x in 0..img.width {
let pixel_color = img.get_pixel(x, y);
if pixel_color.r == color.r && pixel_color.g == color.g && pixel_color.b == color.b {
return true
}
}
}
false
}
fn find_unused_color_in_image(img: &ColorImage) -> Result<Color, String> {
let special_colors = IntoIterator::into_iter([
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),
]);
let rng = Rng::new();
let random_colors = (0..NUM_UNUSED_COLOR_ITERATIONS).map(|_| {
Color::new(
rng.u8(..),
rng.u8(..),
rng.u8(..),
)
});
for color in special_colors.chain(random_colors) {
if !color_exists_in_image(img, color) {
return Ok(color);
}
}
Err(String::from("unable to find unused color in image to use as key"))
}
fn should_key_image(img: &ColorImage) -> bool {
if img.width == 0 || img.height == 0 {
return false;
}
// Check for transparency at several scanlines
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
let mut num_transparent_boundary_pixels = 0;
let y_positions = [0, img.height / 4, img.height / 2, 3 * img.height / 4, img.height - 1];
for y in y_positions {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
num_transparent_boundary_pixels += 1;
}
if num_transparent_boundary_pixels >= threshold {
return true;
}
}
}
false
}
fn color_image_to_svg(config: ConverterConfig) -> Result<(), String> {
let (mut img, width, height);
match read_image(config.input_path) {
Ok(values) => {
img = values.0;
width = values.1;
height = values.2;
},
Err(msg) => return Err(msg),
}
let key_color = if should_key_image(&img) {
let key_color = find_unused_color_in_image(&img)?;
for y in 0..height {
for x in 0..width {
if img.get_pixel(x, y).a == 0 {
img.set_pixel(x, y, &key_color);
}
}
}
key_color
} else {
// The default color is all zeroes, which is treated by visioncortex as a special value meaning no keying will be applied.
Color::default()
};
let runner = Runner::new(RunnerConfig {
diagonal: config.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: config.filter_speckle_area,
good_max_area: (width * height),
is_same_color_a: config.color_precision_loss,
is_same_color_b: 1,
deepen_diff: config.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: if matches!(config.hierarchical, Hierarchical::Cutout) {
KeyingAction::Keep
} else {
KeyingAction::Discard
},
}, img);
let mut clusters = runner.run();
match config.hierarchical {
Hierarchical::Stacked => {}
Hierarchical::Cutout => {
let view = clusters.view();
let image = view.to_color_image();
let runner = Runner::new(RunnerConfig {
diagonal: false,
hierarchical: 64,
batch_size: 25600,
good_min_area: 0,
good_max_area: (image.width * image.height) as usize,
is_same_color_a: 0,
is_same_color_b: 1,
deepen_diff: 0,
hollow_neighbours: 0,
key_color,
keying_action: KeyingAction::Discard,
}, image);
clusters = runner.run();
},
}
let view = clusters.view();
let mut svg = SvgFile::new(width, height, config.path_precision);
for &cluster_index in view.clusters_output.iter().rev() {
let cluster = view.get_cluster(cluster_index);
let paths = cluster.to_compound_path(
&view,
false,
config.mode,
config.corner_threshold,
config.length_threshold,
config.max_iterations,
config.splice_threshold
);
svg.add_path(paths, cluster.residue_color());
}
write_svg(svg, config.output_path)
}
fn binary_image_to_svg(config: ConverterConfig) -> Result<(), String> {
let (img, width, height);
match read_image(config.input_path) {
Ok(values) => {
img = values.0;
width = values.1;
height = values.2;
},
Err(msg) => return Err(msg),
}
let img = img.to_binary_image(|x| x.r < 128);
let clusters = img.to_clusters(false);
let mut svg = SvgFile::new(width, height, config.path_precision);
for i in 0..clusters.len() {
let cluster = clusters.get_cluster(i);
if cluster.size() >= config.filter_speckle_area {
let paths = cluster.to_compound_path(
config.mode,
config.corner_threshold,
config.length_threshold,
config.max_iterations,
config.splice_threshold,
);
svg.add_path(paths, Color::color(&ColorName::Black));
}
}
write_svg(svg, config.output_path)
}
fn read_image(input_path: PathBuf) -> Result<(ColorImage, usize, usize), String> {
let img = image::open(input_path);
let img = match img {
Ok(file) => file.to_rgba8(),
Err(_) => return Err(String::from("No image file found at specified input path")),
};
let (width, height) = (img.width() as usize, img.height() as usize);
let img = ColorImage {pixels: img.as_raw().to_vec(), width, height};
Ok((img, width, height))
}
fn write_svg(svg: SvgFile, output_path: PathBuf) -> Result<(), String> {
let out_file = File::create(output_path);
let mut out_file = match out_file {
Ok(file) => file,
Err(_) => return Err(String::from("Cannot create output file.")),
};
write!(&mut out_file, "{}", svg).expect("failed to write file.");
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2020 Tsang Hao Fung. See the COPYRIGHT
// file at the top-level directory of this distribution and at
// http://rust-lang.org/COPYRIGHT.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
mod config;
mod converter;
mod svg;
#[cfg(feature = "python-binding")]
mod python;
pub use config::*;
pub use converter::*;
pub use svg::*;
#[cfg(feature = "python-binding")]
pub use python::*;
+14
View File
@@ -0,0 +1,14 @@
use vtracer::{Config, convert_image_to_svg};
fn main() {
let config = Config::from_args();
let result = convert_image_to_svg(config);
match result {
Ok(()) => {
println!("Conversion successful.");
},
Err(msg) => {
panic!("Conversion failed with error message: {}", msg);
}
}
}
+81
View File
@@ -0,0 +1,81 @@
use pyo3::prelude::*;
use visioncortex::{PathSimplifyMode};
use super::converter::*;
/// Python binding
#[pyfunction]
fn convert_image_to_svg_py( image_path: &str,
out_path: &str,
colormode: Option<&str>, // "color" or "binary"
hierarchical: Option<&str>, // "stacked" or "cutout"
mode: Option<&str>, // "polygon", "spline", "none"
filter_speckle: Option<usize>, // default: 4
color_precision: Option<i32>, // default: 6
layer_difference: Option<i32>, // default: 16
corner_threshold: Option<i32>, // default: 60
length_threshold: Option<f64>, // in [3.5, 10] default: 4.0
max_iterations: Option<usize>, // default: 10
splice_threshold: Option<i32>, // default: 45
path_precision: Option<u32> // default: 8
) -> PyResult<()> {
let input_path = PathBuf::from(image_path);
let output_path = PathBuf::from(out_path);
// TODO: enforce color mode with an enum so that we only
// accept the strings 'color' or 'binary'
let color_mode = match colormode.unwrap_or("color") {
"color" => ColorMode::Color,
"binary" => ColorMode::Binary,
_ => ColorMode::Color,
};
let hierarchical = match hierarchical.unwrap_or("stacked") {
"stacked" => Hierarchical::Stacked,
"cutout" => Hierarchical::Cutout,
_ => Hierarchical::Stacked,
};
let mode = match mode.unwrap_or("spline") {
"spline" => PathSimplifyMode::Spline,
"polygon" => PathSimplifyMode::Polygon,
"none" => PathSimplifyMode::None,
_ => PathSimplifyMode::Spline,
};
let filter_speckle = filter_speckle.unwrap_or(4);
let color_precision = color_precision.unwrap_or(6);
let layer_difference = layer_difference.unwrap_or(16);
let corner_threshold = corner_threshold.unwrap_or(60);
let length_threshold = length_threshold.unwrap_or(4.0);
let splice_threshold = splice_threshold.unwrap_or(45);
let max_iterations = max_iterations.unwrap_or(10);
let config = Config {
input_path,
output_path,
color_mode,
hierarchical,
filter_speckle,
color_precision,
layer_difference,
mode,
corner_threshold,
length_threshold,
max_iterations,
splice_threshold,
path_precision,
..Default::default()
};
convert_image_to_svg(config).unwrap();
Ok(())
}
/// A Python module implemented in Rust.
#[pymodule]
fn vtracer(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(convert_image_to_svg_py, m)?)?;
Ok(())
}
+65
View File
@@ -0,0 +1,65 @@
use std::fmt;
use visioncortex::{Color, CompoundPath, PointF64};
pub struct SvgFile {
pub paths: Vec<SvgPath>,
pub width: usize,
pub height: usize,
pub path_precision: Option<u32>,
}
pub struct SvgPath {
pub path: CompoundPath,
pub color: Color,
}
impl SvgFile {
pub fn new(width: usize, height: usize, path_precision: Option<u32>) -> Self {
SvgFile {
paths: vec![],
width,
height,
path_precision,
}
}
pub fn add_path(&mut self, path: CompoundPath, color: Color) {
self.paths.push(SvgPath {
path,
color,
})
}
}
impl fmt::Display for SvgFile {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
writeln!(f,
r#"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="{}" height="{}">"#,
self.width, self.height
)?;
for path in &self.paths {
path.fmt_with_precision(f, self.path_precision)?;
};
writeln!(f, "</svg>")
}
}
impl fmt::Display for SvgPath {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.fmt_with_precision(f, None)
}
}
impl SvgPath {
fn fmt_with_precision(&self, f: &mut fmt::Formatter, precision: Option<u32>) -> fmt::Result {
let (string, offset) = self.path.to_svg_string(true, PointF64::default(), precision);
writeln!(
f, "<path d=\"{}\" fill=\"{}\" transform=\"translate({},{})\"/>",
string, self.color.to_hex_string(),
offset.x, offset.y
)
}
}
+75
View File
@@ -0,0 +1,75 @@
<div align="center">
<img src="https://github.com/visioncortex/vtracer/raw/master/docs/images/visioncortex-banner.png">
<h1>VTracer: Python Binding</h1>
<p>
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
</p>
<h3>
<a href="//www.visioncortex.org/vtracer-docs">Article</a>
<span> | </span>
<a href="//www.visioncortex.org/vtracer/">Demo</a>
<span> | </span>
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
</h3>
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
</div>
## Introduction
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrator/using/image-trace.html), VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
A technical description of the algorithm is on [visioncortex.org/vtracer-docs](//www.visioncortex.org/vtracer-docs).
## Install (Python)
```shell
pip install vtracer
```
### Usage (Python)
```python
import vtracer
input_path = "/path/to/some_file.jpg"
output_path = "/path/to/some_file.vtracer.jpg"
# Minimal example: use all default values, generate a multicolor SVG
vtracer.convert_image_to_svg_py(inp, out)
# Single-color example. Good for line art, and much faster than full color:
vtracer.convert_image_to_svg_py(inp, out, colormode='binary')
# All the bells & whistles
vtracer.convert_image_to_svg_py(inp,
out,
colormode = 'color', # ["color"] or "binary"
hierarchical = 'stacked', # ["stacked"] or "cutout"
mode = 'spline', # ["spline"] "polygon", or "none"
filter_speckle = 4, # default: 4
color_precision = 6, # default: 6
layer_difference = 16, # default: 16
corner_threshold = 60, # default: 60
length_threshold = 4.0, # in [3.5, 10] default: 4.0
max_iterations = 10, # default: 10
splice_threshold = 45, # default: 45
path_precision = 3 # default: 8
)
```
## Rust Library
The (Rust) library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
+1
View File
@@ -0,0 +1 @@
from .vtracer import convert_image_to_svg_py
+17
View File
@@ -0,0 +1,17 @@
from typing import Optional
def convert_image_to_svg_py(image_path: str,
out_path: str,
colormode: Optional[str] = None, # ["color"] or "binary"
hierarchical: Optional[str] = None, # ["stacked"] or "cutout"
mode: Optional[str] = None, # ["spline"], "polygon", "none"
filter_speckle: Optional[int] = None, # default: 4
color_precision: Optional[int] = None, # default: 6
layer_difference: Optional[int] = None, # default: 16
corner_threshold: Optional[int] = None, # default: 60
length_threshold: Optional[float] = None, # in [3.5, 10] default: 4.0
max_iterations: Optional[int] = None, # default: 10
splice_threshold: Optional[int] = None, # default: 45
path_precision: Optional[int] = None, # default: 8
) -> None:
...
-24
View File
@@ -1,24 +0,0 @@
[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.1", 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"] }
-231
View File
@@ -1,231 +0,0 @@
//! 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::{ColorMode, 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>,
/// Color image (`color`) or binary image (`bw`).
#[arg(long = "colormode")]
colormode: Option<ColorMode>,
/// 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).
#[arg(short = 'c', long, value_parser = clap::value_parser!(i64).range(0..=180))]
corner_threshold: Option<i64>,
/// Subdivide until all segments are shorter than this length (3.5..=10).
#[arg(short = 'l', long, value_parser = parse_segment_length)]
segment_length: Option<f64>,
/// Minimum angle displacement (degrees) to splice a spline (0..=180).
#[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))]
splice_threshold: Option<i64>,
/// 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+simplify, 2 = + shorthands/grouping.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
optimize: Option<u8>,
}
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.colormode {
config.color_mode = 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.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);
}
// 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
}
}
}
-26
View File
@@ -1,26 +0,0 @@
[package]
name = "vtracer-py"
description = "Python bindings for the vtracer vectorization framework."
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
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.1", 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"] }
-67
View File
@@ -1,67 +0,0 @@
# 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
```
## 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 |
|---|---|---|
| `color_mode` | `"color"` | `"color"` or `"bw"` |
| `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 |
| `path_precision` | `2` | output decimal places |
| `palette` | `None` | list of `#rrggbb` strings |
| `max_colors` | `None` | auto-quantize target |
| `optimize` | `1` | `0` off, `1` quantize+simplify, `2` + shorthands |
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
```
-26
View File
@@ -1,26 +0,0 @@
[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."
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"]
-446
View File
@@ -1,446 +0,0 @@
//! 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, ColorMode, 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 color_mode_str(m: ColorMode) -> &'static str {
match m {
ColorMode::Color => "color",
ColorMode::Binary => "bw",
}
}
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 = (
color_mode = "color",
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,
path_precision = 2,
palette = None,
max_colors = None,
optimize = 1,
))]
#[allow(clippy::too_many_arguments)]
fn new(
color_mode: &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,
path_precision: u32,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: 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 {
color_mode: parse(color_mode)?,
hierarchical: parse(hierarchical)?,
mode: parse(mode)?,
filter_speckle,
color_precision,
layer_difference,
corner_threshold,
length_threshold,
max_iterations,
splice_threshold,
path_precision: Some(path_precision),
palette,
max_colors,
optimize,
},
})
}
/// 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 color_mode(&self) -> &'static str {
color_mode_str(self.inner.color_mode)
}
#[setter]
fn set_color_mode(&mut self, v: &str) -> PyResult<()> {
self.inner.color_mode = parse(v)?;
Ok(())
}
#[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 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;
}
// --- 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(color_mode='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
max_colors={:?}, optimize={})",
color_mode_str(c.color_mode),
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(())
}
-55
View File
@@ -1,55 +0,0 @@
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,
color_mode: str = "color", # "color" | "bw"
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,
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
) -> None: ...
@staticmethod
def bw() -> "Config": ...
@staticmethod
def poster() -> "Config": ...
@staticmethod
def photo() -> "Config": ...
color_mode: 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
path_precision: Optional[int]
palette: list[str]
max_colors: Optional[int]
optimize: 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: ...
-23
View File
@@ -1,23 +0,0 @@
[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"]
[lib]
name = "vtracer"
path = "src/lib.rs"
[dependencies]
visioncortex.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"
-28
View File
@@ -1,28 +0,0 @@
use crate::ir::{Layer, 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;
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());
for layer in seg.layers.drain(..) {
if let Some(last) = merged.last_mut() {
if last.paint == layer.paint {
last.mask = last.mask.union(&layer.mask);
continue;
}
}
merged.push(layer);
}
seg.layers = merged;
}
}
-75
View File
@@ -1,75 +0,0 @@
//! 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)));
}
}
-53
View File
@@ -1,53 +0,0 @@
//! 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
@@ -1,47 +0,0 @@
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
@@ -1,148 +0,0 @@
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);
}
}
}
-43
View File
@@ -1,43 +0,0 @@
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
//!
//! * **Stacked** — each layer is traced independently into closed outlines and
//! stacked in paint order (painter's algorithm).
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
use crate::fitter::CurveFitter;
use crate::ir::{Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, SegmentFitter};
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
/// Independent per-region closed outlines, stacked bottom-to-top.
Stacked(Box<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic(Box<dyn SegmentFitter>),
}
impl Compositing {
/// Run the selected compositor over a segmentation.
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
Compositing::Mosaic(fitter) => compose_mosaic(seg, fitter.as_ref()),
}
}
}
/// Trace every layer's closed outline and stack the shapes in paint order.
pub fn compose_stacked(seg: &Segmentation, fitter: &dyn CurveFitter) -> VectorDoc {
let mut doc = VectorDoc::new(seg.width, seg.height);
for layer in &seg.layers {
let path = fitter.fit_region(&layer.mask);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
}
doc
}
-276
View File
@@ -1,276 +0,0 @@
//! 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};
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
use crate::pipeline::Pipeline;
use crate::svg::SvgWriter;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Color,
Binary,
}
#[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,
}
/// High-level converter configuration. [`Config::build`] turns this into a
/// concrete [`Pipeline`].
#[derive(Debug, Clone)]
pub struct Config {
pub color_mode: ColorMode,
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,
/// Coordinate precision (decimal places) for output.
pub path_precision: Option<u32>,
/// Fixed palette (empty = none). Takes priority over `max_colors`.
pub palette: Vec<Color>,
/// Auto-quantize target color count (None = off).
pub max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
pub optimize: u8,
}
impl Default for Config {
fn default() -> Self {
Self {
color_mode: ColorMode::Color,
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,
path_precision: Some(2),
palette: Vec::new(),
max_colors: None,
optimize: 1,
}
}
}
impl Config {
pub fn from_preset(preset: Preset) -> Self {
match preset {
Preset::Bw => Self {
color_mode: ColorMode::Binary,
..Self::default()
},
Preset::Poster => Self {
color_mode: ColorMode::Color,
color_precision: 8,
..Self::default()
},
Preset::Photo => Self {
color_mode: ColorMode::Color,
filter_speckle: 10,
color_precision: 8,
layer_difference: 48,
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> {
let filter_speckle_area = self.filter_speckle * self.filter_speckle;
match self.color_mode {
ColorMode::Color => Box::new(ColorClusterFrontend {
filter_speckle_area,
color_precision_loss: 8 - self.color_precision,
layer_difference: self.layer_difference,
}),
ColorMode::Binary => Box::new(BinaryFrontend {
filter_speckle_area,
threshold: 128,
diagonal: false,
}),
}
}
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 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(SimplifyPass),
]
}
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,
},
}
}
/// Assemble a concrete pipeline from this configuration.
pub fn build(&self) -> Result<Pipeline, Error> {
let compositing = match self.hierarchical {
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
Hierarchical::Cutout => Compositing::Mosaic(self.segment_fitter()),
};
Ok(Pipeline {
frontend: self.frontend(),
color_fitters: self.color_fitters(),
compositing,
optimizers: self.optimizers(),
writer: self.writer(),
})
}
}
fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
impl FromStr for ColorMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color" => Ok(Self::Color),
"binary" | "bw" | "BW" => Ok(Self::Binary),
_ => Err(format!("unknown color mode {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}")),
}
}
}
-41
View File
@@ -1,41 +0,0 @@
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),
/// 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::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())
}
}
-173
View File
@@ -1,173 +0,0 @@
//! Curve fitters: turn a region's pixel mask into vector outlines.
//!
//! The three built-ins wrap the corresponding visioncortex tracing modes and
//! emit our [`MultiPath`] IR in absolute (document) coordinates:
//!
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
//! * [`PolygonFitter`] — staircase-symmetric DouglasPeucker polygon.
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
//!
//! All three trace *closed* region outlines (outer ring plus holes). Open
//! polyline fitting (needed for the mosaic compositor) will arrive with that
//! milestone.
use visioncortex::clusters::Cluster as BinaryCluster;
use visioncortex::{
CompoundPath, CompoundPathElement, PathSimplifyMode, PointF64, PointI32,
};
use crate::ir::{MultiPath, PathCmd, RegionMask, SubPath};
/// 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.
pub trait CurveFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath;
}
/// 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) -> MultiPath {
trace_region(mask, PathSimplifyMode::None, FitParams::default())
}
}
/// DouglasPeucker polygon with staircase removal.
#[derive(Debug, Clone, Default)]
pub struct PolygonFitter;
impl CurveFitter for PolygonFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
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) -> MultiPath {
trace_region(mask, PathSimplifyMode::Spline, self.params)
}
}
/// Trace every connected component of a masked region and merge the resulting
/// outlines into a single [`MultiPath`] in absolute coordinates.
///
/// This mirrors visioncortex's `Cluster::to_compound_path`: the mask (with
/// holes already punched) is split into connected sub-clusters, each traced
/// independently, then offset into document space.
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> MultiPath {
let mut multi = MultiPath::new();
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 multi, &compound);
}
multi
}
fn append_compound(multi: &mut MultiPath, 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();
multi.push(polyline_subpath(&pts));
}
CompoundPathElement::PathF64(p) => {
multi.push(polyline_subpath(&p.path));
}
CompoundPathElement::Spline(s) => {
multi.push(spline_subpath(&s.points));
}
}
}
}
/// 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 spline of `1 + 3n` points becomes `MoveTo · CubicTo* · Close`.
fn spline_subpath(points: &[PointF64]) -> SubPath {
let mut sub = SubPath::new();
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
return sub;
}
sub.commands.push(PathCmd::MoveTo(points[0]));
let mut i = 1;
while i + 2 < points.len() {
sub.commands
.push(PathCmd::CubicTo(points[i], points[i + 1], points[i + 2]));
i += 3;
}
sub.commands.push(PathCmd::Close);
sub
}
-63
View File
@@ -1,63 +0,0 @@
use visioncortex::{Color, ColorImage, PointI32};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::Frontend;
/// Binary (black/white) frontend: threshold the image then cluster the
/// foreground. Every region is painted black.
#[derive(Debug, Clone)]
pub struct BinaryFrontend {
/// Discard clusters smaller than this many pixels.
pub filter_speckle_area: usize,
/// A pixel is foreground when its red channel is below this threshold.
pub threshold: u8,
/// Whether to connect clusters diagonally.
pub diagonal: bool,
}
impl Default for BinaryFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
threshold: 128,
diagonal: false,
}
}
}
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 threshold = self.threshold;
let binary = img.to_binary_image(|c| c.r < threshold);
let clusters = binary.to_clusters(self.diagonal);
let mut seg = Segmentation::new(width as u32, height as u32);
let black = Color::new(0, 0, 0);
for i in 0..clusters.len() {
let cluster = clusters.get_cluster(i);
if cluster.size() >= self.filter_speckle_area {
let mask = RegionMask::new(
cluster.to_binary_image(),
PointI32 {
x: cluster.rect.left,
y: cluster.rect.top,
},
);
seg.layers.push(Layer {
paint: Paint::Solid(black),
mask,
});
}
}
Ok(seg)
}
}
@@ -1,97 +0,0 @@
use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX};
use visioncortex::{Color, ColorImage, PointI32};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::keying::{apply_key, find_unused_color, should_key_image};
use super::Frontend;
/// Hierarchical color-clustering frontend — the classic VTracer color path.
#[derive(Debug, Clone)]
pub struct ColorClusterFrontend {
/// Discard clusters smaller than this many pixels.
pub filter_speckle_area: usize,
/// Bits of color precision dropped when comparing pixels (0 = full 8-bit).
pub color_precision_loss: i32,
/// Color difference between hierarchical gradient layers.
pub layer_difference: i32,
}
impl Default for ColorClusterFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
color_precision_loss: 2,
layer_difference: 16,
}
}
}
impl Frontend for ColorClusterFrontend {
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 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 runner = Runner::new(
RunnerConfig {
diagonal: self.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: self.filter_speckle_area,
good_max_area: width * height,
is_same_color_a: self.color_precision_loss,
is_same_color_b: 1,
deepen_diff: self.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: KeyingAction::Discard,
},
img,
);
let clusters = runner.run();
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,
});
}
Ok(seg)
}
}
-105
View File
@@ -1,105 +0,0 @@
//! 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);
}
}
}
}
-26
View File
@@ -1,26 +0,0 @@
//! Frontends: algorithms that turn a raster image into a [`Segmentation`].
//!
//! Built-ins:
//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic
//! VTracer color path), including transparency keying.
//! * [`BinaryFrontend`] — threshold to black/white then cluster.
//!
//! Third parties can implement [`Frontend`] to feed external label maps or ML
//! segmentation into the pipeline.
mod binary;
mod color_cluster;
mod keying;
pub use binary::BinaryFrontend;
pub use color_cluster::ColorClusterFrontend;
use visioncortex::ColorImage;
use crate::error::Error;
use crate::ir::Segmentation;
/// A frontend segments a raster image into ordered paint layers.
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
}
-34
View File
@@ -1,34 +0,0 @@
//! 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,
}
}
}
-100
View File
@@ -1,100 +0,0 @@
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 {
let left = self.offset.x.min(other.offset.x);
let top = self.offset.y.min(other.offset.y);
let right = (self.offset.x + self.image.width as i32)
.max(other.offset.x + other.image.width as i32);
let bottom = (self.offset.y + self.image.height as i32)
.max(other.offset.y + other.image.height as i32);
let width = (right - left) as usize;
let height = (bottom - top) as usize;
let mut image = BinaryImage::new_w_h(width, height);
for src in [self, other] {
for y in 0..src.image.height {
for x in 0..src.image.width {
if src.image.get_pixel(x, y) {
let gx = (src.offset.x + x as i32 - left) as usize;
let gy = (src.offset.y + y as i32 - top) as usize;
image.set_pixel(gx, gy, true);
}
}
}
}
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
@@ -1,90 +0,0 @@
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(),
}
}
}
-49
View File
@@ -1,49 +0,0 @@
//! # vtracer
//!
//! A vectorization *framework*: raster images become vector graphics through a
//! pipeline of pluggable stages.
//!
//! ```text
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ 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`], [`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 svg;
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
pub use error::Error;
pub use pipeline::Pipeline;
// Re-export the visioncortex value types callers need at the boundary.
pub use visioncortex::{Color, ColorImage, PointF64, PointI32};
-112
View File
@@ -1,112 +0,0 @@
//! 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 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 → boundary graph → faces → fit → compose.
pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter) -> VectorDoc {
let map = LabelMap::from_segmentation(seg);
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| {
if s.is_ring() {
fitter.fit_ring(s)
} else {
fitter.fit_open(s)
}
})
.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]));
}
}
}
}
-122
View File
@@ -1,122 +0,0 @@
//! 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 super::graph::{
edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef,
};
use super::{LabelMap, RegionId, OUTSIDE};
/// A closed cycle of directed segments bounding (part of) a region.
#[derive(Clone, Debug)]
pub struct Contour(pub Vec<SegRef>);
/// One region and all of its contours (outer + holes).
#[derive(Clone, Debug)]
pub struct Face {
pub region: RegionId,
pub contours: Vec<Contour>,
}
/// 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 mut by_region: Vec<Vec<Contour>> = vec![Vec::new(); map.paints.len()];
// 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) < by_region.len() {
by_region[region as usize].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;
}
if seg.left != OUTSIDE && (seg.left as usize) < by_region.len() {
by_region[seg.left as usize].push(Contour(vec![SegRef {
seg: seg_id as u32,
forward: true,
}]));
}
if seg.right != OUTSIDE && (seg.right as usize) < by_region.len() {
by_region[seg.right as usize].push(Contour(vec![SegRef {
seg: seg_id as u32,
forward: false,
}]));
}
}
by_region
.into_iter()
.enumerate()
.filter(|(_, c)| !c.is_empty())
.map(|(region, contours)| Face {
region: region as RegionId,
contours,
})
.collect()
}
-264
View File
@@ -1,264 +0,0 @@
//! 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;
/// Outset ratio for the 4-point subdivision scheme (matches visioncortex).
const OUTSET_RATIO: f64 = 8.0;
/// Fitted geometry for one boundary segment.
#[derive(Clone, Debug)]
pub enum FittedGeom {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
/// A fitted segment, cached and indexed by segment id.
#[derive(Clone, Debug)]
pub struct FittedSegment {
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_bezier`),
/// 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 into a single cubic, exactly as stacked mode does
/// (`fit_points_with_bezier`: one retract-handled cubic per slice, endpoints
/// pinned to the slice ends).
fn fit_slice(slice: &[PointF64], out: &mut Vec<[PointF64; 4]>) {
match slice.len() {
0 | 1 => {}
2 => out.push(straight_cubic(slice[0], slice[1])),
_ => out.push(SubdivideSmooth::fit_points_with_bezier(slice, FIT_ERROR)),
}
}
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),
}
}
}
-357
View File
@@ -1,357 +0,0 @@
//! 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,
}
}
}
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
/// face-assembly successor rule against a [`LabelMap`].
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
let nw = map.label(x - 1, y - 1);
let ne = map.label(x, y - 1);
let sw = map.label(x - 1, y);
let se = map.label(x, y);
match d {
N => nw,
E => ne,
S => se,
W => sw,
_ => OUTSIDE,
}
}
// 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,
}
}
-323
View File
@@ -1,323 +0,0 @@
//! Mosaic mode: a seam-free, gapless tessellation.
//!
//! Instead of tracing every region independently (which lets neighboring
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
//!
//! ```text
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
//! ```
//!
//! Every boundary curve exists exactly once; the two adjacent regions
//! reference the same fitted geometry, one traversed reversed. Reversal is
//! exact, so the serialized coordinates match on both sides — no seams.
//!
//! Stages 12 (graph + faces) are pure integer arithmetic on the lattice of
//! pixel corners. Only fitting (stage 3) is floating point.
mod compose;
mod face;
mod fit;
mod graph;
pub use compose::compose_mosaic;
pub use fit::{
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
use crate::ir::{Paint, Segmentation};
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
pub type RegionId = u32;
/// Sentinel label for pixels outside any region.
pub const OUTSIDE: RegionId = u32::MAX;
/// A flat partition of the canvas: one region id per pixel, plus the paint for
/// each region. This is the sole input to the boundary-graph extractor.
#[derive(Debug, Clone)]
pub struct LabelMap {
pub width: u32,
pub height: u32,
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
pub labels: Vec<RegionId>,
/// Paint per region, indexed by label.
pub paints: Vec<Paint>,
}
impl LabelMap {
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
/// pixel takes the paint of the topmost layer covering it. Layers are
/// bottom-to-top, so painting them in order lets higher layers win.
pub fn from_segmentation(seg: &Segmentation) -> Self {
let w = seg.width as usize;
let h = seg.height as usize;
let mut labels = vec![OUTSIDE; w * h];
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
for (i, layer) in seg.layers.iter().enumerate() {
let mask = &layer.mask;
for ly in 0..mask.image.height {
for lx in 0..mask.image.width {
if mask.image.get_pixel(lx, ly) {
let gx = mask.offset.x + lx as i32;
let gy = mask.offset.y + ly as i32;
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
labels[gy as usize * w + gx as usize] = i as RegionId;
}
}
}
}
}
LabelMap {
width: seg.width,
height: seg.height,
labels,
paints,
}
}
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
/// Treating outside as a real label removes all image-border special cases.
#[inline]
pub fn label(&self, x: i32, y: i32) -> RegionId {
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
return OUTSIDE;
}
self.labels[y as usize * self.width as usize + x as usize]
}
}
#[cfg(test)]
mod tests {
use super::face::{assemble, Face};
use super::graph::BoundaryGraph;
use super::*;
use crate::ir::Paint;
use visioncortex::{Color, PointF64};
/// Build a label map from a row-major grid (for tests).
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
let n = max.map(|m| m as usize + 1).unwrap_or(0);
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
LabelMap {
width,
height,
labels,
paints,
}
}
/// Reconstruct a face's contour polygons in exact lattice coordinates.
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
face.contours
.iter()
.map(|contour| {
let mut ring: Vec<PointF64> = Vec::new();
for (i, sref) in contour.0.iter().enumerate() {
let pts = &graph.segments[sref.seg as usize].points;
let ordered: Vec<PointF64> = if sref.forward {
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
} else {
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
};
if i == 0 {
ring.extend(ordered);
} else {
ring.extend(ordered[1..].iter().copied());
}
}
ring
})
.collect()
}
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Winding number of point `p` w.r.t. a closed ring (last == first).
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
let mut wn = 0;
for w in ring.windows(2) {
let (a, b) = (w[0], w[1]);
if a.y <= p.y {
if b.y > p.y && is_left(a, b, p) > 0.0 {
wn += 1;
}
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
wn -= 1;
}
}
wn
}
/// The strongest guarantee: rasterize the composed faces at pixel centers
/// and assert the result is byte-identical to the input label map.
fn assert_pixel_roundtrip(map: &LabelMap) {
let graph = BoundaryGraph::extract(map);
let faces = assemble(&graph, map);
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
.iter()
.map(|f| (f.region, face_polygons(&graph, f)))
.collect();
for y in 0..map.height as i32 {
for x in 0..map.width as i32 {
let center = PointF64 {
x: x as f64 + 0.5,
y: y as f64 + 0.5,
};
let mut hits: Vec<RegionId> = Vec::new();
for (region, rings) in &polys {
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
if wn != 0 {
hits.push(*region);
}
}
let expected = map.label(x, y);
if expected == OUTSIDE {
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
} else {
assert_eq!(
hits,
vec![expected],
"({x},{y}) expected region {expected}, got {hits:?}"
);
}
}
}
}
#[test]
fn single_region_is_one_ring() {
let map = grid(3, 2, vec![0; 6]);
let graph = BoundaryGraph::extract(&map);
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
assert_eq!(graph.segments.len(), 1, "one border ring");
assert!(graph.segments[0].is_ring());
assert_pixel_roundtrip(&map);
}
#[test]
fn vertical_split() {
// 4x2, left half 0, right half 1.
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
let graph = BoundaryGraph::extract(&map);
// Two border junctions where the split meets the top and bottom edges.
assert_eq!(graph.nodes.len(), 2);
assert_pixel_roundtrip(&map);
}
#[test]
fn t_junction() {
// top row one region, bottom row split — a degree-3 interior node.
let map = grid(2, 2, vec![0, 0, 1, 2]);
assert_pixel_roundtrip(&map);
}
#[test]
fn checkerboard_pinch() {
// A B / B A — the center corner is a degree-4 pinch; each region is two
// lobes touching there. (The four boundary/border corners are degree-3
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
// the real check that the pinch produces exact, simple contours.
let map = grid(2, 2, vec![0, 1, 1, 0]);
let graph = BoundaryGraph::extract(&map);
let has_degree4 = graph.nodes.iter().any(|n| {
let c = n.corner;
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
});
assert!(has_degree4, "expected a degree-4 pinch node at the center");
assert_pixel_roundtrip(&map);
}
#[test]
fn nested_rings() {
// Concentric squares: 0 outer, 1 middle, 2 center.
let l = |x: i32, y: i32| -> RegionId {
let d = x.min(y).min(5 - x).min(5 - y);
match d {
0 => 0,
1 => 1,
_ => 2,
}
};
let mut labels = Vec::new();
for y in 0..6 {
for x in 0..6 {
labels.push(l(x, y));
}
}
assert_pixel_roundtrip(&grid(6, 6, labels));
}
#[test]
fn outside_region_border_touching() {
// A region that does not fill the canvas; the rest is OUTSIDE.
let mut labels = vec![OUTSIDE; 16];
for y in 1..3 {
for x in 1..3 {
labels[y * 4 + x] = 0;
}
}
assert_pixel_roundtrip(&grid(4, 4, labels));
}
#[test]
fn spline_segments_pin_endpoints_to_lattice() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
// A shape with junctions so there are open (non-ring) segments.
let map = grid(4, 4, vec![
0, 0, 1, 1,
0, 0, 1, 1,
2, 2, 1, 1,
2, 2, 2, 2,
]);
let graph = BoundaryGraph::extract(&map);
let fitter = SplineSegmentFitter::default();
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
let fitted = fitter.fit_open(seg);
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
let end = {
let p = seg.points[seg.points.len() - 1];
PointF64 { x: p.x as f64, y: p.y as f64 }
};
match fitted.geom {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned to node");
assert_eq!(b.last().unwrap()[3], end, "end pinned to node");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
#[test]
fn random_maps_roundtrip() {
// Deterministic LCG; connectivity not required.
let mut state: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as u32
};
for _ in 0..40 {
let w = 2 + next() % 10;
let h = 2 + next() % 10;
let nlabels = 1 + next() % 5;
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
assert_pixel_roundtrip(&grid(w, h, labels));
}
}
}
-207
View File
@@ -1,207 +0,0 @@
//! Optimizer passes over the [`VectorDoc`] before serialization.
//!
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
//! it here (rather than at write time) lets [`SimplifyPass`] act on the
//! rounded geometry, and it bakes offsets into coordinates so the writer
//! never needs a per-path `translate`.
//! * [`SimplifyPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created.
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 SimplifyPass;
/// 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 simplify_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 SimplifyPass {
fn run(&self, doc: &mut VectorDoc) {
for shape in &mut doc.shapes {
let mut subpaths = Vec::with_capacity(shape.path.subpaths.len());
for sub in &shape.path.subpaths {
let simplified = simplify_subpath(sub);
// 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 simplify_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,
]);
SimplifyPass.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);
}
}
-45
View File
@@ -1,45 +0,0 @@
//! 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::VectorDoc;
use crate::optimize::OptimizerPass;
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,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
pub writer: SvgWriter,
}
impl Pipeline {
/// Run the pipeline to the output document IR (before serialization).
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
let mut seg = self.frontend.segment(img)?;
for fitter in &self.color_fitters {
fitter.fit(&mut seg);
}
let mut doc = self.compositing.compose(&seg);
for pass in &self.optimizers {
pass.run(&mut doc);
}
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)?))
}
}
-582
View File
@@ -1,582 +0,0 @@
//! 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}"
);
}
}
}
-215
View File
@@ -1,215 +0,0 @@
//! 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 ~12px 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(mode: FitMode) {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h);
let stacked = Config {
mode,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
.build()
.unwrap()
.to_svg(&img)
.unwrap();
let cutout = Config {
mode,
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)"
);
}
#[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);
}
// --- 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.
#[test]
fn stacked_has_no_seams() {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h); // background fills the whole canvas
let svg = Config {
mode: FitMode::Spline,
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,
"stacked mode leaked {show_through} backdrop pixels — seams/holes in solid overdraw"
);
}
-298
View File
@@ -1,298 +0,0 @@
//! 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, ColorMode, 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 {
color_mode: ColorMode::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()
},
),
]
}
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
}
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#000000"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 512 B

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

Before

Width:  |  Height:  |  Size: 379 B

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

Before

Width:  |  Height:  |  Size: 375 B

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#28C83C"/>
<path d="M36,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#E6D228"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#323CDC"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#DC2828"/>
</svg>

Before

Width:  |  Height:  |  Size: 623 B

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

Before

Width:  |  Height:  |  Size: 2.1 KiB

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#EBEBEB"/>
<path d="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>

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 985 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C0,16.32,0,32.16,0,48c16.32,0,32.16,0,48,0c0-16.32,0-32.16,0-48C31.68,0,15.84,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
<path d="M31.56,9.75C23.31,7.33,14,8.99,9.75,17.5c-.26,.85-.5,1.68-.75,2.5c-1.33,4-1.33,4,.81,10.44c1.84,3.95,3.73,6.83,7.69,8.81c.85,.26,1.68,.5,2.5,.75c4,1.33,4,1.33,10.44-.81c3.95-1.84,6.83-3.73,8.81-7.69c.26-.85,.5-1.67,.75-2.5c.75-2.24,1.06-3.6,.69-5.94c-1.85-5.53-3.65-10.5-9.13-13.31Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0C48,15.84,48,31.68,48,48C32.16,48,16.32,48,0,48C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06C39.1,16.2,41.16,20.44,40.91,26.15C39.91,31.12,37.77,34.55,34,38C29.69,40.33,25.67,41.47,20.81,40.5C16.02,38.91,12.35,36.5,9.89,31.95C8.06,27.32,7.57,23.62,9.15,18.85C11.43,13.88,14.28,10.99,19.31,9C25.64,7.23,29.86,8.66,35.31,12.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 573 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 544 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 544 B

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#285AC8"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
<path d="M29,16c2.56,1.44,2.56,1.44,4,4c.75,4.29,.71,7.73-1.44,11.56C27.73,33.71,24.29,33.75,20,33c-2.56-1.44-2.56-1.44-4-4c-.75-4.29-.71-7.73,1.44-11.56C21.27,15.29,24.71,15.25,29,16Z" fill="#F5F5F5"/>
</svg>

Before

Width:  |  Height:  |  Size: 829 B

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#55FF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#555580"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FFAA80"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#55AA80"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF0080"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#550080"/>
<path d="M24,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AAFF80"/>
<path d="M0,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#00FF80"/>
<path d="M24,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AAAA80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#00AA80"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA5580"/>
<path d="M0,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#005580"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA0080"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0C0,8.04,0,4.08,0,0Z" fill="#000080"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#FFFF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#AA2A80"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#AA2A80"/>
<path d="M0,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#FFFF80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
<path d="M0,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0C0,8.04,0,4.08,0,0ZM24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-89
View File
@@ -1,89 +0,0 @@
//! End-to-end pipeline smoke tests over synthetic images.
use vtracer::{ColorImage, ColorMode, 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 {
color_mode: ColorMode::Binary,
..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);
}
-46
View File
@@ -1,46 +0,0 @@
# VTracer 1.0 Design Documents
VTracer is being rearchitected from a single hardcoded pipeline into a **vectorization framework**. These documents describe the target design.
| Document | Contents |
|---|---|
| [architecture.md](architecture.md) | Workspace layout, core IR, stage traits, pipeline driver, optimizer & SVG writer, CLI |
| [mosaic.md](mosaic.md) | The seam-free cutout/mosaic mode: boundary-graph tracing and shared-edge curve fitting |
| [bindings.md](bindings.md) | Python (PyPI), wasm, and the new Node.js (npm) package |
| [roadmap.md](roadmap.md) | Milestones and verification strategy |
## Motivation
VTracer today (0.6.x) is a thin driver around the `visioncortex` crate: one pipeline (color clustering → per-cluster tracing → SVG string), a CLI, a pyo3 binding, and a web demo that duplicates the pipeline. The rewrite turns it into a framework with pluggable stages:
1. **Frontend** — any algorithm that produces clusters/segmentation from a raster image
2. **Curve fitting backend** — pluggable polyline→curve fitters (pixel, polygon, spline, future potrace-style)
3. **Color fitting** — mapping cluster colors to final paints, including custom fixed palettes
4. **Optimizer** — a pass pipeline that shrinks output (relative path syntax, shorthand commands, precision reduction)
5. **True mosaic cutout** — a perfect, gapless tessellation with shared boundary geometry, replacing today's fake cutout (which re-clusters a re-rendered image and shows seams)
The project stays backend/CLI focused, and everything except image file I/O compiles to `wasm32-unknown-unknown`.
## Decisions
- **`visioncortex` remains a dependency**, wrapped behind traits. Development uses a path/`[patch]` dependency on the local checkout; API additions are committed to visioncortex directly and published as 0.8.x releases. Verified that everything the new design needs is already public: the fitting primitives (`fit_points_with_bezier`, `find_corners`, `subdivide_keep_corners`, `reduce`, `PathSimplify::*`) and cluster pixel access via `ClustersView`.
- **In-repo rewrite, clean break.** New workspace layout, new API, version bump. Old CLI flags are kept only where they map naturally.
- **Python binding stays** (ported to the new API). The **webapp GUI is dropped**; a wasm library crate replaces it.
- **New Node.js library** published to npm, using the wasm build internally plus a native image reader (sharp).
## Pipeline at a glance
```
┌───────────┐ ┌──────────────┐ ┌─────────────────────────────┐
raster ───▶ │ Frontend │ ─▶│ ColorFitter* │ ─▶│ Compositing │
image │ (segment) │ │ (palette, │ │ Stacked: closed outlines │
└───────────┘ │ quantize, │ │ Mosaic: boundary graph + │
│ merge) │ │ shared-edge fit │
└──────────────┘ └──────────────┬──────────────┘
│ CurveFitter
▼ (pixel/polygon/spline)
┌──────────────────────────────┐
SVG ◀──── │ VectorDoc ─ OptimizerPass* ─ │
│ SvgWriter │
└──────────────────────────────┘
```
-153
View File
@@ -1,153 +0,0 @@
# Architecture
## Workspace layout
```
Cargo.toml # workspace
crates/
├── vtracer-core/ # the framework. wasm-safe, no file/image I/O, no clap/pyo3
│ └── src/
│ ├── lib.rs
│ ├── ir/ # Segmentation, LabelMap, VectorDoc, geometry types
│ ├── frontend/ # trait Frontend + ColorClusterFrontend, BinaryFrontend, keying
│ ├── colorfit/ # trait ColorFitter + Identity, FixedPalette, AutoQuantize
│ ├── fitter/ # trait CurveFitter + Pixel, Polygon, Spline
│ ├── compose/ # stacked composition (per-region closed tracing)
│ ├── mosaic/ # boundary-graph extraction + shared-edge fitting (see mosaic.md)
│ ├── optimize/ # trait OptimizerPass + passes over VectorDoc
│ ├── svg/ # writer (absolute/relative, shorthands, precision)
│ └── pipeline.rs # Pipeline driver + Config/presets
├── vtracer/ # publishable bin+lib crate, keeps the crate name.
│ # image I/O (image crate), clap 4 CLI,
│ # pyo3 binding behind `python-binding` feature
└── vtracer-wasm/ # wasm-bindgen bindings over vtracer-core
nodejs/ # npm package: TS wrapper + embedded wasm build + sharp reader
```
- `webapp/` and `cmdapp/` are deleted (git history preserves them).
- `vtracer` re-exports `vtracer-core`, so library users need a single dependency.
- During development the workspace carries `[patch.crates-io] visioncortex = { path = "../visioncortex" }`; releases pin a published 0.8.x.
- `flo_curves` (already in the tree via visioncortex) becomes a direct dependency of `vtracer-core` for configurable-error Bezier fitting.
## Core IR
Value types from `visioncortex` are reused where they fit (`ColorImage`, `Color`, `PointF64`, `CompoundPath`); the pipeline IR is our own:
```rust
/// Frontend output — the general form is ordered layers (painter's algorithm).
pub struct Segmentation {
pub width: u32,
pub height: u32,
pub layers: Vec<Layer>, // bottom-to-top paint order
}
pub struct Layer {
pub paint: Paint, // starts as mean cluster color; ColorFitter may rewrite
pub mask: RegionMask, // the cluster's pixel indices
}
/// Flat partition for mosaic mode, derived by painting layers top-down.
pub struct LabelMap {
pub width: u32,
pub height: u32,
pub labels: Vec<u32>, // one label per pixel; u32::MAX = OUTSIDE (keyed/transparent)
pub paints: Vec<Paint>, // indexed by label
}
/// Output document IR — what the optimizer and the writer operate on.
pub struct VectorDoc { pub width: u32, pub height: u32, pub shapes: Vec<Shape> }
pub struct Shape { pub paint: Paint, pub path: MultiPath } // subpaths: MoveTo + (Line|Cubic)* + Close
pub enum Paint { Solid(Color) } // room for gradients later
```
Why layers, not a label map, as the frontend output: in stacked mode clusters genuinely overlap (each hierarchical cluster is painted over its parents), which a flat label map cannot represent. The flat `LabelMap` needed by mosaic mode is derived from the layers by a top-down flatten — cheap and lossless for that purpose.
## Stage traits
All object-safe; the driver composes boxed trait objects (ergonomic across CLI/py/wasm boundaries, negligible dispatch cost next to the per-pixel work).
```rust
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
}
pub trait ColorFitter {
fn fit(&self, seg: &mut Segmentation);
}
pub trait CurveFitter {
fn fit_closed(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // stacked outlines, rings
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
}
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
pub enum Compositing { Stacked, Mosaic }
pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
}
impl Pipeline {
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> { /* driver */ }
}
```
Driver flow:
1. `frontend.segment(img)``Segmentation`
2. each `ColorFitter` rewrites layer paints (e.g. palette snapping)
3. compositing:
- **Stacked** — trace each layer's closed outlines independently (port of today's `to_compound_path` flow) via `fitter.fit_closed`
- **Mosaic** — flatten to `LabelMap`, merge adjacent same-paint regions, extract the boundary graph, fit each shared edge once via `fitter.fit_open`, assemble faces (see [mosaic.md](mosaic.md))
4. optimizer passes over the `VectorDoc`
5. `SvgWriter` serializes
## Built-in implementations
- **Frontends**
- `ColorClusterFrontend` — wraps `visioncortex::color_clusters::Runner`, including the transparency-keying logic that currently lives in `converter.rs` (find unused key color, key fully-transparent pixels, `KeyingAction`).
- `BinaryFrontend` — threshold → `BinaryImage::to_clusters`.
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
- **ColorFitters**
- `Identity` (today's behavior: mean cluster color)
- `FixedPalette { colors: Vec<Color> }` — snaps each layer paint to the nearest palette entry in OKLab
- `AutoQuantize { max_colors }` — k-means/median-cut over layer paints
- After palette snapping, a built-in merge step unions adjacent regions with identical paint (mosaic path) / merges consecutive identical-paint layers (stacked path).
- **CurveFitters**
- `PixelFitter` — exact lattice polyline
- `PolygonFitter` — staircase-symmetric Douglas-Peucker
- `SplineFitter` — subdivision + corner detection + least-squares cubic fit (port of the visioncortex flow, extended to open polylines with pinned endpoints)
## Optimizer and SVG writer
Two levels: geometry passes over `VectorDoc`, then encoding choices in the writer.
- `QuantizePass { precision }` — round coordinates once, in document space. Replaces today's per-write rounding, and eliminates the per-path `translate(x,y)` transform by baking offsets into coordinates.
- `SimplifyPass` — drop zero-length and collinear-redundant segments *after* quantization.
- `SvgWriter { relative: bool, shorthands: bool, precision }` — per segment picks the shortest encoding:
- relative (`l c s h v`) vs absolute deltas, whichever serializes shorter
- `h`/`v` for axis-aligned lines, `s` for smooth cubic continuations
- number formatting: trim trailing zeros, omit the space before negative numbers, leading-dot decimals
- Paint grouping: shapes sharing a fill emitted inside `<g fill="…">` when it saves bytes.
Output size is a tracked metric: the test suite asserts a byte-size budget against golden samples (see [roadmap.md](roadmap.md)).
## CLI
clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--colormode color|bw`, `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`.
New:
- `--hierarchical stacked|cutout``cutout` now runs the true mosaic pipeline
- `--palette '#112233,#445566,…'` / `--palette-file colors.txt` — fixed palette color fitting
- `--optimize 0..2` — optimizer level (0 = off, 1 = quantize+simplify, 2 = + full writer shorthands/grouping)
- mosaic extras: `--seam-stroke`, `--mosaic-strict` (see mosaic.md)
Range validation moves from `panic!` to clap `value_parser` ranges.
-71
View File
@@ -1,71 +0,0 @@
# Bindings
Backend/CLI focused, with three language surfaces on top of `vtracer-core`. Everything except image file I/O compiles to `wasm32-unknown-unknown`.
## Python (PyPI)
Lives in the `vtracer` crate behind the `python-binding` feature (keeps the existing maturin / PyPI Trusted Publisher workflow intact).
- Ported functions with today's signatures: `convert_image_to_svg_py(image_path, out_path, **config)` and `convert_raw_image_to_svg(img_bytes, img_format=None, **config) -> str`.
- New kwargs: `palette: list[str]` (hex colors), `optimize: int`, and `hierarchical='cutout'` now meaning true mosaic.
## Wasm (`vtracer-wasm` crate)
wasm-bindgen bindings over `vtracer-core`, replacing the old `webapp/` (the GUI demo is dropped).
```text
convert(rgba: Uint8Array, width: u32, height: u32, config_json: string) -> string // SVG
```
- Input is raw RGBA pixels — no image decoding in wasm (keeps the module small; decoding is the host's job).
- The `fastrand/js` feature wiring moves here.
- Built with `wasm-pack`; consumed by the Node.js package below and usable directly in browsers/bundlers.
## Node.js (npm)
New top-level `nodejs/` directory; recommended package name **`@visioncortex/vtracer`** (scoped — avoids collision/squatting on bare `vtracer`).
Design: wasm internally, native image reading.
- The `vtracer-wasm` build (`wasm-pack --target nodejs`) is **embedded in the package** — no network fetch, works offline.
- **[sharp](https://sharp.pixelplumbing.com/)** (native libvips binding with prebuilt binaries) decodes PNG/JPEG/WebP/GIF/AVIF/TIFF to raw RGBA, which is fed to the wasm converter. sharp is a regular dependency (this is a Node-focused library); the pixel-level API still works if the native install fails.
TypeScript API:
```ts
export interface Options {
// camelCase mirror of the Rust Config:
colorMode?: 'color' | 'binary';
hierarchical?: 'stacked' | 'cutout'; // cutout = true mosaic
mode?: 'pixel' | 'polygon' | 'spline';
filterSpeckle?: number;
colorPrecision?: number;
gradientStep?: number;
cornerThreshold?: number;
segmentLength?: number;
spliceThreshold?: number;
pathPrecision?: number;
palette?: string[]; // ['#112233', ...]
optimize?: 0 | 1 | 2;
}
/** Pure wasm — no native dependency needed. */
export function convertPixels(rgba: Uint8Array, width: number, height: number, options?: Options): string;
/** Decodes via sharp (native), then converts. Accepts a file path or an encoded image buffer. */
export function convertImage(input: string | Buffer, options?: Options): Promise<string>;
```
- Tests: vitest (or `node:test`) over the same sample images used by the Rust snapshot tests.
- Publishing: `npm publish` wired into the release workflow alongside crates.io and PyPI.
## visioncortex development flow
`visioncortex` stays a dependency. The workspace carries
```toml
[patch.crates-io]
visioncortex = { path = "../visioncortex" }
```
during development; API additions are committed directly to the local visioncortex repo and published as 0.8.x before a vtracer release, which then pins the published version.
-190
View File
@@ -1,190 +0,0 @@
# Mosaic Mode — Seam-Free Cutout
Today's cutout re-renders the clustered image and re-clusters it, then traces every region independently; independently smoothed neighbors diverge, producing seams. The new mosaic mode replaces it with a topological pipeline that is seam-free **by construction**:
```
label map (Vec<u32>, W·H)
→ 1. boundary-graph extraction (nodes, shared segments, rings) [integer, exact]
→ 2. face assembly (per-region contours as cycles of (seg, dir)) [integer, exact]
→ 3. fit each segment ONCE (pluggable pixel/polygon/spline) [float, endpoints pinned]
→ 4. compose per-region SVG paths from shared fitted segments
```
Every boundary curve exists exactly once; the two adjacent regions reference the same fitted object, one traversed reversed. Reversal is exact for both polylines and cubic Beziers (`[p0,p1,p2,p3] → [p3,p2,p1,p0]`), so the serialized coordinates are identical text on both sides — no seams, no T-junction cracks.
**Coordinate convention**: pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; all boundary geometry lives on the lattice of pixel corners `0..=W × 0..=H` ("crack" boundaries). Stages 12 are pure integer arithmetic.
## 1. Boundary-graph extraction
### Definitions
- `type RegionId = u32; const OUTSIDE: RegionId = u32::MAX;``label(x,y)` returns `OUTSIDE` out of bounds. Treating outside as a real label removes all image-border special cases: border edges and border junctions fall out of the same rules.
- At lattice corner `c=(x,y)` the 2×2 pixel neighborhood is `NW NE / SW SE`. Four potential unit edges at `c`: N present iff `NW≠NE`, E iff `NE≠SE`, S iff `SW≠SE`, W iff `NW≠SW`. Degree = popcount ∈ {0, 2, 3, 4}.
- Quadrant/edge incidence for traversal: NE ↔ {N,E}, SE ↔ {E,S}, SW ↔ {S,W}, NW ↔ {W,N}.
### Node rule (junctions) and the checkerboard decision
**A corner is a node iff degree ≥ 3.**
- Three distinct labels in the 2×2 always gives degree ≥ 3 — "3+ regions meet here" is covered.
- Degree 4 with two labels is exactly the checkerboard `A B / B A` (diagonal contact). **Decision: it is a junction node of 4 edges, and faces are pinched there.** The traversal rule below always takes the sharpest right turn, staying within the current quadrant, never crossing diagonally. If clustering was 8-connected (visioncortex `diagonal: true`), a two-lobe region yields **two separate simple contours** sharing the node coordinate but no edges — emitted as one SVG path with two subpaths. Faces stay simple; the tessellation stays exact.
- Image corners (three quadrants OUTSIDE) are degree-2 chain points, not nodes. Points where two regions meet the border are degree 3 — nodes automatically.
Invariant used by segment tracing: at a degree-2 corner the 2×2 contains exactly two labels and both incident edges separate the same unordered pair — so the (left, right) region pair is constant along any chain of degree-2 corners.
### Data structures
```rust
pub type NodeId = u32;
pub type SegId = u32;
#[derive(Clone, Copy)]
pub struct SegRef { pub seg: SegId, pub forward: bool }
pub struct Node {
pub corner: PointI32, // lattice coords
pub out: [Option<SegRef>; 4], // outgoing directed segment per unit direction N,E,S,W
}
pub struct Segment {
pub points: Vec<PointI32>, // lattice polyline; len >= 2; ring: points[0] == points[last]
pub start: Option<NodeId>, // None,None for rings (no junction anywhere on the loop)
pub end: Option<NodeId>, // start may == end (self-loop pinned at one node)
pub left: RegionId, // region on the left traversing forward (y-down convention)
pub right: RegionId, // either side may be OUTSIDE
}
pub struct Contour(pub Vec<SegRef>); // cycle; a ring is a 1-element contour
pub struct Face { pub region: RegionId, pub contours: Vec<Contour> }
pub struct BoundaryGraph {
pub nodes: Vec<Node>,
pub segments: Vec<Segment>,
pub faces: Vec<Face>,
}
```
Transient: `corner_mask: Vec<u8>` of `(W+1)·(H+1)` (4-bit edge mask + node flag), a corner-index → `NodeId` map, and visited bitsets for undirected edges (horizontal `W·(H+1)`, vertical `(W+1)·H`; closed-form edge ids, no hashing).
"Left" in y-down screen space: heading E → left pixel above; heading S → left pixel to the east; heading W → below; heading N → to the west (4-entry lookup).
### Extraction passes
```
Pass A — classify corners: O((W+1)(H+1))
for each lattice corner: compute 4-bit edge mask from the 2x2 labels
(OUTSIDE for out-of-bounds); allocate a node id where popcount >= 3
Pass B — trace node-to-node segments:
for each node n, for each present direction d not yet visited:
walk unit edges, at each degree-2 corner continue via the unique other
present edge, until reaching a node; record polyline, start/end nodes,
left/right regions; register both directed views in the node tables
Pass C — closed rings:
for each unvisited boundary edge (raster order): walk until returning to
the start corner; record as a Segment with start = end = None
```
Complexity O(W·H + E); every boundary edge is walked exactly once here and once more during face assembly.
Corner cases handled: self-loop segments (a lobe outline returning to the same node — open for fitting purposes, endpoint pinned); whole-image single region (no nodes; Pass C finds the border rectangle as a ring against OUTSIDE); single-pixel regions.
### Successor rule (region kept on the left)
Given an incoming directed unit edge into corner `c`, tracing region R:
```
candidates in priority order: [turn_right(d_in), straight(d_in), turn_left(d_in)]
next = first d such that edge (c,d) is present AND left_pixel(c,d) == R
```
Right-first implements the pinch at checkerboard nodes (both right and straight can have R on the left there; right-first stays in the current quadrant, keeping contours simple). At 3/4-label junctions exactly one candidate qualifies. A u-turn is never needed.
## 2. Face assembly
Lift the successor rule to whole segments (two directed views per segment, 2-bit usage set):
```
for each directed segment s with region R on its left, not yet used:
follow successor at each end node until returning to s → one Contour of R
for each ring r:
left(r) gets [forward], right(r) gets [reversed] (skip OUTSIDE sides)
```
**Winding falls out automatically**: interior-always-on-left gives outer contours one orientation and hole contours the opposite. Therefore each region is emitted as a single `<path fill-rule="nonzero">` whose `d` concatenates all its contours as subpaths — **no containment/nesting computation is needed**. `nonzero` (rather than `evenodd`) is robust to contours touching at pinch points.
Debug invariants: every directed segment used exactly once; per-region i64 shoelace area (holes negative) equals the region's pixel count; the global sum equals W·H minus OUTSIDE pixels.
## 3. Fitting — once per segment, endpoints pinned
```rust
pub enum FittedGeom {
Polyline(Vec<PointF64>), // pixel / polygon backends
Beziers(Vec<[PointF64; 4]>), // spline backend; consecutive curves share endpoints
}
pub trait SegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment; // endpoints pinned to lattice nodes
fn fit_ring(&self, seg: &Segment) -> FittedSegment; // closed loop, no pinned point
}
```
Fitted results are cached in a `Vec<FittedSegment>` indexed by `SegId`; both adjacent faces reference the cache. Reversal happens at composition time and is exact, so shared geometry is bitwise identical — identical f64 values round identically under `path_precision`, and the emitted coordinate text matches on both sides.
### Backends
- **PixelFitter** — identity (lattice points as f64). Exact tessellation; the reference implementation for tests.
- **PolygonFitter** — symmetric open Douglas-Peucker with endpoints always kept (own ~40-line implementation). Deliberately **not** `PathSimplify::remove_staircase`: its directional outset would bias every shared boundary toward one of its two neighbors. Plain DP collapses 1-px staircases to the crack midline — centered between the two regions, which is what a mosaic wants. Self-loops split at the farthest point first.
- **SplineFitter** — open-path port of the visioncortex pipeline:
1. DP(tau) first — staircases must be gone before corner detection, or every stair step reads as a 90° corner.
2. Corner detection without wraparound; **both endpoints forced as corners** (junction nodes stay pinned).
3. Open-path 4-point `subdivide_keep_corners` (no modular indexing; corner points are copied, never displaced).
4. Open-path `find_splice_points` (inflections + accumulated-turn threshold); endpoints forced as splice points.
5. Per slice: least-squares cubic fit. `SubdivideSmooth::fit_points_with_bezier` is already endpoint-exact (p1/p4 are taken from the input), so pinning survives fitting for free — but its internal error is hardcoded to 10.0, so vtracer-core calls `flo_curves::bezier::Curve::fit_from_points` directly with a configurable `max_error`, recursively splitting a slice at its farthest point when the budget is exceeded.
- **Rings** (islands with no junctions) are fitted once as *closed* paths using the closed-path machinery; the island uses the result forward as its outline, the enclosing region uses it reversed as a hole — same cached object, identical geometry.
### Deviation budget and overlap tolerance
Adjacent segments meet only at exact shared node coordinates — gaps are impossible. The remaining risk is a smoothed segment crossing a *different, non-adjacent* segment. Distinct boundary polylines are at least 1 px apart on the lattice, so keeping **maximum deviation < 0.5 px at every stage** (DP tau 0.5, bezier `max_error` 0.5, subdivision defaults well inside that) prevents crossings. This is not formally proven at the Bezier stage (error is sampled), so:
- default: accept the pragmatic budget — a hairline overlap between two abutting fills is visually harmless and can never produce a gap worse than the budget;
- `--mosaic-strict`: sample each fitted segment (~8 samples/curve), and fall back to the DP polyline for any segment exceeding the budget — restoring the hard guarantee at the cost of local smoothness;
- the pixel backend gives bit-exact tessellation.
## 4. Composition
Per region, one `<path fill="{color}" fill-rule="nonzero">`; the `d` string is built contour by contour, emitting each oriented segment while skipping its first point (identical to the previous segment's last point). T-junction cracks are structurally impossible: segments terminate at nodes, no curve ever spans across one, and all incident curves end at the exact integer node coordinate.
## 5. Paint-order independence and anti-aliasing
Geometric coverage is a perfect partition, so rendering is paint-order independent — the defining property of mosaic mode. Antialiasing renderers still blend a hairline along abutting edges (each path is composited independently against the backdrop); that is a renderer artifact of any abutting vector art, not a geometry defect. Optional mitigations:
1. `--seam-stroke` — stroke each path in its own fill color (`stroke-width` 0.51, round joins). Hides AA hairlines; reintroduces mild paint-order sensitivity (cosmetic, documented).
2. `shape-rendering="crispEdges"` output option — kills AA entirely (jaggy but seamless).
3. Stacked mode remains the AA-safe alternative (seams hidden under overdraw); mosaic gives true tessellation semantics — editable, no hidden geometry, order-free.
## Label-map source
`LabelMap::from_clusters(&ClustersView)` stamps dense region ids by iterating `clusters_output` → each cluster's pixel indices. It must **not** read `cluster_indices` directly — that maps pixels to base-level clusters, not the hierarchical output set. Unstamped (keyed/transparent) pixels become `OUTSIDE`.
## Test plan
Unit tests on hand-built const-grid label maps:
- 1×1 and full-image single region → one ring against OUTSIDE
- vertical split `A|B` → 2 border junction nodes, 3 segments, correct left/right and windings
- T-junction `A A / B C` → interior degree-3 node; three faces share the exact node coordinate
- checkerboard `A B / B A` with merged diagonal labels → degree-4 node, pinch: two simple contours touching at the point, exact coverage
- nested islands A ⊃ B ⊃ C → rings only; shared cached geometry asserted
- border-touching region, 1-px corridor, single-pixel island, self-loop segment
- reversal exactness: the two SVG coordinate substrings for a shared segment are identical strings
Property tests (proptest, random maps ≤ 12×12, ≤ 5 labels; label connectivity not required):
- every undirected boundary edge appears in exactly two directed traversals
- per-region shoelace area == pixel count; total == W·H
- **PixelFitter round-trip: scanline-rasterize the composed faces → byte-identical label map** (the strongest end-to-end guarantee; catches winding/pinch/orientation bugs)
- Polygon/Spline: sampled max deviation ≤ budget; all segment endpoints exactly on node lattice coordinates
Integration: run on the sample images; snapshot SVGs; rasterize with resvg and assert the color diff against the label map is confined to a ~1-px boundary band.
-19
View File
@@ -1,19 +0,0 @@
# Roadmap and Verification
## Milestones
Each milestone leaves the repo building and tested.
1. **Scaffold** — new workspace (`crates/vtracer-core`, `crates/vtracer`); IR + stage traits; port the existing stacked pipeline behind them, behavior-identical; golden-SVG snapshot tests over the sample images; CLI ported to clap 4 (range validation via `value_parser`, no more `panic!`).
2. **Writer + optimizer**`VectorDoc` writer with relative/shorthand encoding, `QuantizePass`, `SimplifyPass`; byte-size benchmark vs the 0.6.x output; rasterize-and-diff regression (resvg) proving visual equivalence.
3. **Color fitting**`FixedPalette` (OKLab nearest) + `AutoQuantize` + adjacent-region merge; `--palette` / `--palette-file` CLI.
4. **Mosaic** — boundary-graph module + open-polyline fitting (see [mosaic.md](mosaic.md)); `--hierarchical cutout` switched to the true mosaic; full unit/property test suite.
5. **Bindings** — pyo3 port, `vtracer-wasm`, the npm package under `nodejs/`; delete `webapp/`; CI covers crates.io + PyPI + npm releases.
## Verification strategy
- **Unit** — hand-crafted label maps for mosaic (checkerboard, T-junction, nested islands, border-touching, self-loops); fitter round-trips; writer encoding cases.
- **Snapshot** — golden SVGs for the sample images per preset/mode; asserted byte-size budget for the optimizer.
- **Property** (proptest) — mosaic invariants: every boundary edge used exactly twice; shoelace area == pixel counts; PixelFitter rasterize round-trip is byte-identical to the label map; fitted deviation ≤ 0.5 px budget; endpoints exact on lattice nodes.
- **Visual** — rasterize output with resvg; pixel-diff/SSIM against the input (thresholded) and against pre-rewrite output for stacked mode; mosaic diffs confined to a ~1-px boundary band.
- **Targets** — `cargo build --target wasm32-unknown-unknown -p vtracer-core -p vtracer-wasm`; `maturin build` with `python-binding`; `npm test` in `nodejs/`.
-150
View File
@@ -1,150 +0,0 @@
# Stacked-Mode Equivalence Report
**Question:** does the rewritten 1.0 pipeline (`crates/vtracer`) reproduce the
shipping 0.6.x pipeline (`cmdapp/`) in **stacked** mode, byte-for-byte?
**Verdict:** **Yes.** Across a systematic sweep of **475 parameter
configurations**, every fitted path is geometrically identical (worst
coordinate deviation **1e-8 px** — float-serialization noise). The only
differences are two intentional, visually-invisible ones (documented below).
Date: 2026-07-24. Comparison target: `pixel`, `polygon`, `spline` fitters;
`color` and `bw` color modes.
---
## Scope
- **Stacked only.** Old `--hierarchical cutout` is the *fake* cutout (re-render
the clustered image, re-cluster, retrace); new `cutout` is the topological
mosaic. They are deliberately different algorithms and are **not** expected to
match. Mosaic is verified separately (pixel round-trip + seam tests).
- **Geometry, not pixels.** Comparison parses each SVG's `<path d>` (applying
any `transform="translate()"`) into absolute coordinates and compares those
directly. This is stronger than a raster diff (no antialiasing fuzz) and
isolates the pipeline from the SVG writer.
- **`--path-precision 8`.** High precision so writer rounding can never mask a
real geometry difference. (At the default precision 2, the two writers round
slightly differently — see *Known differences*.)
## Reference oracle
`cmdapp/` (0.6.x) is built with **matched dependencies** — the same local
`visioncortex` 0.9.0 and `image` 0.25 as the new crates — so the comparison
isolates *pipeline logic* from library drift:
- Same `visioncortex` ⇒ identical clustering and curve fitting primitives.
- Same `image` ⇒ identical decoding (JPEG decoding is decoder-version
dependent; PNG is lossless either way).
New is run with `--optimize 0` (no optimizer passes, absolute writer) so the
comparison reflects the tracing/fitting pipeline, not the optimizer. The
optimizer is verified lossless separately.
## Parameter space
| Parameter | Range swept | Affects |
|---|---|---|
| `colormode` | color, bw | frontend |
| `mode` | pixel, polygon, spline | curve fitter |
| `filter_speckle` | 0 16 | frontend (min area) |
| `color_precision` | 1 8 | color clustering |
| `gradient_step` | 0 255 | color layer difference |
| `corner_threshold` | 0 180 | spline |
| `segment_length` | 3.5 10 | spline |
| `splice_threshold` | 0 180 | spline |
The full Cartesian product is ~10¹²; instead the sweep uses a layered strategy
that touches every value of every parameter plus randomized interactions.
## Coverage & results
475 configurations, tank-unit-preview.png (PNG) plus a Gum Tree (JPEG) baseline set:
| Group | Configs | Geometry failures | Worst Δ |
|---|---:|---:|---:|
| Categorical cross (colormode × mode) | 6 | 0 | 1e-8 |
| `filter_speckle` 016 × mode × colormode | 102 | 0 | 1e-8 |
| `color_precision` 18 × mode | 24 | 0 | 1e-8 |
| `gradient_step` 0255 × mode | 39 | 0 | 1e-8 |
| `corner_threshold` 0180 (spline) | 26 | 0 | 1e-8 |
| `segment_length` 3.510 (spline) | 9 | 0 | 1e-8 |
| `splice_threshold` 0180 (spline) | 13 | 0 | 1e-8 |
| Random joint combinations | 250 | 0 | 1e-8 |
| Second image (Gum Tree, JPEG) | 6 | 0 | 1e-8 |
| **Total** | **475** | **0** | **1e-8** |
- **Geometry mismatches (> 1e-6 px): 0.**
- **Empty-path-count divergences: 10** (cosmetic; see below).
By fitter: `pixel` and `polygon` are byte-for-byte identical in both color and
bw. `spline` geometry is identical to 1e-8; the sub-pixel deltas visible at low
`--path-precision` are writer rounding, not geometry.
## Known differences (intentional, invisible)
1. **SVG encoding.** The new writer uses compact relative/shorthand commands
with offsets baked into coordinates; 0.6.x used absolute coordinates plus a
per-path `transform="translate()"`. Same geometry, different bytes — by
design (the new writer is smaller). Verified equal after parsing to absolute
coordinates.
2. **Empty paths.** At `filter_speckle = 0`, tiny (≈1px) clusters survive
filtering; their spline fit is empty. 0.6.x emits a degenerate
`<path d="">` for each (e.g. 67 of them in one bw/spline case); the new
pipeline omits them. They render nothing, so output is visually identical.
This accounts for all 10 "empty-path divergences" and appears only at the
nonsensical `filter_speckle = 0`.
## Bugs found and fixed during this verification
This report's process surfaced two real bugs (both fixed, both now
regression-guarded):
1. **Stacked layers had holes/seams.** The color frontend traced clusters with
holes punched (`to_image_with_hole(.., true)`); stacked mode must trace
*solid* layers and rely on paint-order overdraw (`false`). Symptom: hairline
seams (partial-alpha jumped 4.86% → 0.36% after the fix).
Guard: `stacked_has_no_seams` (a full-coverage image must render fully
opaque — zero backdrop show-through).
2. **Relative writer placed holes wrong.** After `Z`, SVG resets the current
point to the subpath start; the emitter left it at the last vertex, so a
relative `m` for a hole/second subpath was offset. Only visible on
multi-subpath shapes at `optimize=1/2`.
Guard: `relative_and_absolute_encode_same_geometry` (a holed shape must
encode identically absolute vs relative).
## Harness caveats (for reproduction)
- 0.6.x accepts only `--mode` (no `-m`) and treats `--colormode` as binary
**only for the value `bw`**`binary` silently falls through to color. Use
`bw` for both binaries.
- 0.6.x spline mode `pixel` maps to `PathSimplifyMode::None`.
## Reproduction
`cmdapp/` (0.6.x) was removed from the tree after this verification; restore it
from git history (the commit before "Remove the 0.6.x cmdapp crate") to
reproduce.
1. Temporarily point `cmdapp/Cargo.toml` at the matched dependencies
(`image = "0.25"`, `visioncortex = { version = "0.9", path = "../../visioncortex" }`)
and build both binaries:
```sh
cargo build --release --manifest-path cmdapp/Cargo.toml
cargo build --release -p vtracer-cli
```
2. For each configuration, run both binaries in stacked mode with
`--path-precision 8` (new also with `--optimize 0`), remembering the harness
caveats above (`--mode` not `-m`; `--colormode bw`).
3. Parse each SVG's `<path d>` into absolute coordinates (apply any
`transform="translate()"`), drop empty paths, and compare the coordinate
sequences. Equivalent ⇔ per-coordinate deviation < 1e-6.
## Conclusion
In stacked mode the new pipeline is a **byte-for-byte-faithful reimplementation**
of 0.6.x across the full parameter space for `pixel` and `polygon`, and
geometrically identical for `spline`. Remaining differences are limited to the
intentional compact SVG encoding and the omission of degenerate empty paths.
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1783.4 441.4" style="enable-background:new 0 0 1783.4 441.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#A1FCFE;}
.st1{fill:#010357;}
</style>
<rect class="st0" width="1783.4" height="441.4"/>
<g>
<path class="st1" d="M61.4,393.2V73.8H250V113H109v100.4h124.2v39.7H109v140.1L61.4,393.2L61.4,393.2z"/>
<path class="st1" d="M280,393.2V159.7h45.3v233.5L280,393.2L280,393.2z M330.9,92.4c0-15.5-12.5-28-28-28s-28,12.5-28,28
s12.5,28,28,28S330.9,107.9,330.9,92.4z"/>
<path class="st1" d="M383.5,338.6V57.9h45.3v278.8c0,21,3.7,26.1,18.7,26.1c6.1,0,11.7-0.5,16.8-1.4v30.4
c-9.8,2.8-20.1,4.2-29.9,4.2C400.3,396,383.5,376.9,383.5,338.6z"/>
<path class="st1" d="M480.6,275.5c0-78.5,41.6-121.4,110.2-121.4c31.3,0,55.6,9.8,72.9,29.9c17.3,19.6,26.2,45.3,26.2,77.5
c0,7-0.5,14.9-1.9,24.3H525.4c0.9,50.9,22.4,76.6,64.4,76.6c31.8,0,53.2-14.5,53.2-42H687c0,48.6-39.7,77.5-97.6,77.5
C521.7,397.9,480.6,357.3,480.6,275.5z M645,251.7c-0.9-41.6-19.6-62.1-55.6-62.1c-39.7,0-59.8,24.3-63.5,62.1H645z"/>
<path class="st1" d="M720.7,323.2h44.8c1.9,28.5,18.7,38.8,51.8,38.8c28.5,0,46.7-15,46.7-35c0-20.6-16.8-28.5-48.1-36l-13.1-2.8
l-14.5-3.7l-12.6-4.2c-6.1-1.9-10.7-3.3-13.1-4.7c-5.1-3.3-17.3-8.9-21.5-14.5c-7.5-8.9-15.4-21.5-14-38.8c0-21,8.4-37.8,25.2-50
c17.3-12.1,37.8-18.2,62.1-18.2c55.6,0,91.5,22.4,91.5,72.4h-43c-0.5-26.6-16.8-36.4-49-36.4c-24.3,0-40.6,11.2-40.6,31.3
c0,18.7,15.9,25.7,49,33.6l5.6,1.4c10.3,2.3,18.2,4.7,23.3,6.1c5.1,1.4,12.1,4.2,21,8.4c23.8,10.3,34.6,25.7,36.9,56
c0,43.4-37.8,71-92.5,71C754.8,397.9,720.7,371.7,720.7,323.2z"/>
<path class="st1" d="M964,336.7V192.4h-39.2v-32.7h39.7V94.3h44.8v65.4h64v32.7h-64v143.4c0,17.3,8.9,25.7,26.2,25.7
c14.5,0,28-1.9,39.7-5.1v34.6c-13.6,4.2-28,6.1-43.4,6.1C986.9,397,964,375.5,964,336.7z"/>
<path class="st1" d="M1095.3,325.5c0-44.8,35-69.6,93.4-69.6h58.4v-20.6c0-28.5-16.8-45.8-50-45.8c-35.5,0-51.8,16.8-51.8,41.6
h-43.4c0-44.8,33.2-77.1,95.3-77.1c61.2,0,95.7,30.4,95.7,83.1v92.5c0,26.1,3.7,32.2,17.3,33.6h6.5v29.4
c-4.7,1.4-11.7,2.3-21.5,2.3c-26.6,0-41.6-15.9-43.4-41.6c-8.9,23.8-36.4,44.4-74.3,44.4C1127.5,397.9,1095.3,368.5,1095.3,325.5z
M1247.1,297.5v-9.3h-58.4c-32.2,0-48.6,12.6-48.6,37.4c0,22.9,14,36.9,41.6,36.9C1222.3,362.4,1247.1,334.4,1247.1,297.5z"/>
<path class="st1" d="M1351.7,395.1V161.6h43.9l0.9,36.4h0.9c11.7-24.8,34.1-40.6,62.1-40.6c8.4,0,15.9,0.9,22.9,3.3v38.8
c-7.5-1.4-15.9-1.9-25.7-1.9c-33.2,0-59.3,28-59.3,72.9v124.7H1351.7z"/>
<g>
<g>
<rect x="1549" y="146.5" transform="matrix(0.866 -0.5 0.5 0.866 137.8024 841.2704)" class="st1" width="179.4" height="34"/>
</g>
<g>
<rect x="1621.7" y="73.8" transform="matrix(0.5 -0.866 0.866 0.5 677.7373 1500.9166)" class="st1" width="34" height="179.4"/>
</g>
<g>
<rect x="1621.7" y="73.8" class="st1" width="34" height="179.4"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

-6
View File
@@ -1,6 +0,0 @@
/pkg
/target
/node_modules
Cargo.lock
# npm auth token — per-project, never commit
.npmrc
-27
View File
@@ -1,27 +0,0 @@
[package]
name = "vtracer-wasm"
description = "WebAssembly core for the vtracer Node.js package."
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/visioncortex/vtracer/"
# Not the core workspace: this is a wasm-bindgen cdylib built with wasm-pack as
# the Node package's native core. The Node layer does file I/O; image decoding
# happens here in wasm, so the package has no native dependency.
[lib]
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.1", path = "../crates/vtracer" }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
# Pure-Rust decoders that compile to wasm32-unknown-unknown (webp via image-webp).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
[profile.release]
opt-level = "s"
lto = true
-53
View File
@@ -1,53 +0,0 @@
# vtracer (Node.js)
Raster → vector (SVG) for Node, a WebAssembly build of the
[`vtracer`](https://github.com/visioncortex/vtracer) framework. Image decoding
and vectorization both happen in wasm, so there is **no native dependency**
just `npm install`.
## Install
```sh
npm install @visioncortex/vtracer
```
## Usage
```js
const vtracer = require('@visioncortex/vtracer');
// file in, file out
await vtracer.convertFile('in.png', 'out.svg');
await vtracer.convertFile('in.jpg', 'out.svg', { mode: 'polygon', hierarchical: 'cutout' });
// buffers
const svg = vtracer.convertBuffer(fs.readFileSync('in.png'), { preset: 'poster' });
// raw RGBA8 pixels
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
```
## API
- `convertBuffer(buffer, options?) => string` — encoded image (PNG/JPEG/GIF/BMP) → SVG.
- `convertPixels(rgba, width, height, options?) => string` — raw RGBA8 → SVG.
- `convertFile(input, output, options?) => Promise<void>` — read, trace, write.
- `convertFileSync(input, output, options?) => void`.
### `Options` (all optional, camelCase)
`preset` (`"bw" | "poster" | "photo"`, applied first), `colorMode`
(`"color" | "bw"`), `hierarchical` (`"stacked" | "cutout"` for the seam-free
mosaic), `mode` (`"pixel" | "polygon" | "spline"`), `filterSpeckle`,
`colorPrecision`, `layerDifference`, `cornerThreshold`, `lengthThreshold`,
`maxIterations`, `spliceThreshold`, `pathPrecision`, `palette` (list of
`#rrggbb`), `maxColors`, `optimize` (`0 | 1 | 2`).
## Build from source
Requires the Rust toolchain and [`wasm-pack`](https://rustwasm.github.io/wasm-pack/):
```sh
npm run build # wasm-pack build --target nodejs --out-dir pkg
npm test
```
-34
View File
@@ -1,34 +0,0 @@
/** Conversion options. Any field may be omitted; omitted fields use the framework default. */
export interface Options {
/** Applied before other fields: "bw" | "poster" | "photo". */
preset?: 'bw' | 'poster' | 'photo';
colorMode?: 'color' | 'bw';
hierarchical?: 'stacked' | 'cutout';
mode?: 'pixel' | 'polygon' | 'spline';
filterSpeckle?: number;
colorPrecision?: number;
layerDifference?: number;
cornerThreshold?: number;
lengthThreshold?: number;
maxIterations?: number;
spliceThreshold?: number;
pathPrecision?: number;
/** Fixed palette: `#rrggbb` strings. */
palette?: string[];
/** Auto-quantize target color count. */
maxColors?: number;
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
optimize?: number;
}
/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */
export function convertBuffer(buffer: Uint8Array, options?: Options): string;
/** Vectorize a raw RGBA8 buffer (`width * height * 4` bytes) to an SVG string. */
export function convertPixels(rgba: Uint8Array, width: number, height: number, options?: Options): string;
/** Read an image file, vectorize it, and write the SVG to disk. */
export function convertFile(inputPath: string, outputPath: string, options?: Options): Promise<void>;
/** Synchronous {@link convertFile}. */
export function convertFileSync(inputPath: string, outputPath: string, options?: Options): void;
-49
View File
@@ -1,49 +0,0 @@
'use strict';
// Node package: image decoding + vectorization happen in wasm (no native
// dependency); this layer only adds file I/O and a camelCase API.
const fs = require('fs');
const fsp = require('fs/promises');
const wasm = require('./pkg/vtracer_wasm.js');
/**
* Vectorize an encoded image (PNG/JPEG/GIF/BMP) Buffer/Uint8Array to an SVG string.
* @param {Uint8Array} buffer
* @param {object} [options]
* @returns {string}
*/
function convertBuffer(buffer, options = {}) {
return wasm.vectorize_bytes(buffer, options);
}
/**
* Vectorize a raw RGBA8 buffer (width*height*4 bytes) to an SVG string.
* @param {Uint8Array} rgba
* @param {number} width
* @param {number} height
* @param {object} [options]
* @returns {string}
*/
function convertPixels(rgba, width, height, options = {}) {
return wasm.vectorize_rgba(rgba, width, height, options);
}
/**
* Read an image file, vectorize it, and write the SVG to disk.
* @returns {Promise<void>}
*/
async function convertFile(inputPath, outputPath, options = {}) {
const data = await fsp.readFile(inputPath);
const svg = wasm.vectorize_bytes(data, options);
await fsp.writeFile(outputPath, svg);
}
/** Synchronous {@link convertFile}. */
function convertFileSync(inputPath, outputPath, options = {}) {
const data = fs.readFileSync(inputPath);
const svg = wasm.vectorize_bytes(data, options);
fs.writeFileSync(outputPath, svg);
}
module.exports = { convertBuffer, convertPixels, convertFile, convertFileSync };
-35
View File
@@ -1,35 +0,0 @@
{
"name": "@visioncortex/vtracer",
"version": "1.0.0-alpha.1",
"description": "Raster to vector graphics converter (SVG). WebAssembly build of the vtracer framework — no native dependencies.",
"main": "index.js",
"types": "index.d.ts",
"publishConfig": {
"access": "public"
},
"files": [
"index.js",
"index.d.ts",
"pkg/vtracer_wasm.js",
"pkg/vtracer_wasm_bg.wasm",
"pkg/vtracer_wasm.d.ts",
"pkg/vtracer_wasm_bg.wasm.d.ts"
],
"scripts": {
"build": "wasm-pack build --target nodejs --out-dir pkg",
"test": "node test.js",
"publish:local": "node scripts/publish.mjs",
"prepublishOnly": "npm run build"
},
"keywords": ["svg", "vectorization", "raster", "wasm", "computer-graphics"],
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/visioncortex/vtracer.git",
"directory": "nodejs"
},
"homepage": "http://www.visioncortex.org/vtracer",
"engines": {
"node": ">=16"
}
}
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env node
// Build the wasm package and publish it, by default to a local npm registry
// (e.g. a Verdaccio instance at http://localhost:4873).
//
// node scripts/publish.mjs # publish to the local registry
// node scripts/publish.mjs --dry-run # build + pack, don't publish
// node scripts/publish.mjs --registry=http://... # override the registry
// NPM_REGISTRY=http://... node scripts/publish.mjs
//
// The registry may also be given via the NPM_REGISTRY env var.
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const regArg = args.find((a) => a.startsWith('--registry='));
const registry =
(regArg && regArg.slice('--registry='.length)) ||
process.env.NPM_REGISTRY ||
'http://localhost:4873';
function run(cmd, cmdArgs) {
console.log(`\n$ ${cmd} ${cmdArgs.join(' ')}`);
execFileSync(cmd, cmdArgs, { stdio: 'inherit', cwd: pkgDir });
}
// 1. Fresh wasm build (regenerates pkg/).
run('wasm-pack', ['build', '--target', 'nodejs', '--out-dir', 'pkg']);
// 2. Sanity check before publishing.
run('node', ['test.js']);
// 3. Publish (or dry-run) to the chosen registry.
const publishArgs = ['publish', '--registry', registry];
if (dryRun) publishArgs.push('--dry-run');
run('npm', publishArgs);
console.log(`\n${dryRun ? 'dry-run for' : 'published to'} ${registry}`);
-160
View File
@@ -1,160 +0,0 @@
//! WebAssembly core for the vtracer Node package.
//!
//! Exposes vectorization over encoded image bytes or a raw RGBA buffer. Image
//! decoding happens here (in wasm), so the JS layer only needs `fs` — no
//! native dependency. Options are a plain JS object matching [`Options`].
use std::io::Cursor;
use serde::Deserialize;
use vtracer::{Color, ColorImage, Config};
use wasm_bindgen::prelude::*;
/// Conversion options; a subset may be provided from JS (camelCase). Anything
/// omitted uses the framework default.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Options {
color_mode: Option<String>,
hierarchical: Option<String>,
mode: Option<String>,
filter_speckle: Option<usize>,
color_precision: Option<i32>,
layer_difference: Option<i32>,
corner_threshold: Option<i32>,
length_threshold: Option<f64>,
max_iterations: Option<usize>,
splice_threshold: Option<i32>,
path_precision: Option<u32>,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: Option<u8>,
/// One of "bw" | "poster" | "photo"; applied before the other fields.
preset: Option<String>,
}
fn err(msg: impl std::fmt::Display) -> JsValue {
JsValue::from_str(&msg.to_string())
}
fn parse_hex(token: &str) -> Result<Color, JsValue> {
let hex = token.strip_prefix('#').unwrap_or(token);
if hex.len() != 6 {
return Err(err(format!("`{token}` is not a #rrggbb color")));
}
let b = |r: std::ops::Range<usize>| {
u8::from_str_radix(&hex[r], 16).map_err(|_| err(format!("`{token}` is not a #rrggbb color")))
};
Ok(Color::new(b(0..2)?, b(2..4)?, b(4..6)?))
}
fn config_from(options: JsValue) -> Result<Config, JsValue> {
let opts: Options = if options.is_undefined() || options.is_null() {
Options::default()
} else {
serde_wasm_bindgen::from_value(options).map_err(err)?
};
let mut config = match opts.preset.as_deref() {
Some("bw") => Config::from_preset(vtracer::Preset::Bw),
Some("poster") => Config::from_preset(vtracer::Preset::Poster),
Some("photo") => Config::from_preset(vtracer::Preset::Photo),
Some(other) => return Err(err(format!("unknown preset `{other}`"))),
None => Config::default(),
};
if let Some(v) = opts.color_mode {
config.color_mode = v.parse().map_err(err)?;
}
if let Some(v) = opts.hierarchical {
config.hierarchical = v.parse().map_err(err)?;
}
if let Some(v) = opts.mode {
config.mode = v.parse().map_err(err)?;
}
if let Some(v) = opts.filter_speckle {
config.filter_speckle = v;
}
if let Some(v) = opts.color_precision {
config.color_precision = v;
}
if let Some(v) = opts.layer_difference {
config.layer_difference = v;
}
if let Some(v) = opts.corner_threshold {
config.corner_threshold = v;
}
if let Some(v) = opts.length_threshold {
config.length_threshold = v;
}
if let Some(v) = opts.max_iterations {
config.max_iterations = v;
}
if let Some(v) = opts.splice_threshold {
config.splice_threshold = v;
}
if let Some(v) = opts.path_precision {
config.path_precision = Some(v);
}
if let Some(list) = opts.palette {
config.palette = list.iter().map(|s| parse_hex(s)).collect::<Result<_, _>>()?;
}
if let Some(v) = opts.max_colors {
config.max_colors = Some(v);
}
if let Some(v) = opts.optimize {
config.optimize = v;
}
Ok(config)
}
fn to_svg(config: Config, img: ColorImage) -> Result<String, JsValue> {
config.build().map_err(err)?.to_svg(&img).map_err(err)
}
/// Vectorize encoded image bytes (PNG/JPEG/GIF/BMP). Returns the SVG string.
#[wasm_bindgen]
pub fn vectorize_bytes(data: &[u8], options: JsValue) -> Result<String, JsValue> {
let config = config_from(options)?;
let img = image::ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(err)?
.decode()
.map_err(|e| err(format!("failed to decode image: {e}")))?
.to_rgba8();
let (width, height) = (img.width() as usize, img.height() as usize);
to_svg(
config,
ColorImage {
pixels: img.into_raw(),
width,
height,
},
)
}
/// Vectorize a raw RGBA8 buffer (`width * height * 4` bytes). Returns the SVG.
#[wasm_bindgen]
pub fn vectorize_rgba(
data: Vec<u8>,
width: usize,
height: usize,
options: JsValue,
) -> Result<String, JsValue> {
if data.len() != width * height * 4 {
return Err(err(format!(
"rgba length {} != width*height*4 ({})",
data.len(),
width * height * 4
)));
}
let config = config_from(options)?;
to_svg(
config,
ColorImage {
pixels: data,
width,
height,
},
)
}
-52
View File
@@ -1,52 +0,0 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vtracer = require('./index.js');
const SAMPLE = path.join(__dirname, '..', 'docs', 'assets', 'samples', 'tank-unit-preview.png');
const data = fs.readFileSync(SAMPLE);
// encoded bytes, default options
let svg = vtracer.convertBuffer(data);
assert(svg.includes('<svg') && svg.includes('<path'), 'default convertBuffer');
console.log('convertBuffer default:', (svg.match(/<path/g) || []).length, 'paths');
// options: bw preset -> all black
svg = vtracer.convertBuffer(data, { colorMode: 'bw' });
assert(svg.includes('fill="#000000"'), 'bw produces black');
console.log('convertBuffer bw:', (svg.match(/<path/g) || []).length, 'paths');
// options: mosaic + polygon + palette
svg = vtracer.convertBuffer(data, { hierarchical: 'cutout', mode: 'polygon', palette: ['#000000', '#ffffff'], optimize: 2 });
assert(svg.includes('<svg'), 'mosaic+palette');
console.log('convertBuffer cutout/polygon/palette:', (svg.match(/<path/g) || []).length, 'paths');
// preset
svg = vtracer.convertBuffer(data, { preset: 'poster' });
console.log('convertBuffer poster:', (svg.match(/<path/g) || []).length, 'paths');
// raw pixels: 20x20, left red / right blue
const w = 20, h = 20;
const rgba = Buffer.alloc(w * h * 4);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const [r, g, b] = x < w / 2 ? [220, 40, 40] : [40, 40, 220];
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = 255;
}
svg = vtracer.convertPixels(rgba, w, h);
assert(svg.includes('<svg'), 'convertPixels');
console.log('convertPixels:', (svg.match(/<path/g) || []).length, 'paths');
// file I/O
const out = path.join(require('os').tmpdir(), 'vtracer_node_out.svg');
vtracer.convertFileSync(SAMPLE, out, { mode: 'spline' });
assert(fs.statSync(out).size > 0, 'convertFileSync wrote file');
console.log('convertFileSync wrote:', fs.statSync(out).size, 'bytes');
// error handling
assert.throws(() => vtracer.convertBuffer(data, { palette: ['nope'] }), /rrggbb/, 'bad palette rejected');
assert.throws(() => vtracer.convertPixels(Buffer.alloc(8), 10, 10), /rgba length/, 'bad pixel length rejected');
console.log('errors rejected OK');
console.log('ALL OK');
+1 -1
View File
@@ -22,7 +22,7 @@ console_log = { version = "0.2", features = ["color"] }
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
visioncortex = "0.8.1"
visioncortex = "0.6.0"
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
+2 -6
View File
@@ -35,11 +35,7 @@ document.addEventListener('paste', function (e) {
// Download as SVG
document.getElementById('export').addEventListener('click', function (e) {
const blob = new Blob([
`<?xml version="1.0" encoding="UTF-8"?>\n`,
`<!-- Generator: visioncortex VTracer -->\n`,
new XMLSerializer().serializeToString(svg)
], {type: 'octet/stream'}),
const blob = new Blob([new XMLSerializer().serializeToString(svg)], {type: 'octet/stream'}),
url = window.URL.createObjectURL(blob);
this.href = url;
@@ -448,7 +444,7 @@ class ConverterRunner {
this.converter.init();
this.stopped = false;
if (clustering_mode == 'binary') {
svg.style.background = '#fff';
svg.style.background = '#000';
canvas.style.display = 'none';
} else {
svg.style.background = '';
+1 -1
View File
@@ -77,7 +77,7 @@ impl BinaryImageConverter {
self.params.max_iterations,
self.params.splice_threshold
);
let color = Color::color(&ColorName::Black);
let color = Color::color(&ColorName::White);
self.svg.prepend_path(
&paths,
&color,
+3 -84
View File
@@ -1,6 +1,6 @@
use wasm_bindgen::prelude::*;
use visioncortex::{Color, ColorImage, PathSimplifyMode};
use visioncortex::color_clusters::{Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX, IncrementalBuilder, KeyingAction};
use visioncortex::PathSimplifyMode;
use visioncortex::color_clusters::{IncrementalBuilder, Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX};
use crate::canvas::*;
use crate::svg::*;
@@ -8,8 +8,6 @@ use crate::svg::*;
use serde::Deserialize;
use super::util;
const KEYING_THRESHOLD: f32 = 0.2;
#[derive(Debug, Deserialize)]
pub struct ColorImageConverterParams {
pub canvas_id: String,
@@ -69,26 +67,7 @@ impl ColorImageConverter {
pub fn init(&mut self) {
let width = self.canvas.width() as u32;
let height = self.canvas.height() as u32;
let mut image = self.canvas.get_image_data_as_color_image(0, 0, width, height);
let key_color = if Self::should_key_image(&image) {
if let Ok(key_color) = Self::find_unused_color_in_image(&image) {
for y in 0..height as usize {
for x in 0..width as usize {
if image.get_pixel(x, y).a == 0 {
image.set_pixel(x, y, &key_color);
}
}
}
key_color
} else {
Color::default()
}
} else {
// The default color is all zeroes, which is treated by visioncortex as a special value meaning no keying will be applied.
Color::default()
};
let image = self.canvas.get_image_data_as_color_image(0, 0, width, height);
let runner = Runner::new(RunnerConfig {
diagonal: self.params.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
@@ -99,12 +78,6 @@ impl ColorImageConverter {
is_same_color_b: 1,
deepen_diff: self.params.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: if self.params.hierarchical == "cutout" {
KeyingAction::Keep
} else {
KeyingAction::Discard
},
}, image);
self.stage = Stage::Clustering(runner.start());
}
@@ -135,8 +108,6 @@ impl ColorImageConverter {
is_same_color_b: 1,
deepen_diff: 0,
hollow_neighbours: 0,
key_color: Default::default(),
keying_action: KeyingAction::Discard,
}, image);
self.stage = Stage::Reclustering(runner.start());
},
@@ -196,56 +167,4 @@ impl ColorImageConverter {
}) as i32
}
fn color_exists_in_image(img: &ColorImage, color: Color) -> bool {
for y in 0..img.height {
for x in 0..img.width {
let pixel_color = img.get_pixel(x, y);
if pixel_color.r == color.r && pixel_color.g == color.g && pixel_color.b == color.b {
return true
}
}
}
false
}
fn find_unused_color_in_image(img: &ColorImage) -> Result<Color, String> {
let special_colors = IntoIterator::into_iter([
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),
Color::new(128, 128, 128),
]);
for color in special_colors {
if !Self::color_exists_in_image(img, color) {
return Ok(color);
}
}
Err(String::from("unable to find unused color in image to use as key"))
}
fn should_key_image(img: &ColorImage) -> bool {
if img.width == 0 || img.height == 0 {
return false;
}
// Check for transparency at several scanlines
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
let mut num_transparent_pixels = 0;
let y_positions = [0, img.height / 4, img.height / 2, 3 * img.height / 4, img.height - 1];
for y in y_positions {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
num_transparent_pixels += 1;
}
if num_transparent_pixels >= threshold {
return true;
}
}
}
false
}
}
+3
View File
@@ -1,3 +1,6 @@
mod binary_image;
mod color_image;
mod util;
pub use binary_image::*;
pub use color_image::*;