Compare commits
80 Commits
| 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 | |||
| 725adf5364 | |||
| 05f82c7bb5 | |||
| b7ac336b6d | |||
| c03a8ffced | |||
| 3223ba56ec | |||
| ddb47e1ad4 | |||
| 177797108d | |||
| 370083f818 | |||
| c3012c6aef | |||
| 2774fc06c9 | |||
| fa7d021055 | |||
| cc43924601 | |||
| 9dbbd100df | |||
| 37a2570f49 | |||
| b2cd1a9524 | |||
| ac93bd4a51 | |||
| e62f071b34 | |||
| 884092a5b9 | |||
| 5f5a6c6648 | |||
| 022018beb2 | |||
| cc39c8c5ca | |||
| fa1ab68ef3 | |||
| b04738fd18 | |||
| 1080c562ce | |||
| e44e750721 | |||
| 05f5b51db0 | |||
| 68e8e7fdf1 | |||
| f5cce867f2 | |||
| 79dd451da1 | |||
| f71dd47907 | |||
| 594125f737 | |||
| 93e946f3c2 | |||
| 914e4ab875 | |||
| 95b8cbce6c | |||
| b49158f24c | |||
| f35df1f6b2 | |||
| f4c7828049 |
@@ -1,117 +0,0 @@
|
||||
# This file is autogenerated by maturin v1.2.3
|
||||
# To update, run
|
||||
#
|
||||
# maturin generate-ci github
|
||||
#
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
manylinux: auto
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x64, x86]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
architecture: ${{ matrix.target }}
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x86_64, aarch64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
sdist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: "startsWith(github.ref, 'refs/tags/')"
|
||||
needs: [linux, windows, macos, sdist]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
- name: Publish to PyPI
|
||||
uses: PyO3/maturin-action@v1
|
||||
env:
|
||||
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
with:
|
||||
command: upload
|
||||
args: --non-interactive --skip-existing *
|
||||
@@ -0,0 +1,182 @@
|
||||
# Python wheels for crates/vtracer-py (maturin). Regenerate the skeleton with:
|
||||
# maturin generate-ci github -m crates/vtracer-py/Cargo.toml
|
||||
name: Python
|
||||
|
||||
# Wheel builds are heavy (full platform matrix), so they run only on release
|
||||
# tags and on-demand — not on every push/PR. Rust/wasm/Node CI (rust.yml) still
|
||||
# gates ordinary commits.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: ubuntu-22.04
|
||||
target: x86_64
|
||||
- runner: ubuntu-22.04
|
||||
target: x86
|
||||
- runner: ubuntu-22.04
|
||||
target: aarch64
|
||||
- runner: ubuntu-22.04
|
||||
target: armv7
|
||||
- runner: ubuntu-22.04
|
||||
target: s390x
|
||||
- runner: ubuntu-22.04
|
||||
target: ppc64le
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
manylinux: auto
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-linux-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
musllinux:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: ubuntu-22.04
|
||||
target: x86_64
|
||||
- runner: ubuntu-22.04
|
||||
target: x86
|
||||
- runner: ubuntu-22.04
|
||||
target: aarch64
|
||||
- runner: ubuntu-22.04
|
||||
target: armv7
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
manylinux: musllinux_1_2
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-musllinux-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
windows:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: windows-latest
|
||||
target: x64
|
||||
python_arch: x64
|
||||
- runner: windows-latest
|
||||
target: x86
|
||||
python_arch: x86
|
||||
- runner: windows-11-arm
|
||||
target: aarch64
|
||||
python_arch: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.13
|
||||
architecture: ${{ matrix.platform.python_arch }}
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-windows-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
macos:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: macos-15-intel
|
||||
target: x86_64
|
||||
- runner: macos-latest
|
||||
target: aarch64
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-macos-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
sdist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist --manifest-path crates/vtracer-py/Cargo.toml
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-sdist
|
||||
path: dist
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
# Specifying a GitHub environment is optional, but strongly encouraged
|
||||
environment: python
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
|
||||
needs: [linux, musllinux, windows, macos, sdist]
|
||||
permissions:
|
||||
# Use to sign the release artifacts
|
||||
id-token: write
|
||||
# Used to upload release artifacts
|
||||
contents: write
|
||||
# Used to generate artifact attestation
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v6
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-path: 'wheels-*/*'
|
||||
- name: Install uv
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Publish to PyPI
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
run: uv publish 'wheels-*/*'
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Release
|
||||
|
||||
# Builds the `vtracer` CLI binary (crates/vtracer-cli) for each target.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: taiki-e/upload-rust-binary-action@v1
|
||||
with:
|
||||
bin: vtracer
|
||||
target: ${{ matrix.target }}
|
||||
# (required) GitHub token for uploading assets to GitHub Releases.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -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,13 +5,60 @@ 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/).
|
||||
|
||||
## 0.6.0 - 2023-09-08
|
||||
## 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`
|
||||
|
||||
## 0.6.3 - 2023-11-21
|
||||
|
||||
* New converter API https://github.com/visioncortex/vtracer/pull/59
|
||||
|
||||
## 0.6.1 - 2023-09-23
|
||||
|
||||
* Fixed "The two lines are parallel!"
|
||||
|
||||
### Python Binding
|
||||
|
||||
Thanks to the contribution of [@etjones](https://github.com/etjones), we now have an official Python binding! https://github.com/visioncortex/vtracer/pull/55
|
||||
|
||||
https://pypi.org/project/vtracer/0.6.10/
|
||||
|
||||
## 0.5.0 - 2022-10-09
|
||||
|
||||
* Handle transparent png images (cli) (#23)
|
||||
* Handle transparent png images (cli) https://github.com/visioncortex/vtracer/pull/23
|
||||
|
||||
## 0.4.0 - 2021-07-23
|
||||
|
||||
@@ -25,6 +72,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
* Use relative & closed paths
|
||||
|
||||
## 0.1.1 - 2020-11-01
|
||||
|
||||
* SVG namespace
|
||||
|
||||
## 0.1.0 - 2020-10-31
|
||||
|
||||
* Initial release
|
||||
@@ -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) 2022 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,35 +4,32 @@
|
||||
<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="//www.visioncortex.org/vtracer-docs">Article</a>
|
||||
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
|
||||
<span> | </span>
|
||||
<a href="//www.visioncortex.org/vtracer/">Demo</a>
|
||||
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
|
||||
<span> | </span>
|
||||
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases">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 [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans. tl;dr: Potrace uses a `O(n^2)` fitting algorithm, whereas `vtracer` is entirely `O(n)`.
|
||||
|
||||
Comparing to 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).
|
||||
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,69 +37,137 @@ 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.4.0
|
||||
A cmd app to convert images into vector graphics.
|
||||
|
||||
USAGE:
|
||||
vtracer [OPTIONS] --input <input> --output <output>
|
||||
|
||||
FLAGS:
|
||||
-h, --help Prints help information
|
||||
-V, --version Prints version information
|
||||
|
||||
OPTIONS:
|
||||
--colormode <color_mode> True color image `color` (default) or Binary image `bw`
|
||||
-p, --color_precision <color_precision> Number of significant bits to use in an RGB channel
|
||||
-c, --corner_threshold <corner_threshold> Minimum momentary angle (degree) to be considered a corner
|
||||
-f, --filter_speckle <filter_speckle> Discard patches smaller than X px in size
|
||||
-g, --gradient_step <gradient_step> Color difference between gradient layers
|
||||
--hierarchical <hierarchical>
|
||||
Hierarchical clustering `stacked` (default) or non-stacked `cutout`. Only applies to color mode.
|
||||
|
||||
-i, --input <input> Path to input raster image
|
||||
-m, --mode <mode> Curver fitting mode `pixel`, `polygon`, `spline`
|
||||
-o, --output <output> Path to output vector graphics
|
||||
--path_precision <path_precision> Number of decimal places to use in path string
|
||||
--preset <preset> Use one of the preset configs `bw`, `poster`, `photo`
|
||||
-l, --segment_length <segment_length>
|
||||
Perform iterative subdivide smooth until all segments are shorter than this length
|
||||
|
||||
-s, --splice_threshold <splice_threshold> Minimum angle displacement (degree) to splice a spline
|
||||
vtracer input.jpg output.svg
|
||||
# equivalent to:
|
||||
vtracer --input input.jpg --output output.svg
|
||||
```
|
||||
|
||||
Full options (flag names are kebab-case, e.g. `--filter-speckle`):
|
||||
|
||||
```sh
|
||||
Usage: vtracer [OPTIONS] [INPUT] [OUTPUT]
|
||||
|
||||
Arguments:
|
||||
[INPUT] Input raster image (positional; or use --input)
|
||||
[OUTPUT] Output SVG (positional; or use --output)
|
||||
|
||||
Options:
|
||||
-i, --input <INPUT> Path to the input raster image
|
||||
-o, --output <OUTPUT> Path to the output SVG
|
||||
--preset <PRESET> Start from a preset: bw, poster, photo
|
||||
--colormode <COLORMODE> Color image `color` (default) or binary image `bw`
|
||||
--hierarchical <HIERARCHICAL> Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
|
||||
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
|
||||
-f, --filter-speckle <FILTER_SPECKLE> Discard patches smaller than X px in size (0..=128)
|
||||
-p, --color-precision <COLOR_PRECISION> Significant bits per RGB channel (1..=8)
|
||||
-g, --gradient-step <GRADIENT_STEP> Color difference between gradient layers (0..=255)
|
||||
-c, --corner-threshold <CORNER_THRESHOLD> Minimum momentary angle (degrees) to be a corner (0..=180)
|
||||
-l, --segment-length <SEGMENT_LENGTH> Subdivide until all segments are shorter than this (3.5..=10)
|
||||
-s, --splice-threshold <SPLICE_THRESHOLD> Minimum angle displacement (degrees) to splice a spline (0..=180)
|
||||
--path-precision <PATH_PRECISION> Decimal places to use in path coordinates
|
||||
--palette <PALETTE> Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'
|
||||
--palette-file <PALETTE_FILE> Fixed palette from a file (hex colors, comma/newline separated)
|
||||
--max-colors <MAX_COLORS> Auto-quantize to at most N colors
|
||||
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
### New in 1.0
|
||||
|
||||
- **Positional arguments** — `vtracer in.png out.svg`.
|
||||
- **`--hierarchical cutout`** is now a true seam-free mosaic (a gapless
|
||||
tessellation with shared boundaries), replacing the old re-clustered cutout.
|
||||
- **`--palette` / `--palette-file`** — snap colors to a fixed palette
|
||||
(nearest in OKLab); **`--max-colors`** auto-quantizes the palette.
|
||||
- **`--optimize`** — output size passes (coordinate quantization, redundant-
|
||||
point removal, relative/shorthand path encoding).
|
||||
|
||||
## Downloads
|
||||
|
||||
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
|
||||
|
||||
You can also install the program from source from [crates.io/vtracer](https://crates.io/crates/vtracer):
|
||||
|
||||
```sh
|
||||
cargo install vtracer-cli
|
||||
```
|
||||
|
||||
> You are strongly advised to not download from any other third-party sources
|
||||
|
||||
### Usage
|
||||
```
|
||||
./vtracer --input input.jpg --output output.svg
|
||||
|
||||
```sh
|
||||
# simplest form
|
||||
./vtracer input.jpg output.svg
|
||||
|
||||
# black & white line art
|
||||
./vtracer input.jpg output.svg --preset bw
|
||||
|
||||
# seam-free mosaic (gapless tessellation)
|
||||
./vtracer input.jpg output.svg --hierarchical cutout
|
||||
|
||||
# constrain to a fixed palette
|
||||
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
|
||||
```
|
||||
|
||||
## Library
|
||||
### Rust Library
|
||||
|
||||
The library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
|
||||
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
|
||||
|
||||
## Install
|
||||
|
||||
Download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
|
||||
|
||||
or
|
||||
|
||||
Install from source (Rust toolchain needed):
|
||||
|
||||
```
|
||||
cargo install vtracer
|
||||
```sh
|
||||
cargo add vtracer
|
||||
```
|
||||
|
||||
## In the wild
|
||||
### Python Library
|
||||
|
||||
VTracer is used by the following products (feel free to add yours to the list):
|
||||
[`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).
|
||||
|
||||
<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>
|
||||
```sh
|
||||
pip install vtracer
|
||||
```
|
||||
|
||||
```python
|
||||
import vtracer
|
||||
|
||||
# one-liners
|
||||
vtracer.convert_file("in.png", "out.svg")
|
||||
svg = vtracer.convert_bytes(open("in.png", "rb").read())
|
||||
|
||||
# rich, reusable config + presets
|
||||
cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
|
||||
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
|
||||
svg = cfg.convert_bytes(data)
|
||||
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
|
||||
```
|
||||
|
||||
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
|
||||
|
||||
### Node.js Library
|
||||
|
||||
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**.
|
||||
|
||||
```sh
|
||||
npm install @visioncortex/vtracer
|
||||
```
|
||||
|
||||
```js
|
||||
const vtracer = require('@visioncortex/vtracer');
|
||||
|
||||
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
|
||||
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
|
||||
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
VTracer has since been cited by a few academic papers in computer graphics / vision research. Please kindly let us know if you have cited our work:
|
||||
|
||||
+ SKILL 2023 [Framework to Vectorize Digital Artworks for Physical Fabrication based on Geometric Stylization Techniques](https://www.researchgate.net/publication/374448489_Framework_to_Vectorize_Digital_Artworks_for_Physical_Fabrication_based_on_Geometric_Stylization_Techniques)
|
||||
+ arXiv 2023 [Image Vectorization: a Review](https://arxiv.org/abs/2306.06441)
|
||||
+ arXiv 2023 [StarVector: Generating Scalable Vector Graphics Code from Images](https://arxiv.org/abs/2312.11556)
|
||||
+ arXiv 2024 [Text-Based Reasoning About Vector Graphics](https://arxiv.org/abs/2404.06479)
|
||||
+ arXiv 2024 [Delving into LLMs' visual understanding ability using SVG to bridge image and text](https://openreview.net/pdf?id=pwlm6Po61I)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Version 0.6.0 (2023-09-08)
|
||||
|
||||
- Python bindings
|
||||
|
||||
# Version 0.5.0 (2022-10-09)
|
||||
|
||||
- Handle transparent png images
|
||||
|
||||
# Version 0.4.0 (2021-07-23)
|
||||
|
||||
- SVG path string numeric precision
|
||||
|
||||
# Version 0.3.0 (2021-01-24)
|
||||
|
||||
- Added cutout mode
|
||||
|
||||
# Version 0.2.0 (2020-11-15)
|
||||
|
||||
- Use relative & closed paths
|
||||
|
||||
# Version 0.1.1 (2020-11-01)
|
||||
|
||||
- SVG namespace
|
||||
|
||||
# Version 0.1.0 (2020-10-31)
|
||||
|
||||
- Initial release
|
||||
@@ -1,3 +0,0 @@
|
||||
*.svg
|
||||
*.png
|
||||
*.jpg
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "vtracer"
|
||||
version = "0.6.3"
|
||||
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
|
||||
edition = "2021"
|
||||
description = "A cmd app to convert images into vector graphics."
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "http://www.visioncortex.org/vtracer"
|
||||
repository = "https://github.com/visioncortex/vtracer/"
|
||||
categories = ["graphics"]
|
||||
keywords = ["svg", "computer-graphics"]
|
||||
|
||||
[dependencies]
|
||||
clap = "2.33.3"
|
||||
image = "0.23.10"
|
||||
visioncortex = { version = "0.8.0" }
|
||||
fastrand = "1.8"
|
||||
pyo3 = { version = "0.19.0", optional = true }
|
||||
|
||||
[features]
|
||||
python-binding = ["pyo3"]
|
||||
@@ -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) 2022 Tsang Hao Fung
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,28 +0,0 @@
|
||||
[project]
|
||||
name = "vtracer"
|
||||
version = "0.6.3"
|
||||
description = "Python bindings for the Rust Vtracer raster-to-vector library"
|
||||
authors = [ { name = "Chris Tsang", email = "chris.2y3@outlook.com" } ]
|
||||
readme = "vtracer/README.md"
|
||||
requires-python = ">=3.7"
|
||||
license = "MIT"
|
||||
classifiers = [
|
||||
"Programming Language :: Rust",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
python = "^3.7"
|
||||
|
||||
[dev-dependencies]
|
||||
maturin = "^1.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["maturin>=1.2,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[tool.maturin]
|
||||
features = ["pyo3/extension-module"]
|
||||
compatibility = "linux"
|
||||
sdist-include = ["../LICENSE", "../README.md"]
|
||||
@@ -1,397 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
use std::path::PathBuf;
|
||||
use clap::{Arg, App};
|
||||
use visioncortex::PathSimplifyMode;
|
||||
|
||||
pub enum Preset {
|
||||
Bw,
|
||||
Poster,
|
||||
Photo
|
||||
}
|
||||
|
||||
pub enum ColorMode {
|
||||
Color,
|
||||
Binary,
|
||||
}
|
||||
|
||||
pub enum Hierarchical {
|
||||
Stacked,
|
||||
Cutout,
|
||||
}
|
||||
|
||||
/// Converter config
|
||||
pub struct Config {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub color_mode: ColorMode,
|
||||
pub hierarchical: Hierarchical,
|
||||
pub filter_speckle: usize,
|
||||
pub color_precision: i32,
|
||||
pub layer_difference: i32,
|
||||
pub mode: PathSimplifyMode,
|
||||
pub corner_threshold: i32,
|
||||
pub length_threshold: f64,
|
||||
pub max_iterations: usize,
|
||||
pub splice_threshold: i32,
|
||||
pub path_precision: Option<u32>,
|
||||
}
|
||||
|
||||
pub(crate) struct ConverterConfig {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub color_mode: ColorMode,
|
||||
pub hierarchical: Hierarchical,
|
||||
pub filter_speckle_area: usize,
|
||||
pub color_precision_loss: i32,
|
||||
pub layer_difference: i32,
|
||||
pub mode: PathSimplifyMode,
|
||||
pub corner_threshold: f64,
|
||||
pub length_threshold: f64,
|
||||
pub max_iterations: usize,
|
||||
pub splice_threshold: f64,
|
||||
pub path_precision: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_path: PathBuf::default(),
|
||||
output_path: PathBuf::default(),
|
||||
color_mode: ColorMode::Color,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
filter_speckle: 4,
|
||||
color_precision: 6,
|
||||
layer_difference: 16,
|
||||
corner_threshold: 60,
|
||||
length_threshold: 4.0,
|
||||
splice_threshold: 45,
|
||||
max_iterations: 10,
|
||||
path_precision: Some(8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ColorMode {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"color" => Ok(Self::Color),
|
||||
"binary" => Ok(Self::Binary),
|
||||
_ => Err(format!("unknown ColorMode {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Hierarchical {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"stacked" => Ok(Self::Stacked),
|
||||
"cutout" => Ok(Self::Cutout),
|
||||
_ => Err(format!("unknown Hierarchical {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Preset {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"bw" => Ok(Self::Bw),
|
||||
"poster" => Ok(Self::Poster),
|
||||
"photo" => Ok(Self::Photo),
|
||||
_ => Err(format!("unknown Preset {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_simplify_mode_from_str(s: &str) -> PathSimplifyMode {
|
||||
match s {
|
||||
"polygon" => PathSimplifyMode::Polygon,
|
||||
"spline" => PathSimplifyMode::Spline,
|
||||
"none" => PathSimplifyMode::None,
|
||||
_ => panic!("unknown PathSimplifyMode {}", s),
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_args() -> Self {
|
||||
let app = App::new("visioncortex VTracer ".to_owned() + env!("CARGO_PKG_VERSION"))
|
||||
.about("A cmd app to convert images into vector graphics.");
|
||||
|
||||
let app = app.arg(Arg::with_name("input")
|
||||
.long("input")
|
||||
.short("i")
|
||||
.takes_value(true)
|
||||
.help("Path to input raster image")
|
||||
.required(true));
|
||||
|
||||
let app = app.arg(Arg::with_name("output")
|
||||
.long("output")
|
||||
.short("o")
|
||||
.takes_value(true)
|
||||
.help("Path to output vector graphics")
|
||||
.required(true));
|
||||
|
||||
let app = app.arg(Arg::with_name("color_mode")
|
||||
.long("colormode")
|
||||
.takes_value(true)
|
||||
.help("True color image `color` (default) or Binary image `bw`"));
|
||||
|
||||
let app = app.arg(Arg::with_name("hierarchical")
|
||||
.long("hierarchical")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
"Hierarchical clustering `stacked` (default) or non-stacked `cutout`. \
|
||||
Only applies to color mode. "
|
||||
));
|
||||
|
||||
let app = app.arg(Arg::with_name("preset")
|
||||
.long("preset")
|
||||
.takes_value(true)
|
||||
.help("Use one of the preset configs `bw`, `poster`, `photo`"));
|
||||
|
||||
let app = app.arg(Arg::with_name("filter_speckle")
|
||||
.long("filter_speckle")
|
||||
.short("f")
|
||||
.takes_value(true)
|
||||
.help("Discard patches smaller than X px in size"));
|
||||
|
||||
let app = app.arg(Arg::with_name("color_precision")
|
||||
.long("color_precision")
|
||||
.short("p")
|
||||
.takes_value(true)
|
||||
.help("Number of significant bits to use in an RGB channel"));
|
||||
|
||||
let app = app.arg(Arg::with_name("gradient_step")
|
||||
.long("gradient_step")
|
||||
.short("g")
|
||||
.takes_value(true)
|
||||
.help("Color difference between gradient layers"));
|
||||
|
||||
let app = app.arg(Arg::with_name("corner_threshold")
|
||||
.long("corner_threshold")
|
||||
.short("c")
|
||||
.takes_value(true)
|
||||
.help("Minimum momentary angle (degree) to be considered a corner"));
|
||||
|
||||
let app = app.arg(Arg::with_name("segment_length")
|
||||
.long("segment_length")
|
||||
.short("l")
|
||||
.takes_value(true)
|
||||
.help("Perform iterative subdivide smooth until all segments are shorter than this length"));
|
||||
|
||||
let app = app.arg(Arg::with_name("splice_threshold")
|
||||
.long("splice_threshold")
|
||||
.short("s")
|
||||
.takes_value(true)
|
||||
.help("Minimum angle displacement (degree) to splice a spline"));
|
||||
|
||||
let app = app.arg(Arg::with_name("mode")
|
||||
.long("mode")
|
||||
.short("m")
|
||||
.takes_value(true)
|
||||
.help("Curver fitting mode `pixel`, `polygon`, `spline`"));
|
||||
|
||||
let app = app.arg(Arg::with_name("path_precision")
|
||||
.long("path_precision")
|
||||
.takes_value(true)
|
||||
.help("Number of decimal places to use in path string"));
|
||||
|
||||
// Extract matches
|
||||
let matches = app.get_matches();
|
||||
|
||||
let mut config = Config::default();
|
||||
let input_path = matches.value_of("input").expect("Input path is required, please specify it by --input or -i.");
|
||||
let output_path = matches.value_of("output").expect("Output path is required, please specify it by --output or -o.");
|
||||
|
||||
if let Some(value) = matches.value_of("preset") {
|
||||
config = Self::from_preset(Preset::from_str(value).unwrap(), input_path, output_path);
|
||||
}
|
||||
|
||||
config.input_path = PathBuf::from(input_path);
|
||||
config.output_path = PathBuf::from(output_path);
|
||||
|
||||
if let Some(value) = matches.value_of("color_mode") {
|
||||
config.color_mode = ColorMode::from_str(if value.trim() == "bw" || value.trim() == "BW" {"binary"} else {"color"}).unwrap()
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("hierarchical") {
|
||||
config.hierarchical = Hierarchical::from_str(value).unwrap()
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("mode") {
|
||||
let value = value.trim();
|
||||
config.mode = path_simplify_mode_from_str(if value == "pixel" {
|
||||
"none"
|
||||
} else if value == "polygon" {
|
||||
"polygon"
|
||||
} else if value == "spline" {
|
||||
"spline"
|
||||
} else {
|
||||
panic!("Parser Error: Curve fitting mode is invalid: {}", value);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("filter_speckle") {
|
||||
if value.trim().parse::<usize>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<usize>().unwrap();
|
||||
if value > 16 {
|
||||
panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [0,16].", value);
|
||||
}
|
||||
config.filter_speckle = value;
|
||||
} else {
|
||||
panic!("Parser Error: Filter speckle is not a positive integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("color_precision") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 1 || value > 8 {
|
||||
panic!("Out of Range Error: Color precision is invalid at {}. It must be within [1,8].", value);
|
||||
}
|
||||
config.color_precision = value;
|
||||
} else {
|
||||
panic!("Parser Error: Color precision is not an integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("gradient_step") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 0 || value > 255 {
|
||||
panic!("Out of Range Error: Gradient step is invalid at {}. It must be within [0,255].", value);
|
||||
}
|
||||
config.layer_difference = value;
|
||||
} else {
|
||||
panic!("Parser Error: Gradient step is not an integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("corner_threshold") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 0 || value > 180 {
|
||||
panic!("Out of Range Error: Corner threshold is invalid at {}. It must be within [0,180].", value);
|
||||
}
|
||||
config.corner_threshold = value
|
||||
} else {
|
||||
panic!("Parser Error: Corner threshold is not numeric: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("segment_length") {
|
||||
if value.trim().parse::<f64>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<f64>().unwrap();
|
||||
if value < 3.5 || value > 10.0 {
|
||||
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [3.5,10].", value);
|
||||
}
|
||||
config.length_threshold = value;
|
||||
} else {
|
||||
panic!("Parser Error: Segment length is not numeric: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("splice_threshold") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 0 || value > 180 {
|
||||
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [0,180].", value);
|
||||
}
|
||||
config.splice_threshold = value;
|
||||
} else {
|
||||
panic!("Parser Error: Segment length is not numeric: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("path_precision") {
|
||||
if value.trim().parse::<u32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<u32>().ok();
|
||||
config.path_precision = value;
|
||||
} else {
|
||||
panic!("Parser Error: Path precision is not an unsigned integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
pub fn from_preset(preset: Preset, input_path: &str, output_path: &str) -> Self {
|
||||
let input_path = PathBuf::from(input_path);
|
||||
let output_path = PathBuf::from(output_path);
|
||||
match preset {
|
||||
Preset::Bw => Self {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode: ColorMode::Binary,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 4,
|
||||
color_precision: 6,
|
||||
layer_difference: 16,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
corner_threshold: 60,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: 45,
|
||||
path_precision: Some(8),
|
||||
},
|
||||
Preset::Poster => Self {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode: ColorMode::Color,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 4,
|
||||
color_precision: 8,
|
||||
layer_difference: 16,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
corner_threshold: 60,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: 45,
|
||||
path_precision: Some(8),
|
||||
},
|
||||
Preset::Photo => Self {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode: ColorMode::Color,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 10,
|
||||
color_precision: 8,
|
||||
layer_difference: 48,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
corner_threshold: 180,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: 45,
|
||||
path_precision: Some(8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_converter_config(self) -> ConverterConfig {
|
||||
ConverterConfig {
|
||||
input_path: self.input_path,
|
||||
output_path: self.output_path,
|
||||
color_mode: self.color_mode,
|
||||
hierarchical: self.hierarchical,
|
||||
filter_speckle_area: self.filter_speckle * self.filter_speckle,
|
||||
color_precision_loss: 8 - self.color_precision,
|
||||
layer_difference: self.layer_difference,
|
||||
mode: self.mode,
|
||||
corner_threshold: deg2rad(self.corner_threshold),
|
||||
length_threshold: self.length_threshold,
|
||||
max_iterations: self.max_iterations,
|
||||
splice_threshold: deg2rad(self.splice_threshold),
|
||||
path_precision: self.path_precision,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deg2rad(deg: i32) -> f64 {
|
||||
deg as f64 / 180.0 * std::f64::consts::PI
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{fs::File, io::Write};
|
||||
|
||||
use fastrand::Rng;
|
||||
use visioncortex::{Color, ColorImage, ColorName};
|
||||
use visioncortex::color_clusters::{Runner, RunnerConfig, KeyingAction, HIERARCHICAL_MAX};
|
||||
use super::config::{Config, ColorMode, Hierarchical, ConverterConfig};
|
||||
use super::svg::SvgFile;
|
||||
|
||||
const NUM_UNUSED_COLOR_ITERATIONS: usize = 6;
|
||||
/// The fraction of pixels in the top/bottom rows of the image that need to be transparent before
|
||||
/// the entire image will be keyed.
|
||||
const KEYING_THRESHOLD: f32 = 0.2;
|
||||
|
||||
/// Convert an image file into svg file
|
||||
pub fn convert_image_to_svg(config: Config) -> Result<(), String> {
|
||||
let config = config.into_converter_config();
|
||||
match config.color_mode {
|
||||
ColorMode::Color => color_image_to_svg(config),
|
||||
ColorMode::Binary => binary_image_to_svg(config),
|
||||
}
|
||||
}
|
||||
|
||||
fn color_exists_in_image(img: &ColorImage, color: Color) -> bool {
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let pixel_color = img.get_pixel(x, y);
|
||||
if pixel_color.r == color.r && pixel_color.g == color.g && pixel_color.b == color.b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn find_unused_color_in_image(img: &ColorImage) -> Result<Color, String> {
|
||||
let special_colors = IntoIterator::into_iter([
|
||||
Color::new(255, 0, 0),
|
||||
Color::new(0, 255, 0),
|
||||
Color::new(0, 0, 255),
|
||||
Color::new(255, 255, 0),
|
||||
Color::new(0, 255, 255),
|
||||
Color::new(255, 0, 255),
|
||||
]);
|
||||
let rng = Rng::new();
|
||||
let random_colors = (0..NUM_UNUSED_COLOR_ITERATIONS).map(|_| {
|
||||
Color::new(
|
||||
rng.u8(..),
|
||||
rng.u8(..),
|
||||
rng.u8(..),
|
||||
)
|
||||
});
|
||||
for color in special_colors.chain(random_colors) {
|
||||
if !color_exists_in_image(img, color) {
|
||||
return Ok(color);
|
||||
}
|
||||
}
|
||||
Err(String::from("unable to find unused color in image to use as key"))
|
||||
}
|
||||
|
||||
fn should_key_image(img: &ColorImage) -> bool {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for transparency at several scanlines
|
||||
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
|
||||
let mut num_transparent_boundary_pixels = 0;
|
||||
let y_positions = [0, img.height / 4, img.height / 2, 3 * img.height / 4, img.height - 1];
|
||||
for y in y_positions {
|
||||
for x in 0..img.width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
num_transparent_boundary_pixels += 1;
|
||||
}
|
||||
if num_transparent_boundary_pixels >= threshold {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn color_image_to_svg(config: ConverterConfig) -> Result<(), String> {
|
||||
let (mut img, width, height);
|
||||
match read_image(config.input_path) {
|
||||
Ok(values) => {
|
||||
img = values.0;
|
||||
width = values.1;
|
||||
height = values.2;
|
||||
},
|
||||
Err(msg) => return Err(msg),
|
||||
}
|
||||
|
||||
let key_color = if should_key_image(&img) {
|
||||
let key_color = find_unused_color_in_image(&img)?;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
img.set_pixel(x, y, &key_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
key_color
|
||||
} else {
|
||||
// The default color is all zeroes, which is treated by visioncortex as a special value meaning no keying will be applied.
|
||||
Color::default()
|
||||
};
|
||||
|
||||
let runner = Runner::new(RunnerConfig {
|
||||
diagonal: config.layer_difference == 0,
|
||||
hierarchical: HIERARCHICAL_MAX,
|
||||
batch_size: 25600,
|
||||
good_min_area: config.filter_speckle_area,
|
||||
good_max_area: (width * height),
|
||||
is_same_color_a: config.color_precision_loss,
|
||||
is_same_color_b: 1,
|
||||
deepen_diff: config.layer_difference,
|
||||
hollow_neighbours: 1,
|
||||
key_color,
|
||||
keying_action: if matches!(config.hierarchical, Hierarchical::Cutout) {
|
||||
KeyingAction::Keep
|
||||
} else {
|
||||
KeyingAction::Discard
|
||||
},
|
||||
}, img);
|
||||
|
||||
let mut clusters = runner.run();
|
||||
|
||||
match config.hierarchical {
|
||||
Hierarchical::Stacked => {}
|
||||
Hierarchical::Cutout => {
|
||||
let view = clusters.view();
|
||||
let image = view.to_color_image();
|
||||
let runner = Runner::new(RunnerConfig {
|
||||
diagonal: false,
|
||||
hierarchical: 64,
|
||||
batch_size: 25600,
|
||||
good_min_area: 0,
|
||||
good_max_area: (image.width * image.height) as usize,
|
||||
is_same_color_a: 0,
|
||||
is_same_color_b: 1,
|
||||
deepen_diff: 0,
|
||||
hollow_neighbours: 0,
|
||||
key_color,
|
||||
keying_action: KeyingAction::Discard,
|
||||
}, image);
|
||||
clusters = runner.run();
|
||||
},
|
||||
}
|
||||
|
||||
let view = clusters.view();
|
||||
|
||||
let mut svg = SvgFile::new(width, height, config.path_precision);
|
||||
for &cluster_index in view.clusters_output.iter().rev() {
|
||||
let cluster = view.get_cluster(cluster_index);
|
||||
let paths = cluster.to_compound_path(
|
||||
&view,
|
||||
false,
|
||||
config.mode,
|
||||
config.corner_threshold,
|
||||
config.length_threshold,
|
||||
config.max_iterations,
|
||||
config.splice_threshold
|
||||
);
|
||||
svg.add_path(paths, cluster.residue_color());
|
||||
}
|
||||
|
||||
write_svg(svg, config.output_path)
|
||||
}
|
||||
|
||||
fn binary_image_to_svg(config: ConverterConfig) -> Result<(), String> {
|
||||
|
||||
let (img, width, height);
|
||||
match read_image(config.input_path) {
|
||||
Ok(values) => {
|
||||
img = values.0;
|
||||
width = values.1;
|
||||
height = values.2;
|
||||
},
|
||||
Err(msg) => return Err(msg),
|
||||
}
|
||||
let img = img.to_binary_image(|x| x.r < 128);
|
||||
|
||||
let clusters = img.to_clusters(false);
|
||||
|
||||
let mut svg = SvgFile::new(width, height, config.path_precision);
|
||||
for i in 0..clusters.len() {
|
||||
let cluster = clusters.get_cluster(i);
|
||||
if cluster.size() >= config.filter_speckle_area {
|
||||
let paths = cluster.to_compound_path(
|
||||
config.mode,
|
||||
config.corner_threshold,
|
||||
config.length_threshold,
|
||||
config.max_iterations,
|
||||
config.splice_threshold,
|
||||
);
|
||||
svg.add_path(paths, Color::color(&ColorName::Black));
|
||||
}
|
||||
}
|
||||
|
||||
write_svg(svg, config.output_path)
|
||||
}
|
||||
|
||||
fn read_image(input_path: PathBuf) -> Result<(ColorImage, usize, usize), String> {
|
||||
let img = image::open(input_path);
|
||||
let img = match img {
|
||||
Ok(file) => file.to_rgba8(),
|
||||
Err(_) => return Err(String::from("No image file found at specified input path")),
|
||||
};
|
||||
|
||||
let (width, height) = (img.width() as usize, img.height() as usize);
|
||||
let img = ColorImage {pixels: img.as_raw().to_vec(), width, height};
|
||||
|
||||
Ok((img, width, height))
|
||||
}
|
||||
|
||||
fn write_svg(svg: SvgFile, output_path: PathBuf) -> Result<(), String> {
|
||||
let out_file = File::create(output_path);
|
||||
let mut out_file = match out_file {
|
||||
Ok(file) => file,
|
||||
Err(_) => return Err(String::from("Cannot create output file.")),
|
||||
};
|
||||
|
||||
write!(&mut out_file, "{}", svg).expect("failed to write file.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright 2020 Tsang Hao Fung. See the COPYRIGHT
|
||||
// file at the top-level directory of this distribution and at
|
||||
// http://rust-lang.org/COPYRIGHT.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
|
||||
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
|
||||
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
|
||||
// option. This file may not be copied, modified, or distributed
|
||||
// except according to those terms.
|
||||
|
||||
mod config;
|
||||
mod converter;
|
||||
mod svg;
|
||||
#[cfg(feature = "python-binding")]
|
||||
mod python;
|
||||
|
||||
pub use config::*;
|
||||
pub use converter::*;
|
||||
pub use svg::*;
|
||||
#[cfg(feature = "python-binding")]
|
||||
pub use python::*;
|
||||
@@ -1,14 +0,0 @@
|
||||
use vtracer::{Config, convert_image_to_svg};
|
||||
|
||||
fn main() {
|
||||
let config = Config::from_args();
|
||||
let result = convert_image_to_svg(config);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("Conversion successful.");
|
||||
},
|
||||
Err(msg) => {
|
||||
panic!("Conversion failed with error message: {}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use visioncortex::{PathSimplifyMode};
|
||||
use super::converter::*;
|
||||
|
||||
/// Python binding
|
||||
#[pyfunction]
|
||||
fn convert_image_to_svg_py( image_path: &str,
|
||||
out_path: &str,
|
||||
colormode: Option<&str>, // "color" or "binary"
|
||||
hierarchical: Option<&str>, // "stacked" or "cutout"
|
||||
mode: Option<&str>, // "polygon", "spline", "none"
|
||||
filter_speckle: Option<usize>, // default: 4
|
||||
color_precision: Option<i32>, // default: 6
|
||||
layer_difference: Option<i32>, // default: 16
|
||||
corner_threshold: Option<i32>, // default: 60
|
||||
length_threshold: Option<f64>, // in [3.5, 10] default: 4.0
|
||||
max_iterations: Option<usize>, // default: 10
|
||||
splice_threshold: Option<i32>, // default: 45
|
||||
path_precision: Option<u32> // default: 8
|
||||
) -> PyResult<()> {
|
||||
let input_path = PathBuf::from(image_path);
|
||||
let output_path = PathBuf::from(out_path);
|
||||
|
||||
// TODO: enforce color mode with an enum so that we only
|
||||
// accept the strings 'color' or 'binary'
|
||||
let color_mode = match colormode.unwrap_or("color") {
|
||||
"color" => ColorMode::Color,
|
||||
"binary" => ColorMode::Binary,
|
||||
_ => ColorMode::Color,
|
||||
};
|
||||
|
||||
let hierarchical = match hierarchical.unwrap_or("stacked") {
|
||||
"stacked" => Hierarchical::Stacked,
|
||||
"cutout" => Hierarchical::Cutout,
|
||||
_ => Hierarchical::Stacked,
|
||||
};
|
||||
|
||||
let mode = match mode.unwrap_or("spline") {
|
||||
"spline" => PathSimplifyMode::Spline,
|
||||
"polygon" => PathSimplifyMode::Polygon,
|
||||
"none" => PathSimplifyMode::None,
|
||||
_ => PathSimplifyMode::Spline,
|
||||
};
|
||||
|
||||
let filter_speckle = filter_speckle.unwrap_or(4);
|
||||
let color_precision = color_precision.unwrap_or(6);
|
||||
let layer_difference = layer_difference.unwrap_or(16);
|
||||
let corner_threshold = corner_threshold.unwrap_or(60);
|
||||
let length_threshold = length_threshold.unwrap_or(4.0);
|
||||
let splice_threshold = splice_threshold.unwrap_or(45);
|
||||
let max_iterations = max_iterations.unwrap_or(10);
|
||||
|
||||
let config = Config {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode,
|
||||
hierarchical,
|
||||
filter_speckle,
|
||||
color_precision,
|
||||
layer_difference,
|
||||
mode,
|
||||
corner_threshold,
|
||||
length_threshold,
|
||||
max_iterations,
|
||||
splice_threshold,
|
||||
path_precision,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
convert_image_to_svg(config).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Python module implemented in Rust.
|
||||
#[pymodule]
|
||||
fn vtracer(_py: Python, m: &PyModule) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(convert_image_to_svg_py, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,65 +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#"<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.
|
||||
@@ -1,44 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1783.4 441.4" style="enable-background:new 0 0 1783.4 441.4;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#A1FCFE;}
|
||||
.st1{fill:#010357;}
|
||||
</style>
|
||||
<rect class="st0" width="1783.4" height="441.4"/>
|
||||
<g>
|
||||
<path class="st1" d="M61.4,393.2V73.8H250V113H109v100.4h124.2v39.7H109v140.1L61.4,393.2L61.4,393.2z"/>
|
||||
<path class="st1" d="M280,393.2V159.7h45.3v233.5L280,393.2L280,393.2z M330.9,92.4c0-15.5-12.5-28-28-28s-28,12.5-28,28
|
||||
s12.5,28,28,28S330.9,107.9,330.9,92.4z"/>
|
||||
<path class="st1" d="M383.5,338.6V57.9h45.3v278.8c0,21,3.7,26.1,18.7,26.1c6.1,0,11.7-0.5,16.8-1.4v30.4
|
||||
c-9.8,2.8-20.1,4.2-29.9,4.2C400.3,396,383.5,376.9,383.5,338.6z"/>
|
||||
<path class="st1" d="M480.6,275.5c0-78.5,41.6-121.4,110.2-121.4c31.3,0,55.6,9.8,72.9,29.9c17.3,19.6,26.2,45.3,26.2,77.5
|
||||
c0,7-0.5,14.9-1.9,24.3H525.4c0.9,50.9,22.4,76.6,64.4,76.6c31.8,0,53.2-14.5,53.2-42H687c0,48.6-39.7,77.5-97.6,77.5
|
||||
C521.7,397.9,480.6,357.3,480.6,275.5z M645,251.7c-0.9-41.6-19.6-62.1-55.6-62.1c-39.7,0-59.8,24.3-63.5,62.1H645z"/>
|
||||
<path class="st1" d="M720.7,323.2h44.8c1.9,28.5,18.7,38.8,51.8,38.8c28.5,0,46.7-15,46.7-35c0-20.6-16.8-28.5-48.1-36l-13.1-2.8
|
||||
l-14.5-3.7l-12.6-4.2c-6.1-1.9-10.7-3.3-13.1-4.7c-5.1-3.3-17.3-8.9-21.5-14.5c-7.5-8.9-15.4-21.5-14-38.8c0-21,8.4-37.8,25.2-50
|
||||
c17.3-12.1,37.8-18.2,62.1-18.2c55.6,0,91.5,22.4,91.5,72.4h-43c-0.5-26.6-16.8-36.4-49-36.4c-24.3,0-40.6,11.2-40.6,31.3
|
||||
c0,18.7,15.9,25.7,49,33.6l5.6,1.4c10.3,2.3,18.2,4.7,23.3,6.1c5.1,1.4,12.1,4.2,21,8.4c23.8,10.3,34.6,25.7,36.9,56
|
||||
c0,43.4-37.8,71-92.5,71C754.8,397.9,720.7,371.7,720.7,323.2z"/>
|
||||
<path class="st1" d="M964,336.7V192.4h-39.2v-32.7h39.7V94.3h44.8v65.4h64v32.7h-64v143.4c0,17.3,8.9,25.7,26.2,25.7
|
||||
c14.5,0,28-1.9,39.7-5.1v34.6c-13.6,4.2-28,6.1-43.4,6.1C986.9,397,964,375.5,964,336.7z"/>
|
||||
<path class="st1" d="M1095.3,325.5c0-44.8,35-69.6,93.4-69.6h58.4v-20.6c0-28.5-16.8-45.8-50-45.8c-35.5,0-51.8,16.8-51.8,41.6
|
||||
h-43.4c0-44.8,33.2-77.1,95.3-77.1c61.2,0,95.7,30.4,95.7,83.1v92.5c0,26.1,3.7,32.2,17.3,33.6h6.5v29.4
|
||||
c-4.7,1.4-11.7,2.3-21.5,2.3c-26.6,0-41.6-15.9-43.4-41.6c-8.9,23.8-36.4,44.4-74.3,44.4C1127.5,397.9,1095.3,368.5,1095.3,325.5z
|
||||
M1247.1,297.5v-9.3h-58.4c-32.2,0-48.6,12.6-48.6,37.4c0,22.9,14,36.9,41.6,36.9C1222.3,362.4,1247.1,334.4,1247.1,297.5z"/>
|
||||
<path class="st1" d="M1351.7,395.1V161.6h43.9l0.9,36.4h0.9c11.7-24.8,34.1-40.6,62.1-40.6c8.4,0,15.9,0.9,22.9,3.3v38.8
|
||||
c-7.5-1.4-15.9-1.9-25.7-1.9c-33.2,0-59.3,28-59.3,72.9v124.7H1351.7z"/>
|
||||
<g>
|
||||
<g>
|
||||
<rect x="1549" y="146.5" transform="matrix(0.866 -0.5 0.5 0.866 137.8024 841.2704)" class="st1" width="179.4" height="34"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1621.7" y="73.8" transform="matrix(0.5 -0.866 0.866 0.5 677.7373 1500.9166)" class="st1" width="34" height="179.4"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1621.7" y="73.8" class="st1" width="34" height="179.4"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -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::*;
|
||||