Skip to content

Commit b4055f2

Browse files
Update mutex impl
1 parent 2af75e8 commit b4055f2

5 files changed

Lines changed: 480 additions & 27 deletions

File tree

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
"""Compare bioimage-cpp `mutex_watershed_clustering` against affogato's
2+
`compute_mws_clustering` reference, using the three registered lifted multicut
3+
problems as inputs (`local_uvs`/`local_costs` as attractive edges,
4+
`lifted_uvs`/`lifted_costs` as mutex edges).
5+
6+
Two modes:
7+
8+
* ``check`` (default): run a single problem (``--size 2d|3d|grid``), report
9+
partition equivalence + runtimes.
10+
* ``evaluate``: run all registered sizes and print a markdown comparison
11+
table — mirrors the layout of
12+
``development/graph/multicut/evaluate_solvers.py`` and
13+
``development/graph/lifted_multicut/evaluate_solvers.py``.
14+
15+
Not part of the pytest suite (per AGENTS.md). Run manually with affogato
16+
installed.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import argparse
22+
from statistics import median
23+
from time import perf_counter
24+
from typing import Callable
25+
26+
import numpy as np
27+
28+
29+
PROBLEMS = ("2d", "3d", "grid")
30+
DTYPES = ("float32", "float64")
31+
32+
33+
def load_problem(size: str, *, timeout: float):
34+
import bioimage_cpp as bic
35+
36+
problem = bic.graph.load_lifted_multicut_problem(size, timeout=timeout)
37+
bic_graph = bic.graph.UndirectedGraph.from_edges(problem.n_nodes, problem.local_uvs)
38+
return bic_graph, problem
39+
40+
41+
def run_bioimage_cpp(bic_graph, problem, *, dtype: np.dtype) -> np.ndarray:
42+
import bioimage_cpp as bic
43+
44+
return bic.graph.mutex_watershed_clustering(
45+
bic_graph,
46+
problem.local_costs.astype(dtype, copy=False),
47+
problem.lifted_uvs,
48+
problem.lifted_costs.astype(dtype, copy=False),
49+
)
50+
51+
52+
def run_affogato_reference(problem) -> np.ndarray:
53+
# affogato's `compute_mws_clustering` uses float32 weights.
54+
from affogato.segmentation import compute_mws_clustering
55+
56+
return compute_mws_clustering(
57+
int(problem.n_nodes),
58+
problem.local_uvs.astype(np.uint64, copy=False),
59+
problem.lifted_uvs.astype(np.uint64, copy=False),
60+
problem.local_costs.astype(np.float32, copy=False),
61+
problem.lifted_costs.astype(np.float32, copy=False),
62+
)
63+
64+
65+
def _load_validation_metrics():
66+
try:
67+
from elf.validation import rand_index, variation_of_information
68+
69+
return "elf.validation", rand_index, variation_of_information
70+
except ImportError:
71+
from elf.evaluation import rand_index, variation_of_information
72+
73+
return "elf.evaluation", rand_index, variation_of_information
74+
75+
76+
def _canonical_labels(labels: np.ndarray) -> np.ndarray:
77+
# Map to dense ids in first-occurrence order, so two partitions compare
78+
# equal iff they induce the same node grouping (independent of which
79+
# integer happened to be assigned to which cluster).
80+
array = np.asarray(labels)
81+
_, first_index, inverse = np.unique(
82+
array, return_index=True, return_inverse=True
83+
)
84+
order = np.argsort(first_index)
85+
remap = np.empty_like(order)
86+
remap[order] = np.arange(order.size)
87+
return remap[inverse].astype(np.uint64, copy=False)
88+
89+
90+
def compare_partitions(
91+
candidate: np.ndarray,
92+
reference: np.ndarray,
93+
) -> dict:
94+
source, rand_index, variation_of_information = _load_validation_metrics()
95+
vi_split, vi_merge = variation_of_information(candidate, reference)
96+
adapted_rand_error, ri = rand_index(candidate, reference)
97+
partition_equal = bool(
98+
np.array_equal(_canonical_labels(candidate), _canonical_labels(reference))
99+
)
100+
return {
101+
"validation_source": source,
102+
"vi_split": float(vi_split),
103+
"vi_merge": float(vi_merge),
104+
"adapted_rand_error": float(adapted_rand_error),
105+
"rand_index": float(ri),
106+
"partition_equal": partition_equal,
107+
"n_clusters_bic": int(np.unique(candidate).size),
108+
"n_clusters_reference": int(np.unique(reference).size),
109+
}
110+
111+
112+
def time_function_interleaved(
113+
bic_run: Callable[[], np.ndarray],
114+
reference_run: Callable[[], np.ndarray],
115+
repeats: int,
116+
) -> tuple[list[float], np.ndarray, list[float], np.ndarray]:
117+
# Warm up both implementations so JIT / first-call allocation costs do
118+
# not contaminate the timed runs.
119+
bic_result = bic_run()
120+
ref_result = reference_run()
121+
122+
bic_timings: list[float] = []
123+
ref_timings: list[float] = []
124+
for repeat in range(repeats):
125+
if repeat % 2 == 0:
126+
start = perf_counter()
127+
bic_result = bic_run()
128+
bic_timings.append(perf_counter() - start)
129+
start = perf_counter()
130+
ref_result = reference_run()
131+
ref_timings.append(perf_counter() - start)
132+
else:
133+
start = perf_counter()
134+
ref_result = reference_run()
135+
ref_timings.append(perf_counter() - start)
136+
start = perf_counter()
137+
bic_result = bic_run()
138+
bic_timings.append(perf_counter() - start)
139+
return bic_timings, bic_result, ref_timings, ref_result
140+
141+
142+
def run_size(
143+
size: str, *, repeats: int, timeout: float, dtype: np.dtype
144+
) -> dict:
145+
bic_graph, problem = load_problem(size, timeout=timeout)
146+
147+
bic_timings, bic_labels, ref_timings, ref_labels = time_function_interleaved(
148+
lambda: run_bioimage_cpp(bic_graph, problem, dtype=dtype),
149+
lambda: run_affogato_reference(problem),
150+
repeats,
151+
)
152+
153+
metrics = compare_partitions(bic_labels, ref_labels)
154+
bic_median = median(bic_timings)
155+
ref_median = median(ref_timings)
156+
return {
157+
"problem": size,
158+
"dtype": np.dtype(dtype).name,
159+
"nodes": int(problem.n_nodes),
160+
"local_edges": int(problem.local_uvs.shape[0]),
161+
"lifted_edges": int(problem.lifted_uvs.shape[0]),
162+
"bic_runtime_s": bic_median,
163+
"affogato_runtime_s": ref_median,
164+
"runtime_ratio": ref_median / bic_median if bic_median > 0 else float("inf"),
165+
**metrics,
166+
}
167+
168+
169+
def print_check_report(result: dict) -> None:
170+
print(f"problem: size={result['problem']}, dtype={result['dtype']}, "
171+
f"nodes={result['nodes']}, local edges={result['local_edges']}, "
172+
f"lifted edges={result['lifted_edges']}")
173+
print(f"validation metrics: {result['validation_source']}")
174+
print(
175+
"VI split/merge: "
176+
f"{result['vi_split']:.6g} / {result['vi_merge']:.6g}"
177+
)
178+
print(
179+
"adapted rand error / rand index: "
180+
f"{result['adapted_rand_error']:.6g} / {result['rand_index']:.12g}"
181+
)
182+
print(f"partition equality (after canonical relabel): {result['partition_equal']}")
183+
print(f"clusters (bic / affogato): "
184+
f"{result['n_clusters_bic']} / {result['n_clusters_reference']}")
185+
print(f"bioimage-cpp median runtime [s]: {result['bic_runtime_s']:.6f}")
186+
print(f"affogato median runtime [s]: {result['affogato_runtime_s']:.6f}")
187+
print(f"affogato / bioimage-cpp runtime ratio: {result['runtime_ratio']:.3f}x")
188+
189+
190+
def format_float(value: float) -> str:
191+
return f"{value:.6g}"
192+
193+
194+
def print_markdown_table(rows: list[dict]) -> None:
195+
headers = [
196+
"problem",
197+
"dtype",
198+
"nodes",
199+
"local_edges",
200+
"lifted_edges",
201+
"n_clusters_bic",
202+
"n_clusters_affogato",
203+
"vi_split",
204+
"vi_merge",
205+
"adapted_rand_error",
206+
"rand_index",
207+
"partition_equal",
208+
"bic_runtime_s",
209+
"affogato_runtime_s",
210+
"runtime_ratio_affogato_over_bic",
211+
]
212+
print("| " + " | ".join(headers) + " |")
213+
print("| " + " | ".join(["---"] * len(headers)) + " |")
214+
for row in rows:
215+
values = [
216+
row["problem"],
217+
row["dtype"],
218+
str(row["nodes"]),
219+
str(row["local_edges"]),
220+
str(row["lifted_edges"]),
221+
str(row["n_clusters_bic"]),
222+
str(row["n_clusters_reference"]),
223+
format_float(row["vi_split"]),
224+
format_float(row["vi_merge"]),
225+
format_float(row["adapted_rand_error"]),
226+
format_float(row["rand_index"]),
227+
str(row["partition_equal"]),
228+
format_float(row["bic_runtime_s"]),
229+
format_float(row["affogato_runtime_s"]),
230+
format_float(row["runtime_ratio"]),
231+
]
232+
print("| " + " | ".join(values) + " |")
233+
234+
235+
def build_parser() -> argparse.ArgumentParser:
236+
parser = argparse.ArgumentParser(
237+
description=(
238+
"Compare bioimage-cpp `mutex_watershed_clustering` against "
239+
"affogato's `compute_mws_clustering` on the registered lifted "
240+
"multicut problems."
241+
)
242+
)
243+
subparsers = parser.add_subparsers(dest="mode", required=False)
244+
245+
check_parser = subparsers.add_parser(
246+
"check",
247+
help=(
248+
"Run a single problem and print a partition-equivalence + "
249+
"runtime report (default mode)."
250+
),
251+
)
252+
check_parser.add_argument(
253+
"--size",
254+
choices=PROBLEMS,
255+
default="3d",
256+
help="Lifted multicut problem instance to load (default: 3d).",
257+
)
258+
check_parser.add_argument("--repeats", type=int, default=3)
259+
check_parser.add_argument("--timeout", type=float, default=60.0)
260+
check_parser.add_argument(
261+
"--dtype",
262+
choices=DTYPES,
263+
default="float32",
264+
help=(
265+
"Weight dtype for the bioimage-cpp call (default: float32, "
266+
"matching the precision affogato's reference uses internally)."
267+
),
268+
)
269+
270+
evaluate_parser = subparsers.add_parser(
271+
"evaluate",
272+
help=(
273+
"Run all registered problem sizes and print a markdown "
274+
"comparison table."
275+
),
276+
)
277+
evaluate_parser.add_argument(
278+
"--problems",
279+
nargs="+",
280+
choices=PROBLEMS,
281+
default=PROBLEMS,
282+
help="Problems to evaluate. Defaults to all.",
283+
)
284+
evaluate_parser.add_argument("--n-repeats", type=int, default=1)
285+
evaluate_parser.add_argument("--timeout", type=float, default=60.0)
286+
evaluate_parser.add_argument(
287+
"--dtypes",
288+
nargs="+",
289+
choices=DTYPES,
290+
default=("float32",),
291+
help=(
292+
"Weight dtype(s) for the bioimage-cpp call. Each (problem, "
293+
"dtype) pair becomes a row. Defaults to float32 (matches "
294+
"affogato's internal precision)."
295+
),
296+
)
297+
298+
return parser
299+
300+
301+
def main() -> None:
302+
parser = build_parser()
303+
args = parser.parse_args()
304+
305+
# Default to `check` if no subcommand was passed, matching the existing
306+
# `check_*` scripts in this directory.
307+
if args.mode is None or args.mode == "check":
308+
if args.mode is None:
309+
args.size = "3d"
310+
args.repeats = 3
311+
args.timeout = 60.0
312+
args.dtype = "float32"
313+
if args.repeats < 1:
314+
raise ValueError("--repeats must be at least 1")
315+
result = run_size(
316+
args.size,
317+
repeats=args.repeats,
318+
timeout=args.timeout,
319+
dtype=np.dtype(args.dtype),
320+
)
321+
print_check_report(result)
322+
return
323+
324+
if args.n_repeats < 1:
325+
raise ValueError("--n-repeats must be at least 1")
326+
rows = []
327+
for problem in args.problems:
328+
for dtype in args.dtypes:
329+
rows.append(
330+
run_size(
331+
problem,
332+
repeats=args.n_repeats,
333+
timeout=args.timeout,
334+
dtype=np.dtype(dtype),
335+
)
336+
)
337+
print_markdown_table(rows)
338+
339+
340+
if __name__ == "__main__":
341+
main()

include/bioimage_cpp/graph/mutex_watershed.hxx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,16 @@ namespace bioimage_cpp::graph {
2626
//
2727
// This is a port of `compute_mws_clustering` from the affogato library,
2828
// adapted to bioimage-cpp's UndirectedGraph and detail/ primitives.
29-
inline std::vector<std::uint64_t> mutex_watershed_clustering(
29+
//
30+
// Templated on the weight type. Concrete instantiations for `float` and
31+
// `double` are provided by the binding layer; other floating types are
32+
// supported but must be instantiated explicitly.
33+
template <class WeightT>
34+
std::vector<std::uint64_t> mutex_watershed_clustering(
3035
const UndirectedGraph &graph,
31-
const std::vector<double> &edge_costs,
36+
const std::vector<WeightT> &edge_costs,
3237
const std::vector<std::array<std::uint64_t, 2>> &mutex_uvs,
33-
const std::vector<double> &mutex_costs
38+
const std::vector<WeightT> &mutex_costs
3439
) {
3540
const auto number_of_edges = static_cast<std::size_t>(graph.number_of_edges());
3641
if (edge_costs.size() != number_of_edges) {
@@ -64,7 +69,7 @@ inline std::vector<std::uint64_t> mutex_watershed_clustering(
6469
}
6570

6671
struct WeightedEdge {
67-
double weight;
72+
WeightT weight;
6873
std::uint64_t index;
6974
bool is_mutex;
7075
};

0 commit comments

Comments
 (0)