Commit Graph

17 Commits

Author SHA1 Message Date
Chris Tsang ba2b80455e Give each disjoint patch of a region its own face in cutout mode
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).
2026-07-25 20:01:16 +01:00
Chris Tsang 5ff4f3ae05 Union same-paint layers in one pass, not pairwise
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.
2026-07-25 19:44:43 +01:00
Chris Tsang df94675494 Add Session: transparent segmentation caching for interactive tuning
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
2026-07-25 16:08:26 +01:00
Chris Tsang abe21658dc Make filter_speckle a finish-phase filter, tunable without re-clustering
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.
2026-07-25 16:08:10 +01:00
Chris Tsang 5361a51011 Split pipeline into cacheable segment + re-runnable finish
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.
2026-07-25 00:51:46 +01:00
Chris Tsang a350e2532a Enrich binary thresholding: tunable fixed + Bradley–Roth adaptive
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.
2026-07-25 00:26:05 +01:00
Chris Tsang 50042f477d Add progress reporting and cancellation to the pipeline
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.
2026-07-25 00:25:59 +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 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 170b0322a4 Bump pyo3 to 0.26 in vtracer-py 2026-07-24 13:37:25 +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 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 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 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