Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74c29b1a8c | |||
| 5a69ee33a8 | |||
| 2500df76b5 | |||
| 1ddc9ebbf7 | |||
| 327e4b175f | |||
| 1adc60aa0d | |||
| d413a28779 | |||
| f01c709cd1 | |||
| e4210e926c | |||
| 712ce3bf50 | |||
| 845f6a1ed1 | |||
| 74da0efb84 | |||
| 0d77ecf6f9 | |||
| 6f163fcf7d | |||
| febcc28920 | |||
| bf9858e359 | |||
| 7d754e17c3 | |||
| 58221025d5 | |||
| 4a7820bf03 | |||
| ae6731017f | |||
| f9112c5ba9 | |||
| 222cc6cbff | |||
| f4fe428038 | |||
| 640270a608 | |||
| 87dd8f5168 | |||
| 2ef4bfb619 | |||
| ef9496f792 | |||
| d585984e78 | |||
| 9650808263 | |||
| 93a9939587 | |||
| 6671d7d45e | |||
| fb75738328 | |||
| 7864a702da | |||
| 39cc49061e | |||
| e2b47dab0f | |||
| c3f56a6339 | |||
| 46a1b90ccd | |||
| f4872fcfe3 | |||
| 2b0f316778 | |||
| f6c8a139a4 | |||
| ba2b80455e | |||
| 5ff4f3ae05 | |||
| 84ba49f0b9 | |||
| df94675494 | |||
| abe21658dc | |||
| 5361a51011 | |||
| a350e2532a | |||
| 50042f477d | |||
| 1e3895318e | |||
| 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 | |||
| d5dfa9fd73 | |||
| 685009bd21 | |||
| 971bffa948 | |||
| 7be6882d6b | |||
| 386a0bcaab | |||
| 82284ab470 | |||
| ead104f0ce | |||
| a7219fd370 | |||
| 2448dbb3ba | |||
| c9d9073b17 | |||
| 47091b0bf5 | |||
| 8439cc995d | |||
| 0b292ad35f | |||
| ebb17ac22b | |||
| 227850dd83 | |||
| e0dac19c31 | |||
| d18d4f8b81 | |||
| 60a4ce579f | |||
| c1c09d964c | |||
| c2a5626afa | |||
| d0593e716a | |||
| 9ce7df176a | |||
| 5928c0a7f5 | |||
| b2c95a50cd | |||
| bcd3ffb40e | |||
| 20da3efd3c | |||
| 0c27d1f06a | |||
| 0c7c7fc808 |
@@ -0,0 +1 @@
|
||||
docs/** linguist-generated
|
||||
@@ -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,8 @@
|
||||
target
|
||||
# Track the workspace lockfile (reproducible binary/CI builds); keep the
|
||||
# nested crates' lockfiles (nodejs, vtracer-py, webapp) ignored.
|
||||
Cargo.lock
|
||||
!/Cargo.lock
|
||||
*.sublime*
|
||||
.vscode
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## 1.0.0-alpha.4 - 2026-08-29
|
||||
|
||||
### Changed
|
||||
|
||||
* `--watershed-detail` (and `watershed_detail` on `Config` and in the Python and Node bindings) is no longer capped at 255 — it now accepts any value, so extremely fine segmentation is reachable past the old ceiling. The default stays 128 and each +25.5 still roughly doubles the region count.
|
||||
* Bump `visioncortex` to 0.9.3 — a minor upstream bug fix.
|
||||
|
||||
## 1.0.0-alpha.3 - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
* `vtracer-bench`: a blind fidelity benchmark for raster-to-vector tracers — it compares an original raster against a rendered reconstruction and reports one 0..1 score built from PSNR, SSIM, and a clustered-diff "missing patch" metric (geometric mean, so a single collapsed axis drags the score down). Blind to how the reconstruction was produced: render any tracer's output to pixels and score it. A new workspace crate, separate from the four shipped packages.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Watershed no longer leaks a region along a blurred low-contrast crack as a 1-px filament (a slightly soft image could grow a hairline of one region's color running tens of px down a neighbouring boundary). The boundary snap's mixture gate now also admits pixels that blend two *neighbouring* regions — a blend band belongs to its closer flank even when the basin cut misattributed it to a distant region. On the blurred striped synthetic the circle's max boundary error drops from 31.6 px to 1.5 px; crisp images are byte-unaffected.
|
||||
|
||||
## 1.0.0-alpha.2 - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
* Watershed clustering (`--clustering watershed`): a new region-forming frontend — a hierarchical watershed on the pixel graph (Cousty et al. 2009; Najman et al. 2013), controlled by one dial, `--watershed-detail` (0..=255; each +25.5 roughly doubles the region count). Regions follow image content, with no watershed-line pixels.
|
||||
* Boundaries come out calm: antialiased pixels snap to the color-midpoint iso-line instead of meandering with the noise inside the ramp.
|
||||
* `stacked` stacks the merge tree itself (coarse ancestors below, refined regions on top), so overdraw stays seam-free.
|
||||
* `cutout` gets the partition natively; neighbouring faces closer than `max(2, (255 − detail) / 8)` merge, so faces a human cannot tell apart never survive as separate patches.
|
||||
* `WatershedHierarchy` is public, split into `build` (expensive, image-only) and `cut` (near-instant); `Session` re-cuts a cached hierarchy on detail changes, making the slider fully interactive (~25 ms vs ~40 ms on a 1400×775 photo).
|
||||
* Curve simplification (`--simplify <tolerance>`, `Config::simplify`, `simplify` in Python and Node; off by default): a paper.js-style Schneider re-fit — each smooth run between corners is redrawn with the fewest cubics that stay within the tolerance (px). Roughly halves file size (sample photo at tolerance 1: 229 → 138 KB stacked, 103 → 36 KB watershed cutout). Runs on fitted geometry before composition, so cutout simplifies each shared boundary once and stays seam-free; corners and junction endpoints stay pinned.
|
||||
* Binary thresholding: a tunable fixed threshold (`--threshold`) and Bradley–Roth adaptive thresholding for uneven lighting (`--adaptive`, `--adaptive-window`, `--adaptive-t`) — also on `Config`, Python, and Node.
|
||||
* Cutout mode merges neighbouring faces whose colors are within one gradient step, rejoining the near-identical faces that stacked gradient layering splits a smooth area into.
|
||||
|
||||
### Changed
|
||||
|
||||
* `color_mode` is replaced by `clustering` (`color-cluster` | `bw` | `watershed`) across the CLI, Rust, Python, and Node — the field selects the region-forming algorithm, not a color space.
|
||||
* The spline fine-tuning flags (`--corner-threshold`, `--segment-length`, `--splice-threshold`) are hidden from CLI help — still accepted, but without their `-c`/`-l`/`-s` short forms. The defaults serve virtually every conversion; `--simplify` supersedes them.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Spline fitting no longer swings far away from the outline around thin strands (a long-standing defect, fixed via visioncortex 0.9.1): a sparse splice slice could be fitted by a single cubic that passed through every sample yet ballooned up to ~30 px sideways between them. Slices are now densified before fitting and multi-cubic fits kept in full, in both stacked mode and the mosaic fitter.
|
||||
|
||||
## 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) https://github.com/visioncortex/vtracer/pull/23
|
||||
|
||||
## 0.4.0 - 2021-07-23
|
||||
|
||||
* SVG path string numeric precision
|
||||
|
||||
## 0.3.0 - 2021-01-24
|
||||
|
||||
* Added cutout mode
|
||||
|
||||
## 0.2.0 - 2020-11-15
|
||||
|
||||
* Use relative & closed paths
|
||||
|
||||
## 0.1.1 - 2020-11-01
|
||||
|
||||
* SVG namespace
|
||||
|
||||
## 0.1.0 - 2020-10-31
|
||||
|
||||
* Initial release
|
||||
@@ -0,0 +1,62 @@
|
||||
# VTracer App Changelog
|
||||
|
||||
All notable changes to the VTracer desktop app will be documented in this file.
|
||||
|
||||
## 1.0.0-alpha.4 - Build 116 - 2026-08-29
|
||||
|
||||
### Added
|
||||
|
||||
* VTracer 2 preview. Trial license can be activated in app.
|
||||
* SVG saving without a dialog. A preferred save folder can be set in Preferences, or the app can keep asking each time.
|
||||
* A source image display preference, choosing smooth or pixelated scaling for the source image.
|
||||
* Selected SVG shapes now show their bounds, and can be dragged around the canvas to inspect what sits beneath them.
|
||||
* A configurable disk cache limit for VTracer 2, defaulting to 2 GB. Lowering it reclaims space immediately.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Pasting an image now works consistently across the app rather than only when the canvas held focus.
|
||||
|
||||
## 1.0.0-alpha.3 - Build 59 - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
* User-managed presets. A preset stores the complete tracing configuration and its source image; presets can be created or updated by name, reordered, deleted, and restored to the shipped defaults.
|
||||
* Side-by-side comparison mode with synchronized, mirrored views of the source image and generated SVG. Switching between overlay and side-by-side comparison preserves the canvas position and zoom.
|
||||
* A new etched-cat sample demonstrating adaptive black-and-white tracing.
|
||||
|
||||
### Changed
|
||||
|
||||
* The canvas comparator and SVG rendering now remain sharp and stable while zooming, panning, dragging the divider, and switching comparison modes.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Trace sessions recover after an engine panic instead of leaving the app stuck with an unavailable session cache.
|
||||
* Large images and repeated image uploads no longer leave the frontend unable to start a new trace.
|
||||
|
||||
## 1.0.0-alpha.3 - 2026-08-01
|
||||
|
||||
First public preview of the rebuilt VTracer desktop app.
|
||||
|
||||
### Added
|
||||
|
||||
* Native VTracer 1.0 tracing for macOS and Windows, with Linux packaging support.
|
||||
* Cancellable tracing with stage-aware progress. Changing a tracing control aborts obsolete work and immediately starts the new trace.
|
||||
* Session caching that reuses expensive clustering work while tuning compatible curve and color fitting settings.
|
||||
* An interactive source/SVG comparator with a draggable divider, zoom controls, scroll-to-zoom, drag-to-pan, actual-size and fit modes, and a focused full-canvas view.
|
||||
* SVG shape inspection with yellow hover outlines and selectable curve nodes.
|
||||
* Full controls for color, black-and-white, and watershed clustering; stacked and seam-free cutout composition; pixel, polygon, and spline fitting; fixed palettes; adaptive thresholding; and curve simplification.
|
||||
* Open, paste, and drag-and-drop image input, including EXIF orientation normalization for camera images.
|
||||
* A scrollable sample preset strip with image credits.
|
||||
* Native SVG save dialogs and save completion feedback.
|
||||
* System light and dark themes.
|
||||
* In-app update checks, engine release notes, and open-source license information.
|
||||
|
||||
### Changed
|
||||
|
||||
* Tracing runs in the native backend rather than WebAssembly.
|
||||
* The desktop shell serves the frontend and API over an authenticated loopback HTTP session, aligning development and production behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
* External links open directly in the default browser on macOS and Windows without displaying a command window.
|
||||
* Native context menus are suppressed across production app surfaces.
|
||||
@@ -0,0 +1,10 @@
|
||||
# VTracer 2 Changelog
|
||||
|
||||
All notable changes to the VTracer 2 engine will be documented in this file.
|
||||
|
||||
## 1.0.0-alpha.4 - Build 116 - 2026-08-29
|
||||
|
||||
Initial release. Supported systems:
|
||||
|
||||
* macOS on Apple Silicon
|
||||
* Windows 10 x64 with TPM, Windows 11
|
||||
@@ -1,6 +1,34 @@
|
||||
[workspace]
|
||||
|
||||
members = [
|
||||
"cmdapp",
|
||||
"crates/vtracer",
|
||||
"crates/vtracer-cli",
|
||||
"crates/vtracer-bench",
|
||||
]
|
||||
|
||||
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
|
||||
# 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.4"
|
||||
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "http://www.visioncortex.org/vtracer"
|
||||
repository = "https://github.com/visioncortex/vtracer/"
|
||||
|
||||
[workspace.dependencies]
|
||||
visioncortex = "0.9.3"
|
||||
# Schneider curve fitting for the simplify pass. visioncortex pins an old
|
||||
# flo_curves internally for legacy reasons; we depend on the current one
|
||||
# directly and convert at the call boundary.
|
||||
flo_curves = "0.8"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020 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,78 +4,244 @@
|
||||
<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">Document</a>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64-setup.exe">Windows App</a>
|
||||
<span> | </span>
|
||||
<a href="//www.visioncortex.org/vtracer/">Demo</a>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_universal.dmg">macOS App</a>
|
||||
<span> | </span>
|
||||
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64.AppImage">Linux App</a>
|
||||
</h3>
|
||||
|
||||
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
|
||||
<p>
|
||||
<a href="https://crates.io/crates/vtracer"><img src="https://img.shields.io/crates/v/vtracer.svg?label=crates.io&color=blue" alt="Rust library on crates.io"></a>
|
||||
<a href="https://pypi.org/project/vtracer/"><img src="https://img.shields.io/pypi/v/vtracer.svg?label=PyPI&color=blue" alt="Python package on PyPI"></a>
|
||||
<a href="https://www.npmjs.com/package/@visioncortex/vtracer"><img src="https://img.shields.io/npm/v/@visioncortex/vtracer.svg?label=npm&color=blue" alt="Node package on npm"></a>
|
||||
</p>
|
||||
|
||||
</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]() which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
|
||||
Comparing to Potrace, VTracer has an image processing pipeline which can handle colored images. VTracer skips Potrace's expensive optimal-polygon search in favor of a fast, linear pipeline that stays faithful to high-resolution images.
|
||||
|
||||
Comparing to Adobe Illustrator's Live Trace, VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
|
||||
Comparing to Adobe Illustrator's Image Trace, VTracer's output is much more compact 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. By setting both `filter_speckle` and `layer_difference` to `0`, VTracer can also handle low resolution pixel art, effectively achieving `image-rendering: pixelated` for retro game artworks.
|
||||
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
|
||||
## Desktop 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 + HTML5 platform.
|
||||

|
||||
|
||||

|
||||
VTracer App powered by the 1.0 tracing engine:
|
||||
|
||||

|
||||
+ **Native speed**: faster conversions on large images
|
||||
+ **A/B comparator**: compare the trace against the original
|
||||
+ **Curve inspector**: inspect fitted curves up close
|
||||
+ **Curve simplification**: fewer nodes, often half the file size
|
||||
+ **Seam-free cutout**: gapless shapes with shared boundaries
|
||||
+ **Watershed clustering**: edge-aware regions for sharper image traces
|
||||
+ **Adaptive B/W thresholding**: cleaner traces from uneven scans and photos
|
||||
+ **Fixed color palettes**: snap output to your own colors
|
||||
|
||||
## Command Line
|
||||
```
|
||||
visioncortex VTracer
|
||||
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
|
||||
-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
|
||||
--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
|
||||
## Console App
|
||||
|
||||
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
|
||||
|
||||
You can also install the program from source:
|
||||
|
||||
```sh
|
||||
cargo install vtracer-cli
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
# scanned/photographed line art with uneven lighting
|
||||
./vtracer scan.jpg output.svg --clustering bw --adaptive
|
||||
|
||||
# seam-free mosaic (gapless tessellation)
|
||||
./vtracer input.jpg output.svg --hierarchical cutout
|
||||
|
||||
# watershed region forming, cut to taste
|
||||
./vtracer photo.jpg output.svg --clustering watershed --watershed-detail 192
|
||||
|
||||
# constrain to a fixed palette
|
||||
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
|
||||
```
|
||||
|
||||
## Library
|
||||
Full options:
|
||||
|
||||
The library can be found on [crates.io/vtracer](//crates.io/crates/vtracer).
|
||||
```sh
|
||||
Usage: vtracer [OPTIONS] [INPUT] [OUTPUT]
|
||||
|
||||
### Install
|
||||
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
|
||||
--clustering <CLUSTERING> Region forming: `color-cluster` (default), `bw`, `watershed`
|
||||
--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)
|
||||
--simplify <TOLERANCE> Simplify curves: fewest cubics within this tolerance in px (try 1–2.5)
|
||||
--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+cleanup, 2 = + shorthands
|
||||
--threshold <THRESHOLD> Binary mode: fixed threshold 0..=255 (foreground below it)
|
||||
--adaptive Binary mode: Bradley–Roth adaptive threshold (uneven lighting)
|
||||
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
|
||||
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
|
||||
--watershed-detail <WATERSHED_DETAIL> Watershed: hierarchy cut level (default 128; higher = more regions, uncapped)
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
vtracer = "*"
|
||||
|
||||
The spline fine-tuning flags `--corner-threshold <0..=180>`,
|
||||
`--segment-length <3.5..=10>`, and `--splice-threshold <0..=180>` are still
|
||||
accepted but hidden from `--help`: their defaults (60 / 4 / 45) serve
|
||||
virtually every conversion, and `--simplify` is the knob that actually moves
|
||||
output size and smoothness.
|
||||
|
||||
### 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.
|
||||
- **Binary thresholding**: a tunable fixed cutoff (`--threshold`) or
|
||||
**Bradley–Roth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
|
||||
/ `--adaptive-t`) for scans with uneven lighting.
|
||||
- **`--simplify <tolerance>`**: paper.js-style curve simplification: re-fits
|
||||
smooth runs with the fewest cubics that stay within the tolerance (px),
|
||||
typically halving file size; seam-free in cutout mode because shared
|
||||
boundaries are simplified once for both faces.
|
||||
- **`--clustering watershed`**: an alternative region-forming algorithm: a
|
||||
hierarchical watershed on the pixel graph (Cousty et al., TPAMI 2009; Najman,
|
||||
Cousty & Perret, ISMM 2013), cut at `--watershed-detail`. Content-adaptive
|
||||
regions that follow object shape: pairs beautifully with `cutout`.
|
||||
|
||||
## Programming Libraries
|
||||
|
||||
VTracer 1.0 is a vectorization framework with pluggable stages: segmentation, curve fitting, color fitting, and output optimization — usable from the command line or as a library in Rust, Python, and JavaScript:
|
||||
|
||||
| Package | Registry | Source | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `vtracer-cli` | [crates.io](https://crates.io/crates/vtracer-cli) | [`crates/vtracer-cli`](crates/vtracer-cli) | Command-line tool (`vtracer` binary) |
|
||||
| `vtracer` | [crates.io](https://crates.io/crates/vtracer) | [`crates/vtracer`](crates/vtracer) | Rust library / the framework core |
|
||||
| `vtracer` | [PyPI](https://pypi.org/project/vtracer/) | [`crates/vtracer-py`](crates/vtracer-py) | Python native extension (pyo3 + maturin) |
|
||||
| `@visioncortex/vtracer` | [npm](https://www.npmjs.com/package/@visioncortex/vtracer) | [`nodejs`](nodejs) | Node.js WebAssembly build, no native dependency |
|
||||
|
||||
### Rust Library
|
||||
|
||||
```sh
|
||||
cargo add vtracer@1.0.0-alpha.4
|
||||
```
|
||||
|
||||
```rust
|
||||
use vtracer::{ColorImage, Config, FitMode, Hierarchical, Preset, Session};
|
||||
|
||||
// Decode with whatever you like, then hand over pixels.
|
||||
let raw = image::open("in.png")?.to_rgba8();
|
||||
let (width, height) = (raw.width() as usize, raw.height() as usize);
|
||||
let img = ColorImage { pixels: raw.into_raw(), width, height };
|
||||
|
||||
// one-liner
|
||||
let svg = Config::default().build()?.to_svg(&img)?;
|
||||
|
||||
// presets + per-field config
|
||||
let mut cfg = Config::from_preset(Preset::Poster);
|
||||
cfg.mode = FitMode::Polygon;
|
||||
cfg.hierarchical = Hierarchical::Cutout; // seam-free mosaic
|
||||
cfg.max_colors = Some(8);
|
||||
let svg = cfg.build()?.to_svg(&img)?;
|
||||
```
|
||||
|
||||
Split the pipeline when you want the stages separately — `segment` caches, `finish` re-runs:
|
||||
|
||||
```rust
|
||||
let pipeline = cfg.build()?;
|
||||
let seg = pipeline.segment(&img)?; // the expensive part
|
||||
let doc = pipeline.finish(&seg)?; // VectorDoc, ready to serialize
|
||||
```
|
||||
|
||||
See [docs.rs/vtracer](https://docs.rs/vtracer/1.0.0-alpha.4/vtracer/) for the full API.
|
||||
|
||||
### Python Library
|
||||
|
||||
```sh
|
||||
pip install vtracer==1.0.0a4
|
||||
```
|
||||
|
||||
```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")
|
||||
|
||||
# watershed region forming
|
||||
ws = vtracer.Config(clustering="watershed", watershed_detail=192)
|
||||
svg = ws.convert_file("photo.jpg", "photo.svg")
|
||||
|
||||
# binary with adaptive (Bradley–Roth) thresholding
|
||||
bw = vtracer.Config(clustering="bw", adaptive=True)
|
||||
svg = bw.convert_file("scan.jpg", "scan.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**. Decodes PNG, JPEG, GIF, BMP, and WebP; for other formats, decode yourself and pass raw RGBA to `convertPixels`.
|
||||
|
||||
```sh
|
||||
npm install @visioncortex/vtracer@1.0.0-alpha.4
|
||||
```
|
||||
|
||||
```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, { clustering: 'bw' });
|
||||
|
||||
// binary with adaptive thresholding
|
||||
const bw = vtracer.convertBuffer(buffer, { clustering: 'bw', adaptive: true });
|
||||
```
|
||||
|
||||
## 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,22 +0,0 @@
|
||||
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,16 +0,0 @@
|
||||
[package]
|
||||
name = "vtracer"
|
||||
version = "0.3.0"
|
||||
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
|
||||
edition = "2018"
|
||||
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 = "0.4.0"
|
||||
@@ -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,376 +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(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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`"));
|
||||
|
||||
// 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 with value {}", 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 < 1 || value > 16 {
|
||||
panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [1,16].", value);
|
||||
}
|
||||
config.filter_speckle = value;
|
||||
} else {
|
||||
panic!("Parser Error: Filter speckle is not a positive integer with value {}.", 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 with value {}.", 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 with value {}.", 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 with value {}.", 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 with value {}.", 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 with value {}.", 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deg2rad(deg: i32) -> f64 {
|
||||
deg as f64 / 180.0 * std::f64::consts::PI
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{fs::File, io::Write};
|
||||
|
||||
use visioncortex::{Color, ColorImage, ColorName};
|
||||
use visioncortex::color_clusters::{Runner, RunnerConfig, HIERARCHICAL_MAX};
|
||||
use super::config::{Config, ColorMode, Hierarchical, ConverterConfig};
|
||||
use super::svg::SvgFile;
|
||||
|
||||
/// 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_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 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,
|
||||
}, 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,
|
||||
}, image);
|
||||
clusters = runner.run();
|
||||
},
|
||||
}
|
||||
|
||||
let view = clusters.view();
|
||||
|
||||
let mut svg = SvgFile::new(width, height);
|
||||
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);
|
||||
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_rgba(),
|
||||
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,17 +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;
|
||||
|
||||
pub use config::*;
|
||||
pub use converter::*;
|
||||
pub use svg::*;
|
||||
@@ -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,57 +0,0 @@
|
||||
use std::fmt;
|
||||
use visioncortex::{Color, CompoundPath, PointF64};
|
||||
|
||||
pub struct SvgFile {
|
||||
pub paths: Vec<SvgPath>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
pub struct SvgPath {
|
||||
pub path: CompoundPath,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
impl SvgFile {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
SvgFile {
|
||||
paths: vec![],
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
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(f)?;
|
||||
};
|
||||
|
||||
writeln!(f, "</svg>")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SvgPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let (string, offset) = self.path.to_svg_string(true, PointF64::default());
|
||||
writeln!(
|
||||
f, "<path d=\"{}\" fill=\"{}\" transform=\"translate({},{})\"/>",
|
||||
string, self.color.to_hex_string(),
|
||||
offset.x, offset.y
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "vtracer-bench"
|
||||
description = "Blind fidelity benchmark for raster-to-vector tracers: compare the original raster with a rendered reconstruction and get one 0..1 fidelity score built from PSNR, SSIM and a clustered-diff patch metric."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
categories = ["graphics", "development-tools::testing"]
|
||||
keywords = ["vectorization", "benchmark", "fidelity", "ssim", "psnr"]
|
||||
|
||||
[dependencies]
|
||||
visioncortex.workspace = true
|
||||
dssim-core = "3"
|
||||
rgb = "0.8"
|
||||
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
@@ -0,0 +1,104 @@
|
||||
# vtracer-bench
|
||||
|
||||
Blind fidelity benchmark for raster-to-vector tracers.
|
||||
|
||||
It compares an **original raster** with a **rendered reconstruction** and reports one number — a fidelity score in **[0, 1]** — built from three complementary axes. It is *blind* in the sense that it knows nothing about how the reconstruction was produced: any tracer, any format, any renderer. Render your vector output to pixels (same dimensions as the original), then let the benchmark judge.
|
||||
|
||||
```console
|
||||
$ vtracer-bench original.png reconstruction.png
|
||||
psnr 34.77 dB (rmse 4.66) -> 0.6875
|
||||
ssim 0.99541 (dssim 0.00461) -> 0.9954
|
||||
patch 157.8 px rms (14503 bad px, 74 clusters, largest 73) -> 0.9726
|
||||
fidelity 0.9022
|
||||
[csv] 0.9022,34.77,0.00461,4.66,157.8,0.6875,0.9954,0.9726
|
||||
```
|
||||
|
||||
## Why another metric?
|
||||
|
||||
Every classic metric has a blind spot, and tracers exploit all of them:
|
||||
|
||||
- **PSNR** over-values invisible dust and undersells small salient regions — a tracer that drops an eye but nails the background can post a great PSNR.
|
||||
- **SSIM** tracks perceived quality well, but averages globally: a small, fully-lost region barely moves it.
|
||||
- Neither can tell **a thousand scattered ±1 pixels** apart from **one coherent missing patch** of the same total mass — and the missing patch is the failure that actually matters.
|
||||
|
||||
`vtracer-bench` scores all three axes and combines them so that no single blind spot survives:
|
||||
|
||||
| axis | raw metric | subscore in [0, 1] |
|
||||
| --- | --- | --- |
|
||||
| `psnr` | sRGB PSNR over RGB | `1 − log(1+rmse) / log(256)` |
|
||||
| `ssim` | multiscale DSSIM (`dssim-core`) | `SSIM = 1 / (1 + DSSIM)` |
|
||||
| `patch` | clustered-diff "missing patch" detector | `2^(−P / 0.005)` |
|
||||
|
||||
**fidelity = ( psnr¹ · ssim² · patch¹ ) ^ (1/4)** — a *weighted geometric mean*. Geometric, not arithmetic, so a single collapsed axis drags the composite down: a missing face region cannot hide behind good global PSNR. SSIM carries double weight because it tracks visual accuracy best and is the axis most robust to an imperfect source.
|
||||
|
||||
## The three axes
|
||||
|
||||
### psnr — parameter-free squash
|
||||
|
||||
The squash `1 − log(1+rmse)/log(256)` is anchored at the only two natural error scales an 8-bit image has:
|
||||
|
||||
- `rmse = 255` (the full range — noise indistinguishable from a random image) → **0**
|
||||
- `rmse ≤ 1` (the quantization step — errors 8-bit can barely represent) → saturates to **1**
|
||||
|
||||
For `rmse ≫ 1` it equals `psnr / 48.13 dB`, i.e. it stays linear in decibels, but with no hand-picked anchor constants.
|
||||
|
||||
### ssim — perceptual structure
|
||||
|
||||
`dssim-core` computes multiscale structural dissimilarity `d = 1/SSIM − 1`; the subscore is simply `SSIM = 1/(1+d)`, already a natural 0..1. Differences the eye can't see score ~1 regardless of how many pixels they touch.
|
||||
|
||||
### patch — the missing-patch detector
|
||||
|
||||
This is the axis PSNR and SSIM both lack:
|
||||
|
||||
1. A pixel is **bad** iff its RGB Euclidean distance to the original exceeds `--thresh` (default 24 — roughly 14 per channel).
|
||||
2. The bad mask is **opened** (one round of 4-connected erode + dilate). A slightly blurred or recompressed *source* shifts every edge and paints ≤2 px filaments along all boundaries; those vanish under the opening, while genuine missing patches survive. This is what makes the benchmark tolerant of mildly compressed or blurred originals.
|
||||
3. The surviving mask is clustered (4-connected). With cluster areas `aᵢ`, the **patch mass** is `√(Σ aᵢ²)` — a sum of *squares*, so one coherent blob dominates any amount of scattered dust of equal total area.
|
||||
4. With `P = patch mass / (w·h)`, the subscore is `2^(−P/0.005)`: a single coherent blob at 0.5 % of image mass halves the score; scattered dust barely dents it.
|
||||
|
||||
## Calibration
|
||||
|
||||
Scored on a 768×1024 flat-shaded illustration, comparing the original against distorted versions of **itself** — this is how much slack the benchmark gives an imperfect source, and what the top of the scale means:
|
||||
|
||||
| candidate | psnr | ssim | patch | **fidelity** |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| the original itself | 1.000 | 1.000 | 1.000 | **1.0000** |
|
||||
| JPEG quality 95 | 0.816 | 1.000 | 1.000 | **0.9502** |
|
||||
| JPEG quality 75 | 0.718 | 0.999 | 0.994 | **0.9186** |
|
||||
| 0.8 px Gaussian blur | 0.596 | 0.995 | 0.861 | **0.8443** |
|
||||
|
||||
Rule of thumb: **≥ 0.95** is visually indistinguishable, **≥ 0.90** is a faithful trace, **≤ 0.80** has visible geometry or color errors, and a score that *collapses* while PSNR/SSIM stay high means the patch axis found a coherent missing region — look at the `--mask` output.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```console
|
||||
vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
|
||||
```
|
||||
|
||||
- `original`, `candidate` — rasters of identical dimensions (any format `image` decodes). Rendering an SVG to pixels is deliberately out of scope: use the renderer whose output you actually ship (resvg, Chromium, librsvg, …) so the benchmark judges what users see.
|
||||
- `--thresh N` — RGB Euclidean bad-pixel gate for the patch axis (default 24).
|
||||
- `--mask out.png` — write the raw bad-pixel mask (before the opening) for visual inspection.
|
||||
|
||||
The last stdout line is machine-readable:
|
||||
|
||||
```
|
||||
[csv] fidelity,psnr,dssim,rmse,patch_mass,s_psnr,s_ssim,s_patch
|
||||
```
|
||||
|
||||
(RMSE is reported for reference but carries no weight — it is the same MSE that PSNR measures, only on a linear curve; scoring both would double-weight one error.)
|
||||
|
||||
### Library
|
||||
|
||||
```rust
|
||||
use vtracer_bench::{fidelity, DEFAULT_THRESH};
|
||||
|
||||
// orig and cand are interleaved RGB8, both w×h
|
||||
let (report, bad_mask) = fidelity(&orig, &cand, w, h, DEFAULT_THRESH);
|
||||
println!("fidelity {:.4} (psnr {:.2} dB, dssim {:.5})",
|
||||
report.fidelity, report.psnr, report.dssim);
|
||||
```
|
||||
|
||||
`FidelityReport` exposes every raw metric and subscore; the tuning constants (`PATCH_HALF`, `DEFAULT_THRESH`, and the `W_PSNR`/`W_SSIM`/`W_PATCH` weights) are public and documented in `lib.rs`.
|
||||
|
||||
The benchmark is fully deterministic: identical inputs produce byte-identical output.
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Universal tracer fidelity benchmark — original vs reconstruction, blind to
|
||||
//! how the reconstruction was made. Three raw metrics, each squashed to [0,1],
|
||||
//! composed by geometric mean into ONE fidelity score (0 = garbage, 1 = exact):
|
||||
//!
|
||||
//! psnr sRGB PSNR over RGB. Squash: 1 − log(1+rmse)/log(256) — anchored
|
||||
//! at the two natural scales of 8-bit imagery and nothing else:
|
||||
//! rmse = 255 (full range) → 0, rmse ≤ 1 (the quantization step)
|
||||
//! saturates to 1. Equals psnr/48.13dB for rmse ≫ 1, i.e. still
|
||||
//! linear in dB, without arbitrary anchor constants.
|
||||
//! ssim dssim-core multiscale DSSIM d (= 1/SSIM − 1) → SSIM = 1/(1+d),
|
||||
//! already a natural 0..1.
|
||||
//! patch the "missing patch" / systematic-bias detector: bad ⟺ RGB
|
||||
//! Euclidean diff > thresh, OPEN the bad mask (1-round 4-conn
|
||||
//! erode+dilate — a slightly blurred or compressed source shifts
|
||||
//! every edge and paints ≤2px filaments along all boundaries; those
|
||||
//! vanish, real patches survive), then cluster it (visioncortex,
|
||||
//! 4-conn), S = Σ area². Patch mass fraction P = √S / (w·h) — the RMS
|
||||
//! coherent-blob size as a fraction of the image. Squash: 2^(−P/0.005),
|
||||
//! so ONE coherent blob at 0.5% image mass halves the score while the
|
||||
//! same pixel count scattered as dust barely dents it. Exactly the
|
||||
//! failure mode PSNR/SSIM average away.
|
||||
//!
|
||||
//! Composite: weighted geometric mean, fidelity = (psnr¹ · ssim² · patch¹)^(1/4).
|
||||
//! Geometric (not arithmetic) so a single collapsed axis drags the composite
|
||||
//! down — a missing eye can't hide behind good global PSNR. SSIM carries double
|
||||
//! weight: it tracks visual accuracy best and is the axis most robust to a
|
||||
//! mildly compressed or blurred source.
|
||||
|
||||
use visioncortex::BinaryImage;
|
||||
|
||||
/// Patch mass fraction that halves the patch subscore.
|
||||
pub const PATCH_HALF: f64 = 0.005;
|
||||
/// Default RGB Euclidean distance for a pixel to count as "bad".
|
||||
pub const DEFAULT_THRESH: f64 = 24.0;
|
||||
/// Composite weights (geometric): fidelity = (psnr^1 · ssim^2 · patch^1)^(1/4).
|
||||
pub const W_PSNR: f64 = 1.0;
|
||||
pub const W_SSIM: f64 = 2.0;
|
||||
pub const W_PATCH: f64 = 1.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FidelityReport {
|
||||
// raw
|
||||
pub psnr: f64,
|
||||
pub dssim: f64,
|
||||
/// sRGB RMSE — reported for reference, carries no weight (PSNR is the
|
||||
/// same MSE on a log curve; scoring both would double-weight it)
|
||||
pub rmse: f64,
|
||||
/// bad pixels (‖Δrgb‖ > thresh), before the opening
|
||||
pub bad_px: usize,
|
||||
/// 4-conn clusters of bad pixels after the opening
|
||||
pub clusters: usize,
|
||||
/// largest cluster area (px)
|
||||
pub largest: usize,
|
||||
/// √(Σ area²) — RMS coherent-blob mass, in px
|
||||
pub patch_mass: f64,
|
||||
// subscores in [0,1]
|
||||
pub s_psnr: f64,
|
||||
pub s_ssim: f64,
|
||||
pub s_patch: f64,
|
||||
/// geometric mean of the three subscores
|
||||
pub fidelity: f64,
|
||||
}
|
||||
|
||||
fn dssim_score(a_rgb: &[u8], b_rgb: &[u8], w: usize, h: usize) -> f64 {
|
||||
let d = dssim_core::Dssim::new();
|
||||
let to = |buf: &[u8]| {
|
||||
let px: Vec<rgb::RGB<u8>> = (0..w * h)
|
||||
.map(|i| rgb::RGB {
|
||||
r: buf[i * 3],
|
||||
g: buf[i * 3 + 1],
|
||||
b: buf[i * 3 + 2],
|
||||
})
|
||||
.collect();
|
||||
d.create_image_rgb(&px, w, h).expect("dssim image")
|
||||
};
|
||||
let (val, _) = d.compare(&to(a_rgb), &to(b_rgb));
|
||||
val.into()
|
||||
}
|
||||
|
||||
/// Compare an original against a candidate reconstruction, both RGB8, w×h.
|
||||
/// `thresh` is the RGB Euclidean bad-pixel gate (use [`DEFAULT_THRESH`]).
|
||||
/// Returns the report plus the bad-pixel mask (255/0, one byte per pixel).
|
||||
pub fn fidelity(
|
||||
orig_rgb: &[u8],
|
||||
cand_rgb: &[u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
thresh: f64,
|
||||
) -> (FidelityReport, Vec<u8>) {
|
||||
assert_eq!(orig_rgb.len(), w * h * 3);
|
||||
assert_eq!(cand_rgb.len(), w * h * 3);
|
||||
|
||||
// PSNR + RMSE + bad-pixel binarization in one pass
|
||||
let mut sse = 0f64;
|
||||
let mut mask = vec![0u8; w * h];
|
||||
let mut bad_px = 0usize;
|
||||
let t2 = thresh * thresh;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let mut d2 = 0f64;
|
||||
for c in 0..3 {
|
||||
let e = orig_rgb[i * 3 + c] as f64 - cand_rgb[i * 3 + c] as f64;
|
||||
d2 += e * e;
|
||||
}
|
||||
sse += d2;
|
||||
if d2 > t2 {
|
||||
mask[i] = 255;
|
||||
bad_px += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rmse = (sse / (w * h * 3) as f64).sqrt();
|
||||
let psnr = 20.0 * (255.0 / rmse.max(1e-6)).log10();
|
||||
|
||||
let dssim = dssim_score(orig_rgb, cand_rgb, w, h);
|
||||
|
||||
// opening: 1-round 4-conn erode + dilate. Edge-shift filaments (≤2px wide,
|
||||
// the signature of a slightly blurred/compressed source) vanish; genuine
|
||||
// missing patches survive. The reported mask keeps the raw bad pixels.
|
||||
let at = |m: &[u8], x: i64, y: i64| {
|
||||
x >= 0
|
||||
&& y >= 0
|
||||
&& (x as usize) < w
|
||||
&& (y as usize) < h
|
||||
&& m[y as usize * w + x as usize] != 0
|
||||
};
|
||||
let mut eroded = vec![0u8; w * h];
|
||||
for y in 0..h as i64 {
|
||||
for x in 0..w as i64 {
|
||||
if at(&mask, x, y)
|
||||
&& at(&mask, x - 1, y)
|
||||
&& at(&mask, x + 1, y)
|
||||
&& at(&mask, x, y - 1)
|
||||
&& at(&mask, x, y + 1)
|
||||
{
|
||||
eroded[y as usize * w + x as usize] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut bin = BinaryImage::new_w_h(w, h);
|
||||
for y in 0..h as i64 {
|
||||
for x in 0..w as i64 {
|
||||
if at(&eroded, x, y)
|
||||
|| at(&eroded, x - 1, y)
|
||||
|| at(&eroded, x + 1, y)
|
||||
|| at(&eroded, x, y - 1)
|
||||
|| at(&eroded, x, y + 1)
|
||||
{
|
||||
bin.set_pixel(x as usize, y as usize, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sizes: Vec<usize> = bin.to_clusters(false).iter().map(|c| c.size()).collect();
|
||||
let largest = sizes.iter().copied().max().unwrap_or(0);
|
||||
let patch_mass = if sizes.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
sizes
|
||||
.iter()
|
||||
.map(|&a| (a as f64) * (a as f64))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
};
|
||||
let p_frac = patch_mass / (w * h) as f64;
|
||||
|
||||
let s_psnr = 1.0 - (1.0 + rmse).ln() / 256f64.ln();
|
||||
let s_ssim = 1.0 / (1.0 + dssim);
|
||||
let s_patch = (-p_frac / PATCH_HALF * std::f64::consts::LN_2).exp();
|
||||
let fidelity = (s_psnr.powf(W_PSNR) * s_ssim.powf(W_SSIM) * s_patch.powf(W_PATCH))
|
||||
.powf(1.0 / (W_PSNR + W_SSIM + W_PATCH));
|
||||
|
||||
(
|
||||
FidelityReport {
|
||||
psnr,
|
||||
dssim,
|
||||
rmse,
|
||||
bad_px,
|
||||
clusters: sizes.len(),
|
||||
largest,
|
||||
patch_mass,
|
||||
s_psnr,
|
||||
s_ssim,
|
||||
s_patch,
|
||||
fidelity,
|
||||
},
|
||||
mask,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flat(w: usize, h: usize, c: [u8; 3]) -> Vec<u8> {
|
||||
(0..w * h).flat_map(|_| c).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_is_one() {
|
||||
let a = flat(64, 64, [120, 90, 200]);
|
||||
let (r, mask) = fidelity(&a, &a, 64, 64, DEFAULT_THRESH);
|
||||
assert_eq!(r.bad_px, 0);
|
||||
assert!(mask.iter().all(|&m| m == 0));
|
||||
assert!((r.fidelity - 1.0).abs() < 1e-9, "fidelity {}", r.fidelity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coherent_patch_scores_below_scattered_dust() {
|
||||
// same 256 bad pixels: one 16×16 blob vs isolated singles on a 64×64 grid
|
||||
let clean = flat(64, 64, [200, 200, 200]);
|
||||
let mut blob = clean.clone();
|
||||
for y in 24..40 {
|
||||
for x in 24..40 {
|
||||
blob[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
|
||||
}
|
||||
}
|
||||
let mut dust = clean.clone();
|
||||
for k in 0..256 {
|
||||
let (x, y) = ((k % 16) * 4, (k / 16) * 4); // 4px spacing: 256 singleton clusters
|
||||
dust[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
|
||||
}
|
||||
let (rb, _) = fidelity(&clean, &blob, 64, 64, DEFAULT_THRESH);
|
||||
let (rd, _) = fidelity(&clean, &dust, 64, 64, DEFAULT_THRESH);
|
||||
assert_eq!(rb.bad_px, 256);
|
||||
assert_eq!(rd.bad_px, 256);
|
||||
// dust vanishes under the opening entirely; the blob survives
|
||||
assert_eq!(rb.clusters, 1);
|
||||
assert_eq!(rd.clusters, 0);
|
||||
assert!((rd.s_patch - 1.0).abs() < 1e-9);
|
||||
// identical PSNR/RMSE by construction; the patch axis must separate them
|
||||
assert!((rb.rmse - rd.rmse).abs() < 1e-9);
|
||||
assert!(
|
||||
rb.s_patch < rd.s_patch * 0.25,
|
||||
"blob {} dust {}",
|
||||
rb.s_patch,
|
||||
rd.s_patch
|
||||
);
|
||||
assert!(rb.fidelity < rd.fidelity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_shift_filaments_are_tolerated() {
|
||||
// a slightly blurred/compressed source shifts edges: thin bad-px lines
|
||||
// along boundaries. A 2px-wide full-width filament (256 px) must open
|
||||
// away; the same mass as a compact blob must not.
|
||||
let clean = flat(64, 64, [200, 200, 200]);
|
||||
let mut fil = clean.clone();
|
||||
for y in 30..32 {
|
||||
for x in 0..64 {
|
||||
fil[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
|
||||
}
|
||||
}
|
||||
let (rf, _) = fidelity(&clean, &fil, 64, 64, DEFAULT_THRESH);
|
||||
assert_eq!(rf.bad_px, 128);
|
||||
assert_eq!(rf.clusters, 0);
|
||||
assert!(
|
||||
(rf.s_patch - 1.0).abs() < 1e-9,
|
||||
"filament must not count as a patch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worse_is_lower() {
|
||||
let a = flat(32, 32, [100, 100, 100]);
|
||||
let mild: Vec<u8> = a.iter().map(|&v| v + 4).collect();
|
||||
let harsh: Vec<u8> = a.iter().map(|&v| v + 60).collect();
|
||||
let (rm, _) = fidelity(&a, &mild, 32, 32, DEFAULT_THRESH);
|
||||
let (rh, _) = fidelity(&a, &harsh, 32, 32, DEFAULT_THRESH);
|
||||
assert!(rm.fidelity > rh.fidelity);
|
||||
assert!(rh.fidelity < 0.4, "harsh {}", rh.fidelity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Blind fidelity benchmark for raster-to-vector tracers.
|
||||
//!
|
||||
//! vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
|
||||
//!
|
||||
//! Both arguments are rasters of identical dimensions — rendering a vector
|
||||
//! reconstruction to pixels is the caller's responsibility. Prints the raw
|
||||
//! metrics, their [0,1] subscores, the composite fidelity, and a
|
||||
//! machine-readable csv line.
|
||||
|
||||
use vtracer_bench::{DEFAULT_THRESH, fidelity};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let mut thresh = DEFAULT_THRESH;
|
||||
let mut mask_out: Option<String> = None;
|
||||
let mut i = 3;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--thresh" => {
|
||||
i += 1;
|
||||
thresh = args[i].parse().expect("--thresh N");
|
||||
}
|
||||
"--mask" => {
|
||||
i += 1;
|
||||
mask_out = Some(args[i].clone());
|
||||
}
|
||||
a => {
|
||||
eprintln!("unknown flag {a}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let orig = image::open(&args[1]).expect("open original").to_rgb8();
|
||||
let (w, h) = (orig.width() as usize, orig.height() as usize);
|
||||
let img = image::open(&args[2]).expect("open candidate").to_rgb8();
|
||||
assert_eq!(
|
||||
(img.width() as usize, img.height() as usize),
|
||||
(w, h),
|
||||
"candidate raster must match original dimensions"
|
||||
);
|
||||
let cand: Vec<u8> = img.into_raw();
|
||||
|
||||
let (r, mask) = fidelity(orig.as_raw(), &cand, w, h, thresh);
|
||||
|
||||
if let Some(out) = mask_out {
|
||||
image::GrayImage::from_raw(w as u32, h as u32, mask)
|
||||
.unwrap()
|
||||
.save(&out)
|
||||
.expect("save mask");
|
||||
}
|
||||
|
||||
println!(
|
||||
"psnr {:>8.2} dB (rmse {:.2}) -> {:.4}",
|
||||
r.psnr, r.rmse, r.s_psnr
|
||||
);
|
||||
println!(
|
||||
"ssim {:>8.5} (dssim {:.5}) -> {:.4}",
|
||||
r.s_ssim, r.dssim, r.s_ssim
|
||||
);
|
||||
println!(
|
||||
"patch {:>8.1} px rms ({} bad px, {} clusters, largest {}) -> {:.4}",
|
||||
r.patch_mass, r.bad_px, r.clusters, r.largest, r.s_patch
|
||||
);
|
||||
println!("fidelity {:.4}", r.fidelity);
|
||||
println!(
|
||||
"[csv] {:.4},{:.2},{:.5},{:.2},{:.1},{:.4},{:.4},{:.4}",
|
||||
r.fidelity, r.psnr, r.dssim, r.rmse, r.patch_mass, r.s_psnr, r.s_ssim, r.s_patch
|
||||
);
|
||||
}
|
||||
@@ -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.4", 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,291 @@
|
||||
//! Thin command-line front-end over the `vtracer` framework.
|
||||
//!
|
||||
//! Handles the two things the framework deliberately leaves out: image file
|
||||
//! I/O and argument parsing. Everything else is delegated to
|
||||
//! [`vtracer::Config`] / [`vtracer::Pipeline`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use visioncortex::{Color, ColorImage};
|
||||
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
|
||||
|
||||
/// Convert an image into vector graphics.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "vtracer", version, about, rename_all = "kebab-case")]
|
||||
struct Args {
|
||||
/// Input raster image (positional; or use --input).
|
||||
#[arg(value_name = "INPUT")]
|
||||
input_pos: Option<PathBuf>,
|
||||
|
||||
/// Output SVG (positional; or use --output).
|
||||
#[arg(value_name = "OUTPUT")]
|
||||
output_pos: Option<PathBuf>,
|
||||
|
||||
/// Path to the input raster image.
|
||||
#[arg(short = 'i', long = "input", value_name = "INPUT")]
|
||||
input: Option<PathBuf>,
|
||||
|
||||
/// Path to the output SVG.
|
||||
#[arg(short = 'o', long = "output", value_name = "OUTPUT")]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Start from a preset: bw, poster, photo.
|
||||
#[arg(long)]
|
||||
preset: Option<Preset>,
|
||||
|
||||
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
|
||||
#[arg(long)]
|
||||
clustering: Option<Clustering>,
|
||||
|
||||
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
|
||||
#[arg(long)]
|
||||
hierarchical: Option<Hierarchical>,
|
||||
|
||||
/// Curve-fitting mode: pixel, polygon, spline.
|
||||
#[arg(short, long)]
|
||||
mode: Option<FitMode>,
|
||||
|
||||
/// Discard patches smaller than X px in size (0..=128).
|
||||
#[arg(short = 'f', long, value_parser = clap::value_parser!(i64).range(0..=128))]
|
||||
filter_speckle: Option<i64>,
|
||||
|
||||
/// Significant bits per RGB channel (1..=8).
|
||||
#[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(1..=8))]
|
||||
color_precision: Option<i64>,
|
||||
|
||||
/// Color difference between gradient layers (0..=255).
|
||||
#[arg(short = 'g', long, value_parser = clap::value_parser!(i64).range(0..=255))]
|
||||
gradient_step: Option<i64>,
|
||||
|
||||
/// Minimum momentary angle (degrees) to be a corner (0..=180).
|
||||
///
|
||||
/// Hidden from help: a fine-tuning knob few conversions need — the
|
||||
/// default (60) serves; `--simplify` is the knob worth reaching for.
|
||||
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
|
||||
corner_threshold: Option<i64>,
|
||||
|
||||
/// Subdivide until all segments are shorter than this length (3.5..=10).
|
||||
///
|
||||
/// Hidden from help: with `--simplify` reducing anchors by an explicit
|
||||
/// error tolerance, this legacy knob's effect on output is negligible.
|
||||
#[arg(long, hide = true, value_parser = parse_segment_length)]
|
||||
segment_length: Option<f64>,
|
||||
|
||||
/// Minimum angle displacement (degrees) to splice a spline (0..=180).
|
||||
///
|
||||
/// Hidden from help: a fine-tuning knob few conversions need — the
|
||||
/// default (45) serves; `--simplify` is the knob worth reaching for.
|
||||
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
|
||||
splice_threshold: Option<i64>,
|
||||
|
||||
/// Simplify curves: fewest cubics within this tolerance in px (try 1-2.5).
|
||||
#[arg(long, value_name = "TOLERANCE", value_parser = parse_simplify_tolerance)]
|
||||
simplify: Option<f64>,
|
||||
|
||||
/// Decimal places to use in path coordinates.
|
||||
#[arg(long)]
|
||||
path_precision: Option<u32>,
|
||||
|
||||
/// Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'.
|
||||
#[arg(long)]
|
||||
palette: Option<String>,
|
||||
|
||||
/// Fixed palette from a file (one hex color per line or comma-separated).
|
||||
#[arg(long)]
|
||||
palette_file: Option<PathBuf>,
|
||||
|
||||
/// Auto-quantize to at most N colors.
|
||||
#[arg(long)]
|
||||
max_colors: Option<usize>,
|
||||
|
||||
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
|
||||
optimize: Option<u8>,
|
||||
|
||||
/// Binary mode: fixed threshold (0..=255); foreground when intensity is below it.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8))]
|
||||
threshold: Option<u8>,
|
||||
|
||||
/// Binary mode: use Bradley–Roth adaptive thresholding (handles uneven lighting).
|
||||
#[arg(long)]
|
||||
adaptive: bool,
|
||||
|
||||
/// Adaptive window side length in px (0 = auto). Implies --adaptive.
|
||||
#[arg(long)]
|
||||
adaptive_window: Option<u32>,
|
||||
|
||||
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
|
||||
#[arg(long)]
|
||||
adaptive_t: Option<f64>,
|
||||
|
||||
/// Watershed clustering: hierarchy cut level (default 128; higher = more regions, uncapped).
|
||||
#[arg(long, value_parser = clap::value_parser!(u32))]
|
||||
watershed_detail: Option<u32>,
|
||||
}
|
||||
|
||||
fn parse_simplify_tolerance(s: &str) -> Result<f64, String> {
|
||||
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
|
||||
if !v.is_finite() || v <= 0.0 {
|
||||
return Err(format!("simplify tolerance {v} must be positive"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn parse_segment_length(s: &str) -> Result<f64, String> {
|
||||
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
|
||||
if !(3.5..=10.0).contains(&v) {
|
||||
return Err(format!("segment length {v} is out of range [3.5, 10]"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Parse a comma/whitespace/newline separated list of `#rrggbb` colors.
|
||||
fn parse_palette(text: &str) -> Result<Vec<Color>, String> {
|
||||
let mut colors = Vec::new();
|
||||
for token in text.split(|c: char| c == ',' || c.is_whitespace()) {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
colors.push(parse_hex_color(token)?);
|
||||
}
|
||||
Ok(colors)
|
||||
}
|
||||
|
||||
fn parse_hex_color(token: &str) -> Result<Color, String> {
|
||||
let hex = token.strip_prefix('#').unwrap_or(token);
|
||||
if hex.len() != 6 {
|
||||
return Err(format!("`{token}` is not a #rrggbb color"));
|
||||
}
|
||||
let parse = |range: std::ops::Range<usize>| {
|
||||
u8::from_str_radix(&hex[range], 16).map_err(|_| format!("`{token}` is not a #rrggbb color"))
|
||||
};
|
||||
Ok(Color::new(parse(0..2)?, parse(2..4)?, parse(4..6)?))
|
||||
}
|
||||
|
||||
fn build_config(args: &Args) -> Result<Config, String> {
|
||||
let mut config = match args.preset {
|
||||
Some(preset) => Config::from_preset(preset),
|
||||
None => Config::default(),
|
||||
};
|
||||
|
||||
if let Some(v) = args.clustering {
|
||||
config.clustering = v;
|
||||
}
|
||||
if let Some(v) = args.hierarchical {
|
||||
config.hierarchical = v;
|
||||
}
|
||||
if let Some(v) = args.mode {
|
||||
config.mode = v;
|
||||
}
|
||||
if let Some(v) = args.filter_speckle {
|
||||
config.filter_speckle = v as usize;
|
||||
}
|
||||
if let Some(v) = args.color_precision {
|
||||
config.color_precision = v as i32;
|
||||
}
|
||||
if let Some(v) = args.gradient_step {
|
||||
config.layer_difference = v as i32;
|
||||
}
|
||||
if let Some(v) = args.corner_threshold {
|
||||
config.corner_threshold = v as i32;
|
||||
}
|
||||
if let Some(v) = args.segment_length {
|
||||
config.length_threshold = v;
|
||||
}
|
||||
if let Some(v) = args.splice_threshold {
|
||||
config.splice_threshold = v as i32;
|
||||
}
|
||||
if args.simplify.is_some() {
|
||||
config.simplify = args.simplify;
|
||||
}
|
||||
if args.path_precision.is_some() {
|
||||
config.path_precision = args.path_precision;
|
||||
}
|
||||
if let Some(v) = args.optimize {
|
||||
config.optimize = v;
|
||||
}
|
||||
if let Some(v) = args.max_colors {
|
||||
config.max_colors = Some(v);
|
||||
}
|
||||
|
||||
// Binary thresholding: --adaptive (or either adaptive tuning flag) selects
|
||||
// Bradley–Roth; otherwise --threshold tunes the fixed cutoff.
|
||||
if let Some(v) = args.threshold {
|
||||
config.binary_threshold = v;
|
||||
}
|
||||
if args.adaptive || args.adaptive_window.is_some() || args.adaptive_t.is_some() {
|
||||
config.binary_adaptive = true;
|
||||
}
|
||||
if let Some(v) = args.adaptive_window {
|
||||
config.binary_adaptive_window = v;
|
||||
}
|
||||
if let Some(v) = args.adaptive_t {
|
||||
config.binary_adaptive_t = v;
|
||||
}
|
||||
if let Some(v) = args.watershed_detail {
|
||||
config.watershed_detail = v;
|
||||
}
|
||||
|
||||
// Palette: inline flag wins over file; both parse to a color list.
|
||||
if let Some(text) = &args.palette {
|
||||
config.palette = parse_palette(text)?;
|
||||
} else if let Some(path) = &args.palette_file {
|
||||
let text =
|
||||
std::fs::read_to_string(path).map_err(|e| format!("cannot read palette file: {e}"))?;
|
||||
config.palette = parse_palette(&text)?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn read_image(path: &std::path::Path) -> Result<ColorImage, String> {
|
||||
let img = image::open(path)
|
||||
.map_err(|_| "no image file found at specified input path".to_string())?
|
||||
.to_rgba8();
|
||||
let (width, height) = (img.width() as usize, img.height() as usize);
|
||||
Ok(ColorImage {
|
||||
pixels: img.into_raw(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Accept input/output as positionals (`vtracer in.png out.svg`) or as
|
||||
// named flags; an explicit flag takes precedence over the positional.
|
||||
let input = args
|
||||
.input
|
||||
.as_ref()
|
||||
.or(args.input_pos.as_ref())
|
||||
.ok_or("no input path given (positional or --input)")?;
|
||||
let output = args
|
||||
.output
|
||||
.as_ref()
|
||||
.or(args.output_pos.as_ref())
|
||||
.ok_or("no output path given (positional or --output)")?;
|
||||
|
||||
let config = build_config(&args)?;
|
||||
let pipeline = config.build().map_err(|e| e.to_string())?;
|
||||
let img = read_image(input)?;
|
||||
let svg = pipeline.to_svg(&img).map_err(|e| e.to_string())?;
|
||||
std::fs::write(output, svg).map_err(|e| format!("cannot write output file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => {
|
||||
println!("Conversion successful.");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(msg) => {
|
||||
eprintln!("Conversion failed: {msg}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "vtracer-py"
|
||||
description = "Python bindings for the vtracer vectorization framework."
|
||||
version = "1.0.0-alpha.4"
|
||||
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
readme = "README.md"
|
||||
homepage = "http://www.visioncortex.org/vtracer"
|
||||
repository = "https://github.com/visioncortex/vtracer/"
|
||||
|
||||
# Excluded from the workspace: pyo3 `extension-module` cdylibs don't link
|
||||
# libpython, which breaks `cargo test` at the workspace root. Built with
|
||||
# maturin. Deps are declared explicitly (no workspace inheritance).
|
||||
|
||||
[lib]
|
||||
# Python imports this as `vtracer`.
|
||||
name = "vtracer"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
vtracer = { version = "1.0.0-alpha.4", 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,103 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/visioncortex/vtracer/master/docs/images/visioncortex-banner.png">
|
||||
<h1>VTracer</h1>
|
||||
|
||||
<p>
|
||||
<strong>Raster to Vector Graphics Converter</strong>
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64-setup.exe">Windows App</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_universal.dmg">macOS App</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64.AppImage">Linux App</a>
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
|
||||
# vtracer (Python)
|
||||
|
||||
Python bindings for [`vtracer`](https://github.com/visioncortex/vtracer). 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.
|
||||
|
||||
## 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, VTracer has an image processing pipeline which can handle colored images. VTracer skips Potrace's expensive optimal-polygon search in favor of a fast, linear pipeline that stays faithful to high-resolution images.
|
||||
|
||||
Comparing to Adobe Illustrator's Image Trace, VTracer's output is much more compact as we adopt a stacking strategy and avoid producing shapes with holes.
|
||||
|
||||
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
|
||||
|
||||
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pip install vtracer==1.0.0a4
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
import vtracer
|
||||
|
||||
# one-liners
|
||||
vtracer.convert_file("in.png", "out.svg")
|
||||
svg = vtracer.convert_bytes(open("in.png", "rb").read()) # -> str
|
||||
svg = vtracer.convert_pixels(rgba_bytes, width, height) # raw RGBA8
|
||||
|
||||
# a rich, reusable configuration object
|
||||
cfg = vtracer.Config(mode="polygon", filter_speckle=8)
|
||||
cfg.hierarchical = "cutout" # seam-free mosaic
|
||||
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"] # snap to a fixed palette
|
||||
cfg.max_colors = 8 # or auto-quantize
|
||||
cfg.optimize = 2
|
||||
svg = cfg.convert_bytes(data)
|
||||
|
||||
# presets
|
||||
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
|
||||
vtracer.Config.bw().convert_file("scan.png", "lineart.svg")
|
||||
```
|
||||
|
||||
### `Config`
|
||||
|
||||
Constructor keyword arguments (all optional) — also exposed as mutable
|
||||
properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
|
||||
|
||||
| arg | default | notes |
|
||||
|---|---|---|
|
||||
| `clustering` | `"color-cluster"` | `"color-cluster"`, `"bw"`, or `"watershed"` |
|
||||
| `hierarchical` | `"stacked"` | `"stacked"` or `"cutout"` (mosaic) |
|
||||
| `mode` | `"spline"` | `"pixel"`, `"polygon"`, `"spline"` |
|
||||
| `filter_speckle` | `4` | discard patches smaller than X px |
|
||||
| `color_precision` | `6` | significant bits per channel |
|
||||
| `layer_difference` | `16` | color diff between gradient layers |
|
||||
| `corner_threshold` | `60` | degrees |
|
||||
| `length_threshold` | `4.0` | px |
|
||||
| `max_iterations` | `10` | |
|
||||
| `splice_threshold` | `45` | degrees |
|
||||
| `simplify` | `None` | curve simplification tolerance in px (try 1–2.5) |
|
||||
| `path_precision` | `2` | output decimal places |
|
||||
| `palette` | `None` | list of `#rrggbb` strings |
|
||||
| `max_colors` | `None` | auto-quantize target |
|
||||
| `optimize` | `1` | `0` off, `1` quantize+cleanup, `2` + shorthands |
|
||||
| `binary_threshold` | `128` | bw: fixed cutoff, foreground below it |
|
||||
| `adaptive` | `False` | bw: Bradley–Roth adaptive thresholding |
|
||||
| `adaptive_window` | `0` | bw adaptive: window px (`0` = auto) |
|
||||
| `adaptive_t` | `15.0` | bw adaptive: % below local mean |
|
||||
| `watershed_detail` | `128` | watershed: hierarchy cut level (higher = more regions, uncapped) |
|
||||
|
||||
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,27 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.5,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "vtracer"
|
||||
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = { text = "MIT OR Apache-2.0" }
|
||||
authors = [{ name = "Chris Tsang", email = "tyt2y7@gmail.com" }]
|
||||
keywords = ["svg", "vectorization", "raster", "computer-graphics"]
|
||||
classifiers = [
|
||||
"Programming Language :: Rust",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://www.visioncortex.org/vtracer"
|
||||
Repository = "https://github.com/visioncortex/vtracer/"
|
||||
|
||||
[tool.maturin]
|
||||
# Pure-Rust extension module; the compiled library is imported as `vtracer`.
|
||||
module-name = "vtracer"
|
||||
features = ["pyo3/extension-module"]
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Python bindings for the `vtracer` vectorization framework.
|
||||
//!
|
||||
//! The API centers on a mutable [`Config`] object with named properties and
|
||||
//! preset constructors, plus three input paths — a file, encoded image bytes,
|
||||
//! or a raw RGBA buffer — each returning the SVG (or writing it to disk):
|
||||
//!
|
||||
//! ```python
|
||||
//! import vtracer
|
||||
//!
|
||||
//! # one-liners
|
||||
//! vtracer.convert_file("in.png", "out.svg")
|
||||
//! svg = vtracer.convert_bytes(open("in.png", "rb").read())
|
||||
//!
|
||||
//! # rich, reusable config
|
||||
//! cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
|
||||
//! cfg.max_colors = 8
|
||||
//! cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
|
||||
//! svg = cfg.convert_bytes(data)
|
||||
//!
|
||||
//! # presets
|
||||
//! vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
|
||||
//! ```
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pyo3::exceptions::{PyIOError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use ::vtracer::{
|
||||
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
|
||||
};
|
||||
|
||||
// --- string <-> enum helpers -------------------------------------------------
|
||||
|
||||
fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
|
||||
s.parse().map_err(PyValueError::new_err)
|
||||
}
|
||||
|
||||
fn clustering_str(c: Clustering) -> &'static str {
|
||||
match c {
|
||||
Clustering::ColorCluster => "color-cluster",
|
||||
Clustering::Binary => "bw",
|
||||
Clustering::Watershed => "watershed",
|
||||
}
|
||||
}
|
||||
|
||||
fn hierarchical_str(h: Hierarchical) -> &'static str {
|
||||
match h {
|
||||
Hierarchical::Stacked => "stacked",
|
||||
Hierarchical::Cutout => "cutout",
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_str(m: FitMode) -> &'static str {
|
||||
match m {
|
||||
FitMode::Pixel => "pixel",
|
||||
FitMode::Polygon => "polygon",
|
||||
FitMode::Spline => "spline",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex(token: &str) -> PyResult<Color> {
|
||||
let hex = token.strip_prefix('#').unwrap_or(token);
|
||||
if hex.len() != 6 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"`{token}` is not a #rrggbb color"
|
||||
)));
|
||||
}
|
||||
let byte = |r: std::ops::Range<usize>| {
|
||||
u8::from_str_radix(&hex[r], 16)
|
||||
.map_err(|_| PyValueError::new_err(format!("`{token}` is not a #rrggbb color")))
|
||||
};
|
||||
Ok(Color::new(byte(0..2)?, byte(2..4)?, byte(4..6)?))
|
||||
}
|
||||
|
||||
// --- image helpers -----------------------------------------------------------
|
||||
|
||||
fn dynimg_to_color(img: image::DynamicImage) -> ColorImage {
|
||||
let img = img.to_rgba8();
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
ColorImage {
|
||||
pixels: img.into_raw(),
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_bytes(bytes: &[u8], format: Option<&str>) -> PyResult<ColorImage> {
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes));
|
||||
match format {
|
||||
Some(ext) => {
|
||||
let fmt = image::ImageFormat::from_extension(ext)
|
||||
.ok_or_else(|| PyValueError::new_err(format!("unknown image format `{ext}`")))?;
|
||||
reader.set_format(fmt);
|
||||
}
|
||||
None => {
|
||||
reader = reader
|
||||
.with_guessed_format()
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let img = reader
|
||||
.decode()
|
||||
.map_err(|e| PyValueError::new_err(format!("failed to decode image: {e}")))?;
|
||||
Ok(dynimg_to_color(img))
|
||||
}
|
||||
|
||||
// --- Config ------------------------------------------------------------------
|
||||
|
||||
/// Conversion configuration. Construct with keyword arguments or a preset,
|
||||
/// mutate via properties, then call one of the `convert_*` methods.
|
||||
#[pyclass(name = "Config")]
|
||||
#[derive(Clone)]
|
||||
struct PyConfig {
|
||||
inner: CoreConfig,
|
||||
}
|
||||
|
||||
impl PyConfig {
|
||||
fn to_svg(&self, img: &ColorImage) -> PyResult<String> {
|
||||
self.inner
|
||||
.build()
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))?
|
||||
.to_svg(img)
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
clustering = "color-cluster",
|
||||
hierarchical = "stacked",
|
||||
mode = "spline",
|
||||
filter_speckle = 4,
|
||||
color_precision = 6,
|
||||
layer_difference = 16,
|
||||
corner_threshold = 60,
|
||||
length_threshold = 4.0,
|
||||
max_iterations = 10,
|
||||
splice_threshold = 45,
|
||||
simplify = None,
|
||||
path_precision = 2,
|
||||
palette = None,
|
||||
max_colors = None,
|
||||
optimize = 1,
|
||||
binary_threshold = 128,
|
||||
adaptive = false,
|
||||
adaptive_window = 0,
|
||||
adaptive_t = 15.0,
|
||||
watershed_detail = 128,
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
clustering: &str,
|
||||
hierarchical: &str,
|
||||
mode: &str,
|
||||
filter_speckle: usize,
|
||||
color_precision: i32,
|
||||
layer_difference: i32,
|
||||
corner_threshold: i32,
|
||||
length_threshold: f64,
|
||||
max_iterations: usize,
|
||||
splice_threshold: i32,
|
||||
simplify: Option<f64>,
|
||||
path_precision: u32,
|
||||
palette: Option<Vec<String>>,
|
||||
max_colors: Option<usize>,
|
||||
optimize: u8,
|
||||
binary_threshold: u8,
|
||||
adaptive: bool,
|
||||
adaptive_window: u32,
|
||||
adaptive_t: f64,
|
||||
watershed_detail: u32,
|
||||
) -> PyResult<Self> {
|
||||
let palette = match palette {
|
||||
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
Ok(Self {
|
||||
inner: CoreConfig {
|
||||
clustering: parse(clustering)?,
|
||||
hierarchical: parse(hierarchical)?,
|
||||
mode: parse(mode)?,
|
||||
filter_speckle,
|
||||
color_precision,
|
||||
layer_difference,
|
||||
corner_threshold,
|
||||
length_threshold,
|
||||
max_iterations,
|
||||
splice_threshold,
|
||||
simplify,
|
||||
path_precision: Some(path_precision),
|
||||
palette,
|
||||
max_colors,
|
||||
optimize,
|
||||
binary_threshold,
|
||||
binary_adaptive: adaptive,
|
||||
binary_adaptive_window: adaptive_window,
|
||||
binary_adaptive_t: adaptive_t,
|
||||
watershed_detail,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Preset for black & white line art.
|
||||
#[staticmethod]
|
||||
fn bw() -> Self {
|
||||
Self {
|
||||
inner: CoreConfig::from_preset(Preset::Bw),
|
||||
}
|
||||
}
|
||||
|
||||
/// Preset for posterized color art.
|
||||
#[staticmethod]
|
||||
fn poster() -> Self {
|
||||
Self {
|
||||
inner: CoreConfig::from_preset(Preset::Poster),
|
||||
}
|
||||
}
|
||||
|
||||
/// Preset tuned for photographs.
|
||||
#[staticmethod]
|
||||
fn photo() -> Self {
|
||||
Self {
|
||||
inner: CoreConfig::from_preset(Preset::Photo),
|
||||
}
|
||||
}
|
||||
|
||||
// --- properties ---
|
||||
|
||||
#[getter]
|
||||
fn clustering(&self) -> &'static str {
|
||||
clustering_str(self.inner.clustering)
|
||||
}
|
||||
#[setter]
|
||||
fn set_clustering(&mut self, v: &str) -> PyResult<()> {
|
||||
self.inner.clustering = parse(v)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn watershed_detail(&self) -> u32 {
|
||||
self.inner.watershed_detail
|
||||
}
|
||||
#[setter]
|
||||
fn set_watershed_detail(&mut self, v: u32) {
|
||||
self.inner.watershed_detail = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn hierarchical(&self) -> &'static str {
|
||||
hierarchical_str(self.inner.hierarchical)
|
||||
}
|
||||
#[setter]
|
||||
fn set_hierarchical(&mut self, v: &str) -> PyResult<()> {
|
||||
self.inner.hierarchical = parse(v)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn mode(&self) -> &'static str {
|
||||
mode_str(self.inner.mode)
|
||||
}
|
||||
#[setter]
|
||||
fn set_mode(&mut self, v: &str) -> PyResult<()> {
|
||||
self.inner.mode = parse(v)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn filter_speckle(&self) -> usize {
|
||||
self.inner.filter_speckle
|
||||
}
|
||||
#[setter]
|
||||
fn set_filter_speckle(&mut self, v: usize) {
|
||||
self.inner.filter_speckle = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn color_precision(&self) -> i32 {
|
||||
self.inner.color_precision
|
||||
}
|
||||
#[setter]
|
||||
fn set_color_precision(&mut self, v: i32) {
|
||||
self.inner.color_precision = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn layer_difference(&self) -> i32 {
|
||||
self.inner.layer_difference
|
||||
}
|
||||
#[setter]
|
||||
fn set_layer_difference(&mut self, v: i32) {
|
||||
self.inner.layer_difference = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn corner_threshold(&self) -> i32 {
|
||||
self.inner.corner_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_corner_threshold(&mut self, v: i32) {
|
||||
self.inner.corner_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn length_threshold(&self) -> f64 {
|
||||
self.inner.length_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_length_threshold(&mut self, v: f64) {
|
||||
self.inner.length_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn max_iterations(&self) -> usize {
|
||||
self.inner.max_iterations
|
||||
}
|
||||
#[setter]
|
||||
fn set_max_iterations(&mut self, v: usize) {
|
||||
self.inner.max_iterations = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn splice_threshold(&self) -> i32 {
|
||||
self.inner.splice_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_splice_threshold(&mut self, v: i32) {
|
||||
self.inner.splice_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn simplify(&self) -> Option<f64> {
|
||||
self.inner.simplify
|
||||
}
|
||||
#[setter]
|
||||
fn set_simplify(&mut self, v: Option<f64>) {
|
||||
self.inner.simplify = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn path_precision(&self) -> Option<u32> {
|
||||
self.inner.path_precision
|
||||
}
|
||||
#[setter]
|
||||
fn set_path_precision(&mut self, v: Option<u32>) {
|
||||
self.inner.path_precision = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn palette(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.palette
|
||||
.iter()
|
||||
.map(Color::to_hex_string)
|
||||
.collect()
|
||||
}
|
||||
#[setter]
|
||||
fn set_palette(&mut self, v: Vec<String>) -> PyResult<()> {
|
||||
self.inner.palette = v.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn max_colors(&self) -> Option<usize> {
|
||||
self.inner.max_colors
|
||||
}
|
||||
#[setter]
|
||||
fn set_max_colors(&mut self, v: Option<usize>) {
|
||||
self.inner.max_colors = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn optimize(&self) -> u8 {
|
||||
self.inner.optimize
|
||||
}
|
||||
#[setter]
|
||||
fn set_optimize(&mut self, v: u8) {
|
||||
self.inner.optimize = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn binary_threshold(&self) -> u8 {
|
||||
self.inner.binary_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_binary_threshold(&mut self, v: u8) {
|
||||
self.inner.binary_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn adaptive(&self) -> bool {
|
||||
self.inner.binary_adaptive
|
||||
}
|
||||
#[setter]
|
||||
fn set_adaptive(&mut self, v: bool) {
|
||||
self.inner.binary_adaptive = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn adaptive_window(&self) -> u32 {
|
||||
self.inner.binary_adaptive_window
|
||||
}
|
||||
#[setter]
|
||||
fn set_adaptive_window(&mut self, v: u32) {
|
||||
self.inner.binary_adaptive_window = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn adaptive_t(&self) -> f64 {
|
||||
self.inner.binary_adaptive_t
|
||||
}
|
||||
#[setter]
|
||||
fn set_adaptive_t(&mut self, v: f64) {
|
||||
self.inner.binary_adaptive_t = v;
|
||||
}
|
||||
|
||||
// --- conversion ---
|
||||
|
||||
/// Trace the image at `input_path` and write the SVG to `output_path`.
|
||||
fn convert_file(&self, input_path: PathBuf, output_path: PathBuf) -> PyResult<()> {
|
||||
let img = image::open(&input_path).map_err(|e| {
|
||||
PyIOError::new_err(format!("cannot open `{}`: {e}", input_path.display()))
|
||||
})?;
|
||||
let svg = self.to_svg(&dynimg_to_color(img))?;
|
||||
std::fs::write(&output_path, svg).map_err(|e| {
|
||||
PyIOError::new_err(format!("cannot write `{}`: {e}", output_path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Trace encoded image `data` (png/jpg/...) and return the SVG string.
|
||||
/// `format` (e.g. "png") overrides content-based format detection.
|
||||
#[pyo3(signature = (data, format = None))]
|
||||
fn convert_bytes(&self, data: Vec<u8>, format: Option<&str>) -> PyResult<String> {
|
||||
self.to_svg(&decode_bytes(&data, format)?)
|
||||
}
|
||||
|
||||
/// Trace a raw RGBA8 buffer (`width * height * 4` bytes) and return the SVG.
|
||||
fn convert_pixels(&self, rgba: Vec<u8>, width: usize, height: usize) -> PyResult<String> {
|
||||
if rgba.len() != width * height * 4 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"rgba length {} != width*height*4 ({})",
|
||||
rgba.len(),
|
||||
width * height * 4
|
||||
)));
|
||||
}
|
||||
self.to_svg(&ColorImage {
|
||||
pixels: rgba,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
let c = &self.inner;
|
||||
format!(
|
||||
"Config(clustering='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
|
||||
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
|
||||
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
|
||||
max_colors={:?}, optimize={})",
|
||||
clustering_str(c.clustering),
|
||||
hierarchical_str(c.hierarchical),
|
||||
mode_str(c.mode),
|
||||
c.filter_speckle,
|
||||
c.color_precision,
|
||||
c.layer_difference,
|
||||
c.corner_threshold,
|
||||
c.length_threshold,
|
||||
c.max_iterations,
|
||||
c.splice_threshold,
|
||||
c.path_precision,
|
||||
c.palette.len(),
|
||||
c.max_colors,
|
||||
c.optimize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- module-level convenience ------------------------------------------------
|
||||
|
||||
/// Convert a file to SVG on disk, using `config` (or defaults).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (input_path, output_path, config = None))]
|
||||
fn convert_file(
|
||||
input_path: PathBuf,
|
||||
output_path: PathBuf,
|
||||
config: Option<PyConfig>,
|
||||
) -> PyResult<()> {
|
||||
config
|
||||
.unwrap_or_else(default_config)
|
||||
.convert_file(input_path, output_path)
|
||||
}
|
||||
|
||||
/// Convert encoded image bytes to an SVG string, using `config` (or defaults).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (data, config = None, format = None))]
|
||||
fn convert_bytes(
|
||||
data: Vec<u8>,
|
||||
config: Option<PyConfig>,
|
||||
format: Option<&str>,
|
||||
) -> PyResult<String> {
|
||||
config
|
||||
.unwrap_or_else(default_config)
|
||||
.convert_bytes(data, format)
|
||||
}
|
||||
|
||||
/// Convert a raw RGBA8 buffer to an SVG string, using `config` (or defaults).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (rgba, width, height, config = None))]
|
||||
fn convert_pixels(
|
||||
rgba: Vec<u8>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
config: Option<PyConfig>,
|
||||
) -> PyResult<String> {
|
||||
config
|
||||
.unwrap_or_else(default_config)
|
||||
.convert_pixels(rgba, width, height)
|
||||
}
|
||||
|
||||
fn default_config() -> PyConfig {
|
||||
PyConfig {
|
||||
inner: CoreConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[pymodule]
|
||||
#[pyo3(name = "vtracer")]
|
||||
fn vtracer_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyConfig>()?;
|
||||
m.add_function(wrap_pyfunction!(convert_file, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(convert_bytes, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(convert_pixels, m)?)?;
|
||||
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import Optional
|
||||
|
||||
__version__: str
|
||||
|
||||
class Config:
|
||||
"""Conversion configuration. Construct with keyword arguments or a preset,
|
||||
mutate via properties, then call one of the ``convert_*`` methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
|
||||
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
|
||||
mode: str = "spline", # "pixel" | "polygon" | "spline"
|
||||
filter_speckle: int = 4,
|
||||
color_precision: int = 6,
|
||||
layer_difference: int = 16,
|
||||
corner_threshold: int = 60,
|
||||
length_threshold: float = 4.0,
|
||||
max_iterations: int = 10,
|
||||
splice_threshold: int = 45,
|
||||
simplify: Optional[float] = None, # curve simplification tolerance in px (None = off)
|
||||
path_precision: int = 2,
|
||||
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
|
||||
max_colors: Optional[int] = None, # auto-quantize target
|
||||
optimize: int = 1, # 0 | 1 | 2
|
||||
binary_threshold: int = 128, # bw: fixed cutoff 0..=255
|
||||
adaptive: bool = False, # bw: Bradley–Roth adaptive
|
||||
adaptive_window: int = 0, # bw adaptive: window px (0 = auto)
|
||||
adaptive_t: float = 15.0, # bw adaptive: % below local mean
|
||||
watershed_detail: int = 128, # watershed: cut level (higher = more regions, uncapped)
|
||||
) -> None: ...
|
||||
|
||||
@staticmethod
|
||||
def bw() -> "Config": ...
|
||||
@staticmethod
|
||||
def poster() -> "Config": ...
|
||||
@staticmethod
|
||||
def photo() -> "Config": ...
|
||||
|
||||
clustering: str
|
||||
hierarchical: str
|
||||
mode: str
|
||||
filter_speckle: int
|
||||
color_precision: int
|
||||
layer_difference: int
|
||||
corner_threshold: int
|
||||
length_threshold: float
|
||||
max_iterations: int
|
||||
splice_threshold: int
|
||||
simplify: Optional[float]
|
||||
path_precision: Optional[int]
|
||||
palette: list[str]
|
||||
max_colors: Optional[int]
|
||||
optimize: int
|
||||
binary_threshold: int
|
||||
adaptive: bool
|
||||
adaptive_window: int
|
||||
adaptive_t: float
|
||||
watershed_detail: int
|
||||
|
||||
def convert_file(self, input_path: str, output_path: str) -> None: ...
|
||||
def convert_bytes(self, data: bytes, format: Optional[str] = None) -> str: ...
|
||||
def convert_pixels(self, rgba: bytes, width: int, height: int) -> str: ...
|
||||
|
||||
def convert_file(input_path: str, output_path: str, config: Optional[Config] = None) -> None: ...
|
||||
def convert_bytes(data: bytes, config: Optional[Config] = None, format: Optional[str] = None) -> str: ...
|
||||
def convert_pixels(rgba: bytes, width: int, height: int, config: Optional[Config] = None) -> str: ...
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "vtracer"
|
||||
description = "A vectorization framework that converts raster images into vector graphics: pluggable frontends, curve fitters, color fitting, and output optimization."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
categories = ["graphics", "computer-vision"]
|
||||
keywords = ["svg", "vectorization", "computer-graphics"]
|
||||
readme = "../../README.md"
|
||||
|
||||
[lib]
|
||||
name = "vtracer"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
visioncortex.workspace = true
|
||||
flo_curves.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not
|
||||
# compiled for wasm targets, so the library stays wasm-safe.
|
||||
resvg = "0.45"
|
||||
# Decode the sample photo for the spline-fitting regression test. Test-only.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
@@ -0,0 +1,2 @@
|
||||
# This crate is hand-formatted; a stray `cargo fmt` must not rewrite it.
|
||||
disable_all_formatting = true
|
||||
@@ -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,46 @@
|
||||
use crate::ir::{Layer, RegionMask, Segmentation};
|
||||
|
||||
use super::ColorFitter;
|
||||
|
||||
/// Union consecutive layers that share a paint into a single layer. Run this
|
||||
/// after palette snapping (which is what creates runs of identical paints) to
|
||||
/// cut the shape count without changing appearance.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MergeAdjacent;
|
||||
|
||||
/// Collapse one run of same-paint layers and push the result.
|
||||
///
|
||||
/// The whole run is unioned in a single pass — folding pairwise would reallocate
|
||||
/// and rewrite a canvas-sized accumulator once per layer. See
|
||||
/// [`RegionMask::union_all`].
|
||||
fn flush(run: &mut Vec<Layer>, out: &mut Vec<Layer>) {
|
||||
match run.len() {
|
||||
0 => {}
|
||||
1 => out.push(run.pop().expect("run is non-empty")),
|
||||
_ => {
|
||||
let paint = run[0].paint;
|
||||
let masks: Vec<&RegionMask> = run.iter().map(|l| &l.mask).collect();
|
||||
let mask = RegionMask::union_all(&masks);
|
||||
out.push(Layer { paint, mask });
|
||||
run.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorFitter for MergeAdjacent {
|
||||
fn fit(&self, seg: &mut Segmentation) {
|
||||
if seg.layers.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let mut merged: Vec<Layer> = Vec::with_capacity(seg.layers.len());
|
||||
let mut run: Vec<Layer> = Vec::new();
|
||||
for layer in seg.layers.drain(..) {
|
||||
if run.first().is_some_and(|first| first.paint != layer.paint) {
|
||||
flush(&mut run, &mut merged);
|
||||
}
|
||||
run.push(layer);
|
||||
}
|
||||
flush(&mut run, &mut merged);
|
||||
seg.layers = merged;
|
||||
}
|
||||
}
|
||||
@@ -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,131 @@
|
||||
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
|
||||
//!
|
||||
//! * **Stacked** — each layer is traced independently into closed outlines and
|
||||
//! stacked in paint order (painter's algorithm).
|
||||
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
|
||||
//! geometry (see [`crate::mosaic`]).
|
||||
//!
|
||||
//! Both compositors run the pipeline's [`CurvePass`]es over every fitted
|
||||
//! contour before assembling paths — geometry passes have to happen here, on
|
||||
//! the fitted geometry, so that in mosaic mode each shared boundary segment
|
||||
//! is transformed exactly once for both of its faces.
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::fitter::CurveFitter;
|
||||
use crate::ir::{MultiPath, RegionMask, Segmentation, Shape, VectorDoc};
|
||||
use crate::mosaic::{compose_mosaic, SegmentFitter};
|
||||
use crate::progress::{Ctx, Phase};
|
||||
use crate::simplify::CurvePass;
|
||||
|
||||
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
|
||||
pub enum Compositing {
|
||||
/// Independent per-region closed outlines, stacked bottom-to-top.
|
||||
Stacked(Box<dyn CurveFitter>),
|
||||
/// Seam-free gapless tessellation via a shared boundary graph.
|
||||
Mosaic {
|
||||
fitter: Box<dyn SegmentFitter>,
|
||||
/// Merge flattened neighbours whose colors are within this diff —
|
||||
/// rejoins regions the stacked gradient layering had split. Usually
|
||||
/// the clustering gradient step; `0` still merges identical-color
|
||||
/// neighbours, negative disables merging entirely.
|
||||
merge_diff: i32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Compositing {
|
||||
/// Run the selected compositor over a segmentation, applying `passes` to
|
||||
/// every fitted contour before paths are assembled.
|
||||
pub fn compose(&self, seg: &Segmentation, passes: &[Box<dyn CurvePass>]) -> VectorDoc {
|
||||
match self {
|
||||
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref(), passes),
|
||||
Compositing::Mosaic { fitter, merge_diff } => {
|
||||
compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress- and cancellation-aware compositing.
|
||||
///
|
||||
/// Stacked mode reports per-layer progress and can be cancelled between
|
||||
/// layers. Mosaic builds its boundary graph in one pass, so it reports
|
||||
/// coarsely (start/end) and is cancellable only at the boundaries — the
|
||||
/// dominant cost is upstream in clustering, which cancels finely.
|
||||
pub fn compose_with(
|
||||
&self,
|
||||
seg: &Segmentation,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
ctx: &mut Ctx,
|
||||
) -> Result<VectorDoc, Error> {
|
||||
match self {
|
||||
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), passes, ctx),
|
||||
Compositing::Mosaic { fitter, merge_diff } => {
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Compose, 0.0);
|
||||
let doc = compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes);
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Compose, 1.0);
|
||||
Ok(doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit one region's outlines and run the curve passes over each contour.
|
||||
/// Stacked contours are closed rings, so the ring form of each pass applies.
|
||||
fn fit_region(
|
||||
fitter: &dyn CurveFitter,
|
||||
mask: &RegionMask,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
) -> MultiPath {
|
||||
let mut path = MultiPath::new();
|
||||
for mut geom in fitter.fit_region(mask) {
|
||||
for pass in passes {
|
||||
geom = pass.ring(geom);
|
||||
}
|
||||
path.push(geom.into_closed_subpath());
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Progress-aware [`compose_stacked`]: reports after each layer and checks for
|
||||
/// cancellation between them.
|
||||
fn compose_stacked_with(
|
||||
seg: &Segmentation,
|
||||
fitter: &dyn CurveFitter,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
ctx: &mut Ctx,
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let mut doc = VectorDoc::new(seg.width, seg.height);
|
||||
let total = seg.layers.len().max(1);
|
||||
for (i, layer) in seg.layers.iter().enumerate() {
|
||||
ctx.check()?;
|
||||
let path = fit_region(fitter, &layer.mask, passes);
|
||||
if !path.is_empty() {
|
||||
doc.shapes.push(Shape {
|
||||
paint: layer.paint,
|
||||
path,
|
||||
});
|
||||
}
|
||||
ctx.report(Phase::Compose, (i + 1) as f32 / total as f32);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Trace every layer's closed outline and stack the shapes in paint order.
|
||||
pub fn compose_stacked(
|
||||
seg: &Segmentation,
|
||||
fitter: &dyn CurveFitter,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
) -> VectorDoc {
|
||||
let mut doc = VectorDoc::new(seg.width, seg.height);
|
||||
for layer in &seg.layers {
|
||||
let path = fit_region(fitter, &layer.mask, passes);
|
||||
if !path.is_empty() {
|
||||
doc.shapes.push(Shape {
|
||||
paint: layer.paint,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
//! High-level configuration and presets that assemble a [`Pipeline`].
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use visioncortex::Color;
|
||||
|
||||
use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAdjacent};
|
||||
use crate::compose::Compositing;
|
||||
use crate::error::Error;
|
||||
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
|
||||
use crate::frontend::{
|
||||
BinaryFrontend, ColorClusterFrontend, Frontend, Threshold, WatershedFrontend,
|
||||
};
|
||||
use crate::mosaic::{
|
||||
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
|
||||
};
|
||||
use crate::optimize::{CleanupPass, OptimizerPass, QuantizePass};
|
||||
use crate::pipeline::Pipeline;
|
||||
use crate::simplify::{CurvePass, SimplifyCurves};
|
||||
use crate::svg::SvgWriter;
|
||||
|
||||
/// Which region-forming algorithm segments the image.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Clustering {
|
||||
/// Hierarchical color clustering — the classic VTracer path.
|
||||
ColorCluster,
|
||||
/// Threshold to black/white, then cluster the foreground.
|
||||
Binary,
|
||||
/// Hierarchical watershed on the pixel graph, cut at `watershed_detail`.
|
||||
Watershed,
|
||||
}
|
||||
|
||||
/// How regions are combined into the final document.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Hierarchical {
|
||||
/// Trace each layer independently and stack them in paint order (painter's
|
||||
/// algorithm). Simple and robust; smoothed neighbours may drift slightly
|
||||
/// apart along a shared edge.
|
||||
Stacked,
|
||||
/// Seam-free, gapless mosaic: each shared boundary is fitted once and
|
||||
/// referenced by both adjacent faces, so the tessellation never cracks.
|
||||
/// See [`crate::mosaic`].
|
||||
Cutout,
|
||||
}
|
||||
|
||||
/// How a region's pixel outline is turned into vector geometry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FitMode {
|
||||
/// Exact pixel-lattice polyline; no smoothing.
|
||||
Pixel,
|
||||
/// Douglas–Peucker polygon — straight edges, fewer points.
|
||||
Polygon,
|
||||
/// Corner detection plus least-squares cubic Béziers — smooth curves.
|
||||
Spline,
|
||||
}
|
||||
|
||||
/// A starting point for [`Config`], tuned for a common kind of input. See
|
||||
/// [`Config::from_preset`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Preset {
|
||||
/// Black-and-white line art (binary clustering).
|
||||
Bw,
|
||||
/// Flat, poster-like color with a fuller palette.
|
||||
Poster,
|
||||
/// Photographic input: heavier speckle filtering and coarser layering.
|
||||
Photo,
|
||||
}
|
||||
|
||||
/// The clustering-relevant projection of a [`Config`]. Two configs with equal
|
||||
/// keys produce the same [`Segmentation`](crate::Segmentation), so a cached one
|
||||
/// stays valid — this is what [`Session`](crate::Session) compares to decide
|
||||
/// whether to re-segment. Produced by [`Config::segment_key`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SegmentKey {
|
||||
clustering: Clustering,
|
||||
color_precision: i32,
|
||||
layer_difference: i32,
|
||||
filter_speckle: usize,
|
||||
binary_threshold: u8,
|
||||
binary_adaptive: bool,
|
||||
binary_adaptive_window: u32,
|
||||
binary_adaptive_t: f64,
|
||||
watershed_detail: u32,
|
||||
}
|
||||
|
||||
/// High-level converter configuration. [`Config::build`] turns this into a
|
||||
/// concrete [`Pipeline`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Region-forming algorithm (see [`Clustering`]).
|
||||
pub clustering: Clustering,
|
||||
/// How regions are combined — stacked layers or a seam-free mosaic (see
|
||||
/// [`Hierarchical`]).
|
||||
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,
|
||||
/// Curve-fitting mode (see [`FitMode`]).
|
||||
pub mode: FitMode,
|
||||
/// Corner threshold in degrees.
|
||||
pub corner_threshold: i32,
|
||||
/// Segment length threshold in pixels.
|
||||
pub length_threshold: f64,
|
||||
/// Maximum least-squares refinement iterations per spline segment.
|
||||
pub max_iterations: usize,
|
||||
/// Splice threshold in degrees.
|
||||
pub splice_threshold: i32,
|
||||
/// Curve simplification tolerance in px (paper.js-style `simplify`):
|
||||
/// re-fit smooth runs of fitted cubics with the fewest curves that stay
|
||||
/// within this distance, keeping corners in place. `None` = off. Only
|
||||
/// affects spline mode; pixel/polygon polylines pass through untouched.
|
||||
pub simplify: Option<f64>,
|
||||
/// Coordinate precision (decimal places) for output.
|
||||
pub path_precision: Option<u32>,
|
||||
/// Fixed palette (empty = none). Takes priority over `max_colors`.
|
||||
pub palette: Vec<Color>,
|
||||
/// Auto-quantize target color count (None = off).
|
||||
pub max_colors: Option<usize>,
|
||||
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
|
||||
pub optimize: u8,
|
||||
/// Binary-mode fixed threshold (0..=255): foreground when grayscale
|
||||
/// intensity is below this. Ignored when `binary_adaptive` is set.
|
||||
pub binary_threshold: u8,
|
||||
/// Binary mode: use Bradley–Roth adaptive thresholding instead of the fixed
|
||||
/// cutoff (better for uneven lighting).
|
||||
pub binary_adaptive: bool,
|
||||
/// Adaptive window side length in pixels; 0 = auto (~1/8 of the shorter side).
|
||||
pub binary_adaptive_window: u32,
|
||||
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
|
||||
pub binary_adaptive_t: f64,
|
||||
/// Watershed clustering: where to cut the hierarchy. Higher keeps more
|
||||
/// regions (each +25.5 roughly doubles the region count); 0 collapses the
|
||||
/// image to a single region. Uncapped.
|
||||
pub watershed_detail: u32,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clustering: Clustering::ColorCluster,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 4,
|
||||
color_precision: 6,
|
||||
layer_difference: 16,
|
||||
mode: FitMode::Spline,
|
||||
corner_threshold: 60,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: 45,
|
||||
simplify: None,
|
||||
path_precision: Some(2),
|
||||
palette: Vec::new(),
|
||||
max_colors: None,
|
||||
optimize: 1,
|
||||
binary_threshold: 128,
|
||||
binary_adaptive: false,
|
||||
binary_adaptive_window: 0,
|
||||
binary_adaptive_t: 15.0,
|
||||
watershed_detail: 128,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Build a [`Config`] from a [`Preset`], adjusting the defaults for a
|
||||
/// common kind of input.
|
||||
pub fn from_preset(preset: Preset) -> Self {
|
||||
match preset {
|
||||
Preset::Bw => Self {
|
||||
clustering: Clustering::Binary,
|
||||
..Self::default()
|
||||
},
|
||||
Preset::Poster => Self {
|
||||
color_precision: 8,
|
||||
..Self::default()
|
||||
},
|
||||
Preset::Photo => Self {
|
||||
filter_speckle: 10,
|
||||
color_precision: 8,
|
||||
layer_difference: 48,
|
||||
corner_threshold: 180,
|
||||
..Self::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_params(&self) -> FitParams {
|
||||
FitParams {
|
||||
corner_threshold: deg2rad(self.corner_threshold),
|
||||
length_threshold: self.length_threshold,
|
||||
max_iterations: self.max_iterations,
|
||||
splice_threshold: deg2rad(self.splice_threshold),
|
||||
}
|
||||
}
|
||||
|
||||
fn frontend(&self) -> Box<dyn Frontend> {
|
||||
match self.clustering {
|
||||
Clustering::ColorCluster => Box::new(ColorClusterFrontend {
|
||||
color_precision_loss: 8 - self.color_precision,
|
||||
layer_difference: self.layer_difference,
|
||||
good_min_area: self.speckle_area(),
|
||||
}),
|
||||
Clustering::Binary => {
|
||||
let threshold = if self.binary_adaptive {
|
||||
Threshold::Adaptive {
|
||||
window: self.binary_adaptive_window,
|
||||
t: self.binary_adaptive_t,
|
||||
}
|
||||
} else {
|
||||
Threshold::Fixed(self.binary_threshold)
|
||||
};
|
||||
Box::new(BinaryFrontend {
|
||||
threshold,
|
||||
diagonal: false,
|
||||
min_area: self.speckle_area(),
|
||||
})
|
||||
}
|
||||
Clustering::Watershed => Box::new(WatershedFrontend {
|
||||
detail: self.watershed_detail,
|
||||
min_area: self.speckle_area(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Speckle filter area (px), fed to the frontend.
|
||||
pub(crate) fn speckle_area(&self) -> usize {
|
||||
self.filter_speckle * self.filter_speckle
|
||||
}
|
||||
|
||||
fn color_fitters(&self) -> Vec<Box<dyn ColorFitter>> {
|
||||
if !self.palette.is_empty() {
|
||||
vec![
|
||||
Box::new(FixedPalette::new(self.palette.clone())),
|
||||
Box::new(MergeAdjacent),
|
||||
]
|
||||
} else if let Some(max_colors) = self.max_colors {
|
||||
vec![Box::new(AutoQuantize { max_colors }), Box::new(MergeAdjacent)]
|
||||
} else {
|
||||
vec![Box::new(Identity)]
|
||||
}
|
||||
}
|
||||
|
||||
fn fitter(&self) -> Box<dyn CurveFitter> {
|
||||
match self.mode {
|
||||
FitMode::Pixel => Box::new(PixelFitter),
|
||||
FitMode::Polygon => Box::new(PolygonFitter),
|
||||
FitMode::Spline => Box::new(SplineFitter::new(self.fit_params())),
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_fitter(&self) -> Box<dyn SegmentFitter> {
|
||||
match self.mode {
|
||||
FitMode::Pixel => Box::new(PixelSegmentFitter),
|
||||
FitMode::Polygon => Box::new(PolygonSegmentFitter::default()),
|
||||
FitMode::Spline => Box::new(SplineSegmentFitter {
|
||||
corner_threshold: deg2rad(self.corner_threshold),
|
||||
length_threshold: self.length_threshold,
|
||||
max_iterations: self.max_iterations,
|
||||
splice_threshold: deg2rad(self.splice_threshold),
|
||||
..SplineSegmentFitter::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn curve_passes(&self) -> Vec<Box<dyn CurvePass>> {
|
||||
match self.simplify {
|
||||
Some(tolerance) if tolerance > 0.0 => vec![Box::new(SimplifyCurves {
|
||||
tolerance,
|
||||
corner_threshold: deg2rad(self.corner_threshold),
|
||||
})],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn optimizers(&self) -> Vec<Box<dyn OptimizerPass>> {
|
||||
if self.optimize == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let precision = self.path_precision.unwrap_or(2);
|
||||
vec![
|
||||
Box::new(QuantizePass::new(precision)),
|
||||
Box::new(CleanupPass),
|
||||
]
|
||||
}
|
||||
|
||||
fn writer(&self) -> SvgWriter {
|
||||
match self.optimize {
|
||||
0 => SvgWriter {
|
||||
relative: false,
|
||||
shorthands: false,
|
||||
precision: self.path_precision,
|
||||
},
|
||||
1 => SvgWriter {
|
||||
relative: true,
|
||||
shorthands: false,
|
||||
precision: self.path_precision,
|
||||
},
|
||||
_ => SvgWriter {
|
||||
relative: true,
|
||||
shorthands: true,
|
||||
precision: self.path_precision,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The clustering-relevant subset of this config. Changing any field it
|
||||
/// captures (clustering algorithm, color precision, layer difference,
|
||||
/// speckle, binary threshold settings, or watershed detail) requires
|
||||
/// re-segmenting; changing anything else — fit mode, curve params,
|
||||
/// compositing, palette, optimization — reuses a cached segmentation. See
|
||||
/// [`Session`](crate::Session).
|
||||
pub fn segment_key(&self) -> SegmentKey {
|
||||
SegmentKey {
|
||||
clustering: self.clustering,
|
||||
color_precision: self.color_precision,
|
||||
layer_difference: self.layer_difference,
|
||||
filter_speckle: self.filter_speckle,
|
||||
binary_threshold: self.binary_threshold,
|
||||
binary_adaptive: self.binary_adaptive,
|
||||
binary_adaptive_window: self.binary_adaptive_window,
|
||||
binary_adaptive_t: self.binary_adaptive_t,
|
||||
watershed_detail: self.watershed_detail,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble a concrete pipeline from this configuration.
|
||||
pub fn build(&self) -> Result<Pipeline, Error> {
|
||||
let compositing = match self.hierarchical {
|
||||
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
|
||||
Hierarchical::Cutout => Compositing::Mosaic {
|
||||
fitter: self.segment_fitter(),
|
||||
// Rejoin flattened neighbours the clustering split too finely.
|
||||
// Color clustering considers colors within one gradient step
|
||||
// to be the same region (`deepen_diff`), so that is its
|
||||
// tolerance. The watershed dial has no color units (it
|
||||
// targets a region *count*), so its tolerance is anchored
|
||||
// instead: at the default detail (128) it matches the
|
||||
// color-cluster default gradient step (16) and grows linearly
|
||||
// as detail drops; the floor keeps faces a human cannot tell
|
||||
// apart (within a just-noticeable difference) from surviving
|
||||
// as separate patches even at maximum detail.
|
||||
merge_diff: match self.clustering {
|
||||
Clustering::Watershed => {
|
||||
(((255i64 - self.watershed_detail as i64) / 8).max(2)) as i32
|
||||
}
|
||||
_ => self.layer_difference,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Pipeline {
|
||||
frontend: self.frontend(),
|
||||
color_fitters: self.color_fitters(),
|
||||
compositing,
|
||||
curve_passes: self.curve_passes(),
|
||||
optimizers: self.optimizers(),
|
||||
writer: self.writer(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn deg2rad(deg: i32) -> f64 {
|
||||
deg as f64 / 180.0 * std::f64::consts::PI
|
||||
}
|
||||
|
||||
impl FromStr for Clustering {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
|
||||
"binary" | "bw" | "BW" => Ok(Self::Binary),
|
||||
"watershed" => Ok(Self::Watershed),
|
||||
_ => Err(format!("unknown clustering {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Hierarchical {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"stacked" => Ok(Self::Stacked),
|
||||
"cutout" => Ok(Self::Cutout),
|
||||
_ => Err(format!("unknown hierarchical mode {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FitMode {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"pixel" | "none" => Ok(Self::Pixel),
|
||||
"polygon" => Ok(Self::Polygon),
|
||||
"spline" => Ok(Self::Spline),
|
||||
_ => Err(format!("unknown fit mode {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Preset {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"bw" => Ok(Self::Bw),
|
||||
"poster" => Ok(Self::Poster),
|
||||
"photo" => Ok(Self::Photo),
|
||||
_ => Err(format!("unknown preset {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The crate's [`Error`] type, returned by the pipeline and every stage.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Errors produced by the framework stages and the pipeline driver.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// The input image had zero width or height.
|
||||
EmptyImage,
|
||||
/// Transparency keying was requested but no unused key color could be found.
|
||||
NoKeyColor,
|
||||
/// A requested feature is recognized but not yet implemented.
|
||||
Unsupported(String),
|
||||
/// The run was aborted via a [`crate::progress::CancelToken`].
|
||||
Cancelled,
|
||||
/// Any other failure, carrying a human-readable message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::EmptyImage => write!(f, "input image is empty"),
|
||||
Error::NoKeyColor => {
|
||||
write!(f, "unable to find an unused color in image to use as key")
|
||||
}
|
||||
Error::Unsupported(what) => write!(f, "unsupported: {what}"),
|
||||
Error::Cancelled => write!(f, "conversion cancelled"),
|
||||
Error::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<String> for Error {
|
||||
fn from(msg: String) -> Self {
|
||||
Error::Other(msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Error {
|
||||
fn from(msg: &str) -> Self {
|
||||
Error::Other(msg.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Curve fitters: turn a region's pixel mask into vector outlines.
|
||||
//!
|
||||
//! The three built-ins wrap the corresponding visioncortex tracing modes and
|
||||
//! emit [`FittedGeom`] contours in absolute (document) coordinates:
|
||||
//!
|
||||
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
|
||||
//! * [`PolygonFitter`] — staircase-symmetric Douglas–Peucker polygon.
|
||||
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
|
||||
//!
|
||||
//! All three trace *closed* region outlines (outer ring plus holes). The
|
||||
//! mosaic compositor fits open boundary segments instead; see
|
||||
//! [`crate::mosaic::SegmentFitter`].
|
||||
|
||||
use visioncortex::clusters::Cluster as BinaryCluster;
|
||||
use visioncortex::{
|
||||
CompoundPath, CompoundPathElement, PathSimplifyMode, PointF64, PointI32,
|
||||
};
|
||||
|
||||
use crate::ir::{PathCmd, RegionMask, SubPath};
|
||||
|
||||
/// Fitted geometry for one contour — the common currency between the curve
|
||||
/// fitters, the [`CurvePass`](crate::simplify::CurvePass) stage, and
|
||||
/// composition. The stacked fitters produce one per closed outline; the
|
||||
/// mosaic fitters produce one per shared boundary segment.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FittedGeom {
|
||||
/// Polyline (pixel / polygon backends).
|
||||
Polyline(Vec<PointF64>),
|
||||
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
|
||||
Beziers(Vec<[PointF64; 4]>),
|
||||
}
|
||||
|
||||
impl FittedGeom {
|
||||
/// Convert one closed contour into a `MoveTo … Close` subpath.
|
||||
pub fn into_closed_subpath(self) -> SubPath {
|
||||
match self {
|
||||
FittedGeom::Polyline(points) => polyline_subpath(&points),
|
||||
FittedGeom::Beziers(chain) => beziers_subpath(&chain),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fitting parameters shared by the built-in fitters. Only the spline fitter
|
||||
/// consults the smoothing/splice fields.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FitParams {
|
||||
/// Minimum momentary angle (radians) to be considered a corner.
|
||||
pub corner_threshold: f64,
|
||||
/// Subdivide until all segments are shorter than this length (px).
|
||||
pub length_threshold: f64,
|
||||
/// Maximum smoothing iterations.
|
||||
pub max_iterations: usize,
|
||||
/// Minimum angle displacement (radians) to splice a spline.
|
||||
pub splice_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for FitParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
corner_threshold: std::f64::consts::PI / 3.0, // 60°
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: std::f64::consts::PI / 4.0, // 45°
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve fitter traces a region mask into closed vector outlines, one
|
||||
/// [`FittedGeom`] per contour (outer ring or hole).
|
||||
pub trait CurveFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom>;
|
||||
}
|
||||
|
||||
/// Exact lattice polyline; every pixel-boundary step is preserved.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PixelFitter;
|
||||
|
||||
impl CurveFitter for PixelFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
|
||||
trace_region(mask, PathSimplifyMode::None, FitParams::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Douglas–Peucker polygon with staircase removal.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PolygonFitter;
|
||||
|
||||
impl CurveFitter for PolygonFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
|
||||
trace_region(mask, PathSimplifyMode::Polygon, FitParams::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoothed spline (cubic Bézier) fitter.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SplineFitter {
|
||||
pub params: FitParams,
|
||||
}
|
||||
|
||||
impl SplineFitter {
|
||||
pub fn new(params: FitParams) -> Self {
|
||||
Self { params }
|
||||
}
|
||||
}
|
||||
|
||||
impl CurveFitter for SplineFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
|
||||
trace_region(mask, PathSimplifyMode::Spline, self.params)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace every connected component of a masked region and collect the
|
||||
/// resulting outlines, one [`FittedGeom`] per contour, in absolute coordinates.
|
||||
///
|
||||
/// This mirrors visioncortex's `Cluster::to_compound_path`: the mask (with
|
||||
/// holes already punched) is split into connected sub-clusters, each traced
|
||||
/// independently, then offset into document space.
|
||||
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> Vec<FittedGeom> {
|
||||
let mut geoms = Vec::new();
|
||||
for sub in mask.image.to_clusters(false).iter() {
|
||||
let offset = PointI32 {
|
||||
x: mask.offset.x + sub.rect.left,
|
||||
y: mask.offset.y + sub.rect.top,
|
||||
};
|
||||
let compound = BinaryCluster::image_to_compound_path(
|
||||
&offset,
|
||||
&sub.to_binary_image(),
|
||||
mode,
|
||||
params.corner_threshold,
|
||||
params.length_threshold,
|
||||
params.max_iterations,
|
||||
params.splice_threshold,
|
||||
);
|
||||
append_compound(&mut geoms, &compound);
|
||||
}
|
||||
geoms
|
||||
}
|
||||
|
||||
fn append_compound(geoms: &mut Vec<FittedGeom>, compound: &CompoundPath) {
|
||||
for element in compound.iter() {
|
||||
match element {
|
||||
CompoundPathElement::PathI32(p) => {
|
||||
let pts: Vec<PointF64> = p
|
||||
.path
|
||||
.iter()
|
||||
.map(|q| PointF64 {
|
||||
x: q.x as f64,
|
||||
y: q.y as f64,
|
||||
})
|
||||
.collect();
|
||||
geoms.push(FittedGeom::Polyline(pts));
|
||||
}
|
||||
CompoundPathElement::PathF64(p) => {
|
||||
geoms.push(FittedGeom::Polyline(p.path.clone()));
|
||||
}
|
||||
CompoundPathElement::Spline(s) => {
|
||||
geoms.push(FittedGeom::Beziers(spline_chain(&s.points)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spline of `1 + 3n` points becomes a chain of `n` cubics sharing endpoints.
|
||||
fn spline_chain(points: &[PointF64]) -> Vec<[PointF64; 4]> {
|
||||
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut chain = Vec::with_capacity((points.len() - 1) / 3);
|
||||
let mut start = points[0];
|
||||
let mut i = 1;
|
||||
while i + 2 < points.len() {
|
||||
chain.push([start, points[i], points[i + 1], points[i + 2]]);
|
||||
start = points[i + 2];
|
||||
i += 3;
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// A closed polyline whose last point repeats the first becomes
|
||||
/// `MoveTo · LineTo* · Close`.
|
||||
fn polyline_subpath(points: &[PointF64]) -> SubPath {
|
||||
let mut sub = SubPath::new();
|
||||
if points.len() < 2 {
|
||||
return sub;
|
||||
}
|
||||
// The tracer emits closed paths whose final point duplicates the first.
|
||||
let closed = points.first() == points.last();
|
||||
let body_end = if closed { points.len() - 1 } else { points.len() };
|
||||
sub.commands.push(PathCmd::MoveTo(points[0]));
|
||||
for p in &points[1..body_end] {
|
||||
sub.commands.push(PathCmd::LineTo(*p));
|
||||
}
|
||||
sub.commands.push(PathCmd::Close);
|
||||
sub
|
||||
}
|
||||
|
||||
/// A cubic chain becomes `MoveTo · CubicTo* · Close`.
|
||||
fn beziers_subpath(chain: &[[PointF64; 4]]) -> SubPath {
|
||||
let mut sub = SubPath::new();
|
||||
if chain.is_empty() {
|
||||
return sub;
|
||||
}
|
||||
sub.commands.push(PathCmd::MoveTo(chain[0][0]));
|
||||
for c in chain {
|
||||
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
|
||||
}
|
||||
sub.commands.push(PathCmd::Close);
|
||||
sub
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! 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; the
|
||||
//! [`Threshold`] can be a fixed global cutoff or Bradley–Roth adaptive
|
||||
//! thresholding for unevenly-lit input.
|
||||
//! * [`WatershedFrontend`] — hierarchical watershed on the pixel graph.
|
||||
//!
|
||||
//! Third parties can implement [`Frontend`] to feed external label maps or ML
|
||||
//! segmentation into the pipeline.
|
||||
|
||||
mod binary;
|
||||
mod color_cluster;
|
||||
mod keying;
|
||||
mod watershed;
|
||||
|
||||
pub use binary::{BinaryFrontend, Threshold};
|
||||
pub use color_cluster::ColorClusterFrontend;
|
||||
pub use watershed::{WatershedFrontend, WatershedHierarchy};
|
||||
|
||||
use visioncortex::ColorImage;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::Segmentation;
|
||||
use crate::progress::Ctx;
|
||||
|
||||
/// A frontend segments a raster image into ordered paint layers.
|
||||
pub trait Frontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
|
||||
|
||||
/// Progress- and cancellation-aware segmentation.
|
||||
///
|
||||
/// The default runs [`segment`](Frontend::segment) and then honors
|
||||
/// cancellation (coarse: one report at completion, cancel observed after
|
||||
/// the whole segmentation). Frontends that can step incrementally — like
|
||||
/// [`ColorClusterFrontend`] — override this to report fine-grained
|
||||
/// progress and observe cancellation between batches.
|
||||
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
|
||||
let seg = self.segment(img)?;
|
||||
ctx.check()?;
|
||||
ctx.report(crate::progress::Phase::Segment, 1.0);
|
||||
Ok(seg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use visioncortex::{BinaryImage, Color, ColorImage, PointI32, SummedAreaTable};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
||||
|
||||
use super::Frontend;
|
||||
|
||||
/// Grayscale intensity (0..=255) used by every thresholding method. Matches the
|
||||
/// metric `SummedAreaTable::from_color_image` sums, so fixed and adaptive
|
||||
/// thresholds agree on what "dark" means.
|
||||
#[inline]
|
||||
fn intensity(c: Color) -> u32 {
|
||||
(c.r as u32 + c.g as u32 + c.b as u32) / 3
|
||||
}
|
||||
|
||||
/// How the binary frontend separates foreground (dark) from background pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Threshold {
|
||||
/// Global cutoff: a pixel is foreground when its intensity is below this
|
||||
/// value (0..=255). Fast and predictable; best for clean, evenly-lit input.
|
||||
Fixed(u8),
|
||||
/// Bradley–Roth adaptive threshold: a pixel is foreground when its
|
||||
/// intensity is more than `t` percent below the mean of the surrounding
|
||||
/// `window`×`window` block. Handles uneven lighting and shadows that defeat
|
||||
/// a single global cutoff. Computed in one pass with a summed-area table,
|
||||
/// so it stays O(pixels) regardless of window size.
|
||||
Adaptive {
|
||||
/// Window side length in pixels; `0` auto-derives ~1/8 of the shorter
|
||||
/// image dimension (the value suggested by the paper).
|
||||
window: u32,
|
||||
/// Sensitivity, as a percentage below the local mean (paper default 15).
|
||||
t: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Threshold {
|
||||
/// Bradley–Roth adaptive thresholding with the paper's defaults
|
||||
/// (auto window, `t = 15`).
|
||||
pub const fn adaptive() -> Self {
|
||||
Threshold::Adaptive {
|
||||
window: 0,
|
||||
t: 15.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Threshold {
|
||||
fn default() -> Self {
|
||||
Threshold::Fixed(128)
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary (black/white) frontend: threshold the image then cluster the
|
||||
/// foreground. Every region is painted black.
|
||||
///
|
||||
/// The [`Threshold`] chooses how foreground is separated from background — a
|
||||
/// fixed global cutoff ([`Threshold::Fixed`]) or Bradley–Roth adaptive
|
||||
/// thresholding ([`Threshold::Adaptive`]) for scans and photos with uneven
|
||||
/// lighting.
|
||||
///
|
||||
/// Speckle removal drops clusters smaller than `min_area` px as the clusters
|
||||
/// are collected, matching the pre-1.0 binary path (`cluster.size() >= area`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BinaryFrontend {
|
||||
/// How foreground pixels are selected (see [`Threshold`]).
|
||||
pub threshold: Threshold,
|
||||
/// Whether to connect clusters diagonally.
|
||||
pub diagonal: bool,
|
||||
/// Discard clusters smaller than this many pixels (0 = keep all).
|
||||
pub min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for BinaryFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threshold: Threshold::default(),
|
||||
diagonal: false,
|
||||
min_area: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BinaryFrontend {
|
||||
/// Binarize `img` into a foreground mask according to [`Self::threshold`].
|
||||
fn binarize(&self, img: &ColorImage) -> BinaryImage {
|
||||
match self.threshold {
|
||||
Threshold::Fixed(value) => {
|
||||
let value = value as u32;
|
||||
img.to_binary_image(|c| intensity(c) < value)
|
||||
}
|
||||
Threshold::Adaptive { window, t } => adaptive_bradley_roth(img, window, t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bradley–Roth adaptive thresholding via a summed-area table.
|
||||
///
|
||||
/// For each pixel, compare its intensity to the mean of a surrounding window:
|
||||
/// it is foreground when `value <= mean * (1 - t/100)`, i.e. more than `t`
|
||||
/// percent darker than its neighborhood.
|
||||
fn adaptive_bradley_roth(img: &ColorImage, window: u32, t: f64) -> BinaryImage {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let sat = SummedAreaTable::from_color_image(img);
|
||||
|
||||
// Window: 0 => auto (~1/8 of the shorter side, per the paper), min 1.
|
||||
let side = if window == 0 {
|
||||
(w.min(h) / 8).max(1)
|
||||
} else {
|
||||
window as usize
|
||||
};
|
||||
let half = side / 2;
|
||||
let factor = 1.0 - t.clamp(0.0, 100.0) / 100.0;
|
||||
|
||||
let mut out = BinaryImage::new_w_h(w, h);
|
||||
for y in 0..h {
|
||||
let y0 = y.saturating_sub(half);
|
||||
let y1 = (y + half).min(h - 1);
|
||||
for x in 0..w {
|
||||
let x0 = x.saturating_sub(half);
|
||||
let x1 = (x + half).min(w - 1);
|
||||
|
||||
let count = ((x1 - x0 + 1) * (y1 - y0 + 1)) as f64;
|
||||
let sum = sat.get_region_sum_x_y_w_h(x0, y0, x1 - x0 + 1, y1 - y0 + 1) as f64;
|
||||
let value = intensity(img.get_pixel(x, y)) as f64;
|
||||
|
||||
// value <= mean * factor ⇔ value * count <= sum * factor
|
||||
out.set_pixel(x, y, value * count <= sum * factor);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Frontend for BinaryFrontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
let binary = self.binarize(img);
|
||||
let clusters = binary.to_clusters(self.diagonal);
|
||||
|
||||
let mut seg = Segmentation::new(width as u32, height as u32);
|
||||
let black = Color::new(0, 0, 0);
|
||||
for i in 0..clusters.len() {
|
||||
let cluster = clusters.get_cluster(i);
|
||||
if cluster.size() < self.min_area {
|
||||
continue;
|
||||
}
|
||||
let mask = RegionMask::new(
|
||||
cluster.to_binary_image(),
|
||||
PointI32 {
|
||||
x: cluster.rect.left,
|
||||
y: cluster.rect.top,
|
||||
},
|
||||
);
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(black),
|
||||
mask,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(seg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use visioncortex::color_clusters::{
|
||||
Clusters, KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX,
|
||||
};
|
||||
use visioncortex::{Color, ColorImage, PointI32};
|
||||
|
||||
// (Runner is constructed inline in each entry point so its generic closure
|
||||
// types never appear in a return signature.)
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
||||
use crate::progress::{Ctx, Phase};
|
||||
|
||||
use super::keying::{apply_key, find_unused_color, should_key_image};
|
||||
use super::Frontend;
|
||||
|
||||
/// Hierarchical color-clustering frontend — the classic VTracer color path.
|
||||
///
|
||||
/// Speckle removal happens *inside* clustering, via `good_min_area`: it is the
|
||||
/// clusterer's `deepen` gate (visioncortex `patch_good`), so it does far more
|
||||
/// than drop small regions — it decides whether a small/thin patch is absorbed
|
||||
/// into its neighbor (its color averaged in) or kept as its own layer. Forcing
|
||||
/// it to 0 disables the thread-like rejection and changes the whole hierarchy,
|
||||
/// so speckle must be a clustering parameter, not a downstream filter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColorClusterFrontend {
|
||||
/// Bits of color precision dropped when comparing pixels (0 = full 8-bit).
|
||||
pub color_precision_loss: i32,
|
||||
/// Color difference between hierarchical gradient layers.
|
||||
pub layer_difference: i32,
|
||||
/// Minimum area (px) for a patch to be a `deepen` candidate during
|
||||
/// clustering; non-zero also enables visioncortex's thread-like rejection.
|
||||
/// Below it, patches are absorbed into their nearest-color neighbor.
|
||||
pub good_min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for ColorClusterFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color_precision_loss: 2,
|
||||
layer_difference: 16,
|
||||
good_min_area: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorClusterFrontend {
|
||||
/// Apply transparency keying (if warranted) and build the clustering
|
||||
/// inputs: the keyed image, the `RunnerConfig`, and the dimensions. The
|
||||
/// caller constructs `Runner::new(config, image)` inline so the runner's
|
||||
/// generic closure types never surface in a return signature.
|
||||
fn prepare(&self, img: &ColorImage) -> Result<(ColorImage, RunnerConfig, usize, usize), Error> {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
let mut img = img.clone();
|
||||
|
||||
// Transparency keying (stacked mode discards the keyed background).
|
||||
let key_color = if should_key_image(&img) {
|
||||
let key = find_unused_color(&img)?;
|
||||
apply_key(&mut img, key);
|
||||
key
|
||||
} else {
|
||||
// All-zero is the sentinel understood by visioncortex as "no keying".
|
||||
Color::default()
|
||||
};
|
||||
|
||||
let config = RunnerConfig {
|
||||
diagonal: self.layer_difference == 0,
|
||||
hierarchical: HIERARCHICAL_MAX,
|
||||
batch_size: 25600,
|
||||
good_min_area: self.good_min_area,
|
||||
good_max_area: width * height,
|
||||
is_same_color_a: self.color_precision_loss,
|
||||
is_same_color_b: 1,
|
||||
deepen_diff: self.layer_difference,
|
||||
hollow_neighbours: 1,
|
||||
key_color,
|
||||
keying_action: KeyingAction::Discard,
|
||||
};
|
||||
|
||||
Ok((img, config, width, height))
|
||||
}
|
||||
|
||||
/// Turn finished clusters into the layered [`Segmentation`].
|
||||
fn segmentation_from_clusters(clusters: &Clusters, width: usize, height: usize) -> Segmentation {
|
||||
let view = clusters.view();
|
||||
let mut seg = Segmentation::new(width as u32, height as u32);
|
||||
// `clusters_output` is top-to-bottom; reverse to get bottom-to-top
|
||||
// paint order for the layer stack.
|
||||
for &cluster_index in view.clusters_output.iter().rev() {
|
||||
let cluster = view.get_cluster(cluster_index);
|
||||
// Solid cluster masks (no holes punched): stacked mode relies on
|
||||
// paint-order overdraw for occlusion, matching 0.6.x. Punching
|
||||
// holes here would leave the layer below exposed as hairline seams.
|
||||
// The mosaic flatten is unaffected — a higher layer still wins per
|
||||
// pixel — so a solid parent gives the same partition.
|
||||
let image = cluster.to_image_with_hole(view.width, false);
|
||||
let mask = RegionMask::new(
|
||||
image,
|
||||
PointI32 {
|
||||
x: cluster.rect.left,
|
||||
y: cluster.rect.top,
|
||||
},
|
||||
);
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(cluster.residue_color()),
|
||||
mask,
|
||||
});
|
||||
}
|
||||
seg
|
||||
}
|
||||
}
|
||||
|
||||
impl Frontend for ColorClusterFrontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
let (image, config, width, height) = self.prepare(img)?;
|
||||
let clusters = Runner::new(config, image).run();
|
||||
Ok(Self::segmentation_from_clusters(&clusters, width, height))
|
||||
}
|
||||
|
||||
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
|
||||
let (image, config, width, height) = self.prepare(img)?;
|
||||
|
||||
// Drive clustering incrementally so we can publish progress and observe
|
||||
// cancellation between batches. `run()` is exactly this loop, so the
|
||||
// resulting clusters are identical to the blocking path.
|
||||
let mut builder = Runner::new(config, image).start();
|
||||
ctx.report(Phase::Segment, 0.0);
|
||||
while !builder.tick() {
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Segment, builder.progress() as f32 / 100.0);
|
||||
}
|
||||
ctx.check()?;
|
||||
let clusters = builder.result();
|
||||
ctx.report(Phase::Segment, 1.0);
|
||||
|
||||
Ok(Self::segmentation_from_clusters(&clusters, width, height))
|
||||
}
|
||||
}
|
||||
@@ -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,909 @@
|
||||
//! Hierarchical watershed frontend — region forming on the pixel graph.
|
||||
//!
|
||||
//! The image is treated as a 4-adjacency edge-weighted graph (edge weight =
|
||||
//! color difference between the two pixels; no gradient image is built). On it
|
||||
//! we compute the watershed hierarchy by **volume extinction**, following:
|
||||
//!
|
||||
//! * Cousty, Bertrand, Najman, Couprie, *Watershed Cuts: Minimum Spanning
|
||||
//! Forests and the Drop of Water Principle*, IEEE TPAMI 31(8), 2009.
|
||||
//! * Najman, Cousty, Perret, *Playing with Kruskal: Algorithms for
|
||||
//! Morphological Trees in Edge-Weighted Graphs*, ISMM 2013.
|
||||
//!
|
||||
//! The work is split in two so the expensive part can be cached (see
|
||||
//! [`crate::Session`]):
|
||||
//!
|
||||
//! * [`WatershedHierarchy::build`] — Kruskal over counting-sorted edges builds
|
||||
//! the binary partition tree (a flat `parents` array, leaves `0..n`,
|
||||
//! internal nodes created in altitude order); a leaves-to-root pass computes
|
||||
//! each subtree's area and volume; each internal node's *persistence* (the
|
||||
//! volume of the smaller of the two merged basins) becomes the saliency of
|
||||
//! its MST edge. This depends only on the image — no tuning parameters.
|
||||
//! * [`WatershedHierarchy::cut`] — cutting at level λ is single-linkage over
|
||||
//! MST edges with persistence ≤ λ (every pixel gets a label, no
|
||||
//! watershed-line pixels), antialiased boundary pixels are snapped to the
|
||||
//! color-midpoint iso-line (see [`snap_boundaries`]), small basins are
|
||||
//! absorbed, and the surviving merge tree above λ becomes the output layer
|
||||
//! stack.
|
||||
//!
|
||||
//! The cut emits a **stacked hierarchy**, the same principle as the color
|
||||
//! clustering frontend: the root (whole canvas, mean color) is painted first,
|
||||
//! then progressively finer ancestor regions, then the final regions on top.
|
||||
//! Sub-pixel gaps between abutting regions therefore show their common
|
||||
//! ancestor's color instead of an unrelated backdrop, and stacked mode stays
|
||||
//! seam-free by overdraw. Flattening top-down (what cutout does) recovers the
|
||||
//! exact partition, because the final regions are painted last.
|
||||
//!
|
||||
//! Everything is integer and allocation-flat: counting sort over 256 weight
|
||||
//! buckets, path-halving union-find, `u32` node ids. Deterministic across
|
||||
//! platforms.
|
||||
|
||||
use visioncortex::{BinaryImage, Color, ColorImage, PointI32};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
||||
|
||||
use super::Frontend;
|
||||
|
||||
/// Cap on the total painted area of ancestor layers, as a multiple of the
|
||||
/// canvas: keeps a pathological hierarchy (long chains of near-equal
|
||||
/// persistence) from ballooning the stacked output. The root and the final
|
||||
/// regions are always emitted, so coverage never depends on this.
|
||||
const ANCESTOR_AREA_BUDGET: usize = 3;
|
||||
|
||||
/// Watershed frontend: hierarchical watershed by volume, cut at `detail`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WatershedFrontend {
|
||||
/// Detail level: where to cut the hierarchy. The normal range is 0..=255 —
|
||||
/// each +25.5 roughly doubles the region count, 0 collapses the image to a
|
||||
/// single region. Values above 255 are practically uncapped.
|
||||
pub detail: u32,
|
||||
/// Absorb regions smaller than this many pixels into their most
|
||||
/// color-similar neighbour after the cut (0 = keep all).
|
||||
pub min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for WatershedFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
detail: 128,
|
||||
min_area: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat union-find over `u32` ids with path halving.
|
||||
struct Uf(Vec<u32>);
|
||||
|
||||
impl Uf {
|
||||
fn new(n: usize) -> Self {
|
||||
Uf((0..n as u32).collect())
|
||||
}
|
||||
|
||||
fn find(&mut self, mut x: u32) -> u32 {
|
||||
while self.0[x as usize] != x {
|
||||
self.0[x as usize] = self.0[self.0[x as usize] as usize];
|
||||
x = self.0[x as usize];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Union by attaching `b`'s root under `a`'s. Caller passes roots.
|
||||
fn link(&mut self, a: u32, b: u32) {
|
||||
self.0[b as usize] = a;
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge weight: max per-channel absolute difference (L∞), the same family of
|
||||
/// channel-difference metric the rest of vtracer uses. 0..=255.
|
||||
#[inline]
|
||||
fn edge_weight(a: Color, b: Color) -> u8 {
|
||||
let dr = a.r.abs_diff(b.r);
|
||||
let dg = a.g.abs_diff(b.g);
|
||||
let db = a.b.abs_diff(b.b);
|
||||
dr.max(dg).max(db)
|
||||
}
|
||||
|
||||
/// The image's watershed hierarchy: the minimum spanning tree of the pixel
|
||||
/// graph with a persistence (volume extinction) per edge. Building it is the
|
||||
/// expensive step and depends only on the image; [`cut`](Self::cut) derives a
|
||||
/// [`Segmentation`] for any detail level in near-linear time, so interactive
|
||||
/// re-tuning never repays the build (see [`crate::Session`]).
|
||||
pub struct WatershedHierarchy {
|
||||
width: usize,
|
||||
height: usize,
|
||||
/// MST edges as pixel pairs, in Kruskal creation order.
|
||||
mst: Vec<(u32, u32)>,
|
||||
/// Persistence (volume of the smaller merged basin) per MST edge.
|
||||
pers: Vec<u64>,
|
||||
/// MST edge indices by ascending (persistence, index) — the cut order.
|
||||
order: Vec<u32>,
|
||||
}
|
||||
|
||||
impl WatershedHierarchy {
|
||||
/// Build the hierarchy: counting-sorted Kruskal → binary partition tree →
|
||||
/// volume persistence per MST edge. O(n α(n)).
|
||||
pub fn build(img: &ColorImage) -> Result<Self, Error> {
|
||||
let w = img.width;
|
||||
let h = img.height;
|
||||
if w == 0 || h == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
let n = w * h;
|
||||
if n == 1 {
|
||||
return Ok(Self {
|
||||
width: w,
|
||||
height: h,
|
||||
mst: Vec::new(),
|
||||
pers: Vec::new(),
|
||||
order: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- 4-adjacency edges, counting-sorted by weight -------------------
|
||||
// Edge id encodes (pixel, direction): 2*p = right, 2*p+1 = down.
|
||||
// The per-bucket fill preserves edge-id order, so the sort is stable
|
||||
// and the whole construction is deterministic.
|
||||
let px = |i: usize| img.get_pixel(i % w, i / w);
|
||||
let mut counts = [0u32; 256];
|
||||
let mut weight_of = vec![0u8; 2 * n];
|
||||
for i in 0..n {
|
||||
let c = px(i);
|
||||
if i % w + 1 < w {
|
||||
let wgt = edge_weight(c, px(i + 1));
|
||||
weight_of[2 * i] = wgt;
|
||||
counts[wgt as usize] += 1;
|
||||
}
|
||||
if i / w + 1 < h {
|
||||
let wgt = edge_weight(c, px(i + w));
|
||||
weight_of[2 * i + 1] = wgt;
|
||||
counts[wgt as usize] += 1;
|
||||
}
|
||||
}
|
||||
let n_edges = counts.iter().map(|&c| c as usize).sum::<usize>();
|
||||
let mut start = [0usize; 256];
|
||||
let mut acc = 0usize;
|
||||
for b in 0..256 {
|
||||
start[b] = acc;
|
||||
acc += counts[b] as usize;
|
||||
}
|
||||
let mut sorted = vec![0u32; n_edges];
|
||||
let mut fill = start;
|
||||
for i in 0..n {
|
||||
if i % w + 1 < w {
|
||||
let e = 2 * i;
|
||||
let b = weight_of[e] as usize;
|
||||
sorted[fill[b]] = e as u32;
|
||||
fill[b] += 1;
|
||||
}
|
||||
if i / w + 1 < h {
|
||||
let e = 2 * i + 1;
|
||||
let b = weight_of[e] as usize;
|
||||
sorted[fill[b]] = e as u32;
|
||||
fill[b] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kruskal → binary partition tree by altitude --------------------
|
||||
// Leaves 0..n are pixels; each accepted MST edge creates internal node
|
||||
// n+k whose two children are the merged components' current roots.
|
||||
// The grid is connected, so exactly n-1 internal nodes are created and
|
||||
// parent indices are always greater than child indices.
|
||||
let n_nodes = 2 * n - 1;
|
||||
let mut parent = vec![u32::MAX; n_nodes];
|
||||
let mut alt = vec![0u8; n_nodes]; // altitude; leaves at 0
|
||||
let mut child = vec![[0u32; 2]; n - 1]; // children of internal node k
|
||||
let mut mst = vec![(0u32, 0u32); n - 1]; // pixel pair of edge k
|
||||
let mut uf = Uf::new(n);
|
||||
// Current tree node representing each union-find root's component.
|
||||
let mut comp_node: Vec<u32> = (0..n as u32).collect();
|
||||
let mut next = n as u32;
|
||||
for &e in &sorted {
|
||||
let p = (e / 2) as usize;
|
||||
let q = if e % 2 == 0 { p + 1 } else { p + w };
|
||||
let (rp, rq) = (uf.find(p as u32), uf.find(q as u32));
|
||||
if rp == rq {
|
||||
continue;
|
||||
}
|
||||
let k = (next - n as u32) as usize;
|
||||
alt[next as usize] = weight_of[e as usize];
|
||||
child[k] = [comp_node[rp as usize], comp_node[rq as usize]];
|
||||
mst[k] = (p as u32, q as u32);
|
||||
parent[comp_node[rp as usize] as usize] = next;
|
||||
parent[comp_node[rq as usize] as usize] = next;
|
||||
uf.link(rp, rq);
|
||||
comp_node[rp as usize] = next;
|
||||
next += 1;
|
||||
}
|
||||
debug_assert_eq!(next as usize, n_nodes);
|
||||
|
||||
// --- Volume attribute, leaves → root --------------------------------
|
||||
// area = pixels in the subtree; volume = ∫ area over altitude, i.e.
|
||||
// each node contributes area × (parent altitude − own altitude).
|
||||
// Ascending index order visits all children before their parent.
|
||||
let root = n_nodes - 1;
|
||||
let mut area = vec![0u64; n_nodes];
|
||||
for a in area.iter_mut().take(n) {
|
||||
*a = 1;
|
||||
}
|
||||
let mut volume = vec![0u64; n_nodes];
|
||||
for i in 0..root {
|
||||
let pa = parent[i] as usize;
|
||||
area[pa] += area[i];
|
||||
let rise = (alt[pa] - alt[i]) as u64; // parent is never lower
|
||||
volume[i] += area[i] * rise;
|
||||
volume[pa] += volume[i];
|
||||
}
|
||||
|
||||
// --- Persistence per MST edge ----------------------------------------
|
||||
// Plateau fix first (Playing with Kruskal): equal-weight edge chains
|
||||
// create internal nodes at the same altitude as their parent; their
|
||||
// volume is not a real basin measure, so replace it with the max over
|
||||
// children while the altitude is unchanged.
|
||||
let mut corrected = volume;
|
||||
for i in n..n_nodes {
|
||||
let k = i - n;
|
||||
if i != root && alt[i] == alt[parent[i] as usize] {
|
||||
let [c0, c1] = child[k];
|
||||
corrected[i] = corrected[c0 as usize].max(corrected[c1 as usize]);
|
||||
}
|
||||
}
|
||||
// Persistence of a merge = the volume of the smaller side: the level
|
||||
// at which that basin stops existing on its own.
|
||||
let mut pers = vec![0u64; n - 1];
|
||||
for k in 0..n - 1 {
|
||||
let [c0, c1] = child[k];
|
||||
pers[k] = corrected[c0 as usize].min(corrected[c1 as usize]);
|
||||
}
|
||||
|
||||
let mut order: Vec<u32> = (0..(n - 1) as u32).collect();
|
||||
order.sort_by_key(|&k| (pers[k as usize], k));
|
||||
|
||||
Ok(Self {
|
||||
width: w,
|
||||
height: h,
|
||||
mst,
|
||||
pers,
|
||||
order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cut the hierarchy at `detail` and emit the stacked [`Segmentation`].
|
||||
/// Near-linear; safe to call repeatedly with different parameters.
|
||||
pub fn cut(&self, img: &ColorImage, detail: u32, min_area: usize) -> Segmentation {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let n = w * h;
|
||||
let m = self.mst.len();
|
||||
|
||||
// --- Region formation: merge every MST edge with persistence ≤ λ ----
|
||||
// Merging leaves exactly 1 + #{edges above λ} regions, so choosing λ
|
||||
// as the k-th largest persistence targets k regions directly (ties
|
||||
// merge a little more). The persistence distribution is extremely
|
||||
// skewed — most merges are trivia at ≈ 0 — so the dial maps to a
|
||||
// region *count*, exponentially: every +25.5 of detail doubles the
|
||||
// target, from 1 region at 0. The target saturates at the edge count,
|
||||
// so a large detail (≥ 25.5·log2(pixels), e.g. ≥ 612 for a 4096²
|
||||
// image) is practically uncapped: λ = min persistence, keeping every
|
||||
// basin above the zero-persistence trivia.
|
||||
let mut uf = Uf::new(n);
|
||||
if m > 0 {
|
||||
let target = (2f64).powf(detail as f64 / 25.5).round() as usize;
|
||||
let target = target.clamp(1, m);
|
||||
let lambda = self.pers[self.order[m - target] as usize];
|
||||
for &k in &self.order {
|
||||
if self.pers[k as usize] > lambda {
|
||||
break;
|
||||
}
|
||||
let (p, q) = self.mst[k as usize];
|
||||
let (rp, rq) = (uf.find(p), uf.find(q));
|
||||
if rp != rq {
|
||||
uf.link(rp, rq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Compact to region ids and region stats --------------------------
|
||||
// One find per pixel; everything after this works on the (small)
|
||||
// region graph so re-cuts stay cheap.
|
||||
let mut pre_of_root = vec![u32::MAX; n];
|
||||
let mut pre = vec![0u32; n];
|
||||
let mut kp = 0usize;
|
||||
for i in 0..n {
|
||||
let r = uf.find(i as u32) as usize;
|
||||
if pre_of_root[r] == u32::MAX {
|
||||
pre_of_root[r] = kp as u32;
|
||||
kp += 1;
|
||||
}
|
||||
pre[i] = pre_of_root[r];
|
||||
}
|
||||
let mut area = vec![0u64; kp];
|
||||
let mut sum = vec![[0u64; 3]; kp];
|
||||
for i in 0..n {
|
||||
let a = pre[i] as usize;
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
area[a] += 1;
|
||||
sum[a][0] += c.r as u64;
|
||||
sum[a][1] += c.g as u64;
|
||||
sum[a][2] += c.b as u64;
|
||||
}
|
||||
|
||||
// --- Boundary snap, then boundary adjacency ---------------------------
|
||||
snap_boundaries(img, w, h, &mut pre, &mut area, &mut sum);
|
||||
let mut pairs: Vec<(u32, u32)> = Vec::new();
|
||||
for i in 0..n {
|
||||
let a = pre[i];
|
||||
if i % w + 1 < w && pre[i + 1] != a {
|
||||
pairs.push((a, pre[i + 1]));
|
||||
}
|
||||
if i / w + 1 < h && pre[i + w] != a {
|
||||
pairs.push((a, pre[i + w]));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Small-basin absorption on the region graph ----------------------
|
||||
let mut uf_r = Uf::new(kp);
|
||||
absorb_small(min_area, &pairs, &mut uf_r, &mut area, &mut sum);
|
||||
|
||||
// --- Final leaf ids in raster order of first appearance --------------
|
||||
let mut leaf_of = vec![u32::MAX; kp];
|
||||
let mut leaf_root: Vec<u32> = Vec::new(); // leaf id -> absorb root
|
||||
let mut ids = vec![0u32; n];
|
||||
for i in 0..n {
|
||||
let r = uf_r.find(pre[i]) as usize;
|
||||
if leaf_of[r] == u32::MAX {
|
||||
leaf_of[r] = leaf_root.len() as u32;
|
||||
leaf_root.push(r as u32);
|
||||
}
|
||||
ids[i] = leaf_of[r];
|
||||
}
|
||||
let k = leaf_root.len();
|
||||
|
||||
let mean = |s: &[u64; 3], a: u64| {
|
||||
Color::new((s[0] / a) as u8, (s[1] / a) as u8, (s[2] / a) as u8)
|
||||
};
|
||||
|
||||
let mut seg = Segmentation::new(w as u32, h as u32);
|
||||
if k == 1 {
|
||||
// Single region: one solid full-canvas layer.
|
||||
let r = leaf_root[0] as usize;
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&sum[r], area[r])),
|
||||
mask: full_canvas(w, h),
|
||||
});
|
||||
return seg;
|
||||
}
|
||||
|
||||
// --- Merge tree above the cut ----------------------------------------
|
||||
// Re-run all merges (ascending persistence) over the final regions:
|
||||
// each one that still joins two components is a kept split. Nodes 0..k
|
||||
// are the final regions; internal nodes are created in ascending
|
||||
// persistence order, so the reverse is a root-first order in which
|
||||
// every ancestor precedes its descendants. Below-cut edges are almost
|
||||
// all no-ops (their endpoints share a region), but not quite: boundary
|
||||
// snapping can leave a region's only adjacency running through a
|
||||
// below-cut edge, and skipping those would leave the tree unconnected.
|
||||
let n_tree = 2 * k - 1;
|
||||
let mut tree_child: Vec<[u32; 2]> = Vec::with_capacity(k - 1);
|
||||
let mut tree_area = vec![0u64; n_tree];
|
||||
let mut tree_sum = vec![[0u64; 3]; n_tree];
|
||||
for (t, &r) in leaf_root.iter().enumerate() {
|
||||
tree_area[t] = area[r as usize];
|
||||
tree_sum[t] = sum[r as usize];
|
||||
}
|
||||
let mut uf2 = Uf::new(k);
|
||||
let mut node_rep: Vec<u32> = (0..k as u32).collect();
|
||||
let mut next = k as u32;
|
||||
for &e in &self.order {
|
||||
let (p, q) = self.mst[e as usize];
|
||||
let (lp, lq) = (ids[p as usize], ids[q as usize]);
|
||||
if lp == lq {
|
||||
continue; // same region — the bulk of the below-cut edges
|
||||
}
|
||||
let (a, b) = (uf2.find(lp), uf2.find(lq));
|
||||
if a == b {
|
||||
continue; // already merged, or rejoined by absorption
|
||||
}
|
||||
let node = next as usize;
|
||||
tree_child.push([node_rep[a as usize], node_rep[b as usize]]);
|
||||
for ch in [node_rep[a as usize], node_rep[b as usize]] {
|
||||
tree_area[node] += tree_area[ch as usize];
|
||||
for c in 0..3 {
|
||||
tree_sum[node][c] += tree_sum[ch as usize][c];
|
||||
}
|
||||
}
|
||||
uf2.link(a, b);
|
||||
node_rep[a as usize] = next;
|
||||
next += 1;
|
||||
}
|
||||
debug_assert_eq!(next as usize, n_tree);
|
||||
|
||||
// Per-leaf pixel lists, for painting ancestor masks.
|
||||
let mut leaf_len = vec![0u32; k];
|
||||
for &id in &ids {
|
||||
leaf_len[id as usize] += 1;
|
||||
}
|
||||
let mut leaf_start = vec![0usize; k + 1];
|
||||
for t in 0..k {
|
||||
leaf_start[t + 1] = leaf_start[t] + leaf_len[t] as usize;
|
||||
}
|
||||
let mut leaf_px = vec![0u32; n];
|
||||
let mut fill = leaf_start.clone();
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
leaf_px[fill[id as usize]] = i as u32;
|
||||
fill[id as usize] += 1;
|
||||
}
|
||||
|
||||
// --- Emit: root, ancestors (budgeted), then the final regions --------
|
||||
let root = n_tree - 1;
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&tree_sum[root], tree_area[root])),
|
||||
mask: full_canvas(w, h),
|
||||
});
|
||||
let mut budget = ANCESTOR_AREA_BUDGET * n;
|
||||
for node in (k..root).rev() {
|
||||
let node_area = tree_area[node] as usize;
|
||||
if node_area > budget {
|
||||
continue;
|
||||
}
|
||||
budget -= node_area;
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&tree_sum[node], tree_area[node])),
|
||||
mask: node_mask(node, k, &tree_child, &leaf_start, &leaf_px, w),
|
||||
});
|
||||
}
|
||||
for t in 0..k {
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&tree_sum[t], tree_area[t])),
|
||||
mask: node_mask(t, k, &tree_child, &leaf_start, &leaf_px, w),
|
||||
});
|
||||
}
|
||||
seg
|
||||
}
|
||||
}
|
||||
|
||||
fn full_canvas(w: usize, h: usize) -> RegionMask {
|
||||
let mut image = BinaryImage::new_w_h(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
image.set_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
RegionMask::new(image, PointI32 { x: 0, y: 0 })
|
||||
}
|
||||
|
||||
/// Paint a tree node's region (the union of the final regions beneath it)
|
||||
/// into a bbox-cropped mask.
|
||||
fn node_mask(
|
||||
node: usize,
|
||||
k: usize,
|
||||
tree_child: &[[u32; 2]],
|
||||
leaf_start: &[usize],
|
||||
leaf_px: &[u32],
|
||||
w: usize,
|
||||
) -> RegionMask {
|
||||
// Collect the node's leaves.
|
||||
let mut leaves: Vec<usize> = Vec::new();
|
||||
let mut stack = vec![node];
|
||||
while let Some(t) = stack.pop() {
|
||||
if t < k {
|
||||
leaves.push(t);
|
||||
} else {
|
||||
let [a, b] = tree_child[t - k];
|
||||
stack.push(a as usize);
|
||||
stack.push(b as usize);
|
||||
}
|
||||
}
|
||||
// Bounding box over all member pixels.
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for &t in &leaves {
|
||||
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
|
||||
let (x, y) = ((p as usize % w) as i32, (p as usize / w) as i32);
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x);
|
||||
y1 = y1.max(y);
|
||||
}
|
||||
}
|
||||
let (bw, bh) = ((x1 - x0 + 1) as usize, (y1 - y0 + 1) as usize);
|
||||
let mut image = BinaryImage::new_w_h(bw, bh);
|
||||
for &t in &leaves {
|
||||
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
|
||||
let (x, y) = (p as usize % w, p as usize / w);
|
||||
image.set_pixel(x - x0 as usize, y - y0 as usize, true);
|
||||
}
|
||||
}
|
||||
RegionMask::new(image, PointI32 { x: x0, y: y0 })
|
||||
}
|
||||
|
||||
/// How many 1-px boundary-snap sweeps to run: bounds the boundary movement to
|
||||
/// the width of an antialiasing ramp / JPEG halo (compression ringing spreads
|
||||
/// a hard edge over up to ~3 px; a plain AA ramp over 1–2 px).
|
||||
const SNAP_SWEEPS: usize = 4;
|
||||
|
||||
/// Tolerance for the mixture test below: an antialiased blend of two region
|
||||
/// colors satisfies `d(p,A) + d(p,B) = d(A,B)` exactly (L1, per-channel
|
||||
/// between-ness); this slack admits sensor/JPEG noise of a few units per
|
||||
/// channel without admitting genuine third colors.
|
||||
const SNAP_SLACK: i32 = 16;
|
||||
|
||||
/// Re-assign boundary pixels to whichever adjacent region's mean color is
|
||||
/// closest (strictly closer than their own region's mean, L1).
|
||||
///
|
||||
/// The minimum-spanning-forest cut routes the boundary through whichever
|
||||
/// crack of an antialiasing ramp has the minutely-largest weight, so along a
|
||||
/// smooth edge it meanders ±1–2 px with the pixel noise and the fitted curves
|
||||
/// visibly wave (crisp synthetic edges are unaffected: their boundary pixels
|
||||
/// sit exactly at a region's mean). Snapping by color lands the boundary on
|
||||
/// the color-midpoint iso-line of the ramp instead — the same rule color
|
||||
/// quantization applies, which is why the color-cluster frontend never shows
|
||||
/// this.
|
||||
///
|
||||
/// Only pixels whose color is a *mixture* of two adjacent region means may
|
||||
/// flip (`d(p,A) + d(p,B) ≤ d(A,B) + slack`): a pixel of a genuine third
|
||||
/// color — say a dark outline stroke absorbed into a lighter region — must
|
||||
/// stay with its basin even when some other neighbour's mean happens to sit
|
||||
/// closer. The mixture pair is usually the pixel's own region and the flip
|
||||
/// candidate (the classic AA ramp), but a pair of *neighbouring* regions
|
||||
/// also qualifies: on a blurred low-contrast crack the basin cut can leak a
|
||||
/// distant region along the crack's blend band as a 1-px filament — those
|
||||
/// pixels blend the two flanking regions and are unrelated to their own
|
||||
/// region's color, and they belong to the closer flank.
|
||||
/// Sweeps are double-buffered (flips apply after scanning) and each moves the
|
||||
/// boundary at most 1 px, so total movement stays within the ambiguity band;
|
||||
/// regions are never emptied. Only the first sweep scans the whole canvas;
|
||||
/// later sweeps revisit the moving front (last sweep's flips and their
|
||||
/// neighbours), so the cost past sweep one is proportional to the boundary
|
||||
/// that is actually moving.
|
||||
fn snap_boundaries(
|
||||
img: &ColorImage,
|
||||
w: usize,
|
||||
h: usize,
|
||||
labels: &mut [u32],
|
||||
area: &mut [u64],
|
||||
sum: &mut [[u64; 3]],
|
||||
) {
|
||||
let n = w * h;
|
||||
let k = area.len();
|
||||
if k < 2 {
|
||||
return;
|
||||
}
|
||||
// Where a boundary pixel should move, if anywhere: strict improvement
|
||||
// only, gated on the mixture test; the first of the fixed neighbour
|
||||
// order wins ties, keeping the sweep deterministic.
|
||||
let snap_target = |i: usize, labels: &[u32], mean: &[[i32; 3]]| -> Option<u32> {
|
||||
let a = labels[i] as usize;
|
||||
// Neighbour labels, replicated at the canvas border (a no-op
|
||||
// candidate) so the hot path below stays branch-light.
|
||||
let (x, y) = (i % w, i / w);
|
||||
let nb = [
|
||||
labels[if x > 0 { i - 1 } else { i }] as usize,
|
||||
labels[if x + 1 < w { i + 1 } else { i }] as usize,
|
||||
labels[if y > 0 { i - w } else { i }] as usize,
|
||||
labels[if y + 1 < h { i + w } else { i }] as usize,
|
||||
];
|
||||
if nb == [a; 4] {
|
||||
return None; // interior pixel — the overwhelmingly common case
|
||||
}
|
||||
let c = img.get_pixel(x, y);
|
||||
let cv = [c.r as i32, c.g as i32, c.b as i32];
|
||||
let dist = |m: &[i32; 3]| {
|
||||
(cv[0] - m[0]).abs() + (cv[1] - m[1]).abs() + (cv[2] - m[2]).abs()
|
||||
};
|
||||
let da = dist(&mean[a]);
|
||||
// The pixel qualifies as a blend of regions `p` and `q` when its
|
||||
// color sits between their means (L1 between-ness plus noise slack).
|
||||
let mixture = |p: usize, q: usize| -> bool {
|
||||
let dpq: i32 = (0..3).map(|ch| (mean[p][ch] - mean[q][ch]).abs()).sum();
|
||||
dist(&mean[p]) + dist(&mean[q]) <= dpq + SNAP_SLACK
|
||||
};
|
||||
let mut best = (da, a);
|
||||
for b in nb {
|
||||
if b == a {
|
||||
continue;
|
||||
}
|
||||
let db = dist(&mean[b]);
|
||||
if db >= best.0 {
|
||||
continue;
|
||||
}
|
||||
if mixture(a, b) || nb.iter().any(|&c| c != a && c != b && mixture(c, b)) {
|
||||
best = (db, b);
|
||||
}
|
||||
}
|
||||
(best.1 != a).then_some(best.1 as u32)
|
||||
};
|
||||
|
||||
let mut mean = vec![[0i32; 3]; k];
|
||||
let mut flips: Vec<(u32, u32)> = Vec::new(); // (pixel, new label)
|
||||
let mut front: Vec<u32> = Vec::new(); // pixels to rescan; sweep 0 scans all
|
||||
let mut touched: Vec<u32> = Vec::new(); // every front, for the fragment check
|
||||
for sweep in 0..SNAP_SWEEPS {
|
||||
for r in 0..k {
|
||||
for ch in 0..3 {
|
||||
mean[r][ch] = (sum[r][ch] / area[r]) as i32;
|
||||
}
|
||||
}
|
||||
flips.clear();
|
||||
if sweep == 0 {
|
||||
// Interior first with a branch-free neighbour check (the div/mod
|
||||
// and border branches in snap_target would dominate a whole-canvas
|
||||
// scan), then the border rim.
|
||||
for y in 1..h.saturating_sub(1) {
|
||||
for i in y * w + 1..y * w + w.saturating_sub(1) {
|
||||
let a = labels[i];
|
||||
if labels[i - 1] == a
|
||||
&& labels[i + 1] == a
|
||||
&& labels[i - w] == a
|
||||
&& labels[i + w] == a
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = snap_target(i, labels, &mean) {
|
||||
flips.push((i as u32, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let h1 = h.saturating_sub(1);
|
||||
let rim = (0..w)
|
||||
.chain((1..h1).map(|y| y * w))
|
||||
.chain((1..h1).map(|y| y * w + w - 1).filter(|_| w > 1))
|
||||
.chain(if h > 1 { h1 * w..n } else { 0..0 });
|
||||
for i in rim {
|
||||
if let Some(b) = snap_target(i, labels, &mean) {
|
||||
flips.push((i as u32, b));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for &i in &front {
|
||||
if let Some(b) = snap_target(i as usize, labels, &mean) {
|
||||
flips.push((i, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
if flips.is_empty() {
|
||||
break;
|
||||
}
|
||||
for &(i, b) in &flips {
|
||||
let (i, b) = (i as usize, b as usize);
|
||||
let a = labels[i] as usize;
|
||||
if area[a] <= 1 {
|
||||
continue; // never empty a region
|
||||
}
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
labels[i] = b as u32;
|
||||
area[a] -= 1;
|
||||
area[b] += 1;
|
||||
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
|
||||
sum[a][ch] -= v as u64;
|
||||
sum[b][ch] += v as u64;
|
||||
}
|
||||
}
|
||||
// Next sweep revisits each flipped pixel and its 4-neighbourhood,
|
||||
// in raster order for determinism; the same set seeds the fragment
|
||||
// check below (a severed strand is always adjacent to the flipped
|
||||
// bridge pixel that cut it off).
|
||||
front.clear();
|
||||
for &(i, _) in &flips {
|
||||
let i = i as usize;
|
||||
let (x, y) = (i % w, i / w);
|
||||
front.push(i as u32);
|
||||
if x > 0 {
|
||||
front.push((i - 1) as u32);
|
||||
}
|
||||
if x + 1 < w {
|
||||
front.push((i + 1) as u32);
|
||||
}
|
||||
if y > 0 {
|
||||
front.push((i - w) as u32);
|
||||
}
|
||||
if y + 1 < h {
|
||||
front.push((i + w) as u32);
|
||||
}
|
||||
}
|
||||
front.sort_unstable();
|
||||
front.dedup();
|
||||
touched.extend_from_slice(&front);
|
||||
}
|
||||
touched.sort_unstable();
|
||||
touched.dedup();
|
||||
absorb_fragments(img, w, h, labels, area, sum, &touched);
|
||||
}
|
||||
|
||||
/// Fragments a snap flip may pinch off: a pixel can flip toward a neighbour
|
||||
/// whose own flip then strands it, and a flipped bridge pixel can sever a
|
||||
/// thin strand of its source region. Watershed basins are connected by
|
||||
/// construction and everything downstream relies on regions staying coherent
|
||||
/// (the mosaic gives every disjoint patch its own face), so the snap must not
|
||||
/// leave debris: a connected component that is disconnected from the rest of
|
||||
/// its region and fits under this floor is re-assigned to the most
|
||||
/// color-similar adjacent region. (A *substantial* patch severed at a thin
|
||||
/// antialiased neck stays — it makes a coherent face of its own; recoloring
|
||||
/// it would be visible.)
|
||||
const SNAP_FRAGMENT_MAX: usize = SNAP_SWEEPS * SNAP_SWEEPS;
|
||||
|
||||
fn absorb_fragments(
|
||||
img: &ColorImage,
|
||||
w: usize,
|
||||
h: usize,
|
||||
labels: &mut [u32],
|
||||
area: &mut [u64],
|
||||
sum: &mut [[u64; 3]],
|
||||
seeds: &[u32],
|
||||
) {
|
||||
let n = w * h;
|
||||
let mut visited = vec![false; n];
|
||||
let mut comp: Vec<usize> = Vec::new();
|
||||
let mut rim: Vec<u32> = Vec::new(); // adjacent region labels
|
||||
for &s in seeds {
|
||||
let s = s as usize;
|
||||
if visited[s] {
|
||||
continue;
|
||||
}
|
||||
// Flood s's same-label component, capped: hitting the cap — or a
|
||||
// pixel already visited by an earlier over-cap flood of the same
|
||||
// component — proves it is no fragment.
|
||||
let l = labels[s];
|
||||
visited[s] = true;
|
||||
comp.clear();
|
||||
comp.push(s);
|
||||
rim.clear();
|
||||
let mut over = false;
|
||||
let mut qi = 0;
|
||||
'flood: while qi < comp.len() {
|
||||
let i = comp[qi];
|
||||
qi += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
for j in [
|
||||
(x > 0).then(|| i - 1),
|
||||
(x + 1 < w).then(|| i + 1),
|
||||
(y > 0).then(|| i - w),
|
||||
(y + 1 < h).then(|| i + w),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if labels[j] != l {
|
||||
rim.push(labels[j]);
|
||||
continue;
|
||||
}
|
||||
if visited[j] {
|
||||
if !comp.contains(&j) {
|
||||
over = true; // joined an earlier over-cap flood
|
||||
break 'flood;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if comp.len() > SNAP_FRAGMENT_MAX {
|
||||
over = true;
|
||||
break 'flood;
|
||||
}
|
||||
visited[j] = true;
|
||||
comp.push(j);
|
||||
}
|
||||
}
|
||||
// A component as large as its whole region is the region itself, not
|
||||
// a fragment of one. (The flood can end at cap + 1 without tripping
|
||||
// `over`, so re-check the size.)
|
||||
if over
|
||||
|| comp.len() > SNAP_FRAGMENT_MAX
|
||||
|| comp.len() as u64 >= area[l as usize]
|
||||
|| rim.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The whole fragment moves to the adjacent region whose mean is
|
||||
// closest to the fragment's own mean.
|
||||
let mut fsum = [0i64; 3];
|
||||
for &i in &comp {
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
|
||||
fsum[ch] += v as i64;
|
||||
}
|
||||
}
|
||||
let fl = comp.len() as i64;
|
||||
rim.sort_unstable();
|
||||
rim.dedup();
|
||||
let target = rim
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
let d: i64 = (0..3)
|
||||
.map(|ch| {
|
||||
(fsum[ch] / fl - (sum[b as usize][ch] / area[b as usize]) as i64).abs()
|
||||
})
|
||||
.sum();
|
||||
(d, b)
|
||||
})
|
||||
.min()
|
||||
.unwrap()
|
||||
.1 as usize;
|
||||
let l = l as usize;
|
||||
for &i in &comp {
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
labels[i] = target as u32;
|
||||
area[l] -= 1;
|
||||
area[target] += 1;
|
||||
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
|
||||
sum[l][ch] -= v as u64;
|
||||
sum[target][ch] += v as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb regions smaller than `min_area` into their most color-similar
|
||||
/// neighbour, working entirely on the region graph: `pairs` are the boundary
|
||||
/// adjacencies (duplicates fine), `uf` is a region-level union-find, and the
|
||||
/// stats are merged along so downstream consumers see the final regions.
|
||||
/// Sweeps until nothing undersized remains (or an undersized region has no
|
||||
/// neighbour at all).
|
||||
fn absorb_small(
|
||||
min_area: usize,
|
||||
pairs: &[(u32, u32)],
|
||||
uf: &mut Uf,
|
||||
area: &mut [u64],
|
||||
sum: &mut [[u64; 3]],
|
||||
) {
|
||||
if min_area <= 1 {
|
||||
return;
|
||||
}
|
||||
let k = area.len();
|
||||
let mean_diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> u64 {
|
||||
let mut d = 0i64;
|
||||
for ch in 0..3 {
|
||||
d += ((sa[ch] / aa) as i64 - (sb[ch] / ab) as i64).abs();
|
||||
}
|
||||
d as u64
|
||||
};
|
||||
loop {
|
||||
// best[r] = (diff, neighbour_root) for undersized root r
|
||||
let mut best: Vec<(u64, u32)> = vec![(u64::MAX, u32::MAX); k];
|
||||
let mut any_small = false;
|
||||
for &(p, q) in pairs {
|
||||
let (a, b) = (uf.find(p), uf.find(q));
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
for (s, t) in [(a, b), (b, a)] {
|
||||
let (su, tu) = (s as usize, t as usize);
|
||||
if area[su] < min_area as u64 {
|
||||
any_small = true;
|
||||
let d = mean_diff(&sum[su], area[su], &sum[tu], area[tu]);
|
||||
if d < best[su].0 || (d == best[su].0 && t < best[su].1) {
|
||||
best[su] = (d, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any_small {
|
||||
break;
|
||||
}
|
||||
let mut merged = false;
|
||||
for r in 0..k {
|
||||
let (_, tgt) = best[r];
|
||||
if tgt == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let rr = uf.find(r as u32);
|
||||
if rr as usize != r {
|
||||
continue; // already absorbed this sweep
|
||||
}
|
||||
let rt = uf.find(tgt);
|
||||
if rt == rr {
|
||||
continue;
|
||||
}
|
||||
uf.link(rt, rr);
|
||||
area[rt as usize] += area[r];
|
||||
for ch in 0..3 {
|
||||
sum[rt as usize][ch] += sum[r][ch];
|
||||
}
|
||||
merged = true;
|
||||
}
|
||||
if !merged {
|
||||
break; // isolated undersized region (e.g. whole-canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Frontend for WatershedFrontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
Ok(WatershedHierarchy::build(img)?.cut(img, self.detail, self.min_area))
|
||||
}
|
||||
}
|
||||
@@ -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,123 @@
|
||||
use visioncortex::{BinaryImage, PointI32};
|
||||
|
||||
use super::Paint;
|
||||
|
||||
/// A region's pixel coverage: a local binary mask positioned on the canvas.
|
||||
///
|
||||
/// Foreground pixels are `true`. Holes (interior background) are already
|
||||
/// punched out of the mask, so a mask is self-describing for tracing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegionMask {
|
||||
/// Local coverage; `true` = inside the region.
|
||||
pub image: BinaryImage,
|
||||
/// Position of the mask's top-left corner in full-canvas coordinates.
|
||||
pub offset: PointI32,
|
||||
}
|
||||
|
||||
impl RegionMask {
|
||||
pub fn new(image: BinaryImage, offset: PointI32) -> Self {
|
||||
Self { image, offset }
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.image.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.image.height
|
||||
}
|
||||
|
||||
/// Number of foreground pixels.
|
||||
pub fn area(&self) -> usize {
|
||||
let mut count = 0;
|
||||
for y in 0..self.image.height {
|
||||
for x in 0..self.image.width {
|
||||
if self.image.get_pixel(x, y) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Combine two masks into one covering the union of their bounding boxes.
|
||||
/// Foreground is the OR of both; this is used by the layer-merge step.
|
||||
pub fn union(&self, other: &RegionMask) -> RegionMask {
|
||||
Self::union_all(&[self, other])
|
||||
}
|
||||
|
||||
/// Union any number of masks in one pass: size the destination from the
|
||||
/// combined bounding box, then blit each source into it exactly once.
|
||||
///
|
||||
/// Folding [`union`](Self::union) instead costs one full-size allocation and
|
||||
/// rewrite of the accumulator *per input*. That is quadratic in the canvas
|
||||
/// area, and it bites precisely when a palette snap leaves a long run of
|
||||
/// same-paint layers for [`MergeAdjacent`](crate::colorfit::MergeAdjacent):
|
||||
/// the accumulator grows to the full canvas after the first few merges, so
|
||||
/// every remaining layer copies the entire canvas again.
|
||||
///
|
||||
/// An empty input yields an empty mask at the origin.
|
||||
pub fn union_all(masks: &[&RegionMask]) -> RegionMask {
|
||||
let Some((first, rest)) = masks.split_first() else {
|
||||
return RegionMask::new(BinaryImage::new_w_h(0, 0), PointI32 { x: 0, y: 0 });
|
||||
};
|
||||
|
||||
let mut left = first.offset.x;
|
||||
let mut top = first.offset.y;
|
||||
let mut right = first.offset.x + first.image.width as i32;
|
||||
let mut bottom = first.offset.y + first.image.height as i32;
|
||||
for m in rest {
|
||||
left = left.min(m.offset.x);
|
||||
top = top.min(m.offset.y);
|
||||
right = right.max(m.offset.x + m.image.width as i32);
|
||||
bottom = bottom.max(m.offset.y + m.image.height as i32);
|
||||
}
|
||||
|
||||
let width = (right - left) as usize;
|
||||
let height = (bottom - top) as usize;
|
||||
let mut image = BinaryImage::new_w_h(width, height);
|
||||
|
||||
for src in masks {
|
||||
let dx = (src.offset.x - left) as usize;
|
||||
let dy = (src.offset.y - top) as usize;
|
||||
for y in 0..src.image.height {
|
||||
for x in 0..src.image.width {
|
||||
if src.image.get_pixel(x, y) {
|
||||
image.set_pixel(x + dx, y + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RegionMask::new(image, PointI32 { x: left, y: top })
|
||||
}
|
||||
}
|
||||
|
||||
/// A single paint layer. Layers are painted bottom-to-top.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Layer {
|
||||
/// Fill applied to the region. Starts as the cluster's mean color; a
|
||||
/// [`crate::colorfit::ColorFitter`] may rewrite it.
|
||||
pub paint: Paint,
|
||||
/// Pixel coverage of the region.
|
||||
pub mask: RegionMask,
|
||||
}
|
||||
|
||||
/// Frontend output: ordered layers over a canvas, in paint order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Segmentation {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Bottom-to-top paint order.
|
||||
pub layers: Vec<Layer>,
|
||||
}
|
||||
|
||||
impl Segmentation {
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
layers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,105 @@
|
||||
//! # vtracer
|
||||
//!
|
||||
//! Convert raster images into vector graphics (SVG). VTracer is a
|
||||
//! *framework*: the conversion runs as a pipeline of small, swappable stages,
|
||||
//! so you can reach for a one-line convenience call or rebuild the pipeline
|
||||
//! stage by stage.
|
||||
//!
|
||||
//! ## The pipeline
|
||||
//!
|
||||
//! ```text
|
||||
//! image ──▶ Frontend ──▶ ColorFitter ──▶ Compositing ──▶ Optimizer ──▶ SvgWriter ──▶ SVG
|
||||
//! ```
|
||||
//!
|
||||
//! Two intermediate representations carry the work between stages:
|
||||
//!
|
||||
//! * a [`Segmentation`] — the frontend's output: flat-color regions (paint
|
||||
//! layers) over the canvas, which the color fitters may recolor or quantize;
|
||||
//! * a [`VectorDoc`] — the output document: resolved shapes with fitted vector
|
||||
//! paths, which the optimizer passes shrink and the writer serializes.
|
||||
//!
|
||||
//! Compositing is where the geometry is built — it runs the curve
|
||||
//! [`CurveFitter`](fitter::CurveFitter) on each region outline, then any
|
||||
//! geometry [`CurvePass`](simplify::CurvePass)es (curve simplification and the
|
||||
//! like) over the fitted curves. Every stage is a trait in its own module, and
|
||||
//! most can run more than once (a chain of color fitters, several optimizer
|
||||
//! passes):
|
||||
//!
|
||||
//! | Stage | Trait | Module |
|
||||
//! |---|---|---|
|
||||
//! | Region forming | [`Frontend`](frontend::Frontend) | [`frontend`] |
|
||||
//! | Recolor / quantize | [`ColorFitter`](colorfit::ColorFitter) | [`colorfit`] |
|
||||
//! | Curve fitting | [`CurveFitter`](fitter::CurveFitter) | [`fitter`] |
|
||||
//! | Geometry passes | [`CurvePass`](simplify::CurvePass) | [`simplify`] |
|
||||
//! | Compositing | *(stacked or mosaic)* | [`compose`], [`mosaic`] |
|
||||
//! | Optimization | [`OptimizerPass`](optimize::OptimizerPass) | [`optimize`] |
|
||||
//! | Serialization | *(SVG writer)* | [`svg`] |
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! [`Config`] is the high-level entry point: choose options (or start from a
|
||||
//! [`Preset`]), [`build`](Config::build) a [`Pipeline`], and run it. The crate
|
||||
//! does no image decoding — hand it a decoded [`ColorImage`] and get back an
|
||||
//! SVG string.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use vtracer::{Config, ColorImage, Preset};
|
||||
//! # fn load() -> ColorImage { todo!() }
|
||||
//! let img: ColorImage = load();
|
||||
//!
|
||||
//! // one-shot: image → SVG string, all defaults
|
||||
//! let svg = Config::default().build()?.to_svg(&img)?;
|
||||
//!
|
||||
//! // start from a preset and tweak
|
||||
//! let mut cfg = Config::from_preset(Preset::Poster);
|
||||
//! cfg.simplify = Some(1.5); // paper.js-style curve simplification
|
||||
//! let svg = cfg.build()?.to_svg(&img)?;
|
||||
//! # Ok::<(), vtracer::Error>(())
|
||||
//! ```
|
||||
//!
|
||||
//! ## Interactive tuning
|
||||
//!
|
||||
//! Segmentation is the expensive stage. A [`Session`] caches it and re-renders
|
||||
//! only the cheap downstream stages when a non-clustering parameter changes,
|
||||
//! re-segmenting automatically when it must — ideal behind a live UI with
|
||||
//! sliders. See the [`session`] module.
|
||||
//!
|
||||
//! ## Extending the pipeline
|
||||
//!
|
||||
//! Build a [`Pipeline`] by hand to mix in your own stages: implement
|
||||
//! [`Frontend`](frontend::Frontend) to feed an external label map or ML
|
||||
//! segmentation, [`ColorFitter`](colorfit::ColorFitter) for a custom palette
|
||||
//! policy, or [`CurvePass`](simplify::CurvePass) for a geometry transform.
|
||||
//!
|
||||
//! ## No I/O, wasm-safe
|
||||
//!
|
||||
//! Because it performs no file or image I/O, the crate compiles cleanly to
|
||||
//! `wasm32-unknown-unknown`. Decoding and file handling live in the wrappers:
|
||||
//! the `vtracer-cli` command-line tool, the `vtracer` Python package, and the
|
||||
//! `@visioncortex/vtracer` Node package.
|
||||
|
||||
pub mod colorfit;
|
||||
pub mod compose;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod fitter;
|
||||
pub mod frontend;
|
||||
pub mod ir;
|
||||
pub mod mosaic;
|
||||
pub mod optimize;
|
||||
pub mod pipeline;
|
||||
pub mod progress;
|
||||
pub mod session;
|
||||
pub mod simplify;
|
||||
pub mod svg;
|
||||
|
||||
pub use config::{Clustering, Config, FitMode, Hierarchical, Preset, SegmentKey};
|
||||
pub use error::Error;
|
||||
pub use frontend::Threshold;
|
||||
pub use ir::{Segmentation, VectorDoc};
|
||||
pub use pipeline::Pipeline;
|
||||
pub use progress::{CancelToken, Phase, Progress};
|
||||
pub use session::Session;
|
||||
|
||||
// Re-export the visioncortex value types callers need at the boundary.
|
||||
pub use visioncortex::{Color, ColorImage, PointF64, PointI32};
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Mosaic mode: a seam-free, gapless tessellation.
|
||||
//!
|
||||
//! Instead of tracing every region independently (which lets neighboring
|
||||
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
|
||||
//!
|
||||
//! ```text
|
||||
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
|
||||
//! ```
|
||||
//!
|
||||
//! Every boundary curve exists exactly once; the two adjacent regions
|
||||
//! reference the same fitted geometry, one traversed reversed. Reversal is
|
||||
//! exact, so the serialized coordinates match on both sides — no seams.
|
||||
//!
|
||||
//! Stages 1–2 (graph + faces) are pure integer arithmetic on the lattice of
|
||||
//! pixel corners. Only fitting (stage 3) is floating point.
|
||||
|
||||
mod compose;
|
||||
mod face;
|
||||
mod fit;
|
||||
mod graph;
|
||||
|
||||
pub use compose::compose_mosaic;
|
||||
pub use fit::{
|
||||
FittedGeom, FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
|
||||
SplineSegmentFitter,
|
||||
};
|
||||
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
|
||||
|
||||
use crate::ir::{Paint, Segmentation};
|
||||
|
||||
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
|
||||
pub type RegionId = u32;
|
||||
|
||||
/// Sentinel label for pixels outside any region.
|
||||
pub const OUTSIDE: RegionId = u32::MAX;
|
||||
|
||||
/// A flat partition of the canvas: one region id per pixel, plus the paint for
|
||||
/// each region. This is the sole input to the boundary-graph extractor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabelMap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
|
||||
pub labels: Vec<RegionId>,
|
||||
/// Paint per region, indexed by label.
|
||||
pub paints: Vec<Paint>,
|
||||
}
|
||||
|
||||
impl LabelMap {
|
||||
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
|
||||
/// pixel takes the paint of the topmost layer covering it. Layers are
|
||||
/// bottom-to-top, so painting them in order lets higher layers win.
|
||||
pub fn from_segmentation(seg: &Segmentation) -> Self {
|
||||
let w = seg.width as usize;
|
||||
let h = seg.height as usize;
|
||||
let mut labels = vec![OUTSIDE; w * h];
|
||||
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
|
||||
|
||||
for (i, layer) in seg.layers.iter().enumerate() {
|
||||
let mask = &layer.mask;
|
||||
for ly in 0..mask.image.height {
|
||||
for lx in 0..mask.image.width {
|
||||
if mask.image.get_pixel(lx, ly) {
|
||||
let gx = mask.offset.x + lx as i32;
|
||||
let gy = mask.offset.y + ly as i32;
|
||||
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
|
||||
labels[gy as usize * w + gx as usize] = i as RegionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LabelMap {
|
||||
width: seg.width,
|
||||
height: seg.height,
|
||||
labels,
|
||||
paints,
|
||||
}
|
||||
}
|
||||
|
||||
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
|
||||
/// Treating outside as a real label removes all image-border special cases.
|
||||
#[inline]
|
||||
pub fn label(&self, x: i32, y: i32) -> RegionId {
|
||||
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
|
||||
return OUTSIDE;
|
||||
}
|
||||
self.labels[y as usize * self.width as usize + x as usize]
|
||||
}
|
||||
|
||||
/// Merge neighbouring regions whose colors are within `max_diff` of each
|
||||
/// other (the metric is the clustering one: sum of per-channel absolute
|
||||
/// differences, and clustering keeps neighbours together when
|
||||
/// `diff <= deepen_diff`).
|
||||
///
|
||||
/// The stacked hierarchy deliberately splits a gradient into layers one
|
||||
/// `deepen_diff` apart — that's what makes stacking smooth. Flattened into
|
||||
/// a mosaic, that layering degenerates into abutting faces with barely
|
||||
/// distinguishable fills. This pass undoes it: agglomerative union-find
|
||||
/// over the adjacency graph, most-similar pairs first, with each merged
|
||||
/// region's color re-derived as the area-weighted mean so chains only
|
||||
/// combine while they genuinely stay within `max_diff`.
|
||||
///
|
||||
/// `max_diff == 0` still merges *identical*-color neighbours — a boundary
|
||||
/// between two same-colored faces is never useful. Pass a negative value
|
||||
/// to disable merging entirely.
|
||||
pub fn merge_similar(&mut self, max_diff: i32) {
|
||||
let n = self.paints.len();
|
||||
if max_diff < 0 || n < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Area and summed color per region, for weighted mean colors.
|
||||
let mut area = vec![0u64; n];
|
||||
for &l in &self.labels {
|
||||
if l != OUTSIDE {
|
||||
area[l as usize] += 1;
|
||||
}
|
||||
}
|
||||
let mut sum: Vec<[u64; 3]> = (0..n)
|
||||
.map(|i| {
|
||||
let c = self.paints[i].color();
|
||||
[
|
||||
c.r as u64 * area[i],
|
||||
c.g as u64 * area[i],
|
||||
c.b as u64 * area[i],
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Adjacency pairs (right/down scan covers 4-connectivity once).
|
||||
let (w, h) = (self.width as i32, self.height as i32);
|
||||
let mut pairs: Vec<(RegionId, RegionId)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let a = self.label(x, y);
|
||||
if a == OUTSIDE {
|
||||
continue;
|
||||
}
|
||||
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
|
||||
let b = self.label(nx, ny);
|
||||
if b == OUTSIDE || b == a {
|
||||
continue;
|
||||
}
|
||||
let key = (a.min(b), a.max(b));
|
||||
if seen.insert(key) {
|
||||
pairs.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> i32 {
|
||||
let mut d = 0i64;
|
||||
for k in 0..3 {
|
||||
d += ((sa[k] / aa.max(1)) as i64 - (sb[k] / ab.max(1)) as i64).abs();
|
||||
}
|
||||
d as i32
|
||||
};
|
||||
|
||||
// Most-similar pairs first, so gradient chains coalesce around their
|
||||
// closest links; ties break on ids for determinism.
|
||||
pairs.sort_by_key(|&(a, b)| {
|
||||
(
|
||||
diff(&sum[a as usize], area[a as usize], &sum[b as usize], area[b as usize]),
|
||||
a,
|
||||
b,
|
||||
)
|
||||
});
|
||||
|
||||
let mut parent: Vec<RegionId> = (0..n as RegionId).collect();
|
||||
fn find(parent: &mut [RegionId], mut i: RegionId) -> RegionId {
|
||||
while parent[i as usize] != i {
|
||||
parent[i as usize] = parent[parent[i as usize] as usize];
|
||||
i = parent[i as usize];
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
// Colors move as regions absorb one another, so re-sweep the candidate
|
||||
// pairs until nothing merges. Each union is O(α); the sweep count is
|
||||
// tiny in practice (colors only ever move toward each other's mean).
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for &(a, b) in &pairs {
|
||||
let ra = find(&mut parent, a);
|
||||
let rb = find(&mut parent, b);
|
||||
if ra == rb {
|
||||
continue;
|
||||
}
|
||||
let (ia, ib) = (ra as usize, rb as usize);
|
||||
if diff(&sum[ia], area[ia], &sum[ib], area[ib]) <= max_diff {
|
||||
parent[ib] = ra;
|
||||
for k in 0..3 {
|
||||
sum[ia][k] += sum[ib][k];
|
||||
}
|
||||
area[ia] += area[ib];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact surviving roots into dense ids and rewrite labels + paints.
|
||||
let mut remap: Vec<RegionId> = vec![OUTSIDE; n];
|
||||
let mut paints: Vec<Paint> = Vec::new();
|
||||
for l in &mut self.labels {
|
||||
if *l == OUTSIDE {
|
||||
continue;
|
||||
}
|
||||
let root = find(&mut parent, *l);
|
||||
if remap[root as usize] == OUTSIDE {
|
||||
remap[root as usize] = paints.len() as RegionId;
|
||||
let (s, a) = (&sum[root as usize], area[root as usize].max(1));
|
||||
paints.push(Paint::Solid(visioncortex::Color::new(
|
||||
(s[0] / a) as u8,
|
||||
(s[1] / a) as u8,
|
||||
(s[2] / a) as u8,
|
||||
)));
|
||||
}
|
||||
*l = remap[root as usize];
|
||||
}
|
||||
self.paints = paints;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::face::{assemble, Face};
|
||||
use super::graph::BoundaryGraph;
|
||||
use super::*;
|
||||
use crate::ir::Paint;
|
||||
use visioncortex::{Color, PointF64};
|
||||
|
||||
/// Build a label map from a row-major grid (for tests).
|
||||
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
|
||||
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
|
||||
let n = max.map(|m| m as usize + 1).unwrap_or(0);
|
||||
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
|
||||
LabelMap {
|
||||
width,
|
||||
height,
|
||||
labels,
|
||||
paints,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct a face's contour polygons in exact lattice coordinates.
|
||||
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
|
||||
face.contours
|
||||
.iter()
|
||||
.map(|contour| {
|
||||
let mut ring: Vec<PointF64> = Vec::new();
|
||||
for (i, sref) in contour.0.iter().enumerate() {
|
||||
let pts = &graph.segments[sref.seg as usize].points;
|
||||
let ordered: Vec<PointF64> = if sref.forward {
|
||||
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
|
||||
} else {
|
||||
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
|
||||
};
|
||||
if i == 0 {
|
||||
ring.extend(ordered);
|
||||
} else {
|
||||
ring.extend(ordered[1..].iter().copied());
|
||||
}
|
||||
}
|
||||
ring
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
|
||||
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
|
||||
}
|
||||
|
||||
/// Winding number of point `p` w.r.t. a closed ring (last == first).
|
||||
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
|
||||
let mut wn = 0;
|
||||
for w in ring.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if a.y <= p.y {
|
||||
if b.y > p.y && is_left(a, b, p) > 0.0 {
|
||||
wn += 1;
|
||||
}
|
||||
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
|
||||
wn -= 1;
|
||||
}
|
||||
}
|
||||
wn
|
||||
}
|
||||
|
||||
/// The strongest guarantee: rasterize the composed faces at pixel centers
|
||||
/// and assert the result is byte-identical to the input label map.
|
||||
fn assert_pixel_roundtrip(map: &LabelMap) {
|
||||
let graph = BoundaryGraph::extract(map);
|
||||
let faces = assemble(&graph, map);
|
||||
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
|
||||
.iter()
|
||||
.map(|f| (f.region, face_polygons(&graph, f)))
|
||||
.collect();
|
||||
|
||||
for y in 0..map.height as i32 {
|
||||
for x in 0..map.width as i32 {
|
||||
let center = PointF64 {
|
||||
x: x as f64 + 0.5,
|
||||
y: y as f64 + 0.5,
|
||||
};
|
||||
let mut hits: Vec<RegionId> = Vec::new();
|
||||
for (region, rings) in &polys {
|
||||
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
|
||||
if wn != 0 {
|
||||
hits.push(*region);
|
||||
}
|
||||
}
|
||||
let expected = map.label(x, y);
|
||||
if expected == OUTSIDE {
|
||||
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
|
||||
} else {
|
||||
assert_eq!(
|
||||
hits,
|
||||
vec![expected],
|
||||
"({x},{y}) expected region {expected}, got {hits:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_region_is_one_ring() {
|
||||
let map = grid(3, 2, vec![0; 6]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
|
||||
assert_eq!(graph.segments.len(), 1, "one border ring");
|
||||
assert!(graph.segments[0].is_ring());
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_split() {
|
||||
// 4x2, left half 0, right half 1.
|
||||
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
// Two border junctions where the split meets the top and bottom edges.
|
||||
assert_eq!(graph.nodes.len(), 2);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_junction() {
|
||||
// top row one region, bottom row split — a degree-3 interior node.
|
||||
let map = grid(2, 2, vec![0, 0, 1, 2]);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkerboard_pinch() {
|
||||
// A B / B A — the center corner is a degree-4 pinch; each region is two
|
||||
// lobes touching there. (The four boundary/border corners are degree-3
|
||||
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
|
||||
// the real check that the pinch produces exact, simple contours.
|
||||
let map = grid(2, 2, vec![0, 1, 1, 0]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let has_degree4 = graph.nodes.iter().any(|n| {
|
||||
let c = n.corner;
|
||||
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
|
||||
});
|
||||
assert!(has_degree4, "expected a degree-4 pinch node at the center");
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_patches_of_one_region_get_separate_faces() {
|
||||
// Region 0 appears as two islands, separated by a column of region 1.
|
||||
// Each island must get its own face, so they cannot share a path.
|
||||
#[rustfmt::skip]
|
||||
let map = grid(3, 2, vec![
|
||||
0, 1, 0,
|
||||
0, 1, 0,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let faces = assemble(&graph, &map);
|
||||
|
||||
assert_eq!(
|
||||
faces.iter().filter(|f| f.region == 0).count(),
|
||||
2,
|
||||
"each island of region 0 gets its own face"
|
||||
);
|
||||
assert_eq!(faces.len(), 3, "two islands of region 0, plus region 1");
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonal_lobes_share_one_face() {
|
||||
// A B / B A — region 0's lobes meet only at the center corner, which the
|
||||
// successor rule pinches into a single contour. They must stay in one
|
||||
// face: splitting them could separate a hole contour from the ring that
|
||||
// encloses it, and a lone hole ring fills solid under `nonzero`.
|
||||
#[rustfmt::skip]
|
||||
let map = grid(2, 2, vec![
|
||||
0, 1,
|
||||
1, 0,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let faces = assemble(&graph, &map);
|
||||
|
||||
assert_eq!(
|
||||
faces.iter().filter(|f| f.region == 0).count(),
|
||||
1,
|
||||
"diagonally touching lobes stay in one face"
|
||||
);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_rings() {
|
||||
// Concentric squares: 0 outer, 1 middle, 2 center.
|
||||
let l = |x: i32, y: i32| -> RegionId {
|
||||
let d = x.min(y).min(5 - x).min(5 - y);
|
||||
match d {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
};
|
||||
let mut labels = Vec::new();
|
||||
for y in 0..6 {
|
||||
for x in 0..6 {
|
||||
labels.push(l(x, y));
|
||||
}
|
||||
}
|
||||
assert_pixel_roundtrip(&grid(6, 6, labels));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_region_border_touching() {
|
||||
// A region that does not fill the canvas; the rest is OUTSIDE.
|
||||
let mut labels = vec![OUTSIDE; 16];
|
||||
for y in 1..3 {
|
||||
for x in 1..3 {
|
||||
labels[y * 4 + x] = 0;
|
||||
}
|
||||
}
|
||||
assert_pixel_roundtrip(&grid(4, 4, labels));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_segments_pin_endpoints_to_lattice() {
|
||||
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
|
||||
// A shape with junctions so there are open (non-ring) segments.
|
||||
let map = grid(4, 4, vec![
|
||||
0, 0, 1, 1,
|
||||
0, 0, 1, 1,
|
||||
2, 2, 1, 1,
|
||||
2, 2, 2, 2,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let fitter = SplineSegmentFitter::default();
|
||||
let mut checked = 0;
|
||||
for seg in &graph.segments {
|
||||
if seg.is_ring() {
|
||||
continue;
|
||||
}
|
||||
let fitted = fitter.fit_open(seg);
|
||||
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
|
||||
let end = {
|
||||
let p = seg.points[seg.points.len() - 1];
|
||||
PointF64 { x: p.x as f64, y: p.y as f64 }
|
||||
};
|
||||
match fitted.geom {
|
||||
FittedGeom::Beziers(b) => {
|
||||
assert_eq!(b.first().unwrap()[0], start, "start pinned to node");
|
||||
assert_eq!(b.last().unwrap()[3], end, "end pinned to node");
|
||||
}
|
||||
FittedGeom::Polyline(p) => {
|
||||
assert_eq!(*p.first().unwrap(), start);
|
||||
assert_eq!(*p.last().unwrap(), end);
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "expected some open segments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_passes_keep_segment_endpoints_pinned() {
|
||||
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
|
||||
use crate::simplify::{CurvePass, SimplifyCurves};
|
||||
// Simplification runs per shared segment; junction nodes must not
|
||||
// move or the faces meeting there would disagree.
|
||||
let map = grid(4, 4, vec![
|
||||
0, 0, 1, 1,
|
||||
0, 0, 1, 1,
|
||||
2, 2, 1, 1,
|
||||
2, 2, 2, 2,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let fitter = SplineSegmentFitter::default();
|
||||
let pass = SimplifyCurves {
|
||||
tolerance: 2.0,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
};
|
||||
let mut checked = 0;
|
||||
for seg in &graph.segments {
|
||||
if seg.is_ring() {
|
||||
continue;
|
||||
}
|
||||
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
|
||||
let end = {
|
||||
let p = seg.points[seg.points.len() - 1];
|
||||
PointF64 { x: p.x as f64, y: p.y as f64 }
|
||||
};
|
||||
match pass.open(fitter.fit_open(seg).geom) {
|
||||
FittedGeom::Beziers(b) => {
|
||||
assert_eq!(b.first().unwrap()[0], start, "start pinned through pass");
|
||||
assert_eq!(b.last().unwrap()[3], end, "end pinned through pass");
|
||||
}
|
||||
FittedGeom::Polyline(p) => {
|
||||
assert_eq!(*p.first().unwrap(), start);
|
||||
assert_eq!(*p.last().unwrap(), end);
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "expected some open segments");
|
||||
}
|
||||
|
||||
/// Build a label map with explicit per-region gray levels.
|
||||
fn gray_grid(width: u32, height: u32, labels: Vec<RegionId>, grays: &[u8]) -> LabelMap {
|
||||
LabelMap {
|
||||
width,
|
||||
height,
|
||||
labels,
|
||||
paints: grays
|
||||
.iter()
|
||||
.map(|&g| Paint::Solid(Color::new(g, g, g)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_rejoins_close_neighbours() {
|
||||
// Three vertical strips: 100 | 106 | 220. Diff(0,1) = 18 ≤ 20 → merge;
|
||||
// the merged mean (103) vs 220 stays far apart.
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 2, vec![
|
||||
0, 1, 2,
|
||||
0, 1, 2,
|
||||
], &[100, 106, 220]);
|
||||
map.merge_similar(20);
|
||||
|
||||
assert_eq!(map.paints.len(), 2, "strips 0 and 1 merge; 2 survives");
|
||||
assert_eq!(map.label(0, 0), map.label(1, 0));
|
||||
assert_ne!(map.label(0, 0), map.label(2, 0));
|
||||
// Area-weighted mean of two equal strips of 100 and 106.
|
||||
assert_eq!(map.paints[map.label(0, 0) as usize].color().r, 103);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_uses_running_means_not_original_colors() {
|
||||
// Gradient chain 100 | 103 | 106 with threshold 9 (grays g apart diff
|
||||
// by 3g across the three channels). The closest pair merges first
|
||||
// (ties broken by id → strips 0,1 → mean 101); the merged region vs
|
||||
// 106 is then 15 apart, over threshold — the chain must NOT collapse
|
||||
// transitively into one region on the strength of the original colors.
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 103, 106]);
|
||||
map.merge_similar(9);
|
||||
|
||||
assert_eq!(map.paints.len(), 2, "running mean stops the chain");
|
||||
assert_eq!(map.label(0, 0), map.label(1, 0));
|
||||
assert_ne!(map.label(1, 0), map.label(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_ignores_outside_and_non_neighbours() {
|
||||
// Two same-colored regions separated by OUTSIDE: not adjacent, so they
|
||||
// must stay distinct faces (merging them would create a disjoint
|
||||
// region, which face assembly handles, but the ids must stay honest to
|
||||
// the partition).
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 1, vec![0, OUTSIDE, 1], &[100, 100]);
|
||||
map.merge_similar(20);
|
||||
|
||||
assert_eq!(map.paints.len(), 2, "non-adjacent regions never merge");
|
||||
assert_eq!(map.label(1, 0), OUTSIDE, "outside pixels are untouched");
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_zero_threshold_merges_only_identical_colors() {
|
||||
// Regions 0 and 1 share a color; region 2 differs by one level. At
|
||||
// threshold 0 the identical pair merges, the near-identical one stays.
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 100, 101]);
|
||||
map.merge_similar(0);
|
||||
assert_eq!(map.paints.len(), 2, "identical neighbours merge at 0");
|
||||
assert_eq!(map.label(0, 0), map.label(1, 0));
|
||||
assert_ne!(map.label(1, 0), map.label(2, 0));
|
||||
|
||||
// A negative threshold disables merging entirely.
|
||||
let labels = vec![0, 1, 0, 1];
|
||||
let mut map = gray_grid(2, 2, labels.clone(), &[100, 100]);
|
||||
map.merge_similar(-1);
|
||||
assert_eq!(map.labels, labels);
|
||||
assert_eq!(map.paints.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_mosaic_merges_gradient_faces() {
|
||||
use super::compose_mosaic;
|
||||
use super::fit::PixelSegmentFitter;
|
||||
use crate::ir::{Layer, RegionMask, Segmentation};
|
||||
use visioncortex::BinaryImage;
|
||||
|
||||
// A 6x2 canvas of three 2px strips, one gradient step apart (diff 6),
|
||||
// as bottom-to-top layers — exactly what a stacked gradient flattens
|
||||
// into. With merging they are one face; without, three.
|
||||
let mut seg = Segmentation::new(6, 2);
|
||||
for (i, g) in [(0, 100u8), (1, 102), (2, 104)] {
|
||||
let mut image = BinaryImage::new_w_h(2, 2);
|
||||
for y in 0..2 {
|
||||
for x in 0..2 {
|
||||
image.set_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(Color::new(g, g, g)),
|
||||
mask: RegionMask::new(
|
||||
image,
|
||||
visioncortex::PointI32 { x: i * 2, y: 0 },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let unmerged = compose_mosaic(&seg, &PixelSegmentFitter, 0, &[]);
|
||||
let merged = compose_mosaic(&seg, &PixelSegmentFitter, 16, &[]);
|
||||
assert_eq!(unmerged.shapes.len(), 3);
|
||||
assert_eq!(merged.shapes.len(), 1, "gradient strips coalesce into one face");
|
||||
assert_eq!(merged.shapes[0].paint.color().r, 102, "area-weighted mean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_maps_roundtrip() {
|
||||
// Deterministic LCG; connectivity not required.
|
||||
let mut state: u64 = 0x1234_5678_9abc_def0;
|
||||
let mut next = || {
|
||||
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(state >> 33) as u32
|
||||
};
|
||||
for _ in 0..40 {
|
||||
let w = 2 + next() % 10;
|
||||
let h = 2 + next() % 10;
|
||||
let nlabels = 1 + next() % 5;
|
||||
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
|
||||
assert_pixel_roundtrip(&grid(w, h, labels));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Stage 4: compose per-region SVG paths from shared fitted segments.
|
||||
//!
|
||||
//! Each region becomes one shape whose `d` concatenates its contours as
|
||||
//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each
|
||||
//! oriented segment is emitted skipping its first point (identical to the
|
||||
//! previous segment's last point), so shared boundaries are byte-identical on
|
||||
//! both sides.
|
||||
|
||||
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
|
||||
use crate::simplify::CurvePass;
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use super::face::{assemble, Contour, Face};
|
||||
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
|
||||
use super::graph::BoundaryGraph;
|
||||
use super::{LabelMap, Segmentation};
|
||||
|
||||
/// Run the full mosaic pipeline: flatten → merge similar neighbours →
|
||||
/// boundary graph → faces → fit → curve passes → compose.
|
||||
///
|
||||
/// `merge_diff` is the color-difference threshold for
|
||||
/// [`LabelMap::merge_similar`]; pass the clustering `deepen_diff`
|
||||
/// (gradient step) so the flattened mosaic rejoins what only the stacked
|
||||
/// gradient layering had split. `0` still merges identical-color
|
||||
/// neighbours; negative disables merging entirely.
|
||||
///
|
||||
/// `passes` run on each fitted segment before composition — once per shared
|
||||
/// boundary, so both adjacent faces reference the transformed geometry and
|
||||
/// the tessellation stays seam-free.
|
||||
pub fn compose_mosaic(
|
||||
seg: &Segmentation,
|
||||
fitter: &dyn SegmentFitter,
|
||||
merge_diff: i32,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
) -> VectorDoc {
|
||||
let mut map = LabelMap::from_segmentation(seg);
|
||||
map.merge_similar(merge_diff);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let faces = assemble(&graph, &map);
|
||||
|
||||
// Fit every segment exactly once; both adjacent faces share the result.
|
||||
let fitted: Vec<FittedSegment> = graph
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let ring = s.is_ring();
|
||||
let mut geom = if ring {
|
||||
fitter.fit_ring(s).geom
|
||||
} else {
|
||||
fitter.fit_open(s).geom
|
||||
};
|
||||
for pass in passes {
|
||||
geom = if ring { pass.ring(geom) } else { pass.open(geom) };
|
||||
}
|
||||
FittedSegment { geom }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut doc = VectorDoc::new(seg.width, seg.height);
|
||||
for face in &faces {
|
||||
let path = build_path(face, &fitted, &graph);
|
||||
if !path.is_empty() {
|
||||
doc.shapes.push(Shape {
|
||||
paint: map.paints[face.region as usize],
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn build_path(face: &Face, fitted: &[FittedSegment], _graph: &BoundaryGraph) -> MultiPath {
|
||||
let mut mp = MultiPath::new();
|
||||
for contour in &face.contours {
|
||||
let mut sub = SubPath::new();
|
||||
emit_contour(contour, fitted, &mut sub);
|
||||
if !sub.is_empty() {
|
||||
sub.commands.push(PathCmd::Close);
|
||||
mp.subpaths.push(sub);
|
||||
}
|
||||
}
|
||||
mp
|
||||
}
|
||||
|
||||
fn emit_contour(contour: &Contour, fitted: &[FittedSegment], sub: &mut SubPath) {
|
||||
for (i, sref) in contour.0.iter().enumerate() {
|
||||
let geom = &fitted[sref.seg as usize].geom;
|
||||
emit_segment(geom, sref.forward, i == 0, sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one oriented segment's commands. When `first`, opens with a `MoveTo`;
|
||||
/// otherwise the leading point (shared with the previous segment) is skipped.
|
||||
fn emit_segment(geom: &FittedGeom, forward: bool, first: bool, sub: &mut SubPath) {
|
||||
match geom {
|
||||
FittedGeom::Polyline(pts) => {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let ordered: Vec<PointF64> = if forward {
|
||||
pts.clone()
|
||||
} else {
|
||||
pts.iter().rev().copied().collect()
|
||||
};
|
||||
if first {
|
||||
sub.commands.push(PathCmd::MoveTo(ordered[0]));
|
||||
}
|
||||
for p in &ordered[1..] {
|
||||
sub.commands.push(PathCmd::LineTo(*p));
|
||||
}
|
||||
}
|
||||
FittedGeom::Beziers(curves) => {
|
||||
if curves.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Reversing a cubic is exact: [p0,p1,p2,p3] -> [p3,p2,p1,p0], and
|
||||
// the whole chain reverses in order too.
|
||||
let ordered: Vec<[PointF64; 4]> = if forward {
|
||||
curves.clone()
|
||||
} else {
|
||||
curves
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|c| [c[3], c[2], c[1], c[0]])
|
||||
.collect()
|
||||
};
|
||||
if first {
|
||||
sub.commands.push(PathCmd::MoveTo(ordered[0][0]));
|
||||
}
|
||||
for c in &ordered {
|
||||
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Stage 2: face assembly.
|
||||
//!
|
||||
//! Lift the "region kept on the left" successor rule from unit edges to whole
|
||||
//! segments. Following it around each region yields its contours; because the
|
||||
//! interior is always on the left, outer contours and hole contours come out
|
||||
//! with opposite winding automatically — no containment/nesting computation is
|
||||
//! needed, and the region can be filled with a single `nonzero` path.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::graph::{
|
||||
dir_from_delta, edge_present, left_pixel_at, left_pixel_coord, reverse, straight, turn_left,
|
||||
turn_right, BoundaryGraph, SegRef,
|
||||
};
|
||||
use super::{LabelMap, RegionId, OUTSIDE};
|
||||
|
||||
/// Island id for pixels that belong to no region.
|
||||
const NO_ISLAND: u32 = u32::MAX;
|
||||
|
||||
/// A closed cycle of directed segments bounding (part of) a region.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Contour(pub Vec<SegRef>);
|
||||
|
||||
/// One connected patch of a region and all of its contours (outer + holes).
|
||||
///
|
||||
/// A region can appear as several disjoint patches; each gets its own face, so
|
||||
/// isolated islands never share a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Face {
|
||||
pub region: RegionId,
|
||||
pub contours: Vec<Contour>,
|
||||
}
|
||||
|
||||
/// Connected-component ("island") id per pixel, grouping equal labels with
|
||||
/// 8-connectivity. [`OUTSIDE`] pixels get [`NO_ISLAND`].
|
||||
///
|
||||
/// 8-connectivity is what matches [`successor`]: it pinches a checkerboard
|
||||
/// corner into one contour, so two lobes meeting only at a diagonal are walked
|
||||
/// as a single contour and must land in a single face. Splitting them
|
||||
/// (4-connectivity) could put a hole contour in a different face than the ring
|
||||
/// enclosing it, and a lone hole ring fills solid under `nonzero`.
|
||||
fn islands(map: &LabelMap) -> Vec<u32> {
|
||||
let (w, h) = (map.width as usize, map.height as usize);
|
||||
let mut ids = vec![NO_ISLAND; w * h];
|
||||
let mut next = 0u32;
|
||||
let mut stack: Vec<(usize, usize)> = Vec::new();
|
||||
|
||||
for start in 0..w * h {
|
||||
if ids[start] != NO_ISLAND || map.labels[start] == OUTSIDE {
|
||||
continue;
|
||||
}
|
||||
let label = map.labels[start];
|
||||
let id = next;
|
||||
next += 1;
|
||||
ids[start] = id;
|
||||
stack.push((start % w, start / w));
|
||||
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let n = ny as usize * w + nx as usize;
|
||||
if ids[n] == NO_ISLAND && map.labels[n] == label {
|
||||
ids[n] = id;
|
||||
stack.push((nx as usize, ny as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Which island a contour bounds, taken from the region-side pixel flanking its
|
||||
/// first directed edge. Every contour is walked with its region on the left, so
|
||||
/// that pixel is always interior to the patch the contour belongs to — an outer
|
||||
/// ring and the holes inside it therefore agree.
|
||||
fn island_of(graph: &BoundaryGraph, map: &LabelMap, ids: &[u32], r: SegRef) -> u32 {
|
||||
let seg = &graph.segments[r.seg as usize];
|
||||
let (corner, dir) = if seg.is_ring() {
|
||||
let n = seg.points.len();
|
||||
// A ring is used forward by the region on its left, reversed by the one
|
||||
// on its right; take the first step of the chosen direction.
|
||||
let (from, to) = if r.forward {
|
||||
(seg.points[0], seg.points[1])
|
||||
} else {
|
||||
(seg.points[n - 1], seg.points[n - 2])
|
||||
};
|
||||
(from, dir_from_delta(to.x - from.x, to.y - from.y))
|
||||
} else if r.forward {
|
||||
let node = seg.start.expect("non-ring segment has a start node");
|
||||
(graph.nodes[node as usize].corner, seg.first_dir)
|
||||
} else {
|
||||
let node = seg.end.expect("non-ring segment has an end node");
|
||||
(graph.nodes[node as usize].corner, reverse(seg.last_dir))
|
||||
};
|
||||
|
||||
let (px, py) = left_pixel_coord(corner.x, corner.y, dir);
|
||||
if px < 0 || py < 0 || px as u32 >= map.width || py as u32 >= map.height {
|
||||
return NO_ISLAND;
|
||||
}
|
||||
ids[py as usize * map.width as usize + px as usize]
|
||||
}
|
||||
|
||||
/// Left region of a directed segment view.
|
||||
fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId {
|
||||
let seg = &graph.segments[r.seg as usize];
|
||||
if r.forward {
|
||||
seg.left
|
||||
} else {
|
||||
seg.right
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the next unit direction leaving `corner`, keeping region `r` on the
|
||||
/// left: sharpest right turn first (this pinches checkerboard nodes and keeps
|
||||
/// contours simple).
|
||||
fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
|
||||
for &d in &[turn_right(d_in), straight(d_in), turn_left(d_in)] {
|
||||
if edge_present(map, x, y, d) && left_pixel_at(map, x, y, d) == r {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
unreachable!("no successor edge keeps the region on the left");
|
||||
}
|
||||
|
||||
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
|
||||
let ids = islands(map);
|
||||
// Keyed by (region, island) rather than by region alone, so disjoint patches
|
||||
// of one region become separate faces — and separate paths downstream. The
|
||||
// BTreeMap keeps face order deterministic: region ascending, then island in
|
||||
// raster-scan order.
|
||||
let mut by_island: BTreeMap<(RegionId, u32), Vec<Contour>> = BTreeMap::new();
|
||||
// usage[seg][0] = forward view used, [1] = backward view used.
|
||||
let mut used = vec![[false; 2]; graph.segments.len()];
|
||||
|
||||
for seg_id in 0..graph.segments.len() {
|
||||
if graph.segments[seg_id].is_ring() {
|
||||
continue;
|
||||
}
|
||||
for &forward in &[true, false] {
|
||||
let start = SegRef {
|
||||
seg: seg_id as u32,
|
||||
forward,
|
||||
};
|
||||
let region = left_region(graph, start);
|
||||
if region == OUTSIDE || used[seg_id][forward as usize] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut contour = Vec::new();
|
||||
let mut cur = start;
|
||||
loop {
|
||||
used[cur.seg as usize][cur.forward as usize] = true;
|
||||
contour.push(cur);
|
||||
|
||||
let seg = &graph.segments[cur.seg as usize];
|
||||
let (node_id, d_in) = if cur.forward {
|
||||
(seg.end.unwrap(), seg.last_dir)
|
||||
} else {
|
||||
(seg.start.unwrap(), reverse(seg.first_dir))
|
||||
};
|
||||
let corner = graph.nodes[node_id as usize].corner;
|
||||
let d_next = successor(map, corner.x, corner.y, d_in, region);
|
||||
cur = graph.nodes[node_id as usize].out[d_next as usize]
|
||||
.expect("successor direction must have an outgoing segment");
|
||||
|
||||
if cur == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (region as usize) < map.paints.len() {
|
||||
let island = island_of(graph, map, &ids, start);
|
||||
by_island
|
||||
.entry((region, island))
|
||||
.or_default()
|
||||
.push(Contour(contour));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rings: the left side uses it forward, the right side reversed.
|
||||
for seg_id in 0..graph.segments.len() {
|
||||
let seg = &graph.segments[seg_id];
|
||||
if !seg.is_ring() {
|
||||
continue;
|
||||
}
|
||||
for (region, forward) in [(seg.left, true), (seg.right, false)] {
|
||||
if region == OUTSIDE || (region as usize) >= map.paints.len() {
|
||||
continue;
|
||||
}
|
||||
let r = SegRef {
|
||||
seg: seg_id as u32,
|
||||
forward,
|
||||
};
|
||||
let island = island_of(graph, map, &ids, r);
|
||||
by_island
|
||||
.entry((region, island))
|
||||
.or_default()
|
||||
.push(Contour(vec![r]));
|
||||
}
|
||||
}
|
||||
|
||||
by_island
|
||||
.into_iter()
|
||||
.map(|((region, _island), contours)| Face { region, contours })
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes.
|
||||
//!
|
||||
//! A segment is fitted a single time and cached; both adjacent faces reference
|
||||
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
|
||||
//! the shared geometry is bitwise identical and no seam can appear.
|
||||
|
||||
use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideSmooth};
|
||||
|
||||
use super::graph::Segment;
|
||||
|
||||
pub use crate::fitter::FittedGeom;
|
||||
|
||||
/// Outset ratio for the 4-point subdivision scheme (matches visioncortex).
|
||||
const OUTSET_RATIO: f64 = 8.0;
|
||||
|
||||
/// A fitted segment, cached and indexed by segment id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FittedSegment {
|
||||
pub geom: FittedGeom,
|
||||
}
|
||||
|
||||
/// Fits a single boundary segment. `fit_open` pins both endpoints (junction
|
||||
/// nodes must not move); `fit_ring` fits a closed loop with no pinned point.
|
||||
pub trait SegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment;
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment;
|
||||
}
|
||||
|
||||
fn to_f64(points: &[PointI32]) -> Vec<PointF64> {
|
||||
points
|
||||
.iter()
|
||||
.map(|p| PointF64 {
|
||||
x: p.x as f64,
|
||||
y: p.y as f64,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Identity fitter: lattice points as f64. Produces an exact tessellation and
|
||||
/// is the reference backend for tests.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PixelSegmentFitter;
|
||||
|
||||
impl SegmentFitter for PixelSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
}
|
||||
}
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-segment fitter. Uses visioncortex's symmetric `limit_penalties`
|
||||
/// simplification, which collapses 1px staircases toward the crack midline
|
||||
/// (centered, no directional outset) so the boundary stays gapless. Endpoints
|
||||
/// are preserved, pinning junction nodes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PolygonSegmentFitter;
|
||||
|
||||
impl PolygonSegmentFitter {
|
||||
fn fit(&self, seg: &Segment) -> FittedSegment {
|
||||
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SegmentFitter for PolygonSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
self.fit(seg)
|
||||
}
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
self.fit(seg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth (cubic-Bézier) open-path fitter — the mosaic analogue of the stacked
|
||||
/// [`crate::fitter::SplineFitter`], but for open segments with pinned
|
||||
/// endpoints.
|
||||
///
|
||||
/// Staircase removal reuses visioncortex's symmetric `limit_penalties`
|
||||
/// simplification (the same de-noising stacked mode applies), which collapses
|
||||
/// staircases toward the crack midline. Unlike `remove_staircase`, it has no
|
||||
/// directional outset, so the boundary stays centered (≤√2/2 px from its
|
||||
/// crack) and cannot cross a non-adjacent segment — the tessellation stays
|
||||
/// gapless. A distance-based DP can't do this: near the √2/2 threshold it
|
||||
/// can't separate staircase noise from real curvature. Smoothing and per-slice
|
||||
/// cubic fitting then reuse the same visioncortex machinery stacked mode uses
|
||||
/// (open-path variants of the smoothing primitives + `fit_points_with_beziers`),
|
||||
/// so the curve character matches stacked.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SplineSegmentFitter {
|
||||
/// Corner angle threshold, radians.
|
||||
pub corner_threshold: f64,
|
||||
/// Subdivide until segments are shorter than this (px).
|
||||
pub length_threshold: f64,
|
||||
pub max_iterations: usize,
|
||||
/// Splice angle threshold, radians.
|
||||
pub splice_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for SplineSegmentFitter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: std::f64::consts::PI / 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pt(p: PointI32) -> PointF64 {
|
||||
PointF64 {
|
||||
x: p.x as f64,
|
||||
y: p.y as f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate cubic tracing the straight line `a`→`b`.
|
||||
fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] {
|
||||
let c1 = PointF64 {
|
||||
x: a.x + (b.x - a.x) / 3.0,
|
||||
y: a.y + (b.y - a.y) / 3.0,
|
||||
};
|
||||
let c2 = PointF64 {
|
||||
x: a.x + 2.0 * (b.x - a.x) / 3.0,
|
||||
y: a.y + 2.0 * (b.y - a.y) / 3.0,
|
||||
};
|
||||
[a, c1, c2, b]
|
||||
}
|
||||
|
||||
/// Error bound for the per-slice cubic fit. Matches the value stacked mode
|
||||
/// uses in `Spline::from_path_f64`, so mosaic curves have the same character.
|
||||
const FIT_ERROR: f64 = 10.0;
|
||||
|
||||
/// Fit one splice slice, exactly as stacked mode does
|
||||
/// (`fit_points_with_beziers`: the full retract-handled cubic chain per slice,
|
||||
/// outer endpoints pinned to the slice ends — a sparse or multi-curve slice is
|
||||
/// kept faithful instead of being collapsed onto one ballooning cubic).
|
||||
fn fit_slice(slice: &[PointF64], out: &mut Vec<[PointF64; 4]>) {
|
||||
match slice.len() {
|
||||
0 | 1 => {}
|
||||
2 => out.push(straight_cubic(slice[0], slice[1])),
|
||||
_ => out.extend(SubdivideSmooth::fit_points_with_beziers(slice, FIT_ERROR)),
|
||||
}
|
||||
}
|
||||
|
||||
fn spline_to_beziers(spline: &Spline) -> Vec<[PointF64; 4]> {
|
||||
spline
|
||||
.get_control_points()
|
||||
.into_iter()
|
||||
.filter(|w| w.len() == 4)
|
||||
.map(|w| [w[0], w[1], w[2], w[3]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl SegmentFitter for SplineSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
if seg.points.len() <= 2 {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Staircase removal via visioncortex's `limit_penalties` — the
|
||||
// symmetric (area-based, no directional outset) simplifier stacked
|
||||
// mode runs after remove_staircase. Used alone here it collapses
|
||||
// staircases toward the crack midline, so the boundary stays
|
||||
// centered and cannot cross a non-adjacent segment (which would
|
||||
// open a gap in the tessellation). Endpoints are preserved.
|
||||
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
|
||||
if simplified.len() <= 2 {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Corner detection (open, endpoints forced as corners).
|
||||
let mut corners = SubdivideSmooth::find_corners(&simplified, self.corner_threshold, false);
|
||||
// 3. Open 4-point subdivision.
|
||||
let mut path = simplified.to_path_f64();
|
||||
for _ in 0..self.max_iterations {
|
||||
let (np, nc, done) = SubdivideSmooth::subdivide_keep_corners(
|
||||
&path,
|
||||
&corners,
|
||||
OUTSET_RATIO,
|
||||
self.length_threshold,
|
||||
false,
|
||||
);
|
||||
path = np;
|
||||
corners = nc;
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 4. Splice points (open, endpoints forced).
|
||||
let splice = SubdivideSmooth::find_splice_points(&path, self.splice_threshold, false);
|
||||
let cuts: Vec<usize> = splice
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &s)| if s { Some(i) } else { None })
|
||||
.collect();
|
||||
|
||||
// 5. Per-slice cubic fit.
|
||||
let mut beziers = Vec::new();
|
||||
for w in cuts.windows(2) {
|
||||
fit_slice(&path.path[w[0]..=w[1]], &mut beziers);
|
||||
}
|
||||
|
||||
if beziers.is_empty() {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(path.path.clone()),
|
||||
};
|
||||
}
|
||||
|
||||
// Pin the segment's endpoints exactly to the lattice nodes so that
|
||||
// segments meeting at a junction share identical coordinates.
|
||||
beziers.first_mut().unwrap()[0] = pt(seg.points[0]);
|
||||
beziers.last_mut().unwrap()[3] = pt(seg.points[seg.points.len() - 1]);
|
||||
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Beziers(beziers),
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
// Rings are closed loops — this is exactly the stacked closed-spline
|
||||
// pipeline (simplify → smooth → fit).
|
||||
if seg.points.len() <= 4 {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
};
|
||||
}
|
||||
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
|
||||
let smoothed = simplified.smooth(
|
||||
self.corner_threshold,
|
||||
OUTSET_RATIO,
|
||||
self.length_threshold,
|
||||
self.max_iterations,
|
||||
);
|
||||
let spline = Spline::from_path_f64(&smoothed, self.splice_threshold);
|
||||
let beziers = spline_to_beziers(&spline);
|
||||
if beziers.is_empty() {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
};
|
||||
}
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Beziers(beziers),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Stage 1: boundary-graph extraction from a [`LabelMap`].
|
||||
//!
|
||||
//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`.
|
||||
//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run
|
||||
//! along the "cracks" between differing labels.
|
||||
|
||||
use visioncortex::PointI32;
|
||||
|
||||
use super::{LabelMap, RegionId, OUTSIDE};
|
||||
|
||||
pub type NodeId = u32;
|
||||
pub type SegId = u32;
|
||||
|
||||
// Unit directions, arranged clockwise in y-down screen space so that
|
||||
// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal.
|
||||
const N: u8 = 0;
|
||||
const E: u8 = 1;
|
||||
const S: u8 = 2;
|
||||
const W: u8 = 3;
|
||||
/// (dx, dy) per direction.
|
||||
const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||
|
||||
#[inline]
|
||||
pub(super) fn turn_right(d: u8) -> u8 {
|
||||
(d + 1) % 4
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn straight(d: u8) -> u8 {
|
||||
d
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn turn_left(d: u8) -> u8 {
|
||||
(d + 3) % 4
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn reverse(d: u8) -> u8 {
|
||||
(d + 2) % 4
|
||||
}
|
||||
|
||||
/// A directed reference to a segment: either traversed forward or reversed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SegRef {
|
||||
pub seg: SegId,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// A junction corner (degree ≥ 3) with the segment leaving it in each unit
|
||||
/// direction (if any).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Node {
|
||||
pub corner: PointI32,
|
||||
pub out: [Option<SegRef>; 4],
|
||||
}
|
||||
|
||||
/// A maximal boundary chain between two nodes, or a nodeless ring.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Segment {
|
||||
/// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`.
|
||||
pub points: Vec<PointI32>,
|
||||
pub start: Option<NodeId>,
|
||||
pub end: Option<NodeId>,
|
||||
/// Region on the left when traversing forward (y-down convention).
|
||||
pub left: RegionId,
|
||||
pub right: RegionId,
|
||||
/// Direction of the first edge (leaving `start`); unused for rings.
|
||||
pub first_dir: u8,
|
||||
/// Direction of the last edge (arriving at `end`); unused for rings.
|
||||
pub last_dir: u8,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn is_ring(&self) -> bool {
|
||||
self.start.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The extracted boundary graph. Faces are assembled separately (see `face`).
|
||||
pub struct BoundaryGraph {
|
||||
pub nodes: Vec<Node>,
|
||||
pub segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
struct Extractor<'a> {
|
||||
map: &'a LabelMap,
|
||||
w: i32,
|
||||
h: i32,
|
||||
/// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1).
|
||||
node_at: Vec<NodeId>,
|
||||
/// Visited flags for undirected unit edges.
|
||||
visited_v: Vec<bool>, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x
|
||||
visited_h: Vec<bool>, // horizontal edge (x in 0..W, y in 0..=H): y*W + x
|
||||
nodes: Vec<Node>,
|
||||
segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
impl<'a> Extractor<'a> {
|
||||
fn new(map: &'a LabelMap) -> Self {
|
||||
let w = map.width as i32;
|
||||
let h = map.height as i32;
|
||||
let cw = (map.width + 1) as usize;
|
||||
let ch = (map.height + 1) as usize;
|
||||
Extractor {
|
||||
map,
|
||||
w,
|
||||
h,
|
||||
node_at: vec![u32::MAX; cw * ch],
|
||||
visited_v: vec![false; (map.width as usize + 1) * map.height as usize],
|
||||
visited_h: vec![false; map.width as usize * (map.height as usize + 1)],
|
||||
nodes: Vec::new(),
|
||||
segments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn corner_index(&self, x: i32, y: i32) -> usize {
|
||||
y as usize * (self.w as usize + 1) + x as usize
|
||||
}
|
||||
|
||||
/// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`.
|
||||
fn edge_mask(&self, x: i32, y: i32) -> u8 {
|
||||
let nw = self.map.label(x - 1, y - 1);
|
||||
let ne = self.map.label(x, y - 1);
|
||||
let sw = self.map.label(x - 1, y);
|
||||
let se = self.map.label(x, y);
|
||||
let mut m = 0u8;
|
||||
if nw != ne {
|
||||
m |= 1 << N;
|
||||
}
|
||||
if ne != se {
|
||||
m |= 1 << E;
|
||||
}
|
||||
if sw != se {
|
||||
m |= 1 << S;
|
||||
}
|
||||
if nw != sw {
|
||||
m |= 1 << W;
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`.
|
||||
fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) {
|
||||
let nw = self.map.label(x - 1, y - 1);
|
||||
let ne = self.map.label(x, y - 1);
|
||||
let sw = self.map.label(x - 1, y);
|
||||
let se = self.map.label(x, y);
|
||||
match d {
|
||||
N => (nw, ne),
|
||||
E => (ne, se),
|
||||
S => (se, sw),
|
||||
W => (sw, nw),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`.
|
||||
/// Returns the canonical (is_vertical, index).
|
||||
fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) {
|
||||
match d {
|
||||
N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize),
|
||||
S => (true, y as usize * (self.w as usize + 1) + x as usize),
|
||||
E => (false, y as usize * self.w as usize + x as usize),
|
||||
W => (false, y as usize * self.w as usize + (x - 1) as usize),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_visited(&self, x: i32, y: i32, d: u8) -> bool {
|
||||
let (v, i) = self.edge_slot(x, y, d);
|
||||
if v {
|
||||
self.visited_v[i]
|
||||
} else {
|
||||
self.visited_h[i]
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_visited(&mut self, x: i32, y: i32, d: u8) {
|
||||
let (v, i) = self.edge_slot(x, y, d);
|
||||
if v {
|
||||
self.visited_v[i] = true;
|
||||
} else {
|
||||
self.visited_h[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass A — classify corners and allocate node ids for degree ≥ 3.
|
||||
fn classify(&mut self) {
|
||||
for y in 0..=self.h {
|
||||
for x in 0..=self.w {
|
||||
let deg = self.edge_mask(x, y).count_ones();
|
||||
if deg >= 3 {
|
||||
let id = self.nodes.len() as NodeId;
|
||||
self.nodes.push(Node {
|
||||
corner: PointI32 { x, y },
|
||||
out: [None; 4],
|
||||
});
|
||||
let ci = self.corner_index(x, y);
|
||||
self.node_at[ci] = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_id(&self, x: i32, y: i32) -> Option<NodeId> {
|
||||
let id = self.node_at[self.corner_index(x, y)];
|
||||
if id == u32::MAX {
|
||||
None
|
||||
} else {
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to
|
||||
/// the start). Returns the polyline, the final heading, and the corner
|
||||
/// walked to. Marks every traversed edge visited.
|
||||
fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec<PointI32>, u8, i32, i32) {
|
||||
let mut points = vec![PointI32 { x: x0, y: y0 }];
|
||||
let (mut cx, mut cy, mut d) = (x0, y0, d0);
|
||||
loop {
|
||||
self.mark_visited(cx, cy, d);
|
||||
let (dx, dy) = DVEC[d as usize];
|
||||
let (nx, ny) = (cx + dx, cy + dy);
|
||||
points.push(PointI32 { x: nx, y: ny });
|
||||
|
||||
let mask = self.edge_mask(nx, ny);
|
||||
if mask.count_ones() >= 3 {
|
||||
return (points, d, nx, ny); // reached a node
|
||||
}
|
||||
if nx == x0 && ny == y0 {
|
||||
return (points, d, nx, ny); // closed ring
|
||||
}
|
||||
// Degree-2: continue via the unique present edge that is not the
|
||||
// reverse of how we arrived.
|
||||
let rev = reverse(d);
|
||||
let mut nd = d;
|
||||
for cand in 0..4u8 {
|
||||
if cand != rev && (mask & (1 << cand)) != 0 {
|
||||
nd = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
d = nd;
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass B — trace node-to-node segments.
|
||||
fn trace_segments(&mut self) {
|
||||
let node_corners: Vec<PointI32> = self.nodes.iter().map(|n| n.corner).collect();
|
||||
for (nid, corner) in node_corners.iter().enumerate() {
|
||||
let nid = nid as NodeId;
|
||||
let (x, y) = (corner.x, corner.y);
|
||||
let mask = self.edge_mask(x, y);
|
||||
for d in 0..4u8 {
|
||||
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
|
||||
continue;
|
||||
}
|
||||
let (left, right) = self.side_pixels(x, y, d);
|
||||
let (points, last_dir, ex, ey) = self.walk(x, y, d);
|
||||
let end = self
|
||||
.node_id(ex, ey)
|
||||
.expect("segment must end at a node");
|
||||
|
||||
let seg_id = self.segments.len() as SegId;
|
||||
self.segments.push(Segment {
|
||||
points,
|
||||
start: Some(nid),
|
||||
end: Some(end),
|
||||
left,
|
||||
right,
|
||||
first_dir: d,
|
||||
last_dir,
|
||||
});
|
||||
self.nodes[nid as usize].out[d as usize] = Some(SegRef {
|
||||
seg: seg_id,
|
||||
forward: true,
|
||||
});
|
||||
// Leaving the end node backward along this segment.
|
||||
let back = reverse(last_dir);
|
||||
self.nodes[end as usize].out[back as usize] = Some(SegRef {
|
||||
seg: seg_id,
|
||||
forward: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass C — closed rings from any remaining unvisited boundary edges.
|
||||
fn trace_rings(&mut self) {
|
||||
for y in 0..=self.h {
|
||||
for x in 0..=self.w {
|
||||
let mask = self.edge_mask(x, y);
|
||||
for d in 0..4u8 {
|
||||
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
|
||||
continue;
|
||||
}
|
||||
let (left, right) = self.side_pixels(x, y, d);
|
||||
let (points, _last, _ex, _ey) = self.walk(x, y, d);
|
||||
self.segments.push(Segment {
|
||||
points,
|
||||
start: None,
|
||||
end: None,
|
||||
left,
|
||||
right,
|
||||
first_dir: d,
|
||||
last_dir: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BoundaryGraph {
|
||||
pub fn extract(map: &LabelMap) -> BoundaryGraph {
|
||||
let mut ex = Extractor::new(map);
|
||||
ex.classify();
|
||||
ex.trace_segments();
|
||||
ex.trace_rings();
|
||||
BoundaryGraph {
|
||||
nodes: ex.nodes,
|
||||
segments: ex.segments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel flanking the left of the directed edge leaving `(x,y)` in `d`. May be
|
||||
/// out of bounds, in which case it is [`OUTSIDE`] as far as the map is concerned.
|
||||
pub(super) fn left_pixel_coord(x: i32, y: i32, d: u8) -> (i32, i32) {
|
||||
match d {
|
||||
N => (x - 1, y - 1),
|
||||
E => (x, y - 1),
|
||||
S => (x, y),
|
||||
W => (x - 1, y),
|
||||
_ => (x, y),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit direction of a single lattice step.
|
||||
pub(super) fn dir_from_delta(dx: i32, dy: i32) -> u8 {
|
||||
DVEC.iter()
|
||||
.position(|&v| v == (dx, dy))
|
||||
.expect("consecutive lattice points differ by one unit step") as u8
|
||||
}
|
||||
|
||||
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
|
||||
/// face-assembly successor rule against a [`LabelMap`].
|
||||
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
|
||||
if !matches!(d, N | E | S | W) {
|
||||
return OUTSIDE;
|
||||
}
|
||||
let (px, py) = left_pixel_coord(x, y, d);
|
||||
map.label(px, py)
|
||||
}
|
||||
|
||||
// Direction constants and edge-present test needed by face assembly.
|
||||
pub(super) fn edge_present(map: &LabelMap, x: i32, y: i32, d: u8) -> bool {
|
||||
let nw = map.label(x - 1, y - 1);
|
||||
let ne = map.label(x, y - 1);
|
||||
let sw = map.label(x - 1, y);
|
||||
let se = map.label(x, y);
|
||||
match d {
|
||||
N => nw != ne,
|
||||
E => ne != se,
|
||||
S => sw != se,
|
||||
W => nw != sw,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Optimizer passes over the [`VectorDoc`] before serialization.
|
||||
//!
|
||||
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
|
||||
//! it here (rather than at write time) lets [`CleanupPass`] act on the
|
||||
//! rounded geometry, and it bakes offsets into coordinates so the writer
|
||||
//! never needs a per-path `translate`.
|
||||
//! * [`CleanupPass`] — drop zero-length and collinear-redundant segments that
|
||||
//! quantization may have created. (Curve *simplification* is not an
|
||||
//! optimizer pass: it must run on shared fitted geometry before composition
|
||||
//! — see [`crate::simplify`].)
|
||||
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use crate::ir::{MultiPath, PathCmd, SubPath, VectorDoc};
|
||||
|
||||
/// An optimizer pass rewrites the document in place.
|
||||
pub trait OptimizerPass {
|
||||
fn run(&self, doc: &mut VectorDoc);
|
||||
}
|
||||
|
||||
/// Round all coordinates to `precision` decimal places.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct QuantizePass {
|
||||
pub precision: u32,
|
||||
}
|
||||
|
||||
impl QuantizePass {
|
||||
pub fn new(precision: u32) -> Self {
|
||||
Self { precision }
|
||||
}
|
||||
|
||||
fn round(&self, v: f64) -> f64 {
|
||||
let factor = 10f64.powi(self.precision as i32);
|
||||
(v * factor).round() / factor
|
||||
}
|
||||
|
||||
fn round_pt(&self, p: PointF64) -> PointF64 {
|
||||
PointF64 {
|
||||
x: self.round(p.x),
|
||||
y: self.round(p.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OptimizerPass for QuantizePass {
|
||||
fn run(&self, doc: &mut VectorDoc) {
|
||||
for shape in &mut doc.shapes {
|
||||
for sub in &mut shape.path.subpaths {
|
||||
for cmd in &mut sub.commands {
|
||||
*cmd = match *cmd {
|
||||
PathCmd::MoveTo(p) => PathCmd::MoveTo(self.round_pt(p)),
|
||||
PathCmd::LineTo(p) => PathCmd::LineTo(self.round_pt(p)),
|
||||
PathCmd::CubicTo(c1, c2, e) => PathCmd::CubicTo(
|
||||
self.round_pt(c1),
|
||||
self.round_pt(c2),
|
||||
self.round_pt(e),
|
||||
),
|
||||
PathCmd::Close => PathCmd::Close,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove zero-length segments and collinear-redundant line vertices.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct CleanupPass;
|
||||
|
||||
/// Tolerance for treating two points as coincident.
|
||||
const COINCIDENT_EPS: f64 = 1e-6;
|
||||
/// Perpendicular-distance tolerance for treating three points as collinear.
|
||||
const COLLINEAR_EPS: f64 = 1e-4;
|
||||
|
||||
fn approx_eq(a: PointF64, b: PointF64) -> bool {
|
||||
(a.x - b.x).abs() < COINCIDENT_EPS && (a.y - b.y).abs() < COINCIDENT_EPS
|
||||
}
|
||||
|
||||
/// Perpendicular distance of `b` from the line through `a` and `c`.
|
||||
fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool {
|
||||
let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
let base = ((c.x - a.x).powi(2) + (c.y - a.y).powi(2)).sqrt();
|
||||
if base < COINCIDENT_EPS {
|
||||
return true;
|
||||
}
|
||||
(cross.abs() / base) < COLLINEAR_EPS
|
||||
}
|
||||
|
||||
fn cleanup_subpath(sub: &SubPath) -> SubPath {
|
||||
let mut out = SubPath::new();
|
||||
// `prev` is the point active before the last emitted command; `last` is the
|
||||
// current point after it. Both are needed to test collinearity of a run.
|
||||
let mut prev = PointF64::default();
|
||||
let mut last = PointF64::default();
|
||||
|
||||
for cmd in &sub.commands {
|
||||
match *cmd {
|
||||
PathCmd::MoveTo(p) => {
|
||||
out.commands.push(PathCmd::MoveTo(p));
|
||||
prev = p;
|
||||
last = p;
|
||||
}
|
||||
PathCmd::LineTo(p) => {
|
||||
if approx_eq(last, p) {
|
||||
continue; // zero-length
|
||||
}
|
||||
if let Some(PathCmd::LineTo(_)) = out.commands.last() {
|
||||
if collinear(prev, last, p) {
|
||||
*out.commands.last_mut().unwrap() = PathCmd::LineTo(p);
|
||||
last = p; // anchor `prev` unchanged
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.commands.push(PathCmd::LineTo(p));
|
||||
prev = last;
|
||||
last = p;
|
||||
}
|
||||
PathCmd::CubicTo(c1, c2, e) => {
|
||||
out.commands.push(PathCmd::CubicTo(c1, c2, e));
|
||||
prev = last;
|
||||
last = e;
|
||||
}
|
||||
PathCmd::Close => {
|
||||
out.commands.push(PathCmd::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
impl OptimizerPass for CleanupPass {
|
||||
fn run(&self, doc: &mut VectorDoc) {
|
||||
for shape in &mut doc.shapes {
|
||||
let mut subpaths = Vec::with_capacity(shape.path.subpaths.len());
|
||||
for sub in &shape.path.subpaths {
|
||||
let simplified = cleanup_subpath(sub);
|
||||
// Keep only subpaths with real geometry (a MoveTo plus at least
|
||||
// one drawing command beyond Close).
|
||||
let draws = simplified
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| matches!(c, PathCmd::LineTo(_) | PathCmd::CubicTo(..)))
|
||||
.count();
|
||||
if draws > 0 {
|
||||
subpaths.push(simplified);
|
||||
}
|
||||
}
|
||||
shape.path = MultiPath { subpaths };
|
||||
}
|
||||
doc.shapes.retain(|s| !s.path.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ir::{MultiPath, Paint, Shape};
|
||||
use visioncortex::Color;
|
||||
|
||||
fn pt(x: f64, y: f64) -> PointF64 {
|
||||
PointF64 { x, y }
|
||||
}
|
||||
|
||||
fn doc_with(commands: Vec<PathCmd>) -> VectorDoc {
|
||||
let mut doc = VectorDoc::new(100, 100);
|
||||
doc.shapes.push(Shape {
|
||||
paint: Paint::Solid(Color::new(0, 0, 0)),
|
||||
path: MultiPath {
|
||||
subpaths: vec![SubPath { commands }],
|
||||
},
|
||||
});
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_rounds_coordinates() {
|
||||
let mut doc = doc_with(vec![
|
||||
PathCmd::MoveTo(pt(1.234, 5.678)),
|
||||
PathCmd::LineTo(pt(9.876, 0.001)),
|
||||
PathCmd::Close,
|
||||
]);
|
||||
QuantizePass::new(1).run(&mut doc);
|
||||
let cmds = &doc.shapes[0].path.subpaths[0].commands;
|
||||
assert_eq!(cmds[0], PathCmd::MoveTo(pt(1.2, 5.7)));
|
||||
assert_eq!(cmds[1], PathCmd::LineTo(pt(9.9, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_drops_collinear_and_zero_length() {
|
||||
// A straight run of colinear points plus a duplicate should collapse.
|
||||
let mut doc = doc_with(vec![
|
||||
PathCmd::MoveTo(pt(0.0, 0.0)),
|
||||
PathCmd::LineTo(pt(1.0, 0.0)),
|
||||
PathCmd::LineTo(pt(2.0, 0.0)), // collinear with previous run
|
||||
PathCmd::LineTo(pt(2.0, 0.0)), // zero-length
|
||||
PathCmd::LineTo(pt(2.0, 5.0)),
|
||||
PathCmd::Close,
|
||||
]);
|
||||
CleanupPass.run(&mut doc);
|
||||
let cmds = &doc.shapes[0].path.subpaths[0].commands;
|
||||
// MoveTo, one merged horizontal LineTo, one vertical LineTo, Close.
|
||||
assert_eq!(cmds.len(), 4);
|
||||
assert_eq!(cmds[0], PathCmd::MoveTo(pt(0.0, 0.0)));
|
||||
assert_eq!(cmds[1], PathCmd::LineTo(pt(2.0, 0.0)));
|
||||
assert_eq!(cmds[2], PathCmd::LineTo(pt(2.0, 5.0)));
|
||||
assert_eq!(cmds[3], PathCmd::Close);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The pipeline driver: composes the stages and runs an image through them.
|
||||
|
||||
use visioncortex::ColorImage;
|
||||
|
||||
use crate::colorfit::ColorFitter;
|
||||
use crate::compose::Compositing;
|
||||
use crate::error::Error;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::ir::{Segmentation, VectorDoc};
|
||||
use crate::optimize::OptimizerPass;
|
||||
use crate::progress::{CancelToken, Ctx, Phase, Progress};
|
||||
use crate::simplify::CurvePass;
|
||||
use crate::svg::SvgWriter;
|
||||
|
||||
/// A fully-assembled vectorization pipeline. Build one with
|
||||
/// [`crate::Config::build`], or construct it directly for full control.
|
||||
pub struct Pipeline {
|
||||
pub frontend: Box<dyn Frontend>,
|
||||
pub color_fitters: Vec<Box<dyn ColorFitter>>,
|
||||
pub compositing: Compositing,
|
||||
/// Geometry passes over fitted contours (e.g. curve simplification), run
|
||||
/// inside compositing — after curve fitting, before paths are assembled —
|
||||
/// so mosaic mode applies them once per shared boundary segment.
|
||||
pub curve_passes: Vec<Box<dyn CurvePass>>,
|
||||
pub optimizers: Vec<Box<dyn OptimizerPass>>,
|
||||
pub writer: SvgWriter,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// Run the pipeline to the output document IR (before serialization).
|
||||
///
|
||||
/// Equivalent to [`run_with_progress`](Pipeline::run_with_progress) with a
|
||||
/// fresh (never-cancelled) token and a no-op progress callback.
|
||||
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
|
||||
self.run_with_progress(img, &CancelToken::new(), &mut |_| {})
|
||||
}
|
||||
|
||||
/// Run the pipeline, publishing [`Progress`] updates and honoring the
|
||||
/// [`CancelToken`].
|
||||
///
|
||||
/// Intended to be called on a worker thread: hand a clone of `cancel` to
|
||||
/// the UI so a button can abort, and forward `on_progress` to a channel
|
||||
/// that drives a progress bar. Returns [`Error::Cancelled`] if the token is
|
||||
/// tripped. See [`crate::progress`] for a usage example.
|
||||
pub fn run_with_progress(
|
||||
&self,
|
||||
img: &ColorImage,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
let seg = self.frontend.segment_with(img, &mut ctx)?;
|
||||
// `seg` is owned and about to be consumed, so no clone is needed here.
|
||||
self.finish_ctx(seg, &mut ctx)
|
||||
}
|
||||
|
||||
/// Phase 1 of 2 — run **only** the frontend (the expensive clustering step)
|
||||
/// and return a reusable [`Segmentation`].
|
||||
///
|
||||
/// Cache the result and feed it to [`finish`](Pipeline::finish) to
|
||||
/// re-render with different color-fitting, curve-fitting, or optimization
|
||||
/// parameters *without repaying the clustering cost* — the core of an
|
||||
/// interactive tuning loop. Re-run `segment` when a parameter that affects
|
||||
/// clustering itself changes: filter speckle, color precision, layer
|
||||
/// difference, binary threshold, or the frontend choice.
|
||||
pub fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
self.segment_with_progress(img, &CancelToken::new(), &mut |_| {})
|
||||
}
|
||||
|
||||
/// [`segment`](Pipeline::segment) with progress reporting and cancellation.
|
||||
pub fn segment_with_progress(
|
||||
&self,
|
||||
img: &ColorImage,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<Segmentation, Error> {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
self.frontend.segment_with(img, &mut ctx)
|
||||
}
|
||||
|
||||
/// Phase 2 of 2 — color fitting → compositing → optimization, reusing a
|
||||
/// [`Segmentation`] produced by [`segment`](Pipeline::segment).
|
||||
///
|
||||
/// The segmentation is cloned internally (color fitting mutates it), so the
|
||||
/// cached copy stays pristine and can be reused across many `finish` calls
|
||||
/// with different pipelines. The frontend of `self` is not used here; build
|
||||
/// the tuning pipeline with the color/curve/optimize parameters you want
|
||||
/// and the *same* clustering parameters that produced `seg`.
|
||||
pub fn finish(&self, seg: &Segmentation) -> Result<VectorDoc, Error> {
|
||||
self.finish_with_progress(seg, &CancelToken::new(), &mut |_| {})
|
||||
}
|
||||
|
||||
/// [`finish`](Pipeline::finish) with progress reporting and cancellation.
|
||||
/// Progress starts at the [`Phase::Compose`] stage (segmentation is skipped).
|
||||
pub fn finish_with_progress(
|
||||
&self,
|
||||
seg: &Segmentation,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
self.finish_ctx(seg.clone(), &mut ctx)
|
||||
}
|
||||
|
||||
/// Downstream stages (color fit → compose → optimize) over an owned
|
||||
/// segmentation. Shared by the one-shot and two-phase entry points; takes
|
||||
/// ownership so the one-shot path avoids a clone.
|
||||
fn finish_ctx(&self, mut seg: Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
|
||||
for fitter in &self.color_fitters {
|
||||
fitter.fit(&mut seg);
|
||||
ctx.check()?;
|
||||
}
|
||||
|
||||
let mut doc = self.compositing.compose_with(&seg, &self.curve_passes, ctx)?;
|
||||
|
||||
let total = self.optimizers.len().max(1);
|
||||
for (i, pass) in self.optimizers.iter().enumerate() {
|
||||
ctx.check()?;
|
||||
pass.run(&mut doc);
|
||||
ctx.report(Phase::Optimize, (i + 1) as f32 / total as f32);
|
||||
}
|
||||
// Always emit a terminal 100% so a UI can settle even with no passes.
|
||||
ctx.report(Phase::Optimize, 1.0);
|
||||
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Run the pipeline and serialize the result to an SVG string.
|
||||
pub fn to_svg(&self, img: &ColorImage) -> Result<String, Error> {
|
||||
Ok(self.writer.write(&self.run(img)?))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Progress reporting and cancellation for long-running conversions.
|
||||
//!
|
||||
//! [`crate::Pipeline::run_with_progress`] takes a [`CancelToken`] and a
|
||||
//! progress callback. On native targets, run it on a worker thread: the
|
||||
//! callback publishes [`Progress`] to the UI and the token lets the UI abort
|
||||
//! between work batches (clustering checks once per batch, so cancellation is
|
||||
//! near-instant). The pipeline returns [`Error::Cancelled`] when the token is
|
||||
//! tripped.
|
||||
//!
|
||||
//! There is deliberately no cooperative `tick()` here: that only existed in the
|
||||
//! old browser build because the main thread could not block. The same API
|
||||
//! works unchanged from a Web Worker.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use vtracer::{Config, ColorImage};
|
||||
//! use vtracer::progress::{CancelToken, Progress};
|
||||
//!
|
||||
//! # fn load() -> ColorImage { todo!() }
|
||||
//! let pipeline = Config::default().build().unwrap();
|
||||
//! let cancel = CancelToken::new();
|
||||
//! # let img: ColorImage = load();
|
||||
//! // hand `cancel.clone()` to the UI so a button can call `cancel.cancel()`
|
||||
//! let mut on_progress = |p: Progress| eprintln!("{:?} {:.0}%", p.phase, p.fraction * 100.0);
|
||||
//! let doc = pipeline.run_with_progress(&img, &cancel, &mut on_progress);
|
||||
//! ```
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// A cheaply-clonable cancellation flag shared between the UI and the worker.
|
||||
///
|
||||
/// Clone it, hand one copy to the worker thread running the pipeline and keep
|
||||
/// the other; call [`cancel`](CancelToken::cancel) from any thread to request
|
||||
/// an early stop. Clones share the same underlying flag.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CancelToken(Arc<AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
/// A fresh, un-cancelled token.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Request cancellation. Idempotent; safe to call from any thread.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether cancellation has been requested.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pipeline phase a [`Progress`] update belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// Frontend segmentation (color clustering) — usually the dominant cost.
|
||||
Segment,
|
||||
/// Compositing the segmentation into shapes.
|
||||
Compose,
|
||||
/// Output optimization passes.
|
||||
Optimize,
|
||||
}
|
||||
|
||||
/// A progress update: the current [`Phase`] and how far through it we are.
|
||||
///
|
||||
/// `fraction` is *within* the phase, in `0.0..=1.0`. Clustering dominates
|
||||
/// runtime, so a UI can weight the phases or simply show the phase label with
|
||||
/// its fraction (e.g. "Clustering 45%").
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Progress {
|
||||
pub phase: Phase,
|
||||
pub fraction: f32,
|
||||
}
|
||||
|
||||
/// Bundles the cancel token and progress sink threaded through the stages.
|
||||
///
|
||||
/// Stages call [`Ctx::check`] between batches to honor cancellation and
|
||||
/// [`Ctx::report`] to publish progress.
|
||||
pub struct Ctx<'a> {
|
||||
cancel: &'a CancelToken,
|
||||
on_progress: &'a mut dyn FnMut(Progress),
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
/// Construct a context from a token and a progress callback.
|
||||
pub fn new(cancel: &'a CancelToken, on_progress: &'a mut dyn FnMut(Progress)) -> Self {
|
||||
Self {
|
||||
cancel,
|
||||
on_progress,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return [`Error::Cancelled`] if cancellation has been requested.
|
||||
pub fn check(&self) -> Result<(), Error> {
|
||||
if self.cancel.is_cancelled() {
|
||||
Err(Error::Cancelled)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a progress update for `phase` at `fraction` (clamped to 0..=1).
|
||||
pub fn report(&mut self, phase: Phase, fraction: f32) {
|
||||
(self.on_progress)(Progress {
|
||||
phase,
|
||||
fraction: fraction.clamp(0.0, 1.0),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Interactive tuning session: cache the expensive clustering, re-render on the
|
||||
//! cheap stages, and re-segment automatically only when it's actually needed.
|
||||
//!
|
||||
//! A desktop app loads an image once, then calls [`Session::render`] on every
|
||||
//! slider change with a fresh [`Config`]. The session compares the config's
|
||||
//! [`SegmentKey`] to what it last clustered and
|
||||
//! re-segments only if a clustering parameter changed — the caller never has to
|
||||
//! know which parameters those are.
|
||||
//!
|
||||
//! For watershed clustering there is a second cache level: the
|
||||
//! [`WatershedHierarchy`] depends only on the image, so it is built once and
|
||||
//! every re-segmentation (a detail or speckle change) is a near-instant re-cut
|
||||
//! of the cached hierarchy rather than a rebuild.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use vtracer::{Config, Session, ColorImage};
|
||||
//! # fn load() -> ColorImage { todo!() }
|
||||
//! let mut session = Session::new(load());
|
||||
//!
|
||||
//! // First render clusters the image.
|
||||
//! let mut cfg = Config::default();
|
||||
//! let _svg = session.render_svg(&cfg).unwrap();
|
||||
//!
|
||||
//! // Tuning a curve parameter reuses the cached segmentation (no re-cluster).
|
||||
//! cfg.corner_threshold = 90;
|
||||
//! let _svg = session.render_svg(&cfg).unwrap();
|
||||
//!
|
||||
//! // Changing a clustering parameter re-segments automatically.
|
||||
//! cfg.filter_speckle = 8;
|
||||
//! let _svg = session.render_svg(&cfg).unwrap();
|
||||
//! ```
|
||||
|
||||
use visioncortex::ColorImage;
|
||||
|
||||
use crate::config::{Clustering, Config, SegmentKey};
|
||||
use crate::error::Error;
|
||||
use crate::frontend::WatershedHierarchy;
|
||||
use crate::ir::{Segmentation, VectorDoc};
|
||||
use crate::pipeline::Pipeline;
|
||||
use crate::progress::{CancelToken, Ctx, Phase, Progress};
|
||||
|
||||
/// A reusable converter for one image: clusters once, re-renders many times.
|
||||
///
|
||||
/// Build it with the source [`ColorImage`] and drive it with a [`Config`] per
|
||||
/// render. The cached [`Segmentation`] is refreshed transparently whenever the
|
||||
/// config's clustering parameters change.
|
||||
pub struct Session {
|
||||
img: ColorImage,
|
||||
/// The segmentation and the key it was produced with (`None` until the
|
||||
/// first render).
|
||||
cache: Option<(SegmentKey, Segmentation)>,
|
||||
/// The image's watershed hierarchy, built lazily on the first watershed
|
||||
/// render. Parameter-free, so it never goes stale while the image lives.
|
||||
hierarchy: Option<WatershedHierarchy>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start a session over `img`. Nothing is clustered until the first render.
|
||||
pub fn new(img: ColorImage) -> Self {
|
||||
Self {
|
||||
img,
|
||||
cache: None,
|
||||
hierarchy: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cached segmentation is missing or was clustered with
|
||||
/// different parameters than `key`.
|
||||
fn stale(&self, key: &SegmentKey) -> bool {
|
||||
self.cache.as_ref().map_or(true, |(k, _)| k != key)
|
||||
}
|
||||
|
||||
/// The cached segmentation. Only call after ensuring the cache is fresh.
|
||||
fn segmentation(&self) -> &Segmentation {
|
||||
&self.cache.as_ref().expect("cache populated by caller").1
|
||||
}
|
||||
|
||||
/// Produce a fresh segmentation for `cfg`. Watershed goes through the
|
||||
/// hierarchy cache (build once, cut cheaply); everything else runs the
|
||||
/// pipeline's frontend.
|
||||
fn segment(&mut self, cfg: &Config, pipeline: &Pipeline) -> Result<Segmentation, Error> {
|
||||
if cfg.clustering == Clustering::Watershed {
|
||||
if self.hierarchy.is_none() {
|
||||
self.hierarchy = Some(WatershedHierarchy::build(&self.img)?);
|
||||
}
|
||||
let hierarchy = self.hierarchy.as_ref().expect("just built");
|
||||
Ok(hierarchy.cut(&self.img, cfg.watershed_detail, cfg.speckle_area()))
|
||||
} else {
|
||||
pipeline.segment(&self.img)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render to the document IR, re-segmenting only if `cfg`'s clustering
|
||||
/// parameters differ from the cached segmentation's.
|
||||
pub fn render(&mut self, cfg: &Config) -> Result<VectorDoc, Error> {
|
||||
let pipeline = cfg.build()?;
|
||||
let key = cfg.segment_key();
|
||||
if self.stale(&key) {
|
||||
let seg = self.segment(cfg, &pipeline)?;
|
||||
self.cache = Some((key, seg));
|
||||
}
|
||||
pipeline.finish(self.segmentation())
|
||||
}
|
||||
|
||||
/// [`render`](Session::render), serialized to an SVG string.
|
||||
pub fn render_svg(&mut self, cfg: &Config) -> Result<String, Error> {
|
||||
let pipeline = cfg.build()?;
|
||||
let key = cfg.segment_key();
|
||||
if self.stale(&key) {
|
||||
let seg = self.segment(cfg, &pipeline)?;
|
||||
self.cache = Some((key, seg));
|
||||
}
|
||||
Ok(pipeline.writer.write(&pipeline.finish(self.segmentation())?))
|
||||
}
|
||||
|
||||
/// [`render`](Session::render) with progress reporting and cancellation.
|
||||
///
|
||||
/// When a re-segmentation is needed, progress covers the [`Phase::Segment`]
|
||||
/// stage first, then the finish stages; on a cache hit only the finish
|
||||
/// stages report. Hand a clone of `cancel` to the UI to abort a long
|
||||
/// clustering pass. (A watershed re-cut over a cached hierarchy is fast
|
||||
/// enough that it reports coarsely.)
|
||||
pub fn render_with_progress(
|
||||
&mut self,
|
||||
cfg: &Config,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let pipeline = cfg.build()?;
|
||||
let key = cfg.segment_key();
|
||||
if self.stale(&key) {
|
||||
let seg = if cfg.clustering == Clustering::Watershed {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
ctx.check()?;
|
||||
let seg = self.segment(cfg, &pipeline)?;
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Segment, 1.0);
|
||||
seg
|
||||
} else {
|
||||
pipeline.segment_with_progress(&self.img, cancel, on_progress)?
|
||||
};
|
||||
self.cache = Some((key, seg));
|
||||
}
|
||||
pipeline.finish_with_progress(self.segmentation(), cancel, on_progress)
|
||||
}
|
||||
|
||||
/// Drop the cached segmentation and hierarchy, forcing the next render to
|
||||
/// re-cluster. Use after replacing the source image out of band; normally
|
||||
/// unnecessary.
|
||||
pub fn invalidate(&mut self) {
|
||||
self.cache = None;
|
||||
self.hierarchy = None;
|
||||
}
|
||||
|
||||
/// The source image this session renders.
|
||||
pub fn image(&self) -> &ColorImage {
|
||||
&self.img
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Curve passes: geometry transforms between curve fitting and composition.
|
||||
//!
|
||||
//! A [`CurvePass`] rewrites one fitted contour at a time. Passes run *before*
|
||||
//! composition on the fitted geometry itself — in mosaic mode each shared
|
||||
//! boundary segment is transformed exactly once and both adjacent faces
|
||||
//! reference the result, so the tessellation stays seam-free by construction.
|
||||
//! Running instead on the composed [`VectorDoc`](crate::ir::VectorDoc) would
|
||||
//! re-fit the two copies of every shared boundary independently and reopen
|
||||
//! the seams the mosaic exists to prevent.
|
||||
//!
|
||||
//! The built-in pass is [`SimplifyCurves`], the paper.js `simplify` analogue.
|
||||
|
||||
use flo_curves::bezier::{fit_curve_cubic, Curve};
|
||||
use flo_curves::Coord2;
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use crate::fitter::FittedGeom;
|
||||
|
||||
/// A geometry pass over one fitted contour, run between curve fitting and
|
||||
/// composition. Implementations must keep an open chain's endpoints exactly
|
||||
/// (mosaic junction nodes must not move) and keep a ring closed.
|
||||
pub trait CurvePass {
|
||||
/// Transform an open chain; both endpoints are pinned.
|
||||
fn open(&self, geom: FittedGeom) -> FittedGeom;
|
||||
/// Transform a closed ring.
|
||||
fn ring(&self, geom: FittedGeom) -> FittedGeom;
|
||||
}
|
||||
|
||||
/// paper.js-style curve simplification (Schneider's fit): re-fit each smooth
|
||||
/// run of cubics between corners with the fewest curves that stay within
|
||||
/// `tolerance` of the fitted geometry.
|
||||
///
|
||||
/// The spline fitters cut an outline at every splice point and fit each short
|
||||
/// slice separately, so a lazily curving edge carries an anchor per splice.
|
||||
/// This pass samples the fitted curve (~1 px spacing) and re-fits whole
|
||||
/// corner-to-corner runs with `flo_curves`' Schneider implementation
|
||||
/// (`fit_curve_cubic`, tangents taken from the chain's own ends), merging
|
||||
/// those slices down to what the tolerance genuinely requires.
|
||||
///
|
||||
/// A run is replaced only when the re-fit uses strictly fewer cubics and is
|
||||
/// kept verbatim otherwise, so the pass never increases the curve count and
|
||||
/// never moves the geometry more than `tolerance` (measured at the samples).
|
||||
/// Polylines (pixel / polygon modes) pass through untouched.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SimplifyCurves {
|
||||
/// Maximum distance (px) the simplified curve may stray from the fitted
|
||||
/// one. paper.js defaults to 2.5.
|
||||
pub tolerance: f64,
|
||||
/// Tangent-break angle (radians) above which an anchor is a corner and
|
||||
/// must survive in place; runs are re-fitted between corners.
|
||||
pub corner_threshold: f64,
|
||||
}
|
||||
|
||||
impl CurvePass for SimplifyCurves {
|
||||
fn open(&self, geom: FittedGeom) -> FittedGeom {
|
||||
match geom {
|
||||
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, false)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn ring(&self, geom: FittedGeom) -> FittedGeom {
|
||||
match geom {
|
||||
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, true)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SimplifyCurves {
|
||||
fn simplify_chain(&self, mut chain: Vec<[PointF64; 4]>, closed: bool) -> Vec<[PointF64; 4]> {
|
||||
if self.tolerance <= 0.0 || chain.len() < 2 {
|
||||
return chain;
|
||||
}
|
||||
|
||||
if closed {
|
||||
// The re-fit pins run endpoints, so a ring needs a seam. Put it at
|
||||
// the sharpest junction (wraparound included): a corner the fit
|
||||
// would keep anyway, or the least-smooth anchor when the ring has
|
||||
// none, so any residual tangent break lands where it hides best.
|
||||
let angles: Vec<f64> = (0..chain.len())
|
||||
.map(|k| {
|
||||
let prev = if k == 0 { chain.len() - 1 } else { k - 1 };
|
||||
break_angle(&chain[prev], &chain[k])
|
||||
})
|
||||
.collect();
|
||||
let seam = angles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
chain.rotate_left(seam);
|
||||
}
|
||||
|
||||
// Cut into smooth runs at corner anchors (chain ends are always cuts).
|
||||
let mut cuts: Vec<usize> = vec![0];
|
||||
for i in 1..chain.len() {
|
||||
if break_angle(&chain[i - 1], &chain[i]) >= self.corner_threshold {
|
||||
cuts.push(i);
|
||||
}
|
||||
}
|
||||
cuts.push(chain.len());
|
||||
|
||||
let mut out: Vec<[PointF64; 4]> = Vec::with_capacity(chain.len());
|
||||
for w in cuts.windows(2) {
|
||||
let run = &chain[w[0]..w[1]];
|
||||
if run.len() < 2 {
|
||||
out.extend_from_slice(run);
|
||||
continue;
|
||||
}
|
||||
match refit_run(run, self.tolerance) {
|
||||
Some(refit) if refit.len() < run.len() => out.extend(refit),
|
||||
_ => out.extend_from_slice(run),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Schneider-fit one smooth run: sample it, then `fit_curve_cubic` with the
|
||||
/// run's own end tangents (`end_tangent` points backward, per its contract).
|
||||
/// The recursion splits at sample points, so consecutive fitted cubics share
|
||||
/// endpoints exactly; the outer endpoints are pinned to the run's, bit for
|
||||
/// bit. Returns `None` for degenerate (point-like) runs.
|
||||
fn refit_run(run: &[[PointF64; 4]], tolerance: f64) -> Option<Vec<[PointF64; 4]>> {
|
||||
let start_tan = tangent_out(run.first()?)?;
|
||||
let end_tan = tangent_in(run.last()?)?;
|
||||
let samples: Vec<Coord2> = sample_run(run, tolerance)
|
||||
.into_iter()
|
||||
.map(|p| Coord2(p.x, p.y))
|
||||
.collect();
|
||||
|
||||
let fitted: Vec<Curve<Coord2>> = fit_curve_cubic(
|
||||
&samples,
|
||||
&Coord2(start_tan.0, start_tan.1),
|
||||
&Coord2(-end_tan.0, -end_tan.1),
|
||||
tolerance,
|
||||
);
|
||||
if fitted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pt = |c: Coord2| PointF64 { x: c.0, y: c.1 };
|
||||
let mut out: Vec<[PointF64; 4]> = fitted
|
||||
.into_iter()
|
||||
.map(|c| [pt(c.start_point), pt(c.control_points.0), pt(c.control_points.1), pt(c.end_point)])
|
||||
.collect();
|
||||
out.first_mut()?[0] = run[0][0];
|
||||
out.last_mut()?[3] = run[run.len() - 1][3];
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn dist(a: PointF64, b: PointF64) -> f64 {
|
||||
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Unit direction a→b, or `None` when the points (nearly) coincide.
|
||||
fn dir(a: PointF64, b: PointF64) -> Option<(f64, f64)> {
|
||||
let (dx, dy) = (b.x - a.x, b.y - a.y);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-9 {
|
||||
None
|
||||
} else {
|
||||
Some((dx / len, dy / len))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tangent arriving at a cubic's end: the last distinct control point wins.
|
||||
fn tangent_in(c: &[PointF64; 4]) -> Option<(f64, f64)> {
|
||||
dir(c[2], c[3]).or_else(|| dir(c[1], c[3])).or_else(|| dir(c[0], c[3]))
|
||||
}
|
||||
|
||||
/// Tangent leaving a cubic's start: the first distinct control point wins.
|
||||
fn tangent_out(c: &[PointF64; 4]) -> Option<(f64, f64)> {
|
||||
dir(c[0], c[1]).or_else(|| dir(c[0], c[2])).or_else(|| dir(c[0], c[3]))
|
||||
}
|
||||
|
||||
/// Turn angle at the junction of two consecutive cubics. A fully degenerate
|
||||
/// (point-like) neighbour counts as a corner so it is never smoothed across.
|
||||
fn break_angle(prev: &[PointF64; 4], next: &[PointF64; 4]) -> f64 {
|
||||
match (tangent_in(prev), tangent_out(next)) {
|
||||
(Some(a), Some(b)) => (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0).acos(),
|
||||
_ => std::f64::consts::PI,
|
||||
}
|
||||
}
|
||||
|
||||
fn cubic_at(c: &[PointF64; 4], t: f64) -> PointF64 {
|
||||
let u = 1.0 - t;
|
||||
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
PointF64 {
|
||||
x: b0 * c[0].x + b1 * c[1].x + b2 * c[2].x + b3 * c[3].x,
|
||||
y: b0 * c[0].y + b1 * c[1].y + b2 * c[2].y + b3 * c[3].y,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample a run of cubics at roughly 1 px spacing (by control-polygon length),
|
||||
/// tighter when the tolerance is sub-pixel — the fit measures its error at
|
||||
/// the samples, so their spacing is the fidelity guard. The first and last
|
||||
/// samples are the run's endpoints, exactly: `cubic_at` with `t = 1` returns
|
||||
/// `c[3]` bit for bit.
|
||||
fn sample_run(run: &[[PointF64; 4]], tolerance: f64) -> Vec<PointF64> {
|
||||
let spacing = tolerance.clamp(0.25, 1.0);
|
||||
let mut samples = vec![run[0][0]];
|
||||
for c in run {
|
||||
let len = dist(c[0], c[1]) + dist(c[1], c[2]) + dist(c[2], c[3]);
|
||||
let n = ((len / spacing).ceil() as usize).clamp(1, 512);
|
||||
for k in 1..=n {
|
||||
samples.push(cubic_at(c, k as f64 / n as f64));
|
||||
}
|
||||
}
|
||||
samples
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pt(x: f64, y: f64) -> PointF64 {
|
||||
PointF64 { x, y }
|
||||
}
|
||||
|
||||
/// A degenerate cubic tracing the straight line `a`→`b`.
|
||||
fn straight(a: PointF64, b: PointF64) -> [PointF64; 4] {
|
||||
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
[a, lerp(1.0 / 3.0), lerp(2.0 / 3.0), b]
|
||||
}
|
||||
|
||||
/// `n` straight cubics subdividing the segment `a`→`b`.
|
||||
fn straight_chain(a: PointF64, b: PointF64, n: usize) -> Vec<[PointF64; 4]> {
|
||||
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
(0..n)
|
||||
.map(|i| straight(lerp(i as f64 / n as f64), lerp((i + 1) as f64 / n as f64)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One cubic approximating the circular arc `a0..a1` on a circle of
|
||||
/// radius `r` about the origin (the classic 4/3·tan(Δ/4) handle length).
|
||||
fn arc_cubic(r: f64, a0: f64, a1: f64) -> [PointF64; 4] {
|
||||
let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan();
|
||||
let (p0, p3) = (pt(r * a0.cos(), r * a0.sin()), pt(r * a1.cos(), r * a1.sin()));
|
||||
[
|
||||
p0,
|
||||
pt(p0.x - k * r * a0.sin(), p0.y + k * r * a0.cos()),
|
||||
pt(p3.x + k * r * a1.sin(), p3.y - k * r * a1.cos()),
|
||||
p3,
|
||||
]
|
||||
}
|
||||
|
||||
fn pass() -> SimplifyCurves {
|
||||
SimplifyCurves {
|
||||
tolerance: 1.0,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchors(chain: &[[PointF64; 4]]) -> Vec<PointF64> {
|
||||
let mut a: Vec<PointF64> = chain.iter().map(|c| c[0]).collect();
|
||||
a.push(chain.last().unwrap()[3]);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collinear_run_collapses_to_one_cubic() {
|
||||
let chain = straight_chain(pt(0.0, 0.0), pt(100.0, 0.0), 10);
|
||||
let out = match pass().open(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert_eq!(out.len(), 1, "ten collinear cubics become one");
|
||||
assert_eq!(out[0][0], pt(0.0, 0.0), "start pinned");
|
||||
assert_eq!(out[0][3], pt(100.0, 0.0), "end pinned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_survives_in_place() {
|
||||
// An L: two straight runs meeting at a right angle.
|
||||
let mut chain = straight_chain(pt(0.0, 0.0), pt(50.0, 0.0), 5);
|
||||
chain.extend(straight_chain(pt(50.0, 0.0), pt(50.0, 50.0), 5));
|
||||
let out = match pass().open(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert_eq!(out.len(), 2, "one cubic per leg");
|
||||
assert_eq!(out[0][3], pt(50.0, 0.0), "corner anchor exact");
|
||||
assert_eq!(out[1][0], pt(50.0, 0.0), "chain continuous through corner");
|
||||
assert_eq!(out[0][0], pt(0.0, 0.0));
|
||||
assert_eq!(out[1][3], pt(50.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_stays_closed_and_keeps_square_corners() {
|
||||
// A closed square, three cubics per side, seam mid-side (anchor 0 is
|
||||
// smooth) — the pass must rotate the seam onto a corner.
|
||||
let corners = [pt(0.0, 0.0), pt(60.0, 0.0), pt(60.0, 60.0), pt(0.0, 60.0)];
|
||||
let mut chain = Vec::new();
|
||||
for i in 0..4 {
|
||||
chain.extend(straight_chain(corners[i], corners[(i + 1) % 4], 3));
|
||||
}
|
||||
chain.rotate_left(1); // seam mid-side
|
||||
let out = match pass().ring(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert_eq!(out.len(), 4, "one cubic per side");
|
||||
assert_eq!(out[0][0], out.last().unwrap()[3], "ring closed");
|
||||
let mut got = anchors(&out);
|
||||
got.pop(); // last repeats first
|
||||
for c in corners {
|
||||
assert!(got.contains(&c), "corner {c:?} kept, got {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_merges_within_tolerance() {
|
||||
// A quarter circle as 8 short arcs collapses to far fewer cubics, and
|
||||
// the result stays within tolerance of the true circle.
|
||||
let r = 50.0;
|
||||
let n = 8;
|
||||
let chain: Vec<[PointF64; 4]> = (0..n)
|
||||
.map(|i| {
|
||||
let step = std::f64::consts::FRAC_PI_2 / n as f64;
|
||||
arc_cubic(r, i as f64 * step, (i + 1) as f64 * step)
|
||||
})
|
||||
.collect();
|
||||
let tol = 0.5;
|
||||
let p = SimplifyCurves {
|
||||
tolerance: tol,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
};
|
||||
let out = match p.open(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert!(out.len() < 8, "arcs merge, got {}", out.len());
|
||||
for c in &out {
|
||||
for k in 0..=32 {
|
||||
let q = cubic_at(c, k as f64 / 32.0);
|
||||
let radial = ((q.x * q.x + q.y * q.y).sqrt() - r).abs();
|
||||
assert!(radial <= tol + 0.1, "deviation {radial} beyond tolerance");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refit_never_grows_the_chain() {
|
||||
// A single cubic is untouchable; a sharp S of two cubics that cannot
|
||||
// merge within a tiny tolerance is kept verbatim.
|
||||
let lone = vec![arc_cubic(50.0, 0.0, 1.0)];
|
||||
match pass().open(FittedGeom::Beziers(lone.clone())) {
|
||||
FittedGeom::Beziers(c) => assert_eq!(c, lone),
|
||||
_ => panic!("geometry kind changed"),
|
||||
}
|
||||
|
||||
let s_curve = vec![
|
||||
[pt(0.0, 0.0), pt(20.0, 40.0), pt(30.0, 40.0), pt(50.0, 0.0)],
|
||||
[pt(50.0, 0.0), pt(70.0, -40.0), pt(80.0, -40.0), pt(100.0, 0.0)],
|
||||
];
|
||||
let tight = SimplifyCurves {
|
||||
tolerance: 0.01,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
};
|
||||
match tight.open(FittedGeom::Beziers(s_curve.clone())) {
|
||||
FittedGeom::Beziers(c) => {
|
||||
assert!(c.len() <= s_curve.len(), "never more cubics than input")
|
||||
}
|
||||
_ => panic!("geometry kind changed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polylines_pass_through_untouched() {
|
||||
let poly = vec![pt(0.0, 0.0), pt(1.0, 0.0), pt(2.0, 0.0), pt(3.0, 0.0)];
|
||||
match pass().open(FittedGeom::Polyline(poly.clone())) {
|
||||
FittedGeom::Polyline(p) => assert_eq!(p, poly),
|
||||
_ => panic!("polyline must stay a polyline"),
|
||||
}
|
||||
match pass().ring(FittedGeom::Polyline(poly.clone())) {
|
||||
FittedGeom::Polyline(p) => assert_eq!(p, poly),
|
||||
_ => panic!("polyline must stay a polyline"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,122 @@
|
||||
//! Binary thresholding: tunable fixed cutoff and Bradley–Roth adaptive.
|
||||
|
||||
use vtracer::frontend::{BinaryFrontend, Frontend};
|
||||
use vtracer::{ColorImage, Threshold};
|
||||
|
||||
fn gray(w: usize, h: usize, f: impl Fn(usize, usize) -> u8) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = f(x, y);
|
||||
pixels.extend_from_slice(&[v, v, v, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total foreground pixels selected by a frontend over an image.
|
||||
fn foreground_area(front: &BinaryFrontend, img: &ColorImage) -> usize {
|
||||
front
|
||||
.segment(img)
|
||||
.unwrap()
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.mask.area())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// A uniform gray field: a lower fixed threshold selects strictly fewer pixels.
|
||||
#[test]
|
||||
fn fixed_threshold_is_tunable() {
|
||||
// Left third value 80, middle 130, right 180.
|
||||
let img = gray(60, 20, |x, _| match x / 20 {
|
||||
0 => 80,
|
||||
1 => 130,
|
||||
_ => 180,
|
||||
});
|
||||
|
||||
let front = |v: u8| BinaryFrontend {
|
||||
threshold: Threshold::Fixed(v),
|
||||
diagonal: false,
|
||||
min_area: 0,
|
||||
};
|
||||
|
||||
let low = foreground_area(&front(100), &img); // catches only the 80 band
|
||||
let mid = foreground_area(&front(150), &img); // 80 + 130 bands
|
||||
let high = foreground_area(&front(200), &img); // everything
|
||||
|
||||
assert!(
|
||||
low < mid && mid < high,
|
||||
"higher threshold must select more foreground: {low} < {mid} < {high}"
|
||||
);
|
||||
assert_eq!(high, 60 * 20, "threshold above all values selects everything");
|
||||
}
|
||||
|
||||
/// Adaptive thresholding recovers locally-dark marks under a brightness
|
||||
/// gradient that no single global cutoff can separate.
|
||||
#[test]
|
||||
fn adaptive_beats_fixed_under_uneven_lighting() {
|
||||
let (w, h) = (80, 40);
|
||||
|
||||
// Background ramps left(70) → right(210). Two 6x6 marks, each 40 darker
|
||||
// than their local background: one on the dark side, one on the bright side.
|
||||
let bg = |x: usize| 70 + (x * 140 / (w - 1)) as u8;
|
||||
let marks = [(16usize, 17usize), (60, 17)];
|
||||
let is_mark = |x: usize, y: usize| {
|
||||
marks
|
||||
.iter()
|
||||
.any(|&(mx, my)| x >= mx && x < mx + 6 && y >= my && y < my + 6)
|
||||
};
|
||||
let img = gray(w, h, |x, y| {
|
||||
if is_mark(x, y) {
|
||||
bg(x).saturating_sub(40)
|
||||
} else {
|
||||
bg(x)
|
||||
}
|
||||
});
|
||||
|
||||
let base = BinaryFrontend {
|
||||
threshold: Threshold::Fixed(128),
|
||||
diagonal: false,
|
||||
min_area: 4,
|
||||
};
|
||||
|
||||
// A global cutoff can't isolate both marks: 128 catches the dark-side mark
|
||||
// but floods the whole dark half of the ramp, and misses the bright-side
|
||||
// mark (~136) entirely — so fixed has no region on the bright half.
|
||||
let fixed_seg = base.segment(&img).unwrap();
|
||||
let fixed_area: usize = fixed_seg.layers.iter().map(|l| l.mask.area()).sum();
|
||||
let mid = (w as i32) / 2;
|
||||
let fixed_right = fixed_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
|
||||
|
||||
// Adaptive: window comfortably larger than the 6px marks so they fill.
|
||||
let adaptive = BinaryFrontend {
|
||||
threshold: Threshold::Adaptive {
|
||||
window: 21,
|
||||
t: 15.0,
|
||||
},
|
||||
..base.clone()
|
||||
};
|
||||
let adaptive_seg = adaptive.segment(&img).unwrap();
|
||||
let adaptive_area: usize = adaptive_seg.layers.iter().map(|l| l.mask.area()).sum();
|
||||
let adaptive_left = adaptive_seg.layers.iter().any(|l| l.mask.offset.x < mid);
|
||||
let adaptive_right = adaptive_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
|
||||
|
||||
// The point of adaptive: it finds locally-dark marks on *both* sides of the
|
||||
// ramp, where the global threshold catches only the dark half.
|
||||
assert!(!fixed_right, "fixed(128) should miss the bright-side mark");
|
||||
assert!(
|
||||
adaptive_left && adaptive_right,
|
||||
"adaptive should detect marks on both the dark and bright sides"
|
||||
);
|
||||
assert!(adaptive_area > 0, "adaptive must select some foreground");
|
||||
assert!(
|
||||
adaptive_area * 3 < fixed_area,
|
||||
"adaptive should select far less than fixed's flooded half: \
|
||||
adaptive={adaptive_area}, fixed={fixed_area}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Rasterize-and-diff equivalence between stacked and mosaic (cutout) modes.
|
||||
//!
|
||||
//! Both modes render the *same* flattened partition of the image — stacked by
|
||||
//! painting layers top-down, mosaic as a gapless tessellation. So their
|
||||
//! rasterizations must agree in every region interior; they may differ only
|
||||
//! within a thin band along region boundaries, where the two fitting paths
|
||||
//! legitimately place the edge a fraction of a pixel apart. This test asserts
|
||||
//! exactly that: any pixel that differs must lie within ~1–2px of a boundary.
|
||||
//!
|
||||
//! `resvg` is a dev-dependency, so this never enters a wasm build.
|
||||
|
||||
use resvg::{tiny_skia, usvg};
|
||||
use vtracer::{ColorImage, Config, FitMode, Hierarchical};
|
||||
|
||||
/// A few smooth colored discs on a background — curved boundaries, limited
|
||||
/// boundary length, no thin (1px) features.
|
||||
fn blobs(w: usize, h: usize) -> ColorImage {
|
||||
let discs = [
|
||||
(28.0f64, 30.0, 18.0, (210u8, 60, 60)),
|
||||
(64.0, 40.0, 20.0, (60, 160, 90)),
|
||||
(44.0, 68.0, 16.0, (70, 90, 200)),
|
||||
];
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut col = (235u8, 230, 225); // background
|
||||
for &(cx, cy, r, c) in &discs {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
if dx * dx + dy * dy <= r * r {
|
||||
col = c;
|
||||
}
|
||||
}
|
||||
pixels.extend_from_slice(&[col.0, col.1, col.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn rasterize(svg: &str, w: u32, h: u32) -> Vec<u8> {
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
|
||||
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
|
||||
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
pixmap.data().to_vec()
|
||||
}
|
||||
|
||||
/// Max per-channel difference between two RGBA pixels at index `i`.
|
||||
fn pixel_diff(a: &[u8], b: &[u8], i: usize) -> u8 {
|
||||
(0..4)
|
||||
.map(|c| a[i + c].abs_diff(b[i + c]))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mark pixels within Chebyshev radius `r` of a color edge in either image.
|
||||
fn boundary_band(a: &[u8], b: &[u8], w: usize, h: usize, r: i32) -> Vec<bool> {
|
||||
const EDGE: u8 = 24;
|
||||
let idx = |x: usize, y: usize| (y * w + x) * 4;
|
||||
let mut edge = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = idx(x, y);
|
||||
// An edge is where either rendering changes color vs its right/down
|
||||
// neighbor.
|
||||
let mut is_edge = false;
|
||||
for img in [a, b] {
|
||||
if x + 1 < w && neighbor_diff(img, i, idx(x + 1, y)) > EDGE {
|
||||
is_edge = true;
|
||||
}
|
||||
if y + 1 < h && neighbor_diff(img, i, idx(x, y + 1)) > EDGE {
|
||||
is_edge = true;
|
||||
}
|
||||
}
|
||||
if is_edge {
|
||||
edge[y * w + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dilate the edge set by r.
|
||||
let mut band = vec![false; w * h];
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
let mut near = false;
|
||||
'outer: for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && edge[ny as usize * w + nx as usize] {
|
||||
near = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
band[y as usize * w + x as usize] = near;
|
||||
}
|
||||
}
|
||||
band
|
||||
}
|
||||
|
||||
fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
|
||||
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let img = blobs(w, h);
|
||||
|
||||
let stacked = Config {
|
||||
mode,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_svg(&img)
|
||||
.unwrap();
|
||||
|
||||
let cutout = Config {
|
||||
mode,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_svg(&img)
|
||||
.unwrap();
|
||||
|
||||
let a = rasterize(&stacked, w as u32, h as u32);
|
||||
let b = rasterize(&cutout, w as u32, h as u32);
|
||||
assert_eq!(a.len(), b.len());
|
||||
|
||||
let band = boundary_band(&a, &b, w, h, 2);
|
||||
|
||||
const DIFF: u8 = 40;
|
||||
let mut interior_mismatches = 0;
|
||||
for p in 0..(w * h) {
|
||||
let i = p * 4;
|
||||
if pixel_diff(&a, &b, i) > DIFF && !band[p] {
|
||||
interior_mismatches += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Every real difference must live in the boundary band; interiors match.
|
||||
assert_eq!(
|
||||
interior_mismatches, 0,
|
||||
"{mode:?}: {interior_mismatches} interior pixels differ between stacked and cutout \
|
||||
(differences must be confined to the boundary band)"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_equivalent(mode: FitMode) {
|
||||
assert_equivalent_with(mode, vtracer::Clustering::ColorCluster);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_and_cutout_agree_in_interiors_spline() {
|
||||
assert_equivalent(FitMode::Spline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_and_cutout_agree_in_interiors_polygon() {
|
||||
assert_equivalent(FitMode::Polygon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_and_cutout_agree_in_interiors_pixel() {
|
||||
assert_equivalent(FitMode::Pixel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watershed_stacked_and_cutout_agree_in_interiors() {
|
||||
for mode in [FitMode::Pixel, FitMode::Spline] {
|
||||
assert_equivalent_with(mode, vtracer::Clustering::Watershed);
|
||||
}
|
||||
}
|
||||
|
||||
// --- seam / show-through test -------------------------------------------------
|
||||
|
||||
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
|
||||
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
|
||||
pixmap.fill(tiny_skia::Color::from_rgba8(bg[0], bg[1], bg[2], 255));
|
||||
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
pixmap.data().to_vec()
|
||||
}
|
||||
|
||||
/// A full-canvas-coverage image rendered in stacked mode must be fully opaque:
|
||||
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
|
||||
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
|
||||
/// exactly the hole-punching bug this guards against.
|
||||
fn assert_no_seams(clustering: vtracer::Clustering) {
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let img = blobs(w, h); // background fills the whole canvas
|
||||
let svg = Config {
|
||||
mode: FitMode::Spline,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_svg(&img)
|
||||
.unwrap();
|
||||
|
||||
let white = rasterize_on(&svg, w as u32, h as u32, [255, 255, 255, 255]);
|
||||
let black = rasterize_on(&svg, w as u32, h as u32, [0, 0, 0, 255]);
|
||||
|
||||
// Count backdrop-dependent pixels, ignoring the 1px canvas border (the only
|
||||
// legitimate outer-silhouette antialiasing for a full-coverage image).
|
||||
let mut show_through = 0;
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let i = (y * w + x) * 4;
|
||||
if (0..3).any(|c| white[i + c].abs_diff(black[i + c]) > 8) {
|
||||
show_through += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
show_through, 0,
|
||||
"{clustering:?} stacked leaked {show_through} backdrop pixels — seams/holes in overdraw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_has_no_seams() {
|
||||
assert_no_seams(vtracer::Clustering::ColorCluster);
|
||||
}
|
||||
|
||||
/// The watershed frontend emits disjoint region masks; its full-canvas solid
|
||||
/// background layer is what restores overdraw. This guards that construction.
|
||||
#[test]
|
||||
fn watershed_stacked_has_no_seams() {
|
||||
assert_no_seams(vtracer::Clustering::Watershed);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Golden-snapshot tests over synthetic images, exercising every stage —
|
||||
//! hierarchical clustering, all three fitters, color fitting, the optimizer
|
||||
//! passes, and the writer.
|
||||
//!
|
||||
//! Goldens are compared by **rendering** both the stored SVG and the freshly
|
||||
//! produced SVG and diffing pixels, not by byte-equality. The spline fitter's
|
||||
//! cubic fit is floating-point, and f64 results differ by a few ULPs across
|
||||
//! architectures (arm64 vs x86_64); after rounding, a coordinate can flip and
|
||||
//! change the SVG bytes without any real geometry change. A visual diff is
|
||||
//! encoding-agnostic and tolerant of that sub-pixel noise while still catching
|
||||
//! genuine regressions.
|
||||
//!
|
||||
//! Regenerate goldens after an intentional behavior change with:
|
||||
//!
|
||||
//! ```sh
|
||||
//! VTRACER_BLESS=1 cargo test -p vtracer --test golden
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use resvg::{tiny_skia, usvg};
|
||||
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
|
||||
|
||||
// --- synthetic image builders ------------------------------------------------
|
||||
|
||||
fn mk<F: Fn(usize, usize) -> (u8, u8, u8, u8)>(w: usize, h: usize, f: F) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (r, g, b, a) = f(x, y);
|
||||
pixels.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Four vertical color bands.
|
||||
fn bands() -> ColorImage {
|
||||
let cols = [
|
||||
(220, 40, 40),
|
||||
(40, 200, 60),
|
||||
(50, 60, 220),
|
||||
(230, 210, 40),
|
||||
];
|
||||
mk(48, 40, |x, _| {
|
||||
let (r, g, b) = cols[(x * cols.len()) / 48];
|
||||
(r, g, b, 255)
|
||||
})
|
||||
}
|
||||
|
||||
/// Checkerboard of 8x8 cells — exercises region adjacency and holes.
|
||||
fn checker() -> ColorImage {
|
||||
mk(48, 48, |x, y| {
|
||||
if ((x / 8) + (y / 8)) % 2 == 0 {
|
||||
(20, 20, 20, 255)
|
||||
} else {
|
||||
(235, 235, 235, 255)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A filled disc on a contrasting background — exercises curve fitting.
|
||||
fn disc() -> ColorImage {
|
||||
let (cx, cy, r2) = (24.0f64, 24.0f64, 16.0f64 * 16.0);
|
||||
mk(48, 48, |x, y| {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
if dx * dx + dy * dy <= r2 {
|
||||
(200, 60, 60, 255)
|
||||
} else {
|
||||
(240, 240, 240, 255)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// An annulus (disc with a hole) — exercises hole tracing.
|
||||
fn ring() -> ColorImage {
|
||||
let (cx, cy) = (24.0f64, 24.0f64);
|
||||
mk(48, 48, |x, y| {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 <= 20.0 * 20.0 && d2 >= 9.0 * 9.0 {
|
||||
(40, 90, 200, 255)
|
||||
} else {
|
||||
(245, 245, 245, 255)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A 4x4 grid of 16 distinct saturated colors — produces many hierarchical
|
||||
/// layers, and gives auto-quantize something real to reduce.
|
||||
fn swatches() -> ColorImage {
|
||||
let step = [0u8, 85, 170, 255];
|
||||
mk(48, 48, |x, y| {
|
||||
let col = (x / 12).min(3);
|
||||
let row = (y / 12).min(3);
|
||||
(step[col], step[row], 128, 255)
|
||||
})
|
||||
}
|
||||
|
||||
// --- fixture matrix ----------------------------------------------------------
|
||||
|
||||
fn base() -> Config {
|
||||
Config::default()
|
||||
}
|
||||
|
||||
fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
||||
vec![
|
||||
// Fit modes on the same content.
|
||||
("bands_spline", bands(), base()),
|
||||
(
|
||||
"bands_polygon",
|
||||
bands(),
|
||||
Config {
|
||||
mode: FitMode::Polygon,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"bands_pixel",
|
||||
bands(),
|
||||
Config {
|
||||
mode: FitMode::Pixel,
|
||||
optimize: 0,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Curves and holes.
|
||||
("disc_spline", disc(), base()),
|
||||
("ring_spline", ring(), base()),
|
||||
("checker_spline", checker(), base()),
|
||||
// Hierarchical layering.
|
||||
("swatches_color", swatches(), base()),
|
||||
// Binary mode.
|
||||
(
|
||||
"checker_bw",
|
||||
checker(),
|
||||
Config {
|
||||
clustering: Clustering::Binary,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Color fitting: fixed palette (+ merge) and auto-quantize (+ merge).
|
||||
(
|
||||
"bands_palette",
|
||||
bands(),
|
||||
Config {
|
||||
palette: vec![Color::new(0, 0, 0), Color::new(255, 255, 255)],
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"swatches_quant4",
|
||||
swatches(),
|
||||
Config {
|
||||
max_colors: Some(4),
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Optimizer / writer encoding levels on identical geometry.
|
||||
(
|
||||
"disc_opt0",
|
||||
disc(),
|
||||
Config {
|
||||
optimize: 0,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"disc_opt2",
|
||||
disc(),
|
||||
Config {
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Mosaic (seam-free tessellation): exact pixel and polygon fitters.
|
||||
(
|
||||
"disc_mosaic_pixel",
|
||||
disc(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Pixel,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"checker_mosaic_polygon",
|
||||
checker(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Polygon,
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"disc_mosaic_spline",
|
||||
disc(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Spline,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Watershed clustering: stacked and mosaic.
|
||||
(
|
||||
"disc_watershed_spline",
|
||||
disc(),
|
||||
Config {
|
||||
clustering: Clustering::Watershed,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"swatches_watershed_mosaic",
|
||||
swatches(),
|
||||
Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Polygon,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn goldens_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("goldens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_snapshots() {
|
||||
let bless = std::env::var_os("VTRACER_BLESS").is_some();
|
||||
let dir = goldens_dir();
|
||||
if bless {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
let mut mismatches = Vec::new();
|
||||
for (name, img, config) in cases() {
|
||||
let svg = config
|
||||
.build()
|
||||
.unwrap_or_else(|e| panic!("case {name}: build failed: {e}"))
|
||||
.to_svg(&img)
|
||||
.unwrap_or_else(|e| panic!("case {name}: convert failed: {e}"));
|
||||
|
||||
let path = dir.join(format!("{name}.svg"));
|
||||
if bless {
|
||||
std::fs::write(&path, &svg).unwrap();
|
||||
continue;
|
||||
}
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(expected) => {
|
||||
if let Some(diff) = render_diff(&expected, &svg) {
|
||||
mismatches.push(format!("{name}: {diff}"));
|
||||
}
|
||||
}
|
||||
Err(_) => mismatches.push(format!(
|
||||
"{name}: missing golden ({}); run with VTRACER_BLESS=1",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"golden mismatches:\n{}",
|
||||
mismatches.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Render an SVG string to an RGBA pixmap at its intrinsic size.
|
||||
fn render(svg: &str) -> (u32, u32, Vec<u8>) {
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse golden svg");
|
||||
let size = tree.size();
|
||||
let (w, h) = (size.width().ceil() as u32, size.height().ceil() as u32);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(w.max(1), h.max(1)).expect("alloc pixmap");
|
||||
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
(w, h, pixmap.data().to_vec())
|
||||
}
|
||||
|
||||
/// Compare two SVGs by rendering. Returns `Some(reason)` if they differ beyond
|
||||
/// a small tolerance (which absorbs cross-architecture sub-pixel float noise),
|
||||
/// or `None` if visually equivalent.
|
||||
fn render_diff(expected: &str, actual: &str) -> Option<String> {
|
||||
let (ew, eh, a) = render(expected);
|
||||
let (aw, ah, b) = render(actual);
|
||||
if (ew, eh) != (aw, ah) {
|
||||
return Some(format!("size {ew}x{eh} vs {aw}x{ah}"));
|
||||
}
|
||||
// A pixel "differs" only on a clear color change, not antialiasing wobble.
|
||||
const CHANNEL: u8 = 40;
|
||||
let total = (ew * eh) as usize;
|
||||
let differing = (0..total)
|
||||
.filter(|&p| (0..3).any(|c| a[p * 4 + c].abs_diff(b[p * 4 + c]) > CHANNEL))
|
||||
.count();
|
||||
// Allow a tiny fraction for boundary pixels that flip under sub-pixel shifts.
|
||||
let allowed = (total / 200).max(8); // 0.5%, min 8px
|
||||
if differing > allowed {
|
||||
Some(format!(
|
||||
"{differing}/{total} pixels differ (> {allowed} allowed) — real change, re-bless if intended"
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
|
||||
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#000000"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0L48,0L48,40L0,40Z" fill="#28C83C"/>
|
||||
<path d="M36,0L48,0L48,40L36,40Z" fill="#E6D228"/>
|
||||
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
|
||||
<path d="M0,0L12,0L12,40L0,40Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0L48,0l0,40L0,40Z" fill="#28C83C"/>
|
||||
<path d="M36,0L48,0l0,40L36,40Z" fill="#E6D228"/>
|
||||
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
|
||||
<path d="M0,0L12,0l0,40L0,40Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#28C83C"/>
|
||||
<path d="M36,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#E6D228"/>
|
||||
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#323CDC"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#000000"/>
|
||||
<path d="M16,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M0,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M16,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M0,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M16,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M8,8V0H0V8H8Z" fill="#141414"/>
|
||||
<path d="M16,0H8V8h8V0Z" fill="#EBEBEB"/>
|
||||
<path d="M24,0H16V8h8V0Z" fill="#141414"/>
|
||||
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
|
||||
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M48,8V0H40V8h8Z"/>
|
||||
<path d="M8,8H0v8H8V8Z"/>
|
||||
</g>
|
||||
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
|
||||
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
|
||||
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
|
||||
<g fill="#141414">
|
||||
<path d="M48,8H40v8h8V8Z"/>
|
||||
<path d="M8,16H0v8H8V16Z"/>
|
||||
</g>
|
||||
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
|
||||
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
|
||||
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
|
||||
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M48,16H40v8h8V16Z"/>
|
||||
<path d="M8,24H0v8H8V24Z"/>
|
||||
</g>
|
||||
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
|
||||
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
|
||||
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
|
||||
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
|
||||
<g fill="#141414">
|
||||
<path d="M48,24H40v8h8V24Z"/>
|
||||
<path d="M8,32H0v8H8V32Z"/>
|
||||
</g>
|
||||
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
|
||||
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
|
||||
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
|
||||
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M48,32H40v8h8V32Z"/>
|
||||
<path d="M8,40H0v8H8V40Z"/>
|
||||
</g>
|
||||
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
|
||||
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
|
||||
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
|
||||
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
|
||||
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#EBEBEB"/>
|
||||
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M32,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M16,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M8,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M40,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M24,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M16,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M8,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M0,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M40,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M32,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M24,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M16,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M8,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M40,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M24,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M16,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M8,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M0,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M40,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M32,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M24,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M16,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M8,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,8C2.64,8,5.28,8,8,8c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M40,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M24,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M16,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#141414"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0L0,48l48,0L48,0L0,0ZM24,8l1,0l0,1l5,0l0,1l2,0l0,1l2,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,2l1,0l0,2l1,0l0,5l1,0l0,1l-1,0l0,5l-1,0l0,2l-1,0l0,2l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-2,0l0,1l-2,0l0,1l-5,0l0,1l-1,0l0-1l-5,0l0-1l-2,0l0-1l-2,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-2l-1,0l0-2L9,30l0-5L8,25l0-1l1,0l0-5l1,0l0-2l1,0l0-2l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l2,0l0-1l2,0l0-1l5,0l0-1Z" fill="#F0F0F0"/>
|
||||
<path d="M24,8l0,1L19,9l0,1l-2,0l0,1l-2,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,2l-1,0l0,2L9,19l0,5L8,24l0,1l1,0l0,5l1,0l0,2l1,0l0,2l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l2,0l0,1l2,0l0,1l5,0l0,1l1,0l0-1l5,0l0-1l2,0l0-1l2,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-2l1,0l0-2l1,0l0-5l1,0l0-1l-1,0l0-5l-1,0l0-2l-1,0l0-2l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-2,0l0-1l-2,0l0-1L25,9l0-1L24,8Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 985 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C0,16,0,32,0,48c16,0,32,0,48,0c0-16,0-32,0-48C32,0,16,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
|
||||
<path d="M31.56,9.75C23.31,7.33,14,8.99,9.75,17.5c-.26,.85-.5,1.68-.75,2.5c-1.33,4-1.33,4,.81,10.44c1.84,3.95,3.73,6.83,7.69,8.81c.85,.26,1.68,.5,2.5,.75c4,1.33,4,1.33,10.44-.81c3.95-1.84,6.83-3.73,8.81-7.69c.26-.85,.5-1.67,.75-2.5c.75-2.24,1.06-3.6,.69-5.94c-1.85-5.53-3.65-10.5-9.13-13.31Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0C48,16,48,32,48,48C32,48,16,48,0,48C0,32,0,16,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06C39.1,16.2,41.16,20.44,40.91,26.15C39.91,31.12,37.77,34.55,34,38C29.69,40.33,25.67,41.47,20.81,40.5C16.02,38.91,12.35,36.5,9.89,31.95C8.06,27.32,7.57,23.62,9.15,18.85C11.43,13.88,14.28,10.99,19.31,9C25.64,7.23,29.86,8.66,35.31,12.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#E2B1B1"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM12.06,13.69c-2.83,4.54-4.51,8.24-3.72,13.65C9.88,32.82,12.06,35.92,17,39c4.43,2.03,7.98,2.31,12.69,1c5.03-1.99,7.88-4.88,10.16-9.85c1.58-4.77,1.09-8.47-.74-13.1c-2.46-4.55-6.13-6.96-10.92-8.55c-6.36-1.27-11.46,.92-16.13,5.19Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 839 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#285AC8"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
|
||||
<path d="M29,16c2.56,1.44,2.56,1.44,4,4c.75,4.29,.71,7.73-1.44,11.56C27.73,33.71,24.29,33.75,20,33c-2.56-1.44-2.56-1.44-4-4c-.75-4.29-.71-7.73,1.44-11.56C21.27,15.29,24.71,15.25,29,16Z" fill="#F5F5F5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 781 B |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#55FF80"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#555580"/>
|
||||
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FFAA80"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#55AA80"/>
|
||||
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF0080"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#550080"/>
|
||||
<path d="M24,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAFF80"/>
|
||||
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00FF80"/>
|
||||
<path d="M24,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAAA80"/>
|
||||
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00AA80"/>
|
||||
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA5580"/>
|
||||
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#005580"/>
|
||||
<path d="M24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA0080"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0Z" fill="#000080"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#FFFF80"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#AA2A80"/>
|
||||
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#4B9280"/>
|
||||
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#AA2A80"/>
|
||||
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#FFFF80"/>
|
||||
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
|
||||
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
|
||||
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0ZM24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M12,12L12,0L0,0L0,12l12,0Z" fill="#000080"/>
|
||||
<path d="M24,0L12,0l0,12l12,0L24,0Z" fill="#550080"/>
|
||||
<path d="M36,0L24,0l0,12l12,0L36,0Z" fill="#AA0080"/>
|
||||
<path d="M48,12L48,0L36,0l0,12l12,0Z" fill="#FF0080"/>
|
||||
<path d="M12,12L0,12L0,24l12,0l0-12Z" fill="#005580"/>
|
||||
<path d="M24,12L12,12l0,12l12,0l0-12Z" fill="#555580"/>
|
||||
<path d="M36,12L24,12l0,12l12,0l0-12Z" fill="#AA5580"/>
|
||||
<path d="M48,12L36,12l0,12l12,0l0-12Z" fill="#FF5580"/>
|
||||
<path d="M12,24L0,24L0,36l12,0l0-12Z" fill="#00AA80"/>
|
||||
<path d="M24,24L12,24l0,12l12,0l0-12Z" fill="#55AA80"/>
|
||||
<path d="M36,24L24,24l0,12l12,0l0-12Z" fill="#AAAA80"/>
|
||||
<path d="M48,24L36,24l0,12l12,0l0-12Z" fill="#FFAA80"/>
|
||||
<path d="M12,36L0,36L0,48l12,0l0-12Z" fill="#00FF80"/>
|
||||
<path d="M24,36L12,36l0,12l12,0l0-12Z" fill="#55FF80"/>
|
||||
<path d="M36,36L24,36l0,12l12,0l0-12Z" fill="#AAFF80"/>
|
||||
<path d="M48,36L36,36l0,12l12,0l0-12Z" fill="#FFFF80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,103 @@
|
||||
//! End-to-end pipeline smoke tests over synthetic images.
|
||||
|
||||
use vtracer::{ColorImage, Clustering, Config, FitMode, Hierarchical};
|
||||
|
||||
/// Build a `size × size` image split into two vertical color bands.
|
||||
fn two_band_image(size: usize) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(size * size * 4);
|
||||
for _y in 0..size {
|
||||
for x in 0..size {
|
||||
let (r, g, b) = if x < size / 2 {
|
||||
(220, 40, 40)
|
||||
} else {
|
||||
(40, 40, 220)
|
||||
};
|
||||
pixels.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_valid_svg(svg: &str) {
|
||||
assert!(svg.contains("<svg"), "missing <svg> element:\n{svg}");
|
||||
assert!(svg.trim_end().ends_with("</svg>"), "missing </svg> close");
|
||||
assert!(svg.contains("<path"), "expected at least one path:\n{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_color_pipeline_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_fit_modes_produce_svg() {
|
||||
let img = two_band_image(32);
|
||||
for mode in [FitMode::Pixel, FitMode::Polygon, FitMode::Spline] {
|
||||
let config = Config {
|
||||
mode,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_pipeline_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
let config = Config {
|
||||
clustering: Clustering::Binary,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watershed_pipeline_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
for hierarchical in [Hierarchical::Stacked, Hierarchical::Cutout] {
|
||||
let config = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimize_levels_shrink_or_match() {
|
||||
let img = two_band_image(48);
|
||||
let mut sizes = Vec::new();
|
||||
for level in [0u8, 1, 2] {
|
||||
let config = Config {
|
||||
optimize: level,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
sizes.push(svg.len());
|
||||
}
|
||||
// Higher optimization should never produce larger output than level 0.
|
||||
assert!(sizes[1] <= sizes[0], "opt1 {} > opt0 {}", sizes[1], sizes[0]);
|
||||
assert!(sizes[2] <= sizes[0], "opt2 {} > opt0 {}", sizes[2], sizes[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mosaic_cutout_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
let config = Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Progress reporting and cancellation for `Pipeline::run_with_progress`.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use vtracer::progress::{CancelToken, Phase, Progress};
|
||||
use vtracer::{ColorImage, Config, Error};
|
||||
|
||||
/// A checkerboard of two colors — enough clusters that segmentation runs a few
|
||||
/// batches, so incremental progress and mid-run cancellation are observable.
|
||||
fn checker(w: usize, h: usize) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = if (x / 6 + y / 6) % 2 == 0 {
|
||||
(210u8, 60, 60)
|
||||
} else {
|
||||
(60, 90, 200)
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// A token cancelled before the run starts trips promptly and yields no doc.
|
||||
#[test]
|
||||
fn precancelled_returns_cancelled() {
|
||||
let img = checker(64, 64);
|
||||
let pipeline = Config::default().build().unwrap();
|
||||
|
||||
let cancel = CancelToken::new();
|
||||
cancel.cancel();
|
||||
|
||||
let mut cb = |_p: Progress| {};
|
||||
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
|
||||
assert_eq!(result.err(), Some(Error::Cancelled));
|
||||
}
|
||||
|
||||
/// Cancelling from within the progress callback (on the first Segment report)
|
||||
/// trips at the next batch boundary and returns `Cancelled`.
|
||||
#[test]
|
||||
fn cancel_during_progress_trips() {
|
||||
let img = checker(96, 96);
|
||||
let pipeline = Config::default().build().unwrap();
|
||||
|
||||
let cancel = CancelToken::new();
|
||||
let saw_segment = Cell::new(false);
|
||||
|
||||
let mut cb = |p: Progress| {
|
||||
if p.phase == Phase::Segment {
|
||||
saw_segment.set(true);
|
||||
cancel.cancel();
|
||||
}
|
||||
};
|
||||
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
|
||||
|
||||
assert!(saw_segment.get(), "expected at least one Segment report");
|
||||
assert_eq!(result.err(), Some(Error::Cancelled));
|
||||
}
|
||||
|
||||
/// A successful run reports monotonically within each phase, ends at
|
||||
/// Optimize=1.0, and produces the same shapes as the plain `run`.
|
||||
#[test]
|
||||
fn progress_completes_and_matches_run() {
|
||||
let img = checker(64, 64);
|
||||
let pipeline = Config::default().build().unwrap();
|
||||
|
||||
let cancel = CancelToken::new();
|
||||
let last = Cell::new(None::<Progress>);
|
||||
let count = Cell::new(0usize);
|
||||
|
||||
let mut cb = |p: Progress| {
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&p.fraction),
|
||||
"fraction out of range: {}",
|
||||
p.fraction
|
||||
);
|
||||
last.set(Some(p));
|
||||
count.set(count.get() + 1);
|
||||
};
|
||||
let doc = pipeline
|
||||
.run_with_progress(&img, &cancel, &mut cb)
|
||||
.expect("run should succeed");
|
||||
|
||||
assert!(count.get() > 0, "expected progress reports");
|
||||
let final_p = last.get().expect("a final report");
|
||||
assert_eq!(final_p.phase, Phase::Optimize);
|
||||
assert_eq!(final_p.fraction, 1.0);
|
||||
|
||||
// Incremental clustering yields the same clusters as the blocking path,
|
||||
// so both entry points produce identical output.
|
||||
let plain = pipeline.run(&img).expect("plain run should succeed");
|
||||
assert_eq!(doc.shapes.len(), plain.shapes.len());
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Two-phase pipeline: cache the expensive segmentation, re-run the cheap
|
||||
//! downstream stages with different parameters (the interactive tuning loop).
|
||||
|
||||
use vtracer::{ColorImage, Config, FitMode};
|
||||
|
||||
/// A few colored blocks — several clusters, a few holes.
|
||||
fn blocks() -> ColorImage {
|
||||
let (w, h) = (48usize, 48usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = match (x / 16, y / 16) {
|
||||
(0, _) => (220u8, 40, 40),
|
||||
(1, 0) => (40, 200, 60),
|
||||
(1, _) => (50, 60, 220),
|
||||
_ => (230, 210, 40),
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg(mode: FitMode) -> Config {
|
||||
Config {
|
||||
mode,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `finish(segment(img))` equals the one-shot `run(img)`.
|
||||
#[test]
|
||||
fn two_phase_matches_one_shot() {
|
||||
let img = blocks();
|
||||
let pipeline = cfg(FitMode::Spline).build().unwrap();
|
||||
|
||||
let one_shot = pipeline.run(&img).unwrap();
|
||||
let seg = pipeline.segment(&img).unwrap();
|
||||
let two_phase = pipeline.finish(&seg).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
pipeline.writer.write(&one_shot),
|
||||
pipeline.writer.write(&two_phase),
|
||||
"splitting segment/finish must not change the output"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cached segmentation stays pristine — `finish` can be called repeatedly and
|
||||
/// deterministically (color fitting mutates only an internal clone).
|
||||
#[test]
|
||||
fn cached_segmentation_is_reusable() {
|
||||
let img = blocks();
|
||||
let pipeline = cfg(FitMode::Polygon).build().unwrap();
|
||||
|
||||
let seg = pipeline.segment(&img).unwrap();
|
||||
let first = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
|
||||
let second = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
|
||||
|
||||
assert_eq!(first, second, "reusing a cached segmentation must be stable");
|
||||
}
|
||||
|
||||
/// The tuning workflow: segment once, then feed that segmentation to pipelines
|
||||
/// with different curve-fitting parameters. Same regions, different geometry —
|
||||
/// and no re-segmentation. (Speckle, color precision, and layer difference are
|
||||
/// clustering parameters, so changing them requires a fresh `segment`.)
|
||||
#[test]
|
||||
fn tune_curve_fitting_on_cached_segmentation() {
|
||||
let img = blocks();
|
||||
|
||||
// Same clustering parameters (defaults), different fit modes → the
|
||||
// segmentation from one is valid input to the other's `finish`.
|
||||
let pixel = cfg(FitMode::Pixel).build().unwrap();
|
||||
let spline = cfg(FitMode::Spline).build().unwrap();
|
||||
|
||||
let seg = pixel.segment(&img).unwrap();
|
||||
|
||||
let doc_pixel = pixel.finish(&seg).unwrap();
|
||||
let doc_spline = spline.finish(&seg).unwrap();
|
||||
|
||||
// Same partition → same number of shapes.
|
||||
assert_eq!(doc_pixel.shapes.len(), doc_spline.shapes.len());
|
||||
assert!(!doc_pixel.shapes.is_empty());
|
||||
|
||||
// But the fitted geometry differs (straight edges vs cubic curves).
|
||||
assert_ne!(
|
||||
pixel.writer.write(&doc_pixel),
|
||||
spline.writer.write(&doc_spline),
|
||||
"pixel and spline fitting should produce different paths"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! `Session` caches the segmentation and re-segments only when a clustering
|
||||
//! parameter changes — verified both at the key level and end-to-end.
|
||||
|
||||
use visioncortex::Color;
|
||||
use vtracer::{
|
||||
CancelToken, Clustering, ColorImage, Config, FitMode, Hierarchical, Session,
|
||||
};
|
||||
|
||||
/// A few colored blocks — several clusters.
|
||||
fn blocks() -> ColorImage {
|
||||
let (w, h) = (48usize, 48usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = match (x / 16, y / 16) {
|
||||
(0, _) => (220u8, 40, 40),
|
||||
(1, 0) => (40, 200, 60),
|
||||
(1, _) => (50, 60, 220),
|
||||
_ => (230, 210, 40),
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// The key partition: finish-phase params share a segment key; clustering
|
||||
/// params change it. This is the contract `Session` relies on.
|
||||
#[test]
|
||||
fn segment_key_tracks_only_clustering_params() {
|
||||
let base = Config::default();
|
||||
|
||||
// Finish-phase changes → same key (segmentation is reusable).
|
||||
for tweaked in [
|
||||
Config {
|
||||
corner_threshold: 90,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
optimize: 0,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
hierarchical: vtracer::Hierarchical::Cutout,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
max_colors: Some(4),
|
||||
..base.clone()
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
base.segment_key(),
|
||||
tweaked.segment_key(),
|
||||
"finish-phase param must not change the segment key"
|
||||
);
|
||||
}
|
||||
|
||||
// Clustering changes → different key (must re-segment).
|
||||
for tweaked in [
|
||||
Config {
|
||||
filter_speckle: base.filter_speckle + 4,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
color_precision: 4,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
layer_difference: 32,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
clustering: vtracer::Clustering::Binary,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
clustering: vtracer::Clustering::Watershed,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
watershed_detail: 200,
|
||||
..base.clone()
|
||||
},
|
||||
] {
|
||||
assert_ne!(
|
||||
base.segment_key(),
|
||||
tweaked.segment_key(),
|
||||
"clustering param must change the segment key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Session` render equals the one-shot pipeline — for a finish-only change
|
||||
/// (reuses the cache) and for a clustering change (re-segments). Correctness is
|
||||
/// identical either way; the cache is a transparent optimization.
|
||||
#[test]
|
||||
fn session_matches_one_shot() {
|
||||
let img = blocks();
|
||||
let mut session = Session::new(img.clone());
|
||||
|
||||
let base = Config::default();
|
||||
let svg0 = session.render_svg(&base).unwrap();
|
||||
assert_eq!(
|
||||
svg0,
|
||||
base.build().unwrap().to_svg(&img).unwrap(),
|
||||
"first render must match the one-shot pipeline"
|
||||
);
|
||||
|
||||
// Finish-only change: reuses the cached segmentation.
|
||||
let tuned = Config {
|
||||
corner_threshold: 90,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
session.render_svg(&tuned).unwrap(),
|
||||
tuned.build().unwrap().to_svg(&img).unwrap(),
|
||||
"reused-segmentation render must match the one-shot pipeline"
|
||||
);
|
||||
|
||||
// Clustering change: re-segments, still matches the one-shot.
|
||||
let respeckled = Config {
|
||||
filter_speckle: base.filter_speckle + 4,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
session.render_svg(&respeckled).unwrap(),
|
||||
respeckled.build().unwrap().to_svg(&img).unwrap(),
|
||||
"re-segmented render must match the one-shot pipeline"
|
||||
);
|
||||
}
|
||||
|
||||
/// Blocks plus a gradient band and a small fleck — structure that makes every
|
||||
/// clustering parameter (speckle, precision, gradient step, watershed detail,
|
||||
/// thresholds) actually change the output.
|
||||
fn textured() -> ColorImage {
|
||||
let (w, h) = (48usize, 48usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = if y >= 32 {
|
||||
let g = 60 + (x * 3) as u8; // gradient band
|
||||
(g, g, 200)
|
||||
} else if (4..7).contains(&x) && (4..7).contains(&y) {
|
||||
(10, 200, 10) // 9 px fleck
|
||||
} else {
|
||||
match (x / 16, y / 16) {
|
||||
(0, _) => (220u8, 40, 40),
|
||||
(1, _) => (40, 200, 60),
|
||||
_ => (230, 210, 40),
|
||||
}
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exhaustive contract: walk a cumulative sequence of config changes that
|
||||
/// touches every parameter category — finish-phase dials, clustering dials,
|
||||
/// frontend switches (including leaving watershed and coming back to its
|
||||
/// cached hierarchy), compositing, palettes — and after each step the cached
|
||||
/// session render must be byte-identical to a from-scratch one-shot pipeline.
|
||||
#[test]
|
||||
fn session_equals_one_shot_across_param_walk() {
|
||||
let img = textured();
|
||||
let mut session = Session::new(img.clone());
|
||||
let mut cfg = Config::default();
|
||||
|
||||
let steps: Vec<(&str, fn(&mut Config))> = vec![
|
||||
("initial", |_| {}),
|
||||
// Finish-phase changes (cache hits).
|
||||
("corner_threshold", |c| c.corner_threshold = 90),
|
||||
("mode polygon", |c| c.mode = FitMode::Polygon),
|
||||
("optimize 2", |c| c.optimize = 2),
|
||||
("cutout", |c| c.hierarchical = Hierarchical::Cutout),
|
||||
("path_precision", |c| c.path_precision = Some(1)),
|
||||
// Clustering changes (re-segment).
|
||||
("filter_speckle", |c| c.filter_speckle = 6),
|
||||
("layer_difference", |c| c.layer_difference = 32),
|
||||
("color_precision", |c| c.color_precision = 5),
|
||||
// Watershed, incl. cheap re-cuts of the cached hierarchy.
|
||||
("watershed", |c| c.clustering = Clustering::Watershed),
|
||||
("detail 200", |c| c.watershed_detail = 200),
|
||||
("detail 64", |c| c.watershed_detail = 64),
|
||||
("stacked", |c| c.hierarchical = Hierarchical::Stacked),
|
||||
("mode spline", |c| c.mode = FitMode::Spline),
|
||||
// Binary, with both thresholding methods.
|
||||
("binary", |c| c.clustering = Clustering::Binary),
|
||||
("threshold 100", |c| c.binary_threshold = 100),
|
||||
("adaptive", |c| c.binary_adaptive = true),
|
||||
// Back to watershed: the hierarchy cache must still be valid.
|
||||
("watershed again", |c| c.clustering = Clustering::Watershed),
|
||||
("quantize", |c| c.max_colors = Some(4)),
|
||||
// And back to the color path with a palette.
|
||||
("color-cluster", |c| {
|
||||
c.clustering = Clustering::ColorCluster;
|
||||
c.max_colors = None;
|
||||
c.palette = vec![
|
||||
Color::new(0, 0, 0),
|
||||
Color::new(255, 255, 255),
|
||||
Color::new(200, 40, 40),
|
||||
];
|
||||
}),
|
||||
("speckle again", |c| c.filter_speckle = 2),
|
||||
];
|
||||
|
||||
for (name, step) in steps {
|
||||
step(&mut cfg);
|
||||
assert_eq!(
|
||||
session.render_svg(&cfg).unwrap(),
|
||||
cfg.build().unwrap().to_svg(&img).unwrap(),
|
||||
"step `{name}`: cached session render must equal a full rebuild"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The progress-reporting render path (which segments through a different
|
||||
/// branch, including the watershed hierarchy shortcut) produces the same
|
||||
/// document as the plain path and the one-shot pipeline.
|
||||
#[test]
|
||||
fn render_with_progress_matches_plain_render() {
|
||||
let img = textured();
|
||||
for clustering in [
|
||||
Clustering::ColorCluster,
|
||||
Clustering::Watershed,
|
||||
Clustering::Binary,
|
||||
] {
|
||||
let cfg = Config {
|
||||
clustering,
|
||||
..Config::default()
|
||||
};
|
||||
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
|
||||
|
||||
// Fresh session per variant so the progress path does the segmenting.
|
||||
let mut session = Session::new(img.clone());
|
||||
let doc = session
|
||||
.render_with_progress(&cfg, &CancelToken::new(), &mut |_| {})
|
||||
.unwrap();
|
||||
let progress_svg = cfg.build().unwrap().writer.write(&doc);
|
||||
assert_eq!(
|
||||
progress_svg, one_shot,
|
||||
"{clustering:?}: progress path must equal the one-shot pipeline"
|
||||
);
|
||||
|
||||
// And the now-warm cache serves the plain path identically.
|
||||
assert_eq!(
|
||||
session.render_svg(&cfg).unwrap(),
|
||||
one_shot,
|
||||
"{clustering:?}: cache warmed by the progress path must match too"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `invalidate` drops all cached state; the next render rebuilds from scratch
|
||||
/// and still matches.
|
||||
#[test]
|
||||
fn invalidate_then_render_matches() {
|
||||
let img = textured();
|
||||
let cfg = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
..Config::default()
|
||||
};
|
||||
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
|
||||
|
||||
let mut session = Session::new(img);
|
||||
assert_eq!(session.render_svg(&cfg).unwrap(), one_shot);
|
||||
session.invalidate();
|
||||
assert_eq!(
|
||||
session.render_svg(&cfg).unwrap(),
|
||||
one_shot,
|
||||
"render after invalidate must rebuild identically"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The curve-simplification stage, end to end: `Config::simplify` must cut
|
||||
//! anchor counts in both compositing modes without changing geometry kind,
|
||||
//! and leave output untouched when off (the goldens enforce the byte-level
|
||||
//! version of that).
|
||||
|
||||
use vtracer::ir::PathCmd;
|
||||
use vtracer::{ColorImage, Config, FitMode, Hierarchical, VectorDoc};
|
||||
|
||||
/// A filled disc — one long smooth boundary, the best case for merging the
|
||||
/// per-splice cubics the spline fitter emits.
|
||||
fn disc_image(size: usize) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(size * size * 4);
|
||||
let (c, r) = (size as f64 / 2.0, size as f64 * 0.4);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let (dx, dy) = (x as f64 + 0.5 - c, y as f64 + 0.5 - c);
|
||||
let (rr, gg, bb) = if (dx * dx + dy * dy).sqrt() < r {
|
||||
(200, 60, 60)
|
||||
} else {
|
||||
(240, 240, 240)
|
||||
};
|
||||
pixels.extend_from_slice(&[rr, gg, bb, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
}
|
||||
|
||||
fn cubic_count(doc: &VectorDoc) -> usize {
|
||||
doc.shapes
|
||||
.iter()
|
||||
.flat_map(|s| &s.path.subpaths)
|
||||
.flat_map(|sub| &sub.commands)
|
||||
.filter(|c| matches!(c, PathCmd::CubicTo(..)))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn run(config: &Config) -> VectorDoc {
|
||||
config.build().unwrap().run(&disc_image(128)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_reduces_cubics_in_stacked_mode() {
|
||||
let base = Config::default();
|
||||
let simplified = Config {
|
||||
simplify: Some(2.0),
|
||||
..Config::default()
|
||||
};
|
||||
let (before, after) = (cubic_count(&run(&base)), cubic_count(&run(&simplified)));
|
||||
assert!(before > 0, "the disc must be traced with cubics");
|
||||
assert!(
|
||||
after < before,
|
||||
"simplify must reduce anchors: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_reduces_cubics_in_cutout_mode() {
|
||||
let cutout = |simplify| Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
simplify,
|
||||
..Config::default()
|
||||
};
|
||||
let (before, after) = (
|
||||
cubic_count(&run(&cutout(None))),
|
||||
cubic_count(&run(&cutout(Some(2.0)))),
|
||||
);
|
||||
assert!(before > 0, "the disc must be traced with cubics");
|
||||
assert!(
|
||||
after < before,
|
||||
"simplify must reduce anchors: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_leaves_polyline_modes_untouched() {
|
||||
for mode in [FitMode::Pixel, FitMode::Polygon] {
|
||||
let base = Config {
|
||||
mode,
|
||||
..Config::default()
|
||||
};
|
||||
let simplified = Config {
|
||||
simplify: Some(2.0),
|
||||
..base.clone()
|
||||
};
|
||||
let a = base.build().unwrap().to_svg(&disc_image(64)).unwrap();
|
||||
let b = simplified.build().unwrap().to_svg(&disc_image(64)).unwrap();
|
||||
assert_eq!(a, b, "{mode:?} output must not change");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Spline fitting stays anchored to the geometry it approximates.
|
||||
//!
|
||||
//! Regression for the sparse-slice ballooning bug: a splice slice with very
|
||||
//! uneven point spacing (a few-pixel jog then a long straight leg, produced by
|
||||
//! the walker around thin strands) used to be fitted by a single cubic that
|
||||
//! interpolated the samples exactly while swinging ~30 px sideways between
|
||||
//! them — its control points landing far outside the shape itself. The
|
||||
//! Cityscape sample at color precision 8 / gradient step 28 is the real
|
||||
//! reproduction (a 1 px, 330 px-tall strand in the maroon region).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use vtracer::ir::PathCmd;
|
||||
use vtracer::{ColorImage, Config, Hierarchical, VectorDoc};
|
||||
|
||||
fn cityscape() -> ColorImage {
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg");
|
||||
let img = image::open(&p).expect("sample image").to_rgba8();
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
ColorImage {
|
||||
pixels: img.into_raw(),
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cubic's control points must stay within its shape's on-curve bounding
|
||||
/// box plus a small overshoot allowance. The ballooning bug put handles ~25 px
|
||||
/// outside the whole shape; a healthy fit stays within the fit error (10).
|
||||
fn assert_handles_anchored(doc: &VectorDoc, margin: f64) {
|
||||
for (si, shape) in doc.shapes.iter().enumerate() {
|
||||
// Bounding box over on-curve points only.
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
let mut on_curve = |p: &visioncortex::PointF64| {
|
||||
x0 = x0.min(p.x);
|
||||
y0 = y0.min(p.y);
|
||||
x1 = x1.max(p.x);
|
||||
y1 = y1.max(p.y);
|
||||
};
|
||||
for sub in &shape.path.subpaths {
|
||||
for cmd in &sub.commands {
|
||||
match cmd {
|
||||
PathCmd::MoveTo(p) | PathCmd::LineTo(p) => on_curve(p),
|
||||
PathCmd::CubicTo(_, _, p) => on_curve(p),
|
||||
PathCmd::Close => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for sub in &shape.path.subpaths {
|
||||
for cmd in &sub.commands {
|
||||
if let PathCmd::CubicTo(c1, c2, _) = cmd {
|
||||
for q in [c1, c2] {
|
||||
assert!(
|
||||
q.x >= x0 - margin
|
||||
&& q.x <= x1 + margin
|
||||
&& q.y >= y0 - margin
|
||||
&& q.y <= y1 + margin,
|
||||
"shape {si}: control point ({},{}) strays outside \
|
||||
bbox ({x0},{y0})..({x1},{y1}) + {margin}",
|
||||
q.x,
|
||||
q.y
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_handles_stay_anchored_on_photo() {
|
||||
let img = cityscape();
|
||||
let base = Config {
|
||||
color_precision: 8,
|
||||
layer_difference: 28,
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
// Stacked: per-region closed outlines through Spline::from_path_f64.
|
||||
let doc = base.build().unwrap().run(&img).unwrap();
|
||||
assert!(doc.shapes.len() > 500, "sanity: the trace produced real output");
|
||||
assert_handles_anchored(&doc, 15.0);
|
||||
|
||||
// Cutout: open boundary segments through the mosaic's segment fitter.
|
||||
let cutout = Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..base
|
||||
};
|
||||
let doc = cutout.build().unwrap().run(&img).unwrap();
|
||||
assert_handles_anchored(&doc, 15.0);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
//! Watershed frontend: partition invariants, the detail dial, small-basin
|
||||
//! absorption, and the hierarchy stack / cached re-cut behavior.
|
||||
|
||||
use vtracer::frontend::{Frontend, WatershedFrontend, WatershedHierarchy};
|
||||
use vtracer::{Color, ColorImage, Clustering, Config, Hierarchical, Segmentation, Session};
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> (u8, u8, u8)) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (r, g, b) = f(x, y);
|
||||
pixels.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the stacked layers top-down (later layers win), returning one layer
|
||||
/// index per pixel — the partition both compositors ultimately consume.
|
||||
fn flatten(seg: &Segmentation) -> Vec<usize> {
|
||||
let (w, h) = (seg.width as usize, seg.height as usize);
|
||||
let mut labels = vec![usize::MAX; w * h];
|
||||
for (li, layer) in seg.layers.iter().enumerate() {
|
||||
let m = &layer.mask;
|
||||
for y in 0..m.image.height {
|
||||
for x in 0..m.image.width {
|
||||
if m.image.get_pixel(x, y) {
|
||||
let gx = (m.offset.x + x as i32) as usize;
|
||||
let gy = (m.offset.y + y as i32) as usize;
|
||||
labels[gy * w + gx] = li;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
/// The stacked-hierarchy invariants: the bottom layer is a solid full canvas
|
||||
/// (so overdraw is seam-free), every pixel is covered, the flattened
|
||||
/// partition has exactly `regions` distinct labels, and the stack size is
|
||||
/// bounded by the merge tree (at most 2·regions − 1 layers).
|
||||
fn assert_stack(seg: &Segmentation, regions: usize) {
|
||||
let (w, h) = (seg.width as usize, seg.height as usize);
|
||||
let bottom = &seg.layers[0].mask;
|
||||
assert_eq!((bottom.width(), bottom.height()), (w, h), "bottom layer is full-canvas");
|
||||
assert_eq!(bottom.area(), w * h, "bottom layer is solid");
|
||||
assert!(seg.layers.len() <= 2 * regions.max(1) - 1, "stack bounded by the merge tree");
|
||||
|
||||
let labels = flatten(seg);
|
||||
assert!(labels.iter().all(|&l| l != usize::MAX), "every pixel covered");
|
||||
let mut distinct: Vec<usize> = labels.clone();
|
||||
distinct.sort_unstable();
|
||||
distinct.dedup();
|
||||
assert_eq!(distinct.len(), regions, "flattened region count");
|
||||
|
||||
// The final regions must be the topmost layers (painted after every
|
||||
// ancestor), or the flatten would not recover the partition.
|
||||
let first_final = seg.layers.len() - regions;
|
||||
assert!(
|
||||
distinct.iter().all(|&l| l >= first_final),
|
||||
"final regions are the topmost layers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Region count of a segmentation's flattened partition.
|
||||
fn regions(seg: &Segmentation) -> usize {
|
||||
let mut labels = flatten(seg);
|
||||
labels.sort_unstable();
|
||||
labels.dedup();
|
||||
labels.len()
|
||||
}
|
||||
|
||||
/// A flat single-color image is one region no matter the detail level.
|
||||
#[test]
|
||||
fn flat_image_is_one_region() {
|
||||
let img = image(24, 16, |_, _| (90, 120, 150));
|
||||
for detail in [0u32, 128, 255] {
|
||||
let seg = WatershedFrontend {
|
||||
detail,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
assert_eq!(seg.layers.len(), 1, "detail={detail}");
|
||||
assert_stack(&seg, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two clearly separated halves form two regions plus their common ancestor:
|
||||
/// the stack is [root, half, half] and the flatten recovers the exact split.
|
||||
#[test]
|
||||
fn two_tone_image_is_two_regions() {
|
||||
let img = image(32, 20, |x, _| {
|
||||
if x < 16 {
|
||||
(220, 40, 40)
|
||||
} else {
|
||||
(40, 60, 220)
|
||||
}
|
||||
});
|
||||
let seg = WatershedFrontend {
|
||||
detail: 128,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
assert_eq!(seg.layers.len(), 3, "root + two final regions");
|
||||
assert_stack(&seg, 2);
|
||||
// Each final region is exactly one half of the canvas.
|
||||
assert_eq!(seg.layers[1].mask.area(), 16 * 20);
|
||||
assert_eq!(seg.layers[2].mask.area(), 16 * 20);
|
||||
}
|
||||
|
||||
/// Raising detail never decreases the region count (the hierarchy cut is
|
||||
/// monotone in the target).
|
||||
#[test]
|
||||
fn detail_is_monotone() {
|
||||
// A blobby gradient image with structure at several scales.
|
||||
let img = image(64, 48, |x, y| {
|
||||
let v = ((x * 4) as f64).sin() * 40.0 + ((y * 3) as f64).cos() * 40.0;
|
||||
let base = 128i32 + v as i32;
|
||||
let r = (base + ((x / 16) as i32) * 20).clamp(0, 255) as u8;
|
||||
let g = (base + ((y / 12) as i32) * 25).clamp(0, 255) as u8;
|
||||
(r, g, 128)
|
||||
});
|
||||
let mut prev = 0usize;
|
||||
for detail in [0u32, 64, 128, 192, 255] {
|
||||
let seg = WatershedFrontend {
|
||||
detail,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
let k = regions(&seg);
|
||||
assert!(k >= prev, "detail={detail}: {k} < {prev}");
|
||||
assert_stack(&seg, k);
|
||||
prev = k;
|
||||
}
|
||||
assert!(prev > 1, "highest detail should find several regions");
|
||||
}
|
||||
|
||||
/// Small basins are absorbed into a neighbour rather than dropped: the region
|
||||
/// disappears but its pixels stay covered.
|
||||
#[test]
|
||||
fn min_area_absorbs_small_basins() {
|
||||
// Background plus a 3x3 fleck and a 12x12 block, all far apart in color.
|
||||
let img = image(40, 30, |x, y| {
|
||||
if (4..7).contains(&x) && (4..7).contains(&y) {
|
||||
(10, 200, 10) // 9 px fleck
|
||||
} else if (20..32).contains(&x) && (10..22).contains(&y) {
|
||||
(200, 30, 30) // 144 px block
|
||||
} else {
|
||||
(240, 240, 240)
|
||||
}
|
||||
});
|
||||
let keep = WatershedFrontend {
|
||||
detail: 255,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
let absorb = WatershedFrontend {
|
||||
detail: 255,
|
||||
min_area: 16, // fleck (9 px) absorbed, block (144 px) kept
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
|
||||
assert!(regions(&keep) > regions(&absorb), "fleck absorbed");
|
||||
assert_eq!(regions(&absorb), 2, "background + block survive");
|
||||
assert_stack(&absorb, 2);
|
||||
}
|
||||
|
||||
/// Output is deterministic: two runs produce identical layer geometry.
|
||||
#[test]
|
||||
fn deterministic() {
|
||||
let img = image(48, 32, |x, y| {
|
||||
(((x * 7 + y * 13) % 256) as u8, ((x * 3) % 256) as u8, ((y * 5) % 256) as u8)
|
||||
});
|
||||
let front = WatershedFrontend {
|
||||
detail: 160,
|
||||
min_area: 4,
|
||||
};
|
||||
let a = front.segment(&img).unwrap();
|
||||
let b = front.segment(&img).unwrap();
|
||||
assert_eq!(a.layers.len(), b.layers.len());
|
||||
for (la, lb) in a.layers.iter().zip(&b.layers) {
|
||||
assert_eq!(la.paint, lb.paint);
|
||||
assert_eq!(la.mask.offset, lb.mask.offset);
|
||||
assert_eq!(la.mask.area(), lb.mask.area());
|
||||
}
|
||||
}
|
||||
|
||||
/// A cut of a prebuilt hierarchy equals the one-shot frontend — the contract
|
||||
/// behind `Session`'s cached re-cut.
|
||||
#[test]
|
||||
fn hierarchy_recut_matches_one_shot() {
|
||||
let img = image(48, 32, |x, y| {
|
||||
(((x * 5 + y * 3) % 200) as u8, ((x / 8) * 30) as u8, ((y / 8) * 40) as u8)
|
||||
});
|
||||
let hierarchy = WatershedHierarchy::build(&img).unwrap();
|
||||
for detail in [64u32, 128, 200] {
|
||||
let recut = hierarchy.cut(&img, detail, 16);
|
||||
let one_shot = WatershedFrontend {
|
||||
detail,
|
||||
min_area: 16,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
assert_eq!(recut.layers.len(), one_shot.layers.len(), "detail={detail}");
|
||||
for (a, b) in recut.layers.iter().zip(&one_shot.layers) {
|
||||
assert_eq!(a.paint, b.paint);
|
||||
assert_eq!(a.mask.offset, b.mask.offset);
|
||||
assert_eq!(a.mask.area(), b.mask.area());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end through `Session`: retuning watershed detail re-cuts the cached
|
||||
/// hierarchy, and the output still equals the one-shot pipeline.
|
||||
#[test]
|
||||
fn session_recut_matches_one_shot() {
|
||||
let img = image(48, 32, |x, y| {
|
||||
(((x * 5 + y * 3) % 200) as u8, ((x / 8) * 30) as u8, ((y / 8) * 40) as u8)
|
||||
});
|
||||
let mut session = Session::new(img.clone());
|
||||
let base = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
..Config::default()
|
||||
};
|
||||
for detail in [128u32, 200, 64] {
|
||||
let cfg = Config {
|
||||
watershed_detail: detail,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
session.render_svg(&cfg).unwrap(),
|
||||
cfg.build().unwrap().to_svg(&img).unwrap(),
|
||||
"detail={detail}: session re-cut must match the one-shot pipeline"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Watershed + cutout is native: at max detail the partition reaches the
|
||||
/// mosaic essentially untouched, so two *distinguishable* regions within one
|
||||
/// gradient step stay separate faces (the color path's `merge_similar` would
|
||||
/// have rejoined them). Only the just-noticeable-difference floor applies —
|
||||
/// see `cutout_merge_tolerance_follows_detail`.
|
||||
#[test]
|
||||
fn cutout_keeps_watershed_partition() {
|
||||
// Two halves 4 gray-levels apart (12 L1): close enough that the flatten
|
||||
// merge (threshold = layer_difference = 16 >= 3*4) would union them, yet
|
||||
// clearly above the JND floor (2).
|
||||
let img = image(32, 20, |x, _| {
|
||||
if x < 16 {
|
||||
(100, 100, 100)
|
||||
} else {
|
||||
(104, 104, 104)
|
||||
}
|
||||
});
|
||||
let cfg = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
watershed_detail: 255,
|
||||
filter_speckle: 0,
|
||||
..Config::default()
|
||||
};
|
||||
let doc = cfg.build().unwrap().run(&img).unwrap();
|
||||
assert_eq!(
|
||||
doc.shapes.len(),
|
||||
2,
|
||||
"watershed partition must pass to the mosaic unmerged"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cutout merge tolerance is derived from the detail dial —
|
||||
/// `max(2, (255 − detail) / 8)` — because detail has no color units of its
|
||||
/// own. The same two halves 12 L1 apart that max detail keeps separate (see
|
||||
/// above) merge into one face at the default detail, whose tolerance (15)
|
||||
/// matches the color-cluster default gradient step; and a pair a human
|
||||
/// cannot tell apart (within the just-noticeable-difference floor) merges
|
||||
/// even at max detail.
|
||||
#[test]
|
||||
fn cutout_merge_tolerance_follows_detail() {
|
||||
let halves = |a: (u8, u8, u8), b: (u8, u8, u8)| {
|
||||
image(32, 20, |x, _| if x < 16 { a } else { b })
|
||||
};
|
||||
let cfg = |detail| Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
watershed_detail: detail,
|
||||
filter_speckle: 0,
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let img = halves((100, 100, 100), (104, 104, 104));
|
||||
let doc = cfg(128).build().unwrap().run(&img).unwrap();
|
||||
assert_eq!(
|
||||
doc.shapes.len(),
|
||||
1,
|
||||
"near-identical neighbours merge at the default detail"
|
||||
);
|
||||
|
||||
// #863339 next to #863238 (2 L1 apart): indistinguishable by eye, so it
|
||||
// must never survive as two patches, not even at maximum detail.
|
||||
let img = halves((0x86, 0x33, 0x39), (0x86, 0x32, 0x38));
|
||||
let doc = cfg(255).build().unwrap().run(&img).unwrap();
|
||||
assert_eq!(
|
||||
doc.shapes.len(),
|
||||
1,
|
||||
"sub-JND neighbours merge even at max detail"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regions are 4-connected: two same-colored squares touching only at a
|
||||
/// corner are separate basins (and so are the two squares of the other color).
|
||||
#[test]
|
||||
fn diagonal_touch_does_not_connect() {
|
||||
let img = image(16, 16, |x, y| {
|
||||
if (x / 8 + y / 8) % 2 == 0 {
|
||||
(30, 30, 30)
|
||||
} else {
|
||||
(220, 220, 220)
|
||||
}
|
||||
});
|
||||
let seg = WatershedFrontend {
|
||||
detail: 255,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
let labels = flatten(&seg);
|
||||
assert_eq!(regions(&seg), 4, "four quadrants, none diagonally joined");
|
||||
assert_ne!(labels[2 * 16 + 2], labels[10 * 16 + 10], "dark squares separate");
|
||||
assert_ne!(labels[2 * 16 + 10], labels[10 * 16 + 2], "light squares separate");
|
||||
assert_stack(&seg, 4);
|
||||
}
|
||||
|
||||
/// Nested flat zones — a frame around a ring around a core — come out as
|
||||
/// three exact regions, and the ring face (which has a hole) survives both
|
||||
/// compositors.
|
||||
#[test]
|
||||
fn nested_regions() {
|
||||
// Background frame 230, square ring 40 (4..28 minus 10..22), core 130.
|
||||
let img = image(32, 32, |x, y| {
|
||||
let ring = (4..28).contains(&x) && (4..28).contains(&y);
|
||||
let core = (10..22).contains(&x) && (10..22).contains(&y);
|
||||
if core {
|
||||
(130, 130, 130)
|
||||
} else if ring {
|
||||
(40, 40, 40)
|
||||
} else {
|
||||
(230, 230, 230)
|
||||
}
|
||||
});
|
||||
let seg = WatershedFrontend {
|
||||
detail: 255,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
assert_eq!(regions(&seg), 3, "frame + ring + core");
|
||||
let labels = flatten(&seg);
|
||||
let at = |x: usize, y: usize| labels[y * 32 + x];
|
||||
assert_ne!(at(1, 1), at(6, 6), "frame vs ring");
|
||||
assert_ne!(at(6, 6), at(16, 16), "ring vs core");
|
||||
assert_ne!(at(1, 1), at(16, 16), "frame vs core");
|
||||
assert_stack(&seg, 3);
|
||||
|
||||
// The same nesting through the mosaic: three faces, ring with a hole.
|
||||
let cfg = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
watershed_detail: 255,
|
||||
filter_speckle: 0,
|
||||
..Config::default()
|
||||
};
|
||||
let doc = cfg.build().unwrap().run(&img).unwrap();
|
||||
assert_eq!(doc.shapes.len(), 3, "nested faces survive the mosaic");
|
||||
}
|
||||
|
||||
/// Volume extinction, the hierarchy's ranking attribute: a small but vivid
|
||||
/// basin (large color rise) outlives a bigger but faint one. Cutting to two
|
||||
/// regions must keep the black dot, not the barely-different patch.
|
||||
#[test]
|
||||
fn volume_extinction_prefers_vivid_over_large() {
|
||||
let img = image(48, 32, |x, y| {
|
||||
if (4..7).contains(&x) && (4..7).contains(&y) {
|
||||
(0, 0, 0) // 9 px, rise ~128: volume ≈ 1150
|
||||
} else if (20..30).contains(&x) && (10..20).contains(&y) {
|
||||
(132, 132, 132) // 100 px, rise 4: volume ≈ 400
|
||||
} else {
|
||||
(128, 128, 128)
|
||||
}
|
||||
});
|
||||
let seg = WatershedFrontend {
|
||||
detail: 26, // target = 2 regions
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
assert_eq!(regions(&seg), 2);
|
||||
let labels = flatten(&seg);
|
||||
// The surviving split isolates the dot: its 9 pixels share a label that
|
||||
// appears nowhere else.
|
||||
let dot = labels[5 * 48 + 5];
|
||||
let dot_area = labels.iter().filter(|&&l| l == dot).count();
|
||||
assert_eq!(dot_area, 9, "the vivid dot is the kept region");
|
||||
assert_eq!(
|
||||
labels[15 * 48 + 25],
|
||||
labels[0],
|
||||
"the faint patch merged into the background"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plateaus joined by short ramps — the antialiased-boundary shape. Cutting to
|
||||
/// three regions recovers the plateaus, with each region's mean close to its
|
||||
/// plateau value (ramp pixels split between the sides they descend from).
|
||||
#[test]
|
||||
fn plateaus_with_ramps() {
|
||||
// Columns: 40 ×20 | ramp ×2 | 128 ×20 | ramp ×2 | 216 ×20.
|
||||
let level = |x: usize| -> u8 {
|
||||
match x {
|
||||
0..=19 => 40,
|
||||
20 => 69,
|
||||
21 => 99,
|
||||
22..=41 => 128,
|
||||
42 => 157,
|
||||
43 => 187,
|
||||
_ => 216,
|
||||
}
|
||||
};
|
||||
let img = image(64, 16, |x, _| {
|
||||
let v = level(x);
|
||||
(v, v, v)
|
||||
});
|
||||
let seg = WatershedFrontend {
|
||||
detail: 40, // target = 3 regions
|
||||
min_area: 4,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
assert_eq!(regions(&seg), 3);
|
||||
// Means sit near the plateau values — the ramps don't form regions of
|
||||
// their own or drag a mean far off.
|
||||
let mut means: Vec<u8> = seg
|
||||
.layers
|
||||
.iter()
|
||||
.rev()
|
||||
.take(3)
|
||||
.map(|l| l.paint.color().r)
|
||||
.collect();
|
||||
means.sort_unstable();
|
||||
for (mean, plateau) in means.iter().zip([40u8, 128, 216]) {
|
||||
assert!(
|
||||
mean.abs_diff(plateau) <= 20,
|
||||
"region mean {mean} strays from plateau {plateau}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Degenerate geometries: single pixel, single row, single column.
|
||||
#[test]
|
||||
fn degenerate_geometries() {
|
||||
let one = image(1, 1, |_, _| (7, 8, 9));
|
||||
let seg = WatershedFrontend {
|
||||
detail: 128,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&one)
|
||||
.unwrap();
|
||||
assert_eq!(seg.layers.len(), 1);
|
||||
assert_stack(&seg, 1);
|
||||
|
||||
let row = image(16, 1, |x, _| if x < 8 { (0, 0, 0) } else { (255, 255, 255) });
|
||||
let seg = WatershedFrontend {
|
||||
detail: 128,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&row)
|
||||
.unwrap();
|
||||
assert_eq!(regions(&seg), 2, "single row splits");
|
||||
assert_stack(&seg, 2);
|
||||
|
||||
let col = image(1, 16, |_, y| if y < 8 { (0, 0, 0) } else { (255, 255, 255) });
|
||||
let seg = WatershedFrontend {
|
||||
detail: 128,
|
||||
min_area: 0,
|
||||
}
|
||||
.segment(&col)
|
||||
.unwrap();
|
||||
assert_eq!(regions(&seg), 2, "single column splits");
|
||||
assert_stack(&seg, 2);
|
||||
}
|
||||
|
||||
/// …but identical-color neighbours still collapse into one face: regions that
|
||||
/// snap to the same palette entry and share a boundary must not keep a useless
|
||||
/// edge between them. (The dark region sits between them in stack order, so
|
||||
/// the layer-level `MergeAdjacent` cannot be the one doing the merging — only
|
||||
/// the mosaic's same-color merge can.)
|
||||
#[test]
|
||||
fn cutout_merges_identical_palette_faces() {
|
||||
let img = image(32, 32, |x, y| {
|
||||
if y < 16 {
|
||||
if x < 16 {
|
||||
(200, 200, 200) // A: top-left
|
||||
} else {
|
||||
(20, 20, 20) // C: top-right
|
||||
}
|
||||
} else {
|
||||
(180, 180, 180) // B: bottom, touches A
|
||||
}
|
||||
});
|
||||
let cfg = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
watershed_detail: 255,
|
||||
filter_speckle: 0,
|
||||
palette: vec![Color::new(255, 255, 255), Color::new(0, 0, 0)],
|
||||
..Config::default()
|
||||
};
|
||||
let doc = cfg.build().unwrap().run(&img).unwrap();
|
||||
assert_eq!(
|
||||
doc.shapes.len(),
|
||||
2,
|
||||
"A and B snap to the same palette color and share a boundary — one face"
|
||||
);
|
||||
}
|
||||
|
||||
/// An antialiased edge with pixel noise must come out straight: inside the
|
||||
/// ramp the per-pixel differences are near-equal, so the raw
|
||||
/// minimum-spanning-forest boundary meanders with the noise; the boundary
|
||||
/// snap re-assigns ramp pixels by color proximity, landing the cut on the
|
||||
/// color-midpoint iso-line (within a pixel).
|
||||
#[test]
|
||||
fn antialiased_edge_snaps_to_midline() {
|
||||
let (w, h) = (32usize, 16usize);
|
||||
let edge = |x: usize| 6.0 + 0.2 * x as f64; // nearly horizontal
|
||||
let img = image(w, h, |x, y| {
|
||||
// A 4-px linear ramp: adjacent in-ramp differences are near-equal,
|
||||
// so without the snap the cut meanders on the noise.
|
||||
let t = ((y as f64 + 0.5 - edge(x)) / 4.0 + 0.5).clamp(0.0, 1.0);
|
||||
let mut v = (t * 200.0).round() as i32;
|
||||
if t > 0.0 && t < 1.0 {
|
||||
v += ((x * 7 + y * 13) % 5) as i32 - 2; // deterministic "sensor" noise
|
||||
}
|
||||
let v = v.clamp(0, 255) as u8;
|
||||
(v, v, v)
|
||||
});
|
||||
let seg = WatershedFrontend {
|
||||
detail: 26, // target 2 regions
|
||||
min_area: 1,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
let labels = flatten(&seg);
|
||||
assert_eq!(regions(&seg), 2);
|
||||
for x in 0..w {
|
||||
let col: Vec<usize> = (0..h).map(|y| labels[y * w + x]).collect();
|
||||
let cross: Vec<usize> = (1..h).filter(|&y| col[y] != col[y - 1]).collect();
|
||||
assert_eq!(
|
||||
cross.len(),
|
||||
1,
|
||||
"column {x} crosses the boundary exactly once, got {col:?}"
|
||||
);
|
||||
let dev = cross[0] as f64 - edge(x);
|
||||
assert!(
|
||||
dev.abs() <= 1.5,
|
||||
"column {x}: boundary at row {} strays from the edge at {:.1}",
|
||||
cross[0],
|
||||
edge(x)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sizes of the 4-connected components of a label map.
|
||||
fn component_sizes(labels: &[usize], w: usize, h: usize) -> Vec<usize> {
|
||||
let mut seen = vec![false; labels.len()];
|
||||
let mut sizes = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
for start in 0..labels.len() {
|
||||
if seen[start] {
|
||||
continue;
|
||||
}
|
||||
let mut size = 0;
|
||||
seen[start] = true;
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
size += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
for j in [
|
||||
(x > 0).then(|| i - 1),
|
||||
(x + 1 < w).then(|| i + 1),
|
||||
(y > 0).then(|| i - w),
|
||||
(y + 1 < h).then(|| i + w),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !seen[j] && labels[j] == labels[i] {
|
||||
seen[j] = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
sizes.push(size);
|
||||
}
|
||||
sizes
|
||||
}
|
||||
|
||||
/// The boundary snap must not leave debris: a pixel can flip toward a
|
||||
/// neighbour whose own flip then strands it, leaving 1-px chips that the
|
||||
/// mosaic turns into micro-faces wedged between the real ones (faces that
|
||||
/// visually abut but no longer share a fitted boundary). Every connected
|
||||
/// patch of the partition must clear the speckle floor — a *substantial*
|
||||
/// patch severed at a thin antialiased neck is fine (it becomes its own
|
||||
/// tight face), sub-speckle debris is not. The real photo is the
|
||||
/// reproduction: its JPEG noise produced 62 such chips before the snap
|
||||
/// absorbed fragments.
|
||||
#[test]
|
||||
fn snap_leaves_no_debris() {
|
||||
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg");
|
||||
let decoded = image::open(&p).expect("sample image").to_rgba8();
|
||||
let (w, h) = (decoded.width() as usize, decoded.height() as usize);
|
||||
let img = ColorImage {
|
||||
pixels: decoded.into_raw(),
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
let min_area = 16;
|
||||
let seg = WatershedFrontend {
|
||||
detail: 128,
|
||||
min_area,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
let labels = flatten(&seg);
|
||||
let sizes = component_sizes(&labels, w, h);
|
||||
assert!(
|
||||
sizes.iter().all(|&s| s >= min_area),
|
||||
"smallest patch {} px is under the speckle floor ({} patches total)",
|
||||
sizes.iter().min().unwrap(),
|
||||
sizes.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// The snap must not bulldoze genuine detail: a pixel of the *other side's*
|
||||
/// color sitting across the boundary (here a bright pixel notching into the
|
||||
/// dark half) is not a mixture of the two region means, so the mixture gate
|
||||
/// keeps it with its color-correct basin — where a geometric smoothing
|
||||
/// filter would have erased the notch.
|
||||
#[test]
|
||||
fn snap_keeps_genuine_color_detail() {
|
||||
let (w, h) = (16usize, 16usize);
|
||||
let img = image(w, h, |x, y| {
|
||||
if (x, y) == (7, 7) {
|
||||
(190, 190, 190) // bright pixel on the dark side of the edge
|
||||
} else if x < 8 {
|
||||
(0, 0, 0)
|
||||
} else {
|
||||
(200, 200, 200)
|
||||
}
|
||||
});
|
||||
let seg = WatershedFrontend {
|
||||
detail: 26,
|
||||
min_area: 1,
|
||||
}
|
||||
.segment(&img)
|
||||
.unwrap();
|
||||
let labels = flatten(&seg);
|
||||
assert_eq!(regions(&seg), 2);
|
||||
assert_eq!(
|
||||
labels[7 * w + 7],
|
||||
labels[7 * w + 8],
|
||||
"the bright pixel stays with the bright region"
|
||||
);
|
||||
assert_ne!(labels[7 * w + 7], labels[7 * w + 6], "the notch survives");
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
Copyright (c) 2020 Tsang Hao Fung
|
||||
|
||||
All Rights Reserved
|
||||
@@ -1,543 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<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"
|
||||
width="2800px" height="2800px" viewBox="0 0 2800 2800" enable-background="new 0 0 2800 2800" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<polygon fill="#333333" points="2442.8,1360 1625.1,1360 1728.1,1158.7 1625.1,970.2 2442.8,970.2 2347.8,1163.5 "/>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M1875,1170.5c6.3,6.8,9.5,15.3,9.5,25.5l-9.3,117c0,5.6-5.2,9.3-15.6,11.1c-10.4,1.9-19.9,2.8-28.6,2.8
|
||||
h-63.2V997.3h61.8c10.8,0,21.1,2.6,30.9,7.9c9.8,5.3,14.6,12.2,14.6,20.9v93.3l-19,38.5C1862.3,1159.6,1868.6,1163.7,1875,1170.5
|
||||
z M1802.8,1142.7h33.4c0.9,0,2-0.6,3.3-1.9c0.3-0.3,0.6-1.1,0.9-2.3l4.2-111c0-1.2-2.2-1.9-6.5-1.9l-40.9,1.9V1139
|
||||
C1797.2,1141.4,1799,1142.7,1802.8,1142.7z M1845.9,1275.9c0-1.9-0.3-2.8-0.9-2.8V1178c0.9-0.6,1.4-1.1,1.4-1.4
|
||||
c0-0.9-1.1-1.4-3.3-1.4l-35.3-4.6c-0.9,0-1.9,0.3-3,0.9c-1.1,0.6-1.8,1.2-2.1,1.9v126.3l5.1,1.4
|
||||
C1833.2,1291.7,1845.9,1283.4,1845.9,1275.9z"/>
|
||||
<path fill="#FFFFFF" d="M2028.4,1327h-99.4V997.3h99.4v28.8l-58-2.8v105.4l58,10.7v22.3l-65.9-2.8v133.3l65.9-3.7V1327z"/>
|
||||
<path fill="#FFFFFF" d="M2150.5,1062.8l-9.3-36.2l-27.9,3.3l-7,103.1l67.3,12.1l-6.5,154.2c0,6.2-4,12.4-12.1,18.6
|
||||
c-8.1,6.2-15.5,9.3-22.3,9.3h-54.3l-4.2-79.4h27.4l-0.5,40.4l32.5,7.4l7.4-123.5l-62.7-13.9v-149.5l18.6-11.1h72.9l-2.3,65.5
|
||||
H2150.5z"/>
|
||||
<path fill="#FFFFFF" d="M2304.7,1003.8l2.3,29.3h-40.9V1327h-39.5l9.3-285.6l-33.4-11.1l6-33L2304.7,1003.8z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<polygon fill="#333333" points="1660.5,2001.3 167.9,2001.3 356,1714.2 167.9,1445.3 1660.5,1445.3 1487.1,1721 "/>
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M462.7,1802.5c0,16.1,0.8,29.1,2.6,39c1.7,9.9,3.9,17.6,6.7,23c2.8,5.4,6,9.1,9.5,10.9
|
||||
c3.6,1.9,7.2,2.8,10.9,2.8c4.3,0,8-0.5,10.9-1.4c2.9-0.9,5.6-2.2,8.1-3.7l4.6,4.2c-3.4,8-8,14-13.7,17.9
|
||||
c-5.7,3.9-12.2,5.8-19.3,5.8c-9,0-16.5-1.9-22.5-5.6c-6-3.7-9.1-8.7-9.1-14.9c-12.7,0-21-5.4-24.8-16.3
|
||||
c-3.9-10.8-5.8-26.8-5.8-47.8v-154.2h-30.6v-21.8h2.8c8.4,0,15.6-1.9,21.6-5.8c6-3.9,11.3-8.9,15.8-15.1
|
||||
c4.5-6.2,8.4-13.2,11.6-21.1c3.3-7.9,6.3-15.9,9.1-23.9h11.6v65.9c11.1-0.3,20.4-1.2,27.6-2.6c7.3-1.4,12.6-3.8,16-7.2
|
||||
c7.1,1.2,11.8,3.8,13.9,7.7c2.2,3.9,3.3,7.2,3.3,10c0,3.7-1.2,6.9-3.5,9.5c-2.3,2.6-5.4,4.7-9.3,6.3c-3.9,1.6-8.3,2.6-13.2,3.3
|
||||
c-5,0.6-9.9,0.9-14.9,0.9c-6.8,0-13.5-0.5-20-1.4V1802.5z"/>
|
||||
<path fill="#FFFFFF" d="M607.6,1807.1c0,6.2,1,11.3,3,15.3c2,4,4.5,7.3,7.4,9.8c2.9,2.5,6.3,4.3,10.2,5.3
|
||||
c3.9,1.1,7.7,1.6,11.4,1.6v13.9c-3.7,0-7.8-0.2-12.3-0.7c-4.5-0.5-9.2-1-14.2-1.6c-5-0.6-9.7-1.2-14.2-1.6
|
||||
c-4.5-0.5-8.6-0.7-12.3-0.7c-3.7,0-7.8,0.2-12.3,0.7c-4.5,0.5-9.2,1-14.2,1.6c-5,0.6-9.7,1.2-14.2,1.6c-4.5,0.5-8.6,0.7-12.3,0.7
|
||||
v-13.9c8.4,0,15.8-2.3,22.3-7c6.5-4.6,9.8-13,9.8-25.1v-240.5c0-11.4-0.2-21-0.7-28.6c-0.5-7.6-1.8-13.5-3.9-17.6
|
||||
c-2.2-4.2-5.3-7.1-9.3-8.8c-4-1.7-9.6-2.6-16.7-2.6v-11.6c7.7,0,14.5-0.5,20.2-1.4c5.7-0.9,10.9-2.3,15.6-4.2
|
||||
c4.6-1.9,8.9-4,12.8-6.5c3.9-2.5,8-5.1,12.3-7.9h11.6v195.5c3.1-4.9,6.8-9.7,11.1-14.2c4.3-4.5,9.4-8.4,15.3-11.8
|
||||
c5.9-3.4,12.7-6.1,20.4-8.1c7.7-2,16.7-3,26.9-3c13,0,24.6,2,34.8,6c10.2,4,18.8,10.4,25.8,19.3c7,8.8,12.2,20.3,15.8,34.4
|
||||
c3.6,14.1,5.3,31.2,5.3,51.3v55.3c0,19.8,0.7,35.8,2.1,47.8c1.4,12.1,3.3,21.4,5.6,27.9c2.3,6.5,5.1,10.8,8.4,13
|
||||
c3.3,2.2,6.6,3.3,10,3.3c3.4,0,6.5-0.2,9.3-0.7c2.8-0.5,5.9-1.9,9.3-4.4l2.3,1.9c-3.4,8.4-8.5,15.1-15.3,20.2
|
||||
c-6.8,5.1-14.2,7.7-22.3,7.7c-8.1,0-15.2-2.5-21.4-7.4c-6.2-5-9.3-11.6-9.3-20c-4-2.2-7.4-4.4-10-6.7c-2.6-2.3-4.7-5.7-6.3-10
|
||||
c-1.6-4.3-2.6-10.1-3.3-17.4c-0.6-7.3-0.9-16.9-0.9-29v-71c0-19.5-1.1-35.6-3.2-48.3c-2.2-12.7-5.3-22.8-9.3-30.4
|
||||
c-4-7.6-8.9-12.9-14.6-16c-5.7-3.1-12-4.6-18.8-4.6c-7.1,0-14.7,1.5-22.8,4.4c-8.1,2.9-15.4,7.7-22.1,14.4
|
||||
c-6.7,6.7-12.2,15.3-16.5,26c-4.3,10.7-6.5,23.6-6.5,38.8V1807.1z"/>
|
||||
<path fill="#FFFFFF" d="M883.4,1807.1c0,6.2,1,11.3,3,15.3c2,4,4.5,7.3,7.4,9.8c2.9,2.5,6.3,4.3,10.2,5.3
|
||||
c3.9,1.1,7.7,1.6,11.4,1.6v13.9c-3.7,0-7.8-0.2-12.3-0.7c-4.5-0.5-9.2-1-14.2-1.6c-5-0.6-9.7-1.2-14.2-1.6
|
||||
c-4.5-0.5-8.6-0.7-12.3-0.7s-7.8,0.2-12.3,0.7c-4.5,0.5-9.2,1-14.2,1.6c-5,0.6-9.7,1.2-14.2,1.6c-4.5,0.5-8.6,0.7-12.3,0.7v-13.9
|
||||
c8.4,0,15.8-2.3,22.3-7c6.5-4.6,9.8-13,9.8-25.1v-79.9c0-11.4-0.2-21-0.7-28.6c-0.5-7.6-1.8-13.5-3.9-17.6
|
||||
c-2.2-4.2-5.3-7.1-9.3-8.8c-4-1.7-9.6-2.6-16.7-2.6v-13.9c7.7,0,14.3-0.5,19.7-1.4c5.4-0.9,10.3-2.3,14.6-4.2
|
||||
c4.3-1.9,8.4-4,12.1-6.5c3.7-2.5,7.7-5.1,12.1-7.9h13.9V1807.1z M830.9,1567.1c0-9.3,3.3-17.1,9.8-23.5
|
||||
c6.5-6.3,14.2-9.5,23.2-9.5c9,0,16.6,3.2,23,9.5c6.3,6.3,9.5,14.2,9.5,23.5c0,9.3-3.2,17.1-9.5,23.4c-6.3,6.3-14,9.5-23,9.5
|
||||
c-9,0-16.7-3.2-23.2-9.5C834.2,1584.2,830.9,1576.4,830.9,1567.1z"/>
|
||||
<path fill="#FFFFFF" d="M1123.9,1766.3c0-20.7-1-38.1-3-52c-2-13.9-5-25.1-9.1-33.4c-4-8.4-9.1-14.3-15.3-17.9
|
||||
c-6.2-3.6-13.3-5.3-21.4-5.3c-8.1,0-15.7,1.6-23,4.6c-7.3,3.1-13.7,8-19.3,14.6c-5.6,6.7-10,15.3-13.2,26
|
||||
c-3.3,10.7-4.9,23.4-4.9,38.3v65.9c0,6.2,1,11.3,3,15.3c2,4,4.5,7.3,7.4,9.8c2.9,2.5,6.3,4.3,10.2,5.3c3.9,1.1,7.7,1.6,11.4,1.6
|
||||
v13.9c-3.7,0-7.8-0.2-12.3-0.7c-4.5-0.5-9.2-1-14.2-1.6c-5-0.6-9.7-1.2-14.2-1.6c-4.5-0.5-8.6-0.7-12.3-0.7s-7.8,0.2-12.3,0.7
|
||||
c-4.5,0.5-9.2,1-14.2,1.6c-5,0.6-9.7,1.2-14.2,1.6c-4.5,0.5-8.6,0.7-12.3,0.7v-13.9c8.4,0,15.8-2.3,22.3-7s9.8-13,9.8-25.1v-77.5
|
||||
c0-11.4-0.2-21-0.7-28.6c-0.5-7.6-1.8-13.5-3.9-17.6c-2.2-4.2-5.3-7.1-9.3-8.8c-4-1.7-9.6-2.6-16.7-2.6v-11.6
|
||||
c7.7,0,14.5-0.5,20.2-1.4c5.7-0.9,10.9-2.3,15.6-4.2c4.6-1.9,8.9-4,12.8-6.5c3.9-2.5,8-5.1,12.3-7.9h11.6v32.5
|
||||
c5.9-10.8,14-19.7,24.4-26.7c10.4-7,22.7-10.4,36.9-10.4c30.6,0,53.2,9.3,67.8,27.9c14.5,18.6,21.8,49.4,21.8,92.4v48.8
|
||||
c0,6.2,1,11.3,3,15.3c2,4,4.5,7.3,7.4,9.8c2.9,2.5,6.3,4.3,10.2,5.3c3.9,1.1,7.7,1.6,11.4,1.6v16.3c-4,0-7.6-0.2-10.7-0.7
|
||||
c-3.1-0.5-6.3-0.9-9.5-1.4c-3.3-0.5-7-0.9-11.1-1.4c-4.2-0.5-9.2-0.7-15.1-0.7c-4,0-8.6,0.2-13.7,0.7c-5.1,0.5-11.2,1.6-18.3,3.5
|
||||
l-4.2-7.9c2.2-2.2,3.9-6.1,5.1-11.8c1.2-5.7,2.1-12.3,2.6-19.7c0.5-7.4,0.8-15.3,0.9-23.7
|
||||
C1123.8,1781.6,1123.9,1773.7,1123.9,1766.3z"/>
|
||||
<path fill="#FFFFFF" d="M1326.4,1859.6c-16.7,0-31.4-2.8-44.1-8.4c-12.7-5.6-23.4-13.4-32-23.4c-8.7-10.1-15.2-22.1-19.5-36
|
||||
c-4.3-13.9-6.5-29.3-6.5-46c0-19.5,3-36.1,9.1-49.9c6-13.8,13.9-25.1,23.7-34.1c9.8-9,20.9-15.6,33.4-19.7
|
||||
c12.5-4.2,25.3-6.3,38.3-6.3c13.9,0,26.3,2.1,37.1,6.3c10.8,4.2,20.9,12.6,30.2,25.3c1.9-6.8,4.8-12,8.8-15.6
|
||||
c4-3.6,8.7-6.2,13.9-7.9c5.3-1.7,10.8-2.7,16.7-3c5.9-0.3,11.6-0.5,17.2-0.5v13.9c-4,0-7.4,0.9-10,2.6c-2.6,1.7-4.6,6.2-6,13.5
|
||||
c-1.4,7.3-2.3,18.1-2.8,32.5c-0.5,14.4-0.7,34.3-0.7,59.7v105.9c0,35.3-8.8,60.9-26.2,76.8c-17.5,15.9-45,23.9-82.4,23.9
|
||||
c-22.9,0-39.9-2.5-50.8-7.4c-11-5-16.5-11.1-16.5-18.6c-8.1,0-13.8-2.7-17.2-8.1c-3.4-5.4-5.1-10.9-5.1-16.5
|
||||
c0-5.3,1.2-9.8,3.7-13.7c2.5-3.9,5.6-7,9.3-9.5c3.7-2.5,7.7-4.3,11.8-5.6c4.2-1.2,8-1.9,11.4-1.9c0,8.4,1,16.2,3,23.4
|
||||
c2,7.3,5.3,13.6,10,19c4.6,5.4,10.6,9.8,17.9,13c7.3,3.3,16.2,4.9,26.7,4.9c9.3,0,17.8-1.4,25.5-4.2c7.7-2.8,14.3-7.4,19.7-13.9
|
||||
c5.4-6.5,9.7-15,12.8-25.5c3.1-10.5,4.6-23.5,4.6-39v-30.2c-10.5,10.2-21.4,16.8-32.5,19.7
|
||||
C1347.7,1858.1,1336.9,1859.6,1326.4,1859.6z M1333.3,1838.3c10.5,0,19.8-2.4,27.9-7.2c8-4.8,14.6-11.4,19.7-19.7
|
||||
c5.1-8.4,9-18,11.6-29c2.6-11,3.9-22.8,3.9-35.5c0-12.1-1.2-23.4-3.7-34.1c-2.5-10.7-6.3-20.1-11.6-28.3
|
||||
c-5.3-8.2-11.8-14.6-19.7-19.3c-7.9-4.6-17.4-7-28.6-7c-12.1,0-22.3,2.2-30.6,6.7c-8.4,4.5-15,10.7-20,18.6
|
||||
c-5,7.9-8.5,17.2-10.7,27.9c-2.2,10.7-3.3,22.2-3.3,34.6c0,12.1,1,23.7,3,34.8c2,11.1,5.6,21,10.7,29.5
|
||||
c5.1,8.5,11.8,15.3,20,20.4C1310.2,1835.7,1320.6,1838.3,1333.3,1838.3z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1813.5" y="1662.1" fill="#CFD3D2" width="656.6" height="174.5"/>
|
||||
<g>
|
||||
<path fill="#333333" d="M2034.1,1630.7c0-6.5,1.7-17.6,5.1-33.4c3.4-15.8,8-30.8,13.9-45c6.2-15.8,12.5-23.7,19-23.7
|
||||
c5.6,0,8.4,3.4,8.4,10.2c0,13-2.2,27.5-6.7,43.4c-4.5,15.9-10.1,29.5-16.9,40.6c-7.1,10.8-13.5,16.3-19,16.3
|
||||
C2035.3,1638.4,2034.1,1635.7,2034.1,1630.7z"/>
|
||||
<path fill="#333333" d="M2542.5,1744c-11.7-13.9-25.5-25.6-38.3-38.3c-5.5-5.4-14,3-8.5,8.5c9.6,9.5,19.7,18.4,29,28.1h-179.6
|
||||
c-0.1-8.6-0.4-15.7-0.8-21h11.6c3.1,0.6,7.4,0.9,13,0.9c4.3,0,7.9-1.2,10.7-3.5s4.2-5.2,4.2-8.6c0-8.7-7.4-13-22.3-13
|
||||
c-3.7,0-6.8,0.2-9.3,0.5l-7.4,0.5l-1.4-7.4c0-7.4,0.5-18.6,1.4-33.4c0.3-9.9,0.5-21,0.5-33.4c0-8.7-0.2-15.3-0.5-20
|
||||
c10.5,1.6,22.3,4.6,35.3,9.3c3.7-1.5,6.9-3.6,9.5-6.3c2.6-2.6,3.9-5.5,3.9-8.6c0-5.6-6.7-10.4-20.2-14.4c-13.5-4-25.3-6-35.5-6
|
||||
c-13,0-19.5,11-19.5,33c0,4,0.5,10.5,1.4,19.5c0.9,8.7,1.4,15.2,1.4,19.5c0,18.3-0.6,40.9-1.9,67.8c-0.3,8.9-0.5,17.1-0.7,24.7
|
||||
H2240c0.3-3.6,0.5-7.2,0.8-10.8c1.2-16.7,2.6-28.9,3.9-36.7c1.4-7.7,4.3-19.3,8.6-34.8c5.3-17.9,9.2-33.4,11.8-46.2
|
||||
c2.6-12.8,3.9-24.8,3.9-36c0-2.8-1.1-5.2-3.2-7.2c-2.2-2-4.6-3-7.4-3h-10.2c-3.4,20.1-9.3,57.7-17.6,112.8
|
||||
c-3.6,22.5-6.9,43.1-10,61.9h-26.9c-5.2-35.6-9.6-62.2-13.2-79.5c-4-18.6-7.7-34.1-11.1-46.7c-3.4-12.5-7.1-22.5-11.1-29.9
|
||||
c-4-7.7-8.4-11.6-13-11.6c-2.8,0-5.3,1.1-7.7,3.3c-2.3,2.2-3.5,4.6-3.5,7.4c0,4.6,2.8,15.6,8.4,33c2.2,6.5,3.6,11.3,4.2,14.4
|
||||
c10.7,41.4,19.2,77.9,25.7,109.7h-233.7c-0.7-12.5-1.4-25.9-2.3-40.1c-4-57.6-6-102.8-6-135.6c0-3.7,0.7-6.3,2.1-7.7
|
||||
c1.4-1.4,3.6-2.1,6.7-2.1h3.7c1.2,0.3,2.8,0.5,4.6,0.5c3.1,0,5.6-1.2,7.7-3.7c2-2.5,3-5.4,3-8.8c0-3.4-1.3-6.3-3.9-8.8
|
||||
c-2.6-2.5-5.8-3.7-9.5-3.7c-9,0-20,1.1-33,3.2c-7.1,1.2-12.9,3.2-17.4,6c-4.5,2.8-6.7,6-6.7,9.8c0,7.4,3.6,11.1,10.7,11.1
|
||||
c3.7,0,7.3-0.6,10.7-1.9c0.9,6.8,1.4,15.3,1.4,25.5l-0.5,23.7c-0.3,7.1-0.5,15-0.5,23.7c0,24.1,1.4,59.9,4.2,107.3
|
||||
c0,0.5,0.1,1,0.1,1.5h-99.8c-1.9-5.7-4.5-11.6-7.4-16.5c-1.7-2.9-4.4-5.2-7.3-6.8c1.7-4.6-5.2-10.1-10.3-6.1
|
||||
c-4.9,3.8-7.5,10.6-0.7,14.2c2.5,1.4,5.1,1.9,7.3,3.9c2.7,2.5,3.9,7.8,5.4,11.3h-9.8c-7.1-7.8-12.6-17-20-24.6
|
||||
c-5.4-5.6-13.8,2.9-8.5,8.5c4.8,5,8.8,10.6,13,16.1h-13.6c-7.7,0-7.7,12,0,12h13.7c-5.7,6.4-11.6,12.7-16.5,19.7
|
||||
c-4.4,6.4,6,12.4,10.3,6c6.4-9.2,14.5-17.1,21.6-25.8h13.4c0,1.1-0.1,2.1-0.3,3.2c-2.1,8.4-10,16.1-15.3,22.5
|
||||
c-4.9,5.9,3.5,14.4,8.5,8.5c8.2-9.9,18.8-21,19-34.2h98.2c2.5,39.2,3.8,70.6,3.8,94.2c0,4-0.5,6.8-1.6,8.4
|
||||
c-1.1,1.6-2.9,2.3-5.3,2.3c-1.6,0-2.9-0.2-4.2-0.5h-5.6c-9.3,0-13.9,3.7-13.9,11.1c0,9.3,13.3,13.9,39.9,13.9
|
||||
c2.5,0,9-0.3,19.5-0.9c4.6,0,7.7-0.1,9.3-0.2c1.5-0.2,2.5-0.2,2.8-0.2l11.6-7.9v-2.8c0-10.8-6.5-16.3-19.5-16.3
|
||||
c-4,0-7.1,0.2-9.3,0.5v-17.6c0-20.9-0.9-48.9-2.6-84h235.4c1.2,6.2,2.3,12.3,3.3,18.1c6.7,38.1,10,74.9,10,110.5l8.8,7
|
||||
c7.1,0,13.5-5.1,19-15.3c5.6-10.2,10.1-24.3,13.5-42.3c4.2-23.6,7.5-49.6,9.9-78h79.2c-0.3,12.5-0.4,22.9-0.4,31.1l0.9,29.7
|
||||
c0.9,13.3,1.4,23.2,1.4,29.7c0,3.7,0.2,7,0.5,9.8v21.4c0.3,4,2.1,7.4,5.3,10.2c3.2,2.8,6.9,4.2,10.9,4.2h39.5
|
||||
c5.6,0,10.3-1.2,14.2-3.5c3.9-2.3,5.8-5.6,5.8-10c0-7.4-5.9-11.1-17.6-11.1c-7.4,0-13.8,0.2-19,0.5l-15.3,0.5
|
||||
c-0.6,0-0.9-8.7-0.9-26c0-9.3,0.5-23.4,1.4-42.3c0.3-12.4,0.5-26.3,0.5-41.8c0-0.8,0-1.5,0-2.3h181.5
|
||||
c-6.3,9.5-11.7,19.5-17.6,29.3c-4,6.7,6.4,12.7,10.3,6c7.4-12.5,14-25.6,23-37.1C2544.6,1749.7,2544.8,1746.7,2542.5,1744z
|
||||
M2205.4,1828c-0.9-5-2-13.2-3.3-24.6l-5.6-41.3l-1.2-7.9h23.1C2213.5,1783.9,2209.2,1808.5,2205.4,1828z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#F39DA3" d="M1303.8,2143.3c23.4,7.7,42.4,21.8,57,42.4c11.6,16.6,19.6,34.6,23.8,54c4.2,19.4,6.4,37.8,6.4,55.3
|
||||
c0,44.4-8.9,82.1-26.8,112.9c-24.2,41.6-61.6,62.3-112.1,62.3h-144.1V2136H1252C1272.8,2136.3,1290,2138.7,1303.8,2143.3z
|
||||
M1175.7,2194v218.1h64.5c33,0,56-16.2,69-48.7c7.1-17.8,10.7-39.1,10.7-63.7c0-34-5.3-60.1-16-78.3
|
||||
c-10.7-18.2-31.9-27.3-63.7-27.3H1175.7z"/>
|
||||
<path fill="#F39DA3" d="M1700.6,2440.5c-25.2,26-61.7,39-109.5,39c-47.8,0-84.3-13-109.5-39c-33.9-31.9-50.8-77.8-50.8-137.9
|
||||
c0-61.2,16.9-107.2,50.8-137.9c25.2-26,61.7-39,109.5-39c47.8,0,84.3,13,109.5,39c33.7,30.7,50.6,76.6,50.6,137.9
|
||||
C1751.2,2362.7,1734.3,2408.6,1700.6,2440.5z M1657,2389.7c16.2-20.4,24.4-49.4,24.4-87.1c0-37.5-8.1-66.5-24.4-86.9
|
||||
c-16.2-20.5-38.2-30.7-65.9-30.7c-27.7,0-49.7,10.2-66.2,30.6c-16.5,20.4-24.7,49.4-24.7,87.1s8.2,66.7,24.7,87.1
|
||||
c16.5,20.4,38.5,30.6,66.2,30.6C1618.8,2420.3,1640.7,2410.1,1657,2389.7z"/>
|
||||
<path fill="#F39DA3" d="M1803.3,2136h73.2l132.7,233v-233h65.1v334.2h-69.8l-136.1-237.1v237.1h-65.1V2136z"/>
|
||||
<path fill="#F39DA3" d="M2386.9,2195.2h-176.8v71h162.3v58h-162.3v85.9h185v60.1h-253.3V2136h245.1V2195.2z"/>
|
||||
<path fill="#F39DA3" d="M2465.1,2137.1h71v84.7l-18.4,155.8H2484l-18.8-155.8V2137.1z M2466.7,2404.7h67.8v65.5h-67.8V2404.7z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M1200.7,2160.8h-69v227.1h69c34.9,0,59.5-17.4,73.2-51.6c7.3-18.3,11-40.3,11-65.4c0-34.7-5.6-61.8-16.6-80.6
|
||||
C1256.8,2170.7,1234,2160.8,1200.7,2160.8z M1265.5,2333c-12.4,30.9-33.6,45.9-64.9,45.9h-60v-209.1h60c29.9,0,50.1,8.4,59.8,25.1
|
||||
c10.2,17.4,15.4,43,15.4,76.1C1275.9,2294.9,1272.4,2315.8,1265.5,2333z M1324.9,2154.3c-15-21.3-35-36.1-59.2-44.1
|
||||
c-14.1-4.7-32-7.2-53.1-7.5h-148.6v343.2h148.6c52,0,91-21.7,116-64.6c18.2-31.4,27.4-70.1,27.4-115.2c0-17.8-2.2-36.7-6.5-56.3
|
||||
C1345.1,2190,1336.8,2171.3,1324.9,2154.3z M1320.7,2376.8c-11.6,20-26.6,35.2-44.5,45.1c-17.9,10-39.4,15-63.7,15h-139.6v-325.2
|
||||
h139.5c20.2,0.3,37.1,2.7,50.4,7c22.4,7.4,40.8,21.1,54.7,40.7c11.2,16.1,19,33.7,23.1,52.3c4.1,18.9,6.3,37.2,6.3,54.4
|
||||
C1346.9,2309.6,1338.1,2346.8,1320.7,2376.8z M1664.2,2132.8c-26-26.7-63.9-40.3-112.6-40.3c-48.7,0-86.6,13.5-112.6,40.3
|
||||
c-34.6,31.4-52.2,78.9-52.2,141.1c0,61,17.5,108.4,52.1,141.1c26,26.7,63.9,40.3,112.7,40.3c48.8,0,86.7-13.6,112.7-40.3
|
||||
c34.4-32.6,51.9-80.1,51.9-141.1C1716.1,2211.7,1698.7,2164.2,1664.2,2132.8z M1658,2408.5l-0.1,0.1c-24.2,25-60,37.6-106.3,37.6
|
||||
c-46.3,0-82-12.7-106.3-37.6l-0.1-0.1c-32.8-30.9-49.4-76.1-49.4-134.6c0-59.6,16.6-104.9,49.3-134.5l0.2-0.2
|
||||
c24.2-25,60-37.6,106.3-37.6c46.3,0,82,12.7,106.3,37.6l0.2,0.2c32.6,29.7,49.1,74.9,49.1,134.5
|
||||
C1707.1,2332.3,1690.6,2377.6,1658,2408.5z M1551.6,2151.7c-29,0-52.4,10.9-69.7,32.3c-17.1,21.1-25.7,51.4-25.7,89.9
|
||||
c0,38.5,8.7,68.8,25.7,89.9c17.3,21.4,40.7,32.3,69.7,32.3c29,0,52.3-10.9,69.4-32.3c16.8-21.1,25.4-51.4,25.4-89.9
|
||||
c0-38.3-8.5-68.5-25.3-89.7C1603.9,2162.6,1580.5,2151.7,1551.6,2151.7z M1613.9,2358.1c-15.5,19.5-35.9,28.9-62.3,28.9
|
||||
c-26.5,0-47-9.5-62.7-28.9c-15.7-19.5-23.7-47.8-23.7-84.2s8-64.7,23.7-84.2c15.7-19.5,36.2-28.9,62.7-28.9
|
||||
c26.5,0,46.8,9.5,62.3,29c15.5,19.6,23.4,47.9,23.4,84.2C1637.3,2310.3,1629.4,2338.6,1613.9,2358.1z M1965.1,2323.3L1840.9,2105
|
||||
l-1.3-2.3h-80.3v343.2h74.1v-224.7l128.9,224.7h76.9v-343.2h-74.1V2323.3z M1974.1,2111.7h56.1v325.2h-62.7l-143.2-249.5v249.5
|
||||
h-56.1v-325.2h66.1l139.8,245.5V2111.7z M2175,2299.9h162.3v-67H2175v-62h176.8v-68.2h-254.1v343.2H2360v-69.1h-185V2299.9z
|
||||
M2351,2385.9v51.1h-244.3v-325.2h236.1v50.2H2166v80h162.3v49H2166v94.9H2351z M2421.1,2103.9v89.2l19.3,160.3h41.8l18.8-159.8
|
||||
l0-89.7H2421.1z M2492.1,2192.8l-17.9,151.6h-25.8l-18.3-151.6v-79.9h62V2192.8z M2422.7,2445.9h76.8v-74.5h-76.8V2445.9z
|
||||
M2431.7,2380.4h58.8v56.5h-58.8V2380.4z"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="239.8" y="1074.9" fill="#E4DAD8" width="656.6" height="174.5"/>
|
||||
<g>
|
||||
<path fill="#333333" d="M458.5,1312.6c0-7.4,4.8-11.1,14.4-11.1h5.1l4.6,0.5c2.5,0,4.2-0.8,5.1-2.3c0.9-1.5,1.4-4.3,1.4-8.4
|
||||
c0-5.3-0.3-13.5-0.9-24.6c-0.9-7.4-2-20.7-3.2-39.9c-2.8-47.4-4.2-83.1-4.2-107.3c0-8.7,0.2-16.6,0.5-23.7l0.5-23.7
|
||||
c0-10.2-0.5-18.7-1.4-25.5c-5,1.2-8.5,1.9-10.7,1.9c-7.4,0-11.1-3.7-11.1-11.1c0-3.7,2.3-7,7-9.8c4.6-2.8,10.5-4.8,17.6-6
|
||||
c12.4-2.5,23.4-3.7,33-3.7c3.7,0,6.9,1.3,9.5,3.9c2.6,2.6,3.9,5.7,3.9,9.1c0,3.4-1,6.3-3,8.8c-2,2.5-4.6,3.7-7.7,3.7
|
||||
c-1.9,0-3.4-0.2-4.6-0.5h-3.7c-3.1,0-5.3,0.7-6.7,2.1c-1.4,1.4-2.1,3.9-2.1,7.7c0,30,1.9,75.2,5.6,135.6
|
||||
c0.6,11.1,1.9,27.6,3.7,49.2c1.5,14.2,2.3,28.8,2.3,43.6v17.6c1.9-0.3,4.9-0.5,9.3-0.5c13,0,19.5,5.4,19.5,16.3v2.8l-11.6,7.9
|
||||
c-0.3,0-1.3,0.1-3,0.2c-1.7,0.2-4.9,0.2-9.5,0.2c-9.9,0.6-16.3,0.9-19,0.9C472,1326.6,458.5,1321.9,458.5,1312.6z"/>
|
||||
<path fill="#333333" d="M574.8,1327.5c-3.9-3.1-6.1-7.1-6.7-12.1l5.1-7.9c1.9,0,4.8,0.6,8.8,1.9c4.6,0.9,8.2,1.4,10.7,1.4
|
||||
c5.6,0,10.6-2.5,15.1-7.4c4.5-4.9,7.8-11.4,10-19.5c4-14.9,6-28.2,6-39.9c0-20.4-6.5-41.9-19.5-64.5l-5.1-8.8
|
||||
c-5.9-10.2-10.8-19.3-14.9-27.4c-4-8-7.4-16.7-10.2-26c-3.7-12.1-5.6-24.1-5.6-36.2c0-13.6,3.1-27.9,9.3-42.7
|
||||
c3.1-7.1,7.5-12.9,13.2-17.4c5.7-4.5,12.1-6.7,19.3-6.7c5.6,0,10.8,1.8,15.6,5.3c4.8,3.6,7.2,7.8,7.2,12.8c0,2.8-1.1,5-3.3,6.5
|
||||
c-2.2,1.6-4.8,2.3-7.9,2.3c-3.4,0-6.7-0.9-9.8-2.8c-6.8,0.3-12.2,6.3-16.3,18.1c-3.4,10.8-5.1,19.8-5.1,26.9c0,9,1.4,18,4.2,27.2
|
||||
c2.8,9.1,6.3,17.4,10.7,24.8l10.2,18.6l16.3,30.6c4.3,8.7,7.8,18.7,10.4,30.2c2.6,11.5,3.9,23.1,3.9,34.8
|
||||
c0,18.9-4.3,36.8-13,53.9c-4.3,8.7-10.4,15.6-18.3,20.9s-16.6,7.9-26.2,7.9v1.4C583.4,1332.6,578.7,1330.6,574.8,1327.5z"/>
|
||||
</g>
|
||||
<path fill="#333333" d="M428,1164c-20.7-23.1-45.4-42.6-64.7-67.1c-4.2-5.3-13.5,1.9-9.4,7.3c1.6,2,1.6,4.1,1.6,6.6
|
||||
c0,4.9,4.9,6.7,8.4,5.4c16.1,18,34.6,33.8,51.1,51.5c-12.6,9.9-24.9,20.2-36.5,31.2c-11.8,11-24.9,23.5-31.8,38.4
|
||||
c-3.2,7,7.1,13.1,10.3,6c6.7-14.6,19.6-26.3,31.1-37.1c12.7-11.9,26.1-22.9,39.8-33.6C430.6,1170.4,429.9,1166.2,428,1164z"/>
|
||||
<path fill="#333333" d="M356.1,1137.6c-9.2-7.5-20.3-13.3-28.8-21.5c-5.6-5.4-14.1,3.1-8.5,8.5c7.6,7.3,17.2,12.6,25.5,18.9
|
||||
c4.9,3.7,9.7,7.8,13.9,12.4c1.6,1.7,2.9,3.6,3.9,5.7c0.9,1.9,0.5,1.3,0.2,2.3c-1,2.9-5.2,7.1-7.6,9.8c-4.3,4.7-8.9,9.1-13.5,13.6
|
||||
c-7.5,7.2-16,14-22.4,22.2c-4.7,6,3.7,14.6,8.5,8.5c7.2-9.3,17-17,25.5-25.2c7.5-7.2,18.7-16.3,21.5-26.8
|
||||
C377.3,1154.7,363.6,1143.7,356.1,1137.6z"/>
|
||||
<path fill="#333333" d="M659.8,1164c20.7-23.1,45.4-42.6,64.7-67.1c4.2-5.3,13.5,1.9,9.4,7.3c-1.6,2-1.6,4.1-1.6,6.6
|
||||
c0,4.9-4.9,6.7-8.4,5.4c-16.1,18-34.6,33.8-51.1,51.5c12.6,9.9,24.9,20.2,36.5,31.2c11.8,11,24.9,23.5,31.8,38.4
|
||||
c3.2,7-7.1,13.1-10.3,6c-6.7-14.6-19.6-26.3-31.1-37.1c-12.7-11.9-26.1-22.9-39.8-33.6C657.1,1170.4,657.8,1166.2,659.8,1164z"/>
|
||||
<path fill="#333333" d="M731.6,1137.6c9.2-7.5,20.3-13.3,28.8-21.5c5.6-5.4,14.1,3.1,8.5,8.5c-7.6,7.3-17.2,12.6-25.5,18.9
|
||||
c-4.9,3.7-9.7,7.8-13.9,12.4c-1.6,1.7-2.9,3.6-3.9,5.7c-0.9,1.9-0.5,1.3-0.2,2.3c1,2.9,5.2,7.1,7.6,9.8c4.3,4.7,8.9,9.1,13.5,13.6
|
||||
c7.5,7.2,16,14,22.4,22.2c4.7,6-3.7,14.6-8.5,8.5c-7.2-9.3-17-17-25.5-25.2c-7.5-7.2-18.7-16.3-21.5-26.8
|
||||
C710.4,1154.7,724.1,1143.7,731.6,1137.6z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#F39DA3" d="M2550.7,553.8c-758.8,355.4-1517.7-355.4-2276.5,0c0-59.2,0-53.8,0-113c758.8-355.4,1517.7,355.4,2276.5,0
|
||||
C2550.7,500.1,2550.7,494.6,2550.7,553.8z"/>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#333333" d="M344,551.6c0,13,2.6,24.3,7.9,34.1c5.3,9.7,12.2,17.4,20.9,23.1c8.7,5.7,18.6,9.2,29.7,10.7
|
||||
c11.1,1.5,22.8,0.5,34.8-3c10.8-3.1,19.8-8.4,26.9-16.1c7.1-7.7,11.4-19.1,13-34.3c0.5-0.6,0.9-1.2,1.4-1.7
|
||||
c10.8,0.2,18.5,2.9,23,7.8c4.5,4.9,6.7,10.8,6.7,17.6c0,8.4-2.4,14.7-7.2,18.9c-4.8,4.3-9.4,7.7-13.7,10.2
|
||||
c0,5.6-1.4,10.3-4.2,14.2c-2.8,3.9-6.1,7.2-10,9.8c-3.9,2.6-8.1,4.8-12.5,6.4c-4.5,1.6-8.4,2.9-11.8,3.8
|
||||
c-13,3.5-24.8,4.8-35.5,3.6c-10.7-1.2-20.7-3.3-30-6.6s-18.1-7.2-26.5-12c-8.4-4.7-16.8-8.6-25.3-11.6c-8.5-3-17.3-4.7-26.5-5.2
|
||||
c-9.1-0.4-19.1,1.9-30,7.2c-4.8,2.3-9.6,4.7-14.4,7.1c0-6.5,0-13,0-19.5c4-2,8.5-4.5,13.5-7.4c4.9-2.9,9.4-6.5,13.5-10.8
|
||||
c4-4.4,7.4-9.9,10.2-16.5c2.8-6.7,4.2-15,4.2-24.9c0-90.7,0-181.4,0-272.1c0-9.6-3.6-15.3-10.9-17.1c-7.3-1.7-17.4,0.5-30.4,7
|
||||
c0-5.3,1.9-10.8,5.6-16.5c3.7-5.7,10.2-10.9,19.5-15.2c8-3.8,15.2-5.6,21.6-5.7c6.3,0,11.5,1.2,15.6,3.5
|
||||
c4-5.7,9.2-11.3,15.6-16.6c6.3-5.3,13.5-9.6,21.6-12.6c9.3-3.5,15.8-3.8,19.5-1.3c3.7,2.6,5.6,6.5,5.6,11.8
|
||||
c-12.7,4.4-22.8,10.7-30.2,18.6c-7.4,8-11.1,16.8-11.1,26.4C344,361.8,344,456.7,344,551.6z"/>
|
||||
<path fill="#333333" d="M574.3,544.3c-15.5,2.2-29.8,1.8-43-1.1c-13.2-3-24.7-8.3-34.6-16c-9.9-7.7-17.6-17.8-23.2-30.4
|
||||
c-5.6-12.6-8.4-27.5-8.4-44.8c0-17.6,2.8-34,8.4-49c5.6-15,13.3-28.4,23.2-40c9.9-11.6,21.4-21.1,34.6-28.3
|
||||
c13.2-7.2,27.5-12,43-14.2c15.2-2.1,29.4-1.4,42.7,2c13.3,3.5,24.9,9.6,34.8,18.4c9.9,8.8,17.6,19.9,23.2,33.2
|
||||
c5.6,13.4,8.4,28.9,8.4,46.5c0,17.6-2.8,33.5-8.4,47.8c-5.6,14.2-13.3,26.6-23.2,37.1c-9.9,10.6-21.5,19.1-34.8,25.7
|
||||
C603.7,537.9,589.5,542.2,574.3,544.3z M574.8,522.9c11.4-1.6,21.2-5.3,29.3-11.1c8-5.9,14.7-13.3,20-22.3
|
||||
c5.3-9,9.1-19.2,11.6-30.5c2.5-11.4,3.7-23.2,3.7-35.6c0-12.1-1.2-23.4-3.7-34.1c-2.5-10.6-6.3-19.7-11.6-27.1
|
||||
c-5.3-7.4-12-13-20.2-16.7c-8.2-3.7-18-4.8-29.5-3.2c-11.5,1.6-21.3,5.4-29.5,11.3c-8.2,5.9-14.9,13.3-20.2,22.2
|
||||
c-5.3,8.9-9.1,19-11.6,30.4c-2.5,11.3-3.7,23.2-3.7,35.6c0,12.4,1.2,23.9,3.7,34.5c2.5,10.6,6.4,19.7,11.8,27.2
|
||||
c5.4,7.5,12.2,13,20.4,16.5C553.5,523.6,563.3,524.5,574.8,522.9z"/>
|
||||
<path fill="#333333" d="M820,535.9c-18.3-1.1-32.6-5.3-43-12.9c-10.4-7.6-18.2-16.9-23.4-28.1c-5.3-11.2-8.6-23.3-10-36.3
|
||||
c-1.4-13-2.1-25.2-2.1-36.7c0-17-0.2-30.4-0.7-40.2c-0.5-9.8-1.7-17.1-3.7-22.1c-2-4.9-5-8.1-9.1-9.5c-4-1.4-9.8-2-17.2-1.9
|
||||
c0-4.6,0-9.3,0-13.9c7.7-0.2,14.3-0.7,19.7-1.6c5.4-1,10.3-2.3,14.6-4.2c4.3-1.8,8.4-3.9,12.1-6.4c3.7-2.4,7.7-5,12.1-7.6
|
||||
c4.6,0.1,9.3,0.3,13.9,0.4c0,32.7,0,65.3,0,98c0,7.7,0.3,17.2,0.9,28.4c0.6,11.2,2.5,21.9,5.6,32.2c3.1,10.3,7.7,19.3,13.9,26.9
|
||||
c6.2,7.6,14.9,11.7,26,12.4c9.6,0.6,18.1-1.6,25.5-6.9c7.4-5.3,13.6-12.5,18.6-21.8c4.9-9.3,8.7-20.3,11.1-33
|
||||
c2.5-12.7,3.7-26.4,3.7-40.9c0-10.8-0.9-20.8-2.6-29.7c-1.7-9-4.6-16.8-8.8-23.4c-4.2-6.6-9.9-12.2-17.2-16.7
|
||||
c-7.3-4.5-16.5-7.8-27.6-9.8c3.4-5,8.1-9,14.2-11.7c6-2.8,12.1-3.9,18.3-3.3c2.5,0.2,5.2,0.8,8.1,1.7c2.9,0.9,5.6,2,8.1,3.3
|
||||
c2.5,1.3,4.6,3,6.3,5c1.7,2,2.6,4.3,2.6,6.8c10.2,1.1,16.8,6.2,19.7,15.2c2.9,9,4.4,23.3,4.4,42.8c0,18.3-1.1,36.4-3.2,54.4
|
||||
c-2.2,18-6.7,34-13.5,47.9c-6.8,14-16.3,25-28.6,33.1C856.7,533.8,840.4,537.1,820,535.9z"/>
|
||||
<path fill="#333333" d="M1024.7,508.2c0,6.2,1,11.5,3,15.8c2,4.4,4.5,8.1,7.4,11.1c2.9,3,6.3,5.4,10.2,7.2
|
||||
c3.9,1.8,7.7,3,11.4,3.7c0,4.6,0,9.3,0,13.9c-3.7-0.7-7.8-1.7-12.3-3c-4.5-1.3-9.2-2.7-14.2-4.2c-5-1.5-9.7-2.8-14.2-4.1
|
||||
c-4.5-1.2-8.6-2.1-12.3-2.8c-3.7-0.6-7.8-1-12.3-1.3c-4.5-0.2-9.2-0.4-14.2-0.6c-5-0.1-9.7-0.3-14.2-0.5
|
||||
c-4.5-0.2-8.6-0.5-12.3-1c0-4.6,0-9.3,0-13.9c8.4,1.2,15.8-0.1,22.3-3.8c6.5-3.7,9.8-11.5,9.8-23.6c0-26.6,0-53.2,0-79.9
|
||||
c0-11.4-0.2-21-0.7-28.7c-0.5-7.7-1.8-13.7-3.9-18.2c-2.2-4.5-5.3-7.9-9.3-10.2c-4-2.3-9.6-4-16.7-5c0-4.6,0-9.3,0-13.9
|
||||
c7.7,1.1,14.3,1.6,19.7,1.5c5.4-0.1,10.3-0.8,14.6-1.9c4.3-1.2,8.4-2.7,12.1-4.6c3.7-1.9,7.7-3.8,12.1-5.9
|
||||
c4.6,0.8,9.3,1.6,13.9,2.4C1024.7,394,1024.7,451.1,1024.7,508.2z M972.3,259.6c0-9.3,3.3-16.6,9.8-22
|
||||
c6.5-5.3,14.2-7.3,23.2-5.8c9,1.5,16.6,6,23,13.4c6.3,7.5,9.5,15.9,9.5,25.1c0,9.3-3.2,16.5-9.5,21.8c-6.3,5.2-14,7.1-23,5.6
|
||||
c-9-1.5-16.7-5.9-23.2-13.2C975.5,277.2,972.3,268.9,972.3,259.6z"/>
|
||||
<path fill="#333333" d="M1265.3,518.8c0-20.7-1-38.3-3-52.7c-2-14.4-5-26.3-9.1-35.6c-4-9.3-9.1-16.5-15.3-21.5
|
||||
c-6.2-5-13.3-8.5-21.4-10.4c-8.1-1.9-15.7-2.1-23-0.7c-7.3,1.4-13.7,4.9-19.3,10.3c-5.6,5.4-10,13.1-13.2,23.1
|
||||
c-3.3,10-4.9,22.4-4.9,37.2c0,22,0,44,0,65.9c0,6.2,1,11.5,3,16c2,4.5,4.5,8.3,7.4,11.4c2.9,3.1,6.3,5.7,10.2,7.6
|
||||
c3.9,2,7.7,3.4,11.4,4.2c0,4.6,0,9.3,0,13.9c-3.7-0.8-7.8-2-12.3-3.5c-4.5-1.5-9.2-3.1-14.2-4.8c-5-1.7-9.7-3.3-14.2-4.7
|
||||
c-4.5-1.4-8.6-2.6-12.3-3.4c-3.7-0.8-7.8-1.4-12.3-1.9c-4.5-0.5-9.2-0.9-14.2-1.3c-5-0.4-9.7-0.8-14.2-1.3
|
||||
c-4.5-0.4-8.6-1-12.3-1.7c0-4.6,0-9.3,0-13.9c8.4,1.6,15.8,0.8,22.3-2.5c6.5-3.3,9.8-11,9.8-23.1c0-25.8,0-51.7,0-77.5
|
||||
c0-11.5-0.2-21-0.7-28.7c-0.5-7.7-1.8-13.8-3.9-18.5c-2.2-4.6-5.3-8.2-9.3-10.7c-4-2.5-9.6-4.5-16.7-5.9c0-3.9,0-7.7,0-11.6
|
||||
c7.7,1.5,14.5,2.4,20.2,2.7c5.7,0.2,10.9-0.1,15.6-1c4.6-0.9,8.9-2.1,12.8-3.8c3.9-1.7,8-3.4,12.3-5.2c3.9,0.8,7.7,1.7,11.6,2.5
|
||||
c0,10.8,0,21.7,0,32.5c5.9-9.5,14-16.6,24.4-21.3c10.4-4.6,22.7-5.3,36.9-1.9c30.6,7.2,53.2,22,67.8,44.2
|
||||
c14.5,22.1,21.8,54.8,21.8,97.8c0,16.3,0,32.5,0,48.8c0,6.2,1,11.6,3,16.1c2,4.5,4.5,8.4,7.4,11.6c2.9,3.2,6.3,5.8,10.2,7.9
|
||||
c3.9,2.1,7.7,3.5,11.4,4.5c0,5.4,0,10.8,0,16.3c-4-1-7.6-2.1-10.7-3.4c-3.1-1.2-6.3-2.5-9.5-3.8c-3.2-1.3-7-2.7-11.1-4.2
|
||||
c-4.2-1.5-9.2-3-15.1-4.4c-4-1-8.6-1.9-13.7-2.7c-5.1-0.8-11.2-1.1-18.3-1c-1.4-3-2.8-5.9-4.2-8.9c2.2-1.6,3.9-5.2,5.1-10.6
|
||||
c1.2-5.4,2.1-11.8,2.6-19.1c0.5-7.3,0.8-15.1,0.9-23.5C1265.2,534.1,1265.3,526.2,1265.3,518.8z"/>
|
||||
<path fill="#333333" d="M1467.7,662.6c-16.7-4.1-31.4-10.6-44.1-19.4c-12.7-8.8-23.4-19.3-32-31.5
|
||||
c-8.7-12.2-15.2-25.9-19.5-40.9c-4.3-15-6.5-30.9-6.5-47.6c0-19.5,3-35.4,9.1-47.6c6-12.3,13.9-21.6,23.7-28.2
|
||||
c9.8-6.5,20.9-10.3,33.4-11.3c12.5-1,25.3,0.1,38.3,3.3c13.9,3.4,26.3,8.6,37.1,15.4c10.8,6.8,20.9,17.7,30.2,32.6
|
||||
c1.9-6.4,4.8-10.9,8.8-13.5c4-2.6,8.7-4.1,13.9-4.6c5.3-0.5,10.8-0.2,16.7,0.9c5.9,1,11.6,2.2,17.2,3.5c0,4.6,0,9.3,0,13.9
|
||||
c-4-0.9-7.4-0.8-10,0.3c-2.6,1.1-4.6,5.1-6,12.1c-1.4,7-2.3,17.6-2.8,31.9c-0.5,14.3-0.7,34.1-0.7,59.5c0,35.3,0,70.6,0,105.9
|
||||
c0,35.3-8.8,58.9-26.2,70.7c-17.5,11.8-45,13.2-82.4,3.9c-22.9-5.7-39.9-12.4-50.8-20.1c-11-7.7-16.5-15.3-16.5-22.7
|
||||
c-8.1-2-13.8-6.2-17.2-12.4c-3.4-6.3-5.1-12.2-5.1-17.8c0-5.3,1.2-9.5,3.7-12.8c2.5-3.3,5.6-5.6,9.3-7.2
|
||||
c3.7-1.5,7.7-2.4,11.8-2.6c4.2-0.2,8,0.1,11.4,1c0,8.4,1,16.4,3,24.2c2,7.8,5.3,15,10,21.5c4.6,6.6,10.6,12.4,17.9,17.5
|
||||
c7.3,5.1,16.2,8.9,26.7,11.5c9.3,2.3,17.8,3,25.5,2.1c7.7-0.9,14.3-3.9,19.7-9.1c5.4-5.2,9.7-12.7,12.8-22.5
|
||||
c3.1-9.8,4.6-22.4,4.6-37.9c0-10.1,0-20.1,0-30.2c-10.5,7.7-21.4,11.7-32.5,11.9C1489.1,666.4,1478.2,665.2,1467.7,662.6z
|
||||
M1474.7,642.9c10.5,2.6,19.8,2.5,27.9-0.4c8-2.8,14.6-7.8,19.7-15c5.1-7.1,9-15.9,11.6-26.2c2.6-10.4,3.9-21.9,3.9-34.6
|
||||
c0-12.1-1.2-23.7-3.7-35c-2.5-11.3-6.3-21.6-11.6-31.1c-5.3-9.5-11.8-17.5-19.7-24c-7.9-6.6-17.4-11.2-28.6-14
|
||||
c-12.1-3-22.3-3.3-30.6-0.9c-8.4,2.4-15,6.9-20,13.6c-5,6.7-8.5,15-10.7,25.2c-2.2,10.1-3.3,21.4-3.3,33.8c0,12.1,1,23.9,3,35.6
|
||||
c2,11.6,5.6,22.4,10.7,32.2c5.1,9.8,11.8,18.3,20,25.4C1451.5,634.6,1462,639.8,1474.7,642.9z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#333333" d="M1770,616.1c-0.3-16.8-0.7-30.1-1.2-39.9c-0.5-9.8-1.6-17.4-3.5-22.9c-1.9-5.4-4.8-9.2-8.8-11.2
|
||||
c-4-2.1-9.8-3.8-17.2-5.1c0-4.6,0-9.3,0-13.9c7.7,1.4,14.3,2,19.7,2c5.4,0,10.3-0.6,14.6-1.8c4.3-1.2,8.4-2.7,12.1-4.6
|
||||
c3.7-1.9,7.7-3.9,12.1-6c4.6,0.7,9.3,1.4,13.9,2.1c0,32.7,0,65.3,0,98c0,18.6,1.1,34.4,3.2,47.4c2.2,13,5.3,23.7,9.5,32
|
||||
c4.2,8.3,9.1,14.6,14.9,18.7c5.7,4.2,12.1,6.7,19.3,7.5c6.8,0.8,13.6,0.1,20.4-2.1c6.8-2.2,13-6.4,18.6-12.5
|
||||
c5.6-6.1,10.1-14.3,13.5-24.5c3.4-10.2,5.1-22.9,5.1-38.1c0-7.6,0-15.2,0-22.8c0-11.4-0.2-21-0.7-28.6c-0.5-7.6-1.8-13.6-3.9-18
|
||||
c-2.2-4.4-5.3-7.6-9.3-9.7c-4-2.1-9.6-3.5-16.7-4.2c0-4.6,0-9.3,0-13.9c7.7,0.8,14.3,1,19.7,0.5c5.4-0.4,10.3-1.4,14.6-2.9
|
||||
c4.3-1.5,8.4-3.3,12.1-5.6c3.7-2.2,7.7-4.5,12.1-7c4.6,0.3,9.3,0.6,13.9,0.9c0,76,0,152,0,228c0,35.9-8.2,61.3-24.6,75.7
|
||||
c-16.4,14.4-42,20.1-76.6,16c-19.8-2.4-34.9-6.5-45.3-12c-10.4-5.5-15.6-11.4-15.6-17.6c-8.1-1.2-13.8-4.8-17.2-10.8
|
||||
c-3.4-6-5.1-11.7-5.1-17.3c0-5.3,1.2-9.6,3.7-13.1c2.5-3.5,5.6-6.2,9.3-8c3.7-1.9,7.7-3.2,11.8-3.8c4.2-0.6,8-0.7,11.4-0.2
|
||||
c0,7.7,0.9,15.1,2.8,22c1.9,6.9,4.9,13.2,9.1,18.9c4.2,5.7,9.5,10.4,16,14.2c6.5,3.8,14.4,6.3,23.7,7.3c8.4,1,15.9,0.4,22.5-1.7
|
||||
c6.7-2.1,12.4-6.2,17.2-12.2c4.8-6.1,8.5-14.2,11.1-24.5c2.6-10.3,3.9-23.2,3.9-38.7c0-14.4,0-28.8,0-43.2
|
||||
c-3.1,5-6.4,9.6-10,13.8c-3.6,4.2-7.9,7.6-13,10.4c-5.1,2.7-11.1,4.8-18.1,6c-7,1.2-15.2,1.3-24.8,0.1
|
||||
c-15.2-1.9-27.8-6-37.8-12.3c-10.1-6.2-18.3-14.6-24.6-25c-6.3-10.4-10.8-22.8-13.5-37.2C1771.8,650.3,1770.3,634.1,1770,616.1z
|
||||
"/>
|
||||
<path fill="#333333" d="M2122.4,748.3c-15.5,1.1-29.8-0.9-43-5.9c-13.2-5-24.7-12.4-34.6-22.3c-9.9-9.8-17.6-21.8-23.2-35.8
|
||||
c-5.6-14-8.4-29.7-8.4-47c0-17.6,2.8-33.2,8.4-46.8c5.6-13.6,13.3-25,23.2-34.5c9.9-9.5,21.4-16.8,34.6-22.1
|
||||
c13.2-5.2,27.5-8.3,43-9.4c15.2-1.1,29.4,0,42.7,3.4c13.3,3.3,24.9,9,34.8,17c9.9,8,17.6,18.3,23.2,31
|
||||
c5.6,12.7,8.4,27.8,8.4,45.5c0,17.6-2.8,33.9-8.4,48.8c-5.6,14.8-13.3,28-23.2,39.3c-9.9,11.3-21.5,20.4-34.8,27.1
|
||||
C2151.8,743.3,2137.6,747.3,2122.4,748.3z M2122.9,726.9c11.5-0.8,21.2-4.2,29.3-9.9c8-5.7,14.7-13.2,20-22.3
|
||||
c5.3-9.1,9.1-19.4,11.6-30.9c2.5-11.5,3.7-23.4,3.7-35.8c0-12.1-1.2-23.4-3.7-33.9c-2.5-10.5-6.3-19.4-11.6-26.7
|
||||
c-5.3-7.3-12-12.8-20.2-16.7c-8.2-3.8-18-5.3-29.5-4.5c-11.5,0.8-21.3,3.6-29.5,8.4c-8.2,4.9-14.9,11.3-20.2,19.3
|
||||
c-5.3,8-9.1,17.5-11.6,28.4c-2.5,10.9-3.7,22.5-3.7,34.9c0,12.4,1.2,24.1,3.7,35.2c2.5,11.1,6.4,20.8,11.8,29.2
|
||||
c5.4,8.4,12.2,14.9,20.4,19.4C2101.6,725.8,2111.4,727.7,2122.9,726.9z"/>
|
||||
<path fill="#333333" d="M2438.6,548.2c0-11.4-0.2-20.9-0.7-28.3c-0.5-7.4-1.8-12.7-3.9-16c-2.2-3.3-5.3-5-9.3-5.1
|
||||
c-4-0.1-9.6,1.2-16.7,3.8c0-4.6,0-9.3,0-13.9c7.7-2.9,14.3-5.9,19.7-8.9c5.4-3.1,10.3-6.4,14.6-10.1c4.3-3.6,8.4-7.5,12.1-11.6
|
||||
c3.7-4.1,7.7-8.4,12.1-13.1c4.6-2.1,9.3-4.2,13.9-6.3c0,54.9,0,109.9,0,164.8c0,6.2,1,10.8,3,13.9c2,3.1,4.5,5.2,7.4,6.3
|
||||
c2.9,1.1,6.3,1.2,10.2,0.4c3.9-0.8,7.7-2.1,11.4-4c0,5.4,0,10.8,0,16.3c-7.1,2.7-13.2,4.8-18.3,6.4c-5.1,1.7-11.5,4.3-19.3,7.8
|
||||
c-10.5,4.8-21.1,10.4-31.6,16.9c-1.5-0.9-3.1-1.8-4.6-2.7c0-8.7,0-17.3,0-26c-3.4,6.7-6.9,12.9-10.4,18.8
|
||||
c-3.6,5.9-7.8,11.4-12.8,16.5c-5,5.1-10.7,9.9-17.2,14.2c-6.5,4.3-14.2,8-23.2,11c-31,10.2-53.1,6.4-66.4-9.2
|
||||
c-13.3-15.7-20.3-41.7-20.9-77.8c-0.3-16.6-0.7-29.8-1.2-39.4c-0.5-9.6-1.6-16.8-3.5-21.5c-1.9-4.7-4.8-7.3-8.8-7.8
|
||||
c-4-0.5-9.8,0-17.2,1.5c0-4.6,0-9.3,0-13.9c7.7-1.5,14.3-3.4,19.7-5.5c5.4-2.1,10.3-4.6,14.6-7.5c4.3-2.9,8.4-6,12.1-9.4
|
||||
c3.7-3.4,7.7-7.1,12.1-11c4.6-1.2,9.3-2.5,13.9-3.8c0,32.7,0,65.3,0,98c0,18.9,1.2,34.3,3.7,46.3c2.5,12,6,21.2,10.4,27.6
|
||||
c4.5,6.4,9.8,10.3,16,11.6c6.2,1.3,13,0.8,20.4-1.7c8.7-2.9,16.6-8,23.9-15.1c7.3-7.1,13.5-15.8,18.6-25.8
|
||||
c5.1-10,9.1-21.1,11.8-33.2c2.8-12.1,4.2-24.5,4.2-37.2C2438.6,559,2438.6,553.6,2438.6,548.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path fill="#333333" d="M1286.6,691c-61.5,31.3-134.1,13.4-196.7-4.8c-34.3-9.9-68.6-20-102.6-31c-35.5-11.5-71-23.4-106.9-33.8
|
||||
c-63.9-18.5-130.6-33.9-197.5-25.6c-58.5,7.3-115.3,30.6-159.3,70.3c-10.4,9.3-19.3,19.8-28.5,30.3c-9.7,11-18.6,22.9-25.3,35.9
|
||||
c-6.3,12-10.3,25.1-12.1,38.5c-0.8,6-2.1,12.5-2,18.6c0,1.6,0.4,3.1,1.1,4.4c0.2,0.4,0.5,0.8,0.8,1.1c2.9,4.6,11.5,3.6,11.1-3
|
||||
c-0.1-1.4-0.4-2.7-1.1-3.9c0-2.2,0.5-4.4,0.9-6.5c0.7-4.3,0.9-8.7,1.6-13.1c2.5-14.7,8.3-28.3,16.2-40.9
|
||||
c8.3-13.3,19-25.1,29.5-36.8c9.9-11,21-21,32.8-29.9c47.5-35.9,107.2-53.9,166.3-55.6c32.8-1,65.1,4.2,97.1,11
|
||||
c36.6,7.8,72.5,18.4,108.2,29.7c68.2,21.6,136.1,45.4,205.7,62.3c54.8,13.3,114.8,19.4,166.7-7
|
||||
C1299.5,697.8,1293.4,687.5,1286.6,691z"/>
|
||||
<path fill="#333333" d="M2615.3,700.1c-19.5-21.1-46.2-31.3-74.7-29.9c-31.1,1.5-60.2,16.2-86,32.6c-23.5,15-45.3,32.5-67.9,48.8
|
||||
c-41.5,30-84.2,58.7-130.4,81c-44,21.3-95.6,39.1-144.9,29c-26.7-5.4-49.6-20.1-67.4-40.4c-5.1-5.8-13.6,2.7-8.5,8.5
|
||||
c16.4,18.6,37,32.7,60.7,40.3c22.6,7.2,47,7.6,70.4,4.4c52.1-7.3,101-31.8,145.7-58.5c10.4-6.2,20.5-12.7,30.6-19.3
|
||||
c1.7,1.5,4.1,2.1,6.7,0.6c28.2-16.6,55.8-35.1,87.2-45.2c25.8-8.3,55.9-10.6,80.3,3.2c37.7,21.3,33.5,71.2,13.1,103
|
||||
c-4.9,7.7-11,14.5-17.7,20.7c-5.7,5.3,2.8,13.7,8.5,8.5c30.4-28.1,48.1-74.8,29.1-114.1c-9-18.6-26.8-30.9-46.3-36.4
|
||||
c-29.2-8.1-60.8-1.3-88.1,10.3c-2.4,1-4.8,2.1-7.2,3.2c9.6-7,19.1-14,28.7-21c45.9-33.3,112.5-71.5,163.7-26.4
|
||||
c17.3,15.2,31.2,39.9,18.4,62.2c-3.9,6.7,6.5,12.7,10.3,6C2643.8,746.5,2633.2,719.5,2615.3,700.1z"/>
|
||||
<path fill="#333333" d="M2337.5,739.8c-8.2,10.5-19.4,19-29.9,27c-16.6,12.6-34.3,24-52.4,34.4c-19.3,11.1-39.5,20.9-60.6,28.2
|
||||
c-17.6,6.1-36.5,10.7-55.3,8.7c-9.7-1-19-5-25.8-12.1c-5.4-5.5-13.9,2.9-8.5,8.5c26.5,27.2,71.8,14.7,102.5,2.9
|
||||
c40.1-15.4,77.8-38.2,111.6-64.5c9.5-7.4,19.4-15.2,26.9-24.7C2350.6,742.3,2342.2,733.7,2337.5,739.8z"/>
|
||||
<path fill="#333333" d="M2477.5,771.9c-7.5-1.9-10.7,9.7-3.2,11.6c26.8,6.8,29.3,48.6,5.8,60.9c-6.8,3.6-0.8,13.9,6,10.3
|
||||
C2518.8,837.5,2514.2,781.1,2477.5,771.9z"/>
|
||||
<path fill="#333333" d="M2541.4,704.7c-7.2,2.7-4.1,14.3,3.2,11.6c14.1-5.4,26.8-1.8,36.6,9.6c9.1,10.6,13.1,26.8,11.2,40.5
|
||||
c-1.1,7.6,10.5,10.8,11.6,3.2C2608.9,734.2,2581.8,689.4,2541.4,704.7z"/>
|
||||
<path fill="#333333" d="M1268.6,730.3c-2.3,2.6-4.1,5.4-6.1,8.3c-1.4,2.1-4.5,3.3-6.6,4.5c-5,2.8-10.2,5-15.6,6.9
|
||||
c-11,3.9-22.5,6-34.1,7c-26.3,2.3-52.8-0.8-78.6-5.9c-53.1-10.4-104.3-29.5-154.2-50.2c-16.7-6.9-33.1-14.4-50-20.5
|
||||
c-18.2-6.6-36.9-12.1-55.8-16.6c-40.9-9.7-84.5-15.2-126.3-7.8c-20.2,3.6-39.6,11.2-55.7,24c-16.6,13.2-27.2,32.5-32.2,52.9
|
||||
c-1.8,7.5,9.7,10.7,11.6,3.2c21.9-89,136.4-76.2,204.3-59.6c18.5,4.5,36.8,10.2,54.7,16.8c17.4,6.5,34.2,14.2,51.4,21.3
|
||||
c50.3,20.6,101.9,39.4,155.5,49.3c26.6,4.9,54,7.5,81,4.5c12.1-1.4,23.9-3.9,35.3-8.2c5.4-2.1,10.7-4.5,15.8-7.4
|
||||
c2.1-1.2,5.2-2.5,6.8-4.4c2.7-3,4.5-6.6,7.2-9.6C1282.2,733,1273.7,724.5,1268.6,730.3z"/>
|
||||
<path fill="#333333" d="M1186.5,678.2c-63.2-0.3-125.1-22.6-181.5-49.5c-6.9-3.3-13,7-6,10.3c58.4,27.8,122.1,50.9,187.6,51.1
|
||||
C1194.3,690.2,1194.3,678.2,1186.5,678.2z"/>
|
||||
<path fill="#333333" d="M863.6,714.9c-60.2-41.2-189.5,1.3-162.2,88.3c2.3,7.3,13.9,4.2,11.6-3.2
|
||||
c-24.1-76.7,93.2-109.9,144.6-74.7C864,729.7,870,719.3,863.6,714.9z"/>
|
||||
<path fill="#333333" d="M620.9,663.8c-28.2,1.9-53.6,21.5-72.6,41c-19.1,19.6-38.5,46.7-38.9,75.2c-0.1,7.7,11.9,7.7,12,0
|
||||
c0.3-24.9,18-48.3,34.2-65.6c16.5-17.5,40.1-37,65.2-38.7C628.6,675.3,628.7,663.3,620.9,663.8z"/>
|
||||
<path fill="#333333" d="M2644.3,257.2c-65.6-33.2-140.2-35.9-210.5-17.1c-35.4,9.5-69.3,24.4-101,42.7
|
||||
c-32.2,18.5-63,39.7-94.9,58.7c-12.8,7.6-25.7,15-38.7,22.2c-1,0.3-1.9,0.5-2.9,0.8c-1.8,0.5-3,1.5-3.6,2.8
|
||||
c-48,26-98,48.4-150.9,62.6c-35.5,9.5-71.9,14.9-108.7,16c-39.5,1.2-79.3-2.6-118.5-7.7c24.6-3.2,48.7-9.5,71.4-19
|
||||
c27.9-11.7,51.3-28.7,72.8-49.8c24.3-23.8,45.6-50.4,68.4-75.6c17.5-19.3,36.1-37.9,57.1-53.4c17.2-12.7,35.8-23.6,55-33
|
||||
c20.5-10.1,42.3-18.2,65-22.1c18.6-3.2,39.1-2.6,55.4,8c6.5,4.2,12.5-6.2,6-10.3c-19.6-12.8-43.9-12.8-66.1-9
|
||||
c-26.2,4.5-51.5,15.2-75,27.4c-21.1,10.9-40.9,24-59.4,38.9c-26.4,21.3-48.6,47.2-70.8,72.7c-18.9,21.7-38,43.4-59.6,62.6
|
||||
c-19.2,17.1-42.7,29.3-66.8,37.8c-40.7,14.3-85.7,18.6-128.7,12.9c-61.1-11.2-121.6-25.7-182.2-39.4
|
||||
c-36.8-8.4-73.5-16.8-110.3-25.1c-34-7.7-68.5-15.7-100.1-30.8c-7-3.4-13.8-7.2-20.4-11.4c-6.5-4.2-12.5,6.2-6,10.3
|
||||
c28.6,18.3,61.8,28.3,94.5,36.6c34.6,8.7,69.5,16.4,104.3,24c63.7,14.1,127.2,29.5,191.2,42.2c0.6,0.4,1.3,0.7,2.1,0.9
|
||||
c8.9,2,17.9,3.5,27,4.7c10.2,1.9,20.4,3.6,30.7,5.3c78.4,12.8,158.5,20.7,237.2,6.3c69.5-12.7,134.9-41.2,196.5-75
|
||||
c30.4-8.5,59.3-21.4,88.3-33.7c30.6-13,63.3-26.4,96.6-29.7c45.1-4.5,94.6,1.9,134,25.4c17.6,10.5,32.3,25.1,41.2,43.7
|
||||
c10,20.9,9.7,45.1,4.6,67.3c-1.7,7.5,9.8,10.7,11.6,3.2c5.3-23.4,5.7-47.9-3.2-70.5c-7.9-19.9-22.2-36-39.4-48.4
|
||||
c-37.3-26.7-87.1-35.1-132.1-33.7c-51.1,1.5-96.8,23.1-143.2,42.6c13-7.9,25.9-16,38.8-24.1c31.8-20,63.4-39.2,98.6-52.8
|
||||
c68.2-26.4,144.3-33.3,213.7-7.6c8.6,3.2,17,7,25.2,11.2C2645.1,271,2651.2,260.7,2644.3,257.2z"/>
|
||||
<path fill="#333333" d="M2477.7,360.2c-6.8,3.7-0.8,14,6,10.3c17.6-9.5,30.5,7.2,37.9,21.4c8.4,16.1,14.3,36.6,13,54.9
|
||||
c-0.6,7.7,11.4,7.7,12,0C2549,414,2523.5,335.5,2477.7,360.2z"/>
|
||||
<path fill="#333333" d="M2621.6,279.7c-36.8-35.9-97.1-26.5-139.1-5.7c-6.9,3.4-0.9,13.8,6,10.3c37-18.3,91.9-28,124.6,3.9
|
||||
C2618.6,293.6,2627.1,285.1,2621.6,279.7z"/>
|
||||
<path fill="#333333" d="M2247.4,139.1c-31.4-7.7-63.6-7.7-95,0.3c-30.8,7.8-59.4,23.1-85.9,40.4c-11.7,7.6-23.7,15.4-34,24.8
|
||||
c-8.1,7.3-15.1,15.7-21.9,24.2c-16.1,20-30.5,41.4-46,61.9c-10.6,14-21.9,27.8-34.9,39.7c-10,9.1-22.3,18.1-36.2,19.5
|
||||
c-7.6,0.8-7.7,12.7,0,12c20.6-2.1,37.4-15.4,51.6-29.7c17.9-18,32.8-39.1,47.6-59.6c14-19.3,27.8-39.3,44.8-56.2
|
||||
c9.4-9.3,20.7-16.6,31.7-24c13.6-9.1,27.6-17.5,42.3-24.8c41.6-20.7,87.3-28.1,132.7-16.9C2251.8,152.5,2254.9,141,2247.4,139.1z"
|
||||
/>
|
||||
<path fill="#333333" d="M1737,390.8c-51-8.9-102.7-13.4-153.6-22.7c-51.4-9.4-103.3-23.7-146.9-53.4c-6.4-4.4-12.4,6-6,10.3
|
||||
c43.5,29.5,95,44.2,146.1,53.9c52.1,9.9,105.1,14.3,157.4,23.4C1741.3,403.7,1744.6,392.1,1737,390.8z"/>
|
||||
<path fill="#333333" d="M2170.9,336.3c-26.2,13.2-51.6,28-78.2,40.5c-26.3,12.4-54.8,23.2-84.2,23.4c-7.7,0.1-7.7,12,0,12
|
||||
c30.3-0.2,59.3-10.9,86.6-23.3c27.9-12.7,54.4-28.4,81.8-42.2C2183.9,343.2,2177.8,332.8,2170.9,336.3z"/>
|
||||
<path fill="#333333" d="M888.8,272.5c-41.7-34-101-40.2-152.8-41.6c-55.4-1.4-111.3,4.4-165.2,17.5
|
||||
c-55.4,13.4-111.1,36.7-150.6,79.2c-5.2,5.7,3.2,14.1,8.5,8.5c41.6-44.8,101.1-66.7,159.5-79.4c54-11.7,109.9-16.1,165.1-13.1
|
||||
c43,2.4,92.6,9.2,127.1,37.4C886.3,285.8,894.8,277.4,888.8,272.5z"/>
|
||||
<path fill="#333333" d="M904.4,242.8c-103.2-76.8-242-68.2-357.9-27.9c-5.3,1.8-10.6,3.8-15.8,5.8c-7.2,2.7-4.1,14.3,3.2,11.6
|
||||
c114.9-43.5,254.5-57.4,359.8,17.6c1.6,1.1,3.1,2.3,4.6,3.4C904.5,257.8,910.5,247.4,904.4,242.8z"/>
|
||||
<path fill="#333333" d="M248.5,441.4c-4.5-25.1-11.1-50.2-9.8-76c0.4-7.7-11.6-7.7-12,0c-1.2,23.8,4.1,47.3,8.7,70.6
|
||||
c4.7,23.9,7,47.1,4.6,71.4c-4.2,44-20.9,85.7-52.3,117.4c-5.4,5.5,3,14,8.5,8.5c32-32.3,49.1-73.7,54.9-118.4
|
||||
C254.4,490,253,466,248.5,441.4z"/>
|
||||
<path fill="#333333" d="M207.3,474.1c-1.6-7.6-13.1-4.4-11.6,3.2c5.7,27.2,3.1,53.4-7.2,79.2c-9.9,24.8-24.6,47.5-35.3,72
|
||||
c-3.1,7,7.3,13.1,10.3,6c11.2-25.4,26-49.1,36.6-74.8C211.4,532.1,213.4,503.1,207.3,474.1z"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="294.3" y="2162.7" fill="#E4DAD8" width="656.6" height="226.5"/>
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#333333" d="M474.5,2441.4h-99.4v-329.7h99.4v28.8l-58-2.8v105.4l58,10.7v22.3l-65.9-2.8v133.3l65.9-3.7V2441.4z"/>
|
||||
<path fill="#333333" d="M538.1,2441.4l-27.4-329.7h29.3l27.9,318.1h2.3l31.1-313.4h30.6l-45,325H538.1z"/>
|
||||
<path fill="#333333" d="M758.2,2441.4h-99.4v-329.7h99.4v28.8l-58-2.8v105.4l58,10.7v22.3l-65.9-2.8v133.3l65.9-3.7V2441.4z"/>
|
||||
<path fill="#333333" d="M828.7,2113.1c12.7-0.9,25.2-1.4,37.6-1.4h20c4.9,0,9,1.9,12.1,5.6c3.1,3.7,4.8,8.5,5.1,14.4l4.6,133.7
|
||||
l-20.9,27.4l37.1,138.4l-8.8,10.2h-27.4L864.5,2297l-23.7,2.3l-9.8,142.1h-21.4v-324.6C809.7,2115.3,816,2114.1,828.7,2113.1z
|
||||
M873.8,2262.7c1.9,0,2.8-0.9,2.8-2.8v-115.6l-7-7l-32,3.3l1.4,124L873.8,2262.7z"/>
|
||||
</g>
|
||||
<path fill="#333333" d="M941,2512c-7.6-13.9-22.3-21.5-36.7-26.3c-19.3-6.5-40.2-8.4-60.4-9.2c-45-1.9-90.6,2.9-135,10.6
|
||||
c-19.5,3.4-38.5,7.8-57.3,13.9c-32.6,10.8-63.7,25.7-94.6,40.8c-53,25.9-108.4,54.5-168.2,59.5c-48.3,4.1-102.3-5.3-138-40.4
|
||||
c-15.9-15.6-29-37.1-31-59.6c-1.9-21.5,5.1-43.4,19.4-59.6c12.6-14.4,30.6-22.8,49.5-24.9c7.6-0.9,7.7-12.9,0-12
|
||||
c-24,2.7-46.1,13.4-61.3,32.5c-13.8,17.3-20.6,39.4-19.7,61.4c1,23.4,12.3,44,26.8,61.9c15.5,19.1,36.9,32.4,59.7,41.1
|
||||
c45.6,17.3,99,16,145.4,2.4c64.6-18.9,122.3-54.3,184.1-79.9c42.2-17.5,85.6-26.5,130.8-31.7c43.8-5,90.6-8.5,133.9,1.2
|
||||
c15.5,3.5,34.1,9.5,42.3,24.3C934.4,2524.8,944.7,2518.8,941,2512z"/>
|
||||
<path fill="#333333" d="M912.2,2548c-20.9-31.5-61.2-39.1-96.5-39.4c-45.6-0.3-91.6,10.8-134.2,26.3c-23,8.4-45.6,18.5-66.6,31
|
||||
c-15.6,9.3-31.7,20.2-42.9,34.8c-4.7,6,3.7,14.6,8.5,8.5c9.5-12.3,22.5-21.7,35.6-29.9c17.6-11.1,36.6-20.1,55.9-27.9
|
||||
c42.3-17,87.9-28.9,133.6-30.6c33.2-1.3,76.1,2.9,96.3,33.3C906.1,2560.4,916.5,2554.4,912.2,2548z"/>
|
||||
<path fill="#333333" d="M308.2,2541.6c-22.8-4-41.7-17-44.3-41.3c-2.1-18.9,4.3-39,15.5-54.2c4.6-6.2-5.8-12.2-10.3-6
|
||||
c-13.2,17.8-19.9,42-16.7,64c4.1,27.8,26.1,44.4,52.6,49C312.6,2554.5,315.9,2543,308.2,2541.6z"/>
|
||||
<path fill="#333333" d="M418.1,2649.3c-48,5.3-98.9,10.1-145.9-3.8c-21.6-6.4-41.3-17.8-56.6-34.4
|
||||
c-17.8-19.4-27.1-44.5-33.2-69.7c-9.7-40.6-10.8-93.4,24.2-122.7c5.9-5-2.6-13.4-8.5-8.5c-43.5,36.4-37,102.8-22.6,151.3
|
||||
c7.2,24.4,19.8,47.3,38.4,64.9c16.5,15.6,37.3,25.7,59,31.8c46.9,13.1,97.5,8.4,145.2,3.1
|
||||
C425.7,2660.4,425.8,2648.4,418.1,2649.3z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<ellipse fill="#CFD3D2" cx="1289.3" cy="1150" rx="177.5" ry="149.3"/>
|
||||
<g>
|
||||
<path fill="#333333" d="M1189.4,1082.6v29.6h26.8v14.3h-26.8v55.6c0,12.8,3.6,20,14,20c4.9,0,8.5-0.6,10.9-1.3l0.9,14
|
||||
c-3.6,1.5-9.4,2.6-16.6,2.6c-8.7,0-15.8-2.8-20.2-7.9c-5.3-5.5-7.2-14.7-7.2-26.8v-56.2h-16v-14.3h16v-24.7L1189.4,1082.6z"/>
|
||||
<path fill="#333333" d="M1237.3,1064.1h18.7v64.3h0.4c3-5.3,7.7-10,13.4-13.2c5.5-3.2,12.1-5.3,19.2-5.3c13.8,0,36,8.5,36,44.1
|
||||
v61.3h-18.7v-59.2c0-16.6-6.2-30.7-23.8-30.7c-12.1,0-21.7,8.5-25.1,18.7c-1.1,2.6-1.3,5.3-1.3,8.9v62.2h-18.7V1064.1z"/>
|
||||
<path fill="#333333" d="M1365.8,1167.1c0.4,25.3,16.6,35.8,35.3,35.8c13.4,0,21.5-2.3,28.5-5.3l3.2,13.4
|
||||
c-6.6,3-17.9,6.4-34.3,6.4c-31.7,0-50.7-20.9-50.7-51.9c0-31.1,18.3-55.6,48.3-55.6c33.6,0,42.6,29.6,42.6,48.5
|
||||
c0,3.8-0.4,6.8-0.6,8.7H1365.8z M1420.8,1153.7c0.2-11.9-4.9-30.4-26-30.4c-18.9,0-27.2,17.5-28.7,30.4H1420.8z"/>
|
||||
</g>
|
||||
<path fill="#333333" d="M1470.6,1065.4c-20.5-29.4-51-47.3-83.7-60.5c-38.7-15.7-82.3-24.4-124.1-20.2c-24.1,2.4-48.7,5-72.2,11
|
||||
c-23.1,5.9-46.8,16.6-59.8,37.6c-1.4,2.2-1.1,4.4,0,6.1c-14.6,17.8-25.2,38.9-32,60.9c-10.5,33.8-12.1,72.2,0.4,105.7
|
||||
c13.5,36.1,43,63.5,78,78.5c18.4,7.9,37.8,12.3,57.5,15.7c21.2,3.7,42.7,5.9,64.2,6.9c21.7,1,43.6,0.5,65.2-1.9
|
||||
c19.3-2.1,39-5.5,57.1-12.7c27.9-11.1,50.6-31.9,64.1-58.7C1511.7,1181.2,1503.8,1113,1470.6,1065.4z M1427.3,1276.2
|
||||
c-15.8,8.3-33.8,12.2-51.4,15c-21.1,3.4-42.6,4.2-64,3.9c-21.5-0.3-43.1-2.1-64.4-5.3c-19.7-2.9-39.4-6.8-58.1-13.6
|
||||
c-34.9-12.8-65.2-38.5-78.7-73.7c-12.6-32.9-9.9-70.4,0.9-103.4c9.7-29.4,27.5-57.7,53.2-75.5c2.5-1.8,3.1-4.5,2.5-6.9
|
||||
c3.7-1.8,7.5-3.4,11.3-4.9c16.4-6.1,34-9.1,51.3-11.3c17.1-2.2,34.5-4.7,51.7-4.9c21-0.1,42,2.9,62.4,8
|
||||
c20.8,5.1,41.1,12.5,60.2,22.2c15.3,7.7,29.8,16.8,42,29c20.2,20.3,33,47.2,39,75C1497,1185.1,1479.7,1248.6,1427.3,1276.2z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#333333" d="M321.7,853.8c-2.6-19.1-21.2-33.7-39.3-36.6c-4-0.6-8-0.8-12-0.5c-0.6,0-1.2,0.2-1.7,0.3
|
||||
c-1.5-0.6-3.2-0.5-4.9,0.5c-17.3,10-24.9,30.3-20.4,49.4c4.8,20.5,23.9,30.3,43.8,26.9C307.8,890.2,324.7,875.9,321.7,853.8z
|
||||
M291.3,880.4c-15,4.7-30.5,1.5-35.8-14.7c-4.5-13.9,1-29.4,13.2-37.2c0.5,0.1,1.1,0.1,1.7,0.1c15.9-1.1,30.8,6.5,37.7,21.2
|
||||
C315,864.2,304.9,876.2,291.3,880.4z"/>
|
||||
<path fill="#333333" d="M1005.6,918.3c-2.6-19.1-21.2-33.7-39.3-36.6c-4-0.6-8-0.8-12-0.5c-0.6,0-1.2,0.2-1.7,0.3
|
||||
c-1.5-0.6-3.2-0.5-4.9,0.5c-17.3,10-24.9,30.3-20.4,49.4c4.8,20.5,23.9,30.3,43.8,26.9C991.7,954.7,1008.6,940.4,1005.6,918.3z
|
||||
M975.2,944.9c-15,4.7-30.5,1.5-35.8-14.7c-4.5-13.9,1-29.4,13.2-37.2c0.5,0.1,1.1,0.1,1.7,0.1c15.9-1.1,30.8,6.5,37.7,21.2
|
||||
C998.9,928.7,988.8,940.7,975.2,944.9z"/>
|
||||
<path fill="#333333" d="M1043.5,1324.9c-2.6-19.1-21.2-33.7-39.3-36.6c-4-0.6-8-0.8-12-0.5c-0.6,0-1.2,0.2-1.7,0.3
|
||||
c-1.5-0.6-3.2-0.5-4.9,0.5c-17.3,10-24.9,30.3-20.4,49.4c4.8,20.5,23.9,30.3,43.8,26.9C1029.5,1361.3,1046.5,1347.1,1043.5,1324.9
|
||||
z M1013.1,1351.5c-15,4.7-30.5,1.5-35.8-14.7c-4.5-13.9,1-29.4,13.2-37.2c0.5,0.1,1.1,0.1,1.7,0.1c15.9-1.1,30.8,6.5,37.7,21.2
|
||||
C1036.7,1335.3,1026.7,1347.3,1013.1,1351.5z"/>
|
||||
<path fill="#333333" d="M1630.6,1138c-2.6-19.1-21.2-33.7-39.3-36.6c-4-0.6-8-0.8-12-0.5c-0.6,0-1.2,0.2-1.7,0.3
|
||||
c-1.5-0.6-3.2-0.5-4.9,0.5c-17.3,10-24.9,30.3-20.4,49.4c4.8,20.5,23.9,30.3,43.8,26.9C1616.7,1174.4,1633.6,1160.2,1630.6,1138z
|
||||
M1600.2,1164.6c-15,4.7-30.5,1.5-35.8-14.7c-4.5-13.9,1-29.4,13.2-37.2c0.5,0.1,1.1,0.1,1.7,0.1c15.9-1.1,30.8,6.5,37.7,21.2
|
||||
C1623.9,1148.5,1613.8,1160.4,1600.2,1164.6z"/>
|
||||
<path fill="#333333" d="M2610.6,981.9c-2.6-19.1-21.2-33.7-39.3-36.6c-4-0.6-8-0.8-12-0.5c-0.6,0-1.2,0.2-1.7,0.3
|
||||
c-1.5-0.6-3.2-0.5-4.9,0.5c-17.3,10-24.9,30.3-20.4,49.4c4.8,20.5,23.9,30.3,43.8,26.9C2596.6,1018.3,2613.5,1004.1,2610.6,981.9z
|
||||
M2580.2,1008.5c-15,4.7-30.5,1.5-35.8-14.7c-4.5-13.9,1-29.4,13.2-37.2c0.5,0.1,1.1,0.1,1.7,0.1c15.9-1.1,30.8,6.5,37.7,21.2
|
||||
C2603.8,992.4,2593.8,1004.3,2580.2,1008.5z"/>
|
||||
<path fill="#333333" d="M2504.8,1177c-2.6-19.1-21.2-33.7-39.3-36.6c-4-0.6-8-0.8-12-0.5c-0.6,0-1.2,0.2-1.7,0.3
|
||||
c-1.5-0.6-3.2-0.5-4.9,0.5c-17.3,10-24.9,30.3-20.4,49.4c4.8,20.5,23.9,30.3,43.8,26.9C2490.8,1213.4,2507.8,1199.1,2504.8,1177z
|
||||
M2474.4,1203.6c-15,4.7-30.5,1.5-35.8-14.7c-4.5-13.9,1-29.4,13.2-37.2c0.5,0.1,1.1,0.1,1.7,0.1c15.9-1.1,30.8,6.5,37.7,21.2
|
||||
C2498,1187.4,2488,1199.3,2474.4,1203.6z"/>
|
||||
<path fill="#333333" d="M2586.4,1443.7c-2.6-19.1-21.2-33.7-39.3-36.6c-4-0.6-8-0.8-12-0.5c-0.6,0-1.2,0.2-1.7,0.3
|
||||
c-1.5-0.6-3.2-0.5-4.9,0.5c-17.3,10-24.9,30.3-20.4,49.4c4.8,20.5,23.9,30.3,43.8,26.9C2572.5,1480.1,2589.4,1465.8,2586.4,1443.7
|
||||
z M2556,1470.3c-15,4.7-30.5,1.5-35.8-14.7c-4.5-13.9,1-29.4,13.2-37.2c0.5,0.1,1.1,0.1,1.7,0.1c15.9-1.1,30.8,6.5,37.7,21.2
|
||||
C2579.7,1454.1,2569.6,1466,2556,1470.3z"/>
|
||||
<path fill="#333333" d="M2482.2,1049.5c-1-1-2-2-3-3c-2.9-2.9-7.7-2.9-10.6,0c-2.9,2.9-2.9,7.7,0,10.6c1,1,2,2,3,3
|
||||
c2.9,2.9,7.7,2.9,10.6,0C2485.1,1057.2,2485.1,1052.4,2482.2,1049.5z"/>
|
||||
<path fill="#333333" d="M2635.9,1038.3c-9.7,0-9.7,15,0,15S2645.6,1038.3,2635.9,1038.3z"/>
|
||||
<path fill="#333333" d="M2656.9,894.3c-9.7,0-9.7,15,0,15S2666.6,894.3,2656.9,894.3z"/>
|
||||
<path fill="#333333" d="M2515.2,1277.5c-2.9-2.9-7.7-2.9-10.6,0c-1,1-2,2-3,3c-2.9,2.9-2.9,7.7,0,10.6c2.9,2.9,7.7,2.9,10.6,0
|
||||
c1-1,2-2,3-3C2518.1,1285.2,2518.1,1280.4,2515.2,1277.5z"/>
|
||||
<path fill="#333333" d="M2593.9,1188.3c-9.7,0-9.7,15,0,15S2603.6,1188.3,2593.9,1188.3z"/>
|
||||
<path fill="#333333" d="M2662.2,1331.5c-1-1-2-2-3-3c-2.9-2.9-7.7-2.9-10.6,0c-2.9,2.9-2.9,7.7,0,10.6c1,1,2,2,3,3
|
||||
c2.9,2.9,7.7,2.9,10.6,0C2665.1,1339.2,2665.1,1334.4,2662.2,1331.5z"/>
|
||||
<path fill="#333333" d="M2290.9,1455.3c-9.7,0-9.7,15,0,15S2300.6,1455.3,2290.9,1455.3z"/>
|
||||
<path fill="#333333" d="M2563.9,1563.3c-9.7,0-9.7,15,0,15S2573.6,1563.3,2563.9,1563.3z"/>
|
||||
<path fill="#333333" d="M139.7,951.3h-5.1c-4.1,0-7.5,3.4-7.5,7.5s3.4,7.5,7.5,7.5h5.1c4.1,0,7.5-3.4,7.5-7.5
|
||||
S143.8,951.3,139.7,951.3z"/>
|
||||
<path fill="#333333" d="M226.7,753.3c-9.7,0-9.7,15,0,15S236.4,753.3,226.7,753.3z"/>
|
||||
<path fill="#333333" d="M376.7,978.3c-9.7,0-9.7,15,0,15S386.4,978.3,376.7,978.3z"/>
|
||||
<path fill="#333333" d="M515.3,886.2c-1.3-2.3-3.8-4-6.6-4c-4.1,0-7.5,3.4-7.5,7.5c0,1.9,0.6,3.7,1.4,5.4c1.7,3.3,5.5,5.1,9.1,5.1
|
||||
c4.1,0.1,7.5-3.5,7.5-7.5C519.2,889.9,517.6,887.5,515.3,886.2z"/>
|
||||
<path fill="#333333" d="M726.3,970.8c0.2-4.1-3.5-7.5-7.5-7.5c-3.6,0-6.4,2.5-7.2,5.7c-0.5,0.6-1,1.1-1.5,1.8
|
||||
c-1.3,1.7-1.7,3.9-1.8,6c-0.2,4.1,3.5,7.5,7.5,7.5c3.6,0,6.4-2.5,7.2-5.7c0.5-0.6,1-1.1,1.5-1.8
|
||||
C725.7,975.1,726.2,972.8,726.3,970.8z"/>
|
||||
<path fill="#333333" d="M775.8,861.3c-9.7,0-9.7,15,0,15S785.4,861.3,775.8,861.3z"/>
|
||||
<path fill="#333333" d="M134.5,1249.3c-4.6-2.2-9.8-3.2-14.4-0.2c-4.8,3-6.9,8.6-8.7,13.7c-1.3,3.9,1.5,8.2,5.2,9.2
|
||||
c4.1,1.1,7.9-1.4,9.2-5.2c0.3-0.8,0.5-1.5,0.8-2.2c0-0.1,0.1-0.2,0.1-0.3c0-0.1,0.1-0.2,0.2-0.4c0.2-0.4,0.4-0.8,0.6-1.2
|
||||
c3.5,1.3,7.5,0.5,9.5-3C139.1,1256.3,138.2,1251.1,134.5,1249.3z"/>
|
||||
<path fill="#333333" d="M334.7,1359.3c-9.7,0-9.7,15,0,15S344.4,1359.3,334.7,1359.3z"/>
|
||||
<path fill="#333333" d="M778.8,1308.3c-9.7,0-9.7,15,0,15S788.4,1308.3,778.8,1308.3z"/>
|
||||
<path fill="#333333" d="M979.8,1176.3c-9.7,0-9.7,15,0,15S989.5,1176.3,979.8,1176.3z"/>
|
||||
<path fill="#333333" d="M1165.8,891.3c-4.1,0-7.5,3.4-7.5,7.5v3c0,4.1,3.4,7.5,7.5,7.5s7.5-3.4,7.5-7.5v-3
|
||||
C1173.3,894.7,1169.9,891.3,1165.8,891.3z"/>
|
||||
<path fill="#333333" d="M1372.8,924.3c-9.7,0-9.7,15,0,15S1382.5,924.3,1372.8,924.3z"/>
|
||||
<path fill="#333333" d="M1510.1,851.5c-1-1-2-2-3-3c-2.9-2.9-7.7-2.9-10.6,0c-2.9,2.9-2.9,7.7,0,10.6c1,1,2,2,3,3
|
||||
c2.9,2.9,7.7,2.9,10.6,0S1513,854.4,1510.1,851.5z"/>
|
||||
<path fill="#333333" d="M1561.8,975.3c-9.7,0-9.7,15,0,15S1571.5,975.3,1561.8,975.3z"/>
|
||||
<path fill="#333333" d="M1445.3,1369c-2.2-3.7-6.6-4.5-10.3-2.7c-4.8,2.4-10.1,3.6-15,6c-3.7,1.8-4.6,6.9-2.7,10.3
|
||||
c2.2,3.7,6.6,4.5,10.3,2.7c4.8-2.4,10.1-3.6,15-6C1446.3,1377.5,1447.2,1372.4,1445.3,1369z"/>
|
||||
<path fill="#333333" d="M1564.8,1278.3c-9.7,0-9.7,15,0,15S1574.5,1278.3,1564.8,1278.3z"/>
|
||||
<path fill="#333333" d="M1771.9,867.3c-9.7,0-9.7,15,0,15S1781.5,867.3,1771.9,867.3z"/>
|
||||
<path fill="#333333" d="M2027.4,922.2c-1.3-2.3-3.7-3.9-6.5-3.9c-4,0-7.6,3.4-7.5,7.5c0.1,3.7,1.8,7.2,5.1,9.1
|
||||
c1.6,0.9,3.5,1.4,5.4,1.4c4.1,0,7.5-3.4,7.5-7.5C2031.4,925.9,2029.8,923.5,2027.4,922.2z"/>
|
||||
<path fill="#333333" d="M2368.9,879.3c-9.7,0-9.7,15,0,15S2378.6,879.3,2368.9,879.3z"/>
|
||||
<path fill="#333333" d="M1573.8,1725.4h-9c-4.1,0-7.5,3.4-7.5,7.5s3.4,7.5,7.5,7.5h9c4.1,0,7.5-3.4,7.5-7.5
|
||||
S1577.9,1725.4,1573.8,1725.4z"/>
|
||||
<path fill="#333333" d="M1768.9,1589.5c-4.1,0-7.5,3.4-7.5,7.5v3.8c0,4.1,3.4,7.5,7.5,7.5s7.5-3.4,7.5-7.5v-3.8
|
||||
C1776.4,1592.9,1772.9,1589.5,1768.9,1589.5z"/>
|
||||
<path fill="#333333" d="M1741.9,1473.3c-9.7,0-9.7,15,0,15S1751.5,1473.3,1741.9,1473.3z"/>
|
||||
<path fill="#333333" d="M2020.9,1437.3c-9.7,0-9.7,15,0,15S2030.6,1437.3,2020.9,1437.3z"/>
|
||||
<path fill="#333333" d="M1753.9,1929.4c-9.7,0-9.7,15,0,15S1763.5,1929.4,1753.9,1929.4z"/>
|
||||
<path fill="#333333" d="M1888.9,2025.4c-1.1,0-2.1,0.2-3,0.7c-2.7-1.2-6-0.7-8.3,1.5c-2.8,2.8-2.9,7.8,0,10.6
|
||||
c2.5,2.4,6.1,4.8,9.8,4.4c2.4-0.2,4.8-0.9,6.4-2.9c1.7-2.1,2.5-4.1,2.6-6.9C1896.5,2028.8,1892.8,2025.4,1888.9,2025.4z"/>
|
||||
<path fill="#333333" d="M2077.9,1926.4c-9.7,0-9.7,15,0,15S2087.6,1926.4,2077.9,1926.4z"/>
|
||||
<path fill="#333333" d="M2182.9,2022.4c-9.7,0-9.7,15,0,15S2192.6,2022.4,2182.9,2022.4z"/>
|
||||
<path fill="#333333" d="M2389.9,1932.4c-9.7,0-9.7,15,0,15S2399.6,1932.4,2389.9,1932.4z"/>
|
||||
<path fill="#333333" d="M2551.9,2004.4c-4.1,0-7.5,3.4-7.5,7.5v3c0,4.1,3.4,7.5,7.5,7.5s7.5-3.4,7.5-7.5v-3
|
||||
C2559.4,2007.8,2556,2004.4,2551.9,2004.4z"/>
|
||||
<path fill="#333333" d="M201,165.5c-3.1,0-5.7,1.9-6.9,4.6c-2.7,1.1-4.6,3.8-4.6,6.9c0,4.1,3.4,7.5,7.5,7.5c2.1,0,4-0.6,5.9-1.6
|
||||
c3.6-1.9,5.5-6,5.6-9.9C208.6,168.9,205,165.5,201,165.5z"/>
|
||||
<path fill="#333333" d="M577,57.5c-2.6,0-5.5,0-8,1c-4.7,1.9-7.3,5.4-7.5,10.5c-0.2,4.1,3.5,7.5,7.5,7.5c2.9,0,5.3-1.6,6.6-4
|
||||
c0.5,0,1,0,1.4,0c4.1,0,7.5-3.4,7.5-7.5C584.5,60.9,581.1,57.5,577,57.5z"/>
|
||||
<path fill="#333333" d="M1169,253.5h-4c-4.1,0-7.5,3.4-7.5,7.5s3.4,7.5,7.5,7.5h4c4.1,0,7.5-3.4,7.5-7.5S1173.1,253.5,1169,253.5z
|
||||
"/>
|
||||
<path fill="#333333" d="M985,137.5c-9.7,0-9.7,15,0,15S994.7,137.5,985,137.5z"/>
|
||||
<path fill="#333333" d="M1381,141.5c-9.7,0-9.7,15,0,15S1390.7,141.5,1381,141.5z"/>
|
||||
<path fill="#333333" d="M1577,277.5c-9.7,0-9.7,15,0,15S1586.7,277.5,1577,277.5z"/>
|
||||
<path fill="#333333" d="M1762.3,203.7c-1.3-1.3-2.7-2.7-4-4c-2.9-2.9-7.7-2.9-10.6,0c-2.9,2.9-2.9,7.7,0,10.6c1.3,1.3,2.7,2.7,4,4
|
||||
c2.9,2.9,7.7,2.9,10.6,0C1765.2,211.4,1765.2,206.6,1762.3,203.7z"/>
|
||||
<path fill="#333333" d="M2193,257.5c-9.7,0-9.7,15,0,15S2202.7,257.5,2193,257.5z"/>
|
||||
<path fill="#333333" d="M2349,405.5c-9.7,0-9.7,15,0,15S2358.7,405.5,2349,405.5z"/>
|
||||
<path fill="#333333" d="M2417,169.5c-9.7,0-9.7,15,0,15S2426.7,169.5,2417,169.5z"/>
|
||||
<path fill="#333333" d="M2621,513.5c-9.7,0-9.7,15,0,15S2630.7,513.5,2621,513.5z"/>
|
||||
<path fill="#333333" d="M141,1649.5c-9.7,0-9.7,15,0,15S150.7,1649.5,141,1649.5z"/>
|
||||
<path fill="#333333" d="M241,1709.5c-9.7,0-9.7,15,0,15S250.7,1709.5,241,1709.5z"/>
|
||||
<path fill="#333333" d="M121,1917.5c-9.7,0-9.7,15,0,15S130.7,1917.5,121,1917.5z"/>
|
||||
<path fill="#333333" d="M165,2177.5c-9.7,0-9.7,15,0,15S174.7,2177.5,165,2177.5z"/>
|
||||
<path fill="#333333" d="M621,2689.5c-9.7,0-9.7,15,0,15S630.7,2689.5,621,2689.5z"/>
|
||||
<path fill="#333333" d="M1009,2601.5h-8c-4.1,0-7.5,3.4-7.5,7.5s3.4,7.5,7.5,7.5h8c4.1,0,7.5-3.4,7.5-7.5
|
||||
S1013.1,2601.5,1009,2601.5z"/>
|
||||
<path fill="#333333" d="M1397,2057.5h-8c-4.1,0-7.5,3.4-7.5,7.5s3.4,7.5,7.5,7.5h8c4.1,0,7.5-3.4,7.5-7.5
|
||||
S1401.1,2057.5,1397,2057.5z"/>
|
||||
<path fill="#333333" d="M1421,2561.5c-9.7,0-9.7,15,0,15S1430.7,2561.5,1421,2561.5z"/>
|
||||
<path fill="#333333" d="M1821,2561.5c-9.7,0-9.7,15,0,15S1830.7,2561.5,1821,2561.5z"/>
|
||||
<path fill="#333333" d="M2028.6,2613c0.1-0.1,0.1-0.1,0.2-0.2c0.1-0.1,0.3-0.2,0.4-0.3c4-0.3,7.3-3.4,7.3-7.5c0-4-3.4-7.6-7.5-7.5
|
||||
c-8.3,0.3-15.2,7.2-15.5,15.5c-0.1,4.1,3.5,7.5,7.5,7.5c4.1,0,7.2-3.3,7.5-7.2c0,0,0,0,0-0.1
|
||||
C2028.5,2613.1,2028.6,2613.1,2028.6,2613z"/>
|
||||
<path fill="#333333" d="M2301,2549.5c-9.7,0-9.7,15,0,15S2310.7,2549.5,2301,2549.5z"/>
|
||||
<path fill="#333333" d="M2521,2569.5c-9.7,0-9.7,15,0,15S2530.7,2569.5,2521,2569.5z"/>
|
||||
<path fill="#333333" d="M2613,2289.5c-9.7,0-9.7,15,0,15S2622.7,2289.5,2613,2289.5z"/>
|
||||
<path fill="#333333" d="M1217,2577.5c-9.7,0-9.7,15,0,15S1226.7,2577.5,1217,2577.5z"/>
|
||||
<path fill="#333333" d="M1621,2673.5c-9.7,0-9.7,15,0,15S1630.7,2673.5,1621,2673.5z"/>
|
||||
<path fill="#333333" d="M989,2081.5c-9.7,0-9.7,15,0,15S998.7,2081.5,989,2081.5z"/>
|
||||
<path fill="#333333" d="M1013,2293.5c-9.7,0-9.7,15,0,15S1022.7,2293.5,1013,2293.5z"/>
|
||||
<path fill="#333333" d="M658.3,2043.7c-1.3-1.3-2.7-2.7-4-4c-2.9-2.9-7.7-2.9-10.6,0c-2.9,2.9-2.9,7.7,0,10.6c1.3,1.3,2.7,2.7,4,4
|
||||
c2.9,2.9,7.7,2.9,10.6,0C661.2,2051.4,661.2,2046.6,658.3,2043.7z"/>
|
||||
<path fill="#333333" d="M206.3,2303.7c-2.9-2.9-7.7-2.9-10.6,0c-1.3,1.3-2.7,2.7-4,4c-2.9,2.9-2.9,7.7,0,10.6
|
||||
c2.9,2.9,7.7,2.9,10.6,0c1.3-1.3,2.7-2.7,4-4C209.2,2311.4,209.2,2306.6,206.3,2303.7z"/>
|
||||
<path fill="#333333" d="M93,2377.5c-4.1,0-7.5,3.4-7.5,7.5v4c0,4.1,3.4,7.5,7.5,7.5s7.5-3.4,7.5-7.5v-4
|
||||
C100.5,2380.9,97.1,2377.5,93,2377.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"src": "assets/samples/K1_drawing.jpg",
|
||||
"source": "https://commons.wikimedia.org/wiki/File:K1_drawing.jpg",
|
||||
"credit": "<a href=\"https://commons.wikimedia.org/\">Wikimedia</a>"
|
||||
},
|
||||
{
|
||||
"src": "assets/samples/Cityscape Sunset_DFM3-01.jpg",
|
||||
"source": "https://www.vecteezy.com/vector-art/227400-beautiful-cityscape-at-sunset",
|
||||
"credit": "<a href=\"https://www.vecteezy.com/free-vector/building\">Building Vectors by Vecteezy</a>"
|
||||
},
|
||||
{
|
||||
"src": "assets/samples/Gum Tree Vector.jpg",
|
||||
"source": "https://www.vecteezy.com/vector-art/172177-gum-tree-vector",
|
||||
"credit": "<a href=\"https://www.vecteezy.com/free-vector/nature\">Nature Vectors by Vecteezy</a>"
|
||||
},
|
||||
{
|
||||
"src": "assets/samples/vectorstock_31191940.png",
|
||||
"source": "https://www.vectorstock.com/royalty-free-vector/dessert-poster-design-with-chocolate-cake-mousses-vector-31191940",
|
||||
"credit": "<a href=\"https://www.vectorstock.com/royalty-free-vector/dessert-poster-design-with-chocolate-cake-mousses-vector-31191940\">Vector image by VectorStock / vectorstock</a>"
|
||||
},
|
||||
{
|
||||
"src": "assets/samples/angel-luciano-LATYeZyw88c-unsplash-s.jpg",
|
||||
"source": "https://unsplash.com/photos/LATYeZyw88c",
|
||||
"credit": "<span>Photo by <a href=\"https://unsplash.com/@roaming_angel?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText\">Angel Luciano</a> on <a href=\"https://unsplash.com/s/photos/dog?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText\">Unsplash</a></span>"
|
||||
},
|
||||
{
|
||||
"src": "assets/samples/tank-unit-preview.png",
|
||||
"source": "https://opengameart.org/content/sideview-sci-fi-patreon-collection",
|
||||
"credit": "<span>Artwork by <a href=\"https://opengameart.org/content/sideview-sci-fi-patreon-collection\">Luis Zuno</a> on <a href=\"https://opengameart.org/\">opengameart.org</a></span>"
|
||||
}
|
||||
]
|
||||
@@ -1,366 +0,0 @@
|
||||
/******/ (function(modules) { // webpackBootstrap
|
||||
/******/ // install a JSONP callback for chunk loading
|
||||
/******/ function webpackJsonpCallback(data) {
|
||||
/******/ var chunkIds = data[0];
|
||||
/******/ var moreModules = data[1];
|
||||
/******/
|
||||
/******/
|
||||
/******/ // add "moreModules" to the modules object,
|
||||
/******/ // then flag all "chunkIds" as loaded and fire callback
|
||||
/******/ var moduleId, chunkId, i = 0, resolves = [];
|
||||
/******/ for(;i < chunkIds.length; i++) {
|
||||
/******/ chunkId = chunkIds[i];
|
||||
/******/ if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
|
||||
/******/ resolves.push(installedChunks[chunkId][0]);
|
||||
/******/ }
|
||||
/******/ installedChunks[chunkId] = 0;
|
||||
/******/ }
|
||||
/******/ for(moduleId in moreModules) {
|
||||
/******/ if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
|
||||
/******/ modules[moduleId] = moreModules[moduleId];
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ if(parentJsonpFunction) parentJsonpFunction(data);
|
||||
/******/
|
||||
/******/ while(resolves.length) {
|
||||
/******/ resolves.shift()();
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ };
|
||||
/******/
|
||||
/******/
|
||||
/******/ // The module cache
|
||||
/******/ var installedModules = {};
|
||||
/******/
|
||||
/******/ // object to store loaded and loading chunks
|
||||
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
|
||||
/******/ // Promise = chunk loading, 0 = chunk loaded
|
||||
/******/ var installedChunks = {
|
||||
/******/ "main": 0
|
||||
/******/ };
|
||||
/******/
|
||||
/******/
|
||||
/******/
|
||||
/******/ // script path function
|
||||
/******/ function jsonpScriptSrc(chunkId) {
|
||||
/******/ return __webpack_require__.p + "" + chunkId + ".bootstrap.js"
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // object to store loaded and loading wasm modules
|
||||
/******/ var installedWasmModules = {};
|
||||
/******/
|
||||
/******/ function promiseResolve() { return Promise.resolve(); }
|
||||
/******/
|
||||
/******/ var wasmImportObjects = {
|
||||
/******/ "../pkg/vtracer_webapp_bg.wasm": function() {
|
||||
/******/ return {
|
||||
/******/ "./vtracer_webapp_bg.js": {
|
||||
/******/ "__wbindgen_object_drop_ref": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbindgen_object_drop_ref"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbindgen_string_new": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbindgen_string_new"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_new_59cb74e423758ede": function() {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_new_59cb74e423758ede"]();
|
||||
/******/ },
|
||||
/******/ "__wbg_stack_558ba5917b466edd": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_stack_558ba5917b466edd"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_error_4bb6c2a97407129a": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_error_4bb6c2a97407129a"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_instanceof_Window_adf3196bdc02b386": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_instanceof_Window_adf3196bdc02b386"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_document_6cc8d0b87c0a99b9": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_document_6cc8d0b87c0a99b9"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_createElementNS_ea14cb45a87a0719": function(p0i32,p1i32,p2i32,p3i32,p4i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_createElementNS_ea14cb45a87a0719"](p0i32,p1i32,p2i32,p3i32,p4i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_getElementById_0cb6ad9511b1efc0": function(p0i32,p1i32,p2i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_getElementById_0cb6ad9511b1efc0"](p0i32,p1i32,p2i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_setAttribute_727bdb9763037624": function(p0i32,p1i32,p2i32,p3i32,p4i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_setAttribute_727bdb9763037624"](p0i32,p1i32,p2i32,p3i32,p4i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_prepend_fa995bb42f6e2983": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_prepend_fa995bb42f6e2983"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_debug_d101e002eb92f20b": function(p0i32,p1i32,p2i32,p3i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_debug_d101e002eb92f20b"](p0i32,p1i32,p2i32,p3i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_error_cb872335132b1ef7": function(p0i32,p1i32,p2i32,p3i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_error_cb872335132b1ef7"](p0i32,p1i32,p2i32,p3i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_info_a25afde0ff8cd04a": function(p0i32,p1i32,p2i32,p3i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_info_a25afde0ff8cd04a"](p0i32,p1i32,p2i32,p3i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_log_3bafd82835c6de6d": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_log_3bafd82835c6de6d"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_log_64f566ae90a6c43c": function(p0i32,p1i32,p2i32,p3i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_log_64f566ae90a6c43c"](p0i32,p1i32,p2i32,p3i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_warn_f632d7d3f55682b6": function(p0i32,p1i32,p2i32,p3i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_warn_f632d7d3f55682b6"](p0i32,p1i32,p2i32,p3i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_instanceof_CanvasRenderingContext2d_5b86ec94bce38d5b": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_instanceof_CanvasRenderingContext2d_5b86ec94bce38d5b"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_getImageData_888c08c04395524a": function(p0i32,p1f64,p2f64,p3f64,p4f64) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_getImageData_888c08c04395524a"](p0i32,p1f64,p2f64,p3f64,p4f64);
|
||||
/******/ },
|
||||
/******/ "__wbg_instanceof_HtmlCanvasElement_4f5b5ec6cd53ccf3": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_instanceof_HtmlCanvasElement_4f5b5ec6cd53ccf3"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_width_a22f9855caa54b53": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_width_a22f9855caa54b53"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_height_9a404a6b3c61c7ef": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_height_9a404a6b3c61c7ef"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_getContext_37ca0870acb096d9": function(p0i32,p1i32,p2i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_getContext_37ca0870acb096d9"](p0i32,p1i32,p2i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_data_c2cd7a48734589b2": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_data_c2cd7a48734589b2"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_call_8e95613cc6524977": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_call_8e95613cc6524977"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbindgen_object_clone_ref": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbindgen_object_clone_ref"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_newnoargs_f3b8a801d5d4b079": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_newnoargs_f3b8a801d5d4b079"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbg_self_07b2f89e82ceb76d": function() {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_self_07b2f89e82ceb76d"]();
|
||||
/******/ },
|
||||
/******/ "__wbg_window_ba85d88572adc0dc": function() {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_window_ba85d88572adc0dc"]();
|
||||
/******/ },
|
||||
/******/ "__wbg_globalThis_b9277fc37e201fe5": function() {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_globalThis_b9277fc37e201fe5"]();
|
||||
/******/ },
|
||||
/******/ "__wbg_global_e16303fe83e1d57f": function() {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbg_global_e16303fe83e1d57f"]();
|
||||
/******/ },
|
||||
/******/ "__wbindgen_is_undefined": function(p0i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbindgen_is_undefined"](p0i32);
|
||||
/******/ },
|
||||
/******/ "__wbindgen_debug_string": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbindgen_debug_string"](p0i32,p1i32);
|
||||
/******/ },
|
||||
/******/ "__wbindgen_throw": function(p0i32,p1i32) {
|
||||
/******/ return installedModules["../pkg/vtracer_webapp_bg.js"].exports["__wbindgen_throw"](p0i32,p1i32);
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ },
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/
|
||||
/******/ // Check if module is in cache
|
||||
/******/ if(installedModules[moduleId]) {
|
||||
/******/ return installedModules[moduleId].exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = installedModules[moduleId] = {
|
||||
/******/ i: moduleId,
|
||||
/******/ l: false,
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
|
||||
/******/
|
||||
/******/ // Flag the module as loaded
|
||||
/******/ module.l = true;
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // This file contains only the entry chunk.
|
||||
/******/ // The chunk loading function for additional chunks
|
||||
/******/ __webpack_require__.e = function requireEnsure(chunkId) {
|
||||
/******/ var promises = [];
|
||||
/******/
|
||||
/******/
|
||||
/******/ // JSONP chunk loading for javascript
|
||||
/******/
|
||||
/******/ var installedChunkData = installedChunks[chunkId];
|
||||
/******/ if(installedChunkData !== 0) { // 0 means "already installed".
|
||||
/******/
|
||||
/******/ // a Promise means "currently loading".
|
||||
/******/ if(installedChunkData) {
|
||||
/******/ promises.push(installedChunkData[2]);
|
||||
/******/ } else {
|
||||
/******/ // setup Promise in chunk cache
|
||||
/******/ var promise = new Promise(function(resolve, reject) {
|
||||
/******/ installedChunkData = installedChunks[chunkId] = [resolve, reject];
|
||||
/******/ });
|
||||
/******/ promises.push(installedChunkData[2] = promise);
|
||||
/******/
|
||||
/******/ // start chunk loading
|
||||
/******/ var script = document.createElement('script');
|
||||
/******/ var onScriptComplete;
|
||||
/******/
|
||||
/******/ script.charset = 'utf-8';
|
||||
/******/ script.timeout = 120;
|
||||
/******/ if (__webpack_require__.nc) {
|
||||
/******/ script.setAttribute("nonce", __webpack_require__.nc);
|
||||
/******/ }
|
||||
/******/ script.src = jsonpScriptSrc(chunkId);
|
||||
/******/
|
||||
/******/ // create error before stack unwound to get useful stacktrace later
|
||||
/******/ var error = new Error();
|
||||
/******/ onScriptComplete = function (event) {
|
||||
/******/ // avoid mem leaks in IE.
|
||||
/******/ script.onerror = script.onload = null;
|
||||
/******/ clearTimeout(timeout);
|
||||
/******/ var chunk = installedChunks[chunkId];
|
||||
/******/ if(chunk !== 0) {
|
||||
/******/ if(chunk) {
|
||||
/******/ var errorType = event && (event.type === 'load' ? 'missing' : event.type);
|
||||
/******/ var realSrc = event && event.target && event.target.src;
|
||||
/******/ error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
|
||||
/******/ error.name = 'ChunkLoadError';
|
||||
/******/ error.type = errorType;
|
||||
/******/ error.request = realSrc;
|
||||
/******/ chunk[1](error);
|
||||
/******/ }
|
||||
/******/ installedChunks[chunkId] = undefined;
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ var timeout = setTimeout(function(){
|
||||
/******/ onScriptComplete({ type: 'timeout', target: script });
|
||||
/******/ }, 120000);
|
||||
/******/ script.onerror = script.onload = onScriptComplete;
|
||||
/******/ document.head.appendChild(script);
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // Fetch + compile chunk loading for webassembly
|
||||
/******/
|
||||
/******/ var wasmModules = {"0":["../pkg/vtracer_webapp_bg.wasm"]}[chunkId] || [];
|
||||
/******/
|
||||
/******/ wasmModules.forEach(function(wasmModuleId) {
|
||||
/******/ var installedWasmModuleData = installedWasmModules[wasmModuleId];
|
||||
/******/
|
||||
/******/ // a Promise means "currently loading" or "already loaded".
|
||||
/******/ if(installedWasmModuleData)
|
||||
/******/ promises.push(installedWasmModuleData);
|
||||
/******/ else {
|
||||
/******/ var importObject = wasmImportObjects[wasmModuleId]();
|
||||
/******/ var req = fetch(__webpack_require__.p + "" + {"../pkg/vtracer_webapp_bg.wasm":"bad37dda49ff45dee696"}[wasmModuleId] + ".module.wasm");
|
||||
/******/ var promise;
|
||||
/******/ if(importObject instanceof Promise && typeof WebAssembly.compileStreaming === 'function') {
|
||||
/******/ promise = Promise.all([WebAssembly.compileStreaming(req), importObject]).then(function(items) {
|
||||
/******/ return WebAssembly.instantiate(items[0], items[1]);
|
||||
/******/ });
|
||||
/******/ } else if(typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
/******/ promise = WebAssembly.instantiateStreaming(req, importObject);
|
||||
/******/ } else {
|
||||
/******/ var bytesPromise = req.then(function(x) { return x.arrayBuffer(); });
|
||||
/******/ promise = bytesPromise.then(function(bytes) {
|
||||
/******/ return WebAssembly.instantiate(bytes, importObject);
|
||||
/******/ });
|
||||
/******/ }
|
||||
/******/ promises.push(installedWasmModules[wasmModuleId] = promise.then(function(res) {
|
||||
/******/ return __webpack_require__.w[wasmModuleId] = (res.instance || res).exports;
|
||||
/******/ }));
|
||||
/******/ }
|
||||
/******/ });
|
||||
/******/ return Promise.all(promises);
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // expose the modules object (__webpack_modules__)
|
||||
/******/ __webpack_require__.m = modules;
|
||||
/******/
|
||||
/******/ // expose the module cache
|
||||
/******/ __webpack_require__.c = installedModules;
|
||||
/******/
|
||||
/******/ // define getter function for harmony exports
|
||||
/******/ __webpack_require__.d = function(exports, name, getter) {
|
||||
/******/ if(!__webpack_require__.o(exports, name)) {
|
||||
/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // define __esModule on exports
|
||||
/******/ __webpack_require__.r = function(exports) {
|
||||
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
|
||||
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
/******/ }
|
||||
/******/ Object.defineProperty(exports, '__esModule', { value: true });
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // create a fake namespace object
|
||||
/******/ // mode & 1: value is a module id, require it
|
||||
/******/ // mode & 2: merge all properties of value into the ns
|
||||
/******/ // mode & 4: return value when already ns object
|
||||
/******/ // mode & 8|1: behave like require
|
||||
/******/ __webpack_require__.t = function(value, mode) {
|
||||
/******/ if(mode & 1) value = __webpack_require__(value);
|
||||
/******/ if(mode & 8) return value;
|
||||
/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
|
||||
/******/ var ns = Object.create(null);
|
||||
/******/ __webpack_require__.r(ns);
|
||||
/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
|
||||
/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
|
||||
/******/ return ns;
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = function(module) {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ function getDefault() { return module['default']; } :
|
||||
/******/ function getModuleExports() { return module; };
|
||||
/******/ __webpack_require__.d(getter, 'a', getter);
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Object.prototype.hasOwnProperty.call
|
||||
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
|
||||
/******/
|
||||
/******/ // __webpack_public_path__
|
||||
/******/ __webpack_require__.p = "";
|
||||
/******/
|
||||
/******/ // on error function for async loading
|
||||
/******/ __webpack_require__.oe = function(err) { console.error(err); throw err; };
|
||||
/******/
|
||||
/******/ // object with all WebAssembly.instance exports
|
||||
/******/ __webpack_require__.w = {};
|
||||
/******/
|
||||
/******/ var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
|
||||
/******/ var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
|
||||
/******/ jsonpArray.push = webpackJsonpCallback;
|
||||
/******/ jsonpArray = jsonpArray.slice();
|
||||
/******/ for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
|
||||
/******/ var parentJsonpFunction = oldJsonpFunction;
|
||||
/******/
|
||||
/******/
|
||||
/******/ // Load entry module and return exports
|
||||
/******/ return __webpack_require__(__webpack_require__.s = "./bootstrap.js");
|
||||
/******/ })
|
||||
/************************************************************************/
|
||||
/******/ ({
|
||||
|
||||
/***/ "./bootstrap.js":
|
||||
/*!**********************!*\
|
||||
!*** ./bootstrap.js ***!
|
||||
\**********************/
|
||||
/*! no static exports found */
|
||||
/***/ (function(module, exports, __webpack_require__) {
|
||||
|
||||
eval("// A dependency graph that contains any wasm must all be imported\n// asynchronously. This `bootstrap.js` file does the single async import, so\n// that no one else needs to worry about it again.\n__webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, /*! ./index.js */ \"./index.js\"))\n .catch(e => console.error(\"Error importing `index.js`:\", e));\n\n\n//# sourceURL=webpack:///./bootstrap.js?");
|
||||
|
||||
/***/ })
|
||||
|
||||
/******/ });
|
||||
@@ -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,163 @@
|
||||
# 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 CurvePass {
|
||||
fn open(&self, geom: FittedGeom) -> FittedGeom; // endpoints pinned
|
||||
fn ring(&self, geom: FittedGeom) -> FittedGeom; // stays closed
|
||||
}
|
||||
|
||||
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 curve_passes: Vec<Box<dyn CurvePass>>,
|
||||
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))
|
||||
- either way, `CurvePass`es run on each fitted contour *before* paths are assembled — in mosaic mode that means once per shared boundary segment, so both faces reference the transformed geometry and the tessellation stays seam-free. Running them any later (on the `VectorDoc`) would re-fit the two copies of every shared boundary independently and reopen the seams.
|
||||
4. optimizer passes over the `VectorDoc`
|
||||
5. `SvgWriter` serializes
|
||||
|
||||
## Built-in implementations
|
||||
|
||||
- **Frontends** (selected by `Config::clustering`)
|
||||
- `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`.
|
||||
- `WatershedFrontend` — hierarchical watershed by volume on the 4-adjacency pixel graph (Cousty et al. TPAMI 2009; Najman, Cousty & Perret ISMM 2013), cut at `watershed_detail`. Split into `WatershedHierarchy::build` (expensive, image-only) and `cut` (near-instant), so `Session` re-cuts a cached hierarchy when the detail changes. Emits the merge tree as a stacked hierarchy (root first, refined regions on top — the color-cluster principle), so stacked mode stays seam-free and sub-pixel gaps show ancestor colors; in cutout the partition reaches the mosaic untouched (`merge_diff = 0`).
|
||||
- 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)
|
||||
- **CurvePasses** (selected by `Config::simplify`)
|
||||
- `SimplifyCurves { tolerance, corner_threshold }` — the paper.js `simplify` analogue: samples each smooth run of fitted cubics between corners and re-fits it with the fewest curves that stay within `tolerance` px (Schneider's algorithm via a current `flo_curves`, with tangents taken from the chain's own ends; visioncortex's internal copy is pinned to an old flo_curves and block-splits at 200 points, so it is not used here). A run is only replaced when the re-fit is strictly smaller, corners stay in place, open-segment endpoints are pinned bit-for-bit, and rings are seamed at their sharpest junction. Polylines pass through untouched.
|
||||
|
||||
## 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.
|
||||
- `CleanupPass` — drop zero-length and collinear-redundant segments *after* quantization. (Curve *simplification* is deliberately not an optimizer pass — see `CurvePass` above.)
|
||||
- `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`, `--clustering color-cluster|bw|watershed` (formerly `--colormode`), `--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.
|
||||