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,1190 @@
1
+ """Correction section widget for the cell workflow."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import tifffile
10
+ from napari.utils.colormaps import direct_colormap
11
+ from qtpy.QtWidgets import (
12
+ QComboBox,
13
+ QHBoxLayout,
14
+ QLabel,
15
+ QSizePolicy,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ from itasc.correction.labels import (
21
+ clean_stranded_pixels,
22
+ fill_label_holes,
23
+ )
24
+ from itasc.core.tiff import imwrite_grayscale
25
+ from itasc.core.label_store import read_full_tracked_stack
26
+ from itasc.napari.correction._correction_utils import frame_view_2d, remove_unvalidated_labels
27
+ from itasc.napari.lineage_canvas_controller import LineageCanvasController
28
+ from itasc.napari._widget_helpers import (
29
+ make_status as _make_status,
30
+ tool_btn as _tool_btn,
31
+ )
32
+ from itasc.napari.correction._correction_layer_lifecycle import (
33
+ CorrectionViewStateMixin,
34
+ LayerViewState,
35
+ hide_all_layers,
36
+ )
37
+ from itasc.napari.correction._correction_ui import (
38
+ build_correction_toolbar,
39
+ confirm_unsaved_before_deactivate,
40
+ set_checked_without_signal,
41
+ )
42
+ from itasc.napari.correction.correction_widget import CorrectionWidget
43
+ from itasc.napari.ui_style import (
44
+ add_block_pair_row,
45
+ block_grid,
46
+ stage_header_action_button,
47
+ stage_header_label,
48
+ )
49
+ from itasc.napari.widgets import CollapsibleSection
50
+ from itasc.napari._widget_helpers import (
51
+ dslider as _dslider,
52
+ islider as _islider,
53
+ )
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Layers created by the cell correction widget itself carry a [Correction]
58
+ # prefix so they can be cleanly removed on deactivate without clobbering the
59
+ # regular pipeline layers (Tracked: Cell, Cell z-avg, …) shown elsewhere.
60
+ _TRACKED_CELL_LAYER = "[Correction] Cell Labels"
61
+ _CELL_ZAVG_LAYER = "[Correction] Cell z-avg"
62
+ _NUC_ZAVG_LAYER = "[Correction] Nucleus z-avg"
63
+ # Validated-cell overlay (full-editing only): an opaque green border around
64
+ # every cell in every frame it's validated for. Standalone analogue of
65
+ # ValidatedOverlayController's border mode, drawn from the in-memory
66
+ # ``self._validated_tracks`` store instead of validated_cells.json.
67
+ _VALIDATED_OVERLAY_LAYER = "[Correction] Validated: Cell"
68
+ _VALIDATED_OVERLAY_COLOR = "#00ff00"
69
+ _VALIDATED_OVERLAY_CONTOUR = 2
70
+
71
+ # Pipeline-side (non-correction) layer names — used as fallbacks where the
72
+ # user may have loaded layers manually, and as the target for the on-disk
73
+ # Tracked: Cell refresh when correction mode is exited.
74
+ _PIPELINE_TRACKED_CELL_LAYER = "Tracked: Cell"
75
+
76
+
77
+ class CellCorrectionWidget(CorrectionViewStateMixin, QWidget):
78
+ """Qt controls for cell tracking correction workflows.
79
+
80
+ Owns the correction group-box UI, all correction-scoped button handlers,
81
+ and the inline CorrectionWidget. Does not hold a back-reference to the
82
+ parent workflow widget.
83
+
84
+ Parameters
85
+ ----------
86
+ viewer:
87
+ The napari viewer instance.
88
+ pos_dir_provider:
89
+ Zero-argument callable that returns the current position directory
90
+ (or None). When None, the widget falls back to a locally stored
91
+ ``_local_pos_dir``.
92
+ files_widget_refresh_callback:
93
+ Called with the current ``pos_dir`` after save operations so that the
94
+ parent workflow can refresh its file status panel.
95
+ labels_path_provider:
96
+ Optional zero-argument callable returning the tracked-cell-labels file to
97
+ load and save. When given it overrides the default position-dir path
98
+ (``<pos_dir>/3_cell/tracked_labels.tif``), letting a standalone tool point
99
+ the corrector at a flat output file. Defaults to None → unchanged app
100
+ behaviour.
101
+ cell_ref_path_provider, nucleus_ref_path_provider:
102
+ Optional overrides for the cell / nucleus reference-image backdrops
103
+ (default ``<pos_dir>/1_cellpose/*_foreground.tif``). None → app default.
104
+ active_labels_layer_provider:
105
+ Optional zero-argument callable returning the napari **Labels** layer to
106
+ correct right now (or None when the active layer is not a Labels layer).
107
+ When given, the corrector runs in *active-layer mode*: it edits that
108
+ layer's data in place — no on-disk label file, no backdrop loading, no
109
+ layer hiding — and the user saves it via napari. Defaults to None →
110
+ unchanged disk-based app behaviour.
111
+ intensity_frame_provider:
112
+ Optional callable ``(t) -> 2D array | None`` giving the signal frame that
113
+ middle-click spawn snaps a new cell to (see ``add_cell``'s ``image``
114
+ argument); ``None`` (or a returned ``None``) falls back to a plain disk.
115
+ Defaults to None → spawn always stamps a disk (unchanged app behaviour).
116
+ parent:
117
+ Optional Qt parent.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ viewer,
123
+ *,
124
+ pos_dir_provider: Callable[[], Path | None] | None = None,
125
+ files_widget_refresh_callback: Callable[[Path | None], None] | None = None,
126
+ labels_path_provider: Callable[[], Path | None] | None = None,
127
+ cell_ref_path_provider: Callable[[], Path | None] | None = None,
128
+ nucleus_ref_path_provider: Callable[[], Path | None] | None = None,
129
+ active_labels_layer_provider: Callable[[], object] | None = None,
130
+ intensity_frame_provider: Callable[[int], np.ndarray | None] | None = None,
131
+ full_editing: bool = False,
132
+ finalize_btn: QWidget | None = None,
133
+ finalize_header_btn: QWidget | None = None,
134
+ parent: QWidget | None = None,
135
+ ) -> None:
136
+ super().__init__(parent)
137
+ self.viewer = viewer
138
+ # Optional host-owned "Finalize" buttons (the handler lives on the host
139
+ # workflow widget, which owns the promote paths). ``_finalize_btn`` rides
140
+ # at the tail of the correction toolbar (visible only in active mode);
141
+ # ``_finalize_header_btn`` sits beside the on/off toggle in the top bar
142
+ # (always visible). Either may be None → that slot has no finalize action.
143
+ self._finalize_btn = finalize_btn
144
+ self._finalize_header_btn = finalize_header_btn
145
+ # Opt-in (standalone segment+track only): unlock the full DB-free editing
146
+ # toolkit — spawn / erase / merge / swap / split (mouse + Delete), plus a
147
+ # greedy retracker on Q/E. The integrated app never passes this, so its
148
+ # contour-only cell corrector is byte-for-byte unchanged.
149
+ self._full_editing = full_editing
150
+ self._pos_dir_provider = pos_dir_provider
151
+ self._local_pos_dir: Path | None = None
152
+ self._files_widget_refresh = files_widget_refresh_callback or (lambda _pd: None)
153
+ self._labels_path_provider = labels_path_provider
154
+ self._cell_ref_path_provider = cell_ref_path_provider
155
+ self._nucleus_ref_path_provider = nucleus_ref_path_provider
156
+ self._active_labels_layer_provider = active_labels_layer_provider
157
+ self._intensity_frame_provider = intensity_frame_provider
158
+ self._active_bound_layer = None
159
+ self._retrack_keys_layer = None
160
+ self._correction_view_state: LayerViewState | None = None
161
+ self._correction_owned_layers: set[str] = set()
162
+ self._correction_dirty: bool = False
163
+ # DB-free validation store (full-editing/standalone only): there is no
164
+ # project directory to persist validated_cells.json into, so validation
165
+ # lives only for the session, keyed the same as the disk-backed store
166
+ # ({cell_id: {validated frames}}) so remove_unvalidated_labels and the
167
+ # lineage canvas need no adaptation.
168
+ self._validated_tracks: dict[int, set[int]] = {}
169
+ self._lineage_canvas: LineageCanvasController | None = None
170
+ self._setup_ui()
171
+ self._connect_signals()
172
+ # Active-layer mode has no on-disk target — saving is napari's job, so
173
+ # the in-widget disk-save button is hidden.
174
+ if self._active_labels_layer_provider is not None:
175
+ self.save_labels_btn.setVisible(False)
176
+ self._sync_active_btn_enabled()
177
+
178
+ # ------------------------------------------------------------------ #
179
+ # pos_dir property — delegate to provider if available #
180
+ # ------------------------------------------------------------------ #
181
+
182
+ @property
183
+ def _pos_dir(self) -> Path | None:
184
+ if self._pos_dir_provider is not None:
185
+ return self._pos_dir_provider()
186
+ return self._local_pos_dir
187
+
188
+ @_pos_dir.setter
189
+ def _pos_dir(self, value: Path | None) -> None:
190
+ self._local_pos_dir = value
191
+
192
+ # ------------------------------------------------------------------ #
193
+ # UI construction #
194
+ # ------------------------------------------------------------------ #
195
+
196
+ def _setup_ui(self) -> None:
197
+ root = QVBoxLayout(self)
198
+ root.setContentsMargins(0, 0, 0, 0)
199
+ root.setSpacing(0)
200
+
201
+ inner = QWidget(self)
202
+ group_lay = QVBoxLayout(inner)
203
+ group_lay.setContentsMargins(0, 0, 0, 0)
204
+ group_lay.setSpacing(6)
205
+
206
+ self.active_btn = _tool_btn(
207
+ "⏻",
208
+ "Activate correction mode and show correction controls.",
209
+ checkable=True,
210
+ )
211
+ self.active_btn.setToolTip(
212
+ "Activate correction mode and show correction controls."
213
+ )
214
+ self.params_btn = _tool_btn(
215
+ "⚙", "Show correction parameters.", checkable=True
216
+ )
217
+ self.shortcuts_btn = _tool_btn(
218
+ "📖", "Show correction shortcuts.", checkable=True
219
+ )
220
+
221
+ # ── Action toolbar buttons (icon tool-buttons in the body) ────
222
+ # Mirror the nucleus correction widget: the main actions read as a thin
223
+ # vertical icon toolbar beside the editing surface (plain enlarged
224
+ # glyphs, no stage pill) rather than a stack of text buttons.
225
+ # ``outline_btn`` is the visible driver for the embedded correction
226
+ # widget's (now hidden) "Show outlines only" checkbox; the rest are
227
+ # momentary actions.
228
+ self.outline_btn = _tool_btn(
229
+ "◻",
230
+ "Show outlines only — draw labels as contours so the reference "
231
+ "images stay visible underneath.",
232
+ checkable=True,
233
+ )
234
+ self.save_labels_btn = _tool_btn(
235
+ "💾", "Save tracked cell labels to disk."
236
+ )
237
+ self.fill_holes_btn = _tool_btn(
238
+ "🪣",
239
+ "Fill background holes fully enclosed within individual labels.",
240
+ )
241
+ self.cleanup_btn = _tool_btn(
242
+ "🧹",
243
+ "Remove disconnected same-label fragments (honours the Scope selector).",
244
+ )
245
+ # Retracker (standalone full-editing only): re-link every later frame to
246
+ # the current one by greedy geometric similarity (area + centroid IoU -
247
+ # distance). DB-free — there is no validation/locking, just re-linking.
248
+ if self._full_editing:
249
+ self.retrack_back_btn = _tool_btn(
250
+ "↶", "Retrack labels backward from the current frame (Q)."
251
+ )
252
+ self.retrack_fwd_btn = _tool_btn(
253
+ "↷", "Retrack labels forward from the current frame (E)."
254
+ )
255
+ # Validation is DB-free here too: it just flags cell/frame pairs in
256
+ # memory for this session (no on-disk project to persist into).
257
+ self.validate_btn = _tool_btn(
258
+ "✓",
259
+ "Toggle validation for the selected cell across its frames (V).",
260
+ )
261
+ self.remove_unvalidated_btn = _tool_btn(
262
+ "🗑",
263
+ "Remove cell label pixels not marked validated for their frame.",
264
+ )
265
+
266
+ # ── Scope selector (always visible in the active panel) ───────
267
+ # Scope drives Fill Holes / Remove Stranded Fragments, so it lives
268
+ # inline rather than behind the ⚙ params reveal.
269
+ self.scope_row = QWidget(self)
270
+ scope_row = QHBoxLayout(self.scope_row)
271
+ scope_row.setContentsMargins(0, 0, 0, 0)
272
+ scope_lbl = QLabel("Scope:")
273
+ scope_lbl.setToolTip(
274
+ "Applies to Fill Holes and Remove Stranded Fragments."
275
+ )
276
+ scope_row.addWidget(scope_lbl)
277
+ self.correction_scope_combo = QComboBox()
278
+ self.correction_scope_combo.addItems(["Current frame", "All frames"])
279
+ self.correction_scope_combo.setToolTip(
280
+ "Applies to Fill Holes and Remove Stranded Fragments."
281
+ )
282
+ scope_row.addWidget(self.correction_scope_combo)
283
+ scope_row.addStretch(1)
284
+
285
+ # ── Correction parameters (collapsible) ───────────────────────
286
+ params_inner = QWidget(self)
287
+ params_lay = QVBoxLayout(params_inner)
288
+ params_lay.setContentsMargins(0, 0, 0, 0)
289
+ params_lay.setSpacing(6)
290
+
291
+ g = block_grid(horizontal_spacing=12)
292
+ self.hole_radius_spin = _islider(
293
+ 0, 999, 5,
294
+ tooltip="Max hole size (pixels) for the Fill Holes operation.",
295
+ )
296
+ add_block_pair_row(g, 0, "Hole radius:", self.hole_radius_spin)
297
+ if self._full_editing:
298
+ # Cell radius backs middle-click spawning; max dist gates retrack
299
+ # matches. The spawn spinbox lives inside the inner widget (created
300
+ # below) and is relocated here once it exists.
301
+ self.retrack_max_dist_spin = _dslider(
302
+ 0, 500, 50.0, 1.0, 1,
303
+ tooltip="Max centroid distance (px) for a retrack (Q/E) match.",
304
+ )
305
+ add_block_pair_row(
306
+ g, 1, "Retrack max dist:", self.retrack_max_dist_spin
307
+ )
308
+ params_lay.addLayout(g)
309
+
310
+ self.correction_params_section = CollapsibleSection(
311
+ "Correction Parameters",
312
+ params_inner,
313
+ expanded=False,
314
+
315
+ )
316
+ self.correction_params_section.set_header_visible(False)
317
+ self.correction_params_section.setVisible(False)
318
+ group_lay.addWidget(self.correction_params_section)
319
+
320
+ # ── Inline CorrectionWidget ───────────────────────────────────
321
+ # App default: cell labels are tied to the nuclei, so restrict to contour
322
+ # edits only (select + Shift-left extend + Shift-right carve), border
323
+ # highlight, no spawn-radius control.
324
+ # ``full_editing`` (standalone segment+track only) drops ``contour_only``
325
+ # so the whole DB-free toolkit is live — spawn / erase / merge / swap /
326
+ # split (mouse + Delete) — and the shortcuts panel auto-lists them.
327
+ self.correction_widget = CorrectionWidget(
328
+ self.viewer,
329
+ show_activate_btn=False,
330
+ show_shortcuts=False,
331
+ inspector_first=True,
332
+ show_cleanup=False,
333
+ show_spawn_controls=False,
334
+ contour_only=not self._full_editing,
335
+ highlight_style="border",
336
+ )
337
+ # Inline contour edits (extend / carve / split) flow through the inner
338
+ # widget's own status label, so they never hit ``_correction_status`` and
339
+ # its "Unsaved" dirty-tracking. Mark the panel dirty on every edit so the
340
+ # exit confirm fires, just like the nucleus correction widget.
341
+ self.correction_widget.set_edit_callback(self._on_correction_edit)
342
+ if self._intensity_frame_provider is not None:
343
+ self.correction_widget.set_intensity_frame_callback(
344
+ self._intensity_frame_provider
345
+ )
346
+ # The outline view is driven from the toolbar ``outline_btn`` toggle now,
347
+ # so hide the embedded checkbox and keep the two in sync.
348
+ self.correction_widget._outline_btn.setVisible(False)
349
+ if self._full_editing:
350
+ # Relocate the inner widget's spawn-radius spinbox into our params
351
+ # panel (it is created but unparented to a layout when
352
+ # show_spawn_controls=False), so middle-click spawn size is tunable.
353
+ cell_radius_grid = block_grid(horizontal_spacing=12)
354
+ add_block_pair_row(
355
+ cell_radius_grid, 0, "Cell radius:",
356
+ self.correction_widget._cell_radius_spin,
357
+ )
358
+ params_lay.addLayout(cell_radius_grid)
359
+
360
+ # Thin vertical icon toolbar (nucleus styling: enlarged glyphs, ruled
361
+ # groups, no pill) shown to the left of the editing surface.
362
+ toolbar_groups = [
363
+ (self.outline_btn,),
364
+ (self.save_labels_btn,),
365
+ (self.fill_holes_btn, self.cleanup_btn),
366
+ ]
367
+ if self._full_editing:
368
+ toolbar_groups.append((self.retrack_back_btn, self.retrack_fwd_btn))
369
+ toolbar_groups.append((self.validate_btn, self.remove_unvalidated_btn))
370
+ # The host workflow widget's Finalize button rides at the tail of the
371
+ # toolbar (its own ruled group) — finalizing is the natural last step
372
+ # once the labels are corrected.
373
+ if self._finalize_btn is not None:
374
+ toolbar_groups.append((self._finalize_btn,))
375
+ self.toolbar = build_correction_toolbar(self, toolbar_groups)
376
+ self.toolbar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
377
+
378
+ self.correction_shortcuts_section = CollapsibleSection(
379
+ "Correction Shortcuts",
380
+ self.correction_widget.build_shortcuts_widget(),
381
+ expanded=False,
382
+
383
+ )
384
+ self.correction_shortcuts_section.set_header_visible(False)
385
+ self.correction_shortcuts_section.setVisible(False)
386
+ group_lay.addWidget(self.correction_shortcuts_section)
387
+
388
+ self.active_content = QWidget(self)
389
+ active_lay = QVBoxLayout(self.active_content)
390
+ active_lay.setContentsMargins(0, 0, 0, 0)
391
+ active_lay.setSpacing(6)
392
+
393
+ body_row = QHBoxLayout()
394
+ body_row.setContentsMargins(0, 0, 0, 0)
395
+ body_row.setSpacing(6)
396
+ body_row.addWidget(self.toolbar)
397
+ body_row.addWidget(self.correction_widget, stretch=1)
398
+ active_lay.addLayout(body_row)
399
+
400
+ self.correction_status_lbl = _make_status()
401
+ active_lay.addWidget(self.correction_status_lbl)
402
+
403
+ active_lay.addWidget(self.scope_row)
404
+ active_lay.addWidget(self.correction_widget._attrib_lbl)
405
+
406
+ # ── Track-list navigator (full-editing only) ──────────────────
407
+ # Reuses the nucleus workflow's swimlane accordion: it needs a plain
408
+ # (T, Y, X)-ish label stack, not an Ultrack DB, so it is directly
409
+ # portable. There is no intensity backdrop to crop thumbnails from in
410
+ # this standalone tool (the active layer is whatever the user bound),
411
+ # so the film-strip band stays empty; the per-track presence/validated
412
+ # bars and click-to-jump navigation still work fully.
413
+ if self._full_editing:
414
+ self._lineage_canvas = LineageCanvasController(
415
+ self.viewer,
416
+ tracked_data_provider=self._lineage_tracked_data,
417
+ tracked_layer_provider=self._correction_tracked_layer,
418
+ intensity_layer_provider=lambda: None,
419
+ selected_label_provider=lambda: int(
420
+ getattr(self.correction_widget, "_selected_label", 0) or 0
421
+ ),
422
+ current_t_provider=self._current_t,
423
+ on_activate=self._navigate_to_cell_from_lineage,
424
+ validated_tracks_provider=lambda: self._validated_tracks,
425
+ )
426
+ panel = self._lineage_canvas.panel()
427
+ panel.setMinimumHeight(140)
428
+ active_lay.addWidget(panel, stretch=1)
429
+
430
+ self.active_content.setVisible(False)
431
+ group_lay.addWidget(self.active_content)
432
+
433
+ self.header = self._build_correction_header()
434
+
435
+ self.section = CollapsibleSection(
436
+ "Correction",
437
+ inner,
438
+ expanded=False,
439
+
440
+ )
441
+ self.section.set_header_visible(False)
442
+ self.section._toggle.setEnabled(False)
443
+ root.addWidget(self.header)
444
+ root.addWidget(self.section)
445
+
446
+ self.header_lbl = self.correction_header_lbl
447
+ self.correction_active_btn = self.active_btn
448
+ self.correction_params_btn = self.params_btn
449
+ self.correction_shortcuts_btn = self.shortcuts_btn
450
+ self.correction_mode_section = self.section
451
+
452
+ def _build_correction_header(self) -> QWidget:
453
+ header = QWidget(self)
454
+ row = QHBoxLayout(header)
455
+ row.setContentsMargins(0, 0, 0, 0)
456
+ row.setSpacing(4)
457
+
458
+ self.correction_header_lbl = QLabel("Correction")
459
+ stage_header_label(self.correction_header_lbl, "cell")
460
+ header_btns = [self.shortcuts_btn, self.params_btn, self.active_btn]
461
+ # Finalize sits right beside the on/off toggle so the labels can be
462
+ # promoted without entering correction mode.
463
+ if self._finalize_header_btn is not None:
464
+ header_btns.append(self._finalize_header_btn)
465
+ for button in header_btns:
466
+ stage_header_action_button(button, "cell")
467
+ row.addWidget(self.correction_header_lbl)
468
+ row.addWidget(self.shortcuts_btn)
469
+ row.addWidget(self.params_btn)
470
+ row.addWidget(self.active_btn)
471
+ if self._finalize_header_btn is not None:
472
+ row.addWidget(self._finalize_header_btn)
473
+ row.addStretch(1)
474
+ return header
475
+
476
+ # ------------------------------------------------------------------ #
477
+ # Signal wiring #
478
+ # ------------------------------------------------------------------ #
479
+
480
+ def _connect_signals(self) -> None:
481
+ self.save_labels_btn.clicked.connect(self._on_save_labels)
482
+ self.fill_holes_btn.clicked.connect(self._on_fill_holes)
483
+ self.cleanup_btn.clicked.connect(self._on_cleanup)
484
+ self.outline_btn.toggled.connect(self._on_outline_toggled)
485
+ self.correction_widget._outline_btn.toggled.connect(
486
+ self._on_embedded_outline_toggled
487
+ )
488
+ self.active_btn.toggled.connect(self._on_active_button_toggled)
489
+ self.params_btn.toggled.connect(self._on_params_button_toggled)
490
+ self.shortcuts_btn.toggled.connect(self._on_shortcuts_button_toggled)
491
+ if self._full_editing:
492
+ self.retrack_back_btn.clicked.connect(self._on_retrack_backward)
493
+ self.retrack_fwd_btn.clicked.connect(self._on_retrack_forward)
494
+ self.validate_btn.clicked.connect(self._on_toggle_validation)
495
+ self.remove_unvalidated_btn.clicked.connect(
496
+ self._on_remove_unvalidated_labels
497
+ )
498
+ self.correction_widget.add_selection_listener(
499
+ self._on_track_selection_changed
500
+ )
501
+ self.viewer.dims.events.current_step.connect(
502
+ self._on_dims_changed_for_lineage
503
+ )
504
+ if self._active_layer_mode():
505
+ # Standalone tools have no project directory to poll — readiness
506
+ # here is "is the viewer's active layer a Labels layer", which
507
+ # only changes on a selection change.
508
+ active_events = getattr(
509
+ getattr(getattr(self.viewer.layers, "selection", None), "events", None),
510
+ "active", None,
511
+ )
512
+ if active_events is not None:
513
+ active_events.connect(lambda *_a, **_k: self._sync_active_btn_enabled())
514
+ self.active_btn.toggled.connect(lambda _c: self._sync_active_btn_enabled())
515
+
516
+ @staticmethod
517
+ def _set_checked_without_signal(button, checked: bool) -> None:
518
+ set_checked_without_signal(button, checked)
519
+
520
+ def _sync_correction_panel_visibility(self) -> None:
521
+ show_params = self.params_btn.isChecked()
522
+ show_shortcuts = self.shortcuts_btn.isChecked()
523
+ show_active = self.active_btn.isChecked()
524
+
525
+ self.correction_params_section.setVisible(show_params)
526
+ self.correction_shortcuts_section.setVisible(show_shortcuts)
527
+ self.active_content.setVisible(show_active)
528
+
529
+ if show_params or show_shortcuts or show_active:
530
+ self.section.expand()
531
+ else:
532
+ self.section.collapse()
533
+
534
+ def _active_layer_mode(self) -> bool:
535
+ """True when bound to the viewer's active Labels layer (standalone)."""
536
+ return self._active_labels_layer_provider is not None
537
+
538
+ def _correction_data_available(self) -> bool:
539
+ """True when there is a cell-label stack to correct.
540
+
541
+ Active-layer mode: the provider yields a Labels layer (None otherwise).
542
+ Disk mode: a tracked cell-label file exists on disk.
543
+ """
544
+ if self._active_layer_mode():
545
+ return self._active_labels_layer_provider() is not None
546
+ lp = self._cell_labels_path()
547
+ return lp is not None and lp.exists()
548
+
549
+ def _sync_active_btn_enabled(self) -> None:
550
+ """Locally gate the activate button in active-layer mode.
551
+
552
+ Not routed through the parent widget's ``UiGate`` — this widget is
553
+ reused in disk-mode contexts (the app) where this precondition doesn't
554
+ apply. Stays enabled while already checked so an active correction
555
+ session can always be turned back off even if the viewer's active-layer
556
+ selection has since moved off the bound Labels layer.
557
+ """
558
+ if not self._active_layer_mode():
559
+ return
560
+ available = self._correction_data_available() or self.active_btn.isChecked()
561
+ self.active_btn.setEnabled(available)
562
+ self.active_btn.setToolTip(
563
+ "Activate correction mode and show correction controls."
564
+ if available
565
+ else "Select a Labels layer to correct — the active layer is not one."
566
+ )
567
+
568
+ def _toggle_active_layer_correction(self, active: bool) -> None:
569
+ """Bind/unbind the corrector to the viewer's active Labels layer.
570
+
571
+ Standalone mode: there is no on-disk label file — the corrector edits the
572
+ currently-active napari Labels layer in place and the user saves it via
573
+ napari. Activating does not capture/hide other layers or load backdrops;
574
+ deactivating loses nothing (edits already live in the layer), so there is
575
+ no unsaved-changes prompt.
576
+ """
577
+ if active:
578
+ layer = self._active_labels_layer_provider()
579
+ if layer is None:
580
+ self._correction_status(
581
+ "Select a Labels layer to correct — the active layer is not one."
582
+ )
583
+ self._set_checked_without_signal(self.active_btn, False)
584
+ self._sync_correction_panel_visibility()
585
+ return
586
+ self._active_bound_layer = layer
587
+ self.viewer.layers.selection.active = layer
588
+ self.correction_widget.activate_layer(layer)
589
+ self._bind_full_editing_keys(layer)
590
+ if self._full_editing:
591
+ # A fresh binding starts a fresh validation session — stale
592
+ # entries from a previous layer/segment run would otherwise
593
+ # collide with unrelated cell ids by coincidence.
594
+ self._validated_tracks = {}
595
+ self._lineage_canvas.refresh()
596
+ self._refresh_validated_overlay()
597
+ shape = tuple(np.asarray(layer.data).shape)
598
+ self._correction_status(f"Correcting '{layer.name}' {shape}.")
599
+ self._sync_correction_panel_visibility()
600
+ return
601
+ self._set_checked_without_signal(self.active_btn, False)
602
+ self._unbind_full_editing_keys()
603
+ self.correction_widget.deactivate()
604
+ self._active_bound_layer = None
605
+ self._correction_dirty = False
606
+ self._sync_correction_panel_visibility()
607
+
608
+ def _on_active_button_toggled(self, active: bool) -> None:
609
+ if self._active_layer_mode():
610
+ self._toggle_active_layer_correction(active)
611
+ return
612
+ if active:
613
+ if not self._correction_data_available():
614
+ self._correction_status("No cell labels available to correct.")
615
+ self._set_checked_without_signal(self.active_btn, False)
616
+ self._sync_correction_panel_visibility()
617
+ return
618
+ self._capture_correction_view_state()
619
+ hide_all_layers(self.viewer.layers)
620
+
621
+ if not self._load_correction_layers_from_disk():
622
+ self._restore_correction_view_state()
623
+ self._set_checked_without_signal(self.active_btn, False)
624
+ self._sync_correction_panel_visibility()
625
+ return
626
+
627
+ layer = self.viewer.layers[_TRACKED_CELL_LAYER]
628
+ layer.visible = True
629
+ for name in (_CELL_ZAVG_LAYER, _NUC_ZAVG_LAYER):
630
+ if name in self.viewer.layers:
631
+ self.viewer.layers[name].visible = True
632
+ self.viewer.layers.selection.active = layer
633
+ self.correction_widget.activate_layer(layer)
634
+ self._sync_correction_panel_visibility()
635
+ return
636
+
637
+ if not self._confirm_deactivate_with_unsaved_changes():
638
+ self._set_checked_without_signal(self.active_btn, True)
639
+ self._sync_correction_panel_visibility()
640
+ return
641
+
642
+ self._set_checked_without_signal(self.active_btn, False)
643
+ self.correction_widget.deactivate()
644
+ self._refresh_tracked_layer_from_disk()
645
+ self._remove_correction_owned_layers()
646
+ self._restore_correction_view_state()
647
+ self._sync_correction_panel_visibility()
648
+
649
+ def _on_correction_edit(self, t: int, changed_ids: set[int]) -> None:
650
+ """An inline contour edit leaves the cell labels unsaved."""
651
+ self._correction_dirty = True
652
+
653
+ def _on_outline_toggled(self, checked: bool) -> None:
654
+ """Drive the embedded widget's outline view from the toolbar toggle."""
655
+ self.correction_widget._outline_btn.setChecked(checked)
656
+
657
+ def _on_embedded_outline_toggled(self, checked: bool) -> None:
658
+ """Mirror the embedded outline checkbox onto the toolbar toggle.
659
+
660
+ ``activate_layer`` ticks the embedded checkbox on, so this keeps the
661
+ toolbar toggle in step without re-entering ``_on_outline_toggled``.
662
+ """
663
+ self._set_checked_without_signal(self.outline_btn, checked)
664
+
665
+ def _confirm_deactivate_with_unsaved_changes(self) -> bool:
666
+ if not self._correction_dirty:
667
+ return True
668
+ action = confirm_unsaved_before_deactivate(self, save_noun="cell labels")
669
+ if action == "cancel":
670
+ return False
671
+ if action == "save":
672
+ self._on_save_labels()
673
+ self._correction_dirty = False
674
+ return True
675
+
676
+ def _correction_tracked_layer(self):
677
+ """Return the layer being corrected, or None if absent.
678
+
679
+ Active-layer mode: the bound active Labels layer. Disk mode: the
680
+ ``[Correction] Cell Labels`` layer loaded from disk.
681
+ """
682
+ if self._active_layer_mode():
683
+ return self._active_bound_layer
684
+ if _TRACKED_CELL_LAYER in self.viewer.layers:
685
+ return self.viewer.layers[_TRACKED_CELL_LAYER]
686
+ return None
687
+
688
+ def _load_correction_layers_from_disk(self) -> bool:
689
+ lp = self._cell_labels_path()
690
+ if lp is None or not lp.exists():
691
+ self._correction_status("No cell labels file found.")
692
+ return False
693
+ try:
694
+ labels = read_full_tracked_stack(lp)
695
+ except Exception as exc:
696
+ self._correction_status(f"Error reading cell labels: {exc}")
697
+ return False
698
+ czp, nzp = self._cell_foreground_path(), self._nuc_foreground_path()
699
+ cz = (
700
+ np.asarray(tifffile.imread(str(czp)), dtype=np.float32)
701
+ if czp and czp.exists() else None
702
+ )
703
+ nz = (
704
+ np.asarray(tifffile.imread(str(nzp)), dtype=np.float32)
705
+ if nzp and nzp.exists() else None
706
+ )
707
+ self._apply_loaded_layers(labels, cz, nz)
708
+ self._correction_status(f"Loaded cell label stack {labels.shape}.")
709
+ return True
710
+
711
+ def _refresh_tracked_layer_from_disk(self) -> None:
712
+ """Reload the pipeline-side Tracked: Cell layer from disk on deactivate.
713
+
714
+ Mirrors NucleusCorrectionWidget so a subsequent re-solve picks up any
715
+ corrections saved during the session. The in-correction layer
716
+ (``[Correction] Cell Labels``) is torn down separately via
717
+ ``_remove_correction_owned_layers``.
718
+ """
719
+ lp = self._cell_labels_path()
720
+ if lp is None or not lp.exists():
721
+ return
722
+ if _PIPELINE_TRACKED_CELL_LAYER not in self.viewer.layers:
723
+ return
724
+ try:
725
+ data = np.asarray(read_full_tracked_stack(lp), dtype=np.uint32)
726
+ self.viewer.layers[_PIPELINE_TRACKED_CELL_LAYER].data = data
727
+ except Exception:
728
+ pass
729
+
730
+ def _on_params_button_toggled(self, checked: bool) -> None:
731
+ self.correction_params_section._toggle.setChecked(checked)
732
+ if checked:
733
+ self._set_checked_without_signal(self.shortcuts_btn, False)
734
+ self.correction_shortcuts_section._toggle.setChecked(False)
735
+ self._sync_correction_panel_visibility()
736
+
737
+ def _on_shortcuts_button_toggled(self, checked: bool) -> None:
738
+ self.correction_shortcuts_section._toggle.setChecked(checked)
739
+ if checked:
740
+ self._set_checked_without_signal(self.params_btn, False)
741
+ self.correction_params_section._toggle.setChecked(False)
742
+ self._sync_correction_panel_visibility()
743
+
744
+ # ------------------------------------------------------------------ #
745
+ # Status helper #
746
+ # ------------------------------------------------------------------ #
747
+
748
+
749
+ # ------------------------------------------------------------------ #
750
+ # Path helpers (delegate to _pos_dir) #
751
+ # ------------------------------------------------------------------ #
752
+
753
+ def _p(self, *parts: str) -> Path | None:
754
+ return self._pos_dir.joinpath(*parts) if self._pos_dir else None
755
+
756
+ def _cell_labels_path(self) -> Path | None:
757
+ if self._labels_path_provider is not None:
758
+ return self._labels_path_provider()
759
+ return self._p("3_cell", "tracked_labels.tif")
760
+
761
+ def _cell_foreground_path(self) -> Path | None:
762
+ if self._cell_ref_path_provider is not None:
763
+ return self._cell_ref_path_provider()
764
+ return self._p("1_cellpose", "cell_foreground.tif")
765
+
766
+ def _nuc_foreground_path(self) -> Path | None:
767
+ if self._nucleus_ref_path_provider is not None:
768
+ return self._nucleus_ref_path_provider()
769
+ return self._p("1_cellpose", "nucleus_foreground.tif")
770
+
771
+ # ------------------------------------------------------------------ #
772
+ # Frame helpers #
773
+ # ------------------------------------------------------------------ #
774
+
775
+
776
+ def _correction_frame_indices(self, layer) -> list[int]:
777
+ """Return frame indices based on the correction scope combo."""
778
+ if layer.data.ndim < 3:
779
+ return [0]
780
+ if self.correction_scope_combo.currentText() == "All frames":
781
+ return list(range(int(layer.data.shape[0])))
782
+ return [self._current_t()]
783
+
784
+ # ------------------------------------------------------------------ #
785
+ # Load labels #
786
+ # ------------------------------------------------------------------ #
787
+
788
+ @staticmethod
789
+ def _broadcast_ref(image, shape):
790
+ if image is None:
791
+ return None
792
+ if image.ndim == 2 and len(shape) >= 3:
793
+ return np.broadcast_to(image[np.newaxis], (shape[0],) + image.shape).copy()
794
+ return image
795
+
796
+ def _show_layer(self, name, data, kwargs, adder):
797
+ if name in self.viewer.layers:
798
+ try:
799
+ self.viewer.layers[name].data = data
800
+ except Exception:
801
+ self.viewer.layers.remove(self.viewer.layers[name])
802
+ adder(data, name=name, **kwargs)
803
+ else:
804
+ adder(data, name=name, **kwargs)
805
+
806
+ def _apply_loaded_layers(self, labels, cz, nz) -> None:
807
+ # Add the reference images first so the labels layer ends up at the
808
+ # top of the layer stack (otherwise the z-avg images render over it).
809
+ for img, name, cmap in (
810
+ (self._broadcast_ref(cz, labels.shape), _CELL_ZAVG_LAYER, "gray"),
811
+ (self._broadcast_ref(nz, labels.shape), _NUC_ZAVG_LAYER, "I Purple"),
812
+ ):
813
+ if img is None:
814
+ continue
815
+ if name in self.viewer.layers:
816
+ self.viewer.layers[name].data = img
817
+ self.viewer.layers[name].colormap = cmap
818
+ self.viewer.layers[name].blending = "minimum"
819
+ else:
820
+ self.viewer.add_image(img, name=name, colormap=cmap, blending="minimum")
821
+ self._correction_owned_layers.add(name)
822
+ self._show_layer(_TRACKED_CELL_LAYER, labels, {}, self.viewer.add_labels)
823
+ self._correction_owned_layers.add(_TRACKED_CELL_LAYER)
824
+
825
+ # ------------------------------------------------------------------ #
826
+ # Save labels #
827
+ # ------------------------------------------------------------------ #
828
+
829
+ def _on_save_labels(self) -> None:
830
+ if self._active_layer_mode():
831
+ self._correction_status(
832
+ "Save the layer via napari (File ▸ Save Selected Layers)."
833
+ )
834
+ return
835
+ lp = self._cell_labels_path()
836
+ if lp is None:
837
+ self._correction_status("No project open."); return
838
+ layer = self._correction_tracked_layer()
839
+ if layer is None:
840
+ self._correction_status("No labels layer."); return
841
+ data = np.asarray(layer.data)
842
+ if data.ndim != 3:
843
+ self._correction_status("Labels not 3D."); return
844
+ lp.parent.mkdir(parents=True, exist_ok=True)
845
+ imwrite_grayscale(lp, data.astype(np.uint32, copy=False), compression="zlib")
846
+ self._files_widget_refresh(self._pos_dir)
847
+ self._correction_dirty = False
848
+ self._correction_status(f"Saved {data.shape[0]} frames → {lp.name}.")
849
+
850
+ # ------------------------------------------------------------------ #
851
+ # Fill Holes #
852
+ # ------------------------------------------------------------------ #
853
+
854
+ def _on_fill_holes(self) -> None:
855
+ layer = self._correction_tracked_layer()
856
+ if layer is None:
857
+ self._correction_status("No cell labels loaded."); return
858
+ radius = int(self.hole_radius_spin.value())
859
+ frames = self._correction_frame_indices(layer)
860
+
861
+ changed_frames = 0
862
+ changed_pixels = 0
863
+ for t in frames:
864
+ seg2d = self.correction_widget._frame_view(layer, t)
865
+ before = seg2d.copy()
866
+ result = fill_label_holes(seg2d, radius=radius)
867
+ np.copyto(seg2d, result)
868
+ diff = int(np.sum(before != seg2d))
869
+ if diff:
870
+ changed_frames += 1
871
+ changed_pixels += diff
872
+ self.correction_widget._record_history(layer, t, before)
873
+
874
+ if changed_pixels:
875
+ layer.refresh()
876
+ if self.correction_widget._selected_label:
877
+ t_now = self._current_t()
878
+ self.correction_widget._update_highlight(
879
+ t_now, self.correction_widget._selected_label,
880
+ )
881
+ self._correction_status(
882
+ f"Filled holes in {changed_frames} frame(s), "
883
+ f"{changed_pixels} px changed. Unsaved."
884
+ )
885
+ else:
886
+ self._correction_status("No interior holes found.")
887
+
888
+ # ------------------------------------------------------------------ #
889
+ # Remove Stranded Fragments #
890
+ # ------------------------------------------------------------------ #
891
+
892
+ def _on_cleanup(self) -> None:
893
+ """Drop disconnected same-label fragments on the scoped frame(s).
894
+
895
+ Cells stay tied to the nuclei, so this only removes stray pixels that
896
+ broke off a label (e.g. after a carve); it never renumbers or resyncs.
897
+ Per-frame so each change is undoable via the layer history.
898
+ """
899
+ layer = self._correction_tracked_layer()
900
+ if layer is None:
901
+ self._correction_status("No cell labels loaded."); return
902
+ frames = self._correction_frame_indices(layer)
903
+
904
+ changed_frames = 0
905
+ changed_pixels = 0
906
+ for t in frames:
907
+ seg2d = self.correction_widget._frame_view(layer, t)
908
+ before = seg2d.copy()
909
+ clean_stranded_pixels(seg2d) # mutates seg2d in place
910
+ diff = int(np.sum(before != seg2d))
911
+ if diff:
912
+ changed_frames += 1
913
+ changed_pixels += diff
914
+ self.correction_widget._record_history(layer, t, before)
915
+
916
+ if changed_pixels:
917
+ layer.refresh()
918
+ if self.correction_widget._selected_label:
919
+ self.correction_widget._update_highlight(
920
+ self._current_t(), self.correction_widget._selected_label,
921
+ )
922
+ self._correction_status(
923
+ f"Removed stranded fragments in {changed_frames} frame(s), "
924
+ f"{changed_pixels} px changed. Unsaved."
925
+ )
926
+ else:
927
+ self._correction_status("No stranded fragments found.")
928
+
929
+ # ------------------------------------------------------------------ #
930
+ # Retracker (standalone full-editing only) — DB-free #
931
+ # ------------------------------------------------------------------ #
932
+
933
+ def _bind_full_editing_keys(self, layer) -> None:
934
+ """Bind the Q/E retrack + V validation hotkeys (full-editing only).
935
+
936
+ Layer-level keymap entries take precedence over napari's defaults, so
937
+ these reliably drive the DB-free toolkit while correction is active.
938
+ The inner CorrectionWidget already binds Delete (erase) when not
939
+ contour-only.
940
+ """
941
+ if not self._full_editing:
942
+ return
943
+ self._unbind_full_editing_keys()
944
+ for key, slot in (
945
+ ("Q", self._on_retrack_backward),
946
+ ("E", self._on_retrack_forward),
947
+ ("V", self._on_toggle_validation),
948
+ ):
949
+ def handler(_layer, _slot=slot):
950
+ _slot()
951
+
952
+ layer.bind_key(key, handler, overwrite=True)
953
+ self._retrack_keys_layer = layer
954
+
955
+ def _unbind_full_editing_keys(self) -> None:
956
+ layer = self._retrack_keys_layer
957
+ if layer is None:
958
+ return
959
+ for key in ("Q", "E", "V"):
960
+ try:
961
+ layer.bind_key(key, None)
962
+ except Exception:
963
+ pass
964
+ self._retrack_keys_layer = None
965
+
966
+ def _on_retrack_forward(self) -> None:
967
+ self._on_retrack("forward")
968
+
969
+ def _on_retrack_backward(self) -> None:
970
+ self._on_retrack("backward")
971
+
972
+ def _on_retrack(self, direction: str) -> None:
973
+ """Re-link the tracked stack from the current frame outward (DB-free).
974
+
975
+ Greedy geometric similarity to the already-retracked neighbour toward the
976
+ current frame; no validation/locking, so it is the pure standalone case
977
+ of the nucleus widget's Q/E retracker.
978
+ """
979
+ from itasc.cellpose.retrack import retrack_stack
980
+
981
+ layer = self._correction_tracked_layer()
982
+ if layer is None:
983
+ self._correction_status("No labels layer to retrack."); return
984
+ data = np.asarray(layer.data)
985
+ if data.ndim != 3 or data.shape[0] < 2:
986
+ self._correction_status(
987
+ "Retrack needs a 3D time-first stack (>= 2 frames)."
988
+ )
989
+ return
990
+ t0 = self._current_t()
991
+ if direction == "forward" and t0 >= data.shape[0] - 1:
992
+ self._correction_status("Already at the last frame."); return
993
+ if direction == "backward" and t0 <= 0:
994
+ self._correction_status("Already at the first frame."); return
995
+ try:
996
+ out = retrack_stack(
997
+ data,
998
+ start_frame=t0,
999
+ direction=direction,
1000
+ max_dist_px=float(self.retrack_max_dist_spin.value()),
1001
+ )
1002
+ except ValueError as exc:
1003
+ self._correction_status(str(exc)); return
1004
+ layer.data = out
1005
+ layer.refresh()
1006
+ self._correction_dirty = True
1007
+ if self.correction_widget._selected_label:
1008
+ self.correction_widget._update_highlight(
1009
+ t0, self.correction_widget._selected_label,
1010
+ )
1011
+ self._lineage_canvas.refresh()
1012
+ self._refresh_validated_overlay()
1013
+ self._correction_status(f"Retracked {direction} from t={t0}. Unsaved.")
1014
+
1015
+ # ------------------------------------------------------------------ #
1016
+ # Validation + track-list navigator (standalone full-editing only) #
1017
+ # ------------------------------------------------------------------ #
1018
+
1019
+ def _lineage_tracked_data(self) -> np.ndarray | None:
1020
+ layer = self._correction_tracked_layer()
1021
+ return np.asarray(layer.data) if layer is not None else None
1022
+
1023
+ def _current_cell_ids(self, t: int) -> set[int]:
1024
+ layer = self._correction_tracked_layer()
1025
+ if layer is None:
1026
+ return set()
1027
+ frame = frame_view_2d(np.asarray(layer.data), t)
1028
+ if frame is None:
1029
+ return set()
1030
+ return set(int(v) for v in np.unique(frame)) - {0}
1031
+
1032
+ def _frames_with_cell(self, cell_id: int) -> list[int]:
1033
+ layer = self._correction_tracked_layer()
1034
+ if layer is None:
1035
+ return []
1036
+ data = np.asarray(layer.data)
1037
+ if data.ndim < 3:
1038
+ return [0] if int(cell_id) in np.unique(data) else []
1039
+ frames = []
1040
+ for t in range(data.shape[0]):
1041
+ frame = frame_view_2d(data, t)
1042
+ if frame is not None and int(cell_id) in frame:
1043
+ frames.append(t)
1044
+ return frames
1045
+
1046
+ def _deselect_if_selection_gone(self) -> None:
1047
+ """Clear the selection when the selected cell no longer exists at the
1048
+ current frame (a relabel / removal may have dropped it)."""
1049
+ sel = self.correction_widget._selected_label
1050
+ if sel:
1051
+ ct = self._current_t()
1052
+ if sel not in self._current_cell_ids(ct):
1053
+ self.correction_widget.select_label(ct, 0)
1054
+
1055
+ def _on_toggle_validation(self) -> None:
1056
+ """Toggle validation for the selected cell across all its frames (V).
1057
+
1058
+ DB-free: flips membership in the in-memory ``self._validated_tracks``
1059
+ store rather than writing ``validated_cells.json`` (there is no project
1060
+ directory in the standalone active-layer tool).
1061
+ """
1062
+ layer = self._correction_tracked_layer()
1063
+ if layer is None:
1064
+ self._correction_status("No labels layer."); return
1065
+ sel = self.correction_widget._selected_label
1066
+ if not sel:
1067
+ self._correction_status(
1068
+ "Validation toggle: no cell selected (left-click first)."
1069
+ ); return
1070
+ t = self._current_t()
1071
+ if sel not in self._current_cell_ids(t):
1072
+ self._correction_status(f"Cell {sel} not present at t={t}."); return
1073
+ frames = self._frames_with_cell(sel)
1074
+ if not frames:
1075
+ return
1076
+ if sel in self._validated_tracks:
1077
+ del self._validated_tracks[sel]
1078
+ self._correction_status(
1079
+ f"Cell {sel} invalidated across {len(frames)} frame(s)."
1080
+ )
1081
+ else:
1082
+ self._validated_tracks[sel] = set(frames)
1083
+ self._correction_status(
1084
+ f"Cell {sel} validated across {len(frames)} frame(s)."
1085
+ )
1086
+ # Flag-only change (no pixels touched) — recolour without rescanning.
1087
+ self._lineage_canvas.refresh_status()
1088
+ self._refresh_validated_overlay()
1089
+
1090
+ def _on_remove_unvalidated_labels(self) -> None:
1091
+ """Zero out cell label pixels not validated for their frame."""
1092
+ layer = self._correction_tracked_layer()
1093
+ if layer is None:
1094
+ self._correction_status("No cell labels loaded."); return
1095
+ data = np.asarray(layer.data)
1096
+ # remove_unvalidated_labels expects a time-first stack of 2D frames;
1097
+ # squeeze singleton extra dims the same way _frame_view tolerates.
1098
+ squeezed = data
1099
+ while squeezed.ndim > 3:
1100
+ if squeezed.shape[1] != 1:
1101
+ self._correction_status(
1102
+ "Labels layer has a non-singleton extra dimension."
1103
+ )
1104
+ return
1105
+ squeezed = squeezed[:, 0]
1106
+ before = squeezed.copy()
1107
+ try:
1108
+ changed_frames, changed_pixels = remove_unvalidated_labels(
1109
+ squeezed, self._validated_tracks
1110
+ )
1111
+ except ValueError as exc:
1112
+ self._correction_status(str(exc)); return
1113
+ if not changed_pixels:
1114
+ self._correction_status("No unvalidated labels found."); return
1115
+ frame_range = range(squeezed.shape[0]) if squeezed.ndim >= 3 else [0]
1116
+ for t in frame_range:
1117
+ frame_before = before[t] if before.ndim >= 3 else before
1118
+ frame_after = squeezed[t] if squeezed.ndim >= 3 else squeezed
1119
+ if not np.array_equal(frame_before, frame_after):
1120
+ self.correction_widget._record_history(layer, t, frame_before)
1121
+ layer.refresh()
1122
+ self._deselect_if_selection_gone()
1123
+ self._lineage_canvas.refresh()
1124
+ self._refresh_validated_overlay()
1125
+ self._correction_status(
1126
+ f"Removed unvalidated labels in {changed_frames} frame(s), "
1127
+ f"{changed_pixels} px changed. Unsaved."
1128
+ )
1129
+
1130
+ def _refresh_validated_overlay(self) -> None:
1131
+ """Draw an opaque green border around every validated cell.
1132
+
1133
+ Standalone counterpart of ``ValidatedOverlayController``'s border
1134
+ mode: same visual (a napari Labels layer, contour-only, fully
1135
+ opaque), but masked from the in-memory ``self._validated_tracks``
1136
+ store instead of ``validated_cells.json`` (no project directory
1137
+ here to read one from).
1138
+ """
1139
+ layer = self._correction_tracked_layer()
1140
+ data = np.asarray(layer.data) if layer is not None else None
1141
+ if data is None or data.ndim < 3 or not self._validated_tracks:
1142
+ self._remove_validated_overlay()
1143
+ return
1144
+ mask = np.zeros(data.shape, dtype=np.uint8)
1145
+ n_frames = data.shape[0]
1146
+ for cell_id, frames in self._validated_tracks.items():
1147
+ for t in frames:
1148
+ if 0 <= t < n_frames:
1149
+ mask[t][data[t] == int(cell_id)] = 1
1150
+ colormap = direct_colormap({None: (0, 0, 0, 0), 1: _VALIDATED_OVERLAY_COLOR})
1151
+ if _VALIDATED_OVERLAY_LAYER in self.viewer.layers:
1152
+ overlay = self.viewer.layers[_VALIDATED_OVERLAY_LAYER]
1153
+ overlay.data = mask
1154
+ overlay.colormap = colormap
1155
+ else:
1156
+ overlay = self.viewer.add_labels(
1157
+ mask, name=_VALIDATED_OVERLAY_LAYER, opacity=1.0, colormap=colormap,
1158
+ )
1159
+ self._correction_owned_layers.add(_VALIDATED_OVERLAY_LAYER)
1160
+ overlay.contour = _VALIDATED_OVERLAY_CONTOUR
1161
+ overlay.opacity = 1.0
1162
+ self.viewer.layers.selection.active = layer
1163
+
1164
+ def _remove_validated_overlay(self) -> None:
1165
+ if _VALIDATED_OVERLAY_LAYER in self.viewer.layers:
1166
+ self.viewer.layers.remove(self.viewer.layers[_VALIDATED_OVERLAY_LAYER])
1167
+ self._correction_owned_layers.discard(_VALIDATED_OVERLAY_LAYER)
1168
+
1169
+ def _on_track_selection_changed(self, _t: int, cell_id: int) -> None:
1170
+ """Keep the accordion's expanded track in step with the image selection."""
1171
+ self._lineage_canvas.set_selection(int(cell_id or 0))
1172
+
1173
+ def _on_dims_changed_for_lineage(self, _event=None) -> None:
1174
+ self._lineage_canvas.set_current_frame(self._current_t())
1175
+
1176
+ def _navigate_to_cell_from_lineage(self, t: int, cell_id: int) -> None:
1177
+ """Accordion click: jump to frame ``t`` and select ``cell_id``."""
1178
+ try:
1179
+ step = list(self.viewer.dims.current_step)
1180
+ if step:
1181
+ step[0] = int(t)
1182
+ self.viewer.dims.current_step = tuple(step)
1183
+ except Exception:
1184
+ logger.exception("track-list navigation: frame jump failed")
1185
+ if int(cell_id) and int(cell_id) not in self._current_cell_ids(int(t)):
1186
+ return
1187
+ try:
1188
+ self.correction_widget.select_label(int(t), int(cell_id))
1189
+ except Exception:
1190
+ logger.exception("track-list navigation: cell select failed")