diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index c8d8729..5dbcd45 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -696,8 +696,8 @@ shims default to sample A, size small and continue to honor the `BIOIMAGE_CPP_EXTERNAL_MULTICUT_PATH` and `BIOIMAGE_CPP_EXTERNAL_MULTICUT_CACHE` environment variables. -Lifted multicut problems (2D ISBI slice and full 3D volume, built by -`examples/segmentation/serialize_lifted_problem.py`): +Lifted multicut problems (2D ISBI slice, RAG-based 3D volume, and grid-graph +volume): ```python problem = bic.graph.load_lifted_multicut_problem(size="2d") @@ -711,6 +711,8 @@ objective = bic.graph.LiftedMulticutObjective( ) ``` +Valid sizes are `"2d"`, `"3d"`, and `"grid"`. + Notes: - Every download is integrity-checked against a SHA256 in the registry; a diff --git a/development/graph/_grid_affinity_compatibility.py b/development/graph/_grid_affinity_compatibility.py index 5dfcad7..84b3f14 100644 --- a/development/graph/_grid_affinity_compatibility.py +++ b/development/graph/_grid_affinity_compatibility.py @@ -1,22 +1,16 @@ from __future__ import annotations import argparse -from pathlib import Path from statistics import median from time import perf_counter from typing import Callable import numpy as np +def load_problem(): + from bioimage_cpp._data import load_isbi_affinities -PROJECT_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_DATA_PREFIX = PROJECT_ROOT / "examples" / "segmentation" / "isbi-data-" - - -def load_problem(data_prefix: Path | str = DEFAULT_DATA_PREFIX): - from elf.segmentation.utils import load_mutex_watershed_problem - - affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix)) + affinities, offsets = load_isbi_affinities() return np.ascontiguousarray(affinities), [tuple(offset) for offset in offsets] @@ -255,7 +249,6 @@ def print_timing(name: str, first_name: str, first_timings: list[float], def add_common_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--ndim", type=int, choices=(2, 3), default=2) - parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX) # Default bumped from 3 to 5 — median of 3 is the middle sample and is # noisy if anything (GC, cache eviction) lands inside one of the three # runs. With `time_call` doing one warm-up before this, 5 samples gives diff --git a/development/graph/_rag_compatibility.py b/development/graph/_rag_compatibility.py index 71396da..aedc231 100644 --- a/development/graph/_rag_compatibility.py +++ b/development/graph/_rag_compatibility.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -from pathlib import Path from statistics import median from time import perf_counter @@ -10,15 +9,10 @@ from skimage.measure import label as label_components from skimage.segmentation import watershed +def load_problem(): + from bioimage_cpp._data import load_isbi_affinities -PROJECT_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_DATA_PREFIX = PROJECT_ROOT / "examples" / "segmentation" / "isbi-data-" - - -def load_problem(data_prefix: Path | str = DEFAULT_DATA_PREFIX): - from elf.segmentation.utils import load_mutex_watershed_problem - - affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix)) + affinities, offsets = load_isbi_affinities() return np.ascontiguousarray(affinities), [tuple(offset) for offset in offsets] @@ -227,7 +221,6 @@ def run_compatibility_check( ndim: int, repeats: int, threads: int, - data_prefix: Path, z: int, yx_shape: tuple[int, int], zyx_shape: tuple[int, int, int], @@ -238,7 +231,7 @@ def run_compatibility_check( import bioimage_cpp as bic import nifty.graph.rag as nrag - affinities, offsets = load_problem(data_prefix) + affinities, offsets = load_problem() if ndim == 2: direct_affinities, direct_offsets = prepare_2d_problem(affinities, offsets, z, yx_shape) elif ndim == 3: @@ -310,7 +303,6 @@ def _print_timing(name: str, bic_timings: list[float], nifty_timings: list[float def add_common_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX) parser.add_argument("--repeats", type=int, default=3) parser.add_argument("--threads", type=int, default=1) parser.add_argument("--watershed-min-distance", type=int, default=5) diff --git a/development/graph/check_grid_affinity_edges.py b/development/graph/check_grid_affinity_edges.py index d382c3c..49a3024 100644 --- a/development/graph/check_grid_affinity_edges.py +++ b/development/graph/check_grid_affinity_edges.py @@ -37,7 +37,7 @@ def run_check(args: argparse.Namespace) -> None: - affinities, offsets = load_problem(args.data_prefix) + affinities, offsets = load_problem() if args.ndim == 2: affinities, offsets = prepare_2d_problem( affinities, offsets, z=args.z, yx_shape=tuple(args.yx_shape) diff --git a/development/graph/check_grid_affinity_lifted_edges.py b/development/graph/check_grid_affinity_lifted_edges.py index e32c424..4b3e0ff 100644 --- a/development/graph/check_grid_affinity_lifted_edges.py +++ b/development/graph/check_grid_affinity_lifted_edges.py @@ -33,7 +33,7 @@ def run_check(args: argparse.Namespace) -> None: - affinities, offsets = load_problem(args.data_prefix) + affinities, offsets = load_problem() if args.ndim == 2: affinities, offsets = prepare_2d_problem( affinities, offsets, z=args.z, yx_shape=tuple(args.yx_shape) diff --git a/development/graph/check_rag_2d.py b/development/graph/check_rag_2d.py index 1a5c0dc..6d3e28d 100644 --- a/development/graph/check_rag_2d.py +++ b/development/graph/check_rag_2d.py @@ -18,7 +18,6 @@ def main() -> None: ndim=2, repeats=args.repeats, threads=args.threads, - data_prefix=args.data_prefix, z=args.z, yx_shape=tuple(args.shape), zyx_shape=(0, 0, 0), diff --git a/development/graph/check_rag_3d.py b/development/graph/check_rag_3d.py index 1a51a56..dbef77b 100644 --- a/development/graph/check_rag_3d.py +++ b/development/graph/check_rag_3d.py @@ -17,7 +17,6 @@ def main() -> None: ndim=3, repeats=args.repeats, threads=args.threads, - data_prefix=args.data_prefix, z=0, yx_shape=(0, 0), zyx_shape=tuple(args.shape), diff --git a/development/graph/lifted_multicut/_compatibility.py b/development/graph/lifted_multicut/_compatibility.py index 9247210..e173669 100644 --- a/development/graph/lifted_multicut/_compatibility.py +++ b/development/graph/lifted_multicut/_compatibility.py @@ -12,7 +12,7 @@ def parser(description: str) -> argparse.ArgumentParser: arg_parser = argparse.ArgumentParser(description=description) arg_parser.add_argument( "--size", - choices=("2d", "3d"), + choices=("2d", "3d", "grid"), default="3d", help="Lifted multicut problem instance to load (default: 3d).", ) diff --git a/development/graph/lifted_multicut/evaluate_solvers.py b/development/graph/lifted_multicut/evaluate_solvers.py new file mode 100644 index 0000000..8a0afeb --- /dev/null +++ b/development/graph/lifted_multicut/evaluate_solvers.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from statistics import mean +from time import perf_counter +from typing import Callable + +import numpy as np + + +PROBLEMS = ("2d", "3d", "grid") + + +@dataclass(frozen=True) +class SolverConfig: + make_bic_solver: Callable[[], object] + make_nifty_factory: Callable[[object], object] + + +def solver_configs(): + import bioimage_cpp as bic + + return { + "lifted_greedy_additive": SolverConfig( + make_bic_solver=lambda: bic.graph.LiftedGreedyAdditiveMulticut(), + make_nifty_factory=lambda objective: objective.liftedMulticutGreedyAdditiveFactory(), + ), + "lifted_kernighan_lin": SolverConfig( + make_bic_solver=lambda: bic.graph.LiftedKernighanLinMulticut( + number_of_outer_iterations=10 + ), + make_nifty_factory=lambda objective: objective.chainedSolversFactory( + [ + objective.liftedMulticutGreedyAdditiveFactory(), + objective.liftedMulticutKernighanLinFactory( + numberOfOuterIterations=10 + ), + ] + ), + ), + } + + +def load_problem(size: str, *, timeout: float): + import bioimage_cpp as bic + import nifty + import nifty.graph.opt.lifted_multicut as nlmc + + problem = bic.graph.load_lifted_multicut_problem(size, timeout=timeout) + bic_graph = bic.graph.UndirectedGraph.from_edges(problem.n_nodes, problem.local_uvs) + nifty_graph = nifty.graph.undirectedGraph(int(problem.n_nodes)) + nifty_graph.insertEdges(problem.local_uvs.astype(np.uint64, copy=False)) + + def make_nifty_objective(): + objective = nlmc.liftedMulticutObjective(nifty_graph) + objective.setGraphEdgesCosts(problem.local_costs) + if problem.lifted_uvs.shape[0] > 0: + objective.setCosts( + problem.lifted_uvs.astype(np.uint64, copy=False), + problem.lifted_costs.astype(np.float64, copy=False), + ) + return objective + + return bic_graph, nifty_graph, make_nifty_objective, problem + + +def make_bic_objective(bic_graph, problem): + import bioimage_cpp as bic + + return bic.graph.LiftedMulticutObjective( + bic_graph, + problem.local_costs, + lifted_uvs=problem.lifted_uvs, + lifted_costs=problem.lifted_costs, + ) + + +def bic_energy(bic_graph, problem, labels: np.ndarray) -> float: + return float(make_bic_objective(bic_graph, problem).energy(labels)) + + +def nifty_energy(nifty_objective, labels: np.ndarray) -> float: + return float(nifty_objective.evalNodeLabels(labels.astype(np.uint64, copy=False))) + + +def evaluate(problem_name: str, solver_name: str, config: SolverConfig, args): + bic_graph, _, make_nifty_objective, problem = load_problem( + problem_name, timeout=args.timeout + ) + bic_energies = [] + nifty_energies = [] + bic_runtimes = [] + nifty_runtimes = [] + + for _ in range(args.n_repeats): + bic_objective = make_bic_objective(bic_graph, problem) + start = perf_counter() + bic_labels = config.make_bic_solver().optimize(bic_objective) + bic_runtimes.append(perf_counter() - start) + bic_energies.append(bic_energy(bic_graph, problem, bic_labels)) + + nifty_objective = make_nifty_objective() + start = perf_counter() + nifty_labels = ( + config.make_nifty_factory(nifty_objective) + .create(nifty_objective) + .optimize() + ) + nifty_runtimes.append(perf_counter() - start) + nifty_energies.append(nifty_energy(nifty_objective, np.asarray(nifty_labels))) + + bic_runtime = mean(bic_runtimes) + nifty_runtime = mean(nifty_runtimes) + return { + "problem": problem_name, + "solver": solver_name, + "nodes": int(problem.n_nodes), + "local_edges": int(problem.local_uvs.shape[0]), + "lifted_edges": int(problem.lifted_uvs.shape[0]), + "bic_energy": mean(bic_energies), + "nifty_energy": mean(nifty_energies), + "energy_diff": mean(bic_energies) - mean(nifty_energies), + "bic_runtime_s": bic_runtime, + "nifty_runtime_s": nifty_runtime, + "runtime_ratio": nifty_runtime / bic_runtime if bic_runtime > 0 else float("inf"), + } + + +def format_float(value: float) -> str: + return f"{value:.6g}" + + +def print_markdown_table(rows: list[dict]) -> None: + headers = [ + "problem", + "solver", + "nodes", + "local_edges", + "lifted_edges", + "bic_energy", + "nifty_energy", + "energy_diff", + "bic_runtime_s", + "nifty_runtime_s", + "runtime_ratio_nifty_over_bic", + ] + print("| " + " | ".join(headers) + " |") + print("| " + " | ".join(["---"] * len(headers)) + " |") + for row in rows: + values = [ + row["problem"], + row["solver"], + str(row["nodes"]), + str(row["local_edges"]), + str(row["lifted_edges"]), + format_float(row["bic_energy"]), + format_float(row["nifty_energy"]), + format_float(row["energy_diff"]), + format_float(row["bic_runtime_s"]), + format_float(row["nifty_runtime_s"]), + format_float(row["runtime_ratio"]), + ] + print("| " + " | ".join(values) + " |") + + +def main() -> None: + configs = solver_configs() + parser = argparse.ArgumentParser( + description=( + "Evaluate matched bioimage-cpp and nifty lifted multicut solvers " + "on registered lifted multicut problems." + ) + ) + parser.add_argument( + "--solvers", + nargs="+", + choices=tuple(configs.keys()), + default=tuple(configs.keys()), + help="Solvers to evaluate. Defaults to all.", + ) + parser.add_argument( + "--problems", + nargs="+", + choices=PROBLEMS, + default=PROBLEMS, + help="Problems to evaluate. Defaults to all.", + ) + parser.add_argument("--n-repeats", type=int, default=1) + parser.add_argument("--timeout", type=float, default=60.0) + args = parser.parse_args() + + if args.n_repeats < 1: + raise ValueError("--n-repeats must be at least 1") + + rows = [] + for problem_name in args.problems: + for solver_name in args.solvers: + rows.append(evaluate(problem_name, solver_name, configs[solver_name], args)) + print_markdown_table(rows) + + +if __name__ == "__main__": + main() diff --git a/development/graph/multicut/evaluate_solvers.py b/development/graph/multicut/evaluate_solvers.py new file mode 100644 index 0000000..3f57426 --- /dev/null +++ b/development/graph/multicut/evaluate_solvers.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from statistics import mean +from time import perf_counter +from typing import Callable + +import numpy as np + + +PROBLEMS = tuple( + f"{sample}_{size}" + for sample in ("A", "B", "C") + for size in ("small", "medium") +) + + +@dataclass(frozen=True) +class SolverConfig: + make_bic_solver: Callable[[int], object] + make_nifty_factory: Callable[[object, int], object] + + +def solver_configs(): + import bioimage_cpp as bic + + return { + "greedy_additive": SolverConfig( + make_bic_solver=lambda threads: bic.graph.GreedyAdditiveMulticut(), + make_nifty_factory=lambda objective, threads: objective.greedyAdditiveFactory(), + ), + "kernighan_lin": SolverConfig( + make_bic_solver=lambda threads: bic.graph.KernighanLinMulticut( + number_of_outer_iterations=5 + ), + make_nifty_factory=lambda objective, threads: objective.kernighanLinFactory( + warmStartGreedy=True, + numberOfOuterIterations=5, + ), + ), + "greedy_fixation": SolverConfig( + make_bic_solver=lambda threads: bic.graph.GreedyFixationMulticut(), + make_nifty_factory=lambda objective, threads: objective.greedyFixationFactory(), + ), + "chained": SolverConfig( + make_bic_solver=lambda threads: bic.graph.ChainedMulticutSolvers( + [ + bic.graph.GreedyAdditiveMulticut(), + bic.graph.KernighanLinMulticut(number_of_outer_iterations=5), + ] + ), + make_nifty_factory=lambda objective, threads: objective.chainedSolversFactory( + [ + objective.greedyAdditiveFactory(), + objective.kernighanLinFactory(numberOfOuterIterations=5), + ] + ), + ), + "decomposer": SolverConfig( + make_bic_solver=lambda threads: bic.graph.MulticutDecomposer( + bic.graph.GreedyAdditiveMulticut() + ), + make_nifty_factory=lambda objective, threads: objective.multicutDecomposerFactory( + submodelFactory=objective.greedyAdditiveFactory(), + fallthroughFactory=objective.greedyAdditiveFactory(), + numberOfThreads=threads, + ), + ), + "fusion_move": SolverConfig( + make_bic_solver=lambda threads: bic.graph.FusionMoveMulticut( + proposal_generator=bic.graph.WatershedProposalGenerator(), + number_of_threads=threads, + number_of_parallel_proposals=threads, + ), + make_nifty_factory=lambda objective, threads: objective.ccFusionMoveBasedFactory( + proposalGenerator=objective.watershedCcProposals(), + fusionMove=objective.fusionMoveSettings( + mcFactory=objective.greedyAdditiveFactory(), + ), + numberOfIterations=10, + stopIfNoImprovement=4, + numberOfThreads=threads, + warmStartGreedy=True, + ), + ), + } + + +def parse_problem_name(name: str) -> tuple[str, str]: + try: + sample, size = name.split("_", maxsplit=1) + except ValueError as error: + raise argparse.ArgumentTypeError( + f"problem must be SAMPLE_SIZE, got {name!r}" + ) from error + if name not in PROBLEMS: + raise argparse.ArgumentTypeError( + f"unknown problem {name!r}; available: {', '.join(PROBLEMS)}" + ) + return sample, size + + +def load_problem(problem_name: str, *, timeout: float): + import bioimage_cpp as bic + import nifty + + sample, size = parse_problem_name(problem_name) + uv_ids, costs = bic.graph.load_multicut_problem_data( + sample=sample, + size=size, + timeout=timeout, + ) + n_nodes = int(uv_ids.max()) + 1 + bic_graph = bic.graph.UndirectedGraph.from_edges(n_nodes, uv_ids) + nifty_graph = nifty.graph.undirectedGraph(n_nodes) + nifty_graph.insertEdges(uv_ids.astype(np.uint64, copy=False)) + return bic_graph, nifty_graph, costs + + +def bic_energy(graph, costs: np.ndarray, labels: np.ndarray) -> float: + import bioimage_cpp as bic + + return float(bic.graph.MulticutObjective(graph, costs).energy(labels)) + + +def nifty_energy(graph, costs: np.ndarray, labels: np.ndarray) -> float: + import nifty.graph.opt.multicut as nmc + + return float(nmc.multicutObjective(graph, costs).evalNodeLabels(labels)) + + +def evaluate(problem_name: str, solver_name: str, config: SolverConfig, args): + import bioimage_cpp as bic + import nifty.graph.opt.multicut as nmc + + bic_graph, nifty_graph, costs = load_problem(problem_name, timeout=args.timeout) + bic_energies = [] + nifty_energies = [] + bic_runtimes = [] + nifty_runtimes = [] + + for _ in range(args.n_repeats): + bic_objective = bic.graph.MulticutObjective(bic_graph, costs) + start = perf_counter() + bic_labels = config.make_bic_solver(args.threads).optimize(bic_objective) + bic_runtimes.append(perf_counter() - start) + bic_energies.append(bic_energy(bic_graph, costs, bic_labels)) + + nifty_objective = nmc.multicutObjective(nifty_graph, costs) + start = perf_counter() + nifty_labels = ( + config.make_nifty_factory(nifty_objective, args.threads) + .create(nifty_objective) + .optimize() + ) + nifty_runtimes.append(perf_counter() - start) + nifty_energies.append(nifty_energy(nifty_graph, costs, nifty_labels)) + + bic_runtime = mean(bic_runtimes) + nifty_runtime = mean(nifty_runtimes) + return { + "problem": problem_name, + "solver": solver_name, + "nodes": int(bic_graph.number_of_nodes), + "edges": int(bic_graph.number_of_edges), + "bic_energy": mean(bic_energies), + "nifty_energy": mean(nifty_energies), + "energy_diff": mean(bic_energies) - mean(nifty_energies), + "bic_runtime_s": bic_runtime, + "nifty_runtime_s": nifty_runtime, + "runtime_ratio": nifty_runtime / bic_runtime if bic_runtime > 0 else float("inf"), + } + + +def format_float(value: float) -> str: + return f"{value:.6g}" + + +def print_markdown_table(rows: list[dict]) -> None: + headers = [ + "problem", + "solver", + "nodes", + "edges", + "bic_energy", + "nifty_energy", + "energy_diff", + "bic_runtime_s", + "nifty_runtime_s", + "runtime_ratio_nifty_over_bic", + ] + print("| " + " | ".join(headers) + " |") + print("| " + " | ".join(["---"] * len(headers)) + " |") + for row in rows: + values = [ + row["problem"], + row["solver"], + str(row["nodes"]), + str(row["edges"]), + format_float(row["bic_energy"]), + format_float(row["nifty_energy"]), + format_float(row["energy_diff"]), + format_float(row["bic_runtime_s"]), + format_float(row["nifty_runtime_s"]), + format_float(row["runtime_ratio"]), + ] + print("| " + " | ".join(values) + " |") + + +def main() -> None: + configs = solver_configs() + parser = argparse.ArgumentParser( + description=( + "Evaluate matched bioimage-cpp and nifty multicut solvers on " + "registered multicut problems." + ) + ) + parser.add_argument( + "--solvers", + nargs="+", + choices=tuple(configs.keys()), + default=tuple(configs.keys()), + help="Solvers to evaluate. Defaults to all.", + ) + parser.add_argument( + "--problems", + nargs="+", + choices=PROBLEMS, + default=PROBLEMS, + help="Problems to evaluate. Defaults to all.", + ) + parser.add_argument("--n-repeats", type=int, default=1) + parser.add_argument("--threads", type=int, default=1) + parser.add_argument("--timeout", type=float, default=30.0) + args = parser.parse_args() + + if args.n_repeats < 1: + raise ValueError("--n-repeats must be at least 1") + if args.threads < 1: + raise ValueError("--threads must be at least 1") + + rows = [] + for problem_name in args.problems: + for solver_name in args.solvers: + rows.append(evaluate(problem_name, solver_name, configs[solver_name], args)) + print_markdown_table(rows) + + +if __name__ == "__main__": + main() diff --git a/development/segmentation/_mutex_watershed_equivalence.py b/development/segmentation/_mutex_watershed_equivalence.py index fbcb6fa..f76ed0a 100644 --- a/development/segmentation/_mutex_watershed_equivalence.py +++ b/development/segmentation/_mutex_watershed_equivalence.py @@ -1,22 +1,16 @@ from __future__ import annotations import argparse -from pathlib import Path from statistics import median from time import perf_counter from typing import Callable import numpy as np +def load_problem(): + from bioimage_cpp._data import load_isbi_affinities -PROJECT_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_DATA_PREFIX = PROJECT_ROOT / "examples" / "segmentation" / "isbi-data-" - - -def load_problem(data_prefix: Path | str = DEFAULT_DATA_PREFIX): - from elf.segmentation.utils import load_mutex_watershed_problem - - affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix)) + affinities, offsets = load_isbi_affinities() return np.ascontiguousarray(affinities), [tuple(offset) for offset in offsets] @@ -201,12 +195,11 @@ def run_check( *, ndim: int, repeats: int, - data_prefix: Path | str, z: int, yx_shape: tuple[int, int], zyx_shape: tuple[int, int, int], ): - affinities, offsets = load_problem(data_prefix) + affinities, offsets = load_problem() if ndim == 2: affs, used_offsets, attractive_channels = prepare_2d_problem( affinities, offsets, z=z, yx_shape=yx_shape @@ -237,15 +230,6 @@ def run_check( def add_common_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--data-prefix", - type=Path, - default=DEFAULT_DATA_PREFIX, - help=( - "Path prefix for the ISBI mutex watershed data. The loader expects " - "'test.h5' and 'train.h5' suffixes." - ), - ) parser.add_argument( "--repeats", type=int, diff --git a/development/segmentation/check_mutex_watershed_2d.py b/development/segmentation/check_mutex_watershed_2d.py index 22f5c5a..ec10bbe 100644 --- a/development/segmentation/check_mutex_watershed_2d.py +++ b/development/segmentation/check_mutex_watershed_2d.py @@ -29,7 +29,6 @@ def main() -> None: run_check( ndim=2, repeats=args.repeats, - data_prefix=args.data_prefix, z=args.z, yx_shape=tuple(args.shape), zyx_shape=(0, 0, 0), diff --git a/development/segmentation/check_mutex_watershed_3d.py b/development/segmentation/check_mutex_watershed_3d.py index 3d65e2b..0c74b67 100644 --- a/development/segmentation/check_mutex_watershed_3d.py +++ b/development/segmentation/check_mutex_watershed_3d.py @@ -23,7 +23,6 @@ def main() -> None: run_check( ndim=3, repeats=args.repeats, - data_prefix=args.data_prefix, z=0, yx_shape=(0, 0), zyx_shape=tuple(args.shape), diff --git a/examples/segmentation/_lifted_problem.py b/examples/segmentation/_lifted_problem.py index b862012..af6ad1e 100644 --- a/examples/segmentation/_lifted_problem.py +++ b/examples/segmentation/_lifted_problem.py @@ -9,15 +9,13 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path - import numpy as np -from elf.segmentation.utils import load_mutex_watershed_problem from skimage.feature import peak_local_max from skimage.measure import label as label_components from skimage.segmentation import watershed import bioimage_cpp as bic +from bioimage_cpp._data import load_isbi_affinities @dataclass @@ -53,11 +51,10 @@ def local_uvs(self) -> np.ndarray: def load_affinity_problem( - data_prefix: Path, ndim: int, z_slice: int, ) -> AffinityProblem: - affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix)) + affinities, offsets = load_isbi_affinities() offsets = [tuple(int(v) for v in offset) for offset in offsets] if ndim == 2: channels_2d = [index for index, offset in enumerate(offsets) if offset[0] == 0] diff --git a/examples/segmentation/lifted_multicut_from_affinities.py b/examples/segmentation/lifted_multicut_from_affinities.py index 6f5f7e2..b739f5f 100644 --- a/examples/segmentation/lifted_multicut_from_affinities.py +++ b/examples/segmentation/lifted_multicut_from_affinities.py @@ -1,26 +1,19 @@ from __future__ import annotations import argparse -from pathlib import Path import napari -from elf.io import open_file from skimage.segmentation import find_boundaries import bioimage_cpp as bic +from bioimage_cpp._data import load_isbi_raw from _lifted_problem import build_lifted_problem, load_affinity_problem -THIS_DIR = Path(__file__).resolve().parent -DEFAULT_DATA_PREFIX = THIS_DIR / "isbi-data-" - - -def load_raw(data_prefix: Path, ndim: int, z_slice: int): - data_path = data_prefix.with_name(data_prefix.name + "test.h5") - with open_file(data_path, "r") as f: - raw = f["raw"][z_slice] if ndim == 2 else f["raw"][:] - return raw +def load_raw(ndim: int, z_slice: int): + raw = load_isbi_raw() + return raw[z_slice] if ndim == 2 else raw def main(): @@ -32,7 +25,6 @@ def main(): ) parser.add_argument("--ndim", type=int, choices=(2, 3), default=2) parser.add_argument("--z-slice", type=int, default=0) - parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX) parser.add_argument("--local-threshold", type=float, default=0.1) parser.add_argument("--lifted-threshold", type=float, default=0.1) parser.add_argument("--threads", type=int, default=0) @@ -42,8 +34,8 @@ def main(): parser.add_argument("--kl-outer-iterations", type=int, default=10) args = parser.parse_args() - affinity_problem = load_affinity_problem(args.data_prefix, args.ndim, args.z_slice) - raw = load_raw(args.data_prefix, args.ndim, args.z_slice) + affinity_problem = load_affinity_problem(args.ndim, args.z_slice) + raw = load_raw(args.ndim, args.z_slice) lifted_problem = build_lifted_problem( affinity_problem, diff --git a/examples/segmentation/multicut_from_affinities.py b/examples/segmentation/multicut_from_affinities.py index 50b6a57..1926659 100644 --- a/examples/segmentation/multicut_from_affinities.py +++ b/examples/segmentation/multicut_from_affinities.py @@ -1,25 +1,19 @@ from __future__ import annotations import argparse -from pathlib import Path import napari import numpy as np -from elf.io import open_file -from elf.segmentation.utils import load_mutex_watershed_problem from skimage.feature import peak_local_max from skimage.measure import label as label_components from skimage.segmentation import find_boundaries, watershed import bioimage_cpp as bic +from bioimage_cpp._data import load_isbi_affinities, load_isbi_raw -THIS_DIR = Path(__file__).resolve().parent -DEFAULT_DATA_PREFIX = THIS_DIR / "isbi-data-" - - -def load_problem(data_prefix: Path, ndim: int, z_slice: int): - affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix)) +def load_problem(ndim: int, z_slice: int): + affinities, offsets = load_isbi_affinities() offsets = [tuple(int(v) for v in offset) for offset in offsets] if ndim == 2: channels_2d = [index for index, offset in enumerate(offsets) if offset[0] == 0] @@ -36,11 +30,9 @@ def load_problem(data_prefix: Path, ndim: int, z_slice: int): return direct_affinities, direct_offsets -def load_raw(data_prefix: Path, ndim: int, z_slice: int): - data_path = data_prefix.with_name(data_prefix.name + "test.h5") - with open_file(data_path, "r") as f: - raw = f["raw"][z_slice] if ndim == 2 else f["raw"][:] - return raw +def load_raw(ndim: int, z_slice: int) -> np.ndarray: + raw = load_isbi_raw() + return np.ascontiguousarray(raw[z_slice] if ndim == 2 else raw) def make_heightmap(affinities: np.ndarray) -> np.ndarray: @@ -128,7 +120,6 @@ def main(): ) parser.add_argument("--ndim", type=int, choices=(2, 3), default=2) parser.add_argument("--z-slice", type=int, default=0) - parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX) parser.add_argument("--threshold", type=float, default=0.1) parser.add_argument("--threads", type=int, default=0) parser.add_argument("--watershed-min-distance", type=int, default=5) @@ -136,8 +127,8 @@ def main(): parser.add_argument("--max-markers", type=int, default=2048) args = parser.parse_args() - affinities, offsets = load_problem(args.data_prefix, args.ndim, args.z_slice) - raw = load_raw(args.data_prefix, args.ndim, args.z_slice) + affinities, offsets = load_problem(args.ndim, args.z_slice) + raw = load_raw(args.ndim, args.z_slice) heightmap, oversegmentation, segmentation = multicut_from_affinities( affinities, offsets, diff --git a/examples/segmentation/mutex_watershed.py b/examples/segmentation/mutex_watershed.py index a47aa2f..8f64127 100644 --- a/examples/segmentation/mutex_watershed.py +++ b/examples/segmentation/mutex_watershed.py @@ -1,7 +1,6 @@ import napari -from elf.segmentation.utils import load_mutex_watershed_problem -from elf.io import open_file +from bioimage_cpp._data import load_isbi_affinities, load_isbi_raw def mws_bic(affinities, offsets): @@ -26,18 +25,15 @@ def _filter_2d(affinities, offsets): def main(): - prefix = "isbi-data-" - data_path = f"{prefix}test.h5" - affinities, offsets = load_mutex_watershed_problem(prefix=prefix) + affinities, offsets = load_isbi_affinities() + raw = load_isbi_raw() check_2d = True if check_2d: affinities, offsets = _filter_2d(affinities, offsets) + raw = raw[0] segmentation = mws_bic(affinities, offsets) - with open_file(data_path, "r") as f: - raw = f["raw"][0] if check_2d else f["raw"][:] - viewer = napari.Viewer() viewer.add_image(raw, name="raw") viewer.add_image(affinities, name="affinities") diff --git a/examples/segmentation/serialize_grid_lifted_problem.py b/examples/segmentation/serialize_grid_lifted_problem.py new file mode 100644 index 0000000..a67f575 --- /dev/null +++ b/examples/segmentation/serialize_grid_lifted_problem.py @@ -0,0 +1,175 @@ +"""Serialize a grid-graph ISBI lifted-multicut problem to a .npz file. + +This builds a lifted multicut problem directly on the regular pixel/voxel grid +from the ISBI example affinities. It uses ``grid_graph`` plus +``grid_affinity_features_with_lifted`` and writes the same fields as +``serialize_lifted_problem.py``: + + n_nodes : scalar uint64 + local_uvs : (n_local, 2) uint64 + local_costs : (n_local,) float64 + lifted_uvs : (n_lifted, 2) uint64 + lifted_costs : (n_lifted,) float64 +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np + +import bioimage_cpp as bic +from bioimage_cpp._data import load_isbi_affinities + + +THIS_DIR = Path(__file__).resolve().parent +DEFAULT_OUTPUT = THIS_DIR / "grid_lifted_multicut_problem.npz" + + +def load_affinities( + ndim: int, + spatial_shape: tuple[int, ...], + z_slice: int, +) -> tuple[np.ndarray, list[tuple[int, ...]]]: + affinities, offsets = load_isbi_affinities() + offsets = [tuple(int(v) for v in offset) for offset in offsets] + + if ndim == 2: + y, x = spatial_shape + channels_2d = [index for index, offset in enumerate(offsets) if offset[0] == 0] + affinities = affinities[channels_2d, z_slice, :y, :x] + offsets = [offsets[index][1:] for index in channels_2d] + elif ndim == 3: + z, y, x = spatial_shape + affinities = affinities[:, :z, :y, :x] + else: + raise ValueError(f"ndim must be 2 or 3, got {ndim}") + + if affinities.shape[1:] != spatial_shape: + raise ValueError( + f"requested spatial shape {spatial_shape} exceeds available data; " + f"extracted shape is {affinities.shape[1:]}" + ) + + return np.ascontiguousarray(affinities, dtype=np.float32), offsets + + +def parse_spatial_shape(values: list[int] | None, ndim: int) -> tuple[int, ...]: + if values is None: + return (256, 256) if ndim == 2 else (16, 256, 256) + if len(values) != ndim: + raise ValueError( + f"--spatial-shape must contain {ndim} values for {ndim}D, " + f"got {len(values)}" + ) + if any(value <= 0 for value in values): + raise ValueError("--spatial-shape values must be positive") + return tuple(values) + + +def build_grid_lifted_problem( + affinities: np.ndarray, + offsets: list[tuple[int, ...]], + *, + local_threshold: float, + lifted_threshold: float, +): + graph = bic.graph.grid_graph(affinities.shape[1:]) + local_weights, valid_edges, lifted_uvs, lifted_weights, _ = ( + bic.graph.grid_affinity_features_with_lifted(graph, affinities, offsets) + ) + if not np.all(valid_edges): + invalid = int(valid_edges.size - np.count_nonzero(valid_edges)) + raise RuntimeError( + "local affinity offsets did not cover all grid graph edges; " + f"{invalid} edges are missing" + ) + + local_costs = (local_threshold - local_weights).astype(np.float64, copy=False) + lifted_costs = (lifted_threshold - lifted_weights).astype(np.float64, copy=False) + return ( + int(graph.number_of_nodes), + graph.uv_ids(), + np.ascontiguousarray(local_costs), + np.ascontiguousarray(lifted_uvs.astype(np.uint64, copy=False)), + np.ascontiguousarray(lifted_costs), + ) + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Build an ISBI lifted multicut problem directly on a regular grid " + "graph and serialize it to a .npz file." + ) + ) + parser.add_argument("--ndim", type=int, choices=(2, 3), default=2) + parser.add_argument( + "--spatial-shape", + type=int, + nargs="+", + default=None, + metavar=("Y", "X"), + help=( + "Spatial crop shape. Pass Y X for 2D or Z Y X for 3D. " + "Defaults to 256 256 for 2D and 16 256 256 for 3D." + ), + ) + parser.add_argument( + "--z-slice", + type=int, + default=0, + help="Z slice used for 2D extraction.", + ) + parser.add_argument("--local-threshold", type=float, default=0.1) + parser.add_argument("--lifted-threshold", type=float, default=0.1) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + args = parser.parse_args() + + spatial_shape = parse_spatial_shape(args.spatial_shape, args.ndim) + affinities, offsets = load_affinities( + args.ndim, spatial_shape, args.z_slice + ) + n_nodes, local_uvs, local_costs, lifted_uvs, lifted_costs = ( + build_grid_lifted_problem( + affinities, + offsets, + local_threshold=args.local_threshold, + lifted_threshold=args.lifted_threshold, + ) + ) + + local_uvs = np.ascontiguousarray(local_uvs.astype(np.uint64, copy=False)) + n_nodes_array = np.uint64(n_nodes) + + args.output.parent.mkdir(parents=True, exist_ok=True) + np.savez_compressed( + args.output, + n_nodes=n_nodes_array, + local_uvs=local_uvs, + local_costs=local_costs, + lifted_uvs=lifted_uvs, + lifted_costs=lifted_costs, + ) + + print(f"Wrote grid lifted multicut problem to {args.output}") + print(f" ndim: {args.ndim}") + print(f" spatial shape: {spatial_shape}") + print(f" number of nodes: {n_nodes}") + print(f" number of local edges: {local_uvs.shape[0]}") + print(f" number of lifted edges: {lifted_uvs.shape[0]}") + if local_costs.size: + print( + f" local cost range: [{float(local_costs.min()):+.3f}, " + f"{float(local_costs.max()):+.3f}]" + ) + if lifted_costs.size: + print( + f" lifted cost range: [{float(lifted_costs.min()):+.3f}, " + f"{float(lifted_costs.max()):+.3f}]" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/segmentation/serialize_lifted_problem.py b/examples/segmentation/serialize_lifted_problem.py index 4f2da92..c5017b1 100644 --- a/examples/segmentation/serialize_lifted_problem.py +++ b/examples/segmentation/serialize_lifted_problem.py @@ -25,7 +25,6 @@ THIS_DIR = Path(__file__).resolve().parent -DEFAULT_DATA_PREFIX = THIS_DIR / "isbi-data-" DEFAULT_OUTPUT = THIS_DIR / "lifted_multicut_problem.npz" @@ -38,7 +37,6 @@ def main(): ) parser.add_argument("--ndim", type=int, choices=(2, 3), default=2) parser.add_argument("--z-slice", type=int, default=0) - parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX) parser.add_argument("--local-threshold", type=float, default=0.1) parser.add_argument("--lifted-threshold", type=float, default=0.1) parser.add_argument("--threads", type=int, default=0) @@ -48,7 +46,7 @@ def main(): parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) args = parser.parse_args() - affinity_problem = load_affinity_problem(args.data_prefix, args.ndim, args.z_slice) + affinity_problem = load_affinity_problem(args.ndim, args.z_slice) lifted_problem = build_lifted_problem( affinity_problem, local_threshold=args.local_threshold, diff --git a/src/bioimage_cpp/_data.py b/src/bioimage_cpp/_data.py index 13734f0..7993f68 100644 --- a/src/bioimage_cpp/_data.py +++ b/src/bioimage_cpp/_data.py @@ -17,7 +17,12 @@ ``examples/segmentation/serialize_lifted_problem.py``): - ``lifted_multicut_problem_2d.npz`` — 2D ISBI slice (small, ~756 nodes). -- ``lifted_multicut_problem_3d.npz`` — full 3D ISBI volume (large, ~18k nodes). +- ``lifted_multicut_problem_3d.npz`` — full 3D ISBI volume (medium, ~18k nodes). +- ``lifted_multicut_problem_grid.npz`` — lifted multicut problem from grid graph (large, ~260k nodes). + +Affinities: +- ``affinities`` — HDF5 file with sample affinities from the ISBI volume. + Contains affinities under key ``affinities``. """ from __future__ import annotations @@ -26,8 +31,30 @@ from pathlib import Path from typing import Optional +import numpy as np + DEFAULT_CACHE_DIR = Path.home() / ".cache" / "bioimage-cpp" CACHE_ENV_VAR = "BIOIMAGE_CPP_CACHE" +ISBI_AFFINITY_FILENAME = "affinities" +ISBI_AFFINITY_OFFSETS = ( + (-1, 0, 0), + (0, -1, 0), + (0, 0, -1), + (-1, -1, -1), + (-1, 1, 1), + (-1, -1, 1), + (-1, 1, -1), + (0, -9, 0), + (0, 0, -9), + (0, -9, -9), + (0, 9, -9), + (0, -9, -4), + (0, -4, -9), + (0, 4, -9), + (0, 9, -4), + (0, -27, 0), + (0, 0, -27), +) # Each entry is filename -> (url, sha256). To refresh a hash, delete the @@ -66,6 +93,14 @@ "https://owncloud.gwdg.de/index.php/s/ZVzDy8Xb0Dr2Ell/download", "269ce644e2b9f8259f7f2ff827d5808ac5c9bfe6ca0444e298290f23867dce8a", ), + "lifted_multicut_problem_grid.npz": ( + "https://owncloud.gwdg.de/index.php/s/YWNZSYsBd1VwSX1/download", + "20583b2000838ed0942f8f1c343b84287d8bf218d19d77a8b5627924661c5aa3", + ), + "affinities": ( + "https://owncloud.gwdg.de/index.php/s/aAyF2ekzsW7DFJo/download", + "6472ad0fcf3c57a4ae345fda68c3cbb6072ee3e8db67b423502746b46d8cd5e5", + ), } @@ -130,3 +165,52 @@ def fetch(filename: str, *, timeout: Optional[float] = None) -> Path: f"could not download {filename} from {url}: {error}" ) from error return Path(local_path) + + +def affinity_path(*, timeout: Optional[float] = None) -> Path: + """Return the cached path to the registered ISBI affinity HDF5 file.""" + return fetch(ISBI_AFFINITY_FILENAME, timeout=timeout) + + +def load_isbi_affinities( + *, + timeout: Optional[float] = None, +) -> tuple[np.ndarray, list[tuple[int, int, int]]]: + """Load the registered ISBI affinity volume and its offsets. + + The offsets are the fixed channel offsets used by + ``elf.segmentation.utils.load_mutex_watershed_problem`` for this data. + """ + try: + import h5py + except ModuleNotFoundError as error: + raise ModuleNotFoundError( + "h5py is required to load the registered ISBI affinity file. " + "Install it with `pip install h5py`." + ) from error + + with h5py.File(affinity_path(timeout=timeout), "r") as f: + affinities = f["affinities"][:] + return np.ascontiguousarray(affinities), list(ISBI_AFFINITY_OFFSETS) + + +def load_isbi_raw( + *, + timeout: Optional[float] = None, +) -> np.ndarray: + """Load the registered ISBI raw volume. + + The raw data is stored in the same HDF5 file as the affinities under key + ``raw``. + """ + try: + import h5py + except ModuleNotFoundError as error: + raise ModuleNotFoundError( + "h5py is required to load the registered ISBI raw file. " + "Install it with `pip install h5py`." + ) from error + + with h5py.File(affinity_path(timeout=timeout), "r") as f: + raw = f["raw"][:] + return np.ascontiguousarray(raw) diff --git a/src/bioimage_cpp/graph/_external.py b/src/bioimage_cpp/graph/_external.py index 5846d5a..86639cf 100644 --- a/src/bioimage_cpp/graph/_external.py +++ b/src/bioimage_cpp/graph/_external.py @@ -7,8 +7,8 @@ :func:`multicut_problem_path` for the 6 multicut problems (3 samples × 2 sizes from ``elf.segmentation.utils.load_multicut_problem``). - :func:`load_lifted_multicut_problem` / - :func:`lifted_multicut_problem_path` for the 2D and 3D lifted multicut - problems built by ``examples/segmentation/serialize_lifted_problem.py``. + :func:`lifted_multicut_problem_path` for the RAG-based and grid-graph + lifted multicut problems. A legacy compatibility layer (``load_external_multicut_problem`` and friends) delegates to sample A, size small, which is the problem the regression test @@ -29,7 +29,7 @@ VALID_SAMPLES = ("A", "B", "C") VALID_SIZES = ("small", "medium") -VALID_LIFTED_SIZES = ("2d", "3d") +VALID_LIFTED_SIZES = ("2d", "3d", "grid") # Legacy constants. The URL points at the canonical sample-A-small download @@ -132,8 +132,8 @@ def load_lifted_multicut_problem( ---------- size: ``"2d"`` for the small ISBI 2D slice (~756 nodes, fast, used by the - regression test) or ``"3d"`` for the full ISBI volume (~18k nodes, - used by the development comparison scripts). + regression test), ``"3d"`` for the full RAG-based ISBI volume + (~18k nodes), or ``"grid"`` for the grid-graph lifted problem. timeout: Optional HTTP timeout in seconds for the download. """