synapse_net.tools.cristae_analysis_widget

  1import napari
  2import numpy as np
  3
  4from contextlib import contextmanager
  5
  6from napari.utils import progress
  7from napari.utils.notifications import show_info
  8from qtpy.QtCore import Qt
  9from qtpy.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton
 10
 11from .base_widget import BaseWidget
 12from ..cristae_analysis import (
 13    approximate_membrane, compute_mito_crista_statistics, detect_contact_sites,
 14    _open_trimmed_mesh, _gap_radius,
 15)
 16
 17
 18class CristaeAnalysisWidget(BaseWidget):
 19    """Napari widget for the cristae analysis (preview + full per-mitochondrion run).
 20
 21    ``_ORIENTATION_TO_METHOD`` maps the orientation dropdown labels to the ``method`` argument of
 22    :func:`~synapse_net.cristae_analysis.compute_mito_crista_statistics`, and ``_MEMBRANE_TO_MODE`` maps
 23    the membrane-mode labels to the ``membrane_mode`` argument of
 24    :func:`~synapse_net.cristae_analysis.approximate_membrane`. 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    def __init__(self):
 44        super().__init__()
 45
 46        self.viewer = napari.current_viewer()
 47        layout = QVBoxLayout()
 48
 49        self.crista_selector_name = "Crista Mask"
 50        self.mito_selector_name = "Mito Segmentation"
 51
 52        self.crista_selector_widget = self._create_layer_selector(
 53            self.crista_selector_name, layer_type="Labels", prefer_substring="cristae")
 54        self.mito_selector_widget = self._create_layer_selector(
 55            self.mito_selector_name, layer_type="Labels", prefer_substring="mitochondria")
 56
 57        self.settings = self._create_settings_widget()
 58
 59        self.preview_button = QPushButton("Preview Membrane && Junctions")
 60        self.preview_button.clicked.connect(self.on_preview)
 61
 62        self.run_button = QPushButton("Run Cristae Analysis")
 63        self.run_button.clicked.connect(self.on_run)
 64
 65        layout.addWidget(self.crista_selector_widget)
 66        layout.addWidget(self.mito_selector_widget)
 67        layout.addWidget(self.settings)
 68        layout.addWidget(self.preview_button)
 69        layout.addWidget(self.run_button)
 70
 71        self.setLayout(layout)
 72
 73    _MEMBRANE_LAYER = "Membrane Mask"
 74    _MEMBRANE_MESH_LAYER = "Membrane Mesh"
 75    _JUNCTION_LAYER = "Crista-Membrane Junctions"
 76
 77    def _create_settings_widget(self):
 78        setting_values = QWidget()
 79        setting_values.setLayout(QVBoxLayout())
 80
 81        self.save_path, layout = self._add_path_param(
 82            name="save_path", select_type="file", value="",
 83            tooltip="Path to save the analysis results CSV file. An empty path will skip saving. "
 84                    "See docs/cristae_analysis.md for how each column is computed.",
 85        )
 86        setting_values.layout().addLayout(layout)
 87
 88        self.voxel_size_param, layout = self._add_float_param(
 89            "voxel_size", 0.0, min_val=0.0, max_val=100.0,
 90            title="Voxel Size (nm, 0 = auto)", step=0.1,
 91            tooltip="Voxel size of the input volume in nanometers. Set to 0 (default) to auto-detect from layer metadata.",
 92        )
 93        setting_values.layout().addLayout(layout)
 94
 95        self.mm_thickness_param, layout = self._add_float_param(
 96            "mm_thickness", 8.0, min_val=1.0, max_val=30.0,
 97            title="Membrane Thickness (nm)", decimals=1, step=0.5,
 98            tooltip="Thickness of the mitochondrial membrane shell in nanometers.",
 99        )
100        setting_values.layout().addLayout(layout)
101
102        self.border_gap_param, layout = self._add_float_param(
103            "border_gap", 0.0, min_val=0.0, max_val=100.0,
104            title="Border Gap (nm, 0 = same as membrane)", decimals=1, step=0.5,
105            tooltip="Distance from each volume face within which membrane voxels are suppressed. "
106                    "Set to 0 to use the same value as Membrane Thickness.",
107        )
108        setting_values.layout().addLayout(layout)
109
110        self.show_membranes_param = self._add_boolean_param(
111            "show_membranes", False,
112            title="Show Membrane Mesh",
113            tooltip="Add the eroded-mito (lumen) inner surface — the single-wall surface the junction "
114                    "geodesics run along — as a mesh (napari Surface layer) after running.",
115        )
116        setting_values.layout().addWidget(self.show_membranes_param)
117
118        self.orientation_param, layout = self._add_choice_param(
119            "orientation", self._ORIENTATION_SKIP, list(self._ORIENTATION_TO_METHOD.keys()),
120            title="Crista orientation",
121            tooltip="How to compute the crista orientation anisotropy — the most expensive stage "
122                    "(structure tensor). All other metrics (surface areas, junction distances, "
123                    "thickness) are identical regardless of this choice.\n"
124                    "- Fast (downsampled, approximate): ~8x faster; a relative indicator only, not "
125                    "comparable in magnitude to the exact value.\n"
126                    "- Exact (full resolution): the true anisotropy (slowest).\n"
127                    "- Skip (no orientation): fastest; leaves the orientation column empty.",
128        )
129        setting_values.layout().addLayout(layout)
130
131        self.membrane_mode_param, layout = self._add_choice_param(
132            "membrane_mode", self._MEMBRANE_SLICE_2D, list(self._MEMBRANE_TO_MODE.keys()),
133            title="Membrane mode",
134            tooltip="How the membrane shell is approximated.\n"
135                    "- 2D per-slice (z-parallel): erode each Z-slice independently in XY (no z-bleed); "
136                    "the shell has no Z-caps and can fragment across slices (some junction pairs may "
137                    "then have no along-membrane path).\n"
138                    "- 3D connected shell: a single connected 3D shell including the Z-caps (no "
139                    "fragmentation), somewhat slower; thickness acts in all axes.",
140        )
141        setting_values.layout().addLayout(layout)
142
143        return self._make_collapsible(widget=setting_values, title="Advanced Settings")
144
145    def _read_inputs(self):
146        """Validate the selected layers/voxel size and read the shared run/preview parameters.
147
148        ``layer_scale``/``layer_translate`` are inherited from the source (crista) layer so the result
149        layers overlay the input correctly (e.g. when the raw data was loaded with a physical voxel
150        scale).
151
152        Returns (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate, mm_thickness,
153        border_gap) or None (after showing a guidance message) if inputs are incomplete.
154        """
155        crista_mask = self._get_layer_selector_data(self.crista_selector_name)
156        mito_seg = self._get_layer_selector_data(self.mito_selector_name)
157        if crista_mask is None or mito_seg is None:
158            show_info("Please select both a crista mask and a mito segmentation layer.")
159            return None
160
161        metadata = self._get_layer_selector_data(self.crista_selector_name, return_metadata=True)
162        voxel_size = self._handle_resolution(metadata, self.voxel_size_param, crista_mask.ndim, return_as_list=False)
163        if voxel_size is None:
164            show_info("Please provide a voxel size (or ensure layer metadata contains voxel_size).")
165            return None
166
167        ref_layer = self._get_layer_selector_layer(self.crista_selector_name)
168        layer_scale = None if ref_layer is None else ref_layer.scale
169        layer_translate = None if ref_layer is None else ref_layer.translate
170
171        mm_thickness = self.mm_thickness_param.value()
172        border_gap_val = self.border_gap_param.value()
173        border_gap = border_gap_val if border_gap_val > 0.0 else None
174        membrane_mode = self._MEMBRANE_TO_MODE[self.membrane_mode_param.currentText()]
175        return (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
176                mm_thickness, border_gap, membrane_mode)
177
178    def _compute_membrane_and_contacts(self, mito_seg, crista_mask, voxel_size, mm_thickness,
179                                       border_gap, membrane_mode):
180        """The cheap front-end shared by preview and run: membrane shell + crista-membrane junctions.
181
182        Also returns the border-trimmed lumen (eroded-mito interior) so the run can both display it
183        and feed it to the geodesic stage without recomputing the erosion.
184        """
185        membrane_mask, lumen_mask = approximate_membrane(
186            mito_seg, voxel_size,
187            membrane_thickness_nm=mm_thickness, border_gap_nm=border_gap,
188            n_jobs=-1,
189            membrane_mode=membrane_mode,
190            return_lumen=True,
191        )
192        contact_labels, contact_summary = detect_contact_sites(
193            crista_mask.astype(bool), membrane_mask, voxel_size
194        )
195        return membrane_mask, lumen_mask, contact_labels, contact_summary
196
197    @contextmanager
198    def _computing(self, button, busy_text, idle_text, message):
199        """Show a busy state around a synchronous, GUI-thread-blocking action, then restore it.
200
201        Disables and relabels ``button``, sets a wait cursor, shows ``message`` and forces one repaint
202        so the busy state is painted *before* the blocking call — otherwise none of it would render
203        until the call returned and the button would just look stuck. The cursor, button label and
204        enabled state are restored on exit (also on error). Disabling the button also blocks a
205        re-entrant second click while the action is in flight. All Qt calls are guarded so they no-op
206        without a running QApplication.
207        """
208        app = QApplication.instance()
209        button.setEnabled(False)
210        button.setText(busy_text)
211        if app is not None:
212            QApplication.setOverrideCursor(Qt.WaitCursor)
213        show_info(message)
214        if app is not None:
215            app.processEvents()
216        try:
217            yield
218        finally:
219            if app is not None:
220                QApplication.restoreOverrideCursor()
221            button.setEnabled(True)
222            button.setText(idle_text)
223
224    def on_preview(self):
225        """Compute and show ONLY the membrane + junctions (seconds) — the front-end of the pipeline —
226        so the user can tune Membrane Thickness / Border Gap before the expensive per-mito run.
227
228        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
229        """
230        inputs = self._read_inputs()
231        if inputs is None:
232            return
233        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
234         mm_thickness, border_gap, membrane_mode) = inputs
235
236        with self._computing(
237            self.preview_button, "Computing preview…", "Preview Membrane && Junctions",
238            "INFO: Previewing membrane & junctions...",
239        ):
240            pbar = progress(total=2, desc="Preview: membrane & junctions")
241            try:
242                membrane_mask, _lumen_mask, contact_labels, contact_summary = self._compute_membrane_and_contacts(
243                    mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode
244                )
245                pbar.update(1)
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,
249                )
250                if contact_labels.max() > 0:
251                    self.add_or_update_labels(
252                        self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
253                        scale=layer_scale, translate=layer_translate,
254                        blending="translucent_no_depth",
255                    )
256                else:
257                    show_info("INFO: No crista–membrane junctions detected at these settings.")
258                pbar.update(1)
259                show_info(
260                    f"INFO: Preview — {int(membrane_mask.sum())} membrane voxels, "
261                    f"{contact_summary['crista_junction_count']} junctions. "
262                    "Adjust Membrane Thickness / Border Gap and preview again, or Run."
263                )
264            finally:
265                pbar.close()
266
267    def on_run(self):
268        """Run the full per-mitochondrion cristae analysis and add the result layers + stats table.
269
270        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
271        """
272        inputs = self._read_inputs()
273        if inputs is None:
274            return
275        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
276         mm_thickness, border_gap, membrane_mode) = inputs
277
278        with self._computing(
279            self.run_button, "Computing analysis…", "Run Cristae Analysis",
280            "INFO: Approximating mitochondrial membrane & junctions...",
281        ):
282            membrane_mask, lumen_mask, contact_labels, contact_summary = self._compute_membrane_and_contacts(
283                mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode
284            )
285
286            method = self._ORIENTATION_TO_METHOD[self.orientation_param.currentText()]
287            show_info(f"INFO: Running cristae analysis per mitochondrion (orientation: {method})...")
288
289            # compute_mito_crista_statistics calls progress_callback once per mitochondrion, on this
290            # (GUI) thread, so the activity-dock bar can be created/updated here directly.
291            pbar = {"bar": None}
292
293            def _on_progress(done, total):
294                if pbar["bar"] is None:
295                    pbar["bar"] = progress(total=total, desc="Cristae analysis")
296                pbar["bar"].update(1)
297
298            try:
299                stats_df = compute_mito_crista_statistics(
300                    crista_mask, mito_seg, voxel_size,
301                    membrane_mask=membrane_mask,
302                    lumen_mask=lumen_mask,
303                    membrane_thickness_nm=mm_thickness,
304                    border_gap_nm=border_gap,
305                    method=method,
306                    membrane_mode=membrane_mode,
307                    n_jobs=-1,
308                    verbose=True,
309                    progress_callback=_on_progress,
310                )
311            finally:
312                if pbar["bar"] is not None:
313                    pbar["bar"].close()
314
315            if self.show_membranes_param.isChecked():
316                gap_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
317                mesh = _open_trimmed_mesh(
318                    lumen_mask, np.ones(mito_seg.ndim), gap_radius, np.ones((mito_seg.ndim, 2), dtype=bool)
319                )
320                if mesh is not None:
321                    verts, faces = mesh
322                    self.add_or_update_surface(
323                        self._MEMBRANE_MESH_LAYER, verts, faces,
324                        scale=layer_scale, translate=layer_translate,
325                        opacity=0.4, blending="translucent",
326                    )
327                else:
328                    show_info("INFO: No membrane surface to display at these settings.")
329
330            if contact_labels.max() > 0:
331                self.add_or_update_labels(
332                    self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
333                    scale=layer_scale, translate=layer_translate,
334                    blending="translucent_no_depth",
335                )
336            else:
337                show_info("INFO: No crista–membrane junctions detected — junction layer not added.")
338
339            mito_layer = self._get_layer_selector_layer(self.mito_selector_name)
340            self._add_properties_and_table(mito_layer, stats_df, save_path=self.save_path.text())
341
342            n_mito = len(stats_df)
343            n_contacts = contact_summary["crista_junction_count"]
344            show_info(
345                f"INFO: Cristae analysis complete — {n_mito} mitochondria, "
346                f"{n_contacts} crista junction sites detected."
347            )
class CristaeAnalysisWidget(synapse_net.tools.base_widget.BaseWidget):
 19class CristaeAnalysisWidget(BaseWidget):
 20    """Napari widget for the cristae analysis (preview + full per-mitochondrion run).
 21
 22    ``_ORIENTATION_TO_METHOD`` maps the orientation dropdown labels to the ``method`` argument of
 23    :func:`~synapse_net.cristae_analysis.compute_mito_crista_statistics`, and ``_MEMBRANE_TO_MODE`` maps
 24    the membrane-mode labels to the ``membrane_mode`` argument of
 25    :func:`~synapse_net.cristae_analysis.approximate_membrane`. 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    def __init__(self):
 45        super().__init__()
 46
 47        self.viewer = napari.current_viewer()
 48        layout = QVBoxLayout()
 49
 50        self.crista_selector_name = "Crista Mask"
 51        self.mito_selector_name = "Mito Segmentation"
 52
 53        self.crista_selector_widget = self._create_layer_selector(
 54            self.crista_selector_name, layer_type="Labels", prefer_substring="cristae")
 55        self.mito_selector_widget = self._create_layer_selector(
 56            self.mito_selector_name, layer_type="Labels", prefer_substring="mitochondria")
 57
 58        self.settings = self._create_settings_widget()
 59
 60        self.preview_button = QPushButton("Preview Membrane && Junctions")
 61        self.preview_button.clicked.connect(self.on_preview)
 62
 63        self.run_button = QPushButton("Run Cristae Analysis")
 64        self.run_button.clicked.connect(self.on_run)
 65
 66        layout.addWidget(self.crista_selector_widget)
 67        layout.addWidget(self.mito_selector_widget)
 68        layout.addWidget(self.settings)
 69        layout.addWidget(self.preview_button)
 70        layout.addWidget(self.run_button)
 71
 72        self.setLayout(layout)
 73
 74    _MEMBRANE_LAYER = "Membrane Mask"
 75    _MEMBRANE_MESH_LAYER = "Membrane Mesh"
 76    _JUNCTION_LAYER = "Crista-Membrane Junctions"
 77
 78    def _create_settings_widget(self):
 79        setting_values = QWidget()
 80        setting_values.setLayout(QVBoxLayout())
 81
 82        self.save_path, layout = self._add_path_param(
 83            name="save_path", select_type="file", value="",
 84            tooltip="Path to save the analysis results CSV file. An empty path will skip saving. "
 85                    "See docs/cristae_analysis.md for how each column is computed.",
 86        )
 87        setting_values.layout().addLayout(layout)
 88
 89        self.voxel_size_param, layout = self._add_float_param(
 90            "voxel_size", 0.0, min_val=0.0, max_val=100.0,
 91            title="Voxel Size (nm, 0 = auto)", step=0.1,
 92            tooltip="Voxel size of the input volume in nanometers. Set to 0 (default) to auto-detect from layer metadata.",
 93        )
 94        setting_values.layout().addLayout(layout)
 95
 96        self.mm_thickness_param, layout = self._add_float_param(
 97            "mm_thickness", 8.0, min_val=1.0, max_val=30.0,
 98            title="Membrane Thickness (nm)", decimals=1, step=0.5,
 99            tooltip="Thickness of the mitochondrial membrane shell in nanometers.",
100        )
101        setting_values.layout().addLayout(layout)
102
103        self.border_gap_param, layout = self._add_float_param(
104            "border_gap", 0.0, min_val=0.0, max_val=100.0,
105            title="Border Gap (nm, 0 = same as membrane)", decimals=1, step=0.5,
106            tooltip="Distance from each volume face within which membrane voxels are suppressed. "
107                    "Set to 0 to use the same value as Membrane Thickness.",
108        )
109        setting_values.layout().addLayout(layout)
110
111        self.show_membranes_param = self._add_boolean_param(
112            "show_membranes", False,
113            title="Show Membrane Mesh",
114            tooltip="Add the eroded-mito (lumen) inner surface — the single-wall surface the junction "
115                    "geodesics run along — as a mesh (napari Surface layer) after running.",
116        )
117        setting_values.layout().addWidget(self.show_membranes_param)
118
119        self.orientation_param, layout = self._add_choice_param(
120            "orientation", self._ORIENTATION_SKIP, list(self._ORIENTATION_TO_METHOD.keys()),
121            title="Crista orientation",
122            tooltip="How to compute the crista orientation anisotropy — the most expensive stage "
123                    "(structure tensor). All other metrics (surface areas, junction distances, "
124                    "thickness) are identical regardless of this choice.\n"
125                    "- Fast (downsampled, approximate): ~8x faster; a relative indicator only, not "
126                    "comparable in magnitude to the exact value.\n"
127                    "- Exact (full resolution): the true anisotropy (slowest).\n"
128                    "- Skip (no orientation): fastest; leaves the orientation column empty.",
129        )
130        setting_values.layout().addLayout(layout)
131
132        self.membrane_mode_param, layout = self._add_choice_param(
133            "membrane_mode", self._MEMBRANE_SLICE_2D, list(self._MEMBRANE_TO_MODE.keys()),
134            title="Membrane mode",
135            tooltip="How the membrane shell is approximated.\n"
136                    "- 2D per-slice (z-parallel): erode each Z-slice independently in XY (no z-bleed); "
137                    "the shell has no Z-caps and can fragment across slices (some junction pairs may "
138                    "then have no along-membrane path).\n"
139                    "- 3D connected shell: a single connected 3D shell including the Z-caps (no "
140                    "fragmentation), somewhat slower; thickness acts in all axes.",
141        )
142        setting_values.layout().addLayout(layout)
143
144        return self._make_collapsible(widget=setting_values, title="Advanced Settings")
145
146    def _read_inputs(self):
147        """Validate the selected layers/voxel size and read the shared run/preview parameters.
148
149        ``layer_scale``/``layer_translate`` are inherited from the source (crista) layer so the result
150        layers overlay the input correctly (e.g. when the raw data was loaded with a physical voxel
151        scale).
152
153        Returns (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate, mm_thickness,
154        border_gap) or None (after showing a guidance message) if inputs are incomplete.
155        """
156        crista_mask = self._get_layer_selector_data(self.crista_selector_name)
157        mito_seg = self._get_layer_selector_data(self.mito_selector_name)
158        if crista_mask is None or mito_seg is None:
159            show_info("Please select both a crista mask and a mito segmentation layer.")
160            return None
161
162        metadata = self._get_layer_selector_data(self.crista_selector_name, return_metadata=True)
163        voxel_size = self._handle_resolution(metadata, self.voxel_size_param, crista_mask.ndim, return_as_list=False)
164        if voxel_size is None:
165            show_info("Please provide a voxel size (or ensure layer metadata contains voxel_size).")
166            return None
167
168        ref_layer = self._get_layer_selector_layer(self.crista_selector_name)
169        layer_scale = None if ref_layer is None else ref_layer.scale
170        layer_translate = None if ref_layer is None else ref_layer.translate
171
172        mm_thickness = self.mm_thickness_param.value()
173        border_gap_val = self.border_gap_param.value()
174        border_gap = border_gap_val if border_gap_val > 0.0 else None
175        membrane_mode = self._MEMBRANE_TO_MODE[self.membrane_mode_param.currentText()]
176        return (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
177                mm_thickness, border_gap, membrane_mode)
178
179    def _compute_membrane_and_contacts(self, mito_seg, crista_mask, voxel_size, mm_thickness,
180                                       border_gap, membrane_mode):
181        """The cheap front-end shared by preview and run: membrane shell + crista-membrane junctions.
182
183        Also returns the border-trimmed lumen (eroded-mito interior) so the run can both display it
184        and feed it to the geodesic stage without recomputing the erosion.
185        """
186        membrane_mask, lumen_mask = approximate_membrane(
187            mito_seg, voxel_size,
188            membrane_thickness_nm=mm_thickness, border_gap_nm=border_gap,
189            n_jobs=-1,
190            membrane_mode=membrane_mode,
191            return_lumen=True,
192        )
193        contact_labels, contact_summary = detect_contact_sites(
194            crista_mask.astype(bool), membrane_mask, voxel_size
195        )
196        return membrane_mask, lumen_mask, contact_labels, contact_summary
197
198    @contextmanager
199    def _computing(self, button, busy_text, idle_text, message):
200        """Show a busy state around a synchronous, GUI-thread-blocking action, then restore it.
201
202        Disables and relabels ``button``, sets a wait cursor, shows ``message`` and forces one repaint
203        so the busy state is painted *before* the blocking call — otherwise none of it would render
204        until the call returned and the button would just look stuck. The cursor, button label and
205        enabled state are restored on exit (also on error). Disabling the button also blocks a
206        re-entrant second click while the action is in flight. All Qt calls are guarded so they no-op
207        without a running QApplication.
208        """
209        app = QApplication.instance()
210        button.setEnabled(False)
211        button.setText(busy_text)
212        if app is not None:
213            QApplication.setOverrideCursor(Qt.WaitCursor)
214        show_info(message)
215        if app is not None:
216            app.processEvents()
217        try:
218            yield
219        finally:
220            if app is not None:
221                QApplication.restoreOverrideCursor()
222            button.setEnabled(True)
223            button.setText(idle_text)
224
225    def on_preview(self):
226        """Compute and show ONLY the membrane + junctions (seconds) — the front-end of the pipeline —
227        so the user can tune Membrane Thickness / Border Gap before the expensive per-mito run.
228
229        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
230        """
231        inputs = self._read_inputs()
232        if inputs is None:
233            return
234        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
235         mm_thickness, border_gap, membrane_mode) = inputs
236
237        with self._computing(
238            self.preview_button, "Computing preview…", "Preview Membrane && Junctions",
239            "INFO: Previewing membrane & junctions...",
240        ):
241            pbar = progress(total=2, desc="Preview: membrane & junctions")
242            try:
243                membrane_mask, _lumen_mask, contact_labels, contact_summary = self._compute_membrane_and_contacts(
244                    mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode
245                )
246                pbar.update(1)
247                self.add_or_update_labels(
248                    self._MEMBRANE_LAYER, membrane_mask.astype(np.uint8),
249                    scale=layer_scale, translate=layer_translate, opacity=0.4,
250                )
251                if contact_labels.max() > 0:
252                    self.add_or_update_labels(
253                        self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
254                        scale=layer_scale, translate=layer_translate,
255                        blending="translucent_no_depth",
256                    )
257                else:
258                    show_info("INFO: No crista–membrane junctions detected at these settings.")
259                pbar.update(1)
260                show_info(
261                    f"INFO: Preview — {int(membrane_mask.sum())} membrane voxels, "
262                    f"{contact_summary['crista_junction_count']} junctions. "
263                    "Adjust Membrane Thickness / Border Gap and preview again, or Run."
264                )
265            finally:
266                pbar.close()
267
268    def on_run(self):
269        """Run the full per-mitochondrion cristae analysis and add the result layers + stats table.
270
271        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
272        """
273        inputs = self._read_inputs()
274        if inputs is None:
275            return
276        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
277         mm_thickness, border_gap, membrane_mode) = inputs
278
279        with self._computing(
280            self.run_button, "Computing analysis…", "Run Cristae Analysis",
281            "INFO: Approximating mitochondrial membrane & junctions...",
282        ):
283            membrane_mask, lumen_mask, contact_labels, contact_summary = self._compute_membrane_and_contacts(
284                mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode
285            )
286
287            method = self._ORIENTATION_TO_METHOD[self.orientation_param.currentText()]
288            show_info(f"INFO: Running cristae analysis per mitochondrion (orientation: {method})...")
289
290            # compute_mito_crista_statistics calls progress_callback once per mitochondrion, on this
291            # (GUI) thread, so the activity-dock bar can be created/updated here directly.
292            pbar = {"bar": None}
293
294            def _on_progress(done, total):
295                if pbar["bar"] is None:
296                    pbar["bar"] = progress(total=total, desc="Cristae analysis")
297                pbar["bar"].update(1)
298
299            try:
300                stats_df = compute_mito_crista_statistics(
301                    crista_mask, mito_seg, voxel_size,
302                    membrane_mask=membrane_mask,
303                    lumen_mask=lumen_mask,
304                    membrane_thickness_nm=mm_thickness,
305                    border_gap_nm=border_gap,
306                    method=method,
307                    membrane_mode=membrane_mode,
308                    n_jobs=-1,
309                    verbose=True,
310                    progress_callback=_on_progress,
311                )
312            finally:
313                if pbar["bar"] is not None:
314                    pbar["bar"].close()
315
316            if self.show_membranes_param.isChecked():
317                gap_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
318                mesh = _open_trimmed_mesh(
319                    lumen_mask, np.ones(mito_seg.ndim), gap_radius, np.ones((mito_seg.ndim, 2), dtype=bool)
320                )
321                if mesh is not None:
322                    verts, faces = mesh
323                    self.add_or_update_surface(
324                        self._MEMBRANE_MESH_LAYER, verts, faces,
325                        scale=layer_scale, translate=layer_translate,
326                        opacity=0.4, blending="translucent",
327                    )
328                else:
329                    show_info("INFO: No membrane surface to display at these settings.")
330
331            if contact_labels.max() > 0:
332                self.add_or_update_labels(
333                    self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
334                    scale=layer_scale, translate=layer_translate,
335                    blending="translucent_no_depth",
336                )
337            else:
338                show_info("INFO: No crista–membrane junctions detected — junction layer not added.")
339
340            mito_layer = self._get_layer_selector_layer(self.mito_selector_name)
341            self._add_properties_and_table(mito_layer, stats_df, save_path=self.save_path.text())
342
343            n_mito = len(stats_df)
344            n_contacts = contact_summary["crista_junction_count"]
345            show_info(
346                f"INFO: Cristae analysis complete — {n_mito} mitochondria, "
347                f"{n_contacts} crista junction sites detected."
348            )

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(), and _MEMBRANE_TO_MODE maps the membrane-mode labels to the membrane_mode argument of ~synapse_net.cristae_analysis.approximate_membrane(). 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):
225    def on_preview(self):
226        """Compute and show ONLY the membrane + junctions (seconds) — the front-end of the pipeline —
227        so the user can tune Membrane Thickness / Border Gap before the expensive per-mito run.
228
229        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
230        """
231        inputs = self._read_inputs()
232        if inputs is None:
233            return
234        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
235         mm_thickness, border_gap, membrane_mode) = inputs
236
237        with self._computing(
238            self.preview_button, "Computing preview…", "Preview Membrane && Junctions",
239            "INFO: Previewing membrane & junctions...",
240        ):
241            pbar = progress(total=2, desc="Preview: membrane & junctions")
242            try:
243                membrane_mask, _lumen_mask, contact_labels, contact_summary = self._compute_membrane_and_contacts(
244                    mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode
245                )
246                pbar.update(1)
247                self.add_or_update_labels(
248                    self._MEMBRANE_LAYER, membrane_mask.astype(np.uint8),
249                    scale=layer_scale, translate=layer_translate, opacity=0.4,
250                )
251                if contact_labels.max() > 0:
252                    self.add_or_update_labels(
253                        self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
254                        scale=layer_scale, translate=layer_translate,
255                        blending="translucent_no_depth",
256                    )
257                else:
258                    show_info("INFO: No crista–membrane junctions detected at these settings.")
259                pbar.update(1)
260                show_info(
261                    f"INFO: Preview — {int(membrane_mask.sum())} membrane voxels, "
262                    f"{contact_summary['crista_junction_count']} junctions. "
263                    "Adjust Membrane Thickness / Border Gap and preview again, or Run."
264                )
265            finally:
266                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):
268    def on_run(self):
269        """Run the full per-mitochondrion cristae analysis and add the result layers + stats table.
270
271        Runs synchronously; :meth:`_computing` provides the busy feedback while it blocks.
272        """
273        inputs = self._read_inputs()
274        if inputs is None:
275            return
276        (crista_mask, mito_seg, voxel_size, layer_scale, layer_translate,
277         mm_thickness, border_gap, membrane_mode) = inputs
278
279        with self._computing(
280            self.run_button, "Computing analysis…", "Run Cristae Analysis",
281            "INFO: Approximating mitochondrial membrane & junctions...",
282        ):
283            membrane_mask, lumen_mask, contact_labels, contact_summary = self._compute_membrane_and_contacts(
284                mito_seg, crista_mask, voxel_size, mm_thickness, border_gap, membrane_mode
285            )
286
287            method = self._ORIENTATION_TO_METHOD[self.orientation_param.currentText()]
288            show_info(f"INFO: Running cristae analysis per mitochondrion (orientation: {method})...")
289
290            # compute_mito_crista_statistics calls progress_callback once per mitochondrion, on this
291            # (GUI) thread, so the activity-dock bar can be created/updated here directly.
292            pbar = {"bar": None}
293
294            def _on_progress(done, total):
295                if pbar["bar"] is None:
296                    pbar["bar"] = progress(total=total, desc="Cristae analysis")
297                pbar["bar"].update(1)
298
299            try:
300                stats_df = compute_mito_crista_statistics(
301                    crista_mask, mito_seg, voxel_size,
302                    membrane_mask=membrane_mask,
303                    lumen_mask=lumen_mask,
304                    membrane_thickness_nm=mm_thickness,
305                    border_gap_nm=border_gap,
306                    method=method,
307                    membrane_mode=membrane_mode,
308                    n_jobs=-1,
309                    verbose=True,
310                    progress_callback=_on_progress,
311                )
312            finally:
313                if pbar["bar"] is not None:
314                    pbar["bar"].close()
315
316            if self.show_membranes_param.isChecked():
317                gap_radius = _gap_radius(voxel_size, mm_thickness, border_gap, mito_seg.ndim)
318                mesh = _open_trimmed_mesh(
319                    lumen_mask, np.ones(mito_seg.ndim), gap_radius, np.ones((mito_seg.ndim, 2), dtype=bool)
320                )
321                if mesh is not None:
322                    verts, faces = mesh
323                    self.add_or_update_surface(
324                        self._MEMBRANE_MESH_LAYER, verts, faces,
325                        scale=layer_scale, translate=layer_translate,
326                        opacity=0.4, blending="translucent",
327                    )
328                else:
329                    show_info("INFO: No membrane surface to display at these settings.")
330
331            if contact_labels.max() > 0:
332                self.add_or_update_labels(
333                    self._JUNCTION_LAYER, contact_labels.astype(np.uint32),
334                    scale=layer_scale, translate=layer_translate,
335                    blending="translucent_no_depth",
336                )
337            else:
338                show_info("INFO: No crista–membrane junctions detected — junction layer not added.")
339
340            mito_layer = self._get_layer_selector_layer(self.mito_selector_name)
341            self._add_properties_and_table(mito_layer, stats_df, save_path=self.save_path.text())
342
343            n_mito = len(stats_df)
344            n_contacts = contact_summary["crista_junction_count"]
345            show_info(
346                f"INFO: Cristae analysis complete — {n_mito} mitochondria, "
347                f"{n_contacts} crista junction sites detected."
348            )

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

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