157 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
Chris Tsang d5dfa9fd73 Readme 2023-07-24 22:55:26 +08:00
Chris Tsang 685009bd21 Allow filter_speckle to be 0 #47 2023-06-30 05:45:35 +08:00
Chris Tsang 971bffa948 Mentions Aliyun
Foot note: I was in touch with one of their engineers
2022-11-10 17:53:12 +08:00
Chris Tsang 7be6882d6b Readme 2022-10-14 00:32:02 +08:00
Chris Tsang 386a0bcaab Filestar 2022-10-14 00:29:13 +08:00
Chris Tsang 82284ab470 0.5.0 2022-10-09 18:01:35 +08:00
Chris Tsang ead104f0ce Tweak should_key_image 2022-10-09 17:21:00 +08:00
zachwolfe a7219fd370 Alpha channel handling in CLI (#23)
* Support transparent color images

* Remove unnecessary conditional

* Add temporary git url to visioncortex dependency

* Use fastrand instead of rand

* Reduce the number of random iterations when keying

* Add heuristic to avoid expensive calculations for non-transparent input

* Add three additional special keying colours

* Add transparency check to some inner pixels
2022-10-09 15:41:43 +08:00
Oskar Skuteli 2448dbb3ba fix docs typo (#25) 2022-09-25 15:06:19 +08:00
Chris Tsang c9d9073b17 Merge pull request #16 from wolfgangmeyers/fix-converter-warning
Fix deprecation warning from converter.rs
2022-02-11 21:45:33 +08:00
Wolfgang Meyers 47091b0bf5 Fix deprecation warning from converter.ts 2022-02-10 18:13:51 -08:00
Chris Tsang 8439cc995d Refactor path_precision 2021-07-27 21:01:20 +08:00
Chris Tsang 0b292ad35f Readme 2021-07-24 16:48:47 +08:00
Chris Tsang ebb17ac22b vtracer-webapp 2021-07-24 16:37:41 +08:00
Chris Tsang 227850dd83 Release 2021-07-24 15:58:51 +08:00
Chris Tsang e0dac19c31 Article link 2021-07-24 15:42:35 +08:00
Chris Tsang d18d4f8b81 Readme 2021-07-24 00:00:59 +08:00
Chris Tsang 60a4ce579f Release 2021-07-23 23:35:54 +08:00
Chris Tsang c1c09d964c Readme 2021-07-23 23:35:54 +08:00
Chris Tsang c2a5626afa 0.4.0 2021-07-23 23:35:54 +08:00
Bobby Ng d0593e716a SVG path string numeric precision 2021-07-23 23:35:45 +08:00
Chris Tsang 9ce7df176a Update .gitattributes 2021-07-23 18:29:12 +08:00
Chris Tsang 5928c0a7f5 Update screenshots 2021-03-01 21:13:24 +08:00
Chris Tsang b2c95a50cd Update README.md 2021-03-01 21:13:24 +08:00
Chris Tsang bcd3ffb40e Update COPYRIGHT 2021-02-07 14:22:26 +08:00
Chris Tsang 20da3efd3c Create .gitattributes 2021-01-25 00:57:44 +08:00
Chris Tsang 0c27d1f06a Update README.md 2021-01-25 00:53:01 +08:00
Chris Tsang 0c7c7fc808 Demo fixup 2021-01-24 22:12:01 +08:00
Chris Tsang 97ac767844 0.3.0 2021-01-24 21:31:55 +08:00
Chris Tsang 0b37051d40 Cutout mode 2021-01-24 21:31:08 +08:00
Chris Tsang 2695d7b59b Release (with cutout mode) 2021-01-24 21:17:18 +08:00
Chris Tsang 433a68d6d6 Update to visioncortex 0.4.0 2020-12-19 01:34:01 +08:00
Chris Tsang bb3b66780b UI tweaks 2020-12-18 00:32:35 +08:00
Chris Tsang 049ba7f503 UI tweaks 2020-12-18 00:19:57 +08:00
Chris Tsang 9d67b7c554 Release 2020-12-18 00:14:55 +08:00
Chris Tsang fa5d2906c0 Change sample artwork 2020-12-09 15:21:12 +08:00
Chris Tsang fae37a709e Update README.md 2020-12-09 14:51:15 +08:00
Chris Tsang 5c9d5cd757 Example for pixel art 2020-12-09 14:36:07 +08:00
Chris Tsang 1b49880f85 Create rust.yml 2020-11-15 21:10:13 +08:00
Chris Tsang 0b0d69839a Release 2020-11-15 17:33:32 +08:00
Chris Tsang 7ea4600176 Readme 2020-11-15 16:54:46 +08:00
Chris Tsang 20187e0993 0.2.0 2020-11-15 16:27:37 +08:00
Chris Tsang 04d575dec6 UI tweaks 2020-11-15 16:03:26 +08:00
Chris Tsang c8f93acf04 Release 2020-11-09 12:17:55 +08:00
Chris Tsang a33a659b22 Fix memory leak 2020-11-09 12:17:48 +08:00
Chris Tsang 31c3f109a8 Release 2020-11-09 01:00:21 +08:00
Chris Tsang 5f18837b61 Relative & compound path 2020-11-09 00:38:44 +08:00
Chris Tsang 99cb79895b Move license files 2020-11-07 19:20:17 +08:00
Chris Tsang 08483eb7a8 Clarity tracking code 2020-11-07 19:15:30 +08:00
Chris Tsang b5f8753410 Update Readme.md 2020-11-01 17:37:11 +08:00
Chris Tsang 6b86baad75 Add download link 2020-11-01 14:59:40 +08:00
Chris Tsang ba0ab63a92 vtracer 0.1.1 2020-11-01 14:54:33 +08:00
Chris Tsang 3df69f6274 svg namespace 2020-11-01 14:52:44 +08:00
Chris Tsang 5efd479885 Docs 2020-10-31 19:18:45 +08:00
Chris Tsang 105bdfc7a3 Space 2020-10-31 19:16:41 +08:00
Chris Tsang d8b6d1cf33 Docs 2020-10-31 19:12:15 +08:00
Chris Tsang cbb6c97ce4 Publish to crates.io 2020-10-31 18:49:45 +08:00
Sanford Pun 0c4fa1d742 CMD app 2020-10-31 17:42:04 +08:00
Chris Tsang 43c403e06f Release 2020-10-31 16:59:19 +08:00
Chris Tsang 3c2d4f3c8c Tweaks 2020-10-31 01:01:23 +08:00
Chris Tsang 956fd4fa57 Release 2020-10-31 00:26:21 +08:00
Chris Tsang c8f4fc1133 Update Cargo 2020-10-29 11:41:06 +08:00
Chris Tsang 2243d107d5 Runs faster 2020-10-29 11:20:33 +08:00
Chris Tsang 7a62846470 Docs 2020-10-25 15:43:40 +08:00
Chris Tsang 46c49e5867 Docs 2020-10-25 15:06:19 +08:00
Sanford Pun 1544f54b99 Credit 2020-10-25 14:51:19 +08:00
Chris Tsang 00375567cc Touch 2020-10-25 14:49:59 +08:00
Chris Tsang 1d3407eec1 Docs 2020-10-25 14:46:26 +08:00
Chris Tsang 2b2e08d311 License 2020-10-25 14:29:08 +08:00
Chris Tsang f6f4839185 Source Release 2020-10-25 14:22:42 +08:00
Chris Tsang f0a1369dc4 Docs 2020-10-24 21:57:16 +08:00
Chris Tsang 2c423ae43e Docs 2020-10-24 12:03:23 +08:00
Chris Tsang e1bf11d531 Tweaks 2020-10-23 16:25:45 +08:00
Chris Tsang 69b7af1b30 Tweaks 2020-10-23 12:34:24 +08:00
Chris Tsang f0fcb7b4cc icon 2020-10-23 12:17:03 +08:00
Chris Tsang 2aae302edf touch 2020-10-23 12:04:01 +08:00
Chris Tsang 445d32927c Initial Release 2020-10-23 11:59:59 +08:00
84 changed files with 5659 additions and 9815 deletions
-32
View File
@@ -5,38 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## 1.0.0-alpha.3 - 2026-08-01
### Added
* `vtracer-bench`: a blind fidelity benchmark for raster-to-vector tracers — it compares an original raster against a rendered reconstruction and reports one 0..1 score built from PSNR, SSIM, and a clustered-diff "missing patch" metric (geometric mean, so a single collapsed axis drags the score down). Blind to how the reconstruction was produced: render any tracer's output to pixels and score it. A new workspace crate, separate from the four shipped packages.
### Fixed
* Watershed no longer leaks a region along a blurred low-contrast crack as a 1-px filament (a slightly soft image could grow a hairline of one region's color running tens of px down a neighbouring boundary). The boundary snap's mixture gate now also admits pixels that blend two *neighbouring* regions — a blend band belongs to its closer flank even when the basin cut misattributed it to a distant region. On the blurred striped synthetic the circle's max boundary error drops from 31.6 px to 1.5 px; crisp images are byte-unaffected.
## 1.0.0-alpha.2 - 2026-07-27
### Added
* Watershed clustering (`--clustering watershed`): a new region-forming frontend — a hierarchical watershed on the pixel graph (Cousty et al. 2009; Najman et al. 2013), controlled by one dial, `--watershed-detail` (0..=255; each +25.5 roughly doubles the region count). Regions follow image content, with no watershed-line pixels.
* Boundaries come out calm: antialiased pixels snap to the color-midpoint iso-line instead of meandering with the noise inside the ramp.
* `stacked` stacks the merge tree itself (coarse ancestors below, refined regions on top), so overdraw stays seam-free.
* `cutout` gets the partition natively; neighbouring faces closer than `max(2, (255 detail) / 8)` merge, so faces a human cannot tell apart never survive as separate patches.
* `WatershedHierarchy` is public, split into `build` (expensive, image-only) and `cut` (near-instant); `Session` re-cuts a cached hierarchy on detail changes, making the slider fully interactive (~25 ms vs ~40 ms on a 1400×775 photo).
* Curve simplification (`--simplify <tolerance>`, `Config::simplify`, `simplify` in Python and Node; off by default): a paper.js-style Schneider re-fit — each smooth run between corners is redrawn with the fewest cubics that stay within the tolerance (px). Roughly halves file size (sample photo at tolerance 1: 229 → 138 KB stacked, 103 → 36 KB watershed cutout). Runs on fitted geometry before composition, so cutout simplifies each shared boundary once and stays seam-free; corners and junction endpoints stay pinned.
* Binary thresholding: a tunable fixed threshold (`--threshold`) and BradleyRoth adaptive thresholding for uneven lighting (`--adaptive`, `--adaptive-window`, `--adaptive-t`) — also on `Config`, Python, and Node.
* Cutout mode merges neighbouring faces whose colors are within one gradient step, rejoining the near-identical faces that stacked gradient layering splits a smooth area into.
### Changed
* `color_mode` is replaced by `clustering` (`color-cluster` | `bw` | `watershed`) across the CLI, Rust, Python, and Node — the field selects the region-forming algorithm, not a color space.
* The spline fine-tuning flags (`--corner-threshold`, `--segment-length`, `--splice-threshold`) are hidden from CLI help — still accepted, but without their `-c`/`-l`/`-s` short forms. The defaults serve virtually every conversion; `--simplify` supersedes them.
### Fixed
* Spline fitting no longer swings far away from the outline around thin strands (a long-standing defect, fixed via visioncortex 0.9.1): a sparse splice slice could be fitted by a single cubic that passed through every sample yet ballooned up to ~30 px sideways between them. Slices are now densified before fitting and multi-cubic fits kept in full, in both stacked mode and the mosaic fitter.
## 1.0.0-alpha.1 - 2026-07-24
Ground-up rewrite of VTracer into a **vectorization framework** with pluggable stages.
+6 -8
View File
@@ -3,7 +3,6 @@
members = [
"crates/vtracer",
"crates/vtracer-cli",
"crates/vtracer-bench",
]
# The pre-1.0 webapp is kept in the tree for now but is no longer part of the
@@ -19,16 +18,15 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.0.0-alpha.3"
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <chris.2y3@outlook.com>"]
edition = "2024"
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.1"
# Schneider curve fitting for the simplify pass. visioncortex pins an old
# flo_curves internally for legacy reasons; we depend on the current one
# directly and convert at the call boundary.
flo_curves = "0.8"
visioncortex = "0.9"
# For local development against an unreleased visioncortex, add a patch:
# [patch.crates-io]
# visioncortex = { path = "../visioncortex" }
+19 -109
View File
@@ -8,32 +8,15 @@
</p>
<h3>
<a href="https://github.com/visioncortex/vtracer/releases">Releases</a>
<a href="https://www.visioncortex.org/vtracer-docs">Article</a>
<span> | </span>
<a href="https://www.visioncortex.org/vtracer/">Web App</a>
<span> | </span>
<a href="https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.3/VTracer_1.0.0-alpha.3_x64-setup.exe">Windows App</a>
<a href="https://github.com/visioncortex/vtracer/releases">Download</a>
</h3>
<p>
<a href="https://crates.io/crates/vtracer"><img src="https://img.shields.io/crates/v/vtracer.svg?label=crates.io" alt="Rust library on crates.io"></a>
<a href="https://pypi.org/project/vtracer/"><img src="https://img.shields.io/pypi/v/vtracer.svg?label=PyPI" alt="Python package on PyPI"></a>
<a href="https://www.npmjs.com/package/@visioncortex/vtracer"><img src="https://img.shields.io/npm/v/@visioncortex/vtracer.svg?label=npm" alt="Node package on npm"></a>
</p>
</div>
## Packages
VTracer 1.0 is a vectorization **framework** (pluggable frontends, curve fitters, color fitting, and output optimization) shipped across four surfaces from this repository:
| Package | Registry | Source | Use |
| --- | --- | --- | --- |
| `vtracer-cli` | [crates.io](https://crates.io/crates/vtracer-cli) | [`crates/vtracer-cli`](crates/vtracer-cli) | Command-line tool (`vtracer` binary) |
| `vtracer` | [crates.io](https://crates.io/crates/vtracer) | [`crates/vtracer`](crates/vtracer) | Rust library / the framework core |
| `vtracer` | [PyPI](https://pypi.org/project/vtracer/) | [`crates/vtracer-py`](crates/vtracer-py) | Python native extension (pyo3 + maturin) |
| `@visioncortex/vtracer` | [npm](https://www.npmjs.com/package/@visioncortex/vtracer) | [`nodejs`](nodejs) | Node.js WebAssembly build, no native dependency |
## Introduction
visioncortex VTracer is an open source software to convert raster images (like jpg & png) into vector graphics (svg). It can vectorize graphics and photographs and trace the curves to output compact vector files.
@@ -46,20 +29,11 @@ VTracer is originally designed for processing high resolution scans of historic
Technical descriptions of the [tracing algorithm](https://www.visioncortex.org/vtracer-docs) and [clustering algorithm](https://www.visioncortex.org/impression-docs).
## Desktop App
## Desktop App (coming soon)
![screenshot](docs/images/desktop-app.png)
![screenshot](docs/images/screenshot-01.png)
The familiar web-app workflow, now on the native 1.0 engine:
+ **Native speed** — the engine runs natively instead of in-browser wasm, so conversions are quicker
+ **A/B comparator** — a sliding split view to check the trace against the original
+ **Curve inspector** — examine the fitted curves up close
+ **Seam-free cutout** — a true gapless tessellation with shared boundaries, no cracks between shapes
+ **Watershed clustering** — content-adaptive regions that follow object shape, with one interactive detail slider
+ **Curve simplification** — the fewest curves within a pixel tolerance, typically halving file size
+ **Adaptive B/W thresholding** — handles scans and photos with uneven lighting
+ **Fixed color palettes** — snap the output to your own colors
![screenshot](docs/images/screenshot-02.png)
## Cmd App
@@ -84,33 +58,24 @@ Options:
-i, --input <INPUT> Path to the input raster image
-o, --output <OUTPUT> Path to the output SVG
--preset <PRESET> Start from a preset: bw, poster, photo
--clustering <CLUSTERING> Region forming: `color-cluster` (default), `bw`, `watershed`
--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)
--simplify <TOLERANCE> Simplify curves: fewest cubics within this tolerance in px (try 12.5)
-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+cleanup, 2 = + shorthands
--threshold <THRESHOLD> Binary mode: fixed threshold 0..=255 (foreground below it)
--adaptive Binary mode: BradleyRoth adaptive threshold (uneven lighting)
--adaptive-window <ADAPTIVE_WINDOW> Adaptive window size in px (0 = auto); implies --adaptive
--adaptive-t <ADAPTIVE_T> Adaptive sensitivity: % below local mean (default 15)
--watershed-detail <WATERSHED_DETAIL> Watershed: hierarchy cut level 0..=255 (higher = more regions)
--optimize <OPTIMIZE> Output optimization: 0 = off, 1 = quantize+simplify, 2 = + shorthands
-h, --help Print help
-V, --version Print version
```
The spline fine-tuning flags `--corner-threshold <0..=180>`,
`--segment-length <3.5..=10>`, and `--splice-threshold <0..=180>` are still
accepted but hidden from `--help`: their defaults (60 / 4 / 45) serve
virtually every conversion, and `--simplify` is the knob that actually moves
output size and smoothness.
### New in 1.0
- **Positional arguments** — `vtracer in.png out.svg`.
@@ -118,23 +83,14 @@ output size and smoothness.
tessellation with shared boundaries), replacing the old re-clustered cutout.
- **`--palette` / `--palette-file`** — snap colors to a fixed palette
(nearest in OKLab); **`--max-colors`** auto-quantizes the palette.
- **Binary thresholding** — a tunable fixed cutoff (`--threshold`) or
**BradleyRoth adaptive** thresholding (`--adaptive`, with `--adaptive-window`
/ `--adaptive-t`) for scans with uneven lighting.
- **`--simplify <tolerance>`** — paper.js-style curve simplification: re-fits
smooth runs with the fewest cubics that stay within the tolerance (px),
typically halving file size; seam-free in cutout mode because shared
boundaries are simplified once for both faces.
- **`--clustering watershed`** — an alternative region-forming algorithm: a
hierarchical watershed on the pixel graph (Cousty et al., TPAMI 2009; Najman,
Cousty & Perret, ISMM 2013), cut at `--watershed-detail`. Content-adaptive
regions that follow object shape — pairs beautifully with `cutout`.
- **`--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:
You can also install the program from source from [crates.io/vtracer](https://crates.io/crates/vtracer):
```sh
cargo install vtracer-cli
@@ -151,15 +107,9 @@ cargo install vtracer-cli
# black & white line art
./vtracer input.jpg output.svg --preset bw
# scanned/photographed line art with uneven lighting
./vtracer scan.jpg output.svg --clustering bw --adaptive
# seam-free mosaic (gapless tessellation)
./vtracer input.jpg output.svg --hierarchical cutout
# watershed region forming, cut to taste
./vtracer photo.jpg output.svg --clustering watershed --watershed-detail 192
# constrain to a fixed palette
./vtracer input.jpg output.svg --palette '#1b1b1b,#e0c088,#5a7d3c,#8fb0d0'
```
@@ -169,44 +119,15 @@ cargo install vtracer-cli
You can install [`vtracer`](https://crates.io/crates/vtracer) as a Rust library.
```sh
cargo add vtracer@1.0.0-alpha.3
cargo add vtracer
```
```rust
use vtracer::{ColorImage, Config, FitMode, Hierarchical, Preset, Session};
// Decode with whatever you like, then hand over pixels.
let raw = image::open("in.png")?.to_rgba8();
let (width, height) = (raw.width() as usize, raw.height() as usize);
let img = ColorImage { pixels: raw.into_raw(), width, height };
// one-liner
let svg = Config::default().build()?.to_svg(&img)?;
// presets + per-field config
let mut cfg = Config::from_preset(Preset::Poster);
cfg.mode = FitMode::Polygon;
cfg.hierarchical = Hierarchical::Cutout; // seam-free mosaic
cfg.max_colors = Some(8);
let svg = cfg.build()?.to_svg(&img)?;
```
Split the pipeline when you want the stages separately — `segment` caches, `finish` re-runs:
```rust
let pipeline = cfg.build()?;
let seg = pipeline.segment(&img)?; // the expensive part
let doc = pipeline.finish(&seg)?; // VectorDoc, ready to serialize
```
See [docs.rs/vtracer](https://docs.rs/vtracer/1.0.0-alpha.3/vtracer/) for the full API.
### Python Library
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension.
[`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).
```sh
pip install vtracer==1.0.0a3
pip install vtracer
```
```python
@@ -221,24 +142,16 @@ cfg = vtracer.Config(mode="polygon", hierarchical="cutout")
cfg.palette = ["#1b1b1b", "#e0c088", "#5a7d3c"]
svg = cfg.convert_bytes(data)
vtracer.Config.poster().convert_file("photo.jpg", "poster.svg")
# watershed region forming
ws = vtracer.Config(clustering="watershed", watershed_detail=192)
svg = ws.convert_file("photo.jpg", "photo.svg")
# binary with adaptive (BradleyRoth) thresholding
bw = vtracer.Config(clustering="bw", adaptive=True)
svg = bw.convert_file("scan.jpg", "scan.svg")
```
See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
### Node.js Library
[`@visioncortex/vtracer`](https://www.npmjs.com/package/@visioncortex/vtracer) is available for Node as a WebAssembly build (from the [`nodejs`](nodejs/README.md) package) — image decoding and vectorization both run in wasm, so there is **no native dependency**. Decodes PNG, JPEG, GIF, BMP, and WebP; for other formats, decode yourself and pass raw RGBA to `convertPixels`.
[`@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@1.0.0-alpha.3
npm install @visioncortex/vtracer
```
```js
@@ -246,10 +159,7 @@ const vtracer = require('@visioncortex/vtracer');
await vtracer.convertFile('in.png', 'out.svg', { mode: 'polygon' });
const svg = vtracer.convertBuffer(buffer, { preset: 'poster' });
const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
// binary with adaptive thresholding
const bw = vtracer.convertBuffer(buffer, { clustering: 'bw', adaptive: true });
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
```
## Citations
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "vtracer-bench"
description = "Blind fidelity benchmark for raster-to-vector tracers: compare the original raster with a rendered reconstruction and get one 0..1 fidelity score built from PSNR, SSIM and a clustered-diff patch metric."
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
categories = ["graphics", "development-tools::testing"]
keywords = ["vectorization", "benchmark", "fidelity", "ssim", "psnr"]
[dependencies]
visioncortex.workspace = true
dssim-core = "3"
rgb = "0.8"
# Decode-only: trimmed to real input formats (drops the AV1 encoder + OpenEXR).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
-104
View File
@@ -1,104 +0,0 @@
# vtracer-bench
Blind fidelity benchmark for raster-to-vector tracers.
It compares an **original raster** with a **rendered reconstruction** and reports one number — a fidelity score in **[0, 1]** — built from three complementary axes. It is *blind* in the sense that it knows nothing about how the reconstruction was produced: any tracer, any format, any renderer. Render your vector output to pixels (same dimensions as the original), then let the benchmark judge.
```console
$ vtracer-bench original.png reconstruction.png
psnr 34.77 dB (rmse 4.66) -> 0.6875
ssim 0.99541 (dssim 0.00461) -> 0.9954
patch 157.8 px rms (14503 bad px, 74 clusters, largest 73) -> 0.9726
fidelity 0.9022
[csv] 0.9022,34.77,0.00461,4.66,157.8,0.6875,0.9954,0.9726
```
## Why another metric?
Every classic metric has a blind spot, and tracers exploit all of them:
- **PSNR** over-values invisible dust and undersells small salient regions — a tracer that drops an eye but nails the background can post a great PSNR.
- **SSIM** tracks perceived quality well, but averages globally: a small, fully-lost region barely moves it.
- Neither can tell **a thousand scattered ±1 pixels** apart from **one coherent missing patch** of the same total mass — and the missing patch is the failure that actually matters.
`vtracer-bench` scores all three axes and combines them so that no single blind spot survives:
| axis | raw metric | subscore in [0, 1] |
| --- | --- | --- |
| `psnr` | sRGB PSNR over RGB | `1 log(1+rmse) / log(256)` |
| `ssim` | multiscale DSSIM (`dssim-core`) | `SSIM = 1 / (1 + DSSIM)` |
| `patch` | clustered-diff "missing patch" detector | `2^(P / 0.005)` |
**fidelity = ( psnr¹ · ssim² · patch¹ ) ^ (1/4)** — a *weighted geometric mean*. Geometric, not arithmetic, so a single collapsed axis drags the composite down: a missing face region cannot hide behind good global PSNR. SSIM carries double weight because it tracks visual accuracy best and is the axis most robust to an imperfect source.
## The three axes
### psnr — parameter-free squash
The squash `1 log(1+rmse)/log(256)` is anchored at the only two natural error scales an 8-bit image has:
- `rmse = 255` (the full range — noise indistinguishable from a random image) → **0**
- `rmse ≤ 1` (the quantization step — errors 8-bit can barely represent) → saturates to **1**
For `rmse ≫ 1` it equals `psnr / 48.13 dB`, i.e. it stays linear in decibels, but with no hand-picked anchor constants.
### ssim — perceptual structure
`dssim-core` computes multiscale structural dissimilarity `d = 1/SSIM 1`; the subscore is simply `SSIM = 1/(1+d)`, already a natural 0..1. Differences the eye can't see score ~1 regardless of how many pixels they touch.
### patch — the missing-patch detector
This is the axis PSNR and SSIM both lack:
1. A pixel is **bad** iff its RGB Euclidean distance to the original exceeds `--thresh` (default 24 — roughly 14 per channel).
2. The bad mask is **opened** (one round of 4-connected erode + dilate). A slightly blurred or recompressed *source* shifts every edge and paints ≤2 px filaments along all boundaries; those vanish under the opening, while genuine missing patches survive. This is what makes the benchmark tolerant of mildly compressed or blurred originals.
3. The surviving mask is clustered (4-connected). With cluster areas `aᵢ`, the **patch mass** is `√(Σ aᵢ²)` — a sum of *squares*, so one coherent blob dominates any amount of scattered dust of equal total area.
4. With `P = patch mass / (w·h)`, the subscore is `2^(P/0.005)`: a single coherent blob at 0.5 % of image mass halves the score; scattered dust barely dents it.
## Calibration
Scored on a 768×1024 flat-shaded illustration, comparing the original against distorted versions of **itself** — this is how much slack the benchmark gives an imperfect source, and what the top of the scale means:
| candidate | psnr | ssim | patch | **fidelity** |
| --- | --- | --- | --- | --- |
| the original itself | 1.000 | 1.000 | 1.000 | **1.0000** |
| JPEG quality 95 | 0.816 | 1.000 | 1.000 | **0.9502** |
| JPEG quality 75 | 0.718 | 0.999 | 0.994 | **0.9186** |
| 0.8 px Gaussian blur | 0.596 | 0.995 | 0.861 | **0.8443** |
Rule of thumb: **≥ 0.95** is visually indistinguishable, **≥ 0.90** is a faithful trace, **≤ 0.80** has visible geometry or color errors, and a score that *collapses* while PSNR/SSIM stay high means the patch axis found a coherent missing region — look at the `--mask` output.
## Usage
### CLI
```console
vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
```
- `original`, `candidate` — rasters of identical dimensions (any format `image` decodes). Rendering an SVG to pixels is deliberately out of scope: use the renderer whose output you actually ship (resvg, Chromium, librsvg, …) so the benchmark judges what users see.
- `--thresh N` — RGB Euclidean bad-pixel gate for the patch axis (default 24).
- `--mask out.png` — write the raw bad-pixel mask (before the opening) for visual inspection.
The last stdout line is machine-readable:
```
[csv] fidelity,psnr,dssim,rmse,patch_mass,s_psnr,s_ssim,s_patch
```
(RMSE is reported for reference but carries no weight — it is the same MSE that PSNR measures, only on a linear curve; scoring both would double-weight one error.)
### Library
```rust
use vtracer_bench::{fidelity, DEFAULT_THRESH};
// orig and cand are interleaved RGB8, both w×h
let (report, bad_mask) = fidelity(&orig, &cand, w, h, DEFAULT_THRESH);
println!("fidelity {:.4} (psnr {:.2} dB, dssim {:.5})",
report.fidelity, report.psnr, report.dssim);
```
`FidelityReport` exposes every raw metric and subscore; the tuning constants (`PATCH_HALF`, `DEFAULT_THRESH`, and the `W_PSNR`/`W_SSIM`/`W_PATCH` weights) are public and documented in `lib.rs`.
The benchmark is fully deterministic: identical inputs produce byte-identical output.
-247
View File
@@ -1,247 +0,0 @@
//! Universal tracer fidelity benchmark — original vs reconstruction, blind to
//! how the reconstruction was made. Three raw metrics, each squashed to [0,1],
//! composed by geometric mean into ONE fidelity score (0 = garbage, 1 = exact):
//!
//! psnr sRGB PSNR over RGB. Squash: 1 log(1+rmse)/log(256) — anchored
//! at the two natural scales of 8-bit imagery and nothing else:
//! rmse = 255 (full range) → 0, rmse ≤ 1 (the quantization step)
//! saturates to 1. Equals psnr/48.13dB for rmse ≫ 1, i.e. still
//! linear in dB, without arbitrary anchor constants.
//! ssim dssim-core multiscale DSSIM d (= 1/SSIM 1) → SSIM = 1/(1+d),
//! already a natural 0..1.
//! patch the "missing patch" / systematic-bias detector: bad ⟺ RGB
//! Euclidean diff > thresh, OPEN the bad mask (1-round 4-conn
//! erode+dilate — a slightly blurred or compressed source shifts
//! every edge and paints ≤2px filaments along all boundaries; those
//! vanish, real patches survive), then cluster it (visioncortex,
//! 4-conn), S = Σ area². Patch mass fraction P = √S / (w·h) — the RMS
//! coherent-blob size as a fraction of the image. Squash: 2^(P/0.005),
//! so ONE coherent blob at 0.5% image mass halves the score while the
//! same pixel count scattered as dust barely dents it. Exactly the
//! failure mode PSNR/SSIM average away.
//!
//! Composite: weighted geometric mean, fidelity = (psnr¹ · ssim² · patch¹)^(1/4).
//! Geometric (not arithmetic) so a single collapsed axis drags the composite
//! down — a missing eye can't hide behind good global PSNR. SSIM carries double
//! weight: it tracks visual accuracy best and is the axis most robust to a
//! mildly compressed or blurred source.
use visioncortex::BinaryImage;
/// Patch mass fraction that halves the patch subscore.
pub const PATCH_HALF: f64 = 0.005;
/// Default RGB Euclidean distance for a pixel to count as "bad".
pub const DEFAULT_THRESH: f64 = 24.0;
/// Composite weights (geometric): fidelity = (psnr^1 · ssim^2 · patch^1)^(1/4).
pub const W_PSNR: f64 = 1.0;
pub const W_SSIM: f64 = 2.0;
pub const W_PATCH: f64 = 1.0;
#[derive(Debug, Clone, Copy)]
pub struct FidelityReport {
// raw
pub psnr: f64,
pub dssim: f64,
/// sRGB RMSE — reported for reference, carries no weight (PSNR is the
/// same MSE on a log curve; scoring both would double-weight it)
pub rmse: f64,
/// bad pixels (‖Δrgb‖ > thresh), before the opening
pub bad_px: usize,
/// 4-conn clusters of bad pixels after the opening
pub clusters: usize,
/// largest cluster area (px)
pub largest: usize,
/// √(Σ area²) — RMS coherent-blob mass, in px
pub patch_mass: f64,
// subscores in [0,1]
pub s_psnr: f64,
pub s_ssim: f64,
pub s_patch: f64,
/// geometric mean of the three subscores
pub fidelity: f64,
}
fn dssim_score(a_rgb: &[u8], b_rgb: &[u8], w: usize, h: usize) -> f64 {
let d = dssim_core::Dssim::new();
let to = |buf: &[u8]| {
let px: Vec<rgb::RGB<u8>> =
(0..w * h).map(|i| rgb::RGB { r: buf[i * 3], g: buf[i * 3 + 1], b: buf[i * 3 + 2] }).collect();
d.create_image_rgb(&px, w, h).expect("dssim image")
};
let (val, _) = d.compare(&to(a_rgb), &to(b_rgb));
val.into()
}
/// Compare an original against a candidate reconstruction, both RGB8, w×h.
/// `thresh` is the RGB Euclidean bad-pixel gate (use [`DEFAULT_THRESH`]).
/// Returns the report plus the bad-pixel mask (255/0, one byte per pixel).
pub fn fidelity(orig_rgb: &[u8], cand_rgb: &[u8], w: usize, h: usize, thresh: f64) -> (FidelityReport, Vec<u8>) {
assert_eq!(orig_rgb.len(), w * h * 3);
assert_eq!(cand_rgb.len(), w * h * 3);
// PSNR + RMSE + bad-pixel binarization in one pass
let mut sse = 0f64;
let mut mask = vec![0u8; w * h];
let mut bad_px = 0usize;
let t2 = thresh * thresh;
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let mut d2 = 0f64;
for c in 0..3 {
let e = orig_rgb[i * 3 + c] as f64 - cand_rgb[i * 3 + c] as f64;
d2 += e * e;
}
sse += d2;
if d2 > t2 {
mask[i] = 255;
bad_px += 1;
}
}
}
let rmse = (sse / (w * h * 3) as f64).sqrt();
let psnr = 20.0 * (255.0 / rmse.max(1e-6)).log10();
let dssim = dssim_score(orig_rgb, cand_rgb, w, h);
// opening: 1-round 4-conn erode + dilate. Edge-shift filaments (≤2px wide,
// the signature of a slightly blurred/compressed source) vanish; genuine
// missing patches survive. The reported mask keeps the raw bad pixels.
let at = |m: &[u8], x: i64, y: i64| {
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && m[y as usize * w + x as usize] != 0
};
let mut eroded = vec![0u8; w * h];
for y in 0..h as i64 {
for x in 0..w as i64 {
if at(&mask, x, y)
&& at(&mask, x - 1, y)
&& at(&mask, x + 1, y)
&& at(&mask, x, y - 1)
&& at(&mask, x, y + 1)
{
eroded[y as usize * w + x as usize] = 255;
}
}
}
let mut bin = BinaryImage::new_w_h(w, h);
for y in 0..h as i64 {
for x in 0..w as i64 {
if at(&eroded, x, y)
|| at(&eroded, x - 1, y)
|| at(&eroded, x + 1, y)
|| at(&eroded, x, y - 1)
|| at(&eroded, x, y + 1)
{
bin.set_pixel(x as usize, y as usize, true);
}
}
}
let sizes: Vec<usize> = bin.to_clusters(false).iter().map(|c| c.size()).collect();
let largest = sizes.iter().copied().max().unwrap_or(0);
let patch_mass = if sizes.is_empty() {
0.0
} else {
sizes.iter().map(|&a| (a as f64) * (a as f64)).sum::<f64>().sqrt()
};
let p_frac = patch_mass / (w * h) as f64;
let s_psnr = 1.0 - (1.0 + rmse).ln() / 256f64.ln();
let s_ssim = 1.0 / (1.0 + dssim);
let s_patch = (-p_frac / PATCH_HALF * std::f64::consts::LN_2).exp();
let fidelity = (s_psnr.powf(W_PSNR) * s_ssim.powf(W_SSIM) * s_patch.powf(W_PATCH))
.powf(1.0 / (W_PSNR + W_SSIM + W_PATCH));
(
FidelityReport {
psnr,
dssim,
rmse,
bad_px,
clusters: sizes.len(),
largest,
patch_mass,
s_psnr,
s_ssim,
s_patch,
fidelity,
},
mask,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(w: usize, h: usize, c: [u8; 3]) -> Vec<u8> {
(0..w * h).flat_map(|_| c).collect()
}
#[test]
fn identical_is_one() {
let a = flat(64, 64, [120, 90, 200]);
let (r, mask) = fidelity(&a, &a, 64, 64, DEFAULT_THRESH);
assert_eq!(r.bad_px, 0);
assert!(mask.iter().all(|&m| m == 0));
assert!((r.fidelity - 1.0).abs() < 1e-9, "fidelity {}", r.fidelity);
}
#[test]
fn coherent_patch_scores_below_scattered_dust() {
// same 256 bad pixels: one 16×16 blob vs isolated singles on a 64×64 grid
let clean = flat(64, 64, [200, 200, 200]);
let mut blob = clean.clone();
for y in 24..40 {
for x in 24..40 {
blob[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
}
let mut dust = clean.clone();
for k in 0..256 {
let (x, y) = ((k % 16) * 4, (k / 16) * 4); // 4px spacing: 256 singleton clusters
dust[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
let (rb, _) = fidelity(&clean, &blob, 64, 64, DEFAULT_THRESH);
let (rd, _) = fidelity(&clean, &dust, 64, 64, DEFAULT_THRESH);
assert_eq!(rb.bad_px, 256);
assert_eq!(rd.bad_px, 256);
// dust vanishes under the opening entirely; the blob survives
assert_eq!(rb.clusters, 1);
assert_eq!(rd.clusters, 0);
assert!((rd.s_patch - 1.0).abs() < 1e-9);
// identical PSNR/RMSE by construction; the patch axis must separate them
assert!((rb.rmse - rd.rmse).abs() < 1e-9);
assert!(rb.s_patch < rd.s_patch * 0.25, "blob {} dust {}", rb.s_patch, rd.s_patch);
assert!(rb.fidelity < rd.fidelity);
}
#[test]
fn edge_shift_filaments_are_tolerated() {
// a slightly blurred/compressed source shifts edges: thin bad-px lines
// along boundaries. A 2px-wide full-width filament (256 px) must open
// away; the same mass as a compact blob must not.
let clean = flat(64, 64, [200, 200, 200]);
let mut fil = clean.clone();
for y in 30..32 {
for x in 0..64 {
fil[(y * 64 + x) * 3..(y * 64 + x) * 3 + 3].fill(0);
}
}
let (rf, _) = fidelity(&clean, &fil, 64, 64, DEFAULT_THRESH);
assert_eq!(rf.bad_px, 128);
assert_eq!(rf.clusters, 0);
assert!((rf.s_patch - 1.0).abs() < 1e-9, "filament must not count as a patch");
}
#[test]
fn worse_is_lower() {
let a = flat(32, 32, [100, 100, 100]);
let mild: Vec<u8> = a.iter().map(|&v| v + 4).collect();
let harsh: Vec<u8> = a.iter().map(|&v| v + 60).collect();
let (rm, _) = fidelity(&a, &mild, 32, 32, DEFAULT_THRESH);
let (rh, _) = fidelity(&a, &harsh, 32, 32, DEFAULT_THRESH);
assert!(rm.fidelity > rh.fidelity);
assert!(rh.fidelity < 0.4, "harsh {}", rh.fidelity);
}
}
-66
View File
@@ -1,66 +0,0 @@
//! Blind fidelity benchmark for raster-to-vector tracers.
//!
//! vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]
//!
//! Both arguments are rasters of identical dimensions — rendering a vector
//! reconstruction to pixels is the caller's responsibility. Prints the raw
//! metrics, their [0,1] subscores, the composite fidelity, and a
//! machine-readable csv line.
use vtracer_bench::{fidelity, DEFAULT_THRESH};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: vtracer-bench <original> <candidate> [--thresh N] [--mask out.png]");
std::process::exit(2);
}
let mut thresh = DEFAULT_THRESH;
let mut mask_out: Option<String> = None;
let mut i = 3;
while i < args.len() {
match args[i].as_str() {
"--thresh" => {
i += 1;
thresh = args[i].parse().expect("--thresh N");
}
"--mask" => {
i += 1;
mask_out = Some(args[i].clone());
}
a => {
eprintln!("unknown flag {a}");
std::process::exit(2);
}
}
i += 1;
}
let orig = image::open(&args[1]).expect("open original").to_rgb8();
let (w, h) = (orig.width() as usize, orig.height() as usize);
let img = image::open(&args[2]).expect("open candidate").to_rgb8();
assert_eq!(
(img.width() as usize, img.height() as usize),
(w, h),
"candidate raster must match original dimensions"
);
let cand: Vec<u8> = img.into_raw();
let (r, mask) = fidelity(orig.as_raw(), &cand, w, h, thresh);
if let Some(out) = mask_out {
image::GrayImage::from_raw(w as u32, h as u32, mask).unwrap().save(&out).expect("save mask");
}
println!("psnr {:>8.2} dB (rmse {:.2}) -> {:.4}", r.psnr, r.rmse, r.s_psnr);
println!("ssim {:>8.5} (dssim {:.5}) -> {:.4}", r.s_ssim, r.dssim, r.s_ssim);
println!(
"patch {:>8.1} px rms ({} bad px, {} clusters, largest {}) -> {:.4}",
r.patch_mass, r.bad_px, r.clusters, r.largest, r.s_patch
);
println!("fidelity {:.4}", r.fidelity);
println!(
"[csv] {:.4},{:.2},{:.5},{:.2},{:.1},{:.4},{:.4},{:.4}",
r.fidelity, r.psnr, r.dssim, r.rmse, r.patch_mass, r.s_psnr, r.s_ssim, r.s_patch
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ name = "vtracer"
path = "src/main.rs"
[dependencies]
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
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 = [
+15 -75
View File
@@ -9,7 +9,7 @@ use std::process::ExitCode;
use clap::Parser;
use visioncortex::{Color, ColorImage};
use vtracer::{Clustering, Config, FitMode, Hierarchical, Preset};
use vtracer::{ColorMode, Config, FitMode, Hierarchical, Preset};
/// Convert an image into vector graphics.
#[derive(Parser, Debug)]
@@ -35,9 +35,9 @@ struct Args {
#[arg(long)]
preset: Option<Preset>,
/// Region forming: `color-cluster` (default), `bw`, or `watershed`.
#[arg(long)]
clustering: Option<Clustering>,
/// Color image (`color`) or binary image (`bw`).
#[arg(long = "colormode")]
colormode: Option<ColorMode>,
/// Hierarchical clustering: `stacked` (default) or `cutout` (mosaic).
#[arg(long)]
@@ -60,30 +60,17 @@ struct Args {
gradient_step: Option<i64>,
/// Minimum momentary angle (degrees) to be a corner (0..=180).
///
/// Hidden from help: a fine-tuning knob few conversions need — the
/// default (60) serves; `--simplify` is the knob worth reaching for.
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
#[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).
///
/// Hidden from help: with `--simplify` reducing anchors by an explicit
/// error tolerance, this legacy knob's effect on output is negligible.
#[arg(long, hide = true, value_parser = parse_segment_length)]
#[arg(short = 'l', long, value_parser = parse_segment_length)]
segment_length: Option<f64>,
/// Minimum angle displacement (degrees) to splice a spline (0..=180).
///
/// Hidden from help: a fine-tuning knob few conversions need — the
/// default (45) serves; `--simplify` is the knob worth reaching for.
#[arg(long, hide = true, value_parser = clap::value_parser!(i64).range(0..=180))]
#[arg(short = 's', long, value_parser = clap::value_parser!(i64).range(0..=180))]
splice_threshold: Option<i64>,
/// Simplify curves: fewest cubics within this tolerance in px (try 1-2.5).
#[arg(long, value_name = "TOLERANCE", value_parser = parse_simplify_tolerance)]
simplify: Option<f64>,
/// Decimal places to use in path coordinates.
#[arg(long)]
path_precision: Option<u32>,
@@ -100,41 +87,15 @@ struct Args {
#[arg(long)]
max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=2))]
optimize: Option<u8>,
/// Binary mode: fixed threshold (0..=255); foreground when intensity is below it.
#[arg(long, value_parser = clap::value_parser!(u8))]
threshold: Option<u8>,
/// Binary mode: use BradleyRoth adaptive thresholding (handles uneven lighting).
#[arg(long)]
adaptive: bool,
/// Adaptive window side length in px (0 = auto). Implies --adaptive.
#[arg(long)]
adaptive_window: Option<u32>,
/// Adaptive sensitivity: percent below the local mean (default 15). Implies --adaptive.
#[arg(long)]
adaptive_t: Option<f64>,
/// Watershed clustering: hierarchy cut level (0..=255, higher = more regions).
#[arg(long, value_parser = clap::value_parser!(u8))]
watershed_detail: Option<u8>,
}
fn parse_simplify_tolerance(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
if !v.is_finite() || v <= 0.0 {
return Err(format!("simplify tolerance {v} must be positive"));
}
Ok(v)
}
fn parse_segment_length(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|_| format!("`{s}` is not a number"))?;
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]"));
}
@@ -171,8 +132,8 @@ fn build_config(args: &Args) -> Result<Config, String> {
None => Config::default(),
};
if let Some(v) = args.clustering {
config.clustering = v;
if let Some(v) = args.colormode {
config.color_mode = v;
}
if let Some(v) = args.hierarchical {
config.hierarchical = v;
@@ -198,9 +159,6 @@ fn build_config(args: &Args) -> Result<Config, String> {
if let Some(v) = args.splice_threshold {
config.splice_threshold = v as i32;
}
if args.simplify.is_some() {
config.simplify = args.simplify;
}
if args.path_precision.is_some() {
config.path_precision = args.path_precision;
}
@@ -211,30 +169,12 @@ fn build_config(args: &Args) -> Result<Config, String> {
config.max_colors = Some(v);
}
// Binary thresholding: --adaptive (or either adaptive tuning flag) selects
// BradleyRoth; otherwise --threshold tunes the fixed cutoff.
if let Some(v) = args.threshold {
config.binary_threshold = v;
}
if args.adaptive || args.adaptive_window.is_some() || args.adaptive_t.is_some() {
config.binary_adaptive = true;
}
if let Some(v) = args.adaptive_window {
config.binary_adaptive_window = v;
}
if let Some(v) = args.adaptive_t {
config.binary_adaptive_t = v;
}
if let Some(v) = args.watershed_detail {
config.watershed_detail = v;
}
// Palette: inline flag wins over file; both parse to a color list.
if let Some(text) = &args.palette {
config.palette = parse_palette(text)?;
} else if let Some(path) = &args.palette_file {
let text =
std::fs::read_to_string(path).map_err(|e| format!("cannot read palette file: {e}"))?;
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read palette file: {e}"))?;
config.palette = parse_palette(&text)?;
}
+3 -4
View File
@@ -1,11 +1,10 @@
[package]
name = "vtracer-py"
description = "Python bindings for the vtracer vectorization framework."
version = "1.0.0-alpha.3"
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2024"
edition = "2021"
license = "MIT OR Apache-2.0"
readme = "README.md"
homepage = "http://www.visioncortex.org/vtracer"
repository = "https://github.com/visioncortex/vtracer/"
@@ -19,7 +18,7 @@ name = "vtracer"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.3", path = "../vtracer" }
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",
+3 -9
View File
@@ -8,7 +8,7 @@ this crate adds image decoding and a Pythonic API.
## Install
```sh
pip install vtracer==1.0.0a3
pip install vtracer
```
## Usage
@@ -41,7 +41,7 @@ properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| arg | default | notes |
|---|---|---|
| `clustering` | `"color-cluster"` | `"color-cluster"`, `"bw"`, or `"watershed"` |
| `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 |
@@ -51,16 +51,10 @@ properties, plus the presets `Config.bw()`, `Config.poster()`, `Config.photo()`:
| `length_threshold` | `4.0` | px |
| `max_iterations` | `10` | |
| `splice_threshold` | `45` | degrees |
| `simplify` | `None` | curve simplification tolerance in px (try 12.5) |
| `path_precision` | `2` | output decimal places |
| `palette` | `None` | list of `#rrggbb` strings |
| `max_colors` | `None` | auto-quantize target |
| `optimize` | `1` | `0` off, `1` quantize+cleanup, `2` + shorthands |
| `binary_threshold` | `128` | bw: fixed cutoff, foreground below it |
| `adaptive` | `False` | bw: BradleyRoth adaptive thresholding |
| `adaptive_window` | `0` | bw adaptive: window px (`0` = auto) |
| `adaptive_t` | `15.0` | bw adaptive: % below local mean |
| `watershed_detail` | `128` | watershed: hierarchy cut level 0..=255 |
| `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`.
-1
View File
@@ -5,7 +5,6 @@ build-backend = "maturin"
[project]
name = "vtracer"
description = "Raster to vector graphics converter — Python bindings for the vtracer framework."
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT OR Apache-2.0" }
authors = [{ name = "Chris Tsang", email = "tyt2y7@gmail.com" }]
+25 -118
View File
@@ -27,9 +27,7 @@ use std::path::PathBuf;
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use ::vtracer::{
Color, ColorImage, Clustering, Config as CoreConfig, FitMode, Hierarchical, Preset,
};
use ::vtracer::{Color, ColorImage, ColorMode, Config as CoreConfig, FitMode, Hierarchical, Preset};
// --- string <-> enum helpers -------------------------------------------------
@@ -37,11 +35,10 @@ fn parse<T: std::str::FromStr<Err = String>>(s: &str) -> PyResult<T> {
s.parse().map_err(PyValueError::new_err)
}
fn clustering_str(c: Clustering) -> &'static str {
match c {
Clustering::ColorCluster => "color-cluster",
Clustering::Binary => "bw",
Clustering::Watershed => "watershed",
fn color_mode_str(m: ColorMode) -> &'static str {
match m {
ColorMode::Color => "color",
ColorMode::Binary => "bw",
}
}
@@ -130,7 +127,7 @@ impl PyConfig {
impl PyConfig {
#[new]
#[pyo3(signature = (
clustering = "color-cluster",
color_mode = "color",
hierarchical = "stacked",
mode = "spline",
filter_speckle = 4,
@@ -140,20 +137,14 @@ impl PyConfig {
length_threshold = 4.0,
max_iterations = 10,
splice_threshold = 45,
simplify = None,
path_precision = 2,
palette = None,
max_colors = None,
optimize = 1,
binary_threshold = 128,
adaptive = false,
adaptive_window = 0,
adaptive_t = 15.0,
watershed_detail = 128,
))]
#[allow(clippy::too_many_arguments)]
fn new(
clustering: &str,
color_mode: &str,
hierarchical: &str,
mode: &str,
filter_speckle: usize,
@@ -163,16 +154,10 @@ impl PyConfig {
length_threshold: f64,
max_iterations: usize,
splice_threshold: i32,
simplify: Option<f64>,
path_precision: u32,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: u8,
binary_threshold: u8,
adaptive: bool,
adaptive_window: u32,
adaptive_t: f64,
watershed_detail: u8,
) -> PyResult<Self> {
let palette = match palette {
Some(list) => list.iter().map(|s| parse_hex(s)).collect::<PyResult<_>>()?,
@@ -180,7 +165,7 @@ impl PyConfig {
};
Ok(Self {
inner: CoreConfig {
clustering: parse(clustering)?,
color_mode: parse(color_mode)?,
hierarchical: parse(hierarchical)?,
mode: parse(mode)?,
filter_speckle,
@@ -190,16 +175,10 @@ impl PyConfig {
length_threshold,
max_iterations,
splice_threshold,
simplify,
path_precision: Some(path_precision),
palette,
max_colors,
optimize,
binary_threshold,
binary_adaptive: adaptive,
binary_adaptive_window: adaptive_window,
binary_adaptive_t: adaptive_t,
watershed_detail,
},
})
}
@@ -207,48 +186,33 @@ impl PyConfig {
/// Preset for black & white line art.
#[staticmethod]
fn bw() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Bw),
}
Self { inner: CoreConfig::from_preset(Preset::Bw) }
}
/// Preset for posterized color art.
#[staticmethod]
fn poster() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Poster),
}
Self { inner: CoreConfig::from_preset(Preset::Poster) }
}
/// Preset tuned for photographs.
#[staticmethod]
fn photo() -> Self {
Self {
inner: CoreConfig::from_preset(Preset::Photo),
}
Self { inner: CoreConfig::from_preset(Preset::Photo) }
}
// --- properties ---
#[getter]
fn clustering(&self) -> &'static str {
clustering_str(self.inner.clustering)
fn color_mode(&self) -> &'static str {
color_mode_str(self.inner.color_mode)
}
#[setter]
fn set_clustering(&mut self, v: &str) -> PyResult<()> {
self.inner.clustering = parse(v)?;
fn set_color_mode(&mut self, v: &str) -> PyResult<()> {
self.inner.color_mode = parse(v)?;
Ok(())
}
#[getter]
fn watershed_detail(&self) -> u8 {
self.inner.watershed_detail
}
#[setter]
fn set_watershed_detail(&mut self, v: u8) {
self.inner.watershed_detail = v;
}
#[getter]
fn hierarchical(&self) -> &'static str {
hierarchical_str(self.inner.hierarchical)
@@ -332,15 +296,6 @@ impl PyConfig {
self.inner.splice_threshold = v;
}
#[getter]
fn simplify(&self) -> Option<f64> {
self.inner.simplify
}
#[setter]
fn set_simplify(&mut self, v: Option<f64>) {
self.inner.simplify = v;
}
#[getter]
fn path_precision(&self) -> Option<u32> {
self.inner.path_precision
@@ -352,11 +307,7 @@ impl PyConfig {
#[getter]
fn palette(&self) -> Vec<String> {
self.inner
.palette
.iter()
.map(Color::to_hex_string)
.collect()
self.inner.palette.iter().map(Color::to_hex_string).collect()
}
#[setter]
fn set_palette(&mut self, v: Vec<String>) -> PyResult<()> {
@@ -382,53 +333,15 @@ impl PyConfig {
self.inner.optimize = v;
}
#[getter]
fn binary_threshold(&self) -> u8 {
self.inner.binary_threshold
}
#[setter]
fn set_binary_threshold(&mut self, v: u8) {
self.inner.binary_threshold = v;
}
#[getter]
fn adaptive(&self) -> bool {
self.inner.binary_adaptive
}
#[setter]
fn set_adaptive(&mut self, v: bool) {
self.inner.binary_adaptive = v;
}
#[getter]
fn adaptive_window(&self) -> u32 {
self.inner.binary_adaptive_window
}
#[setter]
fn set_adaptive_window(&mut self, v: u32) {
self.inner.binary_adaptive_window = v;
}
#[getter]
fn adaptive_t(&self) -> f64 {
self.inner.binary_adaptive_t
}
#[setter]
fn set_adaptive_t(&mut self, v: f64) {
self.inner.binary_adaptive_t = v;
}
// --- conversion ---
/// Trace the image at `input_path` and write the SVG to `output_path`.
fn convert_file(&self, input_path: PathBuf, output_path: PathBuf) -> PyResult<()> {
let img = image::open(&input_path).map_err(|e| {
PyIOError::new_err(format!("cannot open `{}`: {e}", input_path.display()))
})?;
let 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()))
})
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.
@@ -457,11 +370,11 @@ impl PyConfig {
fn __repr__(&self) -> String {
let c = &self.inner;
format!(
"Config(clustering='{}', hierarchical='{}', mode='{}', filter_speckle={}, \
"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={})",
clustering_str(c.clustering),
color_mode_str(c.color_mode),
hierarchical_str(c.hierarchical),
mode_str(c.mode),
c.filter_speckle,
@@ -489,9 +402,7 @@ fn convert_file(
output_path: PathBuf,
config: Option<PyConfig>,
) -> PyResult<()> {
config
.unwrap_or_else(default_config)
.convert_file(input_path, output_path)
config.unwrap_or_else(default_config).convert_file(input_path, output_path)
}
/// Convert encoded image bytes to an SVG string, using `config` (or defaults).
@@ -502,9 +413,7 @@ fn convert_bytes(
config: Option<PyConfig>,
format: Option<&str>,
) -> PyResult<String> {
config
.unwrap_or_else(default_config)
.convert_bytes(data, format)
config.unwrap_or_else(default_config).convert_bytes(data, format)
}
/// Convert a raw RGBA8 buffer to an SVG string, using `config` (or defaults).
@@ -516,9 +425,7 @@ fn convert_pixels(
height: usize,
config: Option<PyConfig>,
) -> PyResult<String> {
config
.unwrap_or_else(default_config)
.convert_pixels(rgba, width, height)
config.unwrap_or_else(default_config).convert_pixels(rgba, width, height)
}
fn default_config() -> PyConfig {
+2 -14
View File
@@ -8,7 +8,7 @@ class Config:
def __init__(
self,
clustering: str = "color-cluster", # "color-cluster" | "bw" | "watershed"
color_mode: str = "color", # "color" | "bw"
hierarchical: str = "stacked", # "stacked" | "cutout" (mosaic)
mode: str = "spline", # "pixel" | "polygon" | "spline"
filter_speckle: int = 4,
@@ -18,16 +18,10 @@ class Config:
length_threshold: float = 4.0,
max_iterations: int = 10,
splice_threshold: int = 45,
simplify: Optional[float] = None, # curve simplification tolerance in px (None = off)
path_precision: int = 2,
palette: Optional[list[str]] = None, # e.g. ["#112233", "#445566"]
max_colors: Optional[int] = None, # auto-quantize target
optimize: int = 1, # 0 | 1 | 2
binary_threshold: int = 128, # bw: fixed cutoff 0..=255
adaptive: bool = False, # bw: BradleyRoth adaptive
adaptive_window: int = 0, # bw adaptive: window px (0 = auto)
adaptive_t: float = 15.0, # bw adaptive: % below local mean
watershed_detail: int = 128, # watershed: cut level 0..=255
) -> None: ...
@staticmethod
@@ -37,7 +31,7 @@ class Config:
@staticmethod
def photo() -> "Config": ...
clustering: str
color_mode: str
hierarchical: str
mode: str
filter_speckle: int
@@ -47,16 +41,10 @@ class Config:
length_threshold: float
max_iterations: int
splice_threshold: int
simplify: Optional[float]
path_precision: Optional[int]
palette: list[str]
max_colors: Optional[int]
optimize: int
binary_threshold: int
adaptive: bool
adaptive_window: int
adaptive_t: float
watershed_detail: int
def convert_file(self, input_path: str, output_path: str) -> None: ...
def convert_bytes(self, data: bytes, format: Optional[str] = None) -> str: ...
-4
View File
@@ -9,7 +9,6 @@ homepage.workspace = true
repository.workspace = true
categories = ["graphics", "computer-vision"]
keywords = ["svg", "vectorization", "computer-graphics"]
readme = "../../README.md"
[lib]
name = "vtracer"
@@ -17,11 +16,8 @@ path = "src/lib.rs"
[dependencies]
visioncortex.workspace = true
flo_curves.workspace = true
[dev-dependencies]
# Rasterize-and-diff equivalence tests (stacked vs mosaic). Test-only; not
# compiled for wasm targets, so the library stays wasm-safe.
resvg = "0.45"
# Decode the sample photo for the spline-fitting regression test. Test-only.
image = { version = "0.25", default-features = false, features = ["jpeg"] }
-2
View File
@@ -1,2 +0,0 @@
# This crate is hand-formatted; a stray `cargo fmt` must not rewrite it.
disable_all_formatting = true
+7 -25
View File
@@ -1,4 +1,4 @@
use crate::ir::{Layer, RegionMask, Segmentation};
use crate::ir::{Layer, Segmentation};
use super::ColorFitter;
@@ -8,39 +8,21 @@ use super::ColorFitter;
#[derive(Debug, Clone, Default)]
pub struct MergeAdjacent;
/// Collapse one run of same-paint layers and push the result.
///
/// The whole run is unioned in a single pass — folding pairwise would reallocate
/// and rewrite a canvas-sized accumulator once per layer. See
/// [`RegionMask::union_all`].
fn flush(run: &mut Vec<Layer>, out: &mut Vec<Layer>) {
match run.len() {
0 => {}
1 => out.push(run.pop().expect("run is non-empty")),
_ => {
let paint = run[0].paint;
let masks: Vec<&RegionMask> = run.iter().map(|l| &l.mask).collect();
let mask = RegionMask::union_all(&masks);
out.push(Layer { paint, mask });
run.clear();
}
}
}
impl ColorFitter for MergeAdjacent {
fn fit(&self, seg: &mut Segmentation) {
if seg.layers.len() < 2 {
return;
}
let mut merged: Vec<Layer> = Vec::with_capacity(seg.layers.len());
let mut run: Vec<Layer> = Vec::new();
for layer in seg.layers.drain(..) {
if run.first().is_some_and(|first| first.paint != layer.paint) {
flush(&mut run, &mut merged);
if let Some(last) = merged.last_mut() {
if last.paint == layer.paint {
last.mask = last.mask.union(&layer.mask);
continue;
}
}
run.push(layer);
merged.push(layer);
}
flush(&mut run, &mut merged);
seg.layers = merged;
}
}
-131
View File
@@ -1,131 +0,0 @@
//! Compositing: turn a [`Segmentation`] into a [`VectorDoc`].
//!
//! * **Stacked** — each layer is traced independently into closed outlines and
//! stacked in paint order (painter's algorithm).
//! * **Mosaic** — a seam-free gapless tessellation with shared boundary
//! geometry (see [`crate::mosaic`]).
//!
//! Both compositors run the pipeline's [`CurvePass`]es over every fitted
//! contour before assembling paths — geometry passes have to happen here, on
//! the fitted geometry, so that in mosaic mode each shared boundary segment
//! is transformed exactly once for both of its faces.
use crate::error::Error;
use crate::fitter::CurveFitter;
use crate::ir::{MultiPath, RegionMask, Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, SegmentFitter};
use crate::progress::{Ctx, Phase};
use crate::simplify::CurvePass;
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
/// Independent per-region closed outlines, stacked bottom-to-top.
Stacked(Box<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic {
fitter: Box<dyn SegmentFitter>,
/// Merge flattened neighbours whose colors are within this diff —
/// rejoins regions the stacked gradient layering had split. Usually
/// the clustering gradient step; `0` still merges identical-color
/// neighbours, negative disables merging entirely.
merge_diff: i32,
},
}
impl Compositing {
/// Run the selected compositor over a segmentation, applying `passes` to
/// every fitted contour before paths are assembled.
pub fn compose(&self, seg: &Segmentation, passes: &[Box<dyn CurvePass>]) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref(), passes),
Compositing::Mosaic { fitter, merge_diff } => {
compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes)
}
}
}
/// Progress- and cancellation-aware compositing.
///
/// Stacked mode reports per-layer progress and can be cancelled between
/// layers. Mosaic builds its boundary graph in one pass, so it reports
/// coarsely (start/end) and is cancellable only at the boundaries — the
/// dominant cost is upstream in clustering, which cancels finely.
pub fn compose_with(
&self,
seg: &Segmentation,
passes: &[Box<dyn CurvePass>],
ctx: &mut Ctx,
) -> Result<VectorDoc, Error> {
match self {
Compositing::Stacked(fitter) => compose_stacked_with(seg, fitter.as_ref(), passes, ctx),
Compositing::Mosaic { fitter, merge_diff } => {
ctx.check()?;
ctx.report(Phase::Compose, 0.0);
let doc = compose_mosaic(seg, fitter.as_ref(), *merge_diff, passes);
ctx.check()?;
ctx.report(Phase::Compose, 1.0);
Ok(doc)
}
}
}
}
/// Fit one region's outlines and run the curve passes over each contour.
/// Stacked contours are closed rings, so the ring form of each pass applies.
fn fit_region(
fitter: &dyn CurveFitter,
mask: &RegionMask,
passes: &[Box<dyn CurvePass>],
) -> MultiPath {
let mut path = MultiPath::new();
for mut geom in fitter.fit_region(mask) {
for pass in passes {
geom = pass.ring(geom);
}
path.push(geom.into_closed_subpath());
}
path
}
/// Progress-aware [`compose_stacked`]: reports after each layer and checks for
/// cancellation between them.
fn compose_stacked_with(
seg: &Segmentation,
fitter: &dyn CurveFitter,
passes: &[Box<dyn CurvePass>],
ctx: &mut Ctx,
) -> Result<VectorDoc, Error> {
let mut doc = VectorDoc::new(seg.width, seg.height);
let total = seg.layers.len().max(1);
for (i, layer) in seg.layers.iter().enumerate() {
ctx.check()?;
let path = fit_region(fitter, &layer.mask, passes);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
ctx.report(Phase::Compose, (i + 1) as f32 / total as f32);
}
Ok(doc)
}
/// Trace every layer's closed outline and stack the shapes in paint order.
pub fn compose_stacked(
seg: &Segmentation,
fitter: &dyn CurveFitter,
passes: &[Box<dyn CurvePass>],
) -> VectorDoc {
let mut doc = VectorDoc::new(seg.width, seg.height);
for layer in &seg.layers {
let path = fit_region(fitter, &layer.mask, passes);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: layer.paint,
path,
});
}
}
doc
}
+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
}
+23 -137
View File
@@ -8,26 +8,18 @@ use crate::colorfit::{AutoQuantize, ColorFitter, FixedPalette, Identity, MergeAd
use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
use crate::frontend::{
BinaryFrontend, ColorClusterFrontend, Frontend, Threshold, WatershedFrontend,
};
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend};
use crate::mosaic::{
PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter, SplineSegmentFitter,
};
use crate::optimize::{CleanupPass, OptimizerPass, QuantizePass};
use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
use crate::pipeline::Pipeline;
use crate::simplify::{CurvePass, SimplifyCurves};
use crate::svg::SvgWriter;
/// Which region-forming algorithm segments the image.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Clustering {
/// Hierarchical color clustering — the classic VTracer path.
ColorCluster,
/// Threshold to black/white, then cluster the foreground.
pub enum ColorMode {
Color,
Binary,
/// Hierarchical watershed on the pixel graph, cut at `watershed_detail`.
Watershed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -51,29 +43,11 @@ pub enum Preset {
Photo,
}
/// The clustering-relevant projection of a [`Config`]. Two configs with equal
/// keys produce the same [`Segmentation`](crate::Segmentation), so a cached one
/// stays valid — this is what [`Session`](crate::Session) compares to decide
/// whether to re-segment. Kept in sync with [`Config::frontend`] in one place.
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentKey {
clustering: Clustering,
color_precision: i32,
layer_difference: i32,
filter_speckle: usize,
binary_threshold: u8,
binary_adaptive: bool,
binary_adaptive_window: u32,
binary_adaptive_t: f64,
watershed_detail: u8,
}
/// High-level converter configuration. [`Config::build`] turns this into a
/// concrete [`Pipeline`].
#[derive(Debug, Clone)]
pub struct Config {
/// Region-forming algorithm (see [`Clustering`]).
pub clustering: Clustering,
pub color_mode: ColorMode,
pub hierarchical: Hierarchical,
/// Speckle filter given as a side length; the area threshold is its square.
pub filter_speckle: usize,
@@ -89,38 +63,20 @@ pub struct Config {
pub max_iterations: usize,
/// Splice threshold in degrees.
pub splice_threshold: i32,
/// Curve simplification tolerance in px (paper.js-style `simplify`):
/// re-fit smooth runs of fitted cubics with the fewest curves that stay
/// within this distance, keeping corners in place. `None` = off. Only
/// affects spline mode; pixel/polygon polylines pass through untouched.
pub simplify: Option<f64>,
/// Coordinate precision (decimal places) for output.
pub path_precision: Option<u32>,
/// Fixed palette (empty = none). Takes priority over `max_colors`.
pub palette: Vec<Color>,
/// Auto-quantize target color count (None = off).
pub max_colors: Option<usize>,
/// Optimization level: 0 = off, 1 = quantize+cleanup, 2 = + shorthands/grouping.
/// Optimization level: 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping.
pub optimize: u8,
/// Binary-mode fixed threshold (0..=255): foreground when grayscale
/// intensity is below this. Ignored when `binary_adaptive` is set.
pub binary_threshold: u8,
/// Binary mode: use BradleyRoth adaptive thresholding instead of the fixed
/// cutoff (better for uneven lighting).
pub binary_adaptive: bool,
/// Adaptive window side length in pixels; 0 = auto (~1/8 of the shorter side).
pub binary_adaptive_window: u32,
/// Adaptive sensitivity `t`: percent below the local mean (default 15).
pub binary_adaptive_t: f64,
/// Watershed clustering: where to cut the hierarchy (0..=255). Higher
/// keeps more regions; 0 collapses the image to a single region.
pub watershed_detail: u8,
}
impl Default for Config {
fn default() -> Self {
Self {
clustering: Clustering::ColorCluster,
color_mode: ColorMode::Color,
hierarchical: Hierarchical::Stacked,
filter_speckle: 4,
color_precision: 6,
@@ -130,16 +86,10 @@ impl Default for Config {
length_threshold: 4.0,
max_iterations: 10,
splice_threshold: 45,
simplify: None,
path_precision: Some(2),
palette: Vec::new(),
max_colors: None,
optimize: 1,
binary_threshold: 128,
binary_adaptive: false,
binary_adaptive_window: 0,
binary_adaptive_t: 15.0,
watershed_detail: 128,
}
}
}
@@ -148,14 +98,16 @@ impl Config {
pub fn from_preset(preset: Preset) -> Self {
match preset {
Preset::Bw => Self {
clustering: Clustering::Binary,
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,
@@ -175,39 +127,21 @@ impl Config {
}
fn frontend(&self) -> Box<dyn Frontend> {
match self.clustering {
Clustering::ColorCluster => Box::new(ColorClusterFrontend {
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,
good_min_area: self.speckle_area(),
}),
Clustering::Binary => {
let threshold = if self.binary_adaptive {
Threshold::Adaptive {
window: self.binary_adaptive_window,
t: self.binary_adaptive_t,
}
} else {
Threshold::Fixed(self.binary_threshold)
};
Box::new(BinaryFrontend {
threshold,
diagonal: false,
min_area: self.speckle_area(),
})
}
Clustering::Watershed => Box::new(WatershedFrontend {
detail: self.watershed_detail,
min_area: self.speckle_area(),
ColorMode::Binary => Box::new(BinaryFrontend {
filter_speckle_area,
threshold: 128,
diagonal: false,
}),
}
}
/// Speckle filter area (px), fed to the frontend.
pub(crate) fn speckle_area(&self) -> usize {
self.filter_speckle * self.filter_speckle
}
fn color_fitters(&self) -> Vec<Box<dyn ColorFitter>> {
if !self.palette.is_empty() {
vec![
@@ -243,16 +177,6 @@ impl Config {
}
}
fn curve_passes(&self) -> Vec<Box<dyn CurvePass>> {
match self.simplify {
Some(tolerance) if tolerance > 0.0 => vec![Box::new(SimplifyCurves {
tolerance,
corner_threshold: deg2rad(self.corner_threshold),
})],
_ => Vec::new(),
}
}
fn optimizers(&self) -> Vec<Box<dyn OptimizerPass>> {
if self.optimize == 0 {
return Vec::new();
@@ -260,7 +184,7 @@ impl Config {
let precision = self.path_precision.unwrap_or(2);
vec![
Box::new(QuantizePass::new(precision)),
Box::new(CleanupPass),
Box::new(SimplifyPass),
]
}
@@ -284,54 +208,17 @@ impl Config {
}
}
/// The clustering-relevant subset of this config. Changing any field it
/// captures (clustering algorithm, color precision, layer difference,
/// speckle, binary threshold settings, or watershed detail) requires
/// re-segmenting; changing anything else — fit mode, curve params,
/// compositing, palette, optimization — reuses a cached segmentation. See
/// [`Session`](crate::Session).
pub fn segment_key(&self) -> SegmentKey {
SegmentKey {
clustering: self.clustering,
color_precision: self.color_precision,
layer_difference: self.layer_difference,
filter_speckle: self.filter_speckle,
binary_threshold: self.binary_threshold,
binary_adaptive: self.binary_adaptive,
binary_adaptive_window: self.binary_adaptive_window,
binary_adaptive_t: self.binary_adaptive_t,
watershed_detail: self.watershed_detail,
}
}
/// Assemble a concrete pipeline from this configuration.
pub fn build(&self) -> Result<Pipeline, Error> {
let compositing = match self.hierarchical {
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
Hierarchical::Cutout => Compositing::Mosaic {
fitter: self.segment_fitter(),
// Rejoin flattened neighbours the clustering split too finely.
// Color clustering considers colors within one gradient step
// to be the same region (`deepen_diff`), so that is its
// tolerance. The watershed dial has no color units (it
// targets a region *count*), so its tolerance is anchored
// instead: at the default detail (128) it matches the
// color-cluster default gradient step (16) and grows linearly
// as detail drops; the floor keeps faces a human cannot tell
// apart (within a just-noticeable difference) from surviving
// as separate patches even at maximum detail.
merge_diff: match self.clustering {
Clustering::Watershed => ((255 - self.watershed_detail as i32) / 8).max(2),
_ => self.layer_difference,
},
},
Hierarchical::Cutout => Compositing::Mosaic(self.segment_fitter()),
};
Ok(Pipeline {
frontend: self.frontend(),
color_fitters: self.color_fitters(),
compositing,
curve_passes: self.curve_passes(),
optimizers: self.optimizers(),
writer: self.writer(),
})
@@ -342,14 +229,13 @@ fn deg2rad(deg: i32) -> f64 {
deg as f64 / 180.0 * std::f64::consts::PI
}
impl FromStr for Clustering {
impl FromStr for ColorMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"color-cluster" | "colorcluster" | "color" => Ok(Self::ColorCluster),
"color" => Ok(Self::Color),
"binary" | "bw" | "BW" => Ok(Self::Binary),
"watershed" => Ok(Self::Watershed),
_ => Err(format!("unknown clustering {s}")),
_ => Err(format!("unknown color mode {s}")),
}
}
}
-3
View File
@@ -9,8 +9,6 @@ pub enum Error {
NoKeyColor,
/// A requested feature is recognized but not yet implemented.
Unsupported(String),
/// The run was aborted via a [`crate::progress::CancelToken`].
Cancelled,
/// Any other failure, carrying a human-readable message.
Other(String),
}
@@ -23,7 +21,6 @@ impl fmt::Display for Error {
write!(f, "unable to find an unused color in image to use as key")
}
Error::Unsupported(what) => write!(f, "unsupported: {what}"),
Error::Cancelled => write!(f, "conversion cancelled"),
Error::Other(msg) => write!(f, "{msg}"),
}
}
@@ -1,44 +1,22 @@
//! Curve fitters: turn a region's pixel mask into vector outlines.
//!
//! The three built-ins wrap the corresponding visioncortex tracing modes and
//! emit [`FittedGeom`] contours in absolute (document) coordinates:
//! 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). The
//! mosaic compositor fits open boundary segments instead; see
//! [`crate::mosaic::SegmentFitter`].
//! 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::{PathCmd, RegionMask, SubPath};
/// Fitted geometry for one contour — the common currency between the curve
/// fitters, the [`CurvePass`](crate::simplify::CurvePass) stage, and
/// composition. The stacked fitters produce one per closed outline; the
/// mosaic fitters produce one per shared boundary segment.
#[derive(Clone, Debug)]
pub enum FittedGeom {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
impl FittedGeom {
/// Convert one closed contour into a `MoveTo … Close` subpath.
pub fn into_closed_subpath(self) -> SubPath {
match self {
FittedGeom::Polyline(points) => polyline_subpath(&points),
FittedGeom::Beziers(chain) => beziers_subpath(&chain),
}
}
}
use crate::ir::{MultiPath, PathCmd, RegionMask, SubPath};
/// Fitting parameters shared by the built-in fitters. Only the spline fitter
/// consults the smoothing/splice fields.
@@ -65,10 +43,9 @@ impl Default for FitParams {
}
}
/// A curve fitter traces a region mask into closed vector outlines, one
/// [`FittedGeom`] per contour (outer ring or hole).
/// A curve fitter traces a region mask into closed vector outlines.
pub trait CurveFitter {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom>;
fn fit_region(&self, mask: &RegionMask) -> MultiPath;
}
/// Exact lattice polyline; every pixel-boundary step is preserved.
@@ -76,7 +53,7 @@ pub trait CurveFitter {
pub struct PixelFitter;
impl CurveFitter for PixelFitter {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
trace_region(mask, PathSimplifyMode::None, FitParams::default())
}
}
@@ -86,7 +63,7 @@ impl CurveFitter for PixelFitter {
pub struct PolygonFitter;
impl CurveFitter for PolygonFitter {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
trace_region(mask, PathSimplifyMode::Polygon, FitParams::default())
}
}
@@ -104,19 +81,19 @@ impl SplineFitter {
}
impl CurveFitter for SplineFitter {
fn fit_region(&self, mask: &RegionMask) -> Vec<FittedGeom> {
fn fit_region(&self, mask: &RegionMask) -> MultiPath {
trace_region(mask, PathSimplifyMode::Spline, self.params)
}
}
/// Trace every connected component of a masked region and collect the
/// resulting outlines, one [`FittedGeom`] per contour, in absolute coordinates.
/// 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) -> Vec<FittedGeom> {
let mut geoms = Vec::new();
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,
@@ -131,12 +108,12 @@ fn trace_region(mask: &RegionMask, mode: PathSimplifyMode, params: FitParams) ->
params.max_iterations,
params.splice_threshold,
);
append_compound(&mut geoms, &compound);
append_compound(&mut multi, &compound);
}
geoms
multi
}
fn append_compound(geoms: &mut Vec<FittedGeom>, compound: &CompoundPath) {
fn append_compound(multi: &mut MultiPath, compound: &CompoundPath) {
for element in compound.iter() {
match element {
CompoundPathElement::PathI32(p) => {
@@ -148,34 +125,18 @@ fn append_compound(geoms: &mut Vec<FittedGeom>, compound: &CompoundPath) {
y: q.y as f64,
})
.collect();
geoms.push(FittedGeom::Polyline(pts));
multi.push(polyline_subpath(&pts));
}
CompoundPathElement::PathF64(p) => {
geoms.push(FittedGeom::Polyline(p.path.clone()));
multi.push(polyline_subpath(&p.path));
}
CompoundPathElement::Spline(s) => {
geoms.push(FittedGeom::Beziers(spline_chain(&s.points)));
multi.push(spline_subpath(&s.points));
}
}
}
}
/// A spline of `1 + 3n` points becomes a chain of `n` cubics sharing endpoints.
fn spline_chain(points: &[PointF64]) -> Vec<[PointF64; 4]> {
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
return Vec::new();
}
let mut chain = Vec::with_capacity((points.len() - 1) / 3);
let mut start = points[0];
let mut i = 1;
while i + 2 < points.len() {
chain.push([start, points[i], points[i + 1], points[i + 2]]);
start = points[i + 2];
i += 3;
}
chain
}
/// A closed polyline whose last point repeats the first becomes
/// `MoveTo · LineTo* · Close`.
fn polyline_subpath(points: &[PointF64]) -> SubPath {
@@ -194,15 +155,18 @@ fn polyline_subpath(points: &[PointF64]) -> SubPath {
sub
}
/// A cubic chain becomes `MoveTo · CubicTo* · Close`.
fn beziers_subpath(chain: &[[PointF64; 4]]) -> SubPath {
/// A spline of `1 + 3n` points becomes `MoveTo · CubicTo* · Close`.
fn spline_subpath(points: &[PointF64]) -> SubPath {
let mut sub = SubPath::new();
if chain.is_empty() {
if points.len() < 4 || (points.len() - 1) % 3 != 0 {
return sub;
}
sub.commands.push(PathCmd::MoveTo(chain[0][0]));
for c in chain {
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
sub.commands.push(PathCmd::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
-44
View File
@@ -1,44 +0,0 @@
//! Frontends: algorithms that turn a raster image into a [`Segmentation`].
//!
//! Built-ins:
//! * [`ColorClusterFrontend`] — hierarchical color clustering (the classic
//! VTracer color path), including transparency keying.
//! * [`BinaryFrontend`] — threshold to black/white then cluster.
//! * [`WatershedFrontend`] — hierarchical watershed on the pixel graph.
//!
//! Third parties can implement [`Frontend`] to feed external label maps or ML
//! segmentation into the pipeline.
mod binary;
mod color_cluster;
mod keying;
mod watershed;
pub use binary::{BinaryFrontend, Threshold};
pub use color_cluster::ColorClusterFrontend;
pub use watershed::{WatershedFrontend, WatershedHierarchy};
use visioncortex::ColorImage;
use crate::error::Error;
use crate::ir::Segmentation;
use crate::progress::Ctx;
/// A frontend segments a raster image into ordered paint layers.
pub trait Frontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error>;
/// Progress- and cancellation-aware segmentation.
///
/// The default runs [`segment`](Frontend::segment) and then honors
/// cancellation (coarse: one report at completion, cancel observed after
/// the whole segmentation). Frontends that can step incrementally — like
/// [`ColorClusterFrontend`] — override this to report fine-grained
/// progress and observe cancellation between batches.
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
let seg = self.segment(img)?;
ctx.check()?;
ctx.report(crate::progress::Phase::Segment, 1.0);
Ok(seg)
}
}
+21 -119
View File
@@ -1,130 +1,32 @@
use visioncortex::{BinaryImage, Color, ColorImage, PointI32, SummedAreaTable};
use visioncortex::{Color, ColorImage, PointI32};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::Frontend;
/// Grayscale intensity (0..=255) used by every thresholding method. Matches the
/// metric `SummedAreaTable::from_color_image` sums, so fixed and adaptive
/// thresholds agree on what "dark" means.
#[inline]
fn intensity(c: Color) -> u32 {
(c.r as u32 + c.g as u32 + c.b as u32) / 3
}
/// How the binary frontend separates foreground (dark) from background pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Threshold {
/// Global cutoff: a pixel is foreground when its intensity is below this
/// value (0..=255). Fast and predictable; best for clean, evenly-lit input.
Fixed(u8),
/// BradleyRoth adaptive threshold: a pixel is foreground when its
/// intensity is more than `t` percent below the mean of the surrounding
/// `window`×`window` block. Handles uneven lighting and shadows that defeat
/// a single global cutoff. Computed in one pass with a summed-area table,
/// so it stays O(pixels) regardless of window size.
Adaptive {
/// Window side length in pixels; `0` auto-derives ~1/8 of the shorter
/// image dimension (the value suggested by the paper).
window: u32,
/// Sensitivity, as a percentage below the local mean (paper default 15).
t: f64,
},
}
impl Threshold {
/// BradleyRoth adaptive thresholding with the paper's defaults
/// (auto window, `t = 15`).
pub const fn adaptive() -> Self {
Threshold::Adaptive {
window: 0,
t: 15.0,
}
}
}
impl Default for Threshold {
fn default() -> Self {
Threshold::Fixed(128)
}
}
/// Binary (black/white) frontend: threshold the image then cluster the
/// foreground. Every region is painted black.
///
/// Speckle removal drops clusters smaller than `min_area` px as the clusters
/// are collected, matching the pre-1.0 binary path (`cluster.size() >= area`).
#[derive(Debug, Clone)]
pub struct BinaryFrontend {
/// How foreground pixels are selected.
pub threshold: Threshold,
/// 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,
/// Discard clusters smaller than this many pixels (0 = keep all).
pub min_area: usize,
}
impl Default for BinaryFrontend {
fn default() -> Self {
Self {
threshold: Threshold::default(),
filter_speckle_area: 16,
threshold: 128,
diagonal: false,
min_area: 0,
}
}
}
impl BinaryFrontend {
/// Binarize `img` into a foreground mask according to [`Self::threshold`].
fn binarize(&self, img: &ColorImage) -> BinaryImage {
match self.threshold {
Threshold::Fixed(value) => {
let value = value as u32;
img.to_binary_image(|c| intensity(c) < value)
}
Threshold::Adaptive { window, t } => adaptive_bradley_roth(img, window, t),
}
}
}
/// BradleyRoth adaptive thresholding via a summed-area table.
///
/// For each pixel, compare its intensity to the mean of a surrounding window:
/// it is foreground when `value <= mean * (1 - t/100)`, i.e. more than `t`
/// percent darker than its neighborhood.
fn adaptive_bradley_roth(img: &ColorImage, window: u32, t: f64) -> BinaryImage {
let (w, h) = (img.width, img.height);
let sat = SummedAreaTable::from_color_image(img);
// Window: 0 => auto (~1/8 of the shorter side, per the paper), min 1.
let side = if window == 0 {
(w.min(h) / 8).max(1)
} else {
window as usize
};
let half = side / 2;
let factor = 1.0 - t.clamp(0.0, 100.0) / 100.0;
let mut out = BinaryImage::new_w_h(w, h);
for y in 0..h {
let y0 = y.saturating_sub(half);
let y1 = (y + half).min(h - 1);
for x in 0..w {
let x0 = x.saturating_sub(half);
let x1 = (x + half).min(w - 1);
let count = ((x1 - x0 + 1) * (y1 - y0 + 1)) as f64;
let sum = sat.get_region_sum_x_y_w_h(x0, y0, x1 - x0 + 1, y1 - y0 + 1) as f64;
let value = intensity(img.get_pixel(x, y)) as f64;
// value <= mean * factor ⇔ value * count <= sum * factor
out.set_pixel(x, y, value * count <= sum * factor);
}
}
out
}
impl Frontend for BinaryFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
if img.width == 0 || img.height == 0 {
@@ -133,27 +35,27 @@ impl Frontend for BinaryFrontend {
let width = img.width;
let height = img.height;
let binary = self.binarize(img);
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.min_area {
continue;
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,
});
}
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)
+26 -71
View File
@@ -1,54 +1,35 @@
use visioncortex::color_clusters::{
Clusters, KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX,
};
use visioncortex::color_clusters::{KeyingAction, Runner, RunnerConfig, HIERARCHICAL_MAX};
use visioncortex::{Color, ColorImage, PointI32};
// (Runner is constructed inline in each entry point so its generic closure
// types never appear in a return signature.)
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use crate::progress::{Ctx, Phase};
use super::keying::{apply_key, find_unused_color, should_key_image};
use super::Frontend;
/// Hierarchical color-clustering frontend — the classic VTracer color path.
///
/// Speckle removal happens *inside* clustering, via `good_min_area`: it is the
/// clusterer's `deepen` gate (visioncortex `patch_good`), so it does far more
/// than drop small regions — it decides whether a small/thin patch is absorbed
/// into its neighbor (its color averaged in) or kept as its own layer. Forcing
/// it to 0 disables the thread-like rejection and changes the whole hierarchy,
/// so speckle must be a clustering parameter, not a downstream filter.
#[derive(Debug, Clone)]
pub struct ColorClusterFrontend {
/// 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,
/// Minimum area (px) for a patch to be a `deepen` candidate during
/// clustering; non-zero also enables visioncortex's thread-like rejection.
/// Below it, patches are absorbed into their nearest-color neighbor.
pub good_min_area: usize,
}
impl Default for ColorClusterFrontend {
fn default() -> Self {
Self {
filter_speckle_area: 16,
color_precision_loss: 2,
layer_difference: 16,
good_min_area: 0,
}
}
}
impl ColorClusterFrontend {
/// Apply transparency keying (if warranted) and build the clustering
/// inputs: the keyed image, the `RunnerConfig`, and the dimensions. The
/// caller constructs `Runner::new(config, image)` inline so the runner's
/// generic closure types never surface in a return signature.
fn prepare(&self, img: &ColorImage) -> Result<(ColorImage, RunnerConfig, usize, usize), Error> {
impl Frontend for ColorClusterFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
if img.width == 0 || img.height == 0 {
return Err(Error::EmptyImage);
}
@@ -67,26 +48,26 @@ impl ColorClusterFrontend {
Color::default()
};
let config = RunnerConfig {
diagonal: self.layer_difference == 0,
hierarchical: HIERARCHICAL_MAX,
batch_size: 25600,
good_min_area: self.good_min_area,
good_max_area: width * height,
is_same_color_a: self.color_precision_loss,
is_same_color_b: 1,
deepen_diff: self.layer_difference,
hollow_neighbours: 1,
key_color,
keying_action: KeyingAction::Discard,
};
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,
);
Ok((img, config, width, height))
}
/// Turn finished clusters into the layered [`Segmentation`].
fn segmentation_from_clusters(clusters: &Clusters, width: usize, height: usize) -> Segmentation {
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.
@@ -110,33 +91,7 @@ impl ColorClusterFrontend {
mask,
});
}
seg
}
}
impl Frontend for ColorClusterFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
let (image, config, width, height) = self.prepare(img)?;
let clusters = Runner::new(config, image).run();
Ok(Self::segmentation_from_clusters(&clusters, width, height))
}
fn segment_with(&self, img: &ColorImage, ctx: &mut Ctx) -> Result<Segmentation, Error> {
let (image, config, width, height) = self.prepare(img)?;
// Drive clustering incrementally so we can publish progress and observe
// cancellation between batches. `run()` is exactly this loop, so the
// resulting clusters are identical to the blocking path.
let mut builder = Runner::new(config, image).start();
ctx.report(Phase::Segment, 0.0);
while !builder.tick() {
ctx.check()?;
ctx.report(Phase::Segment, builder.progress() as f32 / 100.0);
}
ctx.check()?;
let clusters = builder.result();
ctx.report(Phase::Segment, 1.0);
Ok(Self::segmentation_from_clusters(&clusters, width, height))
Ok(seg)
}
}
+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>;
}
-905
View File
@@ -1,905 +0,0 @@
//! Hierarchical watershed frontend — region forming on the pixel graph.
//!
//! The image is treated as a 4-adjacency edge-weighted graph (edge weight =
//! color difference between the two pixels; no gradient image is built). On it
//! we compute the watershed hierarchy by **volume extinction**, following:
//!
//! * Cousty, Bertrand, Najman, Couprie, *Watershed Cuts: Minimum Spanning
//! Forests and the Drop of Water Principle*, IEEE TPAMI 31(8), 2009.
//! * Najman, Cousty, Perret, *Playing with Kruskal: Algorithms for
//! Morphological Trees in Edge-Weighted Graphs*, ISMM 2013.
//!
//! The work is split in two so the expensive part can be cached (see
//! [`crate::Session`]):
//!
//! * [`WatershedHierarchy::build`] — Kruskal over counting-sorted edges builds
//! the binary partition tree (a flat `parents` array, leaves `0..n`,
//! internal nodes created in altitude order); a leaves-to-root pass computes
//! each subtree's area and volume; each internal node's *persistence* (the
//! volume of the smaller of the two merged basins) becomes the saliency of
//! its MST edge. This depends only on the image — no tuning parameters.
//! * [`WatershedHierarchy::cut`] — cutting at level λ is single-linkage over
//! MST edges with persistence ≤ λ (every pixel gets a label, no
//! watershed-line pixels), antialiased boundary pixels are snapped to the
//! color-midpoint iso-line (see [`snap_boundaries`]), small basins are
//! absorbed, and the surviving merge tree above λ becomes the output layer
//! stack.
//!
//! The cut emits a **stacked hierarchy**, the same principle as the color
//! clustering frontend: the root (whole canvas, mean color) is painted first,
//! then progressively finer ancestor regions, then the final regions on top.
//! Sub-pixel gaps between abutting regions therefore show their common
//! ancestor's color instead of an unrelated backdrop, and stacked mode stays
//! seam-free by overdraw. Flattening top-down (what cutout does) recovers the
//! exact partition, because the final regions are painted last.
//!
//! Everything is integer and allocation-flat: counting sort over 256 weight
//! buckets, path-halving union-find, `u32` node ids. Deterministic across
//! platforms.
use visioncortex::{BinaryImage, Color, ColorImage, PointI32};
use crate::error::Error;
use crate::ir::{Layer, Paint, RegionMask, Segmentation};
use super::Frontend;
/// Cap on the total painted area of ancestor layers, as a multiple of the
/// canvas: keeps a pathological hierarchy (long chains of near-equal
/// persistence) from ballooning the stacked output. The root and the final
/// regions are always emitted, so coverage never depends on this.
const ANCESTOR_AREA_BUDGET: usize = 3;
/// Watershed frontend: hierarchical watershed by volume, cut at `detail`.
#[derive(Debug, Clone)]
pub struct WatershedFrontend {
/// Detail level (0..=255): where to cut the hierarchy. Each +25.5 roughly
/// doubles the region count; 0 collapses the image to a single region.
pub detail: u8,
/// Absorb regions smaller than this many pixels into their most
/// color-similar neighbour after the cut (0 = keep all).
pub min_area: usize,
}
impl Default for WatershedFrontend {
fn default() -> Self {
Self {
detail: 128,
min_area: 16,
}
}
}
/// Flat union-find over `u32` ids with path halving.
struct Uf(Vec<u32>);
impl Uf {
fn new(n: usize) -> Self {
Uf((0..n as u32).collect())
}
fn find(&mut self, mut x: u32) -> u32 {
while self.0[x as usize] != x {
self.0[x as usize] = self.0[self.0[x as usize] as usize];
x = self.0[x as usize];
}
x
}
/// Union by attaching `b`'s root under `a`'s. Caller passes roots.
fn link(&mut self, a: u32, b: u32) {
self.0[b as usize] = a;
}
}
/// Edge weight: max per-channel absolute difference (L∞), the same family of
/// channel-difference metric the rest of vtracer uses. 0..=255.
#[inline]
fn edge_weight(a: Color, b: Color) -> u8 {
let dr = a.r.abs_diff(b.r);
let dg = a.g.abs_diff(b.g);
let db = a.b.abs_diff(b.b);
dr.max(dg).max(db)
}
/// The image's watershed hierarchy: the minimum spanning tree of the pixel
/// graph with a persistence (volume extinction) per edge. Building it is the
/// expensive step and depends only on the image; [`cut`](Self::cut) derives a
/// [`Segmentation`] for any detail level in near-linear time, so interactive
/// re-tuning never repays the build (see [`crate::Session`]).
pub struct WatershedHierarchy {
width: usize,
height: usize,
/// MST edges as pixel pairs, in Kruskal creation order.
mst: Vec<(u32, u32)>,
/// Persistence (volume of the smaller merged basin) per MST edge.
pers: Vec<u64>,
/// MST edge indices by ascending (persistence, index) — the cut order.
order: Vec<u32>,
}
impl WatershedHierarchy {
/// Build the hierarchy: counting-sorted Kruskal → binary partition tree →
/// volume persistence per MST edge. O(n α(n)).
pub fn build(img: &ColorImage) -> Result<Self, Error> {
let w = img.width;
let h = img.height;
if w == 0 || h == 0 {
return Err(Error::EmptyImage);
}
let n = w * h;
if n == 1 {
return Ok(Self {
width: w,
height: h,
mst: Vec::new(),
pers: Vec::new(),
order: Vec::new(),
});
}
// --- 4-adjacency edges, counting-sorted by weight -------------------
// Edge id encodes (pixel, direction): 2*p = right, 2*p+1 = down.
// The per-bucket fill preserves edge-id order, so the sort is stable
// and the whole construction is deterministic.
let px = |i: usize| img.get_pixel(i % w, i / w);
let mut counts = [0u32; 256];
let mut weight_of = vec![0u8; 2 * n];
for i in 0..n {
let c = px(i);
if i % w + 1 < w {
let wgt = edge_weight(c, px(i + 1));
weight_of[2 * i] = wgt;
counts[wgt as usize] += 1;
}
if i / w + 1 < h {
let wgt = edge_weight(c, px(i + w));
weight_of[2 * i + 1] = wgt;
counts[wgt as usize] += 1;
}
}
let n_edges = counts.iter().map(|&c| c as usize).sum::<usize>();
let mut start = [0usize; 256];
let mut acc = 0usize;
for b in 0..256 {
start[b] = acc;
acc += counts[b] as usize;
}
let mut sorted = vec![0u32; n_edges];
let mut fill = start;
for i in 0..n {
if i % w + 1 < w {
let e = 2 * i;
let b = weight_of[e] as usize;
sorted[fill[b]] = e as u32;
fill[b] += 1;
}
if i / w + 1 < h {
let e = 2 * i + 1;
let b = weight_of[e] as usize;
sorted[fill[b]] = e as u32;
fill[b] += 1;
}
}
// --- Kruskal → binary partition tree by altitude --------------------
// Leaves 0..n are pixels; each accepted MST edge creates internal node
// n+k whose two children are the merged components' current roots.
// The grid is connected, so exactly n-1 internal nodes are created and
// parent indices are always greater than child indices.
let n_nodes = 2 * n - 1;
let mut parent = vec![u32::MAX; n_nodes];
let mut alt = vec![0u8; n_nodes]; // altitude; leaves at 0
let mut child = vec![[0u32; 2]; n - 1]; // children of internal node k
let mut mst = vec![(0u32, 0u32); n - 1]; // pixel pair of edge k
let mut uf = Uf::new(n);
// Current tree node representing each union-find root's component.
let mut comp_node: Vec<u32> = (0..n as u32).collect();
let mut next = n as u32;
for &e in &sorted {
let p = (e / 2) as usize;
let q = if e % 2 == 0 { p + 1 } else { p + w };
let (rp, rq) = (uf.find(p as u32), uf.find(q as u32));
if rp == rq {
continue;
}
let k = (next - n as u32) as usize;
alt[next as usize] = weight_of[e as usize];
child[k] = [comp_node[rp as usize], comp_node[rq as usize]];
mst[k] = (p as u32, q as u32);
parent[comp_node[rp as usize] as usize] = next;
parent[comp_node[rq as usize] as usize] = next;
uf.link(rp, rq);
comp_node[rp as usize] = next;
next += 1;
}
debug_assert_eq!(next as usize, n_nodes);
// --- Volume attribute, leaves → root --------------------------------
// area = pixels in the subtree; volume = ∫ area over altitude, i.e.
// each node contributes area × (parent altitude own altitude).
// Ascending index order visits all children before their parent.
let root = n_nodes - 1;
let mut area = vec![0u64; n_nodes];
for a in area.iter_mut().take(n) {
*a = 1;
}
let mut volume = vec![0u64; n_nodes];
for i in 0..root {
let pa = parent[i] as usize;
area[pa] += area[i];
let rise = (alt[pa] - alt[i]) as u64; // parent is never lower
volume[i] += area[i] * rise;
volume[pa] += volume[i];
}
// --- Persistence per MST edge ----------------------------------------
// Plateau fix first (Playing with Kruskal): equal-weight edge chains
// create internal nodes at the same altitude as their parent; their
// volume is not a real basin measure, so replace it with the max over
// children while the altitude is unchanged.
let mut corrected = volume;
for i in n..n_nodes {
let k = i - n;
if i != root && alt[i] == alt[parent[i] as usize] {
let [c0, c1] = child[k];
corrected[i] = corrected[c0 as usize].max(corrected[c1 as usize]);
}
}
// Persistence of a merge = the volume of the smaller side: the level
// at which that basin stops existing on its own.
let mut pers = vec![0u64; n - 1];
for k in 0..n - 1 {
let [c0, c1] = child[k];
pers[k] = corrected[c0 as usize].min(corrected[c1 as usize]);
}
let mut order: Vec<u32> = (0..(n - 1) as u32).collect();
order.sort_by_key(|&k| (pers[k as usize], k));
Ok(Self {
width: w,
height: h,
mst,
pers,
order,
})
}
/// Cut the hierarchy at `detail` and emit the stacked [`Segmentation`].
/// Near-linear; safe to call repeatedly with different parameters.
pub fn cut(&self, img: &ColorImage, detail: u8, min_area: usize) -> Segmentation {
let (w, h) = (self.width, self.height);
let n = w * h;
let m = self.mst.len();
// --- Region formation: merge every MST edge with persistence ≤ λ ----
// Merging leaves exactly 1 + #{edges above λ} regions, so choosing λ
// as the k-th largest persistence targets k regions directly (ties
// merge a little more). The persistence distribution is extremely
// skewed — most merges are trivia at ≈ 0 — so the dial maps to a
// region *count*, exponentially: every +25.5 of detail doubles the
// target, from 1 region at 0 up to 1024 at 255.
let mut uf = Uf::new(n);
if m > 0 {
let target = (2f64).powf(detail as f64 / 25.5).round() as usize;
let target = target.clamp(1, m);
let lambda = self.pers[self.order[m - target] as usize];
for &k in &self.order {
if self.pers[k as usize] > lambda {
break;
}
let (p, q) = self.mst[k as usize];
let (rp, rq) = (uf.find(p), uf.find(q));
if rp != rq {
uf.link(rp, rq);
}
}
}
// --- Compact to region ids and region stats --------------------------
// One find per pixel; everything after this works on the (small)
// region graph so re-cuts stay cheap.
let mut pre_of_root = vec![u32::MAX; n];
let mut pre = vec![0u32; n];
let mut kp = 0usize;
for i in 0..n {
let r = uf.find(i as u32) as usize;
if pre_of_root[r] == u32::MAX {
pre_of_root[r] = kp as u32;
kp += 1;
}
pre[i] = pre_of_root[r];
}
let mut area = vec![0u64; kp];
let mut sum = vec![[0u64; 3]; kp];
for i in 0..n {
let a = pre[i] as usize;
let c = img.get_pixel(i % w, i / w);
area[a] += 1;
sum[a][0] += c.r as u64;
sum[a][1] += c.g as u64;
sum[a][2] += c.b as u64;
}
// --- Boundary snap, then boundary adjacency ---------------------------
snap_boundaries(img, w, h, &mut pre, &mut area, &mut sum);
let mut pairs: Vec<(u32, u32)> = Vec::new();
for i in 0..n {
let a = pre[i];
if i % w + 1 < w && pre[i + 1] != a {
pairs.push((a, pre[i + 1]));
}
if i / w + 1 < h && pre[i + w] != a {
pairs.push((a, pre[i + w]));
}
}
// --- Small-basin absorption on the region graph ----------------------
let mut uf_r = Uf::new(kp);
absorb_small(min_area, &pairs, &mut uf_r, &mut area, &mut sum);
// --- Final leaf ids in raster order of first appearance --------------
let mut leaf_of = vec![u32::MAX; kp];
let mut leaf_root: Vec<u32> = Vec::new(); // leaf id -> absorb root
let mut ids = vec![0u32; n];
for i in 0..n {
let r = uf_r.find(pre[i]) as usize;
if leaf_of[r] == u32::MAX {
leaf_of[r] = leaf_root.len() as u32;
leaf_root.push(r as u32);
}
ids[i] = leaf_of[r];
}
let k = leaf_root.len();
let mean = |s: &[u64; 3], a: u64| {
Color::new((s[0] / a) as u8, (s[1] / a) as u8, (s[2] / a) as u8)
};
let mut seg = Segmentation::new(w as u32, h as u32);
if k == 1 {
// Single region: one solid full-canvas layer.
let r = leaf_root[0] as usize;
seg.layers.push(Layer {
paint: Paint::Solid(mean(&sum[r], area[r])),
mask: full_canvas(w, h),
});
return seg;
}
// --- Merge tree above the cut ----------------------------------------
// Re-run all merges (ascending persistence) over the final regions:
// each one that still joins two components is a kept split. Nodes 0..k
// are the final regions; internal nodes are created in ascending
// persistence order, so the reverse is a root-first order in which
// every ancestor precedes its descendants. Below-cut edges are almost
// all no-ops (their endpoints share a region), but not quite: boundary
// snapping can leave a region's only adjacency running through a
// below-cut edge, and skipping those would leave the tree unconnected.
let n_tree = 2 * k - 1;
let mut tree_child: Vec<[u32; 2]> = Vec::with_capacity(k - 1);
let mut tree_area = vec![0u64; n_tree];
let mut tree_sum = vec![[0u64; 3]; n_tree];
for (t, &r) in leaf_root.iter().enumerate() {
tree_area[t] = area[r as usize];
tree_sum[t] = sum[r as usize];
}
let mut uf2 = Uf::new(k);
let mut node_rep: Vec<u32> = (0..k as u32).collect();
let mut next = k as u32;
for &e in &self.order {
let (p, q) = self.mst[e as usize];
let (lp, lq) = (ids[p as usize], ids[q as usize]);
if lp == lq {
continue; // same region — the bulk of the below-cut edges
}
let (a, b) = (uf2.find(lp), uf2.find(lq));
if a == b {
continue; // already merged, or rejoined by absorption
}
let node = next as usize;
tree_child.push([node_rep[a as usize], node_rep[b as usize]]);
for ch in [node_rep[a as usize], node_rep[b as usize]] {
tree_area[node] += tree_area[ch as usize];
for c in 0..3 {
tree_sum[node][c] += tree_sum[ch as usize][c];
}
}
uf2.link(a, b);
node_rep[a as usize] = next;
next += 1;
}
debug_assert_eq!(next as usize, n_tree);
// Per-leaf pixel lists, for painting ancestor masks.
let mut leaf_len = vec![0u32; k];
for &id in &ids {
leaf_len[id as usize] += 1;
}
let mut leaf_start = vec![0usize; k + 1];
for t in 0..k {
leaf_start[t + 1] = leaf_start[t] + leaf_len[t] as usize;
}
let mut leaf_px = vec![0u32; n];
let mut fill = leaf_start.clone();
for (i, &id) in ids.iter().enumerate() {
leaf_px[fill[id as usize]] = i as u32;
fill[id as usize] += 1;
}
// --- Emit: root, ancestors (budgeted), then the final regions --------
let root = n_tree - 1;
seg.layers.push(Layer {
paint: Paint::Solid(mean(&tree_sum[root], tree_area[root])),
mask: full_canvas(w, h),
});
let mut budget = ANCESTOR_AREA_BUDGET * n;
for node in (k..root).rev() {
let node_area = tree_area[node] as usize;
if node_area > budget {
continue;
}
budget -= node_area;
seg.layers.push(Layer {
paint: Paint::Solid(mean(&tree_sum[node], tree_area[node])),
mask: node_mask(node, k, &tree_child, &leaf_start, &leaf_px, w),
});
}
for t in 0..k {
seg.layers.push(Layer {
paint: Paint::Solid(mean(&tree_sum[t], tree_area[t])),
mask: node_mask(t, k, &tree_child, &leaf_start, &leaf_px, w),
});
}
seg
}
}
fn full_canvas(w: usize, h: usize) -> RegionMask {
let mut image = BinaryImage::new_w_h(w, h);
for y in 0..h {
for x in 0..w {
image.set_pixel(x, y, true);
}
}
RegionMask::new(image, PointI32 { x: 0, y: 0 })
}
/// Paint a tree node's region (the union of the final regions beneath it)
/// into a bbox-cropped mask.
fn node_mask(
node: usize,
k: usize,
tree_child: &[[u32; 2]],
leaf_start: &[usize],
leaf_px: &[u32],
w: usize,
) -> RegionMask {
// Collect the node's leaves.
let mut leaves: Vec<usize> = Vec::new();
let mut stack = vec![node];
while let Some(t) = stack.pop() {
if t < k {
leaves.push(t);
} else {
let [a, b] = tree_child[t - k];
stack.push(a as usize);
stack.push(b as usize);
}
}
// Bounding box over all member pixels.
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for &t in &leaves {
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
let (x, y) = ((p as usize % w) as i32, (p as usize / w) as i32);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
let (bw, bh) = ((x1 - x0 + 1) as usize, (y1 - y0 + 1) as usize);
let mut image = BinaryImage::new_w_h(bw, bh);
for &t in &leaves {
for &p in &leaf_px[leaf_start[t]..leaf_start[t + 1]] {
let (x, y) = (p as usize % w, p as usize / w);
image.set_pixel(x - x0 as usize, y - y0 as usize, true);
}
}
RegionMask::new(image, PointI32 { x: x0, y: y0 })
}
/// How many 1-px boundary-snap sweeps to run: bounds the boundary movement to
/// the width of an antialiasing ramp / JPEG halo (compression ringing spreads
/// a hard edge over up to ~3 px; a plain AA ramp over 12 px).
const SNAP_SWEEPS: usize = 4;
/// Tolerance for the mixture test below: an antialiased blend of two region
/// colors satisfies `d(p,A) + d(p,B) = d(A,B)` exactly (L1, per-channel
/// between-ness); this slack admits sensor/JPEG noise of a few units per
/// channel without admitting genuine third colors.
const SNAP_SLACK: i32 = 16;
/// Re-assign boundary pixels to whichever adjacent region's mean color is
/// closest (strictly closer than their own region's mean, L1).
///
/// The minimum-spanning-forest cut routes the boundary through whichever
/// crack of an antialiasing ramp has the minutely-largest weight, so along a
/// smooth edge it meanders ±12 px with the pixel noise and the fitted curves
/// visibly wave (crisp synthetic edges are unaffected: their boundary pixels
/// sit exactly at a region's mean). Snapping by color lands the boundary on
/// the color-midpoint iso-line of the ramp instead — the same rule color
/// quantization applies, which is why the color-cluster frontend never shows
/// this.
///
/// Only pixels whose color is a *mixture* of two adjacent region means may
/// flip (`d(p,A) + d(p,B) ≤ d(A,B) + slack`): a pixel of a genuine third
/// color — say a dark outline stroke absorbed into a lighter region — must
/// stay with its basin even when some other neighbour's mean happens to sit
/// closer. The mixture pair is usually the pixel's own region and the flip
/// candidate (the classic AA ramp), but a pair of *neighbouring* regions
/// also qualifies: on a blurred low-contrast crack the basin cut can leak a
/// distant region along the crack's blend band as a 1-px filament — those
/// pixels blend the two flanking regions and are unrelated to their own
/// region's color, and they belong to the closer flank.
/// Sweeps are double-buffered (flips apply after scanning) and each moves the
/// boundary at most 1 px, so total movement stays within the ambiguity band;
/// regions are never emptied. Only the first sweep scans the whole canvas;
/// later sweeps revisit the moving front (last sweep's flips and their
/// neighbours), so the cost past sweep one is proportional to the boundary
/// that is actually moving.
fn snap_boundaries(
img: &ColorImage,
w: usize,
h: usize,
labels: &mut [u32],
area: &mut [u64],
sum: &mut [[u64; 3]],
) {
let n = w * h;
let k = area.len();
if k < 2 {
return;
}
// Where a boundary pixel should move, if anywhere: strict improvement
// only, gated on the mixture test; the first of the fixed neighbour
// order wins ties, keeping the sweep deterministic.
let snap_target = |i: usize, labels: &[u32], mean: &[[i32; 3]]| -> Option<u32> {
let a = labels[i] as usize;
// Neighbour labels, replicated at the canvas border (a no-op
// candidate) so the hot path below stays branch-light.
let (x, y) = (i % w, i / w);
let nb = [
labels[if x > 0 { i - 1 } else { i }] as usize,
labels[if x + 1 < w { i + 1 } else { i }] as usize,
labels[if y > 0 { i - w } else { i }] as usize,
labels[if y + 1 < h { i + w } else { i }] as usize,
];
if nb == [a; 4] {
return None; // interior pixel — the overwhelmingly common case
}
let c = img.get_pixel(x, y);
let cv = [c.r as i32, c.g as i32, c.b as i32];
let dist = |m: &[i32; 3]| {
(cv[0] - m[0]).abs() + (cv[1] - m[1]).abs() + (cv[2] - m[2]).abs()
};
let da = dist(&mean[a]);
// The pixel qualifies as a blend of regions `p` and `q` when its
// color sits between their means (L1 between-ness plus noise slack).
let mixture = |p: usize, q: usize| -> bool {
let dpq: i32 = (0..3).map(|ch| (mean[p][ch] - mean[q][ch]).abs()).sum();
dist(&mean[p]) + dist(&mean[q]) <= dpq + SNAP_SLACK
};
let mut best = (da, a);
for b in nb {
if b == a {
continue;
}
let db = dist(&mean[b]);
if db >= best.0 {
continue;
}
if mixture(a, b) || nb.iter().any(|&c| c != a && c != b && mixture(c, b)) {
best = (db, b);
}
}
(best.1 != a).then_some(best.1 as u32)
};
let mut mean = vec![[0i32; 3]; k];
let mut flips: Vec<(u32, u32)> = Vec::new(); // (pixel, new label)
let mut front: Vec<u32> = Vec::new(); // pixels to rescan; sweep 0 scans all
let mut touched: Vec<u32> = Vec::new(); // every front, for the fragment check
for sweep in 0..SNAP_SWEEPS {
for r in 0..k {
for ch in 0..3 {
mean[r][ch] = (sum[r][ch] / area[r]) as i32;
}
}
flips.clear();
if sweep == 0 {
// Interior first with a branch-free neighbour check (the div/mod
// and border branches in snap_target would dominate a whole-canvas
// scan), then the border rim.
for y in 1..h.saturating_sub(1) {
for i in y * w + 1..y * w + w.saturating_sub(1) {
let a = labels[i];
if labels[i - 1] == a
&& labels[i + 1] == a
&& labels[i - w] == a
&& labels[i + w] == a
{
continue;
}
if let Some(b) = snap_target(i, labels, &mean) {
flips.push((i as u32, b));
}
}
}
let h1 = h.saturating_sub(1);
let rim = (0..w)
.chain((1..h1).map(|y| y * w))
.chain((1..h1).map(|y| y * w + w - 1).filter(|_| w > 1))
.chain(if h > 1 { h1 * w..n } else { 0..0 });
for i in rim {
if let Some(b) = snap_target(i, labels, &mean) {
flips.push((i as u32, b));
}
}
} else {
for &i in &front {
if let Some(b) = snap_target(i as usize, labels, &mean) {
flips.push((i, b));
}
}
}
if flips.is_empty() {
break;
}
for &(i, b) in &flips {
let (i, b) = (i as usize, b as usize);
let a = labels[i] as usize;
if area[a] <= 1 {
continue; // never empty a region
}
let c = img.get_pixel(i % w, i / w);
labels[i] = b as u32;
area[a] -= 1;
area[b] += 1;
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
sum[a][ch] -= v as u64;
sum[b][ch] += v as u64;
}
}
// Next sweep revisits each flipped pixel and its 4-neighbourhood,
// in raster order for determinism; the same set seeds the fragment
// check below (a severed strand is always adjacent to the flipped
// bridge pixel that cut it off).
front.clear();
for &(i, _) in &flips {
let i = i as usize;
let (x, y) = (i % w, i / w);
front.push(i as u32);
if x > 0 {
front.push((i - 1) as u32);
}
if x + 1 < w {
front.push((i + 1) as u32);
}
if y > 0 {
front.push((i - w) as u32);
}
if y + 1 < h {
front.push((i + w) as u32);
}
}
front.sort_unstable();
front.dedup();
touched.extend_from_slice(&front);
}
touched.sort_unstable();
touched.dedup();
absorb_fragments(img, w, h, labels, area, sum, &touched);
}
/// Fragments a snap flip may pinch off: a pixel can flip toward a neighbour
/// whose own flip then strands it, and a flipped bridge pixel can sever a
/// thin strand of its source region. Watershed basins are connected by
/// construction and everything downstream relies on regions staying coherent
/// (the mosaic gives every disjoint patch its own face), so the snap must not
/// leave debris: a connected component that is disconnected from the rest of
/// its region and fits under this floor is re-assigned to the most
/// color-similar adjacent region. (A *substantial* patch severed at a thin
/// antialiased neck stays — it makes a coherent face of its own; recoloring
/// it would be visible.)
const SNAP_FRAGMENT_MAX: usize = SNAP_SWEEPS * SNAP_SWEEPS;
fn absorb_fragments(
img: &ColorImage,
w: usize,
h: usize,
labels: &mut [u32],
area: &mut [u64],
sum: &mut [[u64; 3]],
seeds: &[u32],
) {
let n = w * h;
let mut visited = vec![false; n];
let mut comp: Vec<usize> = Vec::new();
let mut rim: Vec<u32> = Vec::new(); // adjacent region labels
for &s in seeds {
let s = s as usize;
if visited[s] {
continue;
}
// Flood s's same-label component, capped: hitting the cap — or a
// pixel already visited by an earlier over-cap flood of the same
// component — proves it is no fragment.
let l = labels[s];
visited[s] = true;
comp.clear();
comp.push(s);
rim.clear();
let mut over = false;
let mut qi = 0;
'flood: while qi < comp.len() {
let i = comp[qi];
qi += 1;
let (x, y) = (i % w, i / w);
for j in [
(x > 0).then(|| i - 1),
(x + 1 < w).then(|| i + 1),
(y > 0).then(|| i - w),
(y + 1 < h).then(|| i + w),
]
.into_iter()
.flatten()
{
if labels[j] != l {
rim.push(labels[j]);
continue;
}
if visited[j] {
if !comp.contains(&j) {
over = true; // joined an earlier over-cap flood
break 'flood;
}
continue;
}
if comp.len() > SNAP_FRAGMENT_MAX {
over = true;
break 'flood;
}
visited[j] = true;
comp.push(j);
}
}
// A component as large as its whole region is the region itself, not
// a fragment of one. (The flood can end at cap + 1 without tripping
// `over`, so re-check the size.)
if over
|| comp.len() > SNAP_FRAGMENT_MAX
|| comp.len() as u64 >= area[l as usize]
|| rim.is_empty()
{
continue;
}
// The whole fragment moves to the adjacent region whose mean is
// closest to the fragment's own mean.
let mut fsum = [0i64; 3];
for &i in &comp {
let c = img.get_pixel(i % w, i / w);
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
fsum[ch] += v as i64;
}
}
let fl = comp.len() as i64;
rim.sort_unstable();
rim.dedup();
let target = rim
.iter()
.map(|&b| {
let d: i64 = (0..3)
.map(|ch| {
(fsum[ch] / fl - (sum[b as usize][ch] / area[b as usize]) as i64).abs()
})
.sum();
(d, b)
})
.min()
.unwrap()
.1 as usize;
let l = l as usize;
for &i in &comp {
let c = img.get_pixel(i % w, i / w);
labels[i] = target as u32;
area[l] -= 1;
area[target] += 1;
for (ch, v) in [c.r, c.g, c.b].into_iter().enumerate() {
sum[l][ch] -= v as u64;
sum[target][ch] += v as u64;
}
}
}
}
/// Absorb regions smaller than `min_area` into their most color-similar
/// neighbour, working entirely on the region graph: `pairs` are the boundary
/// adjacencies (duplicates fine), `uf` is a region-level union-find, and the
/// stats are merged along so downstream consumers see the final regions.
/// Sweeps until nothing undersized remains (or an undersized region has no
/// neighbour at all).
fn absorb_small(
min_area: usize,
pairs: &[(u32, u32)],
uf: &mut Uf,
area: &mut [u64],
sum: &mut [[u64; 3]],
) {
if min_area <= 1 {
return;
}
let k = area.len();
let mean_diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> u64 {
let mut d = 0i64;
for ch in 0..3 {
d += ((sa[ch] / aa) as i64 - (sb[ch] / ab) as i64).abs();
}
d as u64
};
loop {
// best[r] = (diff, neighbour_root) for undersized root r
let mut best: Vec<(u64, u32)> = vec![(u64::MAX, u32::MAX); k];
let mut any_small = false;
for &(p, q) in pairs {
let (a, b) = (uf.find(p), uf.find(q));
if a == b {
continue;
}
for (s, t) in [(a, b), (b, a)] {
let (su, tu) = (s as usize, t as usize);
if area[su] < min_area as u64 {
any_small = true;
let d = mean_diff(&sum[su], area[su], &sum[tu], area[tu]);
if d < best[su].0 || (d == best[su].0 && t < best[su].1) {
best[su] = (d, t);
}
}
}
}
if !any_small {
break;
}
let mut merged = false;
for r in 0..k {
let (_, tgt) = best[r];
if tgt == u32::MAX {
continue;
}
let rr = uf.find(r as u32);
if rr as usize != r {
continue; // already absorbed this sweep
}
let rt = uf.find(tgt);
if rt == rr {
continue;
}
uf.link(rt, rr);
area[rt as usize] += area[r];
for ch in 0..3 {
sum[rt as usize][ch] += sum[r][ch];
}
merged = true;
}
if !merged {
break; // isolated undersized region (e.g. whole-canvas)
}
}
}
impl Frontend for WatershedFrontend {
fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
Ok(WatershedHierarchy::build(img)?.cut(img, self.detail, self.min_area))
}
}
+10 -33
View File
@@ -43,47 +43,24 @@ impl RegionMask {
/// Combine two masks into one covering the union of their bounding boxes.
/// Foreground is the OR of both; this is used by the layer-merge step.
pub fn union(&self, other: &RegionMask) -> RegionMask {
Self::union_all(&[self, other])
}
/// Union any number of masks in one pass: size the destination from the
/// combined bounding box, then blit each source into it exactly once.
///
/// Folding [`union`](Self::union) instead costs one full-size allocation and
/// rewrite of the accumulator *per input*. That is quadratic in the canvas
/// area, and it bites precisely when a palette snap leaves a long run of
/// same-paint layers for [`MergeAdjacent`](crate::colorfit::MergeAdjacent):
/// the accumulator grows to the full canvas after the first few merges, so
/// every remaining layer copies the entire canvas again.
///
/// An empty input yields an empty mask at the origin.
pub fn union_all(masks: &[&RegionMask]) -> RegionMask {
let Some((first, rest)) = masks.split_first() else {
return RegionMask::new(BinaryImage::new_w_h(0, 0), PointI32 { x: 0, y: 0 });
};
let mut left = first.offset.x;
let mut top = first.offset.y;
let mut right = first.offset.x + first.image.width as i32;
let mut bottom = first.offset.y + first.image.height as i32;
for m in rest {
left = left.min(m.offset.x);
top = top.min(m.offset.y);
right = right.max(m.offset.x + m.image.width as i32);
bottom = bottom.max(m.offset.y + m.image.height as i32);
}
let 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 masks {
let dx = (src.offset.x - left) as usize;
let dy = (src.offset.y - top) as usize;
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) {
image.set_pixel(x + dx, y + dy, true);
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);
}
}
}
+8 -15
View File
@@ -4,11 +4,11 @@
//! pipeline of pluggable stages.
//!
//! ```text
//! Frontend ─▶ ColorFitter* ─▶ Compositing ─▶ CurveFitter ─▶ CurvePass* ─▶ VectorDoc
//!
//! OptimizerPass* ─────┤
//!
//! SvgWriter ─▶ SVG
//! 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
@@ -26,8 +26,8 @@
//! ```
//!
//! For finer control, assemble a [`Pipeline`] directly from the stage traits
//! in [`frontend`], [`colorfit`], [`fitter`], [`simplify`], [`compose`],
//! [`optimize`], and [`svg`].
//! in [`frontend`], [`colorfit`], [`fitter`], [`compose`], [`optimize`], and
//! [`svg`].
pub mod colorfit;
pub mod compose;
@@ -39,18 +39,11 @@ pub mod ir;
pub mod mosaic;
pub mod optimize;
pub mod pipeline;
pub mod progress;
pub mod session;
pub mod simplify;
pub mod svg;
pub use config::{Clustering, Config, FitMode, Hierarchical, Preset, SegmentKey};
pub use config::{ColorMode, Config, FitMode, Hierarchical, Preset};
pub use error::Error;
pub use frontend::Threshold;
pub use ir::{Segmentation, VectorDoc};
pub use pipeline::Pipeline;
pub use progress::{CancelToken, Phase, Progress};
pub use session::Session;
// Re-export the visioncortex value types callers need at the boundary.
pub use visioncortex::{Color, ColorImage, PointF64, PointI32};
-664
View File
@@ -1,664 +0,0 @@
//! Mosaic mode: a seam-free, gapless tessellation.
//!
//! Instead of tracing every region independently (which lets neighboring
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
//!
//! ```text
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
//! ```
//!
//! Every boundary curve exists exactly once; the two adjacent regions
//! reference the same fitted geometry, one traversed reversed. Reversal is
//! exact, so the serialized coordinates match on both sides — no seams.
//!
//! Stages 12 (graph + faces) are pure integer arithmetic on the lattice of
//! pixel corners. Only fitting (stage 3) is floating point.
mod compose;
mod face;
mod fit;
mod graph;
pub use compose::compose_mosaic;
pub use fit::{
FittedGeom, FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
SplineSegmentFitter,
};
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
use crate::ir::{Paint, Segmentation};
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
pub type RegionId = u32;
/// Sentinel label for pixels outside any region.
pub const OUTSIDE: RegionId = u32::MAX;
/// A flat partition of the canvas: one region id per pixel, plus the paint for
/// each region. This is the sole input to the boundary-graph extractor.
#[derive(Debug, Clone)]
pub struct LabelMap {
pub width: u32,
pub height: u32,
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
pub labels: Vec<RegionId>,
/// Paint per region, indexed by label.
pub paints: Vec<Paint>,
}
impl LabelMap {
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
/// pixel takes the paint of the topmost layer covering it. Layers are
/// bottom-to-top, so painting them in order lets higher layers win.
pub fn from_segmentation(seg: &Segmentation) -> Self {
let w = seg.width as usize;
let h = seg.height as usize;
let mut labels = vec![OUTSIDE; w * h];
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
for (i, layer) in seg.layers.iter().enumerate() {
let mask = &layer.mask;
for ly in 0..mask.image.height {
for lx in 0..mask.image.width {
if mask.image.get_pixel(lx, ly) {
let gx = mask.offset.x + lx as i32;
let gy = mask.offset.y + ly as i32;
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
labels[gy as usize * w + gx as usize] = i as RegionId;
}
}
}
}
}
LabelMap {
width: seg.width,
height: seg.height,
labels,
paints,
}
}
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
/// Treating outside as a real label removes all image-border special cases.
#[inline]
pub fn label(&self, x: i32, y: i32) -> RegionId {
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
return OUTSIDE;
}
self.labels[y as usize * self.width as usize + x as usize]
}
/// Merge neighbouring regions whose colors are within `max_diff` of each
/// other (the metric is the clustering one: sum of per-channel absolute
/// differences, and clustering keeps neighbours together when
/// `diff <= deepen_diff`).
///
/// The stacked hierarchy deliberately splits a gradient into layers one
/// `deepen_diff` apart — that's what makes stacking smooth. Flattened into
/// a mosaic, that layering degenerates into abutting faces with barely
/// distinguishable fills. This pass undoes it: agglomerative union-find
/// over the adjacency graph, most-similar pairs first, with each merged
/// region's color re-derived as the area-weighted mean so chains only
/// combine while they genuinely stay within `max_diff`.
///
/// `max_diff == 0` still merges *identical*-color neighbours — a boundary
/// between two same-colored faces is never useful. Pass a negative value
/// to disable merging entirely.
pub fn merge_similar(&mut self, max_diff: i32) {
let n = self.paints.len();
if max_diff < 0 || n < 2 {
return;
}
// Area and summed color per region, for weighted mean colors.
let mut area = vec![0u64; n];
for &l in &self.labels {
if l != OUTSIDE {
area[l as usize] += 1;
}
}
let mut sum: Vec<[u64; 3]> = (0..n)
.map(|i| {
let c = self.paints[i].color();
[
c.r as u64 * area[i],
c.g as u64 * area[i],
c.b as u64 * area[i],
]
})
.collect();
// Adjacency pairs (right/down scan covers 4-connectivity once).
let (w, h) = (self.width as i32, self.height as i32);
let mut pairs: Vec<(RegionId, RegionId)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for y in 0..h {
for x in 0..w {
let a = self.label(x, y);
if a == OUTSIDE {
continue;
}
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
let b = self.label(nx, ny);
if b == OUTSIDE || b == a {
continue;
}
let key = (a.min(b), a.max(b));
if seen.insert(key) {
pairs.push(key);
}
}
}
}
let diff = |sa: &[u64; 3], aa: u64, sb: &[u64; 3], ab: u64| -> i32 {
let mut d = 0i64;
for k in 0..3 {
d += ((sa[k] / aa.max(1)) as i64 - (sb[k] / ab.max(1)) as i64).abs();
}
d as i32
};
// Most-similar pairs first, so gradient chains coalesce around their
// closest links; ties break on ids for determinism.
pairs.sort_by_key(|&(a, b)| {
(
diff(&sum[a as usize], area[a as usize], &sum[b as usize], area[b as usize]),
a,
b,
)
});
let mut parent: Vec<RegionId> = (0..n as RegionId).collect();
fn find(parent: &mut [RegionId], mut i: RegionId) -> RegionId {
while parent[i as usize] != i {
parent[i as usize] = parent[parent[i as usize] as usize];
i = parent[i as usize];
}
i
}
// Colors move as regions absorb one another, so re-sweep the candidate
// pairs until nothing merges. Each union is O(α); the sweep count is
// tiny in practice (colors only ever move toward each other's mean).
loop {
let mut changed = false;
for &(a, b) in &pairs {
let ra = find(&mut parent, a);
let rb = find(&mut parent, b);
if ra == rb {
continue;
}
let (ia, ib) = (ra as usize, rb as usize);
if diff(&sum[ia], area[ia], &sum[ib], area[ib]) <= max_diff {
parent[ib] = ra;
for k in 0..3 {
sum[ia][k] += sum[ib][k];
}
area[ia] += area[ib];
changed = true;
}
}
if !changed {
break;
}
}
// Compact surviving roots into dense ids and rewrite labels + paints.
let mut remap: Vec<RegionId> = vec![OUTSIDE; n];
let mut paints: Vec<Paint> = Vec::new();
for l in &mut self.labels {
if *l == OUTSIDE {
continue;
}
let root = find(&mut parent, *l);
if remap[root as usize] == OUTSIDE {
remap[root as usize] = paints.len() as RegionId;
let (s, a) = (&sum[root as usize], area[root as usize].max(1));
paints.push(Paint::Solid(visioncortex::Color::new(
(s[0] / a) as u8,
(s[1] / a) as u8,
(s[2] / a) as u8,
)));
}
*l = remap[root as usize];
}
self.paints = paints;
}
}
#[cfg(test)]
mod tests {
use super::face::{assemble, Face};
use super::graph::BoundaryGraph;
use super::*;
use crate::ir::Paint;
use visioncortex::{Color, PointF64};
/// Build a label map from a row-major grid (for tests).
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
let n = max.map(|m| m as usize + 1).unwrap_or(0);
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
LabelMap {
width,
height,
labels,
paints,
}
}
/// Reconstruct a face's contour polygons in exact lattice coordinates.
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
face.contours
.iter()
.map(|contour| {
let mut ring: Vec<PointF64> = Vec::new();
for (i, sref) in contour.0.iter().enumerate() {
let pts = &graph.segments[sref.seg as usize].points;
let ordered: Vec<PointF64> = if sref.forward {
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
} else {
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
};
if i == 0 {
ring.extend(ordered);
} else {
ring.extend(ordered[1..].iter().copied());
}
}
ring
})
.collect()
}
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Winding number of point `p` w.r.t. a closed ring (last == first).
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
let mut wn = 0;
for w in ring.windows(2) {
let (a, b) = (w[0], w[1]);
if a.y <= p.y {
if b.y > p.y && is_left(a, b, p) > 0.0 {
wn += 1;
}
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
wn -= 1;
}
}
wn
}
/// The strongest guarantee: rasterize the composed faces at pixel centers
/// and assert the result is byte-identical to the input label map.
fn assert_pixel_roundtrip(map: &LabelMap) {
let graph = BoundaryGraph::extract(map);
let faces = assemble(&graph, map);
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
.iter()
.map(|f| (f.region, face_polygons(&graph, f)))
.collect();
for y in 0..map.height as i32 {
for x in 0..map.width as i32 {
let center = PointF64 {
x: x as f64 + 0.5,
y: y as f64 + 0.5,
};
let mut hits: Vec<RegionId> = Vec::new();
for (region, rings) in &polys {
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
if wn != 0 {
hits.push(*region);
}
}
let expected = map.label(x, y);
if expected == OUTSIDE {
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
} else {
assert_eq!(
hits,
vec![expected],
"({x},{y}) expected region {expected}, got {hits:?}"
);
}
}
}
}
#[test]
fn single_region_is_one_ring() {
let map = grid(3, 2, vec![0; 6]);
let graph = BoundaryGraph::extract(&map);
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
assert_eq!(graph.segments.len(), 1, "one border ring");
assert!(graph.segments[0].is_ring());
assert_pixel_roundtrip(&map);
}
#[test]
fn vertical_split() {
// 4x2, left half 0, right half 1.
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
let graph = BoundaryGraph::extract(&map);
// Two border junctions where the split meets the top and bottom edges.
assert_eq!(graph.nodes.len(), 2);
assert_pixel_roundtrip(&map);
}
#[test]
fn t_junction() {
// top row one region, bottom row split — a degree-3 interior node.
let map = grid(2, 2, vec![0, 0, 1, 2]);
assert_pixel_roundtrip(&map);
}
#[test]
fn checkerboard_pinch() {
// A B / B A — the center corner is a degree-4 pinch; each region is two
// lobes touching there. (The four boundary/border corners are degree-3
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
// the real check that the pinch produces exact, simple contours.
let map = grid(2, 2, vec![0, 1, 1, 0]);
let graph = BoundaryGraph::extract(&map);
let has_degree4 = graph.nodes.iter().any(|n| {
let c = n.corner;
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
});
assert!(has_degree4, "expected a degree-4 pinch node at the center");
assert_pixel_roundtrip(&map);
}
#[test]
fn disjoint_patches_of_one_region_get_separate_faces() {
// Region 0 appears as two islands, separated by a column of region 1.
// Each island must get its own face, so they cannot share a path.
#[rustfmt::skip]
let map = grid(3, 2, vec![
0, 1, 0,
0, 1, 0,
]);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
assert_eq!(
faces.iter().filter(|f| f.region == 0).count(),
2,
"each island of region 0 gets its own face"
);
assert_eq!(faces.len(), 3, "two islands of region 0, plus region 1");
assert_pixel_roundtrip(&map);
}
#[test]
fn diagonal_lobes_share_one_face() {
// A B / B A — region 0's lobes meet only at the center corner, which the
// successor rule pinches into a single contour. They must stay in one
// face: splitting them could separate a hole contour from the ring that
// encloses it, and a lone hole ring fills solid under `nonzero`.
#[rustfmt::skip]
let map = grid(2, 2, vec![
0, 1,
1, 0,
]);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
assert_eq!(
faces.iter().filter(|f| f.region == 0).count(),
1,
"diagonally touching lobes stay in one face"
);
assert_pixel_roundtrip(&map);
}
#[test]
fn nested_rings() {
// Concentric squares: 0 outer, 1 middle, 2 center.
let l = |x: i32, y: i32| -> RegionId {
let d = x.min(y).min(5 - x).min(5 - y);
match d {
0 => 0,
1 => 1,
_ => 2,
}
};
let mut labels = Vec::new();
for y in 0..6 {
for x in 0..6 {
labels.push(l(x, y));
}
}
assert_pixel_roundtrip(&grid(6, 6, labels));
}
#[test]
fn outside_region_border_touching() {
// A region that does not fill the canvas; the rest is OUTSIDE.
let mut labels = vec![OUTSIDE; 16];
for y in 1..3 {
for x in 1..3 {
labels[y * 4 + x] = 0;
}
}
assert_pixel_roundtrip(&grid(4, 4, labels));
}
#[test]
fn spline_segments_pin_endpoints_to_lattice() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
// A shape with junctions so there are open (non-ring) segments.
let map = grid(4, 4, vec![
0, 0, 1, 1,
0, 0, 1, 1,
2, 2, 1, 1,
2, 2, 2, 2,
]);
let graph = BoundaryGraph::extract(&map);
let fitter = SplineSegmentFitter::default();
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
let fitted = fitter.fit_open(seg);
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
let end = {
let p = seg.points[seg.points.len() - 1];
PointF64 { x: p.x as f64, y: p.y as f64 }
};
match fitted.geom {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned to node");
assert_eq!(b.last().unwrap()[3], end, "end pinned to node");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
#[test]
fn curve_passes_keep_segment_endpoints_pinned() {
use super::fit::{FittedGeom, SegmentFitter, SplineSegmentFitter};
use crate::simplify::{CurvePass, SimplifyCurves};
// Simplification runs per shared segment; junction nodes must not
// move or the faces meeting there would disagree.
let map = grid(4, 4, vec![
0, 0, 1, 1,
0, 0, 1, 1,
2, 2, 1, 1,
2, 2, 2, 2,
]);
let graph = BoundaryGraph::extract(&map);
let fitter = SplineSegmentFitter::default();
let pass = SimplifyCurves {
tolerance: 2.0,
corner_threshold: std::f64::consts::PI / 3.0,
};
let mut checked = 0;
for seg in &graph.segments {
if seg.is_ring() {
continue;
}
let start = PointF64 { x: seg.points[0].x as f64, y: seg.points[0].y as f64 };
let end = {
let p = seg.points[seg.points.len() - 1];
PointF64 { x: p.x as f64, y: p.y as f64 }
};
match pass.open(fitter.fit_open(seg).geom) {
FittedGeom::Beziers(b) => {
assert_eq!(b.first().unwrap()[0], start, "start pinned through pass");
assert_eq!(b.last().unwrap()[3], end, "end pinned through pass");
}
FittedGeom::Polyline(p) => {
assert_eq!(*p.first().unwrap(), start);
assert_eq!(*p.last().unwrap(), end);
}
}
checked += 1;
}
assert!(checked > 0, "expected some open segments");
}
/// Build a label map with explicit per-region gray levels.
fn gray_grid(width: u32, height: u32, labels: Vec<RegionId>, grays: &[u8]) -> LabelMap {
LabelMap {
width,
height,
labels,
paints: grays
.iter()
.map(|&g| Paint::Solid(Color::new(g, g, g)))
.collect(),
}
}
#[test]
fn merge_similar_rejoins_close_neighbours() {
// Three vertical strips: 100 | 106 | 220. Diff(0,1) = 18 ≤ 20 → merge;
// the merged mean (103) vs 220 stays far apart.
#[rustfmt::skip]
let mut map = gray_grid(3, 2, vec![
0, 1, 2,
0, 1, 2,
], &[100, 106, 220]);
map.merge_similar(20);
assert_eq!(map.paints.len(), 2, "strips 0 and 1 merge; 2 survives");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(0, 0), map.label(2, 0));
// Area-weighted mean of two equal strips of 100 and 106.
assert_eq!(map.paints[map.label(0, 0) as usize].color().r, 103);
assert_pixel_roundtrip(&map);
}
#[test]
fn merge_similar_uses_running_means_not_original_colors() {
// Gradient chain 100 | 103 | 106 with threshold 9 (grays g apart diff
// by 3g across the three channels). The closest pair merges first
// (ties broken by id → strips 0,1 → mean 101); the merged region vs
// 106 is then 15 apart, over threshold — the chain must NOT collapse
// transitively into one region on the strength of the original colors.
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 103, 106]);
map.merge_similar(9);
assert_eq!(map.paints.len(), 2, "running mean stops the chain");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(1, 0), map.label(2, 0));
}
#[test]
fn merge_similar_ignores_outside_and_non_neighbours() {
// Two same-colored regions separated by OUTSIDE: not adjacent, so they
// must stay distinct faces (merging them would create a disjoint
// region, which face assembly handles, but the ids must stay honest to
// the partition).
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, OUTSIDE, 1], &[100, 100]);
map.merge_similar(20);
assert_eq!(map.paints.len(), 2, "non-adjacent regions never merge");
assert_eq!(map.label(1, 0), OUTSIDE, "outside pixels are untouched");
assert_pixel_roundtrip(&map);
}
#[test]
fn merge_similar_zero_threshold_merges_only_identical_colors() {
// Regions 0 and 1 share a color; region 2 differs by one level. At
// threshold 0 the identical pair merges, the near-identical one stays.
#[rustfmt::skip]
let mut map = gray_grid(3, 1, vec![0, 1, 2], &[100, 100, 101]);
map.merge_similar(0);
assert_eq!(map.paints.len(), 2, "identical neighbours merge at 0");
assert_eq!(map.label(0, 0), map.label(1, 0));
assert_ne!(map.label(1, 0), map.label(2, 0));
// A negative threshold disables merging entirely.
let labels = vec![0, 1, 0, 1];
let mut map = gray_grid(2, 2, labels.clone(), &[100, 100]);
map.merge_similar(-1);
assert_eq!(map.labels, labels);
assert_eq!(map.paints.len(), 2);
}
#[test]
fn compose_mosaic_merges_gradient_faces() {
use super::compose_mosaic;
use super::fit::PixelSegmentFitter;
use crate::ir::{Layer, RegionMask, Segmentation};
use visioncortex::BinaryImage;
// A 6x2 canvas of three 2px strips, one gradient step apart (diff 6),
// as bottom-to-top layers — exactly what a stacked gradient flattens
// into. With merging they are one face; without, three.
let mut seg = Segmentation::new(6, 2);
for (i, g) in [(0, 100u8), (1, 102), (2, 104)] {
let mut image = BinaryImage::new_w_h(2, 2);
for y in 0..2 {
for x in 0..2 {
image.set_pixel(x, y, true);
}
}
seg.layers.push(Layer {
paint: Paint::Solid(Color::new(g, g, g)),
mask: RegionMask::new(
image,
visioncortex::PointI32 { x: i * 2, y: 0 },
),
});
}
let unmerged = compose_mosaic(&seg, &PixelSegmentFitter, 0, &[]);
let merged = compose_mosaic(&seg, &PixelSegmentFitter, 16, &[]);
assert_eq!(unmerged.shapes.len(), 3);
assert_eq!(merged.shapes.len(), 1, "gradient strips coalesce into one face");
assert_eq!(merged.shapes[0].paint.color().r, 102, "area-weighted mean");
}
#[test]
fn random_maps_roundtrip() {
// Deterministic LCG; connectivity not required.
let mut state: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as u32
};
for _ in 0..40 {
let w = 2 + next() % 10;
let h = 2 + next() % 10;
let nlabels = 1 + next() % 5;
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
assert_pixel_roundtrip(&grid(w, h, labels));
}
}
}
+6 -29
View File
@@ -7,7 +7,6 @@
//! both sides.
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use crate::simplify::CurvePass;
use visioncortex::PointF64;
use super::face::{assemble, Contour, Face};
@@ -15,26 +14,9 @@ use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
use super::graph::BoundaryGraph;
use super::{LabelMap, Segmentation};
/// Run the full mosaic pipeline: flatten → merge similar neighbours →
/// boundary graph → faces → fit → curve passes → compose.
///
/// `merge_diff` is the color-difference threshold for
/// [`LabelMap::merge_similar`]; pass the clustering `deepen_diff`
/// (gradient step) so the flattened mosaic rejoins what only the stacked
/// gradient layering had split. `0` still merges identical-color
/// neighbours; negative disables merging entirely.
///
/// `passes` run on each fitted segment before composition — once per shared
/// boundary, so both adjacent faces reference the transformed geometry and
/// the tessellation stays seam-free.
pub fn compose_mosaic(
seg: &Segmentation,
fitter: &dyn SegmentFitter,
merge_diff: i32,
passes: &[Box<dyn CurvePass>],
) -> VectorDoc {
let mut map = LabelMap::from_segmentation(seg);
map.merge_similar(merge_diff);
/// 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);
@@ -43,16 +25,11 @@ pub fn compose_mosaic(
.segments
.iter()
.map(|s| {
let ring = s.is_ring();
let mut geom = if ring {
fitter.fit_ring(s).geom
if s.is_ring() {
fitter.fit_ring(s)
} else {
fitter.fit_open(s).geom
};
for pass in passes {
geom = if ring { pass.ring(geom) } else { pass.open(geom) };
fitter.fit_open(s)
}
FittedSegment { geom }
})
.collect();
+22 -114
View File
@@ -6,108 +6,22 @@
//! with opposite winding automatically — no containment/nesting computation is
//! needed, and the region can be filled with a single `nonzero` path.
use std::collections::BTreeMap;
use super::graph::{
dir_from_delta, edge_present, left_pixel_at, left_pixel_coord, reverse, straight, turn_left,
turn_right, BoundaryGraph, SegRef,
edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef,
};
use super::{LabelMap, RegionId, OUTSIDE};
/// Island id for pixels that belong to no region.
const NO_ISLAND: u32 = u32::MAX;
/// A closed cycle of directed segments bounding (part of) a region.
#[derive(Clone, Debug)]
pub struct Contour(pub Vec<SegRef>);
/// One connected patch of a region and all of its contours (outer + holes).
///
/// A region can appear as several disjoint patches; each gets its own face, so
/// isolated islands never share a path.
/// One region and all of its contours (outer + holes).
#[derive(Clone, Debug)]
pub struct Face {
pub region: RegionId,
pub contours: Vec<Contour>,
}
/// Connected-component ("island") id per pixel, grouping equal labels with
/// 8-connectivity. [`OUTSIDE`] pixels get [`NO_ISLAND`].
///
/// 8-connectivity is what matches [`successor`]: it pinches a checkerboard
/// corner into one contour, so two lobes meeting only at a diagonal are walked
/// as a single contour and must land in a single face. Splitting them
/// (4-connectivity) could put a hole contour in a different face than the ring
/// enclosing it, and a lone hole ring fills solid under `nonzero`.
fn islands(map: &LabelMap) -> Vec<u32> {
let (w, h) = (map.width as usize, map.height as usize);
let mut ids = vec![NO_ISLAND; w * h];
let mut next = 0u32;
let mut stack: Vec<(usize, usize)> = Vec::new();
for start in 0..w * h {
if ids[start] != NO_ISLAND || map.labels[start] == OUTSIDE {
continue;
}
let label = map.labels[start];
let id = next;
next += 1;
ids[start] = id;
stack.push((start % w, start / w));
while let Some((x, y)) = stack.pop() {
for dy in -1i32..=1 {
for dx in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let n = ny as usize * w + nx as usize;
if ids[n] == NO_ISLAND && map.labels[n] == label {
ids[n] = id;
stack.push((nx as usize, ny as usize));
}
}
}
}
}
ids
}
/// Which island a contour bounds, taken from the region-side pixel flanking its
/// first directed edge. Every contour is walked with its region on the left, so
/// that pixel is always interior to the patch the contour belongs to — an outer
/// ring and the holes inside it therefore agree.
fn island_of(graph: &BoundaryGraph, map: &LabelMap, ids: &[u32], r: SegRef) -> u32 {
let seg = &graph.segments[r.seg as usize];
let (corner, dir) = if seg.is_ring() {
let n = seg.points.len();
// A ring is used forward by the region on its left, reversed by the one
// on its right; take the first step of the chosen direction.
let (from, to) = if r.forward {
(seg.points[0], seg.points[1])
} else {
(seg.points[n - 1], seg.points[n - 2])
};
(from, dir_from_delta(to.x - from.x, to.y - from.y))
} else if r.forward {
let node = seg.start.expect("non-ring segment has a start node");
(graph.nodes[node as usize].corner, seg.first_dir)
} else {
let node = seg.end.expect("non-ring segment has an end node");
(graph.nodes[node as usize].corner, reverse(seg.last_dir))
};
let (px, py) = left_pixel_coord(corner.x, corner.y, dir);
if px < 0 || py < 0 || px as u32 >= map.width || py as u32 >= map.height {
return NO_ISLAND;
}
ids[py as usize * map.width as usize + px as usize]
}
/// Left region of a directed segment view.
fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId {
let seg = &graph.segments[r.seg as usize];
@@ -131,12 +45,7 @@ fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
}
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
let ids = islands(map);
// Keyed by (region, island) rather than by region alone, so disjoint patches
// of one region become separate faces — and separate paths downstream. The
// BTreeMap keeps face order deterministic: region ascending, then island in
// raster-scan order.
let mut by_island: BTreeMap<(RegionId, u32), Vec<Contour>> = BTreeMap::new();
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()];
@@ -175,12 +84,8 @@ pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
break;
}
}
if (region as usize) < map.paints.len() {
let island = island_of(graph, map, &ids, start);
by_island
.entry((region, island))
.or_default()
.push(Contour(contour));
if (region as usize) < by_region.len() {
by_region[region as usize].push(Contour(contour));
}
}
}
@@ -191,24 +96,27 @@ pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
if !seg.is_ring() {
continue;
}
for (region, forward) in [(seg.left, true), (seg.right, false)] {
if region == OUTSIDE || (region as usize) >= map.paints.len() {
continue;
}
let r = SegRef {
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,
};
let island = island_of(graph, map, &ids, r);
by_island
.entry((region, island))
.or_default()
.push(Contour(vec![r]));
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_island
by_region
.into_iter()
.map(|((region, _island), contours)| Face { region, contours })
.enumerate()
.filter(|(_, c)| !c.is_empty())
.map(|(region, contours)| Face {
region: region as RegionId,
contours,
})
.collect()
}
+14 -8
View File
@@ -8,11 +8,18 @@ use visioncortex::{PathI32, PathSimplify, PointF64, PointI32, Spline, SubdivideS
use super::graph::Segment;
pub use crate::fitter::FittedGeom;
/// Outset ratio for the 4-point subdivision scheme (matches visioncortex).
const OUTSET_RATIO: f64 = 8.0;
/// 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 {
@@ -91,7 +98,7 @@ impl SegmentFitter for PolygonSegmentFitter {
/// gapless. A distance-based DP can't do this: near the √2/2 threshold it
/// can't separate staircase noise from real curvature. Smoothing and per-slice
/// cubic fitting then reuse the same visioncortex machinery stacked mode uses
/// (open-path variants of the smoothing primitives + `fit_points_with_beziers`),
/// (open-path variants of the smoothing primitives + `fit_points_with_bezier`),
/// so the curve character matches stacked.
#[derive(Debug, Clone)]
pub struct SplineSegmentFitter {
@@ -139,15 +146,14 @@ fn straight_cubic(a: PointF64, b: PointF64) -> [PointF64; 4] {
/// uses in `Spline::from_path_f64`, so mosaic curves have the same character.
const FIT_ERROR: f64 = 10.0;
/// Fit one splice slice, exactly as stacked mode does
/// (`fit_points_with_beziers`: the full retract-handled cubic chain per slice,
/// outer endpoints pinned to the slice ends — a sparse or multi-curve slice is
/// kept faithful instead of being collapsed onto one ballooning cubic).
/// 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.extend(SubdivideSmooth::fit_points_with_beziers(slice, FIT_ERROR)),
_ => out.push(SubdivideSmooth::fit_points_with_bezier(slice, FIT_ERROR)),
}
}
+10 -23
View File
@@ -325,33 +325,20 @@ impl BoundaryGraph {
}
}
/// Pixel flanking the left of the directed edge leaving `(x,y)` in `d`. May be
/// out of bounds, in which case it is [`OUTSIDE`] as far as the map is concerned.
pub(super) fn left_pixel_coord(x: i32, y: i32, d: u8) -> (i32, i32) {
match d {
N => (x - 1, y - 1),
E => (x, y - 1),
S => (x, y),
W => (x - 1, y),
_ => (x, y),
}
}
/// Unit direction of a single lattice step.
pub(super) fn dir_from_delta(dx: i32, dy: i32) -> u8 {
DVEC.iter()
.position(|&v| v == (dx, dy))
.expect("consecutive lattice points differ by one unit step") as u8
}
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
/// face-assembly successor rule against a [`LabelMap`].
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
if !matches!(d, N | E | S | W) {
return OUTSIDE;
let 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,
}
let (px, py) = left_pixel_coord(x, y, d);
map.label(px, py)
}
// Direction constants and edge-present test needed by face assembly.
+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));
}
}
}
@@ -1,13 +1,11 @@
//! Optimizer passes over the [`VectorDoc`] before serialization.
//!
//! * [`QuantizePass`] — round every coordinate once, in document space. Doing
//! it here (rather than at write time) lets [`CleanupPass`] act on the
//! 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`.
//! * [`CleanupPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created. (Curve *simplification* is not an
//! optimizer pass: it must run on shared fitted geometry before composition
//! — see [`crate::simplify`].)
//! * [`SimplifyPass`] — drop zero-length and collinear-redundant segments that
//! quantization may have created.
use visioncortex::PointF64;
@@ -65,7 +63,7 @@ impl OptimizerPass for QuantizePass {
/// Remove zero-length segments and collinear-redundant line vertices.
#[derive(Debug, Clone, Copy, Default)]
pub struct CleanupPass;
pub struct SimplifyPass;
/// Tolerance for treating two points as coincident.
const COINCIDENT_EPS: f64 = 1e-6;
@@ -86,7 +84,7 @@ fn collinear(a: PointF64, b: PointF64, c: PointF64) -> bool {
(cross.abs() / base) < COLLINEAR_EPS
}
fn cleanup_subpath(sub: &SubPath) -> SubPath {
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.
@@ -129,12 +127,12 @@ fn cleanup_subpath(sub: &SubPath) -> SubPath {
out
}
impl OptimizerPass for CleanupPass {
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 = cleanup_subpath(sub);
let simplified = simplify_subpath(sub);
// Keep only subpaths with real geometry (a MoveTo plus at least
// one drawing command beyond Close).
let draws = simplified
@@ -187,7 +185,7 @@ mod tests {
}
#[test]
fn cleanup_drops_collinear_and_zero_length() {
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)),
@@ -197,7 +195,7 @@ mod tests {
PathCmd::LineTo(pt(2.0, 5.0)),
PathCmd::Close,
]);
CleanupPass.run(&mut doc);
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);
+4 -91
View File
@@ -6,10 +6,8 @@ use crate::colorfit::ColorFitter;
use crate::compose::Compositing;
use crate::error::Error;
use crate::frontend::Frontend;
use crate::ir::{Segmentation, VectorDoc};
use crate::ir::VectorDoc;
use crate::optimize::OptimizerPass;
use crate::progress::{CancelToken, Ctx, Phase, Progress};
use crate::simplify::CurvePass;
use crate::svg::SvgWriter;
/// A fully-assembled vectorization pipeline. Build one with
@@ -18,109 +16,24 @@ pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub compositing: Compositing,
/// Geometry passes over fitted contours (e.g. curve simplification), run
/// inside compositing — after curve fitting, before paths are assembled —
/// so mosaic mode applies them once per shared boundary segment.
pub curve_passes: Vec<Box<dyn CurvePass>>,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
pub writer: SvgWriter,
}
impl Pipeline {
/// Run the pipeline to the output document IR (before serialization).
///
/// Equivalent to [`run_with_progress`](Pipeline::run_with_progress) with a
/// fresh (never-cancelled) token and a no-op progress callback.
pub fn run(&self, img: &ColorImage) -> Result<VectorDoc, Error> {
self.run_with_progress(img, &CancelToken::new(), &mut |_| {})
}
let mut seg = self.frontend.segment(img)?;
/// Run the pipeline, publishing [`Progress`] updates and honoring the
/// [`CancelToken`].
///
/// Intended to be called on a worker thread: hand a clone of `cancel` to
/// the UI so a button can abort, and forward `on_progress` to a channel
/// that drives a progress bar. Returns [`Error::Cancelled`] if the token is
/// tripped. See [`crate::progress`] for a usage example.
pub fn run_with_progress(
&self,
img: &ColorImage,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<VectorDoc, Error> {
let mut ctx = Ctx::new(cancel, on_progress);
let seg = self.frontend.segment_with(img, &mut ctx)?;
// `seg` is owned and about to be consumed, so no clone is needed here.
self.finish_ctx(seg, &mut ctx)
}
/// Phase 1 of 2 — run **only** the frontend (the expensive clustering step)
/// and return a reusable [`Segmentation`].
///
/// Cache the result and feed it to [`finish`](Pipeline::finish) to
/// re-render with different color-fitting, curve-fitting, or optimization
/// parameters *without repaying the clustering cost* — the core of an
/// interactive tuning loop. Re-run `segment` when a parameter that affects
/// clustering itself changes: filter speckle, color precision, layer
/// difference, binary threshold, or the frontend choice.
pub fn segment(&self, img: &ColorImage) -> Result<Segmentation, Error> {
self.segment_with_progress(img, &CancelToken::new(), &mut |_| {})
}
/// [`segment`](Pipeline::segment) with progress reporting and cancellation.
pub fn segment_with_progress(
&self,
img: &ColorImage,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<Segmentation, Error> {
let mut ctx = Ctx::new(cancel, on_progress);
self.frontend.segment_with(img, &mut ctx)
}
/// Phase 2 of 2 — color fitting → compositing → optimization, reusing a
/// [`Segmentation`] produced by [`segment`](Pipeline::segment).
///
/// The segmentation is cloned internally (color fitting mutates it), so the
/// cached copy stays pristine and can be reused across many `finish` calls
/// with different pipelines. The frontend of `self` is not used here; build
/// the tuning pipeline with the color/curve/optimize parameters you want
/// and the *same* clustering parameters that produced `seg`.
pub fn finish(&self, seg: &Segmentation) -> Result<VectorDoc, Error> {
self.finish_with_progress(seg, &CancelToken::new(), &mut |_| {})
}
/// [`finish`](Pipeline::finish) with progress reporting and cancellation.
/// Progress starts at the [`Phase::Compose`] stage (segmentation is skipped).
pub fn finish_with_progress(
&self,
seg: &Segmentation,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<VectorDoc, Error> {
let mut ctx = Ctx::new(cancel, on_progress);
self.finish_ctx(seg.clone(), &mut ctx)
}
/// Downstream stages (color fit → compose → optimize) over an owned
/// segmentation. Shared by the one-shot and two-phase entry points; takes
/// ownership so the one-shot path avoids a clone.
fn finish_ctx(&self, mut seg: Segmentation, ctx: &mut Ctx) -> Result<VectorDoc, Error> {
for fitter in &self.color_fitters {
fitter.fit(&mut seg);
ctx.check()?;
}
let mut doc = self.compositing.compose_with(&seg, &self.curve_passes, ctx)?;
let mut doc = self.compositing.compose(&seg);
let total = self.optimizers.len().max(1);
for (i, pass) in self.optimizers.iter().enumerate() {
ctx.check()?;
for pass in &self.optimizers {
pass.run(&mut doc);
ctx.report(Phase::Optimize, (i + 1) as f32 / total as f32);
}
// Always emit a terminal 100% so a UI can settle even with no passes.
ctx.report(Phase::Optimize, 1.0);
Ok(doc)
}
-113
View File
@@ -1,113 +0,0 @@
//! Progress reporting and cancellation for long-running conversions.
//!
//! [`crate::Pipeline::run_with_progress`] takes a [`CancelToken`] and a
//! progress callback. On native targets, run it on a worker thread: the
//! callback publishes [`Progress`] to the UI and the token lets the UI abort
//! between work batches (clustering checks once per batch, so cancellation is
//! near-instant). The pipeline returns [`Error::Cancelled`] when the token is
//! tripped.
//!
//! There is deliberately no cooperative `tick()` here: that only existed in the
//! old browser build because the main thread could not block. The same API
//! works unchanged from a Web Worker.
//!
//! ```no_run
//! use vtracer::{Config, ColorImage};
//! use vtracer::progress::{CancelToken, Progress};
//!
//! # fn load() -> ColorImage { todo!() }
//! let pipeline = Config::default().build().unwrap();
//! let cancel = CancelToken::new();
//! # let img: ColorImage = load();
//! // hand `cancel.clone()` to the UI so a button can call `cancel.cancel()`
//! let mut on_progress = |p: Progress| eprintln!("{:?} {:.0}%", p.phase, p.fraction * 100.0);
//! let doc = pipeline.run_with_progress(&img, &cancel, &mut on_progress);
//! ```
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::error::Error;
/// A cheaply-clonable cancellation flag shared between the UI and the worker.
///
/// Clone it, hand one copy to the worker thread running the pipeline and keep
/// the other; call [`cancel`](CancelToken::cancel) from any thread to request
/// an early stop. Clones share the same underlying flag.
#[derive(Clone, Default)]
pub struct CancelToken(Arc<AtomicBool>);
impl CancelToken {
/// A fresh, un-cancelled token.
pub fn new() -> Self {
Self::default()
}
/// Request cancellation. Idempotent; safe to call from any thread.
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
/// Whether cancellation has been requested.
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// Which pipeline phase a [`Progress`] update belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
/// Frontend segmentation (color clustering) — usually the dominant cost.
Segment,
/// Compositing the segmentation into shapes.
Compose,
/// Output optimization passes.
Optimize,
}
/// A progress update: the current [`Phase`] and how far through it we are.
///
/// `fraction` is *within* the phase, in `0.0..=1.0`. Clustering dominates
/// runtime, so a UI can weight the phases or simply show the phase label with
/// its fraction (e.g. "Clustering 45%").
#[derive(Clone, Copy, Debug)]
pub struct Progress {
pub phase: Phase,
pub fraction: f32,
}
/// Bundles the cancel token and progress sink threaded through the stages.
///
/// Stages call [`Ctx::check`] between batches to honor cancellation and
/// [`Ctx::report`] to publish progress.
pub struct Ctx<'a> {
cancel: &'a CancelToken,
on_progress: &'a mut dyn FnMut(Progress),
}
impl<'a> Ctx<'a> {
/// Construct a context from a token and a progress callback.
pub fn new(cancel: &'a CancelToken, on_progress: &'a mut dyn FnMut(Progress)) -> Self {
Self {
cancel,
on_progress,
}
}
/// Return [`Error::Cancelled`] if cancellation has been requested.
pub fn check(&self) -> Result<(), Error> {
if self.cancel.is_cancelled() {
Err(Error::Cancelled)
} else {
Ok(())
}
}
/// Publish a progress update for `phase` at `fraction` (clamped to 0..=1).
pub fn report(&mut self, phase: Phase, fraction: f32) {
(self.on_progress)(Progress {
phase,
fraction: fraction.clamp(0.0, 1.0),
});
}
}
-159
View File
@@ -1,159 +0,0 @@
//! Interactive tuning session: cache the expensive clustering, re-render on the
//! cheap stages, and re-segment automatically only when it's actually needed.
//!
//! A desktop app loads an image once, then calls [`Session::render`] on every
//! slider change with a fresh [`Config`]. The session compares the config's
//! [`SegmentKey`](crate::config::SegmentKey) to what it last clustered and
//! re-segments only if a clustering parameter changed — the caller never has to
//! know which parameters those are.
//!
//! For watershed clustering there is a second cache level: the
//! [`WatershedHierarchy`] depends only on the image, so it is built once and
//! every re-segmentation (a detail or speckle change) is a near-instant re-cut
//! of the cached hierarchy rather than a rebuild.
//!
//! ```no_run
//! use vtracer::{Config, Session, ColorImage};
//! # fn load() -> ColorImage { todo!() }
//! let mut session = Session::new(load());
//!
//! // First render clusters the image.
//! let mut cfg = Config::default();
//! let _svg = session.render_svg(&cfg).unwrap();
//!
//! // Tuning a curve parameter reuses the cached segmentation (no re-cluster).
//! cfg.corner_threshold = 90;
//! let _svg = session.render_svg(&cfg).unwrap();
//!
//! // Changing a clustering parameter re-segments automatically.
//! cfg.filter_speckle = 8;
//! let _svg = session.render_svg(&cfg).unwrap();
//! ```
use visioncortex::ColorImage;
use crate::config::{Clustering, Config, SegmentKey};
use crate::error::Error;
use crate::frontend::WatershedHierarchy;
use crate::ir::{Segmentation, VectorDoc};
use crate::pipeline::Pipeline;
use crate::progress::{CancelToken, Ctx, Phase, Progress};
/// A reusable converter for one image: clusters once, re-renders many times.
///
/// Build it with the source [`ColorImage`] and drive it with a [`Config`] per
/// render. The cached [`Segmentation`] is refreshed transparently whenever the
/// config's clustering parameters change.
pub struct Session {
img: ColorImage,
/// The segmentation and the key it was produced with (`None` until the
/// first render).
cache: Option<(SegmentKey, Segmentation)>,
/// The image's watershed hierarchy, built lazily on the first watershed
/// render. Parameter-free, so it never goes stale while the image lives.
hierarchy: Option<WatershedHierarchy>,
}
impl Session {
/// Start a session over `img`. Nothing is clustered until the first render.
pub fn new(img: ColorImage) -> Self {
Self {
img,
cache: None,
hierarchy: None,
}
}
/// Whether the cached segmentation is missing or was clustered with
/// different parameters than `key`.
fn stale(&self, key: &SegmentKey) -> bool {
self.cache.as_ref().map_or(true, |(k, _)| k != key)
}
/// The cached segmentation. Only call after ensuring the cache is fresh.
fn segmentation(&self) -> &Segmentation {
&self.cache.as_ref().expect("cache populated by caller").1
}
/// Produce a fresh segmentation for `cfg`. Watershed goes through the
/// hierarchy cache (build once, cut cheaply); everything else runs the
/// pipeline's frontend.
fn segment(&mut self, cfg: &Config, pipeline: &Pipeline) -> Result<Segmentation, Error> {
if cfg.clustering == Clustering::Watershed {
if self.hierarchy.is_none() {
self.hierarchy = Some(WatershedHierarchy::build(&self.img)?);
}
let hierarchy = self.hierarchy.as_ref().expect("just built");
Ok(hierarchy.cut(&self.img, cfg.watershed_detail, cfg.speckle_area()))
} else {
pipeline.segment(&self.img)
}
}
/// Render to the document IR, re-segmenting only if `cfg`'s clustering
/// parameters differ from the cached segmentation's.
pub fn render(&mut self, cfg: &Config) -> Result<VectorDoc, Error> {
let pipeline = cfg.build()?;
let key = cfg.segment_key();
if self.stale(&key) {
let seg = self.segment(cfg, &pipeline)?;
self.cache = Some((key, seg));
}
pipeline.finish(self.segmentation())
}
/// [`render`](Session::render), serialized to an SVG string.
pub fn render_svg(&mut self, cfg: &Config) -> Result<String, Error> {
let pipeline = cfg.build()?;
let key = cfg.segment_key();
if self.stale(&key) {
let seg = self.segment(cfg, &pipeline)?;
self.cache = Some((key, seg));
}
Ok(pipeline.writer.write(&pipeline.finish(self.segmentation())?))
}
/// [`render`](Session::render) with progress reporting and cancellation.
///
/// When a re-segmentation is needed, progress covers the [`Phase::Segment`]
/// stage first, then the finish stages; on a cache hit only the finish
/// stages report. Hand a clone of `cancel` to the UI to abort a long
/// clustering pass. (A watershed re-cut over a cached hierarchy is fast
/// enough that it reports coarsely.)
pub fn render_with_progress(
&mut self,
cfg: &Config,
cancel: &CancelToken,
on_progress: &mut dyn FnMut(Progress),
) -> Result<VectorDoc, Error> {
let pipeline = cfg.build()?;
let key = cfg.segment_key();
if self.stale(&key) {
let seg = if cfg.clustering == Clustering::Watershed {
let mut ctx = Ctx::new(cancel, on_progress);
ctx.check()?;
let seg = self.segment(cfg, &pipeline)?;
ctx.check()?;
ctx.report(Phase::Segment, 1.0);
seg
} else {
pipeline.segment_with_progress(&self.img, cancel, on_progress)?
};
self.cache = Some((key, seg));
}
pipeline.finish_with_progress(self.segmentation(), cancel, on_progress)
}
/// Drop the cached segmentation and hierarchy, forcing the next render to
/// re-cluster. Use after replacing the source image out of band; normally
/// unnecessary.
pub fn invalidate(&mut self) {
self.cache = None;
self.hierarchy = None;
}
/// The source image this session renders.
pub fn image(&self) -> &ColorImage {
&self.img
}
}
-383
View File
@@ -1,383 +0,0 @@
//! Curve passes: geometry transforms between curve fitting and composition.
//!
//! A [`CurvePass`] rewrites one fitted contour at a time. Passes run *before*
//! composition on the fitted geometry itself — in mosaic mode each shared
//! boundary segment is transformed exactly once and both adjacent faces
//! reference the result, so the tessellation stays seam-free by construction.
//! Running instead on the composed [`VectorDoc`](crate::ir::VectorDoc) would
//! re-fit the two copies of every shared boundary independently and reopen
//! the seams the mosaic exists to prevent.
//!
//! The built-in pass is [`SimplifyCurves`], the paper.js `simplify` analogue.
use flo_curves::bezier::{fit_curve_cubic, Curve};
use flo_curves::Coord2;
use visioncortex::PointF64;
use crate::fitter::FittedGeom;
/// A geometry pass over one fitted contour, run between curve fitting and
/// composition. Implementations must keep an open chain's endpoints exactly
/// (mosaic junction nodes must not move) and keep a ring closed.
pub trait CurvePass {
/// Transform an open chain; both endpoints are pinned.
fn open(&self, geom: FittedGeom) -> FittedGeom;
/// Transform a closed ring.
fn ring(&self, geom: FittedGeom) -> FittedGeom;
}
/// paper.js-style curve simplification (Schneider's fit): re-fit each smooth
/// run of cubics between corners with the fewest curves that stay within
/// `tolerance` of the fitted geometry.
///
/// The spline fitters cut an outline at every splice point and fit each short
/// slice separately, so a lazily curving edge carries an anchor per splice.
/// This pass samples the fitted curve (~1 px spacing) and re-fits whole
/// corner-to-corner runs with `flo_curves`' Schneider implementation
/// (`fit_curve_cubic`, tangents taken from the chain's own ends), merging
/// those slices down to what the tolerance genuinely requires.
///
/// A run is replaced only when the re-fit uses strictly fewer cubics and is
/// kept verbatim otherwise, so the pass never increases the curve count and
/// never moves the geometry more than `tolerance` (measured at the samples).
/// Polylines (pixel / polygon modes) pass through untouched.
#[derive(Debug, Clone, Copy)]
pub struct SimplifyCurves {
/// Maximum distance (px) the simplified curve may stray from the fitted
/// one. paper.js defaults to 2.5.
pub tolerance: f64,
/// Tangent-break angle (radians) above which an anchor is a corner and
/// must survive in place; runs are re-fitted between corners.
pub corner_threshold: f64,
}
impl CurvePass for SimplifyCurves {
fn open(&self, geom: FittedGeom) -> FittedGeom {
match geom {
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, false)),
other => other,
}
}
fn ring(&self, geom: FittedGeom) -> FittedGeom {
match geom {
FittedGeom::Beziers(chain) => FittedGeom::Beziers(self.simplify_chain(chain, true)),
other => other,
}
}
}
impl SimplifyCurves {
fn simplify_chain(&self, mut chain: Vec<[PointF64; 4]>, closed: bool) -> Vec<[PointF64; 4]> {
if self.tolerance <= 0.0 || chain.len() < 2 {
return chain;
}
if closed {
// The re-fit pins run endpoints, so a ring needs a seam. Put it at
// the sharpest junction (wraparound included): a corner the fit
// would keep anyway, or the least-smooth anchor when the ring has
// none, so any residual tangent break lands where it hides best.
let angles: Vec<f64> = (0..chain.len())
.map(|k| {
let prev = if k == 0 { chain.len() - 1 } else { k - 1 };
break_angle(&chain[prev], &chain[k])
})
.collect();
let seam = angles
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
chain.rotate_left(seam);
}
// Cut into smooth runs at corner anchors (chain ends are always cuts).
let mut cuts: Vec<usize> = vec![0];
for i in 1..chain.len() {
if break_angle(&chain[i - 1], &chain[i]) >= self.corner_threshold {
cuts.push(i);
}
}
cuts.push(chain.len());
let mut out: Vec<[PointF64; 4]> = Vec::with_capacity(chain.len());
for w in cuts.windows(2) {
let run = &chain[w[0]..w[1]];
if run.len() < 2 {
out.extend_from_slice(run);
continue;
}
match refit_run(run, self.tolerance) {
Some(refit) if refit.len() < run.len() => out.extend(refit),
_ => out.extend_from_slice(run),
}
}
out
}
}
/// Schneider-fit one smooth run: sample it, then `fit_curve_cubic` with the
/// run's own end tangents (`end_tangent` points backward, per its contract).
/// The recursion splits at sample points, so consecutive fitted cubics share
/// endpoints exactly; the outer endpoints are pinned to the run's, bit for
/// bit. Returns `None` for degenerate (point-like) runs.
fn refit_run(run: &[[PointF64; 4]], tolerance: f64) -> Option<Vec<[PointF64; 4]>> {
let start_tan = tangent_out(run.first()?)?;
let end_tan = tangent_in(run.last()?)?;
let samples: Vec<Coord2> = sample_run(run, tolerance)
.into_iter()
.map(|p| Coord2(p.x, p.y))
.collect();
let fitted: Vec<Curve<Coord2>> = fit_curve_cubic(
&samples,
&Coord2(start_tan.0, start_tan.1),
&Coord2(-end_tan.0, -end_tan.1),
tolerance,
);
if fitted.is_empty() {
return None;
}
let pt = |c: Coord2| PointF64 { x: c.0, y: c.1 };
let mut out: Vec<[PointF64; 4]> = fitted
.into_iter()
.map(|c| [pt(c.start_point), pt(c.control_points.0), pt(c.control_points.1), pt(c.end_point)])
.collect();
out.first_mut()?[0] = run[0][0];
out.last_mut()?[3] = run[run.len() - 1][3];
Some(out)
}
fn dist(a: PointF64, b: PointF64) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
/// Unit direction a→b, or `None` when the points (nearly) coincide.
fn dir(a: PointF64, b: PointF64) -> Option<(f64, f64)> {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
None
} else {
Some((dx / len, dy / len))
}
}
/// Tangent arriving at a cubic's end: the last distinct control point wins.
fn tangent_in(c: &[PointF64; 4]) -> Option<(f64, f64)> {
dir(c[2], c[3]).or_else(|| dir(c[1], c[3])).or_else(|| dir(c[0], c[3]))
}
/// Tangent leaving a cubic's start: the first distinct control point wins.
fn tangent_out(c: &[PointF64; 4]) -> Option<(f64, f64)> {
dir(c[0], c[1]).or_else(|| dir(c[0], c[2])).or_else(|| dir(c[0], c[3]))
}
/// Turn angle at the junction of two consecutive cubics. A fully degenerate
/// (point-like) neighbour counts as a corner so it is never smoothed across.
fn break_angle(prev: &[PointF64; 4], next: &[PointF64; 4]) -> f64 {
match (tangent_in(prev), tangent_out(next)) {
(Some(a), Some(b)) => (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0).acos(),
_ => std::f64::consts::PI,
}
}
fn cubic_at(c: &[PointF64; 4], t: f64) -> PointF64 {
let u = 1.0 - t;
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
PointF64 {
x: b0 * c[0].x + b1 * c[1].x + b2 * c[2].x + b3 * c[3].x,
y: b0 * c[0].y + b1 * c[1].y + b2 * c[2].y + b3 * c[3].y,
}
}
/// Sample a run of cubics at roughly 1 px spacing (by control-polygon length),
/// tighter when the tolerance is sub-pixel — the fit measures its error at
/// the samples, so their spacing is the fidelity guard. The first and last
/// samples are the run's endpoints, exactly: `cubic_at` with `t = 1` returns
/// `c[3]` bit for bit.
fn sample_run(run: &[[PointF64; 4]], tolerance: f64) -> Vec<PointF64> {
let spacing = tolerance.clamp(0.25, 1.0);
let mut samples = vec![run[0][0]];
for c in run {
let len = dist(c[0], c[1]) + dist(c[1], c[2]) + dist(c[2], c[3]);
let n = ((len / spacing).ceil() as usize).clamp(1, 512);
for k in 1..=n {
samples.push(cubic_at(c, k as f64 / n as f64));
}
}
samples
}
#[cfg(test)]
mod tests {
use super::*;
fn pt(x: f64, y: f64) -> PointF64 {
PointF64 { x, y }
}
/// A degenerate cubic tracing the straight line `a`→`b`.
fn straight(a: PointF64, b: PointF64) -> [PointF64; 4] {
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
[a, lerp(1.0 / 3.0), lerp(2.0 / 3.0), b]
}
/// `n` straight cubics subdividing the segment `a`→`b`.
fn straight_chain(a: PointF64, b: PointF64, n: usize) -> Vec<[PointF64; 4]> {
let lerp = |t: f64| pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
(0..n)
.map(|i| straight(lerp(i as f64 / n as f64), lerp((i + 1) as f64 / n as f64)))
.collect()
}
/// One cubic approximating the circular arc `a0..a1` on a circle of
/// radius `r` about the origin (the classic 4/3·tan(Δ/4) handle length).
fn arc_cubic(r: f64, a0: f64, a1: f64) -> [PointF64; 4] {
let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan();
let (p0, p3) = (pt(r * a0.cos(), r * a0.sin()), pt(r * a1.cos(), r * a1.sin()));
[
p0,
pt(p0.x - k * r * a0.sin(), p0.y + k * r * a0.cos()),
pt(p3.x + k * r * a1.sin(), p3.y - k * r * a1.cos()),
p3,
]
}
fn pass() -> SimplifyCurves {
SimplifyCurves {
tolerance: 1.0,
corner_threshold: std::f64::consts::PI / 3.0,
}
}
fn anchors(chain: &[[PointF64; 4]]) -> Vec<PointF64> {
let mut a: Vec<PointF64> = chain.iter().map(|c| c[0]).collect();
a.push(chain.last().unwrap()[3]);
a
}
#[test]
fn collinear_run_collapses_to_one_cubic() {
let chain = straight_chain(pt(0.0, 0.0), pt(100.0, 0.0), 10);
let out = match pass().open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 1, "ten collinear cubics become one");
assert_eq!(out[0][0], pt(0.0, 0.0), "start pinned");
assert_eq!(out[0][3], pt(100.0, 0.0), "end pinned");
}
#[test]
fn corner_survives_in_place() {
// An L: two straight runs meeting at a right angle.
let mut chain = straight_chain(pt(0.0, 0.0), pt(50.0, 0.0), 5);
chain.extend(straight_chain(pt(50.0, 0.0), pt(50.0, 50.0), 5));
let out = match pass().open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 2, "one cubic per leg");
assert_eq!(out[0][3], pt(50.0, 0.0), "corner anchor exact");
assert_eq!(out[1][0], pt(50.0, 0.0), "chain continuous through corner");
assert_eq!(out[0][0], pt(0.0, 0.0));
assert_eq!(out[1][3], pt(50.0, 50.0));
}
#[test]
fn ring_stays_closed_and_keeps_square_corners() {
// A closed square, three cubics per side, seam mid-side (anchor 0 is
// smooth) — the pass must rotate the seam onto a corner.
let corners = [pt(0.0, 0.0), pt(60.0, 0.0), pt(60.0, 60.0), pt(0.0, 60.0)];
let mut chain = Vec::new();
for i in 0..4 {
chain.extend(straight_chain(corners[i], corners[(i + 1) % 4], 3));
}
chain.rotate_left(1); // seam mid-side
let out = match pass().ring(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert_eq!(out.len(), 4, "one cubic per side");
assert_eq!(out[0][0], out.last().unwrap()[3], "ring closed");
let mut got = anchors(&out);
got.pop(); // last repeats first
for c in corners {
assert!(got.contains(&c), "corner {c:?} kept, got {got:?}");
}
}
#[test]
fn arc_merges_within_tolerance() {
// A quarter circle as 8 short arcs collapses to far fewer cubics, and
// the result stays within tolerance of the true circle.
let r = 50.0;
let n = 8;
let chain: Vec<[PointF64; 4]> = (0..n)
.map(|i| {
let step = std::f64::consts::FRAC_PI_2 / n as f64;
arc_cubic(r, i as f64 * step, (i + 1) as f64 * step)
})
.collect();
let tol = 0.5;
let p = SimplifyCurves {
tolerance: tol,
corner_threshold: std::f64::consts::PI / 3.0,
};
let out = match p.open(FittedGeom::Beziers(chain)) {
FittedGeom::Beziers(c) => c,
_ => panic!("geometry kind changed"),
};
assert!(out.len() < 8, "arcs merge, got {}", out.len());
for c in &out {
for k in 0..=32 {
let q = cubic_at(c, k as f64 / 32.0);
let radial = ((q.x * q.x + q.y * q.y).sqrt() - r).abs();
assert!(radial <= tol + 0.1, "deviation {radial} beyond tolerance");
}
}
}
#[test]
fn refit_never_grows_the_chain() {
// A single cubic is untouchable; a sharp S of two cubics that cannot
// merge within a tiny tolerance is kept verbatim.
let lone = vec![arc_cubic(50.0, 0.0, 1.0)];
match pass().open(FittedGeom::Beziers(lone.clone())) {
FittedGeom::Beziers(c) => assert_eq!(c, lone),
_ => panic!("geometry kind changed"),
}
let s_curve = vec![
[pt(0.0, 0.0), pt(20.0, 40.0), pt(30.0, 40.0), pt(50.0, 0.0)],
[pt(50.0, 0.0), pt(70.0, -40.0), pt(80.0, -40.0), pt(100.0, 0.0)],
];
let tight = SimplifyCurves {
tolerance: 0.01,
corner_threshold: std::f64::consts::PI / 3.0,
};
match tight.open(FittedGeom::Beziers(s_curve.clone())) {
FittedGeom::Beziers(c) => {
assert!(c.len() <= s_curve.len(), "never more cubics than input")
}
_ => panic!("geometry kind changed"),
}
}
#[test]
fn polylines_pass_through_untouched() {
let poly = vec![pt(0.0, 0.0), pt(1.0, 0.0), pt(2.0, 0.0), pt(3.0, 0.0)];
match pass().open(FittedGeom::Polyline(poly.clone())) {
FittedGeom::Polyline(p) => assert_eq!(p, poly),
_ => panic!("polyline must stay a polyline"),
}
match pass().ring(FittedGeom::Polyline(poly.clone())) {
FittedGeom::Polyline(p) => assert_eq!(p, poly),
_ => panic!("polyline must stay a polyline"),
}
}
}
-122
View File
@@ -1,122 +0,0 @@
//! Binary thresholding: tunable fixed cutoff and BradleyRoth adaptive.
use vtracer::frontend::{BinaryFrontend, Frontend};
use vtracer::{ColorImage, Threshold};
fn gray(w: usize, h: usize, f: impl Fn(usize, usize) -> u8) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let v = f(x, y);
pixels.extend_from_slice(&[v, v, v, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// Total foreground pixels selected by a frontend over an image.
fn foreground_area(front: &BinaryFrontend, img: &ColorImage) -> usize {
front
.segment(img)
.unwrap()
.layers
.iter()
.map(|l| l.mask.area())
.sum()
}
/// A uniform gray field: a lower fixed threshold selects strictly fewer pixels.
#[test]
fn fixed_threshold_is_tunable() {
// Left third value 80, middle 130, right 180.
let img = gray(60, 20, |x, _| match x / 20 {
0 => 80,
1 => 130,
_ => 180,
});
let front = |v: u8| BinaryFrontend {
threshold: Threshold::Fixed(v),
diagonal: false,
min_area: 0,
};
let low = foreground_area(&front(100), &img); // catches only the 80 band
let mid = foreground_area(&front(150), &img); // 80 + 130 bands
let high = foreground_area(&front(200), &img); // everything
assert!(
low < mid && mid < high,
"higher threshold must select more foreground: {low} < {mid} < {high}"
);
assert_eq!(high, 60 * 20, "threshold above all values selects everything");
}
/// Adaptive thresholding recovers locally-dark marks under a brightness
/// gradient that no single global cutoff can separate.
#[test]
fn adaptive_beats_fixed_under_uneven_lighting() {
let (w, h) = (80, 40);
// Background ramps left(70) → right(210). Two 6x6 marks, each 40 darker
// than their local background: one on the dark side, one on the bright side.
let bg = |x: usize| 70 + (x * 140 / (w - 1)) as u8;
let marks = [(16usize, 17usize), (60, 17)];
let is_mark = |x: usize, y: usize| {
marks
.iter()
.any(|&(mx, my)| x >= mx && x < mx + 6 && y >= my && y < my + 6)
};
let img = gray(w, h, |x, y| {
if is_mark(x, y) {
bg(x).saturating_sub(40)
} else {
bg(x)
}
});
let base = BinaryFrontend {
threshold: Threshold::Fixed(128),
diagonal: false,
min_area: 4,
};
// A global cutoff can't isolate both marks: 128 catches the dark-side mark
// but floods the whole dark half of the ramp, and misses the bright-side
// mark (~136) entirely — so fixed has no region on the bright half.
let fixed_seg = base.segment(&img).unwrap();
let fixed_area: usize = fixed_seg.layers.iter().map(|l| l.mask.area()).sum();
let mid = (w as i32) / 2;
let fixed_right = fixed_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
// Adaptive: window comfortably larger than the 6px marks so they fill.
let adaptive = BinaryFrontend {
threshold: Threshold::Adaptive {
window: 21,
t: 15.0,
},
..base.clone()
};
let adaptive_seg = adaptive.segment(&img).unwrap();
let adaptive_area: usize = adaptive_seg.layers.iter().map(|l| l.mask.area()).sum();
let adaptive_left = adaptive_seg.layers.iter().any(|l| l.mask.offset.x < mid);
let adaptive_right = adaptive_seg.layers.iter().any(|l| l.mask.offset.x >= mid);
// The point of adaptive: it finds locally-dark marks on *both* sides of the
// ramp, where the global threshold catches only the dark half.
assert!(!fixed_right, "fixed(128) should miss the bright-side mark");
assert!(
adaptive_left && adaptive_right,
"adaptive should detect marks on both the dark and bright sides"
);
assert!(adaptive_area > 0, "adaptive must select some foreground");
assert!(
adaptive_area * 3 < fixed_area,
"adaptive should select far less than fixed's flooded half: \
adaptive={adaptive_area}, fixed={fixed_area}"
);
}
+4 -29
View File
@@ -104,13 +104,12 @@ fn neighbor_diff(img: &[u8], i: usize, j: usize) -> u8 {
(0..4).map(|c| img[i + c].abs_diff(img[j + c])).max().unwrap_or(0)
}
fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
fn assert_equivalent(mode: FitMode) {
let (w, h) = (96usize, 96usize);
let img = blobs(w, h);
let stacked = Config {
mode,
clustering,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
@@ -121,7 +120,6 @@ fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
let cutout = Config {
mode,
clustering,
hierarchical: Hierarchical::Cutout,
..Config::default()
}
@@ -153,10 +151,6 @@ fn assert_equivalent_with(mode: FitMode, clustering: vtracer::Clustering) {
);
}
fn assert_equivalent(mode: FitMode) {
assert_equivalent_with(mode, vtracer::Clustering::ColorCluster);
}
#[test]
fn stacked_and_cutout_agree_in_interiors_spline() {
assert_equivalent(FitMode::Spline);
@@ -172,13 +166,6 @@ fn stacked_and_cutout_agree_in_interiors_pixel() {
assert_equivalent(FitMode::Pixel);
}
#[test]
fn watershed_stacked_and_cutout_agree_in_interiors() {
for mode in [FitMode::Pixel, FitMode::Spline] {
assert_equivalent_with(mode, vtracer::Clustering::Watershed);
}
}
// --- seam / show-through test -------------------------------------------------
fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
@@ -193,12 +180,12 @@ fn rasterize_on(svg: &str, w: u32, h: u32, bg: [u8; 4]) -> Vec<u8> {
/// solid layers overdraw with no gaps, so nothing shows through. Show-through
/// (backdrop-dependent pixels away from the canvas edge) means seams — which is
/// exactly the hole-punching bug this guards against.
fn assert_no_seams(clustering: vtracer::Clustering) {
#[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,
clustering,
hierarchical: Hierarchical::Stacked,
..Config::default()
}
@@ -223,18 +210,6 @@ fn assert_no_seams(clustering: vtracer::Clustering) {
}
assert_eq!(
show_through, 0,
"{clustering:?} stacked leaked {show_through} backdrop pixels — seams/holes in overdraw"
"stacked mode leaked {show_through} backdrop pixels — seams/holes in solid overdraw"
);
}
#[test]
fn stacked_has_no_seams() {
assert_no_seams(vtracer::Clustering::ColorCluster);
}
/// The watershed frontend emits disjoint region masks; its full-canvas solid
/// background layer is what restores overdraw. This guards that construction.
#[test]
fn watershed_stacked_has_no_seams() {
assert_no_seams(vtracer::Clustering::Watershed);
}
+2 -21
View File
@@ -19,7 +19,7 @@
use std::path::PathBuf;
use resvg::{tiny_skia, usvg};
use vtracer::{Color, ColorImage, Clustering, Config, FitMode, Hierarchical};
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
// --- synthetic image builders ------------------------------------------------
@@ -141,7 +141,7 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
"checker_bw",
checker(),
Config {
clustering: Clustering::Binary,
color_mode: ColorMode::Binary,
..base()
},
),
@@ -210,25 +210,6 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
..base()
},
),
// Watershed clustering: stacked and mosaic.
(
"disc_watershed_spline",
disc(),
Config {
clustering: Clustering::Watershed,
..base()
},
),
(
"swatches_watershed_mosaic",
swatches(),
Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
mode: FitMode::Polygon,
..base()
},
),
]
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#000000"/>
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#FFFFFF"/>
<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>

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 512 B

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="40">
<path d="M0,0C16,0,32,0,48,0c0,13.33,0,26.67,0,40c-16,0-32,0-48,0C0,26.67,0,13.33,0,0Z" fill="#28C83C"/>
<path d="M36,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#E6D228"/>
<path d="M24,0c4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0c0-13.33,0-26.67,0-40Z" fill="#323CDC"/>
<path d="M0,0C4,0,8,0,12,0c0,13.33,0,26.67,0,40c-4,0-8,0-12,0C0,26.67,0,13.33,0,0Z" fill="#DC2828"/>
<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>

