Skip to content

Commit eea100c

Browse files
committed
reuse dists, switched from joblib to concurrent.futures, and others
1 parent cf285f1 commit eea100c

9 files changed

Lines changed: 118 additions & 112 deletions

docs/cristae_analysis.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,17 @@ labels layer.)
237237
- **scikit‑image**`measure.marching_cubes`, `measure.mesh_surface_area`, `measure.regionprops`;
238238
`morphology.disk`, `morphology.local_maxima`.
239239
- **pandas** — results table. **tqdm** — progress. **napari** / **qtpy** — the widget/UI.
240-
- **joblib** (loky processes / threads) + **psutil** — parallelism and memory‑aware worker caps.
240+
- **concurrent.futures** (`ThreadPoolExecutor`) + **psutil** — parallelism and memory‑aware worker caps.
241241

242242
---
243243

244244
## Performance & memory (important facts)
245-
- **Adaptive parallelism** across mitochondria: with many mitos it parallelises **across** them
246-
(loky processes, so the GIL‑bound stages scale); with few/one dominant mito it runs them serially
247-
and parallelises **within** the mito (junction Dijkstra threads, threaded Gaussian smoothing,
248-
multithreaded BLAS). Exactly one level of parallelism is active — no oversubscription.
245+
- **Adaptive parallelism** across mitochondria: with many mitos it parallelises **across** them on a
246+
`concurrent.futures.ThreadPoolExecutor` — the heavy per‑mito stages (structure tensor, EDT,
247+
geodesics) are GIL‑releasing C++, so threads scale them — with each worker's inner stages kept
248+
single‑threaded (the EDT/geodesic solvers are called with `number_of_threads=1`); with few/one
249+
dominant mito it runs them serially and parallelises **within** the mito (junction geodesic
250+
threads). Exactly one level of parallelism is active — no oversubscription.
249251
- **Memory‑aware caps** (`_available_memory_bytes`/`_bounded_workers` via `psutil`) bound the worker/
250252
thread counts of every parallel stage so it degrades to fewer workers instead of OOMing.
251253
- Junction geodesics run on the **eroded‑mito surface mesh** (`bioimage_cpp.distance.geodesic_distances_mesh`),

setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"scikit-learn",
2222
"h5py",
2323
"imageio",
24-
"joblib",
2524
"mrcfile",
2625
"pandas",
2726
"pooch",

synapse_net/cristae_analysis.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1+
import multiprocessing as mp
12
import os
3+
from concurrent import futures
24
from typing import Callable, Dict, Optional, Tuple, Union
35

46
import numpy as np
57
import 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
79
from scipy.ndimage import label as ndimage_label
810
from skimage.measure import marching_cubes, mesh_surface_area, regionprops
911
from skimage.morphology import disk, local_maxima
1012
from tqdm import tqdm
1113

12-
from bioimage_cpp.distance import geodesic_distances_mesh
14+
from bioimage_cpp.distance import distance_transform, geodesic_distances_mesh
1315
from 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

synapse_net/tools/base_widget.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44

55
import napari
6+
import numpy as np
67
import qtpy.QtWidgets as QtWidgets
78

89
from napari.utils.notifications import show_info
@@ -353,3 +354,54 @@ def _add_properties_and_table(self, layer, table_data, save_path=""):
353354
if save_path != "":
354355
file_path = self._save_table(self.save_path.text(), table_data)
355356
show_info(f"INFO: Added table and saved file to {file_path}.")
357+
358+
def _add_or_update_layer(self, add_fn, name, data, scale, translate, layer_kwargs):
359+
"""Add a layer via ``add_fn`` (e.g. ``self.viewer.add_labels``), or refresh it in place if a
360+
layer with this name already exists.
361+
362+
On refresh the ``scale``/``translate`` are reapplied (when provided) because a persisted layer
363+
keeps its original transform, which may be stale if the source layer / voxel size changed
364+
between runs; any ``layer_kwargs`` (e.g. ``opacity``, ``blending``, ``colormap``) are reapplied
365+
too. On first add these go through the layer constructor. Returns the (new or existing) layer.
366+
"""
367+
if name in self.viewer.layers:
368+
layer = self.viewer.layers[name]
369+
layer.data = data
370+
if scale is not None:
371+
layer.scale = scale
372+
if translate is not None:
373+
layer.translate = translate
374+
for key, value in layer_kwargs.items():
375+
setattr(layer, key, value)
376+
else:
377+
ctor_kwargs = dict(layer_kwargs)
378+
if scale is not None:
379+
ctor_kwargs["scale"] = scale
380+
if translate is not None:
381+
ctor_kwargs["translate"] = translate
382+
layer = add_fn(data, name=name, **ctor_kwargs)
383+
return layer
384+
385+
def add_or_update_labels(self, name, data, *, scale=None, translate=None, **layer_kwargs):
386+
"""Add a Labels layer, or refresh it in place if one with this name already exists.
387+
388+
See :meth:`_add_or_update_layer` for the refresh/transform semantics. Extra keyword arguments
389+
(``opacity``, ``blending``, ``colormap``, …) are forwarded to the layer. Returns the layer.
390+
"""
391+
return self._add_or_update_layer(
392+
self.viewer.add_labels, name, data, scale, translate, layer_kwargs
393+
)
394+
395+
def add_or_update_surface(self, name, vertices, faces, *, scale=None, translate=None,
396+
values=None, **layer_kwargs):
397+
"""Add a Surface layer, or refresh it in place if one with this name already exists.
398+
399+
Surface layers colour by per-vertex ``values``; a constant array (the default) gives a
400+
flat-coloured surface. See :meth:`_add_or_update_layer` for the refresh/transform semantics.
401+
Returns the layer.
402+
"""
403+
if values is None:
404+
values = np.ones(len(vertices), dtype="float32")
405+
return self._add_or_update_layer(
406+
self.viewer.add_surface, name, (vertices, faces, values), scale, translate, layer_kwargs
407+
)

