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,795 @@
1
+ """Per-channel widget that builds nucleus/cell foreground & contour maps
2
+ directly from Cellpose ``prob_3dt`` and ``dp_3dt`` outputs.
3
+
4
+ Mirrors :class:`CellposeWidget` layout (one row per channel with
5
+ ⚙ params / ▶ run-cancel and a shared status + progress bar).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import threading
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ import napari
15
+ import numpy as np
16
+ from napari.qt.threading import thread_worker
17
+ from qtpy.QtCore import QTimer, Signal
18
+ from qtpy.QtWidgets import (
19
+ QComboBox,
20
+ QHBoxLayout,
21
+ QLabel,
22
+ QProgressBar,
23
+ QSizePolicy,
24
+ QVBoxLayout,
25
+ QWidget,
26
+ )
27
+
28
+ from itasc.napari._preview_cache import FramePreviewCache
29
+ from itasc.napari._widget_helpers import (
30
+ dslider as _dslider,
31
+ islider as _islider,
32
+ tool_btn as _tool_btn,
33
+ )
34
+ from itasc.napari.ui_gate import ControlClass, UiGate
35
+ from itasc.napari.ui_style import (
36
+ add_section_pair_row,
37
+ section_grid,
38
+ stage_header_action_button,
39
+ stage_header_label,
40
+ status_label,
41
+ )
42
+ from itasc.napari.widgets import (
43
+ CollapsibleSection,
44
+ PipelineFilesWidget,
45
+ make_pipeline_files_header,
46
+ )
47
+ from itasc.core.cancellation import CancelledError
48
+ from itasc.cellpose.divergence_maps import (
49
+ _LazyTiffStack,
50
+ build_divergence_maps,
51
+ contour_from_dp,
52
+ foreground_from_prob,
53
+ )
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Per-channel live-preview layer names: (foreground, contours).
58
+ _PREVIEW_LAYER_NAMES = {
59
+ "nucleus": (
60
+ "Divergence preview: Nucleus foreground",
61
+ "Divergence preview: Nucleus contours",
62
+ ),
63
+ "cell": (
64
+ "Divergence preview: Cell foreground",
65
+ "Divergence preview: Cell contours",
66
+ ),
67
+ }
68
+
69
+
70
+ _PIPELINE_FILES = [
71
+ ("Inputs", [
72
+ ("1_cellpose/nucleus_prob.tif", "Nucleus prob 3D+t"),
73
+ ("1_cellpose/nucleus_dp.tif", "Nucleus dp 3D+t"),
74
+ ("1_cellpose/cell_prob.tif", "Cell prob 3D+t"),
75
+ ("1_cellpose/cell_dp.tif", "Cell dp 3D+t"),
76
+ ]),
77
+ ("Outputs", [
78
+ ("1_cellpose/nucleus_contours.tif", "Nucleus contours"),
79
+ ("1_cellpose/nucleus_foreground.tif", "Nucleus foreground"),
80
+ ("1_cellpose/cell_contours.tif", "Cell contours"),
81
+ ("1_cellpose/cell_foreground.tif", "Cell foreground"),
82
+ ]),
83
+ ]
84
+
85
+
86
+ def _make_status() -> QLabel:
87
+ lbl = QLabel("")
88
+ lbl.setWordWrap(True)
89
+ lbl.setVisible(False)
90
+ status_label(lbl)
91
+ return lbl
92
+
93
+
94
+ def _make_progress() -> QProgressBar:
95
+ bar = QProgressBar()
96
+ bar.setRange(0, 100)
97
+ bar.setValue(0)
98
+ bar.setTextVisible(True)
99
+ bar.setVisible(False)
100
+ return bar
101
+
102
+
103
+ class DivergenceMapsWidget(QWidget):
104
+ """Build per-channel foreground & contour maps from Cellpose prob/dp."""
105
+
106
+ _progress_signal = Signal(int, int, str)
107
+ #: Emitted after a channel's maps are written to disk. The embedded (Cellpose)
108
+ #: use passes ``show_pipeline_files=False`` and so has no own Pipeline Files
109
+ #: panel to refresh; the host connects this to repaint the cellpose status.
110
+ maps_built = Signal()
111
+
112
+ def __init__(
113
+ self,
114
+ viewer: napari.Viewer,
115
+ parent: QWidget | None = None,
116
+ *,
117
+ show_pipeline_files: bool = True,
118
+ gate: UiGate | None = None,
119
+ ) -> None:
120
+ super().__init__(parent)
121
+ self.viewer = viewer
122
+ #: App-wide UI gate; a private one is created for standalone use.
123
+ self.gate = gate if gate is not None else UiGate(self)
124
+ self._pos_dir: Path | None = None
125
+ #: Standalone override: when set, prob/dp/foreground/contour maps resolve
126
+ #: flatly under this directory instead of ``<pos_dir>/1_cellpose``.
127
+ self._maps_dir: Path | None = None
128
+ self._running_stage: str | None = None
129
+ self._worker = None
130
+ self._cancel_event: threading.Event | None = None
131
+ self._show_pipeline_files = bool(show_pipeline_files)
132
+
133
+ # Live-preview state. Previews are mutually-exclusive viewer owners, so
134
+ # at most one channel is active at a time.
135
+ self._active_preview_channel: str | None = None
136
+ self._preview_worker = None
137
+ self._preview_pending = False
138
+ # Per-frame result cache (frame t → (fg, contour)) keyed on the active
139
+ # channel + its params, so scrubbing back to a computed frame repaints
140
+ # instantly and any param edit drops every cached frame. Mirrors the
141
+ # cell-segmentation preview's cache; freed when the preview deactivates.
142
+ self._preview_cache = FramePreviewCache()
143
+ # Preview layers whose contrast has not yet been seeded from real data.
144
+ self._preview_needs_autocontrast: set[str] = set()
145
+ self._preview_timer = QTimer(self)
146
+ self._preview_timer.setSingleShot(True)
147
+ self._preview_timer.setInterval(150)
148
+ self._preview_timer.timeout.connect(self._refresh_preview)
149
+
150
+ self._setup_ui()
151
+ self._connect_signals()
152
+ self._register_gate_controls()
153
+ self._progress_signal.connect(self._progress)
154
+
155
+ # ── UI ───────────────────────────────────────────────────────────
156
+ def _setup_ui(self) -> None:
157
+ root = QVBoxLayout(self)
158
+ root.setContentsMargins(2, 2, 2, 2)
159
+ root.setSpacing(6)
160
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
161
+
162
+ if self._show_pipeline_files:
163
+ self._files_widget = PipelineFilesWidget(_PIPELINE_FILES, viewer=self.viewer)
164
+ self.output_files_tracker = self._files_widget
165
+ self.input_files_tracker = self._files_widget
166
+ self._pipeline_files_section = CollapsibleSection(
167
+ "Pipeline Files", self._files_widget, expanded=False,
168
+ )
169
+ (
170
+ self.pipeline_files_header,
171
+ self.pipeline_files_header_lbl,
172
+ self.pipeline_files_toggle_btn,
173
+ ) = make_pipeline_files_header(
174
+ self._pipeline_files_section, stage_key="cellpose", parent=self,
175
+ )
176
+ root.addWidget(self.pipeline_files_header)
177
+ root.addWidget(self._pipeline_files_section)
178
+ else:
179
+ self._files_widget = None
180
+
181
+ # Nucleus row
182
+ self.nucleus_params_btn = _tool_btn(
183
+ "⚙", "Show parameters for nucleus divergence maps.", checkable=True,
184
+ )
185
+ self.nucleus_preview_btn = _tool_btn(
186
+ "◉", "Live preview nucleus divergence maps on the current frame.",
187
+ checkable=True,
188
+ )
189
+ self.nucleus_run_btn = _tool_btn("▶", "Build nucleus divergence maps.")
190
+ for button in (
191
+ self.nucleus_params_btn, self.nucleus_preview_btn, self.nucleus_run_btn,
192
+ ):
193
+ stage_header_action_button(button, "cellpose")
194
+ self.nucleus_section = self._build_channel_params_section("nucleus")
195
+ self.nucleus_section.set_header_visible(False)
196
+ self.nucleus_section.collapse()
197
+ self.nucleus_params_btn.toggled.connect(
198
+ lambda checked: self.nucleus_section._toggle.setChecked(checked)
199
+ )
200
+ root.addLayout(self._stage_row(
201
+ self._stage_label("Nucleus divergence maps"),
202
+ self.nucleus_params_btn,
203
+ self.nucleus_preview_btn,
204
+ self.nucleus_run_btn,
205
+ ))
206
+ root.addWidget(self.nucleus_section)
207
+
208
+ # Cell row
209
+ self.cell_params_btn = _tool_btn(
210
+ "⚙", "Show parameters for cell divergence maps.", checkable=True,
211
+ )
212
+ self.cell_preview_btn = _tool_btn(
213
+ "◉", "Live preview cell divergence maps on the current frame.",
214
+ checkable=True,
215
+ )
216
+ self.cell_run_btn = _tool_btn("▶", "Build cell divergence maps.")
217
+ for button in (
218
+ self.cell_params_btn, self.cell_preview_btn, self.cell_run_btn,
219
+ ):
220
+ stage_header_action_button(button, "cellpose")
221
+ self.cell_section = self._build_channel_params_section("cell")
222
+ self.cell_section.set_header_visible(False)
223
+ self.cell_section.collapse()
224
+ self.cell_params_btn.toggled.connect(
225
+ lambda checked: self.cell_section._toggle.setChecked(checked)
226
+ )
227
+ root.addLayout(self._stage_row(
228
+ self._stage_label("Cell divergence maps"),
229
+ self.cell_params_btn,
230
+ self.cell_preview_btn,
231
+ self.cell_run_btn,
232
+ ))
233
+ root.addWidget(self.cell_section)
234
+
235
+ self.status_lbl = _make_status()
236
+ root.addWidget(self.status_lbl)
237
+ self.progress_bar = _make_progress()
238
+ root.addWidget(self.progress_bar)
239
+
240
+ def _build_channel_params_section(
241
+ self, channel: Literal["nucleus", "cell"],
242
+ ) -> CollapsibleSection:
243
+ body = QWidget(self)
244
+ grid = section_grid()
245
+ grid.setContentsMargins(8, 4, 4, 4)
246
+ body.setLayout(grid)
247
+
248
+ fg_reduction = QComboBox()
249
+ fg_reduction.addItems(["mean", "max"])
250
+ fg_reduction.setCurrentText("mean")
251
+ contour_reduction = QComboBox()
252
+ contour_reduction.addItems(["mean", "max"])
253
+ contour_reduction.setCurrentText("mean")
254
+ fg_smoothing_spin = _dslider(0.0, 20.0, 0.0, 0.1, 2)
255
+ fg_median_spin = _islider(0, 20, 0)
256
+ smoothing_spin = _dslider(0.0, 20.0, 1.0, 0.1, 2)
257
+ median_spin = _islider(0, 20, 0)
258
+ row = 0
259
+ add_section_pair_row(
260
+ grid, row,
261
+ "Foreground z-reduction:", fg_reduction,
262
+ "Contour z-reduction:", contour_reduction,
263
+ ); row += 1
264
+ add_section_pair_row(
265
+ grid, row,
266
+ "Foreground smoothing sigma:", fg_smoothing_spin,
267
+ "Foreground median radius:", fg_median_spin,
268
+ ); row += 1
269
+ add_section_pair_row(
270
+ grid, row,
271
+ "Contour smoothing sigma:", smoothing_spin,
272
+ "Contour median radius:", median_spin,
273
+ )
274
+
275
+ prefix = "nuc" if channel == "nucleus" else "cell"
276
+ setattr(self, f"{prefix}_fg_reduction", fg_reduction)
277
+ setattr(self, f"{prefix}_contour_reduction", contour_reduction)
278
+ setattr(self, f"{prefix}_fg_smoothing_spin", fg_smoothing_spin)
279
+ setattr(self, f"{prefix}_fg_median_spin", fg_median_spin)
280
+ setattr(self, f"{prefix}_smoothing_spin", smoothing_spin)
281
+ setattr(self, f"{prefix}_median_spin", median_spin)
282
+ return CollapsibleSection(
283
+ f"{channel.title()} parameters", body, expanded=False,
284
+ )
285
+
286
+ @staticmethod
287
+ def _stage_label(text: str) -> QLabel:
288
+ return stage_header_label(QLabel(text), "cellpose")
289
+
290
+ @staticmethod
291
+ def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
292
+ row = QHBoxLayout()
293
+ row.setContentsMargins(0, 0, 0, 0)
294
+ row.setSpacing(4)
295
+ row.addWidget(label)
296
+ for w in trailing:
297
+ row.addWidget(w)
298
+ row.addStretch(1)
299
+ return row
300
+
301
+ # ── Signals ──────────────────────────────────────────────────────
302
+ def _connect_signals(self) -> None:
303
+ self.nucleus_run_btn.clicked.connect(lambda: self._on_run("nucleus"))
304
+ self.cell_run_btn.clicked.connect(lambda: self._on_run("cell"))
305
+ self.nucleus_preview_btn.toggled.connect(
306
+ lambda checked: self._on_preview_toggled("nucleus", checked)
307
+ )
308
+ self.cell_preview_btn.toggled.connect(
309
+ lambda checked: self._on_preview_toggled("cell", checked)
310
+ )
311
+ for prefix in ("nuc", "cell"):
312
+ getattr(self, f"{prefix}_fg_reduction").currentTextChanged.connect(
313
+ self._on_param_changed
314
+ )
315
+ getattr(self, f"{prefix}_contour_reduction").currentTextChanged.connect(
316
+ self._on_param_changed
317
+ )
318
+ for suffix in (
319
+ "fg_smoothing_spin", "fg_median_spin",
320
+ "smoothing_spin", "median_spin",
321
+ ):
322
+ getattr(self, f"{prefix}_{suffix}").valueChanged.connect(
323
+ self._on_param_changed
324
+ )
325
+ if hasattr(self.viewer, "dims") and hasattr(self.viewer.dims, "events"):
326
+ try:
327
+ self.viewer.dims.events.current_step.connect(self._on_time_changed)
328
+ except Exception:
329
+ pass
330
+
331
+ def _register_gate_controls(self) -> None:
332
+ """Register the two channel rows with the app-wide UI gate.
333
+
334
+ Each channel's ◉ live preview is a mutually-exclusive viewer owner.
335
+ Build/run writes data downstream owners view, so it is blocked while any
336
+ owner is active. ⚙ params just toggle a panel and stay available.
337
+ """
338
+ g = self.gate
339
+ has_pos = lambda: self._resolved_maps_dir() is not None
340
+ idle = lambda: self._running_stage is None
341
+ for channel, params_btn, preview_btn, run_btn in (
342
+ ("nucleus", self.nucleus_params_btn, self.nucleus_preview_btn, self.nucleus_run_btn),
343
+ ("cell", self.cell_params_btn, self.cell_preview_btn, self.cell_run_btn),
344
+ ):
345
+ token = f"div_preview:{channel}"
346
+ g.register_owner(
347
+ token,
348
+ f"{channel} divergence preview",
349
+ exit_fn=lambda b=preview_btn: b.setChecked(False),
350
+ )
351
+ g.register(params_btn, ControlClass.HARMLESS)
352
+ g.register(
353
+ preview_btn,
354
+ ControlClass.VIEWER_OWNER,
355
+ owner_token=token,
356
+ when=lambda: has_pos() and idle(),
357
+ )
358
+ g.register(
359
+ run_btn,
360
+ ControlClass.RUN_VIEWER,
361
+ when=lambda c=channel: has_pos() and self._running_stage in (None, c),
362
+ )
363
+ g.recompute()
364
+
365
+ def _on_run(self, channel: Literal["nucleus", "cell"]) -> None:
366
+ if self._running_stage is not None:
367
+ self._on_cancel()
368
+ return
369
+ self._start_worker(channel)
370
+
371
+ def _start_worker(self, channel: Literal["nucleus", "cell"]) -> None:
372
+ prob_path, dp_path, contours_out, fg_out = self._channel_paths(channel)
373
+ if prob_path is None:
374
+ self._set_status("No project open.")
375
+ return
376
+ for p in (prob_path, dp_path):
377
+ if p is None or not p.exists():
378
+ self._set_status(f"Missing: {p}")
379
+ return
380
+ params = self._channel_state("nuc" if channel == "nucleus" else "cell")
381
+ cancel_event = threading.Event()
382
+ self._cancel_event = cancel_event
383
+ self._running_stage = channel
384
+ self._set_button_running(channel)
385
+
386
+ def _done(report) -> None:
387
+ self._worker = None
388
+ self._cancel_event = None
389
+ self._running_stage = None
390
+ self._set_button_idle()
391
+ self._progress_bar_hide()
392
+ self._set_status(
393
+ f"{channel.title()} divergence maps built ({report.frames} frames)."
394
+ )
395
+ if self._files_widget is not None:
396
+ self._files_widget.refresh(self._pos_dir)
397
+ # The maps are the cellpose stage's done-signal; tell the host so it
398
+ # refreshes even when this widget has no Pipeline Files panel of its
399
+ # own (the embedded, show_pipeline_files=False case).
400
+ self.maps_built.emit()
401
+
402
+ @thread_worker(
403
+ connect={
404
+ "yielded": self._on_yield,
405
+ "returned": _done,
406
+ "errored": self._on_errored,
407
+ },
408
+ )
409
+ def _worker():
410
+ progress_signal = self._progress_signal
411
+
412
+ def _progress_cb(done: int, total: int, msg: str) -> None:
413
+ progress_signal.emit(
414
+ int(done),
415
+ int(total),
416
+ self._channel_progress_message(channel, str(msg)),
417
+ )
418
+
419
+ yield (0, 1, f"Starting {channel} divergence maps...")
420
+ return build_divergence_maps(
421
+ prob_path,
422
+ dp_path,
423
+ contours_out,
424
+ fg_out,
425
+ foreground_z_reduction=params["foreground_z_reduction"],
426
+ contour_z_reduction=params["contour_z_reduction"],
427
+ smoothing_sigma=params["smoothing_sigma"],
428
+ median_radius=params["median_radius"],
429
+ foreground_smoothing_sigma=params["foreground_smoothing_sigma"],
430
+ foreground_median_radius=params["foreground_median_radius"],
431
+ progress_cb=_progress_cb,
432
+ cancel=cancel_event.is_set,
433
+ )
434
+
435
+ self._worker = _worker()
436
+
437
+ def _run_blocking(self, channel: Literal["nucleus", "cell"]) -> None:
438
+ """Synchronous test helper: runs build_divergence_maps in this thread."""
439
+ prob_path, dp_path, contours_out, fg_out = self._channel_paths(channel)
440
+ if prob_path is None or dp_path is None:
441
+ raise RuntimeError("No project open.")
442
+ params = self._channel_state("nuc" if channel == "nucleus" else "cell")
443
+ contours_out.parent.mkdir(parents=True, exist_ok=True)
444
+ build_divergence_maps(
445
+ prob_path,
446
+ dp_path,
447
+ contours_out,
448
+ fg_out,
449
+ foreground_z_reduction=params["foreground_z_reduction"],
450
+ contour_z_reduction=params["contour_z_reduction"],
451
+ smoothing_sigma=params["smoothing_sigma"],
452
+ median_radius=params["median_radius"],
453
+ foreground_smoothing_sigma=params["foreground_smoothing_sigma"],
454
+ foreground_median_radius=params["foreground_median_radius"],
455
+ )
456
+
457
+ def _channel_paths(self, channel: Literal["nucleus", "cell"]):
458
+ d = self._resolved_maps_dir()
459
+ if d is None:
460
+ return None, None, None, None
461
+ return (
462
+ d / f"{channel}_prob.tif",
463
+ d / f"{channel}_dp.tif",
464
+ d / f"{channel}_contours.tif",
465
+ d / f"{channel}_foreground.tif",
466
+ )
467
+
468
+ def _set_status(self, msg: str) -> None:
469
+ self.status_lbl.setText(msg)
470
+ self.status_lbl.setVisible(bool(msg))
471
+
472
+ def _progress_bar_hide(self) -> None:
473
+ self.progress_bar.setValue(0)
474
+ self.progress_bar.setVisible(False)
475
+
476
+ def _set_button_running(self, channel: Literal["nucleus", "cell"]) -> None:
477
+ run_btn = self.nucleus_run_btn if channel == "nucleus" else self.cell_run_btn
478
+ run_btn.setText("✕")
479
+ run_btn.setToolTip("Cancel.")
480
+ # Enablement (other row, previews) is derived by the gate from
481
+ # ``_running_stage``.
482
+ self.gate.recompute()
483
+
484
+ def _set_button_idle(self) -> None:
485
+ for btn, tip in (
486
+ (self.nucleus_run_btn, "Build nucleus divergence maps."),
487
+ (self.cell_run_btn, "Build cell divergence maps."),
488
+ ):
489
+ btn.setText("▶")
490
+ btn.setToolTip(tip)
491
+ self.gate.recompute()
492
+
493
+ def _on_yield(self, payload) -> None:
494
+ if not isinstance(payload, tuple):
495
+ self._set_status(str(payload))
496
+ return
497
+ done, total, msg = payload
498
+ self._progress(done, total, msg)
499
+
500
+ def _on_errored(self, exc: Exception) -> None:
501
+ self._worker = None
502
+ self._cancel_event = None
503
+ self._running_stage = None
504
+ self._set_button_idle()
505
+ self._progress_bar_hide()
506
+ if isinstance(exc, CancelledError):
507
+ self._set_status("Cancelled.")
508
+ return
509
+ logger.exception("Divergence-maps worker error", exc_info=exc)
510
+ self._set_status(f"Error: {exc}")
511
+
512
+ def _on_cancel(self) -> None:
513
+ if self._cancel_event is not None:
514
+ self._cancel_event.set()
515
+ if self._worker is not None and hasattr(self._worker, "quit"):
516
+ self._worker.quit()
517
+
518
+ def _progress(self, done: int, total: int, msg: str) -> None:
519
+ self.progress_bar.setVisible(True)
520
+ self.progress_bar.setRange(0, max(1, total))
521
+ self.progress_bar.setValue(done)
522
+ self.status_lbl.setText(msg)
523
+ self.status_lbl.setVisible(bool(msg))
524
+
525
+ @staticmethod
526
+ def _channel_progress_message(channel: Literal["nucleus", "cell"], msg: str) -> str:
527
+ prefix = "Divergence maps: "
528
+ if msg.startswith(prefix):
529
+ return f"{channel.title()} divergence maps: {msg[len(prefix):]}"
530
+ return msg
531
+
532
+ # ── Live preview (single frame) ──────────────────────────────────
533
+ def _on_preview_toggled(self, channel: Literal["nucleus", "cell"], checked: bool) -> None:
534
+ token = f"div_preview:{channel}"
535
+ if checked:
536
+ self._active_preview_channel = channel
537
+ self.gate.claim_viewer(token)
538
+ self._refresh_preview()
539
+ else:
540
+ if self._active_preview_channel == channel:
541
+ self._active_preview_channel = None
542
+ self._preview_pending = False
543
+ self.gate.release_viewer(token)
544
+ self._teardown_preview_layers(channel)
545
+ self._preview_cache.clear()
546
+ self._set_status("")
547
+
548
+ def _on_param_changed(self, *_args) -> None:
549
+ if self._active_preview_channel is not None:
550
+ self._preview_timer.start()
551
+
552
+ def _on_time_changed(self, *_args) -> None:
553
+ if self._active_preview_channel is not None:
554
+ self._preview_timer.start()
555
+
556
+ def _refresh_preview(self):
557
+ """Repaint the active channel's current-frame preview, computing if needed.
558
+
559
+ Reuses a cached frame (same channel + params) for an instant repaint —
560
+ even while a worker is in flight — and only starts a compute for a frame
561
+ the cache doesn't already hold. While a pass is in flight, further edits
562
+ arm ``_preview_pending`` so one fresh pass (latest params/frame) fires
563
+ when the current one returns.
564
+ """
565
+ channel = self._active_preview_channel
566
+ if channel is None:
567
+ return None
568
+ prob_path, dp_path, _, _ = self._channel_paths(channel)
569
+ if (
570
+ prob_path is None or not prob_path.exists()
571
+ or dp_path is None or not dp_path.exists()
572
+ ):
573
+ self._set_status("Cellpose prob/dp not found — run Cellpose first.")
574
+ return None
575
+
576
+ shape = self._channel_map_shape(prob_path)
577
+ if shape is None:
578
+ self._set_status("Could not read prob map.")
579
+ return None
580
+ # Create full-length (T, Y, X) layers up front so napari shows a time
581
+ # slider even before any frame is computed; each visited frame fills its
582
+ # own slice on demand.
583
+ self._ensure_preview_layers(channel, shape)
584
+
585
+ t = max(0, min(self._current_t(), shape[0] - 1))
586
+ params = self._channel_state("nuc" if channel == "nucleus" else "cell")
587
+ key = (channel, tuple(sorted(params.items())))
588
+ self._preview_cache.sync(key)
589
+
590
+ cached = self._preview_cache.get(t)
591
+ if cached is not None:
592
+ self._paint_preview(channel, t, *cached)
593
+ self._set_status(f"{channel.title()} divergence preview — frame {t}.")
594
+ return None
595
+
596
+ if self._preview_worker is not None:
597
+ self._preview_pending = True
598
+ return self._preview_worker
599
+
600
+ self._set_status(f"Computing {channel} divergence preview for frame {t}…")
601
+
602
+ @thread_worker(connect={
603
+ "returned": self._preview_done,
604
+ "errored": self._preview_error,
605
+ })
606
+ def _worker():
607
+ fg, contour = self._compute_channel_frame(prob_path, dp_path, t, params)
608
+ return channel, key, t, fg, contour
609
+
610
+ self._preview_worker = _worker()
611
+ return self._preview_worker
612
+
613
+ def _preview_done(self, payload) -> None:
614
+ self._preview_worker = None
615
+ channel, key, t, fg, contour = payload
616
+ self._preview_cache.put(key, t, (fg, contour))
617
+ if self._active_preview_channel == channel:
618
+ self._paint_preview(channel, t, fg, contour)
619
+ self._set_status(f"{channel.title()} divergence preview — frame {t}.")
620
+ if self._preview_pending and self._active_preview_channel is not None:
621
+ self._preview_pending = False
622
+ self._refresh_preview()
623
+
624
+ def _paint_preview(self, channel, t: int, fg, contour) -> None:
625
+ """Fill frame ``t``'s slice of the channel's foreground + contour layers."""
626
+ fg_name, ct_name = _PREVIEW_LAYER_NAMES[channel]
627
+ self._fill_image_layer(fg_name, t, fg)
628
+ self._fill_image_layer(ct_name, t, contour)
629
+
630
+ def _preview_error(self, exc: Exception) -> None:
631
+ self._preview_worker = None
632
+ logger.exception("Divergence preview worker error", exc_info=exc)
633
+ self._set_status(f"Preview failed: {exc}")
634
+
635
+ def _teardown_preview_layers(self, channel: Literal["nucleus", "cell"]) -> None:
636
+ for name in _PREVIEW_LAYER_NAMES[channel]:
637
+ if name in self.viewer.layers:
638
+ self.viewer.layers.remove(name)
639
+ self._preview_needs_autocontrast.discard(name)
640
+
641
+ def _compute_channel_frame(self, prob_path, dp_path, t: int, params: dict):
642
+ """Single-frame foreground + contour maps (worker-thread body)."""
643
+ # Read through the same lazy stack the build path uses so singleton
644
+ # ``Z``/``T`` axes (squeezed on disk for 2D / 2D+t / single-z stacks) are
645
+ # recovered to canonical ``(Z,Y,X)`` / ``(Z,2,Y,X)`` frames.
646
+ prob_t = _LazyTiffStack(prob_path, ndim=4, name="prob").frame(t) # (Z, Y, X)
647
+ dp_t = _LazyTiffStack(dp_path, ndim=5, name="dp").frame(t) # (Z, 2, Y, X)
648
+ fg = foreground_from_prob(
649
+ prob_t[np.newaxis],
650
+ reduction=params["foreground_z_reduction"],
651
+ smoothing_sigma=params["foreground_smoothing_sigma"],
652
+ median_radius=params["foreground_median_radius"],
653
+ )[0]
654
+ contour = contour_from_dp(
655
+ dp_t[np.newaxis],
656
+ smoothing_sigma=params["smoothing_sigma"],
657
+ median_radius=params["median_radius"],
658
+ reduction=params["contour_z_reduction"],
659
+ )[0]
660
+ return fg, contour
661
+
662
+ def _channel_map_shape(self, prob_path):
663
+ """``(T, Y, X)`` from the prob-map TIFF header (no pixel load).
664
+
665
+ Goes through :class:`_LazyTiffStack` so a singleton ``Z`` that TIFF
666
+ squeezes on disk (2D / 2D+t / single-z stacks) is still recovered to the
667
+ canonical ``(T, Z, Y, X)`` shape — otherwise the surviving leading axis is
668
+ mistaken for ``Z`` and ``T`` collapses to ``1``, leaving the preview a
669
+ single-frame layer that renders black at every other timepoint.
670
+ """
671
+ try:
672
+ shape = _LazyTiffStack(prob_path, ndim=4, name="prob").shape
673
+ except Exception:
674
+ return None
675
+ return int(shape[0]), int(shape[-2]), int(shape[-1])
676
+
677
+ def _current_t(self) -> int:
678
+ step = getattr(getattr(self.viewer, "dims", None), "current_step", (0,))
679
+ return int(step[0]) if len(step) >= 1 else 0
680
+
681
+ def _ensure_preview_layers(self, channel: Literal["nucleus", "cell"], shape) -> None:
682
+ """Create zero-filled ``(T, Y, X)`` preview layers if absent/mis-shaped.
683
+
684
+ A full-length stack is what gives napari a time slider; per-frame slices
685
+ are filled on demand as the user scrubs.
686
+ """
687
+ fg_name, ct_name = _PREVIEW_LAYER_NAMES[channel]
688
+ self._ensure_image_layer(fg_name, shape, "gray")
689
+ self._ensure_image_layer(ct_name, shape, "magma")
690
+
691
+ def _ensure_image_layer(self, name: str, shape, colormap: str) -> None:
692
+ if name in self.viewer.layers:
693
+ layer = self.viewer.layers[name]
694
+ if tuple(layer.data.shape) == tuple(shape):
695
+ return
696
+ self.viewer.layers.remove(name)
697
+ self.viewer.add_image(
698
+ np.zeros(shape, dtype=np.float32), name=name, colormap=colormap,
699
+ )
700
+ # Seed contrast from the first real frame this layer receives.
701
+ self._preview_needs_autocontrast.add(name)
702
+
703
+ def _fill_image_layer(self, name: str, t: int, frame: np.ndarray) -> None:
704
+ if name not in self.viewer.layers:
705
+ return
706
+ layer = self.viewer.layers[name]
707
+ data = layer.data
708
+ if getattr(data, "ndim", 0) != 3 or not 0 <= t < data.shape[0]:
709
+ return
710
+ data[t] = np.asarray(frame, dtype=data.dtype)
711
+ if name in self._preview_needs_autocontrast:
712
+ finite = frame[np.isfinite(frame)]
713
+ if finite.size:
714
+ lo, hi = float(finite.min()), float(finite.max())
715
+ if hi > lo:
716
+ layer.contrast_limits = (lo, hi)
717
+ self._preview_needs_autocontrast.discard(name)
718
+ if hasattr(layer, "refresh"):
719
+ layer.refresh()
720
+
721
+ # ── Public API ───────────────────────────────────────────────────
722
+ def refresh(self, pos_dir: Path | str | None) -> None:
723
+ self._pos_dir = None if pos_dir is None or str(pos_dir) == "[no project]" else Path(pos_dir)
724
+ if self._files_widget is not None:
725
+ self._files_widget.refresh(self._pos_dir)
726
+ # A live preview points at the current project's maps; if the project
727
+ # goes away, deactivate it so we don't preview stale paths.
728
+ if self._pos_dir is None and self._active_preview_channel is not None:
729
+ channel = self._active_preview_channel
730
+ btn = self.nucleus_preview_btn if channel == "nucleus" else self.cell_preview_btn
731
+ btn.setChecked(False)
732
+ self._update_enabled()
733
+
734
+ def _update_enabled(self) -> None:
735
+ # Enablement is owned by the gate; its predicates read ``_pos_dir`` and
736
+ # ``_running_stage``.
737
+ self.gate.recompute()
738
+
739
+ def get_state(self) -> dict:
740
+ return {
741
+ "nucleus": self._channel_state("nuc"),
742
+ "cell": self._channel_state("cell"),
743
+ }
744
+
745
+ def set_state(self, state: dict) -> None:
746
+ if not isinstance(state, dict):
747
+ return
748
+ if "nucleus" in state:
749
+ self._apply_channel_state("nuc", state["nucleus"])
750
+ if "cell" in state:
751
+ self._apply_channel_state("cell", state["cell"])
752
+
753
+ def _channel_state(self, prefix: str) -> dict:
754
+ # ``smoothing_sigma`` / ``median_radius`` are the *contour* knobs (kept
755
+ # under their original keys for saved-config back-compat); the
756
+ # ``foreground_*`` keys are the separate foreground knobs.
757
+ return {
758
+ "foreground_z_reduction": getattr(self, f"{prefix}_fg_reduction").currentText(),
759
+ "contour_z_reduction": getattr(self, f"{prefix}_contour_reduction").currentText(),
760
+ "smoothing_sigma": float(getattr(self, f"{prefix}_smoothing_spin").value()),
761
+ "median_radius": int(getattr(self, f"{prefix}_median_spin").value()),
762
+ "foreground_smoothing_sigma": float(getattr(self, f"{prefix}_fg_smoothing_spin").value()),
763
+ "foreground_median_radius": int(getattr(self, f"{prefix}_fg_median_spin").value()),
764
+ }
765
+
766
+ def _apply_channel_state(self, prefix: str, state: dict) -> None:
767
+ if "foreground_z_reduction" in state:
768
+ getattr(self, f"{prefix}_fg_reduction").setCurrentText(state["foreground_z_reduction"])
769
+ if "contour_z_reduction" in state:
770
+ getattr(self, f"{prefix}_contour_reduction").setCurrentText(state["contour_z_reduction"])
771
+ if "smoothing_sigma" in state:
772
+ getattr(self, f"{prefix}_smoothing_spin").setValue(float(state["smoothing_sigma"]))
773
+ if "median_radius" in state:
774
+ getattr(self, f"{prefix}_median_spin").setValue(int(state["median_radius"]))
775
+ if "foreground_smoothing_sigma" in state:
776
+ getattr(self, f"{prefix}_fg_smoothing_spin").setValue(float(state["foreground_smoothing_sigma"]))
777
+ if "foreground_median_radius" in state:
778
+ getattr(self, f"{prefix}_fg_median_spin").setValue(int(state["foreground_median_radius"]))
779
+
780
+ # ── Path helpers ────────────────────────────────────────────────
781
+ def set_maps_dir(self, maps_dir: Path | str | None) -> None:
782
+ """Standalone seam: resolve maps flatly under an explicit directory.
783
+
784
+ The orchestrator drives the widget through ``refresh(pos_dir)`` (maps
785
+ live under ``<pos_dir>/1_cellpose``); the standalone Cellpose piece
786
+ instead points it at the chosen output directory, where the Cellpose
787
+ prob/dp stacks and the derived foreground/contour maps live side by side.
788
+ """
789
+ self._maps_dir = None if maps_dir is None else Path(maps_dir)
790
+ self.gate.recompute()
791
+
792
+ def _resolved_maps_dir(self) -> Path | None:
793
+ if self._maps_dir is not None:
794
+ return self._maps_dir
795
+ return self._pos_dir / "1_cellpose" if self._pos_dir is not None else None