Skip to content

Commit a223867

Browse files
Implement Multicut (#4)
* Implement multicut functionality * Optimization round 1 * Refactor to avoid duplicate functionality * Kernighan lin updates * Implement PQ and update KL * Multicut performance improvements * Update agent instructions
1 parent 24e5ff2 commit a223867

38 files changed

Lines changed: 2915 additions & 658 deletions

AGENTS.md

Lines changed: 87 additions & 404 deletions
Large diffs are not rendered by default.

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,8 @@ nanobind_add_module(_core
2323

2424
target_include_directories(_core PRIVATE include)
2525
target_compile_features(_core PRIVATE cxx_std_20)
26+
target_compile_options(_core PRIVATE
27+
$<$<NOT:$<CONFIG:Debug>>:-O3>
28+
)
2629

2730
install(TARGETS _core LIBRARY DESTINATION bioimage_cpp)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
"--path",
15+
default=None,
16+
help="Path to the external multicut problem. Defaults to the package cache.",
17+
)
18+
arg_parser.add_argument(
19+
"--repeats",
20+
type=int,
21+
default=3,
22+
help="Number of timed repeats per implementation.",
23+
)
24+
arg_parser.add_argument(
25+
"--threads",
26+
type=int,
27+
default=1,
28+
help="Number of threads for solvers that support it.",
29+
)
30+
arg_parser.add_argument(
31+
"--timeout",
32+
type=float,
33+
default=30.0,
34+
help="Download timeout in seconds if the external problem is not cached.",
35+
)
36+
arg_parser.add_argument(
37+
"--energy-bound",
38+
type=float,
39+
default=-76900.0,
40+
help="Maximum accepted energy for both implementations.",
41+
)
42+
return arg_parser
43+
44+
45+
def load_problem(path: str | None, *, timeout: float):
46+
import bioimage_cpp as bic
47+
import nifty
48+
49+
uv_ids, costs = bic.graph.load_external_multicut_problem_data(
50+
path,
51+
timeout=timeout,
52+
)
53+
bic_graph = bic.graph.UndirectedGraph.from_edges(int(uv_ids.max()) + 1, uv_ids)
54+
nifty_graph = nifty.graph.undirectedGraph(int(uv_ids.max()) + 1)
55+
nifty_graph.insertEdges(uv_ids)
56+
return bic_graph, nifty_graph, costs
57+
58+
59+
def time_call(function: Callable[[], np.ndarray], repeats: int):
60+
timings = []
61+
result = None
62+
for _ in range(repeats):
63+
start = perf_counter()
64+
result = function()
65+
timings.append(perf_counter() - start)
66+
assert result is not None
67+
return timings, result
68+
69+
70+
def optimize_bic_solver(make_bic_solver, objective):
71+
objective.reset_labels()
72+
return make_bic_solver().optimize(objective)
73+
74+
75+
def bic_energy(graph, costs: np.ndarray, labels: np.ndarray) -> float:
76+
import bioimage_cpp as bic
77+
78+
return bic.graph.MulticutObjective(graph, costs).energy(labels)
79+
80+
81+
def nifty_energy(graph, costs: np.ndarray, labels: np.ndarray) -> float:
82+
import nifty.graph.opt.multicut as nmc
83+
84+
return float(nmc.multicutObjective(graph, costs).evalNodeLabels(labels))
85+
86+
87+
def run_comparison(
88+
name: str,
89+
make_bic_solver,
90+
make_nifty_solver,
91+
args: argparse.Namespace,
92+
) -> dict[str, float]:
93+
import bioimage_cpp as bic
94+
import nifty.graph.opt.multicut as nmc
95+
96+
bic_graph, nifty_graph, costs = load_problem(args.path, timeout=args.timeout)
97+
bic_objective = bic.graph.MulticutObjective(bic_graph, costs)
98+
nifty_objective = nmc.multicutObjective(nifty_graph, costs)
99+
100+
bic_timings, bic_labels = time_call(
101+
lambda: optimize_bic_solver(make_bic_solver, bic_objective),
102+
args.repeats,
103+
)
104+
nifty_timings, nifty_labels = time_call(
105+
lambda: make_nifty_solver(nifty_objective).create(nifty_objective).optimize(),
106+
args.repeats,
107+
)
108+
109+
bic_score = bic_energy(bic_graph, costs, bic_labels)
110+
nifty_score = nifty_energy(nifty_graph, costs, nifty_labels)
111+
if bic_score > args.energy_bound:
112+
raise AssertionError(
113+
f"bioimage-cpp {name} energy {bic_score:.6f} exceeds bound {args.energy_bound:.6f}"
114+
)
115+
if nifty_score > args.energy_bound:
116+
raise AssertionError(
117+
f"nifty {name} energy {nifty_score:.6f} exceeds bound {args.energy_bound:.6f}"
118+
)
119+
120+
result = {
121+
"bioimage_cpp_energy": bic_score,
122+
"nifty_energy": nifty_score,
123+
"energy_difference": bic_score - nifty_score,
124+
"bioimage_cpp_median_runtime": median(bic_timings),
125+
"nifty_median_runtime": median(nifty_timings),
126+
}
127+
print(f"solver: {name}")
128+
print(f"nodes: {bic_graph.number_of_nodes}, edges: {bic_graph.number_of_edges}")
129+
print(f"bioimage-cpp energy: {bic_score:.6f}")
130+
print(f"nifty energy: {nifty_score:.6f}")
131+
print(f"energy difference: {bic_score - nifty_score:.6f}")
132+
print(f"bioimage-cpp median runtime [s]: {median(bic_timings):.6f}")
133+
print(f"nifty median runtime [s]: {median(nifty_timings):.6f}")
134+
return result
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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("Compare bioimage-cpp and nifty chained multicut solvers.").parse_args()
10+
run_comparison(
11+
"chained",
12+
lambda: bic.graph.ChainedMulticutSolvers(
13+
[
14+
bic.graph.GreedyAdditiveMulticut(),
15+
bic.graph.KernighanLinMulticut(number_of_outer_iterations=5),
16+
]
17+
),
18+
lambda objective: objective.chainedSolversFactory(
19+
[
20+
objective.greedyAdditiveFactory(),
21+
objective.kernighanLinFactory(numberOfOuterIterations=5),
22+
]
23+
),
24+
args,
25+
)
26+
27+
28+
if __name__ == "__main__":
29+
main()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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("Compare bioimage-cpp and nifty decomposer multicut.").parse_args()
10+
run_comparison(
11+
"decomposer",
12+
lambda: bic.graph.MulticutDecomposer(bic.graph.GreedyAdditiveMulticut()),
13+
lambda objective: objective.multicutDecomposerFactory(
14+
submodelFactory=objective.greedyAdditiveFactory(),
15+
fallthroughFactory=objective.greedyAdditiveFactory(),
16+
numberOfThreads=args.threads,
17+
),
18+
args,
19+
)
20+
21+
22+
if __name__ == "__main__":
23+
main()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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("Compare bioimage-cpp and nifty greedy-additive multicut.").parse_args()
10+
run_comparison(
11+
"greedy_additive",
12+
lambda: bic.graph.GreedyAdditiveMulticut(),
13+
lambda objective: objective.greedyAdditiveFactory(),
14+
args,
15+
)
16+
17+
18+
if __name__ == "__main__":
19+
main()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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("Compare bioimage-cpp and nifty greedy-fixation multicut.").parse_args()
10+
run_comparison(
11+
"greedy_fixation",
12+
lambda: bic.graph.GreedyFixationMulticut(),
13+
lambda objective: objective.greedyFixationFactory(),
14+
args,
15+
)
16+
17+
18+
if __name__ == "__main__":
19+
main()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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("Compare bioimage-cpp and nifty Kernighan-Lin multicut.").parse_args()
10+
run_comparison(
11+
"kernighan_lin",
12+
lambda: bic.graph.KernighanLinMulticut(number_of_outer_iterations=5),
13+
lambda objective: objective.kernighanLinFactory(
14+
warmStartGreedy=True,
15+
numberOfOuterIterations=5,
16+
),
17+
args,
18+
)
19+
20+
21+
if __name__ == "__main__":
22+
main()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#pragma once
2+
3+
#include <cstddef>
4+
#include <cstdint>
5+
#include <utility>
6+
7+
namespace bioimage_cpp::detail {
8+
9+
using NodeId = std::uint64_t;
10+
using Edge = std::pair<NodeId, NodeId>;
11+
12+
inline Edge edge_key(NodeId u, NodeId v) {
13+
if (v < u) {
14+
std::swap(u, v);
15+
}
16+
return {u, v};
17+
}
18+
19+
struct EdgeHash {
20+
std::size_t operator()(const Edge &edge) const {
21+
const auto first = static_cast<std::size_t>(edge.first);
22+
const auto second = static_cast<std::size_t>(edge.second);
23+
return first ^ (second + 0x9e3779b97f4a7c15ULL + (first << 6U) + (first >> 2U));
24+
}
25+
};
26+
27+
} // namespace bioimage_cpp::detail
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#pragma once
2+
3+
#include <cstddef>
4+
#include <cstdint>
5+
#include <vector>
6+
7+
namespace bioimage_cpp::detail {
8+
9+
// C-order strides for a row-major array of the given shape, in units of array
10+
// elements (not bytes). The innermost (last) axis has stride 1.
11+
inline std::vector<std::ptrdiff_t> c_order_strides(const std::vector<std::ptrdiff_t> &shape) {
12+
std::vector<std::ptrdiff_t> strides(shape.size(), 1);
13+
for (std::ptrdiff_t axis = static_cast<std::ptrdiff_t>(shape.size()) - 2; axis >= 0; --axis) {
14+
strides[static_cast<std::size_t>(axis)] =
15+
strides[static_cast<std::size_t>(axis + 1)] *
16+
shape[static_cast<std::size_t>(axis + 1)];
17+
}
18+
return strides;
19+
}
20+
21+
// Translate a flat node index by a per-axis offset on a row-major grid.
22+
//
23+
// Returns true when the offset keeps the result inside the grid, in which case
24+
// `target_out` is set to the neighbor's flat index. Returns false otherwise and
25+
// leaves `target_out` unchanged. `strides` must match `shape` and is typically
26+
// `c_order_strides(shape)`.
27+
inline bool valid_offset_target(
28+
const std::uint64_t node,
29+
const std::vector<std::ptrdiff_t> &offset,
30+
const std::vector<std::ptrdiff_t> &shape,
31+
const std::vector<std::ptrdiff_t> &strides,
32+
std::uint64_t &target_out
33+
) {
34+
std::int64_t target_signed = static_cast<std::int64_t>(node);
35+
for (std::size_t axis = 0; axis < shape.size(); ++axis) {
36+
const auto coord =
37+
static_cast<std::ptrdiff_t>(node / static_cast<std::uint64_t>(strides[axis])) %
38+
shape[axis];
39+
const auto neighbor = coord + offset[axis];
40+
if (neighbor < 0 || neighbor >= shape[axis]) {
41+
return false;
42+
}
43+
target_signed += static_cast<std::int64_t>(offset[axis] * strides[axis]);
44+
}
45+
target_out = static_cast<std::uint64_t>(target_signed);
46+
return true;
47+
}
48+
49+
inline bool is_valid_grid_edge(
50+
const std::uint64_t node,
51+
const std::vector<std::ptrdiff_t> &offset,
52+
const std::vector<std::ptrdiff_t> &shape,
53+
const std::vector<std::ptrdiff_t> &strides
54+
) {
55+
std::uint64_t unused = 0;
56+
return valid_offset_target(node, offset, shape, strides, unused);
57+
}
58+
59+
} // namespace bioimage_cpp::detail

0 commit comments

Comments
 (0)