Skip to content

Commit d46f31c

Browse files
Implement edge-weighted graph watershed
1 parent e319956 commit d46f31c

5 files changed

Lines changed: 603 additions & 0 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,50 @@ Notes:
254254
- `number_of_threads=0` uses the library default; pass a positive integer for a
255255
fixed thread count.
256256

257+
## Edge-Weighted Watershed
258+
259+
Nifty:
260+
261+
```python
262+
import nifty.graph as ng
263+
264+
labels = ng.edgeWeightedWatershedsSegmentation(graph, edge_weights, seeds)
265+
```
266+
267+
bioimage-cpp:
268+
269+
```python
270+
import bioimage_cpp as bic
271+
272+
labels = bic.graph.edge_weighted_watershed(graph, edge_weights, seeds)
273+
```
274+
275+
Notes:
276+
277+
- Only the Kruskal variant of nifty's algorithm is provided. Edges are visited
278+
in ascending weight order; two distinct components merge iff at least one is
279+
unlabeled (seed label `0`), so seed boundaries are preserved.
280+
- `graph` may be an `UndirectedGraph` or a `RegionAdjacencyGraph`.
281+
- `edge_weights` must be 1D with length `graph.number_of_edges`. Supported
282+
dtypes are `float32` and `float64`; other floating dtypes are promoted to
283+
`float32` (matching nifty, whose Python binding is `float32`-only). Non-float
284+
dtypes raise `TypeError`.
285+
- `seeds` must be 1D with length `graph.number_of_nodes`. Supported dtypes are
286+
`uint32`, `uint64`, `int32`, `int64`. The value `0` marks unlabeled nodes;
287+
non-zero ids are propagated along low-weight paths. Signed seed arrays must
288+
not contain negative values.
289+
- The output is 1D with length `graph.number_of_nodes` and the same dtype as
290+
`seeds`. Seed label values are preserved (no dense relabeling). Nodes that
291+
no seed can reach remain `0`.
292+
293+
Intentional differences vs. nifty:
294+
295+
- No priority-queue variant — only the simpler sort + union-find Kruskal flow.
296+
For the same input it matches nifty's default behavior (which also dispatches
297+
to the Kruskal implementation).
298+
- No carving / background-bias variant. Build a carving prior into the edge
299+
weights before calling the function if needed.
300+
257301
## Multicut
258302

