Skip to content

Commit 2d7f5d8

Browse files
Implement fusion moves (#10)
* Implement edge-weighted graph watershed * Add fusion moves implementation * Add comparison to nifty * Optimize fusion moves * Implement parallelization for fusion movs
1 parent e319956 commit 2d7f5d8

21 files changed

Lines changed: 2534 additions & 11 deletions

AGENTS.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,57 @@ Before introducing union-finds, priority queues, edge hashing, stride math, thre
3333

3434
If a needed helper does not exist but is generally useful, add it to `detail/` as a header-only utility with a focused API rather than inlining it into one algorithm. Keep the contract small and well-documented; over time `detail/` is what lets multiple modules stay small and consistent.
3535

36+
## Reusable algorithmic infrastructure
37+
38+
Some larger pieces of infrastructure sit above `detail/` but are still
39+
intended to be reused across objective types. Check these before starting a
40+
new fusion-move / proposal-based / contraction-based solver:
41+
42+
- `include/bioimage_cpp/graph/detail/fusion_contract.hxx` — objective-agnostic
43+
agreement-projection primitive. `contract_by_agreement(graph, proposals,
44+
n_proposals, ...)` returns `{contracted_graph, contracted_edge_of_original,
45+
root_of_node}`. Already supports N ≥ 1 proposals; reused by both pairwise
46+
and joint multi-proposal fuses.
47+
- `include/bioimage_cpp/graph/proposal_generator.hxx``ProposalGeneratorBase`
48+
abstract class. Concrete generators (Watershed, GreedyAdditiveMulticut) live
49+
in `proposal_generators/` and depend only on `(graph, edge_costs)` plus an
50+
RNG seed. They emit `std::vector<std::uint64_t>` node labelings and are
51+
therefore reusable across multicut, lifted multicut, mincut, etc.
52+
- `include/bioimage_cpp/graph/multicut/greedy_additive.hxx` — exposes
53+
`GreedyAdditiveWorkspace` so multiple invocations on different graphs
54+
share scratch buffers. Use this pattern (workspace + `reset(graph)`) when
55+
a fusion-move driver calls a sub-solver inside its iteration loop.
56+
- `UndirectedGraph::from_sorted_unique_edges(N, edges, populate_lookup=false)`
57+
— bulk graph construction without the per-edge hash insertion in
58+
`insert_edge`. Pair with `populate_lookup=false` when the consumer only
59+
walks edges / adjacency (the multicut sub-solvers do).
60+
- `detail/threading.hxx::parallel_for_chunks` — the only threading primitive
61+
we use. New parallel solvers should not introduce alternatives.
62+
63+
When porting fusion moves to a new objective (e.g. lifted multicut):
64+
65+
1. The driver loop in `multicut/fusion_move.hxx::FusionMoveSolver::optimize`
66+
is short and dense. Duplicate it for the new objective rather than
67+
abstracting it via a template/CRTP base — the moving parts (cost
68+
aggregation, energy evaluator, sub-solver type) are objective-specific
69+
and template gymnastics buy little.
70+
2. Reuse `contract_by_agreement` unchanged; it operates on the *base* graph
71+
only.
72+
3. Write a new `fuse_multi(...)` that aggregates *both* base and lifted (or
73+
other auxiliary) weights through `contraction.contracted_edge_of_original`
74+
and `contraction.root_of_node`, calls the new objective's sub-solver, and
75+
lifts labels back via `root_of_node`.
76+
4. Reuse the existing `WatershedProposalGenerator` and
77+
`GreedyAdditiveMulticutProposalGenerator` verbatim; they only depend on
78+
the base graph + base costs and emit node labelings. Add objective-specific
79+
generators (e.g. `GreedyAdditiveLiftedMulticutProposalGenerator`) only if a
80+
meaningful new proposal strategy emerges.
81+
5. Reuse the per-thread parallel pattern from `optimize`: stage-1 parallel
82+
proposal generation + parallel pairwise fuse, stage-2 sequential joint
83+
multi-fuse on leftover candidates. Per-thread `GreedyAdditiveWorkspace`
84+
becomes per-thread `<NewObjective>Workspace` if the new sub-solver follows
85+
the same pattern.
86+
3687
## Dependencies
3788

3889
**Allowed**: C++20 stdlib, `nanobind`, `numpy`, `scikit-build-core`, `cmake`, `pytest`, `cibuildwheel` (CI only).
@@ -120,6 +171,25 @@ Tests run against the installed Python package. For each public function, cover:
120171
121172
Correct first, fast second. Benchmark before adding complexity. Prefer algorithmic improvements over build-level tweaks. Release the GIL for expensive kernels. Add threading only after the single-threaded implementation is stable; it must be portable and user-controllable.
122173
174+
### Profiling
175+
176+
Measure before optimizing. The codebase carries a lightweight per-phase profiling utility for exactly this:
177+
178+
- Header: `include/bioimage_cpp/detail/profile.hxx`. Macros: `BIOIMAGE_PROFILE_INIT(name)`, `BIOIMAGE_PROFILE_SCOPE(name, "label")`, `BIOIMAGE_PROFILE_REPORT(name)`.
179+
- Gated behind the `BIOIMAGE_PROFILE` compile-time flag. Outside of profile builds the macros expand to no-ops and a `NullProfiler` stub so the same code compiles unchanged.
180+
- Enable via CMake option: `pip install -e . --no-build-isolation -C cmake.define.BIOIMAGE_PROFILE=ON`. Rebuild without the flag for production work.
181+
- Reports per-phase wall-clock totals to stderr at the end of the instrumented scope (e.g., at the end of `optimize`). Same labels accumulate across multiple invocations of the scope (e.g., per-iteration phases).
182+
183+
Workflow when adding or chasing a performance issue:
184+
185+
1. **Compare standalone primitives first** (`development/.../check_*.py` scripts vs. nifty). If a primitive is already fast, the gap is elsewhere — don't optimize it speculatively.
186+
2. **Instrument the suspect function** by wrapping each logical phase in a `BIOIMAGE_PROFILE_SCOPE`. Pick labels that map to one operation each ("agreement_contract", "sub_solve", "energy_eval", ...), not full call paths.
187+
3. **Build with `BIOIMAGE_PROFILE=ON` and run a realistic problem** — typically the external multicut instance loaded by the comparison scripts. Run with `--repeats 1` so the report isn't drowned out.
188+
4. **Optimize the largest phase** (50% phase beats two 10% phases combined). Re-measure after each change; verify no other phase regressed.
189+
5. **Strip the instrumentation when done** only if it adds clutter; otherwise leave it in place — it's free when the flag is off.
190+
191+
Don't add `std::chrono` snippets ad hoc; use the existing macros so future profiling sessions land in a consistent format.
192+
123193
## Documentation
124194
125195
`README.md` covers: what `bioimage-cpp` is and isn't, install/build, minimal examples, design philosophy. Public functions need concise docstrings documenting input shapes, supported dtypes, output shapes/dtypes, copy behavior, background-label behavior (if relevant), and axis/coordinate conventions.

CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,9 @@ target_compile_options(_core PRIVATE
2727
$<$<NOT:$<CONFIG:Debug>>:-O3>
2828
)
2929

30+
option(BIOIMAGE_PROFILE "Enable per-phase profiling instrumentation (development only)" OFF)
31+
if(BIOIMAGE_PROFILE)
32+
target_compile_definitions(_core PRIVATE BIOIMAGE_PROFILE)
33+
endif()
34+
3035
install(TARGETS _core LIBRARY DESTINATION bioimage_cpp)

MIGRATION_GUIDE.md

Lines changed: 122 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.
@@ -364,6 +408,84 @@ Intentional differences vs. nifty:
364408
`GreedyAdditiveMulticut` and no fallthrough is given — the greedy solver
365409
already operates on each connected component internally.
366410

411+
## Fusion Moves (Multicut)
412+
413+
Nifty exposes the fusion-move multicut solver via the factory hierarchy with a
414+
chosen proposal generator and sub-solver factory.
415+
416+
Nifty:
417+
418+
```python
419+
import nifty.graph.opt.multicut as nmc
420+
421+
objective = nmc.multicutObjective(graph, edge_costs)
422+
pgen = nmc.watershedProposals(sigma=1.0, numberOfSeeds=0.1)
423+
factory = nmc.fusionMoveBasedFactory(
424+
proposalGenerator=pgen,
425+
fusionMove=nmc.fusionMoveSettings(
426+
mcFactory=nmc.greedyAdditiveFactory(),
427+
),
428+
numberOfIterations=10,
429+
stopIfNoImprovement=4,
430+
)
431+
labels = factory.create(objective).optimize()
432+
```
433+
434+
bioimage-cpp:
435+
436+
```python
437+
import bioimage_cpp as bic
438+
439+
objective = bic.graph.MulticutObjective(graph, edge_costs)
440+
solver = bic.graph.FusionMoveMulticut(
441+
proposal_generator=bic.graph.WatershedProposalGenerator(
442+
sigma=1.0, n_seeds_fraction=0.1, seed=0,
443+
),
444+
sub_solver=bic.graph.GreedyAdditiveMulticut(),
445+
number_of_iterations=10,
446+
stop_if_no_improvement=4,
447+
)
448+
labels = solver.optimize(objective)
449+
```
450+
451+
Proposal generators:
452+
453+
| nifty proposal generator | bioimage-cpp proposal generator |
454+
| --- | --- |
455+
| `watershedProposals(sigma=..., numberOfSeeds=...)` | `WatershedProposalGenerator(sigma=..., n_seeds_fraction=..., seed=...)` |
456+
| `greedyAdditiveProposals(sigma=..., weightStopCond=..., nodeNumStopCond=...)` | `GreedyAdditiveProposalGenerator(sigma=..., weight_stop=..., node_num_stop=..., seed=...)` |
457+
458+
Sub-solvers: any built-in multicut solver (`GreedyAdditiveMulticut`,
459+
`GreedyFixationMulticut`, `KernighanLinMulticut`). If `sub_solver` is omitted,
460+
the default is `GreedyAdditiveMulticut` constructed with no-noise defaults.
461+
462+
Intentional differences vs. nifty:
463+
464+
- Single object construction: no separate factory / solver step.
465+
- Proposal generators are Python classes carrying their settings; the C++
466+
proposal-generator object is built lazily when `optimize` is called.
467+
- The driver warm-starts from the trivial singleton labeling by running the
468+
default greedy-additive sub-solver once before the proposal loop.
469+
- A best-of safety net keeps the running energy monotonically non-increasing
470+
across iterations (compared against current, proposals, fused, and the
471+
stage-2 joint fuse).
472+
- Parallel proposal generation and a multi-proposal joint fuse are supported:
473+
`number_of_threads=T` runs `number_of_parallel_proposals=P` proposal
474+
generators in parallel within each iteration. By default `P=2` when `T=1`
475+
and `P=T` when `T>1`; pass an explicit `number_of_parallel_proposals` to
476+
override. Each parallel slot uses an independent proposal generator with
477+
seed `proposal_generator.seed + slot_index` so the result is deterministic
478+
for a given `(seed, T, P)`. When at least two parallel pairwise fuses fail
479+
to improve on the current best, a joint multi-proposal fuse runs over the
480+
surviving fused candidates (matches nifty's `ccFusionMoveBased` stage-2
481+
behaviour).
482+
483+
Notes:
484+
485+
- Custom Python proposal generators are not yet supported; subclass
486+
`ProposalGenerator` and provide your own `_build` returning a C++
487+
proposal-generator object if you need to extend the set.
488+
367489
## Segmentation Overlaps
368490

369491
Nifty:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 fusion-move multicut at matched settings."
11+
).parse_args()
12+
# Match settings on both sides:
13+
# - threads / parallel-proposals = `--threads N` (default 1). We set
14+
# P = T explicitly on both sides so the comparison is apples-to-apples
15+
# regardless of either library's API default for P.
16+
# - greedy-additive warm-start (we do it automatically when initial
17+
# labels are the trivial singleton; nifty exposes it as
18+
# `warmStartGreedy=True`).
19+
# - greedy-additive sub-solver.
20+
threads = int(args.threads)
21+
run_comparison(
22+
"fusion_move",
23+
lambda: bic.graph.FusionMoveMulticut(
24+
proposal_generator=bic.graph.WatershedProposalGenerator(),
25+
number_of_threads=threads,
26+
number_of_parallel_proposals=threads,
27+
),
28+
lambda objective: objective.ccFusionMoveBasedFactory(
29+
proposalGenerator=objective.watershedCcProposals(),
30+
fusionMove=objective.fusionMoveSettings(
31+
mcFactory=objective.greedyAdditiveFactory(),
32+
),
33+
numberOfIterations=10,
34+
stopIfNoImprovement=4,
35+
numberOfThreads=threads,
36+
warmStartGreedy=True,
37+
),
38+
args,
39+
)
40+
41+
42+
if __name__ == "__main__":
43+
main()

