bioimage_py.morphology.regionprops

Advanced per-object morphology: numpy moment features + surface area and centroid correction.

A second pass on top of bioimage_py.morphology.morphology: for each labeled object it crops the sub-volume by the precomputed bounding box, masks == label, and computes shape features directly with numpy (in physical units via resolution) — the physical volume (area), the extent, the equivalent_diameter_area and the major/minor axis_*_length from the object's second moments — plus an optional marching-cubes surface_area (3D) and a corrected centroid (the center-of-mass if it lies inside the object, else the deepest-interior voxel via the Euclidean distance transform).

The moment features reproduce the corresponding scikit-image regionprops definitions exactly, but without constructing a RegionProperties object per label (and without the expensive convex-hull solidity / topological euler_number), so the pass scales linearly in the number of objects. Work is mapped one task per object with the generic bioimage_py.runner.base.Runner.map(), so it runs identically across local / subprocess / slurm; for distributed backends the base table is serialized to a temp file the workers read.

  1"""Advanced per-object morphology: numpy moment features + surface area and centroid correction.
  2
  3A *second pass* on top of :func:`bioimage_py.morphology.morphology`: for each labeled object it crops
  4the sub-volume by the precomputed bounding box, masks ``== label``, and computes shape features
  5directly with numpy (in physical units via ``resolution``) — the physical volume (``area``), the
  6``extent``, the ``equivalent_diameter_area`` and the major/minor ``axis_*_length`` from the object's
  7second moments — plus an optional marching-cubes ``surface_area`` (3D) and a corrected centroid (the
  8center-of-mass if it lies inside the object, else the deepest-interior voxel via the Euclidean
  9distance transform).
 10
 11The moment features reproduce the corresponding scikit-image ``regionprops`` definitions exactly, but
 12without constructing a ``RegionProperties`` object per label (and without the expensive convex-hull
 13``solidity`` / topological ``euler_number``), so the pass scales linearly in the number of objects.
 14Work is mapped one task per object with the generic
 15:meth:`bioimage_py.runner.base.Runner.map`, so it runs identically across ``local`` / ``subprocess``
 16/ ``slurm``; for distributed backends the base table is serialized to a temp file the workers read.
 17"""
 18from __future__ import annotations
 19
 20import functools
 21import os
 22from typing import Any, Callable, Dict, List, Optional, Sequence, Union
 23
 24import bioimage_cpp as bic
 25import numpy as np
 26import pandas as pd
 27
 28from ..runner import get_runner
 29from ..runner.config import RunnerConfig
 30from ..sources import Source, SourceLike, as_source
 31from ..util import check_rerun_args
 32from .morphology import _axis_names
 33
 34__all__ = ["regionprops"]
 35
 36# Per-worker cache so each task reopens the segmentation `SourceSpec` once, not per object (keyed by
 37# the spec's stable fields). The consumed table columns travel in the closure as numpy arrays, so
 38# they need no worker-side caching.
 39_SEG_CACHE: Dict[Any, Source] = {}
 40
 41
 42def _read_table(path: str) -> "pd.DataFrame":
 43    """Read a serialized table from a ``.csv`` / ``.xlsx`` path."""
 44    lower = str(path).lower()
 45    if lower.endswith((".xlsx", ".xls")):
 46        return pd.read_excel(path)
 47    if lower.endswith(".csv"):
 48        return pd.read_csv(path)
 49    raise ValueError(f"Unsupported table format: {path!r} (use .csv or .xlsx/.xls).")
 50
 51
 52def _load_table(table: Union[str, "pd.DataFrame"]) -> "pd.DataFrame":
 53    """Return ``table`` as a DataFrame (pass-through, or read from a path)."""
 54    if isinstance(table, pd.DataFrame):
 55        return table
 56    return _read_table(str(table))
 57
 58
 59def _required_columns(axes: Sequence[str]) -> List[str]:
 60    """The base-morphology columns this op consumes."""
 61    return (["label"] + [f"com_{a}" for a in axes]
 62            + [f"bb_min_{a}" for a in axes] + [f"bb_max_{a}" for a in axes])
 63
 64
 65def _resolve_seg(seg: Union[Source, Any]) -> Source:
 66    """Return an opened segmentation source, reopening (and caching) a `SourceSpec` if needed."""
 67    if isinstance(seg, Source):
 68        return seg
 69    key = (seg.kind, seg.path, seg.internal_path)
 70    src = _SEG_CACHE.get(key)
 71    if src is None:
 72        from ..sources.dispatch import from_spec
 73        src = from_spec(seg)
 74        _SEG_CACHE[key] = src
 75    return src
 76
 77
 78def _column_arrays(df: "pd.DataFrame", axes: Sequence[str]) -> Dict[str, np.ndarray]:
 79    """Extract the consumed columns as numpy arrays (built once, indexed by row position).
 80
 81    Indexing these arrays by position avoids per-object pandas scalar access, which dominates the
 82    cost when there are many objects. Returns ``label`` (``(N,)``) and ``com`` / ``bb_min`` /
 83    ``bb_max`` (each ``(N, ndim)``).
 84    """
 85    return {
 86        "label": df["label"].to_numpy(dtype="int64"),
 87        "com": np.stack([df[f"com_{a}"].to_numpy(dtype="float64") for a in axes], axis=1),
 88        "bb_min": np.stack([df[f"bb_min_{a}"].to_numpy(dtype="int64") for a in axes], axis=1),
 89        "bb_max": np.stack([df[f"bb_max_{a}"].to_numpy(dtype="int64") for a in axes], axis=1),
 90    }
 91
 92
 93def _surface_area(mask: np.ndarray, resolution: Sequence[float]) -> float:
 94    """Physical surface area (nm² etc.) of a 3D mask via marching cubes; 0.0 if degenerate."""
 95    from skimage.measure import marching_cubes, mesh_surface_area
 96    if not mask.any():
 97        return 0.0
 98    # Pad by 1 so border voxels produce closed faces; spacing makes the area physical.
 99    padded = np.pad(mask, 1).astype("float32")
