diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index c88ca9d..70c0e43 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1146,6 +1146,41 @@ lifted_features = bic.graph.features.lifted_affinity_features_complex(...) The output column conventions match the local-edge variants (`SIMPLE_EDGE_FEATURE_NAMES`, `COMPLEX_EDGE_FEATURE_NAMES`). +#### Building lifted edges from per-node labels + +When the lifted edges come from semantic / class labels per RAG node rather +than from long-range affinities, nifty offers +`nifty.distributed.liftedNeighborhoodFromNodeLabels`. The bioimage-cpp +equivalent lives under `bic.graph.lifted_multicut`: + +```python +# nifty +lifted_uvs = nifty.distributed.liftedNeighborhoodFromNodeLabels( + graph, node_labels, graphDepth=2, numberOfThreads=4, + mode='all', ignoreLabel=0, +) + +# bioimage-cpp +lifted_uvs = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, node_labels, graph_depth=2, + mode='all', ignore_label=0, number_of_threads=4, +) +``` + +Both functions return an `(n_lifted, 2)` `uint64` array of `(u, v)` pairs +with `u < v`, sorted lexicographically. The BFS hop distance is restricted +to `[2, graph_depth]`, so base-graph edges are excluded. `mode='same'` / +`'different'` filter by whether `node_labels[u] == node_labels[v]`; +`ignore_label` drops every pair where either endpoint label matches. + +Intentional differences vs. nifty: + +- snake_case parameter names (`graph_depth`, `ignore_label`, + `number_of_threads`); +- `ignore_label` defaults to `None` (no filtering) instead of `0`; +- node `0` is iterated as a source (nifty's distributed variant has an + off-by-one that silently skips it). + End-to-end pipeline (also in `examples/segmentation/lifted_multicut_from_affinities.py`): ```python diff --git a/include/bioimage_cpp/graph/lifted_multicut/lifted_from_node_labels.hxx b/include/bioimage_cpp/graph/lifted_multicut/lifted_from_node_labels.hxx new file mode 100644 index 0000000..438a57c --- /dev/null +++ b/include/bioimage_cpp/graph/lifted_multicut/lifted_from_node_labels.hxx @@ -0,0 +1,144 @@ +#pragma once + +#include "bioimage_cpp/array_view.hxx" +#include "bioimage_cpp/detail/edge_hash.hxx" +#include "bioimage_cpp/detail/threading.hxx" +#include "bioimage_cpp/graph/breadth_first_search.hxx" +#include "bioimage_cpp/graph/undirected_graph.hxx" + +#include +#include +#include +#include +#include +#include +#include + +namespace bioimage_cpp::graph::lifted_multicut { + +enum class LiftedNodeLabelMode { all, same, different }; + +// Discover lifted edges from per-node labels by BFS-neighborhood expansion. +// +// For every source node `u` the BFS reports each reachable node `v` together +// with the hop distance. A pair `(u, v)` with `u < v` becomes a lifted edge +// iff: +// - distance is in [2, graph_depth] (distance 1 corresponds to base edges +// and is excluded); +// - neither labels[u] nor labels[v] equals `ignore_label` (when set); +// - the `mode` predicate matches: `all` keeps every pair, `same` keeps +// pairs with labels[u] == labels[v], `different` keeps the complement. +// +// Returns the deduplicated set sorted lexicographically with `u < v`. +template +std::vector lifted_edges_from_node_labels( + const UndirectedGraph &graph, + const ConstArrayView &node_labels, + const std::uint64_t graph_depth, + const LiftedNodeLabelMode mode, + const std::optional ignore_label, + const std::size_t number_of_threads +) { + if (node_labels.ndim() != 1) { + throw std::invalid_argument( + "node_labels must be a 1D array" + ); + } + if (static_cast(node_labels.shape[0]) != graph.number_of_nodes()) { + throw std::invalid_argument( + "node_labels length must match graph number_of_nodes" + ); + } + if (graph_depth < 1) { + throw std::invalid_argument( + "graph_depth must be >= 1" + ); + } + + const auto n_nodes = static_cast(graph.number_of_nodes()); + if (n_nodes == 0) { + return {}; + } + + const auto n_threads = bioimage_cpp::detail::normalize_thread_count( + number_of_threads, n_nodes + ); + + const auto *labels = node_labels.data; + + const auto label_pair_passes = + [&](const LabelT label_u, const LabelT label_v) -> bool { + if (ignore_label.has_value()) { + if (label_u == *ignore_label || label_v == *ignore_label) { + return false; + } + } + switch (mode) { + case LiftedNodeLabelMode::all: + return true; + case LiftedNodeLabelMode::same: + return label_u == label_v; + case LiftedNodeLabelMode::different: + return label_u != label_v; + } + return false; + }; + + using EdgeSet = std::unordered_set< + bioimage_cpp::detail::Edge, bioimage_cpp::detail::EdgeHash + >; + std::vector per_thread(n_threads); + + bioimage_cpp::detail::parallel_for_chunks( + n_threads, + n_nodes, + [&](const std::size_t thread_id, const std::size_t begin, const std::size_t end) { + auto &out = per_thread[thread_id]; + BfsWorkspace workspace; + for (std::size_t source = begin; source < end; ++source) { + const auto label_u = labels[source]; + if (ignore_label.has_value() && label_u == *ignore_label) { + continue; + } + const auto entries = breadth_first_search( + graph, + static_cast(source), + graph_depth, + /*include_source=*/false, + workspace + ); + for (const auto &entry : entries) { + if (entry.distance < 2) { + continue; + } + if (entry.node <= source) { + continue; + } + const auto label_v = labels[static_cast(entry.node)]; + if (!label_pair_passes(label_u, label_v)) { + continue; + } + out.insert(bioimage_cpp::detail::edge_key( + static_cast(source), entry.node + )); + } + } + } + ); + + EdgeSet merged; + std::size_t total = 0; + for (const auto &set : per_thread) { + total += set.size(); + } + merged.reserve(total); + for (auto &set : per_thread) { + merged.insert(set.begin(), set.end()); + } + + std::vector result(merged.begin(), merged.end()); + std::sort(result.begin(), result.end()); + return result; +} + +} // namespace bioimage_cpp::graph::lifted_multicut diff --git a/src/bindings/graph.cxx b/src/bindings/graph.cxx index be65bdf..541b088 100644 --- a/src/bindings/graph.cxx +++ b/src/bindings/graph.cxx @@ -10,6 +10,7 @@ #include "bioimage_cpp/graph/lifted_from_affinities.hxx" #include "bioimage_cpp/graph/lifted_multicut.hxx" #include "bioimage_cpp/graph/lifted_multicut/fusion_move.hxx" +#include "bioimage_cpp/graph/lifted_multicut/lifted_from_node_labels.hxx" #include "bioimage_cpp/graph/multicut.hxx" #include "bioimage_cpp/graph/mutex_watershed.hxx" #include "bioimage_cpp/graph/multicut/fusion_move.hxx" @@ -24,7 +25,9 @@ #include "bioimage_cpp/graph/undirected_graph.hxx" #include +#include #include +#include #include #include @@ -32,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -1146,6 +1150,58 @@ UInt64Array lifted_edges_from_affinities_t( return result; } +template +UInt64Array lifted_edges_from_node_labels_t( + const Graph &graph, + LabelArray node_labels, + const std::uint64_t graph_depth, + const std::string &mode, + std::optional ignore_label, + const std::size_t number_of_threads +) { + if (node_labels.ndim() != 1) { + throw std::invalid_argument("node_labels must be a 1D array"); + } + if (node_labels.shape(0) != graph.number_of_nodes()) { + throw std::invalid_argument( + "node_labels length must match graph number_of_nodes" + ); + } + graph::lifted_multicut::LiftedNodeLabelMode mode_enum; + if (mode == "all") { + mode_enum = graph::lifted_multicut::LiftedNodeLabelMode::all; + } else if (mode == "same") { + mode_enum = graph::lifted_multicut::LiftedNodeLabelMode::same; + } else if (mode == "different") { + mode_enum = graph::lifted_multicut::LiftedNodeLabelMode::different; + } else { + throw std::invalid_argument( + "mode must be one of 'all', 'same', 'different', got '" + mode + "'" + ); + } + + ConstArrayView labels_view{ + node_labels.data(), + {static_cast(node_labels.shape(0))}, + {}, + }; + + std::vector lifted_edges; + { + nb::gil_scoped_release release; + lifted_edges = graph::lifted_multicut::lifted_edges_from_node_labels( + graph, labels_view, graph_depth, mode_enum, ignore_label, number_of_threads + ); + } + auto result = make_uint64_array({lifted_edges.size(), 2}); + auto *data = result.data(); + for (std::size_t index = 0; index < lifted_edges.size(); ++index) { + data[2 * index] = lifted_edges[index].first; + data[2 * index + 1] = lifted_edges[index].second; + } + return result; +} + template DoubleArray accumulate_lifted_affinity_features_t( LabelArray labels, @@ -1816,6 +1872,47 @@ void bind_graph(nb::module_ &m) { nb::arg("number_of_threads") ); + m.def( + "_lifted_edges_from_node_labels_uint32", + &lifted_edges_from_node_labels_t, + nb::arg("graph"), + nb::arg("node_labels"), + nb::arg("graph_depth"), + nb::arg("mode"), + nb::arg("ignore_label"), + nb::arg("number_of_threads") + ); + m.def( + "_lifted_edges_from_node_labels_uint64", + &lifted_edges_from_node_labels_t, + nb::arg("graph"), + nb::arg("node_labels"), + nb::arg("graph_depth"), + nb::arg("mode"), + nb::arg("ignore_label"), + nb::arg("number_of_threads") + ); + m.def( + "_lifted_edges_from_node_labels_int32", + &lifted_edges_from_node_labels_t, + nb::arg("graph"), + nb::arg("node_labels"), + nb::arg("graph_depth"), + nb::arg("mode"), + nb::arg("ignore_label"), + nb::arg("number_of_threads") + ); + m.def( + "_lifted_edges_from_node_labels_int64", + &lifted_edges_from_node_labels_t, + nb::arg("graph"), + nb::arg("node_labels"), + nb::arg("graph_depth"), + nb::arg("mode"), + nb::arg("ignore_label"), + nb::arg("number_of_threads") + ); + m.def( "_accumulate_lifted_affinity_features_uint32", &accumulate_lifted_affinity_features_t, diff --git a/src/bioimage_cpp/graph/lifted_multicut/__init__.py b/src/bioimage_cpp/graph/lifted_multicut/__init__.py index 0b52145..e7d30df 100644 --- a/src/bioimage_cpp/graph/lifted_multicut/__init__.py +++ b/src/bioimage_cpp/graph/lifted_multicut/__init__.py @@ -15,6 +15,9 @@ :class:`GreedyAdditiveProposalGenerator` re-exported from :mod:`bioimage_cpp.graph.multicut` (the lifted fusion-move solver consumes them). +- :func:`lifted_edges_from_node_labels` — discover lifted edges by combining + a BFS neighborhood on the base graph with a per-node label predicate + (port of ``nifty.distributed.liftedNeighborhoodFromNodeLabels``). """ from __future__ import annotations @@ -33,7 +36,111 @@ _as_edge_costs, _as_node_labels, _as_uv_array, + _normalize_number_of_threads, ) + + +_LIFTED_EDGES_FROM_NODE_LABELS_BY_DTYPE = { + np.dtype("uint32"): _core._lifted_edges_from_node_labels_uint32, + np.dtype("uint64"): _core._lifted_edges_from_node_labels_uint64, + np.dtype("int32"): _core._lifted_edges_from_node_labels_int32, + np.dtype("int64"): _core._lifted_edges_from_node_labels_int64, +} + + +def lifted_edges_from_node_labels( + graph, + node_labels, + graph_depth: int, + *, + mode: str = "all", + ignore_label: int | None = None, + number_of_threads: int = 0, +) -> np.ndarray: + """Discover lifted edges from a BFS neighborhood plus per-node labels. + + For every source node ``u`` the BFS reports each node ``v`` reached within + ``graph_depth`` hops. The pair ``(u, v)`` (with ``u < v``) becomes a lifted + edge iff: + + - the BFS hop distance is in ``[2, graph_depth]`` — base-graph edges + (distance 1) are always excluded; + - neither ``node_labels[u]`` nor ``node_labels[v]`` equals ``ignore_label`` + (when ``ignore_label`` is not ``None``); + - the ``mode`` predicate is satisfied: ``'all'`` keeps every pair, + ``'same'`` keeps pairs with matching labels, ``'different'`` keeps the + complement. + + Mirrors ``nifty.distributed.liftedNeighborhoodFromNodeLabels`` with the + following intentional differences: snake-case parameter names, + ``ignore_label`` defaults to ``None`` (no filtering), and node ``0`` is + iterated as a source (nifty's distributed variant skips it). + + Parameters + ---------- + graph: + :class:`bioimage_cpp.graph.UndirectedGraph` or + :class:`bioimage_cpp.graph.RegionAdjacencyGraph`. + node_labels: + 1D array of length ``graph.number_of_nodes``. Supported dtypes: + ``uint32``, ``uint64``, ``int32``, ``int64``. + graph_depth: + Maximum BFS hop distance (inclusive). Must be ``>= 1``; + ``graph_depth == 1`` returns an empty array because base edges are + excluded by construction. + mode: + ``'all'``, ``'same'``, or ``'different'``. + ignore_label: + If set, drop every pair where either endpoint label equals this value. + number_of_threads: + ``0`` (default) selects the bioimage-cpp default thread count. + + Returns + ------- + np.ndarray + ``(n_lifted, 2)`` ``uint64`` array of ``(u, v)`` pairs with + ``u < v``, sorted lexicographically. + """ + if mode not in ("all", "same", "different"): + raise ValueError( + f"mode must be one of 'all', 'same', 'different', got {mode!r}" + ) + depth = int(graph_depth) + if depth < 1: + raise ValueError(f"graph_depth must be >= 1, got {depth}") + + label_array = np.ascontiguousarray(np.asarray(node_labels)) + if label_array.ndim != 1: + raise ValueError( + f"node_labels must be a 1D array, got ndim={label_array.ndim}" + ) + if label_array.shape[0] != int(graph.number_of_nodes): + raise ValueError( + "node_labels length must match graph number_of_nodes, got " + f"node_labels length={label_array.shape[0]}, " + f"number_of_nodes={int(graph.number_of_nodes)}" + ) + + try: + run = _LIFTED_EDGES_FROM_NODE_LABELS_BY_DTYPE[label_array.dtype] + except KeyError as error: + supported = ", ".join( + str(dtype) for dtype in _LIFTED_EDGES_FROM_NODE_LABELS_BY_DTYPE + ) + raise TypeError( + f"node_labels must have one of dtypes ({supported}), got " + f"dtype={label_array.dtype}" + ) from error + + ignore_arg = None if ignore_label is None else int(ignore_label) + return run( + graph, + label_array, + depth, + mode, + ignore_arg, + _normalize_number_of_threads(number_of_threads), + ) from ..multicut import ( GreedyAdditiveProposalGenerator, ProposalGenerator, @@ -555,6 +662,7 @@ def optimize(self, objective: LiftedMulticutObjective) -> np.ndarray: "LiftedMulticutSolver", "ProposalGenerator", "WatershedProposalGenerator", + "lifted_edges_from_node_labels", "lifted_multicut_problem_path", "load_lifted_multicut_problem", ] diff --git a/tests/graph/lifted_multicut/test_lifted_edges_from_node_labels.py b/tests/graph/lifted_multicut/test_lifted_edges_from_node_labels.py new file mode 100644 index 0000000..957da34 --- /dev/null +++ b/tests/graph/lifted_multicut/test_lifted_edges_from_node_labels.py @@ -0,0 +1,240 @@ +import numpy as np +import pytest + +import bioimage_cpp as bic + + +def _make_chain(n: int): + edges = np.array([[i, i + 1] for i in range(n - 1)], dtype=np.uint64) + return bic.graph.UndirectedGraph.from_edges(n, edges) + + +def _as_pair_set(uvs): + return set(map(tuple, uvs.tolist())) + + +def test_chain_depth_1_returns_empty(): + graph = _make_chain(6) + labels = np.array([0, 1, 2, 3, 4, 5], dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=1, mode="all" + ) + assert out.shape == (0, 2) + assert out.dtype == np.uint64 + + +def test_chain_depth_2_pairs_at_distance_two(): + graph = _make_chain(6) + labels = np.arange(6, dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + assert _as_pair_set(out) == {(0, 2), (1, 3), (2, 4), (3, 5)} + # Sorted lexicographically. + assert out.tolist() == sorted(out.tolist()) + + +def test_chain_depth_3_includes_distance_three(): + graph = _make_chain(6) + labels = np.arange(6, dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=3, mode="all" + ) + assert _as_pair_set(out) == { + (0, 2), (1, 3), (2, 4), (3, 5), # distance 2 + (0, 3), (1, 4), (2, 5), # distance 3 + } + + +def test_mode_same_and_different(): + graph = _make_chain(6) + # Two label-blocks: nodes 0..2 share label 1, nodes 3..5 share label 2. + # At depth=2 the only pairs are at distance 2: + # (0,2): (1,1) same; (1,3): (1,2) different; + # (2,4): (1,2) different; (3,5): (2,2) same. + labels = np.array([1, 1, 1, 2, 2, 2], dtype=np.uint64) + same = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="same" + ) + diff = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="different" + ) + all_pairs = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + + assert _as_pair_set(same) == {(0, 2), (3, 5)} + assert _as_pair_set(diff) == {(1, 3), (2, 4)} + # 'same' + 'different' must partition 'all'. + assert _as_pair_set(same).isdisjoint(_as_pair_set(diff)) + assert _as_pair_set(same) | _as_pair_set(diff) == _as_pair_set(all_pairs) + + +def test_ignore_label_drops_pairs_with_that_label(): + graph = _make_chain(6) + labels = np.array([1, 1, 0, 2, 3, 3], dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all", ignore_label=0 + ) + # Node 2 has the ignore label, so every pair containing it is dropped: + # (0,2), (2,4) are gone; (1,3) and (3,5) remain. + assert _as_pair_set(out) == {(1, 3), (3, 5)} + + +def test_star_graph_emits_all_leaf_leaf_pairs(): + # Node 0 is the center; nodes 1..4 are leaves connected only to 0. + edges = np.array([[0, 1], [0, 2], [0, 3], [0, 4]], dtype=np.uint64) + graph = bic.graph.UndirectedGraph.from_edges(5, edges) + labels = np.arange(5, dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + # Every pair of leaves is at distance 2 via the center. No base edges. + assert _as_pair_set(out) == {(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)} + + +def test_node_zero_is_iterated_as_source(): + # Regression guard: nifty.distributed.liftedNeighborhoodFromNodeLabels + # silently skips node 0 as a source (off-by-one). bic should not. + graph = _make_chain(4) + labels = np.arange(4, dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + pairs = _as_pair_set(out) + assert (0, 2) in pairs + + +def test_disconnected_components(): + edges = np.array([[0, 1], [2, 3]], dtype=np.uint64) + graph = bic.graph.UndirectedGraph.from_edges(4, edges) + labels = np.arange(4, dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=5, mode="all" + ) + # Nothing is at distance >= 2 in either two-node component. + assert out.shape == (0, 2) + + +def test_rag_input_accepted(): + # Build a tiny 2D labeled image and use its RAG directly. + labels_img = np.array( + [ + [0, 0, 1, 1, 2, 2], + [0, 0, 1, 1, 2, 2], + [3, 3, 4, 4, 5, 5], + [3, 3, 4, 4, 5, 5], + ], + dtype=np.uint32, + ) + rag = bic.graph.region_adjacency_graph(labels_img) + node_labels = np.array([10, 10, 20, 10, 10, 20], dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + rag, node_labels, graph_depth=2, mode="all" + ) + assert out.dtype == np.uint64 + assert out.ndim == 2 and out.shape[1] == 2 + # Sanity: every pair is a valid (u < v) and not a base edge. + for u, v in out.tolist(): + assert u < v + assert rag.find_edge(int(u), int(v)) == -1 + + +@pytest.mark.parametrize( + "dtype", [np.uint32, np.uint64, np.int32, np.int64] +) +def test_dtype_variants_match(dtype): + graph = _make_chain(6) + labels = np.array([1, 1, 2, 2, 3, 3], dtype=dtype) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all", ignore_label=0 + ) + # No node has the ignore label; result must match the no-ignore call. + out_noignore = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + assert _as_pair_set(out) == _as_pair_set(out_noignore) + assert out.dtype == np.uint64 + + +def test_round_trip_into_lifted_multicut_objective(): + # The output should plug straight into LiftedMulticutObjective. + graph = _make_chain(6) + base_costs = np.ones(5, dtype=np.float64) + labels = np.array([1, 1, 2, 2, 3, 3], dtype=np.uint64) + lifted_uvs = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="different" + ) + lifted_costs = -np.ones(lifted_uvs.shape[0], dtype=np.float64) + objective = bic.graph.lifted_multicut.LiftedMulticutObjective( + graph, base_costs, + lifted_uvs=lifted_uvs, lifted_costs=lifted_costs, + ) + assert objective.number_of_lifted_edges == lifted_uvs.shape[0] + + +def test_error_on_unknown_mode(): + graph = _make_chain(3) + labels = np.zeros(3, dtype=np.uint64) + with pytest.raises(ValueError, match="mode"): + bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="not-a-mode" + ) + + +def test_error_on_zero_graph_depth(): + graph = _make_chain(3) + labels = np.zeros(3, dtype=np.uint64) + with pytest.raises(ValueError, match="graph_depth"): + bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=0, mode="all" + ) + + +def test_error_on_wrong_ndim(): + graph = _make_chain(3) + labels = np.zeros((3, 1), dtype=np.uint64) + with pytest.raises(ValueError, match="1D"): + bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + + +def test_error_on_length_mismatch(): + graph = _make_chain(3) + labels = np.zeros(5, dtype=np.uint64) + with pytest.raises(ValueError, match="number_of_nodes"): + bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + + +def test_error_on_unsupported_dtype(): + graph = _make_chain(3) + labels = np.zeros(3, dtype=np.float32) + with pytest.raises(TypeError, match="dtype"): + bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + + +def test_threading_produces_same_result(): + graph = _make_chain(10) + labels = np.arange(10, dtype=np.uint64) + single = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=3, mode="all", number_of_threads=1 + ) + multi = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=3, mode="all", number_of_threads=4 + ) + assert _as_pair_set(single) == _as_pair_set(multi) + assert single.tolist() == multi.tolist() # sorted output is deterministic + + +def test_empty_graph(): + graph = bic.graph.UndirectedGraph(0) + labels = np.zeros(0, dtype=np.uint64) + out = bic.graph.lifted_multicut.lifted_edges_from_node_labels( + graph, labels, graph_depth=2, mode="all" + ) + assert out.shape == (0, 2)