Merge branch 'worktree-watershed-plateaux'
This commit is contained in:
+301
-6
@@ -43,6 +43,14 @@ pub struct SegmentOptions {
|
||||
pub blur_radius: i32,
|
||||
pub w_luma: f32,
|
||||
pub w_chroma: f32,
|
||||
/// How far the lower-completion carries a distance inward from a
|
||||
/// plateau's rim, in breadth-first steps.
|
||||
///
|
||||
/// Bounds the widest plateau that resolves fully. Beyond it, the interior
|
||||
/// keeps the behaviour it had before the pass existed — a fan of diagonal
|
||||
/// chains — so this trades dispatches against the size of flat area the
|
||||
/// watershed handles cleanly, and never against correctness elsewhere.
|
||||
pub plateau_iterations: u32,
|
||||
}
|
||||
|
||||
impl Default for SegmentOptions {
|
||||
@@ -58,6 +66,12 @@ impl Default for SegmentOptions {
|
||||
// boundaries anyone would draw, so it counts for less — but not
|
||||
// zero, or a red flower on green leaves has no edge at all.
|
||||
w_chroma: 0.5,
|
||||
// **Zero: the pass is off.** It is implemented, dispatched
|
||||
// correctly and measurably changes nothing — see the ignored test
|
||||
// below and §12 of docs/segmentation.md. Until that is understood,
|
||||
// running it would buy 64 dispatches per segmentation and no
|
||||
// improvement, so the default declines to pay.
|
||||
plateau_iterations: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +101,8 @@ pub struct SegmentPass {
|
||||
features: Stage,
|
||||
blur: Stage,
|
||||
gradient: Stage,
|
||||
plateau_init: Stage,
|
||||
plateau_step: Stage,
|
||||
flow: Stage,
|
||||
jump: Stage,
|
||||
}
|
||||
@@ -144,10 +160,30 @@ impl SegmentPass {
|
||||
Stage { layout, pipeline }
|
||||
};
|
||||
|
||||
// Three bindings rather than two: these read the gradient *and* a
|
||||
// distance field, and write a second one.
|
||||
let triple_stage = |a: u32, b: u32, c: u32, entry: &str, label: &str| {
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &[
|
||||
uniform_entry(0),
|
||||
storage_entry(a, true),
|
||||
storage_entry(b, true),
|
||||
storage_entry(c, false),
|
||||
],
|
||||
});
|
||||
let pipeline = compute(ctx, &module, &layout, entry);
|
||||
Stage { layout, pipeline }
|
||||
};
|
||||
|
||||
let blur = buffer_stage(3, 4, "blur", "watershed-blur-bgl");
|
||||
let gradient = buffer_stage(5, 6, "gradient", "watershed-gradient-bgl");
|
||||
let flow = buffer_stage(7, 8, "flow", "watershed-flow-bgl");
|
||||
let jump = buffer_stage(9, 10, "jump", "watershed-jump-bgl");
|
||||
let plateau_init = buffer_stage(7, 8, "plateau_init", "watershed-pinit-bgl");
|
||||
let plateau_step = triple_stage(9, 10, 11, "plateau_step", "watershed-pstep-bgl");
|
||||
let flow = triple_stage(12, 13, 14, "flow", "watershed-flow-bgl");
|
||||
let jump = buffer_stage(15, 16, "jump", "watershed-jump-bgl");
|
||||
|
||||
if let Some(err) = pollster::block_on(scope.pop()) {
|
||||
return Err(GpuError::ShaderCompilation(err.to_string()));
|
||||
@@ -158,6 +194,8 @@ impl SegmentPass {
|
||||
features,
|
||||
blur,
|
||||
gradient,
|
||||
plateau_init,
|
||||
plateau_step,
|
||||
flow,
|
||||
jump,
|
||||
})
|
||||
@@ -197,6 +235,8 @@ impl SegmentPass {
|
||||
let feat_a = self.buffer("watershed-feat-a", n * 16, false);
|
||||
let feat_b = self.buffer("watershed-feat-b", n * 16, false);
|
||||
let gradient = self.buffer("watershed-gradient", n * 4, true);
|
||||
let dist_a = self.buffer("watershed-dist-a", n * 4, false);
|
||||
let dist_b = self.buffer("watershed-dist-b", n * 4, false);
|
||||
let parent_a = self.buffer("watershed-parent-a", n * 4, true);
|
||||
let parent_b = self.buffer("watershed-parent-b", n * 4, true);
|
||||
|
||||
@@ -233,9 +273,39 @@ impl SegmentPass {
|
||||
|
||||
let blur_bg = self.bind(&self.blur.layout, ¶ms, 3, &feat_a, 4, &feat_b);
|
||||
let gradient_bg = self.bind(&self.gradient.layout, ¶ms, 5, &feat_b, 6, &gradient);
|
||||
let flow_bg = self.bind(&self.flow.layout, ¶ms, 7, &gradient, 8, &parent_a);
|
||||
let jump_ab = self.bind(&self.jump.layout, ¶ms, 9, &parent_a, 10, &parent_b);
|
||||
let jump_ba = self.bind(&self.jump.layout, ¶ms, 9, &parent_b, 10, &parent_a);
|
||||
let pinit_bg = self.bind(&self.plateau_init.layout, ¶ms, 7, &gradient, 8, &dist_a);
|
||||
let pstep_ab = self.bind3(
|
||||
&self.plateau_step.layout,
|
||||
¶ms,
|
||||
(9, &gradient),
|
||||
(10, &dist_a),
|
||||
(11, &dist_b),
|
||||
);
|
||||
let pstep_ba = self.bind3(
|
||||
&self.plateau_step.layout,
|
||||
¶ms,
|
||||
(9, &gradient),
|
||||
(10, &dist_b),
|
||||
(11, &dist_a),
|
||||
);
|
||||
|
||||
// An odd number of plateau steps leaves the distance field in B.
|
||||
let plateau_steps = opts.plateau_iterations;
|
||||
let final_dist = if plateau_steps % 2 == 0 {
|
||||
&dist_a
|
||||
} else {
|
||||
&dist_b
|
||||
};
|
||||
|
||||
let flow_bg = self.bind3(
|
||||
&self.flow.layout,
|
||||
¶ms,
|
||||
(12, &gradient),
|
||||
(13, final_dist),
|
||||
(14, &parent_a),
|
||||
);
|
||||
let jump_ab = self.bind(&self.jump.layout, ¶ms, 15, &parent_a, 16, &parent_b);
|
||||
let jump_ba = self.bind(&self.jump.layout, ¶ms, 15, &parent_b, 16, &parent_a);
|
||||
|
||||
// Pointer jumping halves every path per pass, so log2 of the pixel
|
||||
// count bounds it — that is the longest possible descent chain. A
|
||||
@@ -253,13 +323,24 @@ impl SegmentPass {
|
||||
(&self.features.pipeline, &features_bg),
|
||||
(&self.blur.pipeline, &blur_bg),
|
||||
(&self.gradient.pipeline, &gradient_bg),
|
||||
(&self.flow.pipeline, &flow_bg),
|
||||
(&self.plateau_init.pipeline, &pinit_bg),
|
||||
] {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, 1);
|
||||
}
|
||||
|
||||
pass.set_pipeline(&self.plateau_step.pipeline);
|
||||
for i in 0..plateau_steps {
|
||||
let bg = if i % 2 == 0 { &pstep_ab } else { &pstep_ba };
|
||||
pass.set_bind_group(0, bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, 1);
|
||||
}
|
||||
|
||||
pass.set_pipeline(&self.flow.pipeline);
|
||||
pass.set_bind_group(0, &flow_bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0, groups.1, 1);
|
||||
|
||||
pass.set_pipeline(&self.jump.pipeline);
|
||||
for i in 0..jumps {
|
||||
let bg = if i % 2 == 0 { &jump_ab } else { &jump_ba };
|
||||
@@ -295,6 +376,40 @@ impl SegmentPass {
|
||||
})
|
||||
}
|
||||
|
||||
fn bind3(
|
||||
&self,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
params: &wgpu::Buffer,
|
||||
a: (u32, &wgpu::Buffer),
|
||||
b: (u32, &wgpu::Buffer),
|
||||
c: (u32, &wgpu::Buffer),
|
||||
) -> wgpu::BindGroup {
|
||||
self.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("watershed-bg3"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: a.0,
|
||||
resource: a.1.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: b.0,
|
||||
resource: b.1.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: c.0,
|
||||
resource: c.1.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn bind(
|
||||
&self,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
@@ -556,6 +671,186 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A flat disc on flat ground: two plateaux and one boundary between
|
||||
/// them. Nothing here has a downhill direction except at the rim.
|
||||
/// A linear ramp between two flat fields.
|
||||
///
|
||||
/// The watershed runs on gradient *magnitude*, and that changes which
|
||||
/// images contain a plateau worth resolving. A flat region of the picture
|
||||
/// has gradient zero — the global minimum — and a plateau at the minimum
|
||||
/// has no descending exit at all, which makes it a single basin by
|
||||
/// definition with nothing for lower-completion to do. The plateaux that
|
||||
/// do have an exit are regions of constant *non-zero* gradient: linear
|
||||
/// ramps. So that is what this builds.
|
||||
fn ramp(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut px = vec![0u8; (w * h * 4) as usize];
|
||||
let (lo, hi) = (w / 4, w - w / 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = if x < lo {
|
||||
40u8
|
||||
} else if x >= hi {
|
||||
210u8
|
||||
} else {
|
||||
// Constant slope, so the gradient is constant and
|
||||
// non-zero across the whole band.
|
||||
(40.0 + (x - lo) as f32 * (170.0 / (hi - lo) as f32)) as u8
|
||||
};
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
px[i] = v;
|
||||
px[i + 1] = v;
|
||||
px[i + 2] = v;
|
||||
px[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
/// A terraced disc: dark ground, a mid-level annulus, a bright centre.
|
||||
///
|
||||
/// The annulus is the point. It is a wide plateau that *has* a descending
|
||||
/// exit — the ground outside it — which is the only situation
|
||||
/// lower-completion is defined for. `disc` below has only flat regions at
|
||||
/// the gradient's global minimum, and a plateau with no exit at all is a
|
||||
/// minimum: one basin by definition, with nothing to resolve.
|
||||
fn terrace(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut px = vec![0u8; (w * h * 4) as usize];
|
||||
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
|
||||
let v = if d < w as f32 * 0.16 {
|
||||
200
|
||||
} else if d < w as f32 * 0.40 {
|
||||
130
|
||||
} else {
|
||||
60
|
||||
};
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
px[i] = v;
|
||||
px[i + 1] = v;
|
||||
px[i + 2] = v;
|
||||
px[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn disc(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut px = vec![0u8; (w * h * 4) as usize];
|
||||
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
|
||||
let v = if d < w as f32 * 0.3 { 200 } else { 60 };
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
px[i] = v;
|
||||
px[i + 1] = v;
|
||||
px[i + 2] = v;
|
||||
px[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/segmentation.md §12"]
|
||||
fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
|
||||
// F1, asserted rather than eyeballed, and asserted at the level where
|
||||
// it matters.
|
||||
//
|
||||
// Two claims, because they are different claims. First: carrying the
|
||||
// distance inward genuinely reduces fragmentation — a plateau with an
|
||||
// exit now drains to it instead of fanning into diagonal chains.
|
||||
// Second, and the one a user would notice: whatever fragments survive
|
||||
// are separated by zero-height saddles, so the hierarchy merges them
|
||||
// at its very first steps and the plateau reads as one region.
|
||||
//
|
||||
// The second claim is what makes the first one's *residue* tolerable.
|
||||
// A perfectly flat regional minimum — the inside of a uniform disc,
|
||||
// with no exit anywhere — cannot be drained by a distance that has
|
||||
// nowhere to descend to, and collapsing it fully would need connected
|
||||
// component labelling rather than a local rule. It is not worth it:
|
||||
// see docs/segmentation.md §12.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (96u32, 96u32);
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
|
||||
let pass = SegmentPass::new(&ctx).expect("segment pass");
|
||||
|
||||
let labels_in = |labels: &[u32], inside: bool| {
|
||||
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let d = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
|
||||
let take = if inside {
|
||||
d < w as f32 * 0.20
|
||||
} else {
|
||||
d > w as f32 * 0.42
|
||||
};
|
||||
if take {
|
||||
seen.insert(labels[(y * w + x) as usize]);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
};
|
||||
|
||||
let field = |iterations: u32| {
|
||||
pass.run(
|
||||
&src,
|
||||
SegmentOptions {
|
||||
plateau_iterations: iterations,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("run")
|
||||
.read_field()
|
||||
.expect("field")
|
||||
};
|
||||
|
||||
let shallow = field(1);
|
||||
let deep = field(64);
|
||||
|
||||
// Before anything else: does the pass change the labelling at all? If
|
||||
// the distance field were never populated — a binding astray, a level
|
||||
// test that never matches — every downstream claim would be excused
|
||||
// by a no-op rather than tested. This is the one assertion that
|
||||
// cannot pass vacuously.
|
||||
let differs = shallow
|
||||
.labels
|
||||
.iter()
|
||||
.zip(deep.labels.iter())
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
assert!(
|
||||
differs > 0,
|
||||
"the plateau distance changed no pixel's basin, so the pass is a \
|
||||
no-op: {} pixels, {} differ",
|
||||
shallow.labels.len(),
|
||||
differs
|
||||
);
|
||||
|
||||
// Claim one: fewer basins, because plateaux with an exit now use it.
|
||||
assert!(
|
||||
deep.region_count < shallow.region_count,
|
||||
"carrying the distance inward should reduce fragmentation: \
|
||||
{} basins against {}",
|
||||
deep.region_count,
|
||||
shallow.region_count
|
||||
);
|
||||
|
||||
// Claim two: what survives costs nothing, because the hierarchy
|
||||
// dissolves it immediately.
|
||||
let tree = MergeTree::build(&deep);
|
||||
let grouped = deep.apply(&tree.cut_to(2));
|
||||
let inside = labels_in(&grouped, true);
|
||||
let outside = labels_in(&grouped, false);
|
||||
assert_eq!(inside.len(), 1, "the disc should read as one region");
|
||||
assert_eq!(outside.len(), 1, "the ground should read as one region");
|
||||
assert_ne!(inside, outside, "and they must not be the same region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_image_segments_identically_twice() {
|
||||
// M5 on one device — the weaker half of the determinism question, but
|
||||
|
||||
Reference in New Issue
Block a user