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,1034 @@
1
+ """Local Cellpose-SAM widget — per-channel rows with preview, run, cancel."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import napari
8
+ import numpy as np
9
+ import tifffile
10
+ from napari.qt.threading import thread_worker
11
+ from qtpy.QtCore import Signal
12
+ from qtpy.QtWidgets import (
13
+ QCheckBox,
14
+ QComboBox,
15
+ QHBoxLayout,
16
+ QLabel,
17
+ QProgressBar,
18
+ QSizePolicy,
19
+ QVBoxLayout,
20
+ QWidget,
21
+ )
22
+
23
+ from itasc.napari._standalone_paths import StandalonePathsMixin
24
+ from itasc.napari._widget_helpers import (
25
+ dslider as _dslider,
26
+ islider as _islider,
27
+ tool_btn as _tool_btn,
28
+ )
29
+ from itasc.napari.divergence_maps_widget import DivergenceMapsWidget
30
+ from itasc.napari.ui_gate import ControlClass, UiGate
31
+ from itasc.napari.ui_style import (
32
+ add_section_full_row,
33
+ add_section_pair_row,
34
+ section_grid,
35
+ stage_header_action_button,
36
+ stage_header_label,
37
+ status_label,
38
+ )
39
+ from itasc.napari.widgets import (
40
+ CollapsibleSection,
41
+ PipelineFilesWidget,
42
+ make_pipeline_files_header,
43
+ )
44
+ from itasc.cellpose import cellpose_runner
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # Canonical declared rel-paths for the two configurable inputs. The actual
50
+ # location is resolved live in ``_input_path`` (a relative override under the
51
+ # position dir, an absolute path, or a standalone pick) and need not sit under
52
+ # ``0_input`` — see ``_input_overrides``, which feeds the real paths to the
53
+ # Pipeline Files panel so its status tracks the file the stage will read.
54
+ _INPUT_NUCLEUS_REL = "0_input/nucleus.tif"
55
+ _INPUT_CELL_REL = "0_input/cell.tif"
56
+
57
+ _PIPELINE_FILES = [
58
+ ("Inputs", [
59
+ (_INPUT_NUCLEUS_REL, "Nucleus 3D+t"),
60
+ (_INPUT_CELL_REL, "Cell 3D+t"),
61
+ ]),
62
+ ("Cellpose Outputs", [
63
+ ("1_cellpose/nucleus_prob.tif", "Nucleus prob 3D+t"),
64
+ ("1_cellpose/nucleus_dp.tif", "Nucleus dp 3D+t"),
65
+ ("1_cellpose/cell_prob.tif", "Cell prob 3D+t"),
66
+ ("1_cellpose/cell_dp.tif", "Cell dp 3D+t"),
67
+ ]),
68
+ ("Divergence Maps", [
69
+ ("1_cellpose/nucleus_contours.tif", "Nucleus contours"),
70
+ ("1_cellpose/nucleus_foreground.tif", "Nucleus foreground"),
71
+ ("1_cellpose/cell_contours.tif", "Cell contours"),
72
+ ("1_cellpose/cell_foreground.tif", "Cell foreground"),
73
+ ]),
74
+ ]
75
+
76
+
77
+ _REFERENCE_LAYER_NAMES = {
78
+ "nucleus": "Reference: Nucleus 3D+t",
79
+ "cell": "Reference: Cell 3D+t",
80
+ }
81
+
82
+ # Input dimensionality options; default to 3D+t (the historical assumption).
83
+ _LAYOUT_OPTIONS = ["2D", "2D+t", "3D", "3D+t"]
84
+ _DEFAULT_LAYOUT = "3D+t"
85
+
86
+
87
+ def _layout_combo() -> QComboBox:
88
+ combo = QComboBox()
89
+ combo.addItems(_LAYOUT_OPTIONS)
90
+ combo.setCurrentText(_DEFAULT_LAYOUT)
91
+ return combo
92
+
93
+
94
+ def _make_status() -> QLabel:
95
+ lbl = QLabel("")
96
+ lbl.setWordWrap(True)
97
+ lbl.setVisible(False)
98
+ status_label(lbl)
99
+ return lbl
100
+
101
+
102
+ def _make_progress() -> QProgressBar:
103
+ bar = QProgressBar()
104
+ bar.setRange(0, 100)
105
+ bar.setValue(0)
106
+ bar.setTextVisible(True)
107
+ bar.setVisible(False)
108
+ return bar
109
+
110
+
111
+ class CellposeWidget(StandalonePathsMixin, QWidget):
112
+ """Local Cellpose-SAM runner — two rows (Nucleus, Cell)."""
113
+
114
+ _progress_signal = Signal(int, int, str)
115
+
116
+ #: QSettings application key for the standalone path pickers.
117
+ _SETTINGS_APP = "itasc_cellpose"
118
+
119
+ def __init__(
120
+ self,
121
+ viewer: napari.Viewer,
122
+ parent: QWidget | None = None,
123
+ gate: UiGate | None = None,
124
+ standalone: bool = False,
125
+ ) -> None:
126
+ super().__init__(parent)
127
+ self.viewer = viewer
128
+ #: App-wide UI gate; a private one is created for standalone use.
129
+ self.gate = gate if gate is not None else UiGate(self)
130
+ self._pos_dir: Path | None = None
131
+ #: Standalone explicit paths (orchestrated mode leaves these ``None`` and
132
+ #: derives everything from ``_pos_dir``'s staged subdirectories).
133
+ self._standalone = standalone
134
+ self._sa_nucleus: Path | None = None
135
+ self._sa_cell: Path | None = None
136
+ self._sa_output_dir: Path | None = None
137
+ self._running_stage: str | None = None
138
+ #: Whether the in-flight job can be cancelled. Full runs iterate frames
139
+ #: and poll ``_cancel_requested``; previews are a single blocking frame
140
+ #: with no cancellation point, so they show no ✕ and disable the row.
141
+ self._running_cancellable = True
142
+ self._worker = None
143
+ self._cancel_requested = False
144
+
145
+ self._setup_ui()
146
+ self._connect_signals()
147
+ self._register_gate_controls()
148
+ self._progress_signal.connect(self._progress)
149
+ if self._standalone:
150
+ self._load_standalone_settings()
151
+
152
+ # ------------------------------------------------------------------
153
+ # UI
154
+ # ------------------------------------------------------------------
155
+ def _setup_ui(self) -> None:
156
+ root = QVBoxLayout(self)
157
+ root.setContentsMargins(2, 2, 2, 2)
158
+ root.setSpacing(6)
159
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
160
+
161
+ # ── Input pickers (both modes) + standalone output dir ─────────
162
+ # In the full app every path is fixed by the project structure
163
+ # (inputs at <pos_dir>/0_input/*.tif, maps at <pos_dir>/1_cellpose/),
164
+ # so none of these pickers are shown — see the container visibility gate
165
+ # below. Standalone: the two inputs are explicit and required, and the
166
+ # output-dir picker chooses where the maps are written.
167
+ self._paths_container = QWidget()
168
+ paths_col = QVBoxLayout(self._paths_container)
169
+ paths_col.setContentsMargins(0, 0, 0, 0)
170
+ self._nucleus_edit = self._add_path_row(
171
+ paths_col,
172
+ "Nucleus channel",
173
+ ("raw nucleus stack (.tif)" if self._standalone
174
+ else "default: 0_input/nucleus.tif — or pick any .tif"),
175
+ self._on_browse_nucleus,
176
+ self._on_input_paths_changed,
177
+ )
178
+ self._cell_edit = self._add_path_row(
179
+ paths_col,
180
+ "Cell channel",
181
+ ("raw cell stack (.tif)" if self._standalone
182
+ else "default: 0_input/cell.tif — or pick any .tif"),
183
+ self._on_browse_cell,
184
+ self._on_input_paths_changed,
185
+ )
186
+ self._output_dir_row = QWidget()
187
+ out_col = QVBoxLayout(self._output_dir_row)
188
+ out_col.setContentsMargins(0, 0, 0, 0)
189
+ self._output_dir_edit = self._add_path_row(
190
+ out_col,
191
+ "Output dir",
192
+ "directory for Cellpose maps",
193
+ self._on_browse_output_dir,
194
+ self._apply_standalone_paths,
195
+ )
196
+ paths_col.addWidget(self._output_dir_row)
197
+ root.addWidget(self._paths_container)
198
+ # The full app derives all paths from the project structure, so the
199
+ # input and output pickers are standalone-only.
200
+ self._paths_container.setVisible(self._standalone)
201
+
202
+ # ── Pipeline files ─────────────────────────────────────────────
203
+ self._files_widget = PipelineFilesWidget(_PIPELINE_FILES, viewer=self.viewer)
204
+ self.output_files_tracker = self._files_widget
205
+ self.input_files_tracker = self._files_widget
206
+ self._pipeline_files_section = CollapsibleSection(
207
+ "Pipeline Files", self._files_widget, expanded=False,
208
+ )
209
+ (
210
+ self.pipeline_files_header,
211
+ self.pipeline_files_header_lbl,
212
+ self.pipeline_files_toggle_btn,
213
+ ) = make_pipeline_files_header(
214
+ self._pipeline_files_section, stage_key="cellpose", parent=self,
215
+ )
216
+ root.addWidget(self.pipeline_files_header)
217
+ root.addWidget(self._pipeline_files_section)
218
+
219
+ # ── Nucleus row + params ───────────────────────────────────────
220
+ self.nucleus_params_btn = _tool_btn(
221
+ "⚙", "Show parameters for nucleus Cellpose.", checkable=True,
222
+ )
223
+ self.nucleus_preview_btn = _tool_btn("▷", "Preview on current frame.")
224
+ self.nucleus_run_btn = _tool_btn("▶", "Run nucleus Cellpose on all frames.")
225
+ for button in (
226
+ self.nucleus_params_btn,
227
+ self.nucleus_preview_btn,
228
+ self.nucleus_run_btn,
229
+ ):
230
+ stage_header_action_button(button, "cellpose")
231
+ self.nucleus_section = self._build_nucleus_params_section()
232
+ self.nucleus_section.set_header_visible(False)
233
+ self.nucleus_section.collapse()
234
+ self.nucleus_params_btn.toggled.connect(
235
+ lambda checked: self.nucleus_section._toggle.setChecked(checked)
236
+ )
237
+ root.addLayout(self._stage_row(
238
+ self._stage_label("Nucleus Cellpose"),
239
+ self.nucleus_params_btn,
240
+ self.nucleus_preview_btn,
241
+ self.nucleus_run_btn,
242
+ ))
243
+ root.addWidget(self.nucleus_section)
244
+
245
+ # ── Cell row + params ──────────────────────────────────────────
246
+ self.cell_params_btn = _tool_btn(
247
+ "⚙", "Show parameters for cell Cellpose.", checkable=True,
248
+ )
249
+ self.cell_preview_btn = _tool_btn("▷", "Preview on current frame/z-slice.")
250
+ self.cell_run_btn = _tool_btn("▶", "Run cell Cellpose on all frames.")
251
+ for button in (
252
+ self.cell_params_btn,
253
+ self.cell_preview_btn,
254
+ self.cell_run_btn,
255
+ ):
256
+ stage_header_action_button(button, "cellpose")
257
+ self.cell_section = self._build_cell_params_section()
258
+ self.cell_section.set_header_visible(False)
259
+ self.cell_section.collapse()
260
+ self.cell_params_btn.toggled.connect(
261
+ lambda checked: self.cell_section._toggle.setChecked(checked)
262
+ )
263
+ root.addLayout(self._stage_row(
264
+ self._stage_label("Cell Cellpose"),
265
+ self.cell_params_btn,
266
+ self.cell_preview_btn,
267
+ self.cell_run_btn,
268
+ ))
269
+ root.addWidget(self.cell_section)
270
+
271
+ # ── Status + progress (shared) ─────────────────────────────────
272
+ self.status_lbl = _make_status()
273
+ root.addWidget(self.status_lbl)
274
+ self.progress_bar = _make_progress()
275
+ root.addWidget(self.progress_bar)
276
+
277
+ # ── Divergence maps from Cellpose prob/dp outputs ─────────────
278
+ self.divergence_maps_widget = DivergenceMapsWidget(
279
+ self.viewer,
280
+ show_pipeline_files=False,
281
+ gate=self.gate,
282
+ )
283
+ # The divergence widget owns no Pipeline Files panel here, yet its output
284
+ # (the foreground/contour maps) is the cellpose stage's done-signal.
285
+ # Refresh ours when it finishes so the section dot + catalog rail repaint.
286
+ self.divergence_maps_widget.maps_built.connect(self._on_divergence_maps_built)
287
+ root.addWidget(self.divergence_maps_widget)
288
+
289
+ def _build_nucleus_params_section(self) -> CollapsibleSection:
290
+ body = QWidget(self)
291
+ grid = section_grid()
292
+ grid.setContentsMargins(8, 4, 4, 4)
293
+ body.setLayout(grid)
294
+ self.nuc_layout_combo = _layout_combo()
295
+ self.nuc_3d_chk = QCheckBox("3D mode")
296
+ self.nuc_3d_chk.setChecked(True)
297
+ self.nuc_anisotropy_spin = _dslider(0.1, 20.0, 1.5, 0.1, 2)
298
+ self.nuc_diameter_spin = _dslider(0.0, 500.0, 25.0, 1.0, 1)
299
+ self.nuc_min_size_spin = _islider(0, 100000, 15)
300
+ self.nuc_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
301
+ row = 0
302
+ add_section_pair_row(grid, row, "Input layout:", self.nuc_layout_combo); row += 1
303
+ add_section_full_row(grid, row, self.nuc_3d_chk); row += 1
304
+ add_section_pair_row(
305
+ grid, row,
306
+ "Anisotropy:", self.nuc_anisotropy_spin,
307
+ "Diameter:", self.nuc_diameter_spin,
308
+ ); row += 1
309
+ add_section_pair_row(
310
+ grid, row,
311
+ "Min size:", self.nuc_min_size_spin,
312
+ "Gamma:", self.nuc_gamma_spin,
313
+ )
314
+ # True-3D segmentation only makes sense when the input has a Z axis.
315
+ self.nuc_layout_combo.currentTextChanged.connect(self._sync_nucleus_3d_enabled)
316
+ self._sync_nucleus_3d_enabled(self.nuc_layout_combo.currentText())
317
+ return CollapsibleSection("Nucleus parameters", body, expanded=False)
318
+
319
+ def _sync_nucleus_3d_enabled(self, layout: str) -> None:
320
+ has_z = cellpose_runner.layout_has_z(layout)
321
+ self.nuc_3d_chk.setEnabled(has_z)
322
+ self.nuc_anisotropy_spin.setEnabled(has_z)
323
+
324
+ def _build_cell_params_section(self) -> CollapsibleSection:
325
+ body = QWidget(self)
326
+ grid = section_grid()
327
+ grid.setContentsMargins(8, 4, 4, 4)
328
+ body.setLayout(grid)
329
+ self.cell_layout_combo = _layout_combo()
330
+ self.cell_diameter_spin = _dslider(0.0, 500.0, 0.0, 1.0, 1)
331
+ self.cell_min_size_spin = _islider(0, 100000, 0)
332
+ self.cell_gamma_spin = _dslider(0.1, 5.0, 1.0, 0.1, 2)
333
+ row = 0
334
+ add_section_pair_row(grid, row, "Input layout:", self.cell_layout_combo); row += 1
335
+ add_section_pair_row(
336
+ grid, row,
337
+ "Diameter:", self.cell_diameter_spin,
338
+ "Min size:", self.cell_min_size_spin,
339
+ ); row += 1
340
+ add_section_pair_row(grid, row, "Gamma:", self.cell_gamma_spin)
341
+ return CollapsibleSection("Cell parameters", body, expanded=False)
342
+
343
+ @staticmethod
344
+ def _stage_label(text: str) -> QLabel:
345
+ return stage_header_label(QLabel(text), "cellpose")
346
+
347
+ @staticmethod
348
+ def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
349
+ row = QHBoxLayout()
350
+ row.setContentsMargins(0, 0, 0, 0)
351
+ row.setSpacing(4)
352
+ row.addWidget(label)
353
+ for w in trailing:
354
+ row.addWidget(w)
355
+ row.addStretch(1)
356
+ return row
357
+
358
+ # ------------------------------------------------------------------
359
+ # Signals (run/cancel handlers are filled in in later tasks)
360
+ # ------------------------------------------------------------------
361
+ def _connect_signals(self) -> None:
362
+ self.nucleus_run_btn.clicked.connect(self._on_nucleus_run_clicked)
363
+ self.cell_run_btn.clicked.connect(self._on_cell_run_clicked)
364
+ self.nucleus_preview_btn.clicked.connect(self._on_nucleus_preview)
365
+ self.cell_preview_btn.clicked.connect(self._on_cell_preview)
366
+
367
+ def _on_nucleus_run_clicked(self) -> None:
368
+ if self._running_stage is not None:
369
+ self._on_cancel()
370
+ return
371
+ self._run_channel("nucleus")
372
+
373
+ def _on_cell_run_clicked(self) -> None:
374
+ if self._running_stage is not None:
375
+ self._on_cancel()
376
+ return
377
+ self._run_channel("cell")
378
+
379
+ def _on_nucleus_preview(self) -> None:
380
+ self._preview_channel("nucleus")
381
+
382
+ def _on_cell_preview(self) -> None:
383
+ self._preview_channel("cell")
384
+
385
+ def _on_cancel(self) -> None:
386
+ self._cancel_requested = True
387
+ worker = self._worker
388
+ if worker is not None and hasattr(worker, "quit"):
389
+ worker.quit()
390
+
391
+ # ------------------------------------------------------------------
392
+ # Path helpers
393
+ # ------------------------------------------------------------------
394
+ def _input_path(self, channel: str) -> Path | None:
395
+ if self._standalone:
396
+ return self._sa_nucleus if channel == "nucleus" else self._sa_cell
397
+ if self._pos_dir is None:
398
+ return None
399
+ # Integrated: the input field overrides the default 0_input name. A blank
400
+ # field falls back to the canonical name; a relative override resolves
401
+ # under the position dir, an absolute one is used verbatim.
402
+ edit = self._nucleus_edit if channel == "nucleus" else self._cell_edit
403
+ default_name = "nucleus.tif" if channel == "nucleus" else "cell.tif"
404
+ text = edit.text().strip()
405
+ if text:
406
+ override = Path(text)
407
+ return override if override.is_absolute() else self._pos_dir / override
408
+ return self._pos_dir / "0_input" / default_name
409
+
410
+ def _output_dir(self) -> Path | None:
411
+ if self._standalone:
412
+ return self._sa_output_dir
413
+ return None if self._pos_dir is None else self._pos_dir / "1_cellpose"
414
+
415
+ def _input_overrides(self) -> dict[str, Path | None]:
416
+ """Live locations for the two configurable Pipeline-Files input rows.
417
+
418
+ The rows are declared with the canonical ``0_input/*.tif`` names, but
419
+ the real input is configurable and need not sit under ``0_input``. Resolve
420
+ it via :meth:`_input_path` so the panel checks the file the stage reads.
421
+ """
422
+ return {
423
+ _INPUT_NUCLEUS_REL: self._input_path("nucleus"),
424
+ _INPUT_CELL_REL: self._input_path("cell"),
425
+ }
426
+
427
+ def _refresh_files(self, pos_dir: Path | None) -> None:
428
+ """Refresh the Pipeline Files panel with the live input overrides."""
429
+ self._files_widget.refresh(pos_dir, overrides=self._input_overrides())
430
+
431
+ # ── Standalone helpers ─────────────────────────────────────────────
432
+ # Row building / browse plumbing / QSettings come from StandalonePathsMixin;
433
+ # the apply step is Cellpose-specific (two explicit input stacks + a flat
434
+ # output directory that also serves as the divergence widget's maps dir).
435
+ def _apply_standalone_paths(self) -> None:
436
+ nuc = self._nucleus_edit.text().strip()
437
+ cel = self._cell_edit.text().strip()
438
+ out = self._output_dir_edit.text().strip()
439
+ self._sa_nucleus = Path(nuc) if nuc else None
440
+ self._sa_cell = Path(cel) if cel else None
441
+ self._sa_output_dir = Path(out) if out else None
442
+ self._save_standalone_settings()
443
+ # The existing _pos_dir-based guards and the pipeline-files panel expect a
444
+ # real directory; the output dir doubles as the staged-file root here.
445
+ self._pos_dir = self._sa_output_dir
446
+ self._refresh_files(self._sa_output_dir)
447
+ self.divergence_maps_widget.set_maps_dir(self._sa_output_dir)
448
+ self.gate.recompute()
449
+
450
+ def _on_browse_nucleus(self) -> None:
451
+ self._browse_file_into(
452
+ self._nucleus_edit, "Select nucleus channel", self._on_nucleus_selected
453
+ )
454
+
455
+ def _on_input_paths_changed(self) -> None:
456
+ """Input-field edit handler.
457
+
458
+ Standalone reads all three pickers together (inputs + output dir);
459
+ integrated reads the two input fields lazily in :meth:`_input_path`, so a
460
+ light status refresh is enough — and calling the standalone apply here
461
+ would wrongly clobber ``_pos_dir`` with the (empty) output-dir field.
462
+ """
463
+ if self._standalone:
464
+ self._apply_standalone_paths()
465
+ else:
466
+ self._refresh_files(self._pos_dir)
467
+ self.gate.recompute()
468
+
469
+ def _on_nucleus_selected(self) -> None:
470
+ self._on_input_paths_changed()
471
+ self._autoselect_layout("nucleus")
472
+
473
+ def _on_browse_cell(self) -> None:
474
+ self._browse_file_into(
475
+ self._cell_edit, "Select cell channel", self._on_cell_selected
476
+ )
477
+
478
+ def _on_cell_selected(self) -> None:
479
+ self._on_input_paths_changed()
480
+ self._autoselect_layout("cell")
481
+
482
+ def _on_browse_output_dir(self) -> None:
483
+ self._browse_dir_into(
484
+ self._output_dir_edit, "Select output directory", self._apply_standalone_paths
485
+ )
486
+
487
+ def _standalone_fields(self) -> dict:
488
+ return {
489
+ "nucleus": self._nucleus_edit,
490
+ "cell": self._cell_edit,
491
+ "output_dir": self._output_dir_edit,
492
+ }
493
+
494
+ def _load_standalone_settings(self) -> None:
495
+ self._load_path_settings(self._SETTINGS_APP, self._standalone_fields())
496
+ if any(edit.text().strip() for edit in self._standalone_fields().values()):
497
+ self._apply_standalone_paths()
498
+
499
+ def _save_standalone_settings(self) -> None:
500
+ self._save_path_settings(self._SETTINGS_APP, self._standalone_fields())
501
+
502
+ # ------------------------------------------------------------------
503
+ # Run flow
504
+ # ------------------------------------------------------------------
505
+ def _channel_layout(self, channel: str) -> str:
506
+ combo = self.nuc_layout_combo if channel == "nucleus" else self.cell_layout_combo
507
+ return combo.currentText()
508
+
509
+ def _autoselect_layout(self, channel: str) -> None:
510
+ """Best-effort: preselect the layout from the input file's ndim.
511
+
512
+ Only acts on the unambiguous 2-D / 4-D cases; a 3-D file keeps the user's
513
+ explicit choice. Never raises — a missing/unreadable file is ignored.
514
+ """
515
+ path = self._input_path(channel)
516
+ if path is None:
517
+ return
518
+ try:
519
+ with tifffile.TiffFile(str(path)) as tf:
520
+ ndim = len(tf.series[0].shape)
521
+ except Exception:
522
+ return
523
+ inferred = cellpose_runner.infer_layout_from_ndim(ndim)
524
+ if inferred is None:
525
+ return
526
+ combo = self.nuc_layout_combo if channel == "nucleus" else self.cell_layout_combo
527
+ combo.setCurrentText(inferred)
528
+
529
+ def _build_nucleus_params(self) -> cellpose_runner.NucleusParams:
530
+ # True-3D segmentation requires a Z axis; a Z-less input forces 2D.
531
+ do_3d = self.nuc_3d_chk.isChecked() and cellpose_runner.layout_has_z(
532
+ self._channel_layout("nucleus")
533
+ )
534
+ return cellpose_runner.NucleusParams(
535
+ do_3d=do_3d,
536
+ anisotropy=float(self.nuc_anisotropy_spin.value()),
537
+ diameter=float(self.nuc_diameter_spin.value()),
538
+ min_size=int(self.nuc_min_size_spin.value()),
539
+ gamma=float(self.nuc_gamma_spin.value()),
540
+ )
541
+
542
+ def _build_cell_params(self) -> cellpose_runner.CellParams:
543
+ return cellpose_runner.CellParams(
544
+ diameter=float(self.cell_diameter_spin.value()),
545
+ min_size=int(self.cell_min_size_spin.value()),
546
+ gamma=float(self.cell_gamma_spin.value()),
547
+ )
548
+
549
+ def _run_channel(self, channel: str) -> None:
550
+ if self._pos_dir is None:
551
+ self._status("No project open.")
552
+ return
553
+ in_path = self._input_path(channel)
554
+ if in_path is None or not in_path.exists():
555
+ self._status(f"Missing: {in_path.name if in_path else '(no path)'}")
556
+ return
557
+ out_dir = self._output_dir()
558
+ params = (
559
+ self._build_nucleus_params() if channel == "nucleus"
560
+ else self._build_cell_params()
561
+ )
562
+ layout = self._channel_layout(channel)
563
+ pos_dir = self._pos_dir
564
+ self._cancel_requested = False
565
+
566
+ def _done(result):
567
+ self._worker = None
568
+ self._set_running_stage(None)
569
+ self._clear_progress()
570
+ self._refresh_files(pos_dir)
571
+ self._refresh_divergence(pos_dir)
572
+ label = "Nucleus" if channel == "nucleus" else "Cell"
573
+ self._status(f"{label} Cellpose complete — wrote {channel}_*.tif")
574
+
575
+ def _error(exc):
576
+ self._worker = None
577
+ self._set_running_stage(None)
578
+ self._clear_progress()
579
+ if isinstance(exc, cellpose_runner.CancelledError):
580
+ self._status("Cancelled.")
581
+ else:
582
+ self._status(f"Error: {exc}")
583
+ logger.exception("Cellpose run error", exc_info=exc)
584
+
585
+ progress_signal = self._progress_signal
586
+
587
+ @thread_worker(connect={
588
+ "yielded": self._on_progress,
589
+ "returned": _done,
590
+ "errored": _error,
591
+ })
592
+ def _worker():
593
+ yield (0, 1, "Loading input...")
594
+ stack = cellpose_runner.to_tzyx(
595
+ np.asarray(tifffile.imread(str(in_path))), layout
596
+ )
597
+
598
+ def _cb_progress(done, total, msg):
599
+ progress_signal.emit(int(done), int(total), str(msg))
600
+
601
+ def _cb_cancel():
602
+ return self._cancel_requested
603
+
604
+ if channel == "nucleus":
605
+ prob, dp = cellpose_runner.run_nucleus_stack(
606
+ stack, params,
607
+ progress_cb=_cb_progress, cancel_cb=_cb_cancel,
608
+ )
609
+ else:
610
+ prob, dp = cellpose_runner.run_cell_stack(
611
+ stack, params,
612
+ progress_cb=_cb_progress, cancel_cb=_cb_cancel,
613
+ )
614
+ yield (1, 1, "Writing outputs...")
615
+ cellpose_runner.write_outputs(prob, dp, out_dir, channel)
616
+ return None
617
+
618
+ self._set_running_stage(channel)
619
+ self._status(
620
+ f"Loading Cellpose-SAM model on {cellpose_runner.device_label()} "
621
+ f"(~10s on first run)..." if not cellpose_runner.is_model_loaded()
622
+ else f"Running {channel} Cellpose..."
623
+ )
624
+ self._worker = _worker()
625
+
626
+ # ------------------------------------------------------------------
627
+ # Preview flow
628
+ # ------------------------------------------------------------------
629
+ def _current_tz(self) -> tuple[int, int]:
630
+ step = getattr(getattr(self.viewer, "dims", None), "current_step", (0, 0))
631
+ t = int(step[0]) if len(step) >= 1 else 0
632
+ z = int(step[1]) if len(step) >= 2 else 0
633
+ return t, z
634
+
635
+ @staticmethod
636
+ def _flow_magnitude(dp: np.ndarray) -> np.ndarray:
637
+ # dp has shape (C, ...) — sum-of-squares over the channel axis.
638
+ return np.sqrt(np.sum(np.asarray(dp, dtype=np.float32) ** 2, axis=0))
639
+
640
+ @staticmethod
641
+ def _sigmoid(x: np.ndarray) -> np.ndarray:
642
+ x = np.asarray(x, dtype=np.float32)
643
+ return (1.0 / (1.0 + np.exp(-x))).astype(np.float32)
644
+
645
+ @staticmethod
646
+ def _flow_contrast_limits(flow: np.ndarray) -> tuple[float, float]:
647
+ # Derive limits from the populated frame only — flow_full is mostly
648
+ # zeros, so napari's auto-contrast undersamples and clips the peaks.
649
+ hi = float(np.asarray(flow, dtype=np.float32).max())
650
+ return 0.0, max(hi, 1e-6)
651
+
652
+ def _preview_channel(self, channel: str) -> None:
653
+ if self._running_stage is not None:
654
+ self._status("Cellpose task already running.")
655
+ return
656
+ if self._pos_dir is None:
657
+ self._status("No project open.")
658
+ return
659
+ in_path = self._input_path(channel)
660
+ if in_path is None or not in_path.exists():
661
+ self._status(f"Missing: {in_path.name if in_path else '(no path)'}")
662
+ return
663
+
664
+ params = (
665
+ self._build_nucleus_params() if channel == "nucleus"
666
+ else self._build_cell_params()
667
+ )
668
+ layout = self._channel_layout(channel)
669
+ self._cancel_requested = False
670
+ self._set_running_stage(channel, cancellable=False)
671
+ self._progress(0, 0, f"Loading {channel} reference stack for preview...")
672
+ try:
673
+ stack = cellpose_runner.to_tzyx(
674
+ np.asarray(tifffile.imread(str(in_path))), layout
675
+ )
676
+ self._show_reference_stack(channel, stack)
677
+ t, z = self._current_tz()
678
+ except Exception as exc:
679
+ self._set_running_stage(None)
680
+ self._clear_progress()
681
+ self._status(f"Error: {exc}")
682
+ logger.exception("Cellpose preview load error", exc_info=exc)
683
+ return
684
+
685
+ def _done(result):
686
+ self._worker = None
687
+ self._set_running_stage(None)
688
+ self._clear_progress()
689
+ status_msg, layers = result
690
+ for name, data, kwargs in layers:
691
+ self._show_layer(name, data, kwargs, self.viewer.add_image)
692
+ self._status(status_msg)
693
+
694
+ def _error(exc):
695
+ self._worker = None
696
+ self._set_running_stage(None)
697
+ self._clear_progress()
698
+ self._status(f"Error: {exc}")
699
+ logger.exception("Cellpose preview error", exc_info=exc)
700
+
701
+ @thread_worker(connect={
702
+ "yielded": self._on_progress,
703
+ "returned": _done,
704
+ "errored": _error,
705
+ })
706
+ def _worker():
707
+ T, Z = stack.shape[:2]
708
+ t_clamped = min(max(t, 0), T - 1)
709
+ z_clamped = min(max(z, 0), Z - 1)
710
+
711
+ if channel == "nucleus":
712
+ if params.do_3d:
713
+ yield (
714
+ 0, 0,
715
+ f"Previewing nucleus 3D t={t_clamped} "
716
+ f"on {cellpose_runner.device_label()} "
717
+ f"(Z={Z}, anisotropy={params.anisotropy})...",
718
+ )
719
+ prob_logits, dp = cellpose_runner.run_nucleus_frame(
720
+ stack[t_clamped], z=None, params=params,
721
+ )
722
+ prob = self._sigmoid(prob_logits)
723
+ flow = self._flow_magnitude(dp) # (Z, Y, X)
724
+ prob_full = np.zeros((T, Z, *prob.shape[-2:]), dtype=np.float32)
725
+ flow_full = np.zeros_like(prob_full)
726
+ prob_full[t_clamped] = prob
727
+ flow_full[t_clamped] = flow
728
+ flow_clim = self._flow_contrast_limits(flow)
729
+ status_msg = (
730
+ f"Preview: nucleus 3D t={t_clamped} "
731
+ f"(Z={Z}, anisotropy={params.anisotropy})"
732
+ )
733
+ else:
734
+ yield (
735
+ 0, 0,
736
+ f"Previewing nucleus 2D t={t_clamped} z={z_clamped} "
737
+ f"on {cellpose_runner.device_label()}...",
738
+ )
739
+ prob_logits, dp = cellpose_runner.run_nucleus_frame(
740
+ stack[t_clamped], z=z_clamped, params=params,
741
+ )
742
+ prob = self._sigmoid(prob_logits)
743
+ flow = self._flow_magnitude(dp) # (Y, X)
744
+ prob_full = np.zeros((T, Z, *prob.shape), dtype=np.float32)
745
+ flow_full = np.zeros_like(prob_full)
746
+ prob_full[t_clamped, z_clamped] = prob
747
+ flow_full[t_clamped, z_clamped] = flow
748
+ flow_clim = self._flow_contrast_limits(flow)
749
+ status_msg = (
750
+ f"Preview: nucleus 2D t={t_clamped} z={z_clamped} "
751
+ f"(diameter={params.diameter})"
752
+ )
753
+ return status_msg, [
754
+ (
755
+ "Preview: Nucleus prob",
756
+ prob_full,
757
+ {
758
+ "colormap": "viridis",
759
+ "blending": "additive",
760
+ "contrast_limits": (0.0, 1.0),
761
+ },
762
+ ),
763
+ (
764
+ "Preview: Nucleus flow",
765
+ flow_full,
766
+ {
767
+ "colormap": "inferno",
768
+ "blending": "additive",
769
+ "contrast_limits": flow_clim,
770
+ },
771
+ ),
772
+ ]
773
+
774
+ yield (
775
+ 0, 0,
776
+ f"Previewing cell 2D t={t_clamped} z={z_clamped} "
777
+ f"on {cellpose_runner.device_label()}...",
778
+ )
779
+ prob_logits, dp = cellpose_runner.run_cell_frame(
780
+ stack[t_clamped], z=z_clamped, params=params,
781
+ )
782
+ prob = self._sigmoid(prob_logits)
783
+ flow = self._flow_magnitude(dp)
784
+ prob_full = np.zeros((T, Z, *prob.shape), dtype=np.float32)
785
+ flow_full = np.zeros_like(prob_full)
786
+ prob_full[t_clamped, z_clamped] = prob
787
+ flow_full[t_clamped, z_clamped] = flow
788
+ flow_clim = self._flow_contrast_limits(flow)
789
+ return (
790
+ f"Preview: cell t={t_clamped} z={z_clamped} "
791
+ f"(diameter={params.diameter})",
792
+ [
793
+ (
794
+ "Preview: Cell prob",
795
+ prob_full,
796
+ {
797
+ "colormap": "viridis",
798
+ "blending": "additive",
799
+ "contrast_limits": (0.0, 1.0),
800
+ },
801
+ ),
802
+ (
803
+ "Preview: Cell flow",
804
+ flow_full,
805
+ {
806
+ "colormap": "inferno",
807
+ "blending": "additive",
808
+ "contrast_limits": flow_clim,
809
+ },
810
+ ),
811
+ ],
812
+ )
813
+
814
+ self._worker = _worker()
815
+
816
+ def _show_reference_stack(self, channel: str, stack: np.ndarray) -> None:
817
+ name = _REFERENCE_LAYER_NAMES[channel]
818
+ self._show_layer(
819
+ name, stack,
820
+ {"colormap": "gray", "blending": "additive"},
821
+ self.viewer.add_image,
822
+ )
823
+
824
+ # ------------------------------------------------------------------
825
+ # Public API
826
+ # ------------------------------------------------------------------
827
+ def set_input_names(self, names: dict[str, str]) -> None:
828
+ """Adopt the host's configured raw-input names (integrated mode only).
829
+
830
+ The full app configures the input names once, in the Data-folders panel's
831
+ discovery fields, and they need not be the canonical ``0_input/*.tif``.
832
+ Mirror them into the (hidden) per-channel input fields so ``_input_path``
833
+ — and thus run, preview, and Pipeline Files status — track the file the
834
+ stage will actually read. Standalone owns its own explicit pickers.
835
+ """
836
+ if self._standalone:
837
+ return
838
+ self._nucleus_edit.setText(names.get("nucleus", ""))
839
+ self._cell_edit.setText(names.get("cell", ""))
840
+ # editingFinished does not fire on programmatic setText — refresh by hand.
841
+ self._on_input_paths_changed()
842
+
843
+ def refresh(self, pos_dir: Path | None) -> None:
844
+ self._pos_dir = pos_dir
845
+ self._refresh_files(pos_dir)
846
+ self._refresh_divergence(pos_dir)
847
+ if pos_dir is not None:
848
+ self._autoselect_layout("nucleus")
849
+ self._autoselect_layout("cell")
850
+
851
+ def _on_divergence_maps_built(self) -> None:
852
+ """Repaint Pipeline Files after the embedded divergence widget writes maps.
853
+
854
+ The maps are this stage's tracked output but the divergence widget has no
855
+ Pipeline Files panel of its own, so refresh ours against the active root
856
+ (standalone output dir or the staged position dir).
857
+ """
858
+ root = self._sa_output_dir if self._standalone else self._pos_dir
859
+ self._refresh_files(root)
860
+
861
+ def _refresh_divergence(self, pos_dir: Path | None) -> None:
862
+ """Point the embedded divergence widget at the active maps location.
863
+
864
+ Orchestrated: maps live under ``<pos_dir>/1_cellpose``. Standalone: maps
865
+ live directly in the chosen output directory.
866
+ """
867
+ if self._standalone:
868
+ self.divergence_maps_widget.set_maps_dir(self._sa_output_dir)
869
+ else:
870
+ self.divergence_maps_widget.refresh(pos_dir)
871
+
872
+ def get_state(self) -> dict:
873
+ return {
874
+ "nucleus": {
875
+ "layout": self.nuc_layout_combo.currentText(),
876
+ "do_3d": self.nuc_3d_chk.isChecked(),
877
+ "anisotropy": self.nuc_anisotropy_spin.value(),
878
+ "diameter": self.nuc_diameter_spin.value(),
879
+ "min_size": self.nuc_min_size_spin.value(),
880
+ "gamma": self.nuc_gamma_spin.value(),
881
+ },
882
+ "cell": {
883
+ "layout": self.cell_layout_combo.currentText(),
884
+ "diameter": self.cell_diameter_spin.value(),
885
+ "min_size": self.cell_min_size_spin.value(),
886
+ "gamma": self.cell_gamma_spin.value(),
887
+ },
888
+ "divergence_maps": self.divergence_maps_widget.get_state(),
889
+ }
890
+
891
+ def set_state(self, state: dict) -> None:
892
+ if not isinstance(state, dict):
893
+ return
894
+ nuc = state.get("nucleus", {})
895
+ if isinstance(nuc, dict):
896
+ if nuc.get("layout") in _LAYOUT_OPTIONS:
897
+ self.nuc_layout_combo.setCurrentText(nuc["layout"])
898
+ if "do_3d" in nuc:
899
+ self.nuc_3d_chk.setChecked(bool(nuc["do_3d"]))
900
+ if "anisotropy" in nuc:
901
+ self.nuc_anisotropy_spin.setValue(float(nuc["anisotropy"]))
902
+ if "diameter" in nuc:
903
+ self.nuc_diameter_spin.setValue(float(nuc["diameter"]))
904
+ if "min_size" in nuc:
905
+ self.nuc_min_size_spin.setValue(int(nuc["min_size"]))
906
+ if "gamma" in nuc:
907
+ self.nuc_gamma_spin.setValue(float(nuc["gamma"]))
908
+ cel = state.get("cell", {})
909
+ if isinstance(cel, dict):
910
+ if cel.get("layout") in _LAYOUT_OPTIONS:
911
+ self.cell_layout_combo.setCurrentText(cel["layout"])
912
+ if "diameter" in cel:
913
+ self.cell_diameter_spin.setValue(float(cel["diameter"]))
914
+ if "min_size" in cel:
915
+ self.cell_min_size_spin.setValue(int(cel["min_size"]))
916
+ if "gamma" in cel:
917
+ self.cell_gamma_spin.setValue(float(cel["gamma"]))
918
+ if "divergence_maps" in state:
919
+ self.divergence_maps_widget.set_state(state["divergence_maps"])
920
+
921
+ # ------------------------------------------------------------------
922
+ # State helpers
923
+ # ------------------------------------------------------------------
924
+ def _status(self, msg: str) -> None:
925
+ self.status_lbl.setText(msg)
926
+ self.status_lbl.setVisible(bool(msg))
927
+ if msg:
928
+ logger.info(msg)
929
+
930
+ def _progress(self, done: int, total: int, msg: str) -> None:
931
+ self.progress_bar.setVisible(True)
932
+ self.progress_bar.setRange(0, total)
933
+ self.progress_bar.setValue(done)
934
+ self._status(msg)
935
+
936
+ def _on_progress(self, data) -> None:
937
+ if isinstance(data, tuple):
938
+ self._progress(*data)
939
+ else:
940
+ self._status(str(data))
941
+
942
+ def _clear_progress(self) -> None:
943
+ self.progress_bar.setValue(0)
944
+ self.progress_bar.setVisible(False)
945
+
946
+ def _register_gate_controls(self) -> None:
947
+ """Register the two channel rows with the app-wide UI gate.
948
+
949
+ Cellpose writes to the viewer, so its run/preview/params are blocked
950
+ while any viewer owner (correction / live preview) is active. Within
951
+ the widget, the active channel's row stays usable while the other is
952
+ disabled — expressed via ``when`` predicates over ``_running_stage``.
953
+ """
954
+ g = self.gate
955
+ idle = lambda: self._running_stage is None
956
+ for channel, params_btn, preview_btn, run_btn in (
957
+ ("nucleus", self.nucleus_params_btn, self.nucleus_preview_btn, self.nucleus_run_btn),
958
+ ("cell", self.cell_params_btn, self.cell_preview_btn, self.cell_run_btn),
959
+ ):
960
+ # The run/✕ button stays live on its own row only while a
961
+ # cancellable job (a full run) is in flight; a preview disables it.
962
+ own = lambda c=channel: self._running_stage is None or (
963
+ self._running_stage == c and self._running_cancellable
964
+ )
965
+ # ⚙ params just toggle a parameter panel — always available.
966
+ g.register(params_btn, ControlClass.HARMLESS)
967
+ g.register(preview_btn, ControlClass.RUN_VIEWER, when=idle)
968
+ g.register(run_btn, ControlClass.RUN_VIEWER, when=own)
969
+ g.recompute()
970
+
971
+ def _set_running_stage(self, stage_key: str | None, *, cancellable: bool = True) -> None:
972
+ """Swap the active row's ▶/✕ glyph; enablement is owned by the gate.
973
+
974
+ ``None`` means idle; ``'nucleus'`` or ``'cell'`` claims that row. The
975
+ gate's ``when`` predicates read ``self._running_stage`` to disable the
976
+ other row and the active row's preview while a job is in flight.
977
+
978
+ ``cancellable`` is ``True`` for full runs (the ✕ cancels them); previews
979
+ pass ``False`` — they can't be interrupted, so the glyph stays ▶ and the
980
+ gate disables the whole row until the frame returns.
981
+ """
982
+ self._running_stage = stage_key
983
+ self._running_cancellable = cancellable
984
+ if stage_key is None:
985
+ self.nucleus_run_btn.setText("▶")
986
+ self.nucleus_run_btn.setToolTip("Run nucleus Cellpose on all frames.")
987
+ self.cell_run_btn.setText("▶")
988
+ self.cell_run_btn.setToolTip("Run cell Cellpose on all frames.")
989
+ self._cancel_requested = False
990
+ elif cancellable:
991
+ run_btn = self.nucleus_run_btn if stage_key == "nucleus" else self.cell_run_btn
992
+ run_btn.setText("✕")
993
+ run_btn.setToolTip("Cancel.")
994
+ self.gate.recompute()
995
+
996
+ # ------------------------------------------------------------------
997
+ # Layer helper (mirrors CellWorkflowWidget._show_layer)
998
+ # ------------------------------------------------------------------
999
+ def _show_layer(self, name, data, kwargs, adder):
1000
+ if name in self.viewer.layers:
1001
+ try:
1002
+ layer = self.viewer.layers[name]
1003
+ layer.data = data
1004
+ clim = kwargs.get("contrast_limits")
1005
+ if clim is not None:
1006
+ layer.contrast_limits = clim
1007
+ return
1008
+ except Exception:
1009
+ self.viewer.layers.remove(self.viewer.layers[name])
1010
+ adder(data, name=name, **kwargs)
1011
+ else:
1012
+ adder(data, name=name, **kwargs)
1013
+
1014
+
1015
+ def make_cellpose_widget(napari_viewer=None):
1016
+ """napari plugin factory for the standalone Cellpose piece.
1017
+
1018
+ Patches the napari layer-controls delegate (best-effort) and returns the
1019
+ Cellpose widget in standalone mode, with its own nucleus/cell input stacks
1020
+ and output-directory pickers.
1021
+ """
1022
+ try:
1023
+ from itasc.napari._napari_compat import patch_napari_layer_delegate
1024
+
1025
+ patch_napari_layer_delegate()
1026
+ except Exception:
1027
+ pass
1028
+ # napari does not inject the viewer into function-based widget factories
1029
+ # (only into class-based callables / magicgui types), so ``napari_viewer``
1030
+ # arrives as ``None``. The widget needs a live viewer, so fall back to the
1031
+ # active one.
1032
+ if napari_viewer is None:
1033
+ napari_viewer = napari.current_viewer()
1034
+ return CellposeWidget(napari_viewer, standalone=True)