synapse_net/tools/cristae_analysis_widget.py

Lines changed: 12 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -194,59 +194,6 @@ def _compute_membrane_and_contacts(self, mito_seg, crista_mask, voxel_size, mm_t
194194
)
195195
return membrane_mask, lumen_mask, contact_labels, contact_summary
196196

197-
def _add_or_update_labels(self, name, data, scale, translate, opacity=None, blending=None):
198-
"""Add a Labels layer, or refresh it in place if one with this name already exists.
199-
200-
On refresh the scale/translate are reapplied: a persisted layer keeps its original transform,
201-
which may be stale if the source layer / voxel size changed between runs.
202-
"""
203-
if name in self.viewer.layers:
204-
layer = self.viewer.layers[name]
205-
layer.data = data
206-
if scale is not None:
207-
layer.scale = scale
208-
if translate is not None:
209-
layer.translate = translate
210-
if opacity is not None:
211-
layer.opacity = opacity
212-
if blending is not None:
213-
layer.blending = blending
214-
else:
215-
kwargs = {}
216-
if opacity is not None:
217-
kwargs["opacity"] = opacity
218-
if blending is not None:
219-
kwargs["blending"] = blending
220-
self.viewer.add_labels(data, name=name, scale=scale, translate=translate, **kwargs)
221-
222-
def _add_or_update_surface(self, name, vertices, faces, scale, translate, opacity=None, blending=None):
223-
"""Add a Surface layer, or refresh it in place if one with this name already exists.
224-
225-
Surface layers colour by per-vertex values, so a constant ``values`` gives a flat-coloured
226-
surface. On refresh the scale/translate are reapplied (see :meth:`_add_or_update_labels`).
227-
"""
228-
values = np.ones(len(vertices), dtype="float32")
229-
if name in self.viewer.layers:
230-
layer = self.viewer.layers[name]
231-
layer.data = (vertices, faces, values)
232-
if scale is not None:
233-
layer.scale = scale
234-
if translate is not None:
235-
layer.translate = translate
236-
if opacity is not None:
237-
layer.opacity = opacity
238-
if blending is not None:
239-
layer.blending = blending
240-
else:
241-
kwargs = {}
242-
if opacity is not None:
243-
kwargs["opacity"] = opacity
244-
if blending is not None:
245-
kwargs["blending"] = blending
246-
self.viewer.add_surface(
247-
(vertices, faces, values), name=name, scale=scale, translate=translate, **kwargs
248-
)
249-
250197
@contextmanager
251198
def _computing(self, button, busy_text, idle_text, message):
252199
"""Show a busy state around a synchronous, GUI-thread-blocking action, then restore it.
@@ -296,12 +243,14 @@ def on_preview(self):
296243
mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode
297244
)
298245
pbar.update(1)
299-
self._add_or_update_labels(
300-
self._MEMBRANE_LAYER, membrane_mask.astype(np.uint8), layer_scale, layer_translate, opacity=0.4
246+
self.add_or_update_labels(
247+
self._MEMBRANE_LAYER, membrane_mask.astype(np.uint8),
248+
scale=layer_scale, translate=layer_translate, opacity=0.4,
301249
)
302250
if contact_labels.max() > 0:
303-
self._add_or_update_labels(
304-
self._JUNCTION_LAYER, contact_labels.astype(np.uint32), layer_scale, layer_translate,
251+
self.add_or_update_labels(
252+
self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
253+
scale=layer_scale, translate=layer_translate,
305254
blending="translucent_no_depth",
306255
)
307256
else:
@@ -370,16 +319,18 @@ def _on_progress(done, total):
370319
)
371320
if mesh is not None:
372321
verts, faces = mesh
373-
self._add_or_update_surface(
374-
self._MEMBRANE_MESH_LAYER, verts, faces, layer_scale, layer_translate,
322+
self.add_or_update_surface(
323+
self._MEMBRANE_MESH_LAYER, verts, faces,
324+
scale=layer_scale, translate=layer_translate,
375325
opacity=0.4, blending="translucent",
376326
)
377327
else:
378328
show_info("INFO: No membrane surface to display at these settings.")
379329

380330
if contact_labels.max() > 0:
381-
self._add_or_update_labels(
382-
self._JUNCTION_LAYER, contact_labels.astype(np.uint32), layer_scale, layer_translate,
331+
self.add_or_update_labels(
332+
self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
333+
scale=layer_scale, translate=layer_translate,
383334
blending="translucent_no_depth",
384335
)
385336
else:

0 commit comments

Comments
 (0)