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,1405 @@
1
+ """Cell segmentation workflow widget — simplified divergence pipeline.
2
+
3
+ A single run path and a single preview path (no per-stage run buttons). The
4
+ widget consumes the cached divergence maps produced upstream by
5
+ ``DivergenceMapsWidget`` (``1_cellpose/cell_contours.tif`` +
6
+ ``1_cellpose/cell_foreground.tif``) plus the tracked nucleus seeds, and runs
7
+ the unary-only geodesic-Voronoi pipeline from
8
+ :func:`itasc.segmentation.segment_cells_divergence`:
9
+
10
+ 1. Map cleanup (foreground + contours) — local-mean residual + threshold.
11
+ 2. Temporal contour smoothing (full run only).
12
+ 3. Foreground mask.
13
+ 4. Unary-only segmentation → ``3_cell/tracked_labels.tif``.
14
+
15
+ Live preview recomputes the current frame off the GUI thread on any param edit
16
+ or time scrub. A row of *Compute* checkboxes — Foreground, Contours, Cost,
17
+ Labels — selects which layer groups the preview computes (not merely shows):
18
+ only the ticked groups are calculated, so leaving the slow geodesic *Labels* box
19
+ off (the default) keeps tuning responsive while the cheaper foreground / contour
20
+ / cost intermediates still update. Each computed frame is cached in memory keyed
21
+ on the params, so scrubbing back to an already-computed frame repaints instantly;
22
+ any param edit clears the cache.
23
+
24
+ Temporal smoothing (``memory_tau > 0``) needs the whole movie, so the preview
25
+ computes the cleaned-and-smoothed contour stack once, caches it keyed on the
26
+ contour/temporal knobs, and slices the current frame from it for the cost field
27
+ and labels — reusing the cache across frame scrubs and edits to non-smoothing
28
+ knobs. The previewed frame then matches the full run exactly. The full run
29
+ processes all frames in memory (deriving the foreground fill mask in-process),
30
+ runs the geodesic walk, and persists only the labels. Correction is delegated to
31
+ :class:`CellCorrectionWidget`, unchanged.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import dataclasses
36
+ import logging
37
+ import os
38
+ from pathlib import Path
39
+
40
+ import napari
41
+ import numpy as np
42
+ import tifffile
43
+ from napari.qt.threading import thread_worker
44
+ from qtpy.QtCore import Qt, QTimer, Signal
45
+ from qtpy.QtWidgets import (
46
+ QCheckBox,
47
+ QHBoxLayout,
48
+ QLabel,
49
+ QLineEdit,
50
+ QProgressBar,
51
+ QSizePolicy,
52
+ QVBoxLayout,
53
+ QWidget,
54
+ )
55
+
56
+ from itasc.correction.labels import best_overlapping_label
57
+ from itasc.napari._widget_helpers import (
58
+ dslider as _dslider,
59
+ heading as _heading,
60
+ islider as _islider,
61
+ tool_btn as _tool_btn,
62
+ )
63
+ from itasc.napari._standalone_paths import StandalonePathsMixin
64
+ from itasc.napari.correction.cell_correction_widget import CellCorrectionWidget
65
+ from itasc.napari.ui_gate import ControlClass, UiGate
66
+ from itasc.napari.ui_style import (
67
+ add_section_header,
68
+ add_section_pair_row,
69
+ section_grid,
70
+ stage_header_action_button,
71
+ stage_header_label,
72
+ status_label,
73
+ )
74
+ from itasc.napari.widgets import (
75
+ CollapsibleSection,
76
+ PipelineFilesWidget,
77
+ make_pipeline_files_header,
78
+ )
79
+ from itasc.segmentation import CancelledError
80
+ from itasc.segmentation.cell_divergence_segmentation import (
81
+ CellDivergenceParams,
82
+ clean_and_smooth_contours,
83
+ compute_cell_foreground,
84
+ segment_cells_divergence,
85
+ )
86
+ from itasc.segmentation.cell_label_icm import commit_labels
87
+ from itasc.core.commit import commit_state, promote_labels
88
+ from itasc.napari._paths import NucleusArtifactPaths
89
+
90
+ logger = logging.getLogger(__name__)
91
+
92
+ # ── Preview layer names (pipeline order) ──────────────────────────────────────
93
+ _PREFIX = "[Cell]"
94
+ _FG_RAW_LAYER = f"{_PREFIX} foreground (sigmoid)"
95
+ _FG_CLEAN_LAYER = f"{_PREFIX} foreground cleaned"
96
+ _FG_MASK_LAYER = f"{_PREFIX} foreground mask"
97
+ _CT_RAW_LAYER = f"{_PREFIX} contours raw"
98
+ _CT_CLEAN_LAYER = f"{_PREFIX} contours cleaned"
99
+ _COST_LAYER = f"{_PREFIX} weighted cost field"
100
+ _LABELS_LAYER = f"{_PREFIX} cell labels"
101
+
102
+ # Tracked pipeline layer the correction widget syncs against.
103
+ _TRACKED_CELL_LAYER = "Tracked: Cell"
104
+
105
+ # ── Compute groups ────────────────────────────────────────────────────────────
106
+ # Each checkbox owns one group of preview layers and gates whether that group is
107
+ # *computed at all* (not merely shown). The live preview computes only the
108
+ # groups whose box is ticked and caches the per-frame result so scrubbing back
109
+ # to a frame repaints instantly.
110
+ _FG_GROUP_LAYERS = (_FG_RAW_LAYER, _FG_CLEAN_LAYER, _FG_MASK_LAYER)
111
+ _CONTOUR_GROUP_LAYERS = (_CT_RAW_LAYER, _CT_CLEAN_LAYER)
112
+ _COST_GROUP_LAYERS = (_COST_LAYER,)
113
+ _LABELS_GROUP_LAYERS = (_LABELS_LAYER,)
114
+ _ALL_PREVIEW_LAYERS = (
115
+ _FG_GROUP_LAYERS + _CONTOUR_GROUP_LAYERS
116
+ + _COST_GROUP_LAYERS + _LABELS_GROUP_LAYERS
117
+ )
118
+
119
+ # Colormap per image layer; the rest are napari Labels layers.
120
+ _IMAGE_COLORMAPS = {
121
+ _FG_RAW_LAYER: "gray",
122
+ _FG_CLEAN_LAYER: "gray",
123
+ _CT_RAW_LAYER: "magma",
124
+ _CT_CLEAN_LAYER: "magma",
125
+ _COST_LAYER: "turbo",
126
+ }
127
+ _LABELS_KIND = {_FG_MASK_LAYER, _LABELS_LAYER}
128
+
129
+ # How much a single-frame compute produces, in increasing cost. The desired
130
+ # level is the max over the ticked checkboxes; a cached frame is reused only when
131
+ # its stored level already covers the desired one.
132
+ _LEVEL_NONE = 0
133
+ _LEVEL_FG = 1 # foreground raw/clean/mask — compute_cell_foreground (cheap)
134
+ _LEVEL_SEG = 2 # + contours raw/clean + cost field (with_labels=False)
135
+ _LEVEL_LABELS = 3 # + the geodesic cell labels (with_labels=True, slow)
136
+
137
+
138
+ def _make_status() -> QLabel:
139
+ lbl = QLabel("")
140
+ lbl.setWordWrap(True)
141
+ lbl.setVisible(False)
142
+ status_label(lbl)
143
+ return lbl
144
+
145
+
146
+ def _make_progress() -> QProgressBar:
147
+ bar = QProgressBar()
148
+ bar.setRange(0, 100)
149
+ bar.setValue(0)
150
+ bar.setTextVisible(True)
151
+ bar.setVisible(False)
152
+ return bar
153
+
154
+
155
+ # Canonical input/output paths (kept as literals so the file-contract test and
156
+ # the PipelineFilesWidget stay in sync):
157
+ # 1_cellpose/cell_contours.tif, 1_cellpose/cell_foreground.tif,
158
+ # nucleus_labels.tif (committed) → 3_cell/tracked_labels.tif
159
+
160
+
161
+ class CellWorkflowWidget(StandalonePathsMixin, QWidget):
162
+ """Simplified divergence-based cell segmentation widget."""
163
+
164
+ _run_progress = Signal(str)
165
+
166
+ def __init__(
167
+ self,
168
+ viewer: napari.Viewer,
169
+ parent: QWidget | None = None,
170
+ gate: UiGate | None = None,
171
+ standalone: bool = False,
172
+ ) -> None:
173
+ super().__init__(parent)
174
+ self.viewer = viewer
175
+ #: App-wide UI gate; a private one is created for standalone use.
176
+ self.gate = gate if gate is not None else UiGate(self)
177
+ #: When True the piece runs on its own, with its own input/output path
178
+ #: pickers; the orchestrator drives the embedded widget via refresh().
179
+ self._standalone = standalone
180
+ self._pos_dir: Path | None = None
181
+ #: Standalone explicit inputs/output (mirrors the orchestrator's staged
182
+ #: 1_cellpose/2_nucleus/3_cell layout with arbitrary file locations).
183
+ self._sa_foreground: Path | None = None
184
+ self._sa_contours: Path | None = None
185
+ self._sa_nucleus: Path | None = None
186
+ self._sa_output_dir: Path | None = None
187
+
188
+ # Live preview state — a compute is in flight (None when idle); rapid
189
+ # edits while one runs set _preview_pending so exactly one fresh pass
190
+ # fires when it returns.
191
+ self._preview_active = False
192
+ self._preview_worker = None
193
+ self._preview_pending = False
194
+ # Image preview layers whose contrast has not yet been auto-set. We seed
195
+ # contrast once, from the first real frame a freshly created layer
196
+ # receives, then leave it alone so a scrub to another frame (or a manual
197
+ # contrast tweak) is not clobbered on every refresh.
198
+ self._image_needs_autocontrast: set[str] = set()
199
+ self._preview_timer = QTimer(self)
200
+ self._preview_timer.setSingleShot(True)
201
+ self._preview_timer.setInterval(150)
202
+ self._preview_timer.timeout.connect(self._refresh_preview)
203
+
204
+ # Per-frame result cache: frame t → (level, result). Holds whatever the
205
+ # last compute produced for each visited frame so scrubbing back to an
206
+ # already-computed frame repaints instantly. Keyed (``_frame_cache_key``)
207
+ # on the full param tuple — any param edit clears it.
208
+ self._frame_cache: dict[int, tuple[int, object]] = {}
209
+ self._frame_cache_key: tuple | None = None
210
+
211
+ # Cached cleaned+smoothed contour stack for the preview when
212
+ # ``memory_tau > 0`` (the smoothing needs the whole movie). Keyed on the
213
+ # contour/temporal knobs so it survives frame scrubs and edits to the
214
+ # non-smoothing knobs (fg_*, balance, feature_strength) and only
215
+ # recomputes when a
216
+ # knob that actually changes the smoothing is touched. Dropped on
217
+ # deactivate to free the (T, Y, X) array.
218
+ self._smoothed_stack = None
219
+ self._smoothed_key = None
220
+
221
+ # Full-run worker state.
222
+ self._run_worker = None
223
+ self._running = False
224
+
225
+ self._setup_ui()
226
+ self._connect_signals()
227
+ self._register_gate_controls()
228
+ self._run_progress.connect(self._set_status)
229
+
230
+ if self._standalone:
231
+ self._load_standalone_settings()
232
+
233
+ # ================================================================
234
+ # UI
235
+ # ================================================================
236
+ def _setup_ui(self) -> None:
237
+ root = QVBoxLayout(self)
238
+ root.setContentsMargins(2, 2, 2, 2)
239
+ root.setSpacing(6)
240
+ # Embedded, the widget lives in main_widget's AlignTop scroll layout and
241
+ # a Maximum policy keeps each section compact. Standalone, napari docks it
242
+ # directly, so fill the dock (Preferred) and pin content to the top with a
243
+ # trailing stretch (mirrors NucleusWorkflowWidget).
244
+ if self._standalone:
245
+ root.setAlignment(Qt.AlignTop)
246
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
247
+ else:
248
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
249
+
250
+ # ── Standalone input/output path pickers ──────────────────────
251
+ # Only shown when the piece runs on its own; the orchestrator drives the
252
+ # workspace through refresh() instead. The cell segmentation consumes the
253
+ # Cellpose-produced foreground/contours plus the tracked nucleus seeds.
254
+ self._paths_container = QWidget()
255
+ paths_col = QVBoxLayout(self._paths_container)
256
+ paths_col.setContentsMargins(0, 0, 0, 0)
257
+ paths_col.setSpacing(2)
258
+ apply = self._apply_standalone_paths
259
+ self._foreground_edit = self._add_path_row(
260
+ paths_col, "Foreground:", "Cell foreground .tif",
261
+ lambda: self._browse_file_into(self._foreground_edit, "Select cell foreground image", apply),
262
+ apply,
263
+ )
264
+ self._contours_edit = self._add_path_row(
265
+ paths_col, "Contours:", "Cell contours .tif",
266
+ lambda: self._browse_file_into(self._contours_edit, "Select cell contours image", apply),
267
+ apply,
268
+ )
269
+ self._nucleus_edit = self._add_path_row(
270
+ paths_col, "Nucleus:", "Tracked nucleus labels .tif",
271
+ lambda: self._browse_file_into(self._nucleus_edit, "Select tracked nucleus labels", apply),
272
+ apply,
273
+ )
274
+ self._output_dir_edit = self._add_path_row(
275
+ paths_col, "Output dir:", "Folder for 3_cell/tracked_labels.tif",
276
+ lambda: self._browse_dir_into(self._output_dir_edit, "Select output directory", apply),
277
+ apply,
278
+ )
279
+ root.addWidget(self._paths_container)
280
+ self._paths_container.setVisible(self._standalone)
281
+
282
+ # ── Pipeline files ────────────────────────────────────────────
283
+ self._files_widget = PipelineFilesWidget(
284
+ [
285
+ ("Inputs", [
286
+ ("1_cellpose/cell_contours.tif", "Cell contours (divergence)"),
287
+ ("1_cellpose/cell_foreground.tif", "Cell foreground (sigmoid)"),
288
+ ("nucleus_labels.tif", "Nucleus labels (committed)"),
289
+ ]),
290
+ ("Output", [
291
+ ("3_cell/tracked_labels.tif", "Cell tracked labels"),
292
+ ]),
293
+ ],
294
+ viewer=self.viewer,
295
+ )
296
+ self._pipeline_files_section = CollapsibleSection(
297
+ "Pipeline Files", self._files_widget, expanded=False,
298
+ )
299
+ (
300
+ self.pipeline_files_header,
301
+ self.pipeline_files_header_lbl,
302
+ self.pipeline_files_toggle_btn,
303
+ ) = make_pipeline_files_header(
304
+ self._pipeline_files_section, stage_key="cell", parent=self,
305
+ )
306
+ root.addWidget(self.pipeline_files_header)
307
+ root.addWidget(self._pipeline_files_section)
308
+ # The staged-files panel lists 1_cellpose/2_nucleus/3_cell paths that
309
+ # don't exist in the standalone flat layout; the path pickers cover it.
310
+ self.pipeline_files_header.setVisible(not self._standalone)
311
+ self._pipeline_files_section.setVisible(not self._standalone)
312
+
313
+ # ── Segmentation stage row: ⚙ params / ◉ live preview / ▶ run ──
314
+ self.params_btn = _tool_btn(
315
+ "⚙", "Toggle segmentation parameters.", checkable=True
316
+ )
317
+ self.active_btn = _tool_btn(
318
+ "◉", "Live preview (tune against the current frame).", checkable=True
319
+ )
320
+ self.run_btn = _tool_btn(
321
+ "▶", "Run the full pipeline over all frames and write tracked_labels.tif."
322
+ )
323
+ # Two Finalize buttons promoting the working tracked labels to
324
+ # cell_labels.tif: one at the tail of the correction toolbar (visible in
325
+ # active correction mode) and one beside the correction on/off toggle
326
+ # (always visible). Both are placed by the correction widget below; the
327
+ # handler stays here. Finalize is not a segmentation-stage action, so it
328
+ # is out of the stage row and skips the stage-pill styling loop (the
329
+ # header button is styled by the header builder, the toolbar one plain).
330
+ _finalize_tip = (
331
+ "Finalize: promote tracked labels to cell_labels.tif "
332
+ "(final, downstream-stable output)."
333
+ )
334
+ self.finalize_btn = _tool_btn("✔", _finalize_tip)
335
+ self.finalize_header_btn = _tool_btn("✔", _finalize_tip)
336
+ for button in (self.finalize_btn, self.finalize_header_btn):
337
+ button.clicked.connect(self._on_finalize)
338
+ for button in (self.params_btn, self.active_btn, self.run_btn):
339
+ stage_header_action_button(button, "cell")
340
+
341
+ self.params_section = self._build_params_section()
342
+ self.params_section.set_header_visible(False)
343
+ self.params_section.collapse()
344
+ self.params_btn.toggled.connect(
345
+ lambda checked: self.params_section._toggle.setChecked(checked)
346
+ )
347
+
348
+ root.addLayout(self._stage_row(
349
+ self._stage_label("Segmentation"),
350
+ self.params_btn, self.active_btn, self.run_btn,
351
+ ))
352
+ root.addWidget(self.params_section)
353
+
354
+ self.pipeline_status_lbl = _make_status()
355
+ root.addWidget(self.pipeline_status_lbl)
356
+ self.pipeline_progress_bar = _make_progress()
357
+ root.addWidget(self.pipeline_progress_bar)
358
+
359
+ # ── Correction (delegated, unchanged) ─────────────────────────
360
+ self.cell_correction_widget = CellCorrectionWidget(
361
+ self.viewer,
362
+ pos_dir_provider=lambda: self._pos_dir,
363
+ files_widget_refresh_callback=lambda pd: self._files_widget.refresh(pd),
364
+ finalize_btn=self.finalize_btn,
365
+ finalize_header_btn=self.finalize_header_btn,
366
+ )
367
+ self._install_correction_aliases()
368
+ self.cell_correction_widget.hide()
369
+ root.addWidget(self.correction_header)
370
+ root.addWidget(self.correction_mode_section)
371
+
372
+ def _build_compute_row(self) -> QWidget:
373
+ """The "Compute:" checkbox row — one box per preview layer group.
374
+
375
+ Each box gates whether its group is *computed* by the live preview (not
376
+ just shown). ``_compute_groups`` ties each box to its layers and the
377
+ single-frame compute level it requires; Labels (the slow geodesic walk)
378
+ is off by default.
379
+ """
380
+ self.fg_check = QCheckBox("Foreground")
381
+ self.fg_check.setChecked(True)
382
+ self.fg_check.setToolTip(
383
+ "Compute + show the foreground layers (raw, cleaned, fill mask)."
384
+ )
385
+ self.contour_check = QCheckBox("Contours")
386
+ self.contour_check.setChecked(True)
387
+ self.contour_check.setToolTip(
388
+ "Compute + show the contour layers (raw, cleaned)."
389
+ )
390
+ self.cost_check = QCheckBox("Cost")
391
+ self.cost_check.setChecked(True)
392
+ self.cost_check.setToolTip("Compute + show the weighted cost field.")
393
+ self.labels_check = QCheckBox("Labels")
394
+ self.labels_check.setChecked(False)
395
+ self.labels_check.setToolTip(
396
+ "Compute the cell labels (the slow geodesic step) for the current "
397
+ "frame."
398
+ )
399
+ # (checkbox, owned layers, single-frame compute level it requires).
400
+ self._compute_groups = (
401
+ (self.fg_check, _FG_GROUP_LAYERS, _LEVEL_FG),
402
+ (self.contour_check, _CONTOUR_GROUP_LAYERS, _LEVEL_SEG),
403
+ (self.cost_check, _COST_GROUP_LAYERS, _LEVEL_SEG),
404
+ (self.labels_check, _LABELS_GROUP_LAYERS, _LEVEL_LABELS),
405
+ )
406
+
407
+ row = QWidget()
408
+ lay = QHBoxLayout(row)
409
+ lay.setContentsMargins(8, 0, 4, 0)
410
+ lay.setSpacing(8)
411
+ lay.addWidget(QLabel("Compute:"))
412
+ for check, _layers, _level in self._compute_groups:
413
+ lay.addWidget(check)
414
+ lay.addStretch(1)
415
+ return row
416
+
417
+ def _build_params_section(self) -> CollapsibleSection:
418
+ """One flat panel: the Compute checkboxes on top, then every knob.
419
+
420
+ The Compute row gates which layer groups the live preview computes; the
421
+ grid below holds the cleanup / smoothing / segmentation knobs.
422
+ """
423
+ body = QWidget(self)
424
+ outer = QVBoxLayout(body)
425
+ outer.setContentsMargins(0, 0, 0, 0)
426
+ outer.setSpacing(4)
427
+ outer.addWidget(self._build_compute_row())
428
+
429
+ grid_widget = QWidget()
430
+ grid = section_grid()
431
+ grid.setContentsMargins(8, 4, 4, 4)
432
+ grid_widget.setLayout(grid)
433
+ outer.addWidget(grid_widget)
434
+
435
+ max_workers = max(1, os.cpu_count() or 1)
436
+
437
+ # ── Map cleanup: foreground ──────────────────────────────────
438
+ self.fg_strength_spin = _dslider(
439
+ 0, 1, 0.0, 0.05, 2,
440
+ "0 = raw sigmoid, 1 = full local-mean background subtraction.",
441
+ )
442
+ self.fg_threshold_spin = _dslider(
443
+ 0, 1, 0.1, 0.001, 3,
444
+ "Cleaned-foreground cutoff → fill mask (sigmoid scale).",
445
+ )
446
+ self.fg_window_spin = _islider(
447
+ 3, 201, 51, tooltip="Local-mean window for foreground residual (odd px)."
448
+ )
449
+ # ── Map cleanup: contours ────────────────────────────────────
450
+ self.contour_strength_spin = _dslider(
451
+ 0, 1, 1.0, 0.05, 2, "0 = raw, 1 = full local-mean subtraction."
452
+ )
453
+ self.contour_threshold_spin = _dslider(
454
+ 0, 1, 0.0, 0.001, 3, "Noise floor on normalized contour; below → 0."
455
+ )
456
+ self.contour_norm_pct_spin = _dslider(
457
+ 90, 100, 99.0, 0.5, 1,
458
+ "Percentile mapped to 1.0 in the contour [0,1] normalize.",
459
+ )
460
+ self.contour_window_spin = _islider(
461
+ 3, 201, 51, tooltip="Local-mean window for contour residual (odd px)."
462
+ )
463
+ # ── Temporal smoothing ───────────────────────────────────────
464
+ self.memory_tau_spin = _dslider(
465
+ 0, 1, 0.0, 0.01, 3,
466
+ "Temporal EMA crossover (~the contour value you call weak). 0 = off.",
467
+ )
468
+ self.memory_floor_spin = _dslider(
469
+ 0.001, 0.5, 0.01, 0.001, 3,
470
+ "Min per-frame alpha; ghost half-life (~69 frames @ 0.01).",
471
+ )
472
+ # ── Segmentation ─────────────────────────────────────────────
473
+ self.balance_spin = _dslider(
474
+ 0.0, 1.0, 0.98, 0.01, 2,
475
+ "Contour↔foreground split r: 1 = pure contour, 0 = pure "
476
+ "foreground. cost = 1 + s·[r·contour + (1−r)·(1−fg)].",
477
+ )
478
+ self.feature_strength_spin = _dslider(
479
+ 0, 1000, 100.0, 1.0, 1,
480
+ "Feature strength s: how hard contour/foreground bend the walk vs "
481
+ "a plain distance Voronoi. 0 = pure distance.",
482
+ )
483
+ self.n_workers_spin = _islider(
484
+ 1, max_workers, min(4, max_workers),
485
+ tooltip="Parallel workers for geodesic computation (compute only).",
486
+ )
487
+
488
+ row = 0
489
+ add_section_header(grid, row, _heading("Map cleanup")); row += 1
490
+ add_section_header(grid, row, _heading("Foreground")); row += 1
491
+ add_section_pair_row(
492
+ grid, row,
493
+ "Strength:", self.fg_strength_spin,
494
+ "Threshold:", self.fg_threshold_spin,
495
+ ); row += 1
496
+ add_section_pair_row(grid, row, "Window:", self.fg_window_spin); row += 1
497
+ add_section_header(grid, row, _heading("Contours")); row += 1
498
+ add_section_pair_row(
499
+ grid, row,
500
+ "Strength:", self.contour_strength_spin,
501
+ "Floor:", self.contour_threshold_spin,
502
+ ); row += 1
503
+ add_section_pair_row(
504
+ grid, row,
505
+ "Norm %:", self.contour_norm_pct_spin,
506
+ "Window:", self.contour_window_spin,
507
+ ); row += 1
508
+ add_section_header(grid, row, _heading("Temporal smoothing")); row += 1
509
+ add_section_pair_row(
510
+ grid, row,
511
+ "Memory τ:", self.memory_tau_spin,
512
+ "Memory floor:", self.memory_floor_spin,
513
+ ); row += 1
514
+ add_section_header(grid, row, _heading("Segmentation")); row += 1
515
+ add_section_pair_row(
516
+ grid, row,
517
+ "Balance (r):", self.balance_spin,
518
+ "Strength (s):", self.feature_strength_spin,
519
+ ); row += 1
520
+ add_section_pair_row(grid, row, "Workers:", self.n_workers_spin); row += 1
521
+
522
+ return CollapsibleSection("Segmentation Parameters", body, expanded=False)
523
+
524
+ @staticmethod
525
+ def _stage_label(text: str) -> QLabel:
526
+ return stage_header_label(QLabel(text), "cell")
527
+
528
+ @staticmethod
529
+ def _stage_row(label: QLabel, *trailing: QWidget) -> QHBoxLayout:
530
+ row = QHBoxLayout()
531
+ row.setContentsMargins(0, 0, 0, 0)
532
+ row.setSpacing(4)
533
+ row.addWidget(label)
534
+ for widget in trailing:
535
+ row.addWidget(widget)
536
+ row.addStretch(1)
537
+ return row
538
+
539
+ def _install_correction_aliases(self) -> None:
540
+ """Install compatibility aliases for controls owned by CellCorrectionWidget."""
541
+ c = self.cell_correction_widget
542
+ self.correction_header = c.header
543
+ self.correction_header_lbl = c.header_lbl
544
+ self.correction_shortcuts_btn = c.shortcuts_btn
545
+ self.correction_params_btn = c.params_btn
546
+ self.correction_active_btn = c.active_btn
547
+ self.correction_mode_section = c.section
548
+ self.correction_widget = c.correction_widget
549
+ self.correction_status_lbl = c.correction_status_lbl
550
+ self.correction_shortcuts_section = c.correction_shortcuts_section
551
+ self.save_labels_btn = c.save_labels_btn
552
+ self.fill_holes_btn = c.fill_holes_btn
553
+ self.cleanup_btn = c.cleanup_btn
554
+ self.correction_scope_combo = c.correction_scope_combo
555
+ self.hole_radius_spin = c.hole_radius_spin
556
+
557
+ # ================================================================
558
+ # Signals
559
+ # ================================================================
560
+ def _connect_signals(self) -> None:
561
+ for spin in (
562
+ self.fg_strength_spin, self.fg_threshold_spin, self.memory_tau_spin,
563
+ self.balance_spin, self.feature_strength_spin, self.fg_window_spin,
564
+ self.contour_window_spin, self.contour_strength_spin,
565
+ self.contour_threshold_spin, self.contour_norm_pct_spin,
566
+ self.memory_floor_spin, self.n_workers_spin,
567
+ ):
568
+ spin.valueChanged.connect(self._on_param_changed)
569
+ self.active_btn.toggled.connect(self._on_activate)
570
+ self.run_btn.clicked.connect(self._on_run_clicked)
571
+ for check, layers, _level in self._compute_groups:
572
+ check.toggled.connect(
573
+ lambda checked, lyrs=layers: self._on_compute_toggled(lyrs, checked)
574
+ )
575
+ if hasattr(self.viewer, "dims") and hasattr(self.viewer.dims, "events"):
576
+ try:
577
+ self.viewer.dims.events.current_step.connect(self._on_time_changed)
578
+ except Exception:
579
+ pass
580
+
581
+ def _register_gate_controls(self) -> None:
582
+ """Register this section's controls with the app-wide UI gate.
583
+
584
+ Live preview and cell correction are mutually-exclusive viewer owners
585
+ (across sections too, since the gate is shared). The full-run button
586
+ rebuilds the data those owners view, so it is blocked while either is
587
+ active.
588
+ """
589
+ g = self.gate
590
+ g.register_owner(
591
+ "cell_preview",
592
+ "cell live preview",
593
+ exit_fn=lambda: self.active_btn.setChecked(False),
594
+ )
595
+ g.register_owner(
596
+ "correction:cell",
597
+ "cell correction mode",
598
+ exit_fn=lambda: self.correction_active_btn.setChecked(False),
599
+ )
600
+ g.register(
601
+ self.active_btn,
602
+ ControlClass.VIEWER_OWNER,
603
+ owner_token="cell_preview",
604
+ when=lambda: not self._running,
605
+ )
606
+ g.register(
607
+ self.correction_active_btn,
608
+ ControlClass.VIEWER_OWNER,
609
+ owner_token="correction:cell",
610
+ when=lambda: not self._running,
611
+ )
612
+ g.register(self.run_btn, ControlClass.RUN_VIEWER)
613
+ self.correction_active_btn.toggled.connect(self._on_cell_correction_gate)
614
+ g.recompute()
615
+
616
+ def _on_cell_correction_gate(self, checked: bool) -> None:
617
+ if checked:
618
+ self.gate.claim_viewer("correction:cell")
619
+ else:
620
+ self.gate.release_viewer("correction:cell")
621
+
622
+ # ================================================================
623
+ # Params / paths
624
+ # ================================================================
625
+ def _params(self) -> CellDivergenceParams:
626
+ return CellDivergenceParams(
627
+ fg_window=int(self.fg_window_spin.value()),
628
+ fg_strength=float(self.fg_strength_spin.value()),
629
+ fg_threshold=float(self.fg_threshold_spin.value()),
630
+ contour_window=int(self.contour_window_spin.value()),
631
+ contour_strength=float(self.contour_strength_spin.value()),
632
+ contour_threshold=float(self.contour_threshold_spin.value()),
633
+ contour_norm_pct=float(self.contour_norm_pct_spin.value()),
634
+ memory_tau=float(self.memory_tau_spin.value()),
635
+ memory_floor=float(self.memory_floor_spin.value()),
636
+ balance=float(self.balance_spin.value()),
637
+ feature_strength=float(self.feature_strength_spin.value()),
638
+ n_workers=int(self.n_workers_spin.value()),
639
+ )
640
+
641
+ def _p(self, *parts: str) -> Path | None:
642
+ return self._pos_dir.joinpath(*parts) if self._pos_dir else None
643
+
644
+ def _contours_path(self):
645
+ return self._sa_contours if self._standalone else self._p("1_cellpose", "cell_contours.tif")
646
+
647
+ def _foreground_path(self):
648
+ return self._sa_foreground if self._standalone else self._p("1_cellpose", "cell_foreground.tif")
649
+
650
+ def _nuc_path(self):
651
+ # Staged: consume the *committed* nucleus labels in the position base
652
+ # folder, not the pre-commit 2_nucleus working labels (the commit gate
653
+ # means cell segmentation runs off the validated nucleus tracking).
654
+ return self._sa_nucleus if self._standalone else self._p("nucleus_labels.tif")
655
+
656
+ def _nuc_missing_msg(self) -> str:
657
+ if self._standalone:
658
+ return "Nucleus labels not found."
659
+ # Staged use reads the committed base-folder labels, so the fix is to
660
+ # commit the nucleus stage rather than just re-run its tracking.
661
+ return "Committed nucleus_labels.tif not found — commit the nucleus stage first."
662
+
663
+ def _output_path(self):
664
+ if self._standalone:
665
+ return self._sa_output_dir / "3_cell" / "tracked_labels.tif" if self._sa_output_dir else None
666
+ return self._p("3_cell", "tracked_labels.tif")
667
+
668
+ def _maps_present(self) -> bool:
669
+ ct, fg = self._contours_path(), self._foreground_path()
670
+ return ct is not None and ct.exists() and fg is not None and fg.exists()
671
+
672
+ # ================================================================
673
+ # Public API (consumed by the main widget)
674
+ # ================================================================
675
+ def refresh(self, pos_dir: Path | None) -> None:
676
+ self._pos_dir = pos_dir
677
+ self._files_widget.refresh(pos_dir)
678
+ if pos_dir is None:
679
+ self.correction_widget.deactivate()
680
+ self._refresh_finalize_btn()
681
+
682
+ # ── Commit contract: promote working labels → cell_labels.tif ──────
683
+ def _committed_labels_path(self) -> Path | None:
684
+ if self._standalone:
685
+ return self._sa_output_dir / "cell_labels.tif" if self._sa_output_dir else None
686
+ return NucleusArtifactPaths(self._pos_dir).cell_labels if self._pos_dir else None
687
+
688
+ def _on_finalize(self) -> None:
689
+ working = self._output_path()
690
+ committed = self._committed_labels_path()
691
+ if working is None or committed is None or not working.exists():
692
+ self._set_status("No tracked labels to commit — run the pipeline first.")
693
+ return
694
+ try:
695
+ promote_labels(working, committed)
696
+ except (FileNotFoundError, ValueError) as exc:
697
+ self._set_status(f"Commit failed: {exc}")
698
+ return
699
+ self._set_status(f"Committed cell labels → {committed.name}.")
700
+ self._refresh_finalize_btn()
701
+ # Surface the committed file (and let the host repaint the stage dots).
702
+ self._files_widget.refresh(self._pos_dir)
703
+
704
+ def _finalize_btns(self) -> tuple:
705
+ return tuple(
706
+ b
707
+ for b in (
708
+ getattr(self, "finalize_btn", None),
709
+ getattr(self, "finalize_header_btn", None),
710
+ )
711
+ if b is not None
712
+ )
713
+
714
+ def _refresh_finalize_btn(self) -> None:
715
+ buttons = self._finalize_btns()
716
+ if not buttons:
717
+ return
718
+ working = self._output_path()
719
+ committed = self._committed_labels_path()
720
+ if working is None or committed is None:
721
+ for button in buttons:
722
+ button.setEnabled(False)
723
+ return
724
+ state = commit_state(working, committed)
725
+ tooltip = (
726
+ "Working labels are newer than the committed cell_labels.tif — "
727
+ "re-commit to update."
728
+ if state == "stale"
729
+ else "Finalize: promote tracked labels to cell_labels.tif (final, downstream-stable output)."
730
+ )
731
+ for button in buttons:
732
+ button.setEnabled(state != "missing")
733
+ button.setToolTip(tooltip)
734
+
735
+ # ================================================================
736
+ # Standalone input/output pickers (only built/used when standalone)
737
+ # ================================================================
738
+ # Row building, browse handlers, and QSettings persistence come from
739
+ # StandalonePathsMixin; this widget only declares its fields and the
740
+ # piece-specific step that pushes them into the workspace.
741
+ _SETTINGS_APP = "itasc_segmentation"
742
+
743
+ def _standalone_fields(self) -> dict[str, QLineEdit]:
744
+ return {
745
+ "foreground": self._foreground_edit,
746
+ "contours": self._contours_edit,
747
+ "nucleus": self._nucleus_edit,
748
+ "output_dir": self._output_dir_edit,
749
+ }
750
+
751
+ def _apply_standalone_paths(self) -> None:
752
+ """Push the picker fields into the standalone inputs and re-wire paths.
753
+
754
+ ``_pos_dir`` is set to the output dir so the ``_pos_dir is None`` guards
755
+ (run, preview, correction) pass once an output folder is chosen; the
756
+ input/output path methods resolve to the explicit files in standalone.
757
+ """
758
+ def _val(edit: QLineEdit) -> Path | None:
759
+ text = edit.text().strip()
760
+ return Path(text) if text else None
761
+
762
+ self._sa_foreground = _val(self._foreground_edit)
763
+ self._sa_contours = _val(self._contours_edit)
764
+ self._sa_nucleus = _val(self._nucleus_edit)
765
+ self._sa_output_dir = _val(self._output_dir_edit)
766
+ self._save_standalone_settings()
767
+ self.refresh(self._sa_output_dir)
768
+
769
+ def _load_standalone_settings(self) -> None:
770
+ self._load_path_settings(self._SETTINGS_APP, self._standalone_fields())
771
+ self._apply_standalone_paths()
772
+
773
+ def _save_standalone_settings(self) -> None:
774
+ self._save_path_settings(self._SETTINGS_APP, self._standalone_fields())
775
+
776
+ def get_state(self) -> dict:
777
+ return {
778
+ "cleanup": {
779
+ "fg_window": self.fg_window_spin.value(),
780
+ "fg_strength": self.fg_strength_spin.value(),
781
+ "fg_threshold": self.fg_threshold_spin.value(),
782
+ "contour_window": self.contour_window_spin.value(),
783
+ "contour_strength": self.contour_strength_spin.value(),
784
+ "contour_threshold": self.contour_threshold_spin.value(),
785
+ "contour_norm_pct": self.contour_norm_pct_spin.value(),
786
+ },
787
+ "temporal": {
788
+ "memory_tau": self.memory_tau_spin.value(),
789
+ "memory_floor": self.memory_floor_spin.value(),
790
+ },
791
+ "segmentation": {
792
+ "balance": self.balance_spin.value(),
793
+ "feature_strength": self.feature_strength_spin.value(),
794
+ "n_workers": self.n_workers_spin.value(),
795
+ },
796
+ "preview": {
797
+ "foreground": self.fg_check.isChecked(),
798
+ "contours": self.contour_check.isChecked(),
799
+ "cost": self.cost_check.isChecked(),
800
+ "labels": self.labels_check.isChecked(),
801
+ },
802
+ "correction": {
803
+ "hole_radius": self.hole_radius_spin.value(),
804
+ "scope": self.correction_scope_combo.currentText(),
805
+ },
806
+ }
807
+
808
+ def set_state(self, state: dict) -> None:
809
+ if not isinstance(state, dict):
810
+ return
811
+ _map = {
812
+ "cleanup": {
813
+ "fg_window": self.fg_window_spin,
814
+ "fg_strength": self.fg_strength_spin,
815
+ "fg_threshold": self.fg_threshold_spin,
816
+ "contour_window": self.contour_window_spin,
817
+ "contour_strength": self.contour_strength_spin,
818
+ "contour_threshold": self.contour_threshold_spin,
819
+ "contour_norm_pct": self.contour_norm_pct_spin,
820
+ },
821
+ "temporal": {
822
+ "memory_tau": self.memory_tau_spin,
823
+ "memory_floor": self.memory_floor_spin,
824
+ },
825
+ "segmentation": {
826
+ "balance": self.balance_spin,
827
+ "feature_strength": self.feature_strength_spin,
828
+ "n_workers": self.n_workers_spin,
829
+ },
830
+ "correction": {
831
+ "hole_radius": self.hole_radius_spin,
832
+ },
833
+ }
834
+ for group_key, widgets in _map.items():
835
+ group = state.get(group_key, {})
836
+ if not isinstance(group, dict):
837
+ continue
838
+ for key, spin in widgets.items():
839
+ if key in group:
840
+ spin.setValue(group[key])
841
+ preview = state.get("preview", {})
842
+ if isinstance(preview, dict):
843
+ for key, check in (
844
+ ("foreground", self.fg_check),
845
+ ("contours", self.contour_check),
846
+ ("cost", self.cost_check),
847
+ ("labels", self.labels_check),
848
+ ):
849
+ if key in preview:
850
+ check.setChecked(bool(preview[key]))
851
+ correction = state.get("correction", {})
852
+ if isinstance(correction, dict) and "scope" in correction:
853
+ idx = self.correction_scope_combo.findText(correction["scope"])
854
+ if idx >= 0:
855
+ self.correction_scope_combo.setCurrentIndex(idx)
856
+
857
+ def set_selection_callback(self, fn) -> None:
858
+ self.correction_widget.set_selection_callback(fn)
859
+
860
+ def select_matching_cell_label(
861
+ self, t: int, source_label: int,
862
+ *, source_labels: np.ndarray | None = None,
863
+ ) -> None:
864
+ if "[Correction] Cell Labels" in self.viewer.layers:
865
+ target_layer = self.viewer.layers["[Correction] Cell Labels"]
866
+ elif _TRACKED_CELL_LAYER in self.viewer.layers:
867
+ target_layer = self.viewer.layers[_TRACKED_CELL_LAYER]
868
+ else:
869
+ return
870
+ if source_labels is None:
871
+ if "Tracked: Nucleus" not in self.viewer.layers:
872
+ return
873
+ source_labels = np.asarray(self.viewer.layers["Tracked: Nucleus"].data)
874
+ target = np.asarray(target_layer.data)
875
+ matched = best_overlapping_label(target, source_labels, t, source_label)
876
+ self.correction_widget.select_label(t, matched, notify=False)
877
+
878
+ # ================================================================
879
+ # Status / layer helpers
880
+ # ================================================================
881
+ def _set_status(self, msg: str) -> None:
882
+ self.pipeline_status_lbl.setText(msg)
883
+ self.pipeline_status_lbl.setVisible(bool(msg))
884
+ if msg:
885
+ logger.info(msg)
886
+
887
+ def _show_layer(self, name, data, kwargs, adder):
888
+ if name in self.viewer.layers:
889
+ try:
890
+ self.viewer.layers[name].data = data
891
+ except Exception:
892
+ self.viewer.layers.remove(self.viewer.layers[name])
893
+ adder(data, name=name, **kwargs)
894
+ else:
895
+ adder(data, name=name, **kwargs)
896
+
897
+ def _current_t(self) -> int:
898
+ step = getattr(getattr(self.viewer, "dims", None), "current_step", (0,))
899
+ return int(step[0]) if len(step) >= 1 else 0
900
+
901
+ def _read_frame(self, path, t: int, dtype=np.float32) -> np.ndarray:
902
+ return np.asarray(tifffile.imread(str(path), key=t), dtype=dtype)
903
+
904
+ @staticmethod
905
+ def _shape_of(path):
906
+ """(T, Y, X) from a TIFF header (no pixel load); None if absent."""
907
+ if path is None or not path.exists():
908
+ return None
909
+ with tifffile.TiffFile(str(path)) as tf:
910
+ n_frames = len(tf.pages)
911
+ y, x = tf.pages[0].shape[-2], tf.pages[0].shape[-1]
912
+ return int(n_frames), int(y), int(x)
913
+
914
+ def _map_shape(self):
915
+ """(T, Y, X) from the contours TIFF header (no pixel load)."""
916
+ return self._shape_of(self._contours_path())
917
+
918
+ # ================================================================
919
+ # Live preview (single frame, all intermediates)
920
+ # ================================================================
921
+ def _on_param_changed(self, *_args) -> None:
922
+ if self._preview_active:
923
+ self._preview_timer.start()
924
+
925
+ def _on_time_changed(self, *_args) -> None:
926
+ if self._preview_active:
927
+ self._preview_timer.start()
928
+
929
+ def _on_activate(self, checked: bool) -> None:
930
+ self._preview_active = bool(checked)
931
+ # The live preview owns the viewer; the gate derives cross-section
932
+ # exclusivity from this claim.
933
+ if checked:
934
+ self.gate.claim_viewer("cell_preview")
935
+ self._refresh_preview()
936
+ else:
937
+ self.gate.release_viewer("cell_preview")
938
+ self._preview_pending = False
939
+ for name in _ALL_PREVIEW_LAYERS:
940
+ if name in self.viewer.layers:
941
+ self.viewer.layers.remove(name)
942
+ self._image_needs_autocontrast.clear()
943
+ # Free the resident caches; the preview session is over.
944
+ self._smoothed_stack = None
945
+ self._smoothed_key = None
946
+ self._frame_cache = {}
947
+ self._frame_cache_key = None
948
+ self._set_status("")
949
+
950
+ def _on_compute_toggled(self, group_layers, checked: bool) -> None:
951
+ """Handle a Compute-checkbox toggle: drop hidden groups, refresh shown.
952
+
953
+ Unticking a group removes its layers immediately. Then, while the
954
+ preview is active, a refresh creates/paints any newly-ticked group —
955
+ computing only if the cached frame doesn't already cover the new level,
956
+ so re-ticking a group that was just computed repaints instantly.
957
+ """
958
+ if not checked:
959
+ for name in group_layers:
960
+ if name in self.viewer.layers:
961
+ self.viewer.layers.remove(name)
962
+ self._image_needs_autocontrast.discard(name)
963
+ if self._preview_active:
964
+ self._refresh_preview()
965
+
966
+ @staticmethod
967
+ def _smooth_key(params: CellDivergenceParams) -> tuple:
968
+ """Cache key for the smoothed contour stack: the knobs that determine it.
969
+
970
+ The fg_*, balance and feature_strength knobs are deliberately absent —
971
+ they do not change the smoothed contours, so editing them reuses the
972
+ cached stack.
973
+ """
974
+ return (
975
+ params.contour_window, params.contour_strength,
976
+ params.contour_threshold, params.contour_norm_pct,
977
+ params.memory_tau, params.memory_floor,
978
+ )
979
+
980
+ def _cached_stack_for(self, params: CellDivergenceParams):
981
+ """Return the resident smoothed stack iff it matches ``params``, else None."""
982
+ if (
983
+ self._smoothed_stack is not None
984
+ and self._smoothed_key == self._smooth_key(params)
985
+ ):
986
+ return self._smoothed_stack
987
+ return None
988
+
989
+ def _desired_level(self) -> int:
990
+ """The single-frame compute level needed to fill every ticked group."""
991
+ level = _LEVEL_NONE
992
+ for check, _layers, need in self._compute_groups:
993
+ if check.isChecked():
994
+ level = max(level, need)
995
+ return level
996
+
997
+ def _checked_layer_names(self) -> list[str]:
998
+ """Layer names owned by the currently-ticked Compute checkboxes."""
999
+ names: list[str] = []
1000
+ for check, layers, _need in self._compute_groups:
1001
+ if check.isChecked():
1002
+ names.extend(layers)
1003
+ return names
1004
+
1005
+ def _preview_inputs(self):
1006
+ """Validate the inputs a single-frame compute needs.
1007
+
1008
+ Sets a status message and returns ``None`` on any failure; otherwise
1009
+ returns ``(shape, contours_path, foreground_path, nuc_path)``. The
1010
+ foreground fill mask is derived in-process (no disk prerequisite).
1011
+ """
1012
+ if not self._preview_active:
1013
+ return None
1014
+ if not self._maps_present():
1015
+ self._set_status("Cell divergence maps not found — run Divergence Maps first.")
1016
+ return None
1017
+ nuc_path = self._nuc_path()
1018
+ if nuc_path is None or not nuc_path.exists():
1019
+ self._set_status(self._nuc_missing_msg())
1020
+ return None
1021
+ shape = self._map_shape()
1022
+ if shape is None:
1023
+ self._set_status("Cell divergence maps not found — run Divergence Maps first.")
1024
+ return None
1025
+ return shape, self._contours_path(), self._foreground_path(), nuc_path
1026
+
1027
+ def _compute_frame(
1028
+ self, *, t, level, params, contours_path, foreground_path, nuc_path,
1029
+ cached_stack,
1030
+ ):
1031
+ """Worker-thread body: compute frame ``t`` up to ``level`` only.
1032
+
1033
+ ``_LEVEL_FG`` runs the cheap foreground-only path (no contour read);
1034
+ ``_LEVEL_SEG``/``_LEVEL_LABELS`` run the full divergence pipeline,
1035
+ deriving the fill mask in-process and (for the geodesic) the labels.
1036
+ When ``memory_tau > 0`` the cleaned + smoothed contour stack (whole
1037
+ movie) feeds the frame through ``contours_clean_override`` so the result
1038
+ matches the full run; the stack is taken from ``cached_stack`` when valid,
1039
+ otherwise computed here and returned for the caller to cache. Returns
1040
+ ``(t, result, new_stack)`` where ``new_stack`` is non-``None`` only when
1041
+ it was (re)computed.
1042
+ """
1043
+ fg = self._read_frame(foreground_path, t)[np.newaxis]
1044
+ nuc = self._read_frame(nuc_path, t, dtype=np.uint32)[np.newaxis]
1045
+
1046
+ if level <= _LEVEL_FG:
1047
+ result = compute_cell_foreground(fg, params, nuc, frame=0)
1048
+ return t, result, None
1049
+
1050
+ contour = self._read_frame(contours_path, t)[np.newaxis]
1051
+ new_stack = None
1052
+ override = None
1053
+ if params.memory_tau > 0.0:
1054
+ stack = cached_stack
1055
+ if stack is None:
1056
+ full = np.asarray(
1057
+ tifffile.imread(str(contours_path)), dtype=np.float32
1058
+ )
1059
+ stack = clean_and_smooth_contours(full, params)
1060
+ new_stack = stack
1061
+ override = stack[t]
1062
+
1063
+ result = segment_cells_divergence(
1064
+ contour, fg, nuc, params, frame=0,
1065
+ with_labels=level >= _LEVEL_LABELS,
1066
+ contours_clean_override=override,
1067
+ )
1068
+ return t, result, new_stack
1069
+
1070
+ def _refresh_preview(self):
1071
+ """Repaint the current frame's preview, computing only what's missing.
1072
+
1073
+ Clears the per-frame cache when the params changed, then either repaints
1074
+ the current frame from the cache (when it already covers every ticked
1075
+ group's level) or starts a worker for the shortfall. While a pass is in
1076
+ flight, further edits arm ``_preview_pending`` so one fresh pass (latest
1077
+ params/frame) fires when the current one returns. Returns the started
1078
+ worker, or ``None`` when nothing needed computing.
1079
+ """
1080
+ info = self._preview_inputs()
1081
+ if info is None:
1082
+ return None
1083
+ shape, contours_path, foreground_path, nuc_path = info
1084
+
1085
+ params = self._params()
1086
+ key = dataclasses.astuple(params)
1087
+ if key != self._frame_cache_key:
1088
+ self._frame_cache = {}
1089
+ self._frame_cache_key = key
1090
+ if params.memory_tau <= 0.0:
1091
+ # Smoothing off — release the resident whole-movie stack.
1092
+ self._smoothed_stack = None
1093
+ self._smoothed_key = None
1094
+
1095
+ level = self._desired_level()
1096
+ self._ensure_preview_layers(shape, self._checked_layer_names())
1097
+ if level == _LEVEL_NONE:
1098
+ self._set_status("")
1099
+ return None
1100
+
1101
+ n_frames = shape[0]
1102
+ t = max(0, min(self._current_t(), n_frames - 1))
1103
+
1104
+ cached = self._frame_cache.get(t)
1105
+ if cached is not None and cached[0] >= level:
1106
+ # Already computed to (at least) the needed level — instant repaint.
1107
+ self._apply_result(t, cached[1])
1108
+ self._set_status(self._frame_status(t, cached[1]))
1109
+ return None
1110
+
1111
+ if self._preview_worker is not None:
1112
+ self._preview_pending = True
1113
+ return self._preview_worker
1114
+
1115
+ need_stack = params.memory_tau > 0.0 and level >= _LEVEL_SEG
1116
+ cached_stack = self._cached_stack_for(params) if need_stack else None
1117
+ if need_stack and cached_stack is None:
1118
+ self._set_status(f"Temporal smoothing over {n_frames} frames…")
1119
+ else:
1120
+ self._set_status(f"Computing cell preview for frame {t}…")
1121
+
1122
+ @thread_worker(connect={
1123
+ "returned": self._on_preview_done,
1124
+ "errored": self._on_preview_error,
1125
+ })
1126
+ def _worker():
1127
+ t_, result, new_stack = self._compute_frame(
1128
+ t=t, level=level, params=params, contours_path=contours_path,
1129
+ foreground_path=foreground_path, nuc_path=nuc_path,
1130
+ cached_stack=cached_stack,
1131
+ )
1132
+ return t_, result, params, new_stack, level
1133
+
1134
+ self._preview_worker = _worker()
1135
+ return self._preview_worker
1136
+
1137
+ def _on_preview_done(self, payload) -> None:
1138
+ self._preview_worker = None
1139
+ t, result, params, new_stack, level = payload
1140
+ self._cache_stack(params, new_stack)
1141
+ # Only keep the result if the params still match (an edit mid-flight
1142
+ # cleared and re-keyed the cache; a stale result must not poison it).
1143
+ if dataclasses.astuple(params) == self._frame_cache_key:
1144
+ self._frame_cache[t] = (level, result)
1145
+ if self._preview_active:
1146
+ self._apply_result(t, result)
1147
+ self._set_status(self._frame_status(t, result))
1148
+ if self._preview_pending and self._preview_active:
1149
+ self._preview_pending = False
1150
+ self._refresh_preview()
1151
+ else:
1152
+ self._preview_pending = False
1153
+
1154
+ def _on_preview_error(self, exc: Exception) -> None:
1155
+ self._preview_worker = None
1156
+ self._preview_pending = False
1157
+ self._set_status(f"Cell preview failed: {exc}")
1158
+ logger.exception("Cell preview worker error", exc_info=exc)
1159
+
1160
+ def _cache_stack(self, params: CellDivergenceParams, new_stack) -> None:
1161
+ """Hold a freshly computed smoothed stack resident for later frames/edits."""
1162
+ if new_stack is not None:
1163
+ self._smoothed_stack = new_stack
1164
+ self._smoothed_key = self._smooth_key(params)
1165
+
1166
+ def _apply_result(self, t: int, result) -> None:
1167
+ """Paint frame ``t``'s slice for every ticked group the result supplies.
1168
+
1169
+ Works for both a ``CellForegroundResult`` (foreground only) and a full
1170
+ ``CellDivergenceResult``; missing fields (e.g. contours on a foreground
1171
+ result) are simply skipped — they are never requested unless the deeper
1172
+ compute that produces them ran.
1173
+ """
1174
+ if self.fg_check.isChecked():
1175
+ self._fill_image_layer(_FG_RAW_LAYER, t, result.foreground_raw)
1176
+ self._fill_image_layer(_FG_CLEAN_LAYER, t, result.foreground_clean)
1177
+ self._fill_labels_layer(
1178
+ _FG_MASK_LAYER, t, result.foreground_mask.astype(np.uint8)
1179
+ )
1180
+ if self.contour_check.isChecked() and hasattr(result, "contours_raw"):
1181
+ self._fill_image_layer(_CT_RAW_LAYER, t, result.contours_raw)
1182
+ self._fill_image_layer(_CT_CLEAN_LAYER, t, result.contours_clean)
1183
+ if self.cost_check.isChecked() and hasattr(result, "cost_field"):
1184
+ self._fill_image_layer(
1185
+ _COST_LAYER, t, self._cost_for_display(result.cost_field)
1186
+ )
1187
+ if self.labels_check.isChecked() and getattr(result, "labels", None) is not None:
1188
+ self._fill_labels_layer(_LABELS_LAYER, t, result.labels.astype(np.int32))
1189
+
1190
+ @staticmethod
1191
+ def _frame_status(t: int, result) -> str:
1192
+ coverage = 100.0 * float(result.foreground_mask.mean())
1193
+ msg = f"Frame {t}: {coverage:.0f}% fill coverage"
1194
+ labels = getattr(result, "labels", None)
1195
+ if labels is not None:
1196
+ n_labels = int(np.unique(labels[labels > 0]).size)
1197
+ msg += f", {n_labels} cell labels"
1198
+ return msg
1199
+
1200
+ @staticmethod
1201
+ def _cost_for_display(cost: np.ndarray) -> np.ndarray:
1202
+ """Mask the geodesic-cost background (inf) to NaN for the colormap."""
1203
+ return np.where(np.isfinite(cost), cost, np.nan).astype(np.float32)
1204
+
1205
+ # ── preview layers (one full (T, Y, X) stack per intermediate) ────────
1206
+ # The preview only ever computes the current frame, but the layers are sized
1207
+ # to the whole input movie ``(T, Y, X)`` and painted one frame at a time.
1208
+ # Carrying the time axis is what gives the viewer a frame slider even when no
1209
+ # movie layer is open — otherwise ``current_step`` has no temporal entry and
1210
+ # the preview is stuck on (and mislabels) frame 0. A time scrub recomputes
1211
+ # the newly shown frame (``_on_time_changed``) and paints it into its slice;
1212
+ # previously computed frames stay painted in theirs. Only the layers for the
1213
+ # ticked Compute checkboxes are created.
1214
+ def _ensure_preview_layers(self, shape, names) -> None:
1215
+ for name in names:
1216
+ if name in _LABELS_KIND:
1217
+ self._ensure_labels_layer(name, shape)
1218
+ else:
1219
+ self._ensure_image_layer(name, shape, _IMAGE_COLORMAPS[name])
1220
+
1221
+ def _ensure_image_layer(self, name: str, shape, colormap: str) -> None:
1222
+ if name in self.viewer.layers:
1223
+ layer = self.viewer.layers[name]
1224
+ if tuple(layer.data.shape) == tuple(shape):
1225
+ return
1226
+ was_visible = layer.visible
1227
+ self.viewer.layers.remove(name)
1228
+ else:
1229
+ was_visible = True
1230
+ new_layer = self.viewer.add_image(
1231
+ np.zeros(shape, dtype=np.float32), name=name, colormap=colormap,
1232
+ )
1233
+ new_layer.visible = was_visible
1234
+ # Seed this layer's contrast from the first real frame it receives.
1235
+ self._image_needs_autocontrast.add(name)
1236
+
1237
+ def _ensure_labels_layer(self, name: str, shape) -> None:
1238
+ from napari.layers import Labels
1239
+ if name in self.viewer.layers:
1240
+ layer = self.viewer.layers[name]
1241
+ if isinstance(layer, Labels) and tuple(layer.data.shape) == tuple(shape):
1242
+ return
1243
+ was_visible = layer.visible
1244
+ self.viewer.layers.remove(name)
1245
+ else:
1246
+ was_visible = True
1247
+ new_layer = self.viewer.add_labels(
1248
+ np.zeros(shape, dtype=np.int32), name=name, opacity=0.55
1249
+ )
1250
+ new_layer.visible = was_visible
1251
+
1252
+ def _fill_image_layer(self, name: str, t: int, frame: np.ndarray) -> None:
1253
+ if name not in self.viewer.layers:
1254
+ return
1255
+ layer = self.viewer.layers[name]
1256
+ if layer.data.ndim != 3 or not 0 <= t < layer.data.shape[0]:
1257
+ return
1258
+ layer.data[t] = np.asarray(frame, dtype=layer.data.dtype)
1259
+ if name in self._image_needs_autocontrast:
1260
+ finite = frame[np.isfinite(frame)]
1261
+ if finite.size:
1262
+ lo, hi = float(finite.min()), float(finite.max())
1263
+ if hi > lo:
1264
+ layer.contrast_limits = (lo, hi)
1265
+ self._image_needs_autocontrast.discard(name)
1266
+ layer.refresh()
1267
+
1268
+ def _fill_labels_layer(self, name: str, t: int, frame: np.ndarray) -> None:
1269
+ if name not in self.viewer.layers:
1270
+ return
1271
+ layer = self.viewer.layers[name]
1272
+ if layer.data.ndim != 3 or not 0 <= t < layer.data.shape[0]:
1273
+ return
1274
+ layer.data[t] = np.asarray(frame, dtype=layer.data.dtype)
1275
+ layer.refresh()
1276
+
1277
+ # ================================================================
1278
+ # Full run (final output only)
1279
+ # ================================================================
1280
+ def _on_run_clicked(self) -> None:
1281
+ if self._running:
1282
+ self._on_cancel()
1283
+ else:
1284
+ self._on_run()
1285
+
1286
+ def _on_cancel(self) -> None:
1287
+ if self._run_worker is not None and hasattr(self._run_worker, "quit"):
1288
+ self._run_worker.quit()
1289
+ self._run_worker = None
1290
+ self._running = False
1291
+ self._set_run_idle()
1292
+ self._clear_progress()
1293
+ self._set_status("Cancelled.")
1294
+
1295
+ def _on_run(self) -> None:
1296
+ if self._pos_dir is None:
1297
+ self._set_status("No project open.")
1298
+ return
1299
+ if not self._maps_present():
1300
+ self._set_status("Cell divergence maps not found — run Divergence Maps first.")
1301
+ return
1302
+ nuc_path = self._nuc_path()
1303
+ if nuc_path is None or not nuc_path.exists():
1304
+ self._set_status(self._nuc_missing_msg())
1305
+ return
1306
+
1307
+ params = self._params()
1308
+ contours_path = self._contours_path()
1309
+ foreground_path = self._foreground_path()
1310
+ output_path = self._output_path()
1311
+ pos_dir = self._pos_dir
1312
+
1313
+ def _done(result):
1314
+ self._run_worker = None
1315
+ self._running = False
1316
+ self._set_run_idle()
1317
+ self._clear_progress()
1318
+ labels, n_labels = result
1319
+ self._show_layer(
1320
+ _TRACKED_CELL_LAYER, labels, {"visible": True}, self.viewer.add_labels
1321
+ )
1322
+ self._files_widget.refresh(pos_dir)
1323
+ self._refresh_finalize_btn()
1324
+ self._set_status(
1325
+ f"Segmentation complete — {n_labels} labels, "
1326
+ f"saved to {output_path.name}."
1327
+ )
1328
+
1329
+ def _error(exc):
1330
+ self._run_worker = None
1331
+ self._running = False
1332
+ self._set_run_idle()
1333
+ self._clear_progress()
1334
+ if isinstance(exc, CancelledError):
1335
+ self._set_status("Cancelled.")
1336
+ return
1337
+ self._set_status(f"Error: {exc}")
1338
+ logger.exception("Cell segmentation run error", exc_info=exc)
1339
+
1340
+ @thread_worker(connect={"returned": _done, "errored": _error})
1341
+ def _worker():
1342
+ progress = self._run_progress
1343
+
1344
+ def _cb(msg: str) -> None:
1345
+ progress.emit(str(msg))
1346
+
1347
+ contours = tifffile.imread(str(contours_path))
1348
+ foreground = tifffile.imread(str(foreground_path))
1349
+ nuc = tifffile.imread(str(nuc_path))
1350
+ result = segment_cells_divergence(
1351
+ contours, foreground, nuc, params, progress_cb=_cb,
1352
+ )
1353
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1354
+ commit_labels(result.labels, output_path)
1355
+ n_labels = int(np.unique(result.labels[result.labels > 0]).size)
1356
+ return result.labels, n_labels
1357
+
1358
+ self._set_status("Segmenting all frames…")
1359
+ self.pipeline_progress_bar.setRange(0, 0)
1360
+ self.pipeline_progress_bar.setVisible(True)
1361
+ self._running = True
1362
+ self._set_run_running()
1363
+ self._run_worker = _worker()
1364
+
1365
+ def _set_run_running(self) -> None:
1366
+ self.run_btn.setText("✕")
1367
+ self.run_btn.setToolTip("Cancel.")
1368
+ # ``self._running`` is set by the caller before this runs; the gate
1369
+ # derives active_btn enablement from it.
1370
+ self.gate.set_task("cell_run", True)
1371
+
1372
+ def _set_run_idle(self) -> None:
1373
+ self.run_btn.setText("▶")
1374
+ self.run_btn.setToolTip(
1375
+ "Run the full pipeline over all frames and write tracked_labels.tif."
1376
+ )
1377
+ self.gate.set_task("cell_run", False)
1378
+
1379
+ def _clear_progress(self) -> None:
1380
+ self.pipeline_progress_bar.setRange(0, 100)
1381
+ self.pipeline_progress_bar.setValue(0)
1382
+ self.pipeline_progress_bar.setVisible(False)
1383
+
1384
+
1385
+ def make_cell_segmentation_widget(napari_viewer=None):
1386
+ """napari plugin factory for the standalone cell-segmentation piece.
1387
+
1388
+ Used by the ``itasc-segmentation`` distribution's manifest. Patches the
1389
+ napari layer-controls delegate (best-effort, normally done by the
1390
+ orchestrator) and returns the workflow widget in standalone mode, with its
1391
+ own foreground/contours/nucleus input pickers and output-dir picker.
1392
+ """
1393
+ try:
1394
+ from itasc.napari._napari_compat import patch_napari_layer_delegate
1395
+
1396
+ patch_napari_layer_delegate()
1397
+ except Exception: # pragma: no cover - patch is best-effort
1398
+ pass
1399
+ # napari does not inject the viewer into function-based widget factories
1400
+ # (only into class-based callables / magicgui types), so ``napari_viewer``
1401
+ # arrives as ``None``. The widget needs a live viewer; fall back to the
1402
+ # active one.
1403
+ if napari_viewer is None:
1404
+ napari_viewer = napari.current_viewer()
1405
+ return CellWorkflowWidget(viewer=napari_viewer, standalone=True)