synapse_net.tools.cristae_analysis_widget

  1import napari
  2import numpy as np
  3
  4from napari.utils import progress
  5from napari.utils.notifications import show_info
  6from qtpy.QtWidgets import QWidget, QVBoxLayout, QPushButton
  7
  8from .base_widget import BaseWidget
  9from ..cristae_analysis import (
 10    approximate_membrane, compute_crista_skeleton, compute_mito_crista_statistics,
 11    detect_junctions_per_mito,
 12    _border_zone, _open_trimmed_mesh, _gap_radius, _to_sampling,
 13)
 14
 15
 16class CristaeAnalysisWidget(BaseWidget):
 17    """Napari widget for the cristae analysis (preview + full per-mitochondrion run).
 18
 19    ``_ORIENTATION_TO_METHOD`` maps the orientation dropdown labels to the ``method`` argument of
 20    :func:`~synapse_net.cristae_analysis.compute_mito_crista_statistics`, ``_MEMBRANE_TO_MODE`` maps
 21    the membrane-mode labels to the ``membrane_mode`` argument of
 22    :func:`~synapse_net.cristae_analysis.approximate_membrane`, and ``_JUNCTION_TO_MODE`` maps the
 23    junction-mode labels to the ``junction_mode`` argument of
 24    :func:`~synapse_net.cristae_analysis.detect_junctions`. The ``_*_LAYER`` name constants are
 25    shared by the preview and the full run so re-previewing / running updates the same layers instead
 26    of duplicating them.
 27    """
 28
 29    _ORIENTATION_FAST = "Fast (downsampled, approximate)"
 30    _ORIENTATION_SKIP = "Skip (no orientation)"
 31    _ORIENTATION_TO_METHOD = {
 32        _ORIENTATION_FAST: "fast",
 33        "Exact (full resolution)": "exact",
 34        _ORIENTATION_SKIP: "skip",
 35    }
 36
 37    _MEMBRANE_SLICE_2D = "2D per-slice (z-parallel)"
 38    _MEMBRANE_TO_MODE = {
 39        _MEMBRANE_SLICE_2D: "slice_2d",
 40        "3D connected shell": "shell_3d",
 41    }
 42
 43    _JUNCTION_OVERLAP = "Overlap (crista ∩ membrane)"
 44    _JUNCTION_SKELETON = "Skeleton (crista reaching the membrane)"
 45    _JUNCTION_TO_MODE = {
 46        _JUNCTION_OVERLAP: "overlap",
 47        _JUNCTION_SKELETON: "skeleton",
 48    }
 49
 50    def __init__(self):
 51        super().__init__()
 52
 53        self.viewer = napari.current_viewer()
 54        layout = QVBoxLayout()
 55
 56        self.crista_selector_name = "Crista Mask"
 57        self.mito_selector_name = "Mito Segmentation"
 58
 59        self.crista_selector_widget = self._create_layer_selector(
 60            self.crista_selector_name, layer_type="Labels", prefer_substring="cristae")
 61        self.mito_selector_widget = self._create_layer_selector(
 62            self.mito_selector_name, layer_type="Labels", prefer_substring="mitochondria")
 63
 64        self.settings = self._create_settings_widget()
 65
 66        self.preview_button = QPushButton("Preview Membrane && Junctions")
 67        self.preview_button.clicked.connect(self.on_preview)
 68
 69        self.run_button = QPushButton("Run Cristae Analysis")
 70        self.run_button.clicked.connect(self.on_run)
 71
 72        layout.addWidget(self.crista_selector_widget)
 73        layout.addWidget(self.mito_selector_widget)
 74        layout.addWidget(self.settings)
 75        layout.addWidget(self.preview_button)
 76        layout.addWidget(self.run_button)
 77
 78        self.setLayout(layout)
 79
 80    _MEMBRANE_LAYER = "Membrane Mask"
 81    _MEMBRANE_MESH_LAYER = "Membrane Mesh"
 82    _JUNCTION_LAYER = "Crista-Membrane Junctions"
 83    _SKELETON_LAYER = "Crista Skeleton"
 84    _SKELETON_TERMINI_LAYER = "Crista Skeleton Termini"
 85
 86    def _create_settings_widget(self):
 87        setting_values = QWidget()
 88        setting_values.setLayout(QVBoxLayout())
 89
 90        self.save_path, layout = self._add_path_param(
 91            name="save_path", select_type="file", value="",
 92            tooltip="Path to save the analysis results CSV file. An empty path will skip saving. "
 93                    "See docs/cristae_analysis.md for how each column is computed.",
 94        )
 95        setting_values.layout().addLayout(layout)
 96
 97        self.voxel_size_param, layout = self._add_float_param(
 98            "voxel_size", 0.0, min_val=0.0, max_val=100.0,
 99            title="Voxel Size (nm, 0 = auto)", step=0.1,
100            tooltip="Voxel size of the input volume in nanometers. Set to 0 (default) to auto-detect from layer metadata.",
101        )
102        setting_values.layout().addLayout(layout)
103
104        self.mm_thickness_param, layout = self._add_float_param(
105            "mm_thickness", 8.0, min_val=1.0, max_val=30.0,
106            title="Membrane Thickness (nm)", decimals=1, step=0.5,
107            tooltip="Thickness of the mitochondrial membrane shell in nanometers.",
108        )
109        setting_values.layout().addLayout(layout)
110
111        self.border_gap_param, layout = self._add_float_param(
112            "border_gap", 0.0, min_val=0.0, max_val=100.0,
113            title="Border Gap (nm, 0 = same as membrane)", decimals=1, step=0.5,
114            tooltip="Distance from each volume face within which membrane voxels are suppressed. "
115                    "Set to 0 to use the same value as Membrane Thickness.",
116        )
117        setting_values.layout().addLayout(layout)
118
119        self.show_membranes_param = self._add_boolean_param(
120            "show_membranes", False,
121            title="Show Membrane Mesh",
122            tooltip="Add the eroded-mito (lumen) inner surface — the single-wall surface the junction "
123                    "geodesics run along — as a mesh (napari Surface layer) after running.",
124        )
125        setting_values.layout().addWidget(self.show_membranes_param)
126
127        self.show_skeleton_param = self._add_boolean_param(
128            "show_skeleton", False,
129            title="Show Crista Skeleton",
130            tooltip="Add the crista centerline skeleton as a napari Vectors layer (connected line "
131                    "segments) plus a Points layer for the termini (the skeleton's free ends), both "
132                    "restricted to the mito segmentation so they match what the analysis looks at. "
133                    "Skeleton mode keys its terminus filter on exactly those termini, so this is how "
134                    "you check whether a flagged junction sits at a real crista end or merely on a "
135                    "flank. Available in both junction modes.",
136        )
137        setting_values.layout().addWidget(self.show_skeleton_param)
138
139        self.orientation_param, layout = self._add_choice_param(
140            "orientation", self._ORIENTATION_SKIP, list(self._ORIENTATION_TO_METHOD.keys()),
141            title="Crista orientation",
142            tooltip="How to compute the crista orientation anisotropy — the most expensive stage "
143                    "(structure tensor). All other metrics (surface areas, junction distances, "
144                    "thickness) are identical regardless of this choice.\n"
145                    "- Fast (downsampled, approximate): ~8x faster; a relative indicator only, not "
146                    "comparable in magnitude to the exact value.\n"
147                    "- Exact (full resolution): the true anisotropy (slowest).\n"
148                    "- Skip (no orientation): fastest; leaves the orientation column empty.",
149        )
150        setting_values.layout().addLayout(layout)
151
152        self.membrane_mode_param, layout = self._add_choice_param(
153            "membrane_mode", self._MEMBRANE_SLICE_2D, list(self._MEMBRANE_TO_MODE.keys()),
154            title="Membrane mode",
155            tooltip="How the membrane shell is approximated.\n"
156                    "- 2D per-slice (z-parallel): erode each Z-slice independently in XY (no z-bleed); "
157                    "the shell has no Z-caps and can fragment across slices (some junction pairs may "
158                    "then have no along-membrane path).\n"
159                    "- 3D connected shell: a single connected 3D shell including the Z-caps (no "
160                    "fragmentation), somewhat slower; thickness acts in all axes.",
161        )
162        setting_values.layout().addLayout(layout)
163
164        self.junction_mode_param, layout = self._add_choice_param(
165            "junction_mode", self._JUNCTION_SKELETON, list(self._JUNCTION_TO_MODE.keys()),
166            title="Junction detection",
167            tooltip="How crista–membrane junctions are found.\n"
168                    "- Overlap (crista ∩ membrane): counts the connected components where the crista "
169                    "mask directly overlaps the membrane band. A crista that stops short of the "
170                    "membrane scores no junction, and a lamella rim can fragment into many blobs.\n"
171                    "- Skeleton (crista reaching the membrane): counts crista regions that come "
172                    "within Max Extension of the membrane near a crista terminus (an end of the "
173                    "crista skeleton). Tolerates a crista segmented short of the membrane, and never "
174                    "merges two separate cristae into one junction. Requires 3D data and reports the "
175                    "closest approach as mean_junction_extension_nm.",
176        )
177        setting_values.layout().addLayout(layout)
178
179        self.max_extension_param, layout = self._add_float_param(
180            "max_extension", 0.0, min_val=0.0, max_val=100.0,
181            title="Max Extension (nm, 0 = same as membrane)", decimals=1, step=0.5,
182            tooltip="How far a crista may fall short of the membrane and still count as a junction "
183                    "(Skeleton mode only). Set to 0 to use the same value as Membrane Thickness. "
184                    "Raising it well above the membrane thickness starts counting cristae that merely "
185                    "pass near the boundary, and can fuse junctions whose near-membrane regions touch.",
186        )
187        setting_values.layout().addLayout(layout)
188
189        self.min_junction_volume_param, layout = self._add_float_param(
190            "min_junction_volume", 0.0, min_val=0.0, max_val=100000.0,
191            title="Min Junction Volume (nm³, 0 = default 50)", decimals=1, step=10.0,
192            tooltip="Junction regions smaller than this are discarded (Skeleton mode only). This only "
193                    "removes specks — it does NOT fix the fact that skeleton mode over-counts on "
194                    "densely packed cristae, where the spurious regions are full-sized. Set to 0 to "
195                    "use the default of 50 nm³.",
196        )
197        setting_values.layout().addLayout(layout)
198
199        self.terminus_param, layout = self._add_float_param(
200            "terminus_distance", 0.0, min_val=0.0, max_val=200.0,
201            title="Terminus Distance (nm, 0 = default 20)", decimals=1, step=0.5,
202            tooltip="How close a near-membrane crista region must be to a crista terminus (an end of "
203                    "the crista skeleton) to count as a junction (Skeleton mode only). This is what "
204                    "separates a crista ENDING at the membrane from one merely running ALONGSIDE it. "
205                    "Set to 0 to use the default of 20 nm; raise it to be more permissive.",
206        )
207        setting_values.layout().addLayout(layout)
208
209        return self._make_collapsible(widget=setting_values, title="Advanced Settings")
210
211    def _read_inputs(self):
212        """Validate the selected layers/voxel size and read the shared run/preview parameters.
213
214        ``layer_scale``/``layer_translate`` are inherited from the source (crista) layer so the result
215        layers overlay the input correctly (e.g. when the raw data was loaded with a physical voxel
216        scale).
217
218        Returns (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate, mm_thickness,
219        border_gap, membrane_mode, junction_mode, max_extension, terminus, min_junction_volume)
220        or None (after showing
221        a guidance message) if inputs are incomplete.
222        """
223        crista_mask = self._get_layer_selector_data(self.crista_selector_name)
224        mito_seg = self._get_layer_selector_data(self.mito_selector_name)
225        if crista_mask is None or mito_seg is None:
226            show_info("Please select both a crista mask and a mito segmentation layer.")
227            return None
228
229        metadata = self._get_layer_selector_data(self.crista_selector_name, return_metadata=True)
230        voxel_size = self._handle_resolution(metadata, self.voxel_size_param, crista_mask.ndim, return_as_list=False)
231        if voxel_size is None:
232            show_info("Please provide a voxel size (or ensure layer metadata contains voxel_size).")
233            return None
234
235        ref_layer = self._get_layer_selector_layer(self.crista_selector_name)
236        layer_scale = None if ref_layer is None else ref_layer.scale
237        layer_translate = None if ref_layer is None else ref_layer.translate
238
239        mm_thickness = self.mm_thickness_param.value()
240        border_gap_val = self.border_gap_param.value()
241        border_gap = border_gap_val if border_gap_val > 0.0 else None
242        membrane_mode = self._MEMBRANE_TO_MODE[self.membrane_mode_param.currentText()]
243        junction_mode = self._JUNCTION_TO_MODE[self.junction_mode_param.currentText()]
244        max_extension_val = self.max_extension_param.value()
245        max_extension = max_extension_val if max_extension_val > 0.0 else mm_thickness
246        terminus_val = self.terminus_param.value()
247        terminus = terminus_val if terminus_val > 0.0 else None
248        min_volume_val = self.min_junction_volume_param.value()
249        min_junction_volume = min_volume_val if min_volume_val > 0.0 else None
250        return (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
251                mm_thickness, border_gap, membrane_mode, junction_mode, max_extension, terminus,
252                min_junction_volume)
253
254    def _compute_membrane_and_contacts(self, mito_seg, crista_mask, voxel_size, mm_thickness,
255                                       border_gap, membrane_mode, junction_mode, max_extension,
256                                       terminus, min_junction_volume, with_junctions=True):
257        """The cheap front-end shared by preview and run: membrane shell + crista-membrane junctions.
258
259        Also returns the border-trimmed lumen (eroded-mito interior) so the run can both display it
260        and feed it to the geodesic stage without recomputing the erosion, and the border-zone radius
261        so callers can report how much of the volume it covers.
262
263        Junctions come from :func:`~synapse_net.cristae_analysis.detect_junctions_per_mito`, i.e. the
264        same per-instance detection that fills the statistics table, **not** a single pass over the
265        whole volume. Detecting once globally is cheaper to write and gives different answers: a crista
266        restricted to one mitochondrion is clipped, and clipping moves its skeleton endpoints, so the
267        preview could show junctions the table did not report and vice versa. Per-instance is also the
268        faster of the two here (mito bounding boxes sum to a fraction of the volume, and the cost is
269        dominated by whole-volume distance transforms).
270
271        ``border_radius`` is passed on so skeleton mode does not claim junctions inside the zone where
272        ``approximate_membrane`` removed the membrane as unknown; the detector converts it to per-face
273        radii for each bounding box.
274
275        ``with_junctions=False`` skips the detection and returns ``(None, None)`` in its place, for the
276        run, which takes its junctions from the statistics table instead and would otherwise detect
277        them twice.
278        """
279        membrane_mask, lumen_mask = approximate_membrane(
280            mito_seg, voxel_size,
281            membrane_thickness_nm=mm_thickness, border_gap_nm=border_gap,
282            n_jobs=-1,
283            membrane_mode=membrane_mode,
284            return_lumen=True,
285        )
286        border_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
287        contact_labels = contact_summary = None
288        if with_junctions:
289            contact_labels, contact_summary = detect_junctions_per_mito(
290                crista_mask.astype(bool), mito_seg, membrane_mask, voxel_size,
291                junction_mode=junction_mode, max_extension_nm=max_extension,
292                terminus_nm=terminus, min_junction_volume_nm3=min_junction_volume,
293                border_radius=border_radius, n_jobs=-1, lumen_mask=lumen_mask,
294            )
295        return membrane_mask, lumen_mask, contact_labels, contact_summary, border_radius
296
297    def _add_skeleton_layers(self, crista_mask, mito_seg, voxel_size, layer_scale, layer_translate):
298        """Show the crista centerline skeleton as connected lines and, separately, its termini.
299
300        The termini get their own layer because they are what the skeleton junction mode's terminus
301        filter tests against — seeing them is how you tell a junction at a genuine crista end from one
302        flagged on a flank.
303
304        Two things make the display correspond to what the detector actually uses. The mask is
305        restricted to ``mito_seg > 0``, because the analysis skeletonises each mitochondrion's own
306        crista crop; without that the layer showed a skeleton over cristae the detector never looked at.
307        (It is not an identity: the detector works per mito *instance*, so a crista spanning two touching
308        instances is split there and not here. The junction layer *is* per instance, so a junction can
309        legitimately sit where this layer shows no terminus — clipping a crista to one mitochondrion
310        creates an end at the mitochondrial boundary that the whole-volume skeleton does not have. This
311        layer stays whole-volume because per-instance skeletons would mean concatenating vertex and
312        edge arrays with index offsets for what is a qualitative inspection aid.) And the skeleton is
313        drawn as a **Vectors** layer built from the graph edges rather than one point per vertex, so a
314        centerline reads as a curve instead of as scattered dots.
315
316        ``compute_crista_skeleton`` returns nm coordinates, so they are divided by the voxel size to
317        get array indices: every layer here is added in voxel coordinates with the physical placement
318        left to ``scale``/``translate``, matching the membrane mesh.
319        """
320        crista = crista_mask.astype(bool) & (mito_seg > 0)
321        vertices, is_terminus, edges = compute_crista_skeleton(
322            crista, voxel_size, n_jobs=-1, return_edges=True
323        )
324        if len(vertices) == 0:
325            show_info("INFO: No crista skeleton to display.")
326            return
327        vertices = vertices / _to_sampling(voxel_size, crista.ndim)
328        if len(edges):
329            starts = vertices[edges[:, 0]]
330            self.add_or_update_vectors(
331                self._SKELETON_LAYER, np.stack([starts, vertices[edges[:, 1]] - starts], axis=1),
332                scale=layer_scale, translate=layer_translate,
333                edge_width=0.4, edge_color="cyan", vector_style="line", out_of_slice_display=True,
334                blending="translucent_no_depth",
335            )
336        self.add_or_update_points(
337            self._SKELETON_TERMINI_LAYER, vertices[is_terminus],
338            scale=layer_scale, translate=layer_translate,
339            size=3.0, face_color="magenta", border_width=0.0, out_of_slice_display=True,
340            blending="translucent_no_depth",
341        )
342        show_info(
343            f"INFO: Skeleton — {len(vertices)} vertices, {len(edges)} segments, "
344            f"{int(is_terminus.sum())} termini."
345        )
346
347    def on_preview(self):
348        """Compute and show ONLY the membrane + junctions (seconds) — the front-end of the pipeline —
349        so the user can tune Membrane Thickness / Border Gap before the expensive per-mito run.
350
351        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
352        """
353        inputs = self._read_inputs()
354        if inputs is None:
355            return
356        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
357         mm_thickness, border_gap, membrane_mode, junction_mode, max_extension,
358         terminus, min_junction_volume) = inputs
359
360        with self._computing(
361            self.preview_button, "Computing preview…", "Preview Membrane && Junctions",
362            "INFO: Previewing membrane & junctions...",
363        ):
364            pbar = progress(total=2, desc="Preview: membrane & junctions")
365            try:
366                (membrane_mask, _lumen_mask, contact_labels, contact_summary,
367                 border_radius) = self._compute_membrane_and_contacts(
368                    mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode,
369                    junction_mode, max_extension, terminus, min_junction_volume,
370                )
371                pbar.update(1)
372                self.add_or_update_labels(
373                    self._MEMBRANE_LAYER, membrane_mask.astype(np.uint8),
374                    scale=layer_scale, translate=layer_translate, opacity=0.4,
375                )
376                if contact_labels.max() > 0:
377                    self.add_or_update_labels(
378                        self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
379                        scale=layer_scale, translate=layer_translate,
380                        blending="translucent_no_depth",
381                    )
382                else:
383                    show_info("INFO: No crista–membrane junctions detected at these settings.")
384                if self.show_skeleton_param.isChecked():
385                    self._add_skeleton_layers(crista_mask, mito_seg, voxel_size, layer_scale, layer_translate)
386                pbar.update(1)
387                border_fraction = _border_zone(mito_seg.shape, border_radius).mean()
388                show_info(
389                    f"INFO: Preview — {int(membrane_mask.sum())} membrane voxels, "
390                    f"{contact_summary['crista_junction_count']} junctions. "
391                    f"Border zone (membrane unknown, junctions suppressed) covers "
392                    f"{100 * border_fraction:.0f}% of the volume at Border Gap = "
393                    f"{border_radius} voxels. "
394                    "Adjust Membrane Thickness / Border Gap and preview again, or Run."
395                )
396            finally:
397                pbar.close()
398
399    def on_run(self):
400        """Run the full per-mitochondrion cristae analysis and add the result layers + stats table.
401
402        The junction layer and the reported total come from ``compute_mito_crista_statistics``'s own
403        ``return_junction_labels``, i.e. the very array its counts were computed from, so the picture
404        and the table cannot disagree. The front-end call below therefore asks only for the membrane
405        and lumen: detecting junctions there too would repeat work the table already does.
406
407        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
408        """
409        inputs = self._read_inputs()
410        if inputs is None:
411            return
412        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
413         mm_thickness, border_gap, membrane_mode, junction_mode, max_extension,
414         terminus, min_junction_volume) = inputs
415
416        with self._computing(
417            self.run_button, "Computing analysis…", "Run Cristae Analysis",
418            "INFO: Approximating mitochondrial membrane & junctions...",
419        ):
420            (membrane_mask, lumen_mask, _, _,
421             border_radius) = self._compute_membrane_and_contacts(
422                mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode,
423                junction_mode, max_extension, terminus, min_junction_volume,
424                with_junctions=False,   # they come from the statistics table below
425            )
426
427            method = self._ORIENTATION_TO_METHOD[self.orientation_param.currentText()]
428            show_info(f"INFO: Running cristae analysis per mitochondrion (orientation: {method})...")
429
430            # compute_mito_crista_statistics calls progress_callback once per mitochondrion, on this
431            # (GUI) thread, so the activity-dock bar can be created/updated here directly.
432            pbar = {"bar": None}
433
434            def _on_progress(done, total):
435                if pbar["bar"] is None:
436                    pbar["bar"] = progress(total=total, desc="Cristae analysis")
437                pbar["bar"].update(1)
438
439            try:
440                stats_df, contact_labels = compute_mito_crista_statistics(
441                    crista_mask, mito_seg, voxel_size,
442                    membrane_mask=membrane_mask,
443                    lumen_mask=lumen_mask,
444                    membrane_thickness_nm=mm_thickness,
445                    border_gap_nm=border_gap,
446                    method=method,
447                    membrane_mode=membrane_mode,
448                    junction_mode=junction_mode,
449                    max_extension_nm=max_extension,
450                    terminus_nm=terminus,
451                    min_junction_volume_nm3=min_junction_volume,
452                    n_jobs=-1,
453                    verbose=True,
454                    progress_callback=_on_progress,
455                    return_junction_labels=True,
456                )
457            finally:
458                if pbar["bar"] is not None:
459                    pbar["bar"].close()
460
461            if self.show_membranes_param.isChecked():
462                gap_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
463                mesh = _open_trimmed_mesh(
464                    lumen_mask, np.ones(mito_seg.ndim), gap_radius, np.ones((mito_seg.ndim, 2), dtype=bool)
465                )
466                if mesh is not None:
467                    verts, faces = mesh
468                    self.add_or_update_surface(
469                        self._MEMBRANE_MESH_LAYER, verts, faces,
470                        scale=layer_scale, translate=layer_translate,
471                        opacity=0.4, blending="translucent",
472                    )
473                else:
474                    show_info("INFO: No membrane surface to display at these settings.")
475
476            if self.show_skeleton_param.isChecked():
477                self._add_skeleton_layers(crista_mask, mito_seg, voxel_size, layer_scale, layer_translate)
478
479            if contact_labels.max() > 0:
480                self.add_or_update_labels(
481                    self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
482                    scale=layer_scale, translate=layer_translate,
483                    blending="translucent_no_depth",
484                )
485            else:
486                show_info("INFO: No crista–membrane junctions detected — junction layer not added.")
487
488            mito_layer = self._get_layer_selector_layer(self.mito_selector_name)
489            self._add_properties_and_table(mito_layer, stats_df, save_path=self.save_path.text())
490
491            n_mito = len(stats_df)
492            n_contacts = int(stats_df["crista_junction_count"].sum())
493            show_info(
494                f"INFO: Cristae analysis complete — {n_mito} mitochondria, "
495                f"{n_contacts} crista junction sites detected."
496            )
class CristaeAnalysisWidget(synapse_net.tools.base_widget.BaseWidget):
 17class CristaeAnalysisWidget(BaseWidget):
 18    """Napari widget for the cristae analysis (preview + full per-mitochondrion run).
 19
 20    ``_ORIENTATION_TO_METHOD`` maps the orientation dropdown labels to the ``method`` argument of
 21    :func:`~synapse_net.cristae_analysis.compute_mito_crista_statistics`, ``_MEMBRANE_TO_MODE`` maps
 22    the membrane-mode labels to the ``membrane_mode`` argument of
 23    :func:`~synapse_net.cristae_analysis.approximate_membrane`, and ``_JUNCTION_TO_MODE`` maps the
 24    junction-mode labels to the ``junction_mode`` argument of
 25    :func:`~synapse_net.cristae_analysis.detect_junctions`. The ``_*_LAYER`` name constants are
 26    shared by the preview and the full run so re-previewing / running updates the same layers instead
 27    of duplicating them.
 28    """
 29
 30    _ORIENTATION_FAST = "Fast (downsampled, approximate)"
 31    _ORIENTATION_SKIP = "Skip (no orientation)"
 32    _ORIENTATION_TO_METHOD = {
 33        _ORIENTATION_FAST: "fast",
 34        "Exact (full resolution)": "exact",
 35        _ORIENTATION_SKIP: "skip",
 36    }
 37
 38    _MEMBRANE_SLICE_2D = "2D per-slice (z-parallel)"
 39    _MEMBRANE_TO_MODE = {
 40        _MEMBRANE_SLICE_2D: "slice_2d",
 41        "3D connected shell": "shell_3d",
 42    }
 43
 44    _JUNCTION_OVERLAP = "Overlap (crista ∩ membrane)"
 45    _JUNCTION_SKELETON = "Skeleton (crista reaching the membrane)"
 46    _JUNCTION_TO_MODE = {
 47        _JUNCTION_OVERLAP: "overlap",
 48        _JUNCTION_SKELETON: "skeleton",
 49    }
 50
 51    def __init__(self):
 52        super().__init__()
 53
 54        self.viewer = napari.current_viewer()
 55        layout = QVBoxLayout()
 56
 57        self.crista_selector_name = "Crista Mask"
 58        self.mito_selector_name = "Mito Segmentation"
 59
 60        self.crista_selector_widget = self._create_layer_selector(
 61            self.crista_selector_name, layer_type="Labels", prefer_substring="cristae")
 62        self.mito_selector_widget = self._create_layer_selector(
 63            self.mito_selector_name, layer_type="Labels", prefer_substring="mitochondria")
 64
 65        self.settings = self._create_settings_widget()
 66
 67        self.preview_button = QPushButton("Preview Membrane && Junctions")
 68        self.preview_button.clicked.connect(self.on_preview)
 69
 70        self.run_button = QPushButton("Run Cristae Analysis")
 71        self.run_button.clicked.connect(self.on_run)
 72
 73        layout.addWidget(self.crista_selector_widget)
 74        layout.addWidget(self.mito_selector_widget)
 75        layout.addWidget(self.settings)
 76        layout.addWidget(self.preview_button)
 77        layout.addWidget(self.run_button)
 78
 79        self.setLayout(layout)
 80
 81    _MEMBRANE_LAYER = "Membrane Mask"
 82    _MEMBRANE_MESH_LAYER = "Membrane Mesh"
 83    _JUNCTION_LAYER = "Crista-Membrane Junctions"
 84    _SKELETON_LAYER = "Crista Skeleton"
 85    _SKELETON_TERMINI_LAYER = "Crista Skeleton Termini"
 86
 87    def _create_settings_widget(self):
 88        setting_values = QWidget()
 89        setting_values.setLayout(QVBoxLayout())
 90
 91        self.save_path, layout = self._add_path_param(
 92            name="save_path", select_type="file", value="",
 93            tooltip="Path to save the analysis results CSV file. An empty path will skip saving. "
 94                    "See docs/cristae_analysis.md for how each column is computed.",
 95        )
 96        setting_values.layout().addLayout(layout)
 97
 98        self.voxel_size_param, layout = self._add_float_param(
 99            "voxel_size", 0.0, min_val=0.0, max_val=100.0,
100            title="Voxel Size (nm, 0 = auto)", step=0.1,
101            tooltip="Voxel size of the input volume in nanometers. Set to 0 (default) to auto-detect from layer metadata.",
102        )
103        setting_values.layout().addLayout(layout)
104
105        self.mm_thickness_param, layout = self._add_float_param(
106            "mm_thickness", 8.0, min_val=1.0, max_val=30.0,
107            title="Membrane Thickness (nm)", decimals=1, step=0.5,
108            tooltip="Thickness of the mitochondrial membrane shell in nanometers.",
109        )
110        setting_values.layout().addLayout(layout)
111
112        self.border_gap_param, layout = self._add_float_param(
113            "border_gap", 0.0, min_val=0.0, max_val=100.0,
114            title="Border Gap (nm, 0 = same as membrane)", decimals=1, step=0.5,
115            tooltip="Distance from each volume face within which membrane voxels are suppressed. "
116                    "Set to 0 to use the same value as Membrane Thickness.",
117        )
118        setting_values.layout().addLayout(layout)
119
120        self.show_membranes_param = self._add_boolean_param(
121            "show_membranes", False,
122            title="Show Membrane Mesh",
123            tooltip="Add the eroded-mito (lumen) inner surface — the single-wall surface the junction "
124                    "geodesics run along — as a mesh (napari Surface layer) after running.",
125        )
126        setting_values.layout().addWidget(self.show_membranes_param)
127
128        self.show_skeleton_param = self._add_boolean_param(
129            "show_skeleton", False,
130            title="Show Crista Skeleton",
131            tooltip="Add the crista centerline skeleton as a napari Vectors layer (connected line "
132                    "segments) plus a Points layer for the termini (the skeleton's free ends), both "
133                    "restricted to the mito segmentation so they match what the analysis looks at. "
134                    "Skeleton mode keys its terminus filter on exactly those termini, so this is how "
135                    "you check whether a flagged junction sits at a real crista end or merely on a "
136                    "flank. Available in both junction modes.",
137        )
138        setting_values.layout().addWidget(self.show_skeleton_param)
139
140        self.orientation_param, layout = self._add_choice_param(
141            "orientation", self._ORIENTATION_SKIP, list(self._ORIENTATION_TO_METHOD.keys()),
142            title="Crista orientation",
143            tooltip="How to compute the crista orientation anisotropy — the most expensive stage "
144                    "(structure tensor). All other metrics (surface areas, junction distances, "
145                    "thickness) are identical regardless of this choice.\n"
146                    "- Fast (downsampled, approximate): ~8x faster; a relative indicator only, not "
147                    "comparable in magnitude to the exact value.\n"
148                    "- Exact (full resolution): the true anisotropy (slowest).\n"
149                    "- Skip (no orientation): fastest; leaves the orientation column empty.",
150        )
151        setting_values.layout().addLayout(layout)
152
153        self.membrane_mode_param, layout = self._add_choice_param(
154            "membrane_mode", self._MEMBRANE_SLICE_2D, list(self._MEMBRANE_TO_MODE.keys()),
155            title="Membrane mode",
156            tooltip="How the membrane shell is approximated.\n"
157                    "- 2D per-slice (z-parallel): erode each Z-slice independently in XY (no z-bleed); "
158                    "the shell has no Z-caps and can fragment across slices (some junction pairs may "
159                    "then have no along-membrane path).\n"
160                    "- 3D connected shell: a single connected 3D shell including the Z-caps (no "
161                    "fragmentation), somewhat slower; thickness acts in all axes.",
162        )
163        setting_values.layout().addLayout(layout)
164
165        self.junction_mode_param, layout = self._add_choice_param(
166            "junction_mode", self._JUNCTION_SKELETON, list(self._JUNCTION_TO_MODE.keys()),
167            title="Junction detection",
168            tooltip="How crista–membrane junctions are found.\n"
169                    "- Overlap (crista ∩ membrane): counts the connected components where the crista "
170                    "mask directly overlaps the membrane band. A crista that stops short of the "
171                    "membrane scores no junction, and a lamella rim can fragment into many blobs.\n"
172                    "- Skeleton (crista reaching the membrane): counts crista regions that come "
173                    "within Max Extension of the membrane near a crista terminus (an end of the "
174                    "crista skeleton). Tolerates a crista segmented short of the membrane, and never "
175                    "merges two separate cristae into one junction. Requires 3D data and reports the "
176                    "closest approach as mean_junction_extension_nm.",
177        )
178        setting_values.layout().addLayout(layout)
179
180        self.max_extension_param, layout = self._add_float_param(
181            "max_extension", 0.0, min_val=0.0, max_val=100.0,
182            title="Max Extension (nm, 0 = same as membrane)", decimals=1, step=0.5,
183            tooltip="How far a crista may fall short of the membrane and still count as a junction "
184                    "(Skeleton mode only). Set to 0 to use the same value as Membrane Thickness. "
185                    "Raising it well above the membrane thickness starts counting cristae that merely "
186                    "pass near the boundary, and can fuse junctions whose near-membrane regions touch.",
187        )
188        setting_values.layout().addLayout(layout)
189
190        self.min_junction_volume_param, layout = self._add_float_param(
191            "min_junction_volume", 0.0, min_val=0.0, max_val=100000.0,
192            title="Min Junction Volume (nm³, 0 = default 50)", decimals=1, step=10.0,
193            tooltip="Junction regions smaller than this are discarded (Skeleton mode only). This only "
194                    "removes specks — it does NOT fix the fact that skeleton mode over-counts on "
195                    "densely packed cristae, where the spurious regions are full-sized. Set to 0 to "
196                    "use the default of 50 nm³.",
197        )
198        setting_values.layout().addLayout(layout)
199
200        self.terminus_param, layout = self._add_float_param(
201            "terminus_distance", 0.0, min_val=0.0, max_val=200.0,
202            title="Terminus Distance (nm, 0 = default 20)", decimals=1, step=0.5,
203            tooltip="How close a near-membrane crista region must be to a crista terminus (an end of "
204                    "the crista skeleton) to count as a junction (Skeleton mode only). This is what "
205                    "separates a crista ENDING at the membrane from one merely running ALONGSIDE it. "
206                    "Set to 0 to use the default of 20 nm; raise it to be more permissive.",
207        )
208        setting_values.layout().addLayout(layout)
209
210        return self._make_collapsible(widget=setting_values, title="Advanced Settings")
211
212    def _read_inputs(self):
213        """Validate the selected layers/voxel size and read the shared run/preview parameters.
214
215        ``layer_scale``/``layer_translate`` are inherited from the source (crista) layer so the result
216        layers overlay the input correctly (e.g. when the raw data was loaded with a physical voxel
217        scale).
218
219        Returns (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate, mm_thickness,
220        border_gap, membrane_mode, junction_mode, max_extension, terminus, min_junction_volume)
221        or None (after showing
222        a guidance message) if inputs are incomplete.
223        """
224        crista_mask = self._get_layer_selector_data(self.crista_selector_name)
225        mito_seg = self._get_layer_selector_data(self.mito_selector_name)
226        if crista_mask is None or mito_seg is None:
227            show_info("Please select both a crista mask and a mito segmentation layer.")
228            return None
229
230        metadata = self._get_layer_selector_data(self.crista_selector_name, return_metadata=True)
231        voxel_size = self._handle_resolution(metadata, self.voxel_size_param, crista_mask.ndim, return_as_list=False)
232        if voxel_size is None:
233            show_info("Please provide a voxel size (or ensure layer metadata contains voxel_size).")
234            return None
235
236        ref_layer = self._get_layer_selector_layer(self.crista_selector_name)
237        layer_scale = None if ref_layer is None else ref_layer.scale
238        layer_translate = None if ref_layer is None else ref_layer.translate
239
240        mm_thickness = self.mm_thickness_param.value()
241        border_gap_val = self.border_gap_param.value()
242        border_gap = border_gap_val if border_gap_val > 0.0 else None
243        membrane_mode = self._MEMBRANE_TO_MODE[self.membrane_mode_param.currentText()]
244        junction_mode = self._JUNCTION_TO_MODE[self.junction_mode_param.currentText()]
245        max_extension_val = self.max_extension_param.value()
246        max_extension = max_extension_val if max_extension_val > 0.0 else mm_thickness
247        terminus_val = self.terminus_param.value()
248        terminus = terminus_val if terminus_val > 0.0 else None
249        min_volume_val = self.min_junction_volume_param.value()
250        min_junction_volume = min_volume_val if min_volume_val > 0.0 else None
251        return (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
252                mm_thickness, border_gap, membrane_mode, junction_mode, max_extension, terminus,
253                min_junction_volume)
254
255    def _compute_membrane_and_contacts(self, mito_seg, crista_mask, voxel_size, mm_thickness,
256                                       border_gap, membrane_mode, junction_mode, max_extension,
257                                       terminus, min_junction_volume, with_junctions=True):
258        """The cheap front-end shared by preview and run: membrane shell + crista-membrane junctions.
259
260        Also returns the border-trimmed lumen (eroded-mito interior) so the run can both display it
261        and feed it to the geodesic stage without recomputing the erosion, and the border-zone radius
262        so callers can report how much of the volume it covers.
263
264        Junctions come from :func:`~synapse_net.cristae_analysis.detect_junctions_per_mito`, i.e. the
265        same per-instance detection that fills the statistics table, **not** a single pass over the
266        whole volume. Detecting once globally is cheaper to write and gives different answers: a crista
267        restricted to one mitochondrion is clipped, and clipping moves its skeleton endpoints, so the
268        preview could show junctions the table did not report and vice versa. Per-instance is also the
269        faster of the two here (mito bounding boxes sum to a fraction of the volume, and the cost is
270        dominated by whole-volume distance transforms).
271
272        ``border_radius`` is passed on so skeleton mode does not claim junctions inside the zone where
273        ``approximate_membrane`` removed the membrane as unknown; the detector converts it to per-face
274        radii for each bounding box.
275
276        ``with_junctions=False`` skips the detection and returns ``(None, None)`` in its place, for the
277        run, which takes its junctions from the statistics table instead and would otherwise detect
278        them twice.
279        """
280        membrane_mask, lumen_mask = approximate_membrane(
281            mito_seg, voxel_size,
282            membrane_thickness_nm=mm_thickness, border_gap_nm=border_gap,
283            n_jobs=-1,
284            membrane_mode=membrane_mode,
285            return_lumen=True,
286        )
287        border_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
288        contact_labels = contact_summary = None
289        if with_junctions:
290            contact_labels, contact_summary = detect_junctions_per_mito(
291                crista_mask.astype(bool), mito_seg, membrane_mask, voxel_size,
292                junction_mode=junction_mode, max_extension_nm=max_extension,
293                terminus_nm=terminus, min_junction_volume_nm3=min_junction_volume,
294                border_radius=border_radius, n_jobs=-1, lumen_mask=lumen_mask,
295            )
296        return membrane_mask, lumen_mask, contact_labels, contact_summary, border_radius
297
298    def _add_skeleton_layers(self, crista_mask, mito_seg, voxel_size, layer_scale, layer_translate):
299        """Show the crista centerline skeleton as connected lines and, separately, its termini.
300
301        The termini get their own layer because they are what the skeleton junction mode's terminus
302        filter tests against — seeing them is how you tell a junction at a genuine crista end from one
303        flagged on a flank.
304
305        Two things make the display correspond to what the detector actually uses. The mask is
306        restricted to ``mito_seg > 0``, because the analysis skeletonises each mitochondrion's own
307        crista crop; without that the layer showed a skeleton over cristae the detector never looked at.
308        (It is not an identity: the detector works per mito *instance*, so a crista spanning two touching
309        instances is split there and not here. The junction layer *is* per instance, so a junction can
310        legitimately sit where this layer shows no terminus — clipping a crista to one mitochondrion
311        creates an end at the mitochondrial boundary that the whole-volume skeleton does not have. This
312        layer stays whole-volume because per-instance skeletons would mean concatenating vertex and
313        edge arrays with index offsets for what is a qualitative inspection aid.) And the skeleton is
314        drawn as a **Vectors** layer built from the graph edges rather than one point per vertex, so a
315        centerline reads as a curve instead of as scattered dots.
316
317        ``compute_crista_skeleton`` returns nm coordinates, so they are divided by the voxel size to
318        get array indices: every layer here is added in voxel coordinates with the physical placement
319        left to ``scale``/``translate``, matching the membrane mesh.
320        """
321        crista = crista_mask.astype(bool) & (mito_seg > 0)
322        vertices, is_terminus, edges = compute_crista_skeleton(
323            crista, voxel_size, n_jobs=-1, return_edges=True
324        )
325        if len(vertices) == 0:
326            show_info("INFO: No crista skeleton to display.")
327            return
328        vertices = vertices / _to_sampling(voxel_size, crista.ndim)
329        if len(edges):
330            starts = vertices[edges[:, 0]]
331            self.add_or_update_vectors(
332                self._SKELETON_LAYER, np.stack([starts, vertices[edges[:, 1]] - starts], axis=1),
333                scale=layer_scale, translate=layer_translate,
334                edge_width=0.4, edge_color="cyan", vector_style="line", out_of_slice_display=True,
335                blending="translucent_no_depth",
336            )
337        self.add_or_update_points(
338            self._SKELETON_TERMINI_LAYER, vertices[is_terminus],
339            scale=layer_scale, translate=layer_translate,
340            size=3.0, face_color="magenta", border_width=0.0, out_of_slice_display=True,
341            blending="translucent_no_depth",
342        )
343        show_info(
344            f"INFO: Skeleton — {len(vertices)} vertices, {len(edges)} segments, "
345            f"{int(is_terminus.sum())} termini."
346        )
347
348    def on_preview(self):
349        """Compute and show ONLY the membrane + junctions (seconds) — the front-end of the pipeline —
350        so the user can tune Membrane Thickness / Border Gap before the expensive per-mito run.
351
352        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
353        """
354        inputs = self._read_inputs()
355        if inputs is None:
356            return
357        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
358         mm_thickness, border_gap, membrane_mode, junction_mode, max_extension,
359         terminus, min_junction_volume) = inputs
360
361        with self._computing(
362            self.preview_button, "Computing preview…", "Preview Membrane && Junctions",
363            "INFO: Previewing membrane & junctions...",
364        ):
365            pbar = progress(total=2, desc="Preview: membrane & junctions")
366            try:
367                (membrane_mask, _lumen_mask, contact_labels, contact_summary,
368                 border_radius) = self._compute_membrane_and_contacts(
369                    mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode,
370                    junction_mode, max_extension, terminus, min_junction_volume,
371                )
372                pbar.update(1)
373                self.add_or_update_labels(
374                    self._MEMBRANE_LAYER, membrane_mask.astype(np.uint8),
375                    scale=layer_scale, translate=layer_translate, opacity=0.4,
376                )
377                if contact_labels.max() > 0:
378                    self.add_or_update_labels(
379                        self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
380                        scale=layer_scale, translate=layer_translate,
381                        blending="translucent_no_depth",
382                    )
383                else:
384                    show_info("INFO: No crista–membrane junctions detected at these settings.")
385                if self.show_skeleton_param.isChecked():
386                    self._add_skeleton_layers(crista_mask, mito_seg, voxel_size, layer_scale, layer_translate)
387                pbar.update(1)
388                border_fraction = _border_zone(mito_seg.shape, border_radius).mean()
389                show_info(
390                    f"INFO: Preview — {int(membrane_mask.sum())} membrane voxels, "
391                    f"{contact_summary['crista_junction_count']} junctions. "
392                    f"Border zone (membrane unknown, junctions suppressed) covers "
393                    f"{100 * border_fraction:.0f}% of the volume at Border Gap = "
394                    f"{border_radius} voxels. "
395                    "Adjust Membrane Thickness / Border Gap and preview again, or Run."
396                )
397            finally:
398                pbar.close()
399
400    def on_run(self):
401        """Run the full per-mitochondrion cristae analysis and add the result layers + stats table.
402
403        The junction layer and the reported total come from ``compute_mito_crista_statistics``'s own
404        ``return_junction_labels``, i.e. the very array its counts were computed from, so the picture
405        and the table cannot disagree. The front-end call below therefore asks only for the membrane
406        and lumen: detecting junctions there too would repeat work the table already does.
407
408        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
409        """
410        inputs = self._read_inputs()
411        if inputs is None:
412            return
413        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
414         mm_thickness, border_gap, membrane_mode, junction_mode, max_extension,
415         terminus, min_junction_volume) = inputs
416
417        with self._computing(
418            self.run_button, "Computing analysis…", "Run Cristae Analysis",
419            "INFO: Approximating mitochondrial membrane & junctions...",
420        ):
421            (membrane_mask, lumen_mask, _, _,
422             border_radius) = self._compute_membrane_and_contacts(
423                mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode,
424                junction_mode, max_extension, terminus, min_junction_volume,
425                with_junctions=False,   # they come from the statistics table below
426            )
427
428            method = self._ORIENTATION_TO_METHOD[self.orientation_param.currentText()]
429            show_info(f"INFO: Running cristae analysis per mitochondrion (orientation: {method})...")
430
431            # compute_mito_crista_statistics calls progress_callback once per mitochondrion, on this
432            # (GUI) thread, so the activity-dock bar can be created/updated here directly.
433            pbar = {"bar": None}
434
435            def _on_progress(done, total):
436                if pbar["bar"] is None:
437                    pbar["bar"] = progress(total=total, desc="Cristae analysis")
438                pbar["bar"].update(1)
439
440            try:
441                stats_df, contact_labels = compute_mito_crista_statistics(
442                    crista_mask, mito_seg, voxel_size,
443                    membrane_mask=membrane_mask,
444                    lumen_mask=lumen_mask,
445                    membrane_thickness_nm=mm_thickness,
446                    border_gap_nm=border_gap,
447                    method=method,
448                    membrane_mode=membrane_mode,
449                    junction_mode=junction_mode,
450                    max_extension_nm=max_extension,
451                    terminus_nm=terminus,
452                    min_junction_volume_nm3=min_junction_volume,
453                    n_jobs=-1,
454                    verbose=True,
455                    progress_callback=_on_progress,
456                    return_junction_labels=True,
457                )
458            finally:
459                if pbar["bar"] is not None:
460                    pbar["bar"].close()
461
462            if self.show_membranes_param.isChecked():
463                gap_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
464                mesh = _open_trimmed_mesh(
465                    lumen_mask, np.ones(mito_seg.ndim), gap_radius, np.ones((mito_seg.ndim, 2), dtype=bool)
466                )
467                if mesh is not None:
468                    verts, faces = mesh
469                    self.add_or_update_surface(
470                        self._MEMBRANE_MESH_LAYER, verts, faces,
471                        scale=layer_scale, translate=layer_translate,
472                        opacity=0.4, blending="translucent",
473                    )
474                else:
475                    show_info("INFO: No membrane surface to display at these settings.")
476
477            if self.show_skeleton_param.isChecked():
478                self._add_skeleton_layers(crista_mask, mito_seg, voxel_size, layer_scale, layer_translate)
479
480            if contact_labels.max() > 0:
481                self.add_or_update_labels(
482                    self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
483                    scale=layer_scale, translate=layer_translate,
484                    blending="translucent_no_depth",
485                )
486            else:
487                show_info("INFO: No crista–membrane junctions detected — junction layer not added.")
488
489            mito_layer = self._get_layer_selector_layer(self.mito_selector_name)
490            self._add_properties_and_table(mito_layer, stats_df, save_path=self.save_path.text())
491
492            n_mito = len(stats_df)
493            n_contacts = int(stats_df["crista_junction_count"].sum())
494            show_info(
495                f"INFO: Cristae analysis complete — {n_mito} mitochondria, "
496                f"{n_contacts} crista junction sites detected."
497            )

