Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01833619be | |||
| 15d6f3bbda | |||
| 037003134d | |||
| 142a2987cb | |||
| 034ce42de0 | |||
| f78c77be21 | |||
| 65218f7f93 | |||
| 657177bda1 | |||
| cee28b09b8 | |||
| 8ac000c213 | |||
| f17908803d | |||
| b17f3565c9 | |||
| 2a82a81a27 | |||
| dac83cdcf6 | |||
| 14665d771f | |||
| 71858aec47 | |||
| 539354c4b2 | |||
| 552bc1b8dd | |||
| e8dce3b972 | |||
| 8d5dd9bdc1 | |||
| 71b7210525 | |||
| 19d4e48cb2 | |||
| daa866862b |
@@ -1,117 +0,0 @@
|
||||
# This file is autogenerated by maturin v1.2.3
|
||||
# To update, run
|
||||
#
|
||||
# maturin generate-ci github
|
||||
#
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
manylinux: auto
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x64, x86]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
architecture: ${{ matrix.target }}
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target: [x86_64, aarch64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist --find-interpreter
|
||||
sccache: 'true'
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
sdist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
path: dist
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: "startsWith(github.ref, 'refs/tags/')"
|
||||
needs: [linux, windows, macos, sdist]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: wheels
|
||||
- name: Publish to PyPI
|
||||
uses: PyO3/maturin-action@v1
|
||||
env:
|
||||
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
with:
|
||||
command: upload
|
||||
args: --non-interactive --skip-existing *
|
||||
@@ -0,0 +1,182 @@
|
||||
# Python wheels for crates/vtracer-py (maturin). Regenerate the skeleton with:
|
||||
# maturin generate-ci github -m crates/vtracer-py/Cargo.toml
|
||||
name: Python
|
||||
|
||||
# Wheel builds are heavy (full platform matrix), so they run only on release
|
||||
# tags and on-demand — not on every push/PR. Rust/wasm/Node CI (rust.yml) still
|
||||
# gates ordinary commits.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: ubuntu-22.04
|
||||
target: x86_64
|
||||
- runner: ubuntu-22.04
|
||||
target: x86
|
||||
- runner: ubuntu-22.04
|
||||
target: aarch64
|
||||
- runner: ubuntu-22.04
|
||||
target: armv7
|
||||
- runner: ubuntu-22.04
|
||||
target: s390x
|
||||
- runner: ubuntu-22.04
|
||||
target: ppc64le
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
manylinux: auto
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-linux-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
musllinux:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: ubuntu-22.04
|
||||
target: x86_64
|
||||
- runner: ubuntu-22.04
|
||||
target: x86
|
||||
- runner: ubuntu-22.04
|
||||
target: aarch64
|
||||
- runner: ubuntu-22.04
|
||||
target: armv7
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
manylinux: musllinux_1_2
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-musllinux-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
windows:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: windows-latest
|
||||
target: x64
|
||||
python_arch: x64
|
||||
- runner: windows-latest
|
||||
target: x86
|
||||
python_arch: x86
|
||||
- runner: windows-11-arm
|
||||
target: aarch64
|
||||
python_arch: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.13
|
||||
architecture: ${{ matrix.platform.python_arch }}
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-windows-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
macos:
|
||||
runs-on: ${{ matrix.platform.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- runner: macos-15-intel
|
||||
target: x86_64
|
||||
- runner: macos-latest
|
||||
target: aarch64
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist --find-interpreter --manifest-path crates/vtracer-py/Cargo.toml
|
||||
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
- name: Upload wheels
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-macos-${{ matrix.platform.target }}
|
||||
path: dist
|
||||
|
||||
sdist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist --manifest-path crates/vtracer-py/Cargo.toml
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: wheels-sdist
|
||||
path: dist
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
# Specifying a GitHub environment is optional, but strongly encouraged
|
||||
environment: python
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
|
||||
needs: [linux, musllinux, windows, macos, sdist]
|
||||
permissions:
|
||||
# Use to sign the release artifacts
|
||||
id-token: write
|
||||
# Used to upload release artifacts
|
||||
contents: write
|
||||
# Used to generate artifact attestation
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v6
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-path: 'wheels-*/*'
|
||||
- name: Install uv
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Publish to PyPI
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
run: uv publish 'wheels-*/*'
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Release
|
||||
|
||||
# Builds the `vtracer` CLI binary (crates/vtracer-cli) for each target.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: taiki-e/upload-rust-binary-action@v1
|
||||
with:
|
||||
bin: vtracer
|
||||
target: ${{ matrix.target }}
|
||||
# (required) GitHub token for uploading assets to GitHub Releases.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,22 +1,57 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
push:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
branches:
|
||||
- master
|
||||
- 0.*.x
|
||||
- ci-*
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --workspace --verbose
|
||||
- name: Test
|
||||
run: cargo test --workspace --verbose
|
||||
|
||||
wasm:
|
||||
name: wasm-safety (core)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup target add wasm32-unknown-unknown
|
||||
- name: Build core for wasm32
|
||||
run: cargo build --target wasm32-unknown-unknown -p vtracer
|
||||
|
||||
nodejs:
|
||||
name: Node package
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
- name: Build & test
|
||||
working-directory: nodejs
|
||||
run: |
|
||||
wasm-pack build --target nodejs --out-dir pkg
|
||||
node test.js
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
target
|
||||
Cargo.lock
|
||||
*.sublime*
|
||||
.vscode
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
@@ -5,13 +5,92 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## 0.6.0 - 2023-09-08
|
||||
## 1.0.0-alpha.3 - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
* `vtracer-bench`: a blind fidelity benchmark for raster-to-vector tracers — it compares an original raster against a rendered reconstruction and reports one 0..1 score built from PSNR, SSIM, and a clustered-diff "missing patch" metric (geometric mean, so a single collapsed axis drags the score down). Blind to how the reconstruction was produced: render any tracer's output to pixels and score it. A new workspace crate, separate from the four shipped packages.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Watershed no longer leaks a region along a blurred low-contrast crack as a 1-px filament (a slightly soft image could grow a hairline of one region's color running tens of px down a neighbouring boundary). The boundary snap's mixture gate now also admits pixels that blend two *neighbouring* regions — a blend band belongs to its closer flank even when the basin cut misattributed it to a distant region. On the blurred striped synthetic the circle's max boundary error drops from 31.6 px to 1.5 px; crisp images are byte-unaffected.
|
||||
|
||||
## 1.0.0-alpha.2 - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
* Watershed clustering (`--clustering watershed`): a new region-forming frontend — a hierarchical watershed on the pixel graph (Cousty et al. 2009; Najman et al. 2013), controlled by one dial, `--watershed-detail` (0..=255; each +25.5 roughly doubles the region count). Regions follow image content, with no watershed-line pixels.
|
||||
* Boundaries come out calm: antialiased pixels snap to the color-midpoint iso-line instead of meandering with the noise inside the ramp.
|
||||
* `stacked` stacks the merge tree itself (coarse ancestors below, refined regions on top), so overdraw stays seam-free.
|
||||
* `cutout` gets the partition natively; neighbouring faces closer than `max(2, (255 − detail) / 8)` merge, so faces a human cannot tell apart never survive as separate patches.
|
||||
* `WatershedHierarchy` is public, split into `build` (expensive, image-only) and `cut` (near-instant); `Session` re-cuts a cached hierarchy on detail changes, making the slider fully interactive (~25 ms vs ~40 ms on a 1400×775 photo).
|
||||
* Curve simplification (`--simplify <tolerance>`, `Config::simplify`, `simplify` in Python and Node; off by default): a paper.js-style Schneider re-fit — each smooth run between corners is redrawn with the fewest cubics that stay within the tolerance (px). Roughly halves file size (sample photo at tolerance 1: 229 → 138 KB stacked, 103 → 36 KB watershed cutout). Runs on fitted geometry before composition, so cutout simplifies each shared boundary once and stays seam-free; corners and junction endpoints stay pinned.
|
||||
* Binary thresholding: a tunable fixed threshold (`--threshold`) and Bradley–Roth adaptive thresholding for uneven lighting (`--adaptive`, `--adaptive-window`, `--adaptive-t`) — also on `Config`, Python, and Node.
|
||||
* Cutout mode merges neighbouring faces whose colors are within one gradient step, rejoining the near-identical faces that stacked gradient layering splits a smooth area into.
|
||||
|
||||
### Changed
|
||||
|
||||
* `color_mode` is replaced by `clustering` (`color-cluster` | `bw` | `watershed`) across the CLI, Rust, Python, and Node — the field selects the region-forming algorithm, not a color space.
|
||||
* The spline fine-tuning flags (`--corner-threshold`, `--segment-length`, `--splice-threshold`) are hidden from CLI help — still accepted, but without their `-c`/`-l`/`-s` short forms. The defaults serve virtually every conversion; `--simplify` supersedes them.
|
||||
|
||||
### Fixed
|
||||
|
||||
* Spline fitting no longer swings far away from the outline around thin strands (a long-standing defect, fixed via visioncortex 0.9.1): a sparse splice slice could be fitted by a single cubic that passed through every sample yet ballooned up to ~30 px sideways between them. Slices are now densified before fitting and multi-cubic fits kept in full, in both stacked mode and the mosaic fitter.
|
||||
|
||||
## 1.0.0-alpha.1 - 2026-07-24
|
||||
|
||||
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
|
||||
|
||||
### Added
|
||||
|
||||
* Pluggable pipeline: swappable frontend (segmentation), color fitting (incl. custom palettes), curve-fitting backend, and an optimizer pass phase.
|
||||
* **Mosaic mode**: true seam-free, gapless tessellation via shared boundary-graph tracing (pixel, polygon, and spline fitters), replacing the old "cutout" that produced seams.
|
||||
* SVG optimizer: relative path syntax, shorthand commands, and coordinate-precision reduction for smaller files.
|
||||
* `@visioncortex/vtracer` Node.js package (npm): wasm core with a native image reader.
|
||||
* Rewritten Python bindings (`vtracer-py`) with a richer API; pyo3 bumped to 0.26 (fixes CPython 3.14 segfaults, #124).
|
||||
* CLI accepts positional `input`/`output` arguments (#114).
|
||||
|
||||
### Changed
|
||||
|
||||
* Workspace restructured into `crates/vtracer` (core lib), `crates/vtracer-cli`, `crates/vtracer-py`, and `nodejs/`.
|
||||
* CLI upgraded from clap 2.x to 4.x (#118).
|
||||
* `filter_speckle` CLI cap raised from 16 to 128, matching the web app (#115).
|
||||
* Depends on `visioncortex` 0.9.
|
||||
* Python wheel CI now runs only on release tags and manual dispatch, not on every commit.
|
||||
|
||||
### Removed
|
||||
|
||||
* The pre-1.0 `cmdapp` crate and the demo webapp GUI.
|
||||
|
||||
## 0.6.12 - 2026-02-04
|
||||
|
||||
* Python Binding
|
||||
|
||||
## 0.6.5 - 2025-10-17
|
||||
|
||||
* Update `fastrand` to `2.3`
|
||||
|
||||
## 0.6.4 - 2024-03-29
|
||||
|
||||
* Update `visioncortex` version to `0.8.8`
|
||||
|
||||
## 0.6.3 - 2023-11-21
|
||||
|
||||
* New converter API https://github.com/visioncortex/vtracer/pull/59
|
||||
|
||||
## 0.6.1 - 2023-09-23
|
||||
|
||||
* Fixed "The two lines are parallel!"
|
||||
|
||||
### Python Binding
|
||||
|
||||
Thanks to the contribution of [@etjones](https://github.com/etjones), we now have an official Python binding! https://github.com/visioncortex/vtracer/pull/55
|
||||
|
||||
https://pypi.org/project/vtracer/0.6.10/
|
||||
|
||||
## 0.5.0 - 2022-10-09
|
||||
|
||||
* Handle transparent png images (cli) (#23)
|
||||
* Handle transparent png images (cli) https://github.com/visioncortex/vtracer/pull/23
|
||||
|
||||
## 0.4.0 - 2021-07-23
|
||||
|
||||
@@ -25,6 +104,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
* Use relative & closed paths
|
||||
|
||||
## 0.1.1 - 2020-11-01
|
||||
|
||||
* SVG namespace
|
||||
|
||||
## 0.1.0 - 2020-10-31
|
||||
|
||||
* Initial release
|
||||
@@ -1,7 +1,34 @@
|
||||
[workspace]
|
||||
|
||||
members = [
|
||||
"cmdapp",
|
||||
"webapp",
|
||||
"crates/vtracer",
|
||||
"crates/vtracer-cli",
|
||||
"crates/vtracer-bench",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
|
||||
# build. It is superseded by the crates/ workspace above.
|
||||
exclude = [
|
||||
"webapp",
|
||||
# pyo3 extension-module cdylib; built with maturin, not the core workspace.
|
||||
"crates/vtracer-py",
|
||||
# wasm-bindgen cdylib; built with wasm-pack as the Node package's core.
|
||||
"nodejs",
|
||||
]
|
||||
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0-alpha.3"
|
||||
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.1"
|
||||
# Schneider curve fitting for the simplify pass. visioncortex pins an old
|
||||
# flo_curves internally for legacy reasons; we depend on the current one
|
||||
# directly and convert at the call boundary.
|
||||
flo_curves = "0.8"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2022 Tsang Hao Fung
|
||||
Copyright (c) 2024 TSANG, Hao Fung
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
|
||||
@@ -4,105 +4,260 @@
|
||||
<h1>VTracer</h1>
|
||||
|
||||
<p>
|
||||
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
|
||||
<strong>Raster to Vector Graphics Converter</strong>
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
<a href="//www.visioncortex.org/vtracer-docs">Article</a>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases">Releases</a>
|
||||
<span> | </span>
|
||||
<a href="//www.visioncortex.org/vtracer/">Demo</a>
|
||||
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
|
||||
<span> | </span>
|
||||
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
|
||||
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.3/VTracer_1.0.0-alpha.3_x64-setup.exe">Windows App</a>
|
||||
</h3>
|
||||
|
||||
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
|
||||
<p>
|
||||
<a href="https://crates.io/crates/vtracer"><img src="https://img.shields.io/crates/v/vtracer.svg?label=crates.io" 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" 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" alt="Node package on npm"></a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## Packages
|
||||
|
||||
VTracer 1.0 is a vectorization **framework** (pluggable frontends, curve fitters, color fitting, and output optimization) shipped across four surfaces from this repository:
|
||||
|
||||
| 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 |
|
||||
|
||||
## Introduction
|
||||
|
||||
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
|
||||
|
||||
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
|
||||
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans. tl;dr: Potrace uses a `O(n^2)` fitting algorithm, whereas `vtracer` is entirely `O(n)`.
|
||||
|
||||
Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrator/using/image-trace.html), VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
|
||||
|
||||
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
|
||||
|
||||
A technical description of the algorithm is on [visioncortex.org/vtracer-docs](//www.visioncortex.org/vtracer-docs).
|
||||
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
|
||||
|
||||
## Web App
|
||||
## Desktop App
|
||||
|
||||
VTracer and its [core library](//github.com/visioncortex/visioncortex) is implemented in [Rust](//www.rust-lang.org/). It provides us a solid foundation to develop robust and efficient algorithms and easily bring it to interactive applications. The webapp is a perfect showcase of the capability of the Rust + wasm platform.
|
||||

|
||||
|
||||

|
||||
The familiar web-app workflow, now on the native 1.0 engine:
|
||||
|
||||

|
||||
+ **Native speed** — the engine runs natively instead of in-browser wasm, so conversions are quicker
|
||||
+ **A/B comparator** — a sliding split view to check the trace against the original
|
||||
+ **Curve inspector** — examine the fitted curves up close
|
||||
+ **Seam-free cutout** — a true gapless tessellation with shared boundaries, no cracks between shapes
|
||||
+ **Watershed clustering** — content-adaptive regions that follow object shape, with one interactive detail slider
|
||||
+ **Curve simplification** — the fewest curves within a pixel tolerance, typically halving file size
|
||||
+ **Adaptive B/W thresholding** — handles scans and photos with uneven lighting
|
||||
+ **Fixed color palettes** — snap the output to your own colors
|
||||
|
||||
## Cmd App
|
||||
|
||||
Input and output can be given as positional arguments or as named flags:
|
||||
|
||||
```sh
|
||||
visioncortex VTracer 0.4.0
|
||||
A cmd app to convert images into vector graphics.
|
||||
|
||||
USAGE:
|
||||
vtracer [OPTIONS] --input <input> --output <output>
|
||||
|
||||
FLAGS:
|
||||
-h, --help Prints help information
|
||||
-V, --version Prints version information
|
||||
|
||||
OPTIONS:
|
||||
--colormode <color_mode> True color image `color` (default) or Binary image `bw`
|
||||
-p, --color_precision <color_precision> Number of significant bits to use in an RGB channel
|
||||
-c, --corner_threshold <corner_threshold> Minimum momentary angle (degree) to be considered a corner
|
||||
-f, --filter_speckle <filter_speckle> Discard patches smaller than X px in size
|
||||
-g, --gradient_step <gradient_step> Color difference between gradient layers
|
||||
--hierarchical <hierarchical>
|
||||
Hierarchical clustering `stacked` (default) or non-stacked `cutout`. Only applies to color mode.
|
||||
|
||||
-i, --input <input> Path to input raster image
|
||||
-m, --mode <mode> Curver fitting mode `pixel`, `polygon`, `spline`
|
||||
-o, --output <output> Path to output vector graphics
|
||||
--path_precision <path_precision> Number of decimal places to use in path string
|
||||
--preset <preset> Use one of the preset configs `bw`, `poster`, `photo`
|
||||
-l, --segment_length <segment_length>
|
||||
Perform iterative subdivide smooth until all segments are shorter than this length
|
||||
|
||||
-s, --splice_threshold <splice_threshold> Minimum angle displacement (degree) to splice a spline
|
||||
vtracer input.jpg output.svg
|
||||
# equivalent to:
|
||||
vtracer --input input.jpg --output output.svg
|
||||
```
|
||||
|
||||
Full options (flag names are kebab-case, e.g. `--filter-speckle`):
|
||||
|
||||
```sh
|
||||
Usage: vtracer [OPTIONS] [INPUT] [OUTPUT]
|
||||
|
||||
Arguments:
|
||||
[INPUT] Input raster image (positional; or use --input)
|
||||
[OUTPUT] Output SVG (positional; or use --output)
|
||||
|
||||
Options:
|
||||
-i, --input <INPUT> Path to the input raster image
|
||||
-o, --output <OUTPUT> Path to the output SVG
|
||||
--preset <PRESET> Start from a preset: bw, poster, photo
|
||||
--clustering <CLUSTERING> Region forming: `color-cluster` (default), `bw`, `watershed`
|
||||
--hierarchical <HIERARCHICAL> Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
|
||||
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
|
||||
-f, --filter-speckle <FILTER_SPECKLE> Discard patches smaller than X px in size (0..=128)
|
||||
-p, --color-precision <COLOR_PRECISION> Significant bits per RGB channel (1..=8)
|
||||
-g, --gradient-step <GRADIENT_STEP> Color difference between gradient layers (0..=255)
|
||||
--simplify <TOLERANCE> Simplify curves: fewest cubics within this tolerance in px (try 1–2.5)
|
||||
--path-precision <PATH_PRECISION> Decimal places to use in path coordinates
|
||||
--palette <PALETTE> Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'
|
||||
--palette-file <PALETTE_FILE> Fixed palette from a file (hex colors, comma/newline separated)
|
||||
--max-colors <MAX_COLORS> Auto-quantize to at most N colors
|
||||
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+cleanup, 2 = + shorthands
|
||||
--threshold <THRESHOLD> Binary mode: fixed threshold 0..=255 (foreground below it)
|
||||
--adaptive Binary mode: Bradley–Roth adaptive threshold (uneven lighting)
|
||||
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
|
||||
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
|
||||
--watershed-detail <WATERSHED_DETAIL> Watershed: hierarchy cut level 0..=255 (higher = more regions)
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
The spline fine-tuning flags `--corner-threshold <0..=180>`,
|
||||
`--segment-length <3.5..=10>`, and `--splice-threshold <0..=180>` are still
|
||||
accepted but hidden from `--help`: their defaults (60 / 4 / 45) serve
|
||||
virtually every conversion, and `--simplify` is the knob that actually moves
|
||||
output size and smoothness.
|
||||
|
||||
### New in 1.0
|
||||
|
||||
- **Positional arguments** — `vtracer in.png out.svg`.
|
||||
- **`--hierarchical cutout`** is now a true seam-free mosaic (a gapless
|
||||
tessellation with shared boundaries), replacing the old re-clustered cutout.
|
||||
- **`--palette` / `--palette-file`** — snap colors to a fixed palette
|
||||
(nearest in OKLab); **`--max-colors`** auto-quantizes the palette.
|
||||
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
|
||||
**Bradley–Roth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
|
||||
/ `--adaptive-t`) for scans with uneven lighting.
|
||||
- **`--simplify <tolerance>`** — paper.js-style curve simplification: re-fits
|
||||
smooth runs with the fewest cubics that stay within the tolerance (px),
|
||||
typically halving file size; seam-free in cutout mode because shared
|
||||
boundaries are simplified once for both faces.
|
||||
- **`--clustering watershed`** — an alternative region-forming algorithm: a
|
||||
hierarchical watershed on the pixel graph (Cousty et al., TPAMI 2009; Najman,
|
||||
Cousty & Perret, ISMM 2013), cut at `--watershed-detail`. Content-adaptive
|
||||
regions that follow object shape — pairs beautifully with `cutout`.
|
||||
|
||||
## Downloads
|
||||
|
||||
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
|
||||
|
||||
You can also install the program from source:
|
||||
|
||||
```sh
|
||||
cargo install vtracer-cli
|
||||
```
|
||||
|
||||
> You are strongly advised to not download from any other third-party sources
|
||||
|
||||
### Usage
|
||||
```
|
||||
./vtracer --input input.jpg --output output.svg
|
||||
|
||||
```sh
|
||||
# simplest form
|
||||
./vtracer input.jpg output.svg
|
||||
|
||||
# black & white line art
|
||||
./vtracer input.jpg output.svg --preset bw
|
||||
|
||||
# scanned/photographed line art with uneven lighting
|
||||
./vtracer scan.jpg output.svg --clustering bw --adaptive
|
||||
|
||||
# seam-free mosaic (gapless tessellation)
|
||||
./vtracer input.jpg output.svg --hierarchical cutout
|
||||
|
||||
# watershed region forming, cut to taste
|
||||
./vtracer photo.jpg output.svg --clustering watershed --watershed-detail 192
|
||||
|
||||
# constrain to a fixed palette
|
||||
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
|
||||
```
|
||||
|
||||
## Library
|
||||
### Rust Library
|
||||
|
||||
The library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
|
||||
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
|
||||
|
||||
## Install
|
||||
|
||||
Download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
|
||||
|
||||
or
|
||||
|
||||
Install from source (Rust toolchain needed):
|
||||
|
||||
```
|
||||
cargo install vtracer
|
||||
```sh
|
||||
cargo add vtracer@1.0.0-alpha.3
|
||||
```
|
||||
|
||||
## In the wild
|
||||
```rust
|
||||
use vtracer::{ColorImage, Config, FitMode, Hierarchical, Preset, Session};
|
||||
|
||||
VTracer is used by the following products (feel free to add yours to the list):
|
||||
// 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 };
|
||||
|
||||
<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>
|
||||
// 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.3/vtracer/) for the full API.
|
||||
|
||||
### Python Library
|
||||
|
||||
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension.
|
||||
|
||||
```sh
|
||||
pip install vtracer==1.0.0a3
|
||||
```
|
||||
|
||||
```python
|
||||
import vtracer
|
||||
|
||||
# one-liners
|
||||
vtracer.convert_file("in.png", "out.svg")
|
||||
svg = vtracer.convert_bytes(open("in.png", "rb").read())
|
||||
|
||||
# rich, reusable config + presets
|
||||
cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
|
||||
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
|
||||
svg = cfg.convert_bytes(data)
|
||||
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
|
||||
|
||||
# watershed region forming
|
||||
ws = vtracer.Config(clustering="watershed", watershed_detail=192)
|
||||
svg = ws.convert_file("photo.jpg", "photo.svg")
|
||||
|
||||
# binary with adaptive (Bradley–Roth) thresholding
|
||||
bw = vtracer.Config(clustering="bw", adaptive=True)
|
||||
svg = bw.convert_file("scan.jpg", "scan.svg")
|
||||
```
|
||||
|
||||
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
|
||||
|
||||
### Node.js Library
|
||||
|
||||
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**. Decodes PNG, JPEG, GIF, BMP, and WebP; for other formats, decode yourself and pass raw RGBA to `convertPixels`.
|
||||
|
||||
```sh
|
||||
npm install @visioncortex/vtracer@1.0.0-alpha.3
|
||||
```
|
||||
|
||||
```js
|
||||
const vtracer = require('@visioncortex/vtracer');
|
||||
|
||||
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
|
||||
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
|
||||
const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
|
||||
|
||||
// binary with adaptive thresholding
|
||||
const bw = vtracer.convertBuffer(buffer, { clustering: 'bw', adaptive: true });
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
VTracer has since been cited by a few academic papers in computer graphics / vision research. Please kindly let us know if you have cited our work:
|
||||
|
||||
+ SKILL 2023 [Framework to Vectorize Digital Artworks for Physical Fabrication based on Geometric Stylization Techniques](https://www.researchgate.net/publication/374448489_Framework_to_Vectorize_Digital_Artworks_for_Physical_Fabrication_based_on_Geometric_Stylization_Techniques)
|
||||
+ arXiv 2023 [Image Vectorization: a Review](https://arxiv.org/abs/2306.06441)
|
||||
+ arXiv 2023 [StarVector: Generating Scalable Vector Graphics Code from Images](https://arxiv.org/abs/2312.11556)
|
||||
+ arXiv 2024 [Text-Based Reasoning About Vector Graphics](https://arxiv.org/abs/2404.06479)
|
||||
+ arXiv 2024 [Delving into LLMs' visual understanding ability using SVG to bridge image and text](https://openreview.net/pdf?id=pwlm6Po61I)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Version 0.6.0 (2023-09-08)
|
||||
|
||||
- Python bindings
|
||||
|
||||
# Version 0.5.0 (2022-10-09)
|
||||
|
||||
- Handle transparent png images
|
||||
|
||||
# Version 0.4.0 (2021-07-23)
|
||||
|
||||
- SVG path string numeric precision
|
||||
|
||||
# Version 0.3.0 (2021-01-24)
|
||||
|
||||
- Added cutout mode
|
||||
|
||||
# Version 0.2.0 (2020-11-15)
|
||||
|
||||
- Use relative & closed paths
|
||||
|
||||
# Version 0.1.1 (2020-11-01)
|
||||
|
||||
- SVG namespace
|
||||
|
||||
# Version 0.1.0 (2020-10-31)
|
||||
|
||||
- Initial release
|
||||
@@ -1,3 +0,0 @@
|
||||
*.svg
|
||||
*.png
|
||||
*.jpg
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "vtracer"
|
||||
version = "0.6.3"
|
||||
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
|
||||
edition = "2021"
|
||||
description = "A cmd app to convert images into vector graphics."
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "http://www.visioncortex.org/vtracer"
|
||||
repository = "https://github.com/visioncortex/vtracer/"
|
||||
categories = ["graphics"]
|
||||
keywords = ["svg", "computer-graphics"]
|
||||
|
||||
[dependencies]
|
||||
clap = "2.33.3"
|
||||
image = "0.23.10"
|
||||
visioncortex = { version = "0.8.0" }
|
||||
fastrand = "1.8"
|
||||
pyo3 = { version = "0.19.0", optional = true }
|
||||
|
||||
[features]
|
||||
python-binding = ["pyo3"]
|
||||
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,25 +0,0 @@
|
||||
Copyright (c) 2022 Tsang Hao Fung
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,28 +0,0 @@
|
||||
[project]
|
||||
name = "vtracer"
|
||||
version = "0.6.3"
|
||||
description = "Python bindings for the Rust Vtracer raster-to-vector library"
|
||||
authors = [ { name = "Chris Tsang", email = "chris.2y3@outlook.com" } ]
|
||||
readme = "vtracer/README.md"
|
||||
requires-python = ">=3.7"
|
||||
license = "MIT"
|
||||
classifiers = [
|
||||
"Programming Language :: Rust",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
python = "^3.7"
|
||||
|
||||
[dev-dependencies]
|
||||
maturin = "^1.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["maturin>=1.2,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[tool.maturin]
|
||||
features = ["pyo3/extension-module"]
|
||||
compatibility = "linux"
|
||||
sdist-include = ["../LICENSE", "../README.md"]
|
||||
@@ -1,397 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
use std::path::PathBuf;
|
||||
use clap::{Arg, App};
|
||||
use visioncortex::PathSimplifyMode;
|
||||
|
||||
pub enum Preset {
|
||||
Bw,
|
||||
Poster,
|
||||
Photo
|
||||
}
|
||||
|
||||
pub enum ColorMode {
|
||||
Color,
|
||||
Binary,
|
||||
}
|
||||
|
||||
pub enum Hierarchical {
|
||||
Stacked,
|
||||
Cutout,
|
||||
}
|
||||
|
||||
/// Converter config
|
||||
pub struct Config {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub color_mode: ColorMode,
|
||||
pub hierarchical: Hierarchical,
|
||||
pub filter_speckle: usize,
|
||||
pub color_precision: i32,
|
||||
pub layer_difference: i32,
|
||||
pub mode: PathSimplifyMode,
|
||||
pub corner_threshold: i32,
|
||||
pub length_threshold: f64,
|
||||
pub max_iterations: usize,
|
||||
pub splice_threshold: i32,
|
||||
pub path_precision: Option<u32>,
|
||||
}
|
||||
|
||||
pub(crate) struct ConverterConfig {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub color_mode: ColorMode,
|
||||
pub hierarchical: Hierarchical,
|
||||
pub filter_speckle_area: usize,
|
||||
pub color_precision_loss: i32,
|
||||
pub layer_difference: i32,
|
||||
pub mode: PathSimplifyMode,
|
||||
pub corner_threshold: f64,
|
||||
pub length_threshold: f64,
|
||||
pub max_iterations: usize,
|
||||
pub splice_threshold: f64,
|
||||
pub path_precision: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_path: PathBuf::default(),
|
||||
output_path: PathBuf::default(),
|
||||
color_mode: ColorMode::Color,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
filter_speckle: 4,
|
||||
color_precision: 6,
|
||||
layer_difference: 16,
|
||||
corner_threshold: 60,
|
||||
length_threshold: 4.0,
|
||||
splice_threshold: 45,
|
||||
max_iterations: 10,
|
||||
path_precision: Some(8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ColorMode {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"color" => Ok(Self::Color),
|
||||
"binary" => Ok(Self::Binary),
|
||||
_ => Err(format!("unknown ColorMode {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Hierarchical {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"stacked" => Ok(Self::Stacked),
|
||||
"cutout" => Ok(Self::Cutout),
|
||||
_ => Err(format!("unknown Hierarchical {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Preset {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"bw" => Ok(Self::Bw),
|
||||
"poster" => Ok(Self::Poster),
|
||||
"photo" => Ok(Self::Photo),
|
||||
_ => Err(format!("unknown Preset {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_simplify_mode_from_str(s: &str) -> PathSimplifyMode {
|
||||
match s {
|
||||
"polygon" => PathSimplifyMode::Polygon,
|
||||
"spline" => PathSimplifyMode::Spline,
|
||||
"none" => PathSimplifyMode::None,
|
||||
_ => panic!("unknown PathSimplifyMode {}", s),
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_args() -> Self {
|
||||
let app = App::new("visioncortex VTracer ".to_owned() + env!("CARGO_PKG_VERSION"))
|
||||
.about("A cmd app to convert images into vector graphics.");
|
||||
|
||||
let app = app.arg(Arg::with_name("input")
|
||||
.long("input")
|
||||
.short("i")
|
||||
.takes_value(true)
|
||||
.help("Path to input raster image")
|
||||
.required(true));
|
||||
|
||||
let app = app.arg(Arg::with_name("output")
|
||||
.long("output")
|
||||
.short("o")
|
||||
.takes_value(true)
|
||||
.help("Path to output vector graphics")
|
||||
.required(true));
|
||||
|
||||
let app = app.arg(Arg::with_name("color_mode")
|
||||
.long("colormode")
|
||||
.takes_value(true)
|
||||
.help("True color image `color` (default) or Binary image `bw`"));
|
||||
|
||||
let app = app.arg(Arg::with_name("hierarchical")
|
||||
.long("hierarchical")
|
||||
.takes_value(true)
|
||||
.help(
|
||||
"Hierarchical clustering `stacked` (default) or non-stacked `cutout`. \
|
||||
Only applies to color mode. "
|
||||
));
|
||||
|
||||
let app = app.arg(Arg::with_name("preset")
|
||||
.long("preset")
|
||||
.takes_value(true)
|
||||
.help("Use one of the preset configs `bw`, `poster`, `photo`"));
|
||||
|
||||
let app = app.arg(Arg::with_name("filter_speckle")
|
||||
.long("filter_speckle")
|
||||
.short("f")
|
||||
.takes_value(true)
|
||||
.help("Discard patches smaller than X px in size"));
|
||||
|
||||
let app = app.arg(Arg::with_name("color_precision")
|
||||
.long("color_precision")
|
||||
.short("p")
|
||||
.takes_value(true)
|
||||
.help("Number of significant bits to use in an RGB channel"));
|
||||
|
||||
let app = app.arg(Arg::with_name("gradient_step")
|
||||
.long("gradient_step")
|
||||
.short("g")
|
||||
.takes_value(true)
|
||||
.help("Color difference between gradient layers"));
|
||||
|
||||
let app = app.arg(Arg::with_name("corner_threshold")
|
||||
.long("corner_threshold")
|
||||
.short("c")
|
||||
.takes_value(true)
|
||||
.help("Minimum momentary angle (degree) to be considered a corner"));
|
||||
|
||||
let app = app.arg(Arg::with_name("segment_length")
|
||||
.long("segment_length")
|
||||
.short("l")
|
||||
.takes_value(true)
|
||||
.help("Perform iterative subdivide smooth until all segments are shorter than this length"));
|
||||
|
||||
let app = app.arg(Arg::with_name("splice_threshold")
|
||||
.long("splice_threshold")
|
||||
.short("s")
|
||||
.takes_value(true)
|
||||
.help("Minimum angle displacement (degree) to splice a spline"));
|
||||
|
||||
let app = app.arg(Arg::with_name("mode")
|
||||
.long("mode")
|
||||
.short("m")
|
||||
.takes_value(true)
|
||||
.help("Curver fitting mode `pixel`, `polygon`, `spline`"));
|
||||
|
||||
let app = app.arg(Arg::with_name("path_precision")
|
||||
.long("path_precision")
|
||||
.takes_value(true)
|
||||
.help("Number of decimal places to use in path string"));
|
||||
|
||||
// Extract matches
|
||||
let matches = app.get_matches();
|
||||
|
||||
let mut config = Config::default();
|
||||
let input_path = matches.value_of("input").expect("Input path is required, please specify it by --input or -i.");
|
||||
let output_path = matches.value_of("output").expect("Output path is required, please specify it by --output or -o.");
|
||||
|
||||
if let Some(value) = matches.value_of("preset") {
|
||||
config = Self::from_preset(Preset::from_str(value).unwrap(), input_path, output_path);
|
||||
}
|
||||
|
||||
config.input_path = PathBuf::from(input_path);
|
||||
config.output_path = PathBuf::from(output_path);
|
||||
|
||||
if let Some(value) = matches.value_of("color_mode") {
|
||||
config.color_mode = ColorMode::from_str(if value.trim() == "bw" || value.trim() == "BW" {"binary"} else {"color"}).unwrap()
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("hierarchical") {
|
||||
config.hierarchical = Hierarchical::from_str(value).unwrap()
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("mode") {
|
||||
let value = value.trim();
|
||||
config.mode = path_simplify_mode_from_str(if value == "pixel" {
|
||||
"none"
|
||||
} else if value == "polygon" {
|
||||
"polygon"
|
||||
} else if value == "spline" {
|
||||
"spline"
|
||||
} else {
|
||||
panic!("Parser Error: Curve fitting mode is invalid: {}", value);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("filter_speckle") {
|
||||
if value.trim().parse::<usize>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<usize>().unwrap();
|
||||
if value > 16 {
|
||||
panic!("Out of Range Error: Filter speckle is invalid at {}. It must be within [0,16].", value);
|
||||
}
|
||||
config.filter_speckle = value;
|
||||
} else {
|
||||
panic!("Parser Error: Filter speckle is not a positive integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("color_precision") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 1 || value > 8 {
|
||||
panic!("Out of Range Error: Color precision is invalid at {}. It must be within [1,8].", value);
|
||||
}
|
||||
config.color_precision = value;
|
||||
} else {
|
||||
panic!("Parser Error: Color precision is not an integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("gradient_step") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 0 || value > 255 {
|
||||
panic!("Out of Range Error: Gradient step is invalid at {}. It must be within [0,255].", value);
|
||||
}
|
||||
config.layer_difference = value;
|
||||
} else {
|
||||
panic!("Parser Error: Gradient step is not an integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("corner_threshold") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 0 || value > 180 {
|
||||
panic!("Out of Range Error: Corner threshold is invalid at {}. It must be within [0,180].", value);
|
||||
}
|
||||
config.corner_threshold = value
|
||||
} else {
|
||||
panic!("Parser Error: Corner threshold is not numeric: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("segment_length") {
|
||||
if value.trim().parse::<f64>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<f64>().unwrap();
|
||||
if value < 3.5 || value > 10.0 {
|
||||
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [3.5,10].", value);
|
||||
}
|
||||
config.length_threshold = value;
|
||||
} else {
|
||||
panic!("Parser Error: Segment length is not numeric: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("splice_threshold") {
|
||||
if value.trim().parse::<i32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<i32>().unwrap();
|
||||
if value < 0 || value > 180 {
|
||||
panic!("Out of Range Error: Segment length is invalid at {}. It must be within [0,180].", value);
|
||||
}
|
||||
config.splice_threshold = value;
|
||||
} else {
|
||||
panic!("Parser Error: Segment length is not numeric: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = matches.value_of("path_precision") {
|
||||
if value.trim().parse::<u32>().is_ok() { // is numeric
|
||||
let value = value.trim().parse::<u32>().ok();
|
||||
config.path_precision = value;
|
||||
} else {
|
||||
panic!("Parser Error: Path precision is not an unsigned integer: {}.", value);
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
pub fn from_preset(preset: Preset, input_path: &str, output_path: &str) -> Self {
|
||||
let input_path = PathBuf::from(input_path);
|
||||
let output_path = PathBuf::from(output_path);
|
||||
match preset {
|
||||
Preset::Bw => Self {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode: ColorMode::Binary,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 4,
|
||||
color_precision: 6,
|
||||
layer_difference: 16,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
corner_threshold: 60,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: 45,
|
||||
path_precision: Some(8),
|
||||
},
|
||||
Preset::Poster => Self {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode: ColorMode::Color,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 4,
|
||||
color_precision: 8,
|
||||
layer_difference: 16,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
corner_threshold: 60,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: 45,
|
||||
path_precision: Some(8),
|
||||
},
|
||||
Preset::Photo => Self {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode: ColorMode::Color,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
filter_speckle: 10,
|
||||
color_precision: 8,
|
||||
layer_difference: 48,
|
||||
mode: PathSimplifyMode::Spline,
|
||||
corner_threshold: 180,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: 45,
|
||||
path_precision: Some(8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_converter_config(self) -> ConverterConfig {
|
||||
ConverterConfig {
|
||||
input_path: self.input_path,
|
||||
output_path: self.output_path,
|
||||
color_mode: self.color_mode,
|
||||
hierarchical: self.hierarchical,
|
||||
filter_speckle_area: self.filter_speckle * self.filter_speckle,
|
||||
color_precision_loss: 8 - self.color_precision,
|
||||
layer_difference: self.layer_difference,
|
||||
mode: self.mode,
|
||||
corner_threshold: deg2rad(self.corner_threshold),
|
||||
length_threshold: self.length_threshold,
|
||||
max_iterations: self.max_iterations,
|
||||
splice_threshold: deg2rad(self.splice_threshold),
|
||||
path_precision: self.path_precision,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deg2rad(deg: i32) -> f64 {
|
||||
deg as f64 / 180.0 * std::f64::consts::PI
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{fs::File, io::Write};
|
||||
|
||||
use fastrand::Rng;
|
||||
use visioncortex::{Color, ColorImage, ColorName};
|
||||
use visioncortex::color_clusters::{Runner, RunnerConfig, KeyingAction, HIERARCHICAL_MAX};
|
||||
use super::config::{Config, ColorMode, Hierarchical, ConverterConfig};
|
||||
use super::svg::SvgFile;
|
||||
|
||||
const NUM_UNUSED_COLOR_ITERATIONS: usize = 6;
|
||||
/// The fraction of pixels in the top/bottom rows of the image that need to be transparent before
|
||||
/// the entire image will be keyed.
|
||||
const KEYING_THRESHOLD: f32 = 0.2;
|
||||
|
||||
/// Convert an image file into svg file
|
||||
pub fn convert_image_to_svg(config: Config) -> Result<(), String> {
|
||||
let config = config.into_converter_config();
|
||||
match config.color_mode {
|
||||
ColorMode::Color => color_image_to_svg(config),
|
||||
ColorMode::Binary => binary_image_to_svg(config),
|
||||
}
|
||||
}
|
||||
|
||||
fn color_exists_in_image(img: &ColorImage, color: Color) -> bool {
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let pixel_color = img.get_pixel(x, y);
|
||||
if pixel_color.r == color.r && pixel_color.g == color.g && pixel_color.b == color.b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn find_unused_color_in_image(img: &ColorImage) -> Result<Color, String> {
|
||||
let special_colors = IntoIterator::into_iter([
|
||||
Color::new(255, 0, 0),
|
||||
Color::new(0, 255, 0),
|
||||
Color::new(0, 0, 255),
|
||||
Color::new(255, 255, 0),
|
||||
Color::new(0, 255, 255),
|
||||
Color::new(255, 0, 255),
|
||||
]);
|
||||
let rng = Rng::new();
|
||||
let random_colors = (0..NUM_UNUSED_COLOR_ITERATIONS).map(|_| {
|
||||
Color::new(
|
||||
rng.u8(..),
|
||||
rng.u8(..),
|
||||
rng.u8(..),
|
||||
)
|
||||
});
|
||||
for color in special_colors.chain(random_colors) {
|
||||
if !color_exists_in_image(img, color) {
|
||||
return Ok(color);
|
||||
}
|
||||
}
|
||||
Err(String::from("unable to find unused color in image to use as key"))
|
||||
}
|
||||
|
||||
fn should_key_image(img: &ColorImage) -> bool {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for transparency at several scanlines
|
||||
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
|
||||
let mut num_transparent_boundary_pixels = 0;
|
||||
let y_positions = [0, img.height / 4, img.height / 2, 3 * img.height / 4, img.height - 1];
|
||||
for y in y_positions {
|
||||
for x in 0..img.width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
num_transparent_boundary_pixels += 1;
|
||||
}
|
||||
if num_transparent_boundary_pixels >= threshold {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn color_image_to_svg(config: ConverterConfig) -> Result<(), String> {
|
||||
let (mut img, width, height);
|
||||
match read_image(config.input_path) {
|
||||
Ok(values) => {
|
||||
img = values.0;
|
||||
width = values.1;
|
||||
height = values.2;
|
||||
},
|
||||
Err(msg) => return Err(msg),
|
||||
}
|
||||
|
||||
let key_color = if should_key_image(&img) {
|
||||
let key_color = find_unused_color_in_image(&img)?;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
img.set_pixel(x, y, &key_color);
|
||||
}
|
||||
}
|
||||
}
|
||||
key_color
|
||||
} else {
|
||||
// The default color is all zeroes, which is treated by visioncortex as a special value meaning no keying will be applied.
|
||||
Color::default()
|
||||
};
|
||||
|
||||
let runner = Runner::new(RunnerConfig {
|
||||
diagonal: config.layer_difference == 0,
|
||||
hierarchical: HIERARCHICAL_MAX,
|
||||
batch_size: 25600,
|
||||
good_min_area: config.filter_speckle_area,
|
||||
good_max_area: (width * height),
|
||||
is_same_color_a: config.color_precision_loss,
|
||||
is_same_color_b: 1,
|
||||
deepen_diff: config.layer_difference,
|
||||
hollow_neighbours: 1,
|
||||
key_color,
|
||||
keying_action: if matches!(config.hierarchical, Hierarchical::Cutout) {
|
||||
KeyingAction::Keep
|
||||
} else {
|
||||
KeyingAction::Discard
|
||||
},
|
||||
}, img);
|
||||
|
||||
let mut clusters = runner.run();
|
||||
|
||||
match config.hierarchical {
|
||||
Hierarchical::Stacked => {}
|
||||
Hierarchical::Cutout => {
|
||||
let view = clusters.view();
|
||||
let image = view.to_color_image();
|
||||
let runner = Runner::new(RunnerConfig {
|
||||
diagonal: false,
|
||||
hierarchical: 64,
|
||||
batch_size: 25600,
|
||||
good_min_area: 0,
|
||||
good_max_area: (image.width * image.height) as usize,
|
||||
is_same_color_a: 0,
|
||||
is_same_color_b: 1,
|
||||
deepen_diff: 0,
|
||||
hollow_neighbours: 0,
|
||||
key_color,
|
||||
keying_action: KeyingAction::Discard,
|
||||
}, image);
|
||||
clusters = runner.run();
|
||||
},
|
||||
}
|
||||
|
||||
let view = clusters.view();
|
||||
|
||||
let mut svg = SvgFile::new(width, height, config.path_precision);
|
||||
for &cluster_index in view.clusters_output.iter().rev() {
|
||||
let cluster = view.get_cluster(cluster_index);
|
||||
let paths = cluster.to_compound_path(
|
||||
&view,
|
||||
false,
|
||||
config.mode,
|
||||
config.corner_threshold,
|
||||
config.length_threshold,
|
||||
config.max_iterations,
|
||||
config.splice_threshold
|
||||
);
|
||||
svg.add_path(paths, cluster.residue_color());
|
||||
}
|
||||
|
||||
write_svg(svg, config.output_path)
|
||||
}
|
||||
|
||||
fn binary_image_to_svg(config: ConverterConfig) -> Result<(), String> {
|
||||
|
||||
let (img, width, height);
|
||||
match read_image(config.input_path) {
|
||||
Ok(values) => {
|
||||
img = values.0;
|
||||
width = values.1;
|
||||
height = values.2;
|
||||
},
|
||||
Err(msg) => return Err(msg),
|
||||
}
|
||||
let img = img.to_binary_image(|x| x.r < 128);
|
||||
|
||||
let clusters = img.to_clusters(false);
|
||||
|
||||
let mut svg = SvgFile::new(width, height, config.path_precision);
|
||||
for i in 0..clusters.len() {
|
||||
let cluster = clusters.get_cluster(i);
|
||||
if cluster.size() >= config.filter_speckle_area {
|
||||
let paths = cluster.to_compound_path(
|
||||
config.mode,
|
||||
config.corner_threshold,
|
||||
config.length_threshold,
|
||||
config.max_iterations,
|
||||
config.splice_threshold,
|
||||
);
|
||||
svg.add_path(paths, Color::color(&ColorName::Black));
|
||||
}
|
||||
}
|
||||
|
||||
write_svg(svg, config.output_path)
|
||||
}
|
||||
|
||||
fn read_image(input_path: PathBuf) -> Result<(ColorImage, usize, usize), String> {
|
||||
let img = image::open(input_path);
|
||||
let img = match img {
|
||||
Ok(file) => file.to_rgba8(),
|
||||
Err(_) => return Err(String::from("No image file found at specified input path")),
|
||||
};
|
||||
|
||||
let (width, height) = (img.width() as usize, img.height() as usize);
|
||||
let img = ColorImage {pixels: img.as_raw().to_vec(), width, height};
|
||||
|
||||
Ok((img, width, height))
|
||||
}
|
||||
|
||||
fn write_svg(svg: SvgFile, output_path: PathBuf) -> Result<(), String> {
|
||||
let out_file = File::create(output_path);
|
||||
let mut out_file = match out_file {
|
||||
Ok(file) => file,
|
||||
Err(_) => return Err(String::from("Cannot create output file.")),
|
||||
};
|
||||
|
||||
write!(&mut out_file, "{}", svg).expect("failed to write file.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright 2020 Tsang Hao Fung. See the COPYRIGHT
|
||||
// file at the top-level directory of this distribution and at
|
||||
// http://rust-lang.org/COPYRIGHT.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
|
||||
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
|
||||
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
|
||||
// option. This file may not be copied, modified, or distributed
|
||||
// except according to those terms.
|
||||
|
||||
mod config;
|
||||
mod converter;
|
||||
mod svg;
|
||||
#[cfg(feature = "python-binding")]
|
||||
mod python;
|
||||
|
||||
pub use config::*;
|
||||
pub use converter::*;
|
||||
pub use svg::*;
|
||||
#[cfg(feature = "python-binding")]
|
||||
pub use python::*;
|
||||
@@ -1,14 +0,0 @@
|
||||
use vtracer::{Config, convert_image_to_svg};
|
||||
|
||||
fn main() {
|
||||
let config = Config::from_args();
|
||||
let result = convert_image_to_svg(config);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!("Conversion successful.");
|
||||
},
|
||||
Err(msg) => {
|
||||
panic!("Conversion failed with error message: {}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use visioncortex::{PathSimplifyMode};
|
||||
use super::converter::*;
|
||||
|
||||
/// Python binding
|
||||
#[pyfunction]
|
||||
fn convert_image_to_svg_py( image_path: &str,
|
||||
out_path: &str,
|
||||
colormode: Option<&str>, // "color" or "binary"
|
||||
hierarchical: Option<&str>, // "stacked" or "cutout"
|
||||
mode: Option<&str>, // "polygon", "spline", "none"
|
||||
filter_speckle: Option<usize>, // default: 4
|
||||
color_precision: Option<i32>, // default: 6
|
||||
layer_difference: Option<i32>, // default: 16
|
||||
corner_threshold: Option<i32>, // default: 60
|
||||
length_threshold: Option<f64>, // in [3.5, 10] default: 4.0
|
||||
max_iterations: Option<usize>, // default: 10
|
||||
splice_threshold: Option<i32>, // default: 45
|
||||
path_precision: Option<u32> // default: 8
|
||||
) -> PyResult<()> {
|
||||
let input_path = PathBuf::from(image_path);
|
||||
let output_path = PathBuf::from(out_path);
|
||||
|
||||
// TODO: enforce color mode with an enum so that we only
|
||||
// accept the strings 'color' or 'binary'
|
||||
let color_mode = match colormode.unwrap_or("color") {
|
||||
"color" => ColorMode::Color,
|
||||
"binary" => ColorMode::Binary,
|
||||
_ => ColorMode::Color,
|
||||
};
|
||||
|
||||
let hierarchical = match hierarchical.unwrap_or("stacked") {
|
||||
"stacked" => Hierarchical::Stacked,
|
||||
"cutout" => Hierarchical::Cutout,
|
||||
_ => Hierarchical::Stacked,
|
||||
};
|
||||
|
||||
let mode = match mode.unwrap_or("spline") {
|
||||
"spline" => PathSimplifyMode::Spline,
|
||||
"polygon" => PathSimplifyMode::Polygon,
|
||||
"none" => PathSimplifyMode::None,
|
||||
_ => PathSimplifyMode::Spline,
|
||||
};
|
||||
|
||||
let filter_speckle = filter_speckle.unwrap_or(4);
|
||||
let color_precision = color_precision.unwrap_or(6);
|
||||
let layer_difference = layer_difference.unwrap_or(16);
|
||||
let corner_threshold = corner_threshold.unwrap_or(60);
|
||||
let length_threshold = length_threshold.unwrap_or(4.0);
|
||||
let splice_threshold = splice_threshold.unwrap_or(45);
|
||||
let max_iterations = max_iterations.unwrap_or(10);
|
||||
|
||||
let config = Config {
|
||||
input_path,
|
||||
output_path,
|
||||
color_mode,
|
||||
hierarchical,
|
||||
filter_speckle,
|
||||
color_precision,
|
||||
layer_difference,
|
||||
mode,
|
||||
corner_threshold,
|
||||
length_threshold,
|
||||
max_iterations,
|
||||
splice_threshold,
|
||||
path_precision,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
convert_image_to_svg(config).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Python module implemented in Rust.
|
||||
#[pymodule]
|
||||
fn vtracer(_py: Python, m: &PyModule) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(convert_image_to_svg_py, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
use std::fmt;
|
||||
use visioncortex::{Color, CompoundPath, PointF64};
|
||||
|
||||
pub struct SvgFile {
|
||||
pub paths: Vec<SvgPath>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub path_precision: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct SvgPath {
|
||||
pub path: CompoundPath,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
impl SvgFile {
|
||||
pub fn new(width: usize, height: usize, path_precision: Option<u32>) -> Self {
|
||||
SvgFile {
|
||||
paths: vec![],
|
||||
width,
|
||||
height,
|
||||
path_precision,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_path(&mut self, path: CompoundPath, color: Color) {
|
||||
self.paths.push(SvgPath {
|
||||
path,
|
||||
color,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SvgFile {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(f, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
|
||||
writeln!(f,
|
||||
r#"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="{}" height="{}">"#,
|
||||
self.width, self.height
|
||||
)?;
|
||||
|
||||
for path in &self.paths {
|
||||
path.fmt_with_precision(f, self.path_precision)?;
|
||||
};
|
||||
|
||||
writeln!(f, "</svg>")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SvgPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
self.fmt_with_precision(f, None)
|
||||
}
|
||||
}
|
||||
|
||||
impl SvgPath {
|
||||
fn fmt_with_precision(&self, f: &mut fmt::Formatter, precision: Option<u32>) -> fmt::Result {
|
||||
let (string, offset) = self.path.to_svg_string(true, PointF64::default(), precision);
|
||||
writeln!(
|
||||
f, "<path d=\"{}\" fill=\"{}\" transform=\"translate({},{})\"/>",
|
||||
string, self.color.to_hex_string(),
|
||||
offset.x, offset.y
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/visioncortex/vtracer/raw/master/docs/images/visioncortex-banner.png">
|
||||
|
||||
<h1>VTracer: Python Binding</h1>
|
||||
|
||||
<p>
|
||||
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
<a href="//www.visioncortex.org/vtracer-docs">Article</a>
|
||||
<span> | </span>
|
||||
<a href="//www.visioncortex.org/vtracer/">Demo</a>
|
||||
<span> | </span>
|
||||
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
|
||||
</h3>
|
||||
|
||||
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
|
||||
|
||||
</div>
|
||||
|
||||
## Introduction
|
||||
|
||||
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
|
||||
|
||||
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
|
||||
|
||||
Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrator/using/image-trace.html), VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
|
||||
|
||||
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
|
||||
|
||||
A technical description of the algorithm is on [visioncortex.org/vtracer-docs](//www.visioncortex.org/vtracer-docs).
|
||||
|
||||
## Install (Python)
|
||||
|
||||
```shell
|
||||
pip install vtracer
|
||||
```
|
||||
|
||||
### Usage (Python)
|
||||
|
||||
```python
|
||||
import vtracer
|
||||
|
||||
input_path = "/path/to/some_file.jpg"
|
||||
output_path = "/path/to/some_file.vtracer.jpg"
|
||||
|
||||
# Minimal example: use all default values, generate a multicolor SVG
|
||||
vtracer.convert_image_to_svg_py(inp, out)
|
||||
|
||||
# Single-color example. Good for line art, and much faster than full color:
|
||||
vtracer.convert_image_to_svg_py(inp, out, colormode='binary')
|
||||
|
||||
# All the bells & whistles
|
||||
vtracer.convert_image_to_svg_py(inp,
|
||||
out,
|
||||
colormode = 'color', # ["color"] or "binary"
|
||||
hierarchical = 'stacked', # ["stacked"] or "cutout"
|
||||
mode = 'spline', # ["spline"] "polygon", or "none"
|
||||
filter_speckle = 4, # default: 4
|
||||
color_precision = 6, # default: 6
|
||||
layer_difference = 16, # default: 16
|
||||
corner_threshold = 60, # default: 60
|
||||
length_threshold = 4.0, # in [3.5, 10] default: 4.0
|
||||
max_iterations = 10, # default: 10
|
||||
splice_threshold = 45, # default: 45
|
||||
path_precision = 3 # default: 8
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
## Rust Library
|
||||
|
||||
The (Rust) library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
|
||||
@@ -1 +0,0 @@
|
||||
from .vtracer import convert_image_to_svg_py
|
||||
@@ -1,17 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
def convert_image_to_svg_py(image_path: str,
|
||||
out_path: str,
|
||||
colormode: Optional[str] = None, # ["color"] or "binary"
|
||||
hierarchical: Optional[str] = None, # ["stacked"] or "cutout"
|
||||
mode: Optional[str] = None, # ["spline"], "polygon", "none"
|
||||
filter_speckle: Optional[int] = None, # default: 4
|
||||
color_precision: Optional[int] = None, # default: 6
|
||||
layer_difference: Optional[int] = None, # default: 16
|
||||
corner_threshold: Optional[int] = None, # default: 60
|
||||
length_threshold: Optional[float] = None, # in [3.5, 10] default: 4.0
|
||||
max_iterations: Optional[int] = None, # default: 10
|
||||
splice_threshold: Optional[int] = None, # default: 45
|
||||
path_precision: Optional[int] = None, # default: 8
|
||||
) -> None:
|
||||
...
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "vtracer-bench"
|
||||
description = "Blind fidelity benchmark for raster-to-vector tracers: compare the original raster with a rendered reconstruction and get one 0..1 fidelity score built from PSNR, SSIM and a clustered-diff patch metric."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
categories = ["graphics", "development-tools::testing"]
|
||||
keywords = ["vectorization", "benchmark", "fidelity", "ssim", "psnr"]
|
||||
|
||||
[dependencies]
|
||||
visioncortex.workspace = true
|
||||
dssim-core = "3"
|
||||
rgb = "0.8"
|
||||
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
@@ -0,0 +1,104 @@
|
||||
# vtracer-bench
|
||||
|
||||
Blind fidelity benchmark for raster-to-vector tracers.
|
||||
|
||||
It compares an **original raster** with a **rendered reconstruction** and reports one number — a fidelity score in **[0, 1]** — built from three complementary axes. It is *blind* in the sense that it knows nothing about how the reconstruction was produced: any tracer, any format, any renderer. Render your vector output to pixels (same dimensions as the original), then let the benchmark judge.
|
||||
|
||||
```console
|
||||
$ vtracer-bench original.png reconstruction.png
|
||||
psnr 34.77 dB (rmse 4.66) -> 0.6875
|
||||
ssim 0.99541 (dssim 0.00461) -> 0.9954
|
||||
patch 157.8 px rms (14503 bad px, 74 clusters, largest 73) -> 0.9726
|
||||
fidelity 0.9022
|
||||
[csv] 0.9022,34.77,0.00461,4.66,157.8,0.6875,0.9954,0.9726
|
||||
```
|
||||
|
||||
## Why another metric?
|
||||
|
||||
Every classic metric has a blind spot, and tracers exploit all of them:
|
||||
|
||||
- **PSNR** over-values invisible dust and undersells small salient regions — a tracer that drops an eye but nails the background can post a great PSNR.
|
||||
- **SSIM** tracks perceived quality well, but averages globally: a small, fully-lost region barely moves it.
|
||||
- Neither can tell **a thousand scattered ±1 pixels** apart from **one coherent missing patch** of the same total mass — and the missing patch is the failure that actually matters.
|
||||
|
||||
`vtracer-bench` scores all three axes and combines them so that no single blind spot survives:
|
||||
|
||||
| axis | raw metric | subscore in [0, 1] |
|
||||
| --- | --- | --- |
|
||||
| `psnr` | sRGB PSNR over RGB | `1 − log(1+rmse) / log(256)` |
|
||||
| `ssim` | multiscale DSSIM (`dssim-core`) | `SSIM = 1 / (1 + DSSIM)` |
|
||||
| `patch` | clustered-diff "missing patch" detector | `2^(−P / 0.005)` |
|
||||
|
||||
**fidelity = ( psnr¹ · ssim² · patch¹ ) ^ (1/4)** — a *weighted geometric mean*. Geometric, not arithmetic, so a single collapsed axis drags the composite down: a missing face region cannot hide behind good global PSNR. SSIM carries double weight because it tracks visual accuracy best and is the axis most robust to an imperfect source.
|
||||
|
||||
## The three axes
|
||||
|
||||
### psnr — parameter-free squash
|
||||
|
||||
The squash `1 − log(1+rmse)/log(256)` is anchored at the only two natural error scales an 8-bit image has:
|
||||
|
||||
- `rmse = 255` (the full range — noise indistinguishable from a random image) → **0**
|
||||
- `rmse ≤ 1` (the quantization step — errors 8-bit can barely represent) → saturates to **1**
|
||||
|
||||
For `rmse ≫ 1` it equals `psnr / 48.13 dB`, i.e. it stays linear in decibels, but with no hand-picked anchor constants.
|
||||
|
||||
### ssim — perceptual structure
|
||||
|
||||
`dssim-core` computes multiscale structural dissimilarity `d = 1/SSIM − 1`; the subscore is simply `SSIM = 1/(1+d)`, already a natural 0..1. Differences the eye can't see score ~1 regardless of how many pixels they touch.
|
||||
|
||||
### patch — the missing-patch detector
|
||||
|
||||
This is the axis PSNR and SSIM both lack:
|
||||
|
||||
1. A pixel is **bad** iff its RGB Euclidean distance to the original exceeds `--thresh` (default 24 — roughly 14 per channel).
|
||||
2. The bad mask is **opened** (one round of 4-connected erode + dilate). A slightly blurred or recompressed *source* shifts every edge and paints ≤2 px filaments along all boundaries; those vanish under the opening, while genuine missing patches survive. This is what makes the benchmark tolerant of mildly compressed or blurred originals.
|
||||
3. The surviving mask is clustered (4-connected). With cluster areas `aᵢ`, the **patch mass** is `√(Σ aᵢ²)` — a sum of *squares*, so one coherent blob dominates any amount of scattered dust of equal total area.
|
||||
4. With `P = patch mass / (w·h)`, the subscore is `2^(−P/0.005)`: a single coherent blob at 0.5 % of image mass halves the score; scattered dust barely dents it.
|
||||
|
||||
## Calibration
|
||||
|
||||
Scored on a 768×1024 flat-shaded illustration, comparing the original against distorted versions of **itself** — this is how much slack the benchmark gives an imperfect source, and what the top of the scale means:
|
||||
|
||||
| candidate | psnr | ssim | patch | **fidelity** |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| the original itself | 1.000 | 1.000 | 1.000 | **1.0000** |
|
||||
| JPEG quality 95 | 0.816 | 1.000 | 1.000 | **0.9502** |
|
||||
| JPEG quality 75 | 0.718 | 0.999 | 0.994 | **0.9186** |
|
||||
| 0.8 px Gaussian blur | 0.596 | 0.995 | 0.861 | **0.8443** |
|
||||
|
||||
Rule of thumb: **≥ 0.95** is visually indistinguishable, **≥ 0.90** is a faithful trace, **≤ 0.80** has visible geometry or color errors, and a score that *collapses* while PSNR/SSIM stay high means the patch axis found a coherent missing region — look at the `--mask` output.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```console
|
||||
vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
|
||||
```
|
||||
|
||||
- `original`, `candidate` — rasters of identical dimensions (any format `image` decodes). Rendering an SVG to pixels is deliberately out of scope: use the renderer whose output you actually ship (resvg, Chromium, librsvg, …) so the benchmark judges what users see.
|
||||
- `--thresh N` — RGB Euclidean bad-pixel gate for the patch axis (default 24).
|
||||
- `--mask out.png` — write the raw bad-pixel mask (before the opening) for visual inspection.
|
||||
|
||||
The last stdout line is machine-readable:
|
||||
|
||||
```
|
||||
[csv] fidelity,psnr,dssim,rmse,patch_mass,s_psnr,s_ssim,s_patch
|
||||
```
|
||||
|
||||
(RMSE is reported for reference but carries no weight — it is the same MSE that PSNR measures, only on a linear curve; scoring both would double-weight one error.)
|
||||
|
||||
### Library
|
||||
|
||||
```rust
|
||||
use vtracer_bench::{fidelity, DEFAULT_THRESH};
|
||||
|
||||
// orig and cand are interleaved RGB8, both w×h
|
||||
let (report, bad_mask) = fidelity(&orig, &cand, w, h, DEFAULT_THRESH);
|
||||
println!("fidelity {:.4} (psnr {:.2} dB, dssim {:.5})",
|
||||
report.fidelity, report.psnr, report.dssim);
|
||||
```
|
||||
|
||||
`FidelityReport` exposes every raw metric and subscore; the tuning constants (`PATCH_HALF`, `DEFAULT_THRESH`, and the `W_PSNR`/`W_SSIM`/`W_PATCH` weights) are public and documented in `lib.rs`.
|
||||
|
||||
The benchmark is fully deterministic: identical inputs produce byte-identical output.
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Universal tracer fidelity benchmark — original vs reconstruction, blind to
|
||||
//! how the reconstruction was made. Three raw metrics, each squashed to [0,1],
|
||||
//! composed by geometric mean into ONE fidelity score (0 = garbage, 1 = exact):
|
||||
//!
|
||||
//! psnr sRGB PSNR over RGB. Squash: 1 − log(1+rmse)/log(256) — anchored
|
||||
//! at the two natural scales of 8-bit imagery and nothing else:
|
||||
//! rmse = 255 (full range) → 0, rmse ≤ 1 (the quantization step)
|
||||
//! saturates to 1. Equals psnr/48.13dB for rmse ≫ 1, i.e. still
|
||||
//! linear in dB, without arbitrary anchor constants.
|
||||
//! ssim dssim-core multiscale DSSIM d (= 1/SSIM − 1) → SSIM = 1/(1+d),
|
||||
//! already a natural 0..1.
|
||||
//! patch the "missing patch" / systematic-bias detector: bad ⟺ RGB
|
||||
//! Euclidean diff > thresh, OPEN the bad mask (1-round 4-conn
|
||||
//! erode+dilate — a slightly blurred or compressed source shifts
|
||||
//! every edge and paints ≤2px filaments along all boundaries; those
|
||||
//! vanish, real patches survive), then cluster it (visioncortex,
|
||||
//! 4-conn), S = Σ area². Patch mass fraction P = √S / (w·h) — the RMS
|
||||
//! coherent-blob size as a fraction of the image. Squash: 2^(−P/0.005),
|
||||
//! so ONE coherent blob at 0.5% image mass halves the score while the
|
||||
//! same pixel count scattered as dust barely dents it. Exactly the
|
||||
//! failure mode PSNR/SSIM average away.
|
||||
//!
|
||||
//! Composite: weighted geometric mean, fidelity = (psnr¹ · ssim² · patch¹)^(1/4).
|
||||
//! Geometric (not arithmetic) so a single collapsed axis drags the composite
|
||||
//! down — a missing eye can't hide behind good global PSNR. SSIM carries double
|
||||
//! weight: it tracks visual accuracy best and is the axis most robust to a
|
||||
//! mildly compressed or blurred source.
|
||||
|
||||
use visioncortex::BinaryImage;
|
||||
|
||||
/// Patch mass fraction that halves the patch subscore.
|
||||
pub const PATCH_HALF: f64 = 0.005;
|
||||
/// Default RGB Euclidean distance for a pixel to count as "bad".
|
||||
pub const DEFAULT_THRESH: f64 = 24.0;
|
||||
/// Composite weights (geometric): fidelity = (psnr^1 · ssim^2 · patch^1)^(1/4).
|
||||
pub const W_PSNR: f64 = 1.0;
|
||||
pub const W_SSIM: f64 = 2.0;
|
||||
pub const W_PATCH: f64 = 1.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FidelityReport {
|
||||
// raw
|
||||
pub psnr: f64,
|
||||
pub dssim: f64,
|
||||
/// sRGB RMSE — reported for reference, carries no weight (PSNR is the
|
||||
/// same MSE on a log curve; scoring both would double-weight it)
|
||||
pub rmse: f64,
|
||||
/// bad pixels (‖Δrgb‖ > thresh), before the opening
|
||||
pub bad_px: usize,
|
||||
/// 4-conn clusters of bad pixels after the opening
|
||||
pub clusters: usize,
|
||||
/// largest cluster area (px)
|
||||
pub largest: usize,
|
||||
/// √(Σ area²) — RMS coherent-blob mass, in px
|
||||
pub patch_mass: f64,
|
||||
// subscores in [0,1]
|
||||
pub s_psnr: f64,
|
||||
pub s_ssim: f64,
|
||||
pub s_patch: f64,
|
||||
/// geometric mean of the three subscores
|
||||
pub fidelity: f64,
|
||||
}
|
||||
|
||||
fn dssim_score(a_rgb: &[u8], b_rgb: &[u8], w: usize, h: usize) -> f64 {
|
||||
let d = dssim_core::Dssim::new();
|
||||
let to = |buf: &[u8]| {
|
||||
let px: Vec<rgb::RGB<u8>> =
|
||||
(0..w * h).map(|i| rgb::RGB { r: buf[i * 3], g: buf[i * 3 + 1], b: buf[i * 3 + 2] }).collect();
|
||||
d.create_image_rgb(&px, w, h).expect("dssim image")
|
||||
};
|
||||
let (val, _) = d.compare(&to(a_rgb), &to(b_rgb));
|
||||
val.into()
|
||||
}
|
||||
|
||||
/// Compare an original against a candidate reconstruction, both RGB8, w×h.
|
||||
/// `thresh` is the RGB Euclidean bad-pixel gate (use [`DEFAULT_THRESH`]).
|
||||
/// Returns the report plus the bad-pixel mask (255/0, one byte per pixel).
|
||||
pub fn fidelity(orig_rgb: &[u8], cand_rgb: &[u8], w: usize, h: usize, thresh: f64) -> (FidelityReport, Vec<u8>) {
|
||||
assert_eq!(orig_rgb.len(), w * h * 3);
|
||||
assert_eq!(cand_rgb.len(), w * h * 3);
|
||||
|
||||
// PSNR + RMSE + bad-pixel binarization in one pass
|
||||
let mut sse = 0f64;
|
||||
let mut mask = vec![0u8; w * h];
|
||||
let mut bad_px = 0usize;
|
||||
let t2 = thresh * thresh;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let mut d2 = 0f64;
|
||||
for c in 0..3 {
|
||||
let e = orig_rgb[i * 3 + c] as f64 - cand_rgb[i * 3 + c] as f64;
|
||||
d2 += e * e;
|
||||
}
|
||||
sse += d2;
|
||||
if d2 > t2 {
|
||||
mask[i] = 255;
|
||||
bad_px += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rmse = (sse / (w * h * 3) as f64).sqrt();
|
||||
let psnr = 20.0 * (255.0 / rmse.max(1e-6)).log10();
|
||||
|
||||
let dssim = dssim_score(orig_rgb, cand_rgb, w, h);
|
||||
|
||||
// opening: 1-round 4-conn erode + dilate. Edge-shift filaments (≤2px wide,
|
||||
// the signature of a slightly blurred/compressed source) vanish; genuine
|
||||
// missing patches survive. The reported mask keeps the raw bad pixels.
|
||||
let at = |m: &[u8], x: i64, y: i64| {
|
||||
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && m[y as usize * w + x as usize] != 0
|
||||
};
|
||||
let mut eroded = vec![0u8; w * h];
|
||||
for y in 0..h as i64 {
|
||||
for x in 0..w as i64 {
|
||||
if at(&mask, x, y)
|
||||
&& at(&mask, x - 1, y)
|
||||
&& at(&mask, x + 1, y)
|
||||
&& at(&mask, x, y - 1)
|
||||
&& at(&mask, x, y + 1)
|
||||
{
|
||||
eroded[y as usize * w + x as usize] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut bin = BinaryImage::new_w_h(w, h);
|
||||
for y in 0..h as i64 {
|
||||
for x in 0..w as i64 {
|
||||
if at(&eroded, x, y)
|
||||
|| at(&eroded, x - 1, y)
|
||||
|| at(&eroded, x + 1, y)
|
||||
|| at(&eroded, x, y - 1)
|
||||
|| at(&eroded, x, y + 1)
|
||||
{
|
||||
bin.set_pixel(x as usize, y as usize, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sizes: Vec<usize> = bin.to_clusters(false).iter().map(|c| c.size()).collect();
|
||||
let largest = sizes.iter().copied().max().unwrap_or(0);
|
||||
let patch_mass = if sizes.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
sizes.iter().map(|&a| (a as f64) * (a as f64)).sum::<f64>().sqrt()
|
||||
};
|
||||
let p_frac = patch_mass / (w * h) as f64;
|
||||
|
||||
let s_psnr = 1.0 - (1.0 + rmse).ln() / 256f64.ln();
|
||||
let s_ssim = 1.0 / (1.0 + dssim);
|
||||
let s_patch = (-p_frac / PATCH_HALF * std::f64::consts::LN_2).exp();
|
||||
let fidelity = (s_psnr.powf(W_PSNR) * s_ssim.powf(W_SSIM) * s_patch.powf(W_PATCH))
|
||||
.powf(1.0 / (W_PSNR + W_SSIM + W_PATCH));
|
||||
|
||||
(
|
||||
FidelityReport {
|
||||
psnr,
|
||||
dssim,
|
||||
rmse,
|
||||
bad_px,
|
||||
clusters: sizes.len(),
|
||||
largest,
|
||||
patch_mass,
|
||||
s_psnr,
|
||||
s_ssim,
|
||||
s_patch,
|
||||
fidelity,
|
||||
},
|
||||
mask,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flat(w: usize, h: usize, c: [u8; 3]) -> Vec<u8> {
|
||||
(0..w * h).flat_map(|_| c).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_is_one() {
|
||||
let a = flat(64, 64, [120, 90, 200]);
|
||||
let (r, mask) = fidelity(&a, &a, 64, 64, DEFAULT_THRESH);
|
||||
assert_eq!(r.bad_px, 0);
|
||||
assert!(mask.iter().all(|&m| m == 0));
|
||||
assert!((r.fidelity - 1.0).abs() < 1e-9, "fidelity {}", r.fidelity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coherent_patch_scores_below_scattered_dust() {
|
||||
// same 256 bad pixels: one 16×16 blob vs isolated singles on a 64×64 grid
|
||||
let clean = flat(64, 64, [200, 200, 200]);
|
||||
let mut blob = clean.clone();
|
||||
for y in 24..40 {
|
||||
for x in 24..40 {
|
||||
blob[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
|
||||
}
|
||||
}
|
||||
let mut dust = clean.clone();
|
||||
for k in 0..256 {
|
||||
let (x, y) = ((k % 16) * 4, (k / 16) * 4); // 4px spacing: 256 singleton clusters
|
||||
dust[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
|
||||
}
|
||||
let (rb, _) = fidelity(&clean, &blob, 64, 64, DEFAULT_THRESH);
|
||||
let (rd, _) = fidelity(&clean, &dust, 64, 64, DEFAULT_THRESH);
|
||||
assert_eq!(rb.bad_px, 256);
|
||||
assert_eq!(rd.bad_px, 256);
|
||||
// dust vanishes under the opening entirely; the blob survives
|
||||
assert_eq!(rb.clusters, 1);
|
||||
assert_eq!(rd.clusters, 0);
|
||||
assert!((rd.s_patch - 1.0).abs() < 1e-9);
|
||||
// identical PSNR/RMSE by construction; the patch axis must separate them
|
||||
assert!((rb.rmse - rd.rmse).abs() < 1e-9);
|
||||
assert!(rb.s_patch < rd.s_patch * 0.25, "blob {} dust {}", rb.s_patch, rd.s_patch);
|
||||
assert!(rb.fidelity < rd.fidelity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_shift_filaments_are_tolerated() {
|
||||
// a slightly blurred/compressed source shifts edges: thin bad-px lines
|
||||
// along boundaries. A 2px-wide full-width filament (256 px) must open
|
||||
// away; the same mass as a compact blob must not.
|
||||
let clean = flat(64, 64, [200, 200, 200]);
|
||||
let mut fil = clean.clone();
|
||||
for y in 30..32 {
|
||||
for x in 0..64 {
|
||||
fil[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
|
||||
}
|
||||
}
|
||||
let (rf, _) = fidelity(&clean, &fil, 64, 64, DEFAULT_THRESH);
|
||||
assert_eq!(rf.bad_px, 128);
|
||||
assert_eq!(rf.clusters, 0);
|
||||
assert!((rf.s_patch - 1.0).abs() < 1e-9, "filament must not count as a patch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worse_is_lower() {
|
||||
let a = flat(32, 32, [100, 100, 100]);
|
||||
let mild: Vec<u8> = a.iter().map(|&v| v + 4).collect();
|
||||
let harsh: Vec<u8> = a.iter().map(|&v| v + 60).collect();
|
||||
let (rm, _) = fidelity(&a, &mild, 32, 32, DEFAULT_THRESH);
|
||||
let (rh, _) = fidelity(&a, &harsh, 32, 32, DEFAULT_THRESH);
|
||||
assert!(rm.fidelity > rh.fidelity);
|
||||
assert!(rh.fidelity < 0.4, "harsh {}", rh.fidelity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 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::{fidelity, DEFAULT_THRESH};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let mut thresh = DEFAULT_THRESH;
|
||||
let mut mask_out: Option<String> = None;
|
||||
let mut i = 3;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--thresh" => {
|
||||
i += 1;
|
||||
thresh = args[i].parse().expect("--thresh N");
|
||||
}
|
||||
"--mask" => {
|
||||
i += 1;
|
||||
mask_out = Some(args[i].clone());
|
||||
}
|
||||
a => {
|
||||
eprintln!("unknown flag {a}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let orig = image::open(&args[1]).expect("open original").to_rgb8();
|
||||
let (w, h) = (orig.width() as usize, orig.height() as usize);
|
||||
let img = image::open(&args[2]).expect("open candidate").to_rgb8();
|
||||
assert_eq!(
|
||||
(img.width() as usize, img.height() as usize),
|
||||
(w, h),
|
||||
"candidate raster must match original dimensions"
|
||||
);
|
||||
let cand: Vec<u8> = img.into_raw();
|
||||
|
||||
let (r, mask) = fidelity(orig.as_raw(), &cand, w, h, thresh);
|
||||
|
||||
if let Some(out) = mask_out {
|
||||
image::GrayImage::from_raw(w as u32, h as u32, mask).unwrap().save(&out).expect("save mask");
|
||||
}
|
||||
|
||||
println!("psnr {:>8.2} dB (rmse {:.2}) -> {:.4}", r.psnr, r.rmse, r.s_psnr);
|
||||
println!("ssim {:>8.5} (dssim {:.5}) -> {:.4}", r.s_ssim, r.dssim, r.s_ssim);
|
||||
println!(
|
||||
"patch {:>8.1} px rms ({} bad px, {} clusters, largest {}) -> {:.4}",
|
||||
r.patch_mass, r.bad_px, r.clusters, r.largest, r.s_patch
|
||||
);
|
||||
println!("fidelity {:.4}", r.fidelity);
|
||||
println!(
|
||||
"[csv] {:.4},{:.2},{:.5},{:.2},{:.1},{:.4},{:.4},{:.4}",
|
||||
r.fidelity, r.psnr, r.dssim, r.rmse, r.patch_mass, r.s_psnr, r.s_ssim, r.s_patch
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "vtracer-cli"
|
||||
description = "Command-line front-end for the vtracer vectorization framework."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
categories = ["graphics", "command-line-utilities"]
|
||||
keywords = ["svg", "vectorization", "computer-graphics"]
|
||||
|
||||
[[bin]]
|
||||
name = "vtracer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
|
||||
visioncortex.workspace = true
|
||||
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
|
||||
] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Thin command-line front-end over the `vtracer` framework.
|
||||
//!
|
||||
//! Handles the two things the framework deliberately leaves out: image file
|
||||
//! I/O and argument parsing. Everything else is delegated to
|
||||
//! [`vtracer::Config`] / [`vtracer::Pipeline`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use visioncortex::{Color, ColorImage};
|
||||
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
|
||||
|
||||
/// Convert an image into vector graphics.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "vtracer", version, about, rename_all = "kebab-case")]
|
||||
struct Args {
|
||||
/// Input raster image (positional; or use --input).
|
||||
#[arg(value_name = "INPUT")]
|
||||
input_pos: Option<PathBuf>,
|
||||
|
||||
/// Output SVG (positional; or use --output).
|
||||
#[arg(value_name = "OUTPUT")]
|
||||
output_pos: Option<PathBuf>,
|
||||
|
||||
/// Path to the input raster image.
|
||||
#[arg(short = 'i', long = "input", value_name = "INPUT")]
|
||||
input: Option<PathBuf>,
|
||||
|
||||
/// Path to the output SVG.
|
||||
#[arg(short = 'o', long = "output", value_name = "OUTPUT")]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Start from a preset: bw, poster, photo.
|
||||
#[arg(long)]
|
||||
preset: Option<Preset>,
|
||||
|
||||
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
|
||||
#[arg(long)]
|
||||
clustering: Option<Clustering>,
|
||||
|
||||
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
|
||||
#[arg(long)]
|
||||
hierarchical: Option<Hierarchical>,
|
||||
|
||||
/// Curve-fitting mode: pixel, polygon, spline.
|
||||
#[arg(short, long)]
|
||||
mode: Option<FitMode>,
|
||||
|
||||
/// Discard patches smaller than X px in size (0..=128).
|
||||
#[arg(short = 'f', long, value_parser = clap::value_parser!(i64).range(0..=128))]
|
||||
filter_speckle: Option<i64>,
|
||||
|
||||
/// Significant bits per RGB channel (1..=8).
|
||||
#[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(1..=8))]
|
||||
color_precision: Option<i64>,
|
||||
|
||||
/// Color difference between gradient layers (0..=255).
|
||||
#[arg(short = 'g', long, value_parser = clap::value_parser!(i64).range(0..=255))]
|
||||
gradient_step: Option<i64>,
|
||||
|
||||
/// Minimum momentary angle (degrees) to be a corner (0..=180).
|
||||
///
|
||||
/// Hidden from help: a fine-tuning knob few conversions need — the
|
||||
/// default (60) serves; `--simplify` is the knob worth reaching for.
|
||||
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
|
||||
corner_threshold: Option<i64>,
|
||||
|
||||
/// Subdivide until all segments are shorter than this length (3.5..=10).
|
||||
///
|
||||
/// Hidden from help: with `--simplify` reducing anchors by an explicit
|
||||
/// error tolerance, this legacy knob's effect on output is negligible.
|
||||
#[arg(long, hide = true, value_parser = parse_segment_length)]
|
||||
segment_length: Option<f64>,
|
||||
|
||||
/// Minimum angle displacement (degrees) to splice a spline (0..=180).
|
||||
///
|
||||
/// Hidden from help: a fine-tuning knob few conversions need — the
|
||||
/// default (45) serves; `--simplify` is the knob worth reaching for.
|
||||
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
|
||||
splice_threshold: Option<i64>,
|
||||
|
||||
/// Simplify curves: fewest cubics within this tolerance in px (try 1-2.5).
|
||||
#[arg(long, value_name = "TOLERANCE", value_parser = parse_simplify_tolerance)]
|
||||
simplify: Option<f64>,
|
||||
|
||||
/// Decimal places to use in path coordinates.
|
||||
#[arg(long)]
|
||||
path_precision: Option<u32>,
|
||||
|
||||
/// Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'.
|
||||
#[arg(long)]
|
||||
palette: Option<String>,
|
||||
|
||||
/// Fixed palette from a file (one hex color per line or comma-separated).
|
||||
#[arg(long)]
|
||||
palette_file: Option<PathBuf>,
|
||||
|
||||
/// Auto-quantize to at most N colors.
|
||||
#[arg(long)]
|
||||
max_colors: Option<usize>,
|
||||
|
||||
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
|
||||
optimize: Option<u8>,
|
||||
|
||||
/// Binary mode: fixed threshold (0..=255); foreground when intensity is below it.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8))]
|
||||
threshold: Option<u8>,
|
||||
|
||||
/// Binary mode: use Bradley–Roth adaptive thresholding (handles uneven lighting).
|
||||
#[arg(long)]
|
||||
adaptive: bool,
|
||||
|
||||
/// Adaptive window side length in px (0 = auto). Implies --adaptive.
|
||||
#[arg(long)]
|
||||
adaptive_window: Option<u32>,
|
||||
|
||||
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
|
||||
#[arg(long)]
|
||||
adaptive_t: Option<f64>,
|
||||
|
||||
/// Watershed clustering: hierarchy cut level (0..=255, higher = more regions).
|
||||
#[arg(long, value_parser = clap::value_parser!(u8))]
|
||||
watershed_detail: Option<u8>,
|
||||
}
|
||||
|
||||
fn parse_simplify_tolerance(s: &str) -> Result<f64, String> {
|
||||
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
|
||||
if !v.is_finite() || v <= 0.0 {
|
||||
return Err(format!("simplify tolerance {v} must be positive"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn parse_segment_length(s: &str) -> Result<f64, String> {
|
||||
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
|
||||
if !(3.5..=10.0).contains(&v) {
|
||||
return Err(format!("segment length {v} is out of range [3.5, 10]"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Parse a comma/whitespace/newline separated list of `#rrggbb` colors.
|
||||
fn parse_palette(text: &str) -> Result<Vec<Color>, String> {
|
||||
let mut colors = Vec::new();
|
||||
for token in text.split(|c: char| c == ',' || c.is_whitespace()) {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
colors.push(parse_hex_color(token)?);
|
||||
}
|
||||
Ok(colors)
|
||||
}
|
||||
|
||||
fn parse_hex_color(token: &str) -> Result<Color, String> {
|
||||
let hex = token.strip_prefix('#').unwrap_or(token);
|
||||
if hex.len() != 6 {
|
||||
return Err(format!("`{token}` is not a #rrggbb color"));
|
||||
}
|
||||
let parse = |range: std::ops::Range<usize>| {
|
||||
u8::from_str_radix(&hex[range], 16).map_err(|_| format!("`{token}` is not a #rrggbb color"))
|
||||
};
|
||||
Ok(Color::new(parse(0..2)?, parse(2..4)?, parse(4..6)?))
|
||||
}
|
||||
|
||||
fn build_config(args: &Args) -> Result<Config, String> {
|
||||
let mut config = match args.preset {
|
||||
Some(preset) => Config::from_preset(preset),
|
||||
None => Config::default(),
|
||||
};
|
||||
|
||||
if let Some(v) = args.clustering {
|
||||
config.clustering = v;
|
||||
}
|
||||
if let Some(v) = args.hierarchical {
|
||||
config.hierarchical = v;
|
||||
}
|
||||
if let Some(v) = args.mode {
|
||||
config.mode = v;
|
||||
}
|
||||
if let Some(v) = args.filter_speckle {
|
||||
config.filter_speckle = v as usize;
|
||||
}
|
||||
if let Some(v) = args.color_precision {
|
||||
config.color_precision = v as i32;
|
||||
}
|
||||
if let Some(v) = args.gradient_step {
|
||||
config.layer_difference = v as i32;
|
||||
}
|
||||
if let Some(v) = args.corner_threshold {
|
||||
config.corner_threshold = v as i32;
|
||||
}
|
||||
if let Some(v) = args.segment_length {
|
||||
config.length_threshold = v;
|
||||
}
|
||||
if let Some(v) = args.splice_threshold {
|
||||
config.splice_threshold = v as i32;
|
||||
}
|
||||
if args.simplify.is_some() {
|
||||
config.simplify = args.simplify;
|
||||
}
|
||||
if args.path_precision.is_some() {
|
||||
config.path_precision = args.path_precision;
|
||||
}
|
||||
if let Some(v) = args.optimize {
|
||||
config.optimize = v;
|
||||
}
|
||||
if let Some(v) = args.max_colors {
|
||||
config.max_colors = Some(v);
|
||||
}
|
||||
|
||||
// Binary thresholding: --adaptive (or either adaptive tuning flag) selects
|
||||
// Bradley–Roth; otherwise --threshold tunes the fixed cutoff.
|
||||
if let Some(v) = args.threshold {
|
||||
config.binary_threshold = v;
|
||||
}
|
||||
if args.adaptive || args.adaptive_window.is_some() || args.adaptive_t.is_some() {
|
||||
config.binary_adaptive = true;
|
||||
}
|
||||
if let Some(v) = args.adaptive_window {
|
||||
config.binary_adaptive_window = v;
|
||||
}
|
||||
if let Some(v) = args.adaptive_t {
|
||||
config.binary_adaptive_t = v;
|
||||
}
|
||||
if let Some(v) = args.watershed_detail {
|
||||
config.watershed_detail = v;
|
||||
}
|
||||
|
||||
// Palette: inline flag wins over file; both parse to a color list.
|
||||
if let Some(text) = &args.palette {
|
||||
config.palette = parse_palette(text)?;
|
||||
} else if let Some(path) = &args.palette_file {
|
||||
let text =
|
||||
std::fs::read_to_string(path).map_err(|e| format!("cannot read palette file: {e}"))?;
|
||||
config.palette = parse_palette(&text)?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn read_image(path: &std::path::Path) -> Result<ColorImage, String> {
|
||||
let img = image::open(path)
|
||||
.map_err(|_| "no image file found at specified input path".to_string())?
|
||||
.to_rgba8();
|
||||
let (width, height) = (img.width() as usize, img.height() as usize);
|
||||
Ok(ColorImage {
|
||||
pixels: img.into_raw(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Accept input/output as positionals (`vtracer in.png out.svg`) or as
|
||||
// named flags; an explicit flag takes precedence over the positional.
|
||||
let input = args
|
||||
.input
|
||||
.as_ref()
|
||||
.or(args.input_pos.as_ref())
|
||||
.ok_or("no input path given (positional or --input)")?;
|
||||
let output = args
|
||||
.output
|
||||
.as_ref()
|
||||
.or(args.output_pos.as_ref())
|
||||
.ok_or("no output path given (positional or --output)")?;
|
||||
|
||||
let config = build_config(&args)?;
|
||||
let pipeline = config.build().map_err(|e| e.to_string())?;
|
||||
let img = read_image(input)?;
|
||||
let svg = pipeline.to_svg(&img).map_err(|e| e.to_string())?;
|
||||
std::fs::write(output, svg).map_err(|e| format!("cannot write output file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => {
|
||||
println!("Conversion successful.");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(msg) => {
|
||||
eprintln!("Conversion failed: {msg}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "vtracer-py"
|
||||
description = "Python bindings for the vtracer vectorization framework."
|
||||
version = "1.0.0-alpha.3"
|
||||
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.3", path = "../vtracer" }
|
||||
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
|
||||
] }
|
||||
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py38"] }
|
||||
@@ -0,0 +1,73 @@
|
||||
# vtracer (Python)
|
||||
|
||||
Python bindings for the [`vtracer`](https://github.com/visioncortex/vtracer)
|
||||
raster-to-vector framework. Built with [pyo3](https://pyo3.rs) +
|
||||
[maturin](https://www.maturin.rs); the core Rust crate stays pure (no I/O), and
|
||||
this crate adds image decoding and a Pythonic API.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pip install vtracer==1.0.0a3
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
import vtracer
|
||||
|
||||
# one-liners
|
||||
vtracer.convert_file("in.png", "out.svg")
|
||||
svg = vtracer.convert_bytes(open("in.png", "rb").read()) # -> str
|
||||
svg = vtracer.convert_pixels(rgba_bytes, width, height) # raw RGBA8
|
||||
|
||||
# a rich, reusable configuration object
|
||||
cfg = vtracer.Config(mode="polygon", filter_speckle=8)
|
||||
cfg.hierarchical = "cutout" # seam-free mosaic
|
||||
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"] # snap to a fixed palette
|
||||
cfg.max_colors = 8 # or auto-quantize
|
||||
cfg.optimize = 2
|
||||
svg = cfg.convert_bytes(data)
|
||||
|
||||
# presets
|
||||
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
|
||||
vtracer.Config.bw().convert_file("scan.png", "lineart.svg")
|
||||
```
|
||||
|
||||
### `Config`
|
||||
|
||||
Constructor keyword arguments (all optional) — also exposed as mutable
|
||||
properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
|
||||
|
||||
| arg | default | notes |
|
||||
|---|---|---|
|
||||
| `clustering` | `"color-cluster"` | `"color-cluster"`, `"bw"`, or `"watershed"` |
|
||||
| `hierarchical` | `"stacked"` | `"stacked"` or `"cutout"` (mosaic) |
|
||||
| `mode` | `"spline"` | `"pixel"`, `"polygon"`, `"spline"` |
|
||||
| `filter_speckle` | `4` | discard patches smaller than X px |
|
||||
| `color_precision` | `6` | significant bits per channel |
|
||||
| `layer_difference` | `16` | color diff between gradient layers |
|
||||
| `corner_threshold` | `60` | degrees |
|
||||
| `length_threshold` | `4.0` | px |
|
||||
| `max_iterations` | `10` | |
|
||||
| `splice_threshold` | `45` | degrees |
|
||||
| `simplify` | `None` | curve simplification tolerance in px (try 1–2.5) |
|
||||
| `path_precision` | `2` | output decimal places |
|
||||
| `palette` | `None` | list of `#rrggbb` strings |
|
||||
| `max_colors` | `None` | auto-quantize target |
|
||||
| `optimize` | `1` | `0` off, `1` quantize+cleanup, `2` + shorthands |
|
||||
| `binary_threshold` | `128` | bw: fixed cutoff, foreground below it |
|
||||
| `adaptive` | `False` | bw: Bradley–Roth adaptive thresholding |
|
||||
| `adaptive_window` | `0` | bw adaptive: window px (`0` = auto) |
|
||||
| `adaptive_t` | `15.0` | bw adaptive: % below local mean |
|
||||
| `watershed_detail` | `128` | watershed: hierarchy cut level 0..=255 |
|
||||
|
||||
Each `Config` has `convert_file(input, output)`, `convert_bytes(data, format=None) -> str`,
|
||||
and `convert_pixels(rgba, width, height) -> str`.
|
||||
|
||||
## Build from source
|
||||
|
||||
```sh
|
||||
maturin develop # into the active virtualenv
|
||||
maturin build --release # produce a wheel
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.5,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "vtracer"
|
||||
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = { text = "MIT OR Apache-2.0" }
|
||||
authors = [{ name = "Chris Tsang", email = "tyt2y7@gmail.com" }]
|
||||
keywords = ["svg", "vectorization", "raster", "computer-graphics"]
|
||||
classifiers = [
|
||||
"Programming Language :: Rust",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://www.visioncortex.org/vtracer"
|
||||
Repository = "https://github.com/visioncortex/vtracer/"
|
||||
|
||||
[tool.maturin]
|
||||
# Pure-Rust extension module; the compiled library is imported as `vtracer`.
|
||||
module-name = "vtracer"
|
||||
features = ["pyo3/extension-module"]
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Python bindings for the `vtracer` vectorization framework.
|
||||
//!
|
||||
//! The API centers on a mutable [`Config`] object with named properties and
|
||||
//! preset constructors, plus three input paths — a file, encoded image bytes,
|
||||
//! or a raw RGBA buffer — each returning the SVG (or writing it to disk):
|
||||
//!
|
||||
//! ```python
|
||||
//! import vtracer
|
||||
//!
|
||||
//! # one-liners
|
||||
//! vtracer.convert_file("in.png", "out.svg")
|
||||
//! svg = vtracer.convert_bytes(open("in.png", "rb").read())
|
||||
//!
|
||||
//! # rich, reusable config
|
||||
//! cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
|
||||
//! cfg.max_colors = 8
|
||||
//! cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
|
||||
//! svg = cfg.convert_bytes(data)
|
||||
//!
|
||||
//! # presets
|
||||
//! vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
|
||||
//! ```
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pyo3::exceptions::{PyIOError, PyValueError};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use ::vtracer::{
|
||||
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
|
||||
};
|
||||
|
||||
// --- string <-> enum helpers -------------------------------------------------
|
||||
|
||||
fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
|
||||
s.parse().map_err(PyValueError::new_err)
|
||||
}
|
||||
|
||||
fn clustering_str(c: Clustering) -> &'static str {
|
||||
match c {
|
||||
Clustering::ColorCluster => "color-cluster",
|
||||
Clustering::Binary => "bw",
|
||||
Clustering::Watershed => "watershed",
|
||||
}
|
||||
}
|
||||
|
||||
fn hierarchical_str(h: Hierarchical) -> &'static str {
|
||||
match h {
|
||||
Hierarchical::Stacked => "stacked",
|
||||
Hierarchical::Cutout => "cutout",
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_str(m: FitMode) -> &'static str {
|
||||
match m {
|
||||
FitMode::Pixel => "pixel",
|
||||
FitMode::Polygon => "polygon",
|
||||
FitMode::Spline => "spline",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex(token: &str) -> PyResult<Color> {
|
||||
let hex = token.strip_prefix('#').unwrap_or(token);
|
||||
if hex.len() != 6 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"`{token}` is not a #rrggbb color"
|
||||
)));
|
||||
}
|
||||
let byte = |r: std::ops::Range<usize>| {
|
||||
u8::from_str_radix(&hex[r], 16)
|
||||
.map_err(|_| PyValueError::new_err(format!("`{token}` is not a #rrggbb color")))
|
||||
};
|
||||
Ok(Color::new(byte(0..2)?, byte(2..4)?, byte(4..6)?))
|
||||
}
|
||||
|
||||
// --- image helpers -----------------------------------------------------------
|
||||
|
||||
fn dynimg_to_color(img: image::DynamicImage) -> ColorImage {
|
||||
let img = img.to_rgba8();
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
ColorImage {
|
||||
pixels: img.into_raw(),
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_bytes(bytes: &[u8], format: Option<&str>) -> PyResult<ColorImage> {
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes));
|
||||
match format {
|
||||
Some(ext) => {
|
||||
let fmt = image::ImageFormat::from_extension(ext)
|
||||
.ok_or_else(|| PyValueError::new_err(format!("unknown image format `{ext}`")))?;
|
||||
reader.set_format(fmt);
|
||||
}
|
||||
None => {
|
||||
reader = reader
|
||||
.with_guessed_format()
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let img = reader
|
||||
.decode()
|
||||
.map_err(|e| PyValueError::new_err(format!("failed to decode image: {e}")))?;
|
||||
Ok(dynimg_to_color(img))
|
||||
}
|
||||
|
||||
// --- Config ------------------------------------------------------------------
|
||||
|
||||
/// Conversion configuration. Construct with keyword arguments or a preset,
|
||||
/// mutate via properties, then call one of the `convert_*` methods.
|
||||
#[pyclass(name = "Config")]
|
||||
#[derive(Clone)]
|
||||
struct PyConfig {
|
||||
inner: CoreConfig,
|
||||
}
|
||||
|
||||
impl PyConfig {
|
||||
fn to_svg(&self, img: &ColorImage) -> PyResult<String> {
|
||||
self.inner
|
||||
.build()
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))?
|
||||
.to_svg(img)
|
||||
.map_err(|e| PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
clustering = "color-cluster",
|
||||
hierarchical = "stacked",
|
||||
mode = "spline",
|
||||
filter_speckle = 4,
|
||||
color_precision = 6,
|
||||
layer_difference = 16,
|
||||
corner_threshold = 60,
|
||||
length_threshold = 4.0,
|
||||
max_iterations = 10,
|
||||
splice_threshold = 45,
|
||||
simplify = None,
|
||||
path_precision = 2,
|
||||
palette = None,
|
||||
max_colors = None,
|
||||
optimize = 1,
|
||||
binary_threshold = 128,
|
||||
adaptive = false,
|
||||
adaptive_window = 0,
|
||||
adaptive_t = 15.0,
|
||||
watershed_detail = 128,
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
clustering: &str,
|
||||
hierarchical: &str,
|
||||
mode: &str,
|
||||
filter_speckle: usize,
|
||||
color_precision: i32,
|
||||
layer_difference: i32,
|
||||
corner_threshold: i32,
|
||||
length_threshold: f64,
|
||||
max_iterations: usize,
|
||||
splice_threshold: i32,
|
||||
simplify: Option<f64>,
|
||||
path_precision: u32,
|
||||
palette: Option<Vec<String>>,
|
||||
max_colors: Option<usize>,
|
||||
optimize: u8,
|
||||
binary_threshold: u8,
|
||||
adaptive: bool,
|
||||
adaptive_window: u32,
|
||||
adaptive_t: f64,
|
||||
watershed_detail: u8,
|
||||
) -> PyResult<Self> {
|
||||
let palette = match palette {
|
||||
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
Ok(Self {
|
||||
inner: CoreConfig {
|
||||
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) -> u8 {
|
||||
self.inner.watershed_detail
|
||||
}
|
||||
#[setter]
|
||||
fn set_watershed_detail(&mut self, v: u8) {
|
||||
self.inner.watershed_detail = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn hierarchical(&self) -> &'static str {
|
||||
hierarchical_str(self.inner.hierarchical)
|
||||
}
|
||||
#[setter]
|
||||
fn set_hierarchical(&mut self, v: &str) -> PyResult<()> {
|
||||
self.inner.hierarchical = parse(v)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn mode(&self) -> &'static str {
|
||||
mode_str(self.inner.mode)
|
||||
}
|
||||
#[setter]
|
||||
fn set_mode(&mut self, v: &str) -> PyResult<()> {
|
||||
self.inner.mode = parse(v)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn filter_speckle(&self) -> usize {
|
||||
self.inner.filter_speckle
|
||||
}
|
||||
#[setter]
|
||||
fn set_filter_speckle(&mut self, v: usize) {
|
||||
self.inner.filter_speckle = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn color_precision(&self) -> i32 {
|
||||
self.inner.color_precision
|
||||
}
|
||||
#[setter]
|
||||
fn set_color_precision(&mut self, v: i32) {
|
||||
self.inner.color_precision = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn layer_difference(&self) -> i32 {
|
||||
self.inner.layer_difference
|
||||
}
|
||||
#[setter]
|
||||
fn set_layer_difference(&mut self, v: i32) {
|
||||
self.inner.layer_difference = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn corner_threshold(&self) -> i32 {
|
||||
self.inner.corner_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_corner_threshold(&mut self, v: i32) {
|
||||
self.inner.corner_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn length_threshold(&self) -> f64 {
|
||||
self.inner.length_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_length_threshold(&mut self, v: f64) {
|
||||
self.inner.length_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn max_iterations(&self) -> usize {
|
||||
self.inner.max_iterations
|
||||
}
|
||||
#[setter]
|
||||
fn set_max_iterations(&mut self, v: usize) {
|
||||
self.inner.max_iterations = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn splice_threshold(&self) -> i32 {
|
||||
self.inner.splice_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_splice_threshold(&mut self, v: i32) {
|
||||
self.inner.splice_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn simplify(&self) -> Option<f64> {
|
||||
self.inner.simplify
|
||||
}
|
||||
#[setter]
|
||||
fn set_simplify(&mut self, v: Option<f64>) {
|
||||
self.inner.simplify = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn path_precision(&self) -> Option<u32> {
|
||||
self.inner.path_precision
|
||||
}
|
||||
#[setter]
|
||||
fn set_path_precision(&mut self, v: Option<u32>) {
|
||||
self.inner.path_precision = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn palette(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.palette
|
||||
.iter()
|
||||
.map(Color::to_hex_string)
|
||||
.collect()
|
||||
}
|
||||
#[setter]
|
||||
fn set_palette(&mut self, v: Vec<String>) -> PyResult<()> {
|
||||
self.inner.palette = v.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn max_colors(&self) -> Option<usize> {
|
||||
self.inner.max_colors
|
||||
}
|
||||
#[setter]
|
||||
fn set_max_colors(&mut self, v: Option<usize>) {
|
||||
self.inner.max_colors = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn optimize(&self) -> u8 {
|
||||
self.inner.optimize
|
||||
}
|
||||
#[setter]
|
||||
fn set_optimize(&mut self, v: u8) {
|
||||
self.inner.optimize = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn binary_threshold(&self) -> u8 {
|
||||
self.inner.binary_threshold
|
||||
}
|
||||
#[setter]
|
||||
fn set_binary_threshold(&mut self, v: u8) {
|
||||
self.inner.binary_threshold = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn adaptive(&self) -> bool {
|
||||
self.inner.binary_adaptive
|
||||
}
|
||||
#[setter]
|
||||
fn set_adaptive(&mut self, v: bool) {
|
||||
self.inner.binary_adaptive = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn adaptive_window(&self) -> u32 {
|
||||
self.inner.binary_adaptive_window
|
||||
}
|
||||
#[setter]
|
||||
fn set_adaptive_window(&mut self, v: u32) {
|
||||
self.inner.binary_adaptive_window = v;
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn adaptive_t(&self) -> f64 {
|
||||
self.inner.binary_adaptive_t
|
||||
}
|
||||
#[setter]
|
||||
fn set_adaptive_t(&mut self, v: f64) {
|
||||
self.inner.binary_adaptive_t = v;
|
||||
}
|
||||
|
||||
// --- conversion ---
|
||||
|
||||
/// Trace the image at `input_path` and write the SVG to `output_path`.
|
||||
fn convert_file(&self, input_path: PathBuf, output_path: PathBuf) -> PyResult<()> {
|
||||
let img = image::open(&input_path).map_err(|e| {
|
||||
PyIOError::new_err(format!("cannot open `{}`: {e}", input_path.display()))
|
||||
})?;
|
||||
let svg = self.to_svg(&dynimg_to_color(img))?;
|
||||
std::fs::write(&output_path, svg).map_err(|e| {
|
||||
PyIOError::new_err(format!("cannot write `{}`: {e}", output_path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Trace encoded image `data` (png/jpg/...) and return the SVG string.
|
||||
/// `format` (e.g. "png") overrides content-based format detection.
|
||||
#[pyo3(signature = (data, format = None))]
|
||||
fn convert_bytes(&self, data: Vec<u8>, format: Option<&str>) -> PyResult<String> {
|
||||
self.to_svg(&decode_bytes(&data, format)?)
|
||||
}
|
||||
|
||||
/// Trace a raw RGBA8 buffer (`width * height * 4` bytes) and return the SVG.
|
||||
fn convert_pixels(&self, rgba: Vec<u8>, width: usize, height: usize) -> PyResult<String> {
|
||||
if rgba.len() != width * height * 4 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"rgba length {} != width*height*4 ({})",
|
||||
rgba.len(),
|
||||
width * height * 4
|
||||
)));
|
||||
}
|
||||
self.to_svg(&ColorImage {
|
||||
pixels: rgba,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
let c = &self.inner;
|
||||
format!(
|
||||
"Config(clustering='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
|
||||
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
|
||||
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
|
||||
max_colors={:?}, optimize={})",
|
||||
clustering_str(c.clustering),
|
||||
hierarchical_str(c.hierarchical),
|
||||
mode_str(c.mode),
|
||||
c.filter_speckle,
|
||||
c.color_precision,
|
||||
c.layer_difference,
|
||||
c.corner_threshold,
|
||||
c.length_threshold,
|
||||
c.max_iterations,
|
||||
c.splice_threshold,
|
||||
c.path_precision,
|
||||
c.palette.len(),
|
||||
c.max_colors,
|
||||
c.optimize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- module-level convenience ------------------------------------------------
|
||||
|
||||
/// Convert a file to SVG on disk, using `config` (or defaults).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (input_path, output_path, config = None))]
|
||||
fn convert_file(
|
||||
input_path: PathBuf,
|
||||
output_path: PathBuf,
|
||||
config: Option<PyConfig>,
|
||||
) -> PyResult<()> {
|
||||
config
|
||||
.unwrap_or_else(default_config)
|
||||
.convert_file(input_path, output_path)
|
||||
}
|
||||
|
||||
/// Convert encoded image bytes to an SVG string, using `config` (or defaults).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (data, config = None, format = None))]
|
||||
fn convert_bytes(
|
||||
data: Vec<u8>,
|
||||
config: Option<PyConfig>,
|
||||
format: Option<&str>,
|
||||
) -> PyResult<String> {
|
||||
config
|
||||
.unwrap_or_else(default_config)
|
||||
.convert_bytes(data, format)
|
||||
}
|
||||
|
||||
/// Convert a raw RGBA8 buffer to an SVG string, using `config` (or defaults).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (rgba, width, height, config = None))]
|
||||
fn convert_pixels(
|
||||
rgba: Vec<u8>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
config: Option<PyConfig>,
|
||||
) -> PyResult<String> {
|
||||
config
|
||||
.unwrap_or_else(default_config)
|
||||
.convert_pixels(rgba, width, height)
|
||||
}
|
||||
|
||||
fn default_config() -> PyConfig {
|
||||
PyConfig {
|
||||
inner: CoreConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[pymodule]
|
||||
#[pyo3(name = "vtracer")]
|
||||
fn vtracer_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyConfig>()?;
|
||||
m.add_function(wrap_pyfunction!(convert_file, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(convert_bytes, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(convert_pixels, m)?)?;
|
||||
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import Optional
|
||||
|
||||
__version__: str
|
||||
|
||||
class Config:
|
||||
"""Conversion configuration. Construct with keyword arguments or a preset,
|
||||
mutate via properties, then call one of the ``convert_*`` methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
|
||||
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
|
||||
mode: str = "spline", # "pixel" | "polygon" | "spline"
|
||||
filter_speckle: int = 4,
|
||||
color_precision: int = 6,
|
||||
layer_difference: int = 16,
|
||||
corner_threshold: int = 60,
|
||||
length_threshold: float = 4.0,
|
||||
max_iterations: int = 10,
|
||||
splice_threshold: int = 45,
|
||||
simplify: Optional[float] = None, # curve simplification tolerance in px (None = off)
|
||||
path_precision: int = 2,
|
||||
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
|
||||
max_colors: Optional[int] = None, # auto-quantize target
|
||||
optimize: int = 1, # 0 | 1 | 2
|
||||
binary_threshold: int = 128, # bw: fixed cutoff 0..=255
|
||||
adaptive: bool = False, # bw: Bradley–Roth adaptive
|
||||
adaptive_window: int = 0, # bw adaptive: window px (0 = auto)
|
||||
adaptive_t: float = 15.0, # bw adaptive: % below local mean
|
||||
watershed_detail: int = 128, # watershed: cut level 0..=255
|
||||
) -> None: ...
|
||||
|
||||
@staticmethod
|
||||
def bw() -> "Config": ...
|
||||
@staticmethod
|
||||
def poster() -> "Config": ...
|
||||
@staticmethod
|
||||
def photo() -> "Config": ...
|
||||
|
||||
clustering: str
|
||||
hierarchical: str
|
||||
mode: str
|
||||
filter_speckle: int
|
||||
color_precision: int
|
||||
layer_difference: int
|
||||
corner_threshold: int
|
||||
length_threshold: float
|
||||
max_iterations: int
|
||||
splice_threshold: int
|
||||
simplify: Optional[float]
|
||||
path_precision: Optional[int]
|
||||
palette: list[str]
|
||||
max_colors: Optional[int]
|
||||
optimize: int
|
||||
binary_threshold: int
|
||||
adaptive: bool
|
||||
adaptive_window: int
|
||||
adaptive_t: float
|
||||
watershed_detail: int
|
||||
|
||||
def convert_file(self, input_path: str, output_path: str) -> None: ...
|
||||
def convert_bytes(self, data: bytes, format: Optional[str] = None) -> str: ...
|
||||
def convert_pixels(self, rgba: bytes, width: int, height: int) -> str: ...
|
||||
|
||||
def convert_file(input_path: str, output_path: str, config: Optional[Config] = None) -> None: ...
|
||||
def convert_bytes(data: bytes, config: Optional[Config] = None, format: Optional[str] = None) -> str: ...
|
||||
def convert_pixels(rgba: bytes, width: int, height: int, config: Optional[Config] = None) -> str: ...
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "vtracer"
|
||||
description = "A vectorization framework that converts raster images into vector graphics: pluggable frontends, curve fitters, color fitting, and output optimization."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
categories = ["graphics", "computer-vision"]
|
||||
keywords = ["svg", "vectorization", "computer-graphics"]
|
||||
readme = "../../README.md"
|
||||
|
||||
[lib]
|
||||
name = "vtracer"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
visioncortex.workspace = true
|
||||
flo_curves.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not
|
||||
# compiled for wasm targets, so the library stays wasm-safe.
|
||||
resvg = "0.45"
|
||||
# Decode the sample photo for the spline-fitting regression test. Test-only.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
@@ -0,0 +1,2 @@
|
||||
# This crate is hand-formatted; a stray `cargo fmt` must not rewrite it.
|
||||
disable_all_formatting = true
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Color fitters: rewrite layer paints before compositing.
|
||||
//!
|
||||
//! * [`Identity`] — keep the frontend's mean colors (0.6.x behavior).
|
||||
//! * [`FixedPalette`] — snap each paint to the nearest entry of a fixed
|
||||
//! palette, measured in OKLab.
|
||||
//! * [`AutoQuantize`] — reduce the palette to at most `max_colors` via
|
||||
//! area-weighted median cut.
|
||||
//! * [`MergeAdjacent`] — union consecutive layers that share a paint, cutting
|
||||
//! shape count for free.
|
||||
|
||||
mod merge;
|
||||
mod oklab;
|
||||
mod palette;
|
||||
mod quantize;
|
||||
|
||||
pub use merge::MergeAdjacent;
|
||||
pub use palette::FixedPalette;
|
||||
pub use quantize::AutoQuantize;
|
||||
|
||||
use crate::ir::Segmentation;
|
||||
|
||||
/// A color fitter rewrites the paints of a segmentation in place.
|
||||
pub trait ColorFitter {
|
||||
fn fit(&self, seg: &mut Segmentation);
|
||||
}
|
||||
|
||||
/// No-op fitter: paints keep the frontend's mean cluster colors.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Identity;
|
||||
|
||||
impl ColorFitter for Identity {
|
||||
fn fit(&self, _seg: &mut Segmentation) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ir::{Layer, Paint, RegionMask};
|
||||
use visioncortex::{BinaryImage, Color, PointI32};
|
||||
|
||||
fn layer(color: Color) -> Layer {
|
||||
let mut image = BinaryImage::new_w_h(1, 1);
|
||||
image.set_pixel(0, 0, true);
|
||||
Layer {
|
||||
paint: Paint::Solid(color),
|
||||
mask: RegionMask::new(image, PointI32 { x: 0, y: 0 }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_palette_snaps_to_nearest_oklab() {
|
||||
let mut seg = Segmentation::new(1, 1);
|
||||
seg.layers.push(layer(Color::new(250, 10, 10))); // near red
|
||||
seg.layers.push(layer(Color::new(10, 10, 250))); // near blue
|
||||
|
||||
let palette = FixedPalette::new(vec![Color::new(255, 0, 0), Color::new(0, 0, 255)]);
|
||||
palette.fit(&mut seg);
|
||||
|
||||
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(255, 0, 0)));
|
||||
assert_eq!(seg.layers[1].paint, Paint::Solid(Color::new(0, 0, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_adjacent_unions_same_paint_runs() {
|
||||
let mut seg = Segmentation::new(2, 1);
|
||||
seg.layers.push(layer(Color::new(0, 0, 0)));
|
||||
seg.layers.push(layer(Color::new(0, 0, 0)));
|
||||
seg.layers.push(layer(Color::new(255, 255, 255)));
|
||||
|
||||
MergeAdjacent.fit(&mut seg);
|
||||
|
||||
assert_eq!(seg.layers.len(), 2);
|
||||
assert_eq!(seg.layers[0].paint, Paint::Solid(Color::new(0, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use crate::ir::{Layer, RegionMask, Segmentation};
|
||||
|
||||
use super::ColorFitter;
|
||||
|
||||
/// Union consecutive layers that share a paint into a single layer. Run this
|
||||
/// after palette snapping (which is what creates runs of identical paints) to
|
||||
/// cut the shape count without changing appearance.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MergeAdjacent;
|
||||
|
||||
/// Collapse one run of same-paint layers and push the result.
|
||||
///
|
||||
/// The whole run is unioned in a single pass — folding pairwise would reallocate
|
||||
/// and rewrite a canvas-sized accumulator once per layer. See
|
||||
/// [`RegionMask::union_all`].
|
||||
fn flush(run: &mut Vec<Layer>, out: &mut Vec<Layer>) {
|
||||
match run.len() {
|
||||
0 => {}
|
||||
1 => out.push(run.pop().expect("run is non-empty")),
|
||||
_ => {
|
||||
let paint = run[0].paint;
|
||||
let masks: Vec<&RegionMask> = run.iter().map(|l| &l.mask).collect();
|
||||
let mask = RegionMask::union_all(&masks);
|
||||
out.push(Layer { paint, mask });
|
||||
run.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorFitter for MergeAdjacent {
|
||||
fn fit(&self, seg: &mut Segmentation) {
|
||||
if seg.layers.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let mut merged: Vec<Layer> = Vec::with_capacity(seg.layers.len());
|
||||
let mut run: Vec<Layer> = Vec::new();
|
||||
for layer in seg.layers.drain(..) {
|
||||
if run.first().is_some_and(|first| first.paint != layer.paint) {
|
||||
flush(&mut run, &mut merged);
|
||||
}
|
||||
run.push(layer);
|
||||
}
|
||||
flush(&mut run, &mut merged);
|
||||
seg.layers = merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Minimal sRGB → OKLab conversion for perceptual color distance.
|
||||
//!
|
||||
//! OKLab (Björn Ottosson, 2020) gives a Euclidean space where distance
|
||||
//! approximates perceived color difference far better than raw RGB.
|
||||
|
||||
use visioncortex::Color;
|
||||
|
||||
/// A color in the OKLab space.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Oklab {
|
||||
pub l: f64,
|
||||
pub a: f64,
|
||||
pub b: f64,
|
||||
}
|
||||
|
||||
fn srgb_to_linear(c: u8) -> f64 {
|
||||
let c = c as f64 / 255.0;
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
impl Oklab {
|
||||
pub fn from_color(color: &Color) -> Self {
|
||||
let r = srgb_to_linear(color.r);
|
||||
let g = srgb_to_linear(color.g);
|
||||
let b = srgb_to_linear(color.b);
|
||||
|
||||
let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b;
|
||||
let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b;
|
||||
let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b;
|
||||
|
||||
let l_ = l.cbrt();
|
||||
let m_ = m.cbrt();
|
||||
let s_ = s.cbrt();
|
||||
|
||||
Oklab {
|
||||
l: 0.210_454_255_3 * l_ + 0.793_617_785_0 * m_ - 0.004_072_046_8 * s_,
|
||||
a: 1.977_998_495_1 * l_ - 2.428_592_205_0 * m_ + 0.450_593_709_9 * s_,
|
||||
b: 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766_0 * s_,
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared Euclidean distance (monotonic with distance; avoids the sqrt).
|
||||
pub fn distance_squared(&self, other: &Oklab) -> f64 {
|
||||
let dl = self.l - other.l;
|
||||
let da = self.a - other.a;
|
||||
let db = self.b - other.b;
|
||||
dl * dl + da * da + db * db
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use visioncortex::Color;
|
||||
|
||||
use crate::ir::{Paint, Segmentation};
|
||||
|
||||
use super::oklab::Oklab;
|
||||
use super::ColorFitter;
|
||||
|
||||
/// Snap every layer paint to the nearest color in a fixed palette, measured in
|
||||
/// OKLab. An empty palette leaves paints untouched.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FixedPalette {
|
||||
pub colors: Vec<Color>,
|
||||
}
|
||||
|
||||
impl FixedPalette {
|
||||
pub fn new(colors: Vec<Color>) -> Self {
|
||||
Self { colors }
|
||||
}
|
||||
|
||||
/// The palette entry closest to `color` in OKLab.
|
||||
fn nearest(&self, color: &Color, lab: &[Oklab]) -> Color {
|
||||
let target = Oklab::from_color(color);
|
||||
let mut best = self.colors[0];
|
||||
let mut best_dist = f64::INFINITY;
|
||||
for (i, entry) in self.colors.iter().enumerate() {
|
||||
let dist = target.distance_squared(&lab[i]);
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best = *entry;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorFitter for FixedPalette {
|
||||
fn fit(&self, seg: &mut Segmentation) {
|
||||
if self.colors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lab: Vec<Oklab> = self.colors.iter().map(Oklab::from_color).collect();
|
||||
for layer in &mut seg.layers {
|
||||
let snapped = self.nearest(&layer.paint.color(), &lab);
|
||||
layer.paint = Paint::Solid(snapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use visioncortex::Color;
|
||||
|
||||
use crate::ir::{Paint, Segmentation};
|
||||
|
||||
use super::oklab::Oklab;
|
||||
use super::ColorFitter;
|
||||
|
||||
/// Reduce the layer palette to at most `max_colors` representative colors via
|
||||
/// area-weighted median cut, then snap each layer to the nearest representative
|
||||
/// (in OKLab).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoQuantize {
|
||||
pub max_colors: usize,
|
||||
}
|
||||
|
||||
impl Default for AutoQuantize {
|
||||
fn default() -> Self {
|
||||
Self { max_colors: 16 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Sample {
|
||||
color: Color,
|
||||
weight: u64,
|
||||
}
|
||||
|
||||
struct Bucket {
|
||||
samples: Vec<Sample>,
|
||||
}
|
||||
|
||||
impl Bucket {
|
||||
/// Extent (max - min) of the given channel across the bucket.
|
||||
fn channel_range(&self, channel: usize) -> u8 {
|
||||
let mut lo = u8::MAX;
|
||||
let mut hi = u8::MIN;
|
||||
for s in &self.samples {
|
||||
let v = s.color.rgb_u8()[channel];
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
hi.saturating_sub(lo)
|
||||
}
|
||||
|
||||
fn widest_channel(&self) -> usize {
|
||||
let mut best = 0;
|
||||
let mut best_range = 0u8;
|
||||
for c in 0..3 {
|
||||
let r = self.channel_range(c);
|
||||
if r > best_range {
|
||||
best_range = r;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn total_weight(&self) -> u64 {
|
||||
self.samples.iter().map(|s| s.weight).sum()
|
||||
}
|
||||
|
||||
/// Weighted-average representative color.
|
||||
fn representative(&self) -> Color {
|
||||
let mut r = 0u64;
|
||||
let mut g = 0u64;
|
||||
let mut b = 0u64;
|
||||
let mut w = 0u64;
|
||||
for s in &self.samples {
|
||||
let rgb = s.color.rgb_u8();
|
||||
r += rgb[0] as u64 * s.weight;
|
||||
g += rgb[1] as u64 * s.weight;
|
||||
b += rgb[2] as u64 * s.weight;
|
||||
w += s.weight;
|
||||
}
|
||||
if w == 0 {
|
||||
return Color::new(0, 0, 0);
|
||||
}
|
||||
Color::new((r / w) as u8, (g / w) as u8, (b / w) as u8)
|
||||
}
|
||||
|
||||
/// Split at the weighted median of the widest channel.
|
||||
fn split(mut self) -> (Bucket, Bucket) {
|
||||
let channel = self.widest_channel();
|
||||
self.samples
|
||||
.sort_by_key(|s| s.color.rgb_u8()[channel]);
|
||||
let half = self.total_weight() / 2;
|
||||
let mut acc = 0u64;
|
||||
let mut cut = 1;
|
||||
for (i, s) in self.samples.iter().enumerate() {
|
||||
acc += s.weight;
|
||||
if acc >= half {
|
||||
cut = (i + 1).clamp(1, self.samples.len().saturating_sub(1).max(1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let right = self.samples.split_off(cut);
|
||||
(Bucket { samples: self.samples }, Bucket { samples: right })
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorFitter for AutoQuantize {
|
||||
fn fit(&self, seg: &mut Segmentation) {
|
||||
if self.max_colors == 0 || seg.layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let samples: Vec<Sample> = seg
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| Sample {
|
||||
color: l.paint.color(),
|
||||
weight: l.mask.area() as u64 + 1,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut buckets = vec![Bucket { samples }];
|
||||
while buckets.len() < self.max_colors {
|
||||
// Split the bucket with the widest single-channel range.
|
||||
let target = buckets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| b.samples.len() > 1)
|
||||
.max_by_key(|(_, b)| b.channel_range(b.widest_channel()));
|
||||
let Some((idx, _)) = target else { break };
|
||||
let bucket = buckets.swap_remove(idx);
|
||||
let (a, b) = bucket.split();
|
||||
buckets.push(a);
|
||||
buckets.push(b);
|
||||
}
|
||||
|
||||
let palette: Vec<Color> = buckets.iter().map(Bucket::representative).collect();
|
||||
let lab: Vec<Oklab> = palette.iter().map(Oklab::from_color).collect();
|
||||
|
||||
for layer in &mut seg.layers {
|
||||
let target = Oklab::from_color(&layer.paint.color());
|
||||
let mut best = palette[0];
|
||||
let mut best_dist = f64::INFINITY;
|
||||
for (i, entry) in palette.iter().enumerate() {
|
||||
let d = target.distance_squared(&lab[i]);
|
||||
if d < best_dist {
|
||||
best_dist = d;
|
||||
best = *entry;
|
||||
}
|
||||
}
|
||||
layer.paint = Paint::Solid(best);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
|
||||
//!
|
||||
//! * **Stacked** — each layer is traced independently into closed outlines and
|
||||
//! stacked in paint order (painter's algorithm).
|
||||
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
|
||||
//! geometry (see [`crate::mosaic`]).
|
||||
//!
|
||||
//! Both compositors run the pipeline's [`CurvePass`]es over every fitted
|
||||
//! contour before assembling paths — geometry passes have to happen here, on
|
||||
//! the fitted geometry, so that in mosaic mode each shared boundary segment
|
||||
//! is transformed exactly once for both of its faces.
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::fitter::CurveFitter;
|
||||
use crate::ir::{MultiPath, RegionMask, Segmentation, Shape, VectorDoc};
|
||||
use crate::mosaic::{compose_mosaic, SegmentFitter};
|
||||
use crate::progress::{Ctx, Phase};
|
||||
use crate::simplify::CurvePass;
|
||||
|
||||
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
|
||||
pub enum Compositing {
|
||||
/// Independent per-region closed outlines, stacked bottom-to-top.
|
||||
Stacked(Box<dyn CurveFitter>),
|
||||
/// Seam-free gapless tessellation via a shared boundary graph.
|
||||
Mosaic {
|
||||
fitter: Box<dyn SegmentFitter>,
|
||||
/// Merge flattened neighbours whose colors are within this diff —
|
||||
/// rejoins regions the stacked gradient layering had split. Usually
|
||||
/// the clustering gradient step; `0` still merges identical-color
|
||||
/// neighbours, negative disables merging entirely.
|
||||
merge_diff: i32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Compositing {
|
||||
/// Run the selected compositor over a segmentation, applying `passes` to
|
||||
/// every fitted contour before paths are assembled.
|
||||
pub fn compose(&self, seg: &Segmentation, passes: &[Box<dyn CurvePass>]) -> VectorDoc {
|
||||
match self {
|
||||
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref(), passes),
|
||||
Compositing::Mosaic { fitter, merge_diff } => {
|
||||
compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress- and cancellation-aware compositing.
|
||||
///
|
||||
/// Stacked mode reports per-layer progress and can be cancelled between
|
||||
/// layers. Mosaic builds its boundary graph in one pass, so it reports
|
||||
/// coarsely (start/end) and is cancellable only at the boundaries — the
|
||||
/// dominant cost is upstream in clustering, which cancels finely.
|
||||
pub fn compose_with(
|
||||
&self,
|
||||
seg: &Segmentation,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
ctx: &mut Ctx,
|
||||
) -> Result<VectorDoc, Error> {
|
||||
match self {
|
||||
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), passes, ctx),
|
||||
Compositing::Mosaic { fitter, merge_diff } => {
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Compose, 0.0);
|
||||
let doc = compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes);
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Compose, 1.0);
|
||||
Ok(doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit one region's outlines and run the curve passes over each contour.
|
||||
/// Stacked contours are closed rings, so the ring form of each pass applies.
|
||||
fn fit_region(
|
||||
fitter: &dyn CurveFitter,
|
||||
mask: &RegionMask,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
) -> MultiPath {
|
||||
let mut path = MultiPath::new();
|
||||
for mut geom in fitter.fit_region(mask) {
|
||||
for pass in passes {
|
||||
geom = pass.ring(geom);
|
||||
}
|
||||
path.push(geom.into_closed_subpath());
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Progress-aware [`compose_stacked`]: reports after each layer and checks for
|
||||
/// cancellation between them.
|
||||
fn compose_stacked_with(
|
||||
seg: &Segmentation,
|
||||
fitter: &dyn CurveFitter,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
ctx: &mut Ctx,
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let mut doc = VectorDoc::new(seg.width, seg.height);
|
||||
let total = seg.layers.len().max(1);
|
||||
for (i, layer) in seg.layers.iter().enumerate() {
|
||||
ctx.check()?;
|
||||
let path = fit_region(fitter, &layer.mask, passes);
|
||||
if !path.is_empty() {
|
||||
doc.shapes.push(Shape {
|
||||
paint: layer.paint,
|
||||
path,
|
||||
});
|
||||
}
|
||||
ctx.report(Phase::Compose, (i + 1) as f32 / total as f32);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Trace every layer's closed outline and stack the shapes in paint order.
|
||||
pub fn compose_stacked(
|
||||
seg: &Segmentation,
|
||||
fitter: &dyn CurveFitter,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
) -> VectorDoc {
|
||||
let mut doc = VectorDoc::new(seg.width, seg.height);
|
||||
for layer in &seg.layers {
|
||||
let path = fit_region(fitter, &layer.mask, passes);
|
||||
if !path.is_empty() {
|
||||
doc.shapes.push(Shape {
|
||||
paint: layer.paint,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Hierarchical {
|
||||
Stacked,
|
||||
/// True mosaic cutout — not yet implemented (separate milestone).
|
||||
Cutout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FitMode {
|
||||
Pixel,
|
||||
Polygon,
|
||||
Spline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Preset {
|
||||
Bw,
|
||||
Poster,
|
||||
Photo,
|
||||
}
|
||||
|
||||
/// 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. Kept in sync with [`Config::frontend`] in one place.
|
||||
#[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: u8,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pub hierarchical: Hierarchical,
|
||||
/// Speckle filter given as a side length; the area threshold is its square.
|
||||
pub filter_speckle: usize,
|
||||
/// Significant bits per RGB channel (1..=8).
|
||||
pub color_precision: i32,
|
||||
/// Color difference between gradient layers.
|
||||
pub layer_difference: i32,
|
||||
pub mode: FitMode,
|
||||
/// Corner threshold in degrees.
|
||||
pub corner_threshold: i32,
|
||||
/// Segment length threshold in pixels.
|
||||
pub length_threshold: f64,
|
||||
pub max_iterations: usize,
|
||||
/// Splice threshold in degrees.
|
||||
pub splice_threshold: i32,
|
||||
/// Curve simplification tolerance in px (paper.js-style `simplify`):
|
||||
/// re-fit smooth runs of fitted cubics with the fewest curves that stay
|
||||
/// within this distance, keeping corners in place. `None` = off. Only
|
||||
/// affects spline mode; pixel/polygon polylines pass through untouched.
|
||||
pub simplify: Option<f64>,
|
||||
/// Coordinate precision (decimal places) for output.
|
||||
pub path_precision: Option<u32>,
|
||||
/// Fixed palette (empty = none). Takes priority over `max_colors`.
|
||||
pub palette: Vec<Color>,
|
||||
/// Auto-quantize target color count (None = off).
|
||||
pub max_colors: Option<usize>,
|
||||
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
|
||||
pub optimize: u8,
|
||||
/// Binary-mode fixed threshold (0..=255): foreground when grayscale
|
||||
/// intensity is below this. Ignored when `binary_adaptive` is set.
|
||||
pub binary_threshold: u8,
|
||||
/// Binary mode: use Bradley–Roth adaptive thresholding instead of the fixed
|
||||
/// cutoff (better for uneven lighting).
|
||||
pub binary_adaptive: bool,
|
||||
/// Adaptive window side length in pixels; 0 = auto (~1/8 of the shorter side).
|
||||
pub binary_adaptive_window: u32,
|
||||
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
|
||||
pub binary_adaptive_t: f64,
|
||||
/// Watershed clustering: where to cut the hierarchy (0..=255). Higher
|
||||
/// keeps more regions; 0 collapses the image to a single region.
|
||||
pub watershed_detail: u8,
|
||||
}
|
||||
|
||||
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 {
|
||||
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 => ((255 - self.watershed_detail as i32) / 8).max(2),
|
||||
_ => self.layer_difference,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Pipeline {
|
||||
frontend: self.frontend(),
|
||||
color_fitters: self.color_fitters(),
|
||||
compositing,
|
||||
curve_passes: self.curve_passes(),
|
||||
optimizers: self.optimizers(),
|
||||
writer: self.writer(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn deg2rad(deg: i32) -> f64 {
|
||||
deg as f64 / 180.0 * std::f64::consts::PI
|
||||
}
|
||||
|
||||
impl FromStr for Clustering {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
|
||||
"binary" | "bw" | "BW" => Ok(Self::Binary),
|
||||
"watershed" => Ok(Self::Watershed),
|
||||
_ => Err(format!("unknown clustering {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Hierarchical {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"stacked" => Ok(Self::Stacked),
|
||||
"cutout" => Ok(Self::Cutout),
|
||||
_ => Err(format!("unknown hierarchical mode {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FitMode {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"pixel" | "none" => Ok(Self::Pixel),
|
||||
"polygon" => Ok(Self::Polygon),
|
||||
"spline" => Ok(Self::Spline),
|
||||
_ => Err(format!("unknown fit mode {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Preset {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"bw" => Ok(Self::Bw),
|
||||
"poster" => Ok(Self::Poster),
|
||||
"photo" => Ok(Self::Photo),
|
||||
_ => Err(format!("unknown preset {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Errors produced by the framework stages and the pipeline driver.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// The input image had zero width or height.
|
||||
EmptyImage,
|
||||
/// Transparency keying was requested but no unused key color could be found.
|
||||
NoKeyColor,
|
||||
/// A requested feature is recognized but not yet implemented.
|
||||
Unsupported(String),
|
||||
/// The run was aborted via a [`crate::progress::CancelToken`].
|
||||
Cancelled,
|
||||
/// Any other failure, carrying a human-readable message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::EmptyImage => write!(f, "input image is empty"),
|
||||
Error::NoKeyColor => {
|
||||
write!(f, "unable to find an unused color in image to use as key")
|
||||
}
|
||||
Error::Unsupported(what) => write!(f, "unsupported: {what}"),
|
||||
Error::Cancelled => write!(f, "conversion cancelled"),
|
||||
Error::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<String> for Error {
|
||||
fn from(msg: String) -> Self {
|
||||
Error::Other(msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Error {
|
||||
fn from(msg: &str) -> Self {
|
||||
Error::Other(msg.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Curve fitters: turn a region's pixel mask into vector outlines.
|
||||
//!
|
||||
//! The three built-ins wrap the corresponding visioncortex tracing modes and
|
||||
//! emit [`FittedGeom`] contours in absolute (document) coordinates:
|
||||
//!
|
||||
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
|
||||
//! * [`PolygonFitter`] — staircase-symmetric Douglas–Peucker polygon.
|
||||
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
|
||||
//!
|
||||
//! All three trace *closed* region outlines (outer ring plus holes). The
|
||||
//! mosaic compositor fits open boundary segments instead; see
|
||||
//! [`crate::mosaic::SegmentFitter`].
|
||||
|
||||
use visioncortex::clusters::Cluster as BinaryCluster;
|
||||
use visioncortex::{
|
||||
CompoundPath, CompoundPathElement, PathSimplifyMode, PointF64, PointI32,
|
||||
};
|
||||
|
||||
use crate::ir::{PathCmd, RegionMask, SubPath};
|
||||
|
||||
/// Fitted geometry for one contour — the common currency between the curve
|
||||
/// fitters, the [`CurvePass`](crate::simplify::CurvePass) stage, and
|
||||
/// composition. The stacked fitters produce one per closed outline; the
|
||||
/// mosaic fitters produce one per shared boundary segment.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FittedGeom {
|
||||
/// Polyline (pixel / polygon backends).
|
||||
Polyline(Vec<PointF64>),
|
||||
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
|
||||
Beziers(Vec<[PointF64; 4]>),
|
||||
}
|
||||
|
||||
impl FittedGeom {
|
||||
/// Convert one closed contour into a `MoveTo … Close` subpath.
|
||||
pub fn into_closed_subpath(self) -> SubPath {
|
||||
match self {
|
||||
FittedGeom::Polyline(points) => polyline_subpath(&points),
|
||||
FittedGeom::Beziers(chain) => beziers_subpath(&chain),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fitting parameters shared by the built-in fitters. Only the spline fitter
|
||||
/// consults the smoothing/splice fields.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FitParams {
|
||||
/// Minimum momentary angle (radians) to be considered a corner.
|
||||
pub corner_threshold: f64,
|
||||
/// Subdivide until all segments are shorter than this length (px).
|
||||
pub length_threshold: f64,
|
||||
/// Maximum smoothing iterations.
|
||||
pub max_iterations: usize,
|
||||
/// Minimum angle displacement (radians) to splice a spline.
|
||||
pub splice_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for FitParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
corner_threshold: std::f64::consts::PI / 3.0, // 60°
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: std::f64::consts::PI / 4.0, // 45°
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve fitter traces a region mask into closed vector outlines, one
|
||||
/// [`FittedGeom`] per contour (outer ring or hole).
|
||||
pub trait CurveFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom>;
|
||||
}
|
||||
|
||||
/// Exact lattice polyline; every pixel-boundary step is preserved.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PixelFitter;
|
||||
|
||||
impl CurveFitter for PixelFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
|
||||
trace_region(mask, PathSimplifyMode::None, FitParams::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Douglas–Peucker polygon with staircase removal.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PolygonFitter;
|
||||
|
||||
impl CurveFitter for PolygonFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
|
||||
trace_region(mask, PathSimplifyMode::Polygon, FitParams::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoothed spline (cubic Bézier) fitter.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SplineFitter {
|
||||
pub params: FitParams,
|
||||
}
|
||||
|
||||
impl SplineFitter {
|
||||
pub fn new(params: FitParams) -> Self {
|
||||
Self { params }
|
||||
}
|
||||
}
|
||||
|
||||
impl CurveFitter for SplineFitter {
|
||||
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
|
||||
trace_region(mask, PathSimplifyMode::Spline, self.params)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace every connected component of a masked region and collect the
|
||||
/// resulting outlines, one [`FittedGeom`] per contour, in absolute coordinates.
|
||||
///
|
||||
/// This mirrors visioncortex's `Cluster::to_compound_path`: the mask (with
|
||||
/// holes already punched) is split into connected sub-clusters, each traced
|
||||
/// independently, then offset into document space.
|
||||
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> Vec<FittedGeom> {
|
||||
let mut geoms = Vec::new();
|
||||
for sub in mask.image.to_clusters(false).iter() {
|
||||
let offset = PointI32 {
|
||||
x: mask.offset.x + sub.rect.left,
|
||||
y: mask.offset.y + sub.rect.top,
|
||||
};
|
||||
let compound = BinaryCluster::image_to_compound_path(
|
||||
&offset,
|
||||
&sub.to_binary_image(),
|
||||
mode,
|
||||
params.corner_threshold,
|
||||
params.length_threshold,
|
||||
params.max_iterations,
|
||||
params.splice_threshold,
|
||||
);
|
||||
append_compound(&mut geoms, &compound);
|
||||
}
|
||||
geoms
|
||||
}
|
||||
|
||||
fn append_compound(geoms: &mut Vec<FittedGeom>, compound: &CompoundPath) {
|
||||
for element in compound.iter() {
|
||||
match element {
|
||||
CompoundPathElement::PathI32(p) => {
|
||||
let pts: Vec<PointF64> = p
|
||||
.path
|
||||
.iter()
|
||||
.map(|q| PointF64 {
|
||||
x: q.x as f64,
|
||||
y: q.y as f64,
|
||||
})
|
||||
.collect();
|
||||
geoms.push(FittedGeom::Polyline(pts));
|
||||
}
|
||||
CompoundPathElement::PathF64(p) => {
|
||||
geoms.push(FittedGeom::Polyline(p.path.clone()));
|
||||
}
|
||||
CompoundPathElement::Spline(s) => {
|
||||
geoms.push(FittedGeom::Beziers(spline_chain(&s.points)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spline of `1 + 3n` points becomes a chain of `n` cubics sharing endpoints.
|
||||
fn spline_chain(points: &[PointF64]) -> Vec<[PointF64; 4]> {
|
||||
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut chain = Vec::with_capacity((points.len() - 1) / 3);
|
||||
let mut start = points[0];
|
||||
let mut i = 1;
|
||||
while i + 2 < points.len() {
|
||||
chain.push([start, points[i], points[i + 1], points[i + 2]]);
|
||||
start = points[i + 2];
|
||||
i += 3;
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// A closed polyline whose last point repeats the first becomes
|
||||
/// `MoveTo · LineTo* · Close`.
|
||||
fn polyline_subpath(points: &[PointF64]) -> SubPath {
|
||||
let mut sub = SubPath::new();
|
||||
if points.len() < 2 {
|
||||
return sub;
|
||||
}
|
||||
// The tracer emits closed paths whose final point duplicates the first.
|
||||
let closed = points.first() == points.last();
|
||||
let body_end = if closed { points.len() - 1 } else { points.len() };
|
||||
sub.commands.push(PathCmd::MoveTo(points[0]));
|
||||
for p in &points[1..body_end] {
|
||||
sub.commands.push(PathCmd::LineTo(*p));
|
||||
}
|
||||
sub.commands.push(PathCmd::Close);
|
||||
sub
|
||||
}
|
||||
|
||||
/// A cubic chain becomes `MoveTo · CubicTo* · Close`.
|
||||
fn beziers_subpath(chain: &[[PointF64; 4]]) -> SubPath {
|
||||
let mut sub = SubPath::new();
|
||||
if chain.is_empty() {
|
||||
return sub;
|
||||
}
|
||||
sub.commands.push(PathCmd::MoveTo(chain[0][0]));
|
||||
for c in chain {
|
||||
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
|
||||
}
|
||||
sub.commands.push(PathCmd::Close);
|
||||
sub
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 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.
|
||||
//! * [`WatershedFrontend`] — hierarchical watershed on the pixel graph.
|
||||
//!
|
||||
//! Third parties can implement [`Frontend`] to feed external label maps or ML
|
||||
//! segmentation into the pipeline.
|
||||
|
||||
mod binary;
|
||||
mod color_cluster;
|
||||
mod keying;
|
||||
mod watershed;
|
||||
|
||||
pub use binary::{BinaryFrontend, Threshold};
|
||||
pub use color_cluster::ColorClusterFrontend;
|
||||
pub use watershed::{WatershedFrontend, WatershedHierarchy};
|
||||
|
||||
use visioncortex::ColorImage;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::Segmentation;
|
||||
use crate::progress::Ctx;
|
||||
|
||||
/// A frontend segments a raster image into ordered paint layers.
|
||||
pub trait Frontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
|
||||
|
||||
/// Progress- and cancellation-aware segmentation.
|
||||
///
|
||||
/// The default runs [`segment`](Frontend::segment) and then honors
|
||||
/// cancellation (coarse: one report at completion, cancel observed after
|
||||
/// the whole segmentation). Frontends that can step incrementally — like
|
||||
/// [`ColorClusterFrontend`] — override this to report fine-grained
|
||||
/// progress and observe cancellation between batches.
|
||||
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
|
||||
let seg = self.segment(img)?;
|
||||
ctx.check()?;
|
||||
ctx.report(crate::progress::Phase::Segment, 1.0);
|
||||
Ok(seg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use visioncortex::{BinaryImage, Color, ColorImage, PointI32, SummedAreaTable};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
||||
|
||||
use super::Frontend;
|
||||
|
||||
/// Grayscale intensity (0..=255) used by every thresholding method. Matches the
|
||||
/// metric `SummedAreaTable::from_color_image` sums, so fixed and adaptive
|
||||
/// thresholds agree on what "dark" means.
|
||||
#[inline]
|
||||
fn intensity(c: Color) -> u32 {
|
||||
(c.r as u32 + c.g as u32 + c.b as u32) / 3
|
||||
}
|
||||
|
||||
/// How the binary frontend separates foreground (dark) from background pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Threshold {
|
||||
/// Global cutoff: a pixel is foreground when its intensity is below this
|
||||
/// value (0..=255). Fast and predictable; best for clean, evenly-lit input.
|
||||
Fixed(u8),
|
||||
/// Bradley–Roth adaptive threshold: a pixel is foreground when its
|
||||
/// intensity is more than `t` percent below the mean of the surrounding
|
||||
/// `window`×`window` block. Handles uneven lighting and shadows that defeat
|
||||
/// a single global cutoff. Computed in one pass with a summed-area table,
|
||||
/// so it stays O(pixels) regardless of window size.
|
||||
Adaptive {
|
||||
/// Window side length in pixels; `0` auto-derives ~1/8 of the shorter
|
||||
/// image dimension (the value suggested by the paper).
|
||||
window: u32,
|
||||
/// Sensitivity, as a percentage below the local mean (paper default 15).
|
||||
t: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Threshold {
|
||||
/// Bradley–Roth adaptive thresholding with the paper's defaults
|
||||
/// (auto window, `t = 15`).
|
||||
pub const fn adaptive() -> Self {
|
||||
Threshold::Adaptive {
|
||||
window: 0,
|
||||
t: 15.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Threshold {
|
||||
fn default() -> Self {
|
||||
Threshold::Fixed(128)
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary (black/white) frontend: threshold the image then cluster the
|
||||
/// foreground. Every region is painted black.
|
||||
///
|
||||
/// 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.
|
||||
pub threshold: Threshold,
|
||||
/// Whether to connect clusters diagonally.
|
||||
pub diagonal: bool,
|
||||
/// Discard clusters smaller than this many pixels (0 = keep all).
|
||||
pub min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for BinaryFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threshold: Threshold::default(),
|
||||
diagonal: false,
|
||||
min_area: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BinaryFrontend {
|
||||
/// Binarize `img` into a foreground mask according to [`Self::threshold`].
|
||||
fn binarize(&self, img: &ColorImage) -> BinaryImage {
|
||||
match self.threshold {
|
||||
Threshold::Fixed(value) => {
|
||||
let value = value as u32;
|
||||
img.to_binary_image(|c| intensity(c) < value)
|
||||
}
|
||||
Threshold::Adaptive { window, t } => adaptive_bradley_roth(img, window, t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bradley–Roth adaptive thresholding via a summed-area table.
|
||||
///
|
||||
/// For each pixel, compare its intensity to the mean of a surrounding window:
|
||||
/// it is foreground when `value <= mean * (1 - t/100)`, i.e. more than `t`
|
||||
/// percent darker than its neighborhood.
|
||||
fn adaptive_bradley_roth(img: &ColorImage, window: u32, t: f64) -> BinaryImage {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let sat = SummedAreaTable::from_color_image(img);
|
||||
|
||||
// Window: 0 => auto (~1/8 of the shorter side, per the paper), min 1.
|
||||
let side = if window == 0 {
|
||||
(w.min(h) / 8).max(1)
|
||||
} else {
|
||||
window as usize
|
||||
};
|
||||
let half = side / 2;
|
||||
let factor = 1.0 - t.clamp(0.0, 100.0) / 100.0;
|
||||
|
||||
let mut out = BinaryImage::new_w_h(w, h);
|
||||
for y in 0..h {
|
||||
let y0 = y.saturating_sub(half);
|
||||
let y1 = (y + half).min(h - 1);
|
||||
for x in 0..w {
|
||||
let x0 = x.saturating_sub(half);
|
||||
let x1 = (x + half).min(w - 1);
|
||||
|
||||
let count = ((x1 - x0 + 1) * (y1 - y0 + 1)) as f64;
|
||||
let sum = sat.get_region_sum_x_y_w_h(x0, y0, x1 - x0 + 1, y1 - y0 + 1) as f64;
|
||||
let value = intensity(img.get_pixel(x, y)) as f64;
|
||||
|
||||
// value <= mean * factor ⇔ value * count <= sum * factor
|
||||
out.set_pixel(x, y, value * count <= sum * factor);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Frontend for BinaryFrontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
let binary = self.binarize(img);
|
||||
let clusters = binary.to_clusters(self.diagonal);
|
||||
|
||||
let mut seg = Segmentation::new(width as u32, height as u32);
|
||||
let black = Color::new(0, 0, 0);
|
||||
for i in 0..clusters.len() {
|
||||
let cluster = clusters.get_cluster(i);
|
||||
if cluster.size() < self.min_area {
|
||||
continue;
|
||||
}
|
||||
let mask = RegionMask::new(
|
||||
cluster.to_binary_image(),
|
||||
PointI32 {
|
||||
x: cluster.rect.left,
|
||||
y: cluster.rect.top,
|
||||
},
|
||||
);
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(black),
|
||||
mask,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(seg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use visioncortex::color_clusters::{
|
||||
Clusters, KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX,
|
||||
};
|
||||
use visioncortex::{Color, ColorImage, PointI32};
|
||||
|
||||
// (Runner is constructed inline in each entry point so its generic closure
|
||||
// types never appear in a return signature.)
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
|
||||
use crate::progress::{Ctx, Phase};
|
||||
|
||||
use super::keying::{apply_key, find_unused_color, should_key_image};
|
||||
use super::Frontend;
|
||||
|
||||
/// Hierarchical color-clustering frontend — the classic VTracer color path.
|
||||
///
|
||||
/// Speckle removal happens *inside* clustering, via `good_min_area`: it is the
|
||||
/// clusterer's `deepen` gate (visioncortex `patch_good`), so it does far more
|
||||
/// than drop small regions — it decides whether a small/thin patch is absorbed
|
||||
/// into its neighbor (its color averaged in) or kept as its own layer. Forcing
|
||||
/// it to 0 disables the thread-like rejection and changes the whole hierarchy,
|
||||
/// so speckle must be a clustering parameter, not a downstream filter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColorClusterFrontend {
|
||||
/// Bits of color precision dropped when comparing pixels (0 = full 8-bit).
|
||||
pub color_precision_loss: i32,
|
||||
/// Color difference between hierarchical gradient layers.
|
||||
pub layer_difference: i32,
|
||||
/// Minimum area (px) for a patch to be a `deepen` candidate during
|
||||
/// clustering; non-zero also enables visioncortex's thread-like rejection.
|
||||
/// Below it, patches are absorbed into their nearest-color neighbor.
|
||||
pub good_min_area: usize,
|
||||
}
|
||||
|
||||
impl Default for ColorClusterFrontend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color_precision_loss: 2,
|
||||
layer_difference: 16,
|
||||
good_min_area: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorClusterFrontend {
|
||||
/// Apply transparency keying (if warranted) and build the clustering
|
||||
/// inputs: the keyed image, the `RunnerConfig`, and the dimensions. The
|
||||
/// caller constructs `Runner::new(config, image)` inline so the runner's
|
||||
/// generic closure types never surface in a return signature.
|
||||
fn prepare(&self, img: &ColorImage) -> Result<(ColorImage, RunnerConfig, usize, usize), Error> {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
let mut img = img.clone();
|
||||
|
||||
// Transparency keying (stacked mode discards the keyed background).
|
||||
let key_color = if should_key_image(&img) {
|
||||
let key = find_unused_color(&img)?;
|
||||
apply_key(&mut img, key);
|
||||
key
|
||||
} else {
|
||||
// All-zero is the sentinel understood by visioncortex as "no keying".
|
||||
Color::default()
|
||||
};
|
||||
|
||||
let config = RunnerConfig {
|
||||
diagonal: self.layer_difference == 0,
|
||||
hierarchical: HIERARCHICAL_MAX,
|
||||
batch_size: 25600,
|
||||
good_min_area: self.good_min_area,
|
||||
good_max_area: width * height,
|
||||
is_same_color_a: self.color_precision_loss,
|
||||
is_same_color_b: 1,
|
||||
deepen_diff: self.layer_difference,
|
||||
hollow_neighbours: 1,
|
||||
key_color,
|
||||
keying_action: KeyingAction::Discard,
|
||||
};
|
||||
|
||||
Ok((img, config, width, height))
|
||||
}
|
||||
|
||||
/// Turn finished clusters into the layered [`Segmentation`].
|
||||
fn segmentation_from_clusters(clusters: &Clusters, width: usize, height: usize) -> Segmentation {
|
||||
let view = clusters.view();
|
||||
let mut seg = Segmentation::new(width as u32, height as u32);
|
||||
// `clusters_output` is top-to-bottom; reverse to get bottom-to-top
|
||||
// paint order for the layer stack.
|
||||
for &cluster_index in view.clusters_output.iter().rev() {
|
||||
let cluster = view.get_cluster(cluster_index);
|
||||
// Solid cluster masks (no holes punched): stacked mode relies on
|
||||
// paint-order overdraw for occlusion, matching 0.6.x. Punching
|
||||
// holes here would leave the layer below exposed as hairline seams.
|
||||
// The mosaic flatten is unaffected — a higher layer still wins per
|
||||
// pixel — so a solid parent gives the same partition.
|
||||
let image = cluster.to_image_with_hole(view.width, false);
|
||||
let mask = RegionMask::new(
|
||||
image,
|
||||
PointI32 {
|
||||
x: cluster.rect.left,
|
||||
y: cluster.rect.top,
|
||||
},
|
||||
);
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(cluster.residue_color()),
|
||||
mask,
|
||||
});
|
||||
}
|
||||
seg
|
||||
}
|
||||
}
|
||||
|
||||
impl Frontend for ColorClusterFrontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
let (image, config, width, height) = self.prepare(img)?;
|
||||
let clusters = Runner::new(config, image).run();
|
||||
Ok(Self::segmentation_from_clusters(&clusters, width, height))
|
||||
}
|
||||
|
||||
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
|
||||
let (image, config, width, height) = self.prepare(img)?;
|
||||
|
||||
// Drive clustering incrementally so we can publish progress and observe
|
||||
// cancellation between batches. `run()` is exactly this loop, so the
|
||||
// resulting clusters are identical to the blocking path.
|
||||
let mut builder = Runner::new(config, image).start();
|
||||
ctx.report(Phase::Segment, 0.0);
|
||||
while !builder.tick() {
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Segment, builder.progress() as f32 / 100.0);
|
||||
}
|
||||
ctx.check()?;
|
||||
let clusters = builder.result();
|
||||
ctx.report(Phase::Segment, 1.0);
|
||||
|
||||
Ok(Self::segmentation_from_clusters(&clusters, width, height))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Transparency keying, ported from the 0.6.x `converter.rs`.
|
||||
//!
|
||||
//! When an image has substantial transparency, fully-transparent pixels are
|
||||
//! recolored to an unused "key" color so the clustering runner can treat them
|
||||
//! as a discardable background. The random key search of 0.6.x is replaced by a
|
||||
//! deterministic sweep so results are reproducible and `no_std`/wasm-friendly.
|
||||
|
||||
use visioncortex::{Color, ColorImage};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Fraction of pixels in the sampled rows that must be transparent before the
|
||||
/// whole image is keyed.
|
||||
const KEYING_THRESHOLD: f32 = 0.2;
|
||||
|
||||
/// Whether the image carries enough transparency to warrant keying.
|
||||
pub fn should_key_image(img: &ColorImage) -> bool {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
|
||||
let mut transparent = 0usize;
|
||||
let rows = [
|
||||
0,
|
||||
img.height / 4,
|
||||
img.height / 2,
|
||||
3 * img.height / 4,
|
||||
img.height - 1,
|
||||
];
|
||||
for y in rows {
|
||||
for x in 0..img.width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
transparent += 1;
|
||||
}
|
||||
if transparent >= threshold {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn color_exists(img: &ColorImage, color: Color) -> bool {
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let p = img.get_pixel(x, y);
|
||||
if p.r == color.r && p.g == color.g && p.b == color.b {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find a color not present in the image, to be used as the key. Tries the
|
||||
/// primary/secondary colors first, then does a deterministic sweep of the RGB
|
||||
/// cube. Returns [`Error::NoKeyColor`] only if every probed color is used.
|
||||
pub fn find_unused_color(img: &ColorImage) -> Result<Color, Error> {
|
||||
let specials = [
|
||||
Color::new(255, 0, 0),
|
||||
Color::new(0, 255, 0),
|
||||
Color::new(0, 0, 255),
|
||||
Color::new(255, 255, 0),
|
||||
Color::new(0, 255, 255),
|
||||
Color::new(255, 0, 255),
|
||||
];
|
||||
for &c in specials.iter() {
|
||||
if !color_exists(img, c) {
|
||||
return Ok(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic sweep: step by a value coprime-ish with 256 to spread out.
|
||||
const STEP: u16 = 37;
|
||||
let mut r = 0u16;
|
||||
while r < 256 {
|
||||
let mut g = 0u16;
|
||||
while g < 256 {
|
||||
let mut b = 0u16;
|
||||
while b < 256 {
|
||||
let c = Color::new(r as u8, g as u8, b as u8);
|
||||
if !color_exists(img, c) {
|
||||
return Ok(c);
|
||||
}
|
||||
b += STEP;
|
||||
}
|
||||
g += STEP;
|
||||
}
|
||||
r += STEP;
|
||||
}
|
||||
|
||||
Err(Error::NoKeyColor)
|
||||
}
|
||||
|
||||
/// Recolor every fully-transparent pixel to `key`, in place.
|
||||
pub fn apply_key(img: &mut ColorImage, key: Color) {
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
if img.get_pixel(x, y).a == 0 {
|
||||
img.set_pixel(x, y, &key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
//! 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 (0..=255): where to cut the hierarchy. Each +25.5 roughly
|
||||
/// doubles the region count; 0 collapses the image to a single region.
|
||||
pub detail: u8,
|
||||
/// 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: u8, 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 up to 1024 at 255.
|
||||
let mut uf = Uf::new(n);
|
||||
if m > 0 {
|
||||
let target = (2f64).powf(detail as f64 / 25.5).round() as usize;
|
||||
let target = target.clamp(1, m);
|
||||
let lambda = self.pers[self.order[m - target] as usize];
|
||||
for &k in &self.order {
|
||||
if self.pers[k as usize] > lambda {
|
||||
break;
|
||||
}
|
||||
let (p, q) = self.mst[k as usize];
|
||||
let (rp, rq) = (uf.find(p), uf.find(q));
|
||||
if rp != rq {
|
||||
uf.link(rp, rq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Compact to region ids and region stats --------------------------
|
||||
// One find per pixel; everything after this works on the (small)
|
||||
// region graph so re-cuts stay cheap.
|
||||
let mut pre_of_root = vec![u32::MAX; n];
|
||||
let mut pre = vec![0u32; n];
|
||||
let mut kp = 0usize;
|
||||
for i in 0..n {
|
||||
let r = uf.find(i as u32) as usize;
|
||||
if pre_of_root[r] == u32::MAX {
|
||||
pre_of_root[r] = kp as u32;
|
||||
kp += 1;
|
||||
}
|
||||
pre[i] = pre_of_root[r];
|
||||
}
|
||||
let mut area = vec![0u64; kp];
|
||||
let mut sum = vec![[0u64; 3]; kp];
|
||||
for i in 0..n {
|
||||
let a = pre[i] as usize;
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
area[a] += 1;
|
||||
sum[a][0] += c.r as u64;
|
||||
sum[a][1] += c.g as u64;
|
||||
sum[a][2] += c.b as u64;
|
||||
}
|
||||
|
||||
// --- Boundary snap, then boundary adjacency ---------------------------
|
||||
snap_boundaries(img, w, h, &mut pre, &mut area, &mut sum);
|
||||
let mut pairs: Vec<(u32, u32)> = Vec::new();
|
||||
for i in 0..n {
|
||||
let a = pre[i];
|
||||
if i % w + 1 < w && pre[i + 1] != a {
|
||||
pairs.push((a, pre[i + 1]));
|
||||
}
|
||||
if i / w + 1 < h && pre[i + w] != a {
|
||||
pairs.push((a, pre[i + w]));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Small-basin absorption on the region graph ----------------------
|
||||
let mut uf_r = Uf::new(kp);
|
||||
absorb_small(min_area, &pairs, &mut uf_r, &mut area, &mut sum);
|
||||
|
||||
// --- Final leaf ids in raster order of first appearance --------------
|
||||
let mut leaf_of = vec![u32::MAX; kp];
|
||||
let mut leaf_root: Vec<u32> = Vec::new(); // leaf id -> absorb root
|
||||
let mut ids = vec![0u32; n];
|
||||
for i in 0..n {
|
||||
let r = uf_r.find(pre[i]) as usize;
|
||||
if leaf_of[r] == u32::MAX {
|
||||
leaf_of[r] = leaf_root.len() as u32;
|
||||
leaf_root.push(r as u32);
|
||||
}
|
||||
ids[i] = leaf_of[r];
|
||||
}
|
||||
let k = leaf_root.len();
|
||||
|
||||
let mean = |s: &[u64; 3], a: u64| {
|
||||
Color::new((s[0] / a) as u8, (s[1] / a) as u8, (s[2] / a) as u8)
|
||||
};
|
||||
|
||||
let mut seg = Segmentation::new(w as u32, h as u32);
|
||||
if k == 1 {
|
||||
// Single region: one solid full-canvas layer.
|
||||
let r = leaf_root[0] as usize;
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&sum[r], area[r])),
|
||||
mask: full_canvas(w, h),
|
||||
});
|
||||
return seg;
|
||||
}
|
||||
|
||||
// --- Merge tree above the cut ----------------------------------------
|
||||
// Re-run all merges (ascending persistence) over the final regions:
|
||||
// each one that still joins two components is a kept split. Nodes 0..k
|
||||
// are the final regions; internal nodes are created in ascending
|
||||
// persistence order, so the reverse is a root-first order in which
|
||||
// every ancestor precedes its descendants. Below-cut edges are almost
|
||||
// all no-ops (their endpoints share a region), but not quite: boundary
|
||||
// snapping can leave a region's only adjacency running through a
|
||||
// below-cut edge, and skipping those would leave the tree unconnected.
|
||||
let n_tree = 2 * k - 1;
|
||||
let mut tree_child: Vec<[u32; 2]> = Vec::with_capacity(k - 1);
|
||||
let mut tree_area = vec![0u64; n_tree];
|
||||
let mut tree_sum = vec![[0u64; 3]; n_tree];
|
||||
for (t, &r) in leaf_root.iter().enumerate() {
|
||||
tree_area[t] = area[r as usize];
|
||||
tree_sum[t] = sum[r as usize];
|
||||
}
|
||||
let mut uf2 = Uf::new(k);
|
||||
let mut node_rep: Vec<u32> = (0..k as u32).collect();
|
||||
let mut next = k as u32;
|
||||
for &e in &self.order {
|
||||
let (p, q) = self.mst[e as usize];
|
||||
let (lp, lq) = (ids[p as usize], ids[q as usize]);
|
||||
if lp == lq {
|
||||
continue; // same region — the bulk of the below-cut edges
|
||||
}
|
||||
let (a, b) = (uf2.find(lp), uf2.find(lq));
|
||||
if a == b {
|
||||
continue; // already merged, or rejoined by absorption
|
||||
}
|
||||
let node = next as usize;
|
||||
tree_child.push([node_rep[a as usize], node_rep[b as usize]]);
|
||||
for ch in [node_rep[a as usize], node_rep[b as usize]] {
|
||||
tree_area[node] += tree_area[ch as usize];
|
||||
for c in 0..3 {
|
||||
tree_sum[node][c] += tree_sum[ch as usize][c];
|
||||
}
|
||||
}
|
||||
uf2.link(a, b);
|
||||
node_rep[a as usize] = next;
|
||||
next += 1;
|
||||
}
|
||||
debug_assert_eq!(next as usize, n_tree);
|
||||
|
||||
// Per-leaf pixel lists, for painting ancestor masks.
|
||||
let mut leaf_len = vec![0u32; k];
|
||||
for &id in &ids {
|
||||
leaf_len[id as usize] += 1;
|
||||
}
|
||||
let mut leaf_start = vec![0usize; k + 1];
|
||||
for t in 0..k {
|
||||
leaf_start[t + 1] = leaf_start[t] + leaf_len[t] as usize;
|
||||
}
|
||||
let mut leaf_px = vec![0u32; n];
|
||||
let mut fill = leaf_start.clone();
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
leaf_px[fill[id as usize]] = i as u32;
|
||||
fill[id as usize] += 1;
|
||||
}
|
||||
|
||||
// --- Emit: root, ancestors (budgeted), then the final regions --------
|
||||
let root = n_tree - 1;
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&tree_sum[root], tree_area[root])),
|
||||
mask: full_canvas(w, h),
|
||||
});
|
||||
let mut budget = ANCESTOR_AREA_BUDGET * n;
|
||||
for node in (k..root).rev() {
|
||||
let node_area = tree_area[node] as usize;
|
||||
if node_area > budget {
|
||||
continue;
|
||||
}
|
||||
budget -= node_area;
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&tree_sum[node], tree_area[node])),
|
||||
mask: node_mask(node, k, &tree_child, &leaf_start, &leaf_px, w),
|
||||
});
|
||||
}
|
||||
for t in 0..k {
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(mean(&tree_sum[t], tree_area[t])),
|
||||
mask: node_mask(t, k, &tree_child, &leaf_start, &leaf_px, w),
|
||||
});
|
||||
}
|
||||
seg
|
||||
}
|
||||
}
|
||||
|
||||
fn full_canvas(w: usize, h: usize) -> RegionMask {
|
||||
let mut image = BinaryImage::new_w_h(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
image.set_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
RegionMask::new(image, PointI32 { x: 0, y: 0 })
|
||||
}
|
||||
|
||||
/// Paint a tree node's region (the union of the final regions beneath it)
|
||||
/// into a bbox-cropped mask.
|
||||
fn node_mask(
|
||||
node: usize,
|
||||
k: usize,
|
||||
tree_child: &[[u32; 2]],
|
||||
leaf_start: &[usize],
|
||||
leaf_px: &[u32],
|
||||
w: usize,
|
||||
) -> RegionMask {
|
||||
// Collect the node's leaves.
|
||||
let mut leaves: Vec<usize> = Vec::new();
|
||||
let mut stack = vec![node];
|
||||
while let Some(t) = stack.pop() {
|
||||
if t < k {
|
||||
leaves.push(t);
|
||||
} else {
|
||||
let [a, b] = tree_child[t - k];
|
||||
stack.push(a as usize);
|
||||
stack.push(b as usize);
|
||||
}
|
||||
}
|
||||
// Bounding box over all member pixels.
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for &t in &leaves {
|
||||
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
|
||||
let (x, y) = ((p as usize % w) as i32, (p as usize / w) as i32);
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x);
|
||||
y1 = y1.max(y);
|
||||
}
|
||||
}
|
||||
let (bw, bh) = ((x1 - x0 + 1) as usize, (y1 - y0 + 1) as usize);
|
||||
let mut image = BinaryImage::new_w_h(bw, bh);
|
||||
for &t in &leaves {
|
||||
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
|
||||
let (x, y) = (p as usize % w, p as usize / w);
|
||||
image.set_pixel(x - x0 as usize, y - y0 as usize, true);
|
||||
}
|
||||
}
|
||||
RegionMask::new(image, PointI32 { x: x0, y: y0 })
|
||||
}
|
||||
|
||||
/// How many 1-px boundary-snap sweeps to run: bounds the boundary movement to
|
||||
/// the width of an antialiasing ramp / JPEG halo (compression ringing spreads
|
||||
/// a hard edge over up to ~3 px; a plain AA ramp over 1–2 px).
|
||||
const SNAP_SWEEPS: usize = 4;
|
||||
|
||||
/// Tolerance for the mixture test below: an antialiased blend of two region
|
||||
/// colors satisfies `d(p,A) + d(p,B) = d(A,B)` exactly (L1, per-channel
|
||||
/// between-ness); this slack admits sensor/JPEG noise of a few units per
|
||||
/// channel without admitting genuine third colors.
|
||||
const SNAP_SLACK: i32 = 16;
|
||||
|
||||
/// Re-assign boundary pixels to whichever adjacent region's mean color is
|
||||
/// closest (strictly closer than their own region's mean, L1).
|
||||
///
|
||||
/// The minimum-spanning-forest cut routes the boundary through whichever
|
||||
/// crack of an antialiasing ramp has the minutely-largest weight, so along a
|
||||
/// smooth edge it meanders ±1–2 px with the pixel noise and the fitted curves
|
||||
/// visibly wave (crisp synthetic edges are unaffected: their boundary pixels
|
||||
/// sit exactly at a region's mean). Snapping by color lands the boundary on
|
||||
/// the color-midpoint iso-line of the ramp instead — the same rule color
|
||||
/// quantization applies, which is why the color-cluster frontend never shows
|
||||
/// this.
|
||||
///
|
||||
/// Only pixels whose color is a *mixture* of two adjacent region means may
|
||||
/// flip (`d(p,A) + d(p,B) ≤ d(A,B) + slack`): a pixel of a genuine third
|
||||
/// color — say a dark outline stroke absorbed into a lighter region — must
|
||||
/// stay with its basin even when some other neighbour's mean happens to sit
|
||||
/// closer. The mixture pair is usually the pixel's own region and the flip
|
||||
/// candidate (the classic AA ramp), but a pair of *neighbouring* regions
|
||||
/// also qualifies: on a blurred low-contrast crack the basin cut can leak a
|
||||
/// distant region along the crack's blend band as a 1-px filament — those
|
||||
/// pixels blend the two flanking regions and are unrelated to their own
|
||||
/// region's color, and they belong to the closer flank.
|
||||
/// Sweeps are double-buffered (flips apply after scanning) and each moves the
|
||||
/// boundary at most 1 px, so total movement stays within the ambiguity band;
|
||||
/// regions are never emptied. Only the first sweep scans the whole canvas;
|
||||
/// later sweeps revisit the moving front (last sweep's flips and their
|
||||
/// neighbours), so the cost past sweep one is proportional to the boundary
|
||||
/// that is actually moving.
|
||||
fn snap_boundaries(
|
||||
img: &ColorImage,
|
||||
w: usize,
|
||||
h: usize,
|
||||
labels: &mut [u32],
|
||||
area: &mut [u64],
|
||||
sum: &mut [[u64; 3]],
|
||||
) {
|
||||
let n = w * h;
|
||||
let k = area.len();
|
||||
if k < 2 {
|
||||
return;
|
||||
}
|
||||
// Where a boundary pixel should move, if anywhere: strict improvement
|
||||
// only, gated on the mixture test; the first of the fixed neighbour
|
||||
// order wins ties, keeping the sweep deterministic.
|
||||
let snap_target = |i: usize, labels: &[u32], mean: &[[i32; 3]]| -> Option<u32> {
|
||||
let a = labels[i] as usize;
|
||||
// Neighbour labels, replicated at the canvas border (a no-op
|
||||
// candidate) so the hot path below stays branch-light.
|
||||
let (x, y) = (i % w, i / w);
|
||||
let nb = [
|
||||
labels[if x > 0 { i - 1 } else { i }] as usize,
|
||||
labels[if x + 1 < w { i + 1 } else { i }] as usize,
|
||||
labels[if y > 0 { i - w } else { i }] as usize,
|
||||
labels[if y + 1 < h { i + w } else { i }] as usize,
|
||||
];
|
||||
if nb == [a; 4] {
|
||||
return None; // interior pixel — the overwhelmingly common case
|
||||
}
|
||||
let c = img.get_pixel(x, y);
|
||||
let cv = [c.r as i32, c.g as i32, c.b as i32];
|
||||
let dist = |m: &[i32; 3]| {
|
||||
(cv[0] - m[0]).abs() + (cv[1] - m[1]).abs() + (cv[2] - m[2]).abs()
|
||||
};
|
||||
let da = dist(&mean[a]);
|
||||
// The pixel qualifies as a blend of regions `p` and `q` when its
|
||||
// color sits between their means (L1 between-ness plus noise slack).
|
||||
let mixture = |p: usize, q: usize| -> bool {
|
||||
let dpq: i32 = (0..3).map(|ch| (mean[p][ch] - mean[q][ch]).abs()).sum();
|
||||
dist(&mean[p]) + dist(&mean[q]) <= dpq + SNAP_SLACK
|
||||
};
|
||||
let mut best = (da, a);
|
||||
for b in nb {
|
||||
if b == a {
|
||||
continue;
|
||||
}
|
||||
let db = dist(&mean[b]);
|
||||
if db >= best.0 {
|
||||
continue;
|
||||
}
|
||||
if mixture(a, b) || nb.iter().any(|&c| c != a && c != b && mixture(c, b)) {
|
||||
best = (db, b);
|
||||
}
|
||||
}
|
||||
(best.1 != a).then_some(best.1 as u32)
|
||||
};
|
||||
|
||||
let mut mean = vec![[0i32; 3]; k];
|
||||
let mut flips: Vec<(u32, u32)> = Vec::new(); // (pixel, new label)
|
||||
let mut front: Vec<u32> = Vec::new(); // pixels to rescan; sweep 0 scans all
|
||||
let mut touched: Vec<u32> = Vec::new(); // every front, for the fragment check
|
||||
for sweep in 0..SNAP_SWEEPS {
|
||||
for r in 0..k {
|
||||
for ch in 0..3 {
|
||||
mean[r][ch] = (sum[r][ch] / area[r]) as i32;
|
||||
}
|
||||
}
|
||||
flips.clear();
|
||||
if sweep == 0 {
|
||||
// Interior first with a branch-free neighbour check (the div/mod
|
||||
// and border branches in snap_target would dominate a whole-canvas
|
||||
// scan), then the border rim.
|
||||
for y in 1..h.saturating_sub(1) {
|
||||
for i in y * w + 1..y * w + w.saturating_sub(1) {
|
||||
let a = labels[i];
|
||||
if labels[i - 1] == a
|
||||
&& labels[i + 1] == a
|
||||
&& labels[i - w] == a
|
||||
&& labels[i + w] == a
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = snap_target(i, labels, &mean) {
|
||||
flips.push((i as u32, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let h1 = h.saturating_sub(1);
|
||||
let rim = (0..w)
|
||||
.chain((1..h1).map(|y| y * w))
|
||||
.chain((1..h1).map(|y| y * w + w - 1).filter(|_| w > 1))
|
||||
.chain(if h > 1 { h1 * w..n } else { 0..0 });
|
||||
for i in rim {
|
||||
if let Some(b) = snap_target(i, labels, &mean) {
|
||||
flips.push((i as u32, b));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for &i in &front {
|
||||
if let Some(b) = snap_target(i as usize, labels, &mean) {
|
||||
flips.push((i, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
if flips.is_empty() {
|
||||
break;
|
||||
}
|
||||
for &(i, b) in &flips {
|
||||
let (i, b) = (i as usize, b as usize);
|
||||
let a = labels[i] as usize;
|
||||
if area[a] <= 1 {
|
||||
continue; // never empty a region
|
||||
}
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
labels[i] = b as u32;
|
||||
area[a] -= 1;
|
||||
area[b] += 1;
|
||||
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
|
||||
sum[a][ch] -= v as u64;
|
||||
sum[b][ch] += v as u64;
|
||||
}
|
||||
}
|
||||
// Next sweep revisits each flipped pixel and its 4-neighbourhood,
|
||||
// in raster order for determinism; the same set seeds the fragment
|
||||
// check below (a severed strand is always adjacent to the flipped
|
||||
// bridge pixel that cut it off).
|
||||
front.clear();
|
||||
for &(i, _) in &flips {
|
||||
let i = i as usize;
|
||||
let (x, y) = (i % w, i / w);
|
||||
front.push(i as u32);
|
||||
if x > 0 {
|
||||
front.push((i - 1) as u32);
|
||||
}
|
||||
if x + 1 < w {
|
||||
front.push((i + 1) as u32);
|
||||
}
|
||||
if y > 0 {
|
||||
front.push((i - w) as u32);
|
||||
}
|
||||
if y + 1 < h {
|
||||
front.push((i + w) as u32);
|
||||
}
|
||||
}
|
||||
front.sort_unstable();
|
||||
front.dedup();
|
||||
touched.extend_from_slice(&front);
|
||||
}
|
||||
touched.sort_unstable();
|
||||
touched.dedup();
|
||||
absorb_fragments(img, w, h, labels, area, sum, &touched);
|
||||
}
|
||||
|
||||
/// Fragments a snap flip may pinch off: a pixel can flip toward a neighbour
|
||||
/// whose own flip then strands it, and a flipped bridge pixel can sever a
|
||||
/// thin strand of its source region. Watershed basins are connected by
|
||||
/// construction and everything downstream relies on regions staying coherent
|
||||
/// (the mosaic gives every disjoint patch its own face), so the snap must not
|
||||
/// leave debris: a connected component that is disconnected from the rest of
|
||||
/// its region and fits under this floor is re-assigned to the most
|
||||
/// color-similar adjacent region. (A *substantial* patch severed at a thin
|
||||
/// antialiased neck stays — it makes a coherent face of its own; recoloring
|
||||
/// it would be visible.)
|
||||
const SNAP_FRAGMENT_MAX: usize = SNAP_SWEEPS * SNAP_SWEEPS;
|
||||
|
||||
fn absorb_fragments(
|
||||
img: &ColorImage,
|
||||
w: usize,
|
||||
h: usize,
|
||||
labels: &mut [u32],
|
||||
area: &mut [u64],
|
||||
sum: &mut [[u64; 3]],
|
||||
seeds: &[u32],
|
||||
) {
|
||||
let n = w * h;
|
||||
let mut visited = vec![false; n];
|
||||
let mut comp: Vec<usize> = Vec::new();
|
||||
let mut rim: Vec<u32> = Vec::new(); // adjacent region labels
|
||||
for &s in seeds {
|
||||
let s = s as usize;
|
||||
if visited[s] {
|
||||
continue;
|
||||
}
|
||||
// Flood s's same-label component, capped: hitting the cap — or a
|
||||
// pixel already visited by an earlier over-cap flood of the same
|
||||
// component — proves it is no fragment.
|
||||
let l = labels[s];
|
||||
visited[s] = true;
|
||||
comp.clear();
|
||||
comp.push(s);
|
||||
rim.clear();
|
||||
let mut over = false;
|
||||
let mut qi = 0;
|
||||
'flood: while qi < comp.len() {
|
||||
let i = comp[qi];
|
||||
qi += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
for j in [
|
||||
(x > 0).then(|| i - 1),
|
||||
(x + 1 < w).then(|| i + 1),
|
||||
(y > 0).then(|| i - w),
|
||||
(y + 1 < h).then(|| i + w),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if labels[j] != l {
|
||||
rim.push(labels[j]);
|
||||
continue;
|
||||
}
|
||||
if visited[j] {
|
||||
if !comp.contains(&j) {
|
||||
over = true; // joined an earlier over-cap flood
|
||||
break 'flood;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if comp.len() > SNAP_FRAGMENT_MAX {
|
||||
over = true;
|
||||
break 'flood;
|
||||
}
|
||||
visited[j] = true;
|
||||
comp.push(j);
|
||||
}
|
||||
}
|
||||
// A component as large as its whole region is the region itself, not
|
||||
// a fragment of one. (The flood can end at cap + 1 without tripping
|
||||
// `over`, so re-check the size.)
|
||||
if over
|
||||
|| comp.len() > SNAP_FRAGMENT_MAX
|
||||
|| comp.len() as u64 >= area[l as usize]
|
||||
|| rim.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The whole fragment moves to the adjacent region whose mean is
|
||||
// closest to the fragment's own mean.
|
||||
let mut fsum = [0i64; 3];
|
||||
for &i in &comp {
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
|
||||
fsum[ch] += v as i64;
|
||||
}
|
||||
}
|
||||
let fl = comp.len() as i64;
|
||||
rim.sort_unstable();
|
||||
rim.dedup();
|
||||
let target = rim
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
let d: i64 = (0..3)
|
||||
.map(|ch| {
|
||||
(fsum[ch] / fl - (sum[b as usize][ch] / area[b as usize]) as i64).abs()
|
||||
})
|
||||
.sum();
|
||||
(d, b)
|
||||
})
|
||||
.min()
|
||||
.unwrap()
|
||||
.1 as usize;
|
||||
let l = l as usize;
|
||||
for &i in &comp {
|
||||
let c = img.get_pixel(i % w, i / w);
|
||||
labels[i] = target as u32;
|
||||
area[l] -= 1;
|
||||
area[target] += 1;
|
||||
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
|
||||
sum[l][ch] -= v as u64;
|
||||
sum[target][ch] += v as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb regions smaller than `min_area` into their most color-similar
|
||||
/// neighbour, working entirely on the region graph: `pairs` are the boundary
|
||||
/// adjacencies (duplicates fine), `uf` is a region-level union-find, and the
|
||||
/// stats are merged along so downstream consumers see the final regions.
|
||||
/// Sweeps until nothing undersized remains (or an undersized region has no
|
||||
/// neighbour at all).
|
||||
fn absorb_small(
|
||||
min_area: usize,
|
||||
pairs: &[(u32, u32)],
|
||||
uf: &mut Uf,
|
||||
area: &mut [u64],
|
||||
sum: &mut [[u64; 3]],
|
||||
) {
|
||||
if min_area <= 1 {
|
||||
return;
|
||||
}
|
||||
let k = area.len();
|
||||
let mean_diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> u64 {
|
||||
let mut d = 0i64;
|
||||
for ch in 0..3 {
|
||||
d += ((sa[ch] / aa) as i64 - (sb[ch] / ab) as i64).abs();
|
||||
}
|
||||
d as u64
|
||||
};
|
||||
loop {
|
||||
// best[r] = (diff, neighbour_root) for undersized root r
|
||||
let mut best: Vec<(u64, u32)> = vec![(u64::MAX, u32::MAX); k];
|
||||
let mut any_small = false;
|
||||
for &(p, q) in pairs {
|
||||
let (a, b) = (uf.find(p), uf.find(q));
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
for (s, t) in [(a, b), (b, a)] {
|
||||
let (su, tu) = (s as usize, t as usize);
|
||||
if area[su] < min_area as u64 {
|
||||
any_small = true;
|
||||
let d = mean_diff(&sum[su], area[su], &sum[tu], area[tu]);
|
||||
if d < best[su].0 || (d == best[su].0 && t < best[su].1) {
|
||||
best[su] = (d, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any_small {
|
||||
break;
|
||||
}
|
||||
let mut merged = false;
|
||||
for r in 0..k {
|
||||
let (_, tgt) = best[r];
|
||||
if tgt == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let rr = uf.find(r as u32);
|
||||
if rr as usize != r {
|
||||
continue; // already absorbed this sweep
|
||||
}
|
||||
let rt = uf.find(tgt);
|
||||
if rt == rr {
|
||||
continue;
|
||||
}
|
||||
uf.link(rt, rr);
|
||||
area[rt as usize] += area[r];
|
||||
for ch in 0..3 {
|
||||
sum[rt as usize][ch] += sum[r][ch];
|
||||
}
|
||||
merged = true;
|
||||
}
|
||||
if !merged {
|
||||
break; // isolated undersized region (e.g. whole-canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Frontend for WatershedFrontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
Ok(WatershedHierarchy::build(img)?.cut(img, self.detail, self.min_area))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Core intermediate representation shared by the pipeline stages.
|
||||
//!
|
||||
//! Two IRs flow through the pipeline:
|
||||
//!
|
||||
//! * [`Segmentation`] — the frontend output: ordered paint layers over a
|
||||
//! raster canvas (painter's algorithm, bottom to top). This is what the
|
||||
//! [`crate::colorfit`] stages rewrite.
|
||||
//! * [`VectorDoc`] — the output document: resolved shapes with fitted paths.
|
||||
//! This is what the [`crate::optimize`] passes and the [`crate::svg`] writer
|
||||
//! operate on.
|
||||
|
||||
mod region;
|
||||
mod vector;
|
||||
|
||||
pub use region::{Layer, RegionMask, Segmentation};
|
||||
pub use vector::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
|
||||
|
||||
use visioncortex::Color;
|
||||
|
||||
/// The final appearance of a region. Only solid colors are supported today;
|
||||
/// the enum leaves room for gradients and patterns later.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Paint {
|
||||
Solid(Color),
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
/// The representative solid color of this paint.
|
||||
pub fn color(&self) -> Color {
|
||||
match self {
|
||||
Paint::Solid(c) => *c,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use visioncortex::{BinaryImage, PointI32};
|
||||
|
||||
use super::Paint;
|
||||
|
||||
/// A region's pixel coverage: a local binary mask positioned on the canvas.
|
||||
///
|
||||
/// Foreground pixels are `true`. Holes (interior background) are already
|
||||
/// punched out of the mask, so a mask is self-describing for tracing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegionMask {
|
||||
/// Local coverage; `true` = inside the region.
|
||||
pub image: BinaryImage,
|
||||
/// Position of the mask's top-left corner in full-canvas coordinates.
|
||||
pub offset: PointI32,
|
||||
}
|
||||
|
||||
impl RegionMask {
|
||||
pub fn new(image: BinaryImage, offset: PointI32) -> Self {
|
||||
Self { image, offset }
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.image.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.image.height
|
||||
}
|
||||
|
||||
/// Number of foreground pixels.
|
||||
pub fn area(&self) -> usize {
|
||||
let mut count = 0;
|
||||
for y in 0..self.image.height {
|
||||
for x in 0..self.image.width {
|
||||
if self.image.get_pixel(x, y) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Combine two masks into one covering the union of their bounding boxes.
|
||||
/// Foreground is the OR of both; this is used by the layer-merge step.
|
||||
pub fn union(&self, other: &RegionMask) -> RegionMask {
|
||||
Self::union_all(&[self, other])
|
||||
}
|
||||
|
||||
/// Union any number of masks in one pass: size the destination from the
|
||||
/// combined bounding box, then blit each source into it exactly once.
|
||||
///
|
||||
/// Folding [`union`](Self::union) instead costs one full-size allocation and
|
||||
/// rewrite of the accumulator *per input*. That is quadratic in the canvas
|
||||
/// area, and it bites precisely when a palette snap leaves a long run of
|
||||
/// same-paint layers for [`MergeAdjacent`](crate::colorfit::MergeAdjacent):
|
||||
/// the accumulator grows to the full canvas after the first few merges, so
|
||||
/// every remaining layer copies the entire canvas again.
|
||||
///
|
||||
/// An empty input yields an empty mask at the origin.
|
||||
pub fn union_all(masks: &[&RegionMask]) -> RegionMask {
|
||||
let Some((first, rest)) = masks.split_first() else {
|
||||
return RegionMask::new(BinaryImage::new_w_h(0, 0), PointI32 { x: 0, y: 0 });
|
||||
};
|
||||
|
||||
let mut left = first.offset.x;
|
||||
let mut top = first.offset.y;
|
||||
let mut right = first.offset.x + first.image.width as i32;
|
||||
let mut bottom = first.offset.y + first.image.height as i32;
|
||||
for m in rest {
|
||||
left = left.min(m.offset.x);
|
||||
top = top.min(m.offset.y);
|
||||
right = right.max(m.offset.x + m.image.width as i32);
|
||||
bottom = bottom.max(m.offset.y + m.image.height as i32);
|
||||
}
|
||||
|
||||
let width = (right - left) as usize;
|
||||
let height = (bottom - top) as usize;
|
||||
let mut image = BinaryImage::new_w_h(width, height);
|
||||
|
||||
for src in masks {
|
||||
let dx = (src.offset.x - left) as usize;
|
||||
let dy = (src.offset.y - top) as usize;
|
||||
for y in 0..src.image.height {
|
||||
for x in 0..src.image.width {
|
||||
if src.image.get_pixel(x, y) {
|
||||
image.set_pixel(x + dx, y + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RegionMask::new(image, PointI32 { x: left, y: top })
|
||||
}
|
||||
}
|
||||
|
||||
/// A single paint layer. Layers are painted bottom-to-top.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Layer {
|
||||
/// Fill applied to the region. Starts as the cluster's mean color; a
|
||||
/// [`crate::colorfit::ColorFitter`] may rewrite it.
|
||||
pub paint: Paint,
|
||||
/// Pixel coverage of the region.
|
||||
pub mask: RegionMask,
|
||||
}
|
||||
|
||||
/// Frontend output: ordered layers over a canvas, in paint order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Segmentation {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Bottom-to-top paint order.
|
||||
pub layers: Vec<Layer>,
|
||||
}
|
||||
|
||||
impl Segmentation {
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
layers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use super::Paint;
|
||||
|
||||
/// A single drawing command in a subpath. Coordinates are absolute, in
|
||||
/// full-canvas (document) space — the writer bakes any offset into them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PathCmd {
|
||||
/// Start a new subpath at the given point.
|
||||
MoveTo(PointF64),
|
||||
/// Straight line to the given point.
|
||||
LineTo(PointF64),
|
||||
/// Cubic Bézier: two control points then the endpoint.
|
||||
CubicTo(PointF64, PointF64, PointF64),
|
||||
/// Close the current subpath back to its start.
|
||||
Close,
|
||||
}
|
||||
|
||||
/// One connected outline: a `MoveTo` followed by line/cubic segments, usually
|
||||
/// terminated by `Close`.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SubPath {
|
||||
pub commands: Vec<PathCmd>,
|
||||
}
|
||||
|
||||
impl SubPath {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
|
||||
/// The starting point of the subpath, if any.
|
||||
pub fn start(&self) -> Option<PointF64> {
|
||||
match self.commands.first() {
|
||||
Some(PathCmd::MoveTo(p)) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape may consist of several subpaths (outer ring plus holes).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct MultiPath {
|
||||
pub subpaths: Vec<SubPath>,
|
||||
}
|
||||
|
||||
impl MultiPath {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.subpaths.iter().all(SubPath::is_empty)
|
||||
}
|
||||
|
||||
pub fn push(&mut self, subpath: SubPath) {
|
||||
if !subpath.is_empty() {
|
||||
self.subpaths.push(subpath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A filled shape in the output document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Shape {
|
||||
pub paint: Paint,
|
||||
pub path: MultiPath,
|
||||
}
|
||||
|
||||
/// The output document IR: what the optimizer passes and the writer consume.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VectorDoc {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Shapes in paint order (first drawn is bottom).
|
||||
pub shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl VectorDoc {
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
shapes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! # vtracer
|
||||
//!
|
||||
//! A vectorization *framework*: raster images become vector graphics through a
|
||||
//! pipeline of pluggable stages.
|
||||
//!
|
||||
//! ```text
|
||||
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ CurvePass* ─▶ VectorDoc
|
||||
//! │
|
||||
//! OptimizerPass* ─────┤
|
||||
//! ▼
|
||||
//! SvgWriter ─▶ SVG
|
||||
//! ```
|
||||
//!
|
||||
//! The crate is wasm-safe: it performs no file or image I/O (that lives in the
|
||||
//! `vtracer-cli` wrapper). Everything here compiles to
|
||||
//! `wasm32-unknown-unknown`.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use vtracer::{Config, ColorImage};
|
||||
//!
|
||||
//! # fn load() -> ColorImage { todo!() }
|
||||
//! let img: ColorImage = load();
|
||||
//! let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! For finer control, assemble a [`Pipeline`] directly from the stage traits
|
||||
//! in [`frontend`], [`colorfit`], [`fitter`], [`simplify`], [`compose`],
|
||||
//! [`optimize`], and [`svg`].
|
||||
|
||||
pub mod colorfit;
|
||||
pub mod compose;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod fitter;
|
||||
pub mod frontend;
|
||||
pub mod ir;
|
||||
pub mod mosaic;
|
||||
pub mod optimize;
|
||||
pub mod pipeline;
|
||||
pub mod progress;
|
||||
pub mod session;
|
||||
pub mod simplify;
|
||||
pub mod svg;
|
||||
|
||||
pub use config::{Clustering, Config, FitMode, Hierarchical, Preset, SegmentKey};
|
||||
pub use error::Error;
|
||||
pub use frontend::Threshold;
|
||||
pub use ir::{Segmentation, VectorDoc};
|
||||
pub use pipeline::Pipeline;
|
||||
pub use progress::{CancelToken, Phase, Progress};
|
||||
pub use session::Session;
|
||||
|
||||
// Re-export the visioncortex value types callers need at the boundary.
|
||||
pub use visioncortex::{Color, ColorImage, PointF64, PointI32};
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Mosaic mode: a seam-free, gapless tessellation.
|
||||
//!
|
||||
//! Instead of tracing every region independently (which lets neighboring
|
||||
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
|
||||
//!
|
||||
//! ```text
|
||||
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
|
||||
//! ```
|
||||
//!
|
||||
//! Every boundary curve exists exactly once; the two adjacent regions
|
||||
//! reference the same fitted geometry, one traversed reversed. Reversal is
|
||||
//! exact, so the serialized coordinates match on both sides — no seams.
|
||||
//!
|
||||
//! Stages 1–2 (graph + faces) are pure integer arithmetic on the lattice of
|
||||
//! pixel corners. Only fitting (stage 3) is floating point.
|
||||
|
||||
mod compose;
|
||||
mod face;
|
||||
mod fit;
|
||||
mod graph;
|
||||
|
||||
pub use compose::compose_mosaic;
|
||||
pub use fit::{
|
||||
FittedGeom, FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
|
||||
SplineSegmentFitter,
|
||||
};
|
||||
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
|
||||
|
||||
use crate::ir::{Paint, Segmentation};
|
||||
|
||||
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
|
||||
pub type RegionId = u32;
|
||||
|
||||
/// Sentinel label for pixels outside any region.
|
||||
pub const OUTSIDE: RegionId = u32::MAX;
|
||||
|
||||
/// A flat partition of the canvas: one region id per pixel, plus the paint for
|
||||
/// each region. This is the sole input to the boundary-graph extractor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabelMap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
|
||||
pub labels: Vec<RegionId>,
|
||||
/// Paint per region, indexed by label.
|
||||
pub paints: Vec<Paint>,
|
||||
}
|
||||
|
||||
impl LabelMap {
|
||||
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
|
||||
/// pixel takes the paint of the topmost layer covering it. Layers are
|
||||
/// bottom-to-top, so painting them in order lets higher layers win.
|
||||
pub fn from_segmentation(seg: &Segmentation) -> Self {
|
||||
let w = seg.width as usize;
|
||||
let h = seg.height as usize;
|
||||
let mut labels = vec![OUTSIDE; w * h];
|
||||
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
|
||||
|
||||
for (i, layer) in seg.layers.iter().enumerate() {
|
||||
let mask = &layer.mask;
|
||||
for ly in 0..mask.image.height {
|
||||
for lx in 0..mask.image.width {
|
||||
if mask.image.get_pixel(lx, ly) {
|
||||
let gx = mask.offset.x + lx as i32;
|
||||
let gy = mask.offset.y + ly as i32;
|
||||
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
|
||||
labels[gy as usize * w + gx as usize] = i as RegionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LabelMap {
|
||||
width: seg.width,
|
||||
height: seg.height,
|
||||
labels,
|
||||
paints,
|
||||
}
|
||||
}
|
||||
|
||||
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
|
||||
/// Treating outside as a real label removes all image-border special cases.
|
||||
#[inline]
|
||||
pub fn label(&self, x: i32, y: i32) -> RegionId {
|
||||
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
|
||||
return OUTSIDE;
|
||||
}
|
||||
self.labels[y as usize * self.width as usize + x as usize]
|
||||
}
|
||||
|
||||
/// Merge neighbouring regions whose colors are within `max_diff` of each
|
||||
/// other (the metric is the clustering one: sum of per-channel absolute
|
||||
/// differences, and clustering keeps neighbours together when
|
||||
/// `diff <= deepen_diff`).
|
||||
///
|
||||
/// The stacked hierarchy deliberately splits a gradient into layers one
|
||||
/// `deepen_diff` apart — that's what makes stacking smooth. Flattened into
|
||||
/// a mosaic, that layering degenerates into abutting faces with barely
|
||||
/// distinguishable fills. This pass undoes it: agglomerative union-find
|
||||
/// over the adjacency graph, most-similar pairs first, with each merged
|
||||
/// region's color re-derived as the area-weighted mean so chains only
|
||||
/// combine while they genuinely stay within `max_diff`.
|
||||
///
|
||||
/// `max_diff == 0` still merges *identical*-color neighbours — a boundary
|
||||
/// between two same-colored faces is never useful. Pass a negative value
|
||||
/// to disable merging entirely.
|
||||
pub fn merge_similar(&mut self, max_diff: i32) {
|
||||
let n = self.paints.len();
|
||||
if max_diff < 0 || n < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Area and summed color per region, for weighted mean colors.
|
||||
let mut area = vec![0u64; n];
|
||||
for &l in &self.labels {
|
||||
if l != OUTSIDE {
|
||||
area[l as usize] += 1;
|
||||
}
|
||||
}
|
||||
let mut sum: Vec<[u64; 3]> = (0..n)
|
||||
.map(|i| {
|
||||
let c = self.paints[i].color();
|
||||
[
|
||||
c.r as u64 * area[i],
|
||||
c.g as u64 * area[i],
|
||||
c.b as u64 * area[i],
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Adjacency pairs (right/down scan covers 4-connectivity once).
|
||||
let (w, h) = (self.width as i32, self.height as i32);
|
||||
let mut pairs: Vec<(RegionId, RegionId)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let a = self.label(x, y);
|
||||
if a == OUTSIDE {
|
||||
continue;
|
||||
}
|
||||
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
|
||||
let b = self.label(nx, ny);
|
||||
if b == OUTSIDE || b == a {
|
||||
continue;
|
||||
}
|
||||
let key = (a.min(b), a.max(b));
|
||||
if seen.insert(key) {
|
||||
pairs.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> i32 {
|
||||
let mut d = 0i64;
|
||||
for k in 0..3 {
|
||||
d += ((sa[k] / aa.max(1)) as i64 - (sb[k] / ab.max(1)) as i64).abs();
|
||||
}
|
||||
d as i32
|
||||
};
|
||||
|
||||
// Most-similar pairs first, so gradient chains coalesce around their
|
||||
// closest links; ties break on ids for determinism.
|
||||
pairs.sort_by_key(|&(a, b)| {
|
||||
(
|
||||
diff(&sum[a as usize], area[a as usize], &sum[b as usize], area[b as usize]),
|
||||
a,
|
||||
b,
|
||||
)
|
||||
});
|
||||
|
||||
let mut parent: Vec<RegionId> = (0..n as RegionId).collect();
|
||||
fn find(parent: &mut [RegionId], mut i: RegionId) -> RegionId {
|
||||
while parent[i as usize] != i {
|
||||
parent[i as usize] = parent[parent[i as usize] as usize];
|
||||
i = parent[i as usize];
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
// Colors move as regions absorb one another, so re-sweep the candidate
|
||||
// pairs until nothing merges. Each union is O(α); the sweep count is
|
||||
// tiny in practice (colors only ever move toward each other's mean).
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for &(a, b) in &pairs {
|
||||
let ra = find(&mut parent, a);
|
||||
let rb = find(&mut parent, b);
|
||||
if ra == rb {
|
||||
continue;
|
||||
}
|
||||
let (ia, ib) = (ra as usize, rb as usize);
|
||||
if diff(&sum[ia], area[ia], &sum[ib], area[ib]) <= max_diff {
|
||||
parent[ib] = ra;
|
||||
for k in 0..3 {
|
||||
sum[ia][k] += sum[ib][k];
|
||||
}
|
||||
area[ia] += area[ib];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact surviving roots into dense ids and rewrite labels + paints.
|
||||
let mut remap: Vec<RegionId> = vec![OUTSIDE; n];
|
||||
let mut paints: Vec<Paint> = Vec::new();
|
||||
for l in &mut self.labels {
|
||||
if *l == OUTSIDE {
|
||||
continue;
|
||||
}
|
||||
let root = find(&mut parent, *l);
|
||||
if remap[root as usize] == OUTSIDE {
|
||||
remap[root as usize] = paints.len() as RegionId;
|
||||
let (s, a) = (&sum[root as usize], area[root as usize].max(1));
|
||||
paints.push(Paint::Solid(visioncortex::Color::new(
|
||||
(s[0] / a) as u8,
|
||||
(s[1] / a) as u8,
|
||||
(s[2] / a) as u8,
|
||||
)));
|
||||
}
|
||||
*l = remap[root as usize];
|
||||
}
|
||||
self.paints = paints;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::face::{assemble, Face};
|
||||
use super::graph::BoundaryGraph;
|
||||
use super::*;
|
||||
use crate::ir::Paint;
|
||||
use visioncortex::{Color, PointF64};
|
||||
|
||||
/// Build a label map from a row-major grid (for tests).
|
||||
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
|
||||
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
|
||||
let n = max.map(|m| m as usize + 1).unwrap_or(0);
|
||||
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
|
||||
LabelMap {
|
||||
width,
|
||||
height,
|
||||
labels,
|
||||
paints,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct a face's contour polygons in exact lattice coordinates.
|
||||
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
|
||||
face.contours
|
||||
.iter()
|
||||
.map(|contour| {
|
||||
let mut ring: Vec<PointF64> = Vec::new();
|
||||
for (i, sref) in contour.0.iter().enumerate() {
|
||||
let pts = &graph.segments[sref.seg as usize].points;
|
||||
let ordered: Vec<PointF64> = if sref.forward {
|
||||
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
|
||||
} else {
|
||||
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
|
||||
};
|
||||
if i == 0 {
|
||||
ring.extend(ordered);
|
||||
} else {
|
||||
ring.extend(ordered[1..].iter().copied());
|
||||
}
|
||||
}
|
||||
ring
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
|
||||
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
|
||||
}
|
||||
|
||||
/// Winding number of point `p` w.r.t. a closed ring (last == first).
|
||||
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
|
||||
let mut wn = 0;
|
||||
for w in ring.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if a.y <= p.y {
|
||||
if b.y > p.y && is_left(a, b, p) > 0.0 {
|
||||
wn += 1;
|
||||
}
|
||||
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
|
||||
wn -= 1;
|
||||
}
|
||||
}
|
||||
wn
|
||||
}
|
||||
|
||||
/// The strongest guarantee: rasterize the composed faces at pixel centers
|
||||
/// and assert the result is byte-identical to the input label map.
|
||||
fn assert_pixel_roundtrip(map: &LabelMap) {
|
||||
let graph = BoundaryGraph::extract(map);
|
||||
let faces = assemble(&graph, map);
|
||||
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
|
||||
.iter()
|
||||
.map(|f| (f.region, face_polygons(&graph, f)))
|
||||
.collect();
|
||||
|
||||
for y in 0..map.height as i32 {
|
||||
for x in 0..map.width as i32 {
|
||||
let center = PointF64 {
|
||||
x: x as f64 + 0.5,
|
||||
y: y as f64 + 0.5,
|
||||
};
|
||||
let mut hits: Vec<RegionId> = Vec::new();
|
||||
for (region, rings) in &polys {
|
||||
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
|
||||
if wn != 0 {
|
||||
hits.push(*region);
|
||||
}
|
||||
}
|
||||
let expected = map.label(x, y);
|
||||
if expected == OUTSIDE {
|
||||
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
|
||||
} else {
|
||||
assert_eq!(
|
||||
hits,
|
||||
vec![expected],
|
||||
"({x},{y}) expected region {expected}, got {hits:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_region_is_one_ring() {
|
||||
let map = grid(3, 2, vec![0; 6]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
|
||||
assert_eq!(graph.segments.len(), 1, "one border ring");
|
||||
assert!(graph.segments[0].is_ring());
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_split() {
|
||||
// 4x2, left half 0, right half 1.
|
||||
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
// Two border junctions where the split meets the top and bottom edges.
|
||||
assert_eq!(graph.nodes.len(), 2);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_junction() {
|
||||
// top row one region, bottom row split — a degree-3 interior node.
|
||||
let map = grid(2, 2, vec![0, 0, 1, 2]);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkerboard_pinch() {
|
||||
// A B / B A — the center corner is a degree-4 pinch; each region is two
|
||||
// lobes touching there. (The four boundary/border corners are degree-3
|
||||
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
|
||||
// the real check that the pinch produces exact, simple contours.
|
||||
let map = grid(2, 2, vec![0, 1, 1, 0]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let has_degree4 = graph.nodes.iter().any(|n| {
|
||||
let c = n.corner;
|
||||
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
|
||||
});
|
||||
assert!(has_degree4, "expected a degree-4 pinch node at the center");
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_patches_of_one_region_get_separate_faces() {
|
||||
// Region 0 appears as two islands, separated by a column of region 1.
|
||||
// Each island must get its own face, so they cannot share a path.
|
||||
#[rustfmt::skip]
|
||||
let map = grid(3, 2, vec![
|
||||
0, 1, 0,
|
||||
0, 1, 0,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let faces = assemble(&graph, &map);
|
||||
|
||||
assert_eq!(
|
||||
faces.iter().filter(|f| f.region == 0).count(),
|
||||
2,
|
||||
"each island of region 0 gets its own face"
|
||||
);
|
||||
assert_eq!(faces.len(), 3, "two islands of region 0, plus region 1");
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonal_lobes_share_one_face() {
|
||||
// A B / B A — region 0's lobes meet only at the center corner, which the
|
||||
// successor rule pinches into a single contour. They must stay in one
|
||||
// face: splitting them could separate a hole contour from the ring that
|
||||
// encloses it, and a lone hole ring fills solid under `nonzero`.
|
||||
#[rustfmt::skip]
|
||||
let map = grid(2, 2, vec![
|
||||
0, 1,
|
||||
1, 0,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let faces = assemble(&graph, &map);
|
||||
|
||||
assert_eq!(
|
||||
faces.iter().filter(|f| f.region == 0).count(),
|
||||
1,
|
||||
"diagonally touching lobes stay in one face"
|
||||
);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_rings() {
|
||||
// Concentric squares: 0 outer, 1 middle, 2 center.
|
||||
let l = |x: i32, y: i32| -> RegionId {
|
||||
let d = x.min(y).min(5 - x).min(5 - y);
|
||||
match d {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
};
|
||||
let mut labels = Vec::new();
|
||||
for y in 0..6 {
|
||||
for x in 0..6 {
|
||||
labels.push(l(x, y));
|
||||
}
|
||||
}
|
||||
assert_pixel_roundtrip(&grid(6, 6, labels));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_region_border_touching() {
|
||||
// A region that does not fill the canvas; the rest is OUTSIDE.
|
||||
let mut labels = vec![OUTSIDE; 16];
|
||||
for y in 1..3 {
|
||||
for x in 1..3 {
|
||||
labels[y * 4 + x] = 0;
|
||||
}
|
||||
}
|
||||
assert_pixel_roundtrip(&grid(4, 4, labels));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_segments_pin_endpoints_to_lattice() {
|
||||
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
|
||||
// A shape with junctions so there are open (non-ring) segments.
|
||||
let map = grid(4, 4, vec![
|
||||
0, 0, 1, 1,
|
||||
0, 0, 1, 1,
|
||||
2, 2, 1, 1,
|
||||
2, 2, 2, 2,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let fitter = SplineSegmentFitter::default();
|
||||
let mut checked = 0;
|
||||
for seg in &graph.segments {
|
||||
if seg.is_ring() {
|
||||
continue;
|
||||
}
|
||||
let fitted = fitter.fit_open(seg);
|
||||
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
|
||||
let end = {
|
||||
let p = seg.points[seg.points.len() - 1];
|
||||
PointF64 { x: p.x as f64, y: p.y as f64 }
|
||||
};
|
||||
match fitted.geom {
|
||||
FittedGeom::Beziers(b) => {
|
||||
assert_eq!(b.first().unwrap()[0], start, "start pinned to node");
|
||||
assert_eq!(b.last().unwrap()[3], end, "end pinned to node");
|
||||
}
|
||||
FittedGeom::Polyline(p) => {
|
||||
assert_eq!(*p.first().unwrap(), start);
|
||||
assert_eq!(*p.last().unwrap(), end);
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "expected some open segments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_passes_keep_segment_endpoints_pinned() {
|
||||
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
|
||||
use crate::simplify::{CurvePass, SimplifyCurves};
|
||||
// Simplification runs per shared segment; junction nodes must not
|
||||
// move or the faces meeting there would disagree.
|
||||
let map = grid(4, 4, vec![
|
||||
0, 0, 1, 1,
|
||||
0, 0, 1, 1,
|
||||
2, 2, 1, 1,
|
||||
2, 2, 2, 2,
|
||||
]);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let fitter = SplineSegmentFitter::default();
|
||||
let pass = SimplifyCurves {
|
||||
tolerance: 2.0,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
};
|
||||
let mut checked = 0;
|
||||
for seg in &graph.segments {
|
||||
if seg.is_ring() {
|
||||
continue;
|
||||
}
|
||||
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
|
||||
let end = {
|
||||
let p = seg.points[seg.points.len() - 1];
|
||||
PointF64 { x: p.x as f64, y: p.y as f64 }
|
||||
};
|
||||
match pass.open(fitter.fit_open(seg).geom) {
|
||||
FittedGeom::Beziers(b) => {
|
||||
assert_eq!(b.first().unwrap()[0], start, "start pinned through pass");
|
||||
assert_eq!(b.last().unwrap()[3], end, "end pinned through pass");
|
||||
}
|
||||
FittedGeom::Polyline(p) => {
|
||||
assert_eq!(*p.first().unwrap(), start);
|
||||
assert_eq!(*p.last().unwrap(), end);
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "expected some open segments");
|
||||
}
|
||||
|
||||
/// Build a label map with explicit per-region gray levels.
|
||||
fn gray_grid(width: u32, height: u32, labels: Vec<RegionId>, grays: &[u8]) -> LabelMap {
|
||||
LabelMap {
|
||||
width,
|
||||
height,
|
||||
labels,
|
||||
paints: grays
|
||||
.iter()
|
||||
.map(|&g| Paint::Solid(Color::new(g, g, g)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_rejoins_close_neighbours() {
|
||||
// Three vertical strips: 100 | 106 | 220. Diff(0,1) = 18 ≤ 20 → merge;
|
||||
// the merged mean (103) vs 220 stays far apart.
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 2, vec![
|
||||
0, 1, 2,
|
||||
0, 1, 2,
|
||||
], &[100, 106, 220]);
|
||||
map.merge_similar(20);
|
||||
|
||||
assert_eq!(map.paints.len(), 2, "strips 0 and 1 merge; 2 survives");
|
||||
assert_eq!(map.label(0, 0), map.label(1, 0));
|
||||
assert_ne!(map.label(0, 0), map.label(2, 0));
|
||||
// Area-weighted mean of two equal strips of 100 and 106.
|
||||
assert_eq!(map.paints[map.label(0, 0) as usize].color().r, 103);
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_uses_running_means_not_original_colors() {
|
||||
// Gradient chain 100 | 103 | 106 with threshold 9 (grays g apart diff
|
||||
// by 3g across the three channels). The closest pair merges first
|
||||
// (ties broken by id → strips 0,1 → mean 101); the merged region vs
|
||||
// 106 is then 15 apart, over threshold — the chain must NOT collapse
|
||||
// transitively into one region on the strength of the original colors.
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 103, 106]);
|
||||
map.merge_similar(9);
|
||||
|
||||
assert_eq!(map.paints.len(), 2, "running mean stops the chain");
|
||||
assert_eq!(map.label(0, 0), map.label(1, 0));
|
||||
assert_ne!(map.label(1, 0), map.label(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_ignores_outside_and_non_neighbours() {
|
||||
// Two same-colored regions separated by OUTSIDE: not adjacent, so they
|
||||
// must stay distinct faces (merging them would create a disjoint
|
||||
// region, which face assembly handles, but the ids must stay honest to
|
||||
// the partition).
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 1, vec![0, OUTSIDE, 1], &[100, 100]);
|
||||
map.merge_similar(20);
|
||||
|
||||
assert_eq!(map.paints.len(), 2, "non-adjacent regions never merge");
|
||||
assert_eq!(map.label(1, 0), OUTSIDE, "outside pixels are untouched");
|
||||
assert_pixel_roundtrip(&map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_similar_zero_threshold_merges_only_identical_colors() {
|
||||
// Regions 0 and 1 share a color; region 2 differs by one level. At
|
||||
// threshold 0 the identical pair merges, the near-identical one stays.
|
||||
#[rustfmt::skip]
|
||||
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 100, 101]);
|
||||
map.merge_similar(0);
|
||||
assert_eq!(map.paints.len(), 2, "identical neighbours merge at 0");
|
||||
assert_eq!(map.label(0, 0), map.label(1, 0));
|
||||
assert_ne!(map.label(1, 0), map.label(2, 0));
|
||||
|
||||
// A negative threshold disables merging entirely.
|
||||
let labels = vec![0, 1, 0, 1];
|
||||
let mut map = gray_grid(2, 2, labels.clone(), &[100, 100]);
|
||||
map.merge_similar(-1);
|
||||
assert_eq!(map.labels, labels);
|
||||
assert_eq!(map.paints.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_mosaic_merges_gradient_faces() {
|
||||
use super::compose_mosaic;
|
||||
use super::fit::PixelSegmentFitter;
|
||||
use crate::ir::{Layer, RegionMask, Segmentation};
|
||||
use visioncortex::BinaryImage;
|
||||
|
||||
// A 6x2 canvas of three 2px strips, one gradient step apart (diff 6),
|
||||
// as bottom-to-top layers — exactly what a stacked gradient flattens
|
||||
// into. With merging they are one face; without, three.
|
||||
let mut seg = Segmentation::new(6, 2);
|
||||
for (i, g) in [(0, 100u8), (1, 102), (2, 104)] {
|
||||
let mut image = BinaryImage::new_w_h(2, 2);
|
||||
for y in 0..2 {
|
||||
for x in 0..2 {
|
||||
image.set_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
seg.layers.push(Layer {
|
||||
paint: Paint::Solid(Color::new(g, g, g)),
|
||||
mask: RegionMask::new(
|
||||
image,
|
||||
visioncortex::PointI32 { x: i * 2, y: 0 },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let unmerged = compose_mosaic(&seg, &PixelSegmentFitter, 0, &[]);
|
||||
let merged = compose_mosaic(&seg, &PixelSegmentFitter, 16, &[]);
|
||||
assert_eq!(unmerged.shapes.len(), 3);
|
||||
assert_eq!(merged.shapes.len(), 1, "gradient strips coalesce into one face");
|
||||
assert_eq!(merged.shapes[0].paint.color().r, 102, "area-weighted mean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_maps_roundtrip() {
|
||||
// Deterministic LCG; connectivity not required.
|
||||
let mut state: u64 = 0x1234_5678_9abc_def0;
|
||||
let mut next = || {
|
||||
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(state >> 33) as u32
|
||||
};
|
||||
for _ in 0..40 {
|
||||
let w = 2 + next() % 10;
|
||||
let h = 2 + next() % 10;
|
||||
let nlabels = 1 + next() % 5;
|
||||
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
|
||||
assert_pixel_roundtrip(&grid(w, h, labels));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Stage 4: compose per-region SVG paths from shared fitted segments.
|
||||
//!
|
||||
//! Each region becomes one shape whose `d` concatenates its contours as
|
||||
//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each
|
||||
//! oriented segment is emitted skipping its first point (identical to the
|
||||
//! previous segment's last point), so shared boundaries are byte-identical on
|
||||
//! both sides.
|
||||
|
||||
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
|
||||
use crate::simplify::CurvePass;
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use super::face::{assemble, Contour, Face};
|
||||
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
|
||||
use super::graph::BoundaryGraph;
|
||||
use super::{LabelMap, Segmentation};
|
||||
|
||||
/// Run the full mosaic pipeline: flatten → merge similar neighbours →
|
||||
/// boundary graph → faces → fit → curve passes → compose.
|
||||
///
|
||||
/// `merge_diff` is the color-difference threshold for
|
||||
/// [`LabelMap::merge_similar`]; pass the clustering `deepen_diff`
|
||||
/// (gradient step) so the flattened mosaic rejoins what only the stacked
|
||||
/// gradient layering had split. `0` still merges identical-color
|
||||
/// neighbours; negative disables merging entirely.
|
||||
///
|
||||
/// `passes` run on each fitted segment before composition — once per shared
|
||||
/// boundary, so both adjacent faces reference the transformed geometry and
|
||||
/// the tessellation stays seam-free.
|
||||
pub fn compose_mosaic(
|
||||
seg: &Segmentation,
|
||||
fitter: &dyn SegmentFitter,
|
||||
merge_diff: i32,
|
||||
passes: &[Box<dyn CurvePass>],
|
||||
) -> VectorDoc {
|
||||
let mut map = LabelMap::from_segmentation(seg);
|
||||
map.merge_similar(merge_diff);
|
||||
let graph = BoundaryGraph::extract(&map);
|
||||
let faces = assemble(&graph, &map);
|
||||
|
||||
// Fit every segment exactly once; both adjacent faces share the result.
|
||||
let fitted: Vec<FittedSegment> = graph
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let ring = s.is_ring();
|
||||
let mut geom = if ring {
|
||||
fitter.fit_ring(s).geom
|
||||
} else {
|
||||
fitter.fit_open(s).geom
|
||||
};
|
||||
for pass in passes {
|
||||
geom = if ring { pass.ring(geom) } else { pass.open(geom) };
|
||||
}
|
||||
FittedSegment { geom }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut doc = VectorDoc::new(seg.width, seg.height);
|
||||
for face in &faces {
|
||||
let path = build_path(face, &fitted, &graph);
|
||||
if !path.is_empty() {
|
||||
doc.shapes.push(Shape {
|
||||
paint: map.paints[face.region as usize],
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn build_path(face: &Face, fitted: &[FittedSegment], _graph: &BoundaryGraph) -> MultiPath {
|
||||
let mut mp = MultiPath::new();
|
||||
for contour in &face.contours {
|
||||
let mut sub = SubPath::new();
|
||||
emit_contour(contour, fitted, &mut sub);
|
||||
if !sub.is_empty() {
|
||||
sub.commands.push(PathCmd::Close);
|
||||
mp.subpaths.push(sub);
|
||||
}
|
||||
}
|
||||
mp
|
||||
}
|
||||
|
||||
fn emit_contour(contour: &Contour, fitted: &[FittedSegment], sub: &mut SubPath) {
|
||||
for (i, sref) in contour.0.iter().enumerate() {
|
||||
let geom = &fitted[sref.seg as usize].geom;
|
||||
emit_segment(geom, sref.forward, i == 0, sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one oriented segment's commands. When `first`, opens with a `MoveTo`;
|
||||
/// otherwise the leading point (shared with the previous segment) is skipped.
|
||||
fn emit_segment(geom: &FittedGeom, forward: bool, first: bool, sub: &mut SubPath) {
|
||||
match geom {
|
||||
FittedGeom::Polyline(pts) => {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let ordered: Vec<PointF64> = if forward {
|
||||
pts.clone()
|
||||
} else {
|
||||
pts.iter().rev().copied().collect()
|
||||
};
|
||||
if first {
|
||||
sub.commands.push(PathCmd::MoveTo(ordered[0]));
|
||||
}
|
||||
for p in &ordered[1..] {
|
||||
sub.commands.push(PathCmd::LineTo(*p));
|
||||
}
|
||||
}
|
||||
FittedGeom::Beziers(curves) => {
|
||||
if curves.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Reversing a cubic is exact: [p0,p1,p2,p3] -> [p3,p2,p1,p0], and
|
||||
// the whole chain reverses in order too.
|
||||
let ordered: Vec<[PointF64; 4]> = if forward {
|
||||
curves.clone()
|
||||
} else {
|
||||
curves
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|c| [c[3], c[2], c[1], c[0]])
|
||||
.collect()
|
||||
};
|
||||
if first {
|
||||
sub.commands.push(PathCmd::MoveTo(ordered[0][0]));
|
||||
}
|
||||
for c in &ordered {
|
||||
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Stage 2: face assembly.
|
||||
//!
|
||||
//! Lift the "region kept on the left" successor rule from unit edges to whole
|
||||
//! segments. Following it around each region yields its contours; because the
|
||||
//! interior is always on the left, outer contours and hole contours come out
|
||||
//! with opposite winding automatically — no containment/nesting computation is
|
||||
//! needed, and the region can be filled with a single `nonzero` path.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::graph::{
|
||||
dir_from_delta, edge_present, left_pixel_at, left_pixel_coord, reverse, straight, turn_left,
|
||||
turn_right, BoundaryGraph, SegRef,
|
||||
};
|
||||
use super::{LabelMap, RegionId, OUTSIDE};
|
||||
|
||||
/// Island id for pixels that belong to no region.
|
||||
const NO_ISLAND: u32 = u32::MAX;
|
||||
|
||||
/// A closed cycle of directed segments bounding (part of) a region.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Contour(pub Vec<SegRef>);
|
||||
|
||||
/// One connected patch of a region and all of its contours (outer + holes).
|
||||
///
|
||||
/// A region can appear as several disjoint patches; each gets its own face, so
|
||||
/// isolated islands never share a path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Face {
|
||||
pub region: RegionId,
|
||||
pub contours: Vec<Contour>,
|
||||
}
|
||||
|
||||
/// Connected-component ("island") id per pixel, grouping equal labels with
|
||||
/// 8-connectivity. [`OUTSIDE`] pixels get [`NO_ISLAND`].
|
||||
///
|
||||
/// 8-connectivity is what matches [`successor`]: it pinches a checkerboard
|
||||
/// corner into one contour, so two lobes meeting only at a diagonal are walked
|
||||
/// as a single contour and must land in a single face. Splitting them
|
||||
/// (4-connectivity) could put a hole contour in a different face than the ring
|
||||
/// enclosing it, and a lone hole ring fills solid under `nonzero`.
|
||||
fn islands(map: &LabelMap) -> Vec<u32> {
|
||||
let (w, h) = (map.width as usize, map.height as usize);
|
||||
let mut ids = vec![NO_ISLAND; w * h];
|
||||
let mut next = 0u32;
|
||||
let mut stack: Vec<(usize, usize)> = Vec::new();
|
||||
|
||||
for start in 0..w * h {
|
||||
if ids[start] != NO_ISLAND || map.labels[start] == OUTSIDE {
|
||||
continue;
|
||||
}
|
||||
let label = map.labels[start];
|
||||
let id = next;
|
||||
next += 1;
|
||||
ids[start] = id;
|
||||
stack.push((start % w, start / w));
|
||||
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let n = ny as usize * w + nx as usize;
|
||||
if ids[n] == NO_ISLAND && map.labels[n] == label {
|
||||
ids[n] = id;
|
||||
stack.push((nx as usize, ny as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Which island a contour bounds, taken from the region-side pixel flanking its
|
||||
/// first directed edge. Every contour is walked with its region on the left, so
|
||||
/// that pixel is always interior to the patch the contour belongs to — an outer
|
||||
/// ring and the holes inside it therefore agree.
|
||||
fn island_of(graph: &BoundaryGraph, map: &LabelMap, ids: &[u32], r: SegRef) -> u32 {
|
||||
let seg = &graph.segments[r.seg as usize];
|
||||
let (corner, dir) = if seg.is_ring() {
|
||||
let n = seg.points.len();
|
||||
// A ring is used forward by the region on its left, reversed by the one
|
||||
// on its right; take the first step of the chosen direction.
|
||||
let (from, to) = if r.forward {
|
||||
(seg.points[0], seg.points[1])
|
||||
} else {
|
||||
(seg.points[n - 1], seg.points[n - 2])
|
||||
};
|
||||
(from, dir_from_delta(to.x - from.x, to.y - from.y))
|
||||
} else if r.forward {
|
||||
let node = seg.start.expect("non-ring segment has a start node");
|
||||
(graph.nodes[node as usize].corner, seg.first_dir)
|
||||
} else {
|
||||
let node = seg.end.expect("non-ring segment has an end node");
|
||||
(graph.nodes[node as usize].corner, reverse(seg.last_dir))
|
||||
};
|
||||
|
||||
let (px, py) = left_pixel_coord(corner.x, corner.y, dir);
|
||||
if px < 0 || py < 0 || px as u32 >= map.width || py as u32 >= map.height {
|
||||
return NO_ISLAND;
|
||||
}
|
||||
ids[py as usize * map.width as usize + px as usize]
|
||||
}
|
||||
|
||||
/// Left region of a directed segment view.
|
||||
fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId {
|
||||
let seg = &graph.segments[r.seg as usize];
|
||||
if r.forward {
|
||||
seg.left
|
||||
} else {
|
||||
seg.right
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the next unit direction leaving `corner`, keeping region `r` on the
|
||||
/// left: sharpest right turn first (this pinches checkerboard nodes and keeps
|
||||
/// contours simple).
|
||||
fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
|
||||
for &d in &[turn_right(d_in), straight(d_in), turn_left(d_in)] {
|
||||
if edge_present(map, x, y, d) && left_pixel_at(map, x, y, d) == r {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
unreachable!("no successor edge keeps the region on the left");
|
||||
}
|
||||
|
||||
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
|
||||
let ids = islands(map);
|
||||
// Keyed by (region, island) rather than by region alone, so disjoint patches
|
||||
// of one region become separate faces — and separate paths downstream. The
|
||||
// BTreeMap keeps face order deterministic: region ascending, then island in
|
||||
// raster-scan order.
|
||||
let mut by_island: BTreeMap<(RegionId, u32), Vec<Contour>> = BTreeMap::new();
|
||||
// usage[seg][0] = forward view used, [1] = backward view used.
|
||||
let mut used = vec![[false; 2]; graph.segments.len()];
|
||||
|
||||
for seg_id in 0..graph.segments.len() {
|
||||
if graph.segments[seg_id].is_ring() {
|
||||
continue;
|
||||
}
|
||||
for &forward in &[true, false] {
|
||||
let start = SegRef {
|
||||
seg: seg_id as u32,
|
||||
forward,
|
||||
};
|
||||
let region = left_region(graph, start);
|
||||
if region == OUTSIDE || used[seg_id][forward as usize] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut contour = Vec::new();
|
||||
let mut cur = start;
|
||||
loop {
|
||||
used[cur.seg as usize][cur.forward as usize] = true;
|
||||
contour.push(cur);
|
||||
|
||||
let seg = &graph.segments[cur.seg as usize];
|
||||
let (node_id, d_in) = if cur.forward {
|
||||
(seg.end.unwrap(), seg.last_dir)
|
||||
} else {
|
||||
(seg.start.unwrap(), reverse(seg.first_dir))
|
||||
};
|
||||
let corner = graph.nodes[node_id as usize].corner;
|
||||
let d_next = successor(map, corner.x, corner.y, d_in, region);
|
||||
cur = graph.nodes[node_id as usize].out[d_next as usize]
|
||||
.expect("successor direction must have an outgoing segment");
|
||||
|
||||
if cur == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (region as usize) < map.paints.len() {
|
||||
let island = island_of(graph, map, &ids, start);
|
||||
by_island
|
||||
.entry((region, island))
|
||||
.or_default()
|
||||
.push(Contour(contour));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rings: the left side uses it forward, the right side reversed.
|
||||
for seg_id in 0..graph.segments.len() {
|
||||
let seg = &graph.segments[seg_id];
|
||||
if !seg.is_ring() {
|
||||
continue;
|
||||
}
|
||||
for (region, forward) in [(seg.left, true), (seg.right, false)] {
|
||||
if region == OUTSIDE || (region as usize) >= map.paints.len() {
|
||||
continue;
|
||||
}
|
||||
let r = SegRef {
|
||||
seg: seg_id as u32,
|
||||
forward,
|
||||
};
|
||||
let island = island_of(graph, map, &ids, r);
|
||||
by_island
|
||||
.entry((region, island))
|
||||
.or_default()
|
||||
.push(Contour(vec![r]));
|
||||
}
|
||||
}
|
||||
|
||||
by_island
|
||||
.into_iter()
|
||||
.map(|((region, _island), contours)| Face { region, contours })
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes.
|
||||
//!
|
||||
//! A segment is fitted a single time and cached; both adjacent faces reference
|
||||
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
|
||||
//! the shared geometry is bitwise identical and no seam can appear.
|
||||
|
||||
use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideSmooth};
|
||||
|
||||
use super::graph::Segment;
|
||||
|
||||
pub use crate::fitter::FittedGeom;
|
||||
|
||||
/// Outset ratio for the 4-point subdivision scheme (matches visioncortex).
|
||||
const OUTSET_RATIO: f64 = 8.0;
|
||||
|
||||
/// A fitted segment, cached and indexed by segment id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FittedSegment {
|
||||
pub geom: FittedGeom,
|
||||
}
|
||||
|
||||
/// Fits a single boundary segment. `fit_open` pins both endpoints (junction
|
||||
/// nodes must not move); `fit_ring` fits a closed loop with no pinned point.
|
||||
pub trait SegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment;
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment;
|
||||
}
|
||||
|
||||
fn to_f64(points: &[PointI32]) -> Vec<PointF64> {
|
||||
points
|
||||
.iter()
|
||||
.map(|p| PointF64 {
|
||||
x: p.x as f64,
|
||||
y: p.y as f64,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Identity fitter: lattice points as f64. Produces an exact tessellation and
|
||||
/// is the reference backend for tests.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PixelSegmentFitter;
|
||||
|
||||
impl SegmentFitter for PixelSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
}
|
||||
}
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-segment fitter. Uses visioncortex's symmetric `limit_penalties`
|
||||
/// simplification, which collapses 1px staircases toward the crack midline
|
||||
/// (centered, no directional outset) so the boundary stays gapless. Endpoints
|
||||
/// are preserved, pinning junction nodes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PolygonSegmentFitter;
|
||||
|
||||
impl PolygonSegmentFitter {
|
||||
fn fit(&self, seg: &Segment) -> FittedSegment {
|
||||
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SegmentFitter for PolygonSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
self.fit(seg)
|
||||
}
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
self.fit(seg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth (cubic-Bézier) open-path fitter — the mosaic analogue of the stacked
|
||||
/// [`crate::fitter::SplineFitter`], but for open segments with pinned
|
||||
/// endpoints.
|
||||
///
|
||||
/// Staircase removal reuses visioncortex's symmetric `limit_penalties`
|
||||
/// simplification (the same de-noising stacked mode applies), which collapses
|
||||
/// staircases toward the crack midline. Unlike `remove_staircase`, it has no
|
||||
/// directional outset, so the boundary stays centered (≤√2/2 px from its
|
||||
/// crack) and cannot cross a non-adjacent segment — the tessellation stays
|
||||
/// gapless. A distance-based DP can't do this: near the √2/2 threshold it
|
||||
/// can't separate staircase noise from real curvature. Smoothing and per-slice
|
||||
/// cubic fitting then reuse the same visioncortex machinery stacked mode uses
|
||||
/// (open-path variants of the smoothing primitives + `fit_points_with_beziers`),
|
||||
/// so the curve character matches stacked.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SplineSegmentFitter {
|
||||
/// Corner angle threshold, radians.
|
||||
pub corner_threshold: f64,
|
||||
/// Subdivide until segments are shorter than this (px).
|
||||
pub length_threshold: f64,
|
||||
pub max_iterations: usize,
|
||||
/// Splice angle threshold, radians.
|
||||
pub splice_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for SplineSegmentFitter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
length_threshold: 4.0,
|
||||
max_iterations: 10,
|
||||
splice_threshold: std::f64::consts::PI / 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pt(p: PointI32) -> PointF64 {
|
||||
PointF64 {
|
||||
x: p.x as f64,
|
||||
y: p.y as f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate cubic tracing the straight line `a`→`b`.
|
||||
fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] {
|
||||
let c1 = PointF64 {
|
||||
x: a.x + (b.x - a.x) / 3.0,
|
||||
y: a.y + (b.y - a.y) / 3.0,
|
||||
};
|
||||
let c2 = PointF64 {
|
||||
x: a.x + 2.0 * (b.x - a.x) / 3.0,
|
||||
y: a.y + 2.0 * (b.y - a.y) / 3.0,
|
||||
};
|
||||
[a, c1, c2, b]
|
||||
}
|
||||
|
||||
/// Error bound for the per-slice cubic fit. Matches the value stacked mode
|
||||
/// uses in `Spline::from_path_f64`, so mosaic curves have the same character.
|
||||
const FIT_ERROR: f64 = 10.0;
|
||||
|
||||
/// Fit one splice slice, exactly as stacked mode does
|
||||
/// (`fit_points_with_beziers`: the full retract-handled cubic chain per slice,
|
||||
/// outer endpoints pinned to the slice ends — a sparse or multi-curve slice is
|
||||
/// kept faithful instead of being collapsed onto one ballooning cubic).
|
||||
fn fit_slice(slice: &[PointF64], out: &mut Vec<[PointF64; 4]>) {
|
||||
match slice.len() {
|
||||
0 | 1 => {}
|
||||
2 => out.push(straight_cubic(slice[0], slice[1])),
|
||||
_ => out.extend(SubdivideSmooth::fit_points_with_beziers(slice, FIT_ERROR)),
|
||||
}
|
||||
}
|
||||
|
||||
fn spline_to_beziers(spline: &Spline) -> Vec<[PointF64; 4]> {
|
||||
spline
|
||||
.get_control_points()
|
||||
.into_iter()
|
||||
.filter(|w| w.len() == 4)
|
||||
.map(|w| [w[0], w[1], w[2], w[3]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl SegmentFitter for SplineSegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment {
|
||||
if seg.points.len() <= 2 {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Staircase removal via visioncortex's `limit_penalties` — the
|
||||
// symmetric (area-based, no directional outset) simplifier stacked
|
||||
// mode runs after remove_staircase. Used alone here it collapses
|
||||
// staircases toward the crack midline, so the boundary stays
|
||||
// centered and cannot cross a non-adjacent segment (which would
|
||||
// open a gap in the tessellation). Endpoints are preserved.
|
||||
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
|
||||
if simplified.len() <= 2 {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Corner detection (open, endpoints forced as corners).
|
||||
let mut corners = SubdivideSmooth::find_corners(&simplified, self.corner_threshold, false);
|
||||
// 3. Open 4-point subdivision.
|
||||
let mut path = simplified.to_path_f64();
|
||||
for _ in 0..self.max_iterations {
|
||||
let (np, nc, done) = SubdivideSmooth::subdivide_keep_corners(
|
||||
&path,
|
||||
&corners,
|
||||
OUTSET_RATIO,
|
||||
self.length_threshold,
|
||||
false,
|
||||
);
|
||||
path = np;
|
||||
corners = nc;
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 4. Splice points (open, endpoints forced).
|
||||
let splice = SubdivideSmooth::find_splice_points(&path, self.splice_threshold, false);
|
||||
let cuts: Vec<usize> = splice
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &s)| if s { Some(i) } else { None })
|
||||
.collect();
|
||||
|
||||
// 5. Per-slice cubic fit.
|
||||
let mut beziers = Vec::new();
|
||||
for w in cuts.windows(2) {
|
||||
fit_slice(&path.path[w[0]..=w[1]], &mut beziers);
|
||||
}
|
||||
|
||||
if beziers.is_empty() {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(path.path.clone()),
|
||||
};
|
||||
}
|
||||
|
||||
// Pin the segment's endpoints exactly to the lattice nodes so that
|
||||
// segments meeting at a junction share identical coordinates.
|
||||
beziers.first_mut().unwrap()[0] = pt(seg.points[0]);
|
||||
beziers.last_mut().unwrap()[3] = pt(seg.points[seg.points.len() - 1]);
|
||||
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Beziers(beziers),
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
|
||||
// Rings are closed loops — this is exactly the stacked closed-spline
|
||||
// pipeline (simplify → smooth → fit).
|
||||
if seg.points.len() <= 4 {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
};
|
||||
}
|
||||
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
|
||||
let smoothed = simplified.smooth(
|
||||
self.corner_threshold,
|
||||
OUTSET_RATIO,
|
||||
self.length_threshold,
|
||||
self.max_iterations,
|
||||
);
|
||||
let spline = Spline::from_path_f64(&smoothed, self.splice_threshold);
|
||||
let beziers = spline_to_beziers(&spline);
|
||||
if beziers.is_empty() {
|
||||
return FittedSegment {
|
||||
geom: FittedGeom::Polyline(to_f64(&seg.points)),
|
||||
};
|
||||
}
|
||||
FittedSegment {
|
||||
geom: FittedGeom::Beziers(beziers),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Stage 1: boundary-graph extraction from a [`LabelMap`].
|
||||
//!
|
||||
//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`.
|
||||
//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run
|
||||
//! along the "cracks" between differing labels.
|
||||
|
||||
use visioncortex::PointI32;
|
||||
|
||||
use super::{LabelMap, RegionId, OUTSIDE};
|
||||
|
||||
pub type NodeId = u32;
|
||||
pub type SegId = u32;
|
||||
|
||||
// Unit directions, arranged clockwise in y-down screen space so that
|
||||
// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal.
|
||||
const N: u8 = 0;
|
||||
const E: u8 = 1;
|
||||
const S: u8 = 2;
|
||||
const W: u8 = 3;
|
||||
/// (dx, dy) per direction.
|
||||
const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||
|
||||
#[inline]
|
||||
pub(super) fn turn_right(d: u8) -> u8 {
|
||||
(d + 1) % 4
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn straight(d: u8) -> u8 {
|
||||
d
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn turn_left(d: u8) -> u8 {
|
||||
(d + 3) % 4
|
||||
}
|
||||
#[inline]
|
||||
pub(super) fn reverse(d: u8) -> u8 {
|
||||
(d + 2) % 4
|
||||
}
|
||||
|
||||
/// A directed reference to a segment: either traversed forward or reversed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SegRef {
|
||||
pub seg: SegId,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// A junction corner (degree ≥ 3) with the segment leaving it in each unit
|
||||
/// direction (if any).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Node {
|
||||
pub corner: PointI32,
|
||||
pub out: [Option<SegRef>; 4],
|
||||
}
|
||||
|
||||
/// A maximal boundary chain between two nodes, or a nodeless ring.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Segment {
|
||||
/// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`.
|
||||
pub points: Vec<PointI32>,
|
||||
pub start: Option<NodeId>,
|
||||
pub end: Option<NodeId>,
|
||||
/// Region on the left when traversing forward (y-down convention).
|
||||
pub left: RegionId,
|
||||
pub right: RegionId,
|
||||
/// Direction of the first edge (leaving `start`); unused for rings.
|
||||
pub first_dir: u8,
|
||||
/// Direction of the last edge (arriving at `end`); unused for rings.
|
||||
pub last_dir: u8,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn is_ring(&self) -> bool {
|
||||
self.start.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// The extracted boundary graph. Faces are assembled separately (see `face`).
|
||||
pub struct BoundaryGraph {
|
||||
pub nodes: Vec<Node>,
|
||||
pub segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
struct Extractor<'a> {
|
||||
map: &'a LabelMap,
|
||||
w: i32,
|
||||
h: i32,
|
||||
/// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1).
|
||||
node_at: Vec<NodeId>,
|
||||
/// Visited flags for undirected unit edges.
|
||||
visited_v: Vec<bool>, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x
|
||||
visited_h: Vec<bool>, // horizontal edge (x in 0..W, y in 0..=H): y*W + x
|
||||
nodes: Vec<Node>,
|
||||
segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
impl<'a> Extractor<'a> {
|
||||
fn new(map: &'a LabelMap) -> Self {
|
||||
let w = map.width as i32;
|
||||
let h = map.height as i32;
|
||||
let cw = (map.width + 1) as usize;
|
||||
let ch = (map.height + 1) as usize;
|
||||
Extractor {
|
||||
map,
|
||||
w,
|
||||
h,
|
||||
node_at: vec![u32::MAX; cw * ch],
|
||||
visited_v: vec![false; (map.width as usize + 1) * map.height as usize],
|
||||
visited_h: vec![false; map.width as usize * (map.height as usize + 1)],
|
||||
nodes: Vec::new(),
|
||||
segments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn corner_index(&self, x: i32, y: i32) -> usize {
|
||||
y as usize * (self.w as usize + 1) + x as usize
|
||||
}
|
||||
|
||||
/// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`.
|
||||
fn edge_mask(&self, x: i32, y: i32) -> u8 {
|
||||
let nw = self.map.label(x - 1, y - 1);
|
||||
let ne = self.map.label(x, y - 1);
|
||||
let sw = self.map.label(x - 1, y);
|
||||
let se = self.map.label(x, y);
|
||||
let mut m = 0u8;
|
||||
if nw != ne {
|
||||
m |= 1 << N;
|
||||
}
|
||||
if ne != se {
|
||||
m |= 1 << E;
|
||||
}
|
||||
if sw != se {
|
||||
m |= 1 << S;
|
||||
}
|
||||
if nw != sw {
|
||||
m |= 1 << W;
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`.
|
||||
fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) {
|
||||
let nw = self.map.label(x - 1, y - 1);
|
||||
let ne = self.map.label(x, y - 1);
|
||||
let sw = self.map.label(x - 1, y);
|
||||
let se = self.map.label(x, y);
|
||||
match d {
|
||||
N => (nw, ne),
|
||||
E => (ne, se),
|
||||
S => (se, sw),
|
||||
W => (sw, nw),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`.
|
||||
/// Returns the canonical (is_vertical, index).
|
||||
fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) {
|
||||
match d {
|
||||
N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize),
|
||||
S => (true, y as usize * (self.w as usize + 1) + x as usize),
|
||||
E => (false, y as usize * self.w as usize + x as usize),
|
||||
W => (false, y as usize * self.w as usize + (x - 1) as usize),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_visited(&self, x: i32, y: i32, d: u8) -> bool {
|
||||
let (v, i) = self.edge_slot(x, y, d);
|
||||
if v {
|
||||
self.visited_v[i]
|
||||
} else {
|
||||
self.visited_h[i]
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_visited(&mut self, x: i32, y: i32, d: u8) {
|
||||
let (v, i) = self.edge_slot(x, y, d);
|
||||
if v {
|
||||
self.visited_v[i] = true;
|
||||
} else {
|
||||
self.visited_h[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass A — classify corners and allocate node ids for degree ≥ 3.
|
||||
fn classify(&mut self) {
|
||||
for y in 0..=self.h {
|
||||
for x in 0..=self.w {
|
||||
let deg = self.edge_mask(x, y).count_ones();
|
||||
if deg >= 3 {
|
||||
let id = self.nodes.len() as NodeId;
|
||||
self.nodes.push(Node {
|
||||
corner: PointI32 { x, y },
|
||||
out: [None; 4],
|
||||
});
|
||||
let ci = self.corner_index(x, y);
|
||||
self.node_at[ci] = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node_id(&self, x: i32, y: i32) -> Option<NodeId> {
|
||||
let id = self.node_at[self.corner_index(x, y)];
|
||||
if id == u32::MAX {
|
||||
None
|
||||
} else {
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to
|
||||
/// the start). Returns the polyline, the final heading, and the corner
|
||||
/// walked to. Marks every traversed edge visited.
|
||||
fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec<PointI32>, u8, i32, i32) {
|
||||
let mut points = vec![PointI32 { x: x0, y: y0 }];
|
||||
let (mut cx, mut cy, mut d) = (x0, y0, d0);
|
||||
loop {
|
||||
self.mark_visited(cx, cy, d);
|
||||
let (dx, dy) = DVEC[d as usize];
|
||||
let (nx, ny) = (cx + dx, cy + dy);
|
||||
points.push(PointI32 { x: nx, y: ny });
|
||||
|
||||
let mask = self.edge_mask(nx, ny);
|
||||
if mask.count_ones() >= 3 {
|
||||
return (points, d, nx, ny); // reached a node
|
||||
}
|
||||
if nx == x0 && ny == y0 {
|
||||
return (points, d, nx, ny); // closed ring
|
||||
}
|
||||
// Degree-2: continue via the unique present edge that is not the
|
||||
// reverse of how we arrived.
|
||||
let rev = reverse(d);
|
||||
let mut nd = d;
|
||||
for cand in 0..4u8 {
|
||||
if cand != rev && (mask & (1 << cand)) != 0 {
|
||||
nd = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
d = nd;
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass B — trace node-to-node segments.
|
||||
fn trace_segments(&mut self) {
|
||||
let node_corners: Vec<PointI32> = self.nodes.iter().map(|n| n.corner).collect();
|
||||
for (nid, corner) in node_corners.iter().enumerate() {
|
||||
let nid = nid as NodeId;
|
||||
let (x, y) = (corner.x, corner.y);
|
||||
let mask = self.edge_mask(x, y);
|
||||
for d in 0..4u8 {
|
||||
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
|
||||
continue;
|
||||
}
|
||||
let (left, right) = self.side_pixels(x, y, d);
|
||||
let (points, last_dir, ex, ey) = self.walk(x, y, d);
|
||||
let end = self
|
||||
.node_id(ex, ey)
|
||||
.expect("segment must end at a node");
|
||||
|
||||
let seg_id = self.segments.len() as SegId;
|
||||
self.segments.push(Segment {
|
||||
points,
|
||||
start: Some(nid),
|
||||
end: Some(end),
|
||||
left,
|
||||
right,
|
||||
first_dir: d,
|
||||
last_dir,
|
||||
});
|
||||
self.nodes[nid as usize].out[d as usize] = Some(SegRef {
|
||||
seg: seg_id,
|
||||
forward: true,
|
||||
});
|
||||
// Leaving the end node backward along this segment.
|
||||
let back = reverse(last_dir);
|
||||
self.nodes[end as usize].out[back as usize] = Some(SegRef {
|
||||
seg: seg_id,
|
||||
forward: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass C — closed rings from any remaining unvisited boundary edges.
|
||||
fn trace_rings(&mut self) {
|
||||
for y in 0..=self.h {
|
||||
for x in 0..=self.w {
|
||||
let mask = self.edge_mask(x, y);
|
||||
for d in 0..4u8 {
|
||||
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
|
||||
continue;
|
||||
}
|
||||
let (left, right) = self.side_pixels(x, y, d);
|
||||
let (points, _last, _ex, _ey) = self.walk(x, y, d);
|
||||
self.segments.push(Segment {
|
||||
points,
|
||||
start: None,
|
||||
end: None,
|
||||
left,
|
||||
right,
|
||||
first_dir: d,
|
||||
last_dir: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BoundaryGraph {
|
||||
pub fn extract(map: &LabelMap) -> BoundaryGraph {
|
||||
let mut ex = Extractor::new(map);
|
||||
ex.classify();
|
||||
ex.trace_segments();
|
||||
ex.trace_rings();
|
||||
BoundaryGraph {
|
||||
nodes: ex.nodes,
|
||||
segments: ex.segments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel flanking the left of the directed edge leaving `(x,y)` in `d`. May be
|
||||
/// out of bounds, in which case it is [`OUTSIDE`] as far as the map is concerned.
|
||||
pub(super) fn left_pixel_coord(x: i32, y: i32, d: u8) -> (i32, i32) {
|
||||
match d {
|
||||
N => (x - 1, y - 1),
|
||||
E => (x, y - 1),
|
||||
S => (x, y),
|
||||
W => (x - 1, y),
|
||||
_ => (x, y),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit direction of a single lattice step.
|
||||
pub(super) fn dir_from_delta(dx: i32, dy: i32) -> u8 {
|
||||
DVEC.iter()
|
||||
.position(|&v| v == (dx, dy))
|
||||
.expect("consecutive lattice points differ by one unit step") as u8
|
||||
}
|
||||
|
||||
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
|
||||
/// face-assembly successor rule against a [`LabelMap`].
|
||||
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
|
||||
if !matches!(d, N | E | S | W) {
|
||||
return OUTSIDE;
|
||||
}
|
||||
let (px, py) = left_pixel_coord(x, y, d);
|
||||
map.label(px, py)
|
||||
}
|
||||
|
||||
// Direction constants and edge-present test needed by face assembly.
|
||||
pub(super) fn edge_present(map: &LabelMap, x: i32, y: i32, d: u8) -> bool {
|
||||
let nw = map.label(x - 1, y - 1);
|
||||
let ne = map.label(x, y - 1);
|
||||
let sw = map.label(x - 1, y);
|
||||
let se = map.label(x, y);
|
||||
match d {
|
||||
N => nw != ne,
|
||||
E => ne != se,
|
||||
S => sw != se,
|
||||
W => nw != sw,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Optimizer passes over the [`VectorDoc`] before serialization.
|
||||
//!
|
||||
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
|
||||
//! it here (rather than at write time) lets [`CleanupPass`] act on the
|
||||
//! rounded geometry, and it bakes offsets into coordinates so the writer
|
||||
//! never needs a per-path `translate`.
|
||||
//! * [`CleanupPass`] — drop zero-length and collinear-redundant segments that
|
||||
//! quantization may have created. (Curve *simplification* is not an
|
||||
//! optimizer pass: it must run on shared fitted geometry before composition
|
||||
//! — see [`crate::simplify`].)
|
||||
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use crate::ir::{MultiPath, PathCmd, SubPath, VectorDoc};
|
||||
|
||||
/// An optimizer pass rewrites the document in place.
|
||||
pub trait OptimizerPass {
|
||||
fn run(&self, doc: &mut VectorDoc);
|
||||
}
|
||||
|
||||
/// Round all coordinates to `precision` decimal places.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct QuantizePass {
|
||||
pub precision: u32,
|
||||
}
|
||||
|
||||
impl QuantizePass {
|
||||
pub fn new(precision: u32) -> Self {
|
||||
Self { precision }
|
||||
}
|
||||
|
||||
fn round(&self, v: f64) -> f64 {
|
||||
let factor = 10f64.powi(self.precision as i32);
|
||||
(v * factor).round() / factor
|
||||
}
|
||||
|
||||
fn round_pt(&self, p: PointF64) -> PointF64 {
|
||||
PointF64 {
|
||||
x: self.round(p.x),
|
||||
y: self.round(p.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OptimizerPass for QuantizePass {
|
||||
fn run(&self, doc: &mut VectorDoc) {
|
||||
for shape in &mut doc.shapes {
|
||||
for sub in &mut shape.path.subpaths {
|
||||
for cmd in &mut sub.commands {
|
||||
*cmd = match *cmd {
|
||||
PathCmd::MoveTo(p) => PathCmd::MoveTo(self.round_pt(p)),
|
||||
PathCmd::LineTo(p) => PathCmd::LineTo(self.round_pt(p)),
|
||||
PathCmd::CubicTo(c1, c2, e) => PathCmd::CubicTo(
|
||||
self.round_pt(c1),
|
||||
self.round_pt(c2),
|
||||
self.round_pt(e),
|
||||
),
|
||||
PathCmd::Close => PathCmd::Close,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove zero-length segments and collinear-redundant line vertices.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct CleanupPass;
|
||||
|
||||
/// Tolerance for treating two points as coincident.
|
||||
const COINCIDENT_EPS: f64 = 1e-6;
|
||||
/// Perpendicular-distance tolerance for treating three points as collinear.
|
||||
const COLLINEAR_EPS: f64 = 1e-4;
|
||||
|
||||
fn approx_eq(a: PointF64, b: PointF64) -> bool {
|
||||
(a.x - b.x).abs() < COINCIDENT_EPS && (a.y - b.y).abs() < COINCIDENT_EPS
|
||||
}
|
||||
|
||||
/// Perpendicular distance of `b` from the line through `a` and `c`.
|
||||
fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool {
|
||||
let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
let base = ((c.x - a.x).powi(2) + (c.y - a.y).powi(2)).sqrt();
|
||||
if base < COINCIDENT_EPS {
|
||||
return true;
|
||||
}
|
||||
(cross.abs() / base) < COLLINEAR_EPS
|
||||
}
|
||||
|
||||
fn cleanup_subpath(sub: &SubPath) -> SubPath {
|
||||
let mut out = SubPath::new();
|
||||
// `prev` is the point active before the last emitted command; `last` is the
|
||||
// current point after it. Both are needed to test collinearity of a run.
|
||||
let mut prev = PointF64::default();
|
||||
let mut last = PointF64::default();
|
||||
|
||||
for cmd in &sub.commands {
|
||||
match *cmd {
|
||||
PathCmd::MoveTo(p) => {
|
||||
out.commands.push(PathCmd::MoveTo(p));
|
||||
prev = p;
|
||||
last = p;
|
||||
}
|
||||
PathCmd::LineTo(p) => {
|
||||
if approx_eq(last, p) {
|
||||
continue; // zero-length
|
||||
}
|
||||
if let Some(PathCmd::LineTo(_)) = out.commands.last() {
|
||||
if collinear(prev, last, p) {
|
||||
*out.commands.last_mut().unwrap() = PathCmd::LineTo(p);
|
||||
last = p; // anchor `prev` unchanged
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.commands.push(PathCmd::LineTo(p));
|
||||
prev = last;
|
||||
last = p;
|
||||
}
|
||||
PathCmd::CubicTo(c1, c2, e) => {
|
||||
out.commands.push(PathCmd::CubicTo(c1, c2, e));
|
||||
prev = last;
|
||||
last = e;
|
||||
}
|
||||
PathCmd::Close => {
|
||||
out.commands.push(PathCmd::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
impl OptimizerPass for CleanupPass {
|
||||
fn run(&self, doc: &mut VectorDoc) {
|
||||
for shape in &mut doc.shapes {
|
||||
let mut subpaths = Vec::with_capacity(shape.path.subpaths.len());
|
||||
for sub in &shape.path.subpaths {
|
||||
let simplified = cleanup_subpath(sub);
|
||||
// Keep only subpaths with real geometry (a MoveTo plus at least
|
||||
// one drawing command beyond Close).
|
||||
let draws = simplified
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| matches!(c, PathCmd::LineTo(_) | PathCmd::CubicTo(..)))
|
||||
.count();
|
||||
if draws > 0 {
|
||||
subpaths.push(simplified);
|
||||
}
|
||||
}
|
||||
shape.path = MultiPath { subpaths };
|
||||
}
|
||||
doc.shapes.retain(|s| !s.path.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ir::{MultiPath, Paint, Shape};
|
||||
use visioncortex::Color;
|
||||
|
||||
fn pt(x: f64, y: f64) -> PointF64 {
|
||||
PointF64 { x, y }
|
||||
}
|
||||
|
||||
fn doc_with(commands: Vec<PathCmd>) -> VectorDoc {
|
||||
let mut doc = VectorDoc::new(100, 100);
|
||||
doc.shapes.push(Shape {
|
||||
paint: Paint::Solid(Color::new(0, 0, 0)),
|
||||
path: MultiPath {
|
||||
subpaths: vec![SubPath { commands }],
|
||||
},
|
||||
});
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_rounds_coordinates() {
|
||||
let mut doc = doc_with(vec![
|
||||
PathCmd::MoveTo(pt(1.234, 5.678)),
|
||||
PathCmd::LineTo(pt(9.876, 0.001)),
|
||||
PathCmd::Close,
|
||||
]);
|
||||
QuantizePass::new(1).run(&mut doc);
|
||||
let cmds = &doc.shapes[0].path.subpaths[0].commands;
|
||||
assert_eq!(cmds[0], PathCmd::MoveTo(pt(1.2, 5.7)));
|
||||
assert_eq!(cmds[1], PathCmd::LineTo(pt(9.9, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_drops_collinear_and_zero_length() {
|
||||
// A straight run of colinear points plus a duplicate should collapse.
|
||||
let mut doc = doc_with(vec![
|
||||
PathCmd::MoveTo(pt(0.0, 0.0)),
|
||||
PathCmd::LineTo(pt(1.0, 0.0)),
|
||||
PathCmd::LineTo(pt(2.0, 0.0)), // collinear with previous run
|
||||
PathCmd::LineTo(pt(2.0, 0.0)), // zero-length
|
||||
PathCmd::LineTo(pt(2.0, 5.0)),
|
||||
PathCmd::Close,
|
||||
]);
|
||||
CleanupPass.run(&mut doc);
|
||||
let cmds = &doc.shapes[0].path.subpaths[0].commands;
|
||||
// MoveTo, one merged horizontal LineTo, one vertical LineTo, Close.
|
||||
assert_eq!(cmds.len(), 4);
|
||||
assert_eq!(cmds[0], PathCmd::MoveTo(pt(0.0, 0.0)));
|
||||
assert_eq!(cmds[1], PathCmd::LineTo(pt(2.0, 0.0)));
|
||||
assert_eq!(cmds[2], PathCmd::LineTo(pt(2.0, 5.0)));
|
||||
assert_eq!(cmds[3], PathCmd::Close);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The pipeline driver: composes the stages and runs an image through them.
|
||||
|
||||
use visioncortex::ColorImage;
|
||||
|
||||
use crate::colorfit::ColorFitter;
|
||||
use crate::compose::Compositing;
|
||||
use crate::error::Error;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::ir::{Segmentation, VectorDoc};
|
||||
use crate::optimize::OptimizerPass;
|
||||
use crate::progress::{CancelToken, Ctx, Phase, Progress};
|
||||
use crate::simplify::CurvePass;
|
||||
use crate::svg::SvgWriter;
|
||||
|
||||
/// A fully-assembled vectorization pipeline. Build one with
|
||||
/// [`crate::Config::build`], or construct it directly for full control.
|
||||
pub struct Pipeline {
|
||||
pub frontend: Box<dyn Frontend>,
|
||||
pub color_fitters: Vec<Box<dyn ColorFitter>>,
|
||||
pub compositing: Compositing,
|
||||
/// Geometry passes over fitted contours (e.g. curve simplification), run
|
||||
/// inside compositing — after curve fitting, before paths are assembled —
|
||||
/// so mosaic mode applies them once per shared boundary segment.
|
||||
pub curve_passes: Vec<Box<dyn CurvePass>>,
|
||||
pub optimizers: Vec<Box<dyn OptimizerPass>>,
|
||||
pub writer: SvgWriter,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// Run the pipeline to the output document IR (before serialization).
|
||||
///
|
||||
/// Equivalent to [`run_with_progress`](Pipeline::run_with_progress) with a
|
||||
/// fresh (never-cancelled) token and a no-op progress callback.
|
||||
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
|
||||
self.run_with_progress(img, &CancelToken::new(), &mut |_| {})
|
||||
}
|
||||
|
||||
/// Run the pipeline, publishing [`Progress`] updates and honoring the
|
||||
/// [`CancelToken`].
|
||||
///
|
||||
/// Intended to be called on a worker thread: hand a clone of `cancel` to
|
||||
/// the UI so a button can abort, and forward `on_progress` to a channel
|
||||
/// that drives a progress bar. Returns [`Error::Cancelled`] if the token is
|
||||
/// tripped. See [`crate::progress`] for a usage example.
|
||||
pub fn run_with_progress(
|
||||
&self,
|
||||
img: &ColorImage,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
let seg = self.frontend.segment_with(img, &mut ctx)?;
|
||||
// `seg` is owned and about to be consumed, so no clone is needed here.
|
||||
self.finish_ctx(seg, &mut ctx)
|
||||
}
|
||||
|
||||
/// Phase 1 of 2 — run **only** the frontend (the expensive clustering step)
|
||||
/// and return a reusable [`Segmentation`].
|
||||
///
|
||||
/// Cache the result and feed it to [`finish`](Pipeline::finish) to
|
||||
/// re-render with different color-fitting, curve-fitting, or optimization
|
||||
/// parameters *without repaying the clustering cost* — the core of an
|
||||
/// interactive tuning loop. Re-run `segment` when a parameter that affects
|
||||
/// clustering itself changes: filter speckle, color precision, layer
|
||||
/// difference, binary threshold, or the frontend choice.
|
||||
pub fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
|
||||
self.segment_with_progress(img, &CancelToken::new(), &mut |_| {})
|
||||
}
|
||||
|
||||
/// [`segment`](Pipeline::segment) with progress reporting and cancellation.
|
||||
pub fn segment_with_progress(
|
||||
&self,
|
||||
img: &ColorImage,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<Segmentation, Error> {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
self.frontend.segment_with(img, &mut ctx)
|
||||
}
|
||||
|
||||
/// Phase 2 of 2 — color fitting → compositing → optimization, reusing a
|
||||
/// [`Segmentation`] produced by [`segment`](Pipeline::segment).
|
||||
///
|
||||
/// The segmentation is cloned internally (color fitting mutates it), so the
|
||||
/// cached copy stays pristine and can be reused across many `finish` calls
|
||||
/// with different pipelines. The frontend of `self` is not used here; build
|
||||
/// the tuning pipeline with the color/curve/optimize parameters you want
|
||||
/// and the *same* clustering parameters that produced `seg`.
|
||||
pub fn finish(&self, seg: &Segmentation) -> Result<VectorDoc, Error> {
|
||||
self.finish_with_progress(seg, &CancelToken::new(), &mut |_| {})
|
||||
}
|
||||
|
||||
/// [`finish`](Pipeline::finish) with progress reporting and cancellation.
|
||||
/// Progress starts at the [`Phase::Compose`] stage (segmentation is skipped).
|
||||
pub fn finish_with_progress(
|
||||
&self,
|
||||
seg: &Segmentation,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
self.finish_ctx(seg.clone(), &mut ctx)
|
||||
}
|
||||
|
||||
/// Downstream stages (color fit → compose → optimize) over an owned
|
||||
/// segmentation. Shared by the one-shot and two-phase entry points; takes
|
||||
/// ownership so the one-shot path avoids a clone.
|
||||
fn finish_ctx(&self, mut seg: Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
|
||||
for fitter in &self.color_fitters {
|
||||
fitter.fit(&mut seg);
|
||||
ctx.check()?;
|
||||
}
|
||||
|
||||
let mut doc = self.compositing.compose_with(&seg, &self.curve_passes, ctx)?;
|
||||
|
||||
let total = self.optimizers.len().max(1);
|
||||
for (i, pass) in self.optimizers.iter().enumerate() {
|
||||
ctx.check()?;
|
||||
pass.run(&mut doc);
|
||||
ctx.report(Phase::Optimize, (i + 1) as f32 / total as f32);
|
||||
}
|
||||
// Always emit a terminal 100% so a UI can settle even with no passes.
|
||||
ctx.report(Phase::Optimize, 1.0);
|
||||
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Run the pipeline and serialize the result to an SVG string.
|
||||
pub fn to_svg(&self, img: &ColorImage) -> Result<String, Error> {
|
||||
Ok(self.writer.write(&self.run(img)?))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Progress reporting and cancellation for long-running conversions.
|
||||
//!
|
||||
//! [`crate::Pipeline::run_with_progress`] takes a [`CancelToken`] and a
|
||||
//! progress callback. On native targets, run it on a worker thread: the
|
||||
//! callback publishes [`Progress`] to the UI and the token lets the UI abort
|
||||
//! between work batches (clustering checks once per batch, so cancellation is
|
||||
//! near-instant). The pipeline returns [`Error::Cancelled`] when the token is
|
||||
//! tripped.
|
||||
//!
|
||||
//! There is deliberately no cooperative `tick()` here: that only existed in the
|
||||
//! old browser build because the main thread could not block. The same API
|
||||
//! works unchanged from a Web Worker.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use vtracer::{Config, ColorImage};
|
||||
//! use vtracer::progress::{CancelToken, Progress};
|
||||
//!
|
||||
//! # fn load() -> ColorImage { todo!() }
|
||||
//! let pipeline = Config::default().build().unwrap();
|
||||
//! let cancel = CancelToken::new();
|
||||
//! # let img: ColorImage = load();
|
||||
//! // hand `cancel.clone()` to the UI so a button can call `cancel.cancel()`
|
||||
//! let mut on_progress = |p: Progress| eprintln!("{:?} {:.0}%", p.phase, p.fraction * 100.0);
|
||||
//! let doc = pipeline.run_with_progress(&img, &cancel, &mut on_progress);
|
||||
//! ```
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// A cheaply-clonable cancellation flag shared between the UI and the worker.
|
||||
///
|
||||
/// Clone it, hand one copy to the worker thread running the pipeline and keep
|
||||
/// the other; call [`cancel`](CancelToken::cancel) from any thread to request
|
||||
/// an early stop. Clones share the same underlying flag.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CancelToken(Arc<AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
/// A fresh, un-cancelled token.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Request cancellation. Idempotent; safe to call from any thread.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether cancellation has been requested.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pipeline phase a [`Progress`] update belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// Frontend segmentation (color clustering) — usually the dominant cost.
|
||||
Segment,
|
||||
/// Compositing the segmentation into shapes.
|
||||
Compose,
|
||||
/// Output optimization passes.
|
||||
Optimize,
|
||||
}
|
||||
|
||||
/// A progress update: the current [`Phase`] and how far through it we are.
|
||||
///
|
||||
/// `fraction` is *within* the phase, in `0.0..=1.0`. Clustering dominates
|
||||
/// runtime, so a UI can weight the phases or simply show the phase label with
|
||||
/// its fraction (e.g. "Clustering 45%").
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Progress {
|
||||
pub phase: Phase,
|
||||
pub fraction: f32,
|
||||
}
|
||||
|
||||
/// Bundles the cancel token and progress sink threaded through the stages.
|
||||
///
|
||||
/// Stages call [`Ctx::check`] between batches to honor cancellation and
|
||||
/// [`Ctx::report`] to publish progress.
|
||||
pub struct Ctx<'a> {
|
||||
cancel: &'a CancelToken,
|
||||
on_progress: &'a mut dyn FnMut(Progress),
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
/// Construct a context from a token and a progress callback.
|
||||
pub fn new(cancel: &'a CancelToken, on_progress: &'a mut dyn FnMut(Progress)) -> Self {
|
||||
Self {
|
||||
cancel,
|
||||
on_progress,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return [`Error::Cancelled`] if cancellation has been requested.
|
||||
pub fn check(&self) -> Result<(), Error> {
|
||||
if self.cancel.is_cancelled() {
|
||||
Err(Error::Cancelled)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a progress update for `phase` at `fraction` (clamped to 0..=1).
|
||||
pub fn report(&mut self, phase: Phase, fraction: f32) {
|
||||
(self.on_progress)(Progress {
|
||||
phase,
|
||||
fraction: fraction.clamp(0.0, 1.0),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Interactive tuning session: cache the expensive clustering, re-render on the
|
||||
//! cheap stages, and re-segment automatically only when it's actually needed.
|
||||
//!
|
||||
//! A desktop app loads an image once, then calls [`Session::render`] on every
|
||||
//! slider change with a fresh [`Config`]. The session compares the config's
|
||||
//! [`SegmentKey`](crate::config::SegmentKey) to what it last clustered and
|
||||
//! re-segments only if a clustering parameter changed — the caller never has to
|
||||
//! know which parameters those are.
|
||||
//!
|
||||
//! For watershed clustering there is a second cache level: the
|
||||
//! [`WatershedHierarchy`] depends only on the image, so it is built once and
|
||||
//! every re-segmentation (a detail or speckle change) is a near-instant re-cut
|
||||
//! of the cached hierarchy rather than a rebuild.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use vtracer::{Config, Session, ColorImage};
|
||||
//! # fn load() -> ColorImage { todo!() }
|
||||
//! let mut session = Session::new(load());
|
||||
//!
|
||||
//! // First render clusters the image.
|
||||
//! let mut cfg = Config::default();
|
||||
//! let _svg = session.render_svg(&cfg).unwrap();
|
||||
//!
|
||||
//! // Tuning a curve parameter reuses the cached segmentation (no re-cluster).
|
||||
//! cfg.corner_threshold = 90;
|
||||
//! let _svg = session.render_svg(&cfg).unwrap();
|
||||
//!
|
||||
//! // Changing a clustering parameter re-segments automatically.
|
||||
//! cfg.filter_speckle = 8;
|
||||
//! let _svg = session.render_svg(&cfg).unwrap();
|
||||
//! ```
|
||||
|
||||
use visioncortex::ColorImage;
|
||||
|
||||
use crate::config::{Clustering, Config, SegmentKey};
|
||||
use crate::error::Error;
|
||||
use crate::frontend::WatershedHierarchy;
|
||||
use crate::ir::{Segmentation, VectorDoc};
|
||||
use crate::pipeline::Pipeline;
|
||||
use crate::progress::{CancelToken, Ctx, Phase, Progress};
|
||||
|
||||
/// A reusable converter for one image: clusters once, re-renders many times.
|
||||
///
|
||||
/// Build it with the source [`ColorImage`] and drive it with a [`Config`] per
|
||||
/// render. The cached [`Segmentation`] is refreshed transparently whenever the
|
||||
/// config's clustering parameters change.
|
||||
pub struct Session {
|
||||
img: ColorImage,
|
||||
/// The segmentation and the key it was produced with (`None` until the
|
||||
/// first render).
|
||||
cache: Option<(SegmentKey, Segmentation)>,
|
||||
/// The image's watershed hierarchy, built lazily on the first watershed
|
||||
/// render. Parameter-free, so it never goes stale while the image lives.
|
||||
hierarchy: Option<WatershedHierarchy>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start a session over `img`. Nothing is clustered until the first render.
|
||||
pub fn new(img: ColorImage) -> Self {
|
||||
Self {
|
||||
img,
|
||||
cache: None,
|
||||
hierarchy: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cached segmentation is missing or was clustered with
|
||||
/// different parameters than `key`.
|
||||
fn stale(&self, key: &SegmentKey) -> bool {
|
||||
self.cache.as_ref().map_or(true, |(k, _)| k != key)
|
||||
}
|
||||
|
||||
/// The cached segmentation. Only call after ensuring the cache is fresh.
|
||||
fn segmentation(&self) -> &Segmentation {
|
||||
&self.cache.as_ref().expect("cache populated by caller").1
|
||||
}
|
||||
|
||||
/// Produce a fresh segmentation for `cfg`. Watershed goes through the
|
||||
/// hierarchy cache (build once, cut cheaply); everything else runs the
|
||||
/// pipeline's frontend.
|
||||
fn segment(&mut self, cfg: &Config, pipeline: &Pipeline) -> Result<Segmentation, Error> {
|
||||
if cfg.clustering == Clustering::Watershed {
|
||||
if self.hierarchy.is_none() {
|
||||
self.hierarchy = Some(WatershedHierarchy::build(&self.img)?);
|
||||
}
|
||||
let hierarchy = self.hierarchy.as_ref().expect("just built");
|
||||
Ok(hierarchy.cut(&self.img, cfg.watershed_detail, cfg.speckle_area()))
|
||||
} else {
|
||||
pipeline.segment(&self.img)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render to the document IR, re-segmenting only if `cfg`'s clustering
|
||||
/// parameters differ from the cached segmentation's.
|
||||
pub fn render(&mut self, cfg: &Config) -> Result<VectorDoc, Error> {
|
||||
let pipeline = cfg.build()?;
|
||||
let key = cfg.segment_key();
|
||||
if self.stale(&key) {
|
||||
let seg = self.segment(cfg, &pipeline)?;
|
||||
self.cache = Some((key, seg));
|
||||
}
|
||||
pipeline.finish(self.segmentation())
|
||||
}
|
||||
|
||||
/// [`render`](Session::render), serialized to an SVG string.
|
||||
pub fn render_svg(&mut self, cfg: &Config) -> Result<String, Error> {
|
||||
let pipeline = cfg.build()?;
|
||||
let key = cfg.segment_key();
|
||||
if self.stale(&key) {
|
||||
let seg = self.segment(cfg, &pipeline)?;
|
||||
self.cache = Some((key, seg));
|
||||
}
|
||||
Ok(pipeline.writer.write(&pipeline.finish(self.segmentation())?))
|
||||
}
|
||||
|
||||
/// [`render`](Session::render) with progress reporting and cancellation.
|
||||
///
|
||||
/// When a re-segmentation is needed, progress covers the [`Phase::Segment`]
|
||||
/// stage first, then the finish stages; on a cache hit only the finish
|
||||
/// stages report. Hand a clone of `cancel` to the UI to abort a long
|
||||
/// clustering pass. (A watershed re-cut over a cached hierarchy is fast
|
||||
/// enough that it reports coarsely.)
|
||||
pub fn render_with_progress(
|
||||
&mut self,
|
||||
cfg: &Config,
|
||||
cancel: &CancelToken,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<VectorDoc, Error> {
|
||||
let pipeline = cfg.build()?;
|
||||
let key = cfg.segment_key();
|
||||
if self.stale(&key) {
|
||||
let seg = if cfg.clustering == Clustering::Watershed {
|
||||
let mut ctx = Ctx::new(cancel, on_progress);
|
||||
ctx.check()?;
|
||||
let seg = self.segment(cfg, &pipeline)?;
|
||||
ctx.check()?;
|
||||
ctx.report(Phase::Segment, 1.0);
|
||||
seg
|
||||
} else {
|
||||
pipeline.segment_with_progress(&self.img, cancel, on_progress)?
|
||||
};
|
||||
self.cache = Some((key, seg));
|
||||
}
|
||||
pipeline.finish_with_progress(self.segmentation(), cancel, on_progress)
|
||||
}
|
||||
|
||||
/// Drop the cached segmentation and hierarchy, forcing the next render to
|
||||
/// re-cluster. Use after replacing the source image out of band; normally
|
||||
/// unnecessary.
|
||||
pub fn invalidate(&mut self) {
|
||||
self.cache = None;
|
||||
self.hierarchy = None;
|
||||
}
|
||||
|
||||
/// The source image this session renders.
|
||||
pub fn image(&self) -> &ColorImage {
|
||||
&self.img
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Curve passes: geometry transforms between curve fitting and composition.
|
||||
//!
|
||||
//! A [`CurvePass`] rewrites one fitted contour at a time. Passes run *before*
|
||||
//! composition on the fitted geometry itself — in mosaic mode each shared
|
||||
//! boundary segment is transformed exactly once and both adjacent faces
|
||||
//! reference the result, so the tessellation stays seam-free by construction.
|
||||
//! Running instead on the composed [`VectorDoc`](crate::ir::VectorDoc) would
|
||||
//! re-fit the two copies of every shared boundary independently and reopen
|
||||
//! the seams the mosaic exists to prevent.
|
||||
//!
|
||||
//! The built-in pass is [`SimplifyCurves`], the paper.js `simplify` analogue.
|
||||
|
||||
use flo_curves::bezier::{fit_curve_cubic, Curve};
|
||||
use flo_curves::Coord2;
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use crate::fitter::FittedGeom;
|
||||
|
||||
/// A geometry pass over one fitted contour, run between curve fitting and
|
||||
/// composition. Implementations must keep an open chain's endpoints exactly
|
||||
/// (mosaic junction nodes must not move) and keep a ring closed.
|
||||
pub trait CurvePass {
|
||||
/// Transform an open chain; both endpoints are pinned.
|
||||
fn open(&self, geom: FittedGeom) -> FittedGeom;
|
||||
/// Transform a closed ring.
|
||||
fn ring(&self, geom: FittedGeom) -> FittedGeom;
|
||||
}
|
||||
|
||||
/// paper.js-style curve simplification (Schneider's fit): re-fit each smooth
|
||||
/// run of cubics between corners with the fewest curves that stay within
|
||||
/// `tolerance` of the fitted geometry.
|
||||
///
|
||||
/// The spline fitters cut an outline at every splice point and fit each short
|
||||
/// slice separately, so a lazily curving edge carries an anchor per splice.
|
||||
/// This pass samples the fitted curve (~1 px spacing) and re-fits whole
|
||||
/// corner-to-corner runs with `flo_curves`' Schneider implementation
|
||||
/// (`fit_curve_cubic`, tangents taken from the chain's own ends), merging
|
||||
/// those slices down to what the tolerance genuinely requires.
|
||||
///
|
||||
/// A run is replaced only when the re-fit uses strictly fewer cubics and is
|
||||
/// kept verbatim otherwise, so the pass never increases the curve count and
|
||||
/// never moves the geometry more than `tolerance` (measured at the samples).
|
||||
/// Polylines (pixel / polygon modes) pass through untouched.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SimplifyCurves {
|
||||
/// Maximum distance (px) the simplified curve may stray from the fitted
|
||||
/// one. paper.js defaults to 2.5.
|
||||
pub tolerance: f64,
|
||||
/// Tangent-break angle (radians) above which an anchor is a corner and
|
||||
/// must survive in place; runs are re-fitted between corners.
|
||||
pub corner_threshold: f64,
|
||||
}
|
||||
|
||||
impl CurvePass for SimplifyCurves {
|
||||
fn open(&self, geom: FittedGeom) -> FittedGeom {
|
||||
match geom {
|
||||
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, false)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn ring(&self, geom: FittedGeom) -> FittedGeom {
|
||||
match geom {
|
||||
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, true)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SimplifyCurves {
|
||||
fn simplify_chain(&self, mut chain: Vec<[PointF64; 4]>, closed: bool) -> Vec<[PointF64; 4]> {
|
||||
if self.tolerance <= 0.0 || chain.len() < 2 {
|
||||
return chain;
|
||||
}
|
||||
|
||||
if closed {
|
||||
// The re-fit pins run endpoints, so a ring needs a seam. Put it at
|
||||
// the sharpest junction (wraparound included): a corner the fit
|
||||
// would keep anyway, or the least-smooth anchor when the ring has
|
||||
// none, so any residual tangent break lands where it hides best.
|
||||
let angles: Vec<f64> = (0..chain.len())
|
||||
.map(|k| {
|
||||
let prev = if k == 0 { chain.len() - 1 } else { k - 1 };
|
||||
break_angle(&chain[prev], &chain[k])
|
||||
})
|
||||
.collect();
|
||||
let seam = angles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
chain.rotate_left(seam);
|
||||
}
|
||||
|
||||
// Cut into smooth runs at corner anchors (chain ends are always cuts).
|
||||
let mut cuts: Vec<usize> = vec![0];
|
||||
for i in 1..chain.len() {
|
||||
if break_angle(&chain[i - 1], &chain[i]) >= self.corner_threshold {
|
||||
cuts.push(i);
|
||||
}
|
||||
}
|
||||
cuts.push(chain.len());
|
||||
|
||||
let mut out: Vec<[PointF64; 4]> = Vec::with_capacity(chain.len());
|
||||
for w in cuts.windows(2) {
|
||||
let run = &chain[w[0]..w[1]];
|
||||
if run.len() < 2 {
|
||||
out.extend_from_slice(run);
|
||||
continue;
|
||||
}
|
||||
match refit_run(run, self.tolerance) {
|
||||
Some(refit) if refit.len() < run.len() => out.extend(refit),
|
||||
_ => out.extend_from_slice(run),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Schneider-fit one smooth run: sample it, then `fit_curve_cubic` with the
|
||||
/// run's own end tangents (`end_tangent` points backward, per its contract).
|
||||
/// The recursion splits at sample points, so consecutive fitted cubics share
|
||||
/// endpoints exactly; the outer endpoints are pinned to the run's, bit for
|
||||
/// bit. Returns `None` for degenerate (point-like) runs.
|
||||
fn refit_run(run: &[[PointF64; 4]], tolerance: f64) -> Option<Vec<[PointF64; 4]>> {
|
||||
let start_tan = tangent_out(run.first()?)?;
|
||||
let end_tan = tangent_in(run.last()?)?;
|
||||
let samples: Vec<Coord2> = sample_run(run, tolerance)
|
||||
.into_iter()
|
||||
.map(|p| Coord2(p.x, p.y))
|
||||
.collect();
|
||||
|
||||
let fitted: Vec<Curve<Coord2>> = fit_curve_cubic(
|
||||
&samples,
|
||||
&Coord2(start_tan.0, start_tan.1),
|
||||
&Coord2(-end_tan.0, -end_tan.1),
|
||||
tolerance,
|
||||
);
|
||||
if fitted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pt = |c: Coord2| PointF64 { x: c.0, y: c.1 };
|
||||
let mut out: Vec<[PointF64; 4]> = fitted
|
||||
.into_iter()
|
||||
.map(|c| [pt(c.start_point), pt(c.control_points.0), pt(c.control_points.1), pt(c.end_point)])
|
||||
.collect();
|
||||
out.first_mut()?[0] = run[0][0];
|
||||
out.last_mut()?[3] = run[run.len() - 1][3];
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn dist(a: PointF64, b: PointF64) -> f64 {
|
||||
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Unit direction a→b, or `None` when the points (nearly) coincide.
|
||||
fn dir(a: PointF64, b: PointF64) -> Option<(f64, f64)> {
|
||||
let (dx, dy) = (b.x - a.x, b.y - a.y);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-9 {
|
||||
None
|
||||
} else {
|
||||
Some((dx / len, dy / len))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tangent arriving at a cubic's end: the last distinct control point wins.
|
||||
fn tangent_in(c: &[PointF64; 4]) -> Option<(f64, f64)> {
|
||||
dir(c[2], c[3]).or_else(|| dir(c[1], c[3])).or_else(|| dir(c[0], c[3]))
|
||||
}
|
||||
|
||||
/// Tangent leaving a cubic's start: the first distinct control point wins.
|
||||
fn tangent_out(c: &[PointF64; 4]) -> Option<(f64, f64)> {
|
||||
dir(c[0], c[1]).or_else(|| dir(c[0], c[2])).or_else(|| dir(c[0], c[3]))
|
||||
}
|
||||
|
||||
/// Turn angle at the junction of two consecutive cubics. A fully degenerate
|
||||
/// (point-like) neighbour counts as a corner so it is never smoothed across.
|
||||
fn break_angle(prev: &[PointF64; 4], next: &[PointF64; 4]) -> f64 {
|
||||
match (tangent_in(prev), tangent_out(next)) {
|
||||
(Some(a), Some(b)) => (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0).acos(),
|
||||
_ => std::f64::consts::PI,
|
||||
}
|
||||
}
|
||||
|
||||
fn cubic_at(c: &[PointF64; 4], t: f64) -> PointF64 {
|
||||
let u = 1.0 - t;
|
||||
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
PointF64 {
|
||||
x: b0 * c[0].x + b1 * c[1].x + b2 * c[2].x + b3 * c[3].x,
|
||||
y: b0 * c[0].y + b1 * c[1].y + b2 * c[2].y + b3 * c[3].y,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample a run of cubics at roughly 1 px spacing (by control-polygon length),
|
||||
/// tighter when the tolerance is sub-pixel — the fit measures its error at
|
||||
/// the samples, so their spacing is the fidelity guard. The first and last
|
||||
/// samples are the run's endpoints, exactly: `cubic_at` with `t = 1` returns
|
||||
/// `c[3]` bit for bit.
|
||||
fn sample_run(run: &[[PointF64; 4]], tolerance: f64) -> Vec<PointF64> {
|
||||
let spacing = tolerance.clamp(0.25, 1.0);
|
||||
let mut samples = vec![run[0][0]];
|
||||
for c in run {
|
||||
let len = dist(c[0], c[1]) + dist(c[1], c[2]) + dist(c[2], c[3]);
|
||||
let n = ((len / spacing).ceil() as usize).clamp(1, 512);
|
||||
for k in 1..=n {
|
||||
samples.push(cubic_at(c, k as f64 / n as f64));
|
||||
}
|
||||
}
|
||||
samples
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pt(x: f64, y: f64) -> PointF64 {
|
||||
PointF64 { x, y }
|
||||
}
|
||||
|
||||
/// A degenerate cubic tracing the straight line `a`→`b`.
|
||||
fn straight(a: PointF64, b: PointF64) -> [PointF64; 4] {
|
||||
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
[a, lerp(1.0 / 3.0), lerp(2.0 / 3.0), b]
|
||||
}
|
||||
|
||||
/// `n` straight cubics subdividing the segment `a`→`b`.
|
||||
fn straight_chain(a: PointF64, b: PointF64, n: usize) -> Vec<[PointF64; 4]> {
|
||||
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
(0..n)
|
||||
.map(|i| straight(lerp(i as f64 / n as f64), lerp((i + 1) as f64 / n as f64)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One cubic approximating the circular arc `a0..a1` on a circle of
|
||||
/// radius `r` about the origin (the classic 4/3·tan(Δ/4) handle length).
|
||||
fn arc_cubic(r: f64, a0: f64, a1: f64) -> [PointF64; 4] {
|
||||
let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan();
|
||||
let (p0, p3) = (pt(r * a0.cos(), r * a0.sin()), pt(r * a1.cos(), r * a1.sin()));
|
||||
[
|
||||
p0,
|
||||
pt(p0.x - k * r * a0.sin(), p0.y + k * r * a0.cos()),
|
||||
pt(p3.x + k * r * a1.sin(), p3.y - k * r * a1.cos()),
|
||||
p3,
|
||||
]
|
||||
}
|
||||
|
||||
fn pass() -> SimplifyCurves {
|
||||
SimplifyCurves {
|
||||
tolerance: 1.0,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchors(chain: &[[PointF64; 4]]) -> Vec<PointF64> {
|
||||
let mut a: Vec<PointF64> = chain.iter().map(|c| c[0]).collect();
|
||||
a.push(chain.last().unwrap()[3]);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collinear_run_collapses_to_one_cubic() {
|
||||
let chain = straight_chain(pt(0.0, 0.0), pt(100.0, 0.0), 10);
|
||||
let out = match pass().open(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert_eq!(out.len(), 1, "ten collinear cubics become one");
|
||||
assert_eq!(out[0][0], pt(0.0, 0.0), "start pinned");
|
||||
assert_eq!(out[0][3], pt(100.0, 0.0), "end pinned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_survives_in_place() {
|
||||
// An L: two straight runs meeting at a right angle.
|
||||
let mut chain = straight_chain(pt(0.0, 0.0), pt(50.0, 0.0), 5);
|
||||
chain.extend(straight_chain(pt(50.0, 0.0), pt(50.0, 50.0), 5));
|
||||
let out = match pass().open(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert_eq!(out.len(), 2, "one cubic per leg");
|
||||
assert_eq!(out[0][3], pt(50.0, 0.0), "corner anchor exact");
|
||||
assert_eq!(out[1][0], pt(50.0, 0.0), "chain continuous through corner");
|
||||
assert_eq!(out[0][0], pt(0.0, 0.0));
|
||||
assert_eq!(out[1][3], pt(50.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_stays_closed_and_keeps_square_corners() {
|
||||
// A closed square, three cubics per side, seam mid-side (anchor 0 is
|
||||
// smooth) — the pass must rotate the seam onto a corner.
|
||||
let corners = [pt(0.0, 0.0), pt(60.0, 0.0), pt(60.0, 60.0), pt(0.0, 60.0)];
|
||||
let mut chain = Vec::new();
|
||||
for i in 0..4 {
|
||||
chain.extend(straight_chain(corners[i], corners[(i + 1) % 4], 3));
|
||||
}
|
||||
chain.rotate_left(1); // seam mid-side
|
||||
let out = match pass().ring(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert_eq!(out.len(), 4, "one cubic per side");
|
||||
assert_eq!(out[0][0], out.last().unwrap()[3], "ring closed");
|
||||
let mut got = anchors(&out);
|
||||
got.pop(); // last repeats first
|
||||
for c in corners {
|
||||
assert!(got.contains(&c), "corner {c:?} kept, got {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_merges_within_tolerance() {
|
||||
// A quarter circle as 8 short arcs collapses to far fewer cubics, and
|
||||
// the result stays within tolerance of the true circle.
|
||||
let r = 50.0;
|
||||
let n = 8;
|
||||
let chain: Vec<[PointF64; 4]> = (0..n)
|
||||
.map(|i| {
|
||||
let step = std::f64::consts::FRAC_PI_2 / n as f64;
|
||||
arc_cubic(r, i as f64 * step, (i + 1) as f64 * step)
|
||||
})
|
||||
.collect();
|
||||
let tol = 0.5;
|
||||
let p = SimplifyCurves {
|
||||
tolerance: tol,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
};
|
||||
let out = match p.open(FittedGeom::Beziers(chain)) {
|
||||
FittedGeom::Beziers(c) => c,
|
||||
_ => panic!("geometry kind changed"),
|
||||
};
|
||||
assert!(out.len() < 8, "arcs merge, got {}", out.len());
|
||||
for c in &out {
|
||||
for k in 0..=32 {
|
||||
let q = cubic_at(c, k as f64 / 32.0);
|
||||
let radial = ((q.x * q.x + q.y * q.y).sqrt() - r).abs();
|
||||
assert!(radial <= tol + 0.1, "deviation {radial} beyond tolerance");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refit_never_grows_the_chain() {
|
||||
// A single cubic is untouchable; a sharp S of two cubics that cannot
|
||||
// merge within a tiny tolerance is kept verbatim.
|
||||
let lone = vec![arc_cubic(50.0, 0.0, 1.0)];
|
||||
match pass().open(FittedGeom::Beziers(lone.clone())) {
|
||||
FittedGeom::Beziers(c) => assert_eq!(c, lone),
|
||||
_ => panic!("geometry kind changed"),
|
||||
}
|
||||
|
||||
let s_curve = vec![
|
||||
[pt(0.0, 0.0), pt(20.0, 40.0), pt(30.0, 40.0), pt(50.0, 0.0)],
|
||||
[pt(50.0, 0.0), pt(70.0, -40.0), pt(80.0, -40.0), pt(100.0, 0.0)],
|
||||
];
|
||||
let tight = SimplifyCurves {
|
||||
tolerance: 0.01,
|
||||
corner_threshold: std::f64::consts::PI / 3.0,
|
||||
};
|
||||
match tight.open(FittedGeom::Beziers(s_curve.clone())) {
|
||||
FittedGeom::Beziers(c) => {
|
||||
assert!(c.len() <= s_curve.len(), "never more cubics than input")
|
||||
}
|
||||
_ => panic!("geometry kind changed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polylines_pass_through_untouched() {
|
||||
let poly = vec![pt(0.0, 0.0), pt(1.0, 0.0), pt(2.0, 0.0), pt(3.0, 0.0)];
|
||||
match pass().open(FittedGeom::Polyline(poly.clone())) {
|
||||
FittedGeom::Polyline(p) => assert_eq!(p, poly),
|
||||
_ => panic!("polyline must stay a polyline"),
|
||||
}
|
||||
match pass().ring(FittedGeom::Polyline(poly.clone())) {
|
||||
FittedGeom::Polyline(p) => assert_eq!(p, poly),
|
||||
_ => panic!("polyline must stay a polyline"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
//! Serialize a [`VectorDoc`] to an SVG string.
|
||||
//!
|
||||
//! The writer makes the encoding choices that shrink output without changing
|
||||
//! geometry:
|
||||
//!
|
||||
//! * per segment, the shorter of absolute vs. relative deltas (`L`/`l`, `C`/`c`);
|
||||
//! * `H`/`V` (`h`/`v`) for axis-aligned lines and `S`/`s` for smooth cubic
|
||||
//! continuations;
|
||||
//! * compact number formatting (trimmed zeros, leading-dot decimals, no
|
||||
//! separator before a negative);
|
||||
//! * optional `<g fill>` grouping of consecutive same-fill shapes.
|
||||
//!
|
||||
//! Coordinates are assumed to already be in absolute document space (the
|
||||
//! [`crate::optimize::QuantizePass`] bakes in any offset), so no per-path
|
||||
//! `transform` is emitted.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use visioncortex::PointF64;
|
||||
|
||||
use crate::ir::{Paint, PathCmd, Shape, SubPath, VectorDoc};
|
||||
|
||||
/// SVG serializer configuration.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SvgWriter {
|
||||
/// Allow relative commands where they serialize shorter.
|
||||
pub relative: bool,
|
||||
/// Allow `H`/`V`/`S` shorthands and `<g fill>` grouping.
|
||||
pub shorthands: bool,
|
||||
/// Decimal places for coordinates (`None` = full precision).
|
||||
pub precision: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for SvgWriter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
relative: true,
|
||||
shorthands: true,
|
||||
precision: Some(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SvgWriter {
|
||||
pub fn write(&self, doc: &VectorDoc) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<!-- Generator: visioncortex VTracer {} -->",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">",
|
||||
doc.width, doc.height
|
||||
);
|
||||
|
||||
if self.shorthands {
|
||||
self.write_grouped(&mut out, &doc.shapes);
|
||||
} else {
|
||||
for shape in &doc.shapes {
|
||||
self.write_path(&mut out, shape, true);
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("</svg>\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit shapes, grouping maximal runs of consecutive same-fill shapes into
|
||||
/// a single `<g fill>` (preserving paint order).
|
||||
fn write_grouped(&self, out: &mut String, shapes: &[Shape]) {
|
||||
let mut i = 0;
|
||||
while i < shapes.len() {
|
||||
let fill = shape_fill(&shapes[i]);
|
||||
let mut j = i + 1;
|
||||
while j < shapes.len() && shape_fill(&shapes[j]) == fill {
|
||||
j += 1;
|
||||
}
|
||||
let run = &shapes[i..j];
|
||||
if run.len() > 1 {
|
||||
let _ = writeln!(out, "<g fill=\"{}\">", fill);
|
||||
for shape in run {
|
||||
self.write_path(out, shape, false);
|
||||
}
|
||||
out.push_str("</g>\n");
|
||||
} else {
|
||||
self.write_path(out, &run[0], true);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_path(&self, out: &mut String, shape: &Shape, with_fill: bool) {
|
||||
let d = self.encode_path(shape);
|
||||
if d.is_empty() {
|
||||
return;
|
||||
}
|
||||
if with_fill {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<path d=\"{}\" fill=\"{}\"/>",
|
||||
d,
|
||||
shape_fill(shape)
|
||||
);
|
||||
} else {
|
||||
let _ = writeln!(out, "<path d=\"{}\"/>", d);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_path(&self, shape: &Shape) -> String {
|
||||
let mut emitter = Emitter::new(self.relative, self.shorthands, self.precision);
|
||||
for sub in &shape.path.subpaths {
|
||||
emitter.subpath(sub);
|
||||
}
|
||||
emitter.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_fill(shape: &Shape) -> String {
|
||||
match shape.paint {
|
||||
Paint::Solid(c) => c.to_hex_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming SVG-path encoder that tracks the current point.
|
||||
struct Emitter {
|
||||
relative: bool,
|
||||
shorthands: bool,
|
||||
precision: Option<u32>,
|
||||
out: String,
|
||||
cur: PointF64,
|
||||
/// Start of the current subpath; `cur` returns here after `Z`.
|
||||
subpath_start: PointF64,
|
||||
started: bool,
|
||||
/// Absolute second control point of the previous cubic, for `S` detection.
|
||||
prev_cubic_c2: Option<PointF64>,
|
||||
}
|
||||
|
||||
impl Emitter {
|
||||
fn new(relative: bool, shorthands: bool, precision: Option<u32>) -> Self {
|
||||
Self {
|
||||
relative,
|
||||
shorthands,
|
||||
precision,
|
||||
out: String::new(),
|
||||
cur: PointF64::default(),
|
||||
subpath_start: PointF64::default(),
|
||||
started: false,
|
||||
prev_cubic_c2: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> String {
|
||||
self.out
|
||||
}
|
||||
|
||||
fn subpath(&mut self, sub: &SubPath) {
|
||||
for cmd in &sub.commands {
|
||||
match *cmd {
|
||||
PathCmd::MoveTo(p) => self.move_to(p),
|
||||
PathCmd::LineTo(p) => self.line_to(p),
|
||||
PathCmd::CubicTo(c1, c2, e) => self.cubic_to(c1, c2, e),
|
||||
PathCmd::Close => {
|
||||
self.out.push('Z');
|
||||
// SVG resets the current point to the subpath's start after
|
||||
// Z; a following relative `m`/`l` is measured from there.
|
||||
self.cur = self.subpath_start;
|
||||
self.prev_cubic_c2 = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_to(&mut self, p: PointF64) {
|
||||
if !self.started {
|
||||
// First move is always absolute.
|
||||
let token = format!("M{}", self.coord(p));
|
||||
self.out.push_str(&token);
|
||||
self.started = true;
|
||||
} else {
|
||||
let abs = format!("M{}", self.coord(p));
|
||||
let token = if self.relative {
|
||||
let rel = format!("m{}", self.coord_delta(p));
|
||||
shorter(abs, rel)
|
||||
} else {
|
||||
abs
|
||||
};
|
||||
self.out.push_str(&token);
|
||||
}
|
||||
self.cur = p;
|
||||
self.subpath_start = p;
|
||||
self.prev_cubic_c2 = None;
|
||||
}
|
||||
|
||||
fn line_to(&mut self, p: PointF64) {
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
|
||||
// Axis-aligned shorthands.
|
||||
if self.shorthands {
|
||||
if p.y == self.cur.y {
|
||||
candidates.push(format!("H{}", self.num(p.x)));
|
||||
if self.relative {
|
||||
candidates.push(format!("h{}", self.num(p.x - self.cur.x)));
|
||||
}
|
||||
}
|
||||
if p.x == self.cur.x {
|
||||
candidates.push(format!("V{}", self.num(p.y)));
|
||||
if self.relative {
|
||||
candidates.push(format!("v{}", self.num(p.y - self.cur.y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(format!("L{}", self.coord(p)));
|
||||
if self.relative {
|
||||
candidates.push(format!("l{}", self.coord_delta(p)));
|
||||
}
|
||||
|
||||
self.out.push_str(&shortest(candidates));
|
||||
self.cur = p;
|
||||
self.prev_cubic_c2 = None;
|
||||
}
|
||||
|
||||
fn cubic_to(&mut self, c1: PointF64, c2: PointF64, e: PointF64) {
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
|
||||
// Smooth continuation: c1 is the reflection of the previous cubic's c2.
|
||||
if self.shorthands {
|
||||
if let Some(prev_c2) = self.prev_cubic_c2 {
|
||||
let reflection = PointF64 {
|
||||
x: 2.0 * self.cur.x - prev_c2.x,
|
||||
y: 2.0 * self.cur.y - prev_c2.y,
|
||||
};
|
||||
if approx(reflection, c1) {
|
||||
candidates.push(format!(
|
||||
"S{}",
|
||||
self.coord_list(&[c2, e])
|
||||
));
|
||||
if self.relative {
|
||||
candidates.push(format!(
|
||||
"s{}",
|
||||
self.delta_list(&[c2, e])
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(format!("C{}", self.coord_list(&[c1, c2, e])));
|
||||
if self.relative {
|
||||
candidates.push(format!("c{}", self.delta_list(&[c1, c2, e])));
|
||||
}
|
||||
|
||||
self.out.push_str(&shortest(candidates));
|
||||
self.cur = e;
|
||||
self.prev_cubic_c2 = Some(c2);
|
||||
}
|
||||
|
||||
// --- number/coordinate formatting -------------------------------------
|
||||
|
||||
fn num(&self, v: f64) -> String {
|
||||
fmt_num(v, self.precision)
|
||||
}
|
||||
|
||||
/// Absolute coordinate pair.
|
||||
fn coord(&self, p: PointF64) -> String {
|
||||
join_nums(&[self.num(p.x), self.num(p.y)])
|
||||
}
|
||||
|
||||
/// Delta coordinate pair relative to the current point.
|
||||
fn coord_delta(&self, p: PointF64) -> String {
|
||||
join_nums(&[self.num(p.x - self.cur.x), self.num(p.y - self.cur.y)])
|
||||
}
|
||||
|
||||
/// Absolute list of points, flattened.
|
||||
fn coord_list(&self, pts: &[PointF64]) -> String {
|
||||
let mut nums = Vec::with_capacity(pts.len() * 2);
|
||||
for p in pts {
|
||||
nums.push(self.num(p.x));
|
||||
nums.push(self.num(p.y));
|
||||
}
|
||||
join_nums(&nums)
|
||||
}
|
||||
|
||||
/// Delta list of points relative to the current point (all deltas are from
|
||||
/// `cur`, matching SVG's relative-command semantics for multi-point ops).
|
||||
fn delta_list(&self, pts: &[PointF64]) -> String {
|
||||
let mut nums = Vec::with_capacity(pts.len() * 2);
|
||||
for p in pts {
|
||||
nums.push(self.num(p.x - self.cur.x));
|
||||
nums.push(self.num(p.y - self.cur.y));
|
||||
}
|
||||
join_nums(&nums)
|
||||
}
|
||||
}
|
||||
|
||||
fn approx(a: PointF64, b: PointF64) -> bool {
|
||||
(a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn shorter(a: String, b: String) -> String {
|
||||
if b.len() < a.len() {
|
||||
b
|
||||
} else {
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
fn shortest(candidates: Vec<String>) -> String {
|
||||
candidates
|
||||
.into_iter()
|
||||
.min_by_key(|s| s.len())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Join formatted numbers with the minimal separators SVG allows: a comma,
|
||||
/// except that a leading `-` is self-separating.
|
||||
fn join_nums(nums: &[String]) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, n) in nums.iter().enumerate() {
|
||||
if i > 0 && !n.starts_with('-') {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(n);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Compact number formatting: round to precision, trim trailing zeros, use a
|
||||
/// leading-dot for magnitudes below 1.
|
||||
fn fmt_num(v: f64, precision: Option<u32>) -> String {
|
||||
let v = match precision {
|
||||
Some(p) => {
|
||||
let factor = 10f64.powi(p as i32);
|
||||
(v * factor).round() / factor
|
||||
}
|
||||
None => v,
|
||||
};
|
||||
// Normalize -0.0 to 0.
|
||||
if v == 0.0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
|
||||
let mut s = match precision {
|
||||
Some(p) => format!("{:.*}", p as usize, v),
|
||||
None => format!("{v}"),
|
||||
};
|
||||
|
||||
if s.contains('.') {
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if s.ends_with('.') {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rest) = s.strip_prefix("0.") {
|
||||
s = format!(".{rest}");
|
||||
} else if let Some(rest) = s.strip_prefix("-0.") {
|
||||
s = format!("-.{rest}");
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ir::{MultiPath, Paint, PathCmd, Shape, SubPath};
|
||||
use visioncortex::Color;
|
||||
|
||||
#[test]
|
||||
fn number_formatting() {
|
||||
assert_eq!(fmt_num(0.0, Some(2)), "0");
|
||||
assert_eq!(fmt_num(-0.0, Some(2)), "0");
|
||||
assert_eq!(fmt_num(1.50, Some(2)), "1.5");
|
||||
assert_eq!(fmt_num(0.5, Some(2)), ".5");
|
||||
assert_eq!(fmt_num(-0.5, Some(2)), "-.5");
|
||||
assert_eq!(fmt_num(2.0, Some(2)), "2");
|
||||
assert_eq!(fmt_num(3.14159, Some(2)), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_omits_separator_before_negative() {
|
||||
let nums = vec!["1".to_string(), "-2".to_string(), "3".to_string()];
|
||||
assert_eq!(join_nums(&nums), "1-2,3");
|
||||
}
|
||||
|
||||
fn square_shape() -> Shape {
|
||||
use visioncortex::PointF64;
|
||||
let p = |x, y| PointF64 { x, y };
|
||||
let mut sub = SubPath::new();
|
||||
sub.commands = vec![
|
||||
PathCmd::MoveTo(p(0.0, 0.0)),
|
||||
PathCmd::LineTo(p(10.0, 0.0)),
|
||||
PathCmd::LineTo(p(10.0, 10.0)),
|
||||
PathCmd::LineTo(p(0.0, 10.0)),
|
||||
PathCmd::Close,
|
||||
];
|
||||
Shape {
|
||||
paint: Paint::Solid(Color::new(255, 0, 0)),
|
||||
path: MultiPath { subpaths: vec![sub] },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_axis_aligned_shorthands() {
|
||||
let writer = SvgWriter {
|
||||
relative: true,
|
||||
shorthands: true,
|
||||
precision: Some(2),
|
||||
};
|
||||
let d = writer.encode_path(&square_shape());
|
||||
// Horizontal/vertical lines collapse to H/V/h/v; first move is absolute.
|
||||
assert!(d.starts_with("M0,0"));
|
||||
assert!(d.contains('H') || d.contains('h'));
|
||||
assert!(d.contains('V') || d.contains('v'));
|
||||
assert!(d.ends_with('Z'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_mode_uses_no_relative_commands() {
|
||||
let writer = SvgWriter {
|
||||
relative: false,
|
||||
shorthands: false,
|
||||
precision: Some(2),
|
||||
};
|
||||
let d = writer.encode_path(&square_shape());
|
||||
assert!(!d.contains('l'));
|
||||
assert!(!d.contains('c'));
|
||||
assert!(d.contains('L'));
|
||||
}
|
||||
|
||||
/// A shape with a hole (second subpath). Encoded absolute vs relative must
|
||||
/// describe the *same* geometry — regression for the bug where the current
|
||||
/// point was not reset to the subpath start after `Z`, so the relative `m`
|
||||
/// of the hole was measured from the wrong origin.
|
||||
fn holed_shape() -> Shape {
|
||||
use visioncortex::PointF64;
|
||||
let p = |x, y| PointF64 { x, y };
|
||||
let outer = SubPath {
|
||||
commands: vec![
|
||||
PathCmd::MoveTo(p(0.0, 0.0)),
|
||||
PathCmd::LineTo(p(30.0, 0.0)),
|
||||
PathCmd::LineTo(p(30.0, 30.0)),
|
||||
PathCmd::LineTo(p(0.0, 30.0)),
|
||||
PathCmd::Close,
|
||||
],
|
||||
};
|
||||
let hole = SubPath {
|
||||
commands: vec![
|
||||
PathCmd::MoveTo(p(10.0, 10.0)),
|
||||
PathCmd::LineTo(p(20.0, 10.0)),
|
||||
PathCmd::LineTo(p(20.0, 20.0)),
|
||||
PathCmd::LineTo(p(10.0, 20.0)),
|
||||
PathCmd::Close,
|
||||
],
|
||||
};
|
||||
Shape {
|
||||
paint: Paint::Solid(Color::new(0, 0, 0)),
|
||||
path: MultiPath {
|
||||
subpaths: vec![outer, hole],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an SVG `d` (M/m/L/l/H/h/V/v/Z only) into absolute points.
|
||||
fn parse_abs(d: &str) -> Vec<(f64, f64)> {
|
||||
let mut toks = Vec::new();
|
||||
let mut i = 0;
|
||||
let b = d.as_bytes();
|
||||
while i < b.len() {
|
||||
let c = b[i] as char;
|
||||
if c.is_ascii_alphabetic() {
|
||||
toks.push(c.to_string());
|
||||
i += 1;
|
||||
} else if c == '-' || c == '.' || c.is_ascii_digit() {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < b.len() && {
|
||||
let d = b[i] as char;
|
||||
d.is_ascii_digit() || d == '.'
|
||||
} {
|
||||
i += 1;
|
||||
}
|
||||
toks.push(d[start..i].to_string());
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (mut cx, mut cy, mut sx, mut sy) = (0.0, 0.0, 0.0, 0.0);
|
||||
let mut j = 0;
|
||||
let mut cmd = ' ';
|
||||
let num = |j: &mut usize| -> f64 {
|
||||
let v = toks[*j].parse().unwrap();
|
||||
*j += 1;
|
||||
v
|
||||
};
|
||||
while j < toks.len() {
|
||||
if toks[j].chars().next().unwrap().is_ascii_alphabetic() {
|
||||
cmd = toks[j].chars().next().unwrap();
|
||||
j += 1;
|
||||
}
|
||||
let rel = cmd.is_ascii_lowercase();
|
||||
match cmd.to_ascii_uppercase() {
|
||||
'M' => {
|
||||
let (mut x, mut y) = (num(&mut j), num(&mut j));
|
||||
if rel {
|
||||
x += cx;
|
||||
y += cy;
|
||||
}
|
||||
cx = x;
|
||||
cy = y;
|
||||
sx = x;
|
||||
sy = y;
|
||||
out.push((cx, cy));
|
||||
cmd = if rel { 'l' } else { 'L' };
|
||||
}
|
||||
'L' => {
|
||||
let (mut x, mut y) = (num(&mut j), num(&mut j));
|
||||
if rel {
|
||||
x += cx;
|
||||
y += cy;
|
||||
}
|
||||
cx = x;
|
||||
cy = y;
|
||||
out.push((cx, cy));
|
||||
}
|
||||
'H' => {
|
||||
let mut x = num(&mut j);
|
||||
if rel {
|
||||
x += cx;
|
||||
}
|
||||
cx = x;
|
||||
out.push((cx, cy));
|
||||
}
|
||||
'V' => {
|
||||
let mut y = num(&mut j);
|
||||
if rel {
|
||||
y += cy;
|
||||
}
|
||||
cy = y;
|
||||
out.push((cx, cy));
|
||||
}
|
||||
'Z' => {
|
||||
cx = sx;
|
||||
cy = sy;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_absolute_encode_same_geometry() {
|
||||
let shape = holed_shape();
|
||||
let abs = SvgWriter {
|
||||
relative: false,
|
||||
shorthands: false,
|
||||
precision: Some(2),
|
||||
}
|
||||
.encode_path(&shape);
|
||||
for shorthands in [false, true] {
|
||||
let rel = SvgWriter {
|
||||
relative: true,
|
||||
shorthands,
|
||||
precision: Some(2),
|
||||
}
|
||||
.encode_path(&shape);
|
||||
assert_eq!(
|
||||
parse_abs(&abs),
|
||||
parse_abs(&rel),
|
||||
"relative (shorthands={shorthands}) geometry diverges from absolute:\n abs={abs}\n rel={rel}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Binary thresholding: tunable fixed cutoff and Bradley–Roth adaptive.
|
||||
|
||||
use vtracer::frontend::{BinaryFrontend, Frontend};
|
||||
use vtracer::{ColorImage, Threshold};
|
||||
|
||||
fn gray(w: usize, h: usize, f: impl Fn(usize, usize) -> u8) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = f(x, y);
|
||||
pixels.extend_from_slice(&[v, v, v, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total foreground pixels selected by a frontend over an image.
|
||||
fn foreground_area(front: &BinaryFrontend, img: &ColorImage) -> usize {
|
||||
front
|
||||
.segment(img)
|
||||
.unwrap()
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.mask.area())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// A uniform gray field: a lower fixed threshold selects strictly fewer pixels.
|
||||
#[test]
|
||||
fn fixed_threshold_is_tunable() {
|
||||
// Left third value 80, middle 130, right 180.
|
||||
let img = gray(60, 20, |x, _| match x / 20 {
|
||||
0 => 80,
|
||||
1 => 130,
|
||||
_ => 180,
|
||||
});
|
||||
|
||||
let front = |v: u8| BinaryFrontend {
|
||||
threshold: Threshold::Fixed(v),
|
||||
diagonal: false,
|
||||
min_area: 0,
|
||||
};
|
||||
|
||||
let low = foreground_area(&front(100), &img); // catches only the 80 band
|
||||
let mid = foreground_area(&front(150), &img); // 80 + 130 bands
|
||||
let high = foreground_area(&front(200), &img); // everything
|
||||
|
||||
assert!(
|
||||
low < mid && mid < high,
|
||||
"higher threshold must select more foreground: {low} < {mid} < {high}"
|
||||
);
|
||||
assert_eq!(high, 60 * 20, "threshold above all values selects everything");
|
||||
}
|
||||
|
||||
/// Adaptive thresholding recovers locally-dark marks under a brightness
|
||||
/// gradient that no single global cutoff can separate.
|
||||
#[test]
|
||||
fn adaptive_beats_fixed_under_uneven_lighting() {
|
||||
let (w, h) = (80, 40);
|
||||
|
||||
// Background ramps left(70) → right(210). Two 6x6 marks, each 40 darker
|
||||
// than their local background: one on the dark side, one on the bright side.
|
||||
let bg = |x: usize| 70 + (x * 140 / (w - 1)) as u8;
|
||||
let marks = [(16usize, 17usize), (60, 17)];
|
||||
let is_mark = |x: usize, y: usize| {
|
||||
marks
|
||||
.iter()
|
||||
.any(|&(mx, my)| x >= mx && x < mx + 6 && y >= my && y < my + 6)
|
||||
};
|
||||
let img = gray(w, h, |x, y| {
|
||||
if is_mark(x, y) {
|
||||
bg(x).saturating_sub(40)
|
||||
} else {
|
||||
bg(x)
|
||||
}
|
||||
});
|
||||
|
||||
let base = BinaryFrontend {
|
||||
threshold: Threshold::Fixed(128),
|
||||
diagonal: false,
|
||||
min_area: 4,
|
||||
};
|
||||
|
||||
// A global cutoff can't isolate both marks: 128 catches the dark-side mark
|
||||
// but floods the whole dark half of the ramp, and misses the bright-side
|
||||
// mark (~136) entirely — so fixed has no region on the bright half.
|
||||
let fixed_seg = base.segment(&img).unwrap();
|
||||
let fixed_area: usize = fixed_seg.layers.iter().map(|l| l.mask.area()).sum();
|
||||
let mid = (w as i32) / 2;
|
||||
let fixed_right = fixed_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
|
||||
|
||||
// Adaptive: window comfortably larger than the 6px marks so they fill.
|
||||
let adaptive = BinaryFrontend {
|
||||
threshold: Threshold::Adaptive {
|
||||
window: 21,
|
||||
t: 15.0,
|
||||
},
|
||||
..base.clone()
|
||||
};
|
||||
let adaptive_seg = adaptive.segment(&img).unwrap();
|
||||
let adaptive_area: usize = adaptive_seg.layers.iter().map(|l| l.mask.area()).sum();
|
||||
let adaptive_left = adaptive_seg.layers.iter().any(|l| l.mask.offset.x < mid);
|
||||
let adaptive_right = adaptive_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
|
||||
|
||||
// The point of adaptive: it finds locally-dark marks on *both* sides of the
|
||||
// ramp, where the global threshold catches only the dark half.
|
||||
assert!(!fixed_right, "fixed(128) should miss the bright-side mark");
|
||||
assert!(
|
||||
adaptive_left && adaptive_right,
|
||||
"adaptive should detect marks on both the dark and bright sides"
|
||||
);
|
||||
assert!(adaptive_area > 0, "adaptive must select some foreground");
|
||||
assert!(
|
||||
adaptive_area * 3 < fixed_area,
|
||||
"adaptive should select far less than fixed's flooded half: \
|
||||
adaptive={adaptive_area}, fixed={fixed_area}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Rasterize-and-diff equivalence between stacked and mosaic (cutout) modes.
|
||||
//!
|
||||
//! Both modes render the *same* flattened partition of the image — stacked by
|
||||
//! painting layers top-down, mosaic as a gapless tessellation. So their
|
||||
//! rasterizations must agree in every region interior; they may differ only
|
||||
//! within a thin band along region boundaries, where the two fitting paths
|
||||
//! legitimately place the edge a fraction of a pixel apart. This test asserts
|
||||
//! exactly that: any pixel that differs must lie within ~1–2px of a boundary.
|
||||
//!
|
||||
//! `resvg` is a dev-dependency, so this never enters a wasm build.
|
||||
|
||||
use resvg::{tiny_skia, usvg};
|
||||
use vtracer::{ColorImage, Config, FitMode, Hierarchical};
|
||||
|
||||
/// A few smooth colored discs on a background — curved boundaries, limited
|
||||
/// boundary length, no thin (1px) features.
|
||||
fn blobs(w: usize, h: usize) -> ColorImage {
|
||||
let discs = [
|
||||
(28.0f64, 30.0, 18.0, (210u8, 60, 60)),
|
||||
(64.0, 40.0, 20.0, (60, 160, 90)),
|
||||
(44.0, 68.0, 16.0, (70, 90, 200)),
|
||||
];
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut col = (235u8, 230, 225); // background
|
||||
for &(cx, cy, r, c) in &discs {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
if dx * dx + dy * dy <= r * r {
|
||||
col = c;
|
||||
}
|
||||
}
|
||||
pixels.extend_from_slice(&[col.0, col.1, col.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn rasterize(svg: &str, w: u32, h: u32) -> Vec<u8> {
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
|
||||
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
|
||||
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
pixmap.data().to_vec()
|
||||
}
|
||||
|
||||
/// Max per-channel difference between two RGBA pixels at index `i`.
|
||||
fn pixel_diff(a: &[u8], b: &[u8], i: usize) -> u8 {
|
||||
(0..4)
|
||||
.map(|c| a[i + c].abs_diff(b[i + c]))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mark pixels within Chebyshev radius `r` of a color edge in either image.
|
||||
fn boundary_band(a: &[u8], b: &[u8], w: usize, h: usize, r: i32) -> Vec<bool> {
|
||||
const EDGE: u8 = 24;
|
||||
let idx = |x: usize, y: usize| (y * w + x) * 4;
|
||||
let mut edge = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = idx(x, y);
|
||||
// An edge is where either rendering changes color vs its right/down
|
||||
// neighbor.
|
||||
let mut is_edge = false;
|
||||
for img in [a, b] {
|
||||
if x + 1 < w && neighbor_diff(img, i, idx(x + 1, y)) > EDGE {
|
||||
is_edge = true;
|
||||
}
|
||||
if y + 1 < h && neighbor_diff(img, i, idx(x, y + 1)) > EDGE {
|
||||
is_edge = true;
|
||||
}
|
||||
}
|
||||
if is_edge {
|
||||
edge[y * w + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dilate the edge set by r.
|
||||
let mut band = vec![false; w * h];
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
let mut near = false;
|
||||
'outer: for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && edge[ny as usize * w + nx as usize] {
|
||||
near = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
band[y as usize * w + x as usize] = near;
|
||||
}
|
||||
}
|
||||
band
|
||||
}
|
||||
|
||||
fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
|
||||
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let img = blobs(w, h);
|
||||
|
||||
let stacked = Config {
|
||||
mode,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_svg(&img)
|
||||
.unwrap();
|
||||
|
||||
let cutout = Config {
|
||||
mode,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_svg(&img)
|
||||
.unwrap();
|
||||
|
||||
let a = rasterize(&stacked, w as u32, h as u32);
|
||||
let b = rasterize(&cutout, w as u32, h as u32);
|
||||
assert_eq!(a.len(), b.len());
|
||||
|
||||
let band = boundary_band(&a, &b, w, h, 2);
|
||||
|
||||
const DIFF: u8 = 40;
|
||||
let mut interior_mismatches = 0;
|
||||
for p in 0..(w * h) {
|
||||
let i = p * 4;
|
||||
if pixel_diff(&a, &b, i) > DIFF && !band[p] {
|
||||
interior_mismatches += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Every real difference must live in the boundary band; interiors match.
|
||||
assert_eq!(
|
||||
interior_mismatches, 0,
|
||||
"{mode:?}: {interior_mismatches} interior pixels differ between stacked and cutout \
|
||||
(differences must be confined to the boundary band)"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_equivalent(mode: FitMode) {
|
||||
assert_equivalent_with(mode, vtracer::Clustering::ColorCluster);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_and_cutout_agree_in_interiors_spline() {
|
||||
assert_equivalent(FitMode::Spline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_and_cutout_agree_in_interiors_polygon() {
|
||||
assert_equivalent(FitMode::Polygon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_and_cutout_agree_in_interiors_pixel() {
|
||||
assert_equivalent(FitMode::Pixel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watershed_stacked_and_cutout_agree_in_interiors() {
|
||||
for mode in [FitMode::Pixel, FitMode::Spline] {
|
||||
assert_equivalent_with(mode, vtracer::Clustering::Watershed);
|
||||
}
|
||||
}
|
||||
|
||||
// --- seam / show-through test -------------------------------------------------
|
||||
|
||||
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
|
||||
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
|
||||
pixmap.fill(tiny_skia::Color::from_rgba8(bg[0], bg[1], bg[2], 255));
|
||||
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
pixmap.data().to_vec()
|
||||
}
|
||||
|
||||
/// A full-canvas-coverage image rendered in stacked mode must be fully opaque:
|
||||
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
|
||||
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
|
||||
/// exactly the hole-punching bug this guards against.
|
||||
fn assert_no_seams(clustering: vtracer::Clustering) {
|
||||
let (w, h) = (96usize, 96usize);
|
||||
let img = blobs(w, h); // background fills the whole canvas
|
||||
let svg = Config {
|
||||
mode: FitMode::Spline,
|
||||
clustering,
|
||||
hierarchical: Hierarchical::Stacked,
|
||||
..Config::default()
|
||||
}
|
||||
.build()
|
||||
.unwrap()
|
||||
.to_svg(&img)
|
||||
.unwrap();
|
||||
|
||||
let white = rasterize_on(&svg, w as u32, h as u32, [255, 255, 255, 255]);
|
||||
let black = rasterize_on(&svg, w as u32, h as u32, [0, 0, 0, 255]);
|
||||
|
||||
// Count backdrop-dependent pixels, ignoring the 1px canvas border (the only
|
||||
// legitimate outer-silhouette antialiasing for a full-coverage image).
|
||||
let mut show_through = 0;
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let i = (y * w + x) * 4;
|
||||
if (0..3).any(|c| white[i + c].abs_diff(black[i + c]) > 8) {
|
||||
show_through += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
show_through, 0,
|
||||
"{clustering:?} stacked leaked {show_through} backdrop pixels — seams/holes in overdraw"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_has_no_seams() {
|
||||
assert_no_seams(vtracer::Clustering::ColorCluster);
|
||||
}
|
||||
|
||||
/// The watershed frontend emits disjoint region masks; its full-canvas solid
|
||||
/// background layer is what restores overdraw. This guards that construction.
|
||||
#[test]
|
||||
fn watershed_stacked_has_no_seams() {
|
||||
assert_no_seams(vtracer::Clustering::Watershed);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Golden-snapshot tests over synthetic images, exercising every stage —
|
||||
//! hierarchical clustering, all three fitters, color fitting, the optimizer
|
||||
//! passes, and the writer.
|
||||
//!
|
||||
//! Goldens are compared by **rendering** both the stored SVG and the freshly
|
||||
//! produced SVG and diffing pixels, not by byte-equality. The spline fitter's
|
||||
//! cubic fit is floating-point, and f64 results differ by a few ULPs across
|
||||
//! architectures (arm64 vs x86_64); after rounding, a coordinate can flip and
|
||||
//! change the SVG bytes without any real geometry change. A visual diff is
|
||||
//! encoding-agnostic and tolerant of that sub-pixel noise while still catching
|
||||
//! genuine regressions.
|
||||
//!
|
||||
//! Regenerate goldens after an intentional behavior change with:
|
||||
//!
|
||||
//! ```sh
|
||||
//! VTRACER_BLESS=1 cargo test -p vtracer --test golden
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use resvg::{tiny_skia, usvg};
|
||||
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
|
||||
|
||||
// --- synthetic image builders ------------------------------------------------
|
||||
|
||||
fn mk<F: Fn(usize, usize) -> (u8, u8, u8, u8)>(w: usize, h: usize, f: F) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (r, g, b, a) = f(x, y);
|
||||
pixels.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Four vertical color bands.
|
||||
fn bands() -> ColorImage {
|
||||
let cols = [
|
||||
(220, 40, 40),
|
||||
(40, 200, 60),
|
||||
(50, 60, 220),
|
||||
(230, 210, 40),
|
||||
];
|
||||
mk(48, 40, |x, _| {
|
||||
let (r, g, b) = cols[(x * cols.len()) / 48];
|
||||
(r, g, b, 255)
|
||||
})
|
||||
}
|
||||
|
||||
/// Checkerboard of 8x8 cells — exercises region adjacency and holes.
|
||||
fn checker() -> ColorImage {
|
||||
mk(48, 48, |x, y| {
|
||||
if ((x / 8) + (y / 8)) % 2 == 0 {
|
||||
(20, 20, 20, 255)
|
||||
} else {
|
||||
(235, 235, 235, 255)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A filled disc on a contrasting background — exercises curve fitting.
|
||||
fn disc() -> ColorImage {
|
||||
let (cx, cy, r2) = (24.0f64, 24.0f64, 16.0f64 * 16.0);
|
||||
mk(48, 48, |x, y| {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
if dx * dx + dy * dy <= r2 {
|
||||
(200, 60, 60, 255)
|
||||
} else {
|
||||
(240, 240, 240, 255)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// An annulus (disc with a hole) — exercises hole tracing.
|
||||
fn ring() -> ColorImage {
|
||||
let (cx, cy) = (24.0f64, 24.0f64);
|
||||
mk(48, 48, |x, y| {
|
||||
let dx = x as f64 - cx;
|
||||
let dy = y as f64 - cy;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 <= 20.0 * 20.0 && d2 >= 9.0 * 9.0 {
|
||||
(40, 90, 200, 255)
|
||||
} else {
|
||||
(245, 245, 245, 255)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A 4x4 grid of 16 distinct saturated colors — produces many hierarchical
|
||||
/// layers, and gives auto-quantize something real to reduce.
|
||||
fn swatches() -> ColorImage {
|
||||
let step = [0u8, 85, 170, 255];
|
||||
mk(48, 48, |x, y| {
|
||||
let col = (x / 12).min(3);
|
||||
let row = (y / 12).min(3);
|
||||
(step[col], step[row], 128, 255)
|
||||
})
|
||||
}
|
||||
|
||||
// --- fixture matrix ----------------------------------------------------------
|
||||
|
||||
fn base() -> Config {
|
||||
Config::default()
|
||||
}
|
||||
|
||||
fn cases() -> Vec<(&'static str, ColorImage, Config)> {
|
||||
vec![
|
||||
// Fit modes on the same content.
|
||||
("bands_spline", bands(), base()),
|
||||
(
|
||||
"bands_polygon",
|
||||
bands(),
|
||||
Config {
|
||||
mode: FitMode::Polygon,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"bands_pixel",
|
||||
bands(),
|
||||
Config {
|
||||
mode: FitMode::Pixel,
|
||||
optimize: 0,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Curves and holes.
|
||||
("disc_spline", disc(), base()),
|
||||
("ring_spline", ring(), base()),
|
||||
("checker_spline", checker(), base()),
|
||||
// Hierarchical layering.
|
||||
("swatches_color", swatches(), base()),
|
||||
// Binary mode.
|
||||
(
|
||||
"checker_bw",
|
||||
checker(),
|
||||
Config {
|
||||
clustering: Clustering::Binary,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Color fitting: fixed palette (+ merge) and auto-quantize (+ merge).
|
||||
(
|
||||
"bands_palette",
|
||||
bands(),
|
||||
Config {
|
||||
palette: vec![Color::new(0, 0, 0), Color::new(255, 255, 255)],
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"swatches_quant4",
|
||||
swatches(),
|
||||
Config {
|
||||
max_colors: Some(4),
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Optimizer / writer encoding levels on identical geometry.
|
||||
(
|
||||
"disc_opt0",
|
||||
disc(),
|
||||
Config {
|
||||
optimize: 0,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"disc_opt2",
|
||||
disc(),
|
||||
Config {
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Mosaic (seam-free tessellation): exact pixel and polygon fitters.
|
||||
(
|
||||
"disc_mosaic_pixel",
|
||||
disc(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Pixel,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"checker_mosaic_polygon",
|
||||
checker(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Polygon,
|
||||
optimize: 2,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"disc_mosaic_spline",
|
||||
disc(),
|
||||
Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Spline,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
// Watershed clustering: stacked and mosaic.
|
||||
(
|
||||
"disc_watershed_spline",
|
||||
disc(),
|
||||
Config {
|
||||
clustering: Clustering::Watershed,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
(
|
||||
"swatches_watershed_mosaic",
|
||||
swatches(),
|
||||
Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
mode: FitMode::Polygon,
|
||||
..base()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn goldens_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("goldens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_snapshots() {
|
||||
let bless = std::env::var_os("VTRACER_BLESS").is_some();
|
||||
let dir = goldens_dir();
|
||||
if bless {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
let mut mismatches = Vec::new();
|
||||
for (name, img, config) in cases() {
|
||||
let svg = config
|
||||
.build()
|
||||
.unwrap_or_else(|e| panic!("case {name}: build failed: {e}"))
|
||||
.to_svg(&img)
|
||||
.unwrap_or_else(|e| panic!("case {name}: convert failed: {e}"));
|
||||
|
||||
let path = dir.join(format!("{name}.svg"));
|
||||
if bless {
|
||||
std::fs::write(&path, &svg).unwrap();
|
||||
continue;
|
||||
}
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(expected) => {
|
||||
if let Some(diff) = render_diff(&expected, &svg) {
|
||||
mismatches.push(format!("{name}: {diff}"));
|
||||
}
|
||||
}
|
||||
Err(_) => mismatches.push(format!(
|
||||
"{name}: missing golden ({}); run with VTRACER_BLESS=1",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"golden mismatches:\n{}",
|
||||
mismatches.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Render an SVG string to an RGBA pixmap at its intrinsic size.
|
||||
fn render(svg: &str) -> (u32, u32, Vec<u8>) {
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse golden svg");
|
||||
let size = tree.size();
|
||||
let (w, h) = (size.width().ceil() as u32, size.height().ceil() as u32);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(w.max(1), h.max(1)).expect("alloc pixmap");
|
||||
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
|
||||
(w, h, pixmap.data().to_vec())
|
||||
}
|
||||
|
||||
/// Compare two SVGs by rendering. Returns `Some(reason)` if they differ beyond
|
||||
/// a small tolerance (which absorbs cross-architecture sub-pixel float noise),
|
||||
/// or `None` if visually equivalent.
|
||||
fn render_diff(expected: &str, actual: &str) -> Option<String> {
|
||||
let (ew, eh, a) = render(expected);
|
||||
let (aw, ah, b) = render(actual);
|
||||
if (ew, eh) != (aw, ah) {
|
||||
return Some(format!("size {ew}x{eh} vs {aw}x{ah}"));
|
||||
}
|
||||
// A pixel "differs" only on a clear color change, not antialiasing wobble.
|
||||
const CHANNEL: u8 = 40;
|
||||
let total = (ew * eh) as usize;
|
||||
let differing = (0..total)
|
||||
.filter(|&p| (0..3).any(|c| a[p * 4 + c].abs_diff(b[p * 4 + c]) > CHANNEL))
|
||||
.count();
|
||||
// Allow a tiny fraction for boundary pixels that flip under sub-pixel shifts.
|
||||
let allowed = (total / 200).max(8); // 0.5%, min 8px
|
||||
if differing > allowed {
|
||||
Some(format!(
|
||||
"{differing}/{total} pixels differ (> {allowed} allowed) — real change, re-bless if intended"
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
|
||||
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#000000"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0L48,0L48,40L0,40Z" fill="#28C83C"/>
|
||||
<path d="M36,0L48,0L48,40L36,40Z" fill="#E6D228"/>
|
||||
<path d="M24,0L36,0L36,40L24,40Z" fill="#323CDC"/>
|
||||
<path d="M0,0L12,0L12,40L0,40Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0L48,0l0,40L0,40Z" fill="#28C83C"/>
|
||||
<path d="M36,0L48,0l0,40L36,40Z" fill="#E6D228"/>
|
||||
<path d="M24,0L36,0l0,40L24,40Z" fill="#323CDC"/>
|
||||
<path d="M0,0L12,0l0,40L0,40Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#28C83C"/>
|
||||
<path d="M36,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#E6D228"/>
|
||||
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#323CDC"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#DC2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#000000"/>
|
||||
<path d="M16,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M0,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M16,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M0,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M16,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M32,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M8,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M8,8V0H0V8H8Z" fill="#141414"/>
|
||||
<path d="M16,0H8V8h8V0Z" fill="#EBEBEB"/>
|
||||
<path d="M24,0H16V8h8V0Z" fill="#141414"/>
|
||||
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
|
||||
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M48,8V0H40V8h8Z"/>
|
||||
<path d="M8,8H0v8H8V8Z"/>
|
||||
</g>
|
||||
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
|
||||
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
|
||||
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
|
||||
<g fill="#141414">
|
||||
<path d="M48,8H40v8h8V8Z"/>
|
||||
<path d="M8,16H0v8H8V16Z"/>
|
||||
</g>
|
||||
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
|
||||
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
|
||||
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
|
||||
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M48,16H40v8h8V16Z"/>
|
||||
<path d="M8,24H0v8H8V24Z"/>
|
||||
</g>
|
||||
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
|
||||
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
|
||||
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
|
||||
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
|
||||
<g fill="#141414">
|
||||
<path d="M48,24H40v8h8V24Z"/>
|
||||
<path d="M8,32H0v8H8V32Z"/>
|
||||
</g>
|
||||
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
|
||||
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
|
||||
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
|
||||
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
|
||||
<g fill="#EBEBEB">
|
||||
<path d="M48,32H40v8h8V32Z"/>
|
||||
<path d="M8,40H0v8H8V40Z"/>
|
||||
</g>
|
||||
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
|
||||
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
|
||||
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
|
||||
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
|
||||
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#EBEBEB"/>
|
||||
<path d="M40,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M32,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M24,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M16,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M8,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,40c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M40,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M24,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M16,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M8,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M0,32c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M40,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M32,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M24,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M16,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M8,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,24c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M40,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M24,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M16,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M8,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M0,16c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M40,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M32,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M24,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M16,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M8,8c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,8C2.64,8,5.28,8,8,8c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M40,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M32,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M24,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#EBEBEB"/>
|
||||
<path d="M16,0c2.64,0,5.28,0,8,0c0,2.64,0,5.28,0,8c-2.64,0-5.28,0-8,0c0-2.64,0-5.28,0-8Z" fill="#141414"/>
|
||||
<path d="M0,0C2.64,0,5.28,0,8,0C8,2.64,8,5.28,8,8C5.36,8,2.72,8,0,8C0,5.36,0,2.72,0,0Z" fill="#141414"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0L0,48l48,0L48,0L0,0ZM24,8l1,0l0,1l5,0l0,1l2,0l0,1l2,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,2l1,0l0,2l1,0l0,5l1,0l0,1l-1,0l0,5l-1,0l0,2l-1,0l0,2l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-2,0l0,1l-2,0l0,1l-5,0l0,1l-1,0l0-1l-5,0l0-1l-2,0l0-1l-2,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-2l-1,0l0-2L9,30l0-5L8,25l0-1l1,0l0-5l1,0l0-2l1,0l0-2l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l2,0l0-1l2,0l0-1l5,0l0-1Z" fill="#F0F0F0"/>
|
||||
<path d="M24,8l0,1L19,9l0,1l-2,0l0,1l-2,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,2l-1,0l0,2L9,19l0,5L8,24l0,1l1,0l0,5l1,0l0,2l1,0l0,2l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l2,0l0,1l2,0l0,1l5,0l0,1l1,0l0-1l5,0l0-1l2,0l0-1l2,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-2l1,0l0-2l1,0l0-5l1,0l0-1l-1,0l0-5l-1,0l0-2l-1,0l0-2l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-2,0l0-1l-2,0l0-1L25,9l0-1L24,8Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 985 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C0,16,0,32,0,48c16,0,32,0,48,0c0-16,0-32,0-48C32,0,16,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
|
||||
<path d="M31.56,9.75C23.31,7.33,14,8.99,9.75,17.5c-.26,.85-.5,1.68-.75,2.5c-1.33,4-1.33,4,.81,10.44c1.84,3.95,3.73,6.83,7.69,8.81c.85,.26,1.68,.5,2.5,.75c4,1.33,4,1.33,10.44-.81c3.95-1.84,6.83-3.73,8.81-7.69c.26-.85,.5-1.67,.75-2.5c.75-2.24,1.06-3.6,.69-5.94c-1.85-5.53-3.65-10.5-9.13-13.31Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0C48,16,48,32,48,48C32,48,16,48,0,48C0,32,0,16,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06C39.1,16.2,41.16,20.44,40.91,26.15C39.91,31.12,37.77,34.55,34,38C29.69,40.33,25.67,41.47,20.81,40.5C16.02,38.91,12.35,36.5,9.89,31.95C8.06,27.32,7.57,23.62,9.15,18.85C11.43,13.88,14.28,10.99,19.31,9C25.64,7.23,29.86,8.66,35.31,12.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#E2B1B1"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM12.06,13.69c-2.83,4.54-4.51,8.24-3.72,13.65C9.88,32.82,12.06,35.92,17,39c4.43,2.03,7.98,2.31,12.69,1c5.03-1.99,7.88-4.88,10.16-9.85c1.58-4.77,1.09-8.47-.74-13.1c-2.46-4.55-6.13-6.96-10.92-8.55c-6.36-1.27-11.46,.92-16.13,5.19Z" fill="#F0F0F0"/>
|
||||
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 839 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#285AC8"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
|
||||
<path d="M29,16c2.56,1.44,2.56,1.44,4,4c.75,4.29,.71,7.73-1.44,11.56C27.73,33.71,24.29,33.75,20,33c-2.56-1.44-2.56-1.44-4-4c-.75-4.29-.71-7.73,1.44-11.56C21.27,15.29,24.71,15.25,29,16Z" fill="#F5F5F5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 781 B |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#55FF80"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#555580"/>
|
||||
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FFAA80"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#55AA80"/>
|
||||
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF0080"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#550080"/>
|
||||
<path d="M24,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAFF80"/>
|
||||
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00FF80"/>
|
||||
<path d="M24,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAAA80"/>
|
||||
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00AA80"/>
|
||||
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA5580"/>
|
||||
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#005580"/>
|
||||
<path d="M24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA0080"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0Z" fill="#000080"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
|
||||
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#FFFF80"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#AA2A80"/>
|
||||
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
|
||||
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#4B9280"/>
|
||||
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
|
||||
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#AA2A80"/>
|
||||
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#FFFF80"/>
|
||||
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
|
||||
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
|
||||
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
|
||||
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0ZM24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
|
||||
<path d="M12,12L12,0L0,0L0,12l12,0Z" fill="#000080"/>
|
||||
<path d="M24,0L12,0l0,12l12,0L24,0Z" fill="#550080"/>
|
||||
<path d="M36,0L24,0l0,12l12,0L36,0Z" fill="#AA0080"/>
|
||||
<path d="M48,12L48,0L36,0l0,12l12,0Z" fill="#FF0080"/>
|
||||
<path d="M12,12L0,12L0,24l12,0l0-12Z" fill="#005580"/>
|
||||
<path d="M24,12L12,12l0,12l12,0l0-12Z" fill="#555580"/>
|
||||
<path d="M36,12L24,12l0,12l12,0l0-12Z" fill="#AA5580"/>
|
||||
<path d="M48,12L36,12l0,12l12,0l0-12Z" fill="#FF5580"/>
|
||||
<path d="M12,24L0,24L0,36l12,0l0-12Z" fill="#00AA80"/>
|
||||
<path d="M24,24L12,24l0,12l12,0l0-12Z" fill="#55AA80"/>
|
||||
<path d="M36,24L24,24l0,12l12,0l0-12Z" fill="#AAAA80"/>
|
||||
<path d="M48,24L36,24l0,12l12,0l0-12Z" fill="#FFAA80"/>
|
||||
<path d="M12,36L0,36L0,48l12,0l0-12Z" fill="#00FF80"/>
|
||||
<path d="M24,36L12,36l0,12l12,0l0-12Z" fill="#55FF80"/>
|
||||
<path d="M36,36L24,36l0,12l12,0l0-12Z" fill="#AAFF80"/>
|
||||
<path d="M48,36L36,36l0,12l12,0l0-12Z" fill="#FFFF80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,103 @@
|
||||
//! End-to-end pipeline smoke tests over synthetic images.
|
||||
|
||||
use vtracer::{ColorImage, Clustering, Config, FitMode, Hierarchical};
|
||||
|
||||
/// Build a `size × size` image split into two vertical color bands.
|
||||
fn two_band_image(size: usize) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(size * size * 4);
|
||||
for _y in 0..size {
|
||||
for x in 0..size {
|
||||
let (r, g, b) = if x < size / 2 {
|
||||
(220, 40, 40)
|
||||
} else {
|
||||
(40, 40, 220)
|
||||
};
|
||||
pixels.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_valid_svg(svg: &str) {
|
||||
assert!(svg.contains("<svg"), "missing <svg> element:\n{svg}");
|
||||
assert!(svg.trim_end().ends_with("</svg>"), "missing </svg> close");
|
||||
assert!(svg.contains("<path"), "expected at least one path:\n{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_color_pipeline_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_fit_modes_produce_svg() {
|
||||
let img = two_band_image(32);
|
||||
for mode in [FitMode::Pixel, FitMode::Polygon, FitMode::Spline] {
|
||||
let config = Config {
|
||||
mode,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_pipeline_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
let config = Config {
|
||||
clustering: Clustering::Binary,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watershed_pipeline_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
for hierarchical in [Hierarchical::Stacked, Hierarchical::Cutout] {
|
||||
let config = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
hierarchical,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimize_levels_shrink_or_match() {
|
||||
let img = two_band_image(48);
|
||||
let mut sizes = Vec::new();
|
||||
for level in [0u8, 1, 2] {
|
||||
let config = Config {
|
||||
optimize: level,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
sizes.push(svg.len());
|
||||
}
|
||||
// Higher optimization should never produce larger output than level 0.
|
||||
assert!(sizes[1] <= sizes[0], "opt1 {} > opt0 {}", sizes[1], sizes[0]);
|
||||
assert!(sizes[2] <= sizes[0], "opt2 {} > opt0 {}", sizes[2], sizes[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mosaic_cutout_produces_svg() {
|
||||
let img = two_band_image(32);
|
||||
let config = Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..Config::default()
|
||||
};
|
||||
let svg = config.build().unwrap().to_svg(&img).unwrap();
|
||||
assert_valid_svg(&svg);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Progress reporting and cancellation for `Pipeline::run_with_progress`.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use vtracer::progress::{CancelToken, Phase, Progress};
|
||||
use vtracer::{ColorImage, Config, Error};
|
||||
|
||||
/// A checkerboard of two colors — enough clusters that segmentation runs a few
|
||||
/// batches, so incremental progress and mid-run cancellation are observable.
|
||||
fn checker(w: usize, h: usize) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = if (x / 6 + y / 6) % 2 == 0 {
|
||||
(210u8, 60, 60)
|
||||
} else {
|
||||
(60, 90, 200)
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// A token cancelled before the run starts trips promptly and yields no doc.
|
||||
#[test]
|
||||
fn precancelled_returns_cancelled() {
|
||||
let img = checker(64, 64);
|
||||
let pipeline = Config::default().build().unwrap();
|
||||
|
||||
let cancel = CancelToken::new();
|
||||
cancel.cancel();
|
||||
|
||||
let mut cb = |_p: Progress| {};
|
||||
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
|
||||
assert_eq!(result.err(), Some(Error::Cancelled));
|
||||
}
|
||||
|
||||
/// Cancelling from within the progress callback (on the first Segment report)
|
||||
/// trips at the next batch boundary and returns `Cancelled`.
|
||||
#[test]
|
||||
fn cancel_during_progress_trips() {
|
||||
let img = checker(96, 96);
|
||||
let pipeline = Config::default().build().unwrap();
|
||||
|
||||
let cancel = CancelToken::new();
|
||||
let saw_segment = Cell::new(false);
|
||||
|
||||
let mut cb = |p: Progress| {
|
||||
if p.phase == Phase::Segment {
|
||||
saw_segment.set(true);
|
||||
cancel.cancel();
|
||||
}
|
||||
};
|
||||
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
|
||||
|
||||
assert!(saw_segment.get(), "expected at least one Segment report");
|
||||
assert_eq!(result.err(), Some(Error::Cancelled));
|
||||
}
|
||||
|
||||
/// A successful run reports monotonically within each phase, ends at
|
||||
/// Optimize=1.0, and produces the same shapes as the plain `run`.
|
||||
#[test]
|
||||
fn progress_completes_and_matches_run() {
|
||||
let img = checker(64, 64);
|
||||
let pipeline = Config::default().build().unwrap();
|
||||
|
||||
let cancel = CancelToken::new();
|
||||
let last = Cell::new(None::<Progress>);
|
||||
let count = Cell::new(0usize);
|
||||
|
||||
let mut cb = |p: Progress| {
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&p.fraction),
|
||||
"fraction out of range: {}",
|
||||
p.fraction
|
||||
);
|
||||
last.set(Some(p));
|
||||
count.set(count.get() + 1);
|
||||
};
|
||||
let doc = pipeline
|
||||
.run_with_progress(&img, &cancel, &mut cb)
|
||||
.expect("run should succeed");
|
||||
|
||||
assert!(count.get() > 0, "expected progress reports");
|
||||
let final_p = last.get().expect("a final report");
|
||||
assert_eq!(final_p.phase, Phase::Optimize);
|
||||
assert_eq!(final_p.fraction, 1.0);
|
||||
|
||||
// Incremental clustering yields the same clusters as the blocking path,
|
||||
// so both entry points produce identical output.
|
||||
let plain = pipeline.run(&img).expect("plain run should succeed");
|
||||
assert_eq!(doc.shapes.len(), plain.shapes.len());
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Two-phase pipeline: cache the expensive segmentation, re-run the cheap
|
||||
//! downstream stages with different parameters (the interactive tuning loop).
|
||||
|
||||
use vtracer::{ColorImage, Config, FitMode};
|
||||
|
||||
/// A few colored blocks — several clusters, a few holes.
|
||||
fn blocks() -> ColorImage {
|
||||
let (w, h) = (48usize, 48usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = match (x / 16, y / 16) {
|
||||
(0, _) => (220u8, 40, 40),
|
||||
(1, 0) => (40, 200, 60),
|
||||
(1, _) => (50, 60, 220),
|
||||
_ => (230, 210, 40),
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg(mode: FitMode) -> Config {
|
||||
Config {
|
||||
mode,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `finish(segment(img))` equals the one-shot `run(img)`.
|
||||
#[test]
|
||||
fn two_phase_matches_one_shot() {
|
||||
let img = blocks();
|
||||
let pipeline = cfg(FitMode::Spline).build().unwrap();
|
||||
|
||||
let one_shot = pipeline.run(&img).unwrap();
|
||||
let seg = pipeline.segment(&img).unwrap();
|
||||
let two_phase = pipeline.finish(&seg).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
pipeline.writer.write(&one_shot),
|
||||
pipeline.writer.write(&two_phase),
|
||||
"splitting segment/finish must not change the output"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cached segmentation stays pristine — `finish` can be called repeatedly and
|
||||
/// deterministically (color fitting mutates only an internal clone).
|
||||
#[test]
|
||||
fn cached_segmentation_is_reusable() {
|
||||
let img = blocks();
|
||||
let pipeline = cfg(FitMode::Polygon).build().unwrap();
|
||||
|
||||
let seg = pipeline.segment(&img).unwrap();
|
||||
let first = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
|
||||
let second = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
|
||||
|
||||
assert_eq!(first, second, "reusing a cached segmentation must be stable");
|
||||
}
|
||||
|
||||
/// The tuning workflow: segment once, then feed that segmentation to pipelines
|
||||
/// with different curve-fitting parameters. Same regions, different geometry —
|
||||
/// and no re-segmentation. (Speckle, color precision, and layer difference are
|
||||
/// clustering parameters, so changing them requires a fresh `segment`.)
|
||||
#[test]
|
||||
fn tune_curve_fitting_on_cached_segmentation() {
|
||||
let img = blocks();
|
||||
|
||||
// Same clustering parameters (defaults), different fit modes → the
|
||||
// segmentation from one is valid input to the other's `finish`.
|
||||
let pixel = cfg(FitMode::Pixel).build().unwrap();
|
||||
let spline = cfg(FitMode::Spline).build().unwrap();
|
||||
|
||||
let seg = pixel.segment(&img).unwrap();
|
||||
|
||||
let doc_pixel = pixel.finish(&seg).unwrap();
|
||||
let doc_spline = spline.finish(&seg).unwrap();
|
||||
|
||||
// Same partition → same number of shapes.
|
||||
assert_eq!(doc_pixel.shapes.len(), doc_spline.shapes.len());
|
||||
assert!(!doc_pixel.shapes.is_empty());
|
||||
|
||||
// But the fitted geometry differs (straight edges vs cubic curves).
|
||||
assert_ne!(
|
||||
pixel.writer.write(&doc_pixel),
|
||||
spline.writer.write(&doc_spline),
|
||||
"pixel and spline fitting should produce different paths"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! `Session` caches the segmentation and re-segments only when a clustering
|
||||
//! parameter changes — verified both at the key level and end-to-end.
|
||||
|
||||
use visioncortex::Color;
|
||||
use vtracer::{
|
||||
CancelToken, Clustering, ColorImage, Config, FitMode, Hierarchical, Session,
|
||||
};
|
||||
|
||||
/// A few colored blocks — several clusters.
|
||||
fn blocks() -> ColorImage {
|
||||
let (w, h) = (48usize, 48usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = match (x / 16, y / 16) {
|
||||
(0, _) => (220u8, 40, 40),
|
||||
(1, 0) => (40, 200, 60),
|
||||
(1, _) => (50, 60, 220),
|
||||
_ => (230, 210, 40),
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// The key partition: finish-phase params share a segment key; clustering
|
||||
/// params change it. This is the contract `Session` relies on.
|
||||
#[test]
|
||||
fn segment_key_tracks_only_clustering_params() {
|
||||
let base = Config::default();
|
||||
|
||||
// Finish-phase changes → same key (segmentation is reusable).
|
||||
for tweaked in [
|
||||
Config {
|
||||
corner_threshold: 90,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
optimize: 0,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
hierarchical: vtracer::Hierarchical::Cutout,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
max_colors: Some(4),
|
||||
..base.clone()
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
base.segment_key(),
|
||||
tweaked.segment_key(),
|
||||
"finish-phase param must not change the segment key"
|
||||
);
|
||||
}
|
||||
|
||||
// Clustering changes → different key (must re-segment).
|
||||
for tweaked in [
|
||||
Config {
|
||||
filter_speckle: base.filter_speckle + 4,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
color_precision: 4,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
layer_difference: 32,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
clustering: vtracer::Clustering::Binary,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
clustering: vtracer::Clustering::Watershed,
|
||||
..base.clone()
|
||||
},
|
||||
Config {
|
||||
watershed_detail: 200,
|
||||
..base.clone()
|
||||
},
|
||||
] {
|
||||
assert_ne!(
|
||||
base.segment_key(),
|
||||
tweaked.segment_key(),
|
||||
"clustering param must change the segment key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Session` render equals the one-shot pipeline — for a finish-only change
|
||||
/// (reuses the cache) and for a clustering change (re-segments). Correctness is
|
||||
/// identical either way; the cache is a transparent optimization.
|
||||
#[test]
|
||||
fn session_matches_one_shot() {
|
||||
let img = blocks();
|
||||
let mut session = Session::new(img.clone());
|
||||
|
||||
let base = Config::default();
|
||||
let svg0 = session.render_svg(&base).unwrap();
|
||||
assert_eq!(
|
||||
svg0,
|
||||
base.build().unwrap().to_svg(&img).unwrap(),
|
||||
"first render must match the one-shot pipeline"
|
||||
);
|
||||
|
||||
// Finish-only change: reuses the cached segmentation.
|
||||
let tuned = Config {
|
||||
corner_threshold: 90,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
session.render_svg(&tuned).unwrap(),
|
||||
tuned.build().unwrap().to_svg(&img).unwrap(),
|
||||
"reused-segmentation render must match the one-shot pipeline"
|
||||
);
|
||||
|
||||
// Clustering change: re-segments, still matches the one-shot.
|
||||
let respeckled = Config {
|
||||
filter_speckle: base.filter_speckle + 4,
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
session.render_svg(&respeckled).unwrap(),
|
||||
respeckled.build().unwrap().to_svg(&img).unwrap(),
|
||||
"re-segmented render must match the one-shot pipeline"
|
||||
);
|
||||
}
|
||||
|
||||
/// Blocks plus a gradient band and a small fleck — structure that makes every
|
||||
/// clustering parameter (speckle, precision, gradient step, watershed detail,
|
||||
/// thresholds) actually change the output.
|
||||
fn textured() -> ColorImage {
|
||||
let (w, h) = (48usize, 48usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = if y >= 32 {
|
||||
let g = 60 + (x * 3) as u8; // gradient band
|
||||
(g, g, 200)
|
||||
} else if (4..7).contains(&x) && (4..7).contains(&y) {
|
||||
(10, 200, 10) // 9 px fleck
|
||||
} else {
|
||||
match (x / 16, y / 16) {
|
||||
(0, _) => (220u8, 40, 40),
|
||||
(1, _) => (40, 200, 60),
|
||||
_ => (230, 210, 40),
|
||||
}
|
||||
};
|
||||
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exhaustive contract: walk a cumulative sequence of config changes that
|
||||
/// touches every parameter category — finish-phase dials, clustering dials,
|
||||
/// frontend switches (including leaving watershed and coming back to its
|
||||
/// cached hierarchy), compositing, palettes — and after each step the cached
|
||||
/// session render must be byte-identical to a from-scratch one-shot pipeline.
|
||||
#[test]
|
||||
fn session_equals_one_shot_across_param_walk() {
|
||||
let img = textured();
|
||||
let mut session = Session::new(img.clone());
|
||||
let mut cfg = Config::default();
|
||||
|
||||
let steps: Vec<(&str, fn(&mut Config))> = vec![
|
||||
("initial", |_| {}),
|
||||
// Finish-phase changes (cache hits).
|
||||
("corner_threshold", |c| c.corner_threshold = 90),
|
||||
("mode polygon", |c| c.mode = FitMode::Polygon),
|
||||
("optimize 2", |c| c.optimize = 2),
|
||||
("cutout", |c| c.hierarchical = Hierarchical::Cutout),
|
||||
("path_precision", |c| c.path_precision = Some(1)),
|
||||
// Clustering changes (re-segment).
|
||||
("filter_speckle", |c| c.filter_speckle = 6),
|
||||
("layer_difference", |c| c.layer_difference = 32),
|
||||
("color_precision", |c| c.color_precision = 5),
|
||||
// Watershed, incl. cheap re-cuts of the cached hierarchy.
|
||||
("watershed", |c| c.clustering = Clustering::Watershed),
|
||||
("detail 200", |c| c.watershed_detail = 200),
|
||||
("detail 64", |c| c.watershed_detail = 64),
|
||||
("stacked", |c| c.hierarchical = Hierarchical::Stacked),
|
||||
("mode spline", |c| c.mode = FitMode::Spline),
|
||||
// Binary, with both thresholding methods.
|
||||
("binary", |c| c.clustering = Clustering::Binary),
|
||||
("threshold 100", |c| c.binary_threshold = 100),
|
||||
("adaptive", |c| c.binary_adaptive = true),
|
||||
// Back to watershed: the hierarchy cache must still be valid.
|
||||
("watershed again", |c| c.clustering = Clustering::Watershed),
|
||||
("quantize", |c| c.max_colors = Some(4)),
|
||||
// And back to the color path with a palette.
|
||||
("color-cluster", |c| {
|
||||
c.clustering = Clustering::ColorCluster;
|
||||
c.max_colors = None;
|
||||
c.palette = vec![
|
||||
Color::new(0, 0, 0),
|
||||
Color::new(255, 255, 255),
|
||||
Color::new(200, 40, 40),
|
||||
];
|
||||
}),
|
||||
("speckle again", |c| c.filter_speckle = 2),
|
||||
];
|
||||
|
||||
for (name, step) in steps {
|
||||
step(&mut cfg);
|
||||
assert_eq!(
|
||||
session.render_svg(&cfg).unwrap(),
|
||||
cfg.build().unwrap().to_svg(&img).unwrap(),
|
||||
"step `{name}`: cached session render must equal a full rebuild"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The progress-reporting render path (which segments through a different
|
||||
/// branch, including the watershed hierarchy shortcut) produces the same
|
||||
/// document as the plain path and the one-shot pipeline.
|
||||
#[test]
|
||||
fn render_with_progress_matches_plain_render() {
|
||||
let img = textured();
|
||||
for clustering in [
|
||||
Clustering::ColorCluster,
|
||||
Clustering::Watershed,
|
||||
Clustering::Binary,
|
||||
] {
|
||||
let cfg = Config {
|
||||
clustering,
|
||||
..Config::default()
|
||||
};
|
||||
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
|
||||
|
||||
// Fresh session per variant so the progress path does the segmenting.
|
||||
let mut session = Session::new(img.clone());
|
||||
let doc = session
|
||||
.render_with_progress(&cfg, &CancelToken::new(), &mut |_| {})
|
||||
.unwrap();
|
||||
let progress_svg = cfg.build().unwrap().writer.write(&doc);
|
||||
assert_eq!(
|
||||
progress_svg, one_shot,
|
||||
"{clustering:?}: progress path must equal the one-shot pipeline"
|
||||
);
|
||||
|
||||
// And the now-warm cache serves the plain path identically.
|
||||
assert_eq!(
|
||||
session.render_svg(&cfg).unwrap(),
|
||||
one_shot,
|
||||
"{clustering:?}: cache warmed by the progress path must match too"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `invalidate` drops all cached state; the next render rebuilds from scratch
|
||||
/// and still matches.
|
||||
#[test]
|
||||
fn invalidate_then_render_matches() {
|
||||
let img = textured();
|
||||
let cfg = Config {
|
||||
clustering: Clustering::Watershed,
|
||||
..Config::default()
|
||||
};
|
||||
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
|
||||
|
||||
let mut session = Session::new(img);
|
||||
assert_eq!(session.render_svg(&cfg).unwrap(), one_shot);
|
||||
session.invalidate();
|
||||
assert_eq!(
|
||||
session.render_svg(&cfg).unwrap(),
|
||||
one_shot,
|
||||
"render after invalidate must rebuild identically"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The curve-simplification stage, end to end: `Config::simplify` must cut
|
||||
//! anchor counts in both compositing modes without changing geometry kind,
|
||||
//! and leave output untouched when off (the goldens enforce the byte-level
|
||||
//! version of that).
|
||||
|
||||
use vtracer::ir::PathCmd;
|
||||
use vtracer::{ColorImage, Config, FitMode, Hierarchical, VectorDoc};
|
||||
|
||||
/// A filled disc — one long smooth boundary, the best case for merging the
|
||||
/// per-splice cubics the spline fitter emits.
|
||||
fn disc_image(size: usize) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(size * size * 4);
|
||||
let (c, r) = (size as f64 / 2.0, size as f64 * 0.4);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let (dx, dy) = (x as f64 + 0.5 - c, y as f64 + 0.5 - c);
|
||||
let (rr, gg, bb) = if (dx * dx + dy * dy).sqrt() < r {
|
||||
(200, 60, 60)
|
||||
} else {
|
||||
(240, 240, 240)
|
||||
};
|
||||
pixels.extend_from_slice(&[rr, gg, bb, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
}
|
||||
|
||||
fn cubic_count(doc: &VectorDoc) -> usize {
|
||||
doc.shapes
|
||||
.iter()
|
||||
.flat_map(|s| &s.path.subpaths)
|
||||
.flat_map(|sub| &sub.commands)
|
||||
.filter(|c| matches!(c, PathCmd::CubicTo(..)))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn run(config: &Config) -> VectorDoc {
|
||||
config.build().unwrap().run(&disc_image(128)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_reduces_cubics_in_stacked_mode() {
|
||||
let base = Config::default();
|
||||
let simplified = Config {
|
||||
simplify: Some(2.0),
|
||||
..Config::default()
|
||||
};
|
||||
let (before, after) = (cubic_count(&run(&base)), cubic_count(&run(&simplified)));
|
||||
assert!(before > 0, "the disc must be traced with cubics");
|
||||
assert!(
|
||||
after < before,
|
||||
"simplify must reduce anchors: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_reduces_cubics_in_cutout_mode() {
|
||||
let cutout = |simplify| Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
simplify,
|
||||
..Config::default()
|
||||
};
|
||||
let (before, after) = (
|
||||
cubic_count(&run(&cutout(None))),
|
||||
cubic_count(&run(&cutout(Some(2.0)))),
|
||||
);
|
||||
assert!(before > 0, "the disc must be traced with cubics");
|
||||
assert!(
|
||||
after < before,
|
||||
"simplify must reduce anchors: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_leaves_polyline_modes_untouched() {
|
||||
for mode in [FitMode::Pixel, FitMode::Polygon] {
|
||||
let base = Config {
|
||||
mode,
|
||||
..Config::default()
|
||||
};
|
||||
let simplified = Config {
|
||||
simplify: Some(2.0),
|
||||
..base.clone()
|
||||
};
|
||||
let a = base.build().unwrap().to_svg(&disc_image(64)).unwrap();
|
||||
let b = simplified.build().unwrap().to_svg(&disc_image(64)).unwrap();
|
||||
assert_eq!(a, b, "{mode:?} output must not change");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Spline fitting stays anchored to the geometry it approximates.
|
||||
//!
|
||||
//! Regression for the sparse-slice ballooning bug: a splice slice with very
|
||||
//! uneven point spacing (a few-pixel jog then a long straight leg, produced by
|
||||
//! the walker around thin strands) used to be fitted by a single cubic that
|
||||
//! interpolated the samples exactly while swinging ~30 px sideways between
|
||||
//! them — its control points landing far outside the shape itself. The
|
||||
//! Cityscape sample at color precision 8 / gradient step 28 is the real
|
||||
//! reproduction (a 1 px, 330 px-tall strand in the maroon region).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use vtracer::ir::PathCmd;
|
||||
use vtracer::{ColorImage, Config, Hierarchical, VectorDoc};
|
||||
|
||||
fn cityscape() -> ColorImage {
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg");
|
||||
let img = image::open(&p).expect("sample image").to_rgba8();
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
ColorImage {
|
||||
pixels: img.into_raw(),
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cubic's control points must stay within its shape's on-curve bounding
|
||||
/// box plus a small overshoot allowance. The ballooning bug put handles ~25 px
|
||||
/// outside the whole shape; a healthy fit stays within the fit error (10).
|
||||
fn assert_handles_anchored(doc: &VectorDoc, margin: f64) {
|
||||
for (si, shape) in doc.shapes.iter().enumerate() {
|
||||
// Bounding box over on-curve points only.
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
let mut on_curve = |p: &visioncortex::PointF64| {
|
||||
x0 = x0.min(p.x);
|
||||
y0 = y0.min(p.y);
|
||||
x1 = x1.max(p.x);
|
||||
y1 = y1.max(p.y);
|
||||
};
|
||||
for sub in &shape.path.subpaths {
|
||||
for cmd in &sub.commands {
|
||||
match cmd {
|
||||
PathCmd::MoveTo(p) | PathCmd::LineTo(p) => on_curve(p),
|
||||
PathCmd::CubicTo(_, _, p) => on_curve(p),
|
||||
PathCmd::Close => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for sub in &shape.path.subpaths {
|
||||
for cmd in &sub.commands {
|
||||
if let PathCmd::CubicTo(c1, c2, _) = cmd {
|
||||
for q in [c1, c2] {
|
||||
assert!(
|
||||
q.x >= x0 - margin
|
||||
&& q.x <= x1 + margin
|
||||
&& q.y >= y0 - margin
|
||||
&& q.y <= y1 + margin,
|
||||
"shape {si}: control point ({},{}) strays outside \
|
||||
bbox ({x0},{y0})..({x1},{y1}) + {margin}",
|
||||
q.x,
|
||||
q.y
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_handles_stay_anchored_on_photo() {
|
||||
let img = cityscape();
|
||||
let base = Config {
|
||||
color_precision: 8,
|
||||
layer_difference: 28,
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
// Stacked: per-region closed outlines through Spline::from_path_f64.
|
||||
let doc = base.build().unwrap().run(&img).unwrap();
|
||||
assert!(doc.shapes.len() > 500, "sanity: the trace produced real output");
|
||||
assert_handles_anchored(&doc, 15.0);
|
||||
|
||||
// Cutout: open boundary segments through the mosaic's segment fitter.
|
||||
let cutout = Config {
|
||||
hierarchical: Hierarchical::Cutout,
|
||||
..base
|
||||
};
|
||||
let doc = cutout.build().unwrap().run(&img).unwrap();
|
||||
assert_handles_anchored(&doc, 15.0);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
//! Watershed frontend: partition invariants, the detail dial, small-basin
|
||||
//! absorption, and the hierarchy stack / cached re-cut behavior.
|
||||
|
||||
use vtracer::frontend::{Frontend, WatershedFrontend, WatershedHierarchy};
|
||||
use vtracer::{Color, ColorImage, Clustering, Config, Hierarchical, Segmentation, Session};
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> (u8, u8, u8)) -> ColorImage {
|
||||
let mut pixels = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (r, g, b) = f(x, y);
|
||||
pixels.extend_from_slice(&[r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
ColorImage {
|
||||
pixels,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the stacked layers top-down (later layers win), returning one layer
|
||||
/// index per pixel — the partition both compositors ultimately consume.
|
||||
fn flatten(seg: &Segmentation) -> Vec<usize> {
|
||||
let (w, h) = (seg.width as usize, seg.height as usize);
|
||||
let mut labels = vec![usize::MAX; w * h];
|
||||
for (li, layer) in seg.layers.iter().enumerate() {
|
||||
let m = &layer.mask;
|
||||
for y in 0..m.image.height {
|
||||
for x in 0..m.image.width {
|
||||
if m.image.get_pixel(x, y) {
|
||||
let gx = (m.offset.x + x as i32) as usize;
|
||||
let gy = (m.offset.y + y as i32) as usize;
|
||||
labels[gy * w + gx] = li;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
/// The stacked-hierarchy invariants: the bottom layer is a solid full canvas
|
||||
/// (so overdraw is seam-free), every pixel is covered, the flattened
|
||||
/// partition has exactly `regions` distinct labels, and the stack size is
|
||||
/// bounded by the merge tree (at most 2·regions − 1 layers).
|
||||
fn assert_stack(seg: &Segmentation, regions: usize) {
|
||||
let (w, h) = (seg.width as usize, seg.height as usize);
|
||||
let bottom = &seg.layers[0].mask;
|
||||
assert_eq!((bottom.width(), bottom.height()), (w, h), "bottom layer is full-canvas");
|
||||
assert_eq!(bottom.area(), w * h, "bottom layer is solid");
|
||||
assert!(seg.layers.len() <= 2 * regions.max(1) - 1, "stack bounded by the merge tree");
|
||||
|
||||
let labels = flatten(seg);
|
||||
assert!(labels.iter().all(|&l| l != usize::MAX), "every pixel covered");
|
||||
let mut distinct: Vec<usize> = labels.clone();
|
||||
distinct.sort_unstable();
|
||||
distinct.dedup();
|
||||
assert_eq!(distinct.len(), regions, "flattened region count");
|
||||
|
||||
// The final regions must be the topmost layers (painted after every
|
||||
// ancestor), or the flatten would not recover the partition.
|
||||
let first_final = seg.layers.len() - regions;
|
||||
assert!(
|
||||
distinct.iter().all(|&l| l >= first_final),
|
||||
"final regions are the topmost layers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Region count of a segmentation's flattened partition.
|
||||
fn regions(seg: &Segmentation) -> usize {
|
||||
let mut labels = flatten(seg);
|
||||
labels.sort_unstable();
|
||||
labels.dedup();
|
||||
labels.len()
|
||||
}
|
||||
|
||||
/// A flat single-color image is one region no matter the detail level.
|
||||
#[test]
|
||||
fn flat_image_is_one_region() {
|
||||
let img = image(24, 16, |_, _| (90, 120, 150));
|
||||
for detail in [0u8, 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 [0u8, 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 [64u8, 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 [128u8, 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");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# VTracer 1.0 Design Documents
|
||||
|
||||
VTracer is being rearchitected from a single hardcoded pipeline into a **vectorization framework**. These documents describe the target design.
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | Workspace layout, core IR, stage traits, pipeline driver, optimizer & SVG writer, CLI |
|
||||
| [mosaic.md](mosaic.md) | The seam-free cutout/mosaic mode: boundary-graph tracing and shared-edge curve fitting |
|
||||
| [bindings.md](bindings.md) | Python (PyPI), wasm, and the new Node.js (npm) package |
|
||||
| [roadmap.md](roadmap.md) | Milestones and verification strategy |
|
||||
|
||||
## Motivation
|
||||
|
||||
VTracer today (0.6.x) is a thin driver around the `visioncortex` crate: one pipeline (color clustering → per-cluster tracing → SVG string), a CLI, a pyo3 binding, and a web demo that duplicates the pipeline. The rewrite turns it into a framework with pluggable stages:
|
||||
|
||||
1. **Frontend** — any algorithm that produces clusters/segmentation from a raster image
|
||||
2. **Curve fitting backend** — pluggable polyline→curve fitters (pixel, polygon, spline, future potrace-style)
|
||||
3. **Color fitting** — mapping cluster colors to final paints, including custom fixed palettes
|
||||
4. **Optimizer** — a pass pipeline that shrinks output (relative path syntax, shorthand commands, precision reduction)
|
||||
5. **True mosaic cutout** — a perfect, gapless tessellation with shared boundary geometry, replacing today's fake cutout (which re-clusters a re-rendered image and shows seams)
|
||||
|
||||
The project stays backend/CLI focused, and everything except image file I/O compiles to `wasm32-unknown-unknown`.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **`visioncortex` remains a dependency**, wrapped behind traits. Development uses a path/`[patch]` dependency on the local checkout; API additions are committed to visioncortex directly and published as 0.8.x releases. Verified that everything the new design needs is already public: the fitting primitives (`fit_points_with_bezier`, `find_corners`, `subdivide_keep_corners`, `reduce`, `PathSimplify::*`) and cluster pixel access via `ClustersView`.
|
||||
- **In-repo rewrite, clean break.** New workspace layout, new API, version bump. Old CLI flags are kept only where they map naturally.
|
||||
- **Python binding stays** (ported to the new API). The **webapp GUI is dropped**; a wasm library crate replaces it.
|
||||
- **New Node.js library** published to npm, using the wasm build internally plus a native image reader (sharp).
|
||||
|
||||
## Pipeline at a glance
|
||||
|
||||
```
|
||||
┌───────────┐ ┌──────────────┐ ┌─────────────────────────────┐
|
||||
raster ───▶ │ Frontend │ ─▶│ ColorFitter* │ ─▶│ Compositing │
|
||||
image │ (segment) │ │ (palette, │ │ Stacked: closed outlines │
|
||||
└───────────┘ │ quantize, │ │ Mosaic: boundary graph + │
|
||||
│ merge) │ │ shared-edge fit │
|
||||
└──────────────┘ └──────────────┬──────────────┘
|
||||
│ CurveFitter
|
||||
▼ (pixel/polygon/spline)
|
||||
┌──────────────────────────────┐
|
||||
SVG ◀──── │ VectorDoc ─ OptimizerPass* ─ │
|
||||
│ SvgWriter │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
# Architecture
|
||||
|
||||
## Workspace layout
|
||||
|
||||
```
|
||||
Cargo.toml # workspace
|
||||
crates/
|
||||
├── vtracer-core/ # the framework. wasm-safe, no file/image I/O, no clap/pyo3
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── ir/ # Segmentation, LabelMap, VectorDoc, geometry types
|
||||
│ ├── frontend/ # trait Frontend + ColorClusterFrontend, BinaryFrontend, keying
|
||||
│ ├── colorfit/ # trait ColorFitter + Identity, FixedPalette, AutoQuantize
|
||||
│ ├── fitter/ # trait CurveFitter + Pixel, Polygon, Spline
|
||||
│ ├── compose/ # stacked composition (per-region closed tracing)
|
||||
│ ├── mosaic/ # boundary-graph extraction + shared-edge fitting (see mosaic.md)
|
||||
│ ├── optimize/ # trait OptimizerPass + passes over VectorDoc
|
||||
│ ├── svg/ # writer (absolute/relative, shorthands, precision)
|
||||
│ └── pipeline.rs # Pipeline driver + Config/presets
|
||||
├── vtracer/ # publishable bin+lib crate, keeps the crate name.
|
||||
│ # image I/O (image crate), clap 4 CLI,
|
||||
│ # pyo3 binding behind `python-binding` feature
|
||||
└── vtracer-wasm/ # wasm-bindgen bindings over vtracer-core
|
||||
nodejs/ # npm package: TS wrapper + embedded wasm build + sharp reader
|
||||
```
|
||||
|
||||
- `webapp/` and `cmdapp/` are deleted (git history preserves them).
|
||||
- `vtracer` re-exports `vtracer-core`, so library users need a single dependency.
|
||||
- During development the workspace carries `[patch.crates-io] visioncortex = { path = "../visioncortex" }`; releases pin a published 0.8.x.
|
||||
- `flo_curves` (already in the tree via visioncortex) becomes a direct dependency of `vtracer-core` for configurable-error Bezier fitting.
|
||||
|
||||
## Core IR
|
||||
|
||||
Value types from `visioncortex` are reused where they fit (`ColorImage`, `Color`, `PointF64`, `CompoundPath`); the pipeline IR is our own:
|
||||
|
||||
```rust
|
||||
/// Frontend output — the general form is ordered layers (painter's algorithm).
|
||||
pub struct Segmentation {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub layers: Vec<Layer>, // bottom-to-top paint order
|
||||
}
|
||||
|
||||
pub struct Layer {
|
||||
pub paint: Paint, // starts as mean cluster color; ColorFitter may rewrite
|
||||
pub mask: RegionMask, // the cluster's pixel indices
|
||||
}
|
||||
|
||||
/// Flat partition for mosaic mode, derived by painting layers top-down.
|
||||
pub struct LabelMap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub labels: Vec<u32>, // one label per pixel; u32::MAX = OUTSIDE (keyed/transparent)
|
||||
pub paints: Vec<Paint>, // indexed by label
|
||||
}
|
||||
|
||||
/// Output document IR — what the optimizer and the writer operate on.
|
||||
pub struct VectorDoc { pub width: u32, pub height: u32, pub shapes: Vec<Shape> }
|
||||
pub struct Shape { pub paint: Paint, pub path: MultiPath } // subpaths: MoveTo + (Line|Cubic)* + Close
|
||||
pub enum Paint { Solid(Color) } // room for gradients later
|
||||
```
|
||||
|
||||
Why layers, not a label map, as the frontend output: in stacked mode clusters genuinely overlap (each hierarchical cluster is painted over its parents), which a flat label map cannot represent. The flat `LabelMap` needed by mosaic mode is derived from the layers by a top-down flatten — cheap and lossless for that purpose.
|
||||
|
||||
## Stage traits
|
||||
|
||||
All object-safe; the driver composes boxed trait objects (ergonomic across CLI/py/wasm boundaries, negligible dispatch cost next to the per-pixel work).
|
||||
|
||||
```rust
|
||||
pub trait Frontend {
|
||||
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
|
||||
}
|
||||
|
||||
pub trait ColorFitter {
|
||||
fn fit(&self, seg: &mut Segmentation);
|
||||
}
|
||||
|
||||
pub trait CurveFitter {
|
||||
fn fit_closed(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // stacked outlines, rings
|
||||
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
|
||||
}
|
||||
|
||||
pub trait CurvePass {
|
||||
fn open(&self, geom: FittedGeom) -> FittedGeom; // endpoints pinned
|
||||
fn ring(&self, geom: FittedGeom) -> FittedGeom; // stays closed
|
||||
}
|
||||
|
||||
pub trait OptimizerPass {
|
||||
fn run(&self, doc: &mut VectorDoc);
|
||||
}
|
||||
|
||||
pub enum Compositing { Stacked, Mosaic }
|
||||
|
||||
pub struct Pipeline {
|
||||
pub frontend: Box<dyn Frontend>,
|
||||
pub color_fitters: Vec<Box<dyn ColorFitter>>,
|
||||
pub fitter: Box<dyn CurveFitter>,
|
||||
pub compositing: Compositing,
|
||||
pub curve_passes: Vec<Box<dyn CurvePass>>,
|
||||
pub optimizers: Vec<Box<dyn OptimizerPass>>,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> { /* driver */ }
|
||||
}
|
||||
```
|
||||
|
||||
Driver flow:
|
||||
|
||||
1. `frontend.segment(img)` → `Segmentation`
|
||||
2. each `ColorFitter` rewrites layer paints (e.g. palette snapping)
|
||||
3. compositing:
|
||||
- **Stacked** — trace each layer's closed outlines independently (port of today's `to_compound_path` flow) via `fitter.fit_closed`
|
||||
- **Mosaic** — flatten to `LabelMap`, merge adjacent same-paint regions, extract the boundary graph, fit each shared edge once via `fitter.fit_open`, assemble faces (see [mosaic.md](mosaic.md))
|
||||
- either way, `CurvePass`es run on each fitted contour *before* paths are assembled — in mosaic mode that means once per shared boundary segment, so both faces reference the transformed geometry and the tessellation stays seam-free. Running them any later (on the `VectorDoc`) would re-fit the two copies of every shared boundary independently and reopen the seams.
|
||||
4. optimizer passes over the `VectorDoc`
|
||||
5. `SvgWriter` serializes
|
||||
|
||||
## Built-in implementations
|
||||
|
||||
- **Frontends** (selected by `Config::clustering`)
|
||||
- `ColorClusterFrontend` — wraps `visioncortex::color_clusters::Runner`, including the transparency-keying logic that currently lives in `converter.rs` (find unused key color, key fully-transparent pixels, `KeyingAction`).
|
||||
- `BinaryFrontend` — threshold → `BinaryImage::to_clusters`.
|
||||
- `WatershedFrontend` — hierarchical watershed by volume on the 4-adjacency pixel graph (Cousty et al. TPAMI 2009; Najman, Cousty & Perret ISMM 2013), cut at `watershed_detail`. Split into `WatershedHierarchy::build` (expensive, image-only) and `cut` (near-instant), so `Session` re-cuts a cached hierarchy when the detail changes. Emits the merge tree as a stacked hierarchy (root first, refined regions on top — the color-cluster principle), so stacked mode stays seam-free and sub-pixel gaps show ancestor colors; in cutout the partition reaches the mosaic untouched (`merge_diff = 0`).
|
||||
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
|
||||
- **ColorFitters**
|
||||
- `Identity` (today's behavior: mean cluster color)
|
||||
- `FixedPalette { colors: Vec<Color> }` — snaps each layer paint to the nearest palette entry in OKLab
|
||||
- `AutoQuantize { max_colors }` — k-means/median-cut over layer paints
|
||||
- After palette snapping, a built-in merge step unions adjacent regions with identical paint (mosaic path) / merges consecutive identical-paint layers (stacked path).
|
||||
- **CurveFitters**
|
||||
- `PixelFitter` — exact lattice polyline
|
||||
- `PolygonFitter` — staircase-symmetric Douglas-Peucker
|
||||
- `SplineFitter` — subdivision + corner detection + least-squares cubic fit (port of the visioncortex flow, extended to open polylines with pinned endpoints)
|
||||
- **CurvePasses** (selected by `Config::simplify`)
|
||||
- `SimplifyCurves { tolerance, corner_threshold }` — the paper.js `simplify` analogue: samples each smooth run of fitted cubics between corners and re-fits it with the fewest curves that stay within `tolerance` px (Schneider's algorithm via a current `flo_curves`, with tangents taken from the chain's own ends; visioncortex's internal copy is pinned to an old flo_curves and block-splits at 200 points, so it is not used here). A run is only replaced when the re-fit is strictly smaller, corners stay in place, open-segment endpoints are pinned bit-for-bit, and rings are seamed at their sharpest junction. Polylines pass through untouched.
|
||||
|
||||
## Optimizer and SVG writer
|
||||
|
||||
Two levels: geometry passes over `VectorDoc`, then encoding choices in the writer.
|
||||
|
||||
- `QuantizePass { precision }` — round coordinates once, in document space. Replaces today's per-write rounding, and eliminates the per-path `translate(x,y)` transform by baking offsets into coordinates.
|
||||
- `CleanupPass` — drop zero-length and collinear-redundant segments *after* quantization. (Curve *simplification* is deliberately not an optimizer pass — see `CurvePass` above.)
|
||||
- `SvgWriter { relative: bool, shorthands: bool, precision }` — per segment picks the shortest encoding:
|
||||
- relative (`l c s h v`) vs absolute deltas, whichever serializes shorter
|
||||
- `h`/`v` for axis-aligned lines, `s` for smooth cubic continuations
|
||||
- number formatting: trim trailing zeros, omit the space before negative numbers, leading-dot decimals
|
||||
- Paint grouping: shapes sharing a fill emitted inside `<g fill="…">` when it saves bytes.
|
||||
|
||||
Output size is a tracked metric: the test suite asserts a byte-size budget against golden samples (see [roadmap.md](roadmap.md)).
|
||||
|
||||
## CLI
|
||||
|
||||
clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--clustering color-cluster|bw|watershed` (formerly `--colormode`), `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`.
|
||||
|
||||
New:
|
||||
|
||||
- `--hierarchical stacked|cutout` — `cutout` now runs the true mosaic pipeline
|
||||
- `--palette '#112233,#445566,…'` / `--palette-file colors.txt` — fixed palette color fitting
|
||||
- `--optimize 0..2` — optimizer level (0 = off, 1 = quantize+simplify, 2 = + full writer shorthands/grouping)
|
||||
- mosaic extras: `--seam-stroke`, `--mosaic-strict` (see mosaic.md)
|
||||
|
||||
Range validation moves from `panic!` to clap `value_parser` ranges.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Bindings
|
||||
|
||||
Backend/CLI focused, with three language surfaces on top of `vtracer-core`. Everything except image file I/O compiles to `wasm32-unknown-unknown`.
|
||||
|
||||
## Python (PyPI)
|
||||
|
||||
Lives in the `vtracer` crate behind the `python-binding` feature (keeps the existing maturin / PyPI Trusted Publisher workflow intact).
|
||||
|
||||
- Ported functions with today's signatures: `convert_image_to_svg_py(image_path, out_path, **config)` and `convert_raw_image_to_svg(img_bytes, img_format=None, **config) -> str`.
|
||||
- New kwargs: `palette: list[str]` (hex colors), `optimize: int`, and `hierarchical='cutout'` now meaning true mosaic.
|
||||
|
||||
## Wasm (`vtracer-wasm` crate)
|
||||
|
||||
wasm-bindgen bindings over `vtracer-core`, replacing the old `webapp/` (the GUI demo is dropped).
|
||||
|
||||
```text
|
||||
convert(rgba: Uint8Array, width: u32, height: u32, config_json: string) -> string // SVG
|
||||
```
|
||||
|
||||
- Input is raw RGBA pixels — no image decoding in wasm (keeps the module small; decoding is the host's job).
|
||||
- The `fastrand/js` feature wiring moves here.
|
||||
- Built with `wasm-pack`; consumed by the Node.js package below and usable directly in browsers/bundlers.
|
||||
|
||||
## Node.js (npm)
|
||||
|
||||
New top-level `nodejs/` directory; recommended package name **`@visioncortex/vtracer`** (scoped — avoids collision/squatting on bare `vtracer`).
|
||||
|
||||
Design: wasm internally, native image reading.
|
||||
|
||||
- The `vtracer-wasm` build (`wasm-pack --target nodejs`) is **embedded in the package** — no network fetch, works offline.
|
||||
- **[sharp](https://sharp.pixelplumbing.com/)** (native libvips binding with prebuilt binaries) decodes PNG/JPEG/WebP/GIF/AVIF/TIFF to raw RGBA, which is fed to the wasm converter. sharp is a regular dependency (this is a Node-focused library); the pixel-level API still works if the native install fails.
|
||||
|
||||
TypeScript API:
|
||||
|
||||
```ts
|
||||
export interface Options {
|
||||
// camelCase mirror of the Rust Config:
|
||||
colorMode?: 'color' | 'binary';
|
||||
hierarchical?: 'stacked' | 'cutout'; // cutout = true mosaic
|
||||
mode?: 'pixel' | 'polygon' | 'spline';
|
||||
filterSpeckle?: number;
|
||||
colorPrecision?: number;
|
||||
gradientStep?: number;
|
||||
cornerThreshold?: number;
|
||||
segmentLength?: number;
|
||||
spliceThreshold?: number;
|
||||
pathPrecision?: number;
|
||||
palette?: string[]; // ['#112233', ...]
|
||||
optimize?: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
/** Pure wasm — no native dependency needed. */
|
||||
export function convertPixels(rgba: Uint8Array, width: number, height: number, options?: Options): string;
|
||||
|
||||
/** Decodes via sharp (native), then converts. Accepts a file path or an encoded image buffer. */
|
||||
export function convertImage(input: string | Buffer, options?: Options): Promise<string>;
|
||||
```
|
||||
|
||||
- Tests: vitest (or `node:test`) over the same sample images used by the Rust snapshot tests.
|
||||
- Publishing: `npm publish` wired into the release workflow alongside crates.io and PyPI.
|
||||
|
||||
## visioncortex development flow
|
||||
|
||||
`visioncortex` stays a dependency. The workspace carries
|
||||
|
||||
```toml
|
||||
[patch.crates-io]
|
||||
visioncortex = { path = "../visioncortex" }
|
||||
```
|
||||
|
||||
during development; API additions are committed directly to the local visioncortex repo and published as 0.8.x before a vtracer release, which then pins the published version.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Mosaic Mode — Seam-Free Cutout
|
||||
|
||||
Today's cutout re-renders the clustered image and re-clusters it, then traces every region independently; independently smoothed neighbors diverge, producing seams. The new mosaic mode replaces it with a topological pipeline that is seam-free **by construction**:
|
||||
|
||||
```
|
||||
label map (Vec<u32>, W·H)
|
||||
→ 1. boundary-graph extraction (nodes, shared segments, rings) [integer, exact]
|
||||
→ 2. face assembly (per-region contours as cycles of (seg, dir)) [integer, exact]
|
||||
→ 3. fit each segment ONCE (pluggable pixel/polygon/spline) [float, endpoints pinned]
|
||||
→ 4. compose per-region SVG paths from shared fitted segments
|
||||
```
|
||||
|
||||
Every boundary curve exists exactly once; the two adjacent regions reference the same fitted object, one traversed reversed. Reversal is exact for both polylines and cubic Beziers (`[p0,p1,p2,p3] → [p3,p2,p1,p0]`), so the serialized coordinates are identical text on both sides — no seams, no T-junction cracks.
|
||||
|
||||
**Coordinate convention**: pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; all boundary geometry lives on the lattice of pixel corners `0..=W × 0..=H` ("crack" boundaries). Stages 1–2 are pure integer arithmetic.
|
||||
|
||||
## 1. Boundary-graph extraction
|
||||
|
||||
### Definitions
|
||||
|
||||
- `type RegionId = u32; const OUTSIDE: RegionId = u32::MAX;` — `label(x,y)` returns `OUTSIDE` out of bounds. Treating outside as a real label removes all image-border special cases: border edges and border junctions fall out of the same rules.
|
||||
- At lattice corner `c=(x,y)` the 2×2 pixel neighborhood is `NW NE / SW SE`. Four potential unit edges at `c`: N present iff `NW≠NE`, E iff `NE≠SE`, S iff `SW≠SE`, W iff `NW≠SW`. Degree = popcount ∈ {0, 2, 3, 4}.
|
||||
- Quadrant/edge incidence for traversal: NE ↔ {N,E}, SE ↔ {E,S}, SW ↔ {S,W}, NW ↔ {W,N}.
|
||||
|
||||
### Node rule (junctions) and the checkerboard decision
|
||||
|
||||
**A corner is a node iff degree ≥ 3.**
|
||||
|
||||
- Three distinct labels in the 2×2 always gives degree ≥ 3 — "3+ regions meet here" is covered.
|
||||
- Degree 4 with two labels is exactly the checkerboard `A B / B A` (diagonal contact). **Decision: it is a junction node of 4 edges, and faces are pinched there.** The traversal rule below always takes the sharpest right turn, staying within the current quadrant, never crossing diagonally. If clustering was 8-connected (visioncortex `diagonal: true`), a two-lobe region yields **two separate simple contours** sharing the node coordinate but no edges — emitted as one SVG path with two subpaths. Faces stay simple; the tessellation stays exact.
|
||||
- Image corners (three quadrants OUTSIDE) are degree-2 chain points, not nodes. Points where two regions meet the border are degree 3 — nodes automatically.
|
||||
|
||||
Invariant used by segment tracing: at a degree-2 corner the 2×2 contains exactly two labels and both incident edges separate the same unordered pair — so the (left, right) region pair is constant along any chain of degree-2 corners.
|
||||
|
||||
### Data structures
|
||||
|
||||
```rust
|
||||
pub type NodeId = u32;
|
||||
pub type SegId = u32;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SegRef { pub seg: SegId, pub forward: bool }
|
||||
|
||||
pub struct Node {
|
||||
pub corner: PointI32, // lattice coords
|
||||
pub out: [Option<SegRef>; 4], // outgoing directed segment per unit direction N,E,S,W
|
||||
}
|
||||
|
||||
pub struct Segment {
|
||||
pub points: Vec<PointI32>, // lattice polyline; len >= 2; ring: points[0] == points[last]
|
||||
pub start: Option<NodeId>, // None,None for rings (no junction anywhere on the loop)
|
||||
pub end: Option<NodeId>, // start may == end (self-loop pinned at one node)
|
||||
pub left: RegionId, // region on the left traversing forward (y-down convention)
|
||||
pub right: RegionId, // either side may be OUTSIDE
|
||||
}
|
||||
|
||||
pub struct Contour(pub Vec<SegRef>); // cycle; a ring is a 1-element contour
|
||||
pub struct Face { pub region: RegionId, pub contours: Vec<Contour> }
|
||||
|
||||
pub struct BoundaryGraph {
|
||||
pub nodes: Vec<Node>,
|
||||
pub segments: Vec<Segment>,
|
||||
pub faces: Vec<Face>,
|
||||
}
|
||||
```
|
||||
|
||||
Transient: `corner_mask: Vec<u8>` of `(W+1)·(H+1)` (4-bit edge mask + node flag), a corner-index → `NodeId` map, and visited bitsets for undirected edges (horizontal `W·(H+1)`, vertical `(W+1)·H`; closed-form edge ids, no hashing).
|
||||
|
||||
"Left" in y-down screen space: heading E → left pixel above; heading S → left pixel to the east; heading W → below; heading N → to the west (4-entry lookup).
|
||||
|
||||
### Extraction passes
|
||||
|
||||
```
|
||||
Pass A — classify corners: O((W+1)(H+1))
|
||||
for each lattice corner: compute 4-bit edge mask from the 2x2 labels
|
||||
(OUTSIDE for out-of-bounds); allocate a node id where popcount >= 3
|
||||
|
||||
Pass B — trace node-to-node segments:
|
||||
for each node n, for each present direction d not yet visited:
|
||||
walk unit edges, at each degree-2 corner continue via the unique other
|
||||
present edge, until reaching a node; record polyline, start/end nodes,
|
||||
left/right regions; register both directed views in the node tables
|
||||
|
||||
Pass C — closed rings:
|
||||
for each unvisited boundary edge (raster order): walk until returning to
|
||||
the start corner; record as a Segment with start = end = None
|
||||
```
|
||||
|
||||
Complexity O(W·H + E); every boundary edge is walked exactly once here and once more during face assembly.
|
||||
|
||||
Corner cases handled: self-loop segments (a lobe outline returning to the same node — open for fitting purposes, endpoint pinned); whole-image single region (no nodes; Pass C finds the border rectangle as a ring against OUTSIDE); single-pixel regions.
|
||||
|
||||
### Successor rule (region kept on the left)
|
||||
|
||||
Given an incoming directed unit edge into corner `c`, tracing region R:
|
||||
|
||||
```
|
||||
candidates in priority order: [turn_right(d_in), straight(d_in), turn_left(d_in)]
|
||||
next = first d such that edge (c,d) is present AND left_pixel(c,d) == R
|
||||
```
|
||||
|
||||
Right-first implements the pinch at checkerboard nodes (both right and straight can have R on the left there; right-first stays in the current quadrant, keeping contours simple). At 3/4-label junctions exactly one candidate qualifies. A u-turn is never needed.
|
||||
|
||||
## 2. Face assembly
|
||||
|
||||
Lift the successor rule to whole segments (two directed views per segment, 2-bit usage set):
|
||||
|
||||
```
|
||||
for each directed segment s with region R on its left, not yet used:
|
||||
follow successor at each end node until returning to s → one Contour of R
|
||||
for each ring r:
|
||||
left(r) gets [forward], right(r) gets [reversed] (skip OUTSIDE sides)
|
||||
```
|
||||
|
||||
**Winding falls out automatically**: interior-always-on-left gives outer contours one orientation and hole contours the opposite. Therefore each region is emitted as a single `<path fill-rule="nonzero">` whose `d` concatenates all its contours as subpaths — **no containment/nesting computation is needed**. `nonzero` (rather than `evenodd`) is robust to contours touching at pinch points.
|
||||
|
||||
Debug invariants: every directed segment used exactly once; per-region i64 shoelace area (holes negative) equals the region's pixel count; the global sum equals W·H minus OUTSIDE pixels.
|
||||
|
||||
## 3. Fitting — once per segment, endpoints pinned
|
||||
|
||||
```rust
|
||||
pub enum FittedGeom {
|
||||
Polyline(Vec<PointF64>), // pixel / polygon backends
|
||||
Beziers(Vec<[PointF64; 4]>), // spline backend; consecutive curves share endpoints
|
||||
}
|
||||
|
||||
pub trait SegmentFitter {
|
||||
fn fit_open(&self, seg: &Segment) -> FittedSegment; // endpoints pinned to lattice nodes
|
||||
fn fit_ring(&self, seg: &Segment) -> FittedSegment; // closed loop, no pinned point
|
||||
}
|
||||
```
|
||||
|
||||
Fitted results are cached in a `Vec<FittedSegment>` indexed by `SegId`; both adjacent faces reference the cache. Reversal happens at composition time and is exact, so shared geometry is bitwise identical — identical f64 values round identically under `path_precision`, and the emitted coordinate text matches on both sides.
|
||||
|
||||
### Backends
|
||||
|
||||
- **PixelFitter** — identity (lattice points as f64). Exact tessellation; the reference implementation for tests.
|
||||
- **PolygonFitter** — symmetric open Douglas-Peucker with endpoints always kept (own ~40-line implementation). Deliberately **not** `PathSimplify::remove_staircase`: its directional outset would bias every shared boundary toward one of its two neighbors. Plain DP collapses 1-px staircases to the crack midline — centered between the two regions, which is what a mosaic wants. Self-loops split at the farthest point first.
|
||||
- **SplineFitter** — open-path port of the visioncortex pipeline:
|
||||
1. DP(tau) first — staircases must be gone before corner detection, or every stair step reads as a 90° corner.
|
||||
2. Corner detection without wraparound; **both endpoints forced as corners** (junction nodes stay pinned).
|
||||
3. Open-path 4-point `subdivide_keep_corners` (no modular indexing; corner points are copied, never displaced).
|
||||
4. Open-path `find_splice_points` (inflections + accumulated-turn threshold); endpoints forced as splice points.
|
||||
5. Per slice: least-squares cubic fit. `SubdivideSmooth::fit_points_with_bezier` is already endpoint-exact (p1/p4 are taken from the input), so pinning survives fitting for free — but its internal error is hardcoded to 10.0, so vtracer-core calls `flo_curves::bezier::Curve::fit_from_points` directly with a configurable `max_error`, recursively splitting a slice at its farthest point when the budget is exceeded.
|
||||
- **Rings** (islands with no junctions) are fitted once as *closed* paths using the closed-path machinery; the island uses the result forward as its outline, the enclosing region uses it reversed as a hole — same cached object, identical geometry.
|
||||
|
||||
### Deviation budget and overlap tolerance
|
||||
|
||||
Adjacent segments meet only at exact shared node coordinates — gaps are impossible. The remaining risk is a smoothed segment crossing a *different, non-adjacent* segment. Distinct boundary polylines are at least 1 px apart on the lattice, so keeping **maximum deviation < 0.5 px at every stage** (DP tau 0.5, bezier `max_error` 0.5, subdivision defaults well inside that) prevents crossings. This is not formally proven at the Bezier stage (error is sampled), so:
|
||||
|
||||
- default: accept the pragmatic budget — a hairline overlap between two abutting fills is visually harmless and can never produce a gap worse than the budget;
|
||||
- `--mosaic-strict`: sample each fitted segment (~8 samples/curve), and fall back to the DP polyline for any segment exceeding the budget — restoring the hard guarantee at the cost of local smoothness;
|
||||
- the pixel backend gives bit-exact tessellation.
|
||||
|
||||
## 4. Composition
|
||||
|
||||
Per region, one `<path fill="{color}" fill-rule="nonzero">`; the `d` string is built contour by contour, emitting each oriented segment while skipping its first point (identical to the previous segment's last point). T-junction cracks are structurally impossible: segments terminate at nodes, no curve ever spans across one, and all incident curves end at the exact integer node coordinate.
|
||||
|
||||
## 5. Paint-order independence and anti-aliasing
|
||||
|
||||
Geometric coverage is a perfect partition, so rendering is paint-order independent — the defining property of mosaic mode. Antialiasing renderers still blend a hairline along abutting edges (each path is composited independently against the backdrop); that is a renderer artifact of any abutting vector art, not a geometry defect. Optional mitigations:
|
||||
|
||||
1. `--seam-stroke` — stroke each path in its own fill color (`stroke-width` 0.5–1, round joins). Hides AA hairlines; reintroduces mild paint-order sensitivity (cosmetic, documented).
|
||||
2. `shape-rendering="crispEdges"` output option — kills AA entirely (jaggy but seamless).
|
||||
3. Stacked mode remains the AA-safe alternative (seams hidden under overdraw); mosaic gives true tessellation semantics — editable, no hidden geometry, order-free.
|
||||
|
||||
## Label-map source
|
||||
|
||||
`LabelMap::from_clusters(&ClustersView)` stamps dense region ids by iterating `clusters_output` → each cluster's pixel indices. It must **not** read `cluster_indices` directly — that maps pixels to base-level clusters, not the hierarchical output set. Unstamped (keyed/transparent) pixels become `OUTSIDE`.
|
||||
|
||||
## Test plan
|
||||
|
||||
Unit tests on hand-built const-grid label maps:
|
||||
|
||||
- 1×1 and full-image single region → one ring against OUTSIDE
|
||||
- vertical split `A|B` → 2 border junction nodes, 3 segments, correct left/right and windings
|
||||
- T-junction `A A / B C` → interior degree-3 node; three faces share the exact node coordinate
|
||||
- checkerboard `A B / B A` with merged diagonal labels → degree-4 node, pinch: two simple contours touching at the point, exact coverage
|
||||
- nested islands A ⊃ B ⊃ C → rings only; shared cached geometry asserted
|
||||
- border-touching region, 1-px corridor, single-pixel island, self-loop segment
|
||||
- reversal exactness: the two SVG coordinate substrings for a shared segment are identical strings
|
||||
|
||||
Property tests (proptest, random maps ≤ 12×12, ≤ 5 labels; label connectivity not required):
|
||||
|
||||
- every undirected boundary edge appears in exactly two directed traversals
|
||||
- per-region shoelace area == pixel count; total == W·H
|
||||
- **PixelFitter round-trip: scanline-rasterize the composed faces → byte-identical label map** (the strongest end-to-end guarantee; catches winding/pinch/orientation bugs)
|
||||
- Polygon/Spline: sampled max deviation ≤ budget; all segment endpoints exactly on node lattice coordinates
|
||||
|
||||
Integration: run on the sample images; snapshot SVGs; rasterize with resvg and assert the color diff against the label map is confined to a ~1-px boundary band.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Roadmap and Verification
|
||||
|
||||
## Milestones
|
||||
|
||||
Each milestone leaves the repo building and tested.
|
||||
|
||||
1. **Scaffold** — new workspace (`crates/vtracer-core`, `crates/vtracer`); IR + stage traits; port the existing stacked pipeline behind them, behavior-identical; golden-SVG snapshot tests over the sample images; CLI ported to clap 4 (range validation via `value_parser`, no more `panic!`).
|
||||
2. **Writer + optimizer** — `VectorDoc` writer with relative/shorthand encoding, `QuantizePass`, `SimplifyPass`; byte-size benchmark vs the 0.6.x output; rasterize-and-diff regression (resvg) proving visual equivalence.
|
||||
3. **Color fitting** — `FixedPalette` (OKLab nearest) + `AutoQuantize` + adjacent-region merge; `--palette` / `--palette-file` CLI.
|
||||
4. **Mosaic** — boundary-graph module + open-polyline fitting (see [mosaic.md](mosaic.md)); `--hierarchical cutout` switched to the true mosaic; full unit/property test suite.
|
||||
5. **Bindings** — pyo3 port, `vtracer-wasm`, the npm package under `nodejs/`; delete `webapp/`; CI covers crates.io + PyPI + npm releases.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
- **Unit** — hand-crafted label maps for mosaic (checkerboard, T-junction, nested islands, border-touching, self-loops); fitter round-trips; writer encoding cases.
|
||||
- **Snapshot** — golden SVGs for the sample images per preset/mode; asserted byte-size budget for the optimizer.
|
||||
- **Property** (proptest) — mosaic invariants: every boundary edge used exactly twice; shoelace area == pixel counts; PixelFitter rasterize round-trip is byte-identical to the label map; fitted deviation ≤ 0.5 px budget; endpoints exact on lattice nodes.
|
||||
- **Visual** — rasterize output with resvg; pixel-diff/SSIM against the input (thresholded) and against pre-rewrite output for stacked mode; mosaic diffs confined to a ~1-px boundary band.
|
||||
- **Targets** — `cargo build --target wasm32-unknown-unknown -p vtracer-core -p vtracer-wasm`; `maturin build` with `python-binding`; `npm test` in `nodejs/`.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Stacked-Mode Equivalence Report
|
||||
|
||||
**Question:** does the rewritten 1.0 pipeline (`crates/vtracer`) reproduce the
|
||||
shipping 0.6.x pipeline (`cmdapp/`) in **stacked** mode, byte-for-byte?
|
||||
|
||||
**Verdict:** **Yes.** Across a systematic sweep of **475 parameter
|
||||
configurations**, every fitted path is geometrically identical (worst
|
||||
coordinate deviation **1e-8 px** — float-serialization noise). The only
|
||||
differences are two intentional, visually-invisible ones (documented below).
|
||||
|
||||
Date: 2026-07-24. Comparison target: `pixel`, `polygon`, `spline` fitters;
|
||||
`color` and `bw` color modes.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
- **Stacked only.** Old `--hierarchical cutout` is the *fake* cutout (re-render
|
||||
the clustered image, re-cluster, retrace); new `cutout` is the topological
|
||||
mosaic. They are deliberately different algorithms and are **not** expected to
|
||||
match. Mosaic is verified separately (pixel round-trip + seam tests).
|
||||
- **Geometry, not pixels.** Comparison parses each SVG's `<path d>` (applying
|
||||
any `transform="translate()"`) into absolute coordinates and compares those
|
||||
directly. This is stronger than a raster diff (no antialiasing fuzz) and
|
||||
isolates the pipeline from the SVG writer.
|
||||
- **`--path-precision 8`.** High precision so writer rounding can never mask a
|
||||
real geometry difference. (At the default precision 2, the two writers round
|
||||
slightly differently — see *Known differences*.)
|
||||
|
||||
## Reference oracle
|
||||
|
||||
`cmdapp/` (0.6.x) is built with **matched dependencies** — the same local
|
||||
`visioncortex` 0.9.0 and `image` 0.25 as the new crates — so the comparison
|
||||
isolates *pipeline logic* from library drift:
|
||||
|
||||
- Same `visioncortex` ⇒ identical clustering and curve fitting primitives.
|
||||
- Same `image` ⇒ identical decoding (JPEG decoding is decoder-version
|
||||
dependent; PNG is lossless either way).
|
||||
|
||||
New is run with `--optimize 0` (no optimizer passes, absolute writer) so the
|
||||
comparison reflects the tracing/fitting pipeline, not the optimizer. The
|
||||
optimizer is verified lossless separately.
|
||||
|
||||
## Parameter space
|
||||
|
||||
| Parameter | Range swept | Affects |
|
||||
|---|---|---|
|
||||
| `colormode` | color, bw | frontend |
|
||||
| `mode` | pixel, polygon, spline | curve fitter |
|
||||
| `filter_speckle` | 0 – 16 | frontend (min area) |
|
||||
| `color_precision` | 1 – 8 | color clustering |
|
||||
| `gradient_step` | 0 – 255 | color layer difference |
|
||||
| `corner_threshold` | 0 – 180 | spline |
|
||||
| `segment_length` | 3.5 – 10 | spline |
|
||||
| `splice_threshold` | 0 – 180 | spline |
|
||||
|
||||
The full Cartesian product is ~10¹²; instead the sweep uses a layered strategy
|
||||
that touches every value of every parameter plus randomized interactions.
|
||||
|
||||
## Coverage & results
|
||||
|
||||
475 configurations, tank-unit-preview.png (PNG) plus a Gum Tree (JPEG) baseline set:
|
||||
|
||||
| Group | Configs | Geometry failures | Worst Δ |
|
||||
|---|---:|---:|---:|
|
||||
| Categorical cross (colormode × mode) | 6 | 0 | 1e-8 |
|
||||
| `filter_speckle` 0–16 × mode × colormode | 102 | 0 | 1e-8 |
|
||||
| `color_precision` 1–8 × mode | 24 | 0 | 1e-8 |
|
||||
| `gradient_step` 0–255 × mode | 39 | 0 | 1e-8 |
|
||||
| `corner_threshold` 0–180 (spline) | 26 | 0 | 1e-8 |
|
||||
| `segment_length` 3.5–10 (spline) | 9 | 0 | 1e-8 |
|
||||
| `splice_threshold` 0–180 (spline) | 13 | 0 | 1e-8 |
|
||||
| Random joint combinations | 250 | 0 | 1e-8 |
|
||||
| Second image (Gum Tree, JPEG) | 6 | 0 | 1e-8 |
|
||||
| **Total** | **475** | **0** | **1e-8** |
|
||||
|
||||
- **Geometry mismatches (> 1e-6 px): 0.**
|
||||
- **Empty-path-count divergences: 10** (cosmetic; see below).
|
||||
|
||||
By fitter: `pixel` and `polygon` are byte-for-byte identical in both color and
|
||||
bw. `spline` geometry is identical to 1e-8; the sub-pixel deltas visible at low
|
||||
`--path-precision` are writer rounding, not geometry.
|
||||
|
||||
## Known differences (intentional, invisible)
|
||||
|
||||
1. **SVG encoding.** The new writer uses compact relative/shorthand commands
|
||||
with offsets baked into coordinates; 0.6.x used absolute coordinates plus a
|
||||
per-path `transform="translate()"`. Same geometry, different bytes — by
|
||||
design (the new writer is smaller). Verified equal after parsing to absolute
|
||||
coordinates.
|
||||
|
||||
2. **Empty paths.** At `filter_speckle = 0`, tiny (≈1px) clusters survive
|
||||
filtering; their spline fit is empty. 0.6.x emits a degenerate
|
||||
`<path d="">` for each (e.g. 67 of them in one bw/spline case); the new
|
||||
pipeline omits them. They render nothing, so output is visually identical.
|
||||
This accounts for all 10 "empty-path divergences" and appears only at the
|
||||
nonsensical `filter_speckle = 0`.
|
||||
|
||||
## Bugs found and fixed during this verification
|
||||
|
||||
This report's process surfaced two real bugs (both fixed, both now
|
||||
regression-guarded):
|
||||
|
||||
1. **Stacked layers had holes/seams.** The color frontend traced clusters with
|
||||
holes punched (`to_image_with_hole(.., true)`); stacked mode must trace
|
||||
*solid* layers and rely on paint-order overdraw (`false`). Symptom: hairline
|
||||
seams (partial-alpha jumped 4.86% → 0.36% after the fix).
|
||||
Guard: `stacked_has_no_seams` (a full-coverage image must render fully
|
||||
opaque — zero backdrop show-through).
|
||||
|
||||
2. **Relative writer placed holes wrong.** After `Z`, SVG resets the current
|
||||
point to the subpath start; the emitter left it at the last vertex, so a
|
||||
relative `m` for a hole/second subpath was offset. Only visible on
|
||||
multi-subpath shapes at `optimize=1/2`.
|
||||
Guard: `relative_and_absolute_encode_same_geometry` (a holed shape must
|
||||
encode identically absolute vs relative).
|
||||
|
||||
## Harness caveats (for reproduction)
|
||||
|
||||
- 0.6.x accepts only `--mode` (no `-m`) and treats `--colormode` as binary
|
||||
**only for the value `bw`** — `binary` silently falls through to color. Use
|
||||
`bw` for both binaries.
|
||||
- 0.6.x spline mode `pixel` maps to `PathSimplifyMode::None`.
|
||||
|
||||
## Reproduction
|
||||
|
||||
`cmdapp/` (0.6.x) was removed from the tree after this verification; restore it
|
||||
from git history (the commit before "Remove the 0.6.x cmdapp crate") to
|
||||
reproduce.
|
||||
|
||||
1. Temporarily point `cmdapp/Cargo.toml` at the matched dependencies
|
||||
(`image = "0.25"`, `visioncortex = { version = "0.9", path = "../../visioncortex" }`)
|
||||
and build both binaries:
|
||||
```sh
|
||||
cargo build --release --manifest-path cmdapp/Cargo.toml
|
||||
cargo build --release -p vtracer-cli
|
||||
```
|
||||
2. For each configuration, run both binaries in stacked mode with
|
||||
`--path-precision 8` (new also with `--optimize 0`), remembering the harness
|
||||
caveats above (`--mode` not `-m`; `--colormode bw`).
|
||||
3. Parse each SVG's `<path d>` into absolute coordinates (apply any
|
||||
`transform="translate()"`), drop empty paths, and compare the coordinate
|
||||
sequences. Equivalent ⇔ per-coordinate deviation < 1e-6.
|
||||
|
||||
## Conclusion
|
||||
|
||||
In stacked mode the new pipeline is a **byte-for-byte-faithful reimplementation**
|
||||
of 0.6.x across the full parameter space for `pixel` and `polygon`, and
|
||||
geometrically identical for `spline`. Remaining differences are limited to the
|
||||
intentional compact SVG encoding and the omission of degenerate empty paths.
|
||||
|
After Width: | Height: | Size: 548 KiB |