Skip to content

Commit 6088321

Browse files
MWS implementation WIP
1 parent 930148c commit 6088321

11 files changed

Lines changed: 560 additions & 1 deletion

File tree

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: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
5+
#include <algorithm>
6+
#include <cstddef>
7+
#include <cstdint>
8+
#include <numeric>
9+
#include <stdexcept>
10+
#include <string>
11+
#include <unordered_map>
12+
#include <unordered_set>
13+
#include <vector>
14+
15+
namespace bioimage_cpp {
16+
17+
class DisjointSets {
18+
public:
19+
explicit DisjointSets(const std::size_t size) : parents_(size), ranks_(size, 0) {
20+
std::iota(parents_.begin(), parents_.end(), std::uint64_t{0});
21+
}
22+
23+
std::uint64_t find(const std::uint64_t node) {
24+
if (parents_[node] != node) {
25+
parents_[node] = find(parents_[node]);
26+
}
27+
return parents_[node];
28+
}
29+
30+
std::uint64_t unite_roots(std::uint64_t first, std::uint64_t second) {
31+
if (ranks_[first] < ranks_[second]) {
32+
std::swap(first, second);
33+
}
34+
parents_[second] = first;
35+
if (ranks_[first] == ranks_[second]) {
36+
++ranks_[first];
37+
}
38+
return first;
39+
}
40+
41+
private:
42+
std::vector<std::uint64_t> parents_;
43+
std::vector<std::uint64_t> ranks_;
44+
};
45+
46+
using MutexStorage = std::vector<std::unordered_set<std::uint64_t>>;
47+
48+
inline bool check_mutex(
49+
const std::uint64_t first,
50+
const std::uint64_t second,
51+
const MutexStorage &mutexes
52+
) {
53+
const auto &first_mutexes = mutexes[first];
54+
const auto &second_mutexes = mutexes[second];
55+
if (first_mutexes.size() < second_mutexes.size()) {
56+
return first_mutexes.find(second) != first_mutexes.end();
57+
}
58+
return second_mutexes.find(first) != second_mutexes.end();
59+
}
60+
61+
inline void insert_mutex(
62+
const std::uint64_t first,
63+
const std::uint64_t second,
64+
MutexStorage &mutexes
65+
) {
66+
mutexes[first].insert(second);
67+
mutexes[second].insert(first);
68+
}
69+
70+
inline void merge_mutexes(
71+
const std::uint64_t root_from,
72+
const std::uint64_t root_to,
73+
MutexStorage &mutexes
74+
) {
75+
auto &mutexes_from = mutexes[root_from];
76+
auto &mutexes_to = mutexes[root_to];
77+
78+
for (const auto other_root : mutexes_from) {
79+
auto &other_mutexes = mutexes[other_root];
80+
other_mutexes.erase(root_from);
81+
if (other_root != root_to) {
82+
other_mutexes.insert(root_to);
83+
mutexes_to.insert(other_root);
84+
}
85+
}
86+
mutexes_to.erase(root_from);
87+
mutexes_to.erase(root_to);
88+
mutexes_from.clear();
89+
}
90+
91+
inline std::vector<std::ptrdiff_t> c_order_strides(const std::vector<std::ptrdiff_t> &shape) {
92+
std::vector<std::ptrdiff_t> strides(shape.size(), 1);
93+
for (std::ptrdiff_t axis = static_cast<std::ptrdiff_t>(shape.size()) - 2; axis >= 0; --axis) {
94+
strides[static_cast<std::size_t>(axis)] =
95+
strides[static_cast<std::size_t>(axis + 1)] * shape[static_cast<std::size_t>(axis + 1)];
96+
}
97+
return strides;
98+
}
99+
100+
inline bool is_valid_grid_edge(
101+
const std::uint64_t node,
102+
const std::vector<std::ptrdiff_t> &offset,
103+
const std::vector<std::ptrdiff_t> &shape,
104+
const std::vector<std::ptrdiff_t> &strides
105+
) {
106+
for (std::size_t axis = 0; axis < shape.size(); ++axis) {
107+
const auto coord = static_cast<std::ptrdiff_t>(node / static_cast<std::uint64_t>(strides[axis])) % shape[axis];
108+
const auto neighbor = coord + offset[axis];
109+
if (neighbor < 0 || neighbor >= shape[axis]) {
110+
return false;
111+
}
112+
}
113+
return true;
114+
}
115+
116+
template <class T>
117+
void mutex_watershed_grid(
118+
const ConstArrayView<T> &affinities,
119+
const std::vector<std::vector<std::ptrdiff_t>> &offsets,
120+
const std::size_t number_of_attractive_channels,
121+
const ArrayView<std::uint64_t> &out
122+
) {
123+
if (affinities.ndim() != 3 && affinities.ndim() != 4) {
124+
throw std::invalid_argument(
125+
"affinities must have shape (channels, y, x) or (channels, z, y, x), got ndim=" +
126+
std::to_string(affinities.ndim())
127+
);
128+
}
129+
if (offsets.empty()) {
130+
throw std::invalid_argument("offsets must not be empty");
131+
}
132+
133+
const auto number_of_channels = static_cast<std::size_t>(affinities.shape[0]);
134+
const auto spatial_ndim = static_cast<std::size_t>(affinities.ndim() - 1);
135+
if (offsets.size() != number_of_channels) {
136+
throw std::invalid_argument(
137+
"offsets length must match affinities channel count, got offsets length=" +
138+
std::to_string(offsets.size()) + ", channels=" + std::to_string(number_of_channels)
139+
);
140+
}
141+
if (number_of_attractive_channels > number_of_channels) {
142+
throw std::invalid_argument("number_of_attractive_channels must be <= number of channels");
143+
}
144+
for (const auto &offset : offsets) {
145+
if (offset.size() != spatial_ndim) {
146+
throw std::invalid_argument(
147+
"each offset must have length matching the spatial ndim, got spatial ndim=" +
148+
std::to_string(spatial_ndim)
149+
);
150+
}
151+
}
152+
153+
std::vector<std::ptrdiff_t> spatial_shape(
154+
affinities.shape.begin() + 1,
155+
affinities.shape.end()
156+
);
157+
if (out.shape != spatial_shape) {
158+
throw std::invalid_argument("out shape must match affinities spatial shape");
159+
}
160+
161+
const auto number_of_nodes = static_cast<std::uint64_t>(std::accumulate(
162+
spatial_shape.begin(),
163+
spatial_shape.end(),
164+
std::ptrdiff_t{1},
165+
[](const std::ptrdiff_t a, const std::ptrdiff_t b) { return a * b; }
166+
));
167+
const auto spatial_strides = c_order_strides(spatial_shape);
168+
169+
std::vector<std::ptrdiff_t> offset_strides(number_of_channels, 0);
170+
for (std::size_t channel = 0; channel < number_of_channels; ++channel) {
171+
for (std::size_t axis = 0; axis < spatial_ndim; ++axis) {
172+
offset_strides[channel] += offsets[channel][axis] * spatial_strides[axis];
173+
}
174+
}
175+
176+
const auto number_of_edges = number_of_nodes * number_of_channels;
177+
std::vector<std::uint64_t> edge_order(static_cast<std::size_t>(number_of_edges));
178+
std::iota(edge_order.begin(), edge_order.end(), std::uint64_t{0});
179+
std::sort(edge_order.begin(), edge_order.end(), [&](const std::uint64_t first, const std::uint64_t second) {
180+
const T first_weight = affinities.data[first];
181+
const T second_weight = affinities.data[second];
182+
if (first_weight == second_weight) {
183+
return first < second;
184+
}
185+
return first_weight > second_weight;
186+
});
187+
188+
DisjointSets sets(static_cast<std::size_t>(number_of_nodes));
189+
MutexStorage mutexes(static_cast<std::size_t>(number_of_nodes));
190+
191+
for (const auto edge_id : edge_order) {
192+
const auto channel = static_cast<std::size_t>(edge_id / number_of_nodes);
193+
const auto u = edge_id % number_of_nodes;
194+
if (!is_valid_grid_edge(u, offsets[channel], spatial_shape, spatial_strides)) {
195+
continue;
196+
}
197+
198+
const auto v_signed = static_cast<std::int64_t>(u) + static_cast<std::int64_t>(offset_strides[channel]);
199+
const auto v = static_cast<std::uint64_t>(v_signed);
200+
std::uint64_t root_u = sets.find(u);
201+
std::uint64_t root_v = sets.find(v);
202+
if (root_u == root_v || check_mutex(root_u, root_v, mutexes)) {
203+
continue;
204+
}
205+
206+
const bool is_mutex_edge = channel >= number_of_attractive_channels;
207+
if (is_mutex_edge) {
208+
insert_mutex(root_u, root_v, mutexes);
209+
} else {
210+
const auto new_root = sets.unite_roots(root_u, root_v);
211+
const auto old_root = (new_root == root_u) ? root_v : root_u;
212+
merge_mutexes(old_root, new_root, mutexes);
213+
}
214+
}
215+
216+
std::unordered_map<std::uint64_t, std::uint64_t> label_map;
217+
for (std::uint64_t node = 0; node < number_of_nodes; ++node) {
218+
const auto root = sets.find(node);
219+
auto found = label_map.find(root);
220+
if (found == label_map.end()) {
221+
const auto next_label = static_cast<std::uint64_t>(label_map.size() + 1);
222+
found = label_map.emplace(root, next_label).first;
223+
}
224+
out.data[node] = found->second;
225+
}
226+
}
227+
228+
} // namespace bioimage_cpp

