Skip to content

Commit b9741ef

Browse files
Lifted multicut (#11)
* Add initial lifted multicut implementation, lifted features, and tests * Optimize greedy additive solver * Optimize KL
1 parent 2d7f5d8 commit b9741ef

30 files changed

Lines changed: 4790 additions & 40 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:

0 commit comments

Comments
 (0)