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