mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-08-31 01:15:57 -07:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user