Before

Width:  |  Height:  |  Size: 591 B

After

Width:  |  Height:  |  Size: 623 B

@@ -1,50 +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="M8,8V0H0V8H8Z" fill="#141414"/>
<path d="M16,0H8V8h8V0Z" fill="#EBEBEB"/>
<path d="M24,0H16V8h8V0Z" fill="#141414"/>
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M48,8V0H40V8h8Z"/>
<path d="M8,8H0v8H8V8Z"/>
</g>
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M48,8H40v8h8V8Z"/>
<path d="M8,16H0v8H8V16Z"/>
</g>
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M48,16H40v8h8V16Z"/>
<path d="M8,24H0v8H8V24Z"/>
</g>
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M48,24H40v8h8V24Z"/>
<path d="M8,32H0v8H8V32Z"/>
</g>
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M48,32H40v8h8V32Z"/>
<path d="M8,40H0v8H8V40Z"/>
</g>
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

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

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C0,16,0,32,0,48c16,0,32,0,48,0c0-16,0-32,0-48C32,0,16,0,0,0ZM31.56,9.75c5.48,2.81,7.28,7.78,9.13,13.31c.37,2.34,.06,3.7-.69,5.94c-.25,.83-.49,1.65-.75,2.5c-1.98,3.96-4.86,5.85-8.81,7.69C24,41.33,24,41.33,20,40c-.82-.25-1.65-.49-2.5-.75c-3.96-1.98-5.85-4.86-7.69-8.81C7.67,24,7.67,24,9,20c.25-.82,.49-1.65,.75-2.5C14,8.99,23.31,7.33,31.56,9.75Z" fill="#F0F0F0"/>
<path d="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>

