Skip to content

Commit e319956

Browse files
Add node projection functionality and integration test (#9)
1 parent 2e4df82 commit e319956

7 files changed

Lines changed: 513 additions & 0 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,39 @@ Notes:
221221
- `number_of_threads=0` uses the library default; pass a positive integer for a
222222
fixed thread count.
223223

224+
## Projecting RAG Node Labels to Pixels
225+
226+
Nifty projects scalar node data back to pixels with
227+
`projectScalarNodeDataToPixels`.
228+
229+
Nifty:
230+
231+
```python
232+
import nifty.graph.rag as nrag
233+
234+
rag = nrag.gridRag(labels)
235+
pixel_labels = nrag.projectScalarNodeDataToPixels(rag, node_labels)
236+
```
237+
238+
bioimage-cpp:
239+
240+
```python
241+
rag = bic.graph.region_adjacency_graph(labels)
242+
pixel_labels = bic.graph.project_node_labels_to_pixels(
243+
rag,
244+
labels,
245+
node_labels,
246+
)
247+
```
248+
249+
Notes:
250+
251+
- `labels` must be the over-segmentation used to construct `rag`.
252+
- `node_labels` must be a 1D array with length `rag.number_of_nodes`.
253+
- The output has the same shape as `labels` and dtype `uint64`.
254+
- `number_of_threads=0` uses the library default; pass a positive integer for a
255+
fixed thread count.
256+
224257
## Multicut
225258

226259
Nifty exposes multicut through an objective + factory-style solver hierarchy.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from pathlib import Path
5+
6+
import napari
7+
import numpy as np
8+
from elf.io import open_file
9+
from elf.segmentation.utils import load_mutex_watershed_problem
10+
from skimage.feature import peak_local_max
11+
from skimage.measure import label as label_components
12+
from skimage.segmentation import find_boundaries, watershed
13+
14+
import bioimage_cpp as bic
15+
16+
17+
THIS_DIR = Path(__file__).resolve().parent
18+
DEFAULT_DATA_PREFIX = THIS_DIR / "isbi-data-"
19+
20+
21+
def load_problem(data_prefix: Path, ndim: int, z_slice: int):
22+
affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix))
23+
offsets = [tuple(int(v) for v in offset) for offset in offsets]
24+
if ndim == 2:
25+
channels_2d = [index for index, offset in enumerate(offsets) if offset[0] == 0]
26+
affinities = affinities[channels_2d, z_slice]
27+
offsets = [offsets[index][1:] for index in channels_2d]
28+
elif ndim != 3:
29+
raise ValueError(f"ndim must be 2 or 3, got {ndim}")
30+
31+
direct_channels = [
32+
index for index, offset in enumerate(offsets) if sum(abs(v) for v in offset) == 1
33+
]
34+
direct_affinities = np.ascontiguousarray(affinities[direct_channels], dtype=np.float32)
35+
direct_offsets = [offsets[index] for index in direct_channels]
36+
return direct_affinities, direct_offsets
37+
38+
39+
def load_raw(data_prefix: Path, ndim: int, z_slice: int):
40+
data_path = data_prefix.with_name(data_prefix.name + "test.h5")
41+
with open_file(data_path, "r") as f:
42+
raw = f["raw"][z_slice] if ndim == 2 else f["raw"][:]
43+
return raw
44+
45+
46+
def make_heightmap(affinities: np.ndarray) -> np.ndarray:
47+
return np.ascontiguousarray(np.mean(affinities, axis=0), dtype=np.float32)
48+
49+
50+
def make_watershed_oversegmentation(
51+
heightmap: np.ndarray,
52+
*,
53+
min_distance: int,
54+
grid_spacing: int,
55+
max_markers: int,
56+
) -> np.ndarray:
57+
coordinates = peak_local_max(
58+
-heightmap,
59+
min_distance=min_distance,
60+
exclude_border=False,
61+
num_peaks=max_markers,
62+
)
63+
marker_mask = np.zeros(heightmap.shape, dtype=bool)
64+
if len(coordinates) > 0:
65+
marker_mask[tuple(coordinates.T)] = True
66+
markers = label_components(marker_mask).astype(np.int32, copy=False)
67+
68+
if int(markers.max()) < 2:
69+
markers = np.zeros(heightmap.shape, dtype=np.int32)
70+
slices = tuple(slice(None, None, grid_spacing) for _ in heightmap.shape)
71+
marker_coordinates = np.argwhere(np.ones(heightmap.shape, dtype=bool)[slices])
72+
marker_coordinates *= grid_spacing
73+
for marker_id, coord in enumerate(marker_coordinates, start=1):
74+
markers[tuple(coord)] = marker_id
75+
76+
return watershed(heightmap, markers=markers).astype(np.uint32, copy=False)
77+
78+
79+
def multicut_from_affinities(
80+
affinities: np.ndarray,
81+
offsets: list[tuple[int, ...]],
82+
*,
83+
threshold: float,
84+
number_of_threads: int,
85+
watershed_min_distance: int,
86+
watershed_grid_spacing: int,
87+
max_markers: int,
88+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
89+
heightmap = make_heightmap(affinities)
90+
oversegmentation = make_watershed_oversegmentation(
91+
heightmap,
92+
min_distance=watershed_min_distance,
93+
grid_spacing=watershed_grid_spacing,
94+
max_markers=max_markers,
95+
)
96+
97+
rag = bic.graph.region_adjacency_graph(
98+
oversegmentation, number_of_threads=number_of_threads
99+
)
100+
features = bic.graph.affinity_features(
101+
rag,
102+
oversegmentation,
103+
affinities,
104+
offsets,
105+
number_of_threads=number_of_threads,
106+
)
107+
edge_costs = threshold - features[:, 0]
108+
109+
objective = bic.graph.MulticutObjective(rag, edge_costs)
110+
node_labels = bic.graph.ChainedMulticutSolvers(
111+
[
112+
bic.graph.GreedyAdditiveMulticut(),
113+
bic.graph.KernighanLinMulticut(number_of_outer_iterations=10),
114+
]
115+
).optimize(objective)
116+
segmentation = bic.graph.project_node_labels_to_pixels(
117+
rag,
118+
oversegmentation,
119+
node_labels,
120+
number_of_threads=number_of_threads,
121+
)
122+
return heightmap, oversegmentation, segmentation
123+
124+
125+
def main():
126+
parser = argparse.ArgumentParser(
127+
description="Run watershed oversegmentation + RAG multicut on the ISBI affinity example."
128+
)
129+
parser.add_argument("--ndim", type=int, choices=(2, 3), default=2)
130+
parser.add_argument("--z-slice", type=int, default=0)
131+
parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX)
132+
parser.add_argument("--threshold", type=float, default=0.1)
133+
parser.add_argument("--threads", type=int, default=0)
134+
parser.add_argument("--watershed-min-distance", type=int, default=5)
135+
parser.add_argument("--watershed-grid-spacing", type=int, default=12)
136+
parser.add_argument("--max-markers", type=int, default=2048)
137+
args = parser.parse_args()
138+
139+
affinities, offsets = load_problem(args.data_prefix, args.ndim, args.z_slice)
140+
raw = load_raw(args.data_prefix, args.ndim, args.z_slice)
141+
heightmap, oversegmentation, segmentation = multicut_from_affinities(
142+
affinities,
143+
offsets,
144+
threshold=args.threshold,
145+
number_of_threads=args.threads,
146+
watershed_min_distance=args.watershed_min_distance,
147+
watershed_grid_spacing=args.watershed_grid_spacing,
148+
max_markers=args.max_markers,
149+
)
150+
151+
viewer = napari.Viewer()
152+
viewer.add_image(raw, name="raw")
153+
viewer.add_image(affinities, name="direct affinities")
154+
viewer.add_image(heightmap, name="watershed heightmap")
155+
viewer.add_labels(oversegmentation, name="watershed oversegmentation")
156+
viewer.add_labels(segmentation, name="multicut segmentation")
157+
viewer.add_labels(find_boundaries(segmentation), name="multicut boundaries")
158+
napari.run()
159+
160+
161+
if __name__ == "__main__":
162+
main()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
#include "bioimage_cpp/detail/threading.hxx"
5+
#include "bioimage_cpp/graph/region_adjacency_graph.hxx"
6+
7+
#include <cstddef>
8+
#include <cstdint>
9+
#include <numeric>
10+
#include <stdexcept>
11+
#include <string>
12+
#include <vector>
13+
14+
namespace bioimage_cpp::graph {
15+
16+
namespace detail_projection {
17+
18+
inline std::size_t number_of_pixels(const std::vector<std::ptrdiff_t> &shape) {
19+
return static_cast<std::size_t>(std::accumulate(
20+
shape.begin(),
21+
shape.end(),
22+
std::ptrdiff_t{1},
23+
[](const std::ptrdiff_t a, const std::ptrdiff_t b) { return a * b; }
24+
));
25+
}
26+
27+
inline void require_rag_shape_matches_labels(
28+
const RegionAdjacencyGraph &rag,
29+
const std::vector<std::ptrdiff_t> &labels_shape
30+
) {
31+
const auto &rag_shape = rag.shape();
32+
if (rag_shape.size() != labels_shape.size()) {
33+
throw std::invalid_argument("rag shape must match labels shape");
34+
}
35+
for (std::size_t axis = 0; axis < rag_shape.size(); ++axis) {
36+
if (rag_shape[axis] != static_cast<std::uint64_t>(labels_shape[axis])) {
37+
throw std::invalid_argument("rag shape must match labels shape");
38+
}
39+
}
40+
}
41+
42+
} // namespace detail_projection
43+
44+
template <class LabelT>
45+
void project_node_labels_to_pixels(
46+
const RegionAdjacencyGraph &rag,
47+
const ConstArrayView<LabelT> &labels,
48+
const ConstArrayView<std::uint64_t> &node_labels,
49+
const std::size_t number_of_threads,
50+
const ArrayView<std::uint64_t> &out
51+
) {
52+
if (labels.ndim() != 2 && labels.ndim() != 3) {
53+
throw std::invalid_argument(
54+
"labels must be a 2D or 3D array, got ndim=" +
55+
std::to_string(labels.ndim())
56+
);
57+
}
58+
detail_projection::require_rag_shape_matches_labels(rag, labels.shape);
59+
if (node_labels.ndim() != 1) {
60+
throw std::invalid_argument("node_labels must be a 1D uint64 array");
61+
}
62+
if (node_labels.shape[0] != static_cast<std::ptrdiff_t>(rag.number_of_nodes())) {
63+
throw std::invalid_argument("node_labels length must match rag number_of_nodes");
64+
}
65+
if (out.shape != labels.shape) {
66+
throw std::invalid_argument("out shape must match labels shape");
67+
}
68+
if (detail::max_label(labels) >= static_cast<std::uint64_t>(node_labels.shape[0])) {
69+
throw std::invalid_argument("labels contain a node id outside node_labels");
70+
}
71+
72+
const auto n_pixels = detail_projection::number_of_pixels(labels.shape);
73+
const auto n_threads = detail::normalize_thread_count(number_of_threads, n_pixels);
74+
bioimage_cpp::detail::parallel_for_chunks(
75+
n_threads,
76+
n_pixels,
77+
[&](const std::size_t, const std::size_t begin, const std::size_t end) {
78+
for (std::size_t index = begin; index < end; ++index) {
79+
const auto node = static_cast<std::uint64_t>(labels.data[index]);
80+
out.data[index] = node_labels.data[static_cast<std::size_t>(node)];
81+
}
82+
}
83+
);
84+
}
85+
86+
} // namespace bioimage_cpp::graph