259303
Nifty exposes multicut through an objective + factory-style solver hierarchy.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/detail/union_find.hxx"
4+
#include "bioimage_cpp/graph/undirected_graph.hxx"
5+
6+
#include <algorithm>
7+
#include <cstddef>
8+
#include <cstdint>
9+
#include <stdexcept>
10+
#include <string>
11+
#include <type_traits>
12+
#include <vector>
13+
14+
namespace bioimage_cpp::graph {
15+
16+
// Kruskal-style edge-weighted seeded watershed.
17+
//
18+
// Edges are visited in ascending weight order. Two distinct components are
19+
// merged iff at least one of them is unlabeled (seed label 0); the non-zero
20+
// seed label then propagates. Two distinct already-labeled components are
21+
// never merged, so seed boundaries are preserved. Nodes that no seed can
22+
// reach retain label 0.
23+
//
24+
// `WeightT` is any type orderable with `<` (typically float32 or float64).
25+
// `SeedT` is any integer label type. Signed types must not contain negative
26+
// values; this is checked at the boundary.
27+
template <class WeightT, class SeedT>
28+
inline std::vector<SeedT> edge_weighted_watershed(
29+
const UndirectedGraph &graph,
30+
const std::vector<WeightT> &edge_weights,
31+
const std::vector<SeedT> &seeds
32+
) {
33+
const auto number_of_nodes = graph.number_of_nodes();
34+
const auto number_of_edges = graph.number_of_edges();
35+
36+
if (edge_weights.size() != static_cast<std::size_t>(number_of_edges)) {
37+
throw std::invalid_argument(
38+
"edge_weights length must equal number_of_edges, got " +
39+
std::to_string(edge_weights.size()) + " for number_of_edges=" +
40+
std::to_string(number_of_edges)
41+
);
42+
}
43+
if (seeds.size() != static_cast<std::size_t>(number_of_nodes)) {
44+
throw std::invalid_argument(
45+
"seeds length must equal number_of_nodes, got " +
46+
std::to_string(seeds.size()) + " for number_of_nodes=" +
47+
std::to_string(number_of_nodes)
48+
);
49+
}
50+
if constexpr (std::is_signed_v<SeedT>) {
51+
for (const auto value : seeds) {
52+
if (value < 0) {
53+
throw std::invalid_argument("seeds must not contain negative values");
54+
}
55+
}
56+
}
57+
58+
std::vector<SeedT> labels(seeds);
59+
60+
if (number_of_edges == 0) {
61+
return labels;
62+
}
63+
64+
std::vector<std::uint64_t> order(static_cast<std::size_t>(number_of_edges));
65+
for (std::uint64_t edge = 0; edge < number_of_edges; ++edge) {
66+
order[static_cast<std::size_t>(edge)] = edge;
67+
}
68+
std::stable_sort(
69+
order.begin(),
70+
order.end(),
71+
[&edge_weights](const std::uint64_t a, const std::uint64_t b) {
72+
return edge_weights[static_cast<std::size_t>(a)] <
73+
edge_weights[static_cast<std::size_t>(b)];
74+
}
75+
);
76+
77+
detail::UnionFind sets(static_cast<std::size_t>(number_of_nodes));
78+
79+
constexpr SeedT zero{0};
80+
for (const auto edge : order) {
81+
const auto uv = graph.uv(edge);
82+
const auto ru = sets.find(uv.first);
83+
const auto rv = sets.find(uv.second);
84+
if (ru == rv) {
85+
continue;
86+
}
87+
const auto lu = labels[static_cast<std::size_t>(ru)];
88+
const auto lv = labels[static_cast<std::size_t>(rv)];
89+
if (lu != zero && lv != zero) {
90+
continue;
91+
}
92+
const auto new_label = std::max(lu, lv);
93+
const auto new_root = sets.unite_roots(ru, rv);
94+
labels[static_cast<std::size_t>(new_root)] = new_label;
95+
}
96+
97+
for (std::uint64_t node = 0; node < number_of_nodes; ++node) {
98+
const auto root = sets.find(node);
99+
labels[static_cast<std::size_t>(node)] = labels[static_cast<std::size_t>(root)];
100+
}
101+
102+
return labels;
103+
}
104+
105+
} // namespace bioimage_cpp::graph

src/bindings/graph.cxx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include "bioimage_cpp/array_view.hxx"
44
#include "bioimage_cpp/graph/connected_components.hxx"
5+
#include "bioimage_cpp/graph/edge_weighted_watershed.hxx"
56
#include "bioimage_cpp/graph/feature_accumulation.hxx"
67
#include "bioimage_cpp/graph/multicut.hxx"
78
#include "bioimage_cpp/graph/node_label_projection.hxx"
@@ -12,6 +13,7 @@
1213
#include <nanobind/stl/pair.h>
1314
#include <nanobind/stl/vector.h>
1415

16+
#include <array>
1517
#include <cstddef>
1618
#include <cstdint>
1719
#include <memory>
@@ -285,6 +287,55 @@ UInt64Array graph_connected_components_masked(const Graph &graph, ConstUInt8Arra
285287
return vector_to_uint64_array(labels);
286288
}
287289

