5 Commits

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

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

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

* - README_PY.md needed to be inside the cmdapp directory to display on PyPi.irg
->  v0.6.3
2023-09-08 19:43:13 +08:00
140 changed files with 44956 additions and 17075 deletions
+117
View File
@@ -0,0 +1,117 @@
# This file is autogenerated by maturin v1.2.3
# To update, run
#
# maturin generate-ci github
#
name: CI
on:
push:
tags:
- '*'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
linux:
runs-on: ubuntu-latest
strategy:
matrix:
target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
manylinux: auto
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
windows:
runs-on: windows-latest
strategy:
matrix:
target: [x64, x86]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
architecture: ${{ matrix.target }}
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
macos:
runs-on: macos-latest
strategy:
matrix:
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
- name: Upload wheels
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
sdist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@v3
with:
name: wheels
path: dist
release:
name: Release
runs-on: ubuntu-latest
if: "startsWith(github.ref, 'refs/tags/')"
needs: [linux, windows, macos, sdist]
steps:
- uses: actions/download-artifact@v3
with:
name: wheels
- name: Publish to PyPI
uses: PyO3/maturin-action@v1
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
with:
command: upload
args: --non-interactive --skip-existing *
-182
View File
@@ -1,182 +0,0 @@
# Python wheels for crates/vtracer-py (maturin). Regenerate the skeleton with:
# maturin generate-ci github -m crates/vtracer-py/Cargo.toml
name: Python
# Wheel builds are heavy (full platform matrix), so they run only on release
# tags and on-demand — not on every push/PR. Rust/wasm/Node CI (rust.yml) still
# gates ordinary commits.
on:
push:
tags:
- '*'
workflow_dispatch:
permissions:
contents: read
jobs:
linux:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: ubuntu-22.04
target: x86_64
- runner: ubuntu-22.04
target: x86
- runner: ubuntu-22.04
target: aarch64
- runner: ubuntu-22.04
target: armv7
- runner: ubuntu-22.04
target: s390x
- runner: ubuntu-22.04
target: ppc64le
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
manylinux: auto
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-linux-${{ matrix.platform.target }}
path: dist
musllinux:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: ubuntu-22.04
target: x86_64
- runner: ubuntu-22.04
target: x86
- runner: ubuntu-22.04
target: aarch64
- runner: ubuntu-22.04
target: armv7
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
manylinux: musllinux_1_2
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-musllinux-${{ matrix.platform.target }}
path: dist
windows:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: windows-latest
target: x64
python_arch: x64
- runner: windows-latest
target: x86
python_arch: x86
- runner: windows-11-arm
target: aarch64
python_arch: arm64
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.13
architecture: ${{ matrix.platform.python_arch }}
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-windows-${{ matrix.platform.target }}
path: dist
macos:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: macos-15-intel
target: x86_64
- runner: macos-latest
target: aarch64
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.platform.target }}
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
- name: Upload wheels
uses: actions/upload-artifact@v5
with:
name: wheels-macos-${{ matrix.platform.target }}
path: dist
sdist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist --manifest-path crates/vtracer-py/Cargo.toml
- name: Upload sdist
uses: actions/upload-artifact@v5
with:
name: wheels-sdist
path: dist
release:
name: Release
runs-on: ubuntu-latest
# Specifying a GitHub environment is optional, but strongly encouraged
environment: python
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
needs: [linux, musllinux, windows, macos, sdist]
permissions:
# Use to sign the release artifacts
id-token: write
# Used to upload release artifacts
contents: write
# Used to generate artifact attestation
attestations: write
steps:
- uses: actions/download-artifact@v6
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-path: 'wheels-*/*'
- name: Install uv
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: astral-sh/setup-uv@v7
- name: Publish to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
run: uv publish 'wheels-*/*'
-32
View File
@@ -1,32 +0,0 @@
name: Release
# Builds the `vtracer` CLI binary (crates/vtracer-cli) for each target.
on:
release:
types: [published]
jobs:
release:
strategy:
matrix:
include:
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: taiki-e/upload-rust-binary-action@v1
with:
bin: vtracer
target: ${{ matrix.target }}
# (required) GitHub token for uploading assets to GitHub Releases.
token: ${{ secrets.GITHUB_TOKEN }}
+9 -44
View File
@@ -1,57 +1,22 @@
name: Rust
on:
pull_request:
paths-ignore:
- '**.md'
- '.github/ISSUE_TEMPLATE/**'
push:
paths-ignore:
- '**.md'
- '.github/ISSUE_TEMPLATE/**'
branches:
- master
- 0.*.x
- ci-*
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }}
cancel-in-progress: true
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --workspace --verbose
- name: Test
run: cargo test --workspace --verbose
build:
wasm:
name: wasm-safety (core)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: rustup target add wasm32-unknown-unknown
- name: Build core for wasm32
run: cargo build --target wasm32-unknown-unknown -p vtracer
nodejs:
name: Node package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Build & test
working-directory: nodejs
run: |
wasm-pack build --target nodejs --out-dir pkg
node test.js
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
+1 -5
View File
@@ -1,8 +1,4 @@
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
.DS_Store
.vscode
+2 -92
View File
@@ -5,99 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## 1.0.0-alpha.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 BradleyRoth 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
## 0.6.0 - 2023-09-08
* Python Binding
## 0.6.5 - 2025-10-17
* Update `fastrand` to `2.3`
## 0.6.4 - 2024-03-29
* Update `visioncortex` version to `0.8.8`
## 0.6.3 - 2023-11-21
* New converter API https://github.com/visioncortex/vtracer/pull/59
## 0.6.1 - 2023-09-23
* Fixed "The two lines are parallel!"
### Python Binding
Thanks to the contribution of [@etjones](https://github.com/etjones), we now have an official Python binding! https://github.com/visioncortex/vtracer/pull/55
https://pypi.org/project/vtracer/0.6.10/
## 0.5.0 - 2022-10-09
* Handle transparent png images (cli) https://github.com/visioncortex/vtracer/pull/23
* Handle transparent png images (cli) (#23)
## 0.4.0 - 2021-07-23
@@ -111,10 +25,6 @@ https://pypi.org/project/vtracer/0.6.10/
* Use relative & closed paths
## 0.1.1 - 2020-11-01
* SVG namespace
## 0.1.0 - 2020-10-31
* Initial release
-62
View File
@@ -1,62 +0,0 @@
# 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.
-10
View File
@@ -1,10 +0,0 @@
# 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
Generated
-1090
View File
File diff suppressed because it is too large Load Diff
+2 -29
View File
@@ -1,34 +1,7 @@
[workspace]
members = [
"crates/vtracer",
"crates/vtracer-cli",
"crates/vtracer-bench",
]
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
# build. It is superseded by the crates/ workspace above.
exclude = [
"cmdapp",
"webapp",
# pyo3 extension-module cdylib; built with maturin, not the core workspace.
"crates/vtracer-py",
# wasm-bindgen cdylib; built with wasm-pack as the Node package's core.
"nodejs",
]
resolver = "2"
[workspace.package]
version = "1.0.0-alpha.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"
resolver = "2"
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2024 TSANG, Hao Fung
Copyright (c) 2022 Tsang Hao Fung
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
+66 -205
View File
@@ -4,244 +4,105 @@
<h1>VTracer</h1>
<p>
<strong>Raster to Vector Graphics Converter</strong>
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
</p>
<h3>
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64-setup.exe">Windows App</a>
<a href="//www.visioncortex.org/vtracer-docs">Article</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>
<a href="//www.visioncortex.org/vtracer/">Demo</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>
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
</h3>
<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>
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
</div>
## Introduction
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
Comparing to Potrace, 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 [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
Comparing to Adobe Illustrator's Image Trace, VTracer's output is much more compact as we adopt a stacking strategy and avoid producing shapes with holes.
Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrator/using/image-trace.html), VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
A technical description of the algorithm is on [visioncortex.org/vtracer-docs](//www.visioncortex.org/vtracer-docs).
## Desktop App
## Web App
![screenshot](docs/images/desktop-app.png)
VTracer and its [core library](//github.com/visioncortex/visioncortex) is implemented in [Rust](//www.rust-lang.org/). It provides us a solid foundation to develop robust and efficient algorithms and easily bring it to interactive applications. The webapp is a perfect showcase of the capability of the Rust + wasm platform.
VTracer App powered by the 1.0 tracing engine:
![screenshot](docs/images/screenshot-01.png)
+ **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
![screenshot](docs/images/screenshot-02.png)
## Console App
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
You can also install the program from source:
## Cmd App
```sh
cargo install vtracer-cli
visioncortex VTracer 0.4.0
A cmd app to convert images into vector graphics.
USAGE:
vtracer [OPTIONS] --input <input> --output <output>
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
--colormode <color_mode> True color image `color` (default) or Binary image `bw`
-p, --color_precision <color_precision> Number of significant bits to use in an RGB channel
-c, --corner_threshold <corner_threshold> Minimum momentary angle (degree) to be considered a corner
-f, --filter_speckle <filter_speckle> Discard patches smaller than X px in size
-g, --gradient_step <gradient_step> Color difference between gradient layers
--hierarchical <hierarchical>
Hierarchical clustering `stacked` (default) or non-stacked `cutout`. Only applies to color mode.
-i, --input <input> Path to input raster image
-m, --mode <mode> Curver fitting mode `pixel`, `polygon`, `spline`
-o, --output <output> Path to output vector graphics
--path_precision <path_precision> Number of decimal places to use in path string
--preset <preset> Use one of the preset configs `bw`, `poster`, `photo`
-l, --segment_length <segment_length>
Perform iterative subdivide smooth until all segments are shorter than this length
-s, --splice_threshold <splice_threshold> Minimum angle displacement (degree) to splice a spline
```
### Usage
```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'
```
./vtracer --input input.jpg --output output.svg
```
Full options:
## Library
```sh
Usage: vtracer [OPTIONS] [INPUT] [OUTPUT]
The library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
Arguments:
[INPUT] Input raster image (positional; or use --input)
[OUTPUT] Output SVG (positional; or use --output)
## Install
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 12.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: BradleyRoth 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
Download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
or
Install from source (Rust toolchain needed):
```
cargo install 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.
## In the wild
### New in 1.0
VTracer is used by the following products (feel free to add yours to the list):
- **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
**BradleyRoth 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 (BradleyRoth) 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)
<table>
<tbody>
<tr>
<td><a href="https://logo.aliyun.com/logo#/name"><img src="docs/images/aliyun-logo.png" width="250"/></a>
<br>Smart logo design
</td>
<td></td>
</tr>
</tbody>
</table>
+27
View File
@@ -0,0 +1,27 @@
# Version 0.6.0 (2023-09-08)
- Python bindings
# Version 0.5.0 (2022-10-09)
- Handle transparent png images
# Version 0.4.0 (2021-07-23)
- SVG path string numeric precision
# Version 0.3.0 (2021-01-24)
- Added cutout mode
# Version 0.2.0 (2020-11-15)
- Use relative & closed paths
# Version 0.1.1 (2020-11-01)
- SVG namespace
# Version 0.1.0 (2020-10-31)
- Initial release
+3
View File
@@ -0,0 +1,3 @@
*.svg
*.png
*.jpg
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "vtracer"
version = "0.6.3"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
edition = "2021"
description = "A cmd app to convert images into vector graphics."
license = "MIT OR Apache-2.0"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
categories = ["graphics"]
keywords = ["svg", "computer-graphics"]
[dependencies]
clap = "2.33.3"
image = "0.23.10"
visioncortex = { version = "0.8.0" }
fastrand = "1.8"
pyo3 = { version = "0.19.0", optional = true }
[features]
python-binding = ["pyo3"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+25
View File
@@ -0,0 +1,25 @@
Copyright (c) 2022 Tsang Hao Fung
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+28
View File
@@ -0,0 +1,28 @@
[project]
name = "vtracer"
version = "0.6.3"
description = "Python bindings for the Rust Vtracer raster-to-vector library"
authors = [ { name = "Chris Tsang", email = "chris.2y3@outlook.com" } ]
readme = "vtracer/README.md"
requires-python = ">=3.7"
license = "MIT"
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
[dependencies]
python = "^3.7"
[dev-dependencies]
maturin = "^1.2"
[build-system]
requires = ["maturin>=1.2,<2.0"]
build-backend = "maturin"
[tool.maturin]
features = ["pyo3/extension-module"]
compatibility = "linux"
sdist-include = ["../LICENSE", "../README.md"]
+397
View File
@@ -0,0 +1,397 @@
use std::str::FromStr;
use std::path::PathBuf;
use clap::{Arg, App};
use visioncortex::PathSimplifyMode;
pub enum Preset {
Bw,
Poster,
Photo
}
pub enum ColorMode {
Color,
Binary,
}
pub enum Hierarchical {
Stacked,
Cutout,
}
/// Converter config
pub struct Config {
pub input_path: PathBuf,
pub output_path: PathBuf,
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
pub filter_speckle: usize,
pub color_precision: i32,
pub layer_difference: i32,
pub mode: PathSimplifyMode,
pub corner_threshold: i32,
pub length_threshold: f64,
pub max_iterations: usize,
pub splice_threshold: i32,
pub path_precision: Option<u32>,
}
pub(crate) struct ConverterConfig {
pub input_path: PathBuf,
pub output_path: PathBuf,
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
pub filter_speckle_area: usize,
pub color_precision_loss: i32,
pub layer_difference: i32,
pub mode: PathSimplifyMode,
pub corner_threshold: f64,
pub length_threshold: f64,
pub max_iterations: usize,
pub splice_threshold: f64,
pub path_precision: Option<u32>,
}
impl Default for Config {
fn default() -> Self {
Self {
input_path: PathBuf::default(),
output_path: PathBuf::default(),
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
mode: PathSimplifyMode::Spline,
filter_speckle: 4,
color_precision: 6,
layer_difference: 16,
corner_threshold: 60,
length_threshold: 4.0,
splice_threshold: 45,
max_iterations: 10,
path_precision: Some(8),
}
}
}
impl FromStr for ColorMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color" => Ok(Self::Color),
"binary" => Ok(Self::Binary),
_ => Err(format!("unknown ColorMode {}", s)),
}
}
}
impl FromStr for Hierarchical {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"stacked" => Ok(Self::Stacked),
"cutout" => Ok(Self::Cutout),
_ => Err(format!("unknown Hierarchical {}", s)),
}
}
}
impl FromStr for Preset {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"bw" => Ok(Self::Bw),
"poster" => Ok(Self::Poster),
"photo" => Ok(Self::Photo),
_ => Err(format!("unknown Preset {}", s)),
}
}
}
fn path_simplify_mode_from_str(s: &str) -> PathSimplifyMode {
match s {
"polygon" => PathSimplifyMode::Polygon,
"spline" => PathSimplifyMode::Spline,
"none" => PathSimplifyMode::None,
_ => panic!("unknown PathSimplifyMode {}", s),
}
}
impl Config {
pub fn from_args() -> Self {
let app = App::new("visioncortex VTracer ".to_owned() + env!("CARGO_PKG_VERSION"))
.about("A cmd app to convert images into vector graphics.");
let app = app.arg(Arg::with_name("input")
.long("input")
.short("i")
.takes_value(true)
.help("Path to input raster image")
.required(true));
let app = app.arg(Arg::with_name("output")
.long("output")
.short("o")
.takes_value(true)
.help("Path to output vector graphics")
.required(true));
let app = app.arg(Arg::with_name("color_mode")
.long("colormode")
.takes_value(true)
.help("True color image `color` (default) or Binary image `bw`"));
let app = app.arg(Arg::with_name("hierarchical")
.long("hierarchical")
.takes_value(true)
.help(
"Hierarchical clustering `stacked` (default) or non-stacked `cutout`. \
Only applies to color mode. "
));
let app = app.arg(Arg::with_name("preset")
.long("preset")
.takes_value(true)
.help("Use one of the preset configs `bw`, `poster`, `photo`"));
let app = app.arg(Arg::with_name("filter_speckle")
.long("filter_speckle")
.short("f")
.takes_value(true)
.help("Discard patches smaller than X px in size"));
let app = app.arg(Arg::with_name("color_precision")
.long("color_precision")
.short("p")
.takes_value(true)
.help("Number of significant bits to use in an RGB channel"));
let app = app.arg(Arg::with_name("gradient_step")
.long("gradient_step")
.short("g")
.takes_value(true)
.help("Color difference between gradient layers"));
let app = app.arg(Arg::with_name("corner_threshold")
.long("corner_threshold")
.short("c")
.takes_value(true)
.help("Minimum momentary angle (degree) to be considered a corner"));
let app = app.arg(Arg::with_name("segment_length")
.long("segment_length")
.short("l")
.takes_value(true)
.help("Perform iterative subdivide smooth until all segments are shorter than this length"));
let app = app.arg(Arg::with_name("splice_threshold")
.long("splice_threshold")
.short("s")
.takes_value(true)
.help("Minimum angle displacement (degree) to splice a spline"));
let app = app.arg(Arg::with_name("mode")
.long("mode")
.short("m")
.takes_value(true)
.help("Curver fitting mode `pixel`, `polygon`, `spline`"));
let app = app.arg(Arg::with_name("path_precision")
.long("path_precision")
.takes_value(true)
.help("Number of decimal places to use in path string"));
// Extract matches
let matches = app.get_matches();
let mut config = Config::default();
let input_path = matches.value_of("input").expect("Input path is required, please specify it by --input or -i.");
let output_path = matches.value_of("output").expect("Output path is required, please specify it by --output or -o.");
if let Some(value) = matches.value_of("preset") {
config = Self::from_preset(Preset::from_str(value).unwrap(), input_path, output_path);
}
config.input_path = PathBuf::from(input_path);
config.output_path = PathBuf::from(output_path);
if let Some(value) = matches.value_of("color_mode") {
config.color_mode = ColorMode::from_str(if value.trim() == "bw" || value.trim() == "BW" {"binary"} else {"color"}).unwrap()
}
if let Some(value) = matches.value_of("hierarchical") {
config.hierarchical = Hierarchical::from_str(value).unwrap()
}
if let Some(value) = matches.value_of("mode") {
let value = value.trim();
config.mode = path_simplify_mode_from_str(if value == "pixel" {
"none"
} else if value == "polygon" {
"polygon"
} else if value == "spline" {
"spline"
} else {
panic!("Parser Error: Curve fitting mode is invalid: {}", value);
});
}
if let Some(value) = matches.value_of("filter_speckle") {
if value.trim().parse::<usize>().is_ok() { // is numeric
let value = value.trim().parse::<usize>().unwrap();
if value > 16 {
panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [0,16].", value);
}
config.filter_speckle = value;
} else {
panic!("Parser Error: Filter speckle is not a positive integer: {}.", value);
}
}
if let Some(value) = matches.value_of("color_precision") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 1 || value > 8 {
panic!("Out of Range Error: Color precision is invalid at {}. It must be within [1,8].", value);
}
config.color_precision = value;
} else {
panic!("Parser Error: Color precision is not an integer: {}.", value);
}
}
if let Some(value) = matches.value_of("gradient_step") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 0 || value > 255 {
panic!("Out of Range Error: Gradient step is invalid at {}. It must be within [0,255].", value);
}
config.layer_difference = value;
} else {
panic!("Parser Error: Gradient step is not an integer: {}.", value);
}
}
if let Some(value) = matches.value_of("corner_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 0 || value > 180 {
panic!("Out of Range Error: Corner threshold is invalid at {}. It must be within [0,180].", value);
}
config.corner_threshold = value
} else {
panic!("Parser Error: Corner threshold is not numeric: {}.", value);
}
}
if let Some(value) = matches.value_of("segment_length") {
if value.trim().parse::<f64>().is_ok() { // is numeric
let value = value.trim().parse::<f64>().unwrap();
if value < 3.5 || value > 10.0 {
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [3.5,10].", value);
}
config.length_threshold = value;
} else {
panic!("Parser Error: Segment length is not numeric: {}.", value);
}
}
if let Some(value) = matches.value_of("splice_threshold") {
if value.trim().parse::<i32>().is_ok() { // is numeric
let value = value.trim().parse::<i32>().unwrap();
if value < 0 || value > 180 {
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [0,180].", value);
}
config.splice_threshold = value;
} else {
panic!("Parser Error: Segment length is not numeric: {}.", value);
}
}
if let Some(value) = matches.value_of("path_precision") {
if value.trim().parse::<u32>().is_ok() { // is numeric
let value = value.trim().parse::<u32>().ok();
config.path_precision = value;
} else {
panic!("Parser Error: Path precision is not an unsigned integer: {}.", value);
}
}
config
}
pub fn from_preset(preset: Preset, input_path: &str, output_path: &str) -> Self {
let input_path = PathBuf::from(input_path);
let output_path = PathBuf::from(output_path);
match preset {
Preset::Bw => Self {
input_path,
output_path,
color_mode: ColorMode::Binary,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 6,
layer_difference: 16,
mode: PathSimplifyMode::Spline,
corner_threshold: 60,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
path_precision: Some(8),
},
Preset::Poster => Self {
input_path,
output_path,
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 8,
layer_difference: 16,
mode: PathSimplifyMode::Spline,
corner_threshold: 60,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
path_precision: Some(8),
},
Preset::Photo => Self {
input_path,
output_path,
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 10,
color_precision: 8,
layer_difference: 48,
mode: PathSimplifyMode::Spline,
corner_threshold: 180,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
path_precision: Some(8),
}
}
}
pub(crate) fn into_converter_config(self) -> ConverterConfig {
ConverterConfig {
input_path: self.input_path,
output_path: self.output_path,
color_mode: self.color_mode,
hierarchical: self.hierarchical,
filter_speckle_area: self.filter_speckle * self.filter_speckle,
color_precision_loss: 8 - self.color_precision,
layer_difference: self.layer_difference,
mode: self.mode,
corner_threshold: deg2rad(self.corner_threshold),
length_threshold: self.length_threshold,
max_iterations: self.max_iterations,
splice_threshold: deg2rad(self.splice_threshold),
path_precision: self.path_precision,
}
}
}
fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
+228
View File
@@ -0,0 +1,228 @@
use std::path::PathBuf;
use std::{fs::File, io::Write};
use fastrand::Rng;
use visioncortex::{Color, ColorImage, ColorName};
use visioncortex::color_clusters::{Runner, RunnerConfig, KeyingAction, HIERARCHICAL_MAX};
use super::config::{Config, ColorMode, Hierarchical, ConverterConfig};
use super::svg::SvgFile;
const NUM_UNUSED_COLOR_ITERATIONS: usize = 6;
/// The fraction of pixels in the top/bottom rows of the image that need to be transparent before
/// the entire image will be keyed.
const KEYING_THRESHOLD: f32 = 0.2;
/// Convert an image file into svg file
pub fn convert_image_to_svg(config: Config) -> Result<(), String> {
let config = config.into_converter_config();
match config.color_mode {
ColorMode::Color => color_image_to_svg(config),
ColorMode::Binary => binary_image_to_svg(config),
}
}
fn color_exists_in_image(img: &ColorImage, color: Color) -> bool {
for y in 0..img.height {
for x in 0..img.width {
let pixel_color = img.get_pixel(x, y);
if pixel_color.r == color.r && pixel_color.g == color.g && pixel_color.b == color.b {
return true
}
}
}
false
}
fn find_unused_color_in_image(img: &ColorImage) -> Result<Color, String> {
let special_colors = IntoIterator::into_iter([
Color::new(255, 0, 0),
Color::new(0, 255, 0),
Color::new(0, 0, 255),
Color::new(255, 255, 0),
Color::new(0, 255, 255),
Color::new(255, 0, 255),
]);
let rng = Rng::new();
let random_colors = (0..NUM_UNUSED_COLOR_ITERATIONS).map(|_| {
Color::new(
rng.u8(..),
rng.u8(..),
rng.u8(..),
)
});
for color in special_colors.chain(random_colors) {
if !color_exists_in_image(img, color) {
return Ok(color);
}
}
Err(String::from("unable to find unused color in image to use as key"))
}
fn should_key_image(img: &ColorImage) -> bool {
if img.width == 0 || img.height == 0 {
return false;
}
// Check for transparency at several scanlines
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
let mut num_transparent_boundary_pixels = 0;
let y_positions = [0, img.height / 4, img.height / 2, 3 * img.height / 4, img.height - 1];
for y in y_positions {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
num_transparent_boundary_pixels += 1;
}
if num_transparent_boundary_pixels >= threshold {
return true;
}
}
}
false
}
fn color_image_to_svg(config: ConverterConfig) -> Result<(), String> {
let (mut img, width, height);
match read_image(config.input_path) {
Ok(values) => {
img = values.0;
width = values.1;
height = values.2;
},
Err(msg) => return Err(msg),
}
let key_color = if should_key_image(&img) {
let key_color = find_unused_color_in_image(&img)?;
for y in 0..height {
for x in 0..width {
if img.get_pixel(x, y).a == 0 {
img.set_pixel(x, y, &key_color);
}
}
}
key_color
} else {
// The default color is all zeroes, which is treated by visioncortex as a special value meaning no keying will be applied.
Color::default()
};
let runner = Runner::new(RunnerConfig {
diagonal: config.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: config.filter_speckle_area,
good_max_area: (width * height),
is_same_color_a: config.color_precision_loss,
is_same_color_b: 1,
deepen_diff: config.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: if matches!(config.hierarchical, Hierarchical::Cutout) {
KeyingAction::Keep
} else {
KeyingAction::Discard
},
}, img);
let mut clusters = runner.run();
match config.hierarchical {
Hierarchical::Stacked => {}
Hierarchical::Cutout => {
let view = clusters.view();
let image = view.to_color_image();
let runner = Runner::new(RunnerConfig {
diagonal: false,
hierarchical: 64,
batch_size: 25600,
good_min_area: 0,
good_max_area: (image.width * image.height) as usize,
is_same_color_a: 0,
is_same_color_b: 1,
deepen_diff: 0,
hollow_neighbours: 0,
key_color,
keying_action: KeyingAction::Discard,
}, image);
clusters = runner.run();
},
}
let view = clusters.view();
let mut svg = SvgFile::new(width, height, config.path_precision);
for &cluster_index in view.clusters_output.iter().rev() {
let cluster = view.get_cluster(cluster_index);
let paths = cluster.to_compound_path(
&view,
false,
config.mode,
config.corner_threshold,
config.length_threshold,
config.max_iterations,
config.splice_threshold
);
svg.add_path(paths, cluster.residue_color());
}
write_svg(svg, config.output_path)
}
fn binary_image_to_svg(config: ConverterConfig) -> Result<(), String> {
let (img, width, height);
match read_image(config.input_path) {
Ok(values) => {
img = values.0;
width = values.1;
height = values.2;
},
Err(msg) => return Err(msg),
}
let img = img.to_binary_image(|x| x.r < 128);
let clusters = img.to_clusters(false);
let mut svg = SvgFile::new(width, height, config.path_precision);
for i in 0..clusters.len() {
let cluster = clusters.get_cluster(i);
if cluster.size() >= config.filter_speckle_area {
let paths = cluster.to_compound_path(
config.mode,
config.corner_threshold,
config.length_threshold,
config.max_iterations,
config.splice_threshold,
);
svg.add_path(paths, Color::color(&ColorName::Black));
}
}
write_svg(svg, config.output_path)
}
fn read_image(input_path: PathBuf) -> Result<(ColorImage, usize, usize), String> {
let img = image::open(input_path);
let img = match img {
Ok(file) => file.to_rgba8(),
Err(_) => return Err(String::from("No image file found at specified input path")),
};
let (width, height) = (img.width() as usize, img.height() as usize);
let img = ColorImage {pixels: img.as_raw().to_vec(), width, height};
Ok((img, width, height))
}
fn write_svg(svg: SvgFile, output_path: PathBuf) -> Result<(), String> {
let out_file = File::create(output_path);
let mut out_file = match out_file {
Ok(file) => file,
Err(_) => return Err(String::from("Cannot create output file.")),
};
write!(&mut out_file, "{}", svg).expect("failed to write file.");
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2020 Tsang Hao Fung. See the COPYRIGHT
// file at the top-level directory of this distribution and at
// http://rust-lang.org/COPYRIGHT.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.
mod config;
mod converter;
mod svg;
#[cfg(feature = "python-binding")]
mod python;
pub use config::*;
pub use converter::*;
pub use svg::*;
#[cfg(feature = "python-binding")]
pub use python::*;
+14
View File
@@ -0,0 +1,14 @@
use vtracer::{Config, convert_image_to_svg};
fn main() {
let config = Config::from_args();
let result = convert_image_to_svg(config);
match result {
Ok(()) => {
println!("Conversion successful.");
},
Err(msg) => {
panic!("Conversion failed with error message: {}", msg);
}
}
}
+81
View File
@@ -0,0 +1,81 @@
use pyo3::prelude::*;
use visioncortex::{PathSimplifyMode};
use super::converter::*;
/// Python binding
#[pyfunction]
fn convert_image_to_svg_py( image_path: &str,
out_path: &str,
colormode: Option<&str>, // "color" or "binary"
hierarchical: Option<&str>, // "stacked" or "cutout"
mode: Option<&str>, // "polygon", "spline", "none"
filter_speckle: Option<usize>, // default: 4
color_precision: Option<i32>, // default: 6
layer_difference: Option<i32>, // default: 16
corner_threshold: Option<i32>, // default: 60
length_threshold: Option<f64>, // in [3.5, 10] default: 4.0
max_iterations: Option<usize>, // default: 10
splice_threshold: Option<i32>, // default: 45
path_precision: Option<u32> // default: 8
) -> PyResult<()> {
let input_path = PathBuf::from(image_path);
let output_path = PathBuf::from(out_path);
// TODO: enforce color mode with an enum so that we only
// accept the strings 'color' or 'binary'
let color_mode = match colormode.unwrap_or("color") {
"color" => ColorMode::Color,
"binary" => ColorMode::Binary,
_ => ColorMode::Color,
};
let hierarchical = match hierarchical.unwrap_or("stacked") {
"stacked" => Hierarchical::Stacked,
"cutout" => Hierarchical::Cutout,
_ => Hierarchical::Stacked,
};
let mode = match mode.unwrap_or("spline") {
"spline" => PathSimplifyMode::Spline,
"polygon" => PathSimplifyMode::Polygon,
"none" => PathSimplifyMode::None,
_ => PathSimplifyMode::Spline,
};
let filter_speckle = filter_speckle.unwrap_or(4);
let color_precision = color_precision.unwrap_or(6);
let layer_difference = layer_difference.unwrap_or(16);
let corner_threshold = corner_threshold.unwrap_or(60);
let length_threshold = length_threshold.unwrap_or(4.0);
let splice_threshold = splice_threshold.unwrap_or(45);
let max_iterations = max_iterations.unwrap_or(10);
let config = Config {
input_path,
output_path,
color_mode,
hierarchical,
filter_speckle,
color_precision,
layer_difference,
mode,
corner_threshold,
length_threshold,
max_iterations,
splice_threshold,
path_precision,
..Default::default()
};
convert_image_to_svg(config).unwrap();
Ok(())
}
/// A Python module implemented in Rust.
#[pymodule]
fn vtracer(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(convert_image_to_svg_py, m)?)?;
Ok(())
}
+65
View File
@@ -0,0 +1,65 @@
use std::fmt;
use visioncortex::{Color, CompoundPath, PointF64};
pub struct SvgFile {
pub paths: Vec<SvgPath>,
pub width: usize,
pub height: usize,
pub path_precision: Option<u32>,
}
pub struct SvgPath {
pub path: CompoundPath,
pub color: Color,
}
impl SvgFile {
pub fn new(width: usize, height: usize, path_precision: Option<u32>) -> Self {
SvgFile {
paths: vec![],
width,
height,
path_precision,
}
}
pub fn add_path(&mut self, path: CompoundPath, color: Color) {
self.paths.push(SvgPath {
path,
color,
})
}
}
impl fmt::Display for SvgFile {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
writeln!(f,
r#"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="{}" height="{}">"#,
self.width, self.height
)?;
for path in &self.paths {
path.fmt_with_precision(f, self.path_precision)?;
};
writeln!(f, "</svg>")
}
}
impl fmt::Display for SvgPath {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.fmt_with_precision(f, None)
}
}
impl SvgPath {
fn fmt_with_precision(&self, f: &mut fmt::Formatter, precision: Option<u32>) -> fmt::Result {
let (string, offset) = self.path.to_svg_string(true, PointF64::default(), precision);
writeln!(
f, "<path d=\"{}\" fill=\"{}\" transform=\"translate({},{})\"/>",
string, self.color.to_hex_string(),
offset.x, offset.y
)
}
}
+75
View File
@@ -0,0 +1,75 @@
<div align="center">
<img src="https://github.com/visioncortex/vtracer/raw/master/docs/images/visioncortex-banner.png">
<h1>VTracer: Python Binding</h1>
<p>
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
</p>
<h3>
<a href="//www.visioncortex.org/vtracer-docs">Article</a>
<span> | </span>
<a href="//www.visioncortex.org/vtracer/">Demo</a>
<span> | </span>
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
</h3>
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
</div>
## Introduction
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrator/using/image-trace.html), VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
A technical description of the algorithm is on [visioncortex.org/vtracer-docs](//www.visioncortex.org/vtracer-docs).
## Install (Python)
```shell
pip install vtracer
```
### Usage (Python)
```python
import vtracer
input_path = "/path/to/some_file.jpg"
output_path = "/path/to/some_file.vtracer.jpg"
# Minimal example: use all default values, generate a multicolor SVG
vtracer.convert_image_to_svg_py(inp, out)
# Single-color example. Good for line art, and much faster than full color:
vtracer.convert_image_to_svg_py(inp, out, colormode='binary')
# All the bells & whistles
vtracer.convert_image_to_svg_py(inp,
out,
colormode = 'color', # ["color"] or "binary"
hierarchical = 'stacked', # ["stacked"] or "cutout"
mode = 'spline', # ["spline"] "polygon", or "none"
filter_speckle = 4, # default: 4
color_precision = 6, # default: 6
layer_difference = 16, # default: 16
corner_threshold = 60, # default: 60
length_threshold = 4.0, # in [3.5, 10] default: 4.0
max_iterations = 10, # default: 10
splice_threshold = 45, # default: 45
path_precision = 3 # default: 8
)
```
## Rust Library
The (Rust) library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
+1
View File
@@ -0,0 +1 @@
from .vtracer import convert_image_to_svg_py
+17
View File
@@ -0,0 +1,17 @@
from typing import Optional
def convert_image_to_svg_py(image_path: str,
out_path: str,
colormode: Optional[str] = None, # ["color"] or "binary"
hierarchical: Optional[str] = None, # ["stacked"] or "cutout"
mode: Optional[str] = None, # ["spline"], "polygon", "none"
filter_speckle: Optional[int] = None, # default: 4
color_precision: Optional[int] = None, # default: 6
layer_difference: Optional[int] = None, # default: 16
corner_threshold: Optional[int] = None, # default: 60
length_threshold: Optional[float] = None, # in [3.5, 10] default: 4.0
max_iterations: Optional[int] = None, # default: 10
splice_threshold: Optional[int] = None, # default: 45
path_precision: Optional[int] = None, # default: 8
) -> None:
...
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "vtracer-bench"
description = "Blind fidelity benchmark for raster-to-vector tracers: compare the original raster with a rendered reconstruction and get one 0..1 fidelity score built from PSNR, SSIM and a clustered-diff patch metric."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "development-tools::testing"]
keywords = ["vectorization", "benchmark", "fidelity", "ssim", "psnr"]
[dependencies]
visioncortex.workspace = true
dssim-core = "3"
rgb = "0.8"
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
-104
View File
@@ -1,104 +0,0 @@
# 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.
-274
View File
@@ -1,274 +0,0 @@
//! 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);
}
}
-75
View File
@@ -1,75 +0,0 @@
//! 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
);
}
-24
View File
@@ -1,24 +0,0 @@
[package]
name = "vtracer-cli"
description = "Command-line front-end for the vtracer vectorization framework."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "command-line-utilities"]
keywords = ["svg", "vectorization", "computer-graphics"]
[[bin]]
name = "vtracer"
path = "src/main.rs"
[dependencies]
vtracer = { version = "1.0.0-alpha.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"] }
-291
View File
@@ -1,291 +0,0 @@
//! Thin command-line front-end over the `vtracer` framework.
//!
//! Handles the two things the framework deliberately leaves out: image file
//! I/O and argument parsing. Everything else is delegated to
//! [`vtracer::Config`] / [`vtracer::Pipeline`].
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use visioncortex::{Color, ColorImage};
use vtracer::{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 BradleyRoth 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
// BradleyRoth; 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
}
}
}
-27
View File
@@ -1,27 +0,0 @@
[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"] }
-103
View File
@@ -1,103 +0,0 @@
<div align="center">
<img src="https://raw.githubusercontent.com/visioncortex/vtracer/master/docs/images/visioncortex-banner.png">
<h1>VTracer</h1>
<p>
<strong>Raster to Vector Graphics Converter</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 12.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: BradleyRoth 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
```
-27
View File
@@ -1,27 +0,0 @@
[build-system]
requires = ["maturin>=1.5,<2.0"]
build-backend = "maturin"
[project]
name = "vtracer"
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
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"]
-539
View File
@@ -1,539 +0,0 @@
//! Python bindings for the `vtracer` vectorization framework.
//!
//! The API centers on a mutable [`Config`] object with named properties and
//! preset constructors, plus three input paths — a file, encoded image bytes,
//! or a raw RGBA buffer — each returning the SVG (or writing it to disk):
//!
//! ```python
//! import vtracer
//!
//! # one-liners
//! vtracer.convert_file("in.png", "out.svg")
//! svg = vtracer.convert_bytes(open("in.png", "rb").read())
//!
//! # rich, reusable config
//! cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
//! cfg.max_colors = 8
//! cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
//! svg = cfg.convert_bytes(data)
//!
//! # presets
//! vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
//! ```
use std::io::Cursor;
use std::path::PathBuf;
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use ::vtracer::{
Color, ColorImage, 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(())
}
-67
View File
@@ -1,67 +0,0 @@
from typing import Optional
__version__: str
class Config:
"""Conversion configuration. Construct with keyword arguments or a preset,
mutate via properties, then call one of the ``convert_*`` methods."""
def __init__(
self,
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: BradleyRoth 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: ...
-27
View File
@@ -1,27 +0,0 @@
[package]
name = "vtracer"
description = "A vectorization framework that converts raster images into vector graphics: pluggable frontends, curve fitters, color fitting, and output optimization."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "computer-vision"]
keywords = ["svg", "vectorization", "computer-graphics"]
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"] }
-2
View File
@@ -1,2 +0,0 @@
# This crate is hand-formatted; a stray `cargo fmt` must not rewrite it.
disable_all_formatting = true
-75
View File
@@ -1,75 +0,0 @@
//! Color fitters: rewrite layer paints before compositing.
//!
//! * [`Identity`] — keep the frontend's mean colors (0.6.x behavior).
//! * [`FixedPalette`] — snap each paint to the nearest entry of a fixed
//! palette, measured in OKLab.
//! * [`AutoQuantize`] — reduce the palette to at most `max_colors` via
//! area-weighted median cut.
//! * [`MergeAdjacent`] — union consecutive layers that share a paint, cutting
//! shape count for free.
mod merge;
mod oklab;
mod palette;
mod quantize;
pub use merge::MergeAdjacent;
pub use palette::FixedPalette;
pub use quantize::AutoQuantize;
use crate::ir::Segmentation;
/// A color fitter rewrites the paints of a segmentation in place.
pub trait ColorFitter {
fn fit(&self, seg: &mut Segmentation);
}
/// No-op fitter: paints keep the frontend's mean cluster colors.
#[derive(Debug, Clone, Default)]
pub struct Identity;
impl ColorFitter for Identity {
fn fit(&self, _seg: &mut Segmentation) {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{Layer, Paint, RegionMask};
use visioncortex::{BinaryImage, Color, PointI32};
fn layer(color: Color) -> Layer {
let mut image = BinaryImage::new_w_h(1, 1);
image.set_pixel(0, 0, true);
Layer {
paint: Paint::Solid(color),
mask: RegionMask::new(image, PointI32 { x: 0, y: 0 }),
}
}
#[test]
fn fixed_palette_snaps_to_nearest_oklab() {
let mut seg = Segmentation::new(1, 1);
seg.layers.push(layer(Color::new(250, 10, 10))); // near red
seg.layers.push(layer(Color::new(10, 10, 250))); // near blue
let palette = FixedPalette::new(vec![Color::new(255, 0, 0), Color::new(0, 0, 255)]);
palette.fit(&mut seg);
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(255, 0, 0)));
assert_eq!(seg.layers[1].paint, Paint::Solid(Color::new(0, 0, 255)));
}
#[test]
fn merge_adjacent_unions_same_paint_runs() {
let mut seg = Segmentation::new(2, 1);
seg.layers.push(layer(Color::new(0, 0, 0)));
seg.layers.push(layer(Color::new(0, 0, 0)));
seg.layers.push(layer(Color::new(255, 255, 255)));
MergeAdjacent.fit(&mut seg);
assert_eq!(seg.layers.len(), 2);
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(0, 0, 0)));
}
}
-46
View File
@@ -1,46 +0,0 @@
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;
}
}
-53
View File
@@ -1,53 +0,0 @@
//! Minimal sRGB → OKLab conversion for perceptual color distance.
//!
//! OKLab (Björn Ottosson, 2020) gives a Euclidean space where distance
//! approximates perceived color difference far better than raw RGB.
use visioncortex::Color;
/// A color in the OKLab space.
#[derive(Debug, Clone, Copy)]
pub struct Oklab {
pub l: f64,
pub a: f64,
pub b: f64,
}
fn srgb_to_linear(c: u8) -> f64 {
let c = c as f64 / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
impl Oklab {
pub fn from_color(color: &Color) -> Self {
let r = srgb_to_linear(color.r);
let g = srgb_to_linear(color.g);
let b = srgb_to_linear(color.b);
let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b;
let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b;
let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b;
let l_ = l.cbrt();
let m_ = m.cbrt();
let s_ = s.cbrt();
Oklab {
l: 0.210_454_255_3 * l_ + 0.793_617_785_0 * m_ - 0.004_072_046_8 * s_,
a: 1.977_998_495_1 * l_ - 2.428_592_205_0 * m_ + 0.450_593_709_9 * s_,
b: 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766_0 * s_,
}
}
/// Squared Euclidean distance (monotonic with distance; avoids the sqrt).
pub fn distance_squared(&self, other: &Oklab) -> f64 {
let dl = self.l - other.l;
let da = self.a - other.a;
let db = self.b - other.b;
dl * dl + da * da + db * db
}
}
-47
View File
@@ -1,47 +0,0 @@
use visioncortex::Color;
use crate::ir::{Paint, Segmentation};
use super::oklab::Oklab;
use super::ColorFitter;
/// Snap every layer paint to the nearest color in a fixed palette, measured in
/// OKLab. An empty palette leaves paints untouched.
#[derive(Debug, Clone, Default)]
pub struct FixedPalette {
pub colors: Vec<Color>,
}
impl FixedPalette {
pub fn new(colors: Vec<Color>) -> Self {
Self { colors }
}
/// The palette entry closest to `color` in OKLab.
fn nearest(&self, color: &Color, lab: &[Oklab]) -> Color {
let target = Oklab::from_color(color);
let mut best = self.colors[0];
let mut best_dist = f64::INFINITY;
for (i, entry) in self.colors.iter().enumerate() {
let dist = target.distance_squared(&lab[i]);
if dist < best_dist {
best_dist = dist;
best = *entry;
}
}
best
}
}
impl ColorFitter for FixedPalette {
fn fit(&self, seg: &mut Segmentation) {
if self.colors.is_empty() {
return;
}
let lab: Vec<Oklab> = self.colors.iter().map(Oklab::from_color).collect();
for layer in &mut seg.layers {
let snapped = self.nearest(&layer.paint.color(), &lab);
layer.paint = Paint::Solid(snapped);
}
}
}
-148
View File
@@ -1,148 +0,0 @@
use visioncortex::Color;
use crate::ir::{Paint, Segmentation};
use super::oklab::Oklab;
use super::ColorFitter;
/// Reduce the layer palette to at most `max_colors` representative colors via
/// area-weighted median cut, then snap each layer to the nearest representative
/// (in OKLab).
#[derive(Debug, Clone)]
pub struct AutoQuantize {
pub max_colors: usize,
}
impl Default for AutoQuantize {
fn default() -> Self {
Self { max_colors: 16 }
}
}
#[derive(Clone, Copy)]
struct Sample {
color: Color,
weight: u64,
}
struct Bucket {
samples: Vec<Sample>,
}
impl Bucket {
/// Extent (max - min) of the given channel across the bucket.
fn channel_range(&self, channel: usize) -> u8 {
let mut lo = u8::MAX;
let mut hi = u8::MIN;
for s in &self.samples {
let v = s.color.rgb_u8()[channel];
lo = lo.min(v);
hi = hi.max(v);
}
hi.saturating_sub(lo)
}
fn widest_channel(&self) -> usize {
let mut best = 0;
let mut best_range = 0u8;
for c in 0..3 {
let r = self.channel_range(c);
if r > best_range {
best_range = r;
best = c;
}
}
best
}
fn total_weight(&self) -> u64 {
self.samples.iter().map(|s| s.weight).sum()
}
/// Weighted-average representative color.
fn representative(&self) -> Color {
let mut r = 0u64;
let mut g = 0u64;
let mut b = 0u64;
let mut w = 0u64;
for s in &self.samples {
let rgb = s.color.rgb_u8();
r += rgb[0] as u64 * s.weight;
g += rgb[1] as u64 * s.weight;
b += rgb[2] as u64 * s.weight;
w += s.weight;
}
if w == 0 {
return Color::new(0, 0, 0);
}
Color::new((r / w) as u8, (g / w) as u8, (b / w) as u8)
}
/// Split at the weighted median of the widest channel.
fn split(mut self) -> (Bucket, Bucket) {
let channel = self.widest_channel();
self.samples
.sort_by_key(|s| s.color.rgb_u8()[channel]);
let half = self.total_weight() / 2;
let mut acc = 0u64;
let mut cut = 1;
for (i, s) in self.samples.iter().enumerate() {
acc += s.weight;
if acc >= half {
cut = (i + 1).clamp(1, self.samples.len().saturating_sub(1).max(1));
break;
}
}
let right = self.samples.split_off(cut);
(Bucket { samples: self.samples }, Bucket { samples: right })
}
}
impl ColorFitter for AutoQuantize {
fn fit(&self, seg: &mut Segmentation) {
if self.max_colors == 0 || seg.layers.is_empty() {
return;
}
let samples: Vec<Sample> = seg
.layers
.iter()
.map(|l| Sample {
color: l.paint.color(),
weight: l.mask.area() as u64 + 1,
})
.collect();
let mut buckets = vec![Bucket { samples }];
while buckets.len() < self.max_colors {
// Split the bucket with the widest single-channel range.
let target = buckets
.iter()
.enumerate()
.filter(|(_, b)| b.samples.len() > 1)
.max_by_key(|(_, b)| b.channel_range(b.widest_channel()));
let Some((idx, _)) = target else { break };
let bucket = buckets.swap_remove(idx);
let (a, b) = bucket.split();
buckets.push(a);
buckets.push(b);
}
let palette: Vec<Color> = buckets.iter().map(Bucket::representative).collect();
let lab: Vec<Oklab> = palette.iter().map(Oklab::from_color).collect();
for layer in &mut seg.layers {
let target = Oklab::from_color(&layer.paint.color());
let mut best = palette[0];
let mut best_dist = f64::INFINITY;
for (i, entry) in palette.iter().enumerate() {
let d = target.distance_squared(&lab[i]);
if d < best_dist {
best_dist = d;
best = *entry;
}
}
layer.paint = Paint::Solid(best);
}
}
}
-131
View File
@@ -1,131 +0,0 @@
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
//!
//! * **Stacked** — each layer is traced independently into closed outlines and
//! stacked in paint order (painter's algorithm).
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
//!
//! 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
}
-414
View File
@@ -1,414 +0,0 @@
//! High-level configuration and presets that assemble a [`Pipeline`].
use std::str::FromStr;
use visioncortex::Color;
use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAdjacent};
use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
use crate::frontend::{
BinaryFrontend, ColorClusterFrontend, Frontend, 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,
/// DouglasPeucker 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 BradleyRoth 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}")),
}
}
}
-46
View File
@@ -1,46 +0,0 @@
//! 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())
}
}
-209
View File
@@ -1,209 +0,0 @@
//! Curve fitters: turn a region's pixel mask into vector outlines.
//!
//! The three built-ins wrap the corresponding visioncortex tracing modes and
//! emit [`FittedGeom`] contours in absolute (document) coordinates:
//!
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
//! * [`PolygonFitter`] — staircase-symmetric DouglasPeucker polygon.
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
//!
//! All three trace *closed* region outlines (outer ring plus holes). 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())
}
}
/// DouglasPeucker 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
}
-46
View File
@@ -1,46 +0,0 @@
//! Frontends: algorithms that turn a raster image into a [`Segmentation`].
//!
//! Built-ins:
//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic
//! VTracer color path), including transparency keying.
//! * [`BinaryFrontend`] — threshold to black/white then cluster; the
//! [`Threshold`] can be a fixed global cutoff or BradleyRoth 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)
}
}
-166
View File
@@ -1,166 +0,0 @@
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),
/// BradleyRoth 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 {
/// BradleyRoth 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 BradleyRoth 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),
}
}
}
/// BradleyRoth 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)
}
}
@@ -1,142 +0,0 @@
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))
}
}
-105
View File
@@ -1,105 +0,0 @@
//! Transparency keying, ported from the 0.6.x `converter.rs`.
//!
//! When an image has substantial transparency, fully-transparent pixels are
//! recolored to an unused "key" color so the clustering runner can treat them
//! as a discardable background. The random key search of 0.6.x is replaced by a
//! deterministic sweep so results are reproducible and `no_std`/wasm-friendly.
use visioncortex::{Color, ColorImage};
use crate::error::Error;
/// Fraction of pixels in the sampled rows that must be transparent before the
/// whole image is keyed.
const KEYING_THRESHOLD: f32 = 0.2;
/// Whether the image carries enough transparency to warrant keying.
pub fn should_key_image(img: &ColorImage) -> bool {
if img.width == 0 || img.height == 0 {
return false;
}
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
let mut transparent = 0usize;
let rows = [
0,
img.height / 4,
img.height / 2,
3 * img.height / 4,
img.height - 1,
];
for y in rows {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
transparent += 1;
}
if transparent >= threshold {
return true;
}
}
}
false
}
fn color_exists(img: &ColorImage, color: Color) -> bool {
for y in 0..img.height {
for x in 0..img.width {
let p = img.get_pixel(x, y);
if p.r == color.r && p.g == color.g && p.b == color.b {
return true;
}
}
}
false
}
/// Find a color not present in the image, to be used as the key. Tries the
/// primary/secondary colors first, then does a deterministic sweep of the RGB
/// cube. Returns [`Error::NoKeyColor`] only if every probed color is used.
pub fn find_unused_color(img: &ColorImage) -> Result<Color, Error> {
let specials = [
Color::new(255, 0, 0),
Color::new(0, 255, 0),
Color::new(0, 0, 255),
Color::new(255, 255, 0),
Color::new(0, 255, 255),
Color::new(255, 0, 255),
];
for &c in specials.iter() {
if !color_exists(img, c) {
return Ok(c);
}
}
// Deterministic sweep: step by a value coprime-ish with 256 to spread out.
const STEP: u16 = 37;
let mut r = 0u16;
while r < 256 {
let mut g = 0u16;
while g < 256 {
let mut b = 0u16;
while b < 256 {
let c = Color::new(r as u8, g as u8, b as u8);
if !color_exists(img, c) {
return Ok(c);
}
b += STEP;
}
g += STEP;
}
r += STEP;
}
Err(Error::NoKeyColor)
}
/// Recolor every fully-transparent pixel to `key`, in place.
pub fn apply_key(img: &mut ColorImage, key: Color) {
for y in 0..img.height {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
img.set_pixel(x, y, &key);
}
}
}
}
-909
View File
@@ -1,909 +0,0 @@
//! 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 12 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 ±12 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))
}
}
-34
View File
@@ -1,34 +0,0 @@
//! Core intermediate representation shared by the pipeline stages.
//!
//! Two IRs flow through the pipeline:
//!
//! * [`Segmentation`] — the frontend output: ordered paint layers over a
//! raster canvas (painter's algorithm, bottom to top). This is what the
//! [`crate::colorfit`] stages rewrite.
//! * [`VectorDoc`] — the output document: resolved shapes with fitted paths.
//! This is what the [`crate::optimize`] passes and the [`crate::svg`] writer
//! operate on.
mod region;
mod vector;
pub use region::{Layer, RegionMask, Segmentation};
pub use vector::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use visioncortex::Color;
/// The final appearance of a region. Only solid colors are supported today;
/// the enum leaves room for gradients and patterns later.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Paint {
Solid(Color),
}
impl Paint {
/// The representative solid color of this paint.
pub fn color(&self) -> Color {
match self {
Paint::Solid(c) => *c,
}
}
}
-123
View File
@@ -1,123 +0,0 @@
use visioncortex::{BinaryImage, PointI32};
use super::Paint;
/// A region's pixel coverage: a local binary mask positioned on the canvas.
///
/// Foreground pixels are `true`. Holes (interior background) are already
/// punched out of the mask, so a mask is self-describing for tracing.
#[derive(Debug, Clone)]
pub struct RegionMask {
/// Local coverage; `true` = inside the region.
pub image: BinaryImage,
/// Position of the mask's top-left corner in full-canvas coordinates.
pub offset: PointI32,
}
impl RegionMask {
pub fn new(image: BinaryImage, offset: PointI32) -> Self {
Self { image, offset }
}
pub fn width(&self) -> usize {
self.image.width
}
pub fn height(&self) -> usize {
self.image.height
}
/// Number of foreground pixels.
pub fn area(&self) -> usize {
let mut count = 0;
for y in 0..self.image.height {
for x in 0..self.image.width {
if self.image.get_pixel(x, y) {
count += 1;
}
}
}
count
}
/// Combine two masks into one covering the union of their bounding boxes.
/// Foreground is the OR of both; this is used by the layer-merge step.
pub fn union(&self, other: &RegionMask) -> RegionMask {
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(),
}
}
}
-90
View File
@@ -1,90 +0,0 @@
use visioncortex::PointF64;
use super::Paint;
/// A single drawing command in a subpath. Coordinates are absolute, in
/// full-canvas (document) space — the writer bakes any offset into them.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PathCmd {
/// Start a new subpath at the given point.
MoveTo(PointF64),
/// Straight line to the given point.
LineTo(PointF64),
/// Cubic Bézier: two control points then the endpoint.
CubicTo(PointF64, PointF64, PointF64),
/// Close the current subpath back to its start.
Close,
}
/// One connected outline: a `MoveTo` followed by line/cubic segments, usually
/// terminated by `Close`.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SubPath {
pub commands: Vec<PathCmd>,
}
impl SubPath {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
/// The starting point of the subpath, if any.
pub fn start(&self) -> Option<PointF64> {
match self.commands.first() {
Some(PathCmd::MoveTo(p)) => Some(*p),
_ => None,
}
}
}
/// A shape may consist of several subpaths (outer ring plus holes).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MultiPath {
pub subpaths: Vec<SubPath>,
}
impl MultiPath {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.subpaths.iter().all(SubPath::is_empty)
}
pub fn push(&mut self, subpath: SubPath) {
if !subpath.is_empty() {
self.subpaths.push(subpath);
}
}
}
/// A filled shape in the output document.
#[derive(Debug, Clone)]
pub struct Shape {
pub paint: Paint,
pub path: MultiPath,
}
/// The output document IR: what the optimizer passes and the writer consume.
#[derive(Debug, Clone)]
pub struct VectorDoc {
pub width: u32,
pub height: u32,
/// Shapes in paint order (first drawn is bottom).
pub shapes: Vec<Shape>,
}
impl VectorDoc {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
shapes: Vec::new(),
}
}
}
-105
View File
@@ -1,105 +0,0 @@
//! # 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};
-664
View File
@@ -1,664 +0,0 @@
//! Mosaic mode: a seam-free, gapless tessellation.
//!
//! Instead of tracing every region independently (which lets neighboring
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
//!
//! ```text
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
//! ```
//!
//! Every boundary curve exists exactly once; the two adjacent regions
//! reference the same fitted geometry, one traversed reversed. Reversal is
//! exact, so the serialized coordinates match on both sides — no seams.
//!
//! Stages 12 (graph + faces) are pure integer arithmetic on the lattice of
//! pixel corners. Only fitting (stage 3) is floating point.
mod compose;
mod face;
mod fit;
mod graph;
pub use compose::compose_mosaic;
pub use fit::{
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));
}
}
}
-135
View File
@@ -1,135 +0,0 @@
//! Stage 4: compose per-region SVG paths from shared fitted segments.
//!
//! Each region becomes one shape whose `d` concatenates its contours as
//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each
//! oriented segment is emitted skipping its first point (identical to the
//! previous segment's last point), so shared boundaries are byte-identical on
//! both sides.
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use 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]));
}
}
}
}
-214
View File
@@ -1,214 +0,0 @@
//! Stage 2: face assembly.
//!
//! Lift the "region kept on the left" successor rule from unit edges to whole
//! segments. Following it around each region yields its contours; because the
//! interior is always on the left, outer contours and hole contours come out
//! with opposite winding automatically — no containment/nesting computation is
//! needed, and the region can be filled with a single `nonzero` path.
use 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()
}
-258
View File
@@ -1,258 +0,0 @@
//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes.
//!
//! A segment is fitted a single time and cached; both adjacent faces reference
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
//! the shared geometry is bitwise identical and no seam can appear.
use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideSmooth};
use super::graph::Segment;
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),
}
}
}
-370
View File
@@ -1,370 +0,0 @@
//! Stage 1: boundary-graph extraction from a [`LabelMap`].
//!
//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`.
//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run
//! along the "cracks" between differing labels.
use visioncortex::PointI32;
use super::{LabelMap, RegionId, OUTSIDE};
pub type NodeId = u32;
pub type SegId = u32;
// Unit directions, arranged clockwise in y-down screen space so that
// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal.
const N: u8 = 0;
const E: u8 = 1;
const S: u8 = 2;
const W: u8 = 3;
/// (dx, dy) per direction.
const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
#[inline]
pub(super) fn turn_right(d: u8) -> u8 {
(d + 1) % 4
}
#[inline]
pub(super) fn straight(d: u8) -> u8 {
d
}
#[inline]
pub(super) fn turn_left(d: u8) -> u8 {
(d + 3) % 4
}
#[inline]
pub(super) fn reverse(d: u8) -> u8 {
(d + 2) % 4
}
/// A directed reference to a segment: either traversed forward or reversed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SegRef {
pub seg: SegId,
pub forward: bool,
}
/// A junction corner (degree ≥ 3) with the segment leaving it in each unit
/// direction (if any).
#[derive(Clone, Debug)]
pub struct Node {
pub corner: PointI32,
pub out: [Option<SegRef>; 4],
}
/// A maximal boundary chain between two nodes, or a nodeless ring.
#[derive(Clone, Debug)]
pub struct Segment {
/// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`.
pub points: Vec<PointI32>,
pub start: Option<NodeId>,
pub end: Option<NodeId>,
/// Region on the left when traversing forward (y-down convention).
pub left: RegionId,
pub right: RegionId,
/// Direction of the first edge (leaving `start`); unused for rings.
pub first_dir: u8,
/// Direction of the last edge (arriving at `end`); unused for rings.
pub last_dir: u8,
}
impl Segment {
pub fn is_ring(&self) -> bool {
self.start.is_none()
}
}
/// The extracted boundary graph. Faces are assembled separately (see `face`).
pub struct BoundaryGraph {
pub nodes: Vec<Node>,
pub segments: Vec<Segment>,
}
struct Extractor<'a> {
map: &'a LabelMap,
w: i32,
h: i32,
/// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1).
node_at: Vec<NodeId>,
/// Visited flags for undirected unit edges.
visited_v: Vec<bool>, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x
visited_h: Vec<bool>, // horizontal edge (x in 0..W, y in 0..=H): y*W + x
nodes: Vec<Node>,
segments: Vec<Segment>,
}
impl<'a> Extractor<'a> {
fn new(map: &'a LabelMap) -> Self {
let w = map.width as i32;
let h = map.height as i32;
let cw = (map.width + 1) as usize;
let ch = (map.height + 1) as usize;
Extractor {
map,
w,
h,
node_at: vec![u32::MAX; cw * ch],
visited_v: vec![false; (map.width as usize + 1) * map.height as usize],
visited_h: vec![false; map.width as usize * (map.height as usize + 1)],
nodes: Vec::new(),
segments: Vec::new(),
}
}
#[inline]
fn corner_index(&self, x: i32, y: i32) -> usize {
y as usize * (self.w as usize + 1) + x as usize
}
/// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`.
fn edge_mask(&self, x: i32, y: i32) -> u8 {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
let mut m = 0u8;
if nw != ne {
m |= 1 << N;
}
if ne != se {
m |= 1 << E;
}
if sw != se {
m |= 1 << S;
}
if nw != sw {
m |= 1 << W;
}
m
}
/// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`.
fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
match d {
N => (nw, ne),
E => (ne, se),
S => (se, sw),
W => (sw, nw),
_ => unreachable!(),
}
}
/// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`.
/// Returns the canonical (is_vertical, index).
fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) {
match d {
N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize),
S => (true, y as usize * (self.w as usize + 1) + x as usize),
E => (false, y as usize * self.w as usize + x as usize),
W => (false, y as usize * self.w as usize + (x - 1) as usize),
_ => unreachable!(),
}
}
fn is_visited(&self, x: i32, y: i32, d: u8) -> bool {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i]
} else {
self.visited_h[i]
}
}
fn mark_visited(&mut self, x: i32, y: i32, d: u8) {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i] = true;
} else {
self.visited_h[i] = true;
}
}
/// Pass A — classify corners and allocate node ids for degree ≥ 3.
fn classify(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let deg = self.edge_mask(x, y).count_ones();
if deg >= 3 {
let id = self.nodes.len() as NodeId;
self.nodes.push(Node {
corner: PointI32 { x, y },
out: [None; 4],
});
let ci = self.corner_index(x, y);
self.node_at[ci] = id;
}
}
}
}
fn node_id(&self, x: i32, y: i32) -> Option<NodeId> {
let id = self.node_at[self.corner_index(x, y)];
if id == u32::MAX {
None
} else {
Some(id)
}
}
/// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to
/// the start). Returns the polyline, the final heading, and the corner
/// walked to. Marks every traversed edge visited.
fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec<PointI32>, u8, i32, i32) {
let mut points = vec![PointI32 { x: x0, y: y0 }];
let (mut cx, mut cy, mut d) = (x0, y0, d0);
loop {
self.mark_visited(cx, cy, d);
let (dx, dy) = DVEC[d as usize];
let (nx, ny) = (cx + dx, cy + dy);
points.push(PointI32 { x: nx, y: ny });
let mask = self.edge_mask(nx, ny);
if mask.count_ones() >= 3 {
return (points, d, nx, ny); // reached a node
}
if nx == x0 && ny == y0 {
return (points, d, nx, ny); // closed ring
}
// Degree-2: continue via the unique present edge that is not the
// reverse of how we arrived.
let rev = reverse(d);
let mut nd = d;
for cand in 0..4u8 {
if cand != rev && (mask & (1 << cand)) != 0 {
nd = cand;
break;
}
}
d = nd;
cx = nx;
cy = ny;
}
}
/// Pass B — trace node-to-node segments.
fn trace_segments(&mut self) {
let node_corners: Vec<PointI32> = self.nodes.iter().map(|n| n.corner).collect();
for (nid, corner) in node_corners.iter().enumerate() {
let nid = nid as NodeId;
let (x, y) = (corner.x, corner.y);
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, last_dir, ex, ey) = self.walk(x, y, d);
let end = self
.node_id(ex, ey)
.expect("segment must end at a node");
let seg_id = self.segments.len() as SegId;
self.segments.push(Segment {
points,
start: Some(nid),
end: Some(end),
left,
right,
first_dir: d,
last_dir,
});
self.nodes[nid as usize].out[d as usize] = Some(SegRef {
seg: seg_id,
forward: true,
});
// Leaving the end node backward along this segment.
let back = reverse(last_dir);
self.nodes[end as usize].out[back as usize] = Some(SegRef {
seg: seg_id,
forward: false,
});
}
}
}
/// Pass C — closed rings from any remaining unvisited boundary edges.
fn trace_rings(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, _last, _ex, _ey) = self.walk(x, y, d);
self.segments.push(Segment {
points,
start: None,
end: None,
left,
right,
first_dir: d,
last_dir: 0,
});
}
}
}
}
}
impl BoundaryGraph {
pub fn extract(map: &LabelMap) -> BoundaryGraph {
let mut ex = Extractor::new(map);
ex.classify();
ex.trace_segments();
ex.trace_rings();
BoundaryGraph {
nodes: ex.nodes,
segments: ex.segments,
}
}
}
/// 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,
}
}
-209
View File
@@ -1,209 +0,0 @@
//! Optimizer passes over the [`VectorDoc`] before serialization.
//!
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
//! it here (rather than at write time) lets [`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);
}
}
-132
View File
@@ -1,132 +0,0 @@
//! The pipeline driver: composes the stages and runs an image through them.
use visioncortex::ColorImage;
use crate::colorfit::ColorFitter;
use crate::compose::Compositing;
use crate::error::Error;
use crate::frontend::Frontend;
use crate::ir::{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)?))
}
}
-113
View File
@@ -1,113 +0,0 @@
//! 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),
});
}
}
-159
View File
@@ -1,159 +0,0 @@
//! 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
}
}
-383
View File
@@ -1,383 +0,0 @@
//! 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"),
}
}
}
-582
View File
@@ -1,582 +0,0 @@
//! Serialize a [`VectorDoc`] to an SVG string.
//!
//! The writer makes the encoding choices that shrink output without changing
//! geometry:
//!
//! * per segment, the shorter of absolute vs. relative deltas (`L`/`l`, `C`/`c`);
//! * `H`/`V` (`h`/`v`) for axis-aligned lines and `S`/`s` for smooth cubic
//! continuations;
//! * compact number formatting (trimmed zeros, leading-dot decimals, no
//! separator before a negative);
//! * optional `<g fill>` grouping of consecutive same-fill shapes.
//!
//! Coordinates are assumed to already be in absolute document space (the
//! [`crate::optimize::QuantizePass`] bakes in any offset), so no per-path
//! `transform` is emitted.
use std::fmt::Write as _;
use visioncortex::PointF64;
use crate::ir::{Paint, PathCmd, Shape, SubPath, VectorDoc};
/// SVG serializer configuration.
#[derive(Debug, Clone, Copy)]
pub struct SvgWriter {
/// Allow relative commands where they serialize shorter.
pub relative: bool,
/// Allow `H`/`V`/`S` shorthands and `<g fill>` grouping.
pub shorthands: bool,
/// Decimal places for coordinates (`None` = full precision).
pub precision: Option<u32>,
}
impl Default for SvgWriter {
fn default() -> Self {
Self {
relative: true,
shorthands: true,
precision: Some(2),
}
}
}
impl SvgWriter {
pub fn write(&self, doc: &VectorDoc) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(
out,
"<!-- Generator: visioncortex VTracer {} -->",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">",
doc.width, doc.height
);
if self.shorthands {
self.write_grouped(&mut out, &doc.shapes);
} else {
for shape in &doc.shapes {
self.write_path(&mut out, shape, true);
}
}
out.push_str("</svg>\n");
out
}
/// Emit shapes, grouping maximal runs of consecutive same-fill shapes into
/// a single `<g fill>` (preserving paint order).
fn write_grouped(&self, out: &mut String, shapes: &[Shape]) {
let mut i = 0;
while i < shapes.len() {
let fill = shape_fill(&shapes[i]);
let mut j = i + 1;
while j < shapes.len() && shape_fill(&shapes[j]) == fill {
j += 1;
}
let run = &shapes[i..j];
if run.len() > 1 {
let _ = writeln!(out, "<g fill=\"{}\">", fill);
for shape in run {
self.write_path(out, shape, false);
}
out.push_str("</g>\n");
} else {
self.write_path(out, &run[0], true);
}
i = j;
}
}
fn write_path(&self, out: &mut String, shape: &Shape, with_fill: bool) {
let d = self.encode_path(shape);
if d.is_empty() {
return;
}
if with_fill {
let _ = writeln!(
out,
"<path d=\"{}\" fill=\"{}\"/>",
d,
shape_fill(shape)
);
} else {
let _ = writeln!(out, "<path d=\"{}\"/>", d);
}
}
fn encode_path(&self, shape: &Shape) -> String {
let mut emitter = Emitter::new(self.relative, self.shorthands, self.precision);
for sub in &shape.path.subpaths {
emitter.subpath(sub);
}
emitter.finish()
}
}
fn shape_fill(shape: &Shape) -> String {
match shape.paint {
Paint::Solid(c) => c.to_hex_string(),
}
}
/// Streaming SVG-path encoder that tracks the current point.
struct Emitter {
relative: bool,
shorthands: bool,
precision: Option<u32>,
out: String,
cur: PointF64,
/// Start of the current subpath; `cur` returns here after `Z`.
subpath_start: PointF64,
started: bool,
/// Absolute second control point of the previous cubic, for `S` detection.
prev_cubic_c2: Option<PointF64>,
}
impl Emitter {
fn new(relative: bool, shorthands: bool, precision: Option<u32>) -> Self {
Self {
relative,
shorthands,
precision,
out: String::new(),
cur: PointF64::default(),
subpath_start: PointF64::default(),
started: false,
prev_cubic_c2: None,
}
}
fn finish(self) -> String {
self.out
}
fn subpath(&mut self, sub: &SubPath) {
for cmd in &sub.commands {
match *cmd {
PathCmd::MoveTo(p) => self.move_to(p),
PathCmd::LineTo(p) => self.line_to(p),
PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e),
PathCmd::Close => {
self.out.push('Z');
// SVG resets the current point to the subpath's start after
// Z; a following relative `m`/`l` is measured from there.
self.cur = self.subpath_start;
self.prev_cubic_c2 = None;
}
}
}
}
fn move_to(&mut self, p: PointF64) {
if !self.started {
// First move is always absolute.
let token = format!("M{}", self.coord(p));
self.out.push_str(&token);
self.started = true;
} else {
let abs = format!("M{}", self.coord(p));
let token = if self.relative {
let rel = format!("m{}", self.coord_delta(p));
shorter(abs, rel)
} else {
abs
};
self.out.push_str(&token);
}
self.cur = p;
self.subpath_start = p;
self.prev_cubic_c2 = None;
}
fn line_to(&mut self, p: PointF64) {
let mut candidates: Vec<String> = Vec::new();
// Axis-aligned shorthands.
if self.shorthands {
if p.y == self.cur.y {
candidates.push(format!("H{}", self.num(p.x)));
if self.relative {
candidates.push(format!("h{}", self.num(p.x - self.cur.x)));
}
}
if p.x == self.cur.x {
candidates.push(format!("V{}", self.num(p.y)));
if self.relative {
candidates.push(format!("v{}", self.num(p.y - self.cur.y)));
}
}
}
candidates.push(format!("L{}", self.coord(p)));
if self.relative {
candidates.push(format!("l{}", self.coord_delta(p)));
}
self.out.push_str(&shortest(candidates));
self.cur = p;
self.prev_cubic_c2 = None;
}
fn cubic_to(&mut self, c1: PointF64, c2: PointF64, e: PointF64) {
let mut candidates: Vec<String> = Vec::new();
// Smooth continuation: c1 is the reflection of the previous cubic's c2.
if self.shorthands {
if let Some(prev_c2) = self.prev_cubic_c2 {
let reflection = PointF64 {
x: 2.0 * self.cur.x - prev_c2.x,
y: 2.0 * self.cur.y - prev_c2.y,
};
if approx(reflection, c1) {
candidates.push(format!(
"S{}",
self.coord_list(&[c2, e])
));
if self.relative {
candidates.push(format!(
"s{}",
self.delta_list(&[c2, e])
));
}
}
}
}
candidates.push(format!("C{}", self.coord_list(&[c1, c2, e])));
if self.relative {
candidates.push(format!("c{}", self.delta_list(&[c1, c2, e])));
}
self.out.push_str(&shortest(candidates));
self.cur = e;
self.prev_cubic_c2 = Some(c2);
}
// --- number/coordinate formatting -------------------------------------
fn num(&self, v: f64) -> String {
fmt_num(v, self.precision)
}
/// Absolute coordinate pair.
fn coord(&self, p: PointF64) -> String {
join_nums(&[self.num(p.x), self.num(p.y)])
}
/// Delta coordinate pair relative to the current point.
fn coord_delta(&self, p: PointF64) -> String {
join_nums(&[self.num(p.x - self.cur.x), self.num(p.y - self.cur.y)])
}
/// Absolute list of points, flattened.
fn coord_list(&self, pts: &[PointF64]) -> String {
let mut nums = Vec::with_capacity(pts.len() * 2);
for p in pts {
nums.push(self.num(p.x));
nums.push(self.num(p.y));
}
join_nums(&nums)
}
/// Delta list of points relative to the current point (all deltas are from
/// `cur`, matching SVG's relative-command semantics for multi-point ops).
fn delta_list(&self, pts: &[PointF64]) -> String {
let mut nums = Vec::with_capacity(pts.len() * 2);
for p in pts {
nums.push(self.num(p.x - self.cur.x));
nums.push(self.num(p.y - self.cur.y));
}
join_nums(&nums)
}
}
fn approx(a: PointF64, b: PointF64) -> bool {
(a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6
}
fn shorter(a: String, b: String) -> String {
if b.len() < a.len() {
b
} else {
a
}
}
fn shortest(candidates: Vec<String>) -> String {
candidates
.into_iter()
.min_by_key(|s| s.len())
.unwrap_or_default()
}
/// Join formatted numbers with the minimal separators SVG allows: a comma,
/// except that a leading `-` is self-separating.
fn join_nums(nums: &[String]) -> String {
let mut s = String::new();
for (i, n) in nums.iter().enumerate() {
if i > 0 && !n.starts_with('-') {
s.push(',');
}
s.push_str(n);
}
s
}
/// Compact number formatting: round to precision, trim trailing zeros, use a
/// leading-dot for magnitudes below 1.
fn fmt_num(v: f64, precision: Option<u32>) -> String {
let v = match precision {
Some(p) => {
let factor = 10f64.powi(p as i32);
(v * factor).round() / factor
}
None => v,
};
// Normalize -0.0 to 0.
if v == 0.0 {
return "0".to_string();
}
let mut s = match precision {
Some(p) => format!("{:.*}", p as usize, v),
None => format!("{v}"),
};
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if let Some(rest) = s.strip_prefix("0.") {
s = format!(".{rest}");
} else if let Some(rest) = s.strip_prefix("-0.") {
s = format!("-.{rest}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{MultiPath, Paint, PathCmd, Shape, SubPath};
use visioncortex::Color;
#[test]
fn number_formatting() {
assert_eq!(fmt_num(0.0, Some(2)), "0");
assert_eq!(fmt_num(-0.0, Some(2)), "0");
assert_eq!(fmt_num(1.50, Some(2)), "1.5");
assert_eq!(fmt_num(0.5, Some(2)), ".5");
assert_eq!(fmt_num(-0.5, Some(2)), "-.5");
assert_eq!(fmt_num(2.0, Some(2)), "2");
assert_eq!(fmt_num(3.14159, Some(2)), "3.14");
}
#[test]
fn join_omits_separator_before_negative() {
let nums = vec!["1".to_string(), "-2".to_string(), "3".to_string()];
assert_eq!(join_nums(&nums), "1-2,3");
}
fn square_shape() -> Shape {
use visioncortex::PointF64;
let p = |x, y| PointF64 { x, y };
let mut sub = SubPath::new();
sub.commands = vec![
PathCmd::MoveTo(p(0.0, 0.0)),
PathCmd::LineTo(p(10.0, 0.0)),
PathCmd::LineTo(p(10.0, 10.0)),
PathCmd::LineTo(p(0.0, 10.0)),
PathCmd::Close,
];
Shape {
paint: Paint::Solid(Color::new(255, 0, 0)),
path: MultiPath { subpaths: vec![sub] },
}
}
#[test]
fn encodes_axis_aligned_shorthands() {
let writer = SvgWriter {
relative: true,
shorthands: true,
precision: Some(2),
};
let d = writer.encode_path(&square_shape());
// Horizontal/vertical lines collapse to H/V/h/v; first move is absolute.
assert!(d.starts_with("M0,0"));
assert!(d.contains('H') || d.contains('h'));
assert!(d.contains('V') || d.contains('v'));
assert!(d.ends_with('Z'));
}
#[test]
fn absolute_mode_uses_no_relative_commands() {
let writer = SvgWriter {
relative: false,
shorthands: false,
precision: Some(2),
};
let d = writer.encode_path(&square_shape());
assert!(!d.contains('l'));
assert!(!d.contains('c'));
assert!(d.contains('L'));
}
/// A shape with a hole (second subpath). Encoded absolute vs relative must
/// describe the *same* geometry — regression for the bug where the current
/// point was not reset to the subpath start after `Z`, so the relative `m`
/// of the hole was measured from the wrong origin.
fn holed_shape() -> Shape {
use visioncortex::PointF64;
let p = |x, y| PointF64 { x, y };
let outer = SubPath {
commands: vec![
PathCmd::MoveTo(p(0.0, 0.0)),
PathCmd::LineTo(p(30.0, 0.0)),
PathCmd::LineTo(p(30.0, 30.0)),
PathCmd::LineTo(p(0.0, 30.0)),
PathCmd::Close,
],
};
let hole = SubPath {
commands: vec![
PathCmd::MoveTo(p(10.0, 10.0)),
PathCmd::LineTo(p(20.0, 10.0)),
PathCmd::LineTo(p(20.0, 20.0)),
PathCmd::LineTo(p(10.0, 20.0)),
PathCmd::Close,
],
};
Shape {
paint: Paint::Solid(Color::new(0, 0, 0)),
path: MultiPath {
subpaths: vec![outer, hole],
},
}
}
/// Parse an SVG `d` (M/m/L/l/H/h/V/v/Z only) into absolute points.
fn parse_abs(d: &str) -> Vec<(f64, f64)> {
let mut toks = Vec::new();
let mut i = 0;
let b = d.as_bytes();
while i < b.len() {
let c = b[i] as char;
if c.is_ascii_alphabetic() {
toks.push(c.to_string());
i += 1;
} else if c == '-' || c == '.' || c.is_ascii_digit() {
let start = i;
i += 1;
while i < b.len() && {
let d = b[i] as char;
d.is_ascii_digit() || d == '.'
} {
i += 1;
}
toks.push(d[start..i].to_string());
} else {
i += 1;
}
}
let mut out = Vec::new();
let (mut cx, mut cy, mut sx, mut sy) = (0.0, 0.0, 0.0, 0.0);
let mut j = 0;
let mut cmd = ' ';
let num = |j: &mut usize| -> f64 {
let v = toks[*j].parse().unwrap();
*j += 1;
v
};
while j < toks.len() {
if toks[j].chars().next().unwrap().is_ascii_alphabetic() {
cmd = toks[j].chars().next().unwrap();
j += 1;
}
let rel = cmd.is_ascii_lowercase();
match cmd.to_ascii_uppercase() {
'M' => {
let (mut x, mut y) = (num(&mut j), num(&mut j));
if rel {
x += cx;
y += cy;
}
cx = x;
cy = y;
sx = x;
sy = y;
out.push((cx, cy));
cmd = if rel { 'l' } else { 'L' };
}
'L' => {
let (mut x, mut y) = (num(&mut j), num(&mut j));
if rel {
x += cx;
y += cy;
}
cx = x;
cy = y;
out.push((cx, cy));
}
'H' => {
let mut x = num(&mut j);
if rel {
x += cx;
}
cx = x;
out.push((cx, cy));
}
'V' => {
let mut y = num(&mut j);
if rel {
y += cy;
}
cy = y;
out.push((cx, cy));
}
'Z' => {
cx = sx;
cy = sy;
}
_ => unreachable!(),
}
}
out
}
#[test]
fn relative_and_absolute_encode_same_geometry() {
let shape = holed_shape();
let abs = SvgWriter {
relative: false,
shorthands: false,
precision: Some(2),
}
.encode_path(&shape);
for shorthands in [false, true] {
let rel = SvgWriter {
relative: true,
shorthands,
precision: Some(2),
}
.encode_path(&shape);
assert_eq!(
parse_abs(&abs),
parse_abs(&rel),
"relative (shorthands={shorthands}) geometry diverges from absolute:\n abs={abs}\n rel={rel}"
);
}
}
}
-122
View File
@@ -1,122 +0,0 @@
//! Binary thresholding: tunable fixed cutoff and BradleyRoth 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}"
);
}
-240
View File
@@ -1,240 +0,0 @@
//! Rasterize-and-diff equivalence between stacked and mosaic (cutout) modes.
//!
//! Both modes render the *same* flattened partition of the image — stacked by
//! painting layers top-down, mosaic as a gapless tessellation. So their
//! rasterizations must agree in every region interior; they may differ only
//! within a thin band along region boundaries, where the two fitting paths
//! legitimately place the edge a fraction of a pixel apart. This test asserts
//! exactly that: any pixel that differs must lie within ~12px of a boundary.
//!
//! `resvg` is a dev-dependency, so this never enters a wasm build.
use resvg::{tiny_skia, usvg};
use vtracer::{ColorImage, Config, FitMode, Hierarchical};
/// A few smooth colored discs on a background — curved boundaries, limited
/// boundary length, no thin (1px) features.
fn blobs(w: usize, h: usize) -> ColorImage {
let discs = [
(28.0f64, 30.0, 18.0, (210u8, 60, 60)),
(64.0, 40.0, 20.0, (60, 160, 90)),
(44.0, 68.0, 16.0, (70, 90, 200)),
];
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let mut col = (235u8, 230, 225); // background
for &(cx, cy, r, c) in &discs {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
if dx * dx + dy * dy <= r * r {
col = c;
}
}
pixels.extend_from_slice(&[col.0, col.1, col.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
fn rasterize(svg: &str, w: u32, h: u32) -> Vec<u8> {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
pixmap.data().to_vec()
}
/// Max per-channel difference between two RGBA pixels at index `i`.
fn pixel_diff(a: &[u8], b: &[u8], i: usize) -> u8 {
(0..4)
.map(|c| a[i + c].abs_diff(b[i + c]))
.max()
.unwrap_or(0)
}
/// Mark pixels within Chebyshev radius `r` of a color edge in either image.
fn boundary_band(a: &[u8], b: &[u8], w: usize, h: usize, r: i32) -> Vec<bool> {
const EDGE: u8 = 24;
let idx = |x: usize, y: usize| (y * w + x) * 4;
let mut edge = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let i = idx(x, y);
// An edge is where either rendering changes color vs its right/down
// neighbor.
let mut is_edge = false;
for img in [a, b] {
if x + 1 < w && neighbor_diff(img, i, idx(x + 1, y)) > EDGE {
is_edge = true;
}
if y + 1 < h && neighbor_diff(img, i, idx(x, y + 1)) > EDGE {
is_edge = true;
}
}
if is_edge {
edge[y * w + x] = true;
}
}
}
// Dilate the edge set by r.
let mut band = vec![false; w * h];
for y in 0..h as i32 {
for x in 0..w as i32 {
let mut near = false;
'outer: for dy in -r..=r {
for dx in -r..=r {
let (nx, ny) = (x + dx, y + dy);
if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && edge[ny as usize * w + nx as usize] {
near = true;
break 'outer;
}
}
}
band[y as usize * w + x as usize] = near;
}
}
band
}
fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
}
fn assert_equivalent_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);
}
-317
View File
@@ -1,317 +0,0 @@
//! Golden-snapshot tests over synthetic images, exercising every stage —
//! hierarchical clustering, all three fitters, color fitting, the optimizer
//! passes, and the writer.
//!
//! Goldens are compared by **rendering** both the stored SVG and the freshly
//! produced SVG and diffing pixels, not by byte-equality. The spline fitter's
//! cubic fit is floating-point, and f64 results differ by a few ULPs across
//! architectures (arm64 vs x86_64); after rounding, a coordinate can flip and
//! change the SVG bytes without any real geometry change. A visual diff is
//! encoding-agnostic and tolerant of that sub-pixel noise while still catching
//! genuine regressions.
//!
//! Regenerate goldens after an intentional behavior change with:
//!
//! ```sh
//! VTRACER_BLESS=1 cargo test -p vtracer --test golden
//! ```
use std::path::PathBuf;
use resvg::{tiny_skia, usvg};
use vtracer::{Color, ColorImage, 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
}
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 488 B

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0L48,0L48,40L0,40Z" fill="#28C83C"/>
<path d="M36,0L48,0L48,40L36,40Z" fill="#E6D228"/>
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
<path d="M0,0L12,0L12,40L0,40Z" fill="#DC2828"/>
</svg>

