Skip to content

Commit 599140b

Browse files
Implement region adjacency graph and features
1 parent 28eae5e commit 599140b

7 files changed

Lines changed: 810 additions & 38 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
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+
8+
import numpy as np
9+
from skimage.feature import peak_local_max
10+
from skimage.measure import label as label_components
11+
from skimage.segmentation import watershed
12+
13+
14+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
15+
DEFAULT_DATA_PREFIX = PROJECT_ROOT / "examples" / "segmentation" / "isbi-data-"
16+
17+
18+
def load_problem(data_prefix: Path | str = DEFAULT_DATA_PREFIX):
19+
from elf.segmentation.utils import load_mutex_watershed_problem
20+
21+
affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix))
22+
return np.ascontiguousarray(affinities), [tuple(offset) for offset in offsets]
23+
24+
25+
def prepare_2d_problem(
26+
affinities: np.ndarray,
27+
offsets: list[tuple[int, ...]],
28+
z: int,
29+
yx_shape: tuple[int, int],
30+
):
31+
channels_2d = [index for index, offset in enumerate(offsets) if offset[0] == 0]
32+
y, x = yx_shape
33+
affinities_2d = affinities[channels_2d, z, :y, :x]
34+
offsets_2d = [offsets[index][1:] for index in channels_2d]
35+
return _select_direct_affinity_channels(affinities_2d, offsets_2d)
36+
37+
38+
def prepare_3d_problem(
39+
affinities: np.ndarray,
40+
offsets: list[tuple[int, ...]],
41+
zyx_shape: tuple[int, int, int],
42+
):
43+
z, y, x = zyx_shape
44+
cropped = affinities[:, :z, :y, :x]
45+
return _select_direct_affinity_channels(cropped, offsets)
46+
47+
48+
def _select_direct_affinity_channels(
49+
affinities: np.ndarray,
50+
offsets: list[tuple[int, ...]],
51+
):
52+
direct_channels = [
53+
index for index, offset in enumerate(offsets) if sum(abs(v) for v in offset) == 1
54+
]
55+
direct_affinities = np.ascontiguousarray(affinities[direct_channels])
56+
direct_offsets = [tuple(offsets[index]) for index in direct_channels]
57+
return direct_affinities, direct_offsets
58+
59+
60+
def heightmap_from_affinities(affinities: np.ndarray) -> np.ndarray:
61+
return np.ascontiguousarray(1.0 - np.mean(affinities, axis=0), dtype=np.float32)
62+
63+
64+
def make_watershed_labels(
65+
heightmap: np.ndarray,
66+
*,
67+
min_distance: int,
68+
grid_spacing: int,
69+
max_markers: int,
70+
) -> np.ndarray:
71+
coordinates = peak_local_max(
72+
-heightmap,
73+
min_distance=min_distance,
74+
exclude_border=False,
75+
num_peaks=max_markers,
76+
)
77+
marker_mask = np.zeros(heightmap.shape, dtype=bool)
78+
if len(coordinates) > 0:
79+
marker_mask[tuple(coordinates.T)] = True
80+
markers = label_components(marker_mask).astype(np.int32, copy=False)
81+
82+
if int(markers.max()) < 2:
83+
markers = np.zeros(heightmap.shape, dtype=np.int32)
84+
slices = tuple(slice(None, None, grid_spacing) for _ in heightmap.shape)
85+
marker_coordinates = np.argwhere(np.ones(heightmap.shape, dtype=bool)[slices])
86+
marker_coordinates *= grid_spacing
87+
for marker_id, coord in enumerate(marker_coordinates, start=1):
88+
markers[tuple(coord)] = marker_id
89+
90+
return watershed(heightmap, markers=markers).astype(np.uint32, copy=False)
91+
92+
93+
def time_call(function, repeats: int):
94+
timings = []
95+
result = None
96+
for _ in range(repeats):
97+
start = perf_counter()
98+
result = function()
99+
timings.append(perf_counter() - start)
100+
assert result is not None
101+
return timings, result
102+
103+
104+
def sorted_uv_ids(uv_ids: np.ndarray) -> np.ndarray:
105+
uv_ids = np.asarray(uv_ids, dtype=np.uint64)
106+
if len(uv_ids) == 0:
107+
return uv_ids.reshape(0, 2)
108+
order = np.lexsort((uv_ids[:, 1], uv_ids[:, 0]))
109+
return uv_ids[order]
110+
111+
112+
def feature_order(source_uv_ids: np.ndarray, target_uv_ids: np.ndarray) -> np.ndarray:
113+
source_map = {tuple(map(int, uv)): index for index, uv in enumerate(source_uv_ids)}
114+
return np.array([source_map[tuple(map(int, uv))] for uv in target_uv_ids], dtype=np.int64)
115+
116+
117+
def compare_graphs(bic_rag, nifty_rag) -> dict[str, int]:
118+
bic_uv = sorted_uv_ids(bic_rag.uv_ids())
119+
nifty_uv = sorted_uv_ids(nifty_rag.uvIds())
120+
121+
np.testing.assert_array_equal(bic_uv, nifty_uv)
122+
if bic_rag.number_of_nodes != nifty_rag.numberOfNodes:
123+
raise AssertionError(
124+
"number of graph nodes differs: "
125+
f"bioimage-cpp={bic_rag.number_of_nodes}, nifty={nifty_rag.numberOfNodes}"
126+
)
127+
return {
128+
"number_of_nodes": int(bic_rag.number_of_nodes),
129+
"number_of_edges": int(bic_rag.number_of_edges),
130+
}
131+
132+
133+
def compare_boundary_features(
134+
bic_rag,
135+
nifty_rag,
136+
labels: np.ndarray,
137+
boundary_map: np.ndarray,
138+
*,
139+
threads: int,
140+
repeats: int,
141+
):
142+
import bioimage_cpp as bic
143+
import nifty.graph.rag as nrag
144+
145+
block_shape = list(labels.shape)
146+
bic_timings, bic_features = time_call(
147+
lambda: bic.graph.edge_map_features(
148+
bic_rag, labels, boundary_map, number_of_threads=threads
149+
),
150+
repeats,
151+
)
152+
nifty_timings, nifty_features = time_call(
153+
lambda: nrag.accumulateEdgeMeanAndLength(
154+
nifty_rag,
155+
np.ascontiguousarray(boundary_map, dtype=np.float32),
156+
blockShape=block_shape,
157+
numberOfThreads=threads,
158+
),
159+
repeats,
160+
)
161+
162+
order = feature_order(np.asarray(bic_rag.uv_ids()), np.asarray(nifty_rag.uvIds()))
163+
aligned_bic = bic_features[order]
164+
np.testing.assert_allclose(aligned_bic[:, 0], nifty_features[:, 0], rtol=1.0e-5, atol=1.0e-6)
165+
np.testing.assert_allclose(2.0 * aligned_bic[:, 1], nifty_features[:, 1], rtol=1.0e-5, atol=1.0e-6)
166+
return bic_timings, nifty_timings, {
167+
"max_mean_abs_diff": float(np.max(np.abs(aligned_bic[:, 0] - nifty_features[:, 0]))),
168+
"size_convention": "nifty counts both boundary pixels/voxels; bioimage-cpp counts boundary contacts",
169+
}
170+
171+
172+
def compare_affinity_features(
173+
bic_rag,
174+
nifty_rag,
175+
labels: np.ndarray,
176+
affinities: np.ndarray,
177+
offsets: list[tuple[int, ...]],
178+
*,
179+
threads: int,
180+
repeats: int,
181+
):
182+
import bioimage_cpp as bic
183+
import nifty.graph.rag as nrag
184+
185+
offsets_for_nifty = [list(offset) for offset in offsets]
186+
affs_float32 = np.ascontiguousarray(affinities, dtype=np.float32)
187+
min_val = float(np.min(affs_float32))
188+
max_val = float(np.max(affs_float32))
189+
if min_val == max_val:
190+
max_val = min_val + 1.0
191+
192+
bic_timings, bic_features = time_call(
193+
lambda: bic.graph.affinity_features(
194+
bic_rag,
195+
labels,
196+
affs_float32,
197+
offsets,
198+
number_of_threads=threads,
199+
),
200+
repeats,
201+
)
202+
# The installed Nifty wrapper can crash or fail when an explicit
203+
# numberOfThreads is passed here. The default path is still the reference
204+
# implementation and keeps this compatibility script robust.
205+
nifty_timings, nifty_features_full = time_call(
206+
lambda: nrag.accumulateAffinityStandartFeatures(
207+
nifty_rag,
208+
affs_float32,
209+
offsets_for_nifty,
210+
min_val,
211+
max_val,
212+
),
213+
repeats,
214+
)
215+
nifty_features = nifty_features_full[:, [0, -1]]
216+
217+
order = feature_order(np.asarray(bic_rag.uv_ids()), np.asarray(nifty_rag.uvIds()))
218+
aligned_bic = bic_features[order]
219+
np.testing.assert_allclose(aligned_bic, nifty_features, rtol=1.0e-5, atol=1.0e-6)
220+
return bic_timings, nifty_timings, {
221+
"max_abs_diff": float(np.max(np.abs(aligned_bic - nifty_features))),
222+
}
223+
224+
225+
def run_compatibility_check(
226+
*,
227+
ndim: int,
228+
repeats: int,
229+
threads: int,
230+
data_prefix: Path,
231+
z: int,
232+
yx_shape: tuple[int, int],
233+
zyx_shape: tuple[int, int, int],
234+
watershed_min_distance: int,
235+
watershed_grid_spacing: int,
236+
max_markers: int,
237+
):
238+
import bioimage_cpp as bic
239+
import nifty.graph.rag as nrag
240+
241+
affinities, offsets = load_problem(data_prefix)
242+
if ndim == 2:
243+
direct_affinities, direct_offsets = prepare_2d_problem(affinities, offsets, z, yx_shape)
244+
elif ndim == 3:
245+
direct_affinities, direct_offsets = prepare_3d_problem(affinities, offsets, zyx_shape)
246+
else:
247+
raise ValueError(f"ndim must be 2 or 3, got {ndim}")
248+
249+
heightmap = heightmap_from_affinities(direct_affinities)
250+
watershed_timings, labels = time_call(
251+
lambda: make_watershed_labels(
252+
heightmap,
253+
min_distance=watershed_min_distance,
254+
grid_spacing=watershed_grid_spacing,
255+
max_markers=max_markers,
256+
),
257+
repeats,
258+
)
259+
labels = np.ascontiguousarray(labels, dtype=np.uint32)
260+
boundary_map = heightmap
261+
262+
bic_graph_timings, bic_rag = time_call(
263+
lambda: bic.graph.region_adjacency_graph(labels, number_of_threads=threads),
264+
repeats,
265+
)
266+
nifty_graph_timings, nifty_rag = time_call(
267+
lambda: nrag.gridRag(labels, numberOfThreads=threads),
268+
repeats,
269+
)
270+
graph_summary = compare_graphs(bic_rag, nifty_rag)
271+
272+
boundary_bic_timings, boundary_nifty_timings, boundary_summary = compare_boundary_features(
273+
bic_rag,
274+
nifty_rag,
275+
labels,
276+
boundary_map,
277+
threads=threads,
278+
repeats=repeats,
279+
)
280+
affinity_bic_timings, affinity_nifty_timings, affinity_summary = compare_affinity_features(
281+
bic_rag,
282+
nifty_rag,
283+
labels,
284+
direct_affinities,
285+
direct_offsets,
286+
threads=threads,
287+
repeats=repeats,
288+
)
289+
290+
print(f"RAG compatibility check ({ndim}D)")
291+
print(f"labels shape: {labels.shape}, labels: {labels.max()} regions")
292+
print(f"graph nodes / edges: {graph_summary['number_of_nodes']} / {graph_summary['number_of_edges']}")
293+
print(f"watershed median runtime: {median(watershed_timings):.6f} s")
294+
_print_timing("graph creation", bic_graph_timings, nifty_graph_timings)
295+
_print_timing("boundary-map features", boundary_bic_timings, boundary_nifty_timings)
296+
print(f"boundary-map max mean abs diff: {boundary_summary['max_mean_abs_diff']:.6g}")
297+
print(f"boundary-map size convention: {boundary_summary['size_convention']}")
298+
_print_timing("affinity features", affinity_bic_timings, affinity_nifty_timings)
299+
print(f"affinity feature max abs diff: {affinity_summary['max_abs_diff']:.6g}")
300+
print("nifty affinity timing uses the wrapper default thread handling")
301+
302+
303+
def _print_timing(name: str, bic_timings: list[float], nifty_timings: list[float]):
304+
bic_median = median(bic_timings)
305+
nifty_median = median(nifty_timings)
306+
ratio = nifty_median / bic_median if bic_median > 0 else float("inf")
307+
print(f"{name} bioimage-cpp median runtime: {bic_median:.6f} s")
308+
print(f"{name} nifty median runtime: {nifty_median:.6f} s")
309+
print(f"{name} nifty / bioimage-cpp runtime ratio: {ratio:.3f}x")
310+
311+
312+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
313+
parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX)
314+
parser.add_argument("--repeats", type=int, default=3)
315+
parser.add_argument("--threads", type=int, default=1)
316+
parser.add_argument("--watershed-min-distance", type=int, default=5)
317+
parser.add_argument("--watershed-grid-spacing", type=int, default=12)
318+
parser.add_argument("--max-markers", type=int, default=512)

