bioimage_py.segmentation.label

Block-wise connected-component labeling (multi-stage: label -> merge -> relabel).

The block-wise path runs three ~bioimage_py.runner.base.Runner.run() calls plus one in-process reduction, with the labeled volume persisted in the output source between stages. This mirrors cluster_tools' connected-components workflow.

Inter-stage data visibility (distributed/slurm): stage N+1 is only launched after every stage-N task's .success sentinel is visible, i.e. after all stage-N workers have exited and flushed their writes. Stage N+1 then reads the output's chunks by direct path (zarr/n5 address a chunk by key, with no directory listing), so NFS close-to-open consistency guarantees it sees the fresh data -- the attribute-cache lag that affects sentinel discovery does not apply to reads of an already-named chunk file.

  1"""Block-wise connected-component labeling (multi-stage: label -> merge -> relabel).
  2
  3The block-wise path runs three :meth:`~bioimage_py.runner.base.Runner.run` calls plus one
  4in-process reduction, with the labeled volume persisted in the ``output`` source between
  5stages. This mirrors cluster_tools' connected-components workflow.
  6
  7Inter-stage data visibility (distributed/slurm): stage N+1 is only launched after every stage-N
  8task's ``.success`` sentinel is visible, i.e. after all stage-N workers have exited and flushed
  9their writes. Stage N+1 then reads the output's chunks by direct path (zarr/n5 address a chunk by
 10key, with no directory listing), so NFS close-to-open consistency guarantees it sees the fresh
 11data -- the attribute-cache lag that affects sentinel *discovery* does not apply to reads of an
 12already-named chunk file.
 13"""
 14from __future__ import annotations
 15
 16from typing import Dict, Optional, Sequence, Tuple
 17
 18import bioimage_cpp as bic
 19import numpy as np
 20
 21from ..runner import get_runner
 22from ..runner.config import RunnerConfig
 23from ..sources import Source, SourceLike, as_source
 24from ..util import BlockDescriptor, ComputeFn, full_roi, get_blocking, is_direct, to_roi
 25from .relabel import relabel
 26
 27__all__ = ["label"]
 28
 29
 30def _binarize(data: np.ndarray, threshold: Optional[float]) -> np.ndarray:
 31    """Binarize ``data`` by threshold, or interpret it as a boolean foreground mask."""
 32    if threshold is not None:
 33        return data > threshold
 34    return data if data.dtype == np.dtype(bool) else data.astype(bool)
 35
 36
 37def _resolve_block_shape(src: Source, out: Source,
 38                         block_shape: Optional[Sequence[int]]) -> Tuple[int, ...]:
 39    """Resolve the block shape from the explicit value or the input/output chunks."""
 40    if block_shape is not None:
 41        return tuple(int(b) for b in block_shape)
 42    chunks = src.chunks or out.chunks
 43    if chunks is None:
 44        raise ValueError("block_shape is required for block-wise labeling of an unchunked array.")
 45    return tuple(int(c) for c in chunks)
 46
 47
 48# --- per-block stage functions (built as closures, capturing only picklable values) ----
 49
 50def _make_stage1(shape: Tuple[int, ...], block_shape: Tuple[int, ...], connectivity: int,
 51                 threshold: Optional[float], offset_factor: int) -> ComputeFn:
 52    """Build stage 1: label each block independently and apply a globally-unique offset."""
 53
 54    def _compute(block: BlockDescriptor, inputs: Sequence[Source], outputs: Sequence[Source],
 55                 mask: Optional[Source]) -> Optional[np.ndarray]:
 56        input_, output_ = inputs[0], outputs[0]
 57        roi = to_roi(block)
 58        blocking = get_blocking(shape, block_shape)
 59        block_id = blocking.coordinates_to_block_id([int(c) for c in block.begin])
 60
 61        binary = _binarize(input_[roi], threshold)
 62        if mask is not None:
 63            binary = binary & mask[roi].astype(bool)
 64
 65        if not binary.any():
 66            output_[roi] = np.zeros(binary.shape, dtype="uint64")
 67            return None
 68
 69        comp = bic.segmentation.label(binary, connectivity=connectivity).astype("uint64", copy=False)
 70        offset = np.uint64(int(block_id) * int(offset_factor))
 71        comp[comp != 0] += offset
 72        output_[roi] = comp
 73        # Return the block's actual (globally-unique) labels so stage 3 can relabel only
 74        # over labels that exist, not over the sparse offset space.
 75        return np.unique(comp[comp != 0])
 76
 77    return _compute
 78
 79
 80def _make_stage2(shape: Tuple[int, ...], block_shape: Tuple[int, ...]) -> ComputeFn:
 81    """Build stage 2: collect label equivalences across lower block faces."""
 82
 83    def _compute(block: BlockDescriptor, inputs: Sequence[Source], outputs: Sequence[Source],
 84                 mask: Optional[Source]) -> Optional[np.ndarray]:
 85        output_ = inputs[0]  # the labeled volume, passed as a read-only input
 86        ndim = len(shape)
 87        blocking = get_blocking(shape, block_shape)
 88        block_id = blocking.coordinates_to_block_id([int(c) for c in block.begin])
 89
 90        pairs = []
 91        block_roi = to_roi(block)
 92        for axis in range(ndim):
 93            if blocking.get_neighbor_id(block_id, axis, True) == -1:  # no lower neighbor
 94                continue
 95            b0 = int(block.begin[axis])
 96            slab_roi = list(block_roi)
 97            slab_roi[axis] = slice(b0 - 1, b0 + 1)  # 2-thick slab straddling the boundary
 98            slab = output_[tuple(slab_roi)]
 99            lo = tuple(slice(0, 1) if d == axis else slice(None) for d in range(ndim))