Before

Width:  |  Height:  |  Size: 864 B

After

Width:  |  Height:  |  Size: 888 B

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

Before

Width:  |  Height:  |  Size: 549 B

After

Width:  |  Height:  |  Size: 573 B

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

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 544 B

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

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 544 B

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#E2B1B1"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM12.06,13.69c-2.83,4.54-4.51,8.24-3.72,13.65C9.88,32.82,12.06,35.92,17,39c4.43,2.03,7.98,2.31,12.69,1c5.03-1.99,7.88-4.88,10.16-9.85c1.58-4.77,1.09-8.47-.74-13.1c-2.46-4.55-6.13-6.96-10.92-8.55c-6.36-1.27-11.46,.92-16.13,5.19Z" fill="#F0F0F0"/>
<path d="M35.31,12.06c3.79,4.14,5.85,8.38,5.6,14.09c-1,4.97-3.14,8.4-6.91,11.85c-4.31,2.33-8.33,3.47-13.19,2.5c-4.79-1.59-8.46-4-10.92-8.55c-1.83-4.63-2.32-8.33-.74-13.1c2.28-4.97,5.13-7.86,10.16-9.85c6.33-1.77,10.55-.34,16,3.06Z" fill="#C83C3C"/>
</svg>

Before

Width:  |  Height:  |  Size: 839 B

