Skip to content

Commit fd601d7

Browse files
Add lifted nhood construction functionality (#44)
1 parent 2b9db07 commit fd601d7

5 files changed

Lines changed: 624 additions & 0 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,41 @@ lifted_features = bic.graph.features.lifted_affinity_features_complex(...)
11461146
The output column conventions match the local-edge variants
11471147
(`SIMPLE_EDGE_FEATURE_NAMES`, `COMPLEX_EDGE_FEATURE_NAMES`).
11481148

1149+
#### Building lifted edges from per-node labels
1150+
1151+
When the lifted edges come from semantic / class labels per RAG node rather
1152+
than from long-range affinities, nifty offers
1153+
`nifty.distributed.liftedNeighborhoodFromNodeLabels`. The bioimage-cpp
1154+
equivalent lives under `bic.graph.lifted_multicut`:
1155+
1156+
```python
1157+
# nifty
1158+
lifted_uvs = nifty.distributed.liftedNeighborhoodFromNodeLabels(
1159+
graph, node_labels, graphDepth=2, numberOfThreads=4,
1160+
mode='all', ignoreLabel=0,
1161+
)
1162+
1163+
# bioimage-cpp
1164+
lifted_uvs = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
1165+
graph, node_labels, graph_depth=2,
1166+
mode='all', ignore_label=0, number_of_threads=4,
1167+
)
1168+
```
1169+
1170+
Both functions return an `(n_lifted, 2)` `uint64` array of `(u, v)` pairs
1171+
with `u < v`, sorted lexicographically. The BFS hop distance is restricted
1172+
to `[2, graph_depth]`, so base-graph edges are excluded. `mode='same'` /
1173+
`'different'` filter by whether `node_labels[u] == node_labels[v]`;
1174+
`ignore_label` drops every pair where either endpoint label matches.
1175+
1176+
Intentional differences vs. nifty:
1177+
1178+
- snake_case parameter names (`graph_depth`, `ignore_label`,
1179+
`number_of_threads`);
1180+
- `ignore_label` defaults to `None` (no filtering) instead of `0`;
1181+
- node `0` is iterated as a source (nifty's distributed variant has an
1182+
off-by-one that silently skips it).
1183+
11491184
End-to-end pipeline (also in `examples/segmentation/lifted_multicut_from_affinities.py`):
11501185

11511186
```python
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
#include "bioimage_cpp/detail/edge_hash.hxx"
5+
#include "bioimage_cpp/detail/threading.hxx"
6+
#include "bioimage_cpp/graph/breadth_first_search.hxx"
7+
#include "bioimage_cpp/graph/undirected_graph.hxx"
8+
9+
#include <algorithm>
10+
#include <cstddef>
11+
#include <cstdint>
12+
#include <optional>
13+
#include <stdexcept>
14+
#include <unordered_set>
15+
#include <vector>
16+
17+
namespace bioimage_cpp::graph::lifted_multicut {
18+
19+
enum class LiftedNodeLabelMode { all, same, different };
20+
21+
// Discover lifted edges from per-node labels by BFS-neighborhood expansion.
22+
//
23+
// For every source node `u` the BFS reports each reachable node `v` together
24+
// with the hop distance. A pair `(u, v)` with `u < v` becomes a lifted edge
25+
// iff:
26+
// - distance is in [2, graph_depth] (distance 1 corresponds to base edges
27+
// and is excluded);
28+
// - neither labels[u] nor labels[v] equals `ignore_label` (when set);
29+
// - the `mode` predicate matches: `all` keeps every pair, `same` keeps
30+
// pairs with labels[u] == labels[v], `different` keeps the complement.
31+
//
32+
// Returns the deduplicated set sorted lexicographically with `u < v`.
33+
template <class LabelT>
34+
std::vector<bioimage_cpp::detail::Edge> lifted_edges_from_node_labels(
35+
const UndirectedGraph &graph,
36+
const ConstArrayView<LabelT> &node_labels,
37+
const std::uint64_t graph_depth,
38+
const LiftedNodeLabelMode mode,
39+
const std::optional<LabelT> ignore_label,
40+
const std::size_t number_of_threads
41+
) {
42+
if (node_labels.ndim() != 1) {
43+
throw std::invalid_argument(
44+
"node_labels must be a 1D array"
45+
);
46+
}
47+
if (static_cast<std::uint64_t>(node_labels.shape[0]) != graph.number_of_nodes()) {
48+
throw std::invalid_argument(
49+
"node_labels length must match graph number_of_nodes"
50+
);
51+
}
52+
if (graph_depth < 1) {
53+
throw std::invalid_argument(
54+
"graph_depth must be >= 1"
55+
);
56+
}
57+
58+
const auto n_nodes = static_cast<std::size_t>(graph.number_of_nodes());
59+
if (n_nodes == 0) {
60+
return {};
61+
}
62+
63+
const auto n_threads = bioimage_cpp::detail::normalize_thread_count(
64+
number_of_threads, n_nodes
65+
);
66+
67+
const auto *labels = node_labels.data;
68+
69+
const auto label_pair_passes =
70+
[&](const LabelT label_u, const LabelT label_v) -> bool {
71+
if (ignore_label.has_value()) {
72+
if (label_u == *ignore_label || label_v == *ignore_label) {
73+
return false;
74+
}
75+
}
76+
switch (mode) {
77+
case LiftedNodeLabelMode::all:
78+
return true;
79+
case LiftedNodeLabelMode::same:
80+
return label_u == label_v;
81+
case LiftedNodeLabelMode::different:
82+
return label_u != label_v;
83+
}
84+
return false;
85+
};
86+
87+
using EdgeSet = std::unordered_set<
88+
bioimage_cpp::detail::Edge, bioimage_cpp::detail::EdgeHash
89+
>;
90+
std::vector<EdgeSet> per_thread(n_threads);
91+
92+
bioimage_cpp::detail::parallel_for_chunks(
93+
n_threads,
94+
n_nodes,
95+
[&](const std::size_t thread_id, const std::size_t begin, const std::size_t end) {
96+
auto &out = per_thread[thread_id];
97+
BfsWorkspace workspace;
98+
for (std::size_t source = begin; source < end; ++source) {
99+
const auto label_u = labels[source];
100+
if (ignore_label.has_value() && label_u == *ignore_label) {
101+
continue;
102+
}
103+
const auto entries = breadth_first_search(
104+
graph,
105+
static_cast<std::uint64_t>(source),
106+
graph_depth,
107+
/*include_source=*/false,
108+
workspace
109+
);
110+
for (const auto &entry : entries) {
111+
if (entry.distance < 2) {
112+
continue;
113+
}
114+
if (entry.node <= source) {
115+
continue;
116+
}
117+
const auto label_v = labels[static_cast<std::size_t>(entry.node)];
118+
if (!label_pair_passes(label_u, label_v)) {
119+
continue;
120+
}
121+
out.insert(bioimage_cpp::detail::edge_key(
122+
static_cast<std::uint64_t>(source), entry.node
123+
));
124+
}
125+
}
126+
}
127+
);
128+
129+
EdgeSet merged;
130+
std::size_t total = 0;
131+
for (const auto &set : per_thread) {
132+
total += set.size();
133+
}
134+
merged.reserve(total);
135+
for (auto &set : per_thread) {
136+
merged.insert(set.begin(), set.end());
137+
}
138+
139+
std::vector<bioimage_cpp::detail::Edge> result(merged.begin(), merged.end());
140+
std::sort(result.begin(), result.end());
141+
return result;
142+
}
143+
144+
} // namespace bioimage_cpp::graph::lifted_multicut

src/bindings/graph.cxx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include "bioimage_cpp/graph/lifted_from_affinities.hxx"
1212
#include "bioimage_cpp/graph/lifted_multicut.hxx"
1313
#include "bioimage_cpp/graph/lifted_multicut/fusion_move.hxx"
14+
#include "bioimage_cpp/graph/lifted_multicut/lifted_from_node_labels.hxx"
1415
#include "bioimage_cpp/graph/multicut.hxx"
1516
#include "bioimage_cpp/graph/mutex_watershed.hxx"
1617
#include "bioimage_cpp/graph/multicut/fusion_move.hxx"
@@ -25,14 +26,17 @@
2526
#include "bioimage_cpp/graph/undirected_graph.hxx"
2627

2728
#include <nanobind/ndarray.h>
29+
#include <nanobind/stl/optional.h>
2830
#include <nanobind/stl/pair.h>
31+
#include <nanobind/stl/string.h>
2932
#include <nanobind/stl/unique_ptr.h>
3033
#include <nanobind/stl/vector.h>
3134

3235
#include <array>
3336
#include <cstddef>
3437
#include <cstdint>
3538
#include <memory>
39+
#include <optional>
3640
#include <stdexcept>
3741
#include <string>
3842
#include <utility>
@@ -1161,6 +1165,58 @@ UInt64Array lifted_edges_from_affinities_t(
11611165
return result;
11621166
}
11631167

1168+
template <class LabelT>
1169+
UInt64Array lifted_edges_from_node_labels_t(
1170+
const Graph &graph,
1171+
LabelArray<LabelT> node_labels,
1172+
const std::uint64_t graph_depth,
1173+
const std::string &mode,
1174+
std::optional<LabelT> ignore_label,
1175+
const std::size_t number_of_threads
1176+
) {
1177+
if (node_labels.ndim() != 1) {
1178+
throw std::invalid_argument("node_labels must be a 1D array");
1179+
}
1180+
if (node_labels.shape(0) != graph.number_of_nodes()) {
1181+
throw std::invalid_argument(
1182+
"node_labels length must match graph number_of_nodes"
1183+
);
1184+
}
1185+
graph::lifted_multicut::LiftedNodeLabelMode mode_enum;
1186+
if (mode == "all") {
1187+
mode_enum = graph::lifted_multicut::LiftedNodeLabelMode::all;
1188+
} else if (mode == "same") {
1189+
mode_enum = graph::lifted_multicut::LiftedNodeLabelMode::same;
1190+
} else if (mode == "different") {
1191+
mode_enum = graph::lifted_multicut::LiftedNodeLabelMode::different;
1192+
} else {
1193+
throw std::invalid_argument(
1194+
"mode must be one of 'all', 'same', 'different', got '" + mode + "'"
1195+
);
1196+
}
1197+
1198+
ConstArrayView<LabelT> labels_view{
1199+
node_labels.data(),
1200+
{static_cast<std::ptrdiff_t>(node_labels.shape(0))},
1201+
{},
1202+
};
1203+
1204+
std::vector<bioimage_cpp::detail::Edge> lifted_edges;
1205+
{
1206+
nb::gil_scoped_release release;
1207+
lifted_edges = graph::lifted_multicut::lifted_edges_from_node_labels<LabelT>(
1208+
graph, labels_view, graph_depth, mode_enum, ignore_label, number_of_threads
1209+
);
1210+
}
1211+
auto result = make_uint64_array({lifted_edges.size(), 2});
1212+
auto *data = result.data();
1213+
for (std::size_t index = 0; index < lifted_edges.size(); ++index) {
1214+
data[2 * index] = lifted_edges[index].first;
1215+
data[2 * index + 1] = lifted_edges[index].second;
1216+
}
1217+
return result;
1218+
}
1219+
11641220
template <class LabelT>
11651221
DoubleArray accumulate_lifted_affinity_features_t(
11661222
LabelArray<LabelT> labels,
@@ -1880,6 +1936,47 @@ void bind_graph(nb::module_ &m) {
18801936
nb::arg("number_of_threads")
18811937
);
18821938

1939+
m.def(
1940+
"_lifted_edges_from_node_labels_uint32",
1941+
&lifted_edges_from_node_labels_t<std::uint32_t>,
1942+
nb::arg("graph"),
1943+
nb::arg("node_labels"),
1944+
nb::arg("graph_depth"),
1945+
nb::arg("mode"),
1946+
nb::arg("ignore_label"),
1947+
nb::arg("number_of_threads")
1948+
);
1949+
m.def(
1950+
"_lifted_edges_from_node_labels_uint64",
1951+
&lifted_edges_from_node_labels_t<std::uint64_t>,
1952+
nb::arg("graph"),
1953+
nb::arg("node_labels"),
1954+
nb::arg("graph_depth"),
1955+
nb::arg("mode"),
1956+
nb::arg("ignore_label"),
1957+
nb::arg("number_of_threads")
1958+
);
1959+
m.def(
1960+
"_lifted_edges_from_node_labels_int32",
1961+
&lifted_edges_from_node_labels_t<std::int32_t>,
1962+
nb::arg("graph"),
1963+
nb::arg("node_labels"),
1964+
nb::arg("graph_depth"),
1965+
nb::arg("mode"),
1966+
nb::arg("ignore_label"),
1967+
nb::arg("number_of_threads")
1968+
);
1969+
m.def(
1970+
"_lifted_edges_from_node_labels_int64",
1971+
&lifted_edges_from_node_labels_t<std::int64_t>,
1972+
nb::arg("graph"),
1973+
nb::arg("node_labels"),
1974+
nb::arg("graph_depth"),
1975+
nb::arg("mode"),
1976+
nb::arg("ignore_label"),
1977+
nb::arg("number_of_threads")
1978+
);
1979+
18831980
m.def(
18841981
"_accumulate_lifted_affinity_features_uint32",
18851982
&accumulate_lifted_affinity_features_t<std::uint32_t>,

0 commit comments

Comments
 (0)