src/bindings/graph.cxx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include "bioimage_cpp/graph/connected_components.hxx"
55
#include "bioimage_cpp/graph/feature_accumulation.hxx"
66
#include "bioimage_cpp/graph/multicut.hxx"
7+
#include "bioimage_cpp/graph/node_label_projection.hxx"
78
#include "bioimage_cpp/graph/region_adjacency_graph.hxx"
89
#include "bioimage_cpp/graph/undirected_graph.hxx"
910

@@ -483,6 +484,50 @@ DoubleArray accumulate_affinity_features_t(
483484
return result;
484485
}
485486

487+
template <class LabelT>
488+
UInt64Array project_node_labels_to_pixels_t(
489+
const Rag &rag,
490+
LabelArray<LabelT> labels,
491+
ConstUInt64Array node_labels,
492+
const std::size_t number_of_threads
493+
) {
494+
if (node_labels.ndim() != 1) {
495+
throw std::invalid_argument("node_labels must be a 1D uint64 array");
496+
}
497+
498+
std::vector<std::size_t> output_shape(labels.ndim());
499+
for (std::size_t axis = 0; axis < labels.ndim(); ++axis) {
500+
output_shape[axis] = labels.shape(axis);
501+
}
502+
auto result = make_uint64_array(output_shape);
503+
504+
ConstArrayView<LabelT> labels_view{
505+
labels.data(),
506+
ndarray_shape(labels),
507+
{},
508+
};
509+
ConstArrayView<std::uint64_t> node_labels_view{
510+
node_labels.data(),
511+
{static_cast<std::ptrdiff_t>(node_labels.shape(0))},
512+
{},
513+
};
514+
ArrayView<std::uint64_t> out_view{
515+
result.data(),
516+
ndarray_shape(labels),
517+
{},
518+
};
519+
520+
nb::gil_scoped_release release;
521+
graph::project_node_labels_to_pixels<LabelT>(
522+
rag,
523+
labels_view,
524+
node_labels_view,
525+
number_of_threads,
526+
out_view
527+
);
528+
return result;
529+
}
530+
486531
} // namespace
487532

