This guide explains how to migrate code that used selected nifty or
affogato functionality to bioimage-cpp.
bioimage-cpp is not a drop-in compatibility layer. The package keeps a
smaller, NumPy-first API with Python-style method names, explicit dtype support,
and no I/O dependencies in the C++ core.
Use:
import bioimage_cpp as bicGraph functionality is under bic.graph, segmentation functionality is under
bic.segmentation, ground-truth comparison functionality is under
bic.ground_truth, and small utility functions are under bic.utils.
Nifty:
import nifty.tools as nt
blocking = nt.blocking([0, 0], [100, 80], [32, 32])
block = blocking.getBlock(0)
block_with_halo = blocking.getBlockWithHalo(0, [8, 8])bioimage-cpp:
import bioimage_cpp as bic
blocking = bic.Blocking([0, 0], [100, 80], [32, 32])
block = blocking.get_block(0)
block_with_halo = blocking.get_block_with_halo(0, [8, 8])Name changes:
| nifty-style name | bioimage-cpp name |
|---|---|
roiBegin |
roi_begin |
roiEnd |
roi_end |
blockShape |
block_shape |
blockShift |
block_shift |
blocksPerAxis |
blocks_per_axis |
numberOfBlocks |
number_of_blocks |
blockGridPosition |
block_grid_position |
getNeighborId |
get_neighbor_id |
getBlock |
get_block |
getBlockWithHalo |
get_block_with_halo |
addHalo |
add_halo |
coordinatesToBlockId |
coordinates_to_block_id |
getBlockIdsInBoundingBox |
get_block_ids_in_bounding_box |
getBlockIdsOverlappingBoundingBox |
get_block_ids_overlapping_bounding_box |
getLocalOverlaps |
get_local_overlaps |
getBlockIdsInSlice |
get_block_ids_in_slice |
Intentional improvements over nifty:
coordinates_to_block_idaccounts for bothroi_beginandblock_shift.get_block_ids_overlapping_bounding_boxworks for any dimensionality, not only 3D.- Bounding boxes use NumPy-style half-open intervals:
[begin, end). get_local_overlapsreturnsNoneif blocks do not overlap; otherwise it returns(begin_a, end_a, begin_b, end_b)in local coordinates.
Nifty:
import nifty.graph as ng
graph = ng.undirectedGraph(4)
edge_id = graph.insertEdge(0, 1)
uvs = graph.uvIds()bioimage-cpp:
import bioimage_cpp as bic
graph = bic.graph.UndirectedGraph(4)
edge_id = graph.insert_edge(0, 1)
uvs = graph.uv_ids()The convenience constructor is:
graph = bic.graph.undirected_graph(4)
graph = bic.graph.UndirectedGraph.from_edges(4, [[0, 1], [1, 2]])Important differences:
- Nodes are fixed at construction and have ids
0 .. number_of_nodes - 1. - Re-inserting an existing undirected edge returns the existing edge id.
- Bulk methods accept array-like inputs and return NumPy arrays.
- Python-style names are preferred. A few nifty-style aliases are still present
on
UndirectedGraphfor convenience, but new code should use snake_case. graph.clone()returns an independent deep copy. The C++ class is move-only (it owns a CSR adjacency buffer), so prefer this over reassignment-by-value.graph.freeze()eagerly builds the internal adjacency. Call it after a batch ofinsert_edgecalls if you intend to hand the graph to multiple reader threads, or if you want to ensure subsequentnode_adjacencyreads carry no first-call rebuild cost.
Common method/property mapping:
| nifty-style name | bioimage-cpp name |
|---|---|
numberOfNodes |
number_of_nodes |
numberOfEdges |
number_of_edges |
nodeIdUpperBound |
node_id_upper_bound |
edgeIdUpperBound |
edge_id_upper_bound |
insertEdge |
insert_edge |
insertEdges |
insert_edges |
findEdge |
find_edge |
findEdges |
find_edges |
uvIds |
uv_ids |
nodeAdjacency |
node_adjacency |
serializationSize |
serialization_size |
extractSubgraphFromNodes |
extract_subgraph_from_nodes |
edgesFromNodeList |
edges_from_node_list |
Nifty-style regular grid graphs map to explicit 2D or 3D grid graph classes:
graph = bic.graph.GridGraph2D((height, width))
graph = bic.graph.GridGraph3D((depth, height, width))
graph = bic.graph.grid_graph((height, width))Grid graph nodes use NumPy C-order ids. For a 2D shape (y, x), node
(row, col) has id row * x + col; for 3D (z, y, x), ids follow the same
row-major convention. GridGraph2D and GridGraph3D inherit the regular
UndirectedGraph API, so solvers, connected components, breadth-first search,
and uv_ids() work unchanged.
Important differences:
- Only nearest-neighbor 2D and 3D grids are exposed for now.
- Edge ids are deterministic axis blocks: axis 0 edges first, then axis 1, and axis 2 for 3D.
- Scalar boundary maps can be converted to edge weights with
grid_boundary_features(graph, boundary_map). - Local affinity channels can be converted to edge-aligned weights with
grid_affinity_features(graph, affinities, offsets). - Mixed local and long-range affinity offsets can be converted with
grid_affinity_features_with_lifted(...), which returns local graph weights plus explicit long-rangeuv_idsand weights for lifted multicut or mutex watershed style workflows. - The three grid feature functions preserve
float32andfloat64input dtype end-to-end (no internal copy tofloat64); other dtypes are promoted tofloat64. Output weight arrays match the input dtype. - Grid graph construction does not materialize the per-node adjacency
list. If you only need
uv_ids()and edge features (the common case) you pay nothing for adjacency. The first call tonode_adjacency,connected_components,breadth_first_search, orextract_subgraph_from_nodeson a grid graph triggers a one-shot rebuild; callgraph.freeze()on the construction thread before fan-out if you intend to use those from multiple threads. - Affogato-style masks and seed edges are not part of the public grid feature API yet; the implementation is structured so these filters/extra edges can be added later.
Nifty:
import nifty.graph.rag as nrag
rag = nrag.gridRag(labels)
uvs = rag.uvIds()bioimage-cpp:
import bioimage_cpp as bic
rag = bic.graph.region_adjacency_graph(labels)
uvs = rag.uv_ids()Notes:
- Supported label dtypes are
uint32,uint64,int32, andint64. - Labels must be 2D or 3D.
- Negative signed labels are rejected.
- Nodes correspond to label ids from
0tolabels.max(). - Edge ids are deterministic; RAG edges are sorted lexicographically by endpoint ids.
- Non-contiguous labels are copied to contiguous memory before entering C++.
Nifty has RAG feature helpers such as accumulateEdgeMeanAndLength,
accumulateEdgeStandartFeatures, and affinity feature accumulation helpers.
In bioimage-cpp, these are exposed as explicit NumPy-returning functions.
Simple edge-map features:
rag = bic.graph.region_adjacency_graph(labels)
features = bic.graph.edge_map_features(rag, labels, edge_map)The columns are:
bic.graph.SIMPLE_EDGE_FEATURE_NAMES
# ("mean", "size")Complex edge-map features:
features = bic.graph.edge_map_features_complex(rag, labels, edge_map)The columns are:
bic.graph.COMPLEX_EDGE_FEATURE_NAMES
# ("mean", "median", "std", "min", "max", "p5", "p10",
# "p25", "p75", "p90", "p95", "size")Affinity features:
features = bic.graph.affinity_features(
rag,
labels,
affinities,
offsets=[[0, 1], [1, 0]],
)Complex affinity features:
features = bic.graph.affinity_features_complex(
rag,
labels,
affinities,
offsets=[[0, 1], [1, 0]],
)Notes:
edge_mapmust have the same shape aslabels.affinitiesmust have shape(channels, *labels.shape).offsetsmust have one offset per channel in NumPy axis order.- Feature arrays use
float64output. number_of_threads=0uses the library default; pass a positive integer for a fixed thread count.
Nifty projects scalar node data back to pixels with
projectScalarNodeDataToPixels.
Nifty:
import nifty.graph.rag as nrag
rag = nrag.gridRag(labels)
pixel_labels = nrag.projectScalarNodeDataToPixels(rag, node_labels)bioimage-cpp:
rag = bic.graph.region_adjacency_graph(labels)
pixel_labels = bic.graph.project_node_labels_to_pixels(
rag,
labels,
node_labels,
)Notes:
labelsmust be the over-segmentation used to constructrag.node_labelsmust be a 1D array with lengthrag.number_of_nodes.- The output has the same shape as
labelsand dtypeuint64. number_of_threads=0uses the library default; pass a positive integer for a fixed thread count.
Nifty:
import nifty.graph as ng
labels = ng.edgeWeightedWatershedsSegmentation(graph, edge_weights, seeds)bioimage-cpp:
import bioimage_cpp as bic
labels = bic.graph.edge_weighted_watershed(graph, edge_weights, seeds)Notes:
- Only the Kruskal variant of nifty's algorithm is provided. Edges are visited
in ascending weight order; two distinct components merge iff at least one is
unlabeled (seed label
0), so seed boundaries are preserved. graphmay be anUndirectedGraphor aRegionAdjacencyGraph.edge_weightsmust be 1D with lengthgraph.number_of_edges. Supported dtypes arefloat32andfloat64; other floating dtypes are promoted tofloat32(matching nifty, whose Python binding isfloat32-only). Non-float dtypes raiseTypeError.seedsmust be 1D with lengthgraph.number_of_nodes. Supported dtypes areuint32,uint64,int32,int64. The value0marks unlabeled nodes; non-zero ids are propagated along low-weight paths. Signed seed arrays must not contain negative values.- The output is 1D with length
graph.number_of_nodesand the same dtype asseeds. Seed label values are preserved (no dense relabeling). Nodes that no seed can reach remain0.
Intentional differences vs. nifty:
- No priority-queue variant — only the simpler sort + union-find Kruskal flow. For the same input it matches nifty's default behavior (which also dispatches to the Kruskal implementation).
- No carving / background-bias variant. Build a carving prior into the edge weights before calling the function if needed.
Nifty exposes multicut through an objective + factory-style solver hierarchy.
bioimage-cpp uses an explicit MulticutObjective and a MulticutSolver class
hierarchy with a single optimize(objective) entry point.
Nifty:
import nifty.graph.opt.multicut as nmc
objective = nmc.multicutObjective(graph, edge_costs)
solver = objective.greedyAdditiveFactory().create(objective)
labels = solver.optimize()
energy = objective.evalNodeLabels(labels)bioimage-cpp:
import bioimage_cpp as bic
objective = bic.graph.MulticutObjective(graph, edge_costs)
labels = bic.graph.GreedyAdditiveMulticut().optimize(objective)
energy = objective.energy(labels)MulticutObjective accepts an UndirectedGraph or a RegionAdjacencyGraph and
a 1D edge_costs array of length graph.number_of_edges. The objective owns
the current best labels; optimize updates them in place and also returns
the new array.
Available solvers:
| nifty factory | bioimage-cpp solver |
|---|---|
greedyAdditiveFactory() |
GreedyAdditiveMulticut() |
greedyFixationFactory() |
GreedyFixationMulticut() |
kernighanLinFactory(...) |
KernighanLinMulticut(...) |
chainedSolversFactory([...]) |
ChainedMulticutSolvers([...]) |
multicutDecomposer(submodelFactory=...) |
MulticutDecomposer(sub_solver=...) |
Constructor argument mapping:
| nifty argument | bioimage-cpp argument |
|---|---|
weightStop |
weight_stop |
nodeNumStop |
node_num_stop |
addNoise |
add_noise |
numberOfOuterIterations |
number_of_outer_iterations |
numberOfInnerIterations |
number_of_inner_iterations |
epsilon |
epsilon |
submodelFactory |
sub_solver |
fallthroughFactory |
fallthrough_solver |
numberOfThreads |
number_of_threads |
Kernighan-Lin example:
solver = bic.graph.KernighanLinMulticut(number_of_outer_iterations=5)
labels = solver.optimize(objective)If the objective's labels are left at the default (one cluster per node),
KernighanLinMulticut warm-starts from a greedy-additive solution
internally, matching kernighanLinFactory(warmStartGreedy=True). To skip the
warm-start, set objective.set_labels(...) to a non-trivial labeling first.
Chaining solvers:
solver = bic.graph.ChainedMulticutSolvers([
bic.graph.GreedyAdditiveMulticut(),
bic.graph.KernighanLinMulticut(number_of_outer_iterations=5),
])
labels = solver.optimize(objective)Decomposing a problem into positive-cost connected components and solving each sub-problem with a cheaper solver:
solver = bic.graph.MulticutDecomposer(
sub_solver=bic.graph.KernighanLinMulticut(number_of_outer_iterations=5),
fallthrough_solver=bic.graph.GreedyAdditiveMulticut(),
number_of_threads=0,
)
labels = solver.optimize(objective)Notes:
edge_costsmust befloat64and 1D with lengthgraph.number_of_edges.- Output labels are dense
uint64ids in0 .. number_of_clusters - 1. MulticutObjective.energy(labels)is the multicut energy used internally; it matchesnmc.multicutObjective(...).evalNodeLabels(labels).objective.reset_labels()restores the per-node initial labeling, useful when re-running solvers from a clean state.
Intentional differences vs. nifty:
- Solvers are plain Python classes — no
factory().create(objective)step. - Solver arguments use snake_case and are keyword-only where appropriate.
KernighanLinMulticutruns a border-restricted move chain plus an explicit cluster-split phase, matching nifty's local optima on the standard multicut benchmark while being noticeably faster.MulticutDecomposershort-circuits the trivial case where the sub-solver isGreedyAdditiveMulticutand no fallthrough is given — the greedy solver already operates on each connected component internally.
Nifty exposes the fusion-move multicut solver via the factory hierarchy with a chosen proposal generator and sub-solver factory.
Nifty:
import nifty.graph.opt.multicut as nmc
objective = nmc.multicutObjective(graph, edge_costs)
pgen = nmc.watershedProposals(sigma=1.0, numberOfSeeds=0.1)
factory = nmc.fusionMoveBasedFactory(
proposalGenerator=pgen,
fusionMove=nmc.fusionMoveSettings(
mcFactory=nmc.greedyAdditiveFactory(),
),
numberOfIterations=10,
stopIfNoImprovement=4,
)
labels = factory.create(objective).optimize()bioimage-cpp:
import bioimage_cpp as bic
objective = bic.graph.MulticutObjective(graph, edge_costs)
solver = bic.graph.FusionMoveMulticut(
proposal_generator=bic.graph.WatershedProposalGenerator(
sigma=1.0, n_seeds_fraction=0.1, seed=0,
),
sub_solver=bic.graph.GreedyAdditiveMulticut(),
number_of_iterations=10,
stop_if_no_improvement=4,
)
labels = solver.optimize(objective)Proposal generators:
| nifty proposal generator | bioimage-cpp proposal generator |
|---|---|
watershedProposals(sigma=..., numberOfSeeds=...) |
WatershedProposalGenerator(sigma=..., n_seeds_fraction=..., seed=...) |
greedyAdditiveProposals(sigma=..., weightStopCond=..., nodeNumStopCond=...) |
GreedyAdditiveProposalGenerator(sigma=..., weight_stop=..., node_num_stop=..., seed=...) |
Sub-solvers: any built-in multicut solver (GreedyAdditiveMulticut,
GreedyFixationMulticut, KernighanLinMulticut). If sub_solver is omitted,
the default is GreedyAdditiveMulticut constructed with no-noise defaults.
Intentional differences vs. nifty:
- Single object construction: no separate factory / solver step.
- Proposal generators are Python classes carrying their settings; the C++
proposal-generator object is built lazily when
optimizeis called. - The driver warm-starts from the trivial singleton labeling by running the default greedy-additive sub-solver once before the proposal loop.
- A best-of safety net keeps the running energy monotonically non-increasing across iterations (compared against current, proposals, fused, and the stage-2 joint fuse).
- Parallel proposal generation and a multi-proposal joint fuse are supported:
number_of_threads=Trunsnumber_of_parallel_proposals=Pproposal generators in parallel within each iteration. By defaultP=2whenT=1andP=TwhenT>1; pass an explicitnumber_of_parallel_proposalsto override. Each parallel slot uses an independent proposal generator with seedproposal_generator.seed + slot_indexso the result is deterministic for a given(seed, T, P). When at least two parallel pairwise fuses fail to improve on the current best, a joint multi-proposal fuse runs over the surviving fused candidates (matches nifty'sccFusionMoveBasedstage-2 behaviour).
Notes:
- Custom Python proposal generators are not yet supported; subclass
ProposalGeneratorand provide your own_buildreturning a C++ proposal-generator object if you need to extend the set.
Nifty exposes lifted multicut through a separate objective + solver hierarchy.
bioimage-cpp mirrors the structure with LiftedMulticutObjective and a
LiftedMulticutSolver class hierarchy.
Nifty:
import nifty.graph.opt.lifted_multicut as nlmc
objective = nlmc.liftedMulticutObjective(graph)
objective.insertLiftedEdgesBfs(max_distance=3)
for u, v, w in lifted_weights:
objective.setCost(u, v, w)
solver = objective.liftedMulticutGreedyAdditiveFactory().create(objective)
labels = solver.optimize()bioimage-cpp:
import bioimage_cpp as bic
objective = bic.graph.LiftedMulticutObjective(
graph,
edge_costs,
lifted_uvs=lifted_uvs,
lifted_costs=lifted_costs,
bfs_distance=3, # optional: also insert zero-weight lifted edges within k hops
)
labels = bic.graph.LiftedGreedyAdditiveMulticut().optimize(objective)
energy = objective.energy(labels)LiftedMulticutObjective accepts:
graph— anUndirectedGraphorRegionAdjacencyGraph. The constructor copies the topology, so further mutations on the input graph do not affect the objective.edge_costs— 1Dfloat64array of lengthgraph.number_of_edges.lifted_uvs/lifted_costs— optional(n_lifted, 2)uint64 array and 1D float64 array of equal length, listing the additional lifted edges and their weights.bfs_distance— optional positive integer. Adds a zero-weight lifted edge for every pair of nodes within this many base-graph hops of each other (excluding nodes already connected by a base edge). Pairs with bothlifted_uvsandbfs_distanceto seed the topology and then update specific weights.overwrite_existing— whenTrue, lifted entries that coincide with an existing edge replace its weight; the default accumulates.
Available solvers (no ILP solvers yet):
| nifty factory | bioimage-cpp solver |
|---|---|
liftedMulticutGreedyAdditiveFactory() |
LiftedGreedyAdditiveMulticut() |
liftedMulticutKernighanLinFactory(...) |
LiftedKernighanLinMulticut(...) |
fusionMoveBasedFactory(...) |
FusionMoveLiftedMulticut(...) |
chainedSolversFactory([...]) |
LiftedChainedSolvers([...]) |
FusionMoveLiftedMulticut mirrors FusionMoveMulticut (same proposal-generator
plumbing, same threading + multi-proposal joint-fuse semantics, same best-of
safety net). The differences are:
- Proposal generators operate on the base graph and base edge costs (only
base-graph edges are candidate cut edges; lifted edges contribute to energy
but cannot be contracted directly). The driver extracts the base costs from
objective.weights[:objective.number_of_base_edges]automatically. - Each fuse contracts the base graph by agreement, aggregates both base and
lifted weights onto the contracted lifted-multicut subproblem (lifted edges
whose endpoints land on already-existing contracted base edges fold into
them; the rest become new contracted lifted edges), and solves the
subproblem with a
LiftedMulticutSolver. - The default sub-solver and warm-start are
LiftedGreedyAdditiveMulticut. BothLiftedGreedyAdditiveMulticutandLiftedKernighanLinMulticutare pluggable viasub_solver=.
solver = bic.graph.FusionMoveLiftedMulticut(
proposal_generator=bic.graph.WatershedProposalGenerator(
sigma=1.0, n_seeds_fraction=0.1, seed=0,
),
sub_solver=bic.graph.LiftedKernighanLinMulticut(number_of_outer_iterations=3),
number_of_iterations=10,
stop_if_no_improvement=4,
number_of_threads=4,
)
labels = solver.optimize(objective)A typical warm-started solve combines greedy and KL:
solver = bic.graph.LiftedChainedSolvers([
bic.graph.LiftedGreedyAdditiveMulticut(),
bic.graph.LiftedKernighanLinMulticut(number_of_outer_iterations=10),
])
labels = solver.optimize(objective)Notes:
- Output labels are dense
uint64ids in0 .. number_of_clusters - 1. - Every output cluster is base-graph connected — both solvers enforce this invariant. A strongly attractive lifted edge between two nodes that have no base-graph path between them will not merge their clusters.
LiftedKernighanLinMulticutwarm-starts from the lifted greedy-additive solution when the objective's current labels are the trivial singleton labeling.objective.set_cost(u, v, weight, overwrite=False)updates or inserts a single lifted edge.- The lifted graph is exposed via
objective.lifted_graph; the firstobjective.number_of_base_edgesedges are exactly the base edges in the same order as ingraph.
For the common case of lifted multicut on a watershed over-segmentation,
nifty offers nifty.graph.rag.computeLiftedEdgesFromRagAndOffsets (lifted
edge discovery) and per-channel affinity accumulators. bioimage-cpp exposes
two focused helpers that cover the same workflow:
# Discover lifted edges implied by long-range affinity offsets. 1-hop offsets
# are skipped automatically, so the full offset list can be passed in.
lifted_uvs = bic.graph.lifted_edges_from_affinities(
rag, oversegmentation, offsets, number_of_threads=0,
)
# Accumulate (mean, size) statistics per lifted edge. Pixel pairs whose
# (u, v) does not appear in `lifted_uvs` are skipped, so local edges are
# never contaminated with long-range affinities.
lifted_features = bic.graph.lifted_affinity_features(
oversegmentation, affinities, offsets, lifted_uvs,
number_of_threads=0,
)
# For the 12-column feature set (mean, median, std, min, max, percentiles, size):
lifted_features = bic.graph.lifted_affinity_features_complex(...)The output column conventions match the local-edge variants
(SIMPLE_EDGE_FEATURE_NAMES, COMPLEX_EDGE_FEATURE_NAMES).
End-to-end pipeline (also in examples/segmentation/lifted_multicut_from_affinities.py):
rag = bic.graph.region_adjacency_graph(oversegmentation)
local_costs = local_threshold - bic.graph.affinity_features(
rag, oversegmentation, direct_affinities, direct_offsets,
)[:, 0]
lifted_uvs = bic.graph.lifted_edges_from_affinities(
rag, oversegmentation, long_range_offsets,
)
lifted_costs = lifted_threshold - bic.graph.lifted_affinity_features(
oversegmentation, long_range_affinities, long_range_offsets, lifted_uvs,
)[:, 0]
objective = bic.graph.LiftedMulticutObjective(
rag, local_costs, lifted_uvs=lifted_uvs, lifted_costs=lifted_costs,
)bioimage-cpp ships pooch-backed downloaders for the multicut and lifted
multicut benchmark problems used by the development comparison scripts and
the regression tests. Files are cached under ~/.cache/bioimage-cpp/,
overridable via the BIOIMAGE_CPP_CACHE environment variable.
pooch is an optional runtime dependency — install via the test or data
extras, e.g. pip install bioimage-cpp[data].
Multicut problems (3 samples × 2 sizes, originally from
elf.segmentation.utils.load_multicut_problem):
# Returns (UndirectedGraph, edge_costs)
graph, costs = bic.graph.load_multicut_problem(sample="A", size="small")
# Or just the underlying arrays
uv_ids, costs = bic.graph.load_multicut_problem_data(sample="B", size="medium")
# Or the cached file path
path = bic.graph.multicut_problem_path(sample="C", size="medium")Valid samples are "A", "B", "C"; valid sizes are "small" and
"medium". The legacy load_external_multicut_problem /
load_external_multicut_problem_data / external_multicut_problem_path
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, RAG-based 3D volume, and grid-graph volume):
problem = bic.graph.load_lifted_multicut_problem(size="2d")
# Fields: n_nodes (int), local_uvs, local_costs, lifted_uvs, lifted_costs.
graph = bic.graph.UndirectedGraph.from_edges(problem.n_nodes, problem.local_uvs)
objective = bic.graph.LiftedMulticutObjective(
graph,
problem.local_costs,
lifted_uvs=problem.lifted_uvs,
lifted_costs=problem.lifted_costs,
)Valid sizes are "2d", "3d", and "grid".
Notes:
- Every download is integrity-checked against a SHA256 in the registry; a
corrupted cache file is detected on the next
load_*call. - Downloads are lazy: nothing happens until you call a loader. Re-runs are free (the cached file is reused).
- For air-gapped use, fetch the file once on a machine with network access
and copy
~/.cache/bioimage-cpp/<filename>to the same path on the target machine.
Nifty has an internal BreadthFirstSearch template used during lifted-edge
insertion. bioimage-cpp exposes a Python-friendly free function:
nodes, distances = bic.graph.breadth_first_search(
graph,
source,
max_distance=3, # optional, default: full component
include_source=True, # set to False for k-hop neighborhoods excluding self
)Both output arrays are 1D uint64, listing reached nodes in BFS order with
their hop distance from the source. Useful for building lifted-edge sets
manually, sampling local neighborhoods, or computing graph distances.
Nifty:
import nifty.ground_truth as ngt
overlap = ngt.overlap(segmentation, ground_truth)bioimage-cpp:
import bioimage_cpp as bic
overlap = bic.ground_truth.segmentation_overlap(segmentation, ground_truth)The first input is called labels_a and the second input is called labels_b
in the bioimage-cpp API. Use named structured tables instead of positional
arrays:
table = overlap.overlap_table()
# fields: "label_a", "label_b", "count"
table = overlap.overlap_table(normalize_by="a")
# fields: "label_a", "label_b", "count", "fraction"
overlaps = overlap.overlaps_for_label_a(12, normalize=True)
# fields: "label", "count", "fraction"
best = overlap.best_overlap_for_label_a(12, ignore_zero=True)
# BestOverlap(label=..., count=..., fraction=..., found=...)Other common queries:
overlap.labels_a
overlap.labels_b
overlap.count_a(12)
overlap.count_b(4)
overlap.overlap_count(12, 4)
overlap.counts_a_table()
overlap.counts_b_table()
overlap.best_overlap_for_label_b(4)
overlap.is_label_a_overlapping_with_zero(12)
overlap.different_overlap(12, 13)Intentional improvements over nifty:
- Labels are stored sparsely, so large sparse label ids do not require a dense
vector up to
max_label + 1. - The Python API returns structured arrays with named fields and a
BestOverlapdataclass instead of ambiguous positional arrays. - Both overlap directions are supported explicitly:
overlaps_for_label_a(...)andoverlaps_for_label_b(...). - Normalization is explicit via
normalize_by="a","b", or"total". - Missing labels return count
0; best-overlap queries exposefound=False.
Notes:
- Inputs must be integer arrays with identical shape.
- Signed integer inputs must not contain negative labels.
- Inputs are converted to contiguous
uint64arrays before entering C++.
bioimage-cpp ships two mutex-watershed entry points, mirroring the two
affogato APIs: one that consumes a dense affinity grid, and one that
consumes an arbitrary graph with a separate list of mutex (long-range
repulsive) edges.
Affogato:
from affogato.segmentation import compute_mws_segmentation
seg = compute_mws_segmentation(
weights,
offsets,
number_of_attractive_channels=3,
strides=[1, 1, 1],
)bioimage-cpp:
import bioimage_cpp as bic
seg = bic.segmentation.mutex_watershed(
weights,
offsets,
number_of_attractive_channels=3,
strides=[1, 1, 1],
)Important migration notes:
bioimage-cppexpects the firstnumber_of_attractive_channelschannels to be attractive merge-edge weights and the remaining channels to be mutex-edge weights.- Supported affinity dtypes are
float32andfloat64. - Inputs must represent 2D or 3D grids with shapes
(channels, y, x)or(channels, z, y, x). - Non-contiguous affinity arrays are copied to contiguous memory.
stridessub-sample mutex edges only; attractive edges are always kept.randomized_strides=Trueuses NumPy's global random state, so existingnp.random.seed(...)workflows remain deterministic.- A boolean
maskmay be passed. Edges touchingFalsepixels are ignored and masked pixels are set to label0. - Output labels are
uint64, consecutive, and 1-based for foreground pixels.
For mutex watershed on an arbitrary undirected graph (region adjacency graph
or otherwise) with a separate list of long-range repulsive edges,
bioimage-cpp provides bic.graph.mutex_watershed_clustering. This is a
port of affogato's compute_mws_clustering using the same input format as
LiftedMulticutObjective: a base graph carries the attractive edges, and
long-range (called mutex here) edges are supplied alongside as a (M, 2)
node-pair array. The same (graph, edge_costs, lifted_uvs, lifted_costs)
tuple used to build a lifted multicut problem can be passed to the mutex
watershed clustering without any reshaping.
Affogato:
from affogato.segmentation import compute_mws_clustering
labels = compute_mws_clustering(
number_of_nodes,
uvs.astype(np.uint64),
mutex_uvs.astype(np.uint64),
weights.astype(np.float32),
mutex_weights.astype(np.float32),
)bioimage-cpp:
import bioimage_cpp as bic
graph = bic.graph.UndirectedGraph.from_edges(number_of_nodes, uvs)
labels = bic.graph.mutex_watershed_clustering(
graph,
weights,
mutex_uvs,
mutex_weights,
)Notes:
- The attractive edges are the edges of the base graph; the count and the
ordering of
weightsmust matchgraph.number_of_edges. Mutex edges are supplied separately as(M, 2)uint64pairs with matchingmutex_weights. - Both
weightsandmutex_weightsacceptfloat32andfloat64. The wrapper dispatches to a templated C++ instantiation per dtype; other floating dtypes are cast tofloat32. If the two arrays' dtypes do not match, both are promoted tofloat64rather than silently downcast. - Higher weights are processed first (in descending order) — the same convention affogato uses.
- The implementation reuses the union-find and per-root mutex-set helpers
shared with the grid-based mutex watershed (
detail/mutex_storage.hxx), so behavior is consistent between the two entry points. - Output labels are dense
uint64ids in0 .. number_of_clusters - 1, assigned in first-occurrence order (matches the convention of the graph multicut solvers, not the 1-based foreground labels produced by the grid-based variant). - The function accepts both
UndirectedGraphandRegionAdjacencyGraph. - Tie-breaking is deterministic: when weights are equal, attractive edges
are processed before mutex edges, then by index. Affogato's reference
uses a non-stable
std::sort, so on inputs with many ties the two implementations may produce slightly different (but very similar) partitions. Seedevelopment/graph/check_mutex_clustering.pyfor a comparison harness.
If you used a small helper to apply a dictionary to an integer label array, use
take_dict:
labels = np.array([1, 3, 2, 1], dtype=np.uint64)
relabeling = {1: 10, 2: 20, 3: 30}
out = bic.utils.take_dict(relabeling, labels)Notes:
- Supported input dtypes are
uint32,uint64,int32, andint64. - Output has the same shape and dtype as the input array.
- Every value in the input must be present in the mapping.
- Non-contiguous inputs are copied before entering C++.
bioimage-cpp intentionally does not replace nifty or affogato I/O helpers.
Load TIFF, HDF5, zarr, N5, OME-NGFF, and related formats with existing Python
libraries, then pass NumPy arrays to bioimage-cpp.
The package is designed for small PyPI wheels and does not depend on nifty, vigra, HDF5, z5, xtensor, pybind11, or other large C++ libraries.