|
| 1 | +from typing import Dict, Optional, Tuple, Union |
| 2 | + |
| 3 | +import numpy as np |
| 4 | +import pandas as pd |
| 5 | +from scipy.ndimage import binary_dilation, binary_erosion, distance_transform_edt, gaussian_filter |
| 6 | +from scipy.ndimage import label as ndimage_label |
| 7 | +from skimage.measure import marching_cubes, mesh_surface_area, regionprops |
| 8 | +from skimage.morphology import ball, disk, local_maxima |
| 9 | +from skimage.segmentation import find_boundaries |
| 10 | + |
| 11 | + |
| 12 | +# --------------------------------------------------------------------------- |
| 13 | +# Internal helpers |
| 14 | +# --------------------------------------------------------------------------- |
| 15 | + |
| 16 | +def _to_sampling(voxel_size: Union[float, Dict[str, float]], ndim: int) -> np.ndarray: |
| 17 | + axes = ("z", "y", "x") if ndim == 3 else ("y", "x") |
| 18 | + if isinstance(voxel_size, dict): |
| 19 | + return np.array([voxel_size[ax] for ax in axes[:ndim]], dtype=float) |
| 20 | + return np.full(ndim, float(voxel_size)) |
| 21 | + |
| 22 | + |
| 23 | +def _voxel_radius(thickness_nm: float, voxel_size: Union[float, Dict[str, float]], ndim: int) -> int: |
| 24 | + return max(1, int(round(thickness_nm / float(np.mean(_to_sampling(voxel_size, ndim)))))) |
| 25 | + |
| 26 | + |
| 27 | +def _voxel_radius_xy(thickness_nm: float, voxel_size: Union[float, Dict[str, float]]) -> int: |
| 28 | + """Membrane radius in XY pixels — uses only the Y and X voxel sizes.""" |
| 29 | + if isinstance(voxel_size, dict): |
| 30 | + xy_nm = (voxel_size["y"] + voxel_size["x"]) / 2.0 |
| 31 | + else: |
| 32 | + xy_nm = float(voxel_size) |
| 33 | + return max(1, int(round(thickness_nm / xy_nm))) |
| 34 | + |
| 35 | + |
| 36 | +def _struct_elem(radius: int, ndim: int) -> np.ndarray: |
| 37 | + return ball(radius) if ndim == 3 else disk(radius) |
| 38 | + |
| 39 | + |
| 40 | +def _border_zone(shape: tuple, radius: int) -> np.ndarray: |
| 41 | + """Boolean mask that is True within `radius` voxels of any face of the volume.""" |
| 42 | + mask = np.zeros(shape, dtype=bool) |
| 43 | + for ax in range(len(shape)): |
| 44 | + idx_lo = [slice(None)] * len(shape) |
| 45 | + idx_hi = [slice(None)] * len(shape) |
| 46 | + idx_lo[ax] = slice(0, radius) |
| 47 | + idx_hi[ax] = slice(shape[ax] - radius, None) |
| 48 | + mask[tuple(idx_lo)] = True |
| 49 | + mask[tuple(idx_hi)] = True |
| 50 | + return mask |
| 51 | + |
| 52 | + |
| 53 | +# --------------------------------------------------------------------------- |
| 54 | +# Membrane approximation |
| 55 | +# --------------------------------------------------------------------------- |
| 56 | + |
| 57 | +def approximate_membrane( |
| 58 | + mito_segmentation: np.ndarray, |
| 59 | + voxel_size: Union[float, Dict[str, float]], |
| 60 | + membrane_thickness_nm: float = 8.0, |
| 61 | + border_gap_nm: Optional[float] = None, |
| 62 | +) -> np.ndarray: |
| 63 | + """Approximate the mitochondrial membrane as an inner shell of the segmentation. |
| 64 | +
|
| 65 | + For 3D inputs the erosion is applied independently per Z-slice using a 2D disk, |
| 66 | + so a mitochondrion that changes shape rapidly along Z does not cause the membrane |
| 67 | + shell to bleed into neighbouring slices in XY. For 2D inputs a single erosion is |
| 68 | + applied to the whole array. |
| 69 | +
|
| 70 | + Membrane voxels within border_gap_nm of any volume face are suppressed to avoid |
| 71 | + treating clipped mito edges as membrane. |
| 72 | +
|
| 73 | + Args: |
| 74 | + mito_segmentation: Instance label array (background = 0). |
| 75 | + voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. |
| 76 | + membrane_thickness_nm: Thickness of the membrane shell in nm. |
| 77 | + border_gap_nm: Distance from each volume face within which membrane voxels are |
| 78 | + suppressed. Defaults to membrane_thickness_nm when None. |
| 79 | +
|
| 80 | + Returns: |
| 81 | + membrane_mask: Binary mask of the mitochondrial membrane (outermost shell), |
| 82 | + with border-adjacent voxels zeroed out. |
| 83 | + """ |
| 84 | + ndim = mito_segmentation.ndim |
| 85 | + mito_binary = mito_segmentation > 0 |
| 86 | + |
| 87 | + if ndim == 3: |
| 88 | + membrane_radius = _voxel_radius_xy(membrane_thickness_nm, voxel_size) |
| 89 | + struct = disk(membrane_radius) |
| 90 | + membrane_mask = np.zeros_like(mito_binary) |
| 91 | + for z in range(mito_binary.shape[0]): |
| 92 | + sl = mito_binary[z] |
| 93 | + membrane_mask[z] = sl & ~binary_erosion(sl, structure=struct, border_value=1) |
| 94 | + else: |
| 95 | + membrane_radius = _voxel_radius(membrane_thickness_nm, voxel_size, ndim) |
| 96 | + membrane_mask = mito_binary & ~binary_erosion(mito_binary, structure=disk(membrane_radius), border_value=1) |
| 97 | + |
| 98 | + gap_nm = border_gap_nm if border_gap_nm is not None else membrane_thickness_nm |
| 99 | + gap_radius = _voxel_radius(gap_nm, voxel_size, ndim) |
| 100 | + membrane_mask &= ~_border_zone(mito_segmentation.shape, gap_radius) |
| 101 | + return membrane_mask.astype(bool) |
| 102 | + |
| 103 | + |
| 104 | +# --------------------------------------------------------------------------- |
| 105 | +# Orientation |
| 106 | +# --------------------------------------------------------------------------- |
| 107 | + |
| 108 | +def compute_crista_orientation( |
| 109 | + crista_mask: np.ndarray, |
| 110 | + voxel_size: Union[float, Dict[str, float]], |
| 111 | + neighborhood_size_nm: float = 30.0, |
| 112 | +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: |
| 113 | + """Compute dominant crista orientation via structure tensor. |
| 114 | +
|
| 115 | + Args: |
| 116 | + crista_mask: Binary crista segmentation. |
| 117 | + voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. |
| 118 | + neighborhood_size_nm: Gaussian smoothing radius in nm for tensor averaging. |
| 119 | +
|
| 120 | + Returns: |
| 121 | + eigenvalues: (..., ndim) sorted ascending. |
| 122 | + eigenvectors: (..., ndim, ndim) — columns are principal directions. |
| 123 | + anisotropy: (...) — λ_max / (λ_min + ε) per voxel. High values indicate a strongly |
| 124 | + directional crista (e.g. parallel lamellae); low values indicate isotropic or |
| 125 | + tubular/disordered morphology. |
| 126 | + """ |
| 127 | + ndim = crista_mask.ndim |
| 128 | + sampling = _to_sampling(voxel_size, ndim) |
| 129 | + sigma = neighborhood_size_nm / sampling |
| 130 | + |
| 131 | + grads = np.gradient(crista_mask.astype(np.float32), *sampling.tolist()) |
| 132 | + |
| 133 | + J = np.zeros(crista_mask.shape + (ndim, ndim), dtype=np.float32) |
| 134 | + for i in range(ndim): |
| 135 | + for j in range(i, ndim): |
| 136 | + s = gaussian_filter(grads[i] * grads[j], sigma=sigma) |
| 137 | + J[..., i, j] = s |
| 138 | + J[..., j, i] = s |
| 139 | + |
| 140 | + eigenvalues, eigenvectors = np.linalg.eigh(J) |
| 141 | + anisotropy = eigenvalues[..., -1] / (eigenvalues[..., 0] + 1e-10) |
| 142 | + return eigenvalues, eigenvectors, anisotropy |
| 143 | + |
| 144 | + |
| 145 | +# --------------------------------------------------------------------------- |
| 146 | +# Proximity |
| 147 | +# --------------------------------------------------------------------------- |
| 148 | + |
| 149 | +def compute_crista_proximity( |
| 150 | + crista_mask: np.ndarray, |
| 151 | + membrane_mask: np.ndarray, |
| 152 | + voxel_size: Union[float, Dict[str, float]], |
| 153 | +) -> Tuple[np.ndarray, Dict[str, float]]: |
| 154 | + """Distance from each crista voxel to the nearest membrane voxel (nm). |
| 155 | +
|
| 156 | + Args: |
| 157 | + crista_mask: Binary crista segmentation. |
| 158 | + membrane_mask: Binary membrane mask (OM or IMM). |
| 159 | + voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. |
| 160 | +
|
| 161 | + Returns: |
| 162 | + distance_map: Per-voxel distance to membrane (nm); zero outside crista. |
| 163 | + summary_stats: min_nm, median_nm, max_nm. |
| 164 | + """ |
| 165 | + sampling = _to_sampling(voxel_size, crista_mask.ndim) |
| 166 | + dist = distance_transform_edt(~membrane_mask.astype(bool), sampling=sampling.tolist()) |
| 167 | + crista_dists = dist[crista_mask.astype(bool)] |
| 168 | + |
| 169 | + if crista_dists.size == 0: |
| 170 | + summary: Dict[str, float] = {"min_nm": np.nan, "median_nm": np.nan, "max_nm": np.nan} |
| 171 | + else: |
| 172 | + summary = { |
| 173 | + "min_nm": float(crista_dists.min()), |
| 174 | + "median_nm": float(np.median(crista_dists)), |
| 175 | + "max_nm": float(crista_dists.max()), |
| 176 | + } |
| 177 | + |
| 178 | + distance_map = np.zeros(crista_mask.shape, dtype=np.float32) |
| 179 | + distance_map[crista_mask.astype(bool)] = crista_dists |
| 180 | + return distance_map, summary |
| 181 | + |
| 182 | + |
| 183 | +def compute_crista_density( |
| 184 | + crista_mask: np.ndarray, |
| 185 | + mito_mask: np.ndarray, |
| 186 | + voxel_size: Union[float, Dict[str, float]], |
| 187 | +) -> Dict[str, float]: |
| 188 | + """Volume fraction of crista within a mitochondrion. |
| 189 | +
|
| 190 | + Args: |
| 191 | + crista_mask: Binary crista segmentation. |
| 192 | + mito_mask: Binary or instance mito mask (> 0 = inside mito). |
| 193 | + voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. |
| 194 | +
|
| 195 | + Returns: |
| 196 | + Dict with crista_volume_nm3, mito_volume_nm3, crista_fraction. |
| 197 | + """ |
| 198 | + sampling = _to_sampling(voxel_size, crista_mask.ndim) |
| 199 | + voxel_vol = float(np.prod(sampling)) |
| 200 | + mito_binary = mito_mask > 0 |
| 201 | + |
| 202 | + mito_vol = float(mito_binary.sum()) * voxel_vol |
| 203 | + crista_vol = float((crista_mask.astype(bool) & mito_binary).sum()) * voxel_vol |
| 204 | + return { |
| 205 | + "crista_volume_nm3": crista_vol, |
| 206 | + "mito_volume_nm3": mito_vol, |
| 207 | + "crista_fraction": crista_vol / mito_vol if mito_vol > 0 else np.nan, |
| 208 | + } |
| 209 | + |
| 210 | + |
| 211 | +# --------------------------------------------------------------------------- |
| 212 | +# Contact sites |
| 213 | +# --------------------------------------------------------------------------- |
| 214 | + |
| 215 | +def detect_contact_sites( |
| 216 | + crista_mask: np.ndarray, |
| 217 | + membrane_mask: np.ndarray, |
| 218 | + voxel_size: Union[float, Dict[str, float]], |
| 219 | +) -> Tuple[np.ndarray, Dict[str, float]]: |
| 220 | + """Detect crista-membrane contact sites by voxel adjacency (26-connectivity in 3D). |
| 221 | +
|
| 222 | + Contact = crista voxels that are face-, edge-, or corner-adjacent to any membrane voxel. |
| 223 | +
|
| 224 | + Args: |
| 225 | + crista_mask: Binary crista segmentation. |
| 226 | + membrane_mask: Binary mitochondrial membrane mask. |
| 227 | + voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. |
| 228 | +
|
| 229 | + Returns: |
| 230 | + contact_coords: (N, ndim) integer array of contact voxel coordinates. |
| 231 | + summary: contact_voxel_count, crista_junction_count, contact_volume_nm3. |
| 232 | + """ |
| 233 | + ndim = crista_mask.ndim |
| 234 | + sampling = _to_sampling(voxel_size, ndim) |
| 235 | + voxel_vol = float(np.prod(sampling)) |
| 236 | + |
| 237 | + dilated_imm = binary_dilation(membrane_mask.astype(bool), structure=_struct_elem(1, ndim)) |
| 238 | + contact_mask = crista_mask.astype(bool) & dilated_imm |
| 239 | + |
| 240 | + connectivity_struct = np.ones(ndim * (3,), dtype=bool) |
| 241 | + _, n_regions = ndimage_label(contact_mask, structure=connectivity_struct) |
| 242 | + contact_coords = np.argwhere(contact_mask) |
| 243 | + |
| 244 | + return contact_coords, { |
| 245 | + "contact_voxel_count": int(contact_coords.shape[0]), |
| 246 | + "crista_junction_count": int(n_regions), |
| 247 | + "contact_volume_nm3": float(contact_coords.shape[0]) * voxel_vol, |
| 248 | + } |
| 249 | + |
| 250 | + |
| 251 | +# --------------------------------------------------------------------------- |
| 252 | +# Morphology |
| 253 | +# --------------------------------------------------------------------------- |
| 254 | + |
| 255 | +def compute_crista_morphology( |
| 256 | + crista_mask: np.ndarray, |
| 257 | + voxel_size: Union[float, Dict[str, float]], |
| 258 | + method: str = "both", |
| 259 | +) -> Dict[str, float]: |
| 260 | + """Compute crista shape metrics from binary mask. |
| 261 | +
|
| 262 | + Args: |
| 263 | + crista_mask: Binary crista segmentation. |
| 264 | + voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. |
| 265 | + method: "area" | "medial_axis" | "both". |
| 266 | +
|
| 267 | + Returns: |
| 268 | + Dict with total_surface_area_nm2 (area/both) and avg_thickness_nm (medial_axis/both). |
| 269 | + avg_thickness_nm is 2 × mean distance-transform value at skeleton voxels. |
| 270 | + """ |
| 271 | + if method not in ("area", "medial_axis", "both"): |
| 272 | + raise ValueError(f"method must be 'area', 'medial_axis', or 'both', got {method!r}") |
| 273 | + |
| 274 | + sampling = _to_sampling(voxel_size, crista_mask.ndim) |
| 275 | + spacing = tuple(float(s) for s in sampling) |
| 276 | + result: Dict[str, float] = {} |
| 277 | + |
| 278 | + if method in ("area", "both"): |
| 279 | + verts, faces, _, _ = marching_cubes(crista_mask.astype(np.float32), level=0.5, spacing=spacing) |
| 280 | + result["total_surface_area_nm2"] = float(mesh_surface_area(verts, faces)) |
| 281 | + |
| 282 | + if method in ("medial_axis", "both"): |
| 283 | + dist = distance_transform_edt(crista_mask.astype(bool), sampling=spacing) |
| 284 | + # Local maxima of the EDT form the medial axis; 2 × distance there = local thickness. |
| 285 | + ridges = local_maxima(dist) & crista_mask.astype(bool) |
| 286 | + ridge_dists = dist[ridges] |
| 287 | + result["avg_thickness_nm"] = float(2.0 * np.mean(ridge_dists)) if ridge_dists.size > 0 else np.nan |
| 288 | + |
| 289 | + return result |
| 290 | + |
| 291 | + |
| 292 | +# --------------------------------------------------------------------------- |
| 293 | +# Per-mitochondrion statistics |
| 294 | +# --------------------------------------------------------------------------- |
| 295 | + |
| 296 | +def compute_mito_crista_statistics( |
| 297 | + crista_mask: np.ndarray, |
| 298 | + mito_segmentation: np.ndarray, |
| 299 | + voxel_size: Union[float, Dict[str, float]], |
| 300 | + membrane_mask: Optional[np.ndarray] = None, |
| 301 | + membrane_thickness_nm: float = 8.0, |
| 302 | + border_gap_nm: Optional[float] = None, |
| 303 | +) -> pd.DataFrame: |
| 304 | + """Compute all crista metrics organised by mitochondrial instance. |
| 305 | +
|
| 306 | + Args: |
| 307 | + crista_mask: Binary crista segmentation (global volume). |
| 308 | + mito_segmentation: Instance label array (background = 0). |
| 309 | + voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys. |
| 310 | + membrane_mask: Precomputed membrane mask; recomputed if None. |
| 311 | + membrane_thickness_nm: Membrane shell thickness used if membrane_mask is None. |
| 312 | + border_gap_nm: Border suppression distance passed to approximate_membrane; |
| 313 | + defaults to membrane_thickness_nm when None. |
| 314 | +
|
| 315 | + Returns: |
| 316 | + DataFrame with one row per mito instance: |
| 317 | + label | mito_volume_nm3 | crista_volume_nm3 | crista_fraction | |
| 318 | + contact_voxel_count | crista_junction_count | contact_volume_nm3 | |
| 319 | + avg_crista_to_membrane_nm | crista_orientation_anisotropy | total_surface_area_nm2 | avg_thickness_nm |
| 320 | + """ |
| 321 | + if membrane_mask is None: |
| 322 | + membrane_mask = approximate_membrane( |
| 323 | + mito_segmentation, voxel_size, membrane_thickness_nm, border_gap_nm |
| 324 | + ) |
| 325 | + |
| 326 | + ndim = mito_segmentation.ndim |
| 327 | + sampling = _to_sampling(voxel_size, ndim) |
| 328 | + voxel_vol = float(np.prod(sampling)) |
| 329 | + crista_binary = crista_mask.astype(bool) |
| 330 | + vol_shape = mito_segmentation.shape |
| 331 | + effective_gap_nm = border_gap_nm if border_gap_nm is not None else membrane_thickness_nm |
| 332 | + border_radius = _voxel_radius(effective_gap_nm, voxel_size, ndim) |
| 333 | + |
| 334 | + rows = [] |
| 335 | + for prop in regionprops(mito_segmentation): |
| 336 | + mito_id = prop.label |
| 337 | + bbox = prop.bbox |
| 338 | + slices = tuple(slice(bbox[i], bbox[i + ndim]) for i in range(ndim)) |
| 339 | + touches_border = any( |
| 340 | + bbox[i] < border_radius or bbox[i + ndim] > vol_shape[i] - border_radius |
| 341 | + for i in range(ndim) |
| 342 | + ) |
| 343 | + |
| 344 | + mito_local = mito_segmentation[slices] == mito_id |
| 345 | + crista_local = crista_binary[slices] & mito_local |
| 346 | + membrane_local = membrane_mask[slices] & mito_local |
| 347 | + |
| 348 | + mito_vol = float(mito_local.sum()) * voxel_vol |
| 349 | + crista_vol = float(crista_local.sum()) * voxel_vol |
| 350 | + |
| 351 | + has_crista = crista_local.any() |
| 352 | + has_membrane = membrane_local.any() |
| 353 | + |
| 354 | + if has_crista and has_membrane: |
| 355 | + _, contact_summary = detect_contact_sites(crista_local, membrane_local, voxel_size) |
| 356 | + _, proximity = compute_crista_proximity(crista_local, membrane_local, voxel_size) |
| 357 | + else: |
| 358 | + contact_summary = {"contact_voxel_count": 0, "crista_junction_count": 0, "contact_volume_nm3": 0.0} |
| 359 | + proximity = {"median_nm": np.nan} |
| 360 | + |
| 361 | + if has_crista: |
| 362 | + _, _, anisotropy = compute_crista_orientation(crista_local, voxel_size) |
| 363 | + crista_orientation_anisotropy = float(np.mean(anisotropy[crista_local])) |
| 364 | + morph = compute_crista_morphology(crista_local, voxel_size) |
| 365 | + else: |
| 366 | + crista_orientation_anisotropy = np.nan |
| 367 | + morph = {"total_surface_area_nm2": np.nan, "avg_thickness_nm": np.nan} |
| 368 | + |
| 369 | + rows.append({ |
| 370 | + "mito_label_id": int(mito_id), |
| 371 | + "mito_touches_border": touches_border, |
| 372 | + "mito_volume_nm3": mito_vol, |
| 373 | + "crista_volume_nm3": crista_vol, |
| 374 | + "crista_fraction": crista_vol / mito_vol if mito_vol > 0 else np.nan, |
| 375 | + "contact_voxel_count": contact_summary["contact_voxel_count"], |
| 376 | + "crista_junction_count": contact_summary["crista_junction_count"], |
| 377 | + "contact_volume_nm3": contact_summary["contact_volume_nm3"], |
| 378 | + "avg_crista_to_membrane_nm": proximity["median_nm"], |
| 379 | + "crista_orientation_anisotropy": crista_orientation_anisotropy, |
| 380 | + "total_surface_area_nm2": morph.get("total_surface_area_nm2", np.nan), |
| 381 | + "avg_thickness_nm": morph.get("avg_thickness_nm", np.nan), |
| 382 | + }) |
| 383 | + |
| 384 | + return pd.DataFrame(rows) |
0 commit comments