Before

Width:  |  Height:  |  Size: 379 B

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0L48,0l0,40L0,40Z" fill="#28C83C"/>
<path d="M36,0L48,0l0,40L36,40Z" fill="#E6D228"/>
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
<path d="M0,0L12,0l0,40L0,40Z" fill="#DC2828"/>
</svg>

Before

Width:  |  Height:  |  Size: 375 B

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 591 B

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#000000"/>
<path d="M16,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M32,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M8,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M24,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M40,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M0,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M16,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M32,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M8,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M24,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M40,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M0,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M16,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M32,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M8,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0L0,48l48,0L48,0L0,0ZM24,8l1,0l0,1l5,0l0,1l2,0l0,1l2,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,2l1,0l0,2l1,0l0,5l1,0l0,1l-1,0l0,5l-1,0l0,2l-1,0l0,2l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-2,0l0,1l-2,0l0,1l-5,0l0,1l-1,0l0-1l-5,0l0-1l-2,0l0-1l-2,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-2l-1,0l0-2L9,30l0-5L8,25l0-1l1,0l0-5l1,0l0-2l1,0l0-2l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l2,0l0-1l2,0l0-1l5,0l0-1Z" fill="#F0F0F0"/>
<path d="M24,8l0,1L19,9l0,1l-2,0l0,1l-2,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,2l-1,0l0,2L9,19l0,5L8,24l0,1l1,0l0,5l1,0l0,2l1,0l0,2l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l2,0l0,1l2,0l0,1l5,0l0,1l1,0l0-1l5,0l0-1l2,0l0-1l2,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-2l1,0l0-2l1,0l0-5l1,0l0-1l-1,0l0-5l-1,0l0-2l-1,0l0-2l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-2,0l0-1l-2,0l0-1L25,9l0-1L24,8Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 985 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C0,16,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>

Before

Width:  |  Height:  |  Size: 864 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 549 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 520 B

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 520 B

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 839 B

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 781 B

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-103
View File
@@ -1,103 +0,0 @@
//! 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);
}
-98
View File
@@ -1,98 +0,0 @@
//! 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());
}
-94
View File
@@ -1,94 +0,0 @@
//! 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"
);
}
-282
View File
@@ -1,282 +0,0 @@
//! `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"
);
}
-93
View File
@@ -1,93 +0,0 @@
//! 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");
}
}
-92
View File
@@ -1,92 +0,0 @@
//! 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);
}
-682
View File
@@ -1,682 +0,0 @@
//! 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");
}
+72
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
Copyright (c) 2020 Tsang Hao Fung
The content in the /docs directory is for GitHub pages, and is not covered under open source licenses.
+543
View File
@@ -0,0 +1,543 @@
<?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>

After

Width:  |  Height:  |  Size: 52 KiB

Some files were not shown because too many files have changed in this diff Show More