Skip to content

Commit b28a891

Browse files
Cristae analysis (#171)
* initial draft for cristae analysis widget * updated cristae anaylsis widget to simpler single membrane logic * updated crisate analysis widget * added new model cristae4 and cristae analysis widget as well as test file --------- Co-authored-by: Constantin Pape <constantin.pape@informatik.uni-goettingen.de>
1 parent 3f3e645 commit b28a891

6 files changed

Lines changed: 738 additions & 2 deletions

File tree

synapse_net/cristae_analysis.py

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
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

Comments
 (0)