+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#285AC8"/>
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0ZM10,10c-3.74,4.64-5.89,8.94-6,15c.79,6.14,2.84,11.45,7.79,15.45c4.85,3.28,9.22,5.06,15.21,4.29c6.43-1.34,11.09-4.05,14.81-9.55c2.9-5.06,3.7-9.53,2.5-15.25c-1.95-6.6-5.33-10.58-11.24-13.96C25,2.18,16.58,4.44,10,10Z" fill="#F5F5F5"/>
<path d="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>

Before

Width:  |  Height:  |  Size: 781 B

After

Width:  |  Height:  |  Size: 829 B

+16 -16
View File
@@ -1,20 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#55FF80"/>
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#555580"/>
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FFAA80"/>
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#55AA80"/>
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF0080"/>
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#550080"/>
<path d="M24,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAFF80"/>
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00FF80"/>
<path d="M24,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AAAA80"/>
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#00AA80"/>
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA5580"/>
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#005580"/>
<path d="M24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA0080"/>
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0Z" fill="#000080"/>
<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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0C16,0,32,0,48,0c0,16,0,32,0,48c-16,0-32,0-48,0C0,32,0,16,0,0Z" fill="#FFFF80"/>
<path d="M0,0C16,0,32,0,48,0c0,8,0,16,0,24c-16,0-32,0-48,0C0,16,0,8,0,0Z" fill="#FF5580"/>
<path d="M0,24c8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0c0-8,0-16,0-24Z" fill="#FFFF80"/>
<path d="M0,0C8,0,16,0,24,0c0,8,0,16,0,24c-8,0-16,0-24,0C0,16,0,8,0,0Z" fill="#AA2A80"/>
<path d="M24,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
<path d="M0,24c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#4B9280"/>
<path d="M24,0c8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0c0-4,0-8,0-12Z" fill="#FF5580"/>
<path d="M0,0C8,0,16,0,24,0c0,4,0,8,0,12c-8,0-16,0-24,0C0,8,0,4,0,0Z" fill="#AA2A80"/>
<path d="M0,36c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#FFFF80"/>
<path d="M0,24c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Zm24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
<path d="M24,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
<path d="M0,12c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#4B9280"/>
<path d="M0,0C4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0C0,8,0,4,0,0ZM24,0c4,0,8,0,12,0c0,4,0,8,0,12c-4,0-8,0-12,0c0-4,0-8,0-12Z" fill="#AA2A80"/>
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M12,12L12,0L0,0L0,12l12,0Z" fill="#000080"/>
<path d="M24,0L12,0l0,12l12,0L24,0Z" fill="#550080"/>
<path d="M36,0L24,0l0,12l12,0L36,0Z" fill="#AA0080"/>
<path d="M48,12L48,0L36,0l0,12l12,0Z" fill="#FF0080"/>
<path d="M12,12L0,12L0,24l12,0l0-12Z" fill="#005580"/>
<path d="M24,12L12,12l0,12l12,0l0-12Z" fill="#555580"/>
<path d="M36,12L24,12l0,12l12,0l0-12Z" fill="#AA5580"/>
<path d="M48,12L36,12l0,12l12,0l0-12Z" fill="#FF5580"/>
<path d="M12,24L0,24L0,36l12,0l0-12Z" fill="#00AA80"/>
<path d="M24,24L12,24l0,12l12,0l0-12Z" fill="#55AA80"/>
<path d="M36,24L24,24l0,12l12,0l0-12Z" fill="#AAAA80"/>
<path d="M48,24L36,24l0,12l12,0l0-12Z" fill="#FFAA80"/>
<path d="M12,36L0,36L0,48l12,0l0-12Z" fill="#00FF80"/>
<path d="M24,36L12,36l0,12l12,0l0-12Z" fill="#55FF80"/>
<path d="M36,36L24,36l0,12l12,0l0-12Z" fill="#AAFF80"/>
<path d="M48,36L36,36l0,12l12,0l0-12Z" fill="#FFFF80"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

