Skip to content

Commit 351bb39

Browse files
Mutex watershed (#2)
Implement mutex watershed
1 parent 2047e3d commit 351bb39

16 files changed

Lines changed: 1176 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ htmlcov/
2424
# Local virtual environments
2525
.venv/
2626
venv/
27+
28+
# Data
29+
*.h5

CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ find_package(nanobind CONFIG REQUIRED)
1212
nanobind_add_module(_core
1313
NB_STATIC
1414
src/bindings/module.cxx
15+
src/bindings/segmentation.cxx
1516
src/bindings/utils.cxx
17+
src/cpp/segmentation/mutex_watershed.cxx
1618
src/cpp/take_dict.cxx
1719
)
1820

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ out = bic.utils.take_dict(relabeling, labels)
3030
# array([10, 30, 20, 10], dtype=uint64)
3131
```
3232

33+
```python
34+
affinities = np.ones((2, 4, 4), dtype=np.float32)
35+
offsets = [[0, 1], [1, 0]]
36+
37+
segmentation = bic.segmentation.mutex_watershed(
38+
affinities,
39+
offsets,
40+
number_of_attractive_channels=2,
41+
)
42+
```
43+
3344
## Scope
3445

3546
The project is not a compatibility layer for `nifty`, `vigra`, or other large libraries. It keeps I/O and heavy dependencies out of the C++ core; callers should use existing Python packages for file formats and pass NumPy arrays into `bioimage-cpp`.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from pathlib import Path
5+
from statistics import median
6+
from time import perf_counter
7+
from typing import Callable
8+
9+
import numpy as np
10+
11+
12+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
13+
DEFAULT_DATA_PREFIX = PROJECT_ROOT / "examples" / "segmentation" / "isbi-data-"
14+
15+
16+
def load_problem(data_prefix: Path | str = DEFAULT_DATA_PREFIX):
17+
from elf.segmentation.utils import load_mutex_watershed_problem
18+
19+
affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix))
20+
return np.ascontiguousarray(affinities), [tuple(offset) for offset in offsets]
21+
22+
23+
def prepare_2d_problem(
24+
affinities: np.ndarray,
25+
offsets: list[tuple[int, ...]],
26+
z: int,
27+
yx_shape: tuple[int, int],
28+
):
29+
channels_2d = [i for i, offset in enumerate(offsets) if offset[0] == 0]
30+
y, x = yx_shape
31+
cropped = affinities[channels_2d, z, :y, :x]
32+
offsets_2d = [offsets[i][1:] for i in channels_2d]
33+
return np.ascontiguousarray(cropped), offsets_2d, 2
34+
35+
36+
def prepare_3d_problem(
37+
affinities: np.ndarray,
38+
offsets: list[tuple[int, ...]],
39+
zyx_shape: tuple[int, int, int],
40+
):
41+
z, y, x = zyx_shape
42+
cropped = affinities[:, :z, :y, :x]
43+
return np.ascontiguousarray(cropped), offsets, 3
44+
45+
46+
def run_bioimage_cpp(
47+
affinities: np.ndarray,
48+
offsets: list[tuple[int, ...]],
49+
number_of_attractive_channels: int,
50+
) -> np.ndarray:
51+
import bioimage_cpp as bic
52+
53+
affs = affinities.copy()
54+
affs[:number_of_attractive_channels] *= -1
55+
affs[:number_of_attractive_channels] += 1
56+
return bic.segmentation.mutex_watershed(
57+
affs,
58+
offsets,
59+
number_of_attractive_channels=number_of_attractive_channels,
60+
)
61+
62+
63+
def run_affogato_reference(
64+
affinities: np.ndarray,
65+
offsets: list[tuple[int, ...]],
66+
number_of_attractive_channels: int,
67+
) -> np.ndarray:
68+
from elf.segmentation.mutex_watershed import mutex_watershed
69+
70+
spatial_ndim = len(offsets[0])
71+
if number_of_attractive_channels != spatial_ndim:
72+
raise ValueError(
73+
"the elf mutex_watershed wrapper assumes one attractive channel per "
74+
f"spatial axis, got ndim={spatial_ndim}, attractive channels="
75+
f"{number_of_attractive_channels}"
76+
)
77+
return mutex_watershed(affinities.copy(), offsets, strides=[1] * spatial_ndim)
78+
79+
80+
def _load_validation_metrics():
81+
try:
82+
from elf.validation import rand_index, variation_of_information
83+
84+
return "elf.validation", rand_index, variation_of_information
85+
except ImportError:
86+
from elf.evaluation import rand_index, variation_of_information
87+
88+
return "elf.evaluation", rand_index, variation_of_information
89+
90+
91+
def compare_segmentations(
92+
candidate: np.ndarray,
93+
reference: np.ndarray,
94+
*,
95+
max_vi: float = 1.0e-10,
96+
max_are: float = 1.0e-10,
97+
min_rand_index: float = 1.0 - 1.0e-10,
98+
) -> dict[str, float | str | bool]:
99+
source, rand_index, variation_of_information = _load_validation_metrics()
100+
101+
vi_split, vi_merge = variation_of_information(candidate, reference)
102+
adapted_rand_error, ri = rand_index(candidate, reference)
103+
exact_equal = bool(np.array_equal(candidate, reference))
104+
equivalent = (
105+
vi_split <= max_vi
106+
and vi_merge <= max_vi
107+
and adapted_rand_error <= max_are
108+
and ri >= min_rand_index
109+
)
110+
metrics: dict[str, float | str | bool] = {
111+
"validation_source": source,
112+
"vi_split": float(vi_split),
113+
"vi_merge": float(vi_merge),
114+
"adapted_rand_error": float(adapted_rand_error),
115+
"rand_index": float(ri),
116+
"exact_label_equality": exact_equal,
117+
"equivalent": equivalent,
118+
}
119+
if not equivalent:
120+
raise AssertionError(
121+
"mutex watershed results differ: "
122+
f"VI split={vi_split:.6g}, VI merge={vi_merge:.6g}, "
123+
f"adapted rand error={adapted_rand_error:.6g}, "
124+
f"rand index={ri:.12g}, exact labels={exact_equal}"
125+
)
126+
return metrics
127+
128+
129+
def time_function(
130+
run: Callable[[np.ndarray, list[tuple[int, ...]], int], np.ndarray],
131+
affinities: np.ndarray,
132+
offsets: list[tuple[int, ...]],
133+
number_of_attractive_channels: int,
134+
repeats: int,
135+
) -> tuple[list[float], np.ndarray]:
136+
timings = []
137+
result = None
138+
for _ in range(repeats):
139+
start = perf_counter()
140+
result = run(affinities, offsets, number_of_attractive_channels)
141+
timings.append(perf_counter() - start)
142+
assert result is not None
143+
return timings, result
144+
145+
146+
def print_report(
147+
*,
148+
ndim: int,
149+
affinities: np.ndarray,
150+
metrics: dict[str, float | str | bool],
151+
bic_timings: list[float],
152+
ref_timings: list[float],
153+
):
154+
bic_median = median(bic_timings)
155+
ref_median = median(ref_timings)
156+
speedup = ref_median / bic_median if bic_median > 0 else float("inf")
157+
158+
print(f"Mutex watershed {ndim}D equivalence check")
159+
print(f"affinities shape: {affinities.shape}, dtype: {affinities.dtype}")
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']:.12g}"
168+
)
169+
print(f"exact label equality: {metrics['exact_label_equality']}")
170+
print(f"bioimage-cpp median runtime: {bic_median:.6f} s")
171+
print(f"affogato reference median runtime: {ref_median:.6f} s")
172+
print(f"reference / bioimage-cpp runtime ratio: {speedup:.3f}x")
173+
174+
175+
def run_check(
176+
*,
177+
ndim: int,
178+
repeats: int,
179+
data_prefix: Path | str,
180+
z: int,
181+
yx_shape: tuple[int, int],
182+
zyx_shape: tuple[int, int, int],
183+
):
184+
affinities, offsets = load_problem(data_prefix)
185+
if ndim == 2:
186+
affs, used_offsets, attractive_channels = prepare_2d_problem(
187+
affinities, offsets, z=z, yx_shape=yx_shape
188+
)
189+
elif ndim == 3:
190+
affs, used_offsets, attractive_channels = prepare_3d_problem(
191+
affinities, offsets, zyx_shape=zyx_shape
192+
)
193+
else:
194+
raise ValueError(f"ndim must be 2 or 3, got {ndim}")
195+
196+
ref_timings, ref_seg = time_function(
197+
run_affogato_reference, affs, used_offsets, attractive_channels, repeats
198+
)
199+
bic_timings, bic_seg = time_function(
200+
run_bioimage_cpp, affs, used_offsets, attractive_channels, repeats
201+
)
202+
metrics = compare_segmentations(bic_seg, ref_seg)
203+
print_report(
204+
ndim=ndim,
205+
affinities=affs,
206+
metrics=metrics,
207+
bic_timings=bic_timings,
208+
ref_timings=ref_timings,
209+
)
210+
211+
212+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
213+
parser.add_argument(
214+
"--data-prefix",
215+
type=Path,
216+
default=DEFAULT_DATA_PREFIX,
217+
help=(
218+
"Path prefix for the ISBI mutex watershed data. The loader expects "
219+
"'test.h5' and 'train.h5' suffixes."
220+
),
221+
)
222+
parser.add_argument(
223+
"--repeats",
224+
type=int,
225+
default=3,
226+
help="Number of timed runs for each implementation.",
227+
)
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 _mutex_watershed_equivalence import add_common_arguments, run_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp and affogato mutex 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 example 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=(128, 128),
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+
data_prefix=args.data_prefix,
33+
z=args.z,
34+
yx_shape=tuple(args.shape),
35+
zyx_shape=(0, 0, 0),
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 _mutex_watershed_equivalence import add_common_arguments, run_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp and affogato mutex 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, 96, 96),
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+
data_prefix=args.data_prefix,
27+
z=0,
28+
yx_shape=(0, 0),
29+
zyx_shape=tuple(args.shape),
30+
)
31+
32+
33+
if __name__ == "__main__":
34+
main()

0 commit comments

Comments
 (0)