include/bioimage_cpp/detail/indexed_heap.hxx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,28 @@ public:
133133
sift_up(pos);
134134
}
135135

136+
// Bulk-load the heap from a list of entries in O(n) via Floyd's heapify.
137+
// Replaces any current heap contents. Preconditions:
138+
// - the heap is empty before this call (call `clear()`/`reset_capacity`
139+
// first if needed);
140+
// - every key in `entries` is unique.
141+
// Compared to N successive `push` calls (O(n log n)) this matters when
142+
// initializing a heap from a large edge set in one shot.
143+
void build_heap(std::vector<Entry> entries) {
144+
heap_ = std::move(entries);
145+
for (std::size_t pos = 0; pos < heap_.size(); ++pos) {
146+
locator_.set(heap_[pos].key, pos);
147+
}
148+
if (heap_.size() < 2) {
149+
return;
150+
}
151+
// Sift down from the last internal node back to the root. After each
152+
// sift_down, the subtree rooted at `pos` is heap-ordered.
153+
for (std::size_t pos = heap_.size() / 2; pos-- > 0;) {
154+
sift_down(pos);
155+
}
156+
}
157+
136158
// Precondition: `key` is currently in the heap.
137159
void change(const KeyT &key, PriorityT priority) {
138160
const auto pos = locator_.at(key);

0 commit comments

Comments
 (0)