diff --git a/CHANGELOG.md b/CHANGELOG.md index f8aee64..a3d786e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Binary thresholding: a tunable fixed threshold and Bradley–Roth adaptive thresholding for uneven lighting — CLI `--threshold` / `--adaptive` (`--adaptive-window`, `--adaptive-t`), also on `Config`, Python, and Node. * Cutout mode merges neighbouring mosaic regions whose colors are within one gradient step — the flattened tessellation no longer keeps the near-identical faces that stacked gradient layering splits a smooth area into. -* Watershed clustering (`--clustering watershed`): an alternative region-forming frontend — a hierarchical watershed by volume on the pixel graph (Cousty et al., TPAMI 2009; Najman, Cousty & Perret, ISMM 2013), cut at a single `--watershed-detail` dial (0..=255, each +25.5 roughly doubles the region count). Content-adaptive regions with no watershed-line pixels; antialiased boundary pixels snap to the color-midpoint iso-line, so edges come out as calm as the color-cluster frontend's instead of meandering with the pixel noise inside the ramp. With `cutout` the partition reaches the mosaic natively (no gradient-step re-merge); with `stacked` the merge tree itself is the stack — coarse ancestors below, refined regions on top, the same principle as color clustering — so sub-pixel gaps show ancestor colors and overdraw stays seam-free. +* Watershed clustering (`--clustering watershed`): an alternative region-forming frontend — a hierarchical watershed by volume on the pixel graph (Cousty et al., TPAMI 2009; Najman, Cousty & Perret, ISMM 2013), cut at a single `--watershed-detail` dial (0..=255, each +25.5 roughly doubles the region count). Content-adaptive regions with no watershed-line pixels; antialiased boundary pixels snap to the color-midpoint iso-line, so edges come out as calm as the color-cluster frontend's instead of meandering with the pixel noise inside the ramp. With `cutout` the partition reaches the mosaic natively, and near-identical neighbouring faces merge within a detail-derived tolerance (`max(2, (255 − detail) / 8)`: the color-cluster default gradient step at the default detail, and never less than a just-noticeable difference — faces a human cannot tell apart never survive as separate patches); with `stacked` the merge tree itself is the stack — coarse ancestors below, refined regions on top, the same principle as color clustering — so sub-pixel gaps show ancestor colors and overdraw stays seam-free. * `WatershedHierarchy` is public and split into `build` (expensive, depends only on the image) and `cut` (near-instant): `Session` builds it once and re-cuts on every `watershed_detail`/`filter_speckle` change, making the detail slider fully interactive (~25 ms re-cut vs ~40 ms rebuild on a 1400×775 photo). ### Changed diff --git a/crates/vtracer/src/config.rs b/crates/vtracer/src/config.rs index 0966423..6086a5c 100644 --- a/crates/vtracer/src/config.rs +++ b/crates/vtracer/src/config.rs @@ -293,14 +293,18 @@ impl Config { Hierarchical::Stacked => Compositing::Stacked(self.fitter()), Hierarchical::Cutout => Compositing::Mosaic { fitter: self.segment_fitter(), - // Rejoin flattened neighbours the gradient layering split: - // clustering itself considers colors within one gradient step - // to be the same region (`deepen_diff`). The watershed - // hierarchy already decided every merge, so its partition - // keeps its shape — only identical-color neighbours (e.g. - // after a palette snap) still collapse into one face. + // Rejoin flattened neighbours the clustering split too finely. + // Color clustering considers colors within one gradient step + // to be the same region (`deepen_diff`), so that is its + // tolerance. The watershed dial has no color units (it + // targets a region *count*), so its tolerance is anchored + // instead: at the default detail (128) it matches the + // color-cluster default gradient step (16) and grows linearly + // as detail drops; the floor keeps faces a human cannot tell + // apart (within a just-noticeable difference) from surviving + // as separate patches even at maximum detail. merge_diff: match self.clustering { - Clustering::Watershed => 0, + Clustering::Watershed => ((255 - self.watershed_detail as i32) / 8).max(2), _ => self.layer_difference, }, }, diff --git a/crates/vtracer/tests/watershed.rs b/crates/vtracer/tests/watershed.rs index fb6aa97..d3bf186 100644 --- a/crates/vtracer/tests/watershed.rs +++ b/crates/vtracer/tests/watershed.rs @@ -244,13 +244,16 @@ fn session_recut_matches_one_shot() { } } -/// Watershed + cutout is native: the partition reaches the mosaic untouched, -/// so two regions within one gradient step stay separate faces (the color -/// path's `merge_similar` would have rejoined them). +/// Watershed + cutout is native: at max detail the partition reaches the +/// mosaic essentially untouched, so two *distinguishable* regions within one +/// gradient step stay separate faces (the color path's `merge_similar` would +/// have rejoined them). Only the just-noticeable-difference floor applies — +/// see `cutout_merge_tolerance_follows_detail`. #[test] fn cutout_keeps_watershed_partition() { - // Two halves 4 gray-levels apart: close enough that the flatten merge - // (threshold = layer_difference = 16 >= 3*4) would union them. + // Two halves 4 gray-levels apart (12 L1): close enough that the flatten + // merge (threshold = layer_difference = 16 >= 3*4) would union them, yet + // clearly above the JND floor (2). let img = image(32, 20, |x, _| { if x < 16 { (100, 100, 100) @@ -273,6 +276,45 @@ fn cutout_keeps_watershed_partition() { ); } +/// The cutout merge tolerance is derived from the detail dial — +/// `max(2, (255 − detail) / 8)` — because detail has no color units of its +/// own. The same two halves 12 L1 apart that max detail keeps separate (see +/// above) merge into one face at the default detail, whose tolerance (15) +/// matches the color-cluster default gradient step; and a pair a human +/// cannot tell apart (within the just-noticeable-difference floor) merges +/// even at max detail. +#[test] +fn cutout_merge_tolerance_follows_detail() { + let halves = |a: (u8, u8, u8), b: (u8, u8, u8)| { + image(32, 20, |x, _| if x < 16 { a } else { b }) + }; + let cfg = |detail| Config { + clustering: Clustering::Watershed, + hierarchical: Hierarchical::Cutout, + watershed_detail: detail, + filter_speckle: 0, + ..Config::default() + }; + + let img = halves((100, 100, 100), (104, 104, 104)); + let doc = cfg(128).build().unwrap().run(&img).unwrap(); + assert_eq!( + doc.shapes.len(), + 1, + "near-identical neighbours merge at the default detail" + ); + + // #863339 next to #863238 (2 L1 apart): indistinguishable by eye, so it + // must never survive as two patches, not even at maximum detail. + let img = halves((0x86, 0x33, 0x39), (0x86, 0x32, 0x38)); + let doc = cfg(255).build().unwrap().run(&img).unwrap(); + assert_eq!( + doc.shapes.len(), + 1, + "sub-JND neighbours merge even at max detail" + ); +} + /// Regions are 4-connected: two same-colored squares touching only at a /// corner are separate basins (and so are the two squares of the other color). #[test]