80 Commits

Author SHA1 Message Date
Chris Tsang 6e9b147bbd Add WebP decoding to the Node/wasm package
image-webp is pure Rust and compiles to wasm32; brings the npm
package's input formats closer to the CLI/Python surface. Wasm grows
~115 KB (486 KB -> 601 KB). Also gitignore nodejs/.npmrc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:09:50 +01:00
Chris Tsang ec715f8c3c Prepare 1.0.0-alpha.1 release
- CHANGELOG: document the 1.0 framework rewrite
- CI: build Python wheels only on release tags + manual dispatch,
  not on every push/PR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:24:04 +01:00
Chris Tsang 61ca5e5946 Compare goldens by rendering, not byte-equality
The spline fitter's least-squares cubic fit (flo_curves, f64) diverges by ULPs
across architectures: on x86_64 vs arm64 an arc decomposes into slightly
different control points, changing the SVG bytes with no real geometry change
(verified with an x86_64 emulation: 0 pixels differ by >40, worst channel
delta 20 — visually identical). Byte-exact golden comparison is therefore
inappropriate for the spline output.

Render both the stored golden and the produced SVG (resvg) and diff pixels,
tolerating a tiny fraction for sub-pixel boundary flips. Encoding-agnostic and
architecture-robust, while still catching genuine regressions (which move
boundaries by whole pixels).
2026-07-24 16:47:01 +01:00
Chris Tsang 87b9660ed3 Scope the npm package to @visioncortex/vtracer
Publish under the @visioncortex npm org. Add publishConfig.access=public for
the scoped package and update the install/require examples in both READMEs.
2026-07-24 16:30:28 +01:00
Chris Tsang 5743912da6 Trim image codec features in the CLI and Python crates
vtracer only decodes input, but image's default features pulled a full AV1
encoder (ravif/rav1e) and OpenEXR into the CLI binary and the Python wheel.
Restrict to decode-only input formats (png, jpeg, gif, bmp, webp, tiff, ico,
pnm, tga, qoi). The release binary drops from ~3.23 MB to ~2.46 MB and builds
faster; supported inputs are unchanged in practice (avif decode was never in
image's defaults anyway).
2026-07-24 15:56:28 +01:00
Chris Tsang 601447af6c Depend on published visioncortex 0.9.0; simplify CI
visioncortex 0.9.0 is on crates.io, so the workspace now uses the registry
version instead of the local `../visioncortex` path (a [patch.crates-io]
example is left in a comment for local visioncortex development).

CI no longer needs the adjacent visioncortex checkout / published-crate caveats:
rust.yml, python.yml, and release.yml build from a single checkout.
2026-07-24 14:06:17 +01:00
Chris Tsang 8af376cb7c Update CI for the 1.0 workspace layout
- rust.yml: modernize (checkout@v4), build/test the whole workspace, add a
  wasm32 core build check and a Node package build+test job. Checks out
  visioncortex next to the repo so the local path dependency resolves.
- python.yml: build crates/vtracer-py (was the deleted cmdapp/); note that
  manylinux builds need visioncortex 0.9.0 published to crates.io.
- release.yml: note the same visioncortex prerequisite for the CLI binary.
2026-07-24 13:37:25 +01:00
Chris Tsang 42f94f0d15 Add local-publish script for the Node package
nodejs/scripts/publish.mjs builds the wasm (wasm-pack), runs the smoke test,
then `npm publish` to a configurable registry (default a local one at
http://localhost:4873; override via --registry= or NPM_REGISTRY). Supports
--dry-run. Wired as `npm run publish:local`.
2026-07-24 13:37:25 +01:00
Chris Tsang 170b0322a4 Bump pyo3 to 0.26 in vtracer-py 2026-07-24 13:37:25 +01:00
Chris Tsang 749f0df0bd Add nodejs: WebAssembly Node package with no native dependency
New nodejs/ package: a wasm-bindgen crate (vtracer-wasm) built with wasm-pack
that wraps the vtracer framework, plus a thin JS layer for file I/O. Image
decoding (png/jpeg/gif/bmp via the image crate) runs in wasm too, so the
package has zero native dependencies — no sharp, no node-gyp.

No separate general-purpose wasm crate: the Node package directly owns and
wraps the wasm. Excluded from the cargo workspace (wasm-bindgen cdylib), built
with wasm-pack.

JS API (camelCase options): convertBuffer, convertPixels, convertFile,
convertFileSync — each taking an Options object (preset, colorMode,
hierarchical/cutout mosaic, mode, palette, maxColors, optimize, ...). Ships
index.d.ts types and a node smoke test. README updated.

Verified: builds to wasm32-unknown-unknown; `node test.js` passes; output
matches the CLI/Python bindings (253 paths on the tank sample).
2026-07-24 13:08:53 +01:00
Chris Tsang f76aed78b2 Add vtracer-py: Python bindings with a rich API
New crates/vtracer-py (pyo3 + maturin, abi3) wrapping the vtracer framework.
Rather than a thin CLI-style wrapper, it exposes a mutable `Config` class with
named properties and `bw`/`poster`/`photo` preset constructors, plus three
input paths — `convert_file`, `convert_bytes` (encoded image, optional format),
and `convert_pixels` (raw RGBA8) — available as `Config` methods and
module-level functions. Palette is a list of `#rrggbb` strings; bad inputs
raise ValueError.

The core crate stays pure: image decoding lives here. The crate is excluded
from the cargo workspace (pyo3 extension-module cdylibs don't link libpython,
which breaks `cargo test` at the root) and is built with maturin. Ships a
vtracer.pyi type stub. README updated.
2026-07-24 11:53:47 +01:00
Chris Tsang 57d768e37e Update README CLI docs for the 1.0 command app
Replace the 0.6.x help block with the current options (kebab-case flags,
positional input/output, filter-speckle 0..=128), document the new
capabilities (positional args, seam-free mosaic cutout, fixed palette /
auto-quantize, output optimization levels), and refresh the usage examples.
2026-07-24 11:39:14 +01:00
Chris Tsang 837fde8aa4 Accept positional input/output args in the CLI
`vtracer in.png out.svg` now works alongside the `-i/--input` and
`-o/--output` flags. Input/output become optional positionals plus the
existing flags; an explicit flag wins over the positional, and a clear error
is shown if neither is given.
2026-07-24 11:18:21 +01:00
Chris Tsang 35b4b6f846 Raise filter_speckle CLI cap from 16 to 128
Ports visioncortex/vtracer#115: the command app capped filter_speckle at 16
while the web app allowed up to 128. Match the web app's range.
2026-07-24 11:15:29 +01:00
Chris Tsang 3300f97e37 Add mosaic spline fitter; fix stacked holes & relative writer; add test suite
Feature — mosaic spline segment fitter (crates/vtracer/src/mosaic/fit.rs):
open-path cubic fitting for boundary segments, reusing the now-public
visioncortex primitives (PathSimplify::limit_penalties for symmetric,
gap-free staircase removal; open-path SubdivideSmooth::{find_corners,
subdivide_keep_corners,find_splice_points}; fit_points_with_bezier per splice
slice). Matches stacked spline curve quality; endpoints pinned to lattice
nodes so shared boundaries stay seam-free.

Fix — stacked mode punched holes in cluster masks (to_image_with_hole .. true);
stacked must trace solid layers and occlude by paint-order overdraw (false).
Holes left the layer below exposed as hairline seams.

Fix — the relative SVG writer measured a subpath's opening `m` from the last
vertex instead of the subpath start (SVG resets the current point to the start
after Z), misplacing holes / extra subpaths at optimize=1/2.

Tests — new tests/equivalence.rs: stacked-vs-mosaic interior agreement (all
fitters) and a seam guard (a full-coverage image must render fully opaque).
svg round-trip test (absolute vs relative encode identical geometry). mosaic
spline endpoint-pinning test. Regenerated goldens; added disc_mosaic_spline.
resvg added as a dev-dependency (test-only; not compiled for wasm).

Drop unused MosaicOptions placeholder

The strict/seam-stroke mitigations aren't needed — the mosaic geometry is
already gapless and seam-free. Remove the no-op MosaicOptions struct and thread
it out of Compositing::Mosaic and compose_mosaic.
2026-07-24 11:13:02 +01:00
Chris Tsang 5ac90bb97c Add stacked-mode equivalence report
Documents the systematic verification that the 1.0 pipeline reproduces 0.6.x
stacked output byte-for-byte: 475 parameter configurations (full per-parameter
sweeps + randomized interactions, pixel/polygon/spline, color/bw), geometry
compared against the 0.6.x cmdapp reference at path-precision 8. Zero geometry
mismatches (worst deviation 1e-8). Records the two bugs found and fixed during
verification (stacked hole-punching; relative-writer subpath origin), the
intentional differences (compact SVG encoding, empty-path omission), and the
reproduction procedure.
2026-07-24 10:50:09 +01:00
Chris Tsang 17a9a6e6c5 Add mosaic mode: seam-free tessellation (pixel + polygon)
Implements the topological mosaic pipeline from docs/design/mosaic.md, turning
`--hierarchical cutout` into a true gapless tessellation instead of the old
re-cluster-and-retrace fake.

  LabelMap (flatten Segmentation top-down)
    → boundary-graph extraction  (integer-exact: corners, node rule, segment
                                   and ring tracing on the pixel-corner lattice)
    → face assembly              (left-region successor rule; winding falls out,
                                   so each region is one nonzero-fill path)
    → fit each segment ONCE       (shared by both adjacent faces, reversed
                                   exactly → byte-identical shared boundaries)
    → compose per-region paths

Backends: PixelSegmentFitter (exact reference) and PolygonSegmentFitter
(symmetric open Douglas-Peucker collapsing staircases to the crack midline).
The spline segment fitter is still pending; mosaic + spline currently falls
back to polygon.

Compositing now owns its fitter (Stacked(CurveFitter) / Mosaic(SegmentFitter)).

Tests: single region, vertical split, T-junction, checkerboard pinch, nested
rings, border-touching, and a pixel round-trip property test over 40 random
maps (rasterize composed faces == input label map). Plus two mosaic goldens.
2026-07-23 23:26:18 +01:00
Chris Tsang 572d9e5f82 Add golden-snapshot fixtures to lock in pipeline output
12 synthetic-image cases covering every stage: all three fitters, holes,
region adjacency, hierarchical layering, binary mode, fixed-palette and
auto-quantize color fitting, and the three optimizer/writer levels.

Fixtures are built from in-code images, not the JPEG samples, because JPEG
decoding is image-crate-version dependent and would make goldens fragile.
Regenerate after an intentional change with VTRACER_BLESS=1.
2026-07-23 23:13:06 +01:00
Chris Tsang 660dc4ff93 Remove the 0.6.x cmdapp crate
Superseded by crates/vtracer (framework) + crates/vtracer-cli. Verified the
new pipeline reproduces cmdapp's geometry and colors byte-for-byte (PNG always;
JPEG once the image-crate decoder is held constant), so the old crate is
retired. Drop its now-stale workspace exclude entry. Git history preserves it.
2026-07-23 23:08:04 +01:00
Chris Tsang e46c971845 Rewrite into a vectorization framework (pillars 1–4)
Replace the 0.6.x single-pipeline crate with a stage-based framework, per
docs/design/. Implements Motivation pillars 1–4 (frontend, curve fitting,
color fitting, optimizer); mosaic (5) and bindings are deferred.

Workspace:
- crates/vtracer      — the framework library (wasm-safe, no I/O)
- crates/vtracer-cli  — thin CLI wrapper (clap 4 + image I/O)
- cmdapp/ and webapp/ excluded from the workspace (git-preserved)

Stages behind object-safe traits, composed by a Pipeline driver:
- Frontend: ColorClusterFrontend (+ transparency keying), BinaryFrontend
- CurveFitter: Pixel / Polygon / Spline (region tracing via visioncortex)
- ColorFitter: Identity, FixedPalette (OKLab-nearest), AutoQuantize
  (area-weighted median cut), MergeAdjacent
- OptimizerPass: QuantizePass, SimplifyPass
- SvgWriter: relative/absolute shortest encoding, H/V/S shorthands,
  compact number formatting, <g fill> grouping

visioncortex is a path dependency on the local 0.9.0 checkout.

Verified: 14 unit/integration tests pass; framework builds for
wasm32-unknown-unknown; CLI output renders faithfully via rsvg.
2026-07-23 22:27:24 +01:00
Chris Tsang 913d0ac7e0 New design 2026-07-23 18:11:19 +01:00
Chris Tsang fd9cdb08e6 missing artifacts
Rust / build (push) Has been cancelled
2026-03-23 15:30:48 +00:00
Chris Tsang 3b2991be84 0.6.12 2026-02-04 12:21:42 +00:00
Chris Tsang f24ea56a52 use Trusted Publisher 2026-02-04 11:47:44 +00:00
Chris Tsang 592bee6c5c Regenerate python workflow 2026-02-04 10:11:32 +00:00
Chris Tsang 3ebc358b48 Bump CI 2026-01-30 14:35:48 +00:00
Chris Tsang 3a24c2f755 0.6.6 2026-01-28 18:21:44 +00:00
Chris Tsang 8acb6bd911 bump fastrand
Rust / build (push) Has been cancelled
2025-10-17 22:02:50 +01:00
Chris Tsang efa4351b2c Update README.md 2024-09-27 10:43:47 +01:00
Chris Tsang a46292b5ed Update README.md 2024-09-27 10:43:02 +01:00
Chris Tsang 8889cbc7ea Tweaks
Rust / build (push) Has been cancelled
2024-09-26 12:59:45 +01:00
Wil Carmon 6b379a02ef Update svg.rs (#92) 2024-09-26 12:58:43 +01:00
Wil Carmon 2635d5b874 Update config.rs (#91)
added #[derive(Clone, Debug)]
2024-09-26 12:58:30 +01:00
Chris Tsang f6cf3e8705 Refactor 2024-05-30 10:04:23 +01:00
Chris Tsang 36b16de17a Key transparent image in webapp 2024-05-29 15:10:11 +01:00
Chris Tsang 7887c1ebf8 Update readme 2024-05-02 23:57:13 +01:00
Chris Tsang 6fdfec8610 python 0.6.11 2024-05-02 22:58:11 +01:00
York 1aff9a300a Add support for conversion from python bytes (#79)
* Allow conversion from python bytes

* Update function name

* Add convert_pixels_to_svg python function

* Update README.md

* Update README.md
2024-05-02 22:24:56 +01:00
Chris Tsang ac0a89e08a README 2024-04-20 16:24:41 +01:00
Chris Tsang b09f71a2b5 LICENSE 2024-04-20 16:21:36 +01:00
Chris Tsang e4897dfe99 README 2024-04-20 16:16:57 +01:00
Chris Tsang 4544ca740d README 2024-04-20 16:14:21 +01:00
Chris Tsang 3d92586e33 Update CI script 2024-04-20 15:52:58 +01:00
Chris Tsang 725adf5364 Update README.md 2024-03-30 14:46:52 +00:00
Chris Tsang 05f82c7bb5 Test 2024-03-29 19:26:28 +00:00
Chris Tsang b7ac336b6d Add release script 2024-03-29 19:06:29 +00:00
Chris Tsang c03a8ffced 0.6.4 2024-03-29 19:01:16 +00:00
Chris Tsang 3223ba56ec Upgrade dependency 2024-03-29 18:59:55 +00:00
Chris Tsang ddb47e1ad4 Revert "Experiment with idealizing small circles"
This reverts commit c3012c6aef.
2024-03-29 18:58:06 +00:00
Chris Tsang 177797108d Changelog 2024-03-29 18:57:57 +00:00
Chris Tsang 370083f818 0.6.3 2024-03-29 18:57:57 +00:00
Chris Tsang c3012c6aef Experiment with idealizing small circles 2024-03-29 18:57:57 +00:00
Chris Tsang 2774fc06c9 Reduce default path precision 2023-11-12 23:05:45 +00:00
Chris Tsang fa7d021055 Readme 2023-11-12 20:39:25 +00:00
Chris Tsang cc43924601 cargo fmt 2023-11-12 20:38:01 +00:00
Chris Tsang 9dbbd100df Bump 2023-11-12 20:36:35 +00:00
linkmauve 37a2570f49 Add a function to do in-memory conversion (#59)
* Move path handling out of conversion functions

This lets us decouple file reading/writing from the actual conversion.

* Move Config::from_args() to main.rs

* Remove path support from Config

Instead the input_path and output_path have to be passed to
convert_image_to_svg() manually.

* Add a simplified convert() function

This lets the user convert an image from memory, without encoding it to
PNG, writing it to a file, then reopening this file and decoding it
using the image crate.

It also allows the user to not write a SVG directly to a file.
2023-11-13 04:28:05 +08:00
Chris Tsang b2cd1a9524 Expose as rlib too 2023-10-24 08:43:37 +01:00
Chris Tsang ac93bd4a51 Edit 2023-09-24 11:08:30 +01:00
Chris Tsang e62f071b34 Edit 2023-09-23 11:52:36 +01:00
Chris Tsang 884092a5b9 Edit 2023-09-23 11:49:54 +01:00
Chris Tsang 5f5a6c6648 README 2023-09-23 11:45:36 +01:00
Chris Tsang 022018beb2 0.6.1 2023-09-23 11:12:34 +01:00
Chris Tsang cc39c8c5ca README 2023-09-23 11:06:18 +01:00
Evan Jones fa1ab68ef3 Python Bindings. Again. No, really! (#55)
* - Cargo.toml:  add `crate-type = ["cdylib"] to [lib]; lacking this is what was causing the executable to be put in wheels
- pyproject.toml: add "python-binding" to features for conditional compilation
- main.rs:  `cargo build` was failing for the vtracer executable; I think the previous import scheme had an implicit dependency on the library being built and called 'vtracer'. The other way to do this would have been to add an explicit dependency to the [[bin]], but making explicit imports here solves things directly

* re-enable linux builds; features are defined in the pyproject.toml now rather than in the workflow arguments

* And publish Linux, too!
2023-09-23 02:10:04 +08:00
Chris Tsang b04738fd18 well 2023-09-16 19:38:07 +01:00
Chris Tsang 1080c562ce Try again 2023-09-16 19:27:30 +01:00
Chris Tsang e44e750721 try again 2023-09-16 19:09:20 +01:00
Chris Tsang 05f5b51db0 Add xml comment 2023-09-17 16:24:33 +01:00
Chris Tsang 68e8e7fdf1 Disable Linux for now 2023-09-17 15:47:55 +01:00
Chris Tsang f5cce867f2 python again 2023-09-16 17:59:52 +01:00
Chris Tsang 79dd451da1 Remove filestar
From my knowledge they are no longer using vtracer
2023-09-16 17:53:43 +01:00
Chris Tsang f71dd47907 python release 2023-09-16 17:47:04 +01:00
Chris Tsang 594125f737 Release notes 2023-09-16 15:31:36 +01:00
Chris Tsang 93e946f3c2 python release 2023-09-16 15:05:24 +01:00
Chris Tsang 914e4ab875 python release 2023-09-16 14:51:26 +01:00
Chris Tsang 95b8cbce6c Rename workflow 2023-09-16 14:40:29 +01:00
Chris Tsang b49158f24c Rename workflow 2023-09-16 14:40:17 +01:00
Chris Tsang f35df1f6b2 0.6.0 2023-09-16 14:35:49 +01:00
Evan Jones f4c7828049 Python bindings configured correctly for PyPI releases (#54)
* Python bindings sep 2023 (#52)

* Added maturin-based Python binding, to be deployed to https://pypi.org/project/vtracer/

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

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

* - README_PY.md needed to be inside the cmdapp directory to display on PyPi.irg
->  v0.6.3

* Move code around

* Edit Readme

* Edit RELEASES.md

* Feature guard

* Build wheels with the cmdapp/Cargo.toml rather than top-level Cargo.toml

* use cmdapp/Cargo.toml for all Maturin CI actions, which causes Github to build all platforms python wheels and submit a new release to PyPI

* Bump to 0.6.4 for new PyPI release with all platforms' wheels included

* PyPI didn't accept a 'linux_aarch64' wheel for a release. For the moment, remove the platform until I can convince the action to build 'manylinux_aarch64' or the like

* Version bump while I work out CI & PyPI release wrinkles

* Maturin authors say `compatibility = "linux"` in pyproject.toml is causing PyPI failure. Replacing with "manylinux2014"

* bump to v0.7.0 in preparation for release from original vtracer repo

---------

Co-authored-by: Chris Tsang <chris.2y3@outlook.com>
2023-09-17 06:24:13 +08:00
96 changed files with 6831 additions and 1457 deletions
-117
View File
@@ -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 *
+182
View File
@@ -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-*/*'
+32
View File
@@ -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 }}
+46 -11
View File
@@ -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
+2 -1
View File
@@ -1,4 +1,5 @@
target
Cargo.lock
*.sublime*
.vscode
.vscode
.DS_Store
+53 -2
View File
@@ -5,13 +5,60 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## 0.6.0 - 2023-09-08
## 1.0.0-alpha.1 - 2026-07-24
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
### Added
* Pluggable pipeline: swappable frontend (segmentation), color fitting (incl. custom palettes), curve-fitting backend, and an optimizer pass phase.
* **Mosaic mode**: true seam-free, gapless tessellation via shared boundary-graph tracing (pixel, polygon, and spline fitters), replacing the old "cutout" that produced seams.
* SVG optimizer: relative path syntax, shorthand commands, and coordinate-precision reduction for smaller files.
* `@visioncortex/vtracer` Node.js package (npm): wasm core with a native image reader.
* Rewritten Python bindings (`vtracer-py`) with a richer API; pyo3 bumped to 0.26 (fixes CPython 3.14 segfaults, #124).
* CLI accepts positional `input`/`output` arguments (#114).
### Changed
* Workspace restructured into `crates/vtracer` (core lib), `crates/vtracer-cli`, `crates/vtracer-py`, and `nodejs/`.
* CLI upgraded from clap 2.x to 4.x (#118).
* `filter_speckle` CLI cap raised from 16 to 128, matching the web app (#115).
* Depends on `visioncortex` 0.9.
* Python wheel CI now runs only on release tags and manual dispatch, not on every commit.
### Removed
* The pre-1.0 `cmdapp` crate and the demo webapp GUI.
## 0.6.12 - 2026-02-04
* Python Binding
## 0.6.5 - 2025-10-17
* Update `fastrand` to `2.3`
## 0.6.4 - 2024-03-29
* Update `visioncortex` version to `0.8.8`
## 0.6.3 - 2023-11-21
* New converter API https://github.com/visioncortex/vtracer/pull/59
## 0.6.1 - 2023-09-23
* Fixed "The two lines are parallel!"
### Python Binding
Thanks to the contribution of [@etjones](https://github.com/etjones), we now have an official Python binding! https://github.com/visioncortex/vtracer/pull/55
https://pypi.org/project/vtracer/0.6.10/
## 0.5.0 - 2022-10-09
* Handle transparent png images (cli) (#23)
* Handle transparent png images (cli) https://github.com/visioncortex/vtracer/pull/23
## 0.4.0 - 2021-07-23
@@ -25,6 +72,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
* Use relative & closed paths
## 0.1.1 - 2020-11-01
* SVG namespace
## 0.1.0 - 2020-10-31
* Initial release
+28 -3
View File
@@ -1,7 +1,32 @@
[workspace]
members = [
"cmdapp",
"webapp",
"crates/vtracer",
"crates/vtracer-cli",
]
resolver = "2"
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
# build. It is superseded by the crates/ workspace above.
exclude = [
"webapp",
# pyo3 extension-module cdylib; built with maturin, not the core workspace.
"crates/vtracer-py",
# wasm-bindgen cdylib; built with wasm-pack as the Node package's core.
"nodejs",
]
resolver = "2"
[workspace.package]
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
[workspace.dependencies]
visioncortex = "0.9"
# For local development against an unreleased visioncortex, add a patch:
# [patch.crates-io]
# visioncortex = { path = "../visioncortex" }
+1 -1
View File
@@ -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
+129 -64
View File
@@ -4,35 +4,32 @@
<h1>VTracer</h1>
<p>
<strong>Raster to Vector Graphics Converter built on top of visioncortex</strong>
<strong>Raster to Vector Graphics Converter</strong>
</p>
<h3>
<a href="//www.visioncortex.org/vtracer-docs">Article</a>
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
<span> | </span>
<a href="//www.visioncortex.org/vtracer/">Demo</a>
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
<span> | </span>
<a href="//github.com/visioncortex/vtracer/releases/latest">Download</a>
<a href="https://github.com/visioncortex/vtracer/releases">Download</a>
</h3>
<sub>Built with 🦀 by <a href="//www.visioncortex.org/">The Vision Cortex Research Group</a></sub>
</div>
## Introduction
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans.
Comparing to [Potrace](http://potrace.sourceforge.net/) which only accept binarized inputs (Black & White pixmap), VTracer has an image processing pipeline which can handle colored high resolution scans. tl;dr: Potrace uses a `O(n^2)` fitting algorithm, whereas `vtracer` is entirely `O(n)`.
Comparing to Adobe Illustrator's [Image Trace](https://helpx.adobe.com/illustrator/using/image-trace.html), VTracer's output is much more compact (less shapes) as we adopt a stacking strategy and avoid producing shapes with holes.
VTracer is originally designed for processing high resolution scans of historic blueprints up to gigapixels. At the same time, VTracer can also handle low resolution pixel art, simulating `image-rendering: pixelated` for retro game artworks.
A technical description of the algorithm is on [visioncortex.org/vtracer-docs](//www.visioncortex.org/vtracer-docs).
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
## Web App
VTracer and its [core library](//github.com/visioncortex/visioncortex) is implemented in [Rust](//www.rust-lang.org/). It provides us a solid foundation to develop robust and efficient algorithms and easily bring it to interactive applications. The webapp is a perfect showcase of the capability of the Rust + wasm platform.
## Desktop App (coming soon)
![screenshot](docs/images/screenshot-01.png)
@@ -40,69 +37,137 @@ VTracer and its [core library](//github.com/visioncortex/visioncortex) is implem
## Cmd App
Input and output can be given as positional arguments or as named flags:
```sh
visioncortex VTracer 0.4.0
A cmd app to convert images into vector graphics.
USAGE:
vtracer [OPTIONS] --input <input> --output <output>
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
--colormode <color_mode> True color image `color` (default) or Binary image `bw`
-p, --color_precision <color_precision> Number of significant bits to use in an RGB channel
-c, --corner_threshold <corner_threshold> Minimum momentary angle (degree) to be considered a corner
-f, --filter_speckle <filter_speckle> Discard patches smaller than X px in size
-g, --gradient_step <gradient_step> Color difference between gradient layers
--hierarchical <hierarchical>
Hierarchical clustering `stacked` (default) or non-stacked `cutout`. Only applies to color mode.
-i, --input <input> Path to input raster image
-m, --mode <mode> Curver fitting mode `pixel`, `polygon`, `spline`
-o, --output <output> Path to output vector graphics
--path_precision <path_precision> Number of decimal places to use in path string
--preset <preset> Use one of the preset configs `bw`, `poster`, `photo`
-l, --segment_length <segment_length>
Perform iterative subdivide smooth until all segments are shorter than this length
-s, --splice_threshold <splice_threshold> Minimum angle displacement (degree) to splice a spline
vtracer input.jpg output.svg
# equivalent to:
vtracer --input input.jpg --output output.svg
```
Full options (flag names are kebab-case, e.g. `--filter-speckle`):
```sh
Usage: vtracer [OPTIONS] [INPUT] [OUTPUT]
Arguments:
[INPUT] Input raster image (positional; or use --input)
[OUTPUT] Output SVG (positional; or use --output)
Options:
-i, --input <INPUT> Path to the input raster image
-o, --output <OUTPUT> Path to the output SVG
--preset <PRESET> Start from a preset: bw, poster, photo
--colormode <COLORMODE> Color image `color` (default) or binary image `bw`
--hierarchical <HIERARCHICAL> Clustering: `stacked` (default) or `cutout` (seam-free mosaic)
-m, --mode <MODE> Curve-fitting mode: `pixel`, `polygon`, `spline`
-f, --filter-speckle <FILTER_SPECKLE> Discard patches smaller than X px in size (0..=128)
-p, --color-precision <COLOR_PRECISION> Significant bits per RGB channel (1..=8)
-g, --gradient-step <GRADIENT_STEP> Color difference between gradient layers (0..=255)
-c, --corner-threshold <CORNER_THRESHOLD> Minimum momentary angle (degrees) to be a corner (0..=180)
-l, --segment-length <SEGMENT_LENGTH> Subdivide until all segments are shorter than this (3.5..=10)
-s, --splice-threshold <SPLICE_THRESHOLD> Minimum angle displacement (degrees) to splice a spline (0..=180)
--path-precision <PATH_PRECISION> Decimal places to use in path coordinates
--palette <PALETTE> Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'
--palette-file <PALETTE_FILE> Fixed palette from a file (hex colors, comma/newline separated)
--max-colors <MAX_COLORS> Auto-quantize to at most N colors
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands
-h, --help Print help
-V, --version Print version
```
### New in 1.0
- **Positional arguments** — `vtracer in.png out.svg`.
- **`--hierarchical cutout`** is now a true seam-free mosaic (a gapless
tessellation with shared boundaries), replacing the old re-clustered cutout.
- **`--palette` / `--palette-file`** — snap colors to a fixed palette
(nearest in OKLab); **`--max-colors`** auto-quantizes the palette.
- **`--optimize`** — output size passes (coordinate quantization, redundant-
point removal, relative/shorthand path encoding).
## Downloads
You can download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
You can also install the program from source from [crates.io/vtracer](https://crates.io/crates/vtracer):
```sh
cargo install vtracer-cli
```
> You are strongly advised to not download from any other third-party sources
### Usage
```
./vtracer --input input.jpg --output output.svg
```sh
# simplest form
./vtracer input.jpg output.svg
# black & white line art
./vtracer input.jpg output.svg --preset bw
# seam-free mosaic (gapless tessellation)
./vtracer input.jpg output.svg --hierarchical cutout
# constrain to a fixed palette
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
```
## Library
### Rust Library
The library can be found on [crates.io/vtracer](//crates.io/crates/vtracer) and [crates.io/vtracer-webapp](//crates.io/crates/vtracer-webapp).
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
## Install
Download pre-built binaries from [Releases](https://github.com/visioncortex/vtracer/releases).
or
Install from source (Rust toolchain needed):
```
cargo install vtracer
```sh
cargo add vtracer
```
## In the wild
### Python Library
VTracer is used by the following products (feel free to add yours to the list):
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension (built with [pyo3](https://github.com/PyO3/pyo3) + [maturin](https://www.maturin.rs), from the `crates/vtracer-py` crate).
<table>
<tbody>
<tr>
<td><a href="https://logo.aliyun.com/logo#/name"><img src="docs/images/aliyun-logo.png" width="250"/></a>
<br>Smart logo design
</td>
<td></td>
</tr>
</tbody>
</table>
```sh
pip install vtracer
```
```python
import vtracer
# one-liners
vtracer.convert_file("in.png", "out.svg")
svg = vtracer.convert_bytes(open("in.png", "rb").read())
# rich, reusable config + presets
cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
svg = cfg.convert_bytes(data)
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
```
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
### Node.js Library
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**.
```sh
npm install @visioncortex/vtracer
```
```js
const vtracer = require('@visioncortex/vtracer');
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
```
## Citations
VTracer has since been cited by a few academic papers in computer graphics / vision research. Please kindly let us know if you have cited our work:
+ SKILL 2023 [Framework to Vectorize Digital Artworks for Physical Fabrication based on Geometric Stylization Techniques](https://www.researchgate.net/publication/374448489_Framework_to_Vectorize_Digital_Artworks_for_Physical_Fabrication_based_on_Geometric_Stylization_Techniques)
+ arXiv 2023 [Image Vectorization: a Review](https://arxiv.org/abs/2306.06441)
+ arXiv 2023 [StarVector: Generating Scalable Vector Graphics Code from Images](https://arxiv.org/abs/2312.11556)
+ arXiv 2024 [Text-Based Reasoning About Vector Graphics](https://arxiv.org/abs/2404.06479)
+ arXiv 2024 [Delving into LLMs' visual understanding ability using SVG to bridge image and text](https://openreview.net/pdf?id=pwlm6Po61I)
-27
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
*.svg
*.png
*.jpg
-21
View File
@@ -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"]
-201
View File
@@ -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.
-25
View File
@@ -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.
-28
View File
@@ -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"]
-397
View File
@@ -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
}
-228
View File
@@ -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(())
}
-21
View File
@@ -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::*;
-14
View File
@@ -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);
}
}
}
-81
View File
@@ -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(())
}
-65
View File
@@ -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
)
}
}
-75
View File
@@ -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
View File
@@ -1 +0,0 @@
from .vtracer import convert_image_to_svg_py
-17
View File
@@ -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:
...
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "vtracer-cli"
description = "Command-line front-end for the vtracer vectorization framework."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "command-line-utilities"]
keywords = ["svg", "vectorization", "computer-graphics"]
[[bin]]
name = "vtracer"
path = "src/main.rs"
[dependencies]
vtracer = { version = "1.0.0-alpha.1", path = "../vtracer" }
visioncortex.workspace = true
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = [
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
] }
clap = { version = "4", features = ["derive"] }
+231
View File
@@ -0,0 +1,231 @@
//! Thin command-line front-end over the `vtracer` framework.
//!
//! Handles the two things the framework deliberately leaves out: image file
//! I/O and argument parsing. Everything else is delegated to
//! [`vtracer::Config`] / [`vtracer::Pipeline`].
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use visioncortex::{Color, ColorImage};
use vtracer::{ColorMode, Config, FitMode, Hierarchical, Preset};
/// Convert an image into vector graphics.
#[derive(Parser, Debug)]
#[command(name = "vtracer", version, about, rename_all = "kebab-case")]
struct Args {
/// Input raster image (positional; or use --input).
#[arg(value_name = "INPUT")]
input_pos: Option<PathBuf>,
/// Output SVG (positional; or use --output).
#[arg(value_name = "OUTPUT")]
output_pos: Option<PathBuf>,
/// Path to the input raster image.
#[arg(short = 'i', long = "input", value_name = "INPUT")]
input: Option<PathBuf>,
/// Path to the output SVG.
#[arg(short = 'o', long = "output", value_name = "OUTPUT")]
output: Option<PathBuf>,
/// Start from a preset: bw, poster, photo.
#[arg(long)]
preset: Option<Preset>,
/// Color image (`color`) or binary image (`bw`).
#[arg(long = "colormode")]
colormode: Option<ColorMode>,
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
#[arg(long)]
hierarchical: Option<Hierarchical>,
/// Curve-fitting mode: pixel, polygon, spline.
#[arg(short, long)]
mode: Option<FitMode>,
/// Discard patches smaller than X px in size (0..=128).
#[arg(short = 'f', long, value_parser = clap::value_parser!(i64).range(0..=128))]
filter_speckle: Option<i64>,
/// Significant bits per RGB channel (1..=8).
#[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(1..=8))]
color_precision: Option<i64>,
/// Color difference between gradient layers (0..=255).
#[arg(short = 'g', long, value_parser = clap::value_parser!(i64).range(0..=255))]
gradient_step: Option<i64>,
/// Minimum momentary angle (degrees) to be a corner (0..=180).
#[arg(short = 'c', long, value_parser = clap::value_parser!(i64).range(0..=180))]
corner_threshold: Option<i64>,
/// Subdivide until all segments are shorter than this length (3.5..=10).
#[arg(short = 'l', long, value_parser = parse_segment_length)]
segment_length: Option<f64>,
/// Minimum angle displacement (degrees) to splice a spline (0..=180).
#[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))]
splice_threshold: Option<i64>,
/// Decimal places to use in path coordinates.
#[arg(long)]
path_precision: Option<u32>,
/// Fixed palette: comma-separated hex colors, e.g. '#112233,#445566'.
#[arg(long)]
palette: Option<String>,
/// Fixed palette from a file (one hex color per line or comma-separated).
#[arg(long)]
palette_file: Option<PathBuf>,
/// Auto-quantize to at most N colors.
#[arg(long)]
max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
optimize: Option<u8>,
}
fn parse_segment_length(s: &str) -> Result<f64, String> {
let v: f64 = s
.parse()
.map_err(|_| format!("`{s}` is not a number"))?;
if !(3.5..=10.0).contains(&v) {
return Err(format!("segment length {v} is out of range [3.5, 10]"));
}
Ok(v)
}
/// Parse a comma/whitespace/newline separated list of `#rrggbb` colors.
fn parse_palette(text: &str) -> Result<Vec<Color>, String> {
let mut colors = Vec::new();
for token in text.split(|c: char| c == ',' || c.is_whitespace()) {
let token = token.trim();
if token.is_empty() {
continue;
}
colors.push(parse_hex_color(token)?);
}
Ok(colors)
}
fn parse_hex_color(token: &str) -> Result<Color, String> {
let hex = token.strip_prefix('#').unwrap_or(token);
if hex.len() != 6 {
return Err(format!("`{token}` is not a #rrggbb color"));
}
let parse = |range: std::ops::Range<usize>| {
u8::from_str_radix(&hex[range], 16).map_err(|_| format!("`{token}` is not a #rrggbb color"))
};
Ok(Color::new(parse(0..2)?, parse(2..4)?, parse(4..6)?))
}
fn build_config(args: &Args) -> Result<Config, String> {
let mut config = match args.preset {
Some(preset) => Config::from_preset(preset),
None => Config::default(),
};
if let Some(v) = args.colormode {
config.color_mode = v;
}
if let Some(v) = args.hierarchical {
config.hierarchical = v;
}
if let Some(v) = args.mode {
config.mode = v;
}
if let Some(v) = args.filter_speckle {
config.filter_speckle = v as usize;
}
if let Some(v) = args.color_precision {
config.color_precision = v as i32;
}
if let Some(v) = args.gradient_step {
config.layer_difference = v as i32;
}
if let Some(v) = args.corner_threshold {
config.corner_threshold = v as i32;
}
if let Some(v) = args.segment_length {
config.length_threshold = v;
}
if let Some(v) = args.splice_threshold {
config.splice_threshold = v as i32;
}
if args.path_precision.is_some() {
config.path_precision = args.path_precision;
}
if let Some(v) = args.optimize {
config.optimize = v;
}
if let Some(v) = args.max_colors {
config.max_colors = Some(v);
}
// Palette: inline flag wins over file; both parse to a color list.
if let Some(text) = &args.palette {
config.palette = parse_palette(text)?;
} else if let Some(path) = &args.palette_file {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read palette file: {e}"))?;
config.palette = parse_palette(&text)?;
}
Ok(config)
}
fn read_image(path: &std::path::Path) -> Result<ColorImage, String> {
let img = image::open(path)
.map_err(|_| "no image file found at specified input path".to_string())?
.to_rgba8();
let (width, height) = (img.width() as usize, img.height() as usize);
Ok(ColorImage {
pixels: img.into_raw(),
width,
height,
})
}
fn run() -> Result<(), String> {
let args = Args::parse();
// Accept input/output as positionals (`vtracer in.png out.svg`) or as
// named flags; an explicit flag takes precedence over the positional.
let input = args
.input
.as_ref()
.or(args.input_pos.as_ref())
.ok_or("no input path given (positional or --input)")?;
let output = args
.output
.as_ref()
.or(args.output_pos.as_ref())
.ok_or("no output path given (positional or --output)")?;
let config = build_config(&args)?;
let pipeline = config.build().map_err(|e| e.to_string())?;
let img = read_image(input)?;
let svg = pipeline.to_svg(&img).map_err(|e| e.to_string())?;
std::fs::write(output, svg).map_err(|e| format!("cannot write output file: {e}"))?;
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => {
println!("Conversion successful.");
ExitCode::SUCCESS
}
Err(msg) => {
eprintln!("Conversion failed: {msg}");
ExitCode::FAILURE
}
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "vtracer-py"
description = "Python bindings for the vtracer vectorization framework."
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
# Excluded from the workspace: pyo3 `extension-module` cdylibs don't link
# libpython, which breaks `cargo test` at the workspace root. Built with
# maturin. Deps are declared explicitly (no workspace inheritance).
[lib]
# Python imports this as `vtracer`.
name = "vtracer"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.1", path = "../vtracer" }
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = [
"png", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pnm", "tga", "qoi",
] }
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py38"] }
+67
View File
@@ -0,0 +1,67 @@
# vtracer (Python)
Python bindings for the [`vtracer`](https://github.com/visioncortex/vtracer)
raster-to-vector framework. Built with [pyo3](https://pyo3.rs) +
[maturin](https://www.maturin.rs); the core Rust crate stays pure (no I/O), and
this crate adds image decoding and a Pythonic API.
## Install
```sh
pip install vtracer
```
## Usage
```python
import vtracer
# one-liners
vtracer.convert_file("in.png", "out.svg")
svg = vtracer.convert_bytes(open("in.png", "rb").read()) # -> str
svg = vtracer.convert_pixels(rgba_bytes, width, height) # raw RGBA8
# a rich, reusable configuration object
cfg = vtracer.Config(mode="polygon", filter_speckle=8)
cfg.hierarchical = "cutout" # seam-free mosaic
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"] # snap to a fixed palette
cfg.max_colors = 8 # or auto-quantize
cfg.optimize = 2
svg = cfg.convert_bytes(data)
# presets
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
vtracer.Config.bw().convert_file("scan.png", "lineart.svg")
```
### `Config`
Constructor keyword arguments (all optional) — also exposed as mutable
properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| arg | default | notes |
|---|---|---|
| `color_mode` | `"color"` | `"color"` or `"bw"` |
| `hierarchical` | `"stacked"` | `"stacked"` or `"cutout"` (mosaic) |
| `mode` | `"spline"` | `"pixel"`, `"polygon"`, `"spline"` |
| `filter_speckle` | `4` | discard patches smaller than X px |
| `color_precision` | `6` | significant bits per channel |
| `layer_difference` | `16` | color diff between gradient layers |
| `corner_threshold` | `60` | degrees |
| `length_threshold` | `4.0` | px |
| `max_iterations` | `10` | |
| `splice_threshold` | `45` | degrees |
| `path_precision` | `2` | output decimal places |
| `palette` | `None` | list of `#rrggbb` strings |
| `max_colors` | `None` | auto-quantize target |
| `optimize` | `1` | `0` off, `1` quantize+simplify, `2` + shorthands |
Each `Config` has `convert_file(input, output)`, `convert_bytes(data, format=None) -> str`,
and `convert_pixels(rgba, width, height) -> str`.
## Build from source
```sh
maturin develop # into the active virtualenv
maturin build --release # produce a wheel
```
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["maturin>=1.5,<2.0"]
build-backend = "maturin"
[project]
name = "vtracer"
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
requires-python = ">=3.8"
license = { text = "MIT OR Apache-2.0" }
authors = [{ name = "Chris Tsang", email = "tyt2y7@gmail.com" }]
keywords = ["svg", "vectorization", "raster", "computer-graphics"]
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: 3",
"Topic :: Multimedia :: Graphics",
]
dynamic = ["version"]
[project.urls]
Homepage = "http://www.visioncortex.org/vtracer"
Repository = "https://github.com/visioncortex/vtracer/"
[tool.maturin]
# Pure-Rust extension module; the compiled library is imported as `vtracer`.
module-name = "vtracer"
features = ["pyo3/extension-module"]
+446
View File
@@ -0,0 +1,446 @@
//! Python bindings for the `vtracer` vectorization framework.
//!
//! The API centers on a mutable [`Config`] object with named properties and
//! preset constructors, plus three input paths — a file, encoded image bytes,
//! or a raw RGBA buffer — each returning the SVG (or writing it to disk):
//!
//! ```python
//! import vtracer
//!
//! # one-liners
//! vtracer.convert_file("in.png", "out.svg")
//! svg = vtracer.convert_bytes(open("in.png", "rb").read())
//!
//! # rich, reusable config
//! cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
//! cfg.max_colors = 8
//! cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
//! svg = cfg.convert_bytes(data)
//!
//! # presets
//! vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
//! ```
use std::io::Cursor;
use std::path::PathBuf;
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use ::vtracer::{Color, ColorImage, ColorMode, Config as CoreConfig, FitMode, Hierarchical, Preset};
// --- string <-> enum helpers -------------------------------------------------
fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
s.parse().map_err(PyValueError::new_err)
}
fn color_mode_str(m: ColorMode) -> &'static str {
match m {
ColorMode::Color => "color",
ColorMode::Binary => "bw",
}
}
fn hierarchical_str(h: Hierarchical) -> &'static str {
match h {
Hierarchical::Stacked => "stacked",
Hierarchical::Cutout => "cutout",
}
}
fn mode_str(m: FitMode) -> &'static str {
match m {
FitMode::Pixel => "pixel",
FitMode::Polygon => "polygon",
FitMode::Spline => "spline",
}
}
fn parse_hex(token: &str) -> PyResult<Color> {
let hex = token.strip_prefix('#').unwrap_or(token);
if hex.len() != 6 {
return Err(PyValueError::new_err(format!(
"`{token}` is not a #rrggbb color"
)));
}
let byte = |r: std::ops::Range<usize>| {
u8::from_str_radix(&hex[r], 16)
.map_err(|_| PyValueError::new_err(format!("`{token}` is not a #rrggbb color")))
};
Ok(Color::new(byte(0..2)?, byte(2..4)?, byte(4..6)?))
}
// --- image helpers -----------------------------------------------------------
fn dynimg_to_color(img: image::DynamicImage) -> ColorImage {
let img = img.to_rgba8();
let (w, h) = (img.width() as usize, img.height() as usize);
ColorImage {
pixels: img.into_raw(),
width: w,
height: h,
}
}
fn decode_bytes(bytes: &[u8], format: Option<&str>) -> PyResult<ColorImage> {
let mut reader = image::ImageReader::new(Cursor::new(bytes));
match format {
Some(ext) => {
let fmt = image::ImageFormat::from_extension(ext)
.ok_or_else(|| PyValueError::new_err(format!("unknown image format `{ext}`")))?;
reader.set_format(fmt);
}
None => {
reader = reader
.with_guessed_format()
.map_err(|e| PyValueError::new_err(e.to_string()))?;
}
}
let img = reader
.decode()
.map_err(|e| PyValueError::new_err(format!("failed to decode image: {e}")))?;
Ok(dynimg_to_color(img))
}
// --- Config ------------------------------------------------------------------
/// Conversion configuration. Construct with keyword arguments or a preset,
/// mutate via properties, then call one of the `convert_*` methods.
#[pyclass(name = "Config")]
#[derive(Clone)]
struct PyConfig {
inner: CoreConfig,
}
impl PyConfig {
fn to_svg(&self, img: &ColorImage) -> PyResult<String> {
self.inner
.build()
.map_err(|e| PyValueError::new_err(e.to_string()))?
.to_svg(img)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
}
#[pymethods]
impl PyConfig {
#[new]
#[pyo3(signature = (
color_mode = "color",
hierarchical = "stacked",
mode = "spline",
filter_speckle = 4,
color_precision = 6,
layer_difference = 16,
corner_threshold = 60,
length_threshold = 4.0,
max_iterations = 10,
splice_threshold = 45,
path_precision = 2,
palette = None,
max_colors = None,
optimize = 1,
))]
#[allow(clippy::too_many_arguments)]
fn new(
color_mode: &str,
hierarchical: &str,
mode: &str,
filter_speckle: usize,
color_precision: i32,
layer_difference: i32,
corner_threshold: i32,
length_threshold: f64,
max_iterations: usize,
splice_threshold: i32,
path_precision: u32,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: u8,
) -> PyResult<Self> {
let palette = match palette {
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
None => Vec::new(),
};
Ok(Self {
inner: CoreConfig {
color_mode: parse(color_mode)?,
hierarchical: parse(hierarchical)?,
mode: parse(mode)?,
filter_speckle,
color_precision,
layer_difference,
corner_threshold,
length_threshold,
max_iterations,
splice_threshold,
path_precision: Some(path_precision),
palette,
max_colors,
optimize,
},
})
}
/// Preset for black & white line art.
#[staticmethod]
fn bw() -> Self {
Self { inner: CoreConfig::from_preset(Preset::Bw) }
}
/// Preset for posterized color art.
#[staticmethod]
fn poster() -> Self {
Self { inner: CoreConfig::from_preset(Preset::Poster) }
}
/// Preset tuned for photographs.
#[staticmethod]
fn photo() -> Self {
Self { inner: CoreConfig::from_preset(Preset::Photo) }
}
// --- properties ---
#[getter]
fn color_mode(&self) -> &'static str {
color_mode_str(self.inner.color_mode)
}
#[setter]
fn set_color_mode(&mut self, v: &str) -> PyResult<()> {
self.inner.color_mode = parse(v)?;
Ok(())
}
#[getter]
fn hierarchical(&self) -> &'static str {
hierarchical_str(self.inner.hierarchical)
}
#[setter]
fn set_hierarchical(&mut self, v: &str) -> PyResult<()> {
self.inner.hierarchical = parse(v)?;
Ok(())
}
#[getter]
fn mode(&self) -> &'static str {
mode_str(self.inner.mode)
}
#[setter]
fn set_mode(&mut self, v: &str) -> PyResult<()> {
self.inner.mode = parse(v)?;
Ok(())
}
#[getter]
fn filter_speckle(&self) -> usize {
self.inner.filter_speckle
}
#[setter]
fn set_filter_speckle(&mut self, v: usize) {
self.inner.filter_speckle = v;
}
#[getter]
fn color_precision(&self) -> i32 {
self.inner.color_precision
}
#[setter]
fn set_color_precision(&mut self, v: i32) {
self.inner.color_precision = v;
}
#[getter]
fn layer_difference(&self) -> i32 {
self.inner.layer_difference
}
#[setter]
fn set_layer_difference(&mut self, v: i32) {
self.inner.layer_difference = v;
}
#[getter]
fn corner_threshold(&self) -> i32 {
self.inner.corner_threshold
}
#[setter]
fn set_corner_threshold(&mut self, v: i32) {
self.inner.corner_threshold = v;
}
#[getter]
fn length_threshold(&self) -> f64 {
self.inner.length_threshold
}
#[setter]
fn set_length_threshold(&mut self, v: f64) {
self.inner.length_threshold = v;
}
#[getter]
fn max_iterations(&self) -> usize {
self.inner.max_iterations
}
#[setter]
fn set_max_iterations(&mut self, v: usize) {
self.inner.max_iterations = v;
}
#[getter]
fn splice_threshold(&self) -> i32 {
self.inner.splice_threshold
}
#[setter]
fn set_splice_threshold(&mut self, v: i32) {
self.inner.splice_threshold = v;
}
#[getter]
fn path_precision(&self) -> Option<u32> {
self.inner.path_precision
}
#[setter]
fn set_path_precision(&mut self, v: Option<u32>) {
self.inner.path_precision = v;
}
#[getter]
fn palette(&self) -> Vec<String> {
self.inner.palette.iter().map(Color::to_hex_string).collect()
}
#[setter]
fn set_palette(&mut self, v: Vec<String>) -> PyResult<()> {
self.inner.palette = v.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?;
Ok(())
}
#[getter]
fn max_colors(&self) -> Option<usize> {
self.inner.max_colors
}
#[setter]
fn set_max_colors(&mut self, v: Option<usize>) {
self.inner.max_colors = v;
}
#[getter]
fn optimize(&self) -> u8 {
self.inner.optimize
}
#[setter]
fn set_optimize(&mut self, v: u8) {
self.inner.optimize = v;
}
// --- conversion ---
/// Trace the image at `input_path` and write the SVG to `output_path`.
fn convert_file(&self, input_path: PathBuf, output_path: PathBuf) -> PyResult<()> {
let img = image::open(&input_path)
.map_err(|e| PyIOError::new_err(format!("cannot open `{}`: {e}", input_path.display())))?;
let svg = self.to_svg(&dynimg_to_color(img))?;
std::fs::write(&output_path, svg)
.map_err(|e| PyIOError::new_err(format!("cannot write `{}`: {e}", output_path.display())))
}
/// Trace encoded image `data` (png/jpg/...) and return the SVG string.
/// `format` (e.g. "png") overrides content-based format detection.
#[pyo3(signature = (data, format = None))]
fn convert_bytes(&self, data: Vec<u8>, format: Option<&str>) -> PyResult<String> {
self.to_svg(&decode_bytes(&data, format)?)
}
/// Trace a raw RGBA8 buffer (`width * height * 4` bytes) and return the SVG.
fn convert_pixels(&self, rgba: Vec<u8>, width: usize, height: usize) -> PyResult<String> {
if rgba.len() != width * height * 4 {
return Err(PyValueError::new_err(format!(
"rgba length {} != width*height*4 ({})",
rgba.len(),
width * height * 4
)));
}
self.to_svg(&ColorImage {
pixels: rgba,
width,
height,
})
}
fn __repr__(&self) -> String {
let c = &self.inner;
format!(
"Config(color_mode='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
color_precision={}, layer_difference={}, corner_threshold={}, length_threshold={}, \
max_iterations={}, splice_threshold={}, path_precision={:?}, palette={} colors, \
max_colors={:?}, optimize={})",
color_mode_str(c.color_mode),
hierarchical_str(c.hierarchical),
mode_str(c.mode),
c.filter_speckle,
c.color_precision,
c.layer_difference,
c.corner_threshold,
c.length_threshold,
c.max_iterations,
c.splice_threshold,
c.path_precision,
c.palette.len(),
c.max_colors,
c.optimize,
)
}
}
// --- module-level convenience ------------------------------------------------
/// Convert a file to SVG on disk, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (input_path, output_path, config = None))]
fn convert_file(
input_path: PathBuf,
output_path: PathBuf,
config: Option<PyConfig>,
) -> PyResult<()> {
config.unwrap_or_else(default_config).convert_file(input_path, output_path)
}
/// Convert encoded image bytes to an SVG string, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (data, config = None, format = None))]
fn convert_bytes(
data: Vec<u8>,
config: Option<PyConfig>,
format: Option<&str>,
) -> PyResult<String> {
config.unwrap_or_else(default_config).convert_bytes(data, format)
}
/// Convert a raw RGBA8 buffer to an SVG string, using `config` (or defaults).
#[pyfunction]
#[pyo3(signature = (rgba, width, height, config = None))]
fn convert_pixels(
rgba: Vec<u8>,
width: usize,
height: usize,
config: Option<PyConfig>,
) -> PyResult<String> {
config.unwrap_or_else(default_config).convert_pixels(rgba, width, height)
}
fn default_config() -> PyConfig {
PyConfig {
inner: CoreConfig::default(),
}
}
#[pymodule]
#[pyo3(name = "vtracer")]
fn vtracer_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyConfig>()?;
m.add_function(wrap_pyfunction!(convert_file, m)?)?;
m.add_function(wrap_pyfunction!(convert_bytes, m)?)?;
m.add_function(wrap_pyfunction!(convert_pixels, m)?)?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
from typing import Optional
__version__: str
class Config:
"""Conversion configuration. Construct with keyword arguments or a preset,
mutate via properties, then call one of the ``convert_*`` methods."""
def __init__(
self,
color_mode: str = "color", # "color" | "bw"
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
mode: str = "spline", # "pixel" | "polygon" | "spline"
filter_speckle: int = 4,
color_precision: int = 6,
layer_difference: int = 16,
corner_threshold: int = 60,
length_threshold: float = 4.0,
max_iterations: int = 10,
splice_threshold: int = 45,
path_precision: int = 2,
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
max_colors: Optional[int] = None, # auto-quantize target
optimize: int = 1, # 0 | 1 | 2
) -> None: ...
@staticmethod
def bw() -> "Config": ...
@staticmethod
def poster() -> "Config": ...
@staticmethod
def photo() -> "Config": ...
color_mode: str
hierarchical: str
mode: str
filter_speckle: int
color_precision: int
layer_difference: int
corner_threshold: int
length_threshold: float
max_iterations: int
splice_threshold: int
path_precision: Optional[int]
palette: list[str]
max_colors: Optional[int]
optimize: int
def convert_file(self, input_path: str, output_path: str) -> None: ...
def convert_bytes(self, data: bytes, format: Optional[str] = None) -> str: ...
def convert_pixels(self, rgba: bytes, width: int, height: int) -> str: ...
def convert_file(input_path: str, output_path: str, config: Optional[Config] = None) -> None: ...
def convert_bytes(data: bytes, config: Optional[Config] = None, format: Optional[str] = None) -> str: ...
def convert_pixels(rgba: bytes, width: int, height: int, config: Optional[Config] = None) -> str: ...
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "vtracer"
description = "A vectorization framework that converts raster images into vector graphics: pluggable frontends, curve fitters, color fitting, and output optimization."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "computer-vision"]
keywords = ["svg", "vectorization", "computer-graphics"]
[lib]
name = "vtracer"
path = "src/lib.rs"
[dependencies]
visioncortex.workspace = true
[dev-dependencies]
# Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not
# compiled for wasm targets, so the library stays wasm-safe.
resvg = "0.45"
+28
View File
@@ -0,0 +1,28 @@
use crate::ir::{Layer, Segmentation};
use super::ColorFitter;
/// Union consecutive layers that share a paint into a single layer. Run this
/// after palette snapping (which is what creates runs of identical paints) to
/// cut the shape count without changing appearance.
#[derive(Debug, Clone, Default)]
pub struct MergeAdjacent;
impl ColorFitter for MergeAdjacent {
fn fit(&self, seg: &mut Segmentation) {
if seg.layers.len() < 2 {
return;
}
let mut merged: Vec<Layer> = Vec::with_capacity(seg.layers.len());
for layer in seg.layers.drain(..) {
if let Some(last) = merged.last_mut() {
if last.paint == layer.paint {
last.mask = last.mask.union(&layer.mask);
continue;
}
}
merged.push(layer);
}
seg.layers = merged;
}
}
+75
View File
@@ -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)));
}
}
+53
View File
@@ -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
}
}
+47
View File
@@ -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);
}
}
}
+148
View File
@@ -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);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
//!
//! * **Stacked** — each layer is traced independently into closed outlines and
//! stacked in paint order (painter's algorithm).
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
use crate::fitter::CurveFitter;
use crate::ir::{Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, SegmentFitter};
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
/// Independent per-region closed outlines, stacked bottom-to-top.
Stacked(Box<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic(Box<dyn SegmentFitter>),
}
impl Compositing {
/// Run the selected compositor over a segmentation.
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
Compositing::Mosaic(fitter) => compose_mosaic(seg, fitter.as_ref()),
}
}
}
/// Trace every layer's closed outline and stack the shapes in paint order.
pub fn compose_stacked(seg: &Segmentation, fitter: &dyn CurveFitter) -> VectorDoc {
let mut doc = VectorDoc::new(seg.width, seg.height);
for layer in &seg.layers {
let path = fitter.fit_region(&layer.mask);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
}
doc
}
+276
View File
@@ -0,0 +1,276 @@
//! High-level configuration and presets that assemble a [`Pipeline`].
use std::str::FromStr;
use visioncortex::Color;
use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAdjacent};
use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend};
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
use crate::pipeline::Pipeline;
use crate::svg::SvgWriter;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Color,
Binary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hierarchical {
Stacked,
/// True mosaic cutout — not yet implemented (separate milestone).
Cutout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FitMode {
Pixel,
Polygon,
Spline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preset {
Bw,
Poster,
Photo,
}
/// High-level converter configuration. [`Config::build`] turns this into a
/// concrete [`Pipeline`].
#[derive(Debug, Clone)]
pub struct Config {
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
/// Speckle filter given as a side length; the area threshold is its square.
pub filter_speckle: usize,
/// Significant bits per RGB channel (1..=8).
pub color_precision: i32,
/// Color difference between gradient layers.
pub layer_difference: i32,
pub mode: FitMode,
/// Corner threshold in degrees.
pub corner_threshold: i32,
/// Segment length threshold in pixels.
pub length_threshold: f64,
pub max_iterations: usize,
/// Splice threshold in degrees.
pub splice_threshold: i32,
/// Coordinate precision (decimal places) for output.
pub path_precision: Option<u32>,
/// Fixed palette (empty = none). Takes priority over `max_colors`.
pub palette: Vec<Color>,
/// Auto-quantize target color count (None = off).
pub max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
pub optimize: u8,
}
impl Default for Config {
fn default() -> Self {
Self {
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 6,
layer_difference: 16,
mode: FitMode::Spline,
corner_threshold: 60,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
path_precision: Some(2),
palette: Vec::new(),
max_colors: None,
optimize: 1,
}
}
}
impl Config {
pub fn from_preset(preset: Preset) -> Self {
match preset {
Preset::Bw => Self {
color_mode: ColorMode::Binary,
..Self::default()
},
Preset::Poster => Self {
color_mode: ColorMode::Color,
color_precision: 8,
..Self::default()
},
Preset::Photo => Self {
color_mode: ColorMode::Color,
filter_speckle: 10,
color_precision: 8,
layer_difference: 48,
corner_threshold: 180,
..Self::default()
},
}
}
fn fit_params(&self) -> FitParams {
FitParams {
corner_threshold: deg2rad(self.corner_threshold),
length_threshold: self.length_threshold,
max_iterations: self.max_iterations,
splice_threshold: deg2rad(self.splice_threshold),
}
}
fn frontend(&self) -> Box<dyn Frontend> {
let filter_speckle_area = self.filter_speckle * self.filter_speckle;
match self.color_mode {
ColorMode::Color => Box::new(ColorClusterFrontend {
filter_speckle_area,
color_precision_loss: 8 - self.color_precision,
layer_difference: self.layer_difference,
}),
ColorMode::Binary => Box::new(BinaryFrontend {
filter_speckle_area,
threshold: 128,
diagonal: false,
}),
}
}
fn color_fitters(&self) -> Vec<Box<dyn ColorFitter>> {
if !self.palette.is_empty() {
vec![
Box::new(FixedPalette::new(self.palette.clone())),
Box::new(MergeAdjacent),
]
} else if let Some(max_colors) = self.max_colors {
vec![Box::new(AutoQuantize { max_colors }), Box::new(MergeAdjacent)]
} else {
vec![Box::new(Identity)]
}
}
fn fitter(&self) -> Box<dyn CurveFitter> {
match self.mode {
FitMode::Pixel => Box::new(PixelFitter),
FitMode::Polygon => Box::new(PolygonFitter),
FitMode::Spline => Box::new(SplineFitter::new(self.fit_params())),
}
}
fn segment_fitter(&self) -> Box<dyn SegmentFitter> {
match self.mode {
FitMode::Pixel => Box::new(PixelSegmentFitter),
FitMode::Polygon => Box::new(PolygonSegmentFitter::default()),
FitMode::Spline => Box::new(SplineSegmentFitter {
corner_threshold: deg2rad(self.corner_threshold),
length_threshold: self.length_threshold,
max_iterations: self.max_iterations,
splice_threshold: deg2rad(self.splice_threshold),
..SplineSegmentFitter::default()
}),
}
}
fn optimizers(&self) -> Vec<Box<dyn OptimizerPass>> {
if self.optimize == 0 {
return Vec::new();
}
let precision = self.path_precision.unwrap_or(2);
vec![
Box::new(QuantizePass::new(precision)),
Box::new(SimplifyPass),
]
}
fn writer(&self) -> SvgWriter {
match self.optimize {
0 => SvgWriter {
relative: false,
shorthands: false,
precision: self.path_precision,
},
1 => SvgWriter {
relative: true,
shorthands: false,
precision: self.path_precision,
},
_ => SvgWriter {
relative: true,
shorthands: true,
precision: self.path_precision,
},
}
}
/// Assemble a concrete pipeline from this configuration.
pub fn build(&self) -> Result<Pipeline, Error> {
let compositing = match self.hierarchical {
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
Hierarchical::Cutout => Compositing::Mosaic(self.segment_fitter()),
};
Ok(Pipeline {
frontend: self.frontend(),
color_fitters: self.color_fitters(),
compositing,
optimizers: self.optimizers(),
writer: self.writer(),
})
}
}
fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
impl FromStr for ColorMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color" => Ok(Self::Color),
"binary" | "bw" | "BW" => Ok(Self::Binary),
_ => Err(format!("unknown color mode {s}")),
}
}
}
impl FromStr for Hierarchical {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"stacked" => Ok(Self::Stacked),
"cutout" => Ok(Self::Cutout),
_ => Err(format!("unknown hierarchical mode {s}")),
}
}
}
impl FromStr for FitMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pixel" | "none" => Ok(Self::Pixel),
"polygon" => Ok(Self::Polygon),
"spline" => Ok(Self::Spline),
_ => Err(format!("unknown fit mode {s}")),
}
}
}
impl FromStr for Preset {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"bw" => Ok(Self::Bw),
"poster" => Ok(Self::Poster),
"photo" => Ok(Self::Photo),
_ => Err(format!("unknown preset {s}")),
}
}
}
+41
View File
@@ -0,0 +1,41 @@
use std::fmt;
/// Errors produced by the framework stages and the pipeline driver.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// The input image had zero width or height.
EmptyImage,
/// Transparency keying was requested but no unused key color could be found.
NoKeyColor,
/// A requested feature is recognized but not yet implemented.
Unsupported(String),
/// Any other failure, carrying a human-readable message.
Other(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::EmptyImage => write!(f, "input image is empty"),
Error::NoKeyColor => {
write!(f, "unable to find an unused color in image to use as key")
}
Error::Unsupported(what) => write!(f, "unsupported: {what}"),
Error::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for Error {}
impl From<String> for Error {
fn from(msg: String) -> Self {
Error::Other(msg)
}
}
impl From<&str> for Error {
fn from(msg: &str) -> Self {
Error::Other(msg.to_string())
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Curve fitters: turn a region's pixel mask into vector outlines.
//!
//! The three built-ins wrap the corresponding visioncortex tracing modes and
//! emit our [`MultiPath`] IR in absolute (document) coordinates:
//!
//! * [`PixelFitter`] — exact lattice polyline (no simplification).
//! * [`PolygonFitter`] — staircase-symmetric DouglasPeucker polygon.
//! * [`SplineFitter`] — subdivision + corner detection + least-squares cubics.
//!
//! All three trace *closed* region outlines (outer ring plus holes). Open
//! polyline fitting (needed for the mosaic compositor) will arrive with that
//! milestone.
use visioncortex::clusters::Cluster as BinaryCluster;
use visioncortex::{
CompoundPath, CompoundPathElement, PathSimplifyMode, PointF64, PointI32,
};
use crate::ir::{MultiPath, PathCmd, RegionMask, SubPath};
/// Fitting parameters shared by the built-in fitters. Only the spline fitter
/// consults the smoothing/splice fields.
#[derive(Debug, Clone, Copy)]
pub struct FitParams {
/// Minimum momentary angle (radians) to be considered a corner.
pub corner_threshold: f64,
/// Subdivide until all segments are shorter than this length (px).
pub length_threshold: f64,
/// Maximum smoothing iterations.
pub max_iterations: usize,
/// Minimum angle displacement (radians) to splice a spline.
pub splice_threshold: f64,
}
impl Default for FitParams {
fn default() -> Self {
Self {
corner_threshold: std::f64::consts::PI / 3.0, // 60°
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: std::f64::consts::PI / 4.0, // 45°
}
}
}
/// A curve fitter traces a region mask into closed vector outlines.
pub trait CurveFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath;
}
/// Exact lattice polyline; every pixel-boundary step is preserved.
#[derive(Debug, Clone, Default)]
pub struct PixelFitter;
impl CurveFitter for PixelFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
trace_region(mask, PathSimplifyMode::None, FitParams::default())
}
}
/// DouglasPeucker polygon with staircase removal.
#[derive(Debug, Clone, Default)]
pub struct PolygonFitter;
impl CurveFitter for PolygonFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
trace_region(mask, PathSimplifyMode::Polygon, FitParams::default())
}
}
/// Smoothed spline (cubic Bézier) fitter.
#[derive(Debug, Clone, Default)]
pub struct SplineFitter {
pub params: FitParams,
}
impl SplineFitter {
pub fn new(params: FitParams) -> Self {
Self { params }
}
}
impl CurveFitter for SplineFitter {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
trace_region(mask, PathSimplifyMode::Spline, self.params)
}
}
/// Trace every connected component of a masked region and merge the resulting
/// outlines into a single [`MultiPath`] in absolute coordinates.
///
/// This mirrors visioncortex's `Cluster::to_compound_path`: the mask (with
/// holes already punched) is split into connected sub-clusters, each traced
/// independently, then offset into document space.
fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) -> MultiPath {
let mut multi = MultiPath::new();
for sub in mask.image.to_clusters(false).iter() {
let offset = PointI32 {
x: mask.offset.x + sub.rect.left,
y: mask.offset.y + sub.rect.top,
};
let compound = BinaryCluster::image_to_compound_path(
&offset,
&sub.to_binary_image(),
mode,
params.corner_threshold,
params.length_threshold,
params.max_iterations,
params.splice_threshold,
);
append_compound(&mut multi, &compound);
}
multi
}
fn append_compound(multi: &mut MultiPath, compound: &CompoundPath) {
for element in compound.iter() {
match element {
CompoundPathElement::PathI32(p) => {
let pts: Vec<PointF64> = p
.path
.iter()
.map(|q| PointF64 {
x: q.x as f64,
y: q.y as f64,
})
.collect();
multi.push(polyline_subpath(&pts));
}
CompoundPathElement::PathF64(p) => {
multi.push(polyline_subpath(&p.path));
}
CompoundPathElement::Spline(s) => {
multi.push(spline_subpath(&s.points));
}
}
}
}
/// A closed polyline whose last point repeats the first becomes
/// `MoveTo · LineTo* · Close`.
fn polyline_subpath(points: &[PointF64]) -> SubPath {
let mut sub = SubPath::new();
if points.len() < 2 {
return sub;
}
// The tracer emits closed paths whose final point duplicates the first.
let closed = points.first() == points.last();
let body_end = if closed { points.len() - 1 } else { points.len() };
sub.commands.push(PathCmd::MoveTo(points[0]));
for p in &points[1..body_end] {
sub.commands.push(PathCmd::LineTo(*p));
}
sub.commands.push(PathCmd::Close);
sub
}
/// A spline of `1 + 3n` points becomes `MoveTo · CubicTo* · Close`.
fn spline_subpath(points: &[PointF64]) -> SubPath {
let mut sub = SubPath::new();
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
return sub;
}
sub.commands.push(PathCmd::MoveTo(points[0]));
let mut i = 1;
while i + 2 < points.len() {
sub.commands
.push(PathCmd::CubicTo(points[i], points[i + 1], points[i + 2]));
i += 3;
}
sub.commands.push(PathCmd::Close);
sub
}
+63
View File
@@ -0,0 +1,63 @@
use visioncortex::{Color, ColorImage, PointI32};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::Frontend;
/// Binary (black/white) frontend: threshold the image then cluster the
/// foreground. Every region is painted black.
#[derive(Debug, Clone)]
pub struct BinaryFrontend {
/// Discard clusters smaller than this many pixels.
pub filter_speckle_area: usize,
/// A pixel is foreground when its red channel is below this threshold.
pub threshold: u8,
/// Whether to connect clusters diagonally.
pub diagonal: bool,
}
impl Default for BinaryFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
threshold: 128,
diagonal: false,
}
}
}
impl Frontend for BinaryFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
if img.width == 0 || img.height == 0 {
return Err(Error::EmptyImage);
}
let width = img.width;
let height = img.height;
let threshold = self.threshold;
let binary = img.to_binary_image(|c| c.r < threshold);
let clusters = binary.to_clusters(self.diagonal);
let mut seg = Segmentation::new(width as u32, height as u32);
let black = Color::new(0, 0, 0);
for i in 0..clusters.len() {
let cluster = clusters.get_cluster(i);
if cluster.size() >= self.filter_speckle_area {
let mask = RegionMask::new(
cluster.to_binary_image(),
PointI32 {
x: cluster.rect.left,
y: cluster.rect.top,
},
);
seg.layers.push(Layer {
paint: Paint::Solid(black),
mask,
});
}
}
Ok(seg)
}
}
@@ -0,0 +1,97 @@
use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX};
use visioncortex::{Color, ColorImage, PointI32};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::keying::{apply_key, find_unused_color, should_key_image};
use super::Frontend;
/// Hierarchical color-clustering frontend — the classic VTracer color path.
#[derive(Debug, Clone)]
pub struct ColorClusterFrontend {
/// Discard clusters smaller than this many pixels.
pub filter_speckle_area: usize,
/// Bits of color precision dropped when comparing pixels (0 = full 8-bit).
pub color_precision_loss: i32,
/// Color difference between hierarchical gradient layers.
pub layer_difference: i32,
}
impl Default for ColorClusterFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
color_precision_loss: 2,
layer_difference: 16,
}
}
}
impl Frontend for ColorClusterFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
if img.width == 0 || img.height == 0 {
return Err(Error::EmptyImage);
}
let width = img.width;
let height = img.height;
let mut img = img.clone();
// Transparency keying (stacked mode discards the keyed background).
let key_color = if should_key_image(&img) {
let key = find_unused_color(&img)?;
apply_key(&mut img, key);
key
} else {
// All-zero is the sentinel understood by visioncortex as "no keying".
Color::default()
};
let runner = Runner::new(
RunnerConfig {
diagonal: self.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: self.filter_speckle_area,
good_max_area: width * height,
is_same_color_a: self.color_precision_loss,
is_same_color_b: 1,
deepen_diff: self.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: KeyingAction::Discard,
},
img,
);
let clusters = runner.run();
let view = clusters.view();
let mut seg = Segmentation::new(width as u32, height as u32);
// `clusters_output` is top-to-bottom; reverse to get bottom-to-top
// paint order for the layer stack.
for &cluster_index in view.clusters_output.iter().rev() {
let cluster = view.get_cluster(cluster_index);
// Solid cluster masks (no holes punched): stacked mode relies on
// paint-order overdraw for occlusion, matching 0.6.x. Punching
// holes here would leave the layer below exposed as hairline seams.
// The mosaic flatten is unaffected — a higher layer still wins per
// pixel — so a solid parent gives the same partition.
let image = cluster.to_image_with_hole(view.width, false);
let mask = RegionMask::new(
image,
PointI32 {
x: cluster.rect.left,
y: cluster.rect.top,
},
);
seg.layers.push(Layer {
paint: Paint::Solid(cluster.residue_color()),
mask,
});
}
Ok(seg)
}
}
+105
View File
@@ -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);
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Frontends: algorithms that turn a raster image into a [`Segmentation`].
//!
//! Built-ins:
//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic
//! VTracer color path), including transparency keying.
//! * [`BinaryFrontend`] — threshold to black/white then cluster.
//!
//! Third parties can implement [`Frontend`] to feed external label maps or ML
//! segmentation into the pipeline.
mod binary;
mod color_cluster;
mod keying;
pub use binary::BinaryFrontend;
pub use color_cluster::ColorClusterFrontend;
use visioncortex::ColorImage;
use crate::error::Error;
use crate::ir::Segmentation;
/// A frontend segments a raster image into ordered paint layers.
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
}
+34
View File
@@ -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,
}
}
}
+100
View File
@@ -0,0 +1,100 @@
use visioncortex::{BinaryImage, PointI32};
use super::Paint;
/// A region's pixel coverage: a local binary mask positioned on the canvas.
///
/// Foreground pixels are `true`. Holes (interior background) are already
/// punched out of the mask, so a mask is self-describing for tracing.
#[derive(Debug, Clone)]
pub struct RegionMask {
/// Local coverage; `true` = inside the region.
pub image: BinaryImage,
/// Position of the mask's top-left corner in full-canvas coordinates.
pub offset: PointI32,
}
impl RegionMask {
pub fn new(image: BinaryImage, offset: PointI32) -> Self {
Self { image, offset }
}
pub fn width(&self) -> usize {
self.image.width
}
pub fn height(&self) -> usize {
self.image.height
}
/// Number of foreground pixels.
pub fn area(&self) -> usize {
let mut count = 0;
for y in 0..self.image.height {
for x in 0..self.image.width {
if self.image.get_pixel(x, y) {
count += 1;
}
}
}
count
}
/// Combine two masks into one covering the union of their bounding boxes.
/// Foreground is the OR of both; this is used by the layer-merge step.
pub fn union(&self, other: &RegionMask) -> RegionMask {
let left = self.offset.x.min(other.offset.x);
let top = self.offset.y.min(other.offset.y);
let right = (self.offset.x + self.image.width as i32)
.max(other.offset.x + other.image.width as i32);
let bottom = (self.offset.y + self.image.height as i32)
.max(other.offset.y + other.image.height as i32);
let width = (right - left) as usize;
let height = (bottom - top) as usize;
let mut image = BinaryImage::new_w_h(width, height);
for src in [self, other] {
for y in 0..src.image.height {
for x in 0..src.image.width {
if src.image.get_pixel(x, y) {
let gx = (src.offset.x + x as i32 - left) as usize;
let gy = (src.offset.y + y as i32 - top) as usize;
image.set_pixel(gx, gy, true);
}
}
}
}
RegionMask::new(image, PointI32 { x: left, y: top })
}
}
/// A single paint layer. Layers are painted bottom-to-top.
#[derive(Debug, Clone)]
pub struct Layer {
/// Fill applied to the region. Starts as the cluster's mean color; a
/// [`crate::colorfit::ColorFitter`] may rewrite it.
pub paint: Paint,
/// Pixel coverage of the region.
pub mask: RegionMask,
}
/// Frontend output: ordered layers over a canvas, in paint order.
#[derive(Debug, Clone)]
pub struct Segmentation {
pub width: u32,
pub height: u32,
/// Bottom-to-top paint order.
pub layers: Vec<Layer>,
}
impl Segmentation {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
layers: Vec::new(),
}
}
}
+90
View File
@@ -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(),
}
}
}
+49
View File
@@ -0,0 +1,49 @@
//! # vtracer
//!
//! A vectorization *framework*: raster images become vector graphics through a
//! pipeline of pluggable stages.
//!
//! ```text
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ VectorDoc
//! │
//! OptimizerPass* ─────┤
//! ▼
//! SvgWriter ─▶ SVG
//! ```
//!
//! The crate is wasm-safe: it performs no file or image I/O (that lives in the
//! `vtracer-cli` wrapper). Everything here compiles to
//! `wasm32-unknown-unknown`.
//!
//! ## Quick start
//!
//! ```no_run
//! use vtracer::{Config, ColorImage};
//!
//! # fn load() -> ColorImage { todo!() }
//! let img: ColorImage = load();
//! let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
//! ```
//!
//! For finer control, assemble a [`Pipeline`] directly from the stage traits
//! in [`frontend`], [`colorfit`], [`fitter`], [`compose`], [`optimize`], and
//! [`svg`].
pub mod colorfit;
pub mod compose;
pub mod config;
pub mod error;
pub mod fitter;
pub mod frontend;
pub mod ir;
pub mod mosaic;
pub mod optimize;
pub mod pipeline;
pub mod svg;
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
pub use error::Error;
pub use pipeline::Pipeline;
// Re-export the visioncortex value types callers need at the boundary.
pub use visioncortex::{Color, ColorImage, PointF64, PointI32};
+112
View File
@@ -0,0 +1,112 @@
//! Stage 4: compose per-region SVG paths from shared fitted segments.
//!
//! Each region becomes one shape whose `d` concatenates its contours as
//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each
//! oriented segment is emitted skipping its first point (identical to the
//! previous segment's last point), so shared boundaries are byte-identical on
//! both sides.
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use visioncortex::PointF64;
use super::face::{assemble, Contour, Face};
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
use super::graph::BoundaryGraph;
use super::{LabelMap, Segmentation};
/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose.
pub fn compose_mosaic(seg: &Segmentation, fitter: &dyn SegmentFitter) -> VectorDoc {
let map = LabelMap::from_segmentation(seg);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
// Fit every segment exactly once; both adjacent faces share the result.
let fitted: Vec<FittedSegment> = graph
.segments
.iter()
.map(|s| {
if s.is_ring() {
fitter.fit_ring(s)
} else {
fitter.fit_open(s)
}
})
.collect();
let mut doc = VectorDoc::new(seg.width, seg.height);
for face in &faces {
let path = build_path(face, &fitted, &graph);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: map.paints[face.region as usize],
path,
});
}
}
doc
}
fn build_path(face: &Face, fitted: &[FittedSegment], _graph: &BoundaryGraph) -> MultiPath {
let mut mp = MultiPath::new();
for contour in &face.contours {
let mut sub = SubPath::new();
emit_contour(contour, fitted, &mut sub);
if !sub.is_empty() {
sub.commands.push(PathCmd::Close);
mp.subpaths.push(sub);
}
}
mp
}
fn emit_contour(contour: &Contour, fitted: &[FittedSegment], sub: &mut SubPath) {
for (i, sref) in contour.0.iter().enumerate() {
let geom = &fitted[sref.seg as usize].geom;
emit_segment(geom, sref.forward, i == 0, sub);
}
}
/// Append one oriented segment's commands. When `first`, opens with a `MoveTo`;
/// otherwise the leading point (shared with the previous segment) is skipped.
fn emit_segment(geom: &FittedGeom, forward: bool, first: bool, sub: &mut SubPath) {
match geom {
FittedGeom::Polyline(pts) => {
if pts.len() < 2 {
return;
}
let ordered: Vec<PointF64> = if forward {
pts.clone()
} else {
pts.iter().rev().copied().collect()
};
if first {
sub.commands.push(PathCmd::MoveTo(ordered[0]));
}
for p in &ordered[1..] {
sub.commands.push(PathCmd::LineTo(*p));
}
}
FittedGeom::Beziers(curves) => {
if curves.is_empty() {
return;
}
// Reversing a cubic is exact: [p0,p1,p2,p3] -> [p3,p2,p1,p0], and
// the whole chain reverses in order too.
let ordered: Vec<[PointF64; 4]> = if forward {
curves.clone()
} else {
curves
.iter()
.rev()
.map(|c| [c[3], c[2], c[1], c[0]])
.collect()
};
if first {
sub.commands.push(PathCmd::MoveTo(ordered[0][0]));
}
for c in &ordered {
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Stage 2: face assembly.
//!
//! Lift the "region kept on the left" successor rule from unit edges to whole
//! segments. Following it around each region yields its contours; because the
//! interior is always on the left, outer contours and hole contours come out
//! with opposite winding automatically — no containment/nesting computation is
//! needed, and the region can be filled with a single `nonzero` path.
use super::graph::{
edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef,
};
use super::{LabelMap, RegionId, OUTSIDE};
/// A closed cycle of directed segments bounding (part of) a region.
#[derive(Clone, Debug)]
pub struct Contour(pub Vec<SegRef>);
/// One region and all of its contours (outer + holes).
#[derive(Clone, Debug)]
pub struct Face {
pub region: RegionId,
pub contours: Vec<Contour>,
}
/// Left region of a directed segment view.
fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId {
let seg = &graph.segments[r.seg as usize];
if r.forward {
seg.left
} else {
seg.right
}
}
/// Pick the next unit direction leaving `corner`, keeping region `r` on the
/// left: sharpest right turn first (this pinches checkerboard nodes and keeps
/// contours simple).
fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
for &d in &[turn_right(d_in), straight(d_in), turn_left(d_in)] {
if edge_present(map, x, y, d) && left_pixel_at(map, x, y, d) == r {
return d;
}
}
unreachable!("no successor edge keeps the region on the left");
}
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
let mut by_region: Vec<Vec<Contour>> = vec![Vec::new(); map.paints.len()];
// usage[seg][0] = forward view used, [1] = backward view used.
let mut used = vec![[false; 2]; graph.segments.len()];
for seg_id in 0..graph.segments.len() {
if graph.segments[seg_id].is_ring() {
continue;
}
for &forward in &[true, false] {
let start = SegRef {
seg: seg_id as u32,
forward,
};
let region = left_region(graph, start);
if region == OUTSIDE || used[seg_id][forward as usize] {
continue;
}
let mut contour = Vec::new();
let mut cur = start;
loop {
used[cur.seg as usize][cur.forward as usize] = true;
contour.push(cur);
let seg = &graph.segments[cur.seg as usize];
let (node_id, d_in) = if cur.forward {
(seg.end.unwrap(), seg.last_dir)
} else {
(seg.start.unwrap(), reverse(seg.first_dir))
};
let corner = graph.nodes[node_id as usize].corner;
let d_next = successor(map, corner.x, corner.y, d_in, region);
cur = graph.nodes[node_id as usize].out[d_next as usize]
.expect("successor direction must have an outgoing segment");
if cur == start {
break;
}
}
if (region as usize) < by_region.len() {
by_region[region as usize].push(Contour(contour));
}
}
}
// Rings: the left side uses it forward, the right side reversed.
for seg_id in 0..graph.segments.len() {
let seg = &graph.segments[seg_id];
if !seg.is_ring() {
continue;
}
if seg.left != OUTSIDE && (seg.left as usize) < by_region.len() {
by_region[seg.left as usize].push(Contour(vec![SegRef {
seg: seg_id as u32,
forward: true,
}]));
}
if seg.right != OUTSIDE && (seg.right as usize) < by_region.len() {
by_region[seg.right as usize].push(Contour(vec![SegRef {
seg: seg_id as u32,
forward: false,
}]));
}
}
by_region
.into_iter()
.enumerate()
.filter(|(_, c)| !c.is_empty())
.map(|(region, contours)| Face {
region: region as RegionId,
contours,
})
.collect()
}
+264
View File
@@ -0,0 +1,264 @@
//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes.
//!
//! A segment is fitted a single time and cached; both adjacent faces reference
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
//! the shared geometry is bitwise identical and no seam can appear.
use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideSmooth};
use super::graph::Segment;
/// Outset ratio for the 4-point subdivision scheme (matches visioncortex).
const OUTSET_RATIO: f64 = 8.0;
/// Fitted geometry for one boundary segment.
#[derive(Clone, Debug)]
pub enum FittedGeom {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
/// A fitted segment, cached and indexed by segment id.
#[derive(Clone, Debug)]
pub struct FittedSegment {
pub geom: FittedGeom,
}
/// Fits a single boundary segment. `fit_open` pins both endpoints (junction
/// nodes must not move); `fit_ring` fits a closed loop with no pinned point.
pub trait SegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment;
fn fit_ring(&self, seg: &Segment) -> FittedSegment;
}
fn to_f64(points: &[PointI32]) -> Vec<PointF64> {
points
.iter()
.map(|p| PointF64 {
x: p.x as f64,
y: p.y as f64,
})
.collect()
}
/// Identity fitter: lattice points as f64. Produces an exact tessellation and
/// is the reference backend for tests.
#[derive(Debug, Clone, Default)]
pub struct PixelSegmentFitter;
impl SegmentFitter for PixelSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
}
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
}
}
}
/// Straight-segment fitter. Uses visioncortex's symmetric `limit_penalties`
/// simplification, which collapses 1px staircases toward the crack midline
/// (centered, no directional outset) so the boundary stays gapless. Endpoints
/// are preserved, pinning junction nodes.
#[derive(Debug, Clone, Default)]
pub struct PolygonSegmentFitter;
impl PolygonSegmentFitter {
fn fit(&self, seg: &Segment) -> FittedSegment {
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
FittedSegment {
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
}
}
}
impl SegmentFitter for PolygonSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
self.fit(seg)
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
self.fit(seg)
}
}
/// Smooth (cubic-Bézier) open-path fitter — the mosaic analogue of the stacked
/// [`crate::fitter::SplineFitter`], but for open segments with pinned
/// endpoints.
///
/// Staircase removal reuses visioncortex's symmetric `limit_penalties`
/// simplification (the same de-noising stacked mode applies), which collapses
/// staircases toward the crack midline. Unlike `remove_staircase`, it has no
/// directional outset, so the boundary stays centered (≤√2/2 px from its
/// crack) and cannot cross a non-adjacent segment — the tessellation stays
/// gapless. A distance-based DP can't do this: near the √2/2 threshold it
/// can't separate staircase noise from real curvature. Smoothing and per-slice
/// cubic fitting then reuse the same visioncortex machinery stacked mode uses
/// (open-path variants of the smoothing primitives + `fit_points_with_bezier`),
/// so the curve character matches stacked.
#[derive(Debug, Clone)]
pub struct SplineSegmentFitter {
/// Corner angle threshold, radians.
pub corner_threshold: f64,
/// Subdivide until segments are shorter than this (px).
pub length_threshold: f64,
pub max_iterations: usize,
/// Splice angle threshold, radians.
pub splice_threshold: f64,
}
impl Default for SplineSegmentFitter {
fn default() -> Self {
Self {
corner_threshold: std::f64::consts::PI / 3.0,
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: std::f64::consts::PI / 4.0,
}
}
}
fn pt(p: PointI32) -> PointF64 {
PointF64 {
x: p.x as f64,
y: p.y as f64,
}
}
/// A degenerate cubic tracing the straight line `a`→`b`.
fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] {
let c1 = PointF64 {
x: a.x + (b.x - a.x) / 3.0,
y: a.y + (b.y - a.y) / 3.0,
};
let c2 = PointF64 {
x: a.x + 2.0 * (b.x - a.x) / 3.0,
y: a.y + 2.0 * (b.y - a.y) / 3.0,
};
[a, c1, c2, b]
}
/// Error bound for the per-slice cubic fit. Matches the value stacked mode
/// uses in `Spline::from_path_f64`, so mosaic curves have the same character.
const FIT_ERROR: f64 = 10.0;
/// Fit one splice slice into a single cubic, exactly as stacked mode does
/// (`fit_points_with_bezier`: one retract-handled cubic per slice, endpoints
/// pinned to the slice ends).
fn fit_slice(slice: &[PointF64], out: &mut Vec<[PointF64; 4]>) {
match slice.len() {
0 | 1 => {}
2 => out.push(straight_cubic(slice[0], slice[1])),
_ => out.push(SubdivideSmooth::fit_points_with_bezier(slice, FIT_ERROR)),
}
}
fn spline_to_beziers(spline: &Spline) -> Vec<[PointF64; 4]> {
spline
.get_control_points()
.into_iter()
.filter(|w| w.len() == 4)
.map(|w| [w[0], w[1], w[2], w[3]])
.collect()
}
impl SegmentFitter for SplineSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
if seg.points.len() <= 2 {
return FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
};
}
// 1. Staircase removal via visioncortex's `limit_penalties` — the
// symmetric (area-based, no directional outset) simplifier stacked
// mode runs after remove_staircase. Used alone here it collapses
// staircases toward the crack midline, so the boundary stays
// centered and cannot cross a non-adjacent segment (which would
// open a gap in the tessellation). Endpoints are preserved.
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
if simplified.len() <= 2 {
return FittedSegment {
geom: FittedGeom::Polyline(simplified.path.iter().copied().map(pt).collect()),
};
}
// 2. Corner detection (open, endpoints forced as corners).
let mut corners = SubdivideSmooth::find_corners(&simplified, self.corner_threshold, false);
// 3. Open 4-point subdivision.
let mut path = simplified.to_path_f64();
for _ in 0..self.max_iterations {
let (np, nc, done) = SubdivideSmooth::subdivide_keep_corners(
&path,
&corners,
OUTSET_RATIO,
self.length_threshold,
false,
);
path = np;
corners = nc;
if done {
break;
}
}
// 4. Splice points (open, endpoints forced).
let splice = SubdivideSmooth::find_splice_points(&path, self.splice_threshold, false);
let cuts: Vec<usize> = splice
.iter()
.enumerate()
.filter_map(|(i, &s)| if s { Some(i) } else { None })
.collect();
// 5. Per-slice cubic fit.
let mut beziers = Vec::new();
for w in cuts.windows(2) {
fit_slice(&path.path[w[0]..=w[1]], &mut beziers);
}
if beziers.is_empty() {
return FittedSegment {
geom: FittedGeom::Polyline(path.path.clone()),
};
}
// Pin the segment's endpoints exactly to the lattice nodes so that
// segments meeting at a junction share identical coordinates.
beziers.first_mut().unwrap()[0] = pt(seg.points[0]);
beziers.last_mut().unwrap()[3] = pt(seg.points[seg.points.len() - 1]);
FittedSegment {
geom: FittedGeom::Beziers(beziers),
}
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
// Rings are closed loops — this is exactly the stacked closed-spline
// pipeline (simplify → smooth → fit).
if seg.points.len() <= 4 {
return FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
};
}
let simplified = PathSimplify::limit_penalties(&PathI32::from_points(seg.points.clone()));
let smoothed = simplified.smooth(
self.corner_threshold,
OUTSET_RATIO,
self.length_threshold,
self.max_iterations,
);
let spline = Spline::from_path_f64(&smoothed, self.splice_threshold);
let beziers = spline_to_beziers(&spline);
if beziers.is_empty() {
return FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
};
}
FittedSegment {
geom: FittedGeom::Beziers(beziers),
}
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Stage 1: boundary-graph extraction from a [`LabelMap`].
//!
//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`.
//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run
//! along the "cracks" between differing labels.
use visioncortex::PointI32;
use super::{LabelMap, RegionId, OUTSIDE};
pub type NodeId = u32;
pub type SegId = u32;
// Unit directions, arranged clockwise in y-down screen space so that
// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal.
const N: u8 = 0;
const E: u8 = 1;
const S: u8 = 2;
const W: u8 = 3;
/// (dx, dy) per direction.
const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
#[inline]
pub(super) fn turn_right(d: u8) -> u8 {
(d + 1) % 4
}
#[inline]
pub(super) fn straight(d: u8) -> u8 {
d
}
#[inline]
pub(super) fn turn_left(d: u8) -> u8 {
(d + 3) % 4
}
#[inline]
pub(super) fn reverse(d: u8) -> u8 {
(d + 2) % 4
}
/// A directed reference to a segment: either traversed forward or reversed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SegRef {
pub seg: SegId,
pub forward: bool,
}
/// A junction corner (degree ≥ 3) with the segment leaving it in each unit
/// direction (if any).
#[derive(Clone, Debug)]
pub struct Node {
pub corner: PointI32,
pub out: [Option<SegRef>; 4],
}
/// A maximal boundary chain between two nodes, or a nodeless ring.
#[derive(Clone, Debug)]
pub struct Segment {
/// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`.
pub points: Vec<PointI32>,
pub start: Option<NodeId>,
pub end: Option<NodeId>,
/// Region on the left when traversing forward (y-down convention).
pub left: RegionId,
pub right: RegionId,
/// Direction of the first edge (leaving `start`); unused for rings.
pub first_dir: u8,
/// Direction of the last edge (arriving at `end`); unused for rings.
pub last_dir: u8,
}
impl Segment {
pub fn is_ring(&self) -> bool {
self.start.is_none()
}
}
/// The extracted boundary graph. Faces are assembled separately (see `face`).
pub struct BoundaryGraph {
pub nodes: Vec<Node>,
pub segments: Vec<Segment>,
}
struct Extractor<'a> {
map: &'a LabelMap,
w: i32,
h: i32,
/// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1).
node_at: Vec<NodeId>,
/// Visited flags for undirected unit edges.
visited_v: Vec<bool>, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x
visited_h: Vec<bool>, // horizontal edge (x in 0..W, y in 0..=H): y*W + x
nodes: Vec<Node>,
segments: Vec<Segment>,
}
impl<'a> Extractor<'a> {
fn new(map: &'a LabelMap) -> Self {
let w = map.width as i32;
let h = map.height as i32;
let cw = (map.width + 1) as usize;
let ch = (map.height + 1) as usize;
Extractor {
map,
w,
h,
node_at: vec![u32::MAX; cw * ch],
visited_v: vec![false; (map.width as usize + 1) * map.height as usize],
visited_h: vec![false; map.width as usize * (map.height as usize + 1)],
nodes: Vec::new(),
segments: Vec::new(),
}
}
#[inline]
fn corner_index(&self, x: i32, y: i32) -> usize {
y as usize * (self.w as usize + 1) + x as usize
}
/// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`.
fn edge_mask(&self, x: i32, y: i32) -> u8 {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
let mut m = 0u8;
if nw != ne {
m |= 1 << N;
}
if ne != se {
m |= 1 << E;
}
if sw != se {
m |= 1 << S;
}
if nw != sw {
m |= 1 << W;
}
m
}
/// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`.
fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
match d {
N => (nw, ne),
E => (ne, se),
S => (se, sw),
W => (sw, nw),
_ => unreachable!(),
}
}
/// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`.
/// Returns the canonical (is_vertical, index).
fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) {
match d {
N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize),
S => (true, y as usize * (self.w as usize + 1) + x as usize),
E => (false, y as usize * self.w as usize + x as usize),
W => (false, y as usize * self.w as usize + (x - 1) as usize),
_ => unreachable!(),
}
}
fn is_visited(&self, x: i32, y: i32, d: u8) -> bool {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i]
} else {
self.visited_h[i]
}
}
fn mark_visited(&mut self, x: i32, y: i32, d: u8) {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i] = true;
} else {
self.visited_h[i] = true;
}
}
/// Pass A — classify corners and allocate node ids for degree ≥ 3.
fn classify(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let deg = self.edge_mask(x, y).count_ones();
if deg >= 3 {
let id = self.nodes.len() as NodeId;
self.nodes.push(Node {
corner: PointI32 { x, y },
out: [None; 4],
});
let ci = self.corner_index(x, y);
self.node_at[ci] = id;
}
}
}
}
fn node_id(&self, x: i32, y: i32) -> Option<NodeId> {
let id = self.node_at[self.corner_index(x, y)];
if id == u32::MAX {
None
} else {
Some(id)
}
}
/// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to
/// the start). Returns the polyline, the final heading, and the corner
/// walked to. Marks every traversed edge visited.
fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec<PointI32>, u8, i32, i32) {
let mut points = vec![PointI32 { x: x0, y: y0 }];
let (mut cx, mut cy, mut d) = (x0, y0, d0);
loop {
self.mark_visited(cx, cy, d);
let (dx, dy) = DVEC[d as usize];
let (nx, ny) = (cx + dx, cy + dy);
points.push(PointI32 { x: nx, y: ny });
let mask = self.edge_mask(nx, ny);
if mask.count_ones() >= 3 {
return (points, d, nx, ny); // reached a node
}
if nx == x0 && ny == y0 {
return (points, d, nx, ny); // closed ring
}
// Degree-2: continue via the unique present edge that is not the
// reverse of how we arrived.
let rev = reverse(d);
let mut nd = d;
for cand in 0..4u8 {
if cand != rev && (mask & (1 << cand)) != 0 {
nd = cand;
break;
}
}
d = nd;
cx = nx;
cy = ny;
}
}
/// Pass B — trace node-to-node segments.
fn trace_segments(&mut self) {
let node_corners: Vec<PointI32> = self.nodes.iter().map(|n| n.corner).collect();
for (nid, corner) in node_corners.iter().enumerate() {
let nid = nid as NodeId;
let (x, y) = (corner.x, corner.y);
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, last_dir, ex, ey) = self.walk(x, y, d);
let end = self
.node_id(ex, ey)
.expect("segment must end at a node");
let seg_id = self.segments.len() as SegId;
self.segments.push(Segment {
points,
start: Some(nid),
end: Some(end),
left,
right,
first_dir: d,
last_dir,
});
self.nodes[nid as usize].out[d as usize] = Some(SegRef {
seg: seg_id,
forward: true,
});
// Leaving the end node backward along this segment.
let back = reverse(last_dir);
self.nodes[end as usize].out[back as usize] = Some(SegRef {
seg: seg_id,
forward: false,
});
}
}
}
/// Pass C — closed rings from any remaining unvisited boundary edges.
fn trace_rings(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, _last, _ex, _ey) = self.walk(x, y, d);
self.segments.push(Segment {
points,
start: None,
end: None,
left,
right,
first_dir: d,
last_dir: 0,
});
}
}
}
}
}
impl BoundaryGraph {
pub fn extract(map: &LabelMap) -> BoundaryGraph {
let mut ex = Extractor::new(map);
ex.classify();
ex.trace_segments();
ex.trace_rings();
BoundaryGraph {
nodes: ex.nodes,
segments: ex.segments,
}
}
}
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
/// face-assembly successor rule against a [`LabelMap`].
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
let nw = map.label(x - 1, y - 1);
let ne = map.label(x, y - 1);
let sw = map.label(x - 1, y);
let se = map.label(x, y);
match d {
N => nw,
E => ne,
S => se,
W => sw,
_ => OUTSIDE,
}
}
// Direction constants and edge-present test needed by face assembly.
pub(super) fn edge_present(map: &LabelMap, x: i32, y: i32, d: u8) -> bool {
let nw = map.label(x - 1, y - 1);
let ne = map.label(x, y - 1);
let sw = map.label(x - 1, y);
let se = map.label(x, y);
match d {
N => nw != ne,
E => ne != se,
S => sw != se,
W => nw != sw,
_ => false,
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Mosaic mode: a seam-free, gapless tessellation.
//!
//! Instead of tracing every region independently (which lets neighboring
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
//!
//! ```text
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
//! ```
//!
//! Every boundary curve exists exactly once; the two adjacent regions
//! reference the same fitted geometry, one traversed reversed. Reversal is
//! exact, so the serialized coordinates match on both sides — no seams.
//!
//! Stages 12 (graph + faces) are pure integer arithmetic on the lattice of
//! pixel corners. Only fitting (stage 3) is floating point.
mod compose;
mod face;
mod fit;
mod graph;
pub use compose::compose_mosaic;
pub use fit::{
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
use crate::ir::{Paint, Segmentation};
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
pub type RegionId = u32;
/// Sentinel label for pixels outside any region.
pub const OUTSIDE: RegionId = u32::MAX;
/// A flat partition of the canvas: one region id per pixel, plus the paint for
/// each region. This is the sole input to the boundary-graph extractor.
#[derive(Debug, Clone)]
pub struct LabelMap {
pub width: u32,
pub height: u32,
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
pub labels: Vec<RegionId>,
/// Paint per region, indexed by label.
pub paints: Vec<Paint>,
}
impl LabelMap {
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
/// pixel takes the paint of the topmost layer covering it. Layers are
/// bottom-to-top, so painting them in order lets higher layers win.
pub fn from_segmentation(seg: &Segmentation) -> Self {
let w = seg.width as usize;
let h = seg.height as usize;
let mut labels = vec![OUTSIDE; w * h];
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
for (i, layer) in seg.layers.iter().enumerate() {
let mask = &layer.mask;
for ly in 0..mask.image.height {
for lx in 0..mask.image.width {
if mask.image.get_pixel(lx, ly) {
let gx = mask.offset.x + lx as i32;
let gy = mask.offset.y + ly as i32;
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
labels[gy as usize * w + gx as usize] = i as RegionId;
}
}
}
}
}
LabelMap {
width: seg.width,
height: seg.height,
labels,
paints,
}
}
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
/// Treating outside as a real label removes all image-border special cases.
#[inline]
pub fn label(&self, x: i32, y: i32) -> RegionId {
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
return OUTSIDE;
}
self.labels[y as usize * self.width as usize + x as usize]
}
}
#[cfg(test)]
mod tests {
use super::face::{assemble, Face};
use super::graph::BoundaryGraph;
use super::*;
use crate::ir::Paint;
use visioncortex::{Color, PointF64};
/// Build a label map from a row-major grid (for tests).
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
let n = max.map(|m| m as usize + 1).unwrap_or(0);
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
LabelMap {
width,
height,
labels,
paints,
}
}
/// Reconstruct a face's contour polygons in exact lattice coordinates.
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
face.contours
.iter()
.map(|contour| {
let mut ring: Vec<PointF64> = Vec::new();
for (i, sref) in contour.0.iter().enumerate() {
let pts = &graph.segments[sref.seg as usize].points;
let ordered: Vec<PointF64> = if sref.forward {
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
} else {
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
};
if i == 0 {
ring.extend(ordered);
} else {
ring.extend(ordered[1..].iter().copied());
}
}
ring
})
.collect()
}
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Winding number of point `p` w.r.t. a closed ring (last == first).
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
let mut wn = 0;
for w in ring.windows(2) {
let (a, b) = (w[0], w[1]);
if a.y <= p.y {
if b.y > p.y && is_left(a, b, p) > 0.0 {
wn += 1;
}
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
wn -= 1;
}
}
wn
}
/// The strongest guarantee: rasterize the composed faces at pixel centers
/// and assert the result is byte-identical to the input label map.
fn assert_pixel_roundtrip(map: &LabelMap) {
let graph = BoundaryGraph::extract(map);
let faces = assemble(&graph, map);
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
.iter()
.map(|f| (f.region, face_polygons(&graph, f)))
.collect();
for y in 0..map.height as i32 {
for x in 0..map.width as i32 {
let center = PointF64 {
x: x as f64 + 0.5,
y: y as f64 + 0.5,
};
let mut hits: Vec<RegionId> = Vec::new();
for (region, rings) in &polys {
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
if wn != 0 {
hits.push(*region);
}
}
let expected = map.label(x, y);
if expected == OUTSIDE {
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
} else {
assert_eq!(
hits,
vec![expected],
"({x},{y}) expected region {expected}, got {hits:?}"
);
}
}
}
}
#[test]
fn single_region_is_one_ring() {
let map = grid(3, 2, vec![0; 6]);
let graph = BoundaryGraph::extract(&map);
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
assert_eq!(graph.segments.len(), 1, "one border ring");
assert!(graph.segments[0].is_ring());
assert_pixel_roundtrip(&map);
}
#[test]
fn vertical_split() {
// 4x2, left half 0, right half 1.
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
let graph = BoundaryGraph::extract(&map);
// Two border junctions where the split meets the top and bottom edges.
assert_eq!(graph.nodes.len(), 2);
assert_pixel_roundtrip(&map);
}
#[test]
fn t_junction() {
// top row one region, bottom row split — a degree-3 interior node.
let map = grid(2, 2, vec![0, 0, 1, 2]);
assert_pixel_roundtrip(&map);
}
#[test]
fn checkerboard_pinch() {
// A B / B A — the center corner is a degree-4 pinch; each region is two
// lobes touching there. (The four boundary/border corners are degree-3
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
// the real check that the pinch produces exact, simple contours.
let map = grid(2, 2, vec![0, 1, 1, 0]);
let graph = BoundaryGraph::extract(&map);
let has_degree4 = graph.nodes.iter().any(|n| {
let c = n.corner;
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
});
assert!(has_degree4, "expected a degree-4 pinch node at the center");
assert_pixel_roundtrip(&map);
}
#[test]
fn nested_rings() {
// Concentric squares: 0 outer, 1 middle, 2 center.
let l = |x: i32, y: i32| -> RegionId {
let d = x.min(y).min(5 - x).min(5 - y);
match d {
0 => 0,
1 => 1,
_ => 2,
}
};
let mut labels = Vec::new();
for y in 0..6 {
for x in 0..6 {
labels.push(l(x, y));
}
}
assert_pixel_roundtrip(&grid(6, 6, labels));
}
#[test]
fn outside_region_border_touching() {
// A region that does not fill the canvas; the rest is OUTSIDE.
let mut labels = vec![OUTSIDE; 16];
for y in 1..3 {
for x in 1..3 {
labels[y * 4 + x] = 0;
}
}
assert_pixel_roundtrip(&grid(4, 4, labels));
}
#[test]
fn spline_segments_pin_endpoints_to_lattice() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
// A shape with junctions so there are open (non-ring) segments.
let map = grid(4, 4, vec![
0, 0, 1, 1,
0, 0, 1, 1,
2, 2, 1, 1,
2, 2, 2, 2,
]);
let graph = BoundaryGraph::extract(&map);
let fitter = SplineSegmentFitter::default();
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
let fitted = fitter.fit_open(seg);
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
let end = {
let p = seg.points[seg.points.len() - 1];
PointF64 { x: p.x as f64, y: p.y as f64 }
};
match fitted.geom {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned to node");
assert_eq!(b.last().unwrap()[3], end, "end pinned to node");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
#[test]
fn random_maps_roundtrip() {
// Deterministic LCG; connectivity not required.
let mut state: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as u32
};
for _ in 0..40 {
let w = 2 + next() % 10;
let h = 2 + next() % 10;
let nlabels = 1 + next() % 5;
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
assert_pixel_roundtrip(&grid(w, h, labels));
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Optimizer passes over the [`VectorDoc`] before serialization.
//!
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
//! it here (rather than at write time) lets [`SimplifyPass`] act on the
//! rounded geometry, and it bakes offsets into coordinates so the writer
//! never needs a per-path `translate`.
//! * [`SimplifyPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created.
use visioncortex::PointF64;
use crate::ir::{MultiPath, PathCmd, SubPath, VectorDoc};
/// An optimizer pass rewrites the document in place.
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
/// Round all coordinates to `precision` decimal places.
#[derive(Debug, Clone, Copy)]
pub struct QuantizePass {
pub precision: u32,
}
impl QuantizePass {
pub fn new(precision: u32) -> Self {
Self { precision }
}
fn round(&self, v: f64) -> f64 {
let factor = 10f64.powi(self.precision as i32);
(v * factor).round() / factor
}
fn round_pt(&self, p: PointF64) -> PointF64 {
PointF64 {
x: self.round(p.x),
y: self.round(p.y),
}
}
}
impl OptimizerPass for QuantizePass {
fn run(&self, doc: &mut VectorDoc) {
for shape in &mut doc.shapes {
for sub in &mut shape.path.subpaths {
for cmd in &mut sub.commands {
*cmd = match *cmd {
PathCmd::MoveTo(p) => PathCmd::MoveTo(self.round_pt(p)),
PathCmd::LineTo(p) => PathCmd::LineTo(self.round_pt(p)),
PathCmd::CubicTo(c1, c2, e) => PathCmd::CubicTo(
self.round_pt(c1),
self.round_pt(c2),
self.round_pt(e),
),
PathCmd::Close => PathCmd::Close,
};
}
}
}
}
}
/// Remove zero-length segments and collinear-redundant line vertices.
#[derive(Debug, Clone, Copy, Default)]
pub struct SimplifyPass;
/// Tolerance for treating two points as coincident.
const COINCIDENT_EPS: f64 = 1e-6;
/// Perpendicular-distance tolerance for treating three points as collinear.
const COLLINEAR_EPS: f64 = 1e-4;
fn approx_eq(a: PointF64, b: PointF64) -> bool {
(a.x - b.x).abs() < COINCIDENT_EPS && (a.y - b.y).abs() < COINCIDENT_EPS
}
/// Perpendicular distance of `b` from the line through `a` and `c`.
fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool {
let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
let base = ((c.x - a.x).powi(2) + (c.y - a.y).powi(2)).sqrt();
if base < COINCIDENT_EPS {
return true;
}
(cross.abs() / base) < COLLINEAR_EPS
}
fn simplify_subpath(sub: &SubPath) -> SubPath {
let mut out = SubPath::new();
// `prev` is the point active before the last emitted command; `last` is the
// current point after it. Both are needed to test collinearity of a run.
let mut prev = PointF64::default();
let mut last = PointF64::default();
for cmd in &sub.commands {
match *cmd {
PathCmd::MoveTo(p) => {
out.commands.push(PathCmd::MoveTo(p));
prev = p;
last = p;
}
PathCmd::LineTo(p) => {
if approx_eq(last, p) {
continue; // zero-length
}
if let Some(PathCmd::LineTo(_)) = out.commands.last() {
if collinear(prev, last, p) {
*out.commands.last_mut().unwrap() = PathCmd::LineTo(p);
last = p; // anchor `prev` unchanged
continue;
}
}
out.commands.push(PathCmd::LineTo(p));
prev = last;
last = p;
}
PathCmd::CubicTo(c1, c2, e) => {
out.commands.push(PathCmd::CubicTo(c1, c2, e));
prev = last;
last = e;
}
PathCmd::Close => {
out.commands.push(PathCmd::Close);
}
}
}
out
}
impl OptimizerPass for SimplifyPass {
fn run(&self, doc: &mut VectorDoc) {
for shape in &mut doc.shapes {
let mut subpaths = Vec::with_capacity(shape.path.subpaths.len());
for sub in &shape.path.subpaths {
let simplified = simplify_subpath(sub);
// Keep only subpaths with real geometry (a MoveTo plus at least
// one drawing command beyond Close).
let draws = simplified
.commands
.iter()
.filter(|c| matches!(c, PathCmd::LineTo(_) | PathCmd::CubicTo(..)))
.count();
if draws > 0 {
subpaths.push(simplified);
}
}
shape.path = MultiPath { subpaths };
}
doc.shapes.retain(|s| !s.path.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{MultiPath, Paint, Shape};
use visioncortex::Color;
fn pt(x: f64, y: f64) -> PointF64 {
PointF64 { x, y }
}
fn doc_with(commands: Vec<PathCmd>) -> VectorDoc {
let mut doc = VectorDoc::new(100, 100);
doc.shapes.push(Shape {
paint: Paint::Solid(Color::new(0, 0, 0)),
path: MultiPath {
subpaths: vec![SubPath { commands }],
},
});
doc
}
#[test]
fn quantize_rounds_coordinates() {
let mut doc = doc_with(vec![
PathCmd::MoveTo(pt(1.234, 5.678)),
PathCmd::LineTo(pt(9.876, 0.001)),
PathCmd::Close,
]);
QuantizePass::new(1).run(&mut doc);
let cmds = &doc.shapes[0].path.subpaths[0].commands;
assert_eq!(cmds[0], PathCmd::MoveTo(pt(1.2, 5.7)));
assert_eq!(cmds[1], PathCmd::LineTo(pt(9.9, 0.0)));
}
#[test]
fn simplify_drops_collinear_and_zero_length() {
// A straight run of colinear points plus a duplicate should collapse.
let mut doc = doc_with(vec![
PathCmd::MoveTo(pt(0.0, 0.0)),
PathCmd::LineTo(pt(1.0, 0.0)),
PathCmd::LineTo(pt(2.0, 0.0)), // collinear with previous run
PathCmd::LineTo(pt(2.0, 0.0)), // zero-length
PathCmd::LineTo(pt(2.0, 5.0)),
PathCmd::Close,
]);
SimplifyPass.run(&mut doc);
let cmds = &doc.shapes[0].path.subpaths[0].commands;
// MoveTo, one merged horizontal LineTo, one vertical LineTo, Close.
assert_eq!(cmds.len(), 4);
assert_eq!(cmds[0], PathCmd::MoveTo(pt(0.0, 0.0)));
assert_eq!(cmds[1], PathCmd::LineTo(pt(2.0, 0.0)));
assert_eq!(cmds[2], PathCmd::LineTo(pt(2.0, 5.0)));
assert_eq!(cmds[3], PathCmd::Close);
}
}
+45
View File
@@ -0,0 +1,45 @@
//! The pipeline driver: composes the stages and runs an image through them.
use visioncortex::ColorImage;
use crate::colorfit::ColorFitter;
use crate::compose::Compositing;
use crate::error::Error;
use crate::frontend::Frontend;
use crate::ir::VectorDoc;
use crate::optimize::OptimizerPass;
use crate::svg::SvgWriter;
/// A fully-assembled vectorization pipeline. Build one with
/// [`crate::Config::build`], or construct it directly for full control.
pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
pub writer: SvgWriter,
}
impl Pipeline {
/// Run the pipeline to the output document IR (before serialization).
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
let mut seg = self.frontend.segment(img)?;
for fitter in &self.color_fitters {
fitter.fit(&mut seg);
}
let mut doc = self.compositing.compose(&seg);
for pass in &self.optimizers {
pass.run(&mut doc);
}
Ok(doc)
}
/// Run the pipeline and serialize the result to an SVG string.
pub fn to_svg(&self, img: &ColorImage) -> Result<String, Error> {
Ok(self.writer.write(&self.run(img)?))
}
}
+582
View File
@@ -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}"
);
}
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Rasterize-and-diff equivalence between stacked and mosaic (cutout) modes.
//!
//! Both modes render the *same* flattened partition of the image — stacked by
//! painting layers top-down, mosaic as a gapless tessellation. So their
//! rasterizations must agree in every region interior; they may differ only
//! within a thin band along region boundaries, where the two fitting paths
//! legitimately place the edge a fraction of a pixel apart. This test asserts
//! exactly that: any pixel that differs must lie within ~12px of a boundary.
//!
//! `resvg` is a dev-dependency, so this never enters a wasm build.
use resvg::{tiny_skia, usvg};
use vtracer::{ColorImage, Config, FitMode, Hierarchical};
/// A few smooth colored discs on a background — curved boundaries, limited
/// boundary length, no thin (1px) features.
fn blobs(w: usize, h: usize) -> ColorImage {
let discs = [
(28.0f64, 30.0, 18.0, (210u8, 60, 60)),
(64.0, 40.0, 20.0, (60, 160, 90)),
(44.0, 68.0, 16.0, (70, 90, 200)),
];
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let mut col = (235u8, 230, 225); // background
for &(cx, cy, r, c) in &discs {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
if dx * dx + dy * dy <= r * r {
col = c;
}
}
pixels.extend_from_slice(&[col.0, col.1, col.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
fn rasterize(svg: &str, w: u32, h: u32) -> Vec<u8> {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
pixmap.data().to_vec()
}
/// Max per-channel difference between two RGBA pixels at index `i`.
fn pixel_diff(a: &[u8], b: &[u8], i: usize) -> u8 {
(0..4)
.map(|c| a[i + c].abs_diff(b[i + c]))
.max()
.unwrap_or(0)
}
/// Mark pixels within Chebyshev radius `r` of a color edge in either image.
fn boundary_band(a: &[u8], b: &[u8], w: usize, h: usize, r: i32) -> Vec<bool> {
const EDGE: u8 = 24;
let idx = |x: usize, y: usize| (y * w + x) * 4;
let mut edge = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let i = idx(x, y);
// An edge is where either rendering changes color vs its right/down
// neighbor.
let mut is_edge = false;
for img in [a, b] {
if x + 1 < w && neighbor_diff(img, i, idx(x + 1, y)) > EDGE {
is_edge = true;
}
if y + 1 < h && neighbor_diff(img, i, idx(x, y + 1)) > EDGE {
is_edge = true;
}
}
if is_edge {
edge[y * w + x] = true;
}
}
}
// Dilate the edge set by r.
let mut band = vec![false; w * h];
for y in 0..h as i32 {
for x in 0..w as i32 {
let mut near = false;
'outer: for dy in -r..=r {
for dx in -r..=r {
let (nx, ny) = (x + dx, y + dy);
if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h && edge[ny as usize * w + nx as usize] {
near = true;
break 'outer;
}
}
}
band[y as usize * w + x as usize] = near;
}
}
band
}
fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
}
fn assert_equivalent(mode: FitMode) {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h);
let stacked = Config {
mode,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
.build()
.unwrap()
.to_svg(&img)
.unwrap();
let cutout = Config {
mode,
hierarchical: Hierarchical::Cutout,
..Config::default()
}
.build()
.unwrap()
.to_svg(&img)
.unwrap();
let a = rasterize(&stacked, w as u32, h as u32);
let b = rasterize(&cutout, w as u32, h as u32);
assert_eq!(a.len(), b.len());
let band = boundary_band(&a, &b, w, h, 2);
const DIFF: u8 = 40;
let mut interior_mismatches = 0;
for p in 0..(w * h) {
let i = p * 4;
if pixel_diff(&a, &b, i) > DIFF && !band[p] {
interior_mismatches += 1;
}
}
// Every real difference must live in the boundary band; interiors match.
assert_eq!(
interior_mismatches, 0,
"{mode:?}: {interior_mismatches} interior pixels differ between stacked and cutout \
(differences must be confined to the boundary band)"
);
}
#[test]
fn stacked_and_cutout_agree_in_interiors_spline() {
assert_equivalent(FitMode::Spline);
}
#[test]
fn stacked_and_cutout_agree_in_interiors_polygon() {
assert_equivalent(FitMode::Polygon);
}
#[test]
fn stacked_and_cutout_agree_in_interiors_pixel() {
assert_equivalent(FitMode::Pixel);
}
// --- seam / show-through test -------------------------------------------------
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse svg");
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("alloc pixmap");
pixmap.fill(tiny_skia::Color::from_rgba8(bg[0], bg[1], bg[2], 255));
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
pixmap.data().to_vec()
}
/// A full-canvas-coverage image rendered in stacked mode must be fully opaque:
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
/// exactly the hole-punching bug this guards against.
#[test]
fn stacked_has_no_seams() {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h); // background fills the whole canvas
let svg = Config {
mode: FitMode::Spline,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
.build()
.unwrap()
.to_svg(&img)
.unwrap();
let white = rasterize_on(&svg, w as u32, h as u32, [255, 255, 255, 255]);
let black = rasterize_on(&svg, w as u32, h as u32, [0, 0, 0, 255]);
// Count backdrop-dependent pixels, ignoring the 1px canvas border (the only
// legitimate outer-silhouette antialiasing for a full-coverage image).
let mut show_through = 0;
for y in 1..h - 1 {
for x in 1..w - 1 {
let i = (y * w + x) * 4;
if (0..3).any(|c| white[i + c].abs_diff(black[i + c]) > 8) {
show_through += 1;
}
}
}
assert_eq!(
show_through, 0,
"stacked mode leaked {show_through} backdrop pixels — seams/holes in solid overdraw"
);
}
+298
View File
@@ -0,0 +1,298 @@
//! Golden-snapshot tests over synthetic images, exercising every stage —
//! hierarchical clustering, all three fitters, color fitting, the optimizer
//! passes, and the writer.
//!
//! Goldens are compared by **rendering** both the stored SVG and the freshly
//! produced SVG and diffing pixels, not by byte-equality. The spline fitter's
//! cubic fit is floating-point, and f64 results differ by a few ULPs across
//! architectures (arm64 vs x86_64); after rounding, a coordinate can flip and
//! change the SVG bytes without any real geometry change. A visual diff is
//! encoding-agnostic and tolerant of that sub-pixel noise while still catching
//! genuine regressions.
//!
//! Regenerate goldens after an intentional behavior change with:
//!
//! ```sh
//! VTRACER_BLESS=1 cargo test -p vtracer --test golden
//! ```
use std::path::PathBuf;
use resvg::{tiny_skia, usvg};
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
// --- synthetic image builders ------------------------------------------------
fn mk<F: Fn(usize, usize) -> (u8, u8, u8, u8)>(w: usize, h: usize, f: F) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let (r, g, b, a) = f(x, y);
pixels.extend_from_slice(&[r, g, b, a]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// Four vertical color bands.
fn bands() -> ColorImage {
let cols = [
(220, 40, 40),
(40, 200, 60),
(50, 60, 220),
(230, 210, 40),
];
mk(48, 40, |x, _| {
let (r, g, b) = cols[(x * cols.len()) / 48];
(r, g, b, 255)
})
}
/// Checkerboard of 8x8 cells — exercises region adjacency and holes.
fn checker() -> ColorImage {
mk(48, 48, |x, y| {
if ((x / 8) + (y / 8)) % 2 == 0 {
(20, 20, 20, 255)
} else {
(235, 235, 235, 255)
}
})
}
/// A filled disc on a contrasting background — exercises curve fitting.
fn disc() -> ColorImage {
let (cx, cy, r2) = (24.0f64, 24.0f64, 16.0f64 * 16.0);
mk(48, 48, |x, y| {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
if dx * dx + dy * dy <= r2 {
(200, 60, 60, 255)
} else {
(240, 240, 240, 255)
}
})
}
/// An annulus (disc with a hole) — exercises hole tracing.
fn ring() -> ColorImage {
let (cx, cy) = (24.0f64, 24.0f64);
mk(48, 48, |x, y| {
let dx = x as f64 - cx;
let dy = y as f64 - cy;
let d2 = dx * dx + dy * dy;
if d2 <= 20.0 * 20.0 && d2 >= 9.0 * 9.0 {
(40, 90, 200, 255)
} else {
(245, 245, 245, 255)
}
})
}
/// A 4x4 grid of 16 distinct saturated colors — produces many hierarchical
/// layers, and gives auto-quantize something real to reduce.
fn swatches() -> ColorImage {
let step = [0u8, 85, 170, 255];
mk(48, 48, |x, y| {
let col = (x / 12).min(3);
let row = (y / 12).min(3);
(step[col], step[row], 128, 255)
})
}
// --- fixture matrix ----------------------------------------------------------
fn base() -> Config {
Config::default()
}
fn cases() -> Vec<(&'static str, ColorImage, Config)> {
vec![
// Fit modes on the same content.
("bands_spline", bands(), base()),
(
"bands_polygon",
bands(),
Config {
mode: FitMode::Polygon,
..base()
},
),
(
"bands_pixel",
bands(),
Config {
mode: FitMode::Pixel,
optimize: 0,
..base()
},
),
// Curves and holes.
("disc_spline", disc(), base()),
("ring_spline", ring(), base()),
("checker_spline", checker(), base()),
// Hierarchical layering.
("swatches_color", swatches(), base()),
// Binary mode.
(
"checker_bw",
checker(),
Config {
color_mode: ColorMode::Binary,
..base()
},
),
// Color fitting: fixed palette (+ merge) and auto-quantize (+ merge).
(
"bands_palette",
bands(),
Config {
palette: vec![Color::new(0, 0, 0), Color::new(255, 255, 255)],
optimize: 2,
..base()
},
),
(
"swatches_quant4",
swatches(),
Config {
max_colors: Some(4),
optimize: 2,
..base()
},
),
// Optimizer / writer encoding levels on identical geometry.
(
"disc_opt0",
disc(),
Config {
optimize: 0,
..base()
},
),
(
"disc_opt2",
disc(),
Config {
optimize: 2,
..base()
},
),
// Mosaic (seam-free tessellation): exact pixel and polygon fitters.
(
"disc_mosaic_pixel",
disc(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Pixel,
..base()
},
),
(
"checker_mosaic_polygon",
checker(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Polygon,
optimize: 2,
..base()
},
),
(
"disc_mosaic_spline",
disc(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Spline,
..base()
},
),
]
}
fn goldens_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("goldens")
}
#[test]
fn golden_snapshots() {
let bless = std::env::var_os("VTRACER_BLESS").is_some();
let dir = goldens_dir();
if bless {
std::fs::create_dir_all(&dir).unwrap();
}
let mut mismatches = Vec::new();
for (name, img, config) in cases() {
let svg = config
.build()
.unwrap_or_else(|e| panic!("case {name}: build failed: {e}"))
.to_svg(&img)
.unwrap_or_else(|e| panic!("case {name}: convert failed: {e}"));
let path = dir.join(format!("{name}.svg"));
if bless {
std::fs::write(&path, &svg).unwrap();
continue;
}
match std::fs::read_to_string(&path) {
Ok(expected) => {
if let Some(diff) = render_diff(&expected, &svg) {
mismatches.push(format!("{name}: {diff}"));
}
}
Err(_) => mismatches.push(format!(
"{name}: missing golden ({}); run with VTRACER_BLESS=1",
path.display()
)),
}
}
assert!(
mismatches.is_empty(),
"golden mismatches:\n{}",
mismatches.join("\n")
);
}
/// Render an SVG string to an RGBA pixmap at its intrinsic size.
fn render(svg: &str) -> (u32, u32, Vec<u8>) {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).expect("parse golden svg");
let size = tree.size();
let (w, h) = (size.width().ceil() as u32, size.height().ceil() as u32);
let mut pixmap = tiny_skia::Pixmap::new(w.max(1), h.max(1)).expect("alloc pixmap");
resvg::render(&tree, tiny_skia::Transform::identity(), &mut pixmap.as_mut());
(w, h, pixmap.data().to_vec())
}
/// Compare two SVGs by rendering. Returns `Some(reason)` if they differ beyond
/// a small tolerance (which absorbs cross-architecture sub-pixel float noise),
/// or `None` if visually equivalent.
fn render_diff(expected: &str, actual: &str) -> Option<String> {
let (ew, eh, a) = render(expected);
let (aw, ah, b) = render(actual);
if (ew, eh) != (aw, ah) {
return Some(format!("size {ew}x{eh} vs {aw}x{ah}"));
}
// A pixel "differs" only on a clear color change, not antialiasing wobble.
const CHANNEL: u8 = 40;
let total = (ew * eh) as usize;
let differing = (0..total)
.filter(|&p| (0..3).any(|c| a[p * 4 + c].abs_diff(b[p * 4 + c]) > CHANNEL))
.count();
// Allow a tiny fraction for boundary pixels that flip under sub-pixel shifts.
let allowed = (total / 200).max(8); // 0.5%, min 8px
if differing > allowed {
Some(format!(
"{differing}/{total} pixels differ (> {allowed} allowed) — real change, re-bless if intended"
))
} else {
None
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#000000"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

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

After

Width:  |  Height:  |  Size: 379 B

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

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0C15.84,0,31.68,0,48,0c0,13.2,0,26.4,0,40c-15.84,0-31.68,0-48,0C0,26.8,0,13.6,0,0Z" fill="#28C83C"/>
<path d="M36,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#E6D228"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0c0-13.2,0-26.4,0-40Z" fill="#323CDC"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,13.2,0,26.4,0,40c-3.96,0-7.92,0-12,0C0,26.8,0,13.6,0,0Z" fill="#DC2828"/>
</svg>

After

Width:  |  Height:  |  Size: 623 B

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

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M16,0H8V8h8V0Z" fill="#EBEBEB"/>
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,40H0v8H8V40Z"/>
<path d="M48,32H40v8h8V32Z"/>
</g>
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M8,32H0v8H8V32Z"/>
<path d="M48,24H40v8h8V24Z"/>
</g>
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,24H0v8H8V24Z"/>
<path d="M48,16H40v8h8V16Z"/>
</g>
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M8,16H0v8H8V16Z"/>
<path d="M48,8H40v8h8V8Z"/>
</g>
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,8H0v8H8V8Z"/>
<path d="M48,8V0H40V8h8Z"/>
</g>
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M24,0H16V8h8V0Z"/>
<path d="M8,8V0H0V8H8Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

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

After

Width:  |  Height:  |  Size: 985 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C0,16.32,0,32.16,0,48c16.32,0,32.16,0,48,0c0-16.32,0-32.16,0-48C31.68,0,15.84,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
<path d="M31.56,9.75C23.31,7.33,14,8.99,9.75,17.5c-.26,.85-.5,1.68-.75,2.5c-1.33,4-1.33,4,.81,10.44c1.84,3.95,3.73,6.83,7.69,8.81c.85,.26,1.68,.5,2.5,.75c4,1.33,4,1.33,10.44-.81c3.95-1.84,6.83-3.73,8.81-7.69c.26-.85,.5-1.67,.75-2.5c.75-2.24,1.06-3.6,.69-5.94c-1.85-5.53-3.65-10.5-9.13-13.31Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 888 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0C48,15.84,48,31.68,48,48C32.16,48,16.32,48,0,48C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06C39.1,16.2,41.16,20.44,40.91,26.15C39.91,31.12,37.77,34.55,34,38C29.69,40.33,25.67,41.47,20.81,40.5C16.02,38.91,12.35,36.5,9.89,31.95C8.06,27.32,7.57,23.62,9.15,18.85C11.43,13.88,14.28,10.99,19.31,9C25.64,7.23,29.86,8.66,35.31,12.06Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#285AC8"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
<path d="M29,16c2.56,1.44,2.56,1.44,4,4c.75,4.29,.71,7.73-1.44,11.56C27.73,33.71,24.29,33.75,20,33c-2.56-1.44-2.56-1.44-4-4c-.75-4.29-.71-7.73,1.44-11.56C21.27,15.29,24.71,15.25,29,16Z" fill="#F5F5F5"/>
</svg>

After

Width:  |  Height:  |  Size: 829 B

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#55FF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#555580"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FFAA80"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#55AA80"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF0080"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#550080"/>
<path d="M24,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AAFF80"/>
<path d="M0,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#00FF80"/>
<path d="M24,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AAAA80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#00AA80"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA5580"/>
<path d="M0,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#005580"/>
<path d="M24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA0080"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0C0,8.04,0,4.08,0,0Z" fill="#000080"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C15.84,0,31.68,0,48,0c0,15.84,0,31.68,0,48c-15.84,0-31.68,0-48,0C0,32.16,0,16.32,0,0Z" fill="#FFFF80"/>
<path d="M0,0C15.84,0,31.68,0,48,0c0,7.92,0,15.84,0,24c-15.84,0-31.68,0-48,0C0,16.08,0,8.16,0,0Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0c0-7.92,0-15.84,0-24Z" fill="#FFFF80"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,7.92,0,15.84,0,24c-7.92,0-15.84,0-24,0C0,16.08,0,8.16,0,0Z" fill="#AA2A80"/>
<path d="M24,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,24c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,0c7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0c0-3.96,0-7.92,0-12Z" fill="#FF5580"/>
<path d="M0,0C7.92,0,15.84,0,24,0c0,3.96,0,7.92,0,12c-7.92,0-15.84,0-24,0C0,8.04,0,4.08,0,0Z" fill="#AA2A80"/>
<path d="M0,36c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#FFFF80"/>
<path d="M0,24c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Zm24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M24,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
<path d="M0,12c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#4B9280"/>
<path d="M0,0C3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0C0,8.04,0,4.08,0,0ZM24,0c3.96,0,7.92,0,12,0c0,3.96,0,7.92,0,12c-3.96,0-7.92,0-12,0c0-3.96,0-7.92,0-12Z" fill="#AA2A80"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+89
View File
@@ -0,0 +1,89 @@
//! End-to-end pipeline smoke tests over synthetic images.
use vtracer::{ColorImage, ColorMode, Config, FitMode, Hierarchical};
/// Build a `size × size` image split into two vertical color bands.
fn two_band_image(size: usize) -> ColorImage {
let mut pixels = Vec::with_capacity(size * size * 4);
for _y in 0..size {
for x in 0..size {
let (r, g, b) = if x < size / 2 {
(220, 40, 40)
} else {
(40, 40, 220)
};
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
ColorImage {
pixels,
width: size,
height: size,
}
}
fn assert_valid_svg(svg: &str) {
assert!(svg.contains("<svg"), "missing <svg> element:\n{svg}");
assert!(svg.trim_end().ends_with("</svg>"), "missing </svg> close");
assert!(svg.contains("<path"), "expected at least one path:\n{svg}");
}
#[test]
fn default_color_pipeline_produces_svg() {
let img = two_band_image(32);
let svg = Config::default().build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn all_fit_modes_produce_svg() {
let img = two_band_image(32);
for mode in [FitMode::Pixel, FitMode::Polygon, FitMode::Spline] {
let config = Config {
mode,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
}
#[test]
fn binary_pipeline_produces_svg() {
let img = two_band_image(32);
let config = Config {
color_mode: ColorMode::Binary,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn optimize_levels_shrink_or_match() {
let img = two_band_image(48);
let mut sizes = Vec::new();
for level in [0u8, 1, 2] {
let config = Config {
optimize: level,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
sizes.push(svg.len());
}
// Higher optimization should never produce larger output than level 0.
assert!(sizes[1] <= sizes[0], "opt1 {} > opt0 {}", sizes[1], sizes[0]);
assert!(sizes[2] <= sizes[0], "opt2 {} > opt0 {}", sizes[2], sizes[0]);
}
#[test]
fn mosaic_cutout_produces_svg() {
let img = two_band_image(32);
let config = Config {
hierarchical: Hierarchical::Cutout,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
+46
View File
@@ -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 │
└──────────────────────────────┘
```
+153
View File
@@ -0,0 +1,153 @@
# Architecture
## Workspace layout
```
Cargo.toml # workspace
crates/
├── vtracer-core/ # the framework. wasm-safe, no file/image I/O, no clap/pyo3
│ └── src/
│ ├── lib.rs
│ ├── ir/ # Segmentation, LabelMap, VectorDoc, geometry types
│ ├── frontend/ # trait Frontend + ColorClusterFrontend, BinaryFrontend, keying
│ ├── colorfit/ # trait ColorFitter + Identity, FixedPalette, AutoQuantize
│ ├── fitter/ # trait CurveFitter + Pixel, Polygon, Spline
│ ├── compose/ # stacked composition (per-region closed tracing)
│ ├── mosaic/ # boundary-graph extraction + shared-edge fitting (see mosaic.md)
│ ├── optimize/ # trait OptimizerPass + passes over VectorDoc
│ ├── svg/ # writer (absolute/relative, shorthands, precision)
│ └── pipeline.rs # Pipeline driver + Config/presets
├── vtracer/ # publishable bin+lib crate, keeps the crate name.
│ # image I/O (image crate), clap 4 CLI,
│ # pyo3 binding behind `python-binding` feature
└── vtracer-wasm/ # wasm-bindgen bindings over vtracer-core
nodejs/ # npm package: TS wrapper + embedded wasm build + sharp reader
```
- `webapp/` and `cmdapp/` are deleted (git history preserves them).
- `vtracer` re-exports `vtracer-core`, so library users need a single dependency.
- During development the workspace carries `[patch.crates-io] visioncortex = { path = "../visioncortex" }`; releases pin a published 0.8.x.
- `flo_curves` (already in the tree via visioncortex) becomes a direct dependency of `vtracer-core` for configurable-error Bezier fitting.
## Core IR
Value types from `visioncortex` are reused where they fit (`ColorImage`, `Color`, `PointF64`, `CompoundPath`); the pipeline IR is our own:
```rust
/// Frontend output — the general form is ordered layers (painter's algorithm).
pub struct Segmentation {
pub width: u32,
pub height: u32,
pub layers: Vec<Layer>, // bottom-to-top paint order
}
pub struct Layer {
pub paint: Paint, // starts as mean cluster color; ColorFitter may rewrite
pub mask: RegionMask, // the cluster's pixel indices
}
/// Flat partition for mosaic mode, derived by painting layers top-down.
pub struct LabelMap {
pub width: u32,
pub height: u32,
pub labels: Vec<u32>, // one label per pixel; u32::MAX = OUTSIDE (keyed/transparent)
pub paints: Vec<Paint>, // indexed by label
}
/// Output document IR — what the optimizer and the writer operate on.
pub struct VectorDoc { pub width: u32, pub height: u32, pub shapes: Vec<Shape> }
pub struct Shape { pub paint: Paint, pub path: MultiPath } // subpaths: MoveTo + (Line|Cubic)* + Close
pub enum Paint { Solid(Color) } // room for gradients later
```
Why layers, not a label map, as the frontend output: in stacked mode clusters genuinely overlap (each hierarchical cluster is painted over its parents), which a flat label map cannot represent. The flat `LabelMap` needed by mosaic mode is derived from the layers by a top-down flatten — cheap and lossless for that purpose.
## Stage traits
All object-safe; the driver composes boxed trait objects (ergonomic across CLI/py/wasm boundaries, negligible dispatch cost next to the per-pixel work).
```rust
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
}
pub trait ColorFitter {
fn fit(&self, seg: &mut Segmentation);
}
pub trait CurveFitter {
fn fit_closed(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // stacked outlines, rings
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
}
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
pub enum Compositing { Stacked, Mosaic }
pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
}
impl Pipeline {
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> { /* driver */ }
}
```
Driver flow:
1. `frontend.segment(img)``Segmentation`
2. each `ColorFitter` rewrites layer paints (e.g. palette snapping)
3. compositing:
- **Stacked** — trace each layer's closed outlines independently (port of today's `to_compound_path` flow) via `fitter.fit_closed`
- **Mosaic** — flatten to `LabelMap`, merge adjacent same-paint regions, extract the boundary graph, fit each shared edge once via `fitter.fit_open`, assemble faces (see [mosaic.md](mosaic.md))
4. optimizer passes over the `VectorDoc`
5. `SvgWriter` serializes
## Built-in implementations
- **Frontends**
- `ColorClusterFrontend` — wraps `visioncortex::color_clusters::Runner`, including the transparency-keying logic that currently lives in `converter.rs` (find unused key color, key fully-transparent pixels, `KeyingAction`).
- `BinaryFrontend` — threshold → `BinaryImage::to_clusters`.
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
- **ColorFitters**
- `Identity` (today's behavior: mean cluster color)
- `FixedPalette { colors: Vec<Color> }` — snaps each layer paint to the nearest palette entry in OKLab
- `AutoQuantize { max_colors }` — k-means/median-cut over layer paints
- After palette snapping, a built-in merge step unions adjacent regions with identical paint (mosaic path) / merges consecutive identical-paint layers (stacked path).
- **CurveFitters**
- `PixelFitter` — exact lattice polyline
- `PolygonFitter` — staircase-symmetric Douglas-Peucker
- `SplineFitter` — subdivision + corner detection + least-squares cubic fit (port of the visioncortex flow, extended to open polylines with pinned endpoints)
## Optimizer and SVG writer
Two levels: geometry passes over `VectorDoc`, then encoding choices in the writer.
- `QuantizePass { precision }` — round coordinates once, in document space. Replaces today's per-write rounding, and eliminates the per-path `translate(x,y)` transform by baking offsets into coordinates.
- `SimplifyPass` — drop zero-length and collinear-redundant segments *after* quantization.
- `SvgWriter { relative: bool, shorthands: bool, precision }` — per segment picks the shortest encoding:
- relative (`l c s h v`) vs absolute deltas, whichever serializes shorter
- `h`/`v` for axis-aligned lines, `s` for smooth cubic continuations
- number formatting: trim trailing zeros, omit the space before negative numbers, leading-dot decimals
- Paint grouping: shapes sharing a fill emitted inside `<g fill="…">` when it saves bytes.
Output size is a tracked metric: the test suite asserts a byte-size budget against golden samples (see [roadmap.md](roadmap.md)).
## CLI
clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--colormode color|bw`, `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`.
New:
- `--hierarchical stacked|cutout``cutout` now runs the true mosaic pipeline
- `--palette '#112233,#445566,…'` / `--palette-file colors.txt` — fixed palette color fitting
- `--optimize 0..2` — optimizer level (0 = off, 1 = quantize+simplify, 2 = + full writer shorthands/grouping)
- mosaic extras: `--seam-stroke`, `--mosaic-strict` (see mosaic.md)
Range validation moves from `panic!` to clap `value_parser` ranges.
+71
View File
@@ -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.
+190
View File
@@ -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 12 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.51, 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.
+19
View File
@@ -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/`.
+150
View File
@@ -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` 016 × mode × colormode | 102 | 0 | 1e-8 |
| `color_precision` 18 × mode | 24 | 0 | 1e-8 |
| `gradient_step` 0255 × mode | 39 | 0 | 1e-8 |
| `corner_threshold` 0180 (spline) | 26 | 0 | 1e-8 |
| `segment_length` 3.510 (spline) | 9 | 0 | 1e-8 |
| `splice_threshold` 0180 (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.
-44
View File
@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1783.4 441.4" style="enable-background:new 0 0 1783.4 441.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#A1FCFE;}
.st1{fill:#010357;}
</style>
<rect class="st0" width="1783.4" height="441.4"/>
<g>
<path class="st1" d="M61.4,393.2V73.8H250V113H109v100.4h124.2v39.7H109v140.1L61.4,393.2L61.4,393.2z"/>
<path class="st1" d="M280,393.2V159.7h45.3v233.5L280,393.2L280,393.2z M330.9,92.4c0-15.5-12.5-28-28-28s-28,12.5-28,28
s12.5,28,28,28S330.9,107.9,330.9,92.4z"/>
<path class="st1" d="M383.5,338.6V57.9h45.3v278.8c0,21,3.7,26.1,18.7,26.1c6.1,0,11.7-0.5,16.8-1.4v30.4
c-9.8,2.8-20.1,4.2-29.9,4.2C400.3,396,383.5,376.9,383.5,338.6z"/>
<path class="st1" d="M480.6,275.5c0-78.5,41.6-121.4,110.2-121.4c31.3,0,55.6,9.8,72.9,29.9c17.3,19.6,26.2,45.3,26.2,77.5
c0,7-0.5,14.9-1.9,24.3H525.4c0.9,50.9,22.4,76.6,64.4,76.6c31.8,0,53.2-14.5,53.2-42H687c0,48.6-39.7,77.5-97.6,77.5
C521.7,397.9,480.6,357.3,480.6,275.5z M645,251.7c-0.9-41.6-19.6-62.1-55.6-62.1c-39.7,0-59.8,24.3-63.5,62.1H645z"/>
<path class="st1" d="M720.7,323.2h44.8c1.9,28.5,18.7,38.8,51.8,38.8c28.5,0,46.7-15,46.7-35c0-20.6-16.8-28.5-48.1-36l-13.1-2.8
l-14.5-3.7l-12.6-4.2c-6.1-1.9-10.7-3.3-13.1-4.7c-5.1-3.3-17.3-8.9-21.5-14.5c-7.5-8.9-15.4-21.5-14-38.8c0-21,8.4-37.8,25.2-50
c17.3-12.1,37.8-18.2,62.1-18.2c55.6,0,91.5,22.4,91.5,72.4h-43c-0.5-26.6-16.8-36.4-49-36.4c-24.3,0-40.6,11.2-40.6,31.3
c0,18.7,15.9,25.7,49,33.6l5.6,1.4c10.3,2.3,18.2,4.7,23.3,6.1c5.1,1.4,12.1,4.2,21,8.4c23.8,10.3,34.6,25.7,36.9,56
c0,43.4-37.8,71-92.5,71C754.8,397.9,720.7,371.7,720.7,323.2z"/>
<path class="st1" d="M964,336.7V192.4h-39.2v-32.7h39.7V94.3h44.8v65.4h64v32.7h-64v143.4c0,17.3,8.9,25.7,26.2,25.7
c14.5,0,28-1.9,39.7-5.1v34.6c-13.6,4.2-28,6.1-43.4,6.1C986.9,397,964,375.5,964,336.7z"/>
<path class="st1" d="M1095.3,325.5c0-44.8,35-69.6,93.4-69.6h58.4v-20.6c0-28.5-16.8-45.8-50-45.8c-35.5,0-51.8,16.8-51.8,41.6
h-43.4c0-44.8,33.2-77.1,95.3-77.1c61.2,0,95.7,30.4,95.7,83.1v92.5c0,26.1,3.7,32.2,17.3,33.6h6.5v29.4
c-4.7,1.4-11.7,2.3-21.5,2.3c-26.6,0-41.6-15.9-43.4-41.6c-8.9,23.8-36.4,44.4-74.3,44.4C1127.5,397.9,1095.3,368.5,1095.3,325.5z
M1247.1,297.5v-9.3h-58.4c-32.2,0-48.6,12.6-48.6,37.4c0,22.9,14,36.9,41.6,36.9C1222.3,362.4,1247.1,334.4,1247.1,297.5z"/>
<path class="st1" d="M1351.7,395.1V161.6h43.9l0.9,36.4h0.9c11.7-24.8,34.1-40.6,62.1-40.6c8.4,0,15.9,0.9,22.9,3.3v38.8
c-7.5-1.4-15.9-1.9-25.7-1.9c-33.2,0-59.3,28-59.3,72.9v124.7H1351.7z"/>
<g>
<g>
<rect x="1549" y="146.5" transform="matrix(0.866 -0.5 0.5 0.866 137.8024 841.2704)" class="st1" width="179.4" height="34"/>
</g>
<g>
<rect x="1621.7" y="73.8" transform="matrix(0.5 -0.866 0.866 0.5 677.7373 1500.9166)" class="st1" width="34" height="179.4"/>
</g>
<g>
<rect x="1621.7" y="73.8" class="st1" width="34" height="179.4"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

+6
View File
@@ -0,0 +1,6 @@
/pkg
/target
/node_modules
Cargo.lock
# npm auth token — per-project, never commit
.npmrc
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "vtracer-wasm"
description = "WebAssembly core for the vtracer Node.js package."
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/visioncortex/vtracer/"
# Not the core workspace: this is a wasm-bindgen cdylib built with wasm-pack as
# the Node package's native core. The Node layer does file I/O; image decoding
# happens here in wasm, so the package has no native dependency.
[lib]
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.1", path = "../crates/vtracer" }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
# Pure-Rust decoders that compile to wasm32-unknown-unknown (webp via image-webp).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
[profile.release]
opt-level = "s"
lto = true
+53
View File
@@ -0,0 +1,53 @@
# vtracer (Node.js)
Raster → vector (SVG) for Node, a WebAssembly build of the
[`vtracer`](https://github.com/visioncortex/vtracer) framework. Image decoding
and vectorization both happen in wasm, so there is **no native dependency**
just `npm install`.
## Install
```sh
npm install @visioncortex/vtracer
```
## Usage
```js
const vtracer = require('@visioncortex/vtracer');
// file in, file out
await vtracer.convertFile('in.png', 'out.svg');
await vtracer.convertFile('in.jpg', 'out.svg', { mode: 'polygon', hierarchical: 'cutout' });
// buffers
const svg = vtracer.convertBuffer(fs.readFileSync('in.png'), { preset: 'poster' });
// raw RGBA8 pixels
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
```
## API
- `convertBuffer(buffer, options?) => string` — encoded image (PNG/JPEG/GIF/BMP) → SVG.
- `convertPixels(rgba, width, height, options?) => string` — raw RGBA8 → SVG.
- `convertFile(input, output, options?) => Promise<void>` — read, trace, write.
- `convertFileSync(input, output, options?) => void`.
### `Options` (all optional, camelCase)
`preset` (`"bw" | "poster" | "photo"`, applied first), `colorMode`
(`"color" | "bw"`), `hierarchical` (`"stacked" | "cutout"` for the seam-free
mosaic), `mode` (`"pixel" | "polygon" | "spline"`), `filterSpeckle`,
`colorPrecision`, `layerDifference`, `cornerThreshold`, `lengthThreshold`,
`maxIterations`, `spliceThreshold`, `pathPrecision`, `palette` (list of
`#rrggbb`), `maxColors`, `optimize` (`0 | 1 | 2`).
## Build from source
Requires the Rust toolchain and [`wasm-pack`](https://rustwasm.github.io/wasm-pack/):
```sh
npm run build # wasm-pack build --target nodejs --out-dir pkg
npm test
```
+34
View File
@@ -0,0 +1,34 @@
/** Conversion options. Any field may be omitted; omitted fields use the framework default. */
export interface Options {
/** Applied before other fields: "bw" | "poster" | "photo". */
preset?: 'bw' | 'poster' | 'photo';
colorMode?: 'color' | 'bw';
hierarchical?: 'stacked' | 'cutout';
mode?: 'pixel' | 'polygon' | 'spline';
filterSpeckle?: number;
colorPrecision?: number;
layerDifference?: number;
cornerThreshold?: number;
lengthThreshold?: number;
maxIterations?: number;
spliceThreshold?: number;
pathPrecision?: number;
/** Fixed palette: `#rrggbb` strings. */
palette?: string[];
/** Auto-quantize target color count. */
maxColors?: number;
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
optimize?: number;
}
/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */
export function convertBuffer(buffer: Uint8Array, options?: Options): string;
/** Vectorize a raw RGBA8 buffer (`width * height * 4` bytes) to an SVG string. */
export function convertPixels(rgba: Uint8Array, width: number, height: number, options?: Options): string;
/** Read an image file, vectorize it, and write the SVG to disk. */
export function convertFile(inputPath: string, outputPath: string, options?: Options): Promise<void>;
/** Synchronous {@link convertFile}. */
export function convertFileSync(inputPath: string, outputPath: string, options?: Options): void;
+49
View File
@@ -0,0 +1,49 @@
'use strict';
// Node package: image decoding + vectorization happen in wasm (no native
// dependency); this layer only adds file I/O and a camelCase API.
const fs = require('fs');
const fsp = require('fs/promises');
const wasm = require('./pkg/vtracer_wasm.js');
/**
* Vectorize an encoded image (PNG/JPEG/GIF/BMP) Buffer/Uint8Array to an SVG string.
* @param {Uint8Array} buffer
* @param {object} [options]
* @returns {string}
*/
function convertBuffer(buffer, options = {}) {
return wasm.vectorize_bytes(buffer, options);
}
/**
* Vectorize a raw RGBA8 buffer (width*height*4 bytes) to an SVG string.
* @param {Uint8Array} rgba
* @param {number} width
* @param {number} height
* @param {object} [options]
* @returns {string}
*/
function convertPixels(rgba, width, height, options = {}) {
return wasm.vectorize_rgba(rgba, width, height, options);
}
/**
* Read an image file, vectorize it, and write the SVG to disk.
* @returns {Promise<void>}
*/
async function convertFile(inputPath, outputPath, options = {}) {
const data = await fsp.readFile(inputPath);
const svg = wasm.vectorize_bytes(data, options);
await fsp.writeFile(outputPath, svg);
}
/** Synchronous {@link convertFile}. */
function convertFileSync(inputPath, outputPath, options = {}) {
const data = fs.readFileSync(inputPath);
const svg = wasm.vectorize_bytes(data, options);
fs.writeFileSync(outputPath, svg);
}
module.exports = { convertBuffer, convertPixels, convertFile, convertFileSync };
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@visioncortex/vtracer",
"version": "1.0.0-alpha.1",
"description": "Raster to vector graphics converter (SVG). WebAssembly build of the vtracer framework — no native dependencies.",
"main": "index.js",
"types": "index.d.ts",
"publishConfig": {
"access": "public"
},
"files": [
"index.js",
"index.d.ts",
"pkg/vtracer_wasm.js",
"pkg/vtracer_wasm_bg.wasm",
"pkg/vtracer_wasm.d.ts",
"pkg/vtracer_wasm_bg.wasm.d.ts"
],
"scripts": {
"build": "wasm-pack build --target nodejs --out-dir pkg",
"test": "node test.js",
"publish:local": "node scripts/publish.mjs",
"prepublishOnly": "npm run build"
},
"keywords": ["svg", "vectorization", "raster", "wasm", "computer-graphics"],
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/visioncortex/vtracer.git",
"directory": "nodejs"
},
"homepage": "http://www.visioncortex.org/vtracer",
"engines": {
"node": ">=16"
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// Build the wasm package and publish it, by default to a local npm registry
// (e.g. a Verdaccio instance at http://localhost:4873).
//
// node scripts/publish.mjs # publish to the local registry
// node scripts/publish.mjs --dry-run # build + pack, don't publish
// node scripts/publish.mjs --registry=http://... # override the registry
// NPM_REGISTRY=http://... node scripts/publish.mjs
//
// The registry may also be given via the NPM_REGISTRY env var.
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const regArg = args.find((a) => a.startsWith('--registry='));
const registry =
(regArg && regArg.slice('--registry='.length)) ||
process.env.NPM_REGISTRY ||
'http://localhost:4873';
function run(cmd, cmdArgs) {
console.log(`\n$ ${cmd} ${cmdArgs.join(' ')}`);
execFileSync(cmd, cmdArgs, { stdio: 'inherit', cwd: pkgDir });
}
// 1. Fresh wasm build (regenerates pkg/).
run('wasm-pack', ['build', '--target', 'nodejs', '--out-dir', 'pkg']);
// 2. Sanity check before publishing.
run('node', ['test.js']);
// 3. Publish (or dry-run) to the chosen registry.
const publishArgs = ['publish', '--registry', registry];
if (dryRun) publishArgs.push('--dry-run');
run('npm', publishArgs);
console.log(`\n${dryRun ? 'dry-run for' : 'published to'} ${registry}`);
+160
View File
@@ -0,0 +1,160 @@
//! WebAssembly core for the vtracer Node package.
//!
//! Exposes vectorization over encoded image bytes or a raw RGBA buffer. Image
//! decoding happens here (in wasm), so the JS layer only needs `fs` — no
//! native dependency. Options are a plain JS object matching [`Options`].
use std::io::Cursor;
use serde::Deserialize;
use vtracer::{Color, ColorImage, Config};
use wasm_bindgen::prelude::*;
/// Conversion options; a subset may be provided from JS (camelCase). Anything
/// omitted uses the framework default.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Options {
color_mode: Option<String>,
hierarchical: Option<String>,
mode: Option<String>,
filter_speckle: Option<usize>,
color_precision: Option<i32>,
layer_difference: Option<i32>,
corner_threshold: Option<i32>,
length_threshold: Option<f64>,
max_iterations: Option<usize>,
splice_threshold: Option<i32>,
path_precision: Option<u32>,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: Option<u8>,
/// One of "bw" | "poster" | "photo"; applied before the other fields.
preset: Option<String>,
}
fn err(msg: impl std::fmt::Display) -> JsValue {
JsValue::from_str(&msg.to_string())
}
fn parse_hex(token: &str) -> Result<Color, JsValue> {
let hex = token.strip_prefix('#').unwrap_or(token);
if hex.len() != 6 {
return Err(err(format!("`{token}` is not a #rrggbb color")));
}
let b = |r: std::ops::Range<usize>| {
u8::from_str_radix(&hex[r], 16).map_err(|_| err(format!("`{token}` is not a #rrggbb color")))
};
Ok(Color::new(b(0..2)?, b(2..4)?, b(4..6)?))
}
fn config_from(options: JsValue) -> Result<Config, JsValue> {
let opts: Options = if options.is_undefined() || options.is_null() {
Options::default()
} else {
serde_wasm_bindgen::from_value(options).map_err(err)?
};
let mut config = match opts.preset.as_deref() {
Some("bw") => Config::from_preset(vtracer::Preset::Bw),
Some("poster") => Config::from_preset(vtracer::Preset::Poster),
Some("photo") => Config::from_preset(vtracer::Preset::Photo),
Some(other) => return Err(err(format!("unknown preset `{other}`"))),
None => Config::default(),
};
if let Some(v) = opts.color_mode {
config.color_mode = v.parse().map_err(err)?;
}
if let Some(v) = opts.hierarchical {
config.hierarchical = v.parse().map_err(err)?;
}
if let Some(v) = opts.mode {
config.mode = v.parse().map_err(err)?;
}
if let Some(v) = opts.filter_speckle {
config.filter_speckle = v;
}
if let Some(v) = opts.color_precision {
config.color_precision = v;
}
if let Some(v) = opts.layer_difference {
config.layer_difference = v;
}
if let Some(v) = opts.corner_threshold {
config.corner_threshold = v;
}
if let Some(v) = opts.length_threshold {
config.length_threshold = v;
}
if let Some(v) = opts.max_iterations {
config.max_iterations = v;
}
if let Some(v) = opts.splice_threshold {
config.splice_threshold = v;
}
if let Some(v) = opts.path_precision {
config.path_precision = Some(v);
}
if let Some(list) = opts.palette {
config.palette = list.iter().map(|s| parse_hex(s)).collect::<Result<_, _>>()?;
}
if let Some(v) = opts.max_colors {
config.max_colors = Some(v);
}
if let Some(v) = opts.optimize {
config.optimize = v;
}
Ok(config)
}
fn to_svg(config: Config, img: ColorImage) -> Result<String, JsValue> {
config.build().map_err(err)?.to_svg(&img).map_err(err)
}
/// Vectorize encoded image bytes (PNG/JPEG/GIF/BMP). Returns the SVG string.
#[wasm_bindgen]
pub fn vectorize_bytes(data: &[u8], options: JsValue) -> Result<String, JsValue> {
let config = config_from(options)?;
let img = image::ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(err)?
.decode()
.map_err(|e| err(format!("failed to decode image: {e}")))?
.to_rgba8();
let (width, height) = (img.width() as usize, img.height() as usize);
to_svg(
config,
ColorImage {
pixels: img.into_raw(),
width,
height,
},
)
}
/// Vectorize a raw RGBA8 buffer (`width * height * 4` bytes). Returns the SVG.
#[wasm_bindgen]
pub fn vectorize_rgba(
data: Vec<u8>,
width: usize,
height: usize,
options: JsValue,
) -> Result<String, JsValue> {
if data.len() != width * height * 4 {
return Err(err(format!(
"rgba length {} != width*height*4 ({})",
data.len(),
width * height * 4
)));
}
let config = config_from(options)?;
to_svg(
config,
ColorImage {
pixels: data,
width,
height,
},
)
}
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vtracer = require('./index.js');
const SAMPLE = path.join(__dirname, '..', 'docs', 'assets', 'samples', 'tank-unit-preview.png');
const data = fs.readFileSync(SAMPLE);
// encoded bytes, default options
let svg = vtracer.convertBuffer(data);
assert(svg.includes('<svg') && svg.includes('<path'), 'default convertBuffer');
console.log('convertBuffer default:', (svg.match(/<path/g) || []).length, 'paths');
// options: bw preset -> all black
svg = vtracer.convertBuffer(data, { colorMode: 'bw' });
assert(svg.includes('fill="#000000"'), 'bw produces black');
console.log('convertBuffer bw:', (svg.match(/<path/g) || []).length, 'paths');
// options: mosaic + polygon + palette
svg = vtracer.convertBuffer(data, { hierarchical: 'cutout', mode: 'polygon', palette: ['#000000', '#ffffff'], optimize: 2 });
assert(svg.includes('<svg'), 'mosaic+palette');
console.log('convertBuffer cutout/polygon/palette:', (svg.match(/<path/g) || []).length, 'paths');
// preset
svg = vtracer.convertBuffer(data, { preset: 'poster' });
console.log('convertBuffer poster:', (svg.match(/<path/g) || []).length, 'paths');
// raw pixels: 20x20, left red / right blue
const w = 20, h = 20;
const rgba = Buffer.alloc(w * h * 4);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const [r, g, b] = x < w / 2 ? [220, 40, 40] : [40, 40, 220];
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = 255;
}
svg = vtracer.convertPixels(rgba, w, h);
assert(svg.includes('<svg'), 'convertPixels');
console.log('convertPixels:', (svg.match(/<path/g) || []).length, 'paths');
// file I/O
const out = path.join(require('os').tmpdir(), 'vtracer_node_out.svg');
vtracer.convertFileSync(SAMPLE, out, { mode: 'spline' });
assert(fs.statSync(out).size > 0, 'convertFileSync wrote file');
console.log('convertFileSync wrote:', fs.statSync(out).size, 'bytes');
// error handling
assert.throws(() => vtracer.convertBuffer(data, { palette: ['nope'] }), /rrggbb/, 'bad palette rejected');
assert.throws(() => vtracer.convertPixels(Buffer.alloc(8), 10, 10), /rgba length/, 'bad pixel length rejected');
console.log('errors rejected OK');
console.log('ALL OK');
+1 -1
View File
@@ -22,7 +22,7 @@ console_log = { version = "0.2", features = ["color"] }
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
visioncortex = "0.6.0"
visioncortex = "0.8.1"
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
+6 -2
View File
@@ -35,7 +35,11 @@ document.addEventListener('paste', function (e) {
// Download as SVG
document.getElementById('export').addEventListener('click', function (e) {
const blob = new Blob([new XMLSerializer().serializeToString(svg)], {type: 'octet/stream'}),
const blob = new Blob([
`<?xml version="1.0" encoding="UTF-8"?>\n`,
`<!-- Generator: visioncortex VTracer -->\n`,
new XMLSerializer().serializeToString(svg)
], {type: 'octet/stream'}),
url = window.URL.createObjectURL(blob);
this.href = url;
@@ -444,7 +448,7 @@ class ConverterRunner {
this.converter.init();
this.stopped = false;
if (clustering_mode == 'binary') {
svg.style.background = '#000';
svg.style.background = '#fff';
canvas.style.display = 'none';
} else {
svg.style.background = '';
+1 -1
View File
@@ -77,7 +77,7 @@ impl BinaryImageConverter {
self.params.max_iterations,
self.params.splice_threshold
);
let color = Color::color(&ColorName::White);
let color = Color::color(&ColorName::Black);
self.svg.prepend_path(
&paths,
&color,
+84 -3
View File
@@ -1,6 +1,6 @@
use wasm_bindgen::prelude::*;
use visioncortex::PathSimplifyMode;
use visioncortex::color_clusters::{IncrementalBuilder, Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX};
use visioncortex::{Color, ColorImage, PathSimplifyMode};
use visioncortex::color_clusters::{Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX, IncrementalBuilder, KeyingAction};
use crate::canvas::*;
use crate::svg::*;
@@ -8,6 +8,8 @@ use crate::svg::*;
use serde::Deserialize;
use super::util;
const KEYING_THRESHOLD: f32 = 0.2;
#[derive(Debug, Deserialize)]
pub struct ColorImageConverterParams {
pub canvas_id: String,
@@ -67,7 +69,26 @@ impl ColorImageConverter {
pub fn init(&mut self) {
let width = self.canvas.width() as u32;
let height = self.canvas.height() as u32;
let image = self.canvas.get_image_data_as_color_image(0, 0, width, height);
let mut image = self.canvas.get_image_data_as_color_image(0, 0, width, height);
let key_color = if Self::should_key_image(&image) {
if let Ok(key_color) = Self::find_unused_color_in_image(&image) {
for y in 0..height as usize {
for x in 0..width as usize {
if image.get_pixel(x, y).a == 0 {
image.set_pixel(x, y, &key_color);
}
}
}
key_color
} else {
Color::default()
}
} else {
// The default color is all zeroes, which is treated by visioncortex as a special value meaning no keying will be applied.
Color::default()
};
let runner = Runner::new(RunnerConfig {
diagonal: self.params.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
@@ -78,6 +99,12 @@ impl ColorImageConverter {
is_same_color_b: 1,
deepen_diff: self.params.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: if self.params.hierarchical == "cutout" {
KeyingAction::Keep
} else {
KeyingAction::Discard
},
}, image);
self.stage = Stage::Clustering(runner.start());
}
@@ -108,6 +135,8 @@ impl ColorImageConverter {
is_same_color_b: 1,
deepen_diff: 0,
hollow_neighbours: 0,
key_color: Default::default(),
keying_action: KeyingAction::Discard,
}, image);
self.stage = Stage::Reclustering(runner.start());
},
@@ -167,4 +196,56 @@ impl ColorImageConverter {
}) as i32
}
fn color_exists_in_image(img: &ColorImage, color: Color) -> bool {
for y in 0..img.height {
for x in 0..img.width {
let pixel_color = img.get_pixel(x, y);
if pixel_color.r == color.r && pixel_color.g == color.g && pixel_color.b == color.b {
return true
}
}
}
false
}
fn find_unused_color_in_image(img: &ColorImage) -> Result<Color, String> {
let special_colors = IntoIterator::into_iter([
Color::new(255, 0, 0),
Color::new(0, 255, 0),
Color::new(0, 0, 255),
Color::new(255, 255, 0),
Color::new(0, 255, 255),
Color::new(255, 0, 255),
Color::new(128, 128, 128),
]);
for color in special_colors {
if !Self::color_exists_in_image(img, color) {
return Ok(color);
}
}
Err(String::from("unable to find unused color in image to use as key"))
}
fn should_key_image(img: &ColorImage) -> bool {
if img.width == 0 || img.height == 0 {
return false;
}
// Check for transparency at several scanlines
let threshold = ((img.width * 2) as f32 * KEYING_THRESHOLD) as usize;
let mut num_transparent_pixels = 0;
let y_positions = [0, img.height / 4, img.height / 2, 3 * img.height / 4, img.height - 1];
for y in y_positions {
for x in 0..img.width {
if img.get_pixel(x, y).a == 0 {
num_transparent_pixels += 1;
}
if num_transparent_pixels >= threshold {
return true;
}
}
}
false
}
}
-3
View File
@@ -1,6 +1,3 @@
mod binary_image;
mod color_image;
mod util;
pub use binary_image::*;
pub use color_image::*;