Compare commits
43 Commits
0.6.4
...
1.0.0-alpha.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e9b147bbd | |||
| ec715f8c3c | |||
| 61ca5e5946 | |||
| 87b9660ed3 | |||
| 5743912da6 | |||
| 601447af6c | |||
| 8af376cb7c | |||
| 42f94f0d15 | |||
| 170b0322a4 | |||
| 749f0df0bd | |||
| f76aed78b2 | |||
| 57d768e37e | |||
| 837fde8aa4 | |||
| 35b4b6f846 | |||
| 3300f97e37 | |||
| 5ac90bb97c | |||
| 17a9a6e6c5 | |||
| 572d9e5f82 | |||
| 660dc4ff93 | |||
| e46c971845 | |||
| 913d0ac7e0 | |||
| fd9cdb08e6 | |||
| 3b2991be84 | |||
| f24ea56a52 | |||
| 592bee6c5c | |||
| 3ebc358b48 | |||
| 3a24c2f755 | |||
| 8acb6bd911 | |||
| efa4351b2c | |||
| a46292b5ed | |||
| 8889cbc7ea | |||
| 6b379a02ef | |||
| 2635d5b874 | |||
| f6cf3e8705 | |||
| 36b16de17a | |||
| 7887c1ebf8 | |||
| 6fdfec8610 | |||
| 1aff9a300a | |||
| ac0a89e08a | |||
| b09f71a2b5 | |||
| e4897dfe99 | |||
| 4544ca740d | |||
| 3d92586e33 |
@@ -1,15 +1,14 @@
|
||||
# This file is autogenerated by maturin v1.2.3
|
||||
# To update, run
|
||||
#
|
||||
# maturin generate-ci github
|
||||
#
|
||||
# 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:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -17,86 +16,142 @@ permissions:
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
|
||||
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@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: -m cmdapp/Cargo.toml --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
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@v3
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels
|
||||
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: windows-latest
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x64, x86]
|
||||
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@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
architecture: ${{ matrix.target }}
|
||||
python-version: 3.13
|
||||
architecture: ${{ matrix.platform.python_arch }}
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: -m cmdapp/Cargo.toml --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
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@v3
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels
|
||||
name: wheels-windows-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x86_64, aarch64]
|
||||
platform:
|
||||
- runner: macos-15-intel
|
||||
target: x86_64
|
||||
- runner: macos-latest
|
||||
target: aarch64
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: -m cmdapp/Cargo.toml --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
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@v3
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels
|
||||
name: wheels-macos-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
sdist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: -m cmdapp/Cargo.toml --out dist
|
||||
args: --out dist --manifest-path crates/vtracer-py/Cargo.toml
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels
|
||||
name: wheels-sdist
|
||||
path: dist
|
||||
|
||||
release:
|
||||
@@ -104,18 +159,24 @@ jobs:
|
||||
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:
|
||||
# IMPORTANT: this permission is mandatory for trusted publishing
|
||||
# Use to sign the release artifacts
|
||||
id-token: write
|
||||
if: "startsWith(github.ref, 'refs/tags/')"
|
||||
needs: [windows, macos, sdist, linux]
|
||||
# Used to upload release artifacts
|
||||
contents: write
|
||||
# Used to generate artifact attestation
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v6
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
name: wheels
|
||||
subject-path: 'wheels-*/*'
|
||||
- name: Install uv
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Publish to PyPI
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: upload
|
||||
args: --non-interactive --skip-existing *
|
||||
# manylinux: auto
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
run: uv publish 'wheels-*/*'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
name: Release
|
||||
|
||||
# Builds the `vtracer` CLI binary (crates/vtracer-cli) for each target.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -1,22 +1,57 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
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
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --workspace --verbose
|
||||
- name: Test
|
||||
run: cargo test --workspace --verbose
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
target
|
||||
Cargo.lock
|
||||
*.sublime*
|
||||
.vscode
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
@@ -5,6 +5,39 @@ 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
|
||||
|
||||
* 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`
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
[workspace]
|
||||
|
||||
members = [
|
||||
"cmdapp",
|
||||
"webapp",
|
||||
"crates/vtracer",
|
||||
"crates/vtracer-cli",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
# 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 = [
|
||||
"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" }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2023 Tsang Hao Fung
|
||||
Copyright (c) 2024 TSANG, Hao Fung
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
|
||||
@@ -4,18 +4,17 @@
|
||||
<h1>VTracer</h1>
|
||||
|
||||
<p>
|
||||
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
|
||||
<strong>Raster to Vector Graphics Converter</strong>
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
|
||||
<span> | </span>
|
||||
<a href="https://www.visioncortex.org/vtracer/">Demo</a>
|
||||
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/tag/0.6.0">Download</a>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases">Download</a>
|
||||
</h3>
|
||||
|
||||
<sub>Built with 🦀 by <a href="https://www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
|
||||
</div>
|
||||
|
||||
## Introduction
|
||||
@@ -28,11 +27,9 @@ Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrat
|
||||
|
||||
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](https://www.visioncortex.org/vtracer-docs).
|
||||
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
|
||||
|
||||
## 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.
|
||||
## Desktop App (coming soon)
|
||||
|
||||

|
||||
|
||||
@@ -40,54 +37,84 @@ VTracer and its [core library](//github.com/visioncortex/visioncortex) is implem
|
||||
|
||||
## Cmd App
|
||||
|
||||
Input and output can be given as positional arguments or as named flags:
|
||||
|
||||
```sh
|
||||
visioncortex VTracer 0.6.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
|
||||
vtracer input.jpg output.svg
|
||||
# equivalent to:
|
||||
vtracer --input input.jpg --output output.svg
|
||||
```
|
||||
|
||||
### Install
|
||||
Full options (flag names are kebab-case, e.g. `--filter-speckle`):
|
||||
|
||||
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases/tag/0.6.0).
|
||||
```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
|
||||
cargo install vtracer-cli
|
||||
```
|
||||
|
||||
> You are strongly advised to not download from any other third-party sources
|
||||
|
||||
### Usage
|
||||
|
||||
```sh
|
||||
./vtracer --input input.jpg --output output.svg
|
||||
# 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'
|
||||
```
|
||||
|
||||
## Rust Library
|
||||
### Rust Library
|
||||
|
||||
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
|
||||
|
||||
@@ -95,53 +122,52 @@ You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
|
||||
cargo add vtracer
|
||||
```
|
||||
|
||||
## Python Library
|
||||
### Python Library
|
||||
|
||||
Since `0.6`, [`vtracer`](https://pypi.org/project/vtracer/) is also packaged as Python native extensions, thanks to the awesome [pyo3](https://github.com/PyO3/pyo3) project.
|
||||
[`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).
|
||||
|
||||
```sh
|
||||
pip install vtracer
|
||||
```
|
||||
|
||||
## In the wild
|
||||
```python
|
||||
import vtracer
|
||||
|
||||
VTracer is used by the following projects (feel free to add yours!):
|
||||
# one-liners
|
||||
vtracer.convert_file("in.png", "out.svg")
|
||||
svg = vtracer.convert_bytes(open("in.png", "rb").read())
|
||||
|
||||
<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>
|
||||
# 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")
|
||||
```
|
||||
|
||||
## Anecdotes
|
||||
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
|
||||
|
||||
> The following content is an excerpt from my [unpublished memoir](https://github.com/visioncortex/memoir).
|
||||
### Node.js Library
|
||||
|
||||
### How / when / where did VTracer come about?
|
||||
[`@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**.
|
||||
|
||||
At my teenage, two open source projects in the vector graphics space inspired me the most: Potrace and Anti-Grain Geometry (AGG).
|
||||
```sh
|
||||
npm install @visioncortex/vtracer
|
||||
```
|
||||
|
||||
Many years later, in 2020, I was developing a video processing engine. And it became evident that it requires way more investment to be commercially viable. So before abandoning the project, I wanted to publish *something* as open-source for posterity. At that time, I already developed a prototype vector graphics tracer. It can convert high-resolution scans of hand-drawn blueprints into vectors. But it can only process black and white images, and can only output polygons, not splines.
|
||||
```js
|
||||
const vtracer = require('@visioncortex/vtracer');
|
||||
|
||||
The plan was to fully develop the vectorizer: to handle color images and output splines. I recruited a very talented intern, [@shpun817](https://github.com/shpun817), to work on VTracer. I grafted the frontend of the video processing engine - the ["The Clustering Algorithm"](https://www.visioncortex.org/impression-docs#the-clustering-algorithm) as the pre-processor.
|
||||
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' });
|
||||
```
|
||||
|
||||
Three months later, we published the first version on Reddit. Out of my surprise, the response of such an underwhelming project was overwhelming.
|
||||
## Citations
|
||||
|
||||
## What's next?
|
||||
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:
|
||||
|
||||
There are several things in my mind:
|
||||
|
||||
1. Path simplification. Implement a post-process filter to the output paths to further reduce the number of splines.
|
||||
|
||||
2. Perfect cut-out mode. Right now in cut-out mode, the shapes do not share boundaries perfectly, but have seams.
|
||||
|
||||
3. Pencil tracing. Instead of tracing shapes as closed paths, may be we can attempt to skeletonize the shapes as open paths. The output would be clean, fixed width strokes.
|
||||
|
||||
4. Image cleaning. Right now the tracer works best on losslessly compressed pngs. If an image suffered from jpeg noises, it could impact the tracing quality. We might be able to develop a pre-filtering pass that denoises the input.
|
||||
|
||||
If you are interested in working on them or willing to sponsor its development, feel free to get in touch.
|
||||
+ 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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
*.svg
|
||||
*.png
|
||||
*.jpg
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "vtracer"
|
||||
version = "0.6.4"
|
||||
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.8" }
|
||||
fastrand = "1.8"
|
||||
pyo3 = { version = "0.19.0", optional = true }
|
||||
|
||||
[features]
|
||||
python-binding = ["pyo3"]
|
||||
|
||||
[lib]
|
||||
name = "vtracer"
|
||||
crate-type = ["rlib", "cdylib"]
|
||||
@@ -1,201 +0,0 @@
|
||||
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.
|
||||
@@ -1,25 +0,0 @@
|
||||
Copyright (c) 2023 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.
|
||||
@@ -1,96 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/visioncortex/vtracer/master/docs/images/visioncortex-banner.png">
|
||||
<h1>VTracer</h1>
|
||||
|
||||
<p>
|
||||
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
|
||||
<span> | </span>
|
||||
<a href="https://www.visioncortex.org/vtracer/">Demo</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/latest">Download</a>
|
||||
</h3>
|
||||
|
||||
<sub>Built with 🦀 by <a href="https://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](https://www.visioncortex.org/vtracer-docs).
|
||||
|
||||
## Cmd App
|
||||
|
||||
```sh
|
||||
visioncortex VTracer 0.6.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
|
||||
```
|
||||
|
||||
### Install
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```sh
|
||||
./vtracer --input input.jpg --output output.svg
|
||||
```
|
||||
|
||||
## Rust Library
|
||||
|
||||
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
|
||||
|
||||
```sh
|
||||
cargo add vtracer
|
||||
```
|
||||
|
||||
## Python Library
|
||||
|
||||
Since `0.6`, [`vtracer`](https://pypi.org/project/vtracer/) is also packaged as Python native extensions, thanks to the awesome [pyo3](https://github.com/PyO3/pyo3) project.
|
||||
|
||||
```sh
|
||||
pip install vtracer
|
||||
```
|
||||
@@ -1,28 +0,0 @@
|
||||
[project]
|
||||
name = "vtracer"
|
||||
version = "0.6.10"
|
||||
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", "python-binding"]
|
||||
compatibility = "manylinux2014"
|
||||
sdist-include = ["LICENSE-MIT", "vtracer/README.md"]
|
||||
@@ -1,168 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
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 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 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 {
|
||||
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(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_preset(preset: Preset) -> Self {
|
||||
match preset {
|
||||
Preset::Bw => Self {
|
||||
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(2),
|
||||
},
|
||||
Preset::Poster => Self {
|
||||
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(2),
|
||||
},
|
||||
Preset::Photo => Self {
|
||||
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(2),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_converter_config(self) -> ConverterConfig {
|
||||
ConverterConfig {
|
||||
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
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::{fs::File, io::Write};
|
||||
|
||||
use super::config::{ColorMode, Config, ConverterConfig, Hierarchical};
|
||||
use super::svg::SvgFile;
|
||||
use fastrand::Rng;
|
||||
use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX};
|
||||
use visioncortex::{Color, ColorImage, ColorName};
|
||||
|
||||
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 in-memory image into an in-memory SVG
|
||||
pub fn convert(img: ColorImage, config: Config) -> Result<SvgFile, String> {
|
||||
let config = config.into_converter_config();
|
||||
match config.color_mode {
|
||||
ColorMode::Color => color_image_to_svg(img, config),
|
||||
ColorMode::Binary => binary_image_to_svg(img, config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an image file into svg file
|
||||
pub fn convert_image_to_svg(
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
config: Config,
|
||||
) -> Result<(), String> {
|
||||
let img = read_image(input_path)?;
|
||||
let svg = convert(img, config)?;
|
||||
write_svg(svg, output_path)
|
||||
}
|
||||
|
||||
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(mut img: ColorImage, config: ConverterConfig) -> Result<SvgFile, String> {
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn binary_image_to_svg(img: ColorImage, config: ConverterConfig) -> Result<SvgFile, String> {
|
||||
let img = img.to_binary_image(|x| x.r < 128);
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn read_image(input_path: &Path) -> Result<ColorImage, 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)
|
||||
}
|
||||
|
||||
fn write_svg(svg: SvgFile, output_path: &Path) -> 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(())
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright 2023 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;
|
||||
#[cfg(feature = "python-binding")]
|
||||
mod python;
|
||||
mod svg;
|
||||
|
||||
pub use config::*;
|
||||
pub use converter::*;
|
||||
#[cfg(feature = "python-binding")]
|
||||
pub use python::*;
|
||||
pub use svg::*;
|
||||
pub use visioncortex::ColorImage;
|
||||
@@ -1,282 +0,0 @@
|
||||
mod config;
|
||||
mod converter;
|
||||
mod svg;
|
||||
|
||||
use clap::{App, Arg};
|
||||
use config::{ColorMode, Config, Hierarchical, Preset};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use visioncortex::PathSimplifyMode;
|
||||
|
||||
fn path_simplify_mode_from_str(s: &str) -> PathSimplifyMode {
|
||||
match s {
|
||||
"polygon" => PathSimplifyMode::Polygon,
|
||||
"spline" => PathSimplifyMode::Spline,
|
||||
"none" => PathSimplifyMode::None,
|
||||
_ => panic!("unknown PathSimplifyMode {}", s),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_from_args() -> (PathBuf, PathBuf, Config) {
|
||||
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.");
|
||||
|
||||
let input_path = PathBuf::from(input_path);
|
||||
let output_path = PathBuf::from(output_path);
|
||||
|
||||
if let Some(value) = matches.value_of("preset") {
|
||||
config = Config::from_preset(Preset::from_str(value).unwrap());
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(input_path, output_path, config)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let (input_path, output_path, config) = config_from_args();
|
||||
let result = converter::convert_image_to_svg(&input_path, &output_path, config);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("Conversion successful.");
|
||||
}
|
||||
Err(msg) => {
|
||||
panic!("Conversion failed with error message: {}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use crate::*;
|
||||
use pyo3::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
use visioncortex::PathSimplifyMode;
|
||||
|
||||
/// 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 {
|
||||
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(&input_path, &output_path, 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(())
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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#"<!-- Generator: visioncortex VTracer {} -->"#,
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)?;
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<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 +0,0 @@
|
||||
from .vtracer import convert_image_to_svg_py
|
||||
@@ -1,17 +0,0 @@
|
||||
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:
|
||||
...
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "vtracer-cli"
|
||||
description = "Command-line front-end for the vtracer vectorization framework."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
categories = ["graphics", "command-line-utilities"]
|
||||
keywords = ["svg", "vectorization", "computer-graphics"]
|
||||
|
||||
[[bin]]
|
||||
name = "vtracer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
vtracer = { version = "1.0.0-alpha.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"] }
|
||||
@@ -0,0 +1,231 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[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"] }
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
[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"]
|
||||
@@ -0,0 +1,446 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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: ...
|
||||
@@ -0,0 +1,23 @@
|
||||
[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"
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Color fitters: rewrite layer paints before compositing.
|
||||
//!
|
||||
//! * [`Identity`] — keep the frontend's mean colors (0.6.x behavior).
|
||||
//! * [`FixedPalette`] — snap each paint to the nearest entry of a fixed
|
||||
//! palette, measured in OKLab.
|
||||
//! * [`AutoQuantize`] — reduce the palette to at most `max_colors` via
|
||||
//! area-weighted median cut.
|
||||
//! * [`MergeAdjacent`] — union consecutive layers that share a paint, cutting
|
||||
//! shape count for free.
|
||||
|
||||
mod merge;
|
||||
mod oklab;
|
||||
mod palette;
|
||||
mod quantize;
|
||||
|
||||
pub use merge::MergeAdjacent;
|
||||
pub use palette::FixedPalette;
|
||||
pub use quantize::AutoQuantize;
|
||||
|
||||
use crate::ir::Segmentation;
|
||||
|
||||
/// A color fitter rewrites the paints of a segmentation in place.
|
||||
pub trait ColorFitter {
|
||||
fn fit(&self, seg: &mut Segmentation);
|
||||
}
|
||||
|
||||
/// No-op fitter: paints keep the frontend's mean cluster colors.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Identity;
|
||||
|
||||
impl ColorFitter for Identity {
|
||||
fn fit(&self, _seg: &mut Segmentation) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ir::{Layer, Paint, RegionMask};
|
||||
use visioncortex::{BinaryImage, Color, PointI32};
|
||||
|
||||
fn layer(color: Color) -> Layer {
|
||||
let mut image = BinaryImage::new_w_h(1, 1);
|
||||
image.set_pixel(0, 0, true);
|
||||
Layer {
|
||||
paint: Paint::Solid(color),
|
||||
mask: RegionMask::new(image, PointI32 { x: 0, y: 0 }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_palette_snaps_to_nearest_oklab() {
|
||||
let mut seg = Segmentation::new(1, 1);
|
||||
seg.layers.push(layer(Color::new(250, 10, 10))); // near red
|
||||
seg.layers.push(layer(Color::new(10, 10, 250))); // near blue
|
||||
|
||||
let palette = FixedPalette::new(vec![Color::new(255, 0, 0), Color::new(0, 0, 255)]);
|
||||
palette.fit(&mut seg);
|
||||
|
||||
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(255, 0, 0)));
|
||||
assert_eq!(seg.layers[1].paint, Paint::Solid(Color::new(0, 0, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_adjacent_unions_same_paint_runs() {
|
||||
let mut seg = Segmentation::new(2, 1);
|
||||
seg.layers.push(layer(Color::new(0, 0, 0)));
|
||||
seg.layers.push(layer(Color::new(0, 0, 0)));
|
||||
seg.layers.push(layer(Color::new(255, 255, 255)));
|
||||
|
||||
MergeAdjacent.fit(&mut seg);
|
||||
|
||||
assert_eq!(seg.layers.len(), 2);
|
||||
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(0, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Minimal sRGB → OKLab conversion for perceptual color distance.
|
||||
//!
|
||||
//! OKLab (Björn Ottosson, 2020) gives a Euclidean space where distance
|
||||
//! approximates perceived color difference far better than raw RGB.
|
||||
|
||||
use visioncortex::Color;
|
||||
|
||||
/// A color in the OKLab space.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Oklab {
|
||||
pub l: f64,
|
||||
pub a: f64,
|
||||
pub b: f64,
|
||||
}
|
||||
|
||||
fn srgb_to_linear(c: u8) -> f64 {
|
||||
let c = c as f64 / 255.0;
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
impl Oklab {
|
||||
pub fn from_color(color: &Color) -> Self {
|
||||
let r = srgb_to_linear(color.r);
|
||||
let g = srgb_to_linear(color.g);
|
||||
let b = srgb_to_linear(color.b);
|
||||
|
||||
let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b;
|
||||
let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b;
|
||||
let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b;
|
||||
|
||||
let l_ = l.cbrt();
|
||||
let m_ = m.cbrt();
|
||||
let s_ = s.cbrt();
|
||||
|
||||
Oklab {
|
||||
l: 0.210_454_255_3 * l_ + 0.793_617_785_0 * m_ - 0.004_072_046_8 * s_,
|
||||
a: 1.977_998_495_1 * l_ - 2.428_592_205_0 * m_ + 0.450_593_709_9 * s_,
|
||||
b: 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766_0 * s_,
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared Euclidean distance (monotonic with distance; avoids the sqrt).
|
||||
pub fn distance_squared(&self, other: &Oklab) -> f64 {
|
||||
let dl = self.l - other.l;
|
||||
let da = self.a - other.a;
|
||||
let db = self.b - other.b;
|
||||
dl * dl + da * da + db * db
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use visioncortex::Color;
|
||||
|
||||
use crate::ir::{Paint, Segmentation};
|
||||
|
||||
use super::oklab::Oklab;
|
||||
use super::ColorFitter;
|
||||
|
||||
/// Snap every layer paint to the nearest color in a fixed palette, measured in
|
||||
/// OKLab. An empty palette leaves paints untouched.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FixedPalette {
|
||||
pub colors: Vec<Color>,
|
||||
}
|
||||
|
||||
impl FixedPalette {
|
||||
pub fn new(colors: Vec<Color>) -> Self {
|
||||
Self { colors }
|
||||
}
|
||||
|
||||
/// The palette entry closest to `color` in OKLab.
|
||||
fn nearest(&self, color: &Color, lab: &[Oklab]) -> Color {
|
||||
let target = Oklab::from_color(color);
|
||||
let mut best = self.colors[0];
|
||||
let mut best_dist = f64::INFINITY;
|
||||
for (i, entry) in self.colors.iter().enumerate() {
|
||||
let dist = target.distance_squared(&lab[i]);
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best = *entry;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorFitter for FixedPalette {
|
||||
fn fit(&self, seg: &mut Segmentation) {
|
||||
if self.colors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lab: Vec<Oklab> = self.colors.iter().map(Oklab::from_color).collect();
|
||||
for layer in &mut seg.layers {
|
||||
let snapped = self.nearest(&layer.paint.color(), &lab);
|
||||
layer.paint = Paint::Solid(snapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use visioncortex::Color;
|
||||
|
||||
use crate::ir::{Paint, Segmentation};
|
||||
|
||||
use super::oklab::Oklab;
|
||||
use super::ColorFitter;
|
||||
|
||||
/// Reduce the layer palette to at most `max_colors` representative colors via
|
||||
/// area-weighted median cut, then snap each layer to the nearest representative
|
||||
/// (in OKLab).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoQuantize {
|
||||
pub max_colors: usize,
|
||||
}
|
||||
|
||||
impl Default for AutoQuantize {
|
||||
fn default() -> Self {
|
||||
Self { max_colors: 16 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Sample {
|
||||
color: Color,
|
||||
weight: u64,
|
||||
}
|
||||
|
||||
struct Bucket {
|
||||
samples: Vec<Sample>,
|
||||
}
|
||||
|
||||
impl Bucket {
|
||||
/// Extent (max - min) of the given channel across the bucket.
|
||||
fn channel_range(&self, channel: usize) -> u8 {
|
||||
let mut lo = u8::MAX;
|
||||
let mut hi = u8::MIN;
|
||||
for s in &self.samples {
|
||||
let v = s.color.rgb_u8()[channel];
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
hi.saturating_sub(lo)
|
||||
}
|
||||
|
||||
fn widest_channel(&self) -> usize {
|
||||
let mut best = 0;
|
||||
let mut best_range = 0u8;
|
||||
for c in 0..3 {
|
||||
let r = self.channel_range(c);
|
||||
if r > best_range {
|
||||
best_range = r;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn total_weight(&self) -> u64 {
|
||||
self.samples.iter().map(|s| s.weight).sum()
|
||||
}
|
||||
|
||||
/// Weighted-average representative color.
|
||||
fn representative(&self) -> Color {
|
||||
let mut r = 0u64;
|
||||
let mut g = 0u64;
|
||||
let mut b = 0u64;
|
||||
let mut w = 0u64;
|
||||
for s in &self.samples {
|
||||
let rgb = s.color.rgb_u8();
|
||||
r += rgb[0] as u64 * s.weight;
|
||||
g += rgb[1] as u64 * s.weight;
|
||||
b += rgb[2] as u64 * s.weight;
|
||||
w += s.weight;
|
||||
}
|
||||
if w == 0 {
|
||||
return Color::new(0, 0, 0);
|
||||
}
|
||||
Color::new((r / w) as u8, (g / w) as u8, (b / w) as u8)
|
||||
}
|
||||
|
||||
/// Split at the weighted median of the widest channel.
|
||||
fn split(mut self) -> (Bucket, Bucket) {
|
||||
let channel = self.widest_channel();
|
||||
self.samples
|
||||
.sort_by_key(|s| s.color.rgb_u8()[channel]);
|
||||
let half = self.total_weight() / 2;
|
||||
let mut acc = 0u64;
|
||||
let mut cut = 1;
|
||||
for (i, s) in self.samples.iter().enumerate() {
|
||||
acc += s.weight;
|
||||
if acc >= half {
|
||||
cut = (i + 1).clamp(1, self.samples.len().saturating_sub(1).max(1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let right = self.samples.split_off(cut);
|
||||
(Bucket { samples: self.samples }, Bucket { samples: right })
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorFitter for AutoQuantize {
|
||||
fn fit(&self, seg: &mut Segmentation) {
|
||||
if self.max_colors == 0 || seg.layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let samples: Vec<Sample> = seg
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| Sample {
|
||||
color: l.paint.color(),
|
||||
weight: l.mask.area() as u64 + 1,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut buckets = vec![Bucket { samples }];
|
||||
while buckets.len() < self.max_colors {
|
||||
// Split the bucket with the widest single-channel range.
|
||||
let target = buckets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| b.samples.len() > 1)
|
||||
.max_by_key(|(_, b)| b.channel_range(b.widest_channel()));
|
||||
let Some((idx, _)) = target else { break };
|
||||
let bucket = buckets.swap_remove(idx);
|
||||
let (a, b) = bucket.split();
|
||||
buckets.push(a);
|
||||
buckets.push(b);
|
||||
}
|
||||
|
||||
let palette: Vec<Color> = buckets.iter().map(Bucket::representative).collect();
|
||||
let lab: Vec<Oklab> = palette.iter().map(Oklab::from_color).collect();
|
||||
|
||||
for layer in &mut seg.layers {
|
||||
let target = Oklab::from_color(&layer.paint.color());
|
||||
let mut best = palette[0];
|
||||
let mut best_dist = f64::INFINITY;
|
||||
for (i, entry) in palette.iter().enumerate() {
|
||||
let d = target.distance_squared(&lab[i]);
|
||||
if d < best_dist {
|
||||
best_dist = d;
|
||||
best = *entry;
|
||||
}
|
||||
}
|
||||
layer.paint = Paint::Solid(best);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! 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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 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 Douglas–Peucker 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())
|
||||
}
|
||||
}
|
||||
|
||||
/// Douglas–Peucker 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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Transparency keying, ported from the 0.6.x `converter.rs`.
|
||||
//!
|
||||
//! When an image has substantial transparency, fully-transparent pixels are
|
||||
//! recolored to an unused "key" color so the clustering runner can treat them
|
||||
//! as a discardable background. The random key search of 0.6.x is replaced by a
|
||||
//! deterministic sweep so results are reproducible and `no_std`/wasm-friendly.
|
||||
|
||||
use visioncortex::{Color, ColorImage};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Fraction of pixels in the sampled rows that must be transparent before the
|
||||
/// whole image is keyed.
|
||||
const KEYING_THRESHOLD: f32 = 0.2;
|
||||
|
||||
/// Whether the image carries enough transparency to warrant keying.
|
||||
pub fn should_key_image(img: &ColorImage) -> bool {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
|
||||
let mut transparent = 0usize;
|
||||
let rows = [
|
||||
0,
|
||||
img.height / 4,
|
||||
img.height / 2,
|
||||
3 * img.height / 4,
|
||||
img.height - 1,
|
||||
];
|
||||
for y in rows {
|
||||
for x in 0..img.width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
transparent += 1;
|
||||
}
|
||||
if transparent >= threshold {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn color_exists(img: &ColorImage, color: Color) -> bool {
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let p = img.get_pixel(x, y);
|
||||
if p.r == color.r && p.g == color.g && p.b == color.b {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find a color not present in the image, to be used as the key. Tries the
|
||||
/// primary/secondary colors first, then does a deterministic sweep of the RGB
|
||||
/// cube. Returns [`Error::NoKeyColor`] only if every probed color is used.
|
||||
pub fn find_unused_color(img: &ColorImage) -> Result<Color, Error> {
|
||||
let specials = [
|
||||
Color::new(255, 0, 0),
|
||||
Color::new(0, 255, 0),
|
||||
Color::new(0, 0, 255),
|
||||
Color::new(255, 255, 0),
|
||||
Color::new(0, 255, 255),
|
||||
Color::new(255, 0, 255),
|
||||
];
|
||||
for &c in specials.iter() {
|
||||
if !color_exists(img, c) {
|
||||
return Ok(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic sweep: step by a value coprime-ish with 256 to spread out.
|
||||
const STEP: u16 = 37;
|
||||
let mut r = 0u16;
|
||||
while r < 256 {
|
||||
let mut g = 0u16;
|
||||
while g < 256 {
|
||||
let mut b = 0u16;
|
||||
while b < 256 {
|
||||
let c = Color::new(r as u8, g as u8, b as u8);
|
||||
if !color_exists(img, c) {
|
||||
return Ok(c);
|
||||
}
|
||||
b += STEP;
|
||||
}
|
||||
g += STEP;
|
||||
}
|
||||
r += STEP;
|
||||
}
|
||||
|
||||
Err(Error::NoKeyColor)
|
||||
}
|
||||
|
||||
/// Recolor every fully-transparent pixel to `key`, in place.
|
||||
pub fn apply_key(img: &mut ColorImage, key: Color) {
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
img.set_pixel(x, y, &key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 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>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Core intermediate representation shared by the pipeline stages.
|
||||
//!
|
||||
//! Two IRs flow through the pipeline:
|
||||
//!
|
||||
//! * [`Segmentation`] — the frontend output: ordered paint layers over a
|
||||
//! raster canvas (painter's algorithm, bottom to top). This is what the
|
||||
//! [`crate::colorfit`] stages rewrite.
|
||||
//! * [`VectorDoc`] — the output document: resolved shapes with fitted paths.
|
||||
//! This is what the [`crate::optimize`] passes and the [`crate::svg`] writer
|
||||
//! operate on.
|
||||
|
||||
mod region;
|
||||
mod vector;
|
||||
|
||||
pub use region::{Layer, RegionMask, Segmentation};
|
||||
pub use vector::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
|
||||
|
||||
use visioncortex::Color;
|
||||
|
||||
/// The final appearance of a region. Only solid colors are supported today;
|
||||
/// the enum leaves room for gradients and patterns later.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Paint {
|
||||
Solid(Color),
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
/// The representative solid color of this paint.
|
||||
pub fn color(&self) -> Color {
|
||||
match self {
|
||||
Paint::Solid(c) => *c,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use super::Paint;
|
||||
|
||||
/// A single drawing command in a subpath. Coordinates are absolute, in
|
||||
/// full-canvas (document) space — the writer bakes any offset into them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PathCmd {
|
||||
/// Start a new subpath at the given point.
|
||||
MoveTo(PointF64),
|
||||
/// Straight line to the given point.
|
||||
LineTo(PointF64),
|
||||
/// Cubic Bézier: two control points then the endpoint.
|
||||
CubicTo(PointF64, PointF64, PointF64),
|
||||
/// Close the current subpath back to its start.
|
||||
Close,
|
||||
}
|
||||
|
||||
/// One connected outline: a `MoveTo` followed by line/cubic segments, usually
|
||||
/// terminated by `Close`.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SubPath {
|
||||
pub commands: Vec<PathCmd>,
|
||||
}
|
||||
|
||||
impl SubPath {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
|
||||
/// The starting point of the subpath, if any.
|
||||
pub fn start(&self) -> Option<PointF64> {
|
||||
match self.commands.first() {
|
||||
Some(PathCmd::MoveTo(p)) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape may consist of several subpaths (outer ring plus holes).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct MultiPath {
|
||||
pub subpaths: Vec<SubPath>,
|
||||
}
|
||||
|
||||
impl MultiPath {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.subpaths.iter().all(SubPath::is_empty)
|
||||
}
|
||||
|
||||
pub fn push(&mut self, subpath: SubPath) {
|
||||
if !subpath.is_empty() {
|
||||
self.subpaths.push(subpath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A filled shape in the output document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Shape {
|
||||
pub paint: Paint,
|
||||
pub path: MultiPath,
|
||||
}
|
||||
|
||||
/// The output document IR: what the optimizer passes and the writer consume.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VectorDoc {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Shapes in paint order (first drawn is bottom).
|
||||
pub shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl VectorDoc {
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
shapes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! # 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};
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Mosaic mode: a seam-free, gapless tessellation.
|
||||
//!
|
||||
//! Instead of tracing every region independently (which lets neighboring
|
||||
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
|
||||
//!
|
||||
//! ```text
|
||||
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
|
||||
//! ```
|
||||
//!
|
||||
//! Every boundary curve exists exactly once; the two adjacent regions
|
||||
//! reference the same fitted geometry, one traversed reversed. Reversal is
|
||||
//! exact, so the serialized coordinates match on both sides — no seams.
|
||||
//!
|
||||
//! Stages 1–2 (graph + faces) are pure integer arithmetic on the lattice of
|
||||
//! pixel corners. Only fitting (stage 3) is floating point.
|
||||
|
||||
mod compose;
|
||||
mod face;
|
||||
mod fit;
|
||||
mod graph;
|
||||
|
||||
pub use compose::compose_mosaic;
|
||||
pub use fit::{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 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)?))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
//! Serialize a [`VectorDoc`] to an SVG string.
|
||||
//!
|
||||
//! The writer makes the encoding choices that shrink output without changing
|
||||
//! geometry:
|
||||
//!
|
||||
//! * per segment, the shorter of absolute vs. relative deltas (`L`/`l`, `C`/`c`);
|
||||
//! * `H`/`V` (`h`/`v`) for axis-aligned lines and `S`/`s` for smooth cubic
|
||||
//! continuations;
|
||||
//! * compact number formatting (trimmed zeros, leading-dot decimals, no
|
||||
//! separator before a negative);
|
||||
//! * optional `<g fill>` grouping of consecutive same-fill shapes.
|
||||
//!
|
||||
//! Coordinates are assumed to already be in absolute document space (the
|
||||
//! [`crate::optimize::QuantizePass`] bakes in any offset), so no per-path
|
||||
//! `transform` is emitted.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use crate::ir::{Paint, PathCmd, Shape, SubPath, VectorDoc};
|
||||
|
||||
/// SVG serializer configuration.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SvgWriter {
|
||||
/// Allow relative commands where they serialize shorter.
|
||||
pub relative: bool,
|
||||
/// Allow `H`/`V`/`S` shorthands and `<g fill>` grouping.
|
||||
pub shorthands: bool,
|
||||
/// Decimal places for coordinates (`None` = full precision).
|
||||
pub precision: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for SvgWriter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
relative: true,
|
||||
shorthands: true,
|
||||
precision: Some(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SvgWriter {
|
||||
pub fn write(&self, doc: &VectorDoc) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<!-- Generator: visioncortex VTracer {} -->",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">",
|
||||
doc.width, doc.height
|
||||
);
|
||||
|
||||
if self.shorthands {
|
||||
self.write_grouped(&mut out, &doc.shapes);
|
||||
} else {
|
||||
for shape in &doc.shapes {
|
||||
self.write_path(&mut out, shape, true);
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("</svg>\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit shapes, grouping maximal runs of consecutive same-fill shapes into
|
||||
/// a single `<g fill>` (preserving paint order).
|
||||
fn write_grouped(&self, out: &mut String, shapes: &[Shape]) {
|
||||
let mut i = 0;
|
||||
while i < shapes.len() {
|
||||
let fill = shape_fill(&shapes[i]);
|
||||
let mut j = i + 1;
|
||||
while j < shapes.len() && shape_fill(&shapes[j]) == fill {
|
||||
j += 1;
|
||||
}
|
||||
let run = &shapes[i..j];
|
||||
if run.len() > 1 {
|
||||
let _ = writeln!(out, "<g fill=\"{}\">", fill);
|
||||
for shape in run {
|
||||
self.write_path(out, shape, false);
|
||||
}
|
||||
out.push_str("</g>\n");
|
||||
} else {
|
||||
self.write_path(out, &run[0], true);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_path(&self, out: &mut String, shape: &Shape, with_fill: bool) {
|
||||
let d = self.encode_path(shape);
|
||||
if d.is_empty() {
|
||||
return;
|
||||
}
|
||||
if with_fill {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<path d=\"{}\" fill=\"{}\"/>",
|
||||
d,
|
||||
shape_fill(shape)
|
||||
);
|
||||
} else {
|
||||
let _ = writeln!(out, "<path d=\"{}\"/>", d);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_path(&self, shape: &Shape) -> String {
|
||||
let mut emitter = Emitter::new(self.relative, self.shorthands, self.precision);
|
||||
for sub in &shape.path.subpaths {
|
||||
emitter.subpath(sub);
|
||||
}
|
||||
emitter.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_fill(shape: &Shape) -> String {
|
||||
match shape.paint {
|
||||
Paint::Solid(c) => c.to_hex_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming SVG-path encoder that tracks the current point.
|
||||
struct Emitter {
|
||||
relative: bool,
|
||||
shorthands: bool,
|
||||
precision: Option<u32>,
|
||||
out: String,
|
||||
cur: PointF64,
|
||||
/// Start of the current subpath; `cur` returns here after `Z`.
|
||||
subpath_start: PointF64,
|
||||
started: bool,
|
||||
/// Absolute second control point of the previous cubic, for `S` detection.
|
||||
prev_cubic_c2: Option<PointF64>,
|
||||
}
|
||||
|
||||
impl Emitter {
|
||||
fn new(relative: bool, shorthands: bool, precision: Option<u32>) -> Self {
|
||||
Self {
|
||||
relative,
|
||||
shorthands,
|
||||
precision,
|
||||
out: String::new(),
|
||||
cur: PointF64::default(),
|
||||
subpath_start: PointF64::default(),
|
||||
started: false,
|
||||
prev_cubic_c2: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> String {
|
||||
self.out
|
||||
}
|
||||
|
||||
fn subpath(&mut self, sub: &SubPath) {
|
||||
for cmd in &sub.commands {
|
||||
match *cmd {
|
||||
PathCmd::MoveTo(p) => self.move_to(p),
|
||||
PathCmd::LineTo(p) => self.line_to(p),
|
||||
PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e),
|
||||
PathCmd::Close => {
|
||||
self.out.push('Z');
|
||||
// SVG resets the current point to the subpath's start after
|
||||
// Z; a following relative `m`/`l` is measured from there.
|
||||
self.cur = self.subpath_start;
|
||||
self.prev_cubic_c2 = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_to(&mut self, p: PointF64) {
|
||||
if !self.started {
|
||||
// First move is always absolute.
|
||||
let token = format!("M{}", self.coord(p));
|
||||
self.out.push_str(&token);
|
||||
self.started = true;
|
||||
} else {
|
||||
let abs = format!("M{}", self.coord(p));
|
||||
let token = if self.relative {
|
||||
let rel = format!("m{}", self.coord_delta(p));
|
||||
shorter(abs, rel)
|
||||
} else {
|
||||
abs
|
||||
};
|
||||
self.out.push_str(&token);
|
||||
}
|
||||
self.cur = p;
|
||||
self.subpath_start = p;
|
||||
self.prev_cubic_c2 = None;
|
||||
}
|
||||
|
||||
fn line_to(&mut self, p: PointF64) {
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
|
||||
// Axis-aligned shorthands.
|
||||
if self.shorthands {
|
||||
if p.y == self.cur.y {
|
||||
candidates.push(format!("H{}", self.num(p.x)));
|
||||
if self.relative {
|
||||
candidates.push(format!("h{}", self.num(p.x - self.cur.x)));
|
||||
}
|
||||
}
|
||||
if p.x == self.cur.x {
|
||||
candidates.push(format!("V{}", self.num(p.y)));
|
||||
if self.relative {
|
||||
candidates.push(format!("v{}", self.num(p.y - self.cur.y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(format!("L{}", self.coord(p)));
|
||||
if self.relative {
|
||||
candidates.push(format!("l{}", self.coord_delta(p)));
|
||||
}
|
||||
|
||||
self.out.push_str(&shortest(candidates));
|
||||
self.cur = p;
|
||||
self.prev_cubic_c2 = None;
|
||||
}
|
||||
|
||||
fn cubic_to(&mut self, c1: PointF64, c2: PointF64, e: PointF64) {
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
|
||||
// Smooth continuation: c1 is the reflection of the previous cubic's c2.
|
||||
if self.shorthands {
|
||||
if let Some(prev_c2) = self.prev_cubic_c2 {
|
||||
let reflection = PointF64 {
|
||||
x: 2.0 * self.cur.x - prev_c2.x,
|
||||
y: 2.0 * self.cur.y - prev_c2.y,
|
||||
};
|
||||
if approx(reflection, c1) {
|
||||
candidates.push(format!(
|
||||
"S{}",
|
||||
self.coord_list(&[c2, e])
|
||||
));
|
||||
if self.relative {
|
||||
candidates.push(format!(
|
||||
"s{}",
|
||||
self.delta_list(&[c2, e])
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(format!("C{}", self.coord_list(&[c1, c2, e])));
|
||||
if self.relative {
|
||||
candidates.push(format!("c{}", self.delta_list(&[c1, c2, e])));
|
||||
}
|
||||
|
||||
self.out.push_str(&shortest(candidates));
|
||||
self.cur = e;
|
||||
self.prev_cubic_c2 = Some(c2);
|
||||
}
|
||||
|
||||
// --- number/coordinate formatting -------------------------------------
|
||||
|
||||
fn num(&self, v: f64) -> String {
|
||||
fmt_num(v, self.precision)
|
||||
}
|
||||
|
||||
/// Absolute coordinate pair.
|
||||
fn coord(&self, p: PointF64) -> String {
|
||||
join_nums(&[self.num(p.x), self.num(p.y)])
|
||||
}
|
||||
|
||||
/// Delta coordinate pair relative to the current point.
|
||||
fn coord_delta(&self, p: PointF64) -> String {
|
||||
join_nums(&[self.num(p.x - self.cur.x), self.num(p.y - self.cur.y)])
|
||||
}
|
||||
|
||||
/// Absolute list of points, flattened.
|
||||
fn coord_list(&self, pts: &[PointF64]) -> String {
|
||||
let mut nums = Vec::with_capacity(pts.len() * 2);
|
||||
for p in pts {
|
||||
nums.push(self.num(p.x));
|
||||
nums.push(self.num(p.y));
|
||||
}
|
||||
join_nums(&nums)
|
||||
}
|
||||
|
||||
/// Delta list of points relative to the current point (all deltas are from
|
||||
/// `cur`, matching SVG's relative-command semantics for multi-point ops).
|
||||
fn delta_list(&self, pts: &[PointF64]) -> String {
|
||||
let mut nums = Vec::with_capacity(pts.len() * 2);
|
||||
for p in pts {
|
||||
nums.push(self.num(p.x - self.cur.x));
|
||||
nums.push(self.num(p.y - self.cur.y));
|
||||
}
|
||||
join_nums(&nums)
|
||||
}
|
||||
}
|
||||
|
||||
fn approx(a: PointF64, b: PointF64) -> bool {
|
||||
(a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn shorter(a: String, b: String) -> String {
|
||||
if b.len() < a.len() {
|
||||
b
|
||||
} else {
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
fn shortest(candidates: Vec<String>) -> String {
|
||||
candidates
|
||||
.into_iter()
|
||||
.min_by_key(|s| s.len())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Join formatted numbers with the minimal separators SVG allows: a comma,
|
||||
/// except that a leading `-` is self-separating.
|
||||
fn join_nums(nums: &[String]) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, n) in nums.iter().enumerate() {
|
||||
if i > 0 && !n.starts_with('-') {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(n);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Compact number formatting: round to precision, trim trailing zeros, use a
|
||||
/// leading-dot for magnitudes below 1.
|
||||
fn fmt_num(v: f64, precision: Option<u32>) -> String {
|
||||
let v = match precision {
|
||||
Some(p) => {
|
||||
let factor = 10f64.powi(p as i32);
|
||||
(v * factor).round() / factor
|
||||
}
|
||||
None => v,
|
||||
};
|
||||
// Normalize -0.0 to 0.
|
||||
if v == 0.0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
|
||||
let mut s = match precision {
|
||||
Some(p) => format!("{:.*}", p as usize, v),
|
||||
None => format!("{v}"),
|
||||
};
|
||||
|
||||
if s.contains('.') {
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if s.ends_with('.') {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rest) = s.strip_prefix("0.") {
|
||||
s = format!(".{rest}");
|
||||
} else if let Some(rest) = s.strip_prefix("-0.") {
|
||||
s = format!("-.{rest}");
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ir::{MultiPath, Paint, PathCmd, Shape, SubPath};
|
||||
use visioncortex::Color;
|
||||
|
||||
#[test]
|
||||
fn number_formatting() {
|
||||
assert_eq!(fmt_num(0.0, Some(2)), "0");
|
||||
assert_eq!(fmt_num(-0.0, Some(2)), "0");
|
||||
assert_eq!(fmt_num(1.50, Some(2)), "1.5");
|
||||
assert_eq!(fmt_num(0.5, Some(2)), ".5");
|
||||
assert_eq!(fmt_num(-0.5, Some(2)), "-.5");
|
||||
assert_eq!(fmt_num(2.0, Some(2)), "2");
|
||||
assert_eq!(fmt_num(3.14159, Some(2)), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_omits_separator_before_negative() {
|
||||
let nums = vec!["1".to_string(), "-2".to_string(), "3".to_string()];
|
||||
assert_eq!(join_nums(&nums), "1-2,3");
|
||||
}
|
||||
|
||||
fn square_shape() -> Shape {
|
||||
use visioncortex::PointF64;
|
||||
let p = |x, y| PointF64 { x, y };
|
||||
let mut sub = SubPath::new();
|
||||
sub.commands = vec![
|
||||
PathCmd::MoveTo(p(0.0, 0.0)),
|
||||
PathCmd::LineTo(p(10.0, 0.0)),
|
||||
PathCmd::LineTo(p(10.0, 10.0)),
|
||||
PathCmd::LineTo(p(0.0, 10.0)),
|
||||
PathCmd::Close,
|
||||
];
|
||||
Shape {
|
||||
paint: Paint::Solid(Color::new(255, 0, 0)),
|
||||
path: MultiPath { subpaths: vec![sub] },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_axis_aligned_shorthands() {
|
||||
let writer = SvgWriter {
|
||||
relative: true,
|
||||
shorthands: true,
|
||||
precision: Some(2),
|
||||
};
|
||||
let d = writer.encode_path(&square_shape());
|
||||
// Horizontal/vertical lines collapse to H/V/h/v; first move is absolute.
|
||||
assert!(d.starts_with("M0,0"));
|
||||
assert!(d.contains('H') || d.contains('h'));
|
||||
assert!(d.contains('V') || d.contains('v'));
|
||||
assert!(d.ends_with('Z'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_mode_uses_no_relative_commands() {
|
||||
let writer = SvgWriter {
|
||||
relative: false,
|
||||
shorthands: false,
|
||||
precision: Some(2),
|
||||
};
|
||||
let d = writer.encode_path(&square_shape());
|
||||
assert!(!d.contains('l'));
|
||||
assert!(!d.contains('c'));
|
||||
assert!(d.contains('L'));
|
||||
}
|
||||
|
||||
/// A shape with a hole (second subpath). Encoded absolute vs relative must
|
||||
/// describe the *same* geometry — regression for the bug where the current
|
||||
/// point was not reset to the subpath start after `Z`, so the relative `m`
|
||||
/// of the hole was measured from the wrong origin.
|
||||
fn holed_shape() -> Shape {
|
||||
use visioncortex::PointF64;
|
||||
let p = |x, y| PointF64 { x, y };
|
||||
let outer = SubPath {
|
||||
commands: vec![
|
||||
PathCmd::MoveTo(p(0.0, 0.0)),
|
||||
PathCmd::LineTo(p(30.0, 0.0)),
|
||||
PathCmd::LineTo(p(30.0, 30.0)),
|
||||
PathCmd::LineTo(p(0.0, 30.0)),
|
||||
PathCmd::Close,
|
||||
],
|
||||
};
|
||||
let hole = SubPath {
|
||||
commands: vec![
|
||||
PathCmd::MoveTo(p(10.0, 10.0)),
|
||||
PathCmd::LineTo(p(20.0, 10.0)),
|
||||
PathCmd::LineTo(p(20.0, 20.0)),
|
||||
PathCmd::LineTo(p(10.0, 20.0)),
|
||||
PathCmd::Close,
|
||||
],
|
||||
};
|
||||
Shape {
|
||||
paint: Paint::Solid(Color::new(0, 0, 0)),
|
||||
path: MultiPath {
|
||||
subpaths: vec![outer, hole],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an SVG `d` (M/m/L/l/H/h/V/v/Z only) into absolute points.
|
||||
fn parse_abs(d: &str) -> Vec<(f64, f64)> {
|
||||
let mut toks = Vec::new();
|
||||
let mut i = 0;
|
||||
let b = d.as_bytes();
|
||||
while i < b.len() {
|
||||
let c = b[i] as char;
|
||||
if c.is_ascii_alphabetic() {
|
||||
toks.push(c.to_string());
|
||||
i += 1;
|
||||
} else if c == '-' || c == '.' || c.is_ascii_digit() {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < b.len() && {
|
||||
let d = b[i] as char;
|
||||
d.is_ascii_digit() || d == '.'
|
||||
} {
|
||||
i += 1;
|
||||
}
|
||||
toks.push(d[start..i].to_string());
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (mut cx, mut cy, mut sx, mut sy) = (0.0, 0.0, 0.0, 0.0);
|
||||
let mut j = 0;
|
||||
let mut cmd = ' ';
|
||||
let num = |j: &mut usize| -> f64 {
|
||||
let v = toks[*j].parse().unwrap();
|
||||
*j += 1;
|
||||
v
|
||||
};
|
||||
while j < toks.len() {
|
||||
if toks[j].chars().next().unwrap().is_ascii_alphabetic() {
|
||||
cmd = toks[j].chars().next().unwrap();
|
||||
j += 1;
|
||||
}
|
||||
let rel = cmd.is_ascii_lowercase();
|
||||
match cmd.to_ascii_uppercase() {
|
||||
'M' => {
|
||||
let (mut x, mut y) = (num(&mut j), num(&mut j));
|
||||
if rel {
|
||||
x += cx;
|
||||
y += cy;
|
||||
}
|
||||
cx = x;
|
||||
cy = y;
|
||||
sx = x;
|
||||
sy = y;
|
||||
out.push((cx, cy));
|
||||
cmd = if rel { 'l' } else { 'L' };
|
||||
}
|
||||
'L' => {
|
||||
let (mut x, mut y) = (num(&mut j), num(&mut j));
|
||||
if rel {
|
||||
x += cx;
|
||||
y += cy;
|
||||
}
|
||||
cx = x;
|
||||
cy = y;
|
||||
out.push((cx, cy));
|
||||
}
|
||||
'H' => {
|
||||
let mut x = num(&mut j);
|
||||
if rel {
|
||||
x += cx;
|
||||
}
|
||||
cx = x;
|
||||
out.push((cx, cy));
|
||||
}
|
||||
'V' => {
|
||||
let mut y = num(&mut j);
|
||||
if rel {
|
||||
y += cy;
|
||||
}
|
||||
cy = y;
|
||||
out.push((cx, cy));
|
||||
}
|
||||
'Z' => {
|
||||
cx = sx;
|
||||
cy = sy;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_absolute_encode_same_geometry() {
|
||||
let shape = holed_shape();
|
||||
let abs = SvgWriter {
|
||||
relative: false,
|
||||
shorthands: false,
|
||||
precision: Some(2),
|
||||
}
|
||||
.encode_path(&shape);
|
||||
for shorthands in [false, true] {
|
||||
let rel = SvgWriter {
|
||||
relative: true,
|
||||
shorthands,
|
||||
precision: Some(2),
|
||||
}
|
||||
.encode_path(&shape);
|
||||
assert_eq!(
|
||||
parse_abs(&abs),
|
||||
parse_abs(&rel),
|
||||
"relative (shorthands={shorthands}) geometry diverges from absolute:\n abs={abs}\n rel={rel}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Rasterize-and-diff equivalence between stacked and mosaic (cutout) modes.
|
||||
//!
|
||||
//! Both modes render the *same* flattened partition of the image — stacked by
|
||||
//! painting layers top-down, mosaic as a gapless tessellation. So their
|
||||
//! rasterizations must agree in every region interior; they may differ only
|
||||
//! within a thin band along region boundaries, where the two fitting paths
|
||||
//! legitimately place the edge a fraction of a pixel apart. This test asserts
|
||||
//! exactly that: any pixel that differs must lie within ~1–2px of a boundary.
|
||||
//!
|
||||
//! `resvg` is a dev-dependency, so this never enters a wasm build.
|
||||
|
||||
use resvg::{tiny_skia, usvg};
|
||||
use vtracer::{ColorImage, Config, FitMode, Hierarchical};
|
||||
|
||||
/// A few smooth colored discs on a background — curved boundaries, limited
|
||||
/// boundary length, no thin (1px) features.
|
||||
fn blobs(w: usize, h: usize) -> ColorImage {
|
||||
let discs = [
|
||||
(28.0f64, 30.0, 18.0, (210u8, 60, 60)),
|
||||
(64.0, 40.0, 20.0, (60, 160, 90)),
|
||||
(44.0, 68.0, 16.0, (70, 90, 200)),
|
||||
];
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut col = (235u8, 230, 225); // background
|
||||
for &(cx, cy, r, c) in &discs {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
if dx * dx + dy * dy <= r * r {
|
||||
col = c;
|
||||
}
|
||||
}
|
||||
pixels.extend_from_slice(&[col.0, col.1, col.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn rasterize(svg: &str, w: u32, h: u32) -> Vec<u8> {
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
|
||||
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
|
||||
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
pixmap.data().to_vec()
|
||||
}
|
||||
|
||||
/// Max per-channel difference between two RGBA pixels at index `i`.
|
||||
fn pixel_diff(a: &[u8], b: &[u8], i: usize) -> u8 {
|
||||
(0..4)
|
||||
.map(|c| a[i + c].abs_diff(b[i + c]))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mark pixels within Chebyshev radius `r` of a color edge in either image.
|
||||
fn boundary_band(a: &[u8], b: &[u8], w: usize, h: usize, r: i32) -> Vec<bool> {
|
||||
const EDGE: u8 = 24;
|
||||
let idx = |x: usize, y: usize| (y * w + x) * 4;
|
||||
let mut edge = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = idx(x, y);
|
||||
// An edge is where either rendering changes color vs its right/down
|
||||
// neighbor.
|
||||
let mut is_edge = false;
|
||||
for img in [a, b] {
|
||||
if x + 1 < w && neighbor_diff(img, i, idx(x + 1, y)) > EDGE {
|
||||
is_edge = true;
|
||||
}
|
||||
if y + 1 < h && neighbor_diff(img, i, idx(x, y + 1)) > EDGE {
|
||||
is_edge = true;
|
||||
}
|
||||
}
|
||||
if is_edge {
|
||||
edge[y * w + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dilate the edge set by r.
|
||||
let mut band = vec![false; w * h];
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
let mut near = false;
|
||||
'outer: for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && edge[ny as usize * w + nx as usize] {
|
||||
near = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
band[y as usize * w + x as usize] = near;
|
||||
}
|
||||
}
|
||||
band
|
||||
}
|
||||
|
||||
fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
|
||||
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn assert_equivalent(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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,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>
|
||||
|
After Width: | Height: | Size: 512 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0L48,0L48,40L0,40Z" fill="#28C83C"/>
|
||||
<path d="M36,0L48,0L48,40L36,40Z" fill="#E6D228"/>
|
||||
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
|
||||
<path d="M0,0L12,0L12,40L0,40Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0L48,0l0,40L0,40Z" fill="#28C83C"/>
|
||||
<path d="M36,0L48,0l0,40L36,40Z" fill="#E6D228"/>
|
||||
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
|
||||
<path d="M0,0L12,0l0,40L0,40Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,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>
|
||||
|
After Width: | Height: | Size: 623 B |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#000000"/>
|
||||
<path d="M16,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M0,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M16,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M0,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M16,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,52 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,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>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0L0,48l48,0L48,0L0,0ZM24,8l1,0l0,1l5,0l0,1l2,0l0,1l2,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,2l1,0l0,2l1,0l0,5l1,0l0,1l-1,0l0,5l-1,0l0,2l-1,0l0,2l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-2,0l0,1l-2,0l0,1l-5,0l0,1l-1,0l0-1l-5,0l0-1l-2,0l0-1l-2,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-2l-1,0l0-2L9,30l0-5L8,25l0-1l1,0l0-5l1,0l0-2l1,0l0-2l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l2,0l0-1l2,0l0-1l5,0l0-1Z" fill="#F0F0F0"/>
|
||||
<path d="M24,8l0,1L19,9l0,1l-2,0l0,1l-2,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,2l-1,0l0,2L9,19l0,5L8,24l0,1l1,0l0,5l1,0l0,2l1,0l0,2l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l2,0l0,1l2,0l0,1l5,0l0,1l1,0l0-1l5,0l0-1l2,0l0-1l2,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-2l1,0l0-2l1,0l0-5l1,0l0-1l-1,0l0-5l-1,0l0-2l-1,0l0-2l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-2,0l0-1l-2,0l0-1L25,9l0-1L24,8Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 985 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C0,16.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>
|
||||
|
After Width: | Height: | Size: 888 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,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>
|
||||
|
After Width: | Height: | Size: 573 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#285AC8"/>
|
||||
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
|
||||
<path d="M29,16c2.56,1.44,2.56,1.44,4,4c.75,4.29,.71,7.73-1.44,11.56C27.73,33.71,24.29,33.75,20,33c-2.56-1.44-2.56-1.44-4-4c-.75-4.29-.71-7.73,1.44-11.56C21.27,15.29,24.71,15.25,29,16Z" fill="#F5F5F5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 829 B |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,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>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,89 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,190 @@
|
||||
# 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 1–2 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.5–1, 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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/`.
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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` 0–16 × mode × colormode | 102 | 0 | 1e-8 |
|
||||
| `color_precision` 1–8 × mode | 24 | 0 | 1e-8 |
|
||||
| `gradient_step` 0–255 × mode | 39 | 0 | 1e-8 |
|
||||
| `corner_threshold` 0–180 (spline) | 26 | 0 | 1e-8 |
|
||||
| `segment_length` 3.5–10 (spline) | 9 | 0 | 1e-8 |
|
||||
| `splice_threshold` 0–180 (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.
|
||||
@@ -0,0 +1,6 @@
|
||||
/pkg
|
||||
/target
|
||||
/node_modules
|
||||
Cargo.lock
|
||||
# npm auth token — per-project, never commit
|
||||
.npmrc
|
||||
@@ -0,0 +1,27 @@
|
||||
[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
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
/** 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;
|
||||
@@ -0,0 +1,49 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/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}`);
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'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');
|
||||
@@ -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.6.0"
|
||||
visioncortex = "0.8.1"
|
||||
|
||||
# The `console_error_panic_hook` crate provides better debugging of panics by
|
||||
# logging them with `console.error`. This is great for development, but requires
|
||||
|
||||
@@ -35,7 +35,11 @@ document.addEventListener('paste', function (e) {
|
||||
|
||||
// Download as SVG
|
||||
document.getElementById('export').addEventListener('click', function (e) {
|
||||
const blob = new Blob([new XMLSerializer().serializeToString(svg)], {type: 'octet/stream'}),
|
||||
const blob = new Blob([
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n`,
|
||||
`<!-- Generator: visioncortex VTracer -->\n`,
|
||||
new XMLSerializer().serializeToString(svg)
|
||||
], {type: 'octet/stream'}),
|
||||
url = window.URL.createObjectURL(blob);
|
||||
|
||||
this.href = url;
|
||||
@@ -444,7 +448,7 @@ class ConverterRunner {
|
||||
this.converter.init();
|
||||
this.stopped = false;
|
||||
if (clustering_mode == 'binary') {
|
||||
svg.style.background = '#000';
|
||||
svg.style.background = '#fff';
|
||||
canvas.style.display = 'none';
|
||||
} else {
|
||||
svg.style.background = '';
|
||||
|
||||
@@ -77,7 +77,7 @@ impl BinaryImageConverter {
|
||||
self.params.max_iterations,
|
||||
self.params.splice_threshold
|
||||
);
|
||||
let color = Color::color(&ColorName::White);
|
||||
let color = Color::color(&ColorName::Black);
|
||||
self.svg.prepend_path(
|
||||
&paths,
|
||||
&color,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use visioncortex::PathSimplifyMode;
|
||||
use visioncortex::color_clusters::{IncrementalBuilder, Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX};
|
||||
use visioncortex::{Color, ColorImage, PathSimplifyMode};
|
||||
use visioncortex::color_clusters::{Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX, IncrementalBuilder, KeyingAction};
|
||||
|
||||
use crate::canvas::*;
|
||||
use crate::svg::*;
|
||||
@@ -8,6 +8,8 @@ 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,
|
||||
@@ -67,7 +69,26 @@ impl ColorImageConverter {
|
||||
pub fn init(&mut self) {
|
||||
let width = self.canvas.width() as u32;
|
||||
let height = self.canvas.height() as u32;
|
||||
let image = self.canvas.get_image_data_as_color_image(0, 0, width, height);
|
||||
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 runner = Runner::new(RunnerConfig {
|
||||
diagonal: self.params.layer_difference == 0,
|
||||
hierarchical: HIERARCHICAL_MAX,
|
||||
@@ -78,6 +99,12 @@ 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());
|
||||
}
|
||||
@@ -108,6 +135,8 @@ 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());
|
||||
},
|
||||
@@ -167,4 +196,56 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
mod binary_image;
|
||||
mod color_image;
|
||||
mod util;
|
||||
|
||||
pub use binary_image::*;
|
||||
pub use color_image::*;
|
||||