+2 -16
View File
@@ -1,6 +1,6 @@
//! End-to-end pipeline smoke tests over synthetic images.
use vtracer::{ColorImage, Clustering, Config, FitMode, Hierarchical};
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 {
@@ -52,27 +52,13 @@ fn all_fit_modes_produce_svg() {
fn binary_pipeline_produces_svg() {
let img = two_band_image(32);
let config = Config {
clustering: Clustering::Binary,
color_mode: ColorMode::Binary,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
#[test]
fn watershed_pipeline_produces_svg() {
let img = two_band_image(32);
for hierarchical in [Hierarchical::Stacked, Hierarchical::Cutout] {
let config = Config {
clustering: Clustering::Watershed,
hierarchical,
..Config::default()
};
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}
}
#[test]
fn optimize_levels_shrink_or_match() {
let img = two_band_image(48);
-98
View File
@@ -1,98 +0,0 @@
//! Progress reporting and cancellation for `Pipeline::run_with_progress`.
use std::cell::Cell;
use vtracer::progress::{CancelToken, Phase, Progress};
use vtracer::{ColorImage, Config, Error};
/// A checkerboard of two colors — enough clusters that segmentation runs a few
/// batches, so incremental progress and mid-run cancellation are observable.
fn checker(w: usize, h: usize) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = if (x / 6 + y / 6) % 2 == 0 {
(210u8, 60, 60)
} else {
(60, 90, 200)
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// A token cancelled before the run starts trips promptly and yields no doc.
#[test]
fn precancelled_returns_cancelled() {
let img = checker(64, 64);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
cancel.cancel();
let mut cb = |_p: Progress| {};
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
assert_eq!(result.err(), Some(Error::Cancelled));
}
/// Cancelling from within the progress callback (on the first Segment report)
/// trips at the next batch boundary and returns `Cancelled`.
#[test]
fn cancel_during_progress_trips() {
let img = checker(96, 96);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
let saw_segment = Cell::new(false);
let mut cb = |p: Progress| {
if p.phase == Phase::Segment {
saw_segment.set(true);
cancel.cancel();
}
};
let result = pipeline.run_with_progress(&img, &cancel, &mut cb);
assert!(saw_segment.get(), "expected at least one Segment report");
assert_eq!(result.err(), Some(Error::Cancelled));
}
/// A successful run reports monotonically within each phase, ends at
/// Optimize=1.0, and produces the same shapes as the plain `run`.
#[test]
fn progress_completes_and_matches_run() {
let img = checker(64, 64);
let pipeline = Config::default().build().unwrap();
let cancel = CancelToken::new();
let last = Cell::new(None::<Progress>);
let count = Cell::new(0usize);
let mut cb = |p: Progress| {
assert!(
(0.0..=1.0).contains(&p.fraction),
"fraction out of range: {}",
p.fraction
);
last.set(Some(p));
count.set(count.get() + 1);
};
let doc = pipeline
.run_with_progress(&img, &cancel, &mut cb)
.expect("run should succeed");
assert!(count.get() > 0, "expected progress reports");
let final_p = last.get().expect("a final report");
assert_eq!(final_p.phase, Phase::Optimize);
assert_eq!(final_p.fraction, 1.0);
// Incremental clustering yields the same clusters as the blocking path,
// so both entry points produce identical output.
let plain = pipeline.run(&img).expect("plain run should succeed");
assert_eq!(doc.shapes.len(), plain.shapes.len());
}
-94
View File
@@ -1,94 +0,0 @@
//! Two-phase pipeline: cache the expensive segmentation, re-run the cheap
//! downstream stages with different parameters (the interactive tuning loop).
use vtracer::{ColorImage, Config, FitMode};
/// A few colored blocks — several clusters, a few holes.
fn blocks() -> ColorImage {
let (w, h) = (48usize, 48usize);
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = match (x / 16, y / 16) {
(0, _) => (220u8, 40, 40),
(1, 0) => (40, 200, 60),
(1, _) => (50, 60, 220),
_ => (230, 210, 40),
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
fn cfg(mode: FitMode) -> Config {
Config {
mode,
..Config::default()
}
}
/// `finish(segment(img))` equals the one-shot `run(img)`.
#[test]
fn two_phase_matches_one_shot() {
let img = blocks();
let pipeline = cfg(FitMode::Spline).build().unwrap();
let one_shot = pipeline.run(&img).unwrap();
let seg = pipeline.segment(&img).unwrap();
let two_phase = pipeline.finish(&seg).unwrap();
assert_eq!(
pipeline.writer.write(&one_shot),
pipeline.writer.write(&two_phase),
"splitting segment/finish must not change the output"
);
}
/// A cached segmentation stays pristine — `finish` can be called repeatedly and
/// deterministically (color fitting mutates only an internal clone).
#[test]
fn cached_segmentation_is_reusable() {
let img = blocks();
let pipeline = cfg(FitMode::Polygon).build().unwrap();
let seg = pipeline.segment(&img).unwrap();
let first = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
let second = pipeline.writer.write(&pipeline.finish(&seg).unwrap());
assert_eq!(first, second, "reusing a cached segmentation must be stable");
}
/// The tuning workflow: segment once, then feed that segmentation to pipelines
/// with different curve-fitting parameters. Same regions, different geometry —
/// and no re-segmentation. (Speckle, color precision, and layer difference are
/// clustering parameters, so changing them requires a fresh `segment`.)
#[test]
fn tune_curve_fitting_on_cached_segmentation() {
let img = blocks();
// Same clustering parameters (defaults), different fit modes → the
// segmentation from one is valid input to the other's `finish`.
let pixel = cfg(FitMode::Pixel).build().unwrap();
let spline = cfg(FitMode::Spline).build().unwrap();
let seg = pixel.segment(&img).unwrap();
let doc_pixel = pixel.finish(&seg).unwrap();
let doc_spline = spline.finish(&seg).unwrap();
// Same partition → same number of shapes.
assert_eq!(doc_pixel.shapes.len(), doc_spline.shapes.len());
assert!(!doc_pixel.shapes.is_empty());
// But the fitted geometry differs (straight edges vs cubic curves).
assert_ne!(
pixel.writer.write(&doc_pixel),
spline.writer.write(&doc_spline),
"pixel and spline fitting should produce different paths"
);
}
-282
View File
@@ -1,282 +0,0 @@
//! `Session` caches the segmentation and re-segments only when a clustering
//! parameter changes — verified both at the key level and end-to-end.
use visioncortex::Color;
use vtracer::{
CancelToken, Clustering, ColorImage, Config, FitMode, Hierarchical, Session,
};
/// A few colored blocks — several clusters.
fn blocks() -> ColorImage {
let (w, h) = (48usize, 48usize);
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = match (x / 16, y / 16) {
(0, _) => (220u8, 40, 40),
(1, 0) => (40, 200, 60),
(1, _) => (50, 60, 220),
_ => (230, 210, 40),
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// The key partition: finish-phase params share a segment key; clustering
/// params change it. This is the contract `Session` relies on.
#[test]
fn segment_key_tracks_only_clustering_params() {
let base = Config::default();
// Finish-phase changes → same key (segmentation is reusable).
for tweaked in [
Config {
corner_threshold: 90,
..base.clone()
},
Config {
optimize: 0,
..base.clone()
},
Config {
hierarchical: vtracer::Hierarchical::Cutout,
..base.clone()
},
Config {
max_colors: Some(4),
..base.clone()
},
] {
assert_eq!(
base.segment_key(),
tweaked.segment_key(),
"finish-phase param must not change the segment key"
);
}
// Clustering changes → different key (must re-segment).
for tweaked in [
Config {
filter_speckle: base.filter_speckle + 4,
..base.clone()
},
Config {
color_precision: 4,
..base.clone()
},
Config {
layer_difference: 32,
..base.clone()
},
Config {
clustering: vtracer::Clustering::Binary,
..base.clone()
},
Config {
clustering: vtracer::Clustering::Watershed,
..base.clone()
},
Config {
watershed_detail: 200,
..base.clone()
},
] {
assert_ne!(
base.segment_key(),
tweaked.segment_key(),
"clustering param must change the segment key"
);
}
}
/// A `Session` render equals the one-shot pipeline — for a finish-only change
/// (reuses the cache) and for a clustering change (re-segments). Correctness is
/// identical either way; the cache is a transparent optimization.
#[test]
fn session_matches_one_shot() {
let img = blocks();
let mut session = Session::new(img.clone());
let base = Config::default();
let svg0 = session.render_svg(&base).unwrap();
assert_eq!(
svg0,
base.build().unwrap().to_svg(&img).unwrap(),
"first render must match the one-shot pipeline"
);
// Finish-only change: reuses the cached segmentation.
let tuned = Config {
corner_threshold: 90,
..base.clone()
};
assert_eq!(
session.render_svg(&tuned).unwrap(),
tuned.build().unwrap().to_svg(&img).unwrap(),
"reused-segmentation render must match the one-shot pipeline"
);
// Clustering change: re-segments, still matches the one-shot.
let respeckled = Config {
filter_speckle: base.filter_speckle + 4,
..base.clone()
};
assert_eq!(
session.render_svg(&respeckled).unwrap(),
respeckled.build().unwrap().to_svg(&img).unwrap(),
"re-segmented render must match the one-shot pipeline"
);
}
/// Blocks plus a gradient band and a small fleck — structure that makes every
/// clustering parameter (speckle, precision, gradient step, watershed detail,
/// thresholds) actually change the output.
fn textured() -> ColorImage {
let (w, h) = (48usize, 48usize);
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let c = if y >= 32 {
let g = 60 + (x * 3) as u8; // gradient band
(g, g, 200)
} else if (4..7).contains(&x) && (4..7).contains(&y) {
(10, 200, 10) // 9 px fleck
} else {
match (x / 16, y / 16) {
(0, _) => (220u8, 40, 40),
(1, _) => (40, 200, 60),
_ => (230, 210, 40),
}
};
pixels.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// The exhaustive contract: walk a cumulative sequence of config changes that
/// touches every parameter category — finish-phase dials, clustering dials,
/// frontend switches (including leaving watershed and coming back to its
/// cached hierarchy), compositing, palettes — and after each step the cached
/// session render must be byte-identical to a from-scratch one-shot pipeline.
#[test]
fn session_equals_one_shot_across_param_walk() {
let img = textured();
let mut session = Session::new(img.clone());
let mut cfg = Config::default();
let steps: Vec<(&str, fn(&mut Config))> = vec![
("initial", |_| {}),
// Finish-phase changes (cache hits).
("corner_threshold", |c| c.corner_threshold = 90),
("mode polygon", |c| c.mode = FitMode::Polygon),
("optimize 2", |c| c.optimize = 2),
("cutout", |c| c.hierarchical = Hierarchical::Cutout),
("path_precision", |c| c.path_precision = Some(1)),
// Clustering changes (re-segment).
("filter_speckle", |c| c.filter_speckle = 6),
("layer_difference", |c| c.layer_difference = 32),
("color_precision", |c| c.color_precision = 5),
// Watershed, incl. cheap re-cuts of the cached hierarchy.
("watershed", |c| c.clustering = Clustering::Watershed),
("detail 200", |c| c.watershed_detail = 200),
("detail 64", |c| c.watershed_detail = 64),
("stacked", |c| c.hierarchical = Hierarchical::Stacked),
("mode spline", |c| c.mode = FitMode::Spline),
// Binary, with both thresholding methods.
("binary", |c| c.clustering = Clustering::Binary),
("threshold 100", |c| c.binary_threshold = 100),
("adaptive", |c| c.binary_adaptive = true),
// Back to watershed: the hierarchy cache must still be valid.
("watershed again", |c| c.clustering = Clustering::Watershed),
("quantize", |c| c.max_colors = Some(4)),
// And back to the color path with a palette.
("color-cluster", |c| {
c.clustering = Clustering::ColorCluster;
c.max_colors = None;
c.palette = vec![
Color::new(0, 0, 0),
Color::new(255, 255, 255),
Color::new(200, 40, 40),
];
}),
("speckle again", |c| c.filter_speckle = 2),
];
for (name, step) in steps {
step(&mut cfg);
assert_eq!(
session.render_svg(&cfg).unwrap(),
cfg.build().unwrap().to_svg(&img).unwrap(),
"step `{name}`: cached session render must equal a full rebuild"
);
}
}
/// The progress-reporting render path (which segments through a different
/// branch, including the watershed hierarchy shortcut) produces the same
/// document as the plain path and the one-shot pipeline.
#[test]
fn render_with_progress_matches_plain_render() {
let img = textured();
for clustering in [
Clustering::ColorCluster,
Clustering::Watershed,
Clustering::Binary,
] {
let cfg = Config {
clustering,
..Config::default()
};
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
// Fresh session per variant so the progress path does the segmenting.
let mut session = Session::new(img.clone());
let doc = session
.render_with_progress(&cfg, &CancelToken::new(), &mut |_| {})
.unwrap();
let progress_svg = cfg.build().unwrap().writer.write(&doc);
assert_eq!(
progress_svg, one_shot,
"{clustering:?}: progress path must equal the one-shot pipeline"
);
// And the now-warm cache serves the plain path identically.
assert_eq!(
session.render_svg(&cfg).unwrap(),
one_shot,
"{clustering:?}: cache warmed by the progress path must match too"
);
}
}
/// `invalidate` drops all cached state; the next render rebuilds from scratch
/// and still matches.
#[test]
fn invalidate_then_render_matches() {
let img = textured();
let cfg = Config {
clustering: Clustering::Watershed,
..Config::default()
};
let one_shot = cfg.build().unwrap().to_svg(&img).unwrap();
let mut session = Session::new(img);
assert_eq!(session.render_svg(&cfg).unwrap(), one_shot);
session.invalidate();
assert_eq!(
session.render_svg(&cfg).unwrap(),
one_shot,
"render after invalidate must rebuild identically"
);
}
-93
View File
@@ -1,93 +0,0 @@
//! The curve-simplification stage, end to end: `Config::simplify` must cut
//! anchor counts in both compositing modes without changing geometry kind,
//! and leave output untouched when off (the goldens enforce the byte-level
//! version of that).
use vtracer::ir::PathCmd;
use vtracer::{ColorImage, Config, FitMode, Hierarchical, VectorDoc};
/// A filled disc — one long smooth boundary, the best case for merging the
/// per-splice cubics the spline fitter emits.
fn disc_image(size: usize) -> ColorImage {
let mut pixels = Vec::with_capacity(size * size * 4);
let (c, r) = (size as f64 / 2.0, size as f64 * 0.4);
for y in 0..size {
for x in 0..size {
let (dx, dy) = (x as f64 + 0.5 - c, y as f64 + 0.5 - c);
let (rr, gg, bb) = if (dx * dx + dy * dy).sqrt() < r {
(200, 60, 60)
} else {
(240, 240, 240)
};
pixels.extend_from_slice(&[rr, gg, bb, 255]);
}
}
ColorImage {
pixels,
width: size,
height: size,
}
}
fn cubic_count(doc: &VectorDoc) -> usize {
doc.shapes
.iter()
.flat_map(|s| &s.path.subpaths)
.flat_map(|sub| &sub.commands)
.filter(|c| matches!(c, PathCmd::CubicTo(..)))
.count()
}
fn run(config: &Config) -> VectorDoc {
config.build().unwrap().run(&disc_image(128)).unwrap()
}
#[test]
fn simplify_reduces_cubics_in_stacked_mode() {
let base = Config::default();
let simplified = Config {
simplify: Some(2.0),
..Config::default()
};
let (before, after) = (cubic_count(&run(&base)), cubic_count(&run(&simplified)));
assert!(before > 0, "the disc must be traced with cubics");
assert!(
after < before,
"simplify must reduce anchors: {before} -> {after}"
);
}
#[test]
fn simplify_reduces_cubics_in_cutout_mode() {
let cutout = |simplify| Config {
hierarchical: Hierarchical::Cutout,
simplify,
..Config::default()
};
let (before, after) = (
cubic_count(&run(&cutout(None))),
cubic_count(&run(&cutout(Some(2.0)))),
);
assert!(before > 0, "the disc must be traced with cubics");
assert!(
after < before,
"simplify must reduce anchors: {before} -> {after}"
);
}
#[test]
fn simplify_leaves_polyline_modes_untouched() {
for mode in [FitMode::Pixel, FitMode::Polygon] {
let base = Config {
mode,
..Config::default()
};
let simplified = Config {
simplify: Some(2.0),
..base.clone()
};
let a = base.build().unwrap().to_svg(&disc_image(64)).unwrap();
let b = simplified.build().unwrap().to_svg(&disc_image(64)).unwrap();
assert_eq!(a, b, "{mode:?} output must not change");
}
}
-92
View File
@@ -1,92 +0,0 @@
//! Spline fitting stays anchored to the geometry it approximates.
//!
//! Regression for the sparse-slice ballooning bug: a splice slice with very
//! uneven point spacing (a few-pixel jog then a long straight leg, produced by
//! the walker around thin strands) used to be fitted by a single cubic that
//! interpolated the samples exactly while swinging ~30 px sideways between
//! them — its control points landing far outside the shape itself. The
//! Cityscape sample at color precision 8 / gradient step 28 is the real
//! reproduction (a 1 px, 330 px-tall strand in the maroon region).
use std::path::PathBuf;
use vtracer::ir::PathCmd;
use vtracer::{ColorImage, Config, Hierarchical, VectorDoc};
fn cityscape() -> ColorImage {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg");
let img = image::open(&p).expect("sample image").to_rgba8();
let (w, h) = (img.width() as usize, img.height() as usize);
ColorImage {
pixels: img.into_raw(),
width: w,
height: h,
}
}
/// Every cubic's control points must stay within its shape's on-curve bounding
/// box plus a small overshoot allowance. The ballooning bug put handles ~25 px
/// outside the whole shape; a healthy fit stays within the fit error (10).
fn assert_handles_anchored(doc: &VectorDoc, margin: f64) {
for (si, shape) in doc.shapes.iter().enumerate() {
// Bounding box over on-curve points only.
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut on_curve = |p: &visioncortex::PointF64| {
x0 = x0.min(p.x);
y0 = y0.min(p.y);
x1 = x1.max(p.x);
y1 = y1.max(p.y);
};
for sub in &shape.path.subpaths {
for cmd in &sub.commands {
match cmd {
PathCmd::MoveTo(p) | PathCmd::LineTo(p) => on_curve(p),
PathCmd::CubicTo(_, _, p) => on_curve(p),
PathCmd::Close => {}
}
}
}
for sub in &shape.path.subpaths {
for cmd in &sub.commands {
if let PathCmd::CubicTo(c1, c2, _) = cmd {
for q in [c1, c2] {
assert!(
q.x >= x0 - margin
&& q.x <= x1 + margin
&& q.y >= y0 - margin
&& q.y <= y1 + margin,
"shape {si}: control point ({},{}) strays outside \
bbox ({x0},{y0})..({x1},{y1}) + {margin}",
q.x,
q.y
);
}
}
}
}
}
}
#[test]
fn spline_handles_stay_anchored_on_photo() {
let img = cityscape();
let base = Config {
color_precision: 8,
layer_difference: 28,
..Config::default()
};
// Stacked: per-region closed outlines through Spline::from_path_f64.
let doc = base.build().unwrap().run(&img).unwrap();
assert!(doc.shapes.len() > 500, "sanity: the trace produced real output");
assert_handles_anchored(&doc, 15.0);
// Cutout: open boundary segments through the mosaic's segment fitter.
let cutout = Config {
hierarchical: Hierarchical::Cutout,
..base
};
let doc = cutout.build().unwrap().run(&img).unwrap();
assert_handles_anchored(&doc, 15.0);
}
-682
View File
@@ -1,682 +0,0 @@
//! Watershed frontend: partition invariants, the detail dial, small-basin
//! absorption, and the hierarchy stack / cached re-cut behavior.
use vtracer::frontend::{Frontend, WatershedFrontend, WatershedHierarchy};
use vtracer::{Color, ColorImage, Clustering, Config, Hierarchical, Segmentation, Session};
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> (u8, u8, u8)) -> ColorImage {
let mut pixels = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let (r, g, b) = f(x, y);
pixels.extend_from_slice(&[r, g, b, 255]);
}
}
ColorImage {
pixels,
width: w,
height: h,
}
}
/// Flatten the stacked layers top-down (later layers win), returning one layer
/// index per pixel — the partition both compositors ultimately consume.
fn flatten(seg: &Segmentation) -> Vec<usize> {
let (w, h) = (seg.width as usize, seg.height as usize);
let mut labels = vec![usize::MAX; w * h];
for (li, layer) in seg.layers.iter().enumerate() {
let m = &layer.mask;
for y in 0..m.image.height {
for x in 0..m.image.width {
if m.image.get_pixel(x, y) {
let gx = (m.offset.x + x as i32) as usize;
let gy = (m.offset.y + y as i32) as usize;
labels[gy * w + gx] = li;
}
}
}
}
labels
}
/// The stacked-hierarchy invariants: the bottom layer is a solid full canvas
/// (so overdraw is seam-free), every pixel is covered, the flattened
/// partition has exactly `regions` distinct labels, and the stack size is
/// bounded by the merge tree (at most 2·regions 1 layers).
fn assert_stack(seg: &Segmentation, regions: usize) {
let (w, h) = (seg.width as usize, seg.height as usize);
let bottom = &seg.layers[0].mask;
assert_eq!((bottom.width(), bottom.height()), (w, h), "bottom layer is full-canvas");
assert_eq!(bottom.area(), w * h, "bottom layer is solid");
assert!(seg.layers.len() <= 2 * regions.max(1) - 1, "stack bounded by the merge tree");
let labels = flatten(seg);
assert!(labels.iter().all(|&l| l != usize::MAX), "every pixel covered");
let mut distinct: Vec<usize> = labels.clone();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(distinct.len(), regions, "flattened region count");
// The final regions must be the topmost layers (painted after every
// ancestor), or the flatten would not recover the partition.
let first_final = seg.layers.len() - regions;
assert!(
distinct.iter().all(|&l| l >= first_final),
"final regions are the topmost layers"
);
}
/// Region count of a segmentation's flattened partition.
fn regions(seg: &Segmentation) -> usize {
let mut labels = flatten(seg);
labels.sort_unstable();
labels.dedup();
labels.len()
}
/// A flat single-color image is one region no matter the detail level.
#[test]
fn flat_image_is_one_region() {
let img = image(24, 16, |_, _| (90, 120, 150));
for detail in [0u8, 128, 255] {
let seg = WatershedFrontend {
detail,
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(seg.layers.len(), 1, "detail={detail}");
assert_stack(&seg, 1);
}
}
/// Two clearly separated halves form two regions plus their common ancestor:
/// the stack is [root, half, half] and the flatten recovers the exact split.
#[test]
fn two_tone_image_is_two_regions() {
let img = image(32, 20, |x, _| {
if x < 16 {
(220, 40, 40)
} else {
(40, 60, 220)
}
});
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(seg.layers.len(), 3, "root + two final regions");
assert_stack(&seg, 2);
// Each final region is exactly one half of the canvas.
assert_eq!(seg.layers[1].mask.area(), 16 * 20);
assert_eq!(seg.layers[2].mask.area(), 16 * 20);
}
/// Raising detail never decreases the region count (the hierarchy cut is
/// monotone in the target).
#[test]
fn detail_is_monotone() {
// A blobby gradient image with structure at several scales.
let img = image(64, 48, |x, y| {
let v = ((x * 4) as f64).sin() * 40.0 + ((y * 3) as f64).cos() * 40.0;
let base = 128i32 + v as i32;
let r = (base + ((x / 16) as i32) * 20).clamp(0, 255) as u8;
let g = (base + ((y / 12) as i32) * 25).clamp(0, 255) as u8;
(r, g, 128)
});
let mut prev = 0usize;
for detail in [0u8, 64, 128, 192, 255] {
let seg = WatershedFrontend {
detail,
min_area: 0,
}
.segment(&img)
.unwrap();
let k = regions(&seg);
assert!(k >= prev, "detail={detail}: {k} < {prev}");
assert_stack(&seg, k);
prev = k;
}
assert!(prev > 1, "highest detail should find several regions");
}
/// Small basins are absorbed into a neighbour rather than dropped: the region
/// disappears but its pixels stay covered.
#[test]
fn min_area_absorbs_small_basins() {
// Background plus a 3x3 fleck and a 12x12 block, all far apart in color.
let img = image(40, 30, |x, y| {
if (4..7).contains(&x) && (4..7).contains(&y) {
(10, 200, 10) // 9 px fleck
} else if (20..32).contains(&x) && (10..22).contains(&y) {
(200, 30, 30) // 144 px block
} else {
(240, 240, 240)
}
});
let keep = WatershedFrontend {
detail: 255,
min_area: 0,
}
.segment(&img)
.unwrap();
let absorb = WatershedFrontend {
detail: 255,
min_area: 16, // fleck (9 px) absorbed, block (144 px) kept
}
.segment(&img)
.unwrap();
assert!(regions(&keep) > regions(&absorb), "fleck absorbed");
assert_eq!(regions(&absorb), 2, "background + block survive");
assert_stack(&absorb, 2);
}
/// Output is deterministic: two runs produce identical layer geometry.
#[test]
fn deterministic() {
let img = image(48, 32, |x, y| {
(((x * 7 + y * 13) % 256) as u8, ((x * 3) % 256) as u8, ((y * 5) % 256) as u8)
});
let front = WatershedFrontend {
detail: 160,
min_area: 4,
};
let a = front.segment(&img).unwrap();
let b = front.segment(&img).unwrap();
assert_eq!(a.layers.len(), b.layers.len());
for (la, lb) in a.layers.iter().zip(&b.layers) {
assert_eq!(la.paint, lb.paint);
assert_eq!(la.mask.offset, lb.mask.offset);
assert_eq!(la.mask.area(), lb.mask.area());
}
}
/// A cut of a prebuilt hierarchy equals the one-shot frontend — the contract
/// behind `Session`'s cached re-cut.
#[test]
fn hierarchy_recut_matches_one_shot() {
let img = image(48, 32, |x, y| {
(((x * 5 + y * 3) % 200) as u8, ((x / 8) * 30) as u8, ((y / 8) * 40) as u8)
});
let hierarchy = WatershedHierarchy::build(&img).unwrap();
for detail in [64u8, 128, 200] {
let recut = hierarchy.cut(&img, detail, 16);
let one_shot = WatershedFrontend {
detail,
min_area: 16,
}
.segment(&img)
.unwrap();
assert_eq!(recut.layers.len(), one_shot.layers.len(), "detail={detail}");
for (a, b) in recut.layers.iter().zip(&one_shot.layers) {
assert_eq!(a.paint, b.paint);
assert_eq!(a.mask.offset, b.mask.offset);
assert_eq!(a.mask.area(), b.mask.area());
}
}
}
/// End-to-end through `Session`: retuning watershed detail re-cuts the cached
/// hierarchy, and the output still equals the one-shot pipeline.
#[test]
fn session_recut_matches_one_shot() {
let img = image(48, 32, |x, y| {
(((x * 5 + y * 3) % 200) as u8, ((x / 8) * 30) as u8, ((y / 8) * 40) as u8)
});
let mut session = Session::new(img.clone());
let base = Config {
clustering: Clustering::Watershed,
..Config::default()
};
for detail in [128u8, 200, 64] {
let cfg = Config {
watershed_detail: detail,
..base.clone()
};
assert_eq!(
session.render_svg(&cfg).unwrap(),
cfg.build().unwrap().to_svg(&img).unwrap(),
"detail={detail}: session re-cut must match the one-shot pipeline"
);
}
}
/// Watershed + cutout is native: at max detail the partition reaches the
/// mosaic essentially untouched, so two *distinguishable* regions within one
/// gradient step stay separate faces (the color path's `merge_similar` would
/// have rejoined them). Only the just-noticeable-difference floor applies —
/// see `cutout_merge_tolerance_follows_detail`.
#[test]
fn cutout_keeps_watershed_partition() {
// Two halves 4 gray-levels apart (12 L1): close enough that the flatten
// merge (threshold = layer_difference = 16 >= 3*4) would union them, yet
// clearly above the JND floor (2).
let img = image(32, 20, |x, _| {
if x < 16 {
(100, 100, 100)
} else {
(104, 104, 104)
}
});
let cfg = Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: 255,
filter_speckle: 0,
..Config::default()
};
let doc = cfg.build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
2,
"watershed partition must pass to the mosaic unmerged"
);
}
/// The cutout merge tolerance is derived from the detail dial —
/// `max(2, (255 detail) / 8)` — because detail has no color units of its
/// own. The same two halves 12 L1 apart that max detail keeps separate (see
/// above) merge into one face at the default detail, whose tolerance (15)
/// matches the color-cluster default gradient step; and a pair a human
/// cannot tell apart (within the just-noticeable-difference floor) merges
/// even at max detail.
#[test]
fn cutout_merge_tolerance_follows_detail() {
let halves = |a: (u8, u8, u8), b: (u8, u8, u8)| {
image(32, 20, |x, _| if x < 16 { a } else { b })
};
let cfg = |detail| Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: detail,
filter_speckle: 0,
..Config::default()
};
let img = halves((100, 100, 100), (104, 104, 104));
let doc = cfg(128).build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
1,
"near-identical neighbours merge at the default detail"
);
// #863339 next to #863238 (2 L1 apart): indistinguishable by eye, so it
// must never survive as two patches, not even at maximum detail.
let img = halves((0x86, 0x33, 0x39), (0x86, 0x32, 0x38));
let doc = cfg(255).build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
1,
"sub-JND neighbours merge even at max detail"
);
}
/// Regions are 4-connected: two same-colored squares touching only at a
/// corner are separate basins (and so are the two squares of the other color).
#[test]
fn diagonal_touch_does_not_connect() {
let img = image(16, 16, |x, y| {
if (x / 8 + y / 8) % 2 == 0 {
(30, 30, 30)
} else {
(220, 220, 220)
}
});
let seg = WatershedFrontend {
detail: 255,
min_area: 0,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
assert_eq!(regions(&seg), 4, "four quadrants, none diagonally joined");
assert_ne!(labels[2 * 16 + 2], labels[10 * 16 + 10], "dark squares separate");
assert_ne!(labels[2 * 16 + 10], labels[10 * 16 + 2], "light squares separate");
assert_stack(&seg, 4);
}
/// Nested flat zones — a frame around a ring around a core — come out as
/// three exact regions, and the ring face (which has a hole) survives both
/// compositors.
#[test]
fn nested_regions() {
// Background frame 230, square ring 40 (4..28 minus 10..22), core 130.
let img = image(32, 32, |x, y| {
let ring = (4..28).contains(&x) && (4..28).contains(&y);
let core = (10..22).contains(&x) && (10..22).contains(&y);
if core {
(130, 130, 130)
} else if ring {
(40, 40, 40)
} else {
(230, 230, 230)
}
});
let seg = WatershedFrontend {
detail: 255,
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(regions(&seg), 3, "frame + ring + core");
let labels = flatten(&seg);
let at = |x: usize, y: usize| labels[y * 32 + x];
assert_ne!(at(1, 1), at(6, 6), "frame vs ring");
assert_ne!(at(6, 6), at(16, 16), "ring vs core");
assert_ne!(at(1, 1), at(16, 16), "frame vs core");
assert_stack(&seg, 3);
// The same nesting through the mosaic: three faces, ring with a hole.
let cfg = Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: 255,
filter_speckle: 0,
..Config::default()
};
let doc = cfg.build().unwrap().run(&img).unwrap();
assert_eq!(doc.shapes.len(), 3, "nested faces survive the mosaic");
}
/// Volume extinction, the hierarchy's ranking attribute: a small but vivid
/// basin (large color rise) outlives a bigger but faint one. Cutting to two
/// regions must keep the black dot, not the barely-different patch.
#[test]
fn volume_extinction_prefers_vivid_over_large() {
let img = image(48, 32, |x, y| {
if (4..7).contains(&x) && (4..7).contains(&y) {
(0, 0, 0) // 9 px, rise ~128: volume ≈ 1150
} else if (20..30).contains(&x) && (10..20).contains(&y) {
(132, 132, 132) // 100 px, rise 4: volume ≈ 400
} else {
(128, 128, 128)
}
});
let seg = WatershedFrontend {
detail: 26, // target = 2 regions
min_area: 0,
}
.segment(&img)
.unwrap();
assert_eq!(regions(&seg), 2);
let labels = flatten(&seg);
// The surviving split isolates the dot: its 9 pixels share a label that
// appears nowhere else.
let dot = labels[5 * 48 + 5];
let dot_area = labels.iter().filter(|&&l| l == dot).count();
assert_eq!(dot_area, 9, "the vivid dot is the kept region");
assert_eq!(
labels[15 * 48 + 25],
labels[0],
"the faint patch merged into the background"
);
}
/// Plateaus joined by short ramps — the antialiased-boundary shape. Cutting to
/// three regions recovers the plateaus, with each region's mean close to its
/// plateau value (ramp pixels split between the sides they descend from).
#[test]
fn plateaus_with_ramps() {
// Columns: 40 ×20 | ramp ×2 | 128 ×20 | ramp ×2 | 216 ×20.
let level = |x: usize| -> u8 {
match x {
0..=19 => 40,
20 => 69,
21 => 99,
22..=41 => 128,
42 => 157,
43 => 187,
_ => 216,
}
};
let img = image(64, 16, |x, _| {
let v = level(x);
(v, v, v)
});
let seg = WatershedFrontend {
detail: 40, // target = 3 regions
min_area: 4,
}
.segment(&img)
.unwrap();
assert_eq!(regions(&seg), 3);
// Means sit near the plateau values — the ramps don't form regions of
// their own or drag a mean far off.
let mut means: Vec<u8> = seg
.layers
.iter()
.rev()
.take(3)
.map(|l| l.paint.color().r)
.collect();
means.sort_unstable();
for (mean, plateau) in means.iter().zip([40u8, 128, 216]) {
assert!(
mean.abs_diff(plateau) <= 20,
"region mean {mean} strays from plateau {plateau}"
);
}
}
/// Degenerate geometries: single pixel, single row, single column.
#[test]
fn degenerate_geometries() {
let one = image(1, 1, |_, _| (7, 8, 9));
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&one)
.unwrap();
assert_eq!(seg.layers.len(), 1);
assert_stack(&seg, 1);
let row = image(16, 1, |x, _| if x < 8 { (0, 0, 0) } else { (255, 255, 255) });
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&row)
.unwrap();
assert_eq!(regions(&seg), 2, "single row splits");
assert_stack(&seg, 2);
let col = image(1, 16, |_, y| if y < 8 { (0, 0, 0) } else { (255, 255, 255) });
let seg = WatershedFrontend {
detail: 128,
min_area: 0,
}
.segment(&col)
.unwrap();
assert_eq!(regions(&seg), 2, "single column splits");
assert_stack(&seg, 2);
}
/// …but identical-color neighbours still collapse into one face: regions that
/// snap to the same palette entry and share a boundary must not keep a useless
/// edge between them. (The dark region sits between them in stack order, so
/// the layer-level `MergeAdjacent` cannot be the one doing the merging — only
/// the mosaic's same-color merge can.)
#[test]
fn cutout_merges_identical_palette_faces() {
let img = image(32, 32, |x, y| {
if y < 16 {
if x < 16 {
(200, 200, 200) // A: top-left
} else {
(20, 20, 20) // C: top-right
}
} else {
(180, 180, 180) // B: bottom, touches A
}
});
let cfg = Config {
clustering: Clustering::Watershed,
hierarchical: Hierarchical::Cutout,
watershed_detail: 255,
filter_speckle: 0,
palette: vec![Color::new(255, 255, 255), Color::new(0, 0, 0)],
..Config::default()
};
let doc = cfg.build().unwrap().run(&img).unwrap();
assert_eq!(
doc.shapes.len(),
2,
"A and B snap to the same palette color and share a boundary — one face"
);
}
/// An antialiased edge with pixel noise must come out straight: inside the
/// ramp the per-pixel differences are near-equal, so the raw
/// minimum-spanning-forest boundary meanders with the noise; the boundary
/// snap re-assigns ramp pixels by color proximity, landing the cut on the
/// color-midpoint iso-line (within a pixel).
#[test]
fn antialiased_edge_snaps_to_midline() {
let (w, h) = (32usize, 16usize);
let edge = |x: usize| 6.0 + 0.2 * x as f64; // nearly horizontal
let img = image(w, h, |x, y| {
// A 4-px linear ramp: adjacent in-ramp differences are near-equal,
// so without the snap the cut meanders on the noise.
let t = ((y as f64 + 0.5 - edge(x)) / 4.0 + 0.5).clamp(0.0, 1.0);
let mut v = (t * 200.0).round() as i32;
if t > 0.0 && t < 1.0 {
v += ((x * 7 + y * 13) % 5) as i32 - 2; // deterministic "sensor" noise
}
let v = v.clamp(0, 255) as u8;
(v, v, v)
});
let seg = WatershedFrontend {
detail: 26, // target 2 regions
min_area: 1,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
assert_eq!(regions(&seg), 2);
for x in 0..w {
let col: Vec<usize> = (0..h).map(|y| labels[y * w + x]).collect();
let cross: Vec<usize> = (1..h).filter(|&y| col[y] != col[y - 1]).collect();
assert_eq!(
cross.len(),
1,
"column {x} crosses the boundary exactly once, got {col:?}"
);
let dev = cross[0] as f64 - edge(x);
assert!(
dev.abs() <= 1.5,
"column {x}: boundary at row {} strays from the edge at {:.1}",
cross[0],
edge(x)
);
}
}
/// Sizes of the 4-connected components of a label map.
fn component_sizes(labels: &[usize], w: usize, h: usize) -> Vec<usize> {
let mut seen = vec![false; labels.len()];
let mut sizes = Vec::new();
let mut stack = Vec::new();
for start in 0..labels.len() {
if seen[start] {
continue;
}
let mut size = 0;
seen[start] = true;
stack.push(start);
while let Some(i) = stack.pop() {
size += 1;
let (x, y) = (i % w, i / w);
for j in [
(x > 0).then(|| i - 1),
(x + 1 < w).then(|| i + 1),
(y > 0).then(|| i - w),
(y + 1 < h).then(|| i + w),
]
.into_iter()
.flatten()
{
if !seen[j] && labels[j] == labels[i] {
seen[j] = true;
stack.push(j);
}
}
}
sizes.push(size);
}
sizes
}
/// The boundary snap must not leave debris: a pixel can flip toward a
/// neighbour whose own flip then strands it, leaving 1-px chips that the
/// mosaic turns into micro-faces wedged between the real ones (faces that
/// visually abut but no longer share a fitted boundary). Every connected
/// patch of the partition must clear the speckle floor — a *substantial*
/// patch severed at a thin antialiased neck is fine (it becomes its own
/// tight face), sub-speckle debris is not. The real photo is the
/// reproduction: its JPEG noise produced 62 such chips before the snap
/// absorbed fragments.
#[test]
fn snap_leaves_no_debris() {
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("../../docs/assets/samples/Cityscape Sunset_DFM3-01.jpg");
let decoded = image::open(&p).expect("sample image").to_rgba8();
let (w, h) = (decoded.width() as usize, decoded.height() as usize);
let img = ColorImage {
pixels: decoded.into_raw(),
width: w,
height: h,
};
let min_area = 16;
let seg = WatershedFrontend {
detail: 128,
min_area,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
let sizes = component_sizes(&labels, w, h);
assert!(
sizes.iter().all(|&s| s >= min_area),
"smallest patch {} px is under the speckle floor ({} patches total)",
sizes.iter().min().unwrap(),
sizes.len()
);
}
/// The snap must not bulldoze genuine detail: a pixel of the *other side's*
/// color sitting across the boundary (here a bright pixel notching into the
/// dark half) is not a mixture of the two region means, so the mixture gate
/// keeps it with its color-correct basin — where a geometric smoothing
/// filter would have erased the notch.
#[test]
fn snap_keeps_genuine_color_detail() {
let (w, h) = (16usize, 16usize);
let img = image(w, h, |x, y| {
if (x, y) == (7, 7) {
(190, 190, 190) // bright pixel on the dark side of the edge
} else if x < 8 {
(0, 0, 0)
} else {
(200, 200, 200)
}
});
let seg = WatershedFrontend {
detail: 26,
min_area: 1,
}
.segment(&img)
.unwrap();
let labels = flatten(&seg);
assert_eq!(regions(&seg), 2);
assert_eq!(
labels[7 * w + 7],
labels[7 * w + 8],
"the bright pixel stays with the bright region"
);
assert_ne!(labels[7 * w + 7], labels[7 * w + 6], "the notch survives");
}
+3 -13
View File
@@ -80,11 +80,6 @@ pub trait CurveFitter {
fn fit_open(&self, polyline: &[PointF64]) -> Vec<PathCmd>; // mosaic edges, endpoints pinned
}
pub trait CurvePass {
fn open(&self, geom: FittedGeom) -> FittedGeom; // endpoints pinned
fn ring(&self, geom: FittedGeom) -> FittedGeom; // stays closed
}
pub trait OptimizerPass {
fn run(&self, doc: &mut VectorDoc);
}
@@ -96,7 +91,6 @@ pub struct Pipeline {
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub curve_passes: Vec<Box<dyn CurvePass>>,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
}
@@ -112,16 +106,14 @@ Driver flow:
3. compositing:
- **Stacked** — trace each layer's closed outlines independently (port of today's `to_compound_path` flow) via `fitter.fit_closed`
- **Mosaic** — flatten to `LabelMap`, merge adjacent same-paint regions, extract the boundary graph, fit each shared edge once via `fitter.fit_open`, assemble faces (see [mosaic.md](mosaic.md))
- either way, `CurvePass`es run on each fitted contour *before* paths are assembled — in mosaic mode that means once per shared boundary segment, so both faces reference the transformed geometry and the tessellation stays seam-free. Running them any later (on the `VectorDoc`) would re-fit the two copies of every shared boundary independently and reopen the seams.
4. optimizer passes over the `VectorDoc`
5. `SvgWriter` serializes
## Built-in implementations
- **Frontends** (selected by `Config::clustering`)
- **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`.
- `WatershedFrontend` — hierarchical watershed by volume on the 4-adjacency pixel graph (Cousty et al. TPAMI 2009; Najman, Cousty & Perret ISMM 2013), cut at `watershed_detail`. Split into `WatershedHierarchy::build` (expensive, image-only) and `cut` (near-instant), so `Session` re-cuts a cached hierarchy when the detail changes. Emits the merge tree as a stacked hierarchy (root first, refined regions on top — the color-cluster principle), so stacked mode stays seam-free and sub-pixel gaps show ancestor colors; in cutout the partition reaches the mosaic untouched (`merge_diff = 0`).
- Third parties implement `Frontend` to feed external label maps or ML segmentation.
- **ColorFitters**
- `Identity` (today's behavior: mean cluster color)
@@ -132,15 +124,13 @@ Driver flow:
- `PixelFitter` — exact lattice polyline
- `PolygonFitter` — staircase-symmetric Douglas-Peucker
- `SplineFitter` — subdivision + corner detection + least-squares cubic fit (port of the visioncortex flow, extended to open polylines with pinned endpoints)
- **CurvePasses** (selected by `Config::simplify`)
- `SimplifyCurves { tolerance, corner_threshold }` — the paper.js `simplify` analogue: samples each smooth run of fitted cubics between corners and re-fits it with the fewest curves that stay within `tolerance` px (Schneider's algorithm via a current `flo_curves`, with tangents taken from the chain's own ends; visioncortex's internal copy is pinned to an old flo_curves and block-splits at 200 points, so it is not used here). A run is only replaced when the re-fit is strictly smaller, corners stay in place, open-segment endpoints are pinned bit-for-bit, and rings are seamed at their sharpest junction. Polylines pass through untouched.
## Optimizer and SVG writer
Two levels: geometry passes over `VectorDoc`, then encoding choices in the writer.
- `QuantizePass { precision }` — round coordinates once, in document space. Replaces today's per-write rounding, and eliminates the per-path `translate(x,y)` transform by baking offsets into coordinates.
- `CleanupPass` — drop zero-length and collinear-redundant segments *after* quantization. (Curve *simplification* is deliberately not an optimizer pass — see `CurvePass` above.)
- `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
@@ -151,7 +141,7 @@ Output size is a tracked metric: the test suite asserts a byte-size budget again
## CLI
clap 4 derive, in the `vtracer` crate. Kept flags (mapping naturally): `-i/--input`, `-o/--output`, `--preset bw|poster|photo`, `--clustering color-cluster|bw|watershed` (formerly `--colormode`), `--filter_speckle`, `--color_precision`, `--gradient_step`, `--mode pixel|polygon|spline`, `--corner_threshold`, `--segment_length`, `--splice_threshold`, `--path_precision`.
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:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

-19
View File
@@ -1,19 +0,0 @@
{
"version": "1.0.0-alpha.4.app.151",
"notes": "VTracer 1.0.0-alpha.4 - Build 151 (b8bcabc6)",
"pub_date": "2026-09-09T21:32:04.887Z",
"platforms": {
"macos-universal": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_universal.app.tar.gz",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTVlYOC9hZGs3V3lpeWxpYnVBOEVNbFZXVWZ1YSthY3pBdXJZdG80MHJMYWZxL24xaVNhc2lRQnlPK3FDN09mbUxrVURsa2FCTGZIZFpuNmw3SlY3emc0PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTg3MDc3CWZpbGU6VlRyYWNlci5hcHAudGFyLmd6CjIyL250TEk3VnlVUmVmWktkT0laU1E1bzNhQ0dJSVkwNWtNOFNNRUU1VmRMY3pPN252cGRiWHJUSGJwRHoyN2lZM01MS0JhcjFwZ2FQdFJESGllWkF3PT0K"
},
"windows-x86_64": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64-setup.exe",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTWQ2WGpRRE9VTjdxcE9uaGhPSHBUUWVtYi82Si9JeHN2ZDN1TkUxRnY3V0ZPL3VIeGJsM1dSV2ZFRDlOZmhSMWh2WEdRZ3JiOXdXNnRLNHFtT1Y5d3dnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTg3MjUxCWZpbGU6VlRyYWNlcl8xLjAuMC1hbHBoYS40LmFwcC4xNTFfeDY0LXNldHVwLmV4ZQpMR0pvNkVGWm42YTlzMU1nSVlYeDlCV0NZb1hHR0dIOTdMRTdkSXpxTTFlcmlZWGVkcVZzdmhmTDBsQTBUUHBCL3ptdk82OExPV0NwYUMwY1RQc21Cdz09Cg=="
},
"linux-x86_64": {
"url": "https://github.com/visioncortex/vtracer/releases/download/1.0.0-alpha.4/VTracer_1.0.0-alpha.4_x64.AppImage",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSUENOK1VhM1NxTVVhRjErZk5oVWQvdWRmMndzalNVYWNBQzN0emRVM1kvMWw5KzZjcUFCS0s2TFI4Ymc1Ynl2cWtNcmJDMFBvbU9IT0VYekQzc3VsQUlLdWdHMWFzK2dNPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4MDIyMjUzCWZpbGU6VlRyYWNlcl8xLjAuMC1hbHBoYS40LmFwcC4xMTFfYW1kNjQuQXBwSW1hZ2UKT0RzUkx4azJqTmJvN3AvVFlIQUZZakVHTVFUalVWU2prajhvakp6eHRzTHVNZU5OVUV3WEZCWDd3QkY4YStqSDFqb0Qyam5XU1ZrdWVzK2JjUTI1RGc9PQo="
}
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
[package]
name = "vtracer-wasm"
description = "WebAssembly core for the vtracer Node.js package."
version = "1.0.0-alpha.3"
version = "1.0.0-alpha.1"
authors = ["Chris Tsang <tyt2y7@gmail.com>"]
edition = "2024"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/visioncortex/vtracer/"
@@ -15,7 +15,7 @@ repository = "https://github.com/visioncortex/vtracer/"
crate-type = ["cdylib"]
[dependencies]
vtracer = { version = "1.0.0-alpha.3", path = "../crates/vtracer" }
vtracer = { version = "1.0.0-alpha.1", path = "../crates/vtracer" }
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
+7 -10
View File
@@ -24,7 +24,7 @@ await vtracer.convertFile('in.jpg', 'out.svg', { mode: 'polygon', hierarchical:
const svg = vtracer.convertBuffer(fs.readFileSync('in.png'), { preset: 'poster' });
// raw RGBA8 pixels
const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
const svg2 = vtracer.convertPixels(rgba, width, height, { colorMode: 'bw' });
```
## API
@@ -36,15 +36,12 @@ const svg2 = vtracer.convertPixels(rgba, width, height, { clustering: 'bw' });
### `Options` (all optional, camelCase)
`preset` (`"bw" | "poster" | "photo"`, applied first), `clustering`
(`"color-cluster" | "bw" | "watershed"`), `hierarchical` (`"stacked" | "cutout"`
for the seam-free mosaic), `mode` (`"pixel" | "polygon" | "spline"`),
`filterSpeckle`, `colorPrecision`, `layerDifference`, `cornerThreshold`,
`lengthThreshold`, `maxIterations`, `spliceThreshold`, `simplify` (curve
simplification tolerance in px, try 12.5), `pathPrecision`, `palette` (list of
`#rrggbb`), `maxColors`, `optimize` (`0 | 1 | 2`), `binaryThreshold` /
`adaptive` / `adaptiveWindow` / `adaptiveT` (binary mode), `watershedDetail`
(0..=255).
`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
+1 -14
View File
@@ -2,8 +2,7 @@
export interface Options {
/** Applied before other fields: "bw" | "poster" | "photo". */
preset?: 'bw' | 'poster' | 'photo';
/** Region forming: hierarchical color clustering (default), binary threshold, or watershed. */
clustering?: 'color-cluster' | 'bw' | 'watershed';
colorMode?: 'color' | 'bw';
hierarchical?: 'stacked' | 'cutout';
mode?: 'pixel' | 'polygon' | 'spline';
filterSpeckle?: number;
@@ -13,8 +12,6 @@ export interface Options {
lengthThreshold?: number;
maxIterations?: number;
spliceThreshold?: number;
/** Curve simplification tolerance in px (omit = off; try 1-2.5). */
simplify?: number;
pathPrecision?: number;
/** Fixed palette: `#rrggbb` strings. */
palette?: string[];
@@ -22,16 +19,6 @@ export interface Options {
maxColors?: number;
/** 0 = off, 1 = quantize+simplify, 2 = + shorthands/grouping. */
optimize?: number;
/** Binary mode (`clustering: 'bw'`): fixed threshold 0..=255; foreground when intensity is below it. */
binaryThreshold?: number;
/** Binary mode: use BradleyRoth adaptive thresholding (handles uneven lighting). */
adaptive?: boolean;
/** Adaptive window side length in px; 0 = auto (~1/8 of the shorter side). */
adaptiveWindow?: number;
/** Adaptive sensitivity: percent below the local mean (default 15). */
adaptiveT?: number;
/** Watershed clustering: hierarchy cut level 0..=255 (higher = more regions, default 128). */
watershedDetail?: number;
}
/** Vectorize an encoded image (PNG/JPEG/GIF/BMP) buffer to an SVG string. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@visioncortex/vtracer",
"version": "1.0.0-alpha.3",
"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",
+3 -35
View File
@@ -15,8 +15,7 @@ use wasm_bindgen::prelude::*;
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Options {
/// Region forming: "color-cluster" | "bw" | "watershed".
clustering: Option<String>,
color_mode: Option<String>,
hierarchical: Option<String>,
mode: Option<String>,
filter_speckle: Option<usize>,
@@ -26,22 +25,10 @@ struct Options {
length_threshold: Option<f64>,
max_iterations: Option<usize>,
splice_threshold: Option<i32>,
/// Curve simplification tolerance in px (omit = off).
simplify: Option<f64>,
path_precision: Option<u32>,
palette: Option<Vec<String>>,
max_colors: Option<usize>,
optimize: Option<u8>,
/// Binary-mode fixed threshold (0..=255).
binary_threshold: Option<u8>,
/// Binary mode: use BradleyRoth adaptive thresholding.
adaptive: Option<bool>,
/// Adaptive window side length in px (0 = auto).
adaptive_window: Option<u32>,
/// Adaptive sensitivity: percent below the local mean (default 15).
adaptive_t: Option<f64>,
/// Watershed clustering: hierarchy cut level (0..=255).
watershed_detail: Option<u8>,
/// One of "bw" | "poster" | "photo"; applied before the other fields.
preset: Option<String>,
}
@@ -76,8 +63,8 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
None => Config::default(),
};
if let Some(v) = opts.clustering {
config.clustering = v.parse().map_err(err)?;
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)?;
@@ -106,9 +93,6 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
if let Some(v) = opts.splice_threshold {
config.splice_threshold = v;
}
if let Some(v) = opts.simplify {
config.simplify = Some(v);
}
if let Some(v) = opts.path_precision {
config.path_precision = Some(v);
}
@@ -121,22 +105,6 @@ fn config_from(options: JsValue) -> Result<Config, JsValue> {
if let Some(v) = opts.optimize {
config.optimize = v;
}
if let Some(v) = opts.binary_threshold {
config.binary_threshold = v;
}
// Any adaptive tuning field (or `adaptive: true`) switches on BradleyRoth.
if opts.adaptive == Some(true) || opts.adaptive_window.is_some() || opts.adaptive_t.is_some() {
config.binary_adaptive = true;
}
if let Some(v) = opts.adaptive_window {
config.binary_adaptive_window = v;
}
if let Some(v) = opts.adaptive_t {
config.binary_adaptive_t = v;
}
if let Some(v) = opts.watershed_detail {
config.watershed_detail = v;
}
Ok(config)
}
+2 -10
View File
@@ -12,19 +12,11 @@ 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 clustering -> all black
svg = vtracer.convertBuffer(data, { clustering: 'bw' });
// 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');
// curve simplification shrinks the output
{
const plain = vtracer.convertBuffer(data);
const simplified = vtracer.convertBuffer(data, { simplify: 2 });
assert(simplified.length < plain.length, 'simplify shrinks output');
console.log('convertBuffer simplify:', plain.length, '->', simplified.length, 'bytes');
}
// options: mosaic + polygon + palette
svg = vtracer.convertBuffer(data, { hierarchical: 'cutout', mode: 'polygon', palette: ['#000000', '#ffffff'], optimize: 2 });
assert(svg.includes('<svg'), 'mosaic+palette');
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
#
# Publish the current version of vtracer to every surface. Idempotent: each
# step is skipped if it is already done, so it is safe to re-run after a
# partial or interrupted release.
#
# Surfaces:
# - git push master, push tag (the tag push triggers the PyPI wheels)
# - crates.io vtracer, then vtracer-cli; and vtracer-bench (independent)
# - npm @visioncortex/vtracer
# - GitHub a release from the tag, marked latest (triggers the binaries)
#
# Prerequisites (all in your own shell — this cannot run in a sandbox):
# cargo login • npm login • gh auth login
# version already bumped + committed on master.
#
# Usage: ./scripts/publish.sh # confirm, then publish what's missing
# ./scripts/publish.sh --dry # checks + build/test only, no publish
#
set -euo pipefail
cd "$(dirname "$0")/.."
DRY=0
[ "${1:-}" = "--dry" ] && DRY=1
VERSION=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
TAG="$VERSION"
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
skip() { printf ' \033[2m· %s\033[0m\n' "$*"; }
# --- availability probes (used to skip already-done steps) -------------------
crate_published() { # crate, version
# crates.io rejects requests without a User-Agent (403), and `curl -f` hides
# that as an empty body — so the UA is mandatory or this never matches.
curl -fsS -H "User-Agent: vtracer-publish (github.com/visioncortex/vtracer)" \
"https://crates.io/api/v1/crates/$1/$2" 2>/dev/null | grep -q "\"num\":\"$2\""
}
npm_published() { # pkg@version
npm view "$1" version >/dev/null 2>&1
}
gh_release_exists() { gh release view "$1" >/dev/null 2>&1; }
say "Publishing vtracer $VERSION (dry-run: $DRY)"
# --- preconditions -----------------------------------------------------------
say "Checking preconditions"
[ "$(git rev-parse --abbrev-ref HEAD)" = master ] || { echo "!! not on master"; exit 1; }
[ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "!! tracked files have uncommitted changes — commit first"; exit 1; }
for tool in cargo npm gh curl; do command -v "$tool" >/dev/null || { echo "!! missing: $tool"; exit 1; }; done
# The version we publish comes from Cargo.toml at HEAD; the tag only marks the
# release commit for CI, so it need not be at HEAD (a later commit such as this
# script is fine). Guard only against the genuinely wrong case: a tag whose own
# commit carries a different version than the one we're about to publish.
if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
tag_ver=$(git show "$TAG:Cargo.toml" 2>/dev/null | grep -m1 '^version = ' | sed -E 's/.*"([^"]+)".*/\1/')
[ "$tag_ver" = "$VERSION" ] || { echo "!! tag $TAG marks version $tag_ver, but HEAD is $VERSION"; exit 1; }
skip "tag $TAG already exists (marks $VERSION)"
fi
# --- build + test (always, even on --dry) ------------------------------------
say "Building + testing"
cargo test --workspace
cargo build --release -p vtracer-cli
( cd nodejs && npm run build )
if [ "$DRY" = 1 ]; then say "Dry run complete — checks passed, nothing published."; exit 0; fi
# --- confirm -----------------------------------------------------------------
printf '\nPublish vtracer %s (git, crates.io, npm, GitHub)? Steps already done are skipped. [y/N] ' "$VERSION"
read -r reply
[ "$reply" = y ] || [ "$reply" = Y ] || { echo "aborted."; exit 1; }
# --- 1. git: push master, then the tag (tag push triggers the PyPI wheels) ---
say "git: push master + tag"
git push origin master
git rev-parse "refs/tags/$TAG" >/dev/null 2>&1 || git tag "$TAG"
git push origin "$TAG"
# --- 2. crates.io: core first, then the CLI that depends on it ---------------
if crate_published vtracer "$VERSION"; then
skip "crates.io vtracer $VERSION already published"
else
say "crates.io: publishing vtracer"
cargo publish -p vtracer
printf ' waiting for the index'
until crate_published vtracer "$VERSION"; do printf '.'; sleep 10; done; echo
fi
if crate_published vtracer-cli "$VERSION"; then
skip "crates.io vtracer-cli $VERSION already published"
else
say "crates.io: publishing vtracer-cli"
cargo publish -p vtracer-cli
fi
# vtracer-bench depends only on registry crates (not vtracer), so order is free.
if crate_published vtracer-bench "$VERSION"; then
skip "crates.io vtracer-bench $VERSION already published"
else
say "crates.io: publishing vtracer-bench"
cargo publish -p vtracer-bench
fi
# --- 3. npm ------------------------------------------------------------------
# Published to the default `latest` tag, matching the earlier alpha releases.
# For a prerelease you may prefer: ( cd nodejs && npm publish --tag next )
if npm_published "@visioncortex/vtracer@$VERSION"; then
skip "npm @visioncortex/vtracer@$VERSION already published"
else
say "npm: publishing @visioncortex/vtracer"
( cd nodejs && npm publish )
fi
# --- 4. GitHub release (its creation triggers the binary build workflow) -----
if gh_release_exists "$TAG"; then
skip "GitHub release $TAG already exists"
else
say "GitHub: creating release $TAG"
NOTES=$(awk -v h="## $VERSION" 'index($0,h)==1{f=1;next} /^## /&&f{exit} f' CHANGELOG.md)
# --latest: mark as the latest release (gh would otherwise treat an -alpha
# tag as a prerelease and not promote it).
gh release create "$TAG" --latest --title "$TAG" --notes "${NOTES:-Release $VERSION}"
fi
say "Done. crates.io + npm up to date; PyPI wheels build from the tag; binaries build from the release."
+4 -6
View File
@@ -17,14 +17,12 @@ crate-type = ["cdylib"]
default = ["console_error_panic_hook"]
[dependencies]
cfg-if = "1.0"
console_log = { version = "1.0", features = ["color"] }
# `serde-serialize` was removed upstream in 0.2.84; params arrive as a JSON
# string and are parsed with serde_json, so the feature was never needed.
wasm-bindgen = "0.2"
cfg-if = "0.1"
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.9"
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
-1
View File
@@ -1 +0,0 @@
../../docs/assets
+4873 -3929
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -7,7 +7,7 @@
"private": true,
"main": "index.js",
"scripts": {
"start": "webpack serve",
"start": "webpack-dev-server",
"build": "webpack"
},
"keywords": [
@@ -16,10 +16,10 @@
],
"dependencies": {
"vtracer": "file:../pkg",
"webpack": "^5.101.0"
"webpack": "^4.41.5"
},
"devDependencies": {
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2"
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.1"
}
}
+1 -10
View File
@@ -5,19 +5,10 @@ module.exports = {
output: {
path: path.resolve(__dirname, "dist"),
filename: "bootstrap.js",
clean: true,
},
mode: "development",
// wasm-pack's `bundler` target emits ESM imports of the .wasm module; webpack
// 5 handles those natively once this experiment is on.
experiments: {
asyncWebAssembly: true,
},
devServer: {
//host: "0.0.0.0",
//host: "0.0.0.0",
port: 8080,
// dev-server 5 defaults its static root to ./public, which would leave
// index.html unserved. `assets` symlinks to ../../docs/assets for samples.
static: { directory: __dirname },
}
};
+5 -38
View File
@@ -1,9 +1,6 @@
use wasm_bindgen::prelude::*;
use visioncortex::{Color, ColorImage, PathSimplifyMode};
use visioncortex::color_clusters::{
Cluster, Clusters, ClustersView, IncrementalBuilder, KeyingAction, NeighbourInfo, Runner,
RunnerConfig, HIERARCHICAL_MAX,
};
use visioncortex::color_clusters::{Clusters, Runner, RunnerConfig, HIERARCHICAL_MAX, IncrementalBuilder, KeyingAction};
use crate::canvas::*;
use crate::svg::*;
@@ -41,41 +38,11 @@ pub struct ColorImageConverter {
pub enum Stage {
New,
Clustering(Box<dyn ClusterBuilder>),
Reclustering(Box<dyn ClusterBuilder>),
Clustering(IncrementalBuilder),
Reclustering(IncrementalBuilder),
Vectorize(Clusters),
}
/// visioncortex 0.9 parameterises `IncrementalBuilder` over its four closures,
/// and `Runner::start()` returns them as anonymous `impl Fn` types, so the
/// builder can no longer be named in a field. The stage machine only ever steps
/// it, so erase the closures behind a trait object.
pub trait ClusterBuilder {
fn tick(&mut self) -> bool;
fn progress(&self) -> u32;
fn result(&mut self) -> Clusters;
}
impl<C, D, P, H> ClusterBuilder for IncrementalBuilder<C, D, P, H>
where
C: Fn(Color, Color) -> bool,
D: Fn(Color, Color) -> i32,
P: Fn(&ClustersView, &Cluster, &[NeighbourInfo]) -> bool,
H: Fn(&ClustersView, &Cluster, &[NeighbourInfo]) -> bool,
{
fn tick(&mut self) -> bool {
IncrementalBuilder::tick(self)
}
fn progress(&self) -> u32 {
IncrementalBuilder::progress(self)
}
fn result(&mut self) -> Clusters {
IncrementalBuilder::result(self)
}
}
impl ColorImageConverter {
pub fn new(params: ColorImageConverterParams) -> Self {
let canvas = Canvas::new_from_id(&params.canvas_id);
@@ -139,7 +106,7 @@ impl ColorImageConverter {
KeyingAction::Discard
},
}, image);
self.stage = Stage::Clustering(Box::new(runner.start()));
self.stage = Stage::Clustering(runner.start());
}
pub fn tick(&mut self) -> bool {
@@ -171,7 +138,7 @@ impl ColorImageConverter {
key_color: Default::default(),
keying_action: KeyingAction::Discard,
}, image);
self.stage = Stage::Reclustering(Box::new(runner.start()));
self.stage = Stage::Reclustering(runner.start());
},
_ => panic!("unknown hierarchical `{}`", self.params.hierarchical)
}