290+
template <class T>
291+
using ConstArray1D = nb::ndarray<nb::numpy, const T, nb::c_contig>;
292+
293+
template <class T>
294+
std::vector<T> array_1d_to_vector(
295+
ConstArray1D<T> array,
296+
const char *argument_name,
297+
const std::uint64_t expected_size
298+
) {
299+
if (array.ndim() != 1) {
300+
throw std::invalid_argument(std::string(argument_name) + " must be a 1D array");
301+
}
302+
if (array.shape(0) != static_cast<std::size_t>(expected_size)) {
303+
throw std::invalid_argument(
304+
std::string(argument_name) + " length must match expected size"
305+
);
306+
}
307+
const auto *data = array.data();
308+
return std::vector<T>(data, data + array.shape(0));
309+
}
310+
311+
template <class T>
312+
nb::ndarray<nb::numpy, T, nb::c_contig> vector_to_array_1d(const std::vector<T> &values) {
313+
const std::size_t size = values.size();
314+
auto *data = new T[size];
315+
std::copy(values.begin(), values.end(), data);
316+
nb::capsule owner(data, [](void *p) noexcept { delete[] static_cast<T *>(p); });
317+
const std::array<std::size_t, 1> shape{size};
318+
return nb::ndarray<nb::numpy, T, nb::c_contig>(data, 1, shape.data(), owner);
319+
}
320+
321+
template <class WeightT, class SeedT>
322+
nb::ndarray<nb::numpy, SeedT, nb::c_contig> graph_edge_weighted_watershed_t(
323+
const Graph &graph,
324+
ConstArray1D<WeightT> edge_weights,
325+
ConstArray1D<SeedT> seeds
326+
) {
327+
const auto weight_vector =
328+
array_1d_to_vector<WeightT>(edge_weights, "edge_weights", graph.number_of_edges());
329+
const auto seed_vector =
330+
array_1d_to_vector<SeedT>(seeds, "seeds", graph.number_of_nodes());
331+
std::vector<SeedT> labels;
332+
{
333+
nb::gil_scoped_release release;
334+
labels = graph::edge_weighted_watershed<WeightT, SeedT>(graph, weight_vector, seed_vector);
335+
}
336+
return vector_to_array_1d<SeedT>(labels);
337+
}
338+
288339
double multicut_energy(const Graph &graph, ConstDoubleArray costs, ConstUInt64Array labels) {
289340
const auto cost_vector = double_array_to_vector(costs, "edge_costs", graph.number_of_edges());
290341
const auto label_vector = uint64_array_to_vector(labels, "labels", graph.number_of_nodes());
@@ -605,6 +656,25 @@ void bind_graph(nb::module_ &m) {
605656
nb::arg("graph"),
606657
nb::arg("edge_mask")
607658
);
659+
const auto register_watershed = [&m]<class WeightT, class SeedT>(
660+
const char *name
661+
) {
662+
m.def(
663+
name,
664+
&graph_edge_weighted_watershed_t<WeightT, SeedT>,
665+
nb::arg("graph"),
666+
nb::arg("edge_weights"),
667+
nb::arg("seeds")
668+
);
669+
};
670+
register_watershed.operator()<float, std::uint32_t>("_edge_weighted_watershed_float32_uint32");
671+
register_watershed.operator()<float, std::uint64_t>("_edge_weighted_watershed_float32_uint64");
672+
register_watershed.operator()<float, std::int32_t>("_edge_weighted_watershed_float32_int32");
673+
register_watershed.operator()<float, std::int64_t>("_edge_weighted_watershed_float32_int64");
674+
register_watershed.operator()<double, std::uint32_t>("_edge_weighted_watershed_float64_uint32");
675+
register_watershed.operator()<double, std::uint64_t>("_edge_weighted_watershed_float64_uint64");
676+
register_watershed.operator()<double, std::int32_t>("_edge_weighted_watershed_float64_int32");
677+
register_watershed.operator()<double, std::int64_t>("_edge_weighted_watershed_float64_int64");
608678
m.def(
609679
"_multicut_energy",
610680
&multicut_energy,

src/bioimage_cpp/graph/__init__.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@
3636
np.dtype("int64"): _core._accumulate_affinity_features_int64,
3737
}
3838

39+
_EDGE_WEIGHTED_WATERSHED_BY_DTYPE = {
40+
(np.dtype("float32"), np.dtype("uint32")): _core._edge_weighted_watershed_float32_uint32,
41+
(np.dtype("float32"), np.dtype("uint64")): _core._edge_weighted_watershed_float32_uint64,
42+
(np.dtype("float32"), np.dtype("int32")): _core._edge_weighted_watershed_float32_int32,
43+
(np.dtype("float32"), np.dtype("int64")): _core._edge_weighted_watershed_float32_int64,
44+
(np.dtype("float64"), np.dtype("uint32")): _core._edge_weighted_watershed_float64_uint32,
45+
(np.dtype("float64"), np.dtype("uint64")): _core._edge_weighted_watershed_float64_uint64,
46+
(np.dtype("float64"), np.dtype("int32")): _core._edge_weighted_watershed_float64_int32,
47+
(np.dtype("float64"), np.dtype("int64")): _core._edge_weighted_watershed_float64_int64,
48+
}
49+
3950
_PROJECT_NODE_LABELS_TO_PIXELS_BY_DTYPE = {
4051
np.dtype("uint32"): _core._project_node_labels_to_pixels_uint32,
4152
np.dtype("uint64"): _core._project_node_labels_to_pixels_uint64,
@@ -159,6 +170,17 @@ def _as_node_labels(labels, graph: UndirectedGraph | RegionAdjacencyGraph) -> np
159170
return np.ascontiguousarray(array)
160171

161172

173+
def _as_1d_array(values, dtype, name: str, expected_size: int) -> np.ndarray:
174+
array = np.asarray(values, dtype=dtype)
175+
if array.ndim != 1:
176+
raise ValueError(f"{name} must be a 1D array")
177+
if array.shape[0] != expected_size:
178+
raise ValueError(
179+
f"{name} length must be {expected_size}, got {array.shape[0]}"
180+
)
181+
return np.ascontiguousarray(array)
182+
183+
162184
def _dense_labels(labels) -> np.ndarray:
163185
labels = np.asarray(labels, dtype=np.uint64)
164186
_, dense = np.unique(labels, return_inverse=True)
@@ -207,6 +229,73 @@ def connected_components(
207229
)
208230

209231

232+
def edge_weighted_watershed(
233+
graph: UndirectedGraph | RegionAdjacencyGraph,
234+
edge_weights,
235+
seeds,
236+
) -> np.ndarray:
237+
"""Kruskal-style edge-weighted seeded watershed on an undirected graph.
238+
239+
Edges are visited in ascending weight order. Two distinct components are
240+
merged iff at least one of them is unlabeled (seed label ``0``); the
241+
non-zero seed label then propagates. Two distinct already-labeled
242+
components are never merged, so seed boundaries are preserved.
243+
244+
Parameters
245+
----------
246+
graph:
247+
:class:`UndirectedGraph` or :class:`RegionAdjacencyGraph`.
248+
edge_weights:
249+
1D array of length ``graph.number_of_edges``. Supported dtypes are
250+
``float32`` and ``float64``. Other floating dtypes are cast to
251+
``float32`` (matches nifty); other dtypes raise ``TypeError``.
252+
seeds:
253+
1D array of length ``graph.number_of_nodes``. Supported dtypes are
254+
``uint32``, ``uint64``, ``int32``, ``int64``. ``0`` marks unlabeled
255+
nodes; positive ids are seed labels and propagate along low-weight
256+
paths. Signed seed arrays must not contain negative values.
257+
258+
Returns
259+
-------
260+
np.ndarray
261+
1D array of length ``graph.number_of_nodes`` with the same dtype as
262+
``seeds``. Nodes reachable from a seed receive that seed's label;
263+
unreachable nodes remain ``0``. Seed label values are preserved (no
264+
dense relabeling).
265+
"""
266+
weight_array = np.asarray(edge_weights)
267+
if weight_array.dtype not in (np.dtype("float32"), np.dtype("float64")):
268+
if np.issubdtype(weight_array.dtype, np.floating):
269+
weight_array = weight_array.astype(np.float32, copy=False)
270+
else:
271+
raise TypeError(
272+
"edge_weights must have dtype float32 or float64, got "
273+
f"dtype={weight_array.dtype}"
274+
)
275+
276+
seed_array = np.asarray(seeds)
277+
if seed_array.dtype not in (
278+
np.dtype("uint32"),
279+
np.dtype("uint64"),
280+
np.dtype("int32"),
281+
np.dtype("int64"),
282+
):
283+
raise TypeError(
284+
"seeds must have dtype uint32, uint64, int32, or int64, got "
285+
f"dtype={seed_array.dtype}"
286+
)
287+
288+
weight_array = _as_1d_array(
289+
weight_array, weight_array.dtype, "edge_weights", int(graph.number_of_edges)
290+
)
291+
seed_array = _as_1d_array(
292+
seed_array, seed_array.dtype, "seeds", int(graph.number_of_nodes)
293+
)
294+
295+
run = _EDGE_WEIGHTED_WATERSHED_BY_DTYPE[(weight_array.dtype, seed_array.dtype)]
296+
return run(graph, weight_array, seed_array)
297+
298+
210299
class MulticutObjective:
211300
"""Multicut objective for an undirected graph and edge costs."""
212301

@@ -672,6 +761,7 @@ def _normalize_number_of_threads(number_of_threads: int) -> int:
672761
"connected_components",
673762
"edge_map_features",
674763
"edge_map_features_complex",
764+
"edge_weighted_watershed",
675765
"external_multicut_problem_path",
676766
"load_external_multicut_problem",
677767
"load_external_multicut_problem_data",

0 commit comments

Comments
 (0)