itasc 0.2.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,951 @@
1
+ """Contact Analysis widget: per-position quantity view (currently contacts)."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import tifffile
9
+
10
+ import napari
11
+ from napari.qt.threading import thread_worker
12
+ from qtpy.QtCore import QObject, QSettings, QTimer, Signal
13
+ from qtpy.QtWidgets import (
14
+ QCheckBox,
15
+ QFileDialog,
16
+ QGridLayout,
17
+ QHBoxLayout,
18
+ QLabel,
19
+ QLineEdit,
20
+ QListWidget,
21
+ QProgressBar,
22
+ QPushButton,
23
+ QVBoxLayout,
24
+ QWidget,
25
+ )
26
+
27
+ from itasc.contact_analysis import (
28
+ build_contacts, # noqa: F401 - re-exported for tests that build directly
29
+ discover_contact_batch_jobs,
30
+ ensure_contacts,
31
+ run_contact_batch,
32
+ )
33
+ from itasc.contact_analysis.catalog import CONTACT_ANALYSIS_RELPATH
34
+ from itasc.napari.ui_gate import ControlClass, UiGate
35
+ from itasc.napari.ui_style import action_button, status_label
36
+ from itasc.napari.widgets import (
37
+ CollapsibleSection,
38
+ PipelineFilesWidget,
39
+ make_pipeline_files_header,
40
+ )
41
+
42
+ try: # pragma: no cover - local branch compatibility
43
+ from itasc.contact_analysis.contacts.reader import read_position_contacts
44
+ except ImportError: # pragma: no cover - tests monkeypatch this when absent
45
+ def read_position_contacts(*_args, **_kwargs): # type: ignore[no-redef]
46
+ raise ImportError("itasc.contact_analysis.contacts.reader is unavailable")
47
+
48
+
49
+ try: # pragma: no cover - local branch compatibility
50
+ from itasc.napari.contact_visualization import (
51
+ add_contact_analysis_layers,
52
+ _nucleus_centroids_by_track,
53
+ )
54
+ except ImportError: # pragma: no cover - tests monkeypatch this when absent
55
+ def add_contact_analysis_layers(*_args, **_kwargs): # type: ignore[no-redef]
56
+ raise ImportError("itasc.napari.contact_visualization is unavailable")
57
+
58
+ def _nucleus_centroids_by_track(*_args, **_kwargs): # type: ignore[no-redef]
59
+ raise ImportError("itasc.napari.contact_visualization is unavailable")
60
+
61
+
62
+ def make_contact_analysis_widget(napari_viewer=None):
63
+ """napari plugin entry point: the per-position Contact Analysis dock widget.
64
+
65
+ Returns the bare per-position :class:`ContactAnalysisWidget` in standalone
66
+ mode (own file pickers + config). The cross-position aggregate role now lives
67
+ in the full app's Aggregate capstone (``itasc.napari.aggregate_widget``),
68
+ so there is no separate interactive studio to serve here. Runs the napari
69
+ layer-delegate patch (normally done by the orchestrator package).
70
+ """
71
+ try:
72
+ from itasc.napari._napari_compat import patch_napari_layer_delegate
73
+
74
+ patch_napari_layer_delegate()
75
+ except Exception: # pragma: no cover - patch is best-effort
76
+ pass
77
+ if napari_viewer is None:
78
+ napari_viewer = napari.current_viewer()
79
+ return ContactAnalysisWidget(viewer=napari_viewer, standalone=True)
80
+
81
+
82
+ class _ProgressEmitter(QObject):
83
+ progress = Signal(int, int, str)
84
+
85
+
86
+ class ContactAnalysisWidget(QWidget):
87
+ """Final contact analysis and export."""
88
+
89
+ _contact_analysis_layer_prefix = "[Contact Analysis] "
90
+
91
+ #: QSettings key used to persist display options in standalone mode.
92
+ _SETTINGS_KEY = "itasc_contact/state"
93
+
94
+ def __init__(
95
+ self,
96
+ viewer: object | None = None,
97
+ parent: QWidget | None = None,
98
+ gate: UiGate | None = None,
99
+ standalone: bool = False,
100
+ ) -> None:
101
+ super().__init__(parent)
102
+ self.viewer = viewer
103
+ #: App-wide UI gate; a private one is created for standalone use.
104
+ self.gate = gate if gate is not None else UiGate(self)
105
+ #: When standalone, the widget owns its own file pickers + config and
106
+ #: hides the orchestrator's staged "Pipeline Files" panel. When
107
+ #: orchestrated, the parent injects paths via :meth:`set_context`.
108
+ self._standalone = standalone
109
+ #: Explicit working context (set via :meth:`set_context` or the pickers).
110
+ self._cell_labels_path: Path | None = None
111
+ self._nucleus_labels_path: Path | None = None
112
+ self._out_path: Path | None = None
113
+ #: The position dir driving the Pipeline Files panel (orchestrated only).
114
+ self._status_root: Path | None = None
115
+ self._build_worker = None
116
+ self._batch_worker = None
117
+ self._build_completion_pending = False
118
+ self._build_error_pending = False
119
+ self._progress_emitter = _ProgressEmitter(self)
120
+ self._progress_emitter.progress.connect(self._on_build_progress)
121
+ self._batch_progress_emitter = _ProgressEmitter(self)
122
+ self._batch_progress_emitter.progress.connect(self._on_batch_progress)
123
+ self._batch_completion_pending = False
124
+ self._batch_cancel = False
125
+ self._cached_contact_analysis_path: Path | None = None
126
+ self._cached_contact_analysis: Any = None
127
+ self._cached_cell_labels: np.ndarray | None = None
128
+ self._cached_nucleus_labels: np.ndarray | None = None
129
+ self._cached_track_centroids: dict | None = None
130
+ #: (path, *display-option flags) of the contact-analysis overlay currently
131
+ #: on screen, so a re-Show of the same position+options can skip the
132
+ #: remove/re-add churn (see ``_show_from_disk``). ``None`` when nothing shown.
133
+ self._displayed_contact_analysis_signature: tuple | None = None
134
+ #: Pending deferred overlay-add timer, cancelled if a new Show supersedes it.
135
+ self._pending_show_timer: QTimer | None = None
136
+ #: Positions discovered under the standalone top folder (row index ↔ job).
137
+ self._discovered_jobs: list = []
138
+
139
+ layout = QVBoxLayout(self)
140
+ layout.setContentsMargins(2, 2, 2, 2)
141
+ layout.setSpacing(6)
142
+
143
+ self._files_widget = PipelineFilesWidget(
144
+ [
145
+ ("Inputs", [
146
+ ("nucleus_labels.tif", "Nucleus labels (committed)"),
147
+ ("cell_labels.tif", "Cell labels (committed)"),
148
+ ]),
149
+ ("Output", [
150
+ (CONTACT_ANALYSIS_RELPATH, "Contact analysis"),
151
+ ]),
152
+ ],
153
+ viewer=self.viewer,
154
+ )
155
+ self._pipeline_files_section = CollapsibleSection(
156
+ "Pipeline Files",
157
+ self._files_widget,
158
+ expanded=False,
159
+ )
160
+ (
161
+ self.pipeline_files_header,
162
+ self.pipeline_files_header_lbl,
163
+ self.pipeline_files_toggle_btn,
164
+ ) = make_pipeline_files_header(
165
+ self._pipeline_files_section,
166
+ stage_key="contact_analysis",
167
+ parent=self,
168
+ )
169
+ # The staged "Pipeline Files" panel is an orchestrator concept; standalone
170
+ # use replaces it with the discovery panel below.
171
+ self.pipeline_files_header.setVisible(not self._standalone)
172
+ self._pipeline_files_section.setVisible(not self._standalone)
173
+ layout.addWidget(self.pipeline_files_header)
174
+ layout.addWidget(self._pipeline_files_section)
175
+
176
+ # Standalone: enter a top folder + file names → discovered positions list.
177
+ self._build_discovery_section(layout)
178
+
179
+ self.contact_analysis_status_lbl = QLabel("")
180
+ self.contact_analysis_status_lbl.setWordWrap(True)
181
+ status_label(self.contact_analysis_status_lbl)
182
+ layout.addWidget(self.contact_analysis_status_lbl)
183
+
184
+ self.contact_analysis_progress_bar = QProgressBar()
185
+ self.contact_analysis_progress_bar.setRange(0, 100)
186
+ self.contact_analysis_progress_bar.setValue(0)
187
+ self.contact_analysis_progress_bar.setVisible(False)
188
+ self.contact_analysis_progress_bar.setTextVisible(True)
189
+ layout.addWidget(self.contact_analysis_progress_bar)
190
+
191
+ # Display options, laid out as a two-column grid of toggles.
192
+ self.color_edges_by_id_cb = QCheckBox("Color edges by ID")
193
+ self.color_edges_by_label_cb = QCheckBox("Color edges by label")
194
+ self.hide_border_edges_cb = QCheckBox("Hide border edges")
195
+ options_grid = QGridLayout()
196
+ options_grid.setContentsMargins(0, 0, 0, 0)
197
+ options_grid.setHorizontalSpacing(12)
198
+ options_grid.addWidget(self.color_edges_by_id_cb, 0, 0)
199
+ options_grid.addWidget(self.color_edges_by_label_cb, 0, 1)
200
+ options_grid.addWidget(self.hide_border_edges_cb, 1, 0)
201
+ layout.addLayout(options_grid)
202
+
203
+ # Two build/show actions sit directly below the display options (no
204
+ # trailing stretch, so the panel hugs its content). Visualize computes the
205
+ # .h5 on demand only if it is missing, then shows the overlays; Recompute
206
+ # forces a rebuild before showing, for when the .h5 is stale relative to
207
+ # its inputs.
208
+ actions_row = QHBoxLayout()
209
+ actions_row.setContentsMargins(0, 0, 0, 0)
210
+ actions_row.setSpacing(6)
211
+ self.recompute_btn = QPushButton("Run Contact Analysis")
212
+ self.recompute_btn.setToolTip(
213
+ "Rebuild the contact-analysis .h5 from the current inputs, then show "
214
+ "the overlays. Use this when the result is stale."
215
+ )
216
+ action_button(self.recompute_btn, expand=True)
217
+ actions_row.addWidget(self.recompute_btn, 1)
218
+
219
+ self.visualize_btn = QPushButton("Visualize Contact Analysis")
220
+ self.visualize_btn.setToolTip(
221
+ "Show contact-analysis overlays for the current position. "
222
+ "If the analysis has not been computed yet, it is computed first; "
223
+ "an existing result is shown as-is."
224
+ )
225
+ action_button(self.visualize_btn, expand=True)
226
+ actions_row.addWidget(self.visualize_btn, 1)
227
+ layout.addLayout(actions_row)
228
+
229
+ self.visualize_btn.clicked.connect(lambda: self._on_visualize(overwrite=False))
230
+ self.recompute_btn.clicked.connect(lambda: self._on_visualize(overwrite=True))
231
+ self._register_gate_controls()
232
+ if self._standalone:
233
+ self._load_standalone_settings()
234
+ self._update_status()
235
+
236
+ # --------------------------------------------------------------- discovery UI
237
+ def _make_picker_row(
238
+ self, layout, label: str, on_browse, *, read_only: bool = True
239
+ ) -> QLineEdit:
240
+ """Add a ``label / line-edit / Browse`` row to *layout*; return the edit."""
241
+ row = QHBoxLayout()
242
+ row.setContentsMargins(0, 0, 0, 0)
243
+ row.setSpacing(2)
244
+ lbl = QLabel(label)
245
+ lbl.setFixedWidth(150)
246
+ edit = QLineEdit()
247
+ edit.setReadOnly(read_only)
248
+ browse = QPushButton("Browse...")
249
+ action_button(browse)
250
+ browse.clicked.connect(on_browse)
251
+ row.addWidget(lbl)
252
+ row.addWidget(edit, 1)
253
+ row.addWidget(browse)
254
+ layout.addLayout(row)
255
+ return edit
256
+
257
+ def _build_discovery_section(self, layout) -> None:
258
+ """Standalone discovery panel: a top folder + file names → a list of
259
+ discovered positions to pick from. Hidden when embedded in the
260
+ orchestrator (which drives the widget per-position via ``set_context``).
261
+ """
262
+ container = QWidget()
263
+ col = QVBoxLayout(container)
264
+ col.setContentsMargins(0, 0, 0, 0)
265
+ col.setSpacing(2)
266
+
267
+ self._batch_root_edit = self._make_picker_row(
268
+ col, "Top folder:", self._on_browse_batch_root, read_only=False
269
+ )
270
+ self._batch_root_edit.editingFinished.connect(self._rediscover)
271
+ self._batch_cell_name_edit = self._make_name_row(
272
+ col, "Cell labels name:", "cell_labels.tif"
273
+ )
274
+ self._batch_nucleus_name_edit = self._make_name_row(
275
+ col, "Nucleus name (optional):", "nucleus_labels.tif"
276
+ )
277
+ self._batch_h5_name_edit = self._make_name_row(
278
+ col, "Output .h5 name:", "contact_analysis.h5"
279
+ )
280
+ for edit in (
281
+ self._batch_cell_name_edit,
282
+ self._batch_nucleus_name_edit,
283
+ self._batch_h5_name_edit,
284
+ ):
285
+ edit.editingFinished.connect(self._rediscover)
286
+
287
+ self._discovery_list = QListWidget()
288
+ self._discovery_list.itemSelectionChanged.connect(self._on_job_selected)
289
+ self._discovery_list.itemDoubleClicked.connect(self._on_job_activated)
290
+ col.addWidget(self._discovery_list, 1)
291
+
292
+ self.batch_overwrite_cb = QCheckBox("Overwrite existing")
293
+ col.addWidget(self.batch_overwrite_cb)
294
+
295
+ self.run_batch_btn = QPushButton("Process all")
296
+ action_button(self.run_batch_btn, expand=True)
297
+ col.addWidget(self.run_batch_btn)
298
+
299
+ self.cancel_batch_btn = QPushButton("Cancel batch")
300
+ action_button(self.cancel_batch_btn)
301
+ self.cancel_batch_btn.setEnabled(False)
302
+ col.addWidget(self.cancel_batch_btn)
303
+
304
+ self.batch_progress_bar = QProgressBar()
305
+ self.batch_progress_bar.setRange(0, 100)
306
+ self.batch_progress_bar.setValue(0)
307
+ self.batch_progress_bar.setVisible(False)
308
+ self.batch_progress_bar.setTextVisible(True)
309
+ col.addWidget(self.batch_progress_bar)
310
+
311
+ self.batch_status_lbl = QLabel("")
312
+ self.batch_status_lbl.setWordWrap(True)
313
+ status_label(self.batch_status_lbl)
314
+ col.addWidget(self.batch_status_lbl)
315
+
316
+ self._discovery_container = container
317
+ container.setVisible(self._standalone)
318
+ layout.addWidget(container)
319
+
320
+ self.run_batch_btn.clicked.connect(self._on_run_batch)
321
+ self.cancel_batch_btn.clicked.connect(self._on_cancel_batch)
322
+
323
+ def _make_name_row(self, layout, label: str, default: str) -> QLineEdit:
324
+ """Add a ``label / editable filename`` row pre-filled with *default*."""
325
+ row = QHBoxLayout()
326
+ row.setContentsMargins(0, 0, 0, 0)
327
+ row.setSpacing(2)
328
+ lbl = QLabel(label)
329
+ lbl.setFixedWidth(150)
330
+ edit = QLineEdit()
331
+ edit.setText(default)
332
+ row.addWidget(lbl)
333
+ row.addWidget(edit, 1)
334
+ layout.addLayout(row)
335
+ return edit
336
+
337
+ # ------------------------------------------------------------ discovery logic
338
+ def _discovery_fields(self) -> tuple[str, str, str, str | None]:
339
+ return (
340
+ self._batch_root_edit.text().strip(),
341
+ self._batch_cell_name_edit.text().strip(),
342
+ self._batch_h5_name_edit.text().strip(),
343
+ self._batch_nucleus_name_edit.text().strip() or None,
344
+ )
345
+
346
+ def _job_label(self, job) -> str:
347
+ kind = "cell+nucleus" if job.nucleus_labels else "cell only"
348
+ built = "built" if job.output.exists() else "missing"
349
+ return f"{job.group_dir.name} {kind} [{built}]"
350
+
351
+ def _populate_discovery_list(self) -> None:
352
+ self._discovery_list.clear()
353
+ for job in self._discovered_jobs:
354
+ self._discovery_list.addItem(self._job_label(job))
355
+
356
+ def _refresh_discovery_status(self) -> None:
357
+ """Re-evaluate built/missing badges (outputs may have appeared)."""
358
+ for row, job in enumerate(self._discovered_jobs):
359
+ item = self._discovery_list.item(row)
360
+ if item is not None:
361
+ item.setText(self._job_label(job))
362
+
363
+ def _rediscover(self) -> None:
364
+ """Re-scan the top folder and repopulate the discovered-positions list."""
365
+ root, cell_name, h5_name, nucleus_name = self._discovery_fields()
366
+ self._discovered_jobs = []
367
+ self._discovery_list.clear()
368
+ if not (root and cell_name and h5_name):
369
+ self.batch_status_lbl.setText(
370
+ "Enter a top folder and file names to discover positions."
371
+ )
372
+ return
373
+ try:
374
+ jobs = discover_contact_batch_jobs(
375
+ root, cell_name=cell_name, h5_name=h5_name, nucleus_name=nucleus_name
376
+ )
377
+ except Exception as exc:
378
+ self.batch_status_lbl.setText(f"Discovery error: {exc}")
379
+ return
380
+ self._discovered_jobs = jobs
381
+ self._populate_discovery_list()
382
+ self.batch_status_lbl.setText(
383
+ f"Discovered {len(jobs)} position(s); double-click one to visualize."
384
+ if jobs
385
+ else f"No '{cell_name}' files found under {root}."
386
+ )
387
+ self._save_standalone_settings()
388
+
389
+ def _selected_job(self):
390
+ row = self._discovery_list.currentRow()
391
+ if 0 <= row < len(self._discovered_jobs):
392
+ return self._discovered_jobs[row]
393
+ return None
394
+
395
+ def _on_job_selected(self) -> None:
396
+ job = self._selected_job()
397
+ if job is None:
398
+ return
399
+ # Target the selected position so Visualize/Recompute act on it.
400
+ self.set_context(
401
+ cell_labels=job.cell_labels,
402
+ nucleus_labels=job.nucleus_labels,
403
+ out_path=job.output,
404
+ )
405
+
406
+ def _on_job_activated(self, item) -> None:
407
+ row = self._discovery_list.row(item)
408
+ if not (0 <= row < len(self._discovered_jobs)):
409
+ return
410
+ job = self._discovered_jobs[row]
411
+ self.set_context(
412
+ cell_labels=job.cell_labels,
413
+ nucleus_labels=job.nucleus_labels,
414
+ out_path=job.output,
415
+ )
416
+ self._on_visualize(overwrite=False)
417
+
418
+ # ------------------------------------------------------------------- config
419
+ def get_state(self) -> dict:
420
+ """Serialize display options (the seam shared by orchestrator + standalone)."""
421
+ return {
422
+ "color_edges_by_id": self.color_edges_by_id_cb.isChecked(),
423
+ "color_edges_by_label": self.color_edges_by_label_cb.isChecked(),
424
+ "hide_border_edges": self.hide_border_edges_cb.isChecked(),
425
+ }
426
+
427
+ def set_state(self, state: dict) -> None:
428
+ if not isinstance(state, dict):
429
+ return
430
+ if "color_edges_by_id" in state:
431
+ self.color_edges_by_id_cb.setChecked(bool(state["color_edges_by_id"]))
432
+ if "color_edges_by_label" in state:
433
+ self.color_edges_by_label_cb.setChecked(bool(state["color_edges_by_label"]))
434
+ if "hide_border_edges" in state:
435
+ self.hide_border_edges_cb.setChecked(bool(state["hide_border_edges"]))
436
+
437
+ def _load_standalone_settings(self) -> None:
438
+ s = QSettings()
439
+ raw = s.value(self._SETTINGS_KEY)
440
+ if isinstance(raw, dict):
441
+ self.set_state(raw)
442
+ cell = s.value("itasc_contact/cell_name", "", type=str)
443
+ nucleus = s.value("itasc_contact/nucleus_name", "", type=str)
444
+ h5 = s.value("itasc_contact/h5_name", "", type=str)
445
+ root = s.value("itasc_contact/root", "", type=str)
446
+ if cell:
447
+ self._batch_cell_name_edit.setText(cell)
448
+ if nucleus:
449
+ self._batch_nucleus_name_edit.setText(nucleus)
450
+ if h5:
451
+ self._batch_h5_name_edit.setText(h5)
452
+ if root:
453
+ self._batch_root_edit.setText(root)
454
+ self._rediscover()
455
+
456
+ def _save_standalone_settings(self) -> None:
457
+ if not self._standalone:
458
+ return
459
+ s = QSettings()
460
+ s.setValue(self._SETTINGS_KEY, self.get_state())
461
+ root, cell_name, h5_name, nucleus_name = self._discovery_fields()
462
+ s.setValue("itasc_contact/root", root)
463
+ s.setValue("itasc_contact/cell_name", cell_name)
464
+ s.setValue("itasc_contact/nucleus_name", nucleus_name or "")
465
+ s.setValue("itasc_contact/h5_name", h5_name)
466
+
467
+ def _register_gate_controls(self) -> None:
468
+ """Register contact-analysis actions with the app-wide UI gate.
469
+
470
+ Build/cancel are headless (disk only), so they run regardless of viewer
471
+ ownership. Visualize/Recompute write the viewer, so they are blocked
472
+ while a viewer owner (correction / live preview) is active.
473
+ """
474
+ g = self.gate
475
+ running = lambda: self._build_worker is not None
476
+ batch_running = lambda: self._batch_worker is not None
477
+ # Visualize/Recompute write the viewer (they show overlays) and build
478
+ # first; both need inputs and no in-flight build. Recompute additionally
479
+ # forces a rebuild, but its enablement gates the same way.
480
+ g.register(
481
+ self.visualize_btn,
482
+ ControlClass.RUN_VIEWER,
483
+ when=lambda: self._inputs_ready() and not running(),
484
+ )
485
+ g.register(
486
+ self.recompute_btn,
487
+ ControlClass.RUN_VIEWER,
488
+ when=lambda: self._inputs_ready() and not running(),
489
+ )
490
+ # Batch is headless (disk only); it runs regardless of viewer ownership.
491
+ g.register(
492
+ self.run_batch_btn, ControlClass.RUN_HEADLESS, when=lambda: not batch_running()
493
+ )
494
+ g.register(self.cancel_batch_btn, ControlClass.RUN_HEADLESS, when=batch_running)
495
+ g.recompute()
496
+
497
+ @property
498
+ def cell_labels_path(self) -> Path | None:
499
+ return self._cell_labels_path
500
+
501
+ @property
502
+ def nucleus_labels_path(self) -> Path | None:
503
+ return self._nucleus_labels_path
504
+
505
+ @property
506
+ def contact_analysis_out_path(self) -> Path | None:
507
+ return self._out_path
508
+
509
+ def set_context(
510
+ self,
511
+ *,
512
+ cell_labels: Path | str | None,
513
+ nucleus_labels: Path | str | None = None,
514
+ out_path: Path | str | None = None,
515
+ status_root: Path | str | None = None,
516
+ ) -> None:
517
+ """Set the working context for both orchestrated and standalone use.
518
+
519
+ The orchestrator supplies explicit staged paths plus ``status_root``
520
+ (the position directory) to drive the "Pipeline Files" panel. Standalone
521
+ use targets a position by selecting it in the discovered-positions list,
522
+ which calls this without ``status_root``.
523
+ """
524
+ cell = Path(cell_labels) if cell_labels else None
525
+ nucleus = Path(nucleus_labels) if nucleus_labels else None
526
+ out = Path(out_path) if out_path else None
527
+ # The .h5 is a derived artifact; when no explicit output is given, default
528
+ # it next to the cell-labels file. Orchestrated callers always pass an
529
+ # explicit out_path, so this only fires for standalone selections.
530
+ if out is None and cell is not None:
531
+ out = cell.parent / "contact_analysis.h5"
532
+ if (cell, nucleus, out) != (
533
+ self._cell_labels_path,
534
+ self._nucleus_labels_path,
535
+ self._out_path,
536
+ ):
537
+ self._invalidate_caches()
538
+ self._cell_labels_path = cell
539
+ self._nucleus_labels_path = nucleus
540
+ self._out_path = out
541
+ self._status_root = Path(status_root) if status_root else None
542
+ if not self._standalone:
543
+ self._files_widget.refresh(self._status_root)
544
+ self._update_status()
545
+
546
+ def _refresh_files_tracker(self) -> None:
547
+ """Re-read the Pipeline Files panel after a run wrote a new ``.h5``.
548
+
549
+ This fires the tracker's ``refreshed`` signal, which is how the host
550
+ repaints the stage dots and catalog rail without a manual Refresh.
551
+ Standalone use has no such panel, so it is a no-op there.
552
+ """
553
+ if not self._standalone:
554
+ self._files_widget.refresh(self._status_root)
555
+
556
+ def _invalidate_caches(self) -> None:
557
+ self._cached_contact_analysis_path = None
558
+ self._cached_contact_analysis = None
559
+ self._cached_cell_labels = None
560
+ self._cached_nucleus_labels = None
561
+ self._cached_track_centroids = None
562
+
563
+ def _update_status(self) -> None:
564
+ self._update_action_states()
565
+ if self._cell_labels_path is None:
566
+ self._set_contact_analysis_status(
567
+ "Status: pick a position from the list."
568
+ if self._standalone
569
+ else "Status: no project open."
570
+ )
571
+ elif not self.contact_analysis_status_lbl.text():
572
+ self._set_contact_analysis_status("Status: ready.")
573
+
574
+ def _effective_nucleus_path(self) -> Path | None:
575
+ """Nucleus labels to use, or ``None`` if absent.
576
+
577
+ The orchestrator always wires a nucleus path, but the file may not exist
578
+ yet (e.g. only cell labels have been produced). Nucleus labels are
579
+ optional for contact analysis, so a missing file is treated as "no
580
+ nucleus" rather than a blocking error.
581
+ """
582
+ nucleus = self._nucleus_labels_path
583
+ if nucleus is not None and nucleus.exists():
584
+ return nucleus
585
+ return None
586
+
587
+ def _inputs_ready(self) -> bool:
588
+ # Cell labels are the only required input; nucleus is optional, so a
589
+ # set-but-missing nucleus path must not gate Visualize.
590
+ cell = self._cell_labels_path
591
+ if cell is None or not cell.exists():
592
+ return False
593
+ return self._out_path is not None
594
+
595
+ def _update_action_states(self) -> None:
596
+ # Enablement is owned by the UI gate; its ``when`` predicates read the
597
+ # readiness helpers and ``self._build_worker``.
598
+ self.gate.recompute()
599
+
600
+ def _set_build_running(self, running: bool) -> None:
601
+ self.contact_analysis_progress_bar.setVisible(running)
602
+ if running:
603
+ self.contact_analysis_progress_bar.setRange(0, 100)
604
+ self.contact_analysis_progress_bar.setValue(0)
605
+ else:
606
+ self.contact_analysis_progress_bar.setValue(0)
607
+ self.contact_analysis_progress_bar.setRange(0, 100)
608
+ self._update_action_states()
609
+
610
+ def _set_contact_analysis_status(self, message: str) -> None:
611
+ self.contact_analysis_status_lbl.setText(message)
612
+
613
+ def _on_build_progress(self, done: int, total: int, message: str) -> None:
614
+ if total > 0:
615
+ self.contact_analysis_progress_bar.setRange(0, total)
616
+ self.contact_analysis_progress_bar.setValue(done)
617
+ self._set_contact_analysis_status(f"Status: {message}")
618
+
619
+ def _on_compute_done(self, result: tuple[Path, bool]) -> None:
620
+ output_path, built = result
621
+ self._build_completion_pending = True
622
+ self._build_worker = None
623
+ self._set_build_running(False)
624
+ self._set_contact_analysis_status(
625
+ f"Status: {'computed' if built else 'using existing'} contact analysis "
626
+ f"({output_path.name})."
627
+ )
628
+ # The .h5 cache is keyed by path, but a build rewrites the file in place
629
+ # (same path), so a Recompute would otherwise serve the stale cached read.
630
+ # Drop just the cached analysis so _show_from_disk re-reads the fresh file;
631
+ # the label TIFFs are inputs the build does not touch, so they stay cached.
632
+ if built:
633
+ self._cached_contact_analysis = None
634
+ self._update_status()
635
+ self._refresh_discovery_status()
636
+ self._refresh_files_tracker()
637
+ self._show_from_disk()
638
+
639
+ def _on_build_error(self, exc: Exception) -> None:
640
+ self._build_error_pending = True
641
+ self._build_worker = None
642
+ self._set_build_running(False)
643
+ self._set_contact_analysis_status(f"Status: error: {exc}")
644
+ self._update_status()
645
+
646
+ def _on_visualize(self, *, overwrite: bool = False) -> None:
647
+ """Visualize the contact analysis, computing the .h5 only if needed.
648
+
649
+ With ``overwrite=False`` (Visualize) an existing .h5 is shown as-is and a
650
+ missing one is built first. With ``overwrite=True`` (Recompute) the .h5 is
651
+ always rebuilt. The build runs in a worker; the show happens afterwards.
652
+ """
653
+ cell = self._cell_labels_path
654
+ out = self._out_path
655
+ if cell is None or out is None:
656
+ self._set_contact_analysis_status(
657
+ "Status: pick a position from the list."
658
+ if self._standalone
659
+ else "Status: no project open."
660
+ )
661
+ self._update_action_states()
662
+ return
663
+ if not cell.exists():
664
+ self._set_contact_analysis_status(f"Status: missing cell labels: {cell}")
665
+ self._update_status()
666
+ return
667
+ # Nucleus is optional: if its file is not present yet, compute contacts
668
+ # from cell labels alone instead of refusing to run.
669
+ nucleus = self._effective_nucleus_path()
670
+
671
+ # Fast path: the artifact already exists and no rebuild was requested, so
672
+ # skip the worker and show immediately. ensure_contacts remains the
673
+ # authority on the missing-only policy for the build path below.
674
+ if out.exists() and not overwrite:
675
+ self._set_contact_analysis_status(
676
+ f"Status: showing existing contact analysis ({out.name})."
677
+ )
678
+ self._show_from_disk()
679
+ return
680
+
681
+ self._build_completion_pending = False
682
+ self._build_error_pending = False
683
+ self._set_contact_analysis_status(
684
+ "Status: computing contact analysis (not present yet)..."
685
+ if not overwrite
686
+ else "Status: recomputing contact analysis..."
687
+ )
688
+ self._set_build_running(True)
689
+
690
+ @thread_worker(
691
+ connect={
692
+ "returned": self._on_compute_done,
693
+ "errored": self._on_build_error,
694
+ }
695
+ )
696
+ def _worker():
697
+ return ensure_contacts(
698
+ cell_labels_path=cell,
699
+ output_path=out,
700
+ nucleus_labels_path=nucleus,
701
+ overwrite=overwrite,
702
+ progress_cb=self._progress_emitter.progress.emit,
703
+ )
704
+
705
+ worker = _worker()
706
+ self._build_worker = worker
707
+ if self._build_completion_pending or self._build_error_pending:
708
+ self._build_worker = None
709
+ self._build_completion_pending = False
710
+ self._build_error_pending = False
711
+ self._update_action_states()
712
+
713
+ # ------------------------------------------------------------------- batch
714
+ def _on_browse_batch_root(self) -> None:
715
+ path = QFileDialog.getExistingDirectory(self, "Select top-level folder")
716
+ if path:
717
+ self._batch_root_edit.setText(path)
718
+ self._rediscover()
719
+
720
+ def _on_batch_progress(self, done: int, total: int, label: str) -> None:
721
+ if total > 0:
722
+ self.batch_progress_bar.setRange(0, total)
723
+ self.batch_progress_bar.setValue(done)
724
+ self.batch_status_lbl.setText(f"Batch: {done}/{total} {label}")
725
+
726
+ def _on_batch_done(self, results: list) -> None:
727
+ self._batch_completion_pending = True
728
+ self._batch_worker = None
729
+ self.batch_progress_bar.setVisible(False)
730
+ built = sum(1 for r in results if r.status == "built")
731
+ skipped = sum(1 for r in results if r.status == "skipped")
732
+ failed = sum(1 for r in results if r.status == "failed")
733
+ self.batch_status_lbl.setText(
734
+ f"Processed all: built {built} / skipped {skipped} / failed {failed}"
735
+ )
736
+ self._refresh_discovery_status()
737
+ self._refresh_files_tracker()
738
+ self._update_action_states()
739
+
740
+ def _on_batch_error(self, exc: Exception) -> None:
741
+ self._batch_completion_pending = True
742
+ self._batch_worker = None
743
+ self.batch_progress_bar.setVisible(False)
744
+ self.batch_status_lbl.setText(f"Batch error: {exc}")
745
+ self._update_action_states()
746
+
747
+ def _on_run_batch(self) -> None:
748
+ # Re-scan so the run matches the list the user is looking at.
749
+ self._rediscover()
750
+ jobs = self._discovered_jobs
751
+ if not jobs:
752
+ return
753
+
754
+ overwrite = self.batch_overwrite_cb.isChecked()
755
+ self._batch_cancel = False
756
+ self._batch_completion_pending = False
757
+ self.batch_progress_bar.setRange(0, len(jobs))
758
+ self.batch_progress_bar.setValue(0)
759
+ self.batch_progress_bar.setVisible(True)
760
+ self.batch_status_lbl.setText(f"Batch: {len(jobs)} positions...")
761
+
762
+ @thread_worker(
763
+ connect={
764
+ "returned": self._on_batch_done,
765
+ "errored": self._on_batch_error,
766
+ }
767
+ )
768
+ def _worker():
769
+ return run_contact_batch(
770
+ jobs,
771
+ overwrite=overwrite,
772
+ progress_cb=self._batch_progress_emitter.progress.emit,
773
+ cancel=lambda: self._batch_cancel,
774
+ )
775
+
776
+ worker = _worker()
777
+ self._batch_worker = worker
778
+ if self._batch_completion_pending:
779
+ self._batch_worker = None
780
+ self._batch_completion_pending = False
781
+ self._update_action_states()
782
+
783
+ def _on_cancel_batch(self) -> None:
784
+ self._batch_cancel = True
785
+ worker = self._batch_worker
786
+ if worker is not None:
787
+ self._batch_worker = None
788
+ worker.quit()
789
+ self.batch_progress_bar.setVisible(False)
790
+ self.batch_status_lbl.setText("Batch: cancelled.")
791
+ self._update_action_states()
792
+
793
+ def _show_from_disk(self) -> None:
794
+ if self.viewer is None:
795
+ self._set_contact_analysis_status("Status: no viewer available.")
796
+ self._update_action_states()
797
+ return
798
+ contact_analysis_path = self.contact_analysis_out_path
799
+ if contact_analysis_path is None or not contact_analysis_path.exists():
800
+ self._set_contact_analysis_status("Status: contact analysis file not found.")
801
+ self._update_action_states()
802
+ return
803
+
804
+ # Cache contact analysis to avoid re-reading HDF5 on every Show click.
805
+ # Re-read when there is no cached analysis (first show, context change, or
806
+ # a fresh build dropped it) or when the output path changed. A path change
807
+ # means a different position, so its label/centroid caches are stale too; a
808
+ # same-path rebuild leaves those inputs untouched, so they stay cached.
809
+ if (
810
+ self._cached_contact_analysis is None
811
+ or self._cached_contact_analysis_path != contact_analysis_path
812
+ ):
813
+ if self._cached_contact_analysis_path != contact_analysis_path:
814
+ self._cached_cell_labels = None
815
+ self._cached_nucleus_labels = None
816
+ self._cached_track_centroids = None
817
+ self._cached_contact_analysis = read_position_contacts(contact_analysis_path)
818
+ self._cached_contact_analysis_path = contact_analysis_path
819
+
820
+ # Cache label TIFFs — these are large files whose repeated reading blocks
821
+ # the Qt main thread and causes freezes + ghost layer artifacts
822
+ if self._cached_cell_labels is None:
823
+ if self.cell_labels_path is not None and self.cell_labels_path.exists():
824
+ try:
825
+ self._cached_cell_labels = np.asarray(tifffile.imread(self.cell_labels_path))
826
+ except Exception:
827
+ pass
828
+ if self._cached_nucleus_labels is None:
829
+ if self.nucleus_labels_path is not None and self.nucleus_labels_path.exists():
830
+ try:
831
+ self._cached_nucleus_labels = np.asarray(tifffile.imread(self.nucleus_labels_path))
832
+ except Exception:
833
+ pass
834
+
835
+ # Cache nucleus track centroids — O(T*W*H*N) pixel iteration, very expensive
836
+ if self._cached_track_centroids is None and self._cached_nucleus_labels is not None:
837
+ try:
838
+ self._cached_track_centroids = _nucleus_centroids_by_track(self._cached_nucleus_labels)
839
+ except Exception:
840
+ pass
841
+
842
+ options: dict[str, Any] = {
843
+ "color_edges_by_id": self.color_edges_by_id_cb.isChecked(),
844
+ "color_edges_by_label": self.color_edges_by_label_cb.isChecked(),
845
+ "hide_border_edges": self.hide_border_edges_cb.isChecked(),
846
+ }
847
+ signature = (contact_analysis_path, *options.values())
848
+
849
+ # Skip the remove/re-add when the same position is already shown with the
850
+ # same display options and its layers are still present. Re-adding is pure
851
+ # churn here: it flickers and, via napari's QtLayerList repaint race, can
852
+ # leave phantom (empty, unnamed) rows in the layer list.
853
+ if (
854
+ signature == self._displayed_contact_analysis_signature
855
+ and self._contact_analysis_layer_names()
856
+ ):
857
+ self._set_contact_analysis_status(f"Status: showing {contact_analysis_path.name}")
858
+ self._update_action_states()
859
+ return
860
+
861
+ show_kwargs: dict[str, Any] = {
862
+ "prefix": self._contact_analysis_layer_prefix,
863
+ **options,
864
+ }
865
+ if self._cached_cell_labels is not None:
866
+ show_kwargs["cell_labels"] = self._cached_cell_labels
867
+ if self._cached_nucleus_labels is not None:
868
+ show_kwargs["nucleus_labels"] = self._cached_nucleus_labels
869
+ if self._cached_track_centroids is not None:
870
+ show_kwargs["nucleus_track_centroids"] = self._cached_track_centroids
871
+
872
+ # Clear now, but defer the re-add to the next event-loop tick. Removing the
873
+ # old layers and inserting the new ones in one synchronous slot races
874
+ # napari's QtLayerList repaint and can leave phantom (empty, unnamed) rows
875
+ # in the layer list; letting Qt process the removals first avoids it.
876
+ self._clear_contact_analysis_layers(set_status=False)
877
+ contact_analysis = self._cached_contact_analysis
878
+
879
+ def _deferred_add() -> None:
880
+ self._pending_show_timer = None
881
+ if self.viewer is None or contact_analysis is None:
882
+ return
883
+ add_contact_analysis_layers(self.viewer, contact_analysis, **show_kwargs)
884
+ self._displayed_contact_analysis_signature = signature
885
+ self._set_contact_analysis_status(f"Status: loaded {contact_analysis_path.name}")
886
+ self._update_action_states()
887
+
888
+ # Supersede any still-pending deferred add (e.g. rapid re-clicks) so a
889
+ # later Show can't stack a second set of overlays on top of an earlier one.
890
+ if self._pending_show_timer is not None:
891
+ self._pending_show_timer.stop()
892
+ # Parent the timer to ``self`` so it is destroyed with the widget and never
893
+ # fires its callback against an already-deleted viewer/widget.
894
+ timer = QTimer(self)
895
+ timer.setSingleShot(True)
896
+ timer.timeout.connect(_deferred_add)
897
+ timer.start(0)
898
+ self._pending_show_timer = timer
899
+
900
+ def _contact_analysis_layer_names(self) -> list[str]:
901
+ if self.viewer is None:
902
+ return []
903
+ names: list[str] = []
904
+ for layer in list(self.viewer.layers):
905
+ layer_name = getattr(layer, "name", layer)
906
+ if isinstance(layer_name, str) and layer_name.startswith(self._contact_analysis_layer_prefix):
907
+ names.append(layer_name)
908
+ return names
909
+
910
+ def _clear_contact_analysis_layers(self, *, set_status: bool) -> int:
911
+ # Any clear invalidates the "already shown" fast-path in _show_from_disk.
912
+ self._displayed_contact_analysis_signature = None
913
+ if self.viewer is None:
914
+ return 0
915
+
916
+ removed = 0
917
+ names = self._contact_analysis_layer_names()
918
+ layers = self.viewer.layers
919
+ for name in names:
920
+ layer = None
921
+ try:
922
+ layer = layers[name]
923
+ except Exception:
924
+ layer = name
925
+ cleanup = getattr(layer, "_itasc_frame_shape_cleanup", None)
926
+ if callable(cleanup):
927
+ try:
928
+ cleanup()
929
+ except Exception:
930
+ pass
931
+ # Hide before removal so napari clears the canvas visual first,
932
+ # preventing a ghost frame from persisting in the viewport
933
+ try:
934
+ layer.visible = False
935
+ except Exception:
936
+ pass
937
+ try:
938
+ layers.remove(layer)
939
+ except Exception:
940
+ try:
941
+ del layers[name]
942
+ except Exception:
943
+ pass
944
+ removed += 1
945
+
946
+ if set_status:
947
+ if removed:
948
+ self._set_contact_analysis_status(f"Status: cleared {removed} contact analysis layers.")
949
+ else:
950
+ self._set_contact_analysis_status("Status: no contact analysis layers to clear.")
951
+ return removed