Skip to content

Commit b28a596

Browse files
Implement watershed
1 parent 3174610 commit b28a596

8 files changed

Lines changed: 865 additions & 1 deletion

File tree

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
"""Shared logic for the watershed correctness + runtime comparisons.
2+
3+
Builds a node-heightmap and seed markers from the cached ISBI affinity
4+
volume, then runs ``bioimage_cpp.segmentation.watershed`` against
5+
``skimage.segmentation.watershed`` (connectivity=1). Correctness uses
6+
partition-comparison metrics (VI, rand index) rather than exact label
7+
equality — tie-breaking differs between the two implementations.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
from statistics import median
14+
from time import perf_counter
15+
from typing import Callable
16+
17+
import numpy as np
18+
19+
20+
def load_problem():
21+
from bioimage_cpp._data import load_isbi_affinities
22+
23+
affinities, offsets = load_isbi_affinities()
24+
return np.ascontiguousarray(affinities), [tuple(offset) for offset in offsets]
25+
26+
27+
def _nearest_neighbour_channels(offsets):
28+
"""Return indices of channels whose offset moves one step along a single axis."""
29+
return [
30+
i
31+
for i, offset in enumerate(offsets)
32+
if sum(1 for v in offset if v != 0) == 1
33+
and all(abs(v) <= 1 for v in offset)
34+
]
35+
36+
37+
def make_heightmap(affinities: np.ndarray, offsets: list[tuple[int, ...]]) -> np.ndarray:
38+
"""Build a per-pixel heightmap from the nearest-neighbour affinity channels.
39+
40+
Affinity ~ 1 means "neighbours are in the same object". Inverting and
41+
averaging the nearest-neighbour channels gives a smooth boundary map
42+
suitable as a watershed heightmap.
43+
"""
44+
nn_channels = _nearest_neighbour_channels(offsets)
45+
if not nn_channels:
46+
raise ValueError("no nearest-neighbour affinity channels found")
47+
mean_aff = affinities[nn_channels].mean(axis=0).astype(np.float32, copy=True)
48+
return np.ascontiguousarray(1.0 - mean_aff)
49+
50+
51+
def make_markers(heightmap: np.ndarray, *, smoothing_sigma: float = 1.5) -> np.ndarray:
52+
"""Build seed markers as labelled connected components of the heightmap's local minima.
53+
54+
A small Gaussian smoothing is applied before detecting minima so that
55+
affinity noise doesn't produce thousands of single-pixel seeds.
56+
"""
57+
from scipy import ndimage as ndi
58+
from skimage.morphology import local_minima
59+
from skimage.measure import label
60+
61+
if smoothing_sigma > 0:
62+
smoothed = ndi.gaussian_filter(heightmap, sigma=smoothing_sigma)
63+
else:
64+
smoothed = heightmap
65+
minima = local_minima(smoothed)
66+
markers = label(minima, connectivity=1).astype(np.int32, copy=False)
67+
return np.ascontiguousarray(markers)
68+
69+
70+
def prepare_2d_problem(
71+
affinities: np.ndarray,
72+
offsets: list[tuple[int, ...]],
73+
*,
74+
z: int,
75+
yx_shape: tuple[int, int],
76+
smoothing_sigma: float,
77+
) -> tuple[np.ndarray, np.ndarray]:
78+
channels_2d = [i for i, offset in enumerate(offsets) if offset[0] == 0]
79+
y, x = yx_shape
80+
cropped = affinities[channels_2d, z, :y, :x]
81+
offsets_2d = [offsets[i][1:] for i in channels_2d]
82+
heightmap = make_heightmap(np.ascontiguousarray(cropped), offsets_2d)
83+
markers = make_markers(heightmap, smoothing_sigma=smoothing_sigma)
84+
return heightmap, markers
85+
86+
87+
def prepare_3d_problem(
88+
affinities: np.ndarray,
89+
offsets: list[tuple[int, ...]],
90+
*,
91+
zyx_shape: tuple[int, int, int],
92+
smoothing_sigma: float,
93+
) -> tuple[np.ndarray, np.ndarray]:
94+
z, y, x = zyx_shape
95+
cropped = affinities[:, :z, :y, :x]
96+
heightmap = make_heightmap(np.ascontiguousarray(cropped), offsets)
97+
markers = make_markers(heightmap, smoothing_sigma=smoothing_sigma)
98+
return heightmap, markers
99+
100+
101+
def run_bioimage_cpp(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 run_skimage_reference(heightmap: np.ndarray, markers: np.ndarray) -> np.ndarray:
108+
from skimage.segmentation import watershed as sk_watershed
109+
110+
return sk_watershed(heightmap, markers=markers, connectivity=1)
111+
112+
113+
def _load_validation_metrics():
114+
try:
115+
from elf.validation import rand_index, variation_of_information
116+
117+
return "elf.validation", rand_index, variation_of_information
118+
except ImportError:
119+
from elf.evaluation import rand_index, variation_of_information
120+
121+
return "elf.evaluation", rand_index, variation_of_information
122+
123+
124+
def compare_segmentations(
125+
candidate: np.ndarray,
126+
reference: np.ndarray,
127+
*,
128+
min_rand_index: float = 0.99,
129+
) -> dict[str, float | str | bool]:
130+
"""Partition-style comparison.
131+
132+
Exact label equality is not expected — tie-breaking on equal heights is
133+
implementation-defined for both watersheds, so boundary pixels around
134+
every region can move by 1–2 cells. We use Rand Index as the primary
135+
"do these partitions agree" check (which is the metric that copes
136+
gracefully with boundary jitter); VI and ARE are reported for context
137+
but not gated.
138+
"""
139+
source, rand_index, variation_of_information = _load_validation_metrics()
140+
141+
vi_split, vi_merge = variation_of_information(candidate, reference)
142+
adapted_rand_error, ri = rand_index(candidate, reference)
143+
exact_equal = bool(np.array_equal(candidate, reference))
144+
equivalent = ri >= min_rand_index
145+
metrics: dict[str, float | str | bool] = {
146+
"validation_source": source,
147+
"vi_split": float(vi_split),
148+
"vi_merge": float(vi_merge),
149+
"adapted_rand_error": float(adapted_rand_error),
150+
"rand_index": float(ri),
151+
"exact_label_equality": exact_equal,
152+
"equivalent": equivalent,
153+
}
154+
if not equivalent:
155+
print(
156+
f"WARNING: rand index {ri:.6g} below threshold {min_rand_index:.6g} — "
157+
"watershed partitions disagree substantially"
158+
)
159+
return metrics
160+
161+
162+
def time_functions_interleaved(
163+
first: Callable[[np.ndarray, np.ndarray], np.ndarray],
164+
second: Callable[[np.ndarray, np.ndarray], np.ndarray],
165+
heightmap: np.ndarray,
166+
markers: np.ndarray,
167+
repeats: int,
168+
) -> tuple[list[float], np.ndarray, list[float], np.ndarray]:
169+
def timed_call(run):
170+
start = perf_counter()
171+
result = run(heightmap, markers)
172+
return perf_counter() - start, result
173+
174+
# Warm up imports, JIT/Cython compilation, allocator caches.
175+
first(heightmap, markers)
176+
second(heightmap, markers)
177+
178+
first_timings: list[float] = []
179+
second_timings: list[float] = []
180+
first_result = None
181+
second_result = None
182+
for repeat in range(repeats):
183+
if repeat % 2 == 0:
184+
first_time, first_result = timed_call(first)
185+
second_time, second_result = timed_call(second)
186+
else:
187+
second_time, second_result = timed_call(second)
188+
first_time, first_result = timed_call(first)
189+
first_timings.append(first_time)
190+
second_timings.append(second_time)
191+
192+
assert first_result is not None
193+
assert second_result is not None
194+
return first_timings, first_result, second_timings, second_result
195+
196+
197+
def print_report(
198+
*,
199+
ndim: int,
200+
heightmap: np.ndarray,
201+
markers: np.ndarray,
202+
metrics: dict[str, float | str | bool],
203+
bic_timings: list[float],
204+
ref_timings: list[float],
205+
):
206+
bic_median = median(bic_timings)
207+
ref_median = median(ref_timings)
208+
speedup = ref_median / bic_median if bic_median > 0 else float("inf")
209+
n_markers = int(markers.max())
210+
211+
print(f"Watershed {ndim}D comparison")
212+
print(f"heightmap shape: {heightmap.shape}, dtype: {heightmap.dtype}")
213+
print(f"markers: {n_markers} seeds (dtype={markers.dtype})")
214+
print(f"validation metrics: {metrics['validation_source']}")
215+
print(
216+
"VI split/merge: "
217+
f"{metrics['vi_split']:.6g} / {metrics['vi_merge']:.6g}"
218+
)
219+
print(
220+
"adapted rand error / rand index: "
221+
f"{metrics['adapted_rand_error']:.6g} / {metrics['rand_index']:.6g}"
222+
)
223+
print(f"exact label equality: {metrics['exact_label_equality']}")
224+
print(f"within thresholds: {metrics['equivalent']}")
225+
print(f"bioimage-cpp median runtime: {bic_median:.6f} s")
226+
print(f"skimage reference median runtime: {ref_median:.6f} s")
227+
print(f"reference / bioimage-cpp runtime ratio: {speedup:.3f}x")
228+
229+
230+
def run_check(
231+
*,
232+
ndim: int,
233+
repeats: int,
234+
z: int,
235+
yx_shape: tuple[int, int],
236+
zyx_shape: tuple[int, int, int],
237+
smoothing_sigma: float,
238+
):
239+
affinities, offsets = load_problem()
240+
if ndim == 2:
241+
heightmap, markers = prepare_2d_problem(
242+
affinities,
243+
offsets,
244+
z=z,
245+
yx_shape=yx_shape,
246+
smoothing_sigma=smoothing_sigma,
247+
)
248+
elif ndim == 3:
249+
heightmap, markers = prepare_3d_problem(
250+
affinities,
251+
offsets,
252+
zyx_shape=zyx_shape,
253+
smoothing_sigma=smoothing_sigma,
254+
)
255+
else:
256+
raise ValueError(f"ndim must be 2 or 3, got {ndim}")
257+
258+
ref_timings, ref_seg, bic_timings, bic_seg = time_functions_interleaved(
259+
run_skimage_reference,
260+
run_bioimage_cpp,
261+
heightmap,
262+
markers,
263+
repeats,
264+
)
265+
metrics = compare_segmentations(bic_seg, ref_seg)
266+
print_report(
267+
ndim=ndim,
268+
heightmap=heightmap,
269+
markers=markers,
270+
metrics=metrics,
271+
bic_timings=bic_timings,
272+
ref_timings=ref_timings,
273+
)
274+
275+
276+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
277+
parser.add_argument(
278+
"--repeats",
279+
type=int,
280+
default=3,
281+
help="Number of timed runs for each implementation.",
282+
)
283+
parser.add_argument(
284+
"--smoothing-sigma",
285+
type=float,
286+
default=1.5,
287+
help="Gaussian sigma applied to the heightmap before finding local minima.",
288+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
from _watershed_equivalence import add_common_arguments, run_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp and skimage watershed in 2D."
11+
)
12+
add_common_arguments(parser)
13+
parser.add_argument(
14+
"--z",
15+
type=int,
16+
default=0,
17+
help="Z slice from the ISBI affinities used for the 2D check.",
18+
)
19+
parser.add_argument(
20+
"--shape",
21+
type=int,
22+
nargs=2,
23+
metavar=("Y", "X"),
24+
default=(512, 512),
25+
help="Spatial crop shape for the 2D check.",
26+
)
27+
args = parser.parse_args()
28+
29+
run_check(
30+
ndim=2,
31+
repeats=args.repeats,
32+
z=args.z,
33+
yx_shape=tuple(args.shape),
34+
zyx_shape=(0, 0, 0),
35+
smoothing_sigma=args.smoothing_sigma,
36+
)
37+
38+
39+
if __name__ == "__main__":
40+
main()
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_equivalence import add_common_arguments, run_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp and skimage watershed in 3D."
11+
)
12+
add_common_arguments(parser)
13+
parser.add_argument(
14+
"--shape",
15+
type=int,
16+
nargs=3,
17+
metavar=("Z", "Y", "X"),
18+
default=(6, 512, 512),
19+
help="Spatial crop shape for the 3D check.",
20+
)
21+
args = parser.parse_args()
22+
23+
run_check(
24+
ndim=3,
25+
repeats=args.repeats,
26+
z=0,
27+
yx_shape=(0, 0),
28+
zyx_shape=tuple(args.shape),
29+
smoothing_sigma=args.smoothing_sigma,
30+
)
31+
32+
33+
if __name__ == "__main__":
34+
main()

0 commit comments

Comments
 (0)