src/bindings/module.cxx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#include "segmentation.hxx"
12
#include "utils.hxx"
23

34
#include <nanobind/nanobind.h>
@@ -6,5 +7,6 @@ namespace nb = nanobind;
67

78
NB_MODULE(_core, m) {
89
m.doc() = "C++ extension module for bioimage_cpp.";
10+
bioimage_cpp::bindings::bind_segmentation(m);
911
bioimage_cpp::bindings::bind_utils(m);
1012
}

src/bindings/segmentation.cxx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#include "segmentation.hxx"
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
#include "bioimage_cpp/segmentation/mutex_watershed.hxx"
5+
6+
#include <nanobind/ndarray.h>
7+
#include <nanobind/stl/vector.h>
8+
9+
#include <cstddef>
10+
#include <cstdint>
11+
#include <memory>
12+
#include <numeric>
13+
#include <vector>
14+
15+
namespace nb = nanobind;
16+
17+
namespace bioimage_cpp::bindings {
18+
namespace {
19+
20+
template <class T>
21+
using AffinityArray = nb::ndarray<nb::numpy, const T, nb::c_contig>;
22+
23+
using LabelArray = nb::ndarray<nb::numpy, std::uint64_t, nb::c_contig>;
24+
25+
template <class T>
26+
LabelArray mutex_watershed_grid_t(
27+
AffinityArray<T> affinities,
28+
const std::vector<std::vector<std::ptrdiff_t>> &offsets,
29+
const std::size_t number_of_attractive_channels
30+
) {
31+
std::vector<std::ptrdiff_t> affinity_shape(affinities.ndim());
32+
for (std::size_t axis = 0; axis < affinities.ndim(); ++axis) {
33+
affinity_shape[axis] = static_cast<std::ptrdiff_t>(affinities.shape(axis));
34+
}
35+
36+
std::vector<std::size_t> label_shape;
37+
label_shape.reserve(affinities.ndim() > 0 ? affinities.ndim() - 1 : 0);
38+
std::vector<std::ptrdiff_t> label_view_shape;
39+
label_view_shape.reserve(affinities.ndim() > 0 ? affinities.ndim() - 1 : 0);
40+
for (std::size_t axis = 1; axis < affinities.ndim(); ++axis) {
41+
label_shape.push_back(affinities.shape(axis));
42+
label_view_shape.push_back(static_cast<std::ptrdiff_t>(affinities.shape(axis)));
43+
}
44+
45+
const auto number_of_nodes = std::accumulate(
46+
label_view_shape.begin(),
47+
label_view_shape.end(),
48+
std::ptrdiff_t{1},
49+
[](const std::ptrdiff_t a, const std::ptrdiff_t b) { return a * b; }
50+
);
51+
auto *data = new std::uint64_t[static_cast<std::size_t>(number_of_nodes)]();
52+
nb::capsule owner(data, [](void *p) noexcept { delete[] static_cast<std::uint64_t *>(p); });
53+
54+
ConstArrayView<T> affinities_view{
55+
affinities.data(),
56+
affinity_shape,
57+
{},
58+
};
59+
ArrayView<std::uint64_t> out_view{
60+
data,
61+
label_view_shape,
62+
{},
63+
};
64+
65+
{
66+
nb::gil_scoped_release release;
67+
mutex_watershed_grid<T>(
68+
affinities_view,
69+
offsets,
70+
number_of_attractive_channels,
71+
out_view
72+
);
73+
}
74+
75+
return LabelArray(data, label_shape.size(), label_shape.data(), owner);
76+
}
77+
78+
} // namespace
79+
80+
void bind_segmentation(nb::module_ &m) {
81+
m.def(
82+
"_mutex_watershed_grid_float32",
83+
&mutex_watershed_grid_t<float>,
84+
nb::arg("affinities"),
85+
nb::arg("offsets"),
86+
nb::arg("number_of_attractive_channels"),
87+
"Run mutex watershed on a 2D or 3D image-derived grid graph with float32 affinities."
88+
);
89+
m.def(
90+
"_mutex_watershed_grid_float64",
91+
&mutex_watershed_grid_t<double>,
92+
nb::arg("affinities"),
93+
nb::arg("offsets"),
94+
nb::arg("number_of_attractive_channels"),
95+
"Run mutex watershed on a 2D or 3D image-derived grid graph with float64 affinities."
96+
);
97+
}
98+
99+
} // namespace bioimage_cpp::bindings

src/bindings/segmentation.hxx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#pragma once
2+
3+
#include <nanobind/nanobind.h>
4+
5+
namespace bioimage_cpp::bindings {
6+
7+
void bind_segmentation(nanobind::module_ &m);
8+
9+
} // namespace bioimage_cpp::bindings

src/bioimage_cpp/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Dependency-light bioimage analysis algorithms backed by C++."""
22

33
from ._version import __version__
4+
from . import segmentation
45
from . import utils
56

6-
__all__ = ["__version__", "utils"]
7+
__all__ = ["__version__", "segmentation", "utils"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Segmentation algorithms."""
2+
3+
from .mutex_watershed import mutex_watershed
4+
5+
__all__ = ["mutex_watershed"]

0 commit comments

Comments
 (0)