Skip to content

Commit b87a82e

Browse files
Add stride and mask support to MWS
1 parent c564987 commit b87a82e

6 files changed

Lines changed: 313 additions & 1 deletion

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import napari
2+
3+
from elf.segmentation.utils import load_mutex_watershed_problem
4+
from elf.io import open_file
5+
6+
7+
def mws_bic(affinities, offsets):
8+
import bioimage_cpp as bic
9+
print("Start MWS ...")
10+
affs = affinities.copy()
11+
ndim = len(offsets[0])
12+
affs[:ndim] *= -1
13+
affs[:ndim] += 1
14+
segmentation = bic.segmentation.mutex_watershed(
15+
affs, offsets, number_of_attractive_channels=ndim
16+
)
17+
print("done ...")
18+
return segmentation
19+
20+
21+
def mws_affogato(affinities, offsets):
22+
from elf.segmentation.mutex_watershed import mutex_watershed as mws
23+
print("Start MWS ...")
24+
affs = affinities.copy()
25+
segmentation = mws(affs, offsets, strides=[1, 1, 1])
26+
print("done ...")
27+
return segmentation
28+
29+
30+
def _filter_2d(affinities, offsets):
31+
chans_2d = [i for i, off in enumerate(offsets) if off[0] == 0]
32+
affinities = affinities[chans_2d][:, 0]
33+
offsets = [off[1:] for i, off in enumerate(offsets) if i in chans_2d]
34+
return affinities, offsets
35+
36+
37+
def main():
38+
prefix = "isbi-data-"
39+
data_path = f"{prefix}test.h5"
40+
affinities, offsets = load_mutex_watershed_problem(prefix=prefix)
41+
42+
check_2d = True
43+
if check_2d:
44+
affinities, offsets = _filter_2d(affinities, offsets)
45+
46+
use_reference_impl = False
47+
if use_reference_impl:
48+
segmentation = mws_affogato(affinities, offsets)
49+
else:
50+
segmentation = mws_bic(affinities, offsets)
51+
52+
with open_file(data_path, "r") as f:
53+
raw = f["raw"][0] if check_2d else f["raw"][:]
54+
55+
viewer = napari.Viewer()
56+
viewer.add_image(raw, name="raw")
57+
viewer.add_image(affinities, name="affinities")
58+
viewer.add_labels(segmentation, name="mws-segmentation")
59+
napari.run()
60+
61+
62+
if __name__ == "__main__":
63+
main()

