Commit Graph

15 Commits

Author SHA1 Message Date
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