100            hi = tuple(slice(1, 2) if d == axis else slice(None) for d in range(ndim))
101            labels_b = np.squeeze(slab[lo], axis=axis)  # neighbor side
102            labels_a = np.squeeze(slab[hi], axis=axis)  # this block side
103            keep = (labels_a != 0) & (labels_b != 0)
104            if keep.any():
105                pairs.append(np.stack([labels_a[keep], labels_b[keep]], axis=1).astype("uint64"))
106
107        if not pairs:
108            return None
109        return np.unique(np.concatenate(pairs, axis=0), axis=0)
110
111    return _compute
112
113
114def label(
115    input: SourceLike,
116    output: Optional[SourceLike] = None,
117    *,
118    threshold: Optional[float] = None,
119    connectivity: Optional[int] = None,
120    block_shape: Optional[Tuple[int, ...]] = None,
121    job_type: str = "local",
122    job_config: Optional[RunnerConfig] = None,
123    num_workers: int = 1,
124    mask: Optional[SourceLike] = None,
125) -> SourceLike:
126    """Label connected components of (optionally thresholded) data, block-wise.
127
128    Unlike the single-pass operations, ``label`` is multi-stage with a global cross-block merge
129    (per-block labeling, then a union-find over touching components across block faces), so it
130    does **not** accept ``block_ids`` or ``resume_from``: a failed run must be re-run whole (it is
131    idempotent given the same ``output``).
132
133    Args:
134        input: The input data (a numpy/zarr/n5 array or a `Source`).
135        output: The ``uint64`` output array to write the labels into. Optional for local
136            execution — a numpy array is allocated and returned if omitted; **required** for
137            distributed execution.
138        threshold: If given, the input is binarized as ``input > threshold``; otherwise the
139            input is treated as a binary foreground mask.
140        connectivity: Neighbour connectivity in ``[1, ndim]`` (``1`` = orthogonal). Defaults
141            to ``1``; values ``> 1`` are only supported for the direct (single-block) path.
142        block_shape: Shape of the processing blocks. Defaults to the input/output chunk shape;
143            required for unchunked data.
144        job_type: Execution backend: one of ``"local"``, ``"subprocess"`` or ``"slurm"``.
145        job_config: Backend configuration (a `RunnerConfig` / `SlurmConfig`).
146        num_workers: Number of parallel workers (threads for ``local``, tasks for distributed
147            backends).
148        mask: Optional binary mask; values outside the mask are excluded from the foreground.
149
150    Returns:
151        The output array (the provided ``output``, or a newly allocated numpy array), labeled
152        with consecutive ids (background stays ``0``).
153    """
154    src = as_source(input)
155    ndim = src.ndim
156    conn = 1 if connectivity is None else int(connectivity)
157    if not 1 <= conn <= ndim:
158        raise ValueError(f"connectivity must be in [1, {ndim}], got {conn}.")
159
160    direct = is_direct(job_type, num_workers, block_shape) and mask is None
161    if conn > 1 and not direct:
162        raise NotImplementedError(
163            "Block-wise labeling only supports connectivity=1 (orthogonal). Use the direct "
164            "path (local, single worker, no block_shape, no mask) for higher connectivity."
165        )
166
167    if output is None:
168        if job_type != "local":
169            raise ValueError(
170                f"'output' is required for distributed execution (job_type={job_type!r}); "
171                "pass a file-backed (zarr/n5) output array."
172            )
173        out_array: SourceLike = np.zeros(tuple(src.shape), dtype="uint64")
174    else:
175        out_array = output
176
177    out = as_source(out_array)
178    if out.dtype != np.dtype("uint64"):
179        raise ValueError(f"output must have dtype uint64, got {out.dtype}.")
180
181    if direct:
182        binary = _binarize(src[full_roi(ndim)], threshold)
183        comp = bic.segmentation.label(binary, connectivity=conn).astype("uint64", copy=False)
184        out[full_roi(ndim)] = comp
185        return out_array
186
187    block_shape = _resolve_block_shape(src, out, block_shape)
188    offset_factor = int(np.prod(block_shape))
189    blocking = get_blocking(src.shape, block_shape)
190    n_blocks = int(blocking.number_of_blocks)
191    if (n_blocks * offset_factor) >= int(np.iinfo(np.uint64).max):
192        raise ValueError(
193            "Label id overflow: number_of_blocks * prod(block_shape) exceeds uint64. "
194            "Reduce the block shape or the volume size."
195        )
196
197    runner = get_runner(job_type, job_config)
198
199    # Stage 1: label each block independently with a globally-unique offset.
200    stage1 = _make_stage1(tuple(src.shape), block_shape, conn, threshold, offset_factor)
201    id_results = runner.run(stage1, [input], outputs=[out_array], block_shape=block_shape,
202                            mask=mask, num_workers=num_workers, has_return_val=True,
203                            name="label-blocks")
204    id_arrays = [a for a in id_results if a is not None and len(a)]
205    real_labels = np.unique(np.concatenate(id_arrays)) if id_arrays else np.zeros((0,), dtype="uint64")
206
207    # Stage 2: collect label equivalences across lower block faces.
208    stage2 = _make_stage2(tuple(src.shape), block_shape)
209    pair_results = runner.run(stage2, [out_array], block_shape=block_shape,
210                              num_workers=num_workers, has_return_val=True, name="merge-faces")
211    pairs = [p for p in pair_results if p is not None]
212    assignments = (np.unique(np.concatenate(pairs, axis=0), axis=0)
213                   if pairs else np.zeros((0, 2), dtype="uint64"))
214
215    # Stage 3 (in process): union-find merge, then relabel the labels that exist to consecutive ids.
216    # The stage-1 offset space is sparse (ids run up to ~voxel count), so the union-find is built over
217    # a dense [0..K) compaction of the labels that actually exist -- sized to the component count K,
218    # not the max offset id -- keeping this in-process step O(components) rather than O(voxels). Every
219    # id in `assignments` exists in `real_labels` (stage 2 only reads what stage 1 wrote), so the
220    # compaction covers all pair ids.
221    mapping: Dict[int, int] = {0: 0}
222    if real_labels.size:
223        n_components = int(real_labels.size)
224        dense = {int(lab): idx for idx, lab in enumerate(real_labels.tolist())}
225        uf = bic.utils.UnionFind(n_components)
226        if len(assignments):
227            pu = bic.utils.take_dict(dense, np.ascontiguousarray(assignments[:, 0].astype("uint64")))
228            pv = bic.utils.take_dict(dense, np.ascontiguousarray(assignments[:, 1].astype("uint64")))
229            uf.merge(np.stack([pu, pv], axis=1).astype("uint64"))
230        roots = np.asarray(uf.find(np.arange(n_components, dtype="uint64")))
231        _, new_ids = np.unique(roots, return_inverse=True)  # consecutive component ranks (0-based)
232        for lab, new_id in zip(real_labels.tolist(), new_ids.tolist()):
233            mapping[int(lab)] = int(new_id) + 1  # reserve 0 for background
234
235    # Stage 4: apply the mapping in place through the canonical node-label writer (relabel).
236    out_array = relabel(out_array, mapping, output=out_array, block_shape=block_shape,
237                        job_type=job_type, job_config=job_config, num_workers=num_workers)
238    return out_array
def label( input: 'SourceLike', output: 'Optional[SourceLike]' = None, *, threshold: Optional[float] = None, connectivity: Optional[int] = None, block_shape: Optional[Tuple[int, ...]] = None, job_type: str = 'local', job_config: Optional[bioimage_py.runner.RunnerConfig] = None, num_workers: int = 1, mask: 'Optional[SourceLike]' = None) -> 'SourceLike':
115def label(
116    input: SourceLike,
117    output: Optional[SourceLike] = None,
118    *,
119    threshold: Optional[float] = None,
120    connectivity: Optional[int] = None,
121    block_shape: Optional[Tuple[int, ...]] = None,
122    job_type: str = "local",
123    job_config: Optional[RunnerConfig] = None,
124    num_workers: int = 1,
125    mask: Optional[SourceLike] = None,
126) -> SourceLike:
127    """Label connected components of (optionally thresholded) data, block-wise.
128
129    Unlike the single-pass operations, ``label`` is multi-stage with a global cross-block merge
130    (per-block labeling, then a union-find over touching components across block faces), so it
131    does **not** accept ``block_ids`` or ``resume_from``: a failed run must be re-run whole (it is
132    idempotent given the same ``output``).
133
134    Args:
135        input: The input data (a numpy/zarr/n5 array or a `Source`).
136        output: The ``uint64`` output array to write the labels into. Optional for local
137            execution — a numpy array is allocated and returned if omitted; **required** for
138            distributed execution.
139        threshold: If given, the input is binarized as ``input > threshold``; otherwise the
140            input is treated as a binary foreground mask.
141        connectivity: Neighbour connectivity in ``[1, ndim]`` (``1`` = orthogonal). Defaults
142            to ``1``; values ``> 1`` are only supported for the direct (single-block) path.
143        block_shape: Shape of the processing blocks. Defaults to the input/output chunk shape;
144            required for unchunked data.
145        job_type: Execution backend: one of ``"local"``, ``"subprocess"`` or ``"slurm"``.
146        job_config: Backend configuration (a `RunnerConfig` / `SlurmConfig`).
147        num_workers: Number of parallel workers (threads for ``local``, tasks for distributed
148            backends).
149        mask: Optional binary mask; values outside the mask are excluded from the foreground.
150
151    Returns:
152        The output array (the provided ``output``, or a newly allocated numpy array), labeled
153        with consecutive ids (background stays ``0``).
154    """
155    src = as_source(input)
156    ndim = src.ndim
157    conn = 1 if connectivity is None else int(connectivity)
158    if not 1 <= conn <= ndim:
159        raise ValueError(f"connectivity must be in [1, {ndim}], got {conn}.")
160
161    direct = is_direct(job_type, num_workers, block_shape) and mask is None
162    if conn > 1 and not direct:
163        raise NotImplementedError(
164            "Block-wise labeling only supports connectivity=1 (orthogonal). Use the direct "
165            "path (local, single worker, no block_shape, no mask) for higher connectivity."
166        )
167
168    if output is None:
169        if job_type != "local":
170            raise ValueError(
171                f"'output' is required for distributed execution (job_type={job_type!r}); "
172                "pass a file-backed (zarr/n5) output array."
173            )
174        out_array: SourceLike = np.zeros(tuple(src.shape), dtype="uint64")
175    else:
176        out_array = output
177
178    out = as_source(out_array)
179    if out.dtype != np.dtype("uint64"):
180        raise ValueError(f"output must have dtype uint64, got {out.dtype}.")
181
182    if direct:
183        binary = _binarize(src[full_roi(ndim)], threshold)
184        comp = bic.segmentation.label(binary, connectivity=conn).astype("uint64", copy=False)
185        out[full_roi(ndim)] = comp
186        return out_array
187
188    block_shape = _resolve_block_shape(src, out, block_shape)
189    offset_factor = int(np.prod(block_shape))
190    blocking = get_blocking(src.shape, block_shape)
191    n_blocks = int(blocking.number_of_blocks)
192    if (n_blocks * offset_factor) >= int(np.iinfo(np.uint64).max):
193        raise ValueError(
194            "Label id overflow: number_of_blocks * prod(block_shape) exceeds uint64. "
195            "Reduce the block shape or the volume size."
196        )
197
198    runner = get_runner(job_type, job_config)
199
200    # Stage 1: label each block independently with a globally-unique offset.
201    stage1 = _make_stage1(tuple(src.shape), block_shape, conn, threshold, offset_factor)
202    id_results = runner.run(stage1, [input], outputs=[out_array], block_shape=block_shape,
203                            mask=mask, num_workers=num_workers, has_return_val=True,
204                            name="label-blocks")
205    id_arrays = [a for a in id_results if a is not None and len(a)]
206    real_labels = np.unique(np.concatenate(id_arrays)) if id_arrays else np.zeros((0,), dtype="uint64")
207
208    # Stage 2: collect label equivalences across lower block faces.
209    stage2 = _make_stage2(tuple(src.shape), block_shape)
210    pair_results = runner.run(stage2, [out_array], block_shape=block_shape,
211                              num_workers=num_workers, has_return_val=True, name="merge-faces")
212    pairs = [p for p in pair_results if p is not None]
213    assignments = (np.unique(np.concatenate(pairs, axis=0), axis=0)
214                   if pairs else np.zeros((0, 2), dtype="uint64"))
215
216    # Stage 3 (in process): union-find merge, then relabel the labels that exist to consecutive ids.
217    # The stage-1 offset space is sparse (ids run up to ~voxel count), so the union-find is built over
218    # a dense [0..K) compaction of the labels that actually exist -- sized to the component count K,
219    # not the max offset id -- keeping this in-process step O(components) rather than O(voxels). Every
220    # id in `assignments` exists in `real_labels` (stage 2 only reads what stage 1 wrote), so the
221    # compaction covers all pair ids.
222    mapping: Dict[int, int] = {0: 0}
223    if real_labels.size:
224        n_components = int(real_labels.size)
225        dense = {int(lab): idx for idx, lab in enumerate(real_labels.tolist())}
226        uf = bic.utils.UnionFind(n_components)
227        if len(assignments):
228            pu = bic.utils.take_dict(dense, np.ascontiguousarray(assignments[:, 0].astype("uint64")))
229            pv = bic.utils.take_dict(dense, np.ascontiguousarray(assignments[:, 1].astype("uint64")))
230            uf.merge(np.stack([pu, pv], axis=1).astype("uint64"))
231        roots = np.asarray(uf.find(np.arange(n_components, dtype="uint64")))
232        _, new_ids = np.unique(roots, return_inverse=True)  # consecutive component ranks (0-based)
233        for lab, new_id in zip(real_labels.tolist(), new_ids.tolist()):
234            mapping[int(lab)] = int(new_id) + 1  # reserve 0 for background
235
236    # Stage 4: apply the mapping in place through the canonical node-label writer (relabel).
237    out_array = relabel(out_array, mapping, output=out_array, block_shape=block_shape,
238                        job_type=job_type, job_config=job_config, num_workers=num_workers)
239    return out_array

Label connected components of (optionally thresholded) data, block-wise.

Unlike the single-pass operations, label is multi-stage with a global cross-block merge (per-block labeling, then a union-find over touching components across block faces), so it does not accept block_ids or resume_from: a failed run must be re-run whole (it is idempotent given the same output).

Args: input: The input data (a numpy/zarr/n5 array or a Source). output: The uint64 output array to write the labels into. Optional for local execution — a numpy array is allocated and returned if omitted; required for distributed execution. threshold: If given, the input is binarized as input > threshold; otherwise the input is treated as a binary foreground mask. connectivity: Neighbour connectivity in [1, ndim] (1 = orthogonal). Defaults to 1; values > 1 are only supported for the direct (single-block) path. block_shape: Shape of the processing blocks. Defaults to the input/output chunk shape; required for unchunked data. job_type: Execution backend: one of "local", "subprocess" or "slurm". job_config: Backend configuration (a RunnerConfig / SlurmConfig). num_workers: Number of parallel workers (threads for local, tasks for distributed backends). mask: Optional binary mask; values outside the mask are excluded from the foreground.

Returns: The output array (the provided output, or a newly allocated numpy array), labeled with consecutive ids (background stays 0).