Napari widget for the cristae analysis (preview + full per-mitochondrion run).

_ORIENTATION_TO_METHOD maps the orientation dropdown labels to the method argument of ~synapse_net.cristae_analysis.compute_mito_crista_statistics(), _MEMBRANE_TO_MODE maps the membrane-mode labels to the membrane_mode argument of ~synapse_net.cristae_analysis.approximate_membrane(), and _JUNCTION_TO_MODE maps the junction-mode labels to the junction_mode argument of ~synapse_net.cristae_analysis.detect_junctions(). The _*_LAYER name constants are shared by the preview and the full run so re-previewing / running updates the same layers instead of duplicating them.

viewer
crista_selector_name
mito_selector_name
crista_selector_widget
mito_selector_widget
settings
preview_button
run_button
def on_preview(self):
348    def on_preview(self):
349        """Compute and show ONLY the membrane + junctions (seconds) — the front-end of the pipeline —
350        so the user can tune Membrane Thickness / Border Gap before the expensive per-mito run.
351
352        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
353        """
354        inputs = self._read_inputs()
355        if inputs is None:
356            return
357        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
358         mm_thickness, border_gap, membrane_mode, junction_mode, max_extension,
359         terminus, min_junction_volume) = inputs
360
361        with self._computing(
362            self.preview_button, "Computing preview…", "Preview Membrane && Junctions",
363            "INFO: Previewing membrane & junctions...",
364        ):
365            pbar = progress(total=2, desc="Preview: membrane & junctions")
366            try:
367                (membrane_mask, _lumen_mask, contact_labels, contact_summary,
368                 border_radius) = self._compute_membrane_and_contacts(
369                    mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode,
370                    junction_mode, max_extension, terminus, min_junction_volume,
371                )
372                pbar.update(1)
373                self.add_or_update_labels(
374                    self._MEMBRANE_LAYER, membrane_mask.astype(np.uint8),
375                    scale=layer_scale, translate=layer_translate, opacity=0.4,
376                )
377                if contact_labels.max() > 0:
378                    self.add_or_update_labels(
379                        self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
380                        scale=layer_scale, translate=layer_translate,
381                        blending="translucent_no_depth",
382                    )
383                else:
384                    show_info("INFO: No crista–membrane junctions detected at these settings.")
385                if self.show_skeleton_param.isChecked():
386                    self._add_skeleton_layers(crista_mask, mito_seg, voxel_size, layer_scale, layer_translate)
387                pbar.update(1)
388                border_fraction = _border_zone(mito_seg.shape, border_radius).mean()
389                show_info(
390                    f"INFO: Preview — {int(membrane_mask.sum())} membrane voxels, "
391                    f"{contact_summary['crista_junction_count']} junctions. "
392                    f"Border zone (membrane unknown, junctions suppressed) covers "
393                    f"{100 * border_fraction:.0f}% of the volume at Border Gap = "
394                    f"{border_radius} voxels. "
395                    "Adjust Membrane Thickness / Border Gap and preview again, or Run."
396                )
397            finally:
398                pbar.close()

