Skip to content

Commit e31c9c1

Browse files
Add watershed from affinities
1 parent bf5dad3 commit e31c9c1

10 files changed

Lines changed: 1637 additions & 2 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,44 @@ Notes:
841841
- Signed integer inputs must not contain negative labels.
842842
- Inputs are converted to contiguous `uint64` arrays before entering C++.
843843

844+
## Marker-controlled Watershed
845+
846+
`bioimage-cpp` ships two marker-controlled watershed entry points: one that
847+
consumes a node-valued heightmap and one that consumes an edge-valued
848+
nearest-neighbour affinity map. Both share the same flooding scaffolding
849+
internally — a 65536-bucket Meyer-style monotone queue — and have the same
850+
public-facing semantics: markers are mandatory, connectivity is 1
851+
(4-neighbour in 2D, 6-neighbour in 3D), an optional foreground mask is
852+
supported, and tie-breaking on equal heights / equal affinities is
853+
unspecified.
854+
855+
```python
856+
# Heightmap-driven (analogous to skimage.segmentation.watershed)
857+
labels = bic.segmentation.watershed(image, markers, mask=optional_mask)
858+
859+
# Affinity-driven: edge priorities, no heightmap derivation needed
860+
labels = bic.segmentation.watershed_from_affinities(
861+
affinities, # (C, *spatial), C == spatial_ndim
862+
offsets=[(-1, 0), (0, -1)], # one NN offset per channel, same sign
863+
markers=markers,
864+
mask=optional_mask,
865+
)
866+
```
867+
868+
Notes for `watershed_from_affinities`:
869+
870+
- Each channel must encode a single nearest-neighbour edge (exactly one
871+
±1 entry, the rest zero). All offsets must have the same sign — mixing
872+
positive and negative directions is rejected. The function dispatches
873+
to a positive-direction or negative-direction specialisation at the C++
874+
layer so the inner loop has no per-channel sign branches.
875+
- Offsets may be passed in any axis order; the channel ↔ axis mapping is
876+
rebuilt internally.
877+
- Higher affinity is processed first (high affinity = strong bond).
878+
- Compared to `affogato.segmentation.compute_mws_segmentation`, there are
879+
no mutex (repulsive) channels and no long-range offsets — use
880+
`bic.segmentation.mutex_watershed` for that.
881+
844882
## Mutex Watershed
845883

