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 )
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.
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.
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.