Skip to content

Commit 8ba78c9

Browse files
Add initial lifted multicut implementation, lifted features, and tests
1 parent 2d7f5d8 commit 8ba78c9

29 files changed

Lines changed: 4398 additions & 35 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ venv/
2727

2828
# Data
2929
*.h5
30+
*.npz
3031

3132
CLAUDE.md

MIGRATION_GUIDE.md

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,209 @@ Notes:
486486
`ProposalGenerator` and provide your own `_build` returning a C++
487487
proposal-generator object if you need to extend the set.
488488

489+
## Lifted Multicut
490+
491+
Nifty exposes lifted multicut through a separate objective + solver hierarchy.
492+
`bioimage-cpp` mirrors the structure with `LiftedMulticutObjective` and a
493+
`LiftedMulticutSolver` class hierarchy.
494+
495+
Nifty:
496+
497+
```python
498+
import nifty.graph.opt.lifted_multicut as nlmc
499+
500+
objective = nlmc.liftedMulticutObjective(graph)
501+
objective.insertLiftedEdgesBfs(max_distance=3)
502+
for u, v, w in lifted_weights:
503+
objective.setCost(u, v, w)
504+
solver = objective.liftedMulticutGreedyAdditiveFactory().create(objective)
505+
labels = solver.optimize()
506+
```
507+
508+
bioimage-cpp:
509+
510+
```python
511+
import bioimage_cpp as bic
512+
513+
objective = bic.graph.LiftedMulticutObjective(
514+
graph,
515+
edge_costs,
516+
lifted_uvs=lifted_uvs,
517+
lifted_costs=lifted_costs,
518+
bfs_distance=3, # optional: also insert zero-weight lifted edges within k hops
519+
)
520+
labels = bic.graph.LiftedGreedyAdditiveMulticut().optimize(objective)
521+
energy = objective.energy(labels)
522+
```
523+
524+
`LiftedMulticutObjective` accepts:
525+
526+
- `graph` — an `UndirectedGraph` or `RegionAdjacencyGraph`. The constructor
527+
copies the topology, so further mutations on the input graph do not affect
528+
the objective.
529+
- `edge_costs` — 1D `float64` array of length `graph.number_of_edges`.
530+
- `lifted_uvs` / `lifted_costs` — optional `(n_lifted, 2)` uint64 array and 1D
531+
float64 array of equal length, listing the additional lifted edges and
532+
their weights.
533+
- `bfs_distance` — optional positive integer. Adds a zero-weight lifted edge
534+
for every pair of nodes within this many base-graph hops of each other
535+
(excluding nodes already connected by a base edge). Pairs with both
536+
`lifted_uvs` and `bfs_distance` to seed the topology and then update
537+
specific weights.
538+
- `overwrite_existing` — when `True`, lifted entries that coincide with an
539+
existing edge replace its weight; the default accumulates.
540+
541+
Available solvers (no fusion-move / ILP solvers yet):
542+
543+
| nifty factory | bioimage-cpp solver |
544+
| --- | --- |
545+
| `liftedMulticutGreedyAdditiveFactory()` | `LiftedGreedyAdditiveMulticut()` |
546+
| `liftedMulticutKernighanLinFactory(...)` | `LiftedKernighanLinMulticut(...)` |
547+
| `chainedSolversFactory([...])` | `LiftedChainedSolvers([...])` |
548+
549+
A typical warm-started solve combines greedy and KL:
550+
551+
```python
552+
solver = bic.graph.LiftedChainedSolvers([
553+
bic.graph.LiftedGreedyAdditiveMulticut(),
554+
bic.graph.LiftedKernighanLinMulticut(number_of_outer_iterations=10),
555+
])
556+
labels = solver.optimize(objective)
557+
```
558+
559+
Notes:
560+
561+
- Output labels are dense `uint64` ids in `0 .. number_of_clusters - 1`.
562+
- Every output cluster is *base-graph connected* — both solvers enforce this
563+
invariant. A strongly attractive lifted edge between two nodes that have no
564+
base-graph path between them will not merge their clusters.
565+
- `LiftedKernighanLinMulticut` warm-starts from the lifted greedy-additive
566+
solution when the objective's current labels are the trivial singleton
567+
labeling.
568+
- `objective.set_cost(u, v, weight, overwrite=False)` updates or inserts a
569+
single lifted edge.
570+
- The lifted graph is exposed via `objective.lifted_graph`; the first
571+
`objective.number_of_base_edges` edges are exactly the base edges in the
572+
same order as in `graph`.
573+
574+
### Building a lifted multicut problem from affinities
575+
576+
For the common case of lifted multicut on a watershed over-segmentation,
577+
nifty offers `nifty.graph.rag.computeLiftedEdgesFromRagAndOffsets` (lifted
578+
edge discovery) and per-channel affinity accumulators. bioimage-cpp exposes
579+
two focused helpers that cover the same workflow:
580+
581+
```python
582+
# Discover lifted edges implied by long-range affinity offsets. 1-hop offsets
583+
# are skipped automatically, so the full offset list can be passed in.
584+
lifted_uvs = bic.graph.lifted_edges_from_affinities(
585+
rag, oversegmentation, offsets, number_of_threads=0,
586+
)
587+
588+
# Accumulate (mean, size) statistics per lifted edge. Pixel pairs whose
589+
# (u, v) does not appear in `lifted_uvs` are skipped, so local edges are
590+
# never contaminated with long-range affinities.
591+
lifted_features = bic.graph.lifted_affinity_features(
592+
oversegmentation, affinities, offsets, lifted_uvs,
593+
number_of_threads=0,
594+
)
595+
# For the 12-column feature set (mean, median, std, min, max, percentiles, size):
596+
lifted_features = bic.graph.lifted_affinity_features_complex(...)
597+
```
598+
599+
The output column conventions match the local-edge variants
600+
(`SIMPLE_EDGE_FEATURE_NAMES`, `COMPLEX_EDGE_FEATURE_NAMES`).
601+
602+
End-to-end pipeline (also in `examples/segmentation/lifted_multicut_from_affinities.py`):
603+
604+
```python
605+
rag = bic.graph.region_adjacency_graph(oversegmentation)
606+
local_costs = local_threshold - bic.graph.affinity_features(
607+
rag, oversegmentation, direct_affinities, direct_offsets,
608+
)[:, 0]
609+
lifted_uvs = bic.graph.lifted_edges_from_affinities(
610+
rag, oversegmentation, long_range_offsets,
611+
)
612+
lifted_costs = lifted_threshold - bic.graph.lifted_affinity_features(
613+
oversegmentation, long_range_affinities, long_range_offsets, lifted_uvs,
614+
)[:, 0]
615+
objective = bic.graph.LiftedMulticutObjective(
616+
rag, local_costs, lifted_uvs=lifted_uvs, lifted_costs=lifted_costs,
617+
)
618+
```
619+
620+
## External Problem Instances
621+
622+
bioimage-cpp ships pooch-backed downloaders for the multicut and lifted
623+
multicut benchmark problems used by the development comparison scripts and
624+
the regression tests. Files are cached under `~/.cache/bioimage-cpp/`,
625+
overridable via the `BIOIMAGE_CPP_CACHE` environment variable.
626+
627+
`pooch` is an optional runtime dependency — install via the `test` or `data`
628+
extras, e.g. `pip install bioimage-cpp[data]`.
629+
630+
Multicut problems (3 samples × 2 sizes, originally from
631+
`elf.segmentation.utils.load_multicut_problem`):
632+
633+
```python
634+
# Returns (UndirectedGraph, edge_costs)
635+
graph, costs = bic.graph.load_multicut_problem(sample="A", size="small")
636+
# Or just the underlying arrays
637+
uv_ids, costs = bic.graph.load_multicut_problem_data(sample="B", size="medium")
638+
# Or the cached file path
639+
path = bic.graph.multicut_problem_path(sample="C", size="medium")
640+
```
641+
642+
Valid samples are `"A"`, `"B"`, `"C"`; valid sizes are `"small"` and
643+
`"medium"`. The legacy `load_external_multicut_problem` /
644+
`load_external_multicut_problem_data` / `external_multicut_problem_path`
645+
shims default to sample A, size small and continue to honor the
646+
`BIOIMAGE_CPP_EXTERNAL_MULTICUT_PATH` and
647+
`BIOIMAGE_CPP_EXTERNAL_MULTICUT_CACHE` environment variables.
648+
649+
Lifted multicut problems (2D ISBI slice and full 3D volume, built by
650+
`examples/segmentation/serialize_lifted_problem.py`):
651+
652+
```python
653+
problem = bic.graph.load_lifted_multicut_problem(size="2d")
654+
# Fields: n_nodes (int), local_uvs, local_costs, lifted_uvs, lifted_costs.
655+
graph = bic.graph.UndirectedGraph.from_edges(problem.n_nodes, problem.local_uvs)
656+
objective = bic.graph.LiftedMulticutObjective(
657+
graph,
658+
problem.local_costs,
659+
lifted_uvs=problem.lifted_uvs,
660+
lifted_costs=problem.lifted_costs,
661+
)
662+
```
663+
664+
Notes:
665+
666+
- Every download is integrity-checked against a SHA256 in the registry; a
667+
corrupted cache file is detected on the next `load_*` call.
668+
- Downloads are lazy: nothing happens until you call a loader. Re-runs are
669+
free (the cached file is reused).
670+
- For air-gapped use, fetch the file once on a machine with network access
671+
and copy `~/.cache/bioimage-cpp/<filename>` to the same path on the target
672+
machine.
673+
674+
## Breadth-First Search
675+
676+
Nifty has an internal `BreadthFirstSearch` template used during lifted-edge
677+
insertion. `bioimage-cpp` exposes a Python-friendly free function:
678+
679+
```python
680+
nodes, distances = bic.graph.breadth_first_search(
681+
graph,
682+
source,
683+
max_distance=3, # optional, default: full component
684+
include_source=True, # set to False for k-hop neighborhoods excluding self
685+
)
686+
```
687+
688+
Both output arrays are 1D `uint64`, listing reached nodes in BFS order with
689+
their hop distance from the source. Useful for building lifted-edge sets
690+
manually, sampling local neighborhoods, or computing graph distances.
691+
489692
## Segmentation Overlaps
490693

