synapse_net.cristae_analysis
1import multiprocessing as mp 2import os 3from concurrent import futures 4from typing import Callable, Dict, Optional, Tuple, Union 5 6import numpy as np 7import pandas as pd 8from scipy.ndimage import binary_erosion, center_of_mass 9from scipy.ndimage import label as ndimage_label 10from skimage.measure import mesh_surface_area, regionprops 11from skimage.morphology import disk, local_maxima 12from tqdm import tqdm 13 14from bioimage_cpp.distance import distance_transform, geodesic_distances_mesh 15from bioimage_cpp.filters import structure_tensor_eigenvalues 16from bioimage_cpp.mesh import marching_cubes 17 18 19# --------------------------------------------------------------------------- 20# Internal helpers 21# --------------------------------------------------------------------------- 22 23def _to_sampling(voxel_size: Union[float, Dict[str, float]], ndim: int) -> np.ndarray: 24 axes = ("z", "y", "x") if ndim == 3 else ("y", "x") 25 if isinstance(voxel_size, dict): 26 return np.array([voxel_size[ax] for ax in axes[:ndim]], dtype=float) 27 return np.full(ndim, float(voxel_size)) 28 29 30def _voxel_radius(thickness_nm: float, voxel_size: Union[float, Dict[str, float]], ndim: int) -> int: 31 return max(1, int(round(thickness_nm / float(np.mean(_to_sampling(voxel_size, ndim)))))) 32 33 34def _voxel_radius_xy(thickness_nm: float, voxel_size: Union[float, Dict[str, float]]) -> int: 35 """Membrane radius in XY pixels — uses only the Y and X voxel sizes.""" 36 if isinstance(voxel_size, dict): 37 xy_nm = (voxel_size["y"] + voxel_size["x"]) / 2.0 38 else: 39 xy_nm = float(voxel_size) 40 return max(1, int(round(thickness_nm / xy_nm))) 41 42 43def _gap_radius( 44 voxel_size: Union[float, Dict[str, float]], 45 membrane_thickness_nm: float, 46 border_gap_nm: Optional[float], 47 ndim: int, 48) -> int: 49 """Border-zone / mesh-trim radius in voxels; ``border_gap_nm`` defaults to ``membrane_thickness_nm``.""" 50 gap_nm = border_gap_nm if border_gap_nm is not None else membrane_thickness_nm 51 return _voxel_radius(gap_nm, voxel_size, ndim) 52 53 54def _border_zone(shape: tuple, radius: int) -> np.ndarray: 55 """Boolean mask that is True within `radius` voxels of any face of the volume.""" 56 mask = np.zeros(shape, dtype=bool) 57 for ax in range(len(shape)): 58 idx_lo = [slice(None)] * len(shape) 59 idx_hi = [slice(None)] * len(shape) 60 idx_lo[ax] = slice(0, radius) 61 idx_hi[ax] = slice(shape[ax] - radius, None) 62 mask[tuple(idx_lo)] = True 63 mask[tuple(idx_hi)] = True 64 return mask 65 66 67def _surface_mesh( 68 mask: np.ndarray, 69 sampling: np.ndarray, 70 closed_faces: Optional[np.ndarray] = None, 71) -> Optional[Tuple[np.ndarray, np.ndarray]]: 72 """Triangle-mesh surface of a binary mask via marching cubes. 73 74 Each side of the array is padded by one background voxel before meshing so that objects touching 75 the array edge yield a closed surface there. ``marching_cubes`` only emits triangles for cube 76 cells that exist inside the array, so leaving a side *unpadded* omits that boundary face — an open 77 mesh at that face — while interior surfaces still close. ``closed_faces`` chooses this per side: 78 pad+close a face where there is genuine background beyond it, leave open a face where the object 79 is clipped by the volume boundary (membrane presence unknown there). 80 81 Vertices are returned in the mask's own **unpadded** index frame (nm): a mask voxel at array index 82 ``(z, y, x)`` maps to physical coordinates ``index * sampling`` regardless of the padding, so 83 callers snapping voxel coordinates onto the mesh use ``index * sampling`` with no offset. 84 85 Args: 86 mask: Binary segmentation. 87 sampling: Voxel size per axis (nm), in array (z, y, x) order. 88 closed_faces: Optional ``(ndim, 2)`` boolean array; ``closed_faces[a, s]`` True closes 89 (pads) side ``s`` (0 = low, 1 = high) of axis ``a``, False leaves it open. Defaults to 90 all True (fully closed watertight surface). 91 92 Returns: 93 (vertices, faces) with vertices in nm (unpadded mask frame), or None if the mask is empty. 94 """ 95 binary = mask.astype(bool) 96 if not binary.any(): 97 return None 98 if closed_faces is None: 99 closed_faces = np.ones((binary.ndim, 2), dtype=bool) 100 else: 101 closed_faces = np.asarray(closed_faces, dtype=bool) 102 pad_width = [(int(closed_faces[a, 0]), int(closed_faces[a, 1])) for a in range(binary.ndim)] 103 padded = np.pad(binary.astype(np.float32), pad_width) 104 verts, faces, _, _ = marching_cubes(padded, level=0.5, spacing=tuple(float(s) for s in sampling)) 105 pad_before = np.array([pw[0] for pw in pad_width], dtype=float) 106 verts = verts - pad_before * np.asarray(sampling, dtype=float) 107 return verts, faces 108 109 110def _surface_area( 111 mask: np.ndarray, 112 sampling: np.ndarray, 113 closed_faces: Optional[np.ndarray] = None, 114) -> float: 115 """Surface area (nm^2) of a binary mask via marching cubes. 116 117 Args: 118 mask: Binary segmentation. 119 sampling: Voxel size per axis (nm), in array (z, y, x) order. 120 closed_faces: Optional per-side padding spec forwarded to :func:`_surface_mesh` — leave a 121 clipped volume-boundary face open so its fabricated cap is not counted as surface area. 122 Defaults to a fully closed (watertight) surface. 123 124 Returns: 125 Surface area in nm^2, or NaN if the mask is empty. 126 """ 127 mesh = _surface_mesh(mask, sampling, closed_faces=closed_faces) 128 if mesh is None: 129 return np.nan 130 return float(mesh_surface_area(*mesh)) 131 132 133def _open_trimmed_mesh( 134 mask: np.ndarray, 135 sampling: np.ndarray, 136 gap_radius: int, 137 boundary: np.ndarray, 138) -> Optional[Tuple[np.ndarray, np.ndarray]]: 139 """Surface mesh of ``mask`` trimmed to the certain region and left OPEN at volume-boundary faces. 140 141 Near a clipped volume face the segmentation is cut off and membrane presence is unknown, so the 142 surface must neither flare into that region nor be capped there — a cap is a fabricated flat disk 143 that lets geodesics shortcut straight across instead of wrapping around the tube wall. For each 144 ``(axis, side)`` flagged True in ``boundary`` (an ``(ndim, 2)`` bool of volume-boundary faces), 145 ``gap_radius`` voxels are cropped off that face so the trim plane becomes an array boundary, and 146 that face is then left open (marching cubes omits it). Interior faces stay closed. 147 148 Args: 149 mask: Binary segmentation. 150 sampling: Voxel size per axis (nm), in array (z, y, x) order. 151 gap_radius: Border-zone width in voxels cropped off each flagged face (matches the membrane's 152 border-gap trim). 153 boundary: ``(ndim, 2)`` bool; True where the face is at the volume boundary (crop + open). 154 155 Returns: 156 (vertices, faces) with vertices in nm in the original (uncropped) ``mask`` index frame, or None 157 if the trimmed mask is empty. 158 """ 159 ndim = mask.ndim 160 boundary = np.asarray(boundary, dtype=bool) 161 lo = [gap_radius if boundary[a, 0] else 0 for a in range(ndim)] 162 hi = [mask.shape[a] - (gap_radius if boundary[a, 1] else 0) for a in range(ndim)] 163 mesh = _surface_mesh( 164 mask[tuple(slice(lo[a], hi[a]) for a in range(ndim))], sampling, closed_faces=~boundary 165 ) 166 if mesh is None: 167 return None 168 verts, faces = mesh 169 verts = verts + np.array(lo, dtype=float) * np.asarray(sampling, dtype=float) 170 return verts, faces 171 172 173def _medial_axis_thickness_nm(mask: np.ndarray, sampling: np.ndarray) -> float: 174 """Local thickness (nm) of a mask via the distance transform, with no mesh generation. 175 176 The medial axis is approximated by the local maxima of the interior EDT; the thickness is 177 ``2 × mean(EDT)`` there (the EDT at the medial axis is the half-thickness). This is the same 178 estimator used by :func:`compute_crista_morphology`'s ``medial_axis`` branch, factored out so 179 the distance-based (``method="fast"``) surface-area estimates can reuse it. 180 181 Args: 182 mask: Binary segmentation. 183 sampling: Voxel size per axis (nm), in array (z, y, x) order. 184 185 Returns: 186 Mean local thickness in nm, or NaN if the mask is empty. 187 """ 188 binary = mask.astype(bool) 189 if not binary.any(): 190 return np.nan 191 dist = distance_transform(binary, sampling=tuple(float(s) for s in sampling), number_of_threads=1) 192 ridges = local_maxima(dist) & binary 193 ridge_dists = dist[ridges] 194 return float(2.0 * np.mean(ridge_dists)) if ridge_dists.size > 0 else np.nan 195 196 197def _available_memory_bytes() -> int: 198 """Best-effort available RAM in bytes (used to keep parallel working sets from OOMing).""" 199 try: 200 import psutil 201 return int(psutil.virtual_memory().available) 202 except Exception: 203 try: 204 return int(os.sysconf("SC_AVPHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")) 205 except Exception: 206 return 4 * 1024 ** 3 207 208 209def _bounded_workers(n_jobs: int, per_worker_bytes: int, fraction: float = 0.5) -> int: 210 """Resolve n_jobs to a worker count whose combined working set fits in memory. 211 212 n_jobs: 1 = serial, -1 = all cores, else that many. The result is additionally capped so 213 ``workers * per_worker_bytes <= fraction * available_RAM`` (at least 1). 214 """ 215 workers = os.cpu_count() if n_jobs == -1 else max(1, int(n_jobs)) 216 if per_worker_bytes > 0: 217 budget = int(_available_memory_bytes() * fraction) 218 workers = min(workers, max(1, budget // int(per_worker_bytes))) 219 return int(max(1, workers)) 220 221 222# --------------------------------------------------------------------------- 223# Membrane approximation 224# --------------------------------------------------------------------------- 225 226def approximate_membrane( 227 mito_segmentation: np.ndarray, 228 voxel_size: Union[float, Dict[str, float]], 229 membrane_thickness_nm: float = 8.0, 230 border_gap_nm: Optional[float] = None, 231 n_jobs: int = 1, 232 membrane_mode: str = "slice_2d", 233 return_lumen: bool = False, 234) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]: 235 """Approximate the mitochondrial membrane as the outer shell of the segmentation. 236 237 Two shell constructions are available via ``membrane_mode``: 238 239 - ``"slice_2d"`` (default): erode each Z-slice **independently in 2D** by an XY disk of radius 240 ``round(thickness / xy_voxel)`` and keep ``slice & ~eroded``. A mitochondrion that changes shape 241 rapidly along Z does not bleed into neighbouring slices, and the per-slice erosions are 242 parallelised over Z (``n_jobs``). A separable Z-only erosion (radius ``round(thickness / 243 z_voxel)``, no XY coupling) then adds **Z-caps** where a mito column truly ends in Z; ends 244 clipped by a volume Z-face are left uncapped (``border_value=1`` + the ``border_gap`` trim). The 245 XY shell can still fragment across slices. (2D inputs get a single 2D erosion.) 246 - ``"shell_3d"``: a full 3D morphological erosion, ``mito & ~erode3d(mito, k)`` with 247 ``k = round(thickness / mean_voxel)`` iterations of a 3×3×3 structuring element, per instance on 248 its padded bounding box. A single **connected** shell including the Z-caps (no per-slice 249 fragmentation), at a higher cost; thickness acts in all axes. 250 251 The eroded interior is the "lumen"; its surface is the single-wall mesh used by the geodesic 252 backend and the display, so the mesh follows the chosen mode. 253 254 Membrane voxels within ``border_gap_nm`` of any volume face are removed so clipped mito edges are 255 not treated as membrane. The lumen is NOT trimmed here — the mesh is trimmed to the certain region 256 (and left open there) at mesh time by :func:`_open_trimmed_mesh`, which requires the untrimmed 257 interior to produce an open cut rather than a fabricated cap. 258 259 Implementation notes: ``"slice_2d"`` erodes each Z-slice on the mito XY bbox with a 260 ``membrane_radius`` margin, so the cropped ``border_value=1`` erosion matches eroding the full 261 slice (empty slices are skipped), then adds Z-caps via a separable Z-only line erosion (which 262 inspects only the same column, so no XY-shape bleed); ``border_value=1`` leaves ends clipped by a 263 volume Z-face uncapped, and the ``border_gap`` removal clears anything near a face, so only true 264 ends are capped. ``"shell_3d"`` erodes the *merged* binary in each instance's padded bbox so 265 instances that share a boundary are handled together. 266 267 Args: 268 mito_segmentation: Instance label array (background = 0). 269 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 270 membrane_thickness_nm: Thickness of the membrane shell in nm. 271 border_gap_nm: Distance from each volume face within which membrane voxels are 272 suppressed. Defaults to membrane_thickness_nm when None. 273 n_jobs: Workers for the per-Z-slice erosion (``"slice_2d"`` only): 1 = serial, -1 = all cores. 274 Results are identical regardless of n_jobs. 275 membrane_mode: ``"slice_2d"`` (default, per-slice 2D, z-parallel) or ``"shell_3d"`` 276 (connected 3D shell). 277 return_lumen: If True, also return the eroded-mito interior ("lumen") mask — the single-wall 278 surface source for the geodesic/display mesh. It is the plain eroded interior (not 279 ``mito & ~membrane``, which would re-include the outer shell where the membrane is 280 border-trimmed); trimming to the certain region happens at mesh time. 281 282 Returns: 283 membrane_mask: Binary mask of the mitochondrial membrane (outer shell), with border-adjacent 284 voxels zeroed out. If ``return_lumen`` is True, returns ``(membrane_mask, lumen_mask)`` 285 where ``lumen_mask`` is the (untrimmed) eroded interior described above. 286 """ 287 if membrane_mode not in ("slice_2d", "shell_3d"): 288 raise ValueError(f"membrane_mode must be 'slice_2d' or 'shell_3d', got {membrane_mode!r}") 289 ndim = mito_segmentation.ndim 290 mito_binary = mito_segmentation > 0 291 292 # NOTE (possible simplification, deferred): the "shell_3d" branch below builds the shell with an 293 # iterated 3x3x3 erosion per instance. It could likely be a single anisotropic distance transform 294 # instead — membrane = mito & (distance_transform(mito, sampling) <= thickness), lumen = the rest — 295 # which is simpler and handles anisotropy directly. This would NOT replace "slice_2d": a distance 296 # transform couples all axes, so it cannot reproduce slice_2d's per-Z-slice-independent erosion, 297 # whose whole purpose is to stop the shell bleeding across slices in XY. Worth investigating. 298 if membrane_mode == "shell_3d": 299 sampling = _to_sampling(voxel_size, ndim) 300 k = max(1, int(round(float(membrane_thickness_nm) / float(np.mean(sampling))))) 301 struct = np.ones((3,) * ndim, dtype=bool) 302 membrane_mask = np.zeros(mito_segmentation.shape, dtype=bool) 303 lumen_mask = np.zeros(mito_segmentation.shape, dtype=bool) 304 for prop in regionprops(mito_segmentation): 305 bbox = prop.bbox 306 sl = tuple( 307 slice(max(0, bbox[i] - k), min(mito_segmentation.shape[i], bbox[i + ndim] + k)) 308 for i in range(ndim) 309 ) 310 sub = mito_binary[sl] 311 eroded = binary_erosion(sub, structure=struct, iterations=k, border_value=1) 312 cur = mito_segmentation[sl] == prop.label 313 membrane_mask[sl] |= cur & ~eroded 314 lumen_mask[sl] |= cur & eroded 315 elif ndim == 3: 316 membrane_radius = _voxel_radius_xy(membrane_thickness_nm, voxel_size) 317 struct = disk(membrane_radius) 318 membrane_mask = np.zeros_like(mito_binary) 319 lumen_mask = np.zeros_like(mito_binary) 320 coords = np.argwhere(mito_binary) 321 if coords.size: 322 zmin, ymin, xmin = coords.min(axis=0) 323 zmax, ymax, xmax = coords.max(axis=0) + 1 324 m = membrane_radius 325 y0, y1 = max(0, ymin - m), min(mito_binary.shape[1], ymax + m) 326 x0, x1 = max(0, xmin - m), min(mito_binary.shape[2], xmax + m) 327 328 def _erode_slice(z): 329 sl = mito_binary[z, y0:y1, x0:x1] 330 if not sl.any(): 331 return z, None 332 eroded = binary_erosion(sl, structure=struct, border_value=1) 333 return z, (sl & ~eroded, eroded) 334 335 z_range = range(int(zmin), int(zmax)) 336 if n_jobs == 1: 337 results = [_erode_slice(z) for z in z_range] 338 else: 339 n_workers = mp.cpu_count() if n_jobs == -1 else n_jobs 340 with futures.ThreadPoolExecutor(n_workers) as tp: 341 results = list(tp.map(_erode_slice, z_range)) 342 for z, res in results: 343 if res is not None: 344 mem_sl, lum_sl = res 345 membrane_mask[z, y0:y1, x0:x1] = mem_sl 346 lumen_mask[z, y0:y1, x0:x1] = lum_sl 347 348 k_z = max(1, int(round(float(membrane_thickness_nm) / float(_to_sampling(voxel_size, ndim)[0])))) 349 z0m, z1m = max(0, int(zmin) - k_z), min(mito_binary.shape[0], int(zmax) + k_z) 350 sub = mito_binary[z0m:z1m, y0:y1, x0:x1] 351 z_eroded = binary_erosion(sub, structure=np.ones((2 * k_z + 1, 1, 1), dtype=bool), border_value=1) 352 membrane_mask[z0m:z1m, y0:y1, x0:x1] |= sub & ~z_eroded 353 lumen_mask[z0m:z1m, y0:y1, x0:x1] &= z_eroded 354 else: 355 membrane_radius = _voxel_radius(membrane_thickness_nm, voxel_size, ndim) 356 eroded = binary_erosion(mito_binary, structure=disk(membrane_radius), border_value=1) 357 membrane_mask = mito_binary & ~eroded 358 lumen_mask = mito_binary & eroded 359 360 gap_radius = _gap_radius(voxel_size, membrane_thickness_nm, border_gap_nm, ndim) 361 membrane_mask &= ~_border_zone(mito_segmentation.shape, gap_radius) 362 if return_lumen: 363 return membrane_mask.astype(bool), lumen_mask.astype(bool) 364 return membrane_mask.astype(bool) 365 366 367# --------------------------------------------------------------------------- 368# Orientation 369# --------------------------------------------------------------------------- 370 371def compute_crista_orientation( 372 crista_mask: np.ndarray, 373 voxel_size: Union[float, Dict[str, float]], 374 neighborhood_size_nm: float = 30.0, 375) -> np.ndarray: 376 """Compute the per-voxel crista orientation anisotropy via the structure tensor. 377 378 Uses ``bioimage_cpp.filters.structure_tensor_eigenvalues`` (a fast C++ routine). Only the 379 anisotropy is produced (the principal directions / eigenvectors are not computed). 380 381 The structure tensor's outer/integration sigma is ``neighborhood_size_nm`` per axis (in voxels); 382 the inner (derivative) sigma must be > 0, so a minimal 1-voxel scale is used. Eigenvalues are 383 non-negative in theory, but the solver emits tiny negatives for near-rank-deficient tensors 384 (degenerate sheets/tubes), so they are clamped to 0 and the ratio is taken as 385 ``max/min`` over the trailing axis — order-agnostic and sign-safe, so a tiny negative minor 386 eigenvalue cannot flip the denominator and blow the ratio up. 387 388 Args: 389 crista_mask: Binary crista segmentation. 390 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 391 neighborhood_size_nm: Gaussian integration radius in nm for tensor averaging (the 392 structure tensor's outer/integration scale). 393 394 Returns: 395 anisotropy: (...) — λ_max / (λ_min + ε) per voxel. High values indicate a strongly 396 directional crista (e.g. parallel lamellae); low values indicate isotropic or 397 tubular/disordered morphology. Magnitude only (rotation-invariant). 398 """ 399 ndim = crista_mask.ndim 400 sampling = _to_sampling(voxel_size, ndim) 401 402 outer_sigma = [float(s) for s in (neighborhood_size_nm / sampling)] 403 inner_sigma = 1.0 404 evals = structure_tensor_eigenvalues(crista_mask.astype(np.float32), inner_sigma, outer_sigma) 405 evals = np.clip(evals, 0.0, None) 406 anisotropy = evals.max(axis=-1) / (evals.min(axis=-1) + 1e-10) 407 return anisotropy.astype(np.float32) 408 409 410def _scale_voxel_size( 411 voxel_size: Union[float, Dict[str, float]], factor: float 412) -> Union[float, Dict[str, float]]: 413 """Multiply a voxel size (scalar or z/y/x dict) by ``factor``, preserving its type.""" 414 if isinstance(voxel_size, dict): 415 return {ax: voxel_size[ax] * factor for ax in voxel_size} 416 return float(voxel_size) * factor 417 418 419def _downsampled_orientation_anisotropy( 420 crista_mask: np.ndarray, 421 voxel_size: Union[float, Dict[str, float]], 422 factor: int = 2, 423) -> float: 424 """Mean crista orientation anisotropy computed on a downsampled crop (fast, approximate). 425 426 The crista mask is **nearest-neighbour** downsampled by ``factor`` per axis (strided subsampling, 427 i.e. every ``factor``-th voxel), and :func:`compute_crista_orientation` is run on that coarser grid 428 at the correspondingly scaled voxel size (so the physical structure-tensor neighbourhood is 429 unchanged). This is ~``factor**ndim`` times cheaper than the full-resolution structure tensor — the 430 dominant cost of the analysis. Nearest-neighbour keeps the segmentation binary; block-mean 431 (``downscale_local_mean``) would blur it into meaningless partial-occupancy values. 432 433 Because thin cristae (only a few voxels across) lose structure when downsampled, the returned 434 anisotropy is a *relative* indicator only: it preserves the ordering between mitochondria but is 435 not comparable in magnitude to the full-resolution ``method="exact"`` value. 436 437 Args: 438 crista_mask: Binary crista segmentation. 439 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 440 factor: Integer downsampling factor per axis. 441 442 Returns: 443 Mean anisotropy over the downsampled crista region, or NaN if it vanishes when downsampled. 444 """ 445 ndim = crista_mask.ndim 446 sub = crista_mask[(slice(None, None, factor),) * ndim] 447 region = sub.astype(bool) 448 if not region.any(): 449 return np.nan 450 anisotropy = compute_crista_orientation(sub, _scale_voxel_size(voxel_size, factor)) 451 return float(np.mean(anisotropy[region])) 452 453 454# --------------------------------------------------------------------------- 455# Proximity 456# --------------------------------------------------------------------------- 457 458def compute_crista_proximity( 459 crista_mask: np.ndarray, 460 membrane_mask: np.ndarray, 461 voxel_size: Union[float, Dict[str, float]], 462 membrane_distance: Optional[np.ndarray] = None, 463) -> Tuple[np.ndarray, Dict[str, float]]: 464 """Distance from each crista voxel to the nearest membrane voxel (nm). 465 466 Args: 467 crista_mask: Binary crista segmentation. 468 membrane_mask: Binary membrane mask (OM or IMM). 469 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 470 membrane_distance: Optional precomputed per-voxel distance to the nearest membrane 471 voxel (nm), i.e. ``distance_transform(~membrane_mask, sampling=...)``. When 472 given, the distance transform is not recomputed (used to avoid redundant work in 473 :func:`compute_mito_crista_statistics`). 474 475 Returns: 476 distance_map: Per-voxel distance to membrane (nm); zero outside crista. 477 summary_stats: min_nm, median_nm, max_nm. 478 """ 479 sampling = _to_sampling(voxel_size, crista_mask.ndim) 480 if membrane_distance is None: 481 dist = distance_transform(~membrane_mask.astype(bool), sampling=sampling.tolist(), number_of_threads=1) 482 else: 483 dist = membrane_distance 484 crista_dists = dist[crista_mask.astype(bool)] 485 486 if crista_dists.size == 0: 487 summary: Dict[str, float] = {"min_nm": np.nan, "median_nm": np.nan, "max_nm": np.nan} 488 else: 489 summary = { 490 "min_nm": float(crista_dists.min()), 491 "median_nm": float(np.median(crista_dists)), 492 "max_nm": float(crista_dists.max()), 493 } 494 495 distance_map = np.zeros(crista_mask.shape, dtype=np.float32) 496 distance_map[crista_mask.astype(bool)] = crista_dists 497 return distance_map, summary 498 499 500# --------------------------------------------------------------------------- 501# Contact sites 502# --------------------------------------------------------------------------- 503 504def detect_contact_sites( 505 crista_mask: np.ndarray, 506 membrane_mask: np.ndarray, 507 voxel_size: Union[float, Dict[str, float]], 508) -> Tuple[np.ndarray, Dict[str, float]]: 509 """Detect crista-membrane contact sites as the direct overlap of the two masks. 510 511 Contact = crista voxels that are also membrane voxels (the pure intersection of the crista 512 mask and the mitochondrial membrane band). No dilation/erosion is applied here, so the 513 detected junctions correspond exactly to the visible overlap of the two layers; connected 514 overlaps are grouped into junctions with 26-connectivity in 3D. 515 516 Args: 517 crista_mask: Binary crista segmentation. 518 membrane_mask: Binary mitochondrial membrane mask (as displayed). 519 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 520 521 Returns: 522 contact_labels: Integer array (same shape as the input) where each connected 523 junction has a unique ID (0 = background, 1..n = junctions). Contact voxel 524 coordinates are recoverable via ``np.argwhere(contact_labels > 0)``. 525 summary: contact_voxel_count, crista_junction_count, contact_volume_nm3. 526 """ 527 ndim = crista_mask.ndim 528 sampling = _to_sampling(voxel_size, ndim) 529 voxel_vol = float(np.prod(sampling)) 530 531 contact_mask = crista_mask.astype(bool) & membrane_mask.astype(bool) 532 533 connectivity_struct = np.ones(ndim * (3,), dtype=bool) 534 contact_labels, n_regions = ndimage_label(contact_mask, structure=connectivity_struct) 535 contact_voxel_count = int(np.count_nonzero(contact_labels)) 536 537 return contact_labels, { 538 "contact_voxel_count": contact_voxel_count, 539 "crista_junction_count": int(n_regions), 540 "contact_volume_nm3": float(contact_voxel_count) * voxel_vol, 541 } 542 543 544_JUNCTION_DISTANCE_NAN = { 545 "junction_count": 0, 546 "mean_nn_junction_distance_nm": np.nan, 547 "median_nn_junction_distance_nm": np.nan, 548 "junction_clustering_index": np.nan, 549} 550 551 552def _junction_matrix_mesh( 553 centroids: np.ndarray, 554 sampling: np.ndarray, 555 vertices: np.ndarray, 556 faces: np.ndarray, 557 n_jobs: int = 1, 558) -> Optional[np.ndarray]: 559 """Pairwise junction geodesic distances (nm) along a triangle-mesh surface. 560 561 Snaps each junction centroid to its nearest mesh vertex and calls 562 ``bioimage_cpp.distance.geodesic_distances_mesh`` (passing all junction vertices as sources, so it 563 returns the full pairwise matrix directly). The mesh (see :func:`_surface_mesh`) returns vertices 564 in the unpadded mask index frame, so voxel centroids map to it as ``index * sampling`` with no 565 offset. Returns None (junction distances become NaN) when the mesh is empty. 566 567 Args: 568 centroids: (n, ndim) junction centroids in voxel coordinates. 569 sampling: Voxel size per axis (nm), array (z, y, x) order. 570 vertices: Mesh vertices (n_vertices, 3) in nm (unpadded mask frame). 571 faces: Mesh triangle indices (n_faces, 3). 572 n_jobs: Forwarded to the C++ solver's ``number_of_threads``: -1/0 map to 0 (the solver's 573 "use hardware_concurrency"), otherwise that many threads. 574 575 Returns: 576 (n, n) geodesic distance matrix in nm (0 diagonal, NaN for disconnected pairs), or None. 577 Disconnected pairs come back from the solver as ``+inf`` and are converted to NaN. 578 """ 579 verts = np.ascontiguousarray(vertices, dtype=np.float64) 580 tris = np.ascontiguousarray(faces, dtype=np.int64) 581 if verts.shape[0] == 0 or tris.shape[0] == 0: 582 return None 583 from scipy.spatial import cKDTree 584 585 points = np.asarray(centroids, dtype=float) * sampling 586 _, vertex_ids = cKDTree(verts).query(points) 587 vertex_ids = np.atleast_1d(np.asarray(vertex_ids, dtype=np.int64)) 588 n_threads = 0 if n_jobs in (-1, 0) else max(1, int(n_jobs)) 589 dm = np.asarray( 590 geodesic_distances_mesh(verts, tris, vertex_ids, number_of_threads=n_threads), dtype=float 591 ) 592 dm[~np.isfinite(dm)] = np.nan 593 np.fill_diagonal(dm, 0.0) 594 return dm 595 596 597def compute_junction_distances( 598 contact_labels: np.ndarray, 599 membrane_mask: np.ndarray, 600 voxel_size: Union[float, Dict[str, float]], 601 surface_area_nm2: Optional[float] = None, 602 n_jobs: int = 1, 603 mesh_vertices: Optional[np.ndarray] = None, 604 mesh_faces: Optional[np.ndarray] = None, 605) -> Tuple[np.ndarray, Dict[str, float]]: 606 """Geodesic distances between crista-membrane junctions along the eroded-mito surface mesh. 607 608 Each junction (a connected component in ``contact_labels``) is reduced to its centroid, snapped to 609 the nearest vertex of a triangle mesh, and pairwise surface geodesics are computed with 610 ``bioimage_cpp.distance.geodesic_distances_mesh``. The mesh is the **eroded-mito (lumen) surface** 611 passed in as ``mesh_vertices``/``mesh_faces`` by :func:`_single_mito_row` (a clean, single-wall 612 surface at the membrane's inner edge). If no mesh is supplied — or no usable surface mesh exists 613 (empty membrane / degenerate mesh) — the junction distances are NaN. (There is no membrane-band 614 fallback mesh: the metric is defined on the lumen surface, and meshing the thick membrane band 615 would give a different, capped double-wall surface.) 616 617 A Clark-Evans nearest-neighbour index summarises whether the junctions are clustered. 618 619 Args: 620 contact_labels: Integer junction label array (0 = background, 1..n = junctions), 621 e.g. the first return value of :func:`detect_contact_sites`. 622 membrane_mask: Binary mitochondrial membrane mask the junctions sit on. Only used for the 623 empty-membrane early-out (no membrane → NaN); it is not meshed. 624 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 625 surface_area_nm2: Membrane/mito surface area used as the reference area for the 626 Clark-Evans expectation. If None or non-positive, the clustering index is NaN. 627 n_jobs: 1 = serial, -1 = all cores (forwarded to the mesh solver's thread count). 628 mesh_vertices: Optional (n_vertices, 3) mesh vertices in nm (unpadded mask frame; see 629 :func:`_surface_mesh`) — the eroded-mito (lumen) surface. If omitted, the junction 630 distances are NaN. 631 mesh_faces: Optional (n_faces, 3) triangle indices matching ``mesh_vertices``. 632 633 Returns: 634 distance_matrix: (n, n) geodesic distances in nm between junctions; the diagonal is 635 0 and unreachable pairs (disconnected fragments) are NaN. Empty for fewer than two 636 junctions. 637 summary: junction_count, mean_nn_junction_distance_nm, median_nn_junction_distance_nm, 638 junction_clustering_index (Clark-Evans R: < 1 clustered, ~ 1 random, > 1 dispersed). 639 640 Notes: 641 The Clark-Evans expected nearest-neighbour distance uses the standard 2-D planar 642 approximation ``0.5 * sqrt(A / n)`` with ``A = surface_area_nm2``. 643 644 Each junction's nearest-neighbour distance is the smallest distance to a *reachable* other 645 junction: self (diagonal) and unreachable (NaN) pairs are set to +inf before the per-row 646 minimum, and rows with no reachable neighbour (min stays +inf) are dropped. 647 """ 648 ndim = contact_labels.ndim 649 sampling = _to_sampling(voxel_size, ndim) 650 membrane = membrane_mask.astype(bool) 651 652 labels = [lbl for lbl in np.unique(contact_labels) if lbl != 0] 653 n = len(labels) 654 if n < 2 or not membrane.any(): 655 summary = dict(_JUNCTION_DISTANCE_NAN) 656 summary["junction_count"] = n 657 return np.zeros((n, n), dtype=float), summary 658 659 centroids = np.atleast_2d( 660 np.asarray(center_of_mass(contact_labels > 0, labels=contact_labels, index=labels), dtype=float) 661 ) 662 663 if mesh_vertices is not None and mesh_faces is not None and len(mesh_faces) > 0: 664 distance_matrix = _junction_matrix_mesh(centroids, sampling, mesh_vertices, mesh_faces, n_jobs) 665 else: 666 distance_matrix = None 667 668 if distance_matrix is None: 669 summary = dict(_JUNCTION_DISTANCE_NAN) 670 summary["junction_count"] = n 671 return np.full((n, n), np.nan, dtype=float), summary 672 673 dm = distance_matrix.copy() 674 np.fill_diagonal(dm, np.inf) 675 dm[~np.isfinite(dm)] = np.inf 676 row_min = dm.min(axis=1) 677 nn_distances = row_min[np.isfinite(row_min)] 678 679 mean_nn = float(np.mean(nn_distances)) if nn_distances.size else np.nan 680 median_nn = float(np.median(nn_distances)) if nn_distances.size else np.nan 681 682 clustering_index = np.nan 683 if surface_area_nm2 is not None and surface_area_nm2 > 0 and np.isfinite(mean_nn): 684 expected_nn = 0.5 * np.sqrt(float(surface_area_nm2) / n) 685 if expected_nn > 0: 686 clustering_index = mean_nn / expected_nn 687 688 return distance_matrix, { 689 "junction_count": n, 690 "mean_nn_junction_distance_nm": mean_nn, 691 "median_nn_junction_distance_nm": median_nn, 692 "junction_clustering_index": clustering_index, 693 } 694 695 696# --------------------------------------------------------------------------- 697# Morphology 698# --------------------------------------------------------------------------- 699 700def compute_crista_morphology( 701 crista_mask: np.ndarray, 702 voxel_size: Union[float, Dict[str, float]], 703 method: str = "both", 704) -> Dict[str, float]: 705 """Compute crista shape metrics from binary mask. 706 707 Args: 708 crista_mask: Binary crista segmentation. 709 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 710 method: "area" | "medial_axis" | "both". 711 712 Returns: 713 Dict with cristae_surface_area_nm2 (area/both) and avg_thickness_nm (medial_axis/both). 714 avg_thickness_nm is 2 × mean distance-transform value at skeleton voxels. 715 """ 716 if method not in ("area", "medial_axis", "both"): 717 raise ValueError(f"method must be 'area', 'medial_axis', or 'both', got {method!r}") 718 719 sampling = _to_sampling(voxel_size, crista_mask.ndim) 720 result: Dict[str, float] = {} 721 722 if method in ("area", "both"): 723 result["cristae_surface_area_nm2"] = _surface_area(crista_mask, sampling) 724 725 if method in ("medial_axis", "both"): 726 result["avg_thickness_nm"] = _medial_axis_thickness_nm(crista_mask, sampling) 727 728 return result 729 730 731# --------------------------------------------------------------------------- 732# Per-mitochondrion statistics 733# --------------------------------------------------------------------------- 734 735def _single_mito_row( 736 label: int, 737 bbox: tuple, 738 mito_crop: np.ndarray, 739 crista_crop: np.ndarray, 740 membrane_crop: np.ndarray, 741 voxel_size: Union[float, Dict[str, float]], 742 sampling: np.ndarray, 743 voxel_vol: float, 744 vol_shape: tuple, 745 border_radius: int, 746 method: str = "skip", 747 inner_n_jobs: int = 1, 748 lumen_crop: Optional[np.ndarray] = None, 749) -> Dict[str, float]: 750 """Compute the statistics row for a single mitochondrion instance. 751 752 Factored out of :func:`compute_mito_crista_statistics` so the per-mito work (which is 753 independent between instances) can be parallelised. Takes the bbox-cropped label arrays 754 (``mito_crop`` = label array cropped to ``bbox``; ``crista_crop``/``membrane_crop`` the 755 matching binary crops), so a worker only touches its own mito's bbox region rather than the 756 whole volume. ``inner_n_jobs`` is forwarded to the (parallelisable) junction-distance stage. 757 758 ``method`` controls only the crista orientation anisotropy — every other metric (marching-cubes 759 surface areas, geodesic junction distances, EDT proximity/thickness) is computed identically for 760 all modes. ``"skip"`` (the default) leaves the anisotropy NaN and is the fastest; ``"fast"`` 761 computes it on a 2× downsampled crop (~8× cheaper, a *relative* indicator only, not comparable to 762 exact); ``"exact"`` uses the full-resolution structure tensor (the dominant cost). 763 764 ``lumen_crop`` is the (optional) bbox-cropped eroded lumen from :func:`approximate_membrane` 765 (``return_lumen=True``) used for the junction geodesic mesh; without it the mesh falls back to 766 ``mito_local & ~membrane_local`` (used only when a caller supplies their own membrane, and 767 contaminated near clipped faces). Both the lumen geodesic mesh and the mito outer-surface-area mesh 768 are trimmed/opened at faces where the mito is clipped by the volume boundary: the lumen via 769 :func:`_open_trimmed_mesh` (trimmed to the certain region and left open, so geodesics do not 770 shortcut across a cap), the mito surface via ``closed_faces`` (open, so a fabricated cap is not 771 counted as membrane area). The membrane distance transform is freed before the (memory-heavy) 772 orientation stage to cap peak memory. 773 """ 774 ndim = mito_crop.ndim 775 touches_border = any( 776 bbox[i] < border_radius or bbox[i + ndim] > vol_shape[i] - border_radius 777 for i in range(ndim) 778 ) 779 780 mito_local = mito_crop == label 781 crista_local = crista_crop & mito_local 782 membrane_local = membrane_crop & mito_local 783 784 mito_vol = float(mito_local.sum()) * voxel_vol 785 crista_vol = float(crista_local.sum()) * voxel_vol 786 787 boundary = np.array( 788 [[bbox[a] == 0, bbox[a + ndim] == vol_shape[a]] for a in range(ndim)], dtype=bool 789 ) 790 791 has_crista = crista_local.any() 792 has_membrane = membrane_local.any() 793 mito_surface = _surface_area(mito_local, sampling, closed_faces=~boundary) 794 795 if has_crista and has_membrane: 796 contact_labels_local, contact_summary = detect_contact_sites(crista_local, membrane_local, voxel_size) 797 membrane_distance = distance_transform(~membrane_local, sampling=sampling.tolist(), number_of_threads=1) 798 _, proximity = compute_crista_proximity( 799 crista_local, membrane_local, voxel_size, membrane_distance=membrane_distance 800 ) 801 lumen_local = (lumen_crop & mito_local) if lumen_crop is not None else (mito_local & ~membrane_local) 802 lumen_mesh = _open_trimmed_mesh(lumen_local, sampling, border_radius, boundary) 803 mesh_verts, mesh_faces = lumen_mesh if lumen_mesh is not None else (None, None) 804 _, junction_dist = compute_junction_distances( 805 contact_labels_local, membrane_local, voxel_size, 806 surface_area_nm2=mito_surface, n_jobs=inner_n_jobs, 807 mesh_vertices=mesh_verts, mesh_faces=mesh_faces, 808 ) 809 del membrane_distance, contact_labels_local 810 else: 811 contact_summary = {"contact_voxel_count": 0, "crista_junction_count": 0, "contact_volume_nm3": 0.0} 812 proximity = {"median_nm": np.nan} 813 junction_dist = dict(_JUNCTION_DISTANCE_NAN) 814 815 if has_crista: 816 morph = compute_crista_morphology(crista_local, voxel_size) 817 crista_surface = morph.get("cristae_surface_area_nm2", np.nan) 818 avg_thickness_nm = morph.get("avg_thickness_nm", np.nan) 819 if method == "skip": 820 crista_orientation_anisotropy = np.nan 821 elif method == "fast": 822 crista_orientation_anisotropy = _downsampled_orientation_anisotropy( 823 crista_local, voxel_size, factor=2 824 ) 825 else: 826 anisotropy = compute_crista_orientation(crista_local, voxel_size) 827 crista_orientation_anisotropy = float(np.mean(anisotropy[crista_local])) 828 else: 829 crista_orientation_anisotropy = np.nan 830 crista_surface = np.nan 831 avg_thickness_nm = np.nan 832 833 if mito_surface and mito_surface > 0 and np.isfinite(crista_surface): 834 crista_to_mito_surface_ratio = crista_surface / mito_surface 835 else: 836 crista_to_mito_surface_ratio = np.nan 837 838 return { 839 "mito_label_id": int(label), 840 "mito_touches_border": touches_border, 841 "mito_volume_nm3": mito_vol, 842 "crista_volume_nm3": crista_vol, 843 "crista_fraction": crista_vol / mito_vol if mito_vol > 0 else np.nan, 844 "contact_voxel_count": contact_summary["contact_voxel_count"], 845 "crista_junction_count": contact_summary["crista_junction_count"], 846 "contact_volume_nm3": contact_summary["contact_volume_nm3"], 847 "avg_crista_to_membrane_nm": proximity["median_nm"], 848 "mean_nn_junction_distance_nm": junction_dist["mean_nn_junction_distance_nm"], 849 "median_nn_junction_distance_nm": junction_dist["median_nn_junction_distance_nm"], 850 "junction_clustering_index": junction_dist["junction_clustering_index"], 851 "crista_orientation_anisotropy": crista_orientation_anisotropy, 852 "cristae_surface_area_nm2": crista_surface, 853 "mito_surface_area_nm2": mito_surface, 854 "crista_to_mito_surface_ratio": crista_to_mito_surface_ratio, 855 "avg_thickness_nm": avg_thickness_nm, 856 } 857 858 859def compute_mito_crista_statistics( 860 crista_mask: np.ndarray, 861 mito_segmentation: np.ndarray, 862 voxel_size: Union[float, Dict[str, float]], 863 membrane_mask: Optional[np.ndarray] = None, 864 membrane_thickness_nm: float = 8.0, 865 border_gap_nm: Optional[float] = None, 866 method: str = "skip", 867 n_jobs: int = 1, 868 verbose: bool = False, 869 progress_callback: Optional[Callable[[int, int], None]] = None, 870 membrane_mode: str = "slice_2d", 871 lumen_mask: Optional[np.ndarray] = None, 872) -> pd.DataFrame: 873 """Compute all crista metrics organised by mitochondrial instance. 874 875 Args: 876 crista_mask: Binary crista segmentation (global volume). 877 mito_segmentation: Instance label array (background = 0). 878 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 879 membrane_mask: Precomputed membrane mask; recomputed if None. 880 membrane_thickness_nm: Membrane shell thickness used if membrane_mask is None. 881 border_gap_nm: Border suppression distance passed to approximate_membrane; 882 defaults to membrane_thickness_nm when None. 883 method: How the crista orientation anisotropy is computed — this is the ONLY metric that 884 differs between modes; surface areas (marching cubes), junction distances (geodesic 885 along the membrane) and thickness/proximity (EDT) are computed identically for all of 886 them. ``"skip"`` (default) does not compute orientation at all 887 (``crista_orientation_anisotropy`` is NaN) and is the fastest — use it when only the other 888 metrics are needed. ``"fast"`` computes the anisotropy on a 2× downsampled crista crop 889 (~8× cheaper — the structure tensor is by far the dominant cost); the resulting value is a 890 *relative* indicator that preserves the ordering between mitochondria but is systematically 891 different in magnitude from the full-resolution value and is NOT comparable to 892 ``method="exact"``. ``"exact"`` computes the anisotropy from the full-resolution structure 893 tensor (use it when the magnitude must be precise). 894 n_jobs: Number of workers for processing mitochondria in parallel (they are 895 independent). 1 (default) runs serially; other values use a ``concurrent.futures`` 896 thread pool (-1 = all cores). Results are identical regardless of n_jobs. 897 verbose: If True, show a terminal tqdm progress bar over mitochondria. 898 progress_callback: Optional callable invoked once per completed mitochondrion with 899 (completed_count, total_count) — e.g. to drive a napari progress bar. It is 900 always called from the calling thread (the futures are consumed here as they 901 complete), so GUI updates from it need no cross-thread marshaling. 902 membrane_mode: How the membrane shell is built when ``membrane_mask`` is None — 903 ``"slice_2d"`` (default, per-Z-slice 2D erosion, z-parallel) or ``"shell_3d"`` (connected 904 3D shell). See :func:`approximate_membrane`. 905 lumen_mask: Optional eroded-mito interior matching ``membrane_mask``, i.e. the second return 906 value of ``approximate_membrane(..., return_lumen=True)``. It is the clean single-wall 907 surface the junction geodesics run along. Only used when ``membrane_mask`` is also 908 supplied (when the membrane is built here, the matching lumen is derived automatically); 909 when neither is available the geodesic mesh falls back to ``mito & ~membrane``, which is 910 contaminated by the membrane's border-gap suppression near clipped volume faces. 911 912 The junction nearest-neighbour distances are geodesics along the eroded-mito surface mesh 913 (``bioimage_cpp.distance.geodesic_distances_mesh``); for a mito with no usable mesh (empty 914 membrane / degenerate mesh) those columns are NaN. 915 916 Implementation notes: each mito is pre-cropped to its bounding box by basic slicing (views, so 917 cropping is memory-free). Parallelism is adaptive and single-level (never oversubscribed): with 918 many mitochondria the work is parallelised *across* them on a ``concurrent.futures`` 919 ``ThreadPoolExecutor`` — the heavy per-mito stages (structure tensor, EDT, geodesics) are 920 GIL-releasing C++, so threads scale them — with each worker's inner stages kept single-threaded 921 (the EDT/geodesic solvers are called with ``number_of_threads=1``); with few mitochondria they run 922 serially and each mito's junction-distance stage gets all cores. The concurrent worker count is 923 additionally capped so the combined per-mito working set (tensor components + label crops, 924 ~40 bytes/voxel of the largest mito) fits in RAM. Results stream in as they complete and are 925 finally sorted by label for an n_jobs-independent ordering. 926 927 Returns: 928 DataFrame with one row per mito instance: 929 label | mito_volume_nm3 | crista_volume_nm3 | crista_fraction | 930 contact_voxel_count | crista_junction_count | contact_volume_nm3 | 931 avg_crista_to_membrane_nm | mean_nn_junction_distance_nm | median_nn_junction_distance_nm | 932 junction_clustering_index | crista_orientation_anisotropy | cristae_surface_area_nm2 | 933 mito_surface_area_nm2 | crista_to_mito_surface_ratio | avg_thickness_nm. 934 cristae_surface_area_nm2 is the crista surface area; crista_to_mito_surface_ratio is 935 crista surface / mitochondrial outer-membrane surface (can exceed 1 for folded cristae). 936 The *_nn_junction_distance_nm columns are geodesic nearest-neighbour distances between 937 crista-membrane junctions along the membrane; junction_clustering_index is a Clark-Evans 938 index (< 1 clustered, ~ 1 random, > 1 dispersed). ``crista_orientation_anisotropy`` is 939 computed at full resolution for ``method="exact"``, on a downsampled crop (relative-only, 940 not comparable) for ``method="fast"``, and left NaN for ``method="skip"``. 941 """ 942 if method not in ("fast", "exact", "skip"): 943 raise ValueError(f"method must be 'fast', 'exact', or 'skip', got {method!r}") 944 if membrane_mask is None: 945 membrane_mask, lumen_mask = approximate_membrane( 946 mito_segmentation, voxel_size, membrane_thickness_nm, border_gap_nm, 947 n_jobs=n_jobs, membrane_mode=membrane_mode, return_lumen=True, 948 ) 949 950 ndim = mito_segmentation.ndim 951 sampling = _to_sampling(voxel_size, ndim) 952 voxel_vol = float(np.prod(sampling)) 953 crista_binary = crista_mask.astype(bool) 954 vol_shape = mito_segmentation.shape 955 border_radius = _gap_radius(voxel_size, membrane_thickness_nm, border_gap_nm, ndim) 956 957 tasks = [] 958 for prop in regionprops(mito_segmentation): 959 bbox = prop.bbox 960 slices = tuple(slice(bbox[i], bbox[i + ndim]) for i in range(ndim)) 961 lumen_crop = None if lumen_mask is None else lumen_mask[slices] 962 tasks.append(( 963 int(prop.label), bbox, 964 mito_segmentation[slices], crista_binary[slices], membrane_mask[slices], 965 lumen_crop, 966 )) 967 total = len(tasks) 968 969 def _run(task, inner_n_jobs): 970 label, bbox, mito_crop, crista_crop, membrane_crop, lumen_crop = task 971 return _single_mito_row( 972 label, bbox, mito_crop, crista_crop, membrane_crop, 973 voxel_size, sampling, voxel_vol, vol_shape, border_radius, 974 method=method, inner_n_jobs=inner_n_jobs, lumen_crop=lumen_crop, 975 ) 976 977 n_workers = os.cpu_count() if n_jobs == -1 else max(1, n_jobs) 978 across = n_workers > 1 and total >= n_workers 979 980 rows = [] 981 982 def _consume(results): 983 for i, row in enumerate( 984 tqdm(results, total=total, desc="Cristae analysis", disable=not verbose), start=1 985 ): 986 rows.append(row) 987 if progress_callback is not None: 988 progress_callback(i, total) 989 990 if across: 991 max_voxels = max(int(task[2].size) for task in tasks) 992 across_workers = _bounded_workers(n_jobs, per_worker_bytes=max_voxels * 40) 993 # Parallelise across mitochondria with a thread pool (the heavy per-mito stages — structure 994 # tensor, EDT, geodesics — are GIL-releasing C++). Each worker's inner stages run 995 # single-threaded (``_run(task, 1)`` passes ``number_of_threads=1`` down to the EDT/geodesic 996 # solvers) so the across-mito threads do not oversubscribe the cores. Results stream in as they 997 # complete (``as_completed``) to drive the progress bar; rows are label-sorted below. 998 with futures.ThreadPoolExecutor(across_workers) as tp: 999 submitted = [tp.submit(_run, task, 1) for task in tasks] 1000 _consume(future.result() for future in futures.as_completed(submitted)) 1001 else: 1002 _consume(_run(task, n_workers) for task in tasks) 1003 1004 rows.sort(key=lambda row: row["mito_label_id"]) 1005 return pd.DataFrame(rows)
227def approximate_membrane( 228 mito_segmentation: np.ndarray, 229 voxel_size: Union[float, Dict[str, float]], 230 membrane_thickness_nm: float = 8.0, 231 border_gap_nm: Optional[float] = None, 232 n_jobs: int = 1, 233 membrane_mode: str = "slice_2d", 234 return_lumen: bool = False, 235) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]: 236 """Approximate the mitochondrial membrane as the outer shell of the segmentation. 237 238 Two shell constructions are available via ``membrane_mode``: 239 240 - ``"slice_2d"`` (default): erode each Z-slice **independently in 2D** by an XY disk of radius 241 ``round(thickness / xy_voxel)`` and keep ``slice & ~eroded``. A mitochondrion that changes shape 242 rapidly along Z does not bleed into neighbouring slices, and the per-slice erosions are 243 parallelised over Z (``n_jobs``). A separable Z-only erosion (radius ``round(thickness / 244 z_voxel)``, no XY coupling) then adds **Z-caps** where a mito column truly ends in Z; ends 245 clipped by a volume Z-face are left uncapped (``border_value=1`` + the ``border_gap`` trim). The 246 XY shell can still fragment across slices. (2D inputs get a single 2D erosion.) 247 - ``"shell_3d"``: a full 3D morphological erosion, ``mito & ~erode3d(mito, k)`` with 248 ``k = round(thickness / mean_voxel)`` iterations of a 3×3×3 structuring element, per instance on 249 its padded bounding box. A single **connected** shell including the Z-caps (no per-slice 250 fragmentation), at a higher cost; thickness acts in all axes. 251 252 The eroded interior is the "lumen"; its surface is the single-wall mesh used by the geodesic 253 backend and the display, so the mesh follows the chosen mode. 254 255 Membrane voxels within ``border_gap_nm`` of any volume face are removed so clipped mito edges are 256 not treated as membrane. The lumen is NOT trimmed here — the mesh is trimmed to the certain region 257 (and left open there) at mesh time by :func:`_open_trimmed_mesh`, which requires the untrimmed 258 interior to produce an open cut rather than a fabricated cap. 259 260 Implementation notes: ``"slice_2d"`` erodes each Z-slice on the mito XY bbox with a 261 ``membrane_radius`` margin, so the cropped ``border_value=1`` erosion matches eroding the full 262 slice (empty slices are skipped), then adds Z-caps via a separable Z-only line erosion (which 263 inspects only the same column, so no XY-shape bleed); ``border_value=1`` leaves ends clipped by a 264 volume Z-face uncapped, and the ``border_gap`` removal clears anything near a face, so only true 265 ends are capped. ``"shell_3d"`` erodes the *merged* binary in each instance's padded bbox so 266 instances that share a boundary are handled together. 267 268 Args: 269 mito_segmentation: Instance label array (background = 0). 270 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 271 membrane_thickness_nm: Thickness of the membrane shell in nm. 272 border_gap_nm: Distance from each volume face within which membrane voxels are 273 suppressed. Defaults to membrane_thickness_nm when None. 274 n_jobs: Workers for the per-Z-slice erosion (``"slice_2d"`` only): 1 = serial, -1 = all cores. 275 Results are identical regardless of n_jobs. 276 membrane_mode: ``"slice_2d"`` (default, per-slice 2D, z-parallel) or ``"shell_3d"`` 277 (connected 3D shell). 278 return_lumen: If True, also return the eroded-mito interior ("lumen") mask — the single-wall 279 surface source for the geodesic/display mesh. It is the plain eroded interior (not 280 ``mito & ~membrane``, which would re-include the outer shell where the membrane is 281 border-trimmed); trimming to the certain region happens at mesh time. 282 283 Returns: 284 membrane_mask: Binary mask of the mitochondrial membrane (outer shell), with border-adjacent 285 voxels zeroed out. If ``return_lumen`` is True, returns ``(membrane_mask, lumen_mask)`` 286 where ``lumen_mask`` is the (untrimmed) eroded interior described above. 287 """ 288 if membrane_mode not in ("slice_2d", "shell_3d"): 289 raise ValueError(f"membrane_mode must be 'slice_2d' or 'shell_3d', got {membrane_mode!r}") 290 ndim = mito_segmentation.ndim 291 mito_binary = mito_segmentation > 0 292 293 # NOTE (possible simplification, deferred): the "shell_3d" branch below builds the shell with an 294 # iterated 3x3x3 erosion per instance. It could likely be a single anisotropic distance transform 295 # instead — membrane = mito & (distance_transform(mito, sampling) <= thickness), lumen = the rest — 296 # which is simpler and handles anisotropy directly. This would NOT replace "slice_2d": a distance 297 # transform couples all axes, so it cannot reproduce slice_2d's per-Z-slice-independent erosion, 298 # whose whole purpose is to stop the shell bleeding across slices in XY. Worth investigating. 299 if membrane_mode == "shell_3d": 300 sampling = _to_sampling(voxel_size, ndim) 301 k = max(1, int(round(float(membrane_thickness_nm) / float(np.mean(sampling))))) 302 struct = np.ones((3,) * ndim, dtype=bool) 303 membrane_mask = np.zeros(mito_segmentation.shape, dtype=bool) 304 lumen_mask = np.zeros(mito_segmentation.shape, dtype=bool) 305 for prop in regionprops(mito_segmentation): 306 bbox = prop.bbox 307 sl = tuple( 308 slice(max(0, bbox[i] - k), min(mito_segmentation.shape[i], bbox[i + ndim] + k)) 309 for i in range(ndim) 310 ) 311 sub = mito_binary[sl] 312 eroded = binary_erosion(sub, structure=struct, iterations=k, border_value=1) 313 cur = mito_segmentation[sl] == prop.label 314 membrane_mask[sl] |= cur & ~eroded 315 lumen_mask[sl] |= cur & eroded 316 elif ndim == 3: 317 membrane_radius = _voxel_radius_xy(membrane_thickness_nm, voxel_size) 318 struct = disk(membrane_radius) 319 membrane_mask = np.zeros_like(mito_binary) 320 lumen_mask = np.zeros_like(mito_binary) 321 coords = np.argwhere(mito_binary) 322 if coords.size: 323 zmin, ymin, xmin = coords.min(axis=0) 324 zmax, ymax, xmax = coords.max(axis=0) + 1 325 m = membrane_radius 326 y0, y1 = max(0, ymin - m), min(mito_binary.shape[1], ymax + m) 327 x0, x1 = max(0, xmin - m), min(mito_binary.shape[2], xmax + m) 328 329 def _erode_slice(z): 330 sl = mito_binary[z, y0:y1, x0:x1] 331 if not sl.any(): 332 return z, None 333 eroded = binary_erosion(sl, structure=struct, border_value=1) 334 return z, (sl & ~eroded, eroded) 335 336 z_range = range(int(zmin), int(zmax)) 337 if n_jobs == 1: 338 results = [_erode_slice(z) for z in z_range] 339 else: 340 n_workers = mp.cpu_count() if n_jobs == -1 else n_jobs 341 with futures.ThreadPoolExecutor(n_workers) as tp: 342 results = list(tp.map(_erode_slice, z_range)) 343 for z, res in results: 344 if res is not None: 345 mem_sl, lum_sl = res 346 membrane_mask[z, y0:y1, x0:x1] = mem_sl 347 lumen_mask[z, y0:y1, x0:x1] = lum_sl 348 349 k_z = max(1, int(round(float(membrane_thickness_nm) / float(_to_sampling(voxel_size, ndim)[0])))) 350 z0m, z1m = max(0, int(zmin) - k_z), min(mito_binary.shape[0], int(zmax) + k_z) 351 sub = mito_binary[z0m:z1m, y0:y1, x0:x1] 352 z_eroded = binary_erosion(sub, structure=np.ones((2 * k_z + 1, 1, 1), dtype=bool), border_value=1) 353 membrane_mask[z0m:z1m, y0:y1, x0:x1] |= sub & ~z_eroded 354 lumen_mask[z0m:z1m, y0:y1, x0:x1] &= z_eroded 355 else: 356 membrane_radius = _voxel_radius(membrane_thickness_nm, voxel_size, ndim) 357 eroded = binary_erosion(mito_binary, structure=disk(membrane_radius), border_value=1) 358 membrane_mask = mito_binary & ~eroded 359 lumen_mask = mito_binary & eroded 360 361 gap_radius = _gap_radius(voxel_size, membrane_thickness_nm, border_gap_nm, ndim) 362 membrane_mask &= ~_border_zone(mito_segmentation.shape, gap_radius) 363 if return_lumen: 364 return membrane_mask.astype(bool), lumen_mask.astype(bool) 365 return membrane_mask.astype(bool)
Approximate the mitochondrial membrane as the outer shell of the segmentation.
Two shell constructions are available via membrane_mode:
"slice_2d"(default): erode each Z-slice independently in 2D by an XY disk of radiusround(thickness / xy_voxel)and keepslice & ~eroded. A mitochondrion that changes shape rapidly along Z does not bleed into neighbouring slices, and the per-slice erosions are parallelised over Z (n_jobs). A separable Z-only erosion (radiusround(thickness / z_voxel), no XY coupling) then adds Z-caps where a mito column truly ends in Z; ends clipped by a volume Z-face are left uncapped (border_value=1+ theborder_gaptrim). The XY shell can still fragment across slices. (2D inputs get a single 2D erosion.)"shell_3d": a full 3D morphological erosion,mito & ~erode3d(mito, k)withk = round(thickness / mean_voxel)iterations of a 3×3×3 structuring element, per instance on its padded bounding box. A single connected shell including the Z-caps (no per-slice fragmentation), at a higher cost; thickness acts in all axes.
The eroded interior is the "lumen"; its surface is the single-wall mesh used by the geodesic backend and the display, so the mesh follows the chosen mode.
Membrane voxels within border_gap_nm of any volume face are removed so clipped mito edges are
not treated as membrane. The lumen is NOT trimmed here — the mesh is trimmed to the certain region
(and left open there) at mesh time by _open_trimmed_mesh(), which requires the untrimmed
interior to produce an open cut rather than a fabricated cap.
Implementation notes: "slice_2d" erodes each Z-slice on the mito XY bbox with a
membrane_radius margin, so the cropped border_value=1 erosion matches eroding the full
slice (empty slices are skipped), then adds Z-caps via a separable Z-only line erosion (which
inspects only the same column, so no XY-shape bleed); border_value=1 leaves ends clipped by a
volume Z-face uncapped, and the border_gap removal clears anything near a face, so only true
ends are capped. "shell_3d" erodes the merged binary in each instance's padded bbox so
instances that share a boundary are handled together.
Arguments:
- mito_segmentation: Instance label array (background = 0).
- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
- membrane_thickness_nm: Thickness of the membrane shell in nm.
- border_gap_nm: Distance from each volume face within which membrane voxels are suppressed. Defaults to membrane_thickness_nm when None.
- n_jobs: Workers for the per-Z-slice erosion (
"slice_2d"only): 1 = serial, -1 = all cores. Results are identical regardless of n_jobs. - membrane_mode:
"slice_2d"(default, per-slice 2D, z-parallel) or"shell_3d"(connected 3D shell). - return_lumen: If True, also return the eroded-mito interior ("lumen") mask — the single-wall
surface source for the geodesic/display mesh. It is the plain eroded interior (not
mito & ~membrane, which would re-include the outer shell where the membrane is border-trimmed); trimming to the certain region happens at mesh time.
Returns:
membrane_mask: Binary mask of the mitochondrial membrane (outer shell), with border-adjacent voxels zeroed out. If
return_lumenis True, returns(membrane_mask, lumen_mask)wherelumen_maskis the (untrimmed) eroded interior described above.
372def compute_crista_orientation( 373 crista_mask: np.ndarray, 374 voxel_size: Union[float, Dict[str, float]], 375 neighborhood_size_nm: float = 30.0, 376) -> np.ndarray: 377 """Compute the per-voxel crista orientation anisotropy via the structure tensor. 378 379 Uses ``bioimage_cpp.filters.structure_tensor_eigenvalues`` (a fast C++ routine). Only the 380 anisotropy is produced (the principal directions / eigenvectors are not computed). 381 382 The structure tensor's outer/integration sigma is ``neighborhood_size_nm`` per axis (in voxels); 383 the inner (derivative) sigma must be > 0, so a minimal 1-voxel scale is used. Eigenvalues are 384 non-negative in theory, but the solver emits tiny negatives for near-rank-deficient tensors 385 (degenerate sheets/tubes), so they are clamped to 0 and the ratio is taken as 386 ``max/min`` over the trailing axis — order-agnostic and sign-safe, so a tiny negative minor 387 eigenvalue cannot flip the denominator and blow the ratio up. 388 389 Args: 390 crista_mask: Binary crista segmentation. 391 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 392 neighborhood_size_nm: Gaussian integration radius in nm for tensor averaging (the 393 structure tensor's outer/integration scale). 394 395 Returns: 396 anisotropy: (...) — λ_max / (λ_min + ε) per voxel. High values indicate a strongly 397 directional crista (e.g. parallel lamellae); low values indicate isotropic or 398 tubular/disordered morphology. Magnitude only (rotation-invariant). 399 """ 400 ndim = crista_mask.ndim 401 sampling = _to_sampling(voxel_size, ndim) 402 403 outer_sigma = [float(s) for s in (neighborhood_size_nm / sampling)] 404 inner_sigma = 1.0 405 evals = structure_tensor_eigenvalues(crista_mask.astype(np.float32), inner_sigma, outer_sigma) 406 evals = np.clip(evals, 0.0, None) 407 anisotropy = evals.max(axis=-1) / (evals.min(axis=-1) + 1e-10) 408 return anisotropy.astype(np.float32)
Compute the per-voxel crista orientation anisotropy via the structure tensor.
Uses bioimage_cpp.filters.structure_tensor_eigenvalues (a fast C++ routine). Only the
anisotropy is produced (the principal directions / eigenvectors are not computed).
The structure tensor's outer/integration sigma is neighborhood_size_nm per axis (in voxels);
the inner (derivative) sigma must be > 0, so a minimal 1-voxel scale is used. Eigenvalues are
non-negative in theory, but the solver emits tiny negatives for near-rank-deficient tensors
(degenerate sheets/tubes), so they are clamped to 0 and the ratio is taken as
max/min over the trailing axis — order-agnostic and sign-safe, so a tiny negative minor
eigenvalue cannot flip the denominator and blow the ratio up.
Arguments:
- crista_mask: Binary crista segmentation.
- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
- neighborhood_size_nm: Gaussian integration radius in nm for tensor averaging (the structure tensor's outer/integration scale).
Returns:
anisotropy: (...) — λ_max / (λ_min + ε) per voxel. High values indicate a strongly directional crista (e.g. parallel lamellae); low values indicate isotropic or tubular/disordered morphology. Magnitude only (rotation-invariant).
459def compute_crista_proximity( 460 crista_mask: np.ndarray, 461 membrane_mask: np.ndarray, 462 voxel_size: Union[float, Dict[str, float]], 463 membrane_distance: Optional[np.ndarray] = None, 464) -> Tuple[np.ndarray, Dict[str, float]]: 465 """Distance from each crista voxel to the nearest membrane voxel (nm). 466 467 Args: 468 crista_mask: Binary crista segmentation. 469 membrane_mask: Binary membrane mask (OM or IMM). 470 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 471 membrane_distance: Optional precomputed per-voxel distance to the nearest membrane 472 voxel (nm), i.e. ``distance_transform(~membrane_mask, sampling=...)``. When 473 given, the distance transform is not recomputed (used to avoid redundant work in 474 :func:`compute_mito_crista_statistics`). 475 476 Returns: 477 distance_map: Per-voxel distance to membrane (nm); zero outside crista. 478 summary_stats: min_nm, median_nm, max_nm. 479 """ 480 sampling = _to_sampling(voxel_size, crista_mask.ndim) 481 if membrane_distance is None: 482 dist = distance_transform(~membrane_mask.astype(bool), sampling=sampling.tolist(), number_of_threads=1) 483 else: 484 dist = membrane_distance 485 crista_dists = dist[crista_mask.astype(bool)] 486 487 if crista_dists.size == 0: 488 summary: Dict[str, float] = {"min_nm": np.nan, "median_nm": np.nan, "max_nm": np.nan} 489 else: 490 summary = { 491 "min_nm": float(crista_dists.min()), 492 "median_nm": float(np.median(crista_dists)), 493 "max_nm": float(crista_dists.max()), 494 } 495 496 distance_map = np.zeros(crista_mask.shape, dtype=np.float32) 497 distance_map[crista_mask.astype(bool)] = crista_dists 498 return distance_map, summary
Distance from each crista voxel to the nearest membrane voxel (nm).
Arguments:
- crista_mask: Binary crista segmentation.
- membrane_mask: Binary membrane mask (OM or IMM).
- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
- membrane_distance: Optional precomputed per-voxel distance to the nearest membrane
voxel (nm), i.e.
distance_transform(~membrane_mask, sampling=...). When given, the distance transform is not recomputed (used to avoid redundant work incompute_mito_crista_statistics()).
Returns:
distance_map: Per-voxel distance to membrane (nm); zero outside crista. summary_stats: min_nm, median_nm, max_nm.
505def detect_contact_sites( 506 crista_mask: np.ndarray, 507 membrane_mask: np.ndarray, 508 voxel_size: Union[float, Dict[str, float]], 509) -> Tuple[np.ndarray, Dict[str, float]]: 510 """Detect crista-membrane contact sites as the direct overlap of the two masks. 511 512 Contact = crista voxels that are also membrane voxels (the pure intersection of the crista 513 mask and the mitochondrial membrane band). No dilation/erosion is applied here, so the 514 detected junctions correspond exactly to the visible overlap of the two layers; connected 515 overlaps are grouped into junctions with 26-connectivity in 3D. 516 517 Args: 518 crista_mask: Binary crista segmentation. 519 membrane_mask: Binary mitochondrial membrane mask (as displayed). 520 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 521 522 Returns: 523 contact_labels: Integer array (same shape as the input) where each connected 524 junction has a unique ID (0 = background, 1..n = junctions). Contact voxel 525 coordinates are recoverable via ``np.argwhere(contact_labels > 0)``. 526 summary: contact_voxel_count, crista_junction_count, contact_volume_nm3. 527 """ 528 ndim = crista_mask.ndim 529 sampling = _to_sampling(voxel_size, ndim) 530 voxel_vol = float(np.prod(sampling)) 531 532 contact_mask = crista_mask.astype(bool) & membrane_mask.astype(bool) 533 534 connectivity_struct = np.ones(ndim * (3,), dtype=bool) 535 contact_labels, n_regions = ndimage_label(contact_mask, structure=connectivity_struct) 536 contact_voxel_count = int(np.count_nonzero(contact_labels)) 537 538 return contact_labels, { 539 "contact_voxel_count": contact_voxel_count, 540 "crista_junction_count": int(n_regions), 541 "contact_volume_nm3": float(contact_voxel_count) * voxel_vol, 542 }
Detect crista-membrane contact sites as the direct overlap of the two masks.
Contact = crista voxels that are also membrane voxels (the pure intersection of the crista mask and the mitochondrial membrane band). No dilation/erosion is applied here, so the detected junctions correspond exactly to the visible overlap of the two layers; connected overlaps are grouped into junctions with 26-connectivity in 3D.
Arguments:
- crista_mask: Binary crista segmentation.
- membrane_mask: Binary mitochondrial membrane mask (as displayed).
- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
Returns:
contact_labels: Integer array (same shape as the input) where each connected junction has a unique ID (0 = background, 1..n = junctions). Contact voxel coordinates are recoverable via
np.argwhere(contact_labels > 0). summary: contact_voxel_count, crista_junction_count, contact_volume_nm3.
598def compute_junction_distances( 599 contact_labels: np.ndarray, 600 membrane_mask: np.ndarray, 601 voxel_size: Union[float, Dict[str, float]], 602 surface_area_nm2: Optional[float] = None, 603 n_jobs: int = 1, 604 mesh_vertices: Optional[np.ndarray] = None, 605 mesh_faces: Optional[np.ndarray] = None, 606) -> Tuple[np.ndarray, Dict[str, float]]: 607 """Geodesic distances between crista-membrane junctions along the eroded-mito surface mesh. 608 609 Each junction (a connected component in ``contact_labels``) is reduced to its centroid, snapped to 610 the nearest vertex of a triangle mesh, and pairwise surface geodesics are computed with 611 ``bioimage_cpp.distance.geodesic_distances_mesh``. The mesh is the **eroded-mito (lumen) surface** 612 passed in as ``mesh_vertices``/``mesh_faces`` by :func:`_single_mito_row` (a clean, single-wall 613 surface at the membrane's inner edge). If no mesh is supplied — or no usable surface mesh exists 614 (empty membrane / degenerate mesh) — the junction distances are NaN. (There is no membrane-band 615 fallback mesh: the metric is defined on the lumen surface, and meshing the thick membrane band 616 would give a different, capped double-wall surface.) 617 618 A Clark-Evans nearest-neighbour index summarises whether the junctions are clustered. 619 620 Args: 621 contact_labels: Integer junction label array (0 = background, 1..n = junctions), 622 e.g. the first return value of :func:`detect_contact_sites`. 623 membrane_mask: Binary mitochondrial membrane mask the junctions sit on. Only used for the 624 empty-membrane early-out (no membrane → NaN); it is not meshed. 625 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 626 surface_area_nm2: Membrane/mito surface area used as the reference area for the 627 Clark-Evans expectation. If None or non-positive, the clustering index is NaN. 628 n_jobs: 1 = serial, -1 = all cores (forwarded to the mesh solver's thread count). 629 mesh_vertices: Optional (n_vertices, 3) mesh vertices in nm (unpadded mask frame; see 630 :func:`_surface_mesh`) — the eroded-mito (lumen) surface. If omitted, the junction 631 distances are NaN. 632 mesh_faces: Optional (n_faces, 3) triangle indices matching ``mesh_vertices``. 633 634 Returns: 635 distance_matrix: (n, n) geodesic distances in nm between junctions; the diagonal is 636 0 and unreachable pairs (disconnected fragments) are NaN. Empty for fewer than two 637 junctions. 638 summary: junction_count, mean_nn_junction_distance_nm, median_nn_junction_distance_nm, 639 junction_clustering_index (Clark-Evans R: < 1 clustered, ~ 1 random, > 1 dispersed). 640 641 Notes: 642 The Clark-Evans expected nearest-neighbour distance uses the standard 2-D planar 643 approximation ``0.5 * sqrt(A / n)`` with ``A = surface_area_nm2``. 644 645 Each junction's nearest-neighbour distance is the smallest distance to a *reachable* other 646 junction: self (diagonal) and unreachable (NaN) pairs are set to +inf before the per-row 647 minimum, and rows with no reachable neighbour (min stays +inf) are dropped. 648 """ 649 ndim = contact_labels.ndim 650 sampling = _to_sampling(voxel_size, ndim) 651 membrane = membrane_mask.astype(bool) 652 653 labels = [lbl for lbl in np.unique(contact_labels) if lbl != 0] 654 n = len(labels) 655 if n < 2 or not membrane.any(): 656 summary = dict(_JUNCTION_DISTANCE_NAN) 657 summary["junction_count"] = n 658 return np.zeros((n, n), dtype=float), summary 659 660 centroids = np.atleast_2d( 661 np.asarray(center_of_mass(contact_labels > 0, labels=contact_labels, index=labels), dtype=float) 662 ) 663 664 if mesh_vertices is not None and mesh_faces is not None and len(mesh_faces) > 0: 665 distance_matrix = _junction_matrix_mesh(centroids, sampling, mesh_vertices, mesh_faces, n_jobs) 666 else: 667 distance_matrix = None 668 669 if distance_matrix is None: 670 summary = dict(_JUNCTION_DISTANCE_NAN) 671 summary["junction_count"] = n 672 return np.full((n, n), np.nan, dtype=float), summary 673 674 dm = distance_matrix.copy() 675 np.fill_diagonal(dm, np.inf) 676 dm[~np.isfinite(dm)] = np.inf 677 row_min = dm.min(axis=1) 678 nn_distances = row_min[np.isfinite(row_min)] 679 680 mean_nn = float(np.mean(nn_distances)) if nn_distances.size else np.nan 681 median_nn = float(np.median(nn_distances)) if nn_distances.size else np.nan 682 683 clustering_index = np.nan 684 if surface_area_nm2 is not None and surface_area_nm2 > 0 and np.isfinite(mean_nn): 685 expected_nn = 0.5 * np.sqrt(float(surface_area_nm2) / n) 686 if expected_nn > 0: 687 clustering_index = mean_nn / expected_nn 688 689 return distance_matrix, { 690 "junction_count": n, 691 "mean_nn_junction_distance_nm": mean_nn, 692 "median_nn_junction_distance_nm": median_nn, 693 "junction_clustering_index": clustering_index, 694 }
Geodesic distances between crista-membrane junctions along the eroded-mito surface mesh.
Each junction (a connected component in contact_labels) is reduced to its centroid, snapped to
the nearest vertex of a triangle mesh, and pairwise surface geodesics are computed with
bioimage_cpp.distance.geodesic_distances_mesh. The mesh is the eroded-mito (lumen) surface
passed in as mesh_vertices/mesh_faces by _single_mito_row() (a clean, single-wall
surface at the membrane's inner edge). If no mesh is supplied — or no usable surface mesh exists
(empty membrane / degenerate mesh) — the junction distances are NaN. (There is no membrane-band
fallback mesh: the metric is defined on the lumen surface, and meshing the thick membrane band
would give a different, capped double-wall surface.)
A Clark-Evans nearest-neighbour index summarises whether the junctions are clustered.
Arguments:
- contact_labels: Integer junction label array (0 = background, 1..n = junctions),
e.g. the first return value of
detect_contact_sites(). - membrane_mask: Binary mitochondrial membrane mask the junctions sit on. Only used for the empty-membrane early-out (no membrane → NaN); it is not meshed.
- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
- surface_area_nm2: Membrane/mito surface area used as the reference area for the Clark-Evans expectation. If None or non-positive, the clustering index is NaN.
- n_jobs: 1 = serial, -1 = all cores (forwarded to the mesh solver's thread count).
- mesh_vertices: Optional (n_vertices, 3) mesh vertices in nm (unpadded mask frame; see
_surface_mesh()) — the eroded-mito (lumen) surface. If omitted, the junction distances are NaN. - mesh_faces: Optional (n_faces, 3) triangle indices matching
mesh_vertices.
Returns:
distance_matrix: (n, n) geodesic distances in nm between junctions; the diagonal is 0 and unreachable pairs (disconnected fragments) are NaN. Empty for fewer than two junctions. summary: junction_count, mean_nn_junction_distance_nm, median_nn_junction_distance_nm, junction_clustering_index (Clark-Evans R: < 1 clustered, ~ 1 random, > 1 dispersed).
Notes:
The Clark-Evans expected nearest-neighbour distance uses the standard 2-D planar approximation
0.5 * sqrt(A / n)withA = surface_area_nm2.Each junction's nearest-neighbour distance is the smallest distance to a reachable other junction: self (diagonal) and unreachable (NaN) pairs are set to +inf before the per-row minimum, and rows with no reachable neighbour (min stays +inf) are dropped.
701def compute_crista_morphology( 702 crista_mask: np.ndarray, 703 voxel_size: Union[float, Dict[str, float]], 704 method: str = "both", 705) -> Dict[str, float]: 706 """Compute crista shape metrics from binary mask. 707 708 Args: 709 crista_mask: Binary crista segmentation. 710 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 711 method: "area" | "medial_axis" | "both". 712 713 Returns: 714 Dict with cristae_surface_area_nm2 (area/both) and avg_thickness_nm (medial_axis/both). 715 avg_thickness_nm is 2 × mean distance-transform value at skeleton voxels. 716 """ 717 if method not in ("area", "medial_axis", "both"): 718 raise ValueError(f"method must be 'area', 'medial_axis', or 'both', got {method!r}") 719 720 sampling = _to_sampling(voxel_size, crista_mask.ndim) 721 result: Dict[str, float] = {} 722 723 if method in ("area", "both"): 724 result["cristae_surface_area_nm2"] = _surface_area(crista_mask, sampling) 725 726 if method in ("medial_axis", "both"): 727 result["avg_thickness_nm"] = _medial_axis_thickness_nm(crista_mask, sampling) 728 729 return result
Compute crista shape metrics from binary mask.
Arguments:
- crista_mask: Binary crista segmentation.
- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
- method: "area" | "medial_axis" | "both".
Returns:
Dict with cristae_surface_area_nm2 (area/both) and avg_thickness_nm (medial_axis/both). avg_thickness_nm is 2 × mean distance-transform value at skeleton voxels.
860def compute_mito_crista_statistics( 861 crista_mask: np.ndarray, 862 mito_segmentation: np.ndarray, 863 voxel_size: Union[float, Dict[str, float]], 864 membrane_mask: Optional[np.ndarray] = None, 865 membrane_thickness_nm: float = 8.0, 866 border_gap_nm: Optional[float] = None, 867 method: str = "skip", 868 n_jobs: int = 1, 869 verbose: bool = False, 870 progress_callback: Optional[Callable[[int, int], None]] = None, 871 membrane_mode: str = "slice_2d", 872 lumen_mask: Optional[np.ndarray] = None, 873) -> pd.DataFrame: 874 """Compute all crista metrics organised by mitochondrial instance. 875 876 Args: 877 crista_mask: Binary crista segmentation (global volume). 878 mito_segmentation: Instance label array (background = 0). 879 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. 880 membrane_mask: Precomputed membrane mask; recomputed if None. 881 membrane_thickness_nm: Membrane shell thickness used if membrane_mask is None. 882 border_gap_nm: Border suppression distance passed to approximate_membrane; 883 defaults to membrane_thickness_nm when None. 884 method: How the crista orientation anisotropy is computed — this is the ONLY metric that 885 differs between modes; surface areas (marching cubes), junction distances (geodesic 886 along the membrane) and thickness/proximity (EDT) are computed identically for all of 887 them. ``"skip"`` (default) does not compute orientation at all 888 (``crista_orientation_anisotropy`` is NaN) and is the fastest — use it when only the other 889 metrics are needed. ``"fast"`` computes the anisotropy on a 2× downsampled crista crop 890 (~8× cheaper — the structure tensor is by far the dominant cost); the resulting value is a 891 *relative* indicator that preserves the ordering between mitochondria but is systematically 892 different in magnitude from the full-resolution value and is NOT comparable to 893 ``method="exact"``. ``"exact"`` computes the anisotropy from the full-resolution structure 894 tensor (use it when the magnitude must be precise). 895 n_jobs: Number of workers for processing mitochondria in parallel (they are 896 independent). 1 (default) runs serially; other values use a ``concurrent.futures`` 897 thread pool (-1 = all cores). Results are identical regardless of n_jobs. 898 verbose: If True, show a terminal tqdm progress bar over mitochondria. 899 progress_callback: Optional callable invoked once per completed mitochondrion with 900 (completed_count, total_count) — e.g. to drive a napari progress bar. It is 901 always called from the calling thread (the futures are consumed here as they 902 complete), so GUI updates from it need no cross-thread marshaling. 903 membrane_mode: How the membrane shell is built when ``membrane_mask`` is None — 904 ``"slice_2d"`` (default, per-Z-slice 2D erosion, z-parallel) or ``"shell_3d"`` (connected 905 3D shell). See :func:`approximate_membrane`. 906 lumen_mask: Optional eroded-mito interior matching ``membrane_mask``, i.e. the second return 907 value of ``approximate_membrane(..., return_lumen=True)``. It is the clean single-wall 908 surface the junction geodesics run along. Only used when ``membrane_mask`` is also 909 supplied (when the membrane is built here, the matching lumen is derived automatically); 910 when neither is available the geodesic mesh falls back to ``mito & ~membrane``, which is 911 contaminated by the membrane's border-gap suppression near clipped volume faces. 912 913 The junction nearest-neighbour distances are geodesics along the eroded-mito surface mesh 914 (``bioimage_cpp.distance.geodesic_distances_mesh``); for a mito with no usable mesh (empty 915 membrane / degenerate mesh) those columns are NaN. 916 917 Implementation notes: each mito is pre-cropped to its bounding box by basic slicing (views, so 918 cropping is memory-free). Parallelism is adaptive and single-level (never oversubscribed): with 919 many mitochondria the work is parallelised *across* them on a ``concurrent.futures`` 920 ``ThreadPoolExecutor`` — the heavy per-mito stages (structure tensor, EDT, geodesics) are 921 GIL-releasing C++, so threads scale them — with each worker's inner stages kept single-threaded 922 (the EDT/geodesic solvers are called with ``number_of_threads=1``); with few mitochondria they run 923 serially and each mito's junction-distance stage gets all cores. The concurrent worker count is 924 additionally capped so the combined per-mito working set (tensor components + label crops, 925 ~40 bytes/voxel of the largest mito) fits in RAM. Results stream in as they complete and are 926 finally sorted by label for an n_jobs-independent ordering. 927 928 Returns: 929 DataFrame with one row per mito instance: 930 label | mito_volume_nm3 | crista_volume_nm3 | crista_fraction | 931 contact_voxel_count | crista_junction_count | contact_volume_nm3 | 932 avg_crista_to_membrane_nm | mean_nn_junction_distance_nm | median_nn_junction_distance_nm | 933 junction_clustering_index | crista_orientation_anisotropy | cristae_surface_area_nm2 | 934 mito_surface_area_nm2 | crista_to_mito_surface_ratio | avg_thickness_nm. 935 cristae_surface_area_nm2 is the crista surface area; crista_to_mito_surface_ratio is 936 crista surface / mitochondrial outer-membrane surface (can exceed 1 for folded cristae). 937 The *_nn_junction_distance_nm columns are geodesic nearest-neighbour distances between 938 crista-membrane junctions along the membrane; junction_clustering_index is a Clark-Evans 939 index (< 1 clustered, ~ 1 random, > 1 dispersed). ``crista_orientation_anisotropy`` is 940 computed at full resolution for ``method="exact"``, on a downsampled crop (relative-only, 941 not comparable) for ``method="fast"``, and left NaN for ``method="skip"``. 942 """ 943 if method not in ("fast", "exact", "skip"): 944 raise ValueError(f"method must be 'fast', 'exact', or 'skip', got {method!r}") 945 if membrane_mask is None: 946 membrane_mask, lumen_mask = approximate_membrane( 947 mito_segmentation, voxel_size, membrane_thickness_nm, border_gap_nm, 948 n_jobs=n_jobs, membrane_mode=membrane_mode, return_lumen=True, 949 ) 950 951 ndim = mito_segmentation.ndim 952 sampling = _to_sampling(voxel_size, ndim) 953 voxel_vol = float(np.prod(sampling)) 954 crista_binary = crista_mask.astype(bool) 955 vol_shape = mito_segmentation.shape 956 border_radius = _gap_radius(voxel_size, membrane_thickness_nm, border_gap_nm, ndim) 957 958 tasks = [] 959 for prop in regionprops(mito_segmentation): 960 bbox = prop.bbox 961 slices = tuple(slice(bbox[i], bbox[i + ndim]) for i in range(ndim)) 962 lumen_crop = None if lumen_mask is None else lumen_mask[slices] 963 tasks.append(( 964 int(prop.label), bbox, 965 mito_segmentation[slices], crista_binary[slices], membrane_mask[slices], 966 lumen_crop, 967 )) 968 total = len(tasks) 969 970 def _run(task, inner_n_jobs): 971 label, bbox, mito_crop, crista_crop, membrane_crop, lumen_crop = task 972 return _single_mito_row( 973 label, bbox, mito_crop, crista_crop, membrane_crop, 974 voxel_size, sampling, voxel_vol, vol_shape, border_radius, 975 method=method, inner_n_jobs=inner_n_jobs, lumen_crop=lumen_crop, 976 ) 977 978 n_workers = os.cpu_count() if n_jobs == -1 else max(1, n_jobs) 979 across = n_workers > 1 and total >= n_workers 980 981 rows = [] 982 983 def _consume(results): 984 for i, row in enumerate( 985 tqdm(results, total=total, desc="Cristae analysis", disable=not verbose), start=1 986 ): 987 rows.append(row) 988 if progress_callback is not None: 989 progress_callback(i, total) 990 991 if across: 992 max_voxels = max(int(task[2].size) for task in tasks) 993 across_workers = _bounded_workers(n_jobs, per_worker_bytes=max_voxels * 40) 994 # Parallelise across mitochondria with a thread pool (the heavy per-mito stages — structure 995 # tensor, EDT, geodesics — are GIL-releasing C++). Each worker's inner stages run 996 # single-threaded (``_run(task, 1)`` passes ``number_of_threads=1`` down to the EDT/geodesic 997 # solvers) so the across-mito threads do not oversubscribe the cores. Results stream in as they 998 # complete (``as_completed``) to drive the progress bar; rows are label-sorted below. 999 with futures.ThreadPoolExecutor(across_workers) as tp: 1000 submitted = [tp.submit(_run, task, 1) for task in tasks] 1001 _consume(future.result() for future in futures.as_completed(submitted)) 1002 else: 1003 _consume(_run(task, n_workers) for task in tasks) 1004 1005 rows.sort(key=lambda row: row["mito_label_id"]) 1006 return pd.DataFrame(rows)
Compute all crista metrics organised by mitochondrial instance.
Arguments:
- crista_mask: Binary crista segmentation (global volume).
- mito_segmentation: Instance label array (background = 0).
- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
- membrane_mask: Precomputed membrane mask; recomputed if None.
- membrane_thickness_nm: Membrane shell thickness used if membrane_mask is None.
- border_gap_nm: Border suppression distance passed to approximate_membrane; defaults to membrane_thickness_nm when None.
- method: How the crista orientation anisotropy is computed — this is the ONLY metric that
differs between modes; surface areas (marching cubes), junction distances (geodesic
along the membrane) and thickness/proximity (EDT) are computed identically for all of
them.
"skip"(default) does not compute orientation at all (crista_orientation_anisotropyis NaN) and is the fastest — use it when only the other metrics are needed."fast"computes the anisotropy on a 2× downsampled crista crop (~8× cheaper — the structure tensor is by far the dominant cost); the resulting value is a relative indicator that preserves the ordering between mitochondria but is systematically different in magnitude from the full-resolution value and is NOT comparable tomethod="exact"."exact"computes the anisotropy from the full-resolution structure tensor (use it when the magnitude must be precise). - n_jobs: Number of workers for processing mitochondria in parallel (they are
independent). 1 (default) runs serially; other values use a
concurrent.futuresthread pool (-1 = all cores). Results are identical regardless of n_jobs. - verbose: If True, show a terminal tqdm progress bar over mitochondria.
- progress_callback: Optional callable invoked once per completed mitochondrion with (completed_count, total_count) — e.g. to drive a napari progress bar. It is always called from the calling thread (the futures are consumed here as they complete), so GUI updates from it need no cross-thread marshaling.
- membrane_mode: How the membrane shell is built when
membrane_maskis None —"slice_2d"(default, per-Z-slice 2D erosion, z-parallel) or"shell_3d"(connected 3D shell). Seeapproximate_membrane(). - lumen_mask: Optional eroded-mito interior matching
membrane_mask, i.e. the second return value ofapproximate_membrane(..., return_lumen=True). It is the clean single-wall surface the junction geodesics run along. Only used whenmembrane_maskis also supplied (when the membrane is built here, the matching lumen is derived automatically); when neither is available the geodesic mesh falls back tomito & ~membrane, which is contaminated by the membrane's border-gap suppression near clipped volume faces.
The junction nearest-neighbour distances are geodesics along the eroded-mito surface mesh
(bioimage_cpp.distance.geodesic_distances_mesh); for a mito with no usable mesh (empty
membrane / degenerate mesh) those columns are NaN.
Implementation notes: each mito is pre-cropped to its bounding box by basic slicing (views, so
cropping is memory-free). Parallelism is adaptive and single-level (never oversubscribed): with
many mitochondria the work is parallelised across them on a concurrent.futures
ThreadPoolExecutor — the heavy per-mito stages (structure tensor, EDT, geodesics) are
GIL-releasing C++, so threads scale them — with each worker's inner stages kept single-threaded
(the EDT/geodesic solvers are called with number_of_threads=1); with few mitochondria they run
serially and each mito's junction-distance stage gets all cores. The concurrent worker count is
additionally capped so the combined per-mito working set (tensor components + label crops,
~40 bytes/voxel of the largest mito) fits in RAM. Results stream in as they complete and are
finally sorted by label for an n_jobs-independent ordering.
Returns:
DataFrame with one row per mito instance: label | mito_volume_nm3 | crista_volume_nm3 | crista_fraction | contact_voxel_count | crista_junction_count | contact_volume_nm3 | avg_crista_to_membrane_nm | mean_nn_junction_distance_nm | median_nn_junction_distance_nm | junction_clustering_index | crista_orientation_anisotropy | cristae_surface_area_nm2 | mito_surface_area_nm2 | crista_to_mito_surface_ratio | avg_thickness_nm. cristae_surface_area_nm2 is the crista surface area; crista_to_mito_surface_ratio is crista surface / mitochondrial outer-membrane surface (can exceed 1 for folded cristae). The *_nn_junction_distance_nm columns are geodesic nearest-neighbour distances between crista-membrane junctions along the membrane; junction_clustering_index is a Clark-Evans index (< 1 clustered, ~ 1 random, > 1 dispersed).
crista_orientation_anisotropyis computed at full resolution formethod="exact", on a downsampled crop (relative-only, not comparable) formethod="fast", and left NaN formethod="skip".