488533
void bind_graph(nb::module_ &m) {
@@ -701,6 +746,38 @@ void bind_graph(nb::module_ &m) {
701746
nb::arg("compute_complex_features"),
702747
nb::arg("number_of_threads")
703748
);
749+
m.def(
750+
"_project_node_labels_to_pixels_uint32",
751+
&project_node_labels_to_pixels_t<std::uint32_t>,
752+
nb::arg("rag"),
753+
nb::arg("labels"),
754+
nb::arg("node_labels"),
755+
nb::arg("number_of_threads")
756+
);
757+
m.def(
758+
"_project_node_labels_to_pixels_uint64",
759+
&project_node_labels_to_pixels_t<std::uint64_t>,
760+
nb::arg("rag"),
761+
nb::arg("labels"),
762+
nb::arg("node_labels"),
763+
nb::arg("number_of_threads")
764+
);
765+
m.def(
766+
"_project_node_labels_to_pixels_int32",
767+
&project_node_labels_to_pixels_t<std::int32_t>,
768+
nb::arg("rag"),
769+
nb::arg("labels"),
770+
nb::arg("node_labels"),
771+
nb::arg("number_of_threads")
772+
);
773+
m.def(
774+
"_project_node_labels_to_pixels_int64",
775+
&project_node_labels_to_pixels_t<std::int64_t>,
776+
nb::arg("rag"),
777+
nb::arg("labels"),
778+
nb::arg("node_labels"),
779+
nb::arg("number_of_threads")
780+
);
704781
}
705782

706783
} // namespace bioimage_cpp::bindings

0 commit comments

Comments
 (0)