491694
Nifty:
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from statistics import median
5+
from time import perf_counter
6+
from typing import Callable
7+
8+
import numpy as np
9+
10+
11+
def parser(description: str) -> argparse.ArgumentParser:
12+
arg_parser = argparse.ArgumentParser(description=description)
13+
arg_parser.add_argument(
14+
"--size",
15+
choices=("2d", "3d"),
16+
default="3d",
17+
help="Lifted multicut problem instance to load (default: 3d).",
18+
)
19+
arg_parser.add_argument(
20+
"--repeats",
21+
type=int,
22+
default=3,
23+
help="Number of timed repeats per implementation.",
24+
)
25+
arg_parser.add_argument(
26+
"--timeout",
27+
type=float,
28+
default=60.0,
29+
help="Download timeout in seconds if the lifted problem is not cached.",
30+
)
31+
arg_parser.add_argument(
32+
"--energy-bound",
33+
type=float,
34+
default=None,
35+
help=(
36+
"Optional maximum accepted energy for both implementations. If "
37+
"omitted, energies are reported but not asserted."
38+
),
39+
)
40+
return arg_parser
41+
42+
43+
def load_problem(size: str, *, timeout: float):
44+
import bioimage_cpp as bic
45+
import nifty
46+
import nifty.graph.opt.lifted_multicut as nlmc
47+
48+
problem = bic.graph.load_lifted_multicut_problem(size, timeout=timeout)
49+
50+
bic_graph = bic.graph.UndirectedGraph.from_edges(
51+
problem.n_nodes, problem.local_uvs
52+
)
53+
54+
nifty_graph = nifty.graph.undirectedGraph(int(problem.n_nodes))
55+
nifty_graph.insertEdges(problem.local_uvs.astype(np.uint64, copy=False))
56+
nifty_objective = nlmc.liftedMulticutObjective(nifty_graph)
57+
nifty_objective.setGraphEdgesCosts(problem.local_costs)
58+
if problem.lifted_uvs.shape[0] > 0:
59+
nifty_objective.setCosts(
60+
problem.lifted_uvs.astype(np.uint64, copy=False),
61+
problem.lifted_costs.astype(np.float64, copy=False),
62+
)
63+
64+
return bic_graph, nifty_objective, problem
65+
66+
67+
def time_call(function: Callable[[], np.ndarray], repeats: int):
68+
timings = []
69+
result = None
70+
for _ in range(repeats):
71+
start = perf_counter()
72+
result = function()
73+
timings.append(perf_counter() - start)
74+
assert result is not None
75+
return timings, result
76+
77+
78+
def optimize_bic_solver(make_bic_solver, bic_graph, problem):
79+
import bioimage_cpp as bic
80+
81+
objective = bic.graph.LiftedMulticutObjective(
82+
bic_graph,
83+
problem.local_costs,
84+
lifted_uvs=problem.lifted_uvs,
85+
lifted_costs=problem.lifted_costs,
86+
)
87+
return make_bic_solver().optimize(objective)
88+
89+
90+
def bic_energy(bic_graph, problem, labels: np.ndarray) -> float:
91+
import bioimage_cpp as bic
92+
93+
objective = bic.graph.LiftedMulticutObjective(
94+
bic_graph,
95+
problem.local_costs,
96+
lifted_uvs=problem.lifted_uvs,
97+
lifted_costs=problem.lifted_costs,
98+
)
99+
return float(objective.energy(labels))
100+
101+
102+
def nifty_energy(nifty_objective, labels: np.ndarray) -> float:
103+
return float(nifty_objective.evalNodeLabels(labels.astype(np.uint64, copy=False)))
104+
105+
106+
def run_comparison(
107+
name: str,
108+
make_bic_solver,
109+
make_nifty_solver,
110+
args: argparse.Namespace,
111+
) -> dict[str, float]:
112+
bic_graph, nifty_objective, problem = load_problem(args.size, timeout=args.timeout)
113+
114+
bic_timings, bic_labels = time_call(
115+
lambda: optimize_bic_solver(make_bic_solver, bic_graph, problem),
116+
args.repeats,
117+
)
118+
nifty_timings, nifty_labels = time_call(
119+
lambda: make_nifty_solver(nifty_objective).create(nifty_objective).optimize(),
120+
args.repeats,
121+
)
122+
123+
bic_score = bic_energy(bic_graph, problem, bic_labels)
124+
nifty_score = nifty_energy(nifty_objective, np.asarray(nifty_labels))
125+
126+
if args.energy_bound is not None:
127+
if bic_score > args.energy_bound:
128+
raise AssertionError(
129+
f"bioimage-cpp {name} energy {bic_score:.6f} exceeds bound "
130+
f"{args.energy_bound:.6f}"
131+
)
132+
if nifty_score > args.energy_bound:
133+
raise AssertionError(
134+
f"nifty {name} energy {nifty_score:.6f} exceeds bound "
135+
f"{args.energy_bound:.6f}"
136+
)
137+
138+
result = {
139+
"bioimage_cpp_energy": bic_score,
140+
"nifty_energy": nifty_score,
141+
"energy_difference": bic_score - nifty_score,
142+
"bioimage_cpp_median_runtime": median(bic_timings),
143+
"nifty_median_runtime": median(nifty_timings),
144+
}
145+
print(f"solver: {name}")
146+
print(
147+
f"problem: size={args.size}, nodes={problem.n_nodes}, "
148+
f"local edges={problem.local_uvs.shape[0]}, "
149+
f"lifted edges={problem.lifted_uvs.shape[0]}"
150+
)
151+
print(f"bioimage-cpp energy: {bic_score:.6f}")
152+
print(f"nifty energy: {nifty_score:.6f}")
153+
print(f"energy difference: {bic_score - nifty_score:.6f}")
154+
print(f"bioimage-cpp median runtime [s]: {median(bic_timings):.6f}")
155+
print(f"nifty median runtime [s]: {median(nifty_timings):.6f}")
156+
return result
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
3+
import bioimage_cpp as bic
4+
5+
from _compatibility import parser, run_comparison
6+
7+
8+
def main() -> None:
9+
args = parser(
10+
"Compare bioimage-cpp and nifty greedy-additive lifted multicut."
11+
).parse_args()
12+
run_comparison(
13+
"lifted_greedy_additive",
14+
lambda: bic.graph.LiftedGreedyAdditiveMulticut(),
15+
lambda objective: objective.liftedMulticutGreedyAdditiveFactory(),
16+
args,
17+
)
18+
19+
20+
if __name__ == "__main__":
21+
main()

0 commit comments

Comments
 (0)