mirror of
https://github.com/visioncortex/vtracer.git
synced 2026-09-30 07:51:20 -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.
|
* 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.
|
* 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).
|
* `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
|
### Changed
|
||||||
|
|||||||
@@ -293,14 +293,18 @@ impl Config {
|
|||||||
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
|
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
|
||||||
Hierarchical::Cutout => Compositing::Mosaic {
|
Hierarchical::Cutout => Compositing::Mosaic {
|
||||||
fitter: self.segment_fitter(),
|
fitter: self.segment_fitter(),
|
||||||
// Rejoin flattened neighbours the gradient layering split:
|
// Rejoin flattened neighbours the clustering split too finely.
|
||||||
// clustering itself considers colors within one gradient step
|
// Color clustering considers colors within one gradient step
|
||||||
// to be the same region (`deepen_diff`). The watershed
|
// to be the same region (`deepen_diff`), so that is its
|
||||||
// hierarchy already decided every merge, so its partition
|
// tolerance. The watershed dial has no color units (it
|
||||||
// keeps its shape — only identical-color neighbours (e.g.
|
// targets a region *count*), so its tolerance is anchored
|
||||||
// after a palette snap) still collapse into one face.
|
// 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 {
|
merge_diff: match self.clustering {
|
||||||
Clustering::Watershed => 0,
|
Clustering::Watershed => ((255 - self.watershed_detail as i32) / 8).max(2),
|
||||||
_ => self.layer_difference,
|
_ => self.layer_difference,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -244,13 +244,16 @@ fn session_recut_matches_one_shot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watershed + cutout is native: the partition reaches the mosaic untouched,
|
/// Watershed + cutout is native: at max detail the partition reaches the
|
||||||
/// so two regions within one gradient step stay separate faces (the color
|
/// mosaic essentially untouched, so two *distinguishable* regions within one
|
||||||
/// path's `merge_similar` would have rejoined them).
|
/// 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]
|
#[test]
|
||||||
fn cutout_keeps_watershed_partition() {
|
fn cutout_keeps_watershed_partition() {
|
||||||
// Two halves 4 gray-levels apart: close enough that the flatten merge
|
// Two halves 4 gray-levels apart (12 L1): close enough that the flatten
|
||||||
// (threshold = layer_difference = 16 >= 3*4) would union them.
|
// merge (threshold = layer_difference = 16 >= 3*4) would union them, yet
|
||||||
|
// clearly above the JND floor (2).
|
||||||
let img = image(32, 20, |x, _| {
|
let img = image(32, 20, |x, _| {
|
||||||
if x < 16 {
|
if x < 16 {
|
||||||
(100, 100, 100)
|
(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
|
/// 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).
|
/// corner are separate basins (and so are the two squares of the other color).
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user