include/bioimage_cpp/segmentation/mutex_watershed.hxx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ inline bool is_valid_grid_edge(
116116
template <class T>
117117
void mutex_watershed_grid(
118118
const ConstArrayView<T> &affinities,
119+
const ConstArrayView<std::uint8_t> &valid_edges,
119120
const std::vector<std::vector<std::ptrdiff_t>> &offsets,
120121
const std::size_t number_of_attractive_channels,
121122
const ArrayView<std::uint64_t> &out
@@ -129,6 +130,9 @@ void mutex_watershed_grid(
129130
if (offsets.empty()) {
130131
throw std::invalid_argument("offsets must not be empty");
131132
}
133+
if (valid_edges.shape != affinities.shape) {
134+
throw std::invalid_argument("valid_edges shape must match affinities shape");
135+
}
132136

133137
const auto number_of_channels = static_cast<std::size_t>(affinities.shape[0]);
134138
const auto spatial_ndim = static_cast<std::size_t>(affinities.ndim() - 1);
@@ -189,6 +193,10 @@ void mutex_watershed_grid(
189193
MutexStorage mutexes(static_cast<std::size_t>(number_of_nodes));
190194

191195
for (const auto edge_id : edge_order) {
196+
if (valid_edges.data[edge_id] == 0) {
197+
continue;
198+
}
199+
192200
const auto channel = static_cast<std::size_t>(edge_id / number_of_nodes);
193201
const auto u = edge_id % number_of_nodes;
194202
if (!is_valid_grid_edge(u, offsets[channel], spatial_shape, spatial_strides)) {

src/bindings/segmentation.cxx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,24 @@ namespace {
2020
template <class T>
2121
using AffinityArray = nb::ndarray<nb::numpy, const T, nb::c_contig>;
2222

23+
using ValidEdgeArray = nb::ndarray<nb::numpy, const std::uint8_t, nb::c_contig>;
2324
using LabelArray = nb::ndarray<nb::numpy, std::uint64_t, nb::c_contig>;
2425

2526
template <class T>
2627
LabelArray mutex_watershed_grid_t(
2728
AffinityArray<T> affinities,
29+
ValidEdgeArray valid_edges,
2830
const std::vector<std::vector<std::ptrdiff_t>> &offsets,
2931
const std::size_t number_of_attractive_channels
3032
) {
3133
std::vector<std::ptrdiff_t> affinity_shape(affinities.ndim());
3234
for (std::size_t axis = 0; axis < affinities.ndim(); ++axis) {
3335
affinity_shape[axis] = static_cast<std::ptrdiff_t>(affinities.shape(axis));
3436
}
37+
std::vector<std::ptrdiff_t> valid_edges_shape(valid_edges.ndim());
38+
for (std::size_t axis = 0; axis < valid_edges.ndim(); ++axis) {
39+
valid_edges_shape[axis] = static_cast<std::ptrdiff_t>(valid_edges.shape(axis));
40+
}
3541

3642
std::vector<std::size_t> label_shape;
3743
label_shape.reserve(affinities.ndim() > 0 ? affinities.ndim() - 1 : 0);
@@ -56,6 +62,11 @@ LabelArray mutex_watershed_grid_t(
5662
affinity_shape,
5763
{},
5864
};
65+
ConstArrayView<std::uint8_t> valid_edges_view{
66+
valid_edges.data(),
67+
valid_edges_shape,
68+
{},
69+
};
5970
ArrayView<std::uint64_t> out_view{
6071
data,
6172
label_view_shape,
@@ -66,6 +77,7 @@ LabelArray mutex_watershed_grid_t(
6677
nb::gil_scoped_release release;
6778
mutex_watershed_grid<T>(
6879
affinities_view,
80+
valid_edges_view,
6981
offsets,
7082
number_of_attractive_channels,
7183
out_view
@@ -82,6 +94,7 @@ void bind_segmentation(nb::module_ &m) {
8294
"_mutex_watershed_grid_float32",
8395
&mutex_watershed_grid_t<float>,
8496
nb::arg("affinities"),
97+
nb::arg("valid_edges"),
8598
nb::arg("offsets"),
8699
nb::arg("number_of_attractive_channels"),
87100
"Run mutex watershed on a 2D or 3D image-derived grid graph with float32 affinities."
@@ -90,6 +103,7 @@ void bind_segmentation(nb::module_ &m) {
90103
"_mutex_watershed_grid_float64",
91104
&mutex_watershed_grid_t<double>,
92105
nb::arg("affinities"),
106+
nb::arg("valid_edges"),
93107
nb::arg("offsets"),
94108
nb::arg("number_of_attractive_channels"),
95109
"Run mutex watershed on a 2D or 3D image-derived grid graph with float64 affinities."

src/bioimage_cpp/segmentation/mutex_watershed.py

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ def mutex_watershed(
1818
affinities: np.ndarray,
1919
offsets: Sequence[Sequence[int]],
2020
number_of_attractive_channels: int,
21+
*,
22+
strides: Sequence[int] | None = None,
23+
randomized_strides: bool = False,
24+
mask: np.ndarray | None = None,
2125
) -> np.ndarray:
2226
"""Run mutex watershed on a 2D or 3D image-derived grid graph.
2327
@@ -33,6 +37,17 @@ def mutex_watershed(
3337
number_of_attractive_channels:
3438
The first this many affinity channels are attractive merge edges. The
3539
remaining channels are mutex edges.
40+
strides:
41+
Optional spatial sub-sampling strides for mutex edges. Attractive
42+
channels are always kept. If given, it must have one positive integer
43+
per spatial dimension.
44+
randomized_strides:
45+
If ``True``, sub-sample mutex edges randomly with probability
46+
``1 / np.prod(strides)`` instead of on a regular grid.
47+
mask:
48+
Optional boolean foreground mask with shape ``affinities.shape[1:]``.
49+
Edges touching ``False`` pixels are ignored and ``False`` pixels are
50+
labeled as background ``0`` in the output.
3651
3752
Returns
3853
-------
@@ -73,5 +88,140 @@ def mutex_watershed(
7388
if number_of_attractive_channels > array.shape[0]:
7489
raise ValueError("number_of_attractive_channels must be <= number of channels")
7590

91+
normalized_strides = _normalize_strides(strides, spatial_ndim, randomized_strides)
92+
valid_edges = _compute_valid_edges(
93+
array.shape,
94+
normalized_offsets,
95+
number_of_attractive_channels,
96+
normalized_strides,
97+
randomized_strides,
98+
mask,
99+
)
100+
76101
contiguous = np.ascontiguousarray(array)
77-
return run(contiguous, normalized_offsets, number_of_attractive_channels)
102+
labels = run(
103+
contiguous,
104+
valid_edges,
105+
normalized_offsets,
106+
number_of_attractive_channels,
107+
)
108+
if mask is not None:
109+
labels[np.logical_not(np.asarray(mask))] = 0
110+
return labels
111+
112+
113+
def _normalize_strides(
114+
strides: Sequence[int] | None,
115+
spatial_ndim: int,
116+
randomized_strides: bool,
117+
) -> tuple[int, ...] | None:
118+
if strides is None:
119+
if randomized_strides:
120+
raise ValueError("randomized_strides requires strides")
121+
return None
122+
123+
normalized = tuple(int(stride) for stride in strides)
124+
if len(normalized) != spatial_ndim:
125+
raise ValueError(
126+
"strides length must match the spatial ndim, got "
127+
f"strides length={len(normalized)}, spatial ndim={spatial_ndim}"
128+
)
129+
if any(stride <= 0 for stride in normalized):
130+
raise ValueError("strides must contain only positive integers")
131+
return normalized
132+
133+
134+
def _valid_source_slices(
135+
image_shape: tuple[int, ...],
136+
offset: tuple[int, ...],
137+
) -> tuple[slice, ...] | None:
138+
slices = []
139+
for axis_size, step in zip(image_shape, offset, strict=True):
140+
if step > 0:
141+
if step >= axis_size:
142+
return None
143+
slices.append(slice(0, axis_size - step))
144+
elif step < 0:
145+
if -step >= axis_size:
146+
return None
147+
slices.append(slice(-step, axis_size))
148+
else:
149+
slices.append(slice(None))
150+
return tuple(slices)
151+
152+
153+
def _neighbor_slices(
154+
image_shape: tuple[int, ...],
155+
offset: tuple[int, ...],
156+
) -> tuple[slice, ...] | None:
157+
slices = []
158+
for axis_size, step in zip(image_shape, offset, strict=True):
159+
if step > 0:
160+
if step >= axis_size:
161+
return None
162+
slices.append(slice(step, axis_size))
163+
elif step < 0:
164+
if -step >= axis_size:
165+
return None
166+
slices.append(slice(0, axis_size + step))
167+
else:
168+
slices.append(slice(None))
169+
return tuple(slices)
170+
171+
172+
def _compute_valid_edges(
173+
affinity_shape: tuple[int, ...],
174+
offsets: Sequence[tuple[int, ...]],
175+
number_of_attractive_channels: int,
176+
strides: tuple[int, ...] | None,
177+
randomized_strides: bool,
178+
mask: np.ndarray | None,
179+
) -> np.ndarray:
180+
image_shape = tuple(int(size) for size in affinity_shape[1:])
181+
valid_edges = np.zeros(affinity_shape, dtype=bool)
182+
183+
for channel, offset in enumerate(offsets):
184+
source_slices = _valid_source_slices(image_shape, offset)
185+
if source_slices is not None:
186+
valid_edges[(channel,) + source_slices] = True
187+
188+
if strides is not None:
189+
stride_edges = np.zeros_like(valid_edges, dtype=bool)
190+
stride_edges[:number_of_attractive_channels] = True
191+
if randomized_strides:
192+
stride_factor = 1.0 / np.prod(strides)
193+
stride_edges[number_of_attractive_channels:] = (
194+
np.random.random(
195+
valid_edges[number_of_attractive_channels:].shape
196+
)
197+
< stride_factor
198+
)
199+
else:
200+
valid_slice = (slice(number_of_attractive_channels, None),) + tuple(
201+
slice(None, None, stride) for stride in strides
202+
)
203+
stride_edges[valid_slice] = True
204+
valid_edges &= stride_edges
205+
206+
if mask is not None:
207+
mask_array = np.asarray(mask)
208+
if mask_array.shape != image_shape:
209+
raise ValueError(
210+
"mask shape must match affinities spatial shape, got "
211+
f"mask shape={mask_array.shape}, spatial shape={image_shape}"
212+
)
213+
if mask_array.dtype != np.dtype("bool"):
214+
raise TypeError(f"mask must have dtype bool, got dtype={mask_array.dtype}")
215+
216+
invalid = np.logical_not(mask_array)
217+
for channel, offset in enumerate(offsets):
218+
source_slices = _valid_source_slices(image_shape, offset)
219+
neighbor_slices = _neighbor_slices(image_shape, offset)
220+
if source_slices is None or neighbor_slices is None:
221+
continue
222+
touches_invalid = invalid[source_slices] | invalid[neighbor_slices]
223+
channel_valid = valid_edges[(channel,) + source_slices]
224+
channel_valid[touches_invalid] = False
225+
valid_edges[(channel,) + source_slices] = channel_valid
226+
227+
return np.ascontiguousarray(valid_edges, dtype=np.uint8)

src/cpp/segmentation/mutex_watershed.cxx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ namespace bioimage_cpp {
66

77
template void mutex_watershed_grid<float>(
88
const ConstArrayView<float> &,
9+
const ConstArrayView<std::uint8_t> &,
910
const std::vector<std::vector<std::ptrdiff_t>> &,
1011
std::size_t,
1112
const ArrayView<std::uint64_t> &
1213
);
1314

1415
template void mutex_watershed_grid<double>(
1516
const ConstArrayView<double> &,
17+
const ConstArrayView<std::uint8_t> &,
1618
const std::vector<std::vector<std::ptrdiff_t>> &,
1719
std::size_t,
1820
const ArrayView<std::uint64_t> &

0 commit comments

Comments
 (0)