100    try:
101        verts, faces, _, _ = marching_cubes(padded, level=0.5, spacing=tuple(resolution))
102        return float(mesh_surface_area(verts, faces))
103    except (RuntimeError, ValueError):
104        # Objects too thin/small for a closed surface.
105        return 0.0
106
107
108def _axis_lengths(coords: np.ndarray, spacing: np.ndarray, ndim: int) -> "tuple[float, float]":
109    """Major/minor axis lengths (physical) of the inertia-equivalent ellipse/ellipsoid.
110
111    Mirrors scikit-image: the inertia tensor is ``trace(C) * I - C`` for the population covariance
112    ``C`` of the (physical) voxel coordinates; the axis lengths derive from its eigenvalues (sorted
113    descending). Returns ``(0.0, 0.0)`` for an empty object.
114    """
115    n = coords.shape[0]
116    if n == 0:
117        return 0.0, 0.0
118    x = coords.astype("float64") * spacing
119    xc = x - x.mean(axis=0)
120    cov = (xc.T @ xc) / n  # population covariance == central second moments / area.
121    inertia = np.trace(cov) * np.eye(ndim) - cov
122    ev = np.sort(np.clip(np.linalg.eigvalsh(inertia), 0.0, None))[::-1]  # descending.
123    if ndim == 3:
124        major = float(np.sqrt(max(0.0, 10.0 * (ev[0] + ev[1] - ev[2]))))
125        minor = float(np.sqrt(max(0.0, 10.0 * (-ev[0] + ev[1] + ev[2]))))
126        return major, minor
127    # 2D (and the nD fallback): the ellipse axes are 4 * sqrt(extreme eigenvalues).
128    return float(4.0 * np.sqrt(ev[0])), float(4.0 * np.sqrt(ev[-1]))
129
130
131def _corrected_centroid(mask: np.ndarray, com_global_vox: np.ndarray, origin: np.ndarray,
132                        resolution: Sequence[float]) -> np.ndarray:
133    """Return the corrected centroid in physical units.
134
135    Keeps the center-of-mass when it lands inside the object; otherwise uses the deepest-interior
136    voxel (argmax of the Euclidean distance transform), then scales to physical units.
137    """
138    spacing = np.asarray(resolution, dtype="float64")
139    if not mask.any():
140        return com_global_vox * spacing
141    com_local = com_global_vox - origin
142    rounded = np.round(com_local).astype(int)
143    inside = (np.all(rounded >= 0) and np.all(rounded < np.asarray(mask.shape))
144              and bool(mask[tuple(rounded)]))
145    if inside:
146        centroid_vox = com_global_vox
147    else:
148        # Deepest-interior point: argmax of the (anisotropic) exact EDT from bioimage_cpp.
149        dt = bic.distance.distance_transform(mask.astype("uint8"), sampling=tuple(resolution))
150        idx = np.unravel_index(int(np.argmax(dt)), mask.shape)
151        centroid_vox = np.asarray(idx, dtype="float64") + origin
152    return centroid_vox * spacing
153
154
155def _object_features(index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
156    """Compute the feature row for a single object (the per-item function handed to ``runner.map``)."""
157    seg = _resolve_seg(ctx["seg"])
158    axes, ndim = ctx["axes"], ctx["ndim"]
159    res = np.asarray(ctx["resolution"], dtype="float64")
160
161    label = int(ctx["label"][index])
162    com = ctx["com"][index]
163    bb_min = ctx["bb_min"][index]
164    bb_max = ctx["bb_max"][index]
165
166    crop = np.asarray(seg[tuple(slice(int(lo), int(hi)) for lo, hi in zip(bb_min, bb_max))])
167    mask = crop == label
168    coords = np.argwhere(mask)  # crop-local voxel coordinates of the object.
169    n_voxels = int(coords.shape[0])
170    area = n_voxels * float(np.prod(res))  # physical volume under spacing (== skimage's 'area').
171
172    bbox_voxels = int(np.prod(bb_max - bb_min))
173    major, minor = _axis_lengths(coords, res, ndim)
174    result: Dict[str, Any] = {
175        "label": label,
176        "n_voxels": n_voxels,
177        "area": area,
178        "extent": (n_voxels / bbox_voxels) if bbox_voxels > 0 else float("nan"),
179        "equivalent_diameter_area": ((2 * ndim * area / np.pi) ** (1.0 / ndim)) if area > 0 else 0.0,
180        "axis_major_length": major,
181        "axis_minor_length": minor,
182    }
183
184    if ctx["compute_surface"] and ndim == 3:
185        result["surface_area"] = _surface_area(mask, ctx["resolution"])
186
187    centroid = _corrected_centroid(mask, com, bb_min.astype("float64"), ctx["resolution"])
188    for a, ax in enumerate(axes):
189        result[f"centroid_{ax}"] = float(centroid[a])
190    for a, ax in enumerate(axes):
191        result[f"bb_min_{ax}"] = int(bb_min[a])
192        result[f"bb_max_{ax}"] = int(bb_max[a])
193    return result
194
195
196def _order_columns(axes: Sequence[str], compute_surface: bool, ndim: int) -> List[str]:
197    """The fixed output column order: identity, shape features, geometry, then surface."""
198    cols = ["label", "n_voxels", "area", "extent", "equivalent_diameter_area",
199            "axis_major_length", "axis_minor_length"]
200    cols += [f"centroid_{a}" for a in axes]
201    cols += [f"bb_min_{a}" for a in axes] + [f"bb_max_{a}" for a in axes]
202    if compute_surface and ndim == 3:
203        cols.append("surface_area")
204    return cols
205
206
207def _write_table(out: "pd.DataFrame", output_path: str) -> None:
208    """Write the result table to a ``.csv`` / ``.xlsx`` path (creating parent dirs)."""
209    parent = os.path.dirname(os.path.abspath(str(output_path)))
210    if parent:
211        os.makedirs(parent, exist_ok=True)
212    lower = str(output_path).lower()
213    if lower.endswith((".xlsx", ".xls")):
214        out.to_excel(output_path, index=False)
215    elif lower.endswith(".csv"):
216        out.to_csv(output_path, index=False)
217    else:
218        raise ValueError(f"Unsupported output format: {output_path!r} (use .csv or .xlsx/.xls).")
219
220
221def regionprops(
222    input: SourceLike,
223    table: Union[str, "pd.DataFrame"],
224    *,
225    resolution: Optional[Sequence[float]] = None,
226    compute_surface: bool = False,
227    output_path: Optional[str] = None,
228    num_workers: int = 1,
229    job_type: str = "local",
230    job_config: Optional[RunnerConfig] = None,
231    item_ids: Optional[Sequence[int]] = None,
232    resume_from: Optional[str] = None,
233    pre_cleanup: Optional[Callable[[str], None]] = None,
234) -> "pd.DataFrame":
235    """Compute per-object morphology features for a labeled volume, one task per object.
236
237    For each object listed in ``table`` (the output of :func:`morphology`), the sub-volume is cropped by
238    its bounding box, masked to the label, and described with numpy in physical units (via
239    ``resolution``): the physical volume ``area``, the ``extent`` (filled fraction of the bounding box),
240    the ``equivalent_diameter_area`` (diameter of the ball with the same volume) and the major/minor
241    ``axis_*_length`` (from the object's second moments). These reproduce the corresponding
242    scikit-image ``regionprops`` definitions exactly. Optionally a marching-cubes ``surface_area`` (3D
243    only) and a corrected centroid (the center-of-mass when it lies inside the object, otherwise the
244    deepest-interior voxel — the argmax of the Euclidean distance transform) are added.
245
246    Args:
247        input: The labeled segmentation (a numpy/zarr/n5 array or a `Source`); integer-typed. For the
248            ``subprocess``/``slurm`` backends it must be file-backed (zarr/n5).
249        table: The base morphology table — a pandas DataFrame or a path to a ``.csv`` / ``.xlsx`` file.
250            Must contain ``label``, ``com_<axis>``, ``bb_min_<axis>`` and ``bb_max_<axis>`` (``bb_max``
251            is the exclusive slice stop, as produced by :func:`morphology`).
252        resolution: Per-axis physical voxel size in array (e.g. z, y, x) order. Defaults to ones (voxel
253            units).
254        compute_surface: Whether to add a marching-cubes ``surface_area`` (3D inputs only). This is the
255            most expensive per-object step, so it is off by default.
256        output_path: Optional ``.csv`` / ``.xlsx`` path to also write the result to.
257        num_workers: Number of parallel workers (threads for ``local``, tasks for distributed backends).
258        job_type: Execution backend: one of ``"local"``, ``"subprocess"`` or ``"slurm"``.
259        job_config: Backend configuration (a `RunnerConfig` / `SlurmConfig`).
260        item_ids: Restrict processing to these item indices (rows of ``table``), e.g. to re-run
261            previously failed objects; the result then covers only those rows. An item id indexes a
262            *row* of ``table``, so the same table (same row order) must be passed as in the original
263            run. Mutually exclusive with ``resume_from``.
264        resume_from: Distributed only; the preserved temp folder of a failed run to resume and merge
265            (see ``runner.run``). The result then covers all objects (the already-completed merged
266            with the re-run ones). The recommended rerun path for regionprops. Mutually exclusive
267            with ``item_ids``.
268        pre_cleanup: Optional ``pre_cleanup(tmp_folder)`` callback invoked on the orchestrating
269            process with the job temp folder right before it is deleted (distributed backends only).
270            Use it to read out the per-task timing files under ``tmp_folder/timings/`` before cleanup.
271            Ignored for the ``local`` backend (no temp folder).
272
273    Returns:
274        A pandas DataFrame with one row per object, sorted by ``label``: ``label``, ``n_voxels`` (raw
275        voxel count), ``area`` (physical volume), ``extent``, ``equivalent_diameter_area``,
276        ``axis_major_length``, ``axis_minor_length``, ``centroid_<axis>`` (corrected, physical),
277        ``bb_min_<axis>``/``bb_max_<axis>`` (global voxels), and ``surface_area`` (only when
278        ``compute_surface`` and the input is 3D).
279    """
280    check_rerun_args(job_type, resume_from, item_ids, subset_name="item_ids")
281    src = as_source(input)
282    if not np.issubdtype(np.dtype(src.dtype), np.integer):
283        raise ValueError(f"regionprops expects an integer label image, got dtype {src.dtype}.")
284    ndim = src.ndim
285    axes = _axis_names(ndim)
286
287    if resolution is None:
288        resolution = tuple(1.0 for _ in range(ndim))
289    else:
290        resolution = tuple(float(r) for r in resolution)
291        if len(resolution) != ndim:
292            raise ValueError(f"resolution {resolution} does not match the input ndim {ndim}.")
293
294    df = _load_table(table)
295    missing = [c for c in _required_columns(axes) if c not in df.columns]
296    if missing:
297        raise ValueError(
298            f"table is missing required columns {missing}; pass the output of "
299            "bioimage_py.morphology.morphology (label / com_* / bb_min_* / bb_max_*)."
300        )
301
302    n = len(df)
303    if n == 0:
304        cols = _order_columns(axes, compute_surface, ndim)
305        return pd.DataFrame({c: pd.Series(dtype="float64") for c in cols})
306
307    seg_arg: Any = src
308    if job_type != "local":
309        try:
310            seg_arg = src.to_spec()
311        except ValueError as err:
312            raise ValueError(
313                f"Distributed regionprops requires a file-backed (zarr/n5) segmentation. {err}"
314            ) from err
315
316    # The consumed columns travel with the closure as numpy arrays (built once): for distributed
317    # backends they are cloudpickled into the single shared payload the workers read.
318    ctx = {
319        "seg": seg_arg, "resolution": resolution, "axes": tuple(axes), "ndim": ndim,
320        "compute_surface": bool(compute_surface), **_column_arrays(df, axes),
321    }
322    runner = get_runner(job_type, job_config)
323    results = runner.map(functools.partial(_object_features, ctx=ctx), n,
324                         item_ids=item_ids, resume_from=resume_from,
325                         num_workers=num_workers, has_return_val=True, name="regionprops",
326                         pre_cleanup=pre_cleanup)
327
328    out = pd.DataFrame(results)
329    out = out[_order_columns(axes, compute_surface, ndim)].sort_values("label")
330    out = out.reset_index(drop=True)
331    if output_path is not None:
332        _write_table(out, output_path)
333    return out
def regionprops( input: 'SourceLike', table: Union[str, pandas.DataFrame], *, resolution: Optional[Sequence[float]] = None, compute_surface: bool = False, output_path: Optional[str] = None, num_workers: int = 1, job_type: str = 'local', job_config: Optional[bioimage_py.runner.RunnerConfig] = None, item_ids: Optional[Sequence[int]] = None, resume_from: Optional[str] = None, pre_cleanup: Optional[Callable[[str], NoneType]] = None) -> pandas.DataFrame:
222def regionprops(
223    input: SourceLike,
224    table: Union[str, "pd.DataFrame"],
225    *,
226    resolution: Optional[Sequence[float]] = None,
227    compute_surface: bool = False,
228    output_path: Optional[str] = None,
229    num_workers: int = 1,
230    job_type: str = "local",
231    job_config: Optional[RunnerConfig] = None,
232    item_ids: Optional[Sequence[int]] = None,
233    resume_from: Optional[str] = None,
234    pre_cleanup: Optional[Callable[[str], None]] = None,
235) -> "pd.DataFrame":
236    """Compute per-object morphology features for a labeled volume, one task per object.
237
238    For each object listed in ``table`` (the output of :func:`morphology`), the sub-volume is cropped by
239    its bounding box, masked to the label, and described with numpy in physical units (via
240    ``resolution``): the physical volume ``area``, the ``extent`` (filled fraction of the bounding box),
241    the ``equivalent_diameter_area`` (diameter of the ball with the same volume) and the major/minor
242    ``axis_*_length`` (from the object's second moments). These reproduce the corresponding
243    scikit-image ``regionprops`` definitions exactly. Optionally a marching-cubes ``surface_area`` (3D
244    only) and a corrected centroid (the center-of-mass when it lies inside the object, otherwise the
245    deepest-interior voxel — the argmax of the Euclidean distance transform) are added.
246
247    Args:
248        input: The labeled segmentation (a numpy/zarr/n5 array or a `Source`); integer-typed. For the
249            ``subprocess``/``slurm`` backends it must be file-backed (zarr/n5).
250        table: The base morphology table — a pandas DataFrame or a path to a ``.csv`` / ``.xlsx`` file.
251            Must contain ``label``, ``com_<axis>``, ``bb_min_<axis>`` and ``bb_max_<axis>`` (``bb_max``
252            is the exclusive slice stop, as produced by :func:`morphology`).
253        resolution: Per-axis physical voxel size in array (e.g. z, y, x) order. Defaults to ones (voxel
254            units).
255        compute_surface: Whether to add a marching-cubes ``surface_area`` (3D inputs only). This is the
256            most expensive per-object step, so it is off by default.
257        output_path: Optional ``.csv`` / ``.xlsx`` path to also write the result to.
258        num_workers: Number of parallel workers (threads for ``local``, tasks for distributed backends).
259        job_type: Execution backend: one of ``"local"``, ``"subprocess"`` or ``"slurm"``.
260        job_config: Backend configuration (a `RunnerConfig` / `SlurmConfig`).
261        item_ids: Restrict processing to these item indices (rows of ``table``), e.g. to re-run
262            previously failed objects; the result then covers only those rows. An item id indexes a
263            *row* of ``table``, so the same table (same row order) must be passed as in the original
264            run. Mutually exclusive with ``resume_from``.
265        resume_from: Distributed only; the preserved temp folder of a failed run to resume and merge
266            (see ``runner.run``). The result then covers all objects (the already-completed merged
267            with the re-run ones). The recommended rerun path for regionprops. Mutually exclusive
268            with ``item_ids``.
269        pre_cleanup: Optional ``pre_cleanup(tmp_folder)`` callback invoked on the orchestrating
270            process with the job temp folder right before it is deleted (distributed backends only).
271            Use it to read out the per-task timing files under ``tmp_folder/timings/`` before cleanup.
272            Ignored for the ``local`` backend (no temp folder).
273
274    Returns:
275        A pandas DataFrame with one row per object, sorted by ``label``: ``label``, ``n_voxels`` (raw
276        voxel count), ``area`` (physical volume), ``extent``, ``equivalent_diameter_area``,
277        ``axis_major_length``, ``axis_minor_length``, ``centroid_<axis>`` (corrected, physical),
278        ``bb_min_<axis>``/``bb_max_<axis>`` (global voxels), and ``surface_area`` (only when
279        ``compute_surface`` and the input is 3D).
280    """
281    check_rerun_args(job_type, resume_from, item_ids, subset_name="item_ids")
282    src = as_source(input)
283    if not np.issubdtype(np.dtype(src.dtype), np.integer):
284        raise ValueError(f"regionprops expects an integer label image, got dtype {src.dtype}.")
285    ndim = src.ndim
286    axes = _axis_names(ndim)
287
288    if resolution is None:
289        resolution = tuple(1.0 for _ in range(ndim))
290    else:
291        resolution = tuple(float(r) for r in resolution)
292        if len(resolution) != ndim:
293            raise ValueError(f"resolution {resolution} does not match the input ndim {ndim}.")
294
295    df = _load_table(table)
296    missing = [c for c in _required_columns(axes) if c not in df.columns]
297    if missing:
298        raise ValueError(
299            f"table is missing required columns {missing}; pass the output of "
300            "bioimage_py.morphology.morphology (label / com_* / bb_min_* / bb_max_*)."
301        )
302
303    n = len(df)
304    if n == 0:
305        cols = _order_columns(axes, compute_surface, ndim)
306        return pd.DataFrame({c: pd.Series(dtype="float64") for c in cols})
307
308    seg_arg: Any = src
309    if job_type != "local":
310        try:
311            seg_arg = src.to_spec()
312        except ValueError as err:
313            raise ValueError(
314                f"Distributed regionprops requires a file-backed (zarr/n5) segmentation. {err}"
315            ) from err
316
317    # The consumed columns travel with the closure as numpy arrays (built once): for distributed
318    # backends they are cloudpickled into the single shared payload the workers read.
319    ctx = {
320        "seg": seg_arg, "resolution": resolution, "axes": tuple(axes), "ndim": ndim,
321        "compute_surface": bool(compute_surface), **_column_arrays(df, axes),
322    }
323    runner = get_runner(job_type, job_config)
324    results = runner.map(functools.partial(_object_features, ctx=ctx), n,
325                         item_ids=item_ids, resume_from=resume_from,
326                         num_workers=num_workers, has_return_val=True, name="regionprops",
327                         pre_cleanup=pre_cleanup)
328
329    out = pd.DataFrame(results)
330    out = out[_order_columns(axes, compute_surface, ndim)].sort_values("label")
331    out = out.reset_index(drop=True)
332    if output_path is not None:
333        _write_table(out, output_path)
334    return out

Compute per-object morphology features for a labeled volume, one task per object.

For each object listed in table (the output of morphology()), the sub-volume is cropped by its bounding box, masked to the label, and described with numpy in physical units (via resolution): the physical volume area, the extent (filled fraction of the bounding box), the equivalent_diameter_area (diameter of the ball with the same volume) and the major/minor axis_*_length (from the object's second moments). These reproduce the corresponding scikit-image regionprops definitions exactly. Optionally a marching-cubes surface_area (3D only) and a corrected centroid (the center-of-mass when it lies inside the object, otherwise the deepest-interior voxel — the argmax of the Euclidean distance transform) are added.

Args: input: The labeled segmentation (a numpy/zarr/n5 array or a Source); integer-typed. For the subprocess/slurm backends it must be file-backed (zarr/n5). table: The base morphology table — a pandas DataFrame or a path to a .csv / .xlsx file. Must contain label, com_<axis>, bb_min_<axis> and bb_max_<axis> (bb_max is the exclusive slice stop, as produced by morphology()). resolution: Per-axis physical voxel size in array (e.g. z, y, x) order. Defaults to ones (voxel units). compute_surface: Whether to add a marching-cubes surface_area (3D inputs only). This is the most expensive per-object step, so it is off by default. output_path: Optional .csv / .xlsx path to also write the result to. num_workers: Number of parallel workers (threads for local, tasks for distributed backends). job_type: Execution backend: one of "local", "subprocess" or "slurm". job_config: Backend configuration (a RunnerConfig / SlurmConfig). item_ids: Restrict processing to these item indices (rows of table), e.g. to re-run previously failed objects; the result then covers only those rows. An item id indexes a row of table, so the same table (same row order) must be passed as in the original run. Mutually exclusive with resume_from. resume_from: Distributed only; the preserved temp folder of a failed run to resume and merge (see runner.run). The result then covers all objects (the already-completed merged with the re-run ones). The recommended rerun path for regionprops. Mutually exclusive with item_ids. pre_cleanup: Optional pre_cleanup(tmp_folder) callback invoked on the orchestrating process with the job temp folder right before it is deleted (distributed backends only). Use it to read out the per-task timing files under tmp_folder/timings/ before cleanup. Ignored for the local backend (no temp folder).

Returns: A pandas DataFrame with one row per object, sorted by label: label, n_voxels (raw voxel count), area (physical volume), extent, equivalent_diameter_area, axis_major_length, axis_minor_length, centroid_<axis> (corrected, physical), bb_min_<axis>/bb_max_<axis> (global voxels), and surface_area (only when compute_surface and the input is 3D).