development/graph/check_rag_2d.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
from _rag_compatibility import add_common_arguments, run_compatibility_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp and Nifty RAG functionality in 2D."
11+
)
12+
add_common_arguments(parser)
13+
parser.add_argument("--z", type=int, default=0)
14+
parser.add_argument("--shape", type=int, nargs=2, metavar=("Y", "X"), default=(128, 128))
15+
args = parser.parse_args()
16+
17+
run_compatibility_check(
18+
ndim=2,
19+
repeats=args.repeats,
20+
threads=args.threads,
21+
data_prefix=args.data_prefix,
22+
z=args.z,
23+
yx_shape=tuple(args.shape),
24+
zyx_shape=(0, 0, 0),
25+
watershed_min_distance=args.watershed_min_distance,
26+
watershed_grid_spacing=args.watershed_grid_spacing,
27+
max_markers=args.max_markers,
28+
)
29+
30+
31+
if __name__ == "__main__":
32+
main()

development/graph/check_rag_3d.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
from _rag_compatibility import add_common_arguments, run_compatibility_check
6+
7+
8+
def main() -> None:
9+
parser = argparse.ArgumentParser(
10+
description="Compare bioimage-cpp and Nifty RAG functionality in 3D."
11+
)
12+
add_common_arguments(parser)
13+
parser.add_argument("--shape", type=int, nargs=3, metavar=("Z", "Y", "X"), default=(6, 96, 96))
14+
args = parser.parse_args()
15+
16+
run_compatibility_check(
17+
ndim=3,
18+
repeats=args.repeats,
19+
threads=args.threads,
20+
data_prefix=args.data_prefix,
21+
z=0,
22+
yx_shape=(0, 0),
23+
zyx_shape=tuple(args.shape),
24+
watershed_min_distance=args.watershed_min_distance,
25+
watershed_grid_spacing=args.watershed_grid_spacing,
26+
max_markers=args.max_markers,
27+
)
28+
29+
30+
if __name__ == "__main__":
31+
main()

include/bioimage_cpp/graph/region_adjacency_graph.hxx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ inline std::vector<Edge> merge_edge_sets(
183183
} // namespace detail
184184

185185
template <class T>
186-
RegionAdjacencyGraph grid_region_adjacency_graph(
186+
RegionAdjacencyGraph region_adjacency_graph(
187187
const ConstArrayView<T> &labels,
188188
const std::size_t number_of_threads
189189
) {

0 commit comments

Comments
 (0)