- version 1.0.0-alpha.2 across the workspace, Python, and Node packages
- CHANGELOG: date the unreleased section
- README: bump install snippets and docs.rs link
Config(simplify=...) / property in Python; simplify option in Node,
with the tolerance documented in .pyi, index.d.ts, and both READMEs.
The Node README's option list also catches up with the clustering
rename and the binary/watershed options, and test.js drops the stale
colorMode key (silently ignored since the rename, so its bw assertion
was testing the default path) and asserts simplify shrinks output.
Each bullet leads with the feature and states the user-visible win.
Native speed (vs the old wasm webapp), the A/B sliding comparator, and
the curve inspector head the list; watershed clustering and curve
simplification join from the 1.0 engine.
--corner-threshold, --segment-length, and --splice-threshold are still
accepted but no longer listed, and their -c/-l/-s short forms are gone:
the defaults serve virtually every conversion, and --simplify supersedes
them as the knob that actually moves output size (sweeping segment
length 3.5..=10 shifts the sample photo by 25% alone but under 2% once
simplify is on). README options block synced with the new help text.
A new pipeline slot between curve fitting and composition: CurvePasses
rewrite each fitted contour, so mosaic mode transforms every shared
boundary segment exactly once and the tessellation stays seam-free by
construction. SimplifyCurves re-fits each smooth run between corners
with the fewest cubics within the tolerance (Schneider's algorithm via
a current flo_curves — visioncortex's copy is pinned to an old one and
block-splits at 200 points), with tangents from the chain's own ends,
corners kept in place, junction endpoints pinned bit-for-bit, and rings
seamed at their sharpest junction. Off by default; polylines pass
through untouched. Cityscape at tolerance 1: 229 -> 138 KB stacked,
103 -> 36 KB watershed cutout, with render diffs under golden noise.
CurveFitter now returns Vec<FittedGeom> (promoted from mosaic::fit) so
stacked contours flow through the same pass machinery; the optimizer's
SimplifyPass is renamed CleanupPass to free the word.
The detail dial has no color units - it targets a region count
(2^(detail/25.5)) and the cut threshold is volume persistence - so a
cutout merge tolerance cannot fall out of it dimensionally. Anchor it
instead: at max detail the user asked for every distinction the hierarchy
can make (merge only identical colors, as before), and at the default
detail (128) it matches the color-cluster default gradient step (16),
which is the tolerance the cutout merge was designed around. Linear in
between: merge_diff = (255 - detail) / 8, reaching 1 at detail 247.
Floor the watershed cutout merge tolerance at a just-noticeable difference
Faces a human cannot tell apart (e.g. #863339 next to #863238, 2 L1
apart) are pointless as separate patches at any detail, so the derived
tolerance becomes max(2, (255 - detail) / 8): the 248..=255 band merges
sub-JND neighbours instead of nothing. The default-detail anchor (16, the
color-cluster default gradient step) is unchanged. Cityscape cutout at
max detail: 992 faces down to 886.
Inside an antialiasing ramp (or JPEG halo) the per-pixel differences are
near-equal, so the minimum-spanning-forest cut meanders +-1-2 px with the
pixel noise and the fitted curves visibly wave. After the cut, boundary
pixels whose color is a mixture of the two adjacent region means are
re-assigned to the closer mean - the same rule color quantization applies,
which is why the color-cluster frontend never showed this. A mixture gate
keeps genuine third-color detail (e.g. dark outline strokes) with its
basin, and crisp synthetic edges are untouched (goldens unchanged).
The merge-tree replay now walks the full edge order: snapping can leave a
region's only adjacency running through a below-cut edge, and skipping
those left the tree unconnected. Costs ~6 ms on a 1400x775 cut (~30 ms
before, ~36 ms after); the first sweep scans the canvas, later sweeps
revisit only the moving front.
Absorb the fragments boundary snapping pinches off
A snap flip can strand a pixel (its supporting neighbour flips away in the
same sweep) or sever a thin strand of its source region. Watershed basins
are connected by construction and the mosaic gives every disjoint patch its
own face, so this debris surfaced in cutout mode as 1-px micro-faces wedged
between the real faces - visually gapless, but neighbours no longer shared
a fitted boundary (62 chips on the Cityscape sample, some with degenerate
zero-area outlines).
After the sweeps, flood every touched component with a small cap: one that
is disconnected from the rest of its region and fits under the floor moves
wholesale to the adjacent region with the closest mean. Substantial patches
severed at a thin antialiased neck stay - they make coherent faces of their
own, and recoloring them would be visible. Seeded by the sweep fronts, so
the cost is proportional to the flips, not the canvas (cut stays ~36 ms).
Cityscape cutout: 95 faces back down to 33 (32 pre-snap), zero coverage
gaps. Guarded by snap_leaves_no_debris on the real photo (smallest patch
was 1 px before, 147 patches; now every patch clears the speckle floor).
A splice slice with very uneven point spacing — a few-pixel jog followed
by a long straight leg, which the walker produces around thin strands —
was fitted by a single cubic that interpolated every sample exactly while
swinging up to ~30 px sideways between them (the fit error was only
measured at the samples). On the Cityscape sample at -p 8 -g 28 the
maroon strand's wall bulged across its 2 px gap; a long-standing defect
present in pre-1.0 vtracer as well.
The fix lives in visioncortex 0.9.1 (densify slices with witness points,
keep the full multi-cubic chain): the mosaic's shared-boundary spline
fitter switches to the new fit_points_with_beziers so open segments get
the same treatment as stacked mode. Verified against the exact pixel-mode
rasterization — the fitted wall now matches it at every probed row — with
identical path counts and only +3 cubics across the whole Cityscape
output. All three workspaces (core, py, nodejs) carry a [patch.crates-io]
entry pointing at the local visioncortex until 0.9.1 is published.
Regression coverage for spline handle anchoring; re-bless goldens
tests/spline_fit.rs traces the real reproduction (the Cityscape sample at
color precision 8 / gradient step 28, whose maroon region contains a 1 px
by 330 px strand) through both compositors and asserts the property the
sparse-slice bug violated: every cubic's control points stay within its
shape's on-curve bounding box plus a 15 px allowance. The ballooning fit
put a handle 25 px outside the whole shape, so the margin is decisive in
both directions. The sample photo is decoded via a test-only image
dev-dependency.
The synthetic goldens are re-blessed: the fitting fix shifts fixture
coordinates slightly (witness points refine fits even on clean shapes);
the old files still passed the render-diff, so the visual change is
sub-tolerance, but goldens should byte-match intentional behavior.
Depend on published visioncortex 0.9.1; drop local path patches
Workspace, vtracer-py, and the nodejs wasm crate move from edition 2021
to 2024 (no code changes needed — clean build and test run on 1.95).
Module directories switch from foo/mod.rs to the modern foo.rs layout;
single-file modules (svg, optimize, compose, fitter) collapse from
directories into plain files. Pure git renames, history preserved.
Five new cases exercising behavior rather than plumbing:
- diagonal_touch_does_not_connect — regions are 4-connected; same-colored
squares meeting at a corner stay separate basins.
- nested_regions — frame/ring/core flat zones come out as three exact
regions, and the holed ring face survives the mosaic.
- volume_extinction_prefers_vivid_over_large — the hierarchy's ranking
attribute: a 9 px black dot (volume ~1150) outlives a 100 px
barely-different patch (volume ~400) when cutting to two regions.
- plateaus_with_ramps — the antialiased-boundary shape: three plateaus
joined by short ramps cut to three regions whose means stay near the
plateau values; ramps neither form regions nor drag the means.
- degenerate_geometries — 1x1, 16x1, and 1x16 images segment correctly.
assert_stack additionally bounds the layer count by the merge tree
(at most 2K-1 layers).
Making watershed's cutout native (merge_diff = 0) silently disabled the
same-color merge too: merge_similar early-returned on a non-positive
threshold, so two adjacent faces with the very same fill — e.g. after a
palette snap — kept a useless boundary between them. A threshold of 0 now
means 'merge only identical-color neighbours' (a boundary between two
same-colored faces is never useful); negative disables merging entirely.
The regression test snaps two boundary-sharing regions to the same
palette entry with a third region between them in stack order, so the
layer-level MergeAdjacent (consecutive runs only) cannot mask the mosaic
merge: 3 faces with the bug, 2 with the fix. The zero-threshold unit test
now asserts the new semantics, and negative-threshold identity.
A 21-step cumulative parameter walk covering every category — finish-phase
dials (true cache hits, since the segment key provably ignores them),
clustering dials, frontend switches including leaving watershed and
returning to its cached hierarchy, compositing, thresholds, palettes and
quantization — asserting after each step that the session render is
byte-identical to a from-scratch pipeline. Plus: the progress-reporting
render path (a separate segmenting branch with the watershed hierarchy
shortcut) equals the plain path, a cache warmed by the progress path
serves the plain path identically, and invalidate() rebuilds identically.
Three refinements that make the watershed frontend a first-class citizen
of both compositing modes and of interactive tuning:
Stacked mode now stacks for real. Instead of one full-canvas background
plus disjoint regions, the cut emits the merge tree itself: the root
(whole canvas, mean color) first, then progressively finer ancestor
regions, then the final regions on top — the same principle as the color
clustering frontend, just with watershed-born clusters. Sub-pixel gaps
between abutting regions therefore show their common ancestor's color
rather than an unrelated backdrop, and overdraw stays seam-free. A
painted-area budget (3x canvas) keeps pathological persistence chains
from ballooning the stack; the root and final regions are always emitted
so coverage never depends on it.
Cutout is native. The watershed hierarchy already decided every merge, so
the flattened partition reaches the mosaic untouched: merge_diff is 0 for
watershed (the gradient-step re-merge still applies to the color path).
Faces are exactly the cut regions.
Re-cuts are cached. WatershedHierarchy is now public and split into
build(img) — Kruskal, BPT, volume persistence; depends only on the image
— and cut(detail, min_area), which is near-linear: region formation,
graph-level small-basin absorption (region adjacencies, not pixel
sweeps), then the merge tree. Session builds the hierarchy lazily on the
first watershed render and re-cuts it on every watershed_detail or
filter_speckle change: ~25 ms per re-cut vs ~40 ms rebuild on a 1400x775
photo, with the one-shot Frontend::segment path unchanged (build + cut),
so Session output still equals the one-shot pipeline exactly.
Tests: flatten-based stack invariants (solid bottom layer, full coverage,
final regions topmost, exact region counts), hierarchy re-cut == one-shot,
Session re-cut == one-shot across detail changes, and cutout keeping two
regions one gradient step apart that the color path's merge would rejoin.
Watershed goldens re-blessed for the new stack structure.
An alternative region-forming frontend selected by --clustering watershed
(the color_mode field is replaced by clustering: color-cluster | bw |
watershed across CLI, Rust, Python, and Node — it selects the algorithm,
not a color space).
The algorithm is the watershed hierarchy by volume on the 4-adjacency
pixel graph, implemented from the papers:
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", ISMM 2013.
Edge weights are the max per-channel color difference between adjacent
pixels (no gradient image); a counting-sorted Kruskal pass builds the
binary partition tree as a flat parents array; a leaves-to-root pass
computes subtree area and volume; each merge's persistence (the volume of
the smaller side, plateau-corrected) becomes its MST edge's saliency; and
cutting the hierarchy is single-linkage over MST edges below the cut
level. Watershed cuts label every pixel — no watershed-line pixel class —
so the output is a strict, gapless partition that drops straight into
both stacked and cutout modes. Integer arithmetic and flat u32 arrays
throughout; deterministic across platforms; ~66 ms on a 1400x775 photo.
The one dial, --watershed-detail (0..=255), maps exponentially to a
target region count (each +25.5 doubles it) since the persistence
distribution is far too skewed for a linear threshold. filter_speckle
absorbs undersized basins into their most color-similar neighbour rather
than dropping them, preserving the partition. The largest region is
emitted first as a solid full-canvas background layer so stacked mode
keeps its seam-free overdraw; the mosaic flatten is unaffected.
Tests: partition invariant (disjoint masks tiling the canvas), detail
monotonicity, min-area absorption, determinism, watershed cases in the
pipeline/golden suites, stacked-vs-cutout interior equivalence, a
watershed seam test, and SegmentKey coverage for the new params.
disable_all_formatting in crates/vtracer/rustfmt.toml makes rustfmt a no-op
for every file under the crate, so an accidental workspace fmt cannot
rewrite it.
The stacked hierarchy deliberately splits smooth areas into gradient layers
one deepen_diff apart — that is what makes stacking look continuous. When
cutout flattens those layers into a mosaic, the layering degenerates into
abutting faces with barely distinguishable fills that clustering would have
treated as one region.
Add LabelMap::merge_similar: agglomerative union-find over the flattened
adjacency graph using the clustering color metric (sum of per-channel
absolute diffs, merge when <= deepen_diff). Most-similar pairs union first
and each merged region's color is re-derived as the area-weighted mean, so
gradient chains only coalesce while they genuinely stay within the
threshold — no transitive collapse. compose_mosaic runs it between
flattening and boundary extraction; Compositing::Mosaic carries the
threshold and Config wires it to layer_difference (gradient step), so there
is no new knob.
On the gum-tree sample (poster preset, cutout) this drops 919 faces to 745
with no visible difference. Covered by unit tests for the merge semantics
(running means, OUTSIDE handling, zero threshold) plus a compose-level test
that gradient strips coalesce into one face; goldens and the stacked/cutout
equivalence suite are unaffected.
Face assembly bucketed contours by region label, so a region appearing as
several disjoint patches contributed all of their contours to one face — and
compose emitted them as subpaths of a single <path>. Isolated islands were
therefore not separately addressable downstream.
Faces are now keyed by (region, island). `islands` flood-fills the label map
into connected components in one pass, and `island_of` attributes a contour via
the region-side pixel flanking its first directed edge: every contour is walked
with its region on the left, so that pixel is interior to the patch the contour
bounds, and an outer ring agrees with the holes inside it. graph gains
`left_pixel_coord` (the coordinate half of `left_pixel_at`, which now delegates
to it) and `dir_from_delta`, needed because a ring has no start node or
first_dir. A BTreeMap keeps face order deterministic: region ascending, then
island in raster-scan order.
Connectivity is 8-way to match the successor rule, which pinches a checkerboard
corner into a single contour: lobes meeting only at a diagonal are walked as one
contour and must stay in one face. Splitting them could separate a hole contour
from the ring enclosing it, and a lone hole ring fills solid under nonzero.
Gum tree in cutout goes 492 -> 517 paths and the tank sprite 1022 -> 1023, with
renders byte-identical in both cases: the change is structural only. Costs one
O(W*H) pass over a pipeline that is already O(W*H).
MergeAdjacent folded RegionMask::union over each run of same-paint layers, and
every union allocates a mask over the combined bounding box and copies both
inputs into it. The accumulator reaches full-canvas size after the first few
merges, so each remaining layer reallocated and rewrote the whole canvas —
O(n * width * height) for a run of n layers.
A single palette color is the worst case, since every layer then shares a paint
and the entire stack folds into one accumulator: 474 layers at 1400x775 spent
~0.8s of the 1.17s conversion there. --max-colors escaped it only because runs
of identical consecutive paints stay short.
RegionMask::union_all sizes the destination from the combined bounding box in
one cheap pass, then blits each source exactly once; union delegates to it with
two elements. MergeAdjacent groups a run and unions it as a whole. Gum tree with
a one-color palette goes 1.17s -> 0.31s (now under the no-palette baseline,
since one merged layer leaves less geometry to fit), with byte-identical output.
Only the --palette and --max-colors paths construct MergeAdjacent, so the
default pipeline is untouched.
A stateful, image-owning converter for the desktop tuning loop. The consumer
calls render / render_svg / render_with_progress with a fresh Config each
frame and never reasons about cachability: Session compares the Config's
SegmentKey (its clustering-relevant projection — color mode, color precision,
layer difference, speckle, binary threshold settings) to what it last
clustered and re-segments only when that changes. Everything else — fit mode,
curve params, compositing, palette, optimization — reuses the cached
Segmentation.
Config::segment_key is public too, so callers that hold their own state (e.g.
the wasm/JS side) can compare keys with the same source of truth.
Tests cover the key partition (finish-phase params share a key; clustering
params change it) and that Session output matches the one-shot pipeline for
both a reused-segmentation render and a re-segmented one.
Add desktop app screenshot referenced by the README
Condense the unreleased changelog notes
Speckle removal moves out of the frontends into Segmentation::filter_speckle,
applied in the finish phase. The color frontend now clusters with
good_min_area = 0 and the binary frontend emits every cluster, so the cached
segmentation retains all regions and the speckle threshold can be retuned via
finish() with no re-clustering. Pipeline gains a speckle_area field
(Config sets it from filter_speckle^2).
Frontend structs drop their filter_speckle_area field. Output on clean images
is unchanged (golden/equivalence pass unblessed); noisy images are filtered
downstream instead of during clustering. Adds a test tuning filter_speckle on
one cached segmentation.
Add finish-phase thin-strand filter (restores thread-like rejection)
good_min_area = 0 disabled visioncortex's thread-like rejection (which was
gated on good_min_area > 0). Reintroduce it in our repo as a finish-phase
step: Segmentation::filter_thin drops regions whose perimeter >= area
(average thickness under ~2px), using the same Shape::image_boundary_list
metric so the heuristic matches. It's toggleable on a cached segmentation
(Config::filter_thin, on by default), unlike the clustering-time version.
Exposed via CLI --keep-thin, Python filter_thin, and Node filterThin. Adds
RegionMask::perimeter/is_thin and a reuse test toggling it on one cached
segmentation. Clean-image goldens are unaffected (large regions aren't thin).
README: document binary thresholding, --keep-thin, and finish-phase filters
Add the new CLI flags (--threshold, --adaptive, --adaptive-window,
--adaptive-t, --keep-thin) to the options block and "New in 1.0"; note that
--optimize is encoding-only (precision is --path-precision) and that speckle/
thin filtering run after clustering. Add adaptive-threshold examples for CLI,
Python, and Node.
Return speckle/thin filtering to clustering (fix gum-tree regression)
good_min_area is visioncortex's clustering `deepen` gate, not a speckle
post-filter: it decides whether a small or thread-like patch is absorbed
into its nearest-color neighbour or kept as its own layer, and it enables
the thread-like rejection (perimeter < area). An earlier change set it to 0
to make filter_speckle "retunable downstream", which disabled the thin
check and reshaped the whole hierarchy — dissolving gradient-boundary
structure that clustering is meant to absorb. The Gum Tree preset's central
trunk vanished at gradient-step ~26 where the pre-1.0 path held it to 128.
Clean-logo goldens couldn't exercise it, so it shipped green.
Follow the proven webapp model instead: speckle lives inside clustering
(good_min_area = filter_speckle^2 for the colour frontend; a post-cluster
size gate for the binary frontend). The segment/finish split stays — it is
the progressive model (cluster once, re-run colour/curve/optimize cheaply);
clustering params (speckle, colour precision, layer difference, binary
threshold) re-segment.
Remove the downstream band-aids: Segmentation::filter_speckle/filter_thin,
RegionMask::perimeter/is_thin, the pipeline speckle_area/filter_thin fields,
Config::filter_thin, CLI --keep-thin, Python filter_thin, Node filterThin.
Update the two reuse tests that encoded the wrong contract and the binary
threshold test to use BinaryFrontend::min_area. All goldens, equivalence,
progress, and reuse tests pass.
Pipeline::segment runs only the frontend (the expensive clustering) and
returns a reusable Segmentation; Pipeline::finish re-runs just color
fitting, compositing, and optimization over a cached segmentation. This
restores the old stage-reuse workflow: cluster once, then tune curve-fit
or color-fit params without repaying clustering. Both have
*_with_progress variants; run/run_with_progress now compose the two
(one-shot path moves the owned segmentation, so it adds no clone).
finish clones the segmentation internally (color fitting mutates it), so
the cached copy stays pristine across many finish calls. Segmentation and
VectorDoc are re-exported at the crate root.
BinaryFrontend gains a Threshold enum: Fixed(u8) (now tunable, was
hardcoded to 128) and Adaptive { window, t } — Bradley–Roth adaptive
thresholding computed via visioncortex's SummedAreaTable, O(pixels)
regardless of window size, for images with uneven lighting. Both use a
shared (r+g+b)/3 intensity so they agree on "dark"; the grayscale
checker_bw golden is unaffected.
Exposed through Config and all bindings: CLI (--threshold, --adaptive,
--adaptive-window, --adaptive-t), Python (constructor kwargs + getters/
setters), and the Node package (binaryThreshold, adaptive, adaptiveWindow,
adaptiveT). Adds tests covering fixed tunability and adaptive recovering
locally-dark marks under a brightness gradient that a global cutoff can't.
Pipeline::run_with_progress(img, &CancelToken, &mut on_progress)
publishes per-phase Progress and aborts (Error::Cancelled) when the
token trips. The color-cluster frontend now drives visioncortex's
IncrementalBuilder so clustering reports fine-grained progress and
checks cancellation between batches; run() delegates to the new path,
which is output-identical since Runner::run() is that same tick loop.
Intended for native desktop apps (Tauri/egui/iced): run on a worker
thread, hand the UI a CancelToken clone for a cancel button, and
forward progress to a bar. Replaces the old browser-only cooperative
tick() API, which existed only because the main thread couldn't block.
Add package badges, a Packages overview table, and note the npm
build's supported input formats (PNG/JPEG/GIF/BMP/WebP).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- 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>
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).
Publish under the @visioncortex npm org. Add publishConfig.access=public for
the scoped package and update the install/require examples in both READMEs.
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).
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.
- 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.
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`.
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).
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.
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.
`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.
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.
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.
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.
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.
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.
* 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.
* - 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!
* 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>
* 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
@@ -5,16 +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.
@@ -46,7 +46,7 @@ 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)

@@ -169,7 +169,7 @@ 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@1.0.0-alpha.2
```
```rust
@@ -199,14 +199,14 @@ let seg = pipeline.segment(&img)?; // the expensive part
letdoc=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.
See [docs.rs/vtracer](https://docs.rs/vtracer/1.0.0-alpha.2/vtracer/) for the full API.
### Python Library
[`vtracer`](https://pypi.org/project/vtracer/) is also packaged as a Python native extension.
```sh
pip install vtracer==1.0.0a3
pip install --pre vtracer
```
```python
@@ -238,7 +238,7 @@ See [`crates/vtracer-py`](crates/vtracer-py/README.md) for the full API.
[`@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`.
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."
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
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:
**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:
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.
-`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.
(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.)
`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.
say "Done. crates.io + npm up to date; PyPI wheels build from the tag; binaries build from the release."
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.