Commit Graph

22 Commits

Author SHA1 Message Date
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 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 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 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 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 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 3b2991be84 0.6.12 2026-02-04 12:21:42 +00:00
Chris Tsang c03a8ffced 0.6.4 2024-03-29 19:01:16 +00:00
Chris Tsang 177797108d Changelog 2024-03-29 18:57:57 +00:00
Chris Tsang 022018beb2 0.6.1 2023-09-23 11:12:34 +01:00
Chris Tsang 594125f737 Release notes 2023-09-16 15:31:36 +01:00
Chris Tsang f35df1f6b2 0.6.0 2023-09-16 14:35:49 +01:00
Evan Jones f4c7828049 Python bindings configured correctly for PyPI releases (#54)
* Python bindings sep 2023 (#52)

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

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

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

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

* Move code around

* Edit Readme

* Edit RELEASES.md

* Feature guard

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

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

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

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

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

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

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

---------

Co-authored-by: Chris Tsang <chris.2y3@outlook.com>
2023-09-17 06:24:13 +08:00
Chris Tsang 82284ab470 0.5.0 2022-10-09 18:01:35 +08:00