Compute and show ONLY the membrane + junctions (seconds) — the front-end of the pipeline — so the user can tune Membrane Thickness / Border Gap before the expensive per-mito run.

Runs synchronously; _computing() provides the busy feedback while it blocks.

def on_run(self):
400    def on_run(self):
401        """Run the full per-mitochondrion cristae analysis and add the result layers + stats table.
402
403        The junction layer and the reported total come from ``compute_mito_crista_statistics``'s own
404        ``return_junction_labels``, i.e. the very array its counts were computed from, so the picture
405        and the table cannot disagree. The front-end call below therefore asks only for the membrane
406        and lumen: detecting junctions there too would repeat work the table already does.
407
408        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
409        """
410        inputs = self._read_inputs()
411        if inputs is None:
412            return
413        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
414         mm_thickness, border_gap, membrane_mode, junction_mode, max_extension,
415         terminus, min_junction_volume) = inputs
416
417        with self._computing(
418            self.run_button, "Computing analysis…", "Run Cristae Analysis",
419            "INFO: Approximating mitochondrial membrane & junctions...",
420        ):
421            (membrane_mask, lumen_mask, _, _,
422             border_radius) = self._compute_membrane_and_contacts(
423                mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode,
424                junction_mode, max_extension, terminus, min_junction_volume,
425                with_junctions=False,   # they come from the statistics table below
426            )
427
428            method = self._ORIENTATION_TO_METHOD[self.orientation_param.currentText()]
429            show_info(f"INFO: Running cristae analysis per mitochondrion (orientation: {method})...")
430
431            # compute_mito_crista_statistics calls progress_callback once per mitochondrion, on this
432            # (GUI) thread, so the activity-dock bar can be created/updated here directly.
433            pbar = {"bar": None}
434
435            def _on_progress(done, total):
436                if pbar["bar"] is None:
437                    pbar["bar"] = progress(total=total, desc="Cristae analysis")
438                pbar["bar"].update(1)
439
440            try:
441                stats_df, contact_labels = compute_mito_crista_statistics(
442                    crista_mask, mito_seg, voxel_size,
443                    membrane_mask=membrane_mask,
444                    lumen_mask=lumen_mask,
445                    membrane_thickness_nm=mm_thickness,
446                    border_gap_nm=border_gap,
447                    method=method,
448                    membrane_mode=membrane_mode,
449                    junction_mode=junction_mode,
450                    max_extension_nm=max_extension,
451                    terminus_nm=terminus,
452                    min_junction_volume_nm3=min_junction_volume,
453                    n_jobs=-1,
454                    verbose=True,
455                    progress_callback=_on_progress,
456                    return_junction_labels=True,
457                )
458            finally:
459                if pbar["bar"] is not None:
460                    pbar["bar"].close()
461
462            if self.show_membranes_param.isChecked():
463                gap_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
464                mesh = _open_trimmed_mesh(
465                    lumen_mask, np.ones(mito_seg.ndim), gap_radius, np.ones((mito_seg.ndim, 2), dtype=bool)
466                )
467                if mesh is not None:
468                    verts, faces = mesh
469                    self.add_or_update_surface(
470                        self._MEMBRANE_MESH_LAYER, verts, faces,
471                        scale=layer_scale, translate=layer_translate,
472                        opacity=0.4, blending="translucent",
473                    )
474                else:
475                    show_info("INFO: No membrane surface to display at these settings.")
476
477            if self.show_skeleton_param.isChecked():
478                self._add_skeleton_layers(crista_mask, mito_seg, voxel_size, layer_scale, layer_translate)
479
480            if contact_labels.max() > 0:
481                self.add_or_update_labels(
482                    self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
483                    scale=layer_scale, translate=layer_translate,
484                    blending="translucent_no_depth",
485                )
486            else:
487                show_info("INFO: No crista–membrane junctions detected — junction layer not added.")
488
489            mito_layer = self._get_layer_selector_layer(self.mito_selector_name)
490            self._add_properties_and_table(mito_layer, stats_df, save_path=self.save_path.text())
491
492            n_mito = len(stats_df)
493            n_contacts = int(stats_df["crista_junction_count"].sum())
494            show_info(
495                f"INFO: Cristae analysis complete — {n_mito} mitochondria, "
496                f"{n_contacts} crista junction sites detected."
497            )

Run the full per-mitochondrion cristae analysis and add the result layers + stats table.

The junction layer and the reported total come from compute_mito_crista_statistics's own return_junction_labels, i.e. the very array its counts were computed from, so the picture and the table cannot disagree. The front-end call below therefore asks only for the membrane and lumen: detecting junctions there too would repeat work the table already does.

Runs synchronously; _computing() provides the busy feedback while it blocks.