846884
`bioimage-cpp` ships two mutex-watershed entry points, mirroring the two
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""Shared logic for the affinity-watershed comparison scripts.
2+
3+
Runs ``bioimage_cpp.segmentation.watershed_from_affinities`` directly on the
4+
nearest-neighbour ISBI affinity channels and compares against
5+
``bioimage_cpp.segmentation.watershed`` running on the heightmap derived
6+
from those same channels (``1 − mean(NN affinities)``). Both algorithms see
7+
identical markers (local minima of the smoothed heightmap). Partition
8+
agreement is reported but expected to be partial — the two algorithms have
9+
deliberately different priority semantics (edge-keyed vs node-keyed) and the
10+
purpose of this script is to surface that difference, not to gate it.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
from statistics import median
17+
from time import perf_counter
18+
from typing import Callable
19+
20+
import numpy as np
21+
22+
# Reuse the existing equivalence helpers — heightmap + marker generation are
23+
# identical to the node-watershed comparison.
24+
from _watershed_equivalence import (
25+
_load_validation_metrics,
26+
load_problem,
27+
make_heightmap,
28+
make_markers,
29+
)
30+
31+
32+
def _nearest_neighbour_channels(offsets):
33+
return [
34+
i
35+
for i, offset in enumerate(offsets)
36+
if sum(1 for v in offset if v != 0) == 1
37+
and all(abs(v) <= 1 for v in offset)
38+
]
39+
40+
41+
def prepare_2d_problem(
42+
affinities: np.ndarray,
43+
offsets: list[tuple[int, ...]],
44+
*,
45+
z: int,
46+
yx_shape: tuple[int, int],
47+
smoothing_sigma: float,
48+
) -> tuple[np.ndarray, list[tuple[int, ...]], np.ndarray, np.ndarray]:
49+
"""Return (nn_affinities_2d, nn_offsets_2d, heightmap, markers)."""
50+
channels_2d = [i for i, offset in enumerate(offsets) if offset[0] == 0]
51+
offsets_2d = [offsets[i][1:] for i in channels_2d]
52+
# Keep only nearest-neighbour channels for the affinity watershed input.
53+
nn_idx_within_2d = _nearest_neighbour_channels(offsets_2d)
54+
nn_channels_in_full = [channels_2d[i] for i in nn_idx_within_2d]
55+
nn_offsets_2d = [offsets_2d[i] for i in nn_idx_within_2d]
56+
y, x = yx_shape
57+
58+
nn_affinities = np.ascontiguousarray(
59+
affinities[nn_channels_in_full, z, :y, :x]
60+
)
61+
62+
# Reuse the existing heightmap/marker pipeline so the markers are
63+
# identical to the node-watershed scripts'.
64+
full_2d_affs = np.ascontiguousarray(affinities[channels_2d, z, :y, :x])
65+
heightmap = make_heightmap(full_2d_affs, offsets_2d)
66+
markers = make_markers(heightmap, smoothing_sigma=smoothing_sigma)
67+
return nn_affinities, nn_offsets_2d, heightmap, markers
68+
69+
70+
def prepare_3d_problem(
71+
affinities: np.ndarray,
72+
offsets: list[tuple[int, ...]],
73+
*,
74+
zyx_shape: tuple[int, int, int],
75+
smoothing_sigma: float,
76+
) -> tuple[np.ndarray, list[tuple[int, ...]], np.ndarray, np.ndarray]:
77+
z, y, x = zyx_shape
78+
nn_channels = _nearest_neighbour_channels(offsets)
79+
nn_affinities = np.ascontiguousarray(
80+
affinities[nn_channels, :z, :y, :x]
81+
)
82+
nn_offsets = [offsets[i] for i in nn_channels]
83+
full_3d_affs = np.ascontiguousarray(affinities[:, :z, :y, :x])
84+
heightmap = make_heightmap(full_3d_affs, offsets)
85+
markers = make_markers(heightmap, smoothing_sigma=smoothing_sigma)
86+
return nn_affinities, nn_offsets, heightmap, markers
87+
88+
89+
def run_from_affinities(
90+
nn_affinities: np.ndarray,
91+
nn_offsets: list[tuple[int, ...]],
92+
markers: np.ndarray,
93+
) -> np.ndarray:
94+
import bioimage_cpp as bic
95+
96+
return bic.segmentation.watershed_from_affinities(
97+
nn_affinities, nn_offsets, markers,
98+
)
99+
100+
101+
def run_node_watershed(heightmap: np.ndarray, markers: np.ndarray) -> np.ndarray:
102+
import bioimage_cpp as bic
103+
104+
return bic.segmentation.watershed(heightmap, markers)
105+
106+
107+
def compare_segmentations(
108+
candidate: np.ndarray,
109+
reference: np.ndarray,
110+
) -> dict[str, float | str | bool]:
111+
source, rand_index, variation_of_information = _load_validation_metrics()
112+
113+
vi_split, vi_merge = variation_of_information(candidate, reference)
114+
adapted_rand_error, ri = rand_index(candidate, reference)
115+
exact_equal = bool(np.array_equal(candidate, reference))
116+
return {
117+
"validation_source": source,
118+
"vi_split": float(vi_split),
119+
"vi_merge": float(vi_merge),
120+
"adapted_rand_error": float(adapted_rand_error),
121+
"rand_index": float(ri),
122+
"exact_label_equality": exact_equal,
123+
}
124+
125+
126+
def time_runs(
127+
fn: Callable[[], np.ndarray],
128+
repeats: int,
129+
) -> tuple[list[float], np.ndarray]:
130+
fn() # warmup
131+
timings: list[float] = []
132+
last_result: np.ndarray | None = None
133+
for _ in range(repeats):
134+
start = perf_counter()
135+
last_result = fn()
136+
timings.append(perf_counter() - start)
137+
assert last_result is not None
138+
return timings, last_result
139+
140+
141+
def print_report(
142+
*,
143+
ndim: int,
144+
nn_affinities: np.ndarray,
145+
markers: np.ndarray,
146+
metrics: dict[str, float | str | bool],
147+
aff_timings: list[float],
148+
node_timings: list[float],
149+
):
150+
aff_median = median(aff_timings)
151+
node_median = median(node_timings)
152+
ratio = node_median / aff_median if aff_median > 0 else float("inf")
153+
n_markers = int(markers.max())
154+
155+
print(f"Watershed-from-affinities {ndim}D comparison")
156+
print(
157+
f"NN-affinity shape: {nn_affinities.shape}, dtype: {nn_affinities.dtype}"
158+
)
159+
print(f"markers: {n_markers} seeds")
160+
print(f"validation metrics: {metrics['validation_source']}")
161+
print(
162+
"VI split/merge: "
163+
f"{metrics['vi_split']:.6g} / {metrics['vi_merge']:.6g}"
164+
)
165+
print(
166+
"adapted rand error / rand index: "
167+
f"{metrics['adapted_rand_error']:.6g} / {metrics['rand_index']:.6g}"
168+
)
169+
print(f"watershed_from_affinities median runtime: {aff_median:.6f} s")
170+
print(f"watershed (node-based) median runtime: {node_median:.6f} s")
171+
print(f"node-based / affinity-based runtime ratio: {ratio:.3f}x")
172+
173+
174+
def run_check(
175+
*,
176+
ndim: int,
177+
repeats: int,
178+
z: int,
179+
yx_shape: tuple[int, int],
180+
zyx_shape: tuple[int, int, int],
181+
smoothing_sigma: float,
182+
):
183+
affinities, offsets = load_problem()
184+
if ndim == 2:
185+
nn_affinities, nn_offsets, heightmap, markers = prepare_2d_problem(
186+
affinities, offsets, z=z, yx_shape=yx_shape,
187+
smoothing_sigma=smoothing_sigma,
188+
)
189+
elif ndim == 3:
190+
nn_affinities, nn_offsets, heightmap, markers = prepare_3d_problem(
191+
affinities, offsets, zyx_shape=zyx_shape,
192+
smoothing_sigma=smoothing_sigma,
193+
)
194+
else:
195+
raise ValueError(f"ndim must be 2 or 3, got {ndim}")
196+
197+
aff_timings, aff_labels = time_runs(
198+
lambda: run_from_affinities(nn_affinities, nn_offsets, markers),
199+
repeats,
200+
)
201+
node_timings, node_labels = time_runs(
202+
lambda: run_node_watershed(heightmap, markers),
203+
repeats,
204+
)
205+
206+
metrics = compare_segmentations(aff_labels, node_labels)
207+
print_report(
208+
ndim=ndim,
209+
nn_affinities=nn_affinities,
210+
markers=markers,
211+
metrics=metrics,
212+
aff_timings=aff_timings,
213+
node_timings=node_timings,
214+
)
215+
216+
217+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
218+
parser.add_argument(
219+
"--repeats", type=int, default=3,
220+
help="Number of timed runs for each implementation.",
221+
)
222+
parser.add_argument(
223+
"--smoothing-sigma", type=float, default=1.5,
224+
help="Gaussian sigma applied to the heightmap before finding local minima.",
225+
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
from _watershed_from_affinities_equivalence import add_common_arguments, run_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp affinity-based watershed against the node-based watershed in 2D."
11+
)
12+
add_common_arguments(parser)
13+
parser.add_argument(
14+
"--z", type=int, default=0,
15+
help="Z slice from the ISBI affinities used for the 2D check.",
16+
)
17+
parser.add_argument(
18+
"--shape", type=int, nargs=2, metavar=("Y", "X"), default=(512, 512),
19+
help="Spatial crop shape for the 2D check.",
20+
)
21+
args = parser.parse_args()
22+
23+
run_check(
24+
ndim=2,
25+
repeats=args.repeats,
26+
z=args.z,
27+
yx_shape=tuple(args.shape),
28+
zyx_shape=(0, 0, 0),
29+
smoothing_sigma=args.smoothing_sigma,
30+
)
31+
32+
33+
if __name__ == "__main__":
34+
main()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
from _watershed_from_affinities_equivalence import add_common_arguments, run_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp affinity-based watershed against the node-based watershed in 3D."
11+
)
12+
add_common_arguments(parser)
13+
parser.add_argument(
14+
"--shape", type=int, nargs=3, metavar=("Z", "Y", "X"), default=(6, 512, 512),
15+
help="Spatial crop shape for the 3D check.",
16+
)
17+
args = parser.parse_args()
18+
19+
run_check(
20+
ndim=3,
21+
repeats=args.repeats,
22+
z=0,
23+
yx_shape=(0, 0),
24+
zyx_shape=tuple(args.shape),
25+
smoothing_sigma=args.smoothing_sigma,
26+
)
27+
28+
29+
if __name__ == "__main__":
30+
main()

0 commit comments

Comments
 (0)