Skip to content

Commit 397e482

Browse files
Add fusion moves implementation
1 parent d46f31c commit 397e482

10 files changed

Lines changed: 1151 additions & 0 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,77 @@ Intentional differences vs. nifty:
408408
`GreedyAdditiveMulticut` and no fallthrough is given — the greedy solver
409409
already operates on each connected component internally.
410410

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-three safety net (current, proposal, fused) keeps the running
470+
energy monotonically non-increasing across iterations.
471+
- The current implementation is single-threaded and uses pairwise
472+
(proposal, current) fuses. `number_of_threads` and
473+
`number_of_parallel_proposals` are kept on the API for forward compatibility
474+
but must be left at their defaults (`1` and `2` respectively).
475+
476+
Notes:
477+
478+
- Custom Python proposal generators are not yet supported; subclass
479+
`ProposalGenerator` and provide your own `_build` returning a C++
480+
proposal-generator object if you need to extend the set.
481+
411482
## Segmentation Overlaps
412483

413484
Nifty:
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/detail/relabel.hxx"
4+
#include "bioimage_cpp/detail/union_find.hxx"
5+
#include "bioimage_cpp/graph/undirected_graph.hxx"
6+
7+
#include <cstddef>
8+
#include <cstdint>
9+
#include <stdexcept>
10+
#include <string>
11+
#include <vector>
12+
13+
namespace bioimage_cpp::graph::detail {
14+
15+
struct AgreementContraction {
16+
// Contracted graph: one node per agreement component, dense ids in
17+
// [0, number_of_components).
18+
UndirectedGraph contracted_graph;
19+
// For every original edge id: the contracted edge id it maps onto, or
20+
// -1 if both endpoints collapsed into the same component.
21+
std::vector<std::int64_t> contracted_edge_of_original;
22+
// For every original node id: its dense agreement-component id.
23+
std::vector<std::uint64_t> root_of_node;
24+
};
25+
26+
// Build the agreement-projection contracted graph: merge `(u, v)` iff every
27+
// proposal labels `u` and `v` identically, then dense-relabel and assemble the
28+
// contracted graph with one edge per distinct surviving (root_u, root_v) pair.
29+
//
30+
// `proposals` is a row-major buffer of shape (n_proposals, number_of_nodes).
31+
// Passing 0 proposals collapses every edge (all proposals trivially agree)
32+
// and yields a single-node contracted graph; we reject that as a usage error.
33+
inline AgreementContraction contract_by_agreement(
34+
const UndirectedGraph &graph,
35+
const std::uint64_t *proposals,
36+
const std::size_t n_proposals,
37+
const std::size_t n_nodes_per_proposal
38+
) {
39+
if (n_proposals == 0) {
40+
throw std::invalid_argument("at least one proposal is required");
41+
}
42+
const auto number_of_nodes = graph.number_of_nodes();
43+
const auto number_of_edges = graph.number_of_edges();
44+
if (n_nodes_per_proposal != static_cast<std::size_t>(number_of_nodes)) {
45+
throw std::invalid_argument(
46+
"proposal width must equal number_of_nodes, got " +
47+
std::to_string(n_nodes_per_proposal) + " for number_of_nodes=" +
48+
std::to_string(number_of_nodes)
49+
);
50+
}
51+
52+
bioimage_cpp::detail::UnionFind sets(static_cast<std::size_t>(number_of_nodes));
53+
for (std::uint64_t edge = 0; edge < number_of_edges; ++edge) {
54+
const auto uv = graph.uv(edge);
55+
const auto u = static_cast<std::size_t>(uv.first);
56+
const auto v = static_cast<std::size_t>(uv.second);
57+
bool agree = true;
58+
for (std::size_t p = 0; p < n_proposals; ++p) {
59+
const auto *row = proposals + p * n_nodes_per_proposal;
60+
if (row[u] != row[v]) {
61+
agree = false;
62+
break;
63+
}
64+
}
65+
if (agree) {
66+
sets.merge(uv.first, uv.second);
67+
}
68+
}
69+
70+
std::vector<std::uint64_t> raw_root(static_cast<std::size_t>(number_of_nodes));
71+
for (std::uint64_t node = 0; node < number_of_nodes; ++node) {
72+
raw_root[static_cast<std::size_t>(node)] = sets.find(node);
73+
}
74+
auto root_of_node = bioimage_cpp::detail::dense_relabel(raw_root);
75+
76+
std::uint64_t number_of_components = 0;
77+
for (const auto root : root_of_node) {
78+
if (root + 1 > number_of_components) {
79+
number_of_components = root + 1;
80+
}
81+
}
82+
83+
UndirectedGraph contracted_graph(number_of_components);
84+
std::vector<std::int64_t> contracted_edge_of_original(
85+
static_cast<std::size_t>(number_of_edges), -1
86+
);
87+
88+
for (std::uint64_t edge = 0; edge < number_of_edges; ++edge) {
89+
const auto uv = graph.uv(edge);
90+
const auto ru = root_of_node[static_cast<std::size_t>(uv.first)];
91+
const auto rv = root_of_node[static_cast<std::size_t>(uv.second)];
92+
if (ru == rv) {
93+
continue;
94+
}
95+
const auto inserted = contracted_graph.insert_edge(ru, rv);
96+
contracted_edge_of_original[static_cast<std::size_t>(edge)] =
97+
static_cast<std::int64_t>(inserted);
98+
}
99+
100+
return AgreementContraction{
101+
std::move(contracted_graph),
102+
std::move(contracted_edge_of_original),
103+
std::move(root_of_node),
104+
};
105+
}
106+
107+
} // namespace bioimage_cpp::graph::detail
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/graph/detail/fusion_contract.hxx"
4+
#include "bioimage_cpp/graph/multicut/greedy_additive.hxx"
5+
#include "bioimage_cpp/graph/multicut/objective.hxx"
6+
#include "bioimage_cpp/graph/proposal_generator.hxx"
7+
#include "bioimage_cpp/graph/undirected_graph.hxx"
8+
9+
#include <algorithm>
10+
#include <cstddef>
11+
#include <cstdint>
12+
#include <memory>
13+
#include <stdexcept>
14+
#include <utility>
15+
#include <vector>
16+
17+
namespace bioimage_cpp::graph::multicut {
18+
19+
class FusionMoveSolver final : public SolverBase {
20+
public:
21+
// The proposal generator is borrowed; the caller owns its lifetime.
22+
// The sub-solver pointer is optional: when null, the driver uses a default
23+
// greedy-additive sub-solver internally.
24+
//
25+
// `number_of_threads` and `number_of_parallel_proposals` are reserved for
26+
// future use; v1 only supports the single-threaded pairwise
27+
// (proposal, current) fuse and rejects other values.
28+
FusionMoveSolver(
29+
ProposalGeneratorBase &proposal_generator,
30+
const SolverBase *sub_solver = nullptr,
31+
const std::size_t number_of_iterations = 10,
32+
const std::size_t stop_if_no_improvement = 4,
33+
const std::size_t number_of_threads = 1,
34+
const std::size_t number_of_parallel_proposals = 2
35+
)
36+
: proposal_generator_(proposal_generator),
37+
sub_solver_(sub_solver),
38+
number_of_iterations_(number_of_iterations),
39+
stop_if_no_improvement_(stop_if_no_improvement) {
40+
if (number_of_threads != 1) {
41+
throw std::invalid_argument(
42+
"FusionMoveSolver currently supports number_of_threads=1 only"
43+
);
44+
}
45+
if (number_of_parallel_proposals != 2) {
46+
throw std::invalid_argument(
47+
"FusionMoveSolver currently supports number_of_parallel_proposals=2 only"
48+
);
49+
}
50+
}
51+
52+
std::vector<std::uint64_t> optimize(Objective &objective) const override {
53+
const auto &graph = objective.graph();
54+
const auto &costs = objective.costs();
55+
const auto number_of_nodes = graph.number_of_nodes();
56+
57+
std::vector<std::uint64_t> current = objective.labels();
58+
if (number_of_nodes == 0 || graph.number_of_edges() == 0) {
59+
objective.set_labels(current);
60+
return objective.labels();
61+
}
62+
63+
const auto default_sub_solver = GreedyAdditiveSolver();
64+
const auto &sub_solver = sub_solver_ != nullptr
65+
? *sub_solver_
66+
: static_cast<const SolverBase &>(default_sub_solver);
67+
68+
// Warm start from greedy-additive if the caller passed the trivial
69+
// singleton labeling.
70+
if (is_singleton_labeling(current)) {
71+
Objective warm_objective(graph, costs);
72+
current = default_sub_solver.optimize(warm_objective);
73+
}
74+
75+
double current_energy = energy(graph, costs, current);
76+
77+
std::vector<std::uint64_t> proposal(static_cast<std::size_t>(number_of_nodes));
78+
std::size_t iterations_without_improvement = 0;
79+
80+
for (std::size_t iteration = 0; iteration < number_of_iterations_; ++iteration) {
81+
proposal_generator_.generate(current, proposal);
82+
83+
const auto fused = fuse_pair(graph, costs, current, proposal, sub_solver);
84+
const auto fused_energy = energy(graph, costs, fused);
85+
const auto proposal_energy = energy(graph, costs, proposal);
86+
87+
// Best-of safety net across the three candidates so an iteration
88+
// can never raise the running energy.
89+
double best_energy = current_energy;
90+
const std::vector<std::uint64_t> *best = &current;
91+
if (fused_energy < best_energy) {
92+
best_energy = fused_energy;
93+
best = &fused;
94+
}
95+
if (proposal_energy < best_energy) {
96+
best_energy = proposal_energy;
97+
best = &proposal;
98+
}
99+
100+
if (best_energy < current_energy) {
101+
current = *best;
102+
current_energy = best_energy;
103+
iterations_without_improvement = 0;
104+
} else {
105+
++iterations_without_improvement;
106+
if (iterations_without_improvement >= stop_if_no_improvement_) {
107+
break;
108+
}
109+
}
110+
}
111+
112+
objective.set_labels(current);
113+
return objective.labels();
114+
}
115+
116+
private:
117+
static bool is_singleton_labeling(const std::vector<std::uint64_t> &labels) {
118+
for (std::size_t index = 0; index < labels.size(); ++index) {
119+
if (labels[index] != static_cast<std::uint64_t>(index)) {
120+
return false;
121+
}
122+
}
123+
return true;
124+
}
125+
126+
static std::vector<std::uint64_t> fuse_pair(
127+
const UndirectedGraph &graph,
128+
const std::vector<double> &costs,
129+
const std::vector<std::uint64_t> &current,
130+
const std::vector<std::uint64_t> &proposal,
131+
const SolverBase &sub_solver
132+
) {
133+
const auto number_of_nodes = static_cast<std::size_t>(graph.number_of_nodes());
134+
std::vector<std::uint64_t> stacked(2 * number_of_nodes);
135+
std::copy(current.begin(), current.end(), stacked.begin());
136+
std::copy(proposal.begin(), proposal.end(), stacked.begin() + number_of_nodes);
137+
138+
auto contraction = ::bioimage_cpp::graph::detail::contract_by_agreement(
139+
graph, stacked.data(), 2, number_of_nodes
140+
);
141+
142+
const auto &contracted_graph = contraction.contracted_graph;
143+
const auto number_of_contracted_edges = contracted_graph.number_of_edges();
144+
145+
std::vector<double> contracted_costs(
146+
static_cast<std::size_t>(number_of_contracted_edges), 0.0
147+
);
148+
for (std::uint64_t edge = 0; edge < graph.number_of_edges(); ++edge) {
149+
const auto target = contraction.contracted_edge_of_original[
150+
static_cast<std::size_t>(edge)
151+
];
152+
if (target < 0) {
153+
continue;
154+
}
155+
contracted_costs[static_cast<std::size_t>(target)] +=
156+
costs[static_cast<std::size_t>(edge)];
157+
}
158+
159+
if (number_of_contracted_edges == 0) {
160+
std::vector<std::uint64_t> result(number_of_nodes);
161+
for (std::uint64_t node = 0; node < graph.number_of_nodes(); ++node) {
162+
result[static_cast<std::size_t>(node)] = contraction.root_of_node[
163+
static_cast<std::size_t>(node)
164+
];
165+
}
166+
return result;
167+
}
168+
169+
Objective sub_objective(contracted_graph, std::move(contracted_costs));
170+
const auto sub_labels = sub_solver.optimize(sub_objective);
171+
172+
std::vector<std::uint64_t> result(number_of_nodes);
173+
for (std::uint64_t node = 0; node < graph.number_of_nodes(); ++node) {
174+
const auto root = contraction.root_of_node[static_cast<std::size_t>(node)];
175+
result[static_cast<std::size_t>(node)] = sub_labels[
176+
static_cast<std::size_t>(root)
177+
];
178+
}
179+
return result;
180+
}
181+
182+
ProposalGeneratorBase &proposal_generator_;
183+
const SolverBase *sub_solver_;
184+
std::size_t number_of_iterations_;
185+
std::size_t stop_if_no_improvement_;
186+
};
187+
188+
} // namespace bioimage_cpp::graph::multicut

0 commit comments

Comments
 (0)