Commit Graph

195 Commits

Author SHA1 Message Date
Chris Tsang 74da0efb84 Uncap watershed detail in the Node binding + sync docs
`6f163fc` widened watershed_detail from u8 to u32 in the core, CLI, and
Python binding but not the Node/wasm binding, which still assigned a u8 to
config.watershed_detail — a wasm build error (E0308). Widen the Node
Options field to u32 and refresh every "0..=255" watershed doc (CLI help,
Config field, .pyi, index.d.ts, and all three READMEs) to reflect that the
level is now uncapped (default 128; higher = more regions).
2026-08-08 21:50:55 +01:00
Chris Tsang 0d77ecf6f9 new app 2026-08-08 21:48:48 +01:00
Chris Tsang 6f163fcf7d Uncap watershed details 2026-08-06 16:14:44 +01:00
Chris Tsang febcc28920 Improve core lib docs for docs.rs
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled
- lib.rs: rewrite the crate landing page — single-line pipeline diagram,
  define the Segmentation/VectorDoc IRs, stage->trait->module table with
  intra-doc links, and Quick start / Interactive tuning / Extending /
  wasm-safe sections.
- config.rs: fix stale Hierarchical::Cutout doc (it is the implemented
  seam-free mosaic, not "not yet implemented"); document the previously
  bare Hierarchical/FitMode/Preset enums and variants, the hierarchical/
  mode/max_iterations fields, and from_preset.
- frontend: surface adaptive thresholding — BinaryFrontend module bullet
  and struct doc now point at Threshold::Fixed / Threshold::Adaptive.
- error.rs: add the missing module-level doc.
- Fix two pre-existing rustdoc link warnings (private-item link in
  config.rs, redundant explicit target in session.rs); crate now
  documents warning-free under -D warnings.
2026-08-02 02:41:45 +01:00
Chris Tsang bf9858e359 Readme 2026-08-02 02:24:15 +01:00
Chris Tsang 7d754e17c3 publish script 2026-08-01 23:42:47 +01:00
Chris Tsang 58221025d5 Prepare 1.0.0-alpha.3 release
- version 1.0.0-alpha.3 across the workspace, Python, and Node packages
  (and the vtracer dep pins, install snippets, and docs.rs link)
- CHANGELOG: date the section; note the new vtracer-bench crate and the
  watershed blurred-crack filament fix

(Supersampling was explored after alpha.2 and reverted -- it did not
improve output enough to keep.)
1.0.0-alpha.3
2026-08-01 21:22:33 +01:00
Chris Tsang 4a7820bf03 Snap gate: admit blends of two neighbouring regions
The mixture gate only tested betweenness against the pixel's OWN region
and the flip candidate, so a pixel blending two foreign flanks could
never flip: on a blurred low-contrast crack the basin cut leaks a
distant region along the crack's blend band as a 1-px filament (the
striped synthetic under 1.2 px blur grows a 30 px hairline of the
circle's red along a stripe boundary), and the snap visited it every
sweep yet was forbidden to fix it. A pixel that reads as a blend of two
neighbouring regions now flips to the closer of them; genuine third
colors (outline strokes) still fail betweenness against every pair and
stay put.

Blurred synthetic circle: max boundary error 31.6 -> 1.5 px, rms
5.6 -> 0.7. Crisp images byte-unaffected (fidelity 0.9261 unchanged);
all watershed property tests pass unchanged.
2026-08-01 01:44:23 +01:00
Chris Tsang ae6731017f add vtracer-bench 2026-08-01 01:44:23 +01:00
Chris Tsang f9112c5ba9 Ship the Python README as the PyPI description
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled
The `vtracer` package never declared a `readme`, so its PyPI page rendered
an empty description. Point `[project].readme` at the crate's README, and
mirror it in Cargo.toml alongside the core crate's convention.

Pin the documented install to 1.0.0a2 while the 1.0 line is pre-release:
`--pre` would resolve it, but a bare `pip install vtracer` still lands on
0.6.15, whose cp314 wheels segfault on any keyword argument (pyo3 0.19
predates CPython 3.14 moving `PyTupleObject::ob_item`, #124).
2026-07-31 16:35:01 +01:00
Chris Tsang 222cc6cbff Prepare 1.0.0-alpha.2 release
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled
- 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
1.0.0-alpha.2
2026-07-27 23:20:26 +01:00
Chris Tsang f4fe428038 Expose simplify in the Python and Node bindings
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.
2026-07-27 23:19:42 +01:00
Chris Tsang 640270a608 Reword the desktop app feature list
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.
2026-07-27 23:13:16 +01:00
Chris Tsang 87dd8f5168 Tidy the unreleased changelog
Watershed leads with its sub-features as nested bullets instead of one
paragraph-length entry; every bullet trimmed to its user-visible point.
2026-07-27 23:07:29 +01:00
Chris Tsang 2ef4bfb619 Hide the spline fine-tuning flags from CLI help
--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.
2026-07-27 23:04:00 +01:00
Chris Tsang ef9496f792 Add a curve-simplification stage (--simplify), paper.js style
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.
2026-07-27 22:30:05 +01:00
Chris Tsang d585984e78 Derive the watershed cutout merge tolerance from the detail dial
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.
2026-07-27 21:26:49 +01:00
Chris Tsang 9650808263 Snap watershed boundaries to the color-midpoint iso-line
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).
2026-07-27 21:26:49 +01:00
Chris Tsang 93a9939587 fix webapp 2026-07-27 19:56:03 +01:00
Chris Tsang 6671d7d45e Fix spline ballooning on sparse slices (visioncortex 0.9.1)
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
2026-07-27 19:56:03 +01:00
Chris Tsang fb75738328 Rust edition 2024; drop mod.rs for module-named files
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.
2026-07-27 15:41:30 +01:00
Chris Tsang 7864a702da Watershed: synthetic tests pinning the algorithm's semantics
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).
2026-07-27 15:34:55 +01:00
Chris Tsang 39cc49061e Cutout: merge_diff 0 still merges identical-color faces
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.
2026-07-27 15:29:55 +01:00
Chris Tsang e2b47dab0f Session: prove cached renders equal full rebuilds
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.
2026-07-27 15:19:25 +01:00
Chris Tsang c3f56a6339 Watershed: hierarchy stacking, native cutout, and cached re-cuts
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.
2026-07-27 15:12:12 +01:00
Chris Tsang 46a1b90ccd Add watershed clustering: hierarchical watershed frontend
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.
2026-07-27 14:45:25 +01:00
Chris Tsang f4872fcfe3 Shield the hand-formatted core crate from stray cargo fmt runs
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled
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.
2026-07-27 13:45:12 +01:00
Chris Tsang 2b0f316778 Cutout: merge neighbouring mosaic regions within one gradient step
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled
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.
2026-07-26 23:31:31 +01:00
Chris Tsang f6c8a139a4 update screenshot
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled
2026-07-26 00:31:10 +01:00
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 84ba49f0b9 upgrade webapp 2026-07-25 17:21:55 +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 1e3895318e README: surface the 1.0 packages (crates.io, PyPI, npm)
Rust / test (push) Has been cancelled
Rust / wasm-safety (core) (push) Has been cancelled
Rust / Node package (push) Has been cancelled
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>
2026-07-24 18:21:09 +01:00
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>
1.0.0-alpha.1
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