1+ import multiprocessing as mp
12import os
3+ from concurrent import futures
24from typing import Callable , Dict , Optional , Tuple , Union
35
46import numpy as np
57import pandas as pd
6- from scipy .ndimage import binary_erosion , center_of_mass , distance_transform_edt
8+ from scipy .ndimage import binary_erosion , center_of_mass
79from scipy .ndimage import label as ndimage_label
810from skimage .measure import marching_cubes , mesh_surface_area , regionprops
911from skimage .morphology import disk , local_maxima
1012from tqdm import tqdm
1113
12- from bioimage_cpp .distance import geodesic_distances_mesh
14+ from bioimage_cpp .distance import distance_transform , geodesic_distances_mesh
1315from bioimage_cpp .filters import structure_tensor_eigenvalues
1416
1517
@@ -185,7 +187,7 @@ def _medial_axis_thickness_nm(mask: np.ndarray, sampling: np.ndarray) -> float:
185187 binary = mask .astype (bool )
186188 if not binary .any ():
187189 return np .nan
188- dist = distance_transform_edt (binary , sampling = tuple (float (s ) for s in sampling ))
190+ dist = distance_transform (binary , sampling = tuple (float (s ) for s in sampling ), number_of_threads = 1 )
189191 ridges = local_maxima (dist ) & binary
190192 ridge_dists = dist [ridges ]
191193 return float (2.0 * np .mean (ridge_dists )) if ridge_dists .size > 0 else np .nan
@@ -286,6 +288,12 @@ def approximate_membrane(
286288 ndim = mito_segmentation .ndim
287289 mito_binary = mito_segmentation > 0
288290
291+ # NOTE (possible simplification, deferred): the "shell_3d" branch below builds the shell with an
292+ # iterated 3x3x3 erosion per instance. It could likely be a single anisotropic distance transform
293+ # instead — membrane = mito & (distance_transform(mito, sampling) <= thickness), lumen = the rest —
294+ # which is simpler and handles anisotropy directly. This would NOT replace "slice_2d": a distance
295+ # transform couples all axes, so it cannot reproduce slice_2d's per-Z-slice-independent erosion,
296+ # whose whole purpose is to stop the shell bleeding across slices in XY. Worth investigating.
289297 if membrane_mode == "shell_3d" :
290298 sampling = _to_sampling (voxel_size , ndim )
291299 k = max (1 , int (round (float (membrane_thickness_nm ) / float (np .mean (sampling )))))
@@ -327,10 +335,9 @@ def _erode_slice(z):
327335 if n_jobs == 1 :
328336 results = [_erode_slice (z ) for z in z_range ]
329337 else :
330- from joblib import Parallel , delayed
331- results = Parallel (n_jobs = n_jobs , prefer = "threads" )(
332- delayed (_erode_slice )(z ) for z in z_range
333- )
338+ n_workers = mp .cpu_count () if n_jobs == - 1 else n_jobs
339+ with futures .ThreadPoolExecutor (n_workers ) as tp :
340+ results = list (tp .map (_erode_slice , z_range ))
334341 for z , res in results :
335342 if res is not None :
336343 mem_sl , lum_sl = res
@@ -460,7 +467,7 @@ def compute_crista_proximity(
460467 membrane_mask: Binary membrane mask (OM or IMM).
461468 voxel_size: Voxel size in nm — scalar or dict with "z"/"y"/"x" keys.
462469 membrane_distance: Optional precomputed per-voxel distance to the nearest membrane
463- voxel (nm), i.e. ``distance_transform_edt (~membrane_mask, sampling=...)``. When
470+ voxel (nm), i.e. ``distance_transform (~membrane_mask, sampling=...)``. When
464471 given, the distance transform is not recomputed (used to avoid redundant work in
465472 :func:`compute_mito_crista_statistics`).
466473
@@ -470,7 +477,7 @@ def compute_crista_proximity(
470477 """
471478 sampling = _to_sampling (voxel_size , crista_mask .ndim )
472479 if membrane_distance is None :
473- dist = distance_transform_edt (~ membrane_mask .astype (bool ), sampling = sampling .tolist ())
480+ dist = distance_transform (~ membrane_mask .astype (bool ), sampling = sampling .tolist (), number_of_threads = 1 )
474481 else :
475482 dist = membrane_distance
476483 crista_dists = dist [crista_mask .astype (bool )]
@@ -746,7 +753,7 @@ def _single_mito_row(
746753 Factored out of :func:`compute_mito_crista_statistics` so the per-mito work (which is
747754 independent between instances) can be parallelised. Takes the bbox-cropped label arrays
748755 (``mito_crop`` = label array cropped to ``bbox``; ``crista_crop``/``membrane_crop`` the
749- matching binary crops), so it can be dispatched to a process worker without pickling the
756+ matching binary crops), so a worker only touches its own mito's bbox region rather than the
750757 whole volume. ``inner_n_jobs`` is forwarded to the (parallelisable) junction-distance stage.
751758
752759 ``method`` controls only the crista orientation anisotropy — every other metric (marching-cubes
@@ -788,7 +795,7 @@ def _single_mito_row(
788795
789796 if has_crista and has_membrane :
790797 contact_labels_local , contact_summary = detect_contact_sites (crista_local , membrane_local , voxel_size )
791- membrane_distance = distance_transform_edt (~ membrane_local , sampling = sampling .tolist ())
798+ membrane_distance = distance_transform (~ membrane_local , sampling = sampling .tolist (), number_of_threads = 1 )
792799 _ , proximity = compute_crista_proximity (
793800 crista_local , membrane_local , voxel_size , membrane_distance = membrane_distance
794801 )
@@ -886,13 +893,13 @@ def compute_mito_crista_statistics(
886893 ``method="exact"``. ``"exact"`` computes the anisotropy from the full-resolution structure
887894 tensor (use it when the magnitude must be precise).
888895 n_jobs: Number of workers for processing mitochondria in parallel (they are
889- independent). 1 (default) runs serially; other values use a joblib thread pool
890- (-1 = all cores). Results are identical regardless of n_jobs.
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.
891898 verbose: If True, show a terminal tqdm progress bar over mitochondria.
892899 progress_callback: Optional callable invoked once per completed mitochondrion with
893900 (completed_count, total_count) — e.g. to drive a napari progress bar. It is
894- always called from the calling thread (the joblib results generator is consumed
895- here ), so GUI updates from it need no cross-thread marshaling.
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.
896903 membrane_mode: How the membrane shell is built when ``membrane_mask`` is None —
897904 ``"slice_2d"`` (default, per-Z-slice 2D erosion, z-parallel) or ``"shell_3d"`` (connected
898905 3D shell). See :func:`approximate_membrane`.
@@ -908,14 +915,15 @@ def compute_mito_crista_statistics(
908915 membrane / degenerate mesh) those columns are NaN.
909916
910917 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.
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.
919927
920928 Returns:
921929 DataFrame with one row per mito instance:
@@ -981,15 +989,16 @@ def _consume(results):
981989 progress_callback (i , total )
982990
983991 if across :
984- from joblib import Parallel , delayed , parallel_config
985992 max_voxels = max (int (task [2 ].size ) for task in tasks )
986993 across_workers = _bounded_workers (n_jobs , per_worker_bytes = max_voxels * 40 )
987- with parallel_config (backend = "loky" , inner_max_num_threads = 1 ):
988- _consume (
989- Parallel (n_jobs = across_workers , return_as = "generator_unordered" )(
990- delayed (_run )(task , 1 ) for task in tasks
991- )
992- )
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 ))
9931002 else :
9941003 _consume (_run (task , n_workers ) for task in tasks )
9951004
0 commit comments