@@ -37,6 +37,17 @@ def _voxel_radius_xy(thickness_nm: float, voxel_size: Union[float, Dict[str, flo
3737 return max (1 , int (round (thickness_nm / xy_nm )))
3838
3939
40+ def _gap_radius (
41+ voxel_size : Union [float , Dict [str , float ]],
42+ membrane_thickness_nm : float ,
43+ border_gap_nm : Optional [float ],
44+ ndim : int ,
45+ ) -> int :
46+ """Border-zone / mesh-trim radius in voxels; ``border_gap_nm`` defaults to ``membrane_thickness_nm``."""
47+ gap_nm = border_gap_nm if border_gap_nm is not None else membrane_thickness_nm
48+ return _voxel_radius (gap_nm , voxel_size , ndim )
49+
50+
4051def _border_zone (shape : tuple , radius : int ) -> np .ndarray :
4152 """Boolean mask that is True within `radius` voxels of any face of the volume."""
4253 mask = np .zeros (shape , dtype = bool )
@@ -89,7 +100,7 @@ def _surface_mesh(
89100 padded = np .pad (binary .astype (np .float32 ), pad_width )
90101 verts , faces , _ , _ = marching_cubes (padded , level = 0.5 , spacing = tuple (float (s ) for s in sampling ))
91102 pad_before = np .array ([pw [0 ] for pw in pad_width ], dtype = float )
92- verts = verts - pad_before * np .asarray (sampling , dtype = float ) # padded frame -> unpadded mask frame
103+ verts = verts - pad_before * np .asarray (sampling , dtype = float )
93104 return verts , faces
94105
95106
@@ -152,7 +163,7 @@ def _open_trimmed_mesh(
152163 if mesh is None :
153164 return None
154165 verts , faces = mesh
155- verts = verts + np .array (lo , dtype = float ) * np .asarray (sampling , dtype = float ) # -> original frame
166+ verts = verts + np .array (lo , dtype = float ) * np .asarray (sampling , dtype = float )
156167 return verts , faces
157168
158169
@@ -189,7 +200,7 @@ def _available_memory_bytes() -> int:
189200 try :
190201 return int (os .sysconf ("SC_AVPHYS_PAGES" ) * os .sysconf ("SC_PAGE_SIZE" ))
191202 except Exception :
192- return 4 * 1024 ** 3 # conservative fallback
203+ return 4 * 1024 ** 3
193204
194205
195206def _bounded_workers (n_jobs : int , per_worker_bytes : int , fraction : float = 0.5 ) -> int :
@@ -242,6 +253,14 @@ def approximate_membrane(
242253 (and left open there) at mesh time by :func:`_open_trimmed_mesh`, which requires the untrimmed
243254 interior to produce an open cut rather than a fabricated cap.
244255
256+ Implementation notes: ``"slice_2d"`` erodes each Z-slice on the mito XY bbox with a
257+ ``membrane_radius`` margin, so the cropped ``border_value=1`` erosion matches eroding the full
258+ slice (empty slices are skipped), then adds Z-caps via a separable Z-only line erosion (which
259+ inspects only the same column, so no XY-shape bleed); ``border_value=1`` leaves ends clipped by a
260+ volume Z-face uncapped, and the ``border_gap`` removal clears anything near a face, so only true
261+ ends are capped. ``"shell_3d"`` erodes the *merged* binary in each instance's padded bbox so
262+ instances that share a boundary are handled together.
263+
245264 Args:
246265 mito_segmentation: Instance label array (background = 0).
247266 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
@@ -268,7 +287,6 @@ def approximate_membrane(
268287 mito_binary = mito_segmentation > 0
269288
270289 if membrane_mode == "shell_3d" :
271- # Full 3D erosion → single connected shell (incl. Z-caps), per instance on its padded bbox.
272290 sampling = _to_sampling (voxel_size , ndim )
273291 k = max (1 , int (round (float (membrane_thickness_nm ) / float (np .mean (sampling )))))
274292 struct = np .ones ((3 ,) * ndim , dtype = bool )
@@ -280,15 +298,12 @@ def approximate_membrane(
280298 slice (max (0 , bbox [i ] - k ), min (mito_segmentation .shape [i ], bbox [i + ndim ] + k ))
281299 for i in range (ndim )
282300 )
283- sub = mito_binary [sl ] # merged mito (all instances in the crop) → shared boundaries
301+ sub = mito_binary [sl ]
284302 eroded = binary_erosion (sub , structure = struct , iterations = k , border_value = 1 )
285303 cur = mito_segmentation [sl ] == prop .label
286304 membrane_mask [sl ] |= cur & ~ eroded
287305 lumen_mask [sl ] |= cur & eroded
288306 elif ndim == 3 :
289- # Per-Z-slice 2D erosion (no z-bleed), parallelised over Z. Only the mito XY bounding box
290- # needs eroding; a `membrane_radius` margin makes the cropped erosion (border_value=1)
291- # identical to eroding the full slice, and empty slices are skipped.
292307 membrane_radius = _voxel_radius_xy (membrane_thickness_nm , voxel_size )
293308 struct = disk (membrane_radius )
294309 membrane_mask = np .zeros_like (mito_binary )
@@ -306,7 +321,7 @@ def _erode_slice(z):
306321 if not sl .any ():
307322 return z , None
308323 eroded = binary_erosion (sl , structure = struct , border_value = 1 )
309- return z , (sl & ~ eroded , eroded ) # (membrane shell, lumen interior)
324+ return z , (sl & ~ eroded , eroded )
310325
311326 z_range = range (int (zmin ), int (zmax ))
312327 if n_jobs == 1 :
@@ -322,12 +337,6 @@ def _erode_slice(z):
322337 membrane_mask [z , y0 :y1 , x0 :x1 ] = mem_sl
323338 lumen_mask [z , y0 :y1 , x0 :x1 ] = lum_sl
324339
325- # Z-caps: the per-slice XY erosion never caps a column-end, so a mito that truly ends in Z
326- # (its top/bottom face, not a clipped volume face) would have no membrane there. Erode
327- # along Z only (a line structuring element that inspects just the same (y, x) column, so no
328- # XY-shape bleeds across slices) and treat the removed voxels as membrane. border_value=1
329- # leaves a clipped end (mito at a volume Z-face) uneroded — and the border_gap suppression
330- # below clears anything near a face — so only true ends are capped.
331340 k_z = max (1 , int (round (float (membrane_thickness_nm ) / float (_to_sampling (voxel_size , ndim )[0 ]))))
332341 z0m , z1m = max (0 , int (zmin ) - k_z ), min (mito_binary .shape [0 ], int (zmax ) + k_z )
333342 sub = mito_binary [z0m :z1m , y0 :y1 , x0 :x1 ]
@@ -340,8 +349,7 @@ def _erode_slice(z):
340349 membrane_mask = mito_binary & ~ eroded
341350 lumen_mask = mito_binary & eroded
342351
343- gap_nm = border_gap_nm if border_gap_nm is not None else membrane_thickness_nm
344- gap_radius = _voxel_radius (gap_nm , voxel_size , ndim )
352+ gap_radius = _gap_radius (voxel_size , membrane_thickness_nm , border_gap_nm , ndim )
345353 membrane_mask &= ~ _border_zone (mito_segmentation .shape , gap_radius )
346354 if return_lumen :
347355 return membrane_mask .astype (bool ), lumen_mask .astype (bool )
@@ -362,6 +370,13 @@ def compute_crista_orientation(
362370 Uses ``bioimage_cpp.filters.structure_tensor_eigenvalues`` (a fast C++ routine). Only the
363371 anisotropy is produced (the principal directions / eigenvectors are not computed).
364372
373+ The structure tensor's outer/integration sigma is ``neighborhood_size_nm`` per axis (in voxels);
374+ the inner (derivative) sigma must be > 0, so a minimal 1-voxel scale is used. Eigenvalues are
375+ non-negative in theory, but the solver emits tiny negatives for near-rank-deficient tensors
376+ (degenerate sheets/tubes), so they are clamped to 0 and the ratio is taken as
377+ ``max/min`` over the trailing axis — order-agnostic and sign-safe, so a tiny negative minor
378+ eigenvalue cannot flip the denominator and blow the ratio up.
379+
365380 Args:
366381 crista_mask: Binary crista segmentation.
367382 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
@@ -376,15 +391,9 @@ def compute_crista_orientation(
376391 ndim = crista_mask .ndim
377392 sampling = _to_sampling (voxel_size , ndim )
378393
379- # outer_sigma is the integration scale (per axis, in voxels); inner_sigma is the derivative
380- # smoothing and must be > 0 (0 is rejected), so use a minimal 1-voxel scale.
381394 outer_sigma = [float (s ) for s in (neighborhood_size_nm / sampling )]
382395 inner_sigma = 1.0
383396 evals = structure_tensor_eigenvalues (crista_mask .astype (np .float32 ), inner_sigma , outer_sigma )
384- # Structure-tensor eigenvalues are non-negative in theory, but the solver emits tiny negatives for
385- # near-rank-deficient tensors (degenerate sheets/tubes). Clamp them and take λ_max/λ_min via
386- # max/min over the trailing axis — order-agnostic and sign-safe, so a tiny negative minor
387- # eigenvalue can't flip the denominator and blow the ratio up.
388397 evals = np .clip (evals , 0.0 , None )
389398 anisotropy = evals .max (axis = - 1 ) / (evals .min (axis = - 1 ) + 1e-10 )
390399 return anisotropy .astype (np .float32 )
@@ -426,8 +435,6 @@ def _downsampled_orientation_anisotropy(
426435 Mean anisotropy over the downsampled crista region, or NaN if it vanishes when downsampled.
427436 """
428437 ndim = crista_mask .ndim
429- # Nearest-neighbour downsample by strided subsampling — keeps the mask binary and needs no float
430- # conversion (compute_crista_orientation casts internally).
431438 sub = crista_mask [(slice (None , None , factor ),) * ndim ]
432439 region = sub .astype (bool )
433440 if not region .any ():
@@ -482,34 +489,6 @@ def compute_crista_proximity(
482489 return distance_map , summary
483490
484491
485- def compute_crista_density (
486- crista_mask : np .ndarray ,
487- mito_mask : np .ndarray ,
488- voxel_size : Union [float , Dict [str , float ]],
489- ) -> Dict [str , float ]:
490- """Volume fraction of crista within a mitochondrion.
491-
492- Args:
493- crista_mask: Binary crista segmentation.
494- mito_mask: Binary or instance mito mask (> 0 = inside mito).
495- voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
496-
497- Returns:
498- Dict with crista_volume_nm3, mito_volume_nm3, crista_fraction.
499- """
500- sampling = _to_sampling (voxel_size , crista_mask .ndim )
501- voxel_vol = float (np .prod (sampling ))
502- mito_binary = mito_mask > 0
503-
504- mito_vol = float (mito_binary .sum ()) * voxel_vol
505- crista_vol = float ((crista_mask .astype (bool ) & mito_binary ).sum ()) * voxel_vol
506- return {
507- "crista_volume_nm3" : crista_vol ,
508- "mito_volume_nm3" : mito_vol ,
509- "crista_fraction" : crista_vol / mito_vol if mito_vol > 0 else np .nan ,
510- }
511-
512-
513492# ---------------------------------------------------------------------------
514493# Contact sites
515494# ---------------------------------------------------------------------------
@@ -582,26 +561,27 @@ def _junction_matrix_mesh(
582561 sampling: Voxel size per axis (nm), array (z, y, x) order.
583562 vertices: Mesh vertices (n_vertices, 3) in nm (unpadded mask frame).
584563 faces: Mesh triangle indices (n_faces, 3).
585- n_jobs: 1 = serial, -1 = all cores (forwarded to the C++ solver's thread count).
564+ n_jobs: Forwarded to the C++ solver's ``number_of_threads``: -1/0 map to 0 (the solver's
565+ "use hardware_concurrency"), otherwise that many threads.
586566
587567 Returns:
588568 (n, n) geodesic distance matrix in nm (0 diagonal, NaN for disconnected pairs), or None.
569+ Disconnected pairs come back from the solver as ``+inf`` and are converted to NaN.
589570 """
590571 verts = np .ascontiguousarray (vertices , dtype = np .float64 )
591572 tris = np .ascontiguousarray (faces , dtype = np .int64 )
592573 if verts .shape [0 ] == 0 or tris .shape [0 ] == 0 :
593574 return None
594575 from scipy .spatial import cKDTree
595576
596- # Mask voxel (z, y, x) sits at index * sampling in the unpadded mesh frame (_surface_mesh).
597577 points = np .asarray (centroids , dtype = float ) * sampling
598578 _ , vertex_ids = cKDTree (verts ).query (points )
599579 vertex_ids = np .atleast_1d (np .asarray (vertex_ids , dtype = np .int64 ))
600- n_threads = 0 if n_jobs in (- 1 , 0 ) else max (1 , int (n_jobs )) # bic: 0 = hardware_concurrency
580+ n_threads = 0 if n_jobs in (- 1 , 0 ) else max (1 , int (n_jobs ))
601581 dm = np .asarray (
602582 geodesic_distances_mesh (verts , tris , vertex_ids , number_of_threads = n_threads ), dtype = float
603583 )
604- dm [~ np .isfinite (dm )] = np .nan # disconnected components come back as +inf
584+ dm [~ np .isfinite (dm )] = np .nan
605585 np .fill_diagonal (dm , 0.0 )
606586 return dm
607587
@@ -651,6 +631,10 @@ def compute_junction_distances(
651631 Notes:
652632 The Clark-Evans expected nearest-neighbour distance uses the standard 2-D planar
653633 approximation ``0.5 * sqrt(A / n)`` with ``A = surface_area_nm2``.
634+
635+ Each junction's nearest-neighbour distance is the smallest distance to a *reachable* other
636+ junction: self (diagonal) and unreachable (NaN) pairs are set to +inf before the per-row
637+ minimum, and rows with no reachable neighbour (min stays +inf) are dropped.
654638 """
655639 ndim = contact_labels .ndim
656640 sampling = _to_sampling (voxel_size , ndim )
@@ -663,12 +647,10 @@ def compute_junction_distances(
663647 summary ["junction_count" ] = n
664648 return np .zeros ((n , n ), dtype = float ), summary
665649
666- # Junction centroids in one labelled reduction (uniform weights → geometric centroid).
667650 centroids = np .atleast_2d (
668651 np .asarray (center_of_mass (contact_labels > 0 , labels = contact_labels , index = labels ), dtype = float )
669652 )
670653
671- # Surface geodesic along the supplied eroded-mito mesh (or a mesh built from the membrane).
672654 if mesh_vertices is not None and mesh_faces is not None and len (mesh_faces ) > 0 :
673655 mesh = (mesh_vertices , mesh_faces )
674656 else :
@@ -678,14 +660,10 @@ def compute_junction_distances(
678660 )
679661
680662 if distance_matrix is None :
681- # No usable surface mesh (empty membrane / degenerate mesh) → distances are NaN.
682663 summary = dict (_JUNCTION_DISTANCE_NAN )
683664 summary ["junction_count" ] = n
684665 return np .full ((n , n ), np .nan , dtype = float ), summary
685666
686- # Nearest-neighbour distance per junction (nearest reachable other junction). Vectorised:
687- # exclude self (diagonal) and unreachable pairs (NaN) by setting them to +inf, take the row
688- # minimum, and drop rows with no reachable neighbour (min stays +inf).
689667 dm = distance_matrix .copy ()
690668 np .fill_diagonal (dm , np .inf )
691669 dm [~ np .isfinite (dm )] = np .inf
@@ -739,7 +717,6 @@ def compute_crista_morphology(
739717 result ["cristae_surface_area_nm2" ] = _surface_area (crista_mask , sampling )
740718
741719 if method in ("medial_axis" , "both" ):
742- # Local maxima of the EDT form the medial axis; 2 × distance there = local thickness.
743720 result ["avg_thickness_nm" ] = _medial_axis_thickness_nm (crista_mask , sampling )
744721
745722 return result
@@ -785,7 +762,8 @@ def _single_mito_row(
785762 are trimmed/opened at faces where the mito is clipped by the volume boundary: the lumen via
786763 :func:`_open_trimmed_mesh` (trimmed to the certain region and left open, so geodesics do not
787764 shortcut across a cap), the mito surface via ``closed_faces`` (open, so a fabricated cap is not
788- counted as membrane area).
765+ counted as membrane area). The membrane distance transform is freed before the (memory-heavy)
766+ orientation stage to cap peak memory.
789767 """
790768 ndim = mito_crop .ndim
791769 touches_border = any (
@@ -800,8 +778,6 @@ def _single_mito_row(
800778 mito_vol = float (mito_local .sum ()) * voxel_vol
801779 crista_vol = float (crista_local .sum ()) * voxel_vol
802780
803- # True where the bbox face is at the volume boundary (mito clipped there). The mito outer surface
804- # leaves these open (no fabricated cap in the area); the lumen mesh is trimmed + opened there.
805781 boundary = np .array (
806782 [[bbox [a ] == 0 , bbox [a + ndim ] == vol_shape [a ]] for a in range (ndim )], dtype = bool
807783 )
@@ -824,7 +800,6 @@ def _single_mito_row(
824800 surface_area_nm2 = mito_surface , n_jobs = inner_n_jobs ,
825801 mesh_vertices = mesh_verts , mesh_faces = mesh_faces ,
826802 )
827- # Free the (potentially large) distance-transform array before the orientation computation.
828803 del membrane_distance , contact_labels_local
829804 else :
830805 contact_summary = {"contact_voxel_count" : 0 , "crista_junction_count" : 0 , "contact_volume_nm3" : 0.0 }
@@ -841,7 +816,7 @@ def _single_mito_row(
841816 crista_orientation_anisotropy = _downsampled_orientation_anisotropy (
842817 crista_local , voxel_size , factor = 2
843818 )
844- else : # exact
819+ else :
845820 anisotropy = compute_crista_orientation (crista_local , voxel_size )
846821 crista_orientation_anisotropy = float (np .mean (anisotropy [crista_local ]))
847822 else :
@@ -932,6 +907,16 @@ def compute_mito_crista_statistics(
932907 (``bioimage_cpp.distance.geodesic_distances_mesh``); for a mito with no usable mesh (empty
933908 membrane / degenerate mesh) those columns are NaN.
934909
910+ Implementation notes: each mito is pre-cropped to its bounding box by basic slicing (views, so
911+ cropping is memory-free; only the bbox region is pickled to a process worker). Parallelism is
912+ adaptive and single-level (never oversubscribed): with many mitochondria the work is parallelised
913+ *across* them as loky processes (so the GIL-bound stages scale) with each worker's inner stages
914+ serial and BLAS capped (``inner_max_num_threads=1``); with few mitochondria they run serially and
915+ each mito's junction-distance stage gets all cores while BLAS multithreads the orientation. The
916+ concurrent worker count is additionally capped so the combined per-mito working set (tensor
917+ components + label crops, ~40 bytes/voxel of the largest mito) fits in RAM. Rows are finally
918+ sorted by label for an n_jobs-independent ordering.
919+
935920 Returns:
936921 DataFrame with one row per mito instance:
937922 label | mito_volume_nm3 | crista_volume_nm3 | crista_fraction |
@@ -950,8 +935,6 @@ def compute_mito_crista_statistics(
950935 if method not in ("fast" , "exact" , "skip" ):
951936 raise ValueError (f"method must be 'fast', 'exact', or 'skip', got { method !r} " )
952937 if membrane_mask is None :
953- # Derive the matching lumen alongside the membrane; a caller-supplied membrane keeps its
954- # (possibly None) lumen_mask and falls back per-instance in _single_mito_row.
955938 membrane_mask , lumen_mask = approximate_membrane (
956939 mito_segmentation , voxel_size , membrane_thickness_nm , border_gap_nm ,
957940 n_jobs = n_jobs , membrane_mode = membrane_mode , return_lumen = True ,
@@ -962,11 +945,8 @@ def compute_mito_crista_statistics(
962945 voxel_vol = float (np .prod (sampling ))
963946 crista_binary = crista_mask .astype (bool )
964947 vol_shape = mito_segmentation .shape
965- effective_gap_nm = border_gap_nm if border_gap_nm is not None else membrane_thickness_nm
966- border_radius = _voxel_radius (effective_gap_nm , voxel_size , ndim )
948+ border_radius = _gap_radius (voxel_size , membrane_thickness_nm , border_gap_nm , ndim )
967949
968- # Pre-crop each mito to its bounding box (basic slicing → views, so this is memory-free;
969- # for the process path only the bbox region gets pickled, not the whole volume).
970950 tasks = []
971951 for prop in regionprops (mito_segmentation ):
972952 bbox = prop .bbox
@@ -988,11 +968,6 @@ def _run(task, inner_n_jobs):
988968 )
989969
990970 n_workers = os .cpu_count () if n_jobs == - 1 else max (1 , n_jobs )
991- # Adaptive: many mitochondria → parallelize ACROSS them (processes, so the GIL-bound stages
992- # actually scale), BLAS capped per worker, inner stages serial. Few mitochondria (e.g. one
993- # dominant mito) → run them serially but give each mito's junction-distance stage all the
994- # cores and let BLAS multithread the orientation. Exactly one level of parallelism is ever
995- # active, so there is no process/thread oversubscription.
996971 across = n_workers > 1 and total >= n_workers
997972
998973 rows = []
@@ -1007,12 +982,8 @@ def _consume(results):
1007982
1008983 if across :
1009984 from joblib import Parallel , delayed , parallel_config
1010- # Cap concurrent workers so their combined per-mito working set (graph + tensor
1011- # components + label crops, ~40 bytes/voxel of the largest mito) fits in RAM.
1012985 max_voxels = max (int (task [2 ].size ) for task in tasks )
1013986 across_workers = _bounded_workers (n_jobs , per_worker_bytes = max_voxels * 40 )
1014- # loky processes bypass the GIL; inner_max_num_threads=1 stops each worker's BLAS from
1015- # oversubscribing against the pool.
1016987 with parallel_config (backend = "loky" , inner_max_num_threads = 1 ):
1017988 _consume (
1018989 Parallel (n_jobs = across_workers , return_as = "generator_unordered" )(
@@ -1022,6 +993,5 @@ def _consume(results):
1022993 else :
1023994 _consume (_run (task , n_workers ) for task in tasks )
1024995
1025- # Stable, n_jobs-independent ordering.
1026996 rows .sort (key = lambda row : row ["mito_label_id" ])
1027997 return pd .DataFrame (rows )
0 commit comments