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

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

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

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