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, and utility functionality (blocking, relabeling, overlap
measurement, union-find, etc.) is under bic.utils.
bic.graph keeps the core graph types and graph-level algorithms
(UndirectedGraph, GridGraph2D, GridGraph3D, RegionAdjacencyGraph,
connected_components, breadth_first_search, edge_weighted_watershed,
region_adjacency_graph, project_node_labels_to_pixels) at the top level.
Algorithmic domains live in dedicated submodules:
bic.graph.multicut— multicut objective and solvers, fusion-move proposal generators, multicut problem loaders.bic.graph.lifted_multicut— lifted multicut objective and solvers, lifted multicut problem loaders. Proposal generators are re-exported frombic.graph.multicuthere for convenience.bic.graph.mutex_watershed— graph-based mutex watershed clustering (with and without semantic constraints).bic.graph.features— edge-feature accumulation for RAGs and grid graphs (boundary maps, affinity channels, lifted edge features).
Affogato:
from affogato.affinities import compute_affinities
affinities, mask = compute_affinities(labels, offsets, ignore_label=0)bioimage-cpp:
import bioimage_cpp as bic
affinities, mask = bic.affinities.compute_affinities(
labels,
offsets,
ignore_label=0,
)Notes:
- Supported label dtypes are
uint32,uint64,int32, andint64. - Labels must be 2D or 3D and are copied to C-contiguous memory when needed.
- Offsets are in NumPy axis order and must have one entry per spatial axis.
- The affinity output is
float32with shape(n_offsets, *labels.shape). - Pass
return_mask=Falseto skip the validity-mask allocation when only the affinity array is needed. number_of_threadsmust be a positive integer; the default is1.
Affogato:
from affogato.affinities import compute_embedding_distances
distances = compute_embedding_distances(values, offsets, norm="l2")bioimage-cpp:
import bioimage_cpp as bic
distances = bic.affinities.compute_embedding_distances(
values,
offsets,
norm="l2",
)Notes:
valueshas shape(C, *spatial)and dtypefloat32.spatialmust be 2D or 3D; non-contiguous arrays are copied to C-contiguous memory when needed.- Offsets are in NumPy axis order and must have one entry per spatial axis.
- The output is
float32with shape(n_offsets, *spatial). Out-of-bounds positions are left at0.0. - Supported norms are
"l1"(new in bioimage-cpp),"l2", and"cosine". Cosine on a zero-norm channel vector yieldsNaN/Inf(matching affogato). - No mask is returned (matches affogato).
number_of_threadsparallelizes over offset channels.
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.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.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.
bioimage-cpp mirrors affogato's two semantic-mutex-watershed entry points
— compute_semantic_mws_segmentation for affinity volumes and
compute_semantic_mws_clustering for an arbitrary graph. Both extend the
regular mutex watershed with a third edge type: per-pixel (or per-node)
"semantic edges" that tag each cluster with a class id. Two clusters that
have been tagged with different class ids cannot subsequently merge.
Affogato:
from affogato.segmentation import compute_semantic_mws_segmentation
labels, semantic_labels = compute_semantic_mws_segmentation(
weights,
offsets,
number_of_attractive_channels=3,
strides=[1, 1, 1],
)bioimage-cpp:
import bioimage_cpp as bic
labels, semantic_labels = bic.segmentation.semantic_mutex_watershed(
weights,
offsets,
number_of_attractive_channels=3,
strides=[1, 1, 1],
)Channel layout (identical to affogato):
- Channels
[0, number_of_attractive_channels)are attractive grid edges. - Channels
[number_of_attractive_channels, len(offsets))are mutex grid edges. - Channels
[len(offsets), affinities.shape[0])are per-semantic-class affinities; channellen(offsets) + cscores how strongly each pixel belongs to classc.
Important migration notes:
- Inputs must represent 2D or 3D grids with shapes
(channels, y, x)or(channels, z, y, x)andchannels > len(offsets)(usebic.segmentation.mutex_watershedif there are no semantic channels). - Supported affinity dtypes are
float32andfloat64. - Returned
labelsareuint64, consecutive, and 1-based for foreground pixels (matching the regular grid-based mutex watershed).semantic_labelsisint64with-1reserved for clusters that received no class assignment. - A boolean
maskmay be passed. Edges touchingFalsepixels are ignored. Masked pixels are set to label0inlabelsand to themask_labelparameter (default0) insemantic_labels. stridesandrandomized_stridesfollow the same convention as the regular grid mutex watershed (mutex channels only; attractive channels are always kept).
Affogato:
from affogato.segmentation import compute_semantic_mws_clustering
labels, semantic_labels = compute_semantic_mws_clustering(
number_of_nodes,
uvs.astype(np.uint64),
mutex_uvs.astype(np.uint64),
semantic_node_classes.astype(np.uint64),
weights.astype(np.float32),
mutex_weights.astype(np.float32),
semantic_weights.astype(np.float32),
)bioimage-cpp:
import bioimage_cpp as bic
graph = bic.graph.UndirectedGraph.from_edges(number_of_nodes, uvs)
labels, semantic_labels = bic.graph.mutex_watershed.semantic_mutex_watershed_clustering(
graph,
weights,
mutex_uvs,
mutex_weights,
semantic_node_classes,
semantic_weights,
)Input format mirrors mutex_watershed_clustering plus two extra arrays:
semantic_node_classesis an(n_semantic, 2)uint64table. Column 0 is a node id and column 1 is the semantic class id (a non-negative integer interpreted asint64internally).semantic_weightsis a 1Dfloat32/float64array of lengthn_semanticgiving one weight per(node, class)candidate.
Notes:
- All three weight arrays (
weights,mutex_weights,semantic_weights) must have the same floating dtype, or all three are promoted tofloat64. - Output
labelsare denseuint64ids in0 .. number_of_clusters - 1(first-occurrence order, matching the regular graph mutex watershed — not the 1-based foreground labels produced by the grid variant).semantic_labelsisint64with-1for unassigned clusters. - Accepts both
UndirectedGraphandRegionAdjacencyGraph.
The bioimage-cpp port fixes a missing merge_semantic_labels call on
attractive merges in affogato's graph kernel (compute_semantic_mws_clustering):
without that call, a node that has been tagged with a class can have its
tag dropped when it later becomes the non-root of a merge. Affogato's
array kernel additionally invokes boost::disjoint_sets::link(u, v) on
raw node ids rather than their roots, which corrupts the union-find tree
on multi-class inputs and over-fragments the result. The unit-test
problems shipped with affogato do not exercise these paths heavily, so
this only shows up on realistic multi-class data.
For most inputs the two implementations agree. On dense multi-class
inputs they may not; the development scripts under
development/segmentation/check_semantic_mutex_watershed_{2d,3d}.py and
development/graph/check_semantic_mutex_clustering.py print VI/ARI
partition metrics and a semantic-label match fraction so the deviation is
measurable. The bioimage-cpp partitions match an independent Python
reference implementation of the algorithm.
bioimage-cpp also provides an affinity-driven marker-controlled watershed
for nearest-neighbour affinity maps. It is useful when edge priorities are
already available and no heightmap derivation is needed.
import bioimage_cpp as bic
# Affinity-driven: edge priorities, no heightmap derivation needed
labels = bic.segmentation.watershed_from_affinities(
affinities, # (C, *spatial), C == spatial_ndim
offsets=[(-1, 0), (0, -1)], # one NN offset per channel, same sign
markers=markers,
mask=optional_mask,
)Notes for watershed_from_affinities:
- Each channel must encode a single nearest-neighbour edge (exactly one ±1 entry, the rest zero). All offsets must have the same sign — mixing positive and negative directions is rejected. The function dispatches to a positive-direction or negative-direction specialisation at the C++ layer so the inner loop has no per-channel sign branches.
- Offsets may be passed in any axis order; the channel ↔ axis mapping is rebuilt internally.
- Higher affinity is processed first (high affinity = strong bond).
- Compared to
affogato.segmentation.compute_mws_segmentation, there are no mutex (repulsive) channels and no long-range offsets — usebic.segmentation.mutex_watershedfor that.
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.- The internal adjacency is built lazily on the first
node_adjacencyread, and that lazy build is not thread-safe. The built-in multi-threaded algorithms freeze the graph internally before fanning out, so passing a graph straight into them is safe. But if you build a graph and then share it across your own threads (concurrentnode_adjacencyreads, a BFS, etc.), callgraph.freeze()once on the construction thread first — racing the first read across threads corrupts the adjacency (nondeterministic results, possible crashes).freeze()eagerly builds the adjacency and is a no-op once built; it also removes the first-call rebuild cost from laternode_adjacencyreads. This applies to all graph types (GridGraph2D,GridGraph3D,RegionAdjacencyGraph).
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:
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 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.
bioimage-cpp exposes NumPy-only affine transformations under
bic.transformation. HDF5, zarr, N5, and OME-NGFF loading stays in Python;
load the desired chunk or subvolume first, then pass the NumPy array here.
Nifty:
import nifty.transformation as nt
out = nt.affineTransformation(
data,
matrix,
order=1,
bounding_box=(slice(0, 64), slice(0, 64)),
fill_value=0,
)bioimage-cpp:
import bioimage_cpp as bic
out = bic.transformation.affine_transform(
data,
matrix,
bounding_box=(slice(0, 64), slice(0, 64)),
order=1,
fill_value=0,
)Important differences from nifty:
- Only NumPy arrays are accepted.
affineTransformationH5,affineTransformationZ5, and coordinate-file transformations are not reproduced. - The API is snake_case only:
affine_transform. matrixmaps output coordinates to input coordinates in NumPy axis order. Matrix shapes(ndim, ndim + 1)and homogeneous(ndim + 1, ndim + 1)are accepted.bounding_box=Nonetransformsslice(0, data.shape[d])for every axis. Custom bounding boxes are one slice per axis and cannot use a step.- Supported interpolation orders are
0(nearest),1(linear),2/4/5(quadratic / quartic / quintic B-spline), and3(Keys cubic convolution,a = -0.5). The order set matchesscipy.ndimage. - Order
3is interpolating (reproduces input samples at integer coordinates). Orders2,4,5are smoothing B-spline kernels: they exactly matchscipy.ndimage.affine_transform(..., prefilter=False, mode='grid-constant'), which is not scipy's default. We do not run the cubic-spline IIR prefilter that scipy applies whenprefilter=True, sobic.transformation.affine_transform(..., order=3)is not numerically equivalent to scipy's defaultorder=3. Seedevelopment/transformation/PERFORMANCE_NOTES.mdfor the prefilter cost analysis and the sketch of how we would add it. Practical guidance:- For nifty parity, use
order=0ororder=1. - For OpenCV-style "smooth cubic that hits the samples", use
order=3. - For scipy
prefilter=Falseparity, useorder=2/4/5. - For scipy
prefilter=Trueparity, you currently have to prefilter the input yourself withscipy.ndimage.spline_filterbefore calling ouraffine_transform.
- For nifty parity, use
- Border handling for orders 0, 1, and 3 follows
scipy.ndimage.affine_transform(..., mode='constant'): any output coordinate that maps to an input coordinate inside[0, shape - 1]along every axis is interpolated; coordinates fully outside are replaced withfill_value. In particular the last row/column/slice is sampled (nifty's older NumPy affine path treats the last index as out-of-bounds). Orders2/4/5usemode='grid-constant'semantics: each kernel tap independently picks upfill_valuewhen it is out of bounds, with no outer cliff at the input border. - Output dtype is preserved for all supported input dtypes, including
integer inputs with linear, cubic, or spline interpolation. Integer
outputs round to the nearest integer and clamp to the dtype range, so
cubic / spline overshoots are well defined for
uint8/int8/etc. - An optional
out=keyword writes the result into a pre-allocated C-contiguous NumPy array of matching shape and dtype.
bioimage-cpp deliberately stops at NumPy; format-specific entry points
(affineTransformationH5, affineTransformationZ5) are out of scope for
the C++ core. For a downstream library that wants to recreate them, the
NumPy primitives compose naturally — chunk the output frame, read
just the input bounding box needed for each output chunk, transform with
bic.transformation.affine_transform, write the result back:
import numpy as np
import bioimage_cpp as bic
def affine_transform_chunked(in_dataset, out_dataset, matrix, *,
output_shape, order=1, fill_value=0,
out_block_shape=(64, 256, 256), halo=None):
"""Apply an affine to a large array, one output block at a time.
`in_dataset` and `out_dataset` are array-like (numpy / h5py.Dataset /
zarr.Array / tensorstore / ...). `matrix` maps output coordinates to
input coordinates in NumPy axis order (the same convention as
`bic.transformation.affine_transform`).
"""
ndim = len(output_shape)
linear = np.asarray(matrix, dtype=np.float64)[:ndim, :ndim]
translation = np.asarray(matrix, dtype=np.float64)[:ndim, ndim]
# Default halo: kernel half-width per axis (order/2 rounded up) plus a
# safety margin for floating-point coordinate drift.
if halo is None:
halo = tuple([order + 2] * ndim)
in_shape = np.asarray(in_dataset.shape)
out_block = np.asarray(out_block_shape)
# Walk the output frame block by block.
block_starts = [
range(0, output_shape[k], out_block[k]) for k in range(ndim)
]
for corner in np.ndindex(*(len(b) for b in block_starts)):
out_start = np.array([block_starts[k][corner[k]] for k in range(ndim)])
out_stop = np.minimum(out_start + out_block, output_shape)
# 1. Find the input bounding box that all output voxels in this
# block could possibly sample. The 8 (2D: 4) corners of the
# output block are mapped through `matrix`; the axis-aligned
# bounding box of those mapped points (plus a halo) is what we
# need from the input array.
corners = np.stack(np.meshgrid(*[
[out_start[k], out_stop[k] - 1] for k in range(ndim)
], indexing="ij"), axis=-1).reshape(-1, ndim).astype(np.float64)
in_corners = corners @ linear.T + translation
in_lo = np.floor(in_corners.min(axis=0)).astype(np.int64) - np.asarray(halo)
in_hi = np.ceil(in_corners.max(axis=0)).astype(np.int64) + np.asarray(halo)
# 2. Clip to the input array. Anything outside becomes fill_value
# via the affine_transform's border handling.
in_lo_clipped = np.maximum(in_lo, 0)
in_hi_clipped = np.minimum(in_hi, in_shape)
if np.any(in_hi_clipped <= in_lo_clipped):
# Output block lies entirely outside the input frame.
out_block_data = np.full(
tuple((out_stop - out_start).tolist()),
fill_value, dtype=out_dataset.dtype,
)
else:
slicer = tuple(slice(int(lo), int(hi))
for lo, hi in zip(in_lo_clipped, in_hi_clipped))
in_block = np.ascontiguousarray(in_dataset[slicer])
# 3. Translate `matrix` into the input-block-local frame.
# Our convention: input = linear @ output + translation.
# For the local block, input_local = input - in_lo_clipped.
local_matrix = np.hstack([linear, (translation - in_lo_clipped)[:, None]])
# 4. Run the affine on the in-memory block. We pass the local
# bounding box in **output** coordinates: this block of the
# output spans (out_start, out_stop).
out_block_data = bic.transformation.affine_transform(
in_block,
local_matrix,
bounding_box=tuple(slice(int(a), int(b))
for a, b in zip(out_start, out_stop)),
order=order,
fill_value=fill_value,
)
out_dataset[tuple(slice(int(a), int(b))
for a, b in zip(out_start, out_stop))] = out_block_dataNotes:
- The halo accounts for the kernel's tap reach; the safety margin handles
floating-point drift in the corner mapping.
order + 2is conservative for orders ≤ 5. - This pattern works for
h5py.Dataset,zarr.Array,tensorstore, or any other lazy-array library that supports NumPy-style indexing — there is nothing format-specific in the body. - For best throughput, choose
out_block_shapeto match the on-disk chunking ofout_dataset(one block = one chunk write) and large enough in each axis that the input-side read is also full chunks. - Anti-aliasing for downsampling pipelines: replace
bic.transformation.affine_transform(...)in step 4 withbic.transformation.resample(...). The Gaussian sigma is derived frommatrixand is identical for every block, so the per-block smoothing cost is constant. - For random-access transformations (large rotations, perspective warps) the per-block input bounding box can be much larger than the output block. A real implementation should either cap the read size and skip obviously-empty output blocks, or partition the output into a finer grid for those cases.
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.utils.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.
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++.
nifty.tools.computeRLE computes the COCO-style binary run-length encoding of an
array. Use bic.utils.compute_rle:
import nifty.tools as nt
mask = np.array([[0, 0, 1], [1, 1, 0], [0, 1, 1]], dtype=np.uint8)
rle = nt.computeRLE(mask) # nifty: Python list [2, 3, 2, 2]
rle = bic.utils.compute_rle(mask) # bioimage-cpp: np.int64 array([2, 3, 2, 2])Notes:
- The array is flattened in C-order and interpreted as binary
(zero vs. nonzero). Counts always start with a run of zeros and then
alternate; a leading
0is emitted when the first element is nonzero. - Supported input dtypes are
bool,uint8,uint16,uint32,uint64,int32, andint64. - Behavioral difference: nifty returns a Python
list;bioimage-cppreturns a 1-D NumPyint64array. - Non-contiguous inputs are copied before entering C++.
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.
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.multicut.load_multicut_problem(sample="A", size="small")
# Or just the underlying arrays
uv_ids, costs = bic.graph.multicut.load_multicut_problem_data(sample="B", size="medium")
# Or the cached file path
path = bic.graph.multicut.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.lifted_multicut.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.lifted_multicut.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-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. - Nifty's
projectEdgeIdsToPixelsandprojectEdgeIdsToPixelsWithOffsetsmap tograph.project_edge_ids_to_pixels()andgraph.project_edge_ids_to_pixels_with_offsets(offsets, *, strides=None, mask=None)onGridGraph2D/GridGraph3D. The basic form returns anint64array of shape(ndim, *graph.shape)with each grid edge id written at its pivot pixel and-1elsewhere. The offsets form returns(array, n_valid): anint64array of shape(len(offsets), *graph.shape)whose non--1entries are a sequential counter over the in-bounds (and filter-accepted) targets, plus the total count.strideskeeps only coords aligned along every axis;maskkeeps only coords where a boolean array of shape(len(offsets), *graph.shape)is true. Like in nifty,stridesandmaskare mutually exclusive — passing both raisesValueError. - 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.
The label-multiset data structure stores, for each spatial block of a label
volume, a histogram of (label, count) pairs over the underlying voxels,
with identical histograms across blocks deduplicated into shared storage.
It is used by Paintera to build multi-resolution label pyramids.
nifty.tools exposes three functions / classes — readSubset,
downsampleMultiset, and MultisetMerger — operating on five flat arrays
(offsets, entry_sizes, entry_offsets, ids, counts). bioimage-cpp
keeps the same algorithm and storage layout but wraps it in a
LabelMultiset dataclass plus a level-0 bootstrap helper.
Nifty:
import nifty.tools as nt
# nifty does not provide a "from labels" helper; level-0 multisets are
# typically constructed by the caller (e.g. by writing histograms manually
# to N5 chunks).
blocking = nt.blocking([0, 0, 0], list(labels.shape), [2, 2, 2])
argmax, new_offsets, new_ids, new_counts = nt.downsampleMultiset(
blocking, offsets, entry_sizes, entry_offsets, ids, counts,
restrict_set=-1,
)
ids, counts = nt.readSubset(offsets, sizes, ids, counts, True)
merger = nt.MultisetMerger(unique_offsets, entry_sizes, ids, counts)
merger.update(unique_offsets, entry_sizes, ids, counts, offsets)bioimage-cpp:
import bioimage_cpp as bic
from bioimage_cpp.label_multiset import (
LabelMultiset,
MultisetMerger,
downsample_multiset,
multiset_from_labels,
read_subset,
)
# Build the level-0 multiset directly from a label volume.
ms0 = multiset_from_labels(labels, block_shape=(1, 1, 1))
# Downsample one level. `blocking.roi_end` must match the input's spatial
# extent (i.e. the shape used to build ms0).
blocking = bic.utils.Blocking([0, 0, 0], list(labels.shape), [2, 2, 2])
ms1 = downsample_multiset(ms0, blocking, restrict_set=-1)
# Merge entries from a list of (offset, size) ranges into one histogram.
ids, counts = read_subset(offsets, sizes, ms1.ids, ms1.counts)
# Deduplicating merger — constructor takes one offset per unique entry.
merger = MultisetMerger.from_multiset(ms1)
merger.update(unique_offsets, entry_sizes, ids, counts, offsets)Name and API changes:
| nifty-style name | bioimage-cpp name |
|---|---|
readSubset |
read_subset |
downsampleMultiset |
downsample_multiset |
MultisetMerger |
MultisetMerger |
MultisetMerger.get_ids() |
MultisetMerger.ids (property) |
MultisetMerger.get_counts() |
MultisetMerger.counts |
restrict_set (keyword) |
restrict_set (keyword, same default -1) |
Notes:
- A
LabelMultisetcarries all five arrays (offsets,entry_offsets,entry_sizes,ids,counts) plusargmax. Nifty'sdownsampleMultisetreturns only four of them and leaves the caller to reconstructentry_offsets/entry_sizes;bioimage-cppreturns them directly so multi-level downsample chains compose without bookkeeping. multiset_from_labels(labels, block_shape)builds the level-0 multiset from auint32oruint64label volume in one call. There is no nifty equivalent.MultisetMerger.__init__takes one offset per unique entry (lengthn_unique), matching nifty's contract. UseMultisetMerger.from_multiset(ms)to construct one directly from aLabelMultiset.- Count dtype is
uint32(nifty usesint32). Convert at the boundary if you are reading nifty-written data. - 2D and 3D blockings are both supported. The bindings instantiate
uint64ids,uint32counts, anduint64offsets; wider dtype matrices can be added on demand. - The
LabelMultisetWrapperz5/N5 reader fromnifty/tools/label_multiset_wrapper.hxxis intentionally not ported — I/O stays out of the C++ core. Read/write Paintera-format chunks withzarr/numpyin Python if needed.
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.lifted_multicut.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.lifted_multicut.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.lifted_multicut.FusionMoveLiftedMulticut(
proposal_generator=bic.graph.lifted_multicut.WatershedProposalGenerator(
sigma=1.0, n_seeds_fraction=0.1, seed=0,
),
sub_solver=bic.graph.lifted_multicut.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.lifted_multicut.LiftedChainedSolvers([
bic.graph.lifted_multicut.LiftedGreedyAdditiveMulticut(),
bic.graph.lifted_multicut.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.features.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.features.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.features.lifted_affinity_features_complex(...)The output column conventions match the local-edge variants
(SIMPLE_EDGE_FEATURE_NAMES, COMPLEX_EDGE_FEATURE_NAMES).
When the lifted edges come from semantic / class labels per RAG node rather
than from long-range affinities, nifty offers
nifty.distributed.liftedNeighborhoodFromNodeLabels. The bioimage-cpp
equivalent lives under bic.graph.lifted_multicut:
# nifty
lifted_uvs = nifty.distributed.liftedNeighborhoodFromNodeLabels(
graph, node_labels, graphDepth=2, numberOfThreads=4,
mode='all', ignoreLabel=0,
)
# bioimage-cpp
lifted_uvs = bic.graph.lifted_multicut.lifted_edges_from_node_labels(
graph, node_labels, graph_depth=2,
mode='all', ignore_label=0, number_of_threads=4,
)Both functions return an (n_lifted, 2) uint64 array of (u, v) pairs
with u < v, sorted lexicographically. The BFS hop distance is restricted
to [2, graph_depth], so base-graph edges are excluded. mode='same' /
'different' filter by whether node_labels[u] == node_labels[v];
ignore_label drops every pair where either endpoint label matches.
Intentional differences vs. nifty:
- snake_case parameter names (
graph_depth,ignore_label,number_of_threads); ignore_labeldefaults toNone(no filtering) instead of0;- node
0is iterated as a source (nifty's distributed variant has an off-by-one that silently skips it).
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.features.affinity_features(
rag, oversegmentation, direct_affinities, direct_offsets,
)[:, 0]
lifted_uvs = bic.graph.features.lifted_edges_from_affinities(
rag, oversegmentation, long_range_offsets,
)
lifted_costs = lifted_threshold - bic.graph.features.lifted_affinity_features(
oversegmentation, long_range_affinities, long_range_offsets, lifted_uvs,
)[:, 0]
objective = bic.graph.lifted_multicut.LiftedMulticutObjective(
rag, local_costs, lifted_uvs=lifted_uvs, lifted_costs=lifted_costs,
)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.multicut.MulticutObjective(graph, edge_costs)
labels = bic.graph.multicut.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.multicut.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.multicut.ChainedMulticutSolvers([
bic.graph.multicut.GreedyAdditiveMulticut(),
bic.graph.multicut.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.multicut.MulticutDecomposer(
sub_solver=bic.graph.multicut.KernighanLinMulticut(number_of_outer_iterations=5),
fallthrough_solver=bic.graph.multicut.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.multicut.MulticutObjective(graph, edge_costs)
solver = bic.graph.multicut.FusionMoveMulticut(
proposal_generator=bic.graph.multicut.WatershedProposalGenerator(
sigma=1.0, n_seeds_fraction=0.1, seed=0,
),
sub_solver=bic.graph.multicut.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.
bioimage_cpp.graph.agglomeration provides hierarchical agglomerative
clustering driven by a small set of policy classes, matching the policies
in nifty.graph.agglo. Each policy is a max-heap-style driver (smaller
edge indicator = stronger merge candidate, matching nifty's convention)
with policy-specific priority computation, merge rule, and stopping
criterion. All policies accept any UndirectedGraph subclass —
RegionAdjacencyGraph, GridGraph2D/GridGraph3D included.
Nifty:
import nifty.graph.agglo as nagglo
# Hierarchical, edge-weighted clustering.
policy = nagglo.edgeWeightedClusterPolicy(
graph=graph,
edgeIndicators=edge_indicators,
edgeSizes=edge_sizes,
nodeSizes=node_sizes,
numberOfNodesStop=number_of_clusters_stop,
sizeRegularizer=0.5,
)
labels = nagglo.agglomerativeClustering(policy).run().result()bioimage-cpp:
labels = bic.graph.agglomeration.EdgeWeightedClusterPolicy(
num_clusters_stop=number_of_clusters_stop,
size_regularizer=0.5,
).optimize(graph, edge_indicators, edge_sizes=edge_sizes, node_sizes=node_sizes)Mapping:
| Nifty | bioimage-cpp |
|---|---|
edgeWeightedClusterPolicy(...) |
EdgeWeightedClusterPolicy(num_clusters_stop=, size_regularizer=).optimize(graph, edge_indicators, edge_sizes=, node_sizes=) |
nodeAndEdgeWeightedClusterPolicy(...) |
NodeAndEdgeWeightedClusterPolicy(num_clusters_stop=, size_regularizer=, beta=).optimize(graph, edge_indicators, node_features, edge_sizes=, node_sizes=) |
malaClusterPolicy(...) |
MalaClusterPolicy(num_bins=, bin_min=, bin_max=, num_clusters_stop=, num_edges_stop=, threshold=).optimize(graph, edge_indicators) |
gaspClusterPolicy(...) (signed weights + linkage) |
GaspClusterPolicy(num_clusters_stop=, linkage=).optimize(graph, edge_weights, edge_sizes=, is_mergeable=) |
GaspClusterPolicy linkage strings map to the rules in Bailoni et al.'s
GASP framework: "sum", "mean", "max", "min", "abs_max",
"mutex_watershed". The mutex_watershed linkage treats a negative
heap-top weight as a cannot-link constraint; the others apply the chosen
linkage update without imposing hard constraints from signs. The
optional is_mergeable mask marks edges that should be used only to
install cluster-level cannot-link constraints.
Differences from nifty:
optimizereturns denseuint64node labels directly. Nifty exposes a separate driver (agglomerativeClustering(policy).run().result()); the underlying loop is the same.- Both
float32andfloat64inputs are accepted; computation runs infloat64internally. - Tie-breaks follow the deterministic order of edge ids returned by
UndirectedGraph, which may differ from nifty's. On inputs where many edges share the same indicator value, this combines with the hierarchical agglomeration's positive feedback loop (each tied merge changes node sizes, which changes the harmonic size factorsFac, which changes future priorities) to give cascading divergence. On the external multicut problem sample C/medium, where 86% of indicator values are non-unique, perturbing the indicators of a single bic run by 1e-9 random noise can change the final partition's adjusted Rand index vs. its own unperturbed output by ~0.5 (the algorithm is chaotically sensitive to tie-breaking under non-zerosize_regularizer). Both partitions are valid clusterings; partition agreement (VI, ARI) is the appropriate comparison metric, not label equality.
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's ragCoordinates scans the label volume once and caches, per edge, the
pixel coordinates of the boundary between the two adjacent regions, so that
per-edge values (e.g. edge probabilities) can be painted back onto a volume.
Nifty:
import nifty.graph.rag as nrag
rag = nrag.gridRag(labels)
rag_coords = nrag.ragCoordinates(rag)
storage = rag_coords.storageLengths()
volume = rag_coords.edgesToVolume(edge_values, edgeDirection=0)bioimage-cpp:
rag = bic.graph.region_adjacency_graph(labels)
rag_coords = bic.graph.rag_coordinates(rag, labels)
storage = rag_coords.storage_lengths()
coords = rag_coords.edge_coordinates(edge_id) # (n_points, ndim)
volume = rag_coords.edges_to_volume(edge_values, edge_direction=0)Notes:
labelsmust be the over-segmentation used to constructrag, and is passed explicitly (nifty's RAG holds an internal reference to it; ours does not). Supported label dtypes:uint32,uint64,int32,int64.- A boundary "contact" is a pair of directly adjacent pixels with different
labels. Each contact contributes two coordinates to its edge: the
lower-coordinate pixel followed by its
+axisneighbor.storage_lengthstherefore reports2 * n_contactsper edge, and coordinates are stored in scan order (NumPy axis order, C-contiguous). edge_directionselects which side(s) to report / paint:0= both (default),1= lower-coordinate pixel only,2= higher-coordinate pixel only.edges_to_volumereturns a volume of the label shape and dtype matchingedge_values(supported:float32,float64,uint32,uint64). Non-boundary pixels are set toignore_value. Painting is sequential in ascending edge id, so where several edges' boundaries coincide on a pixel the highest edge id wins — a deterministic, race-free rule (nifty's paralleledgesToVolumedoes not guarantee a tie-break order).- The cached object can be reused across many
edges_to_volumecalls without re-scanning the labels.number_of_threads=0(onrag_coordinates) uses the library default.
Nifty's gridRagAccumulateLabels projects a second label volume onto a RAG
by taking a per-node majority vote (commonly used to project a ground-truth
segmentation onto an over-segmentation).
Nifty:
import nifty.graph.rag as nrag
rag = nrag.gridRag(labels)
node_labels = nrag.gridRagAccumulateLabels(rag, gt)
# ignore label 0 in the ground truth (nifty's "ignoreBackground"):
node_labels = nrag.gridRagAccumulateLabels(rag, gt, ignoreBackground=True)bioimage-cpp:
rag = bic.graph.region_adjacency_graph(labels)
node_labels = bic.graph.features.accumulate_labels(rag, labels, gt)
# arbitrary ignore value (covers nifty's ignoreBackground=True by passing 0):
node_labels = bic.graph.features.accumulate_labels(
rag, labels, gt, ignore_value=0
)Notes:
labelsmust be the over-segmentation used to constructrag.other_labelsmust have the same shape aslabels. Supported dtypes for both arrays:uint32,uint64,int32,int64; they may differ.- The output has length
rag.number_of_nodesand the same dtype asother_labels. Nodes whose pixels are all ignored receive0. - Ties in the majority vote are broken by smaller label id (deterministic).
Nifty's tie-breaking depends on
std::unordered_mapiteration order and is therefore platform-dependent;bicresolves ties deterministically. ignore_valueis more general than nifty's booleanignoreBackground: pass0to reproduceignoreBackground=True, or any other value to skip arbitrary sentinels (e.g.255or-1).
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.features.edge_map_features(rag, labels, edge_map)The columns are:
bic.graph.features.SIMPLE_EDGE_FEATURE_NAMES
# ("mean", "size")Complex edge-map features:
features = bic.graph.features.edge_map_features_complex(rag, labels, edge_map)The columns are:
bic.graph.features.COMPLEX_EDGE_FEATURE_NAMES
# ("mean", "median", "std", "min", "max", "p5", "p10",
# "p25", "p75", "p90", "p95", "size")Affinity features:
features = bic.graph.features.affinity_features(
rag,
labels,
affinities,
offsets=[[0, 1], [1, 0]],
)Complex affinity features:
features = bic.graph.features.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:
import nifty.ground_truth as ngt
overlap = ngt.overlap(segmentation, ground_truth)bioimage-cpp:
import bioimage_cpp as bic
overlap = bic.utils.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++.
Nifty exposes a disjoint-set / union-find structure as nifty.ufd.ufd.
bioimage-cpp provides the same primitive under bic.utils.
Nifty:
import nifty.ufd as nufd
import numpy as np
uf = nufd.ufd(5)
uf.merge(0, 1)
uf.merge(np.array([[2, 3], [3, 4]], dtype="uint64"))
roots = uf.find(np.array([0, 1, 2, 3, 4], dtype="uint64"))
labels = uf.elementLabeling()bioimage-cpp:
import bioimage_cpp as bic
import numpy as np
uf = bic.utils.UnionFind(5)
uf.merge(0, 1)
uf.merge(np.array([[2, 3], [3, 4]], dtype=np.uint64))
roots = uf.find(np.array([0, 1, 2, 3, 4], dtype=np.uint64))
labels = uf.element_labeling()Method mapping:
| nifty-style name | bioimage-cpp name |
|---|---|
find(node) / find(array) |
find(node) / find(array) |
merge(u, v) / merge(array) |
merge(u, v) / merge(array) |
elementLabeling |
element_labeling |
numberOfElements |
size (property) |
Notes:
- The constructor takes a single
sizeargument; all elements start as singletons. - Scalar
find/mergeaccept Python integers and return Python integers. - Bulk
find(nodes)accepts a 1Duint64array and returns a 1Duint64array of roots of the same length. - Bulk
merge(edges)accepts an(N, 2)uint64array of node-pair edges and applies the merges in row order. element_labeling()returns auint64array of lengthsize, each entry the (path-compressed) root of that element. Use this when you want the final labeling as one array rather than via repeatedfindcalls.merge_to(stable, removed)is also available: it forcesstable's root to survive the union regardless of rank.reset(n)reinitialises the structure tonsingletons, reusing capacity where possible.- The GIL is released around bulk operations, so multiple threads can run
bulk merges on independent
UnionFindinstances in parallel.
affine_transform itself never pre-smooths the input; downsampling without
prior low-pass filtering aliases. bic.transformation.resample is a thin
Python wrapper that computes a per-input-axis Gaussian sigma from the
matrix's linear part and pre-smooths via bic.filters.gaussian_smoothing
before sampling:
import bioimage_cpp as bic
# Downsample by 2x on each axis, anti-aliased:
matrix = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0]]
small = bic.transformation.resample(
image, matrix,
bounding_box=(slice(0, h // 2), slice(0, w // 2)),
order=1, # any supported order
anti_aliasing=True, # default; uses the heuristic sigma
)
# Explicit sigma (skips the heuristic):
small = bic.transformation.resample(image, matrix, anti_aliasing_sigma=[1.0, 1.0])
# Inspect what the heuristic would pick without resampling:
sigma = bic.transformation.compute_anti_aliasing_sigma(matrix, image.ndim)The heuristic mirrors skimage.transform.resize: per input axis,
sigma = max(0, (||row_of_linear_part|| - 1) / 2). Pure rotations
produce all-zero sigma (no smoothing); a uniform 2× downsample produces
sigma = 0.5 per axis.
bioimage-cpp ships a marker-controlled watershed entry point that consumes a
node-valued heightmap, analogous to skimage.segmentation.watershed. Markers
are mandatory, connectivity is 1 (4-neighbour in 2D, 6-neighbour in 3D), an
optional foreground mask is supported, and tie-breaking on equal heights is
unspecified.
# Heightmap-driven (analogous to skimage.segmentation.watershed)
labels = bic.segmentation.watershed(image, markers, mask=optional_mask)bic.segmentation.relabel_sequential mirrors
skimage.segmentation.relabel_sequential: it remaps an integer label array so
all non-zero labels become consecutive starting at offset, in sorted order
of the original label values. Label 0 is preserved as background. The
return is a (relabeled, forward_map, inverse_map) tuple with the same
indexing semantics as skimage (forward_map[old] == new,
inverse_map[new] == old).
labels = np.array([0, 5, 10, 5, 0, 200], dtype=np.uint32)
relabeled, forward_map, inverse_map = bic.segmentation.relabel_sequential(labels)
# relabeled -> [0, 1, 2, 1, 0, 3]
# forward_map[5] == 1, forward_map[10] == 2, forward_map[200] == 3
# inverse_map -> [0, 5, 10, 200]
# Custom offset works the same way; only label 0 is treated as background.
relabeled, _, _ = bic.segmentation.relabel_sequential(labels, offset=10)
# relabeled -> [0, 10, 11, 10, 0, 12]Notes:
- Supported input dtypes are
uint32,uint64,int32, andint64. Therelabeled,forward_map, andinverse_maparrays all share the input dtype (skimage picks the smallest dtype that fits the output range; this implementation does not). offsetmust be a positive integer (>= 1).- Negative values in signed-dtype inputs are rejected.
- Non-contiguous inputs are copied before entering C++.
- Single-threaded but typically ~7–11× faster than skimage and ~12–28×
faster than
vigra.analysis.relabelConsecutiveon dense label fields (1024² and 128³ arrays with hundreds to hundreds-of-thousands of distinct labels). The internal kernel allocates a forward-map LUT of sizemax(label_field) + 1, so adversarial inputs with very largemaxand few distinct labels will use more memory than a hashmap-based implementation.
bioimage-cpp provides pixel-grid connected-components labeling for 2D and
3D arrays, mirroring skimage.measure.label. Two non-background pixels share
a component iff there is a path of connectivity-neighbour steps between
them along which the input value is constant.
Skimage:
from skimage.measure import label
labels = label(image, background=0, connectivity=None)bioimage-cpp:
import bioimage_cpp as bic
labels = bic.segmentation.label(image, background=0, connectivity=None)Vigra has a closely related entry point on binary / labeled inputs:
import vigra.analysis as va
labels = va.labelMultiArrayWithBackground(
image, neighborhood="direct", background_value=0,
)bic.segmentation.label covers both cases — it labels equal-value runs (as
skimage.measure.label does), and for binary masks it agrees with vigra's
labelMultiArrayWithBackground partition. neighborhood="direct" maps to
connectivity=1, neighborhood="indirect" maps to connectivity=image.ndim.
Important migration notes:
- Supported input dtypes are
bool,uint8,uint16,uint32,uint64,int32,int64. Floating-point inputs are rejected. Non-contiguous arrays are copied to contiguous memory. connectivityis an integer in[1, image.ndim].1is orthogonal neighbours only (4-connectivity in 2D, 6-connectivity in 3D);image.ndimenables full diagonal connectivity (8-connectivity in 2D, 26-connectivity in 3D);2in 3D is 18-connectivity.connectivity=Nonedefaults toimage.ndim, matchingskimage.measure.label.backgroundis the pixel value treated as background. Background pixels stay0in the output; other equal-valued pixels start at label1.- The output dtype is always
uint64.skimage.measure.labelreturnsintp; cast if you need bit-for-bit dtype parity. - Output labels are dense, start at
1, and are assigned in row-major first-occurrence order — same convention as skimage. - Passing a
boolarray enables an internal fast path that skips per-pixel value-equality compares. Convertuint8masks toboolfirst if your data is binary. - Only 2D and 3D inputs are supported in v1.
skimage.measure.labelaccepts arbitrary ndim; loop over slices externally if you need 4D+. return_num=Truefromskimage.measure.labelis not provided. Useint(labels.max())to get the component count.
Performance characteristics (single-threaded, against skimage 0.25 and
vigra 1.11):
- On integer inputs (
uint8/uint16/…), bioimage-cpp clearly beats both skimage and vigra across the tested grid (2D 512²–2048², 3D 64³–128³, all connectivities, binary and multi-value). Typical margin is 1.5×–3× faster than skimage and 2×–8× faster than vigra. - On
boolinputs, skimage ships a separately tuned 2D kernel that is very fast at large sizes. bioimage-cpp matches it at small/medium sizes and on all 3D cases; at 2D 2048² the skimage-bool path is currently ahead by roughly 1.7×. Convert touint8to fall back onto the general path if you need to win at every 2D size.
bioimage-cpp ships a small Gaussian-derivative filter set under
bic.filters. The scope is the "ilastik filter set" exposed by
fastfilters, which is also the most-used subset of vigra.filters.
Vigra / fastfilters:
import vigra.filters as vf
out = vf.gaussianSmoothing(img, sigma=1.5)
ev = vf.hessianOfGaussianEigenvalues(img, scale=1.5)
import fastfilters as ff
out = ff.gaussianSmoothing(img, sigma=1.5)
ev = ff.hessianOfGaussianEigenvalues(img, scale=1.5)bioimage-cpp:
import bioimage_cpp as bic
out = bic.filters.gaussian_smoothing(img, sigma=1.5)
ev = bic.filters.hessian_of_gaussian_eigenvalues(img, sigma=1.5)Name mapping:
| vigra / fastfilters name | bioimage-cpp name |
|---|---|
gaussianSmoothing |
gaussian_smoothing |
gaussianDerivative |
gaussian_derivative |
gaussianGradientMagnitude |
gaussian_gradient_magnitude |
laplacianOfGaussian |
laplacian_of_gaussian |
hessianOfGaussianEigenvalues |
hessian_of_gaussian_eigenvalues |
structureTensorEigenvalues |
structure_tensor_eigenvalues |
Common parameters:
sigmais a positive scalar or a per-axis sequence of lengthimage.ndim. Anisotropic sigma is supported on every filter.gaussian_derivativetakes anorderargument that is a scalar or a per-axis sequence of ints in{0, 1, 2}.structure_tensor_eigenvaluestakes positionalinner_sigmaandouter_sigma(vigra calls theminnerScale/outerScale).window_sizecontrols the kernel radius:radius = ceil(window_size * sigma).0.0(the default) selects the vigra-style default3 + 0.5 * order. Matches the same-named parameter in vigra/fastfilters.
Important differences from vigra and fastfilters:
- Only 2D and 3D scalar (single-channel) inputs are supported in v1.
Channels and leading batch axes should be looped externally — matches
fastfilters' convention. Vigra's
taggedView/AxisInfomachinery is not reproduced. - C++ kernels operate on
float32.float64inputs are accepted and the output is cast back tofloat64.uint8anduint16are accepted with afloat32output (the typical ML-feature use case). - Boundary handling is
mirror(matches scipymode="mirror"— reflection without edge-pixel repeat). Other boundary modes are not exposed yet; the C++ layer carries an enum for future tiled processing. - Eigenvalue outputs have a trailing axis of size
image.ndim, sorted largest → smallest. This matchesfastfilters. To get vigra's ascending order, reverse withresult[..., ::-1]. - No IIR / recursive Gaussian, no
convolve/recursiveFilter2D, no morphology, no nonlinear diffusion, and no non-local means in v1. Usescipy.ndimage,skimage, or the original vigra/fastfilters bindings if you need those.
Implementation notes:
- All six filters are written as portable C++20 scalar code that the compiler auto-vectorizes. No SIMD intrinsics, no per-file ISA flags, no runtime CPU dispatch, no vendored SIMD library. This keeps the build light enough to ship as portable PyPI wheels across Linux/macOS/Windows and x86_64/arm64.
- Single-threaded for now. Threading can be added later via
detail/threading.hxx::parallel_for_chunkswithout changing the public API.
bioimage-cpp exposes exact binary Euclidean distance transforms under
bic.distance. The implementation uses the separable
Felzenszwalb–Huttenlocher algorithm, complexity O(N · ndim), with optional
multithreading across the orthogonal lines of each axis sweep.
SciPy / vigra:
from scipy import ndimage
dist = ndimage.distance_transform_edt(mask, sampling=(2.0, 1.0))
import vigra.filters as vf
vec = vf.vectorDistanceTransform(mask)bioimage-cpp:
import bioimage_cpp as bic
# One call can return any combination of distances, feature indices, and
# difference vectors — the C++ kernel computes them in a single sweep.
dist = bic.distance.distance_transform(mask, sampling=(2.0, 1.0))
dist, idx, vec = bic.distance.distance_transform(
mask,
sampling=(2.0, 1.0),
return_distances=True,
return_indices=True,
return_vectors=True,
)
# Short alias kept for parity with vigra; equivalent to the call above with
# return_distances=False, return_indices=False, return_vectors=True.
vec = bic.distance.vector_difference_transform(mask, sampling=(2.0, 1.0))Name mapping:
| scipy / vigra name | bioimage-cpp name |
|---|---|
scipy.ndimage.distance_transform_edt |
distance_transform |
vigra.filters.vectorDistanceTransform |
vector_difference_transform |
Important differences:
- Distance-valued outputs are
float32, not SciPy'sfloat64. Indices areint32with shape(ndim, *mask.shape)(matches SciPy's layout). Vectors arefloat32with shape(*mask.shape, ndim); components are sampled displacements(feature_coord - pixel_coord) * sampling[ax]per axis. distance_transformfollows SciPy's binary convention: nonzero values are foreground and distances are measured to the nearest zero-valued element.boolanduint8C-contiguous inputs are fast-pathed without a copy; other dtypes are converted viaarray != 0.- A single
distance_transformcall can return any non-empty subset ofdistances,indices, andvectorsvia the correspondingreturn_distances/return_indices/return_vectorsflags. The result is the array itself when only one output is requested, otherwise a tuple in(distances, indices, vectors)order with omitted entries skipped. - Pre-allocated output buffers are supported via the
distances=,indices=, andvectors=keyword arguments. They must be C-contiguous, writable, of the documented shape and dtype, and are written into in place. Pre-allocated outputs are excluded from the return value (matching SciPy's convention); the call returnsNoneif every requested output was preallocated. number_of_threadsselects the thread count for the per-axis sweep.1(the default) is single-threaded;0usesstd::thread::hardware_concurrency(); positive values pin an explicit count. Output is deterministic and bitwise identical across thread counts.- For an all-foreground input (no zero-valued elements), the result matches
SciPy: distances and indices report a virtual background point at
axis-0 coordinate
-1and0on all other axes. The first row ofindiceswill then contain-1everywhere.
nifty.filters.nonMaximumDistanceSuppression filters a set of candidate
points using a distance map: each point's suppression radius is the distance
value at its own location, and from every group of points that fall within
one another's radius only the one with the largest distance value is kept.
bioimage-cpp exposes the same algorithm as
bic.distance.non_maximum_distance_suppression.
nifty:
from nifty.filters import nonMaximumDistanceSuppression
# distanceMap: float32 array; points: uint64 array of shape (N, ndim)
kept = nonMaximumDistanceSuppression(distanceMap, points)bioimage-cpp:
import bioimage_cpp as bic
kept = bic.distance.non_maximum_distance_suppression(distance_map, points)Name mapping:
| nifty name | bioimage-cpp name |
|---|---|
nifty.filters.nonMaximumDistanceSuppression |
non_maximum_distance_suppression |
Important differences:
- Snake_case naming, consistent with the rest of
bic.distance. pointsmay beint64,uint64,int32, oruint32; the returned array has shape(K, ndim)and preserves the inputpointsdtype (nifty always returneduint64). Output rows are the retained points in ascending input-index order.distance_mapis coerced to C-contiguousfloat32if needed. The per-point radius is dynamic (the distance value at each point), matching nifty; there is no fixed-radius mode.- The algorithm is otherwise identical to nifty, including its float arithmetic, so results match element-for-element. It uses an O(N²) pairwise distance matrix; threshold the distance map first to keep the candidate count modest.
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.