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.
This commit is contained in:
Chris Tsang
2026-07-27 20:59:25 +01:00
parent 9650808263
commit d585984e78
3 changed files with 59 additions and 13 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
* Binary thresholding: a tunable fixed threshold and BradleyRoth 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
+11 -7
View File
@@ -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,
},
},
+47 -5
View File
@@ -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]