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 21:26:49 +01:00
parent 9650808263
commit d585984e78
3 changed files with 59 additions and 13 deletions
+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]