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,2185 @@
1
+ """Correction section widget for the nucleus workflow.
2
+
3
+ The public class :class:`NucleusCorrectionWidget` is a coordinator over a set of
4
+ focused collaborators (controllers + stateless helper modules + the
5
+ ``tracking_ultrack`` algorithms); see its docstring for the architecture map.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Callable
11
+ from types import SimpleNamespace
12
+
13
+ import napari
14
+ import numpy as np
15
+ import tifffile
16
+ from napari.qt.threading import thread_worker as _thread_worker
17
+ from qtpy.QtCore import Qt, QTimer
18
+ from qtpy.QtWidgets import (
19
+ QHBoxLayout,
20
+ QSizePolicy,
21
+ QSplitter,
22
+ QVBoxLayout,
23
+ QWidget,
24
+ )
25
+
26
+ from itasc.napari.correction._correction_centroids import (
27
+ apply_neutral_label_colormap,
28
+ ensure_label_colormap_entries,
29
+ refresh_label_colormap,
30
+ )
31
+ from itasc.napari.correction._correction_anchor import (
32
+ anchor_correction,
33
+ without_anchor_correction,
34
+ )
35
+ from itasc.napari.correction._correction_utils import (
36
+ frame_view_2d,
37
+ reassign_ids_ordered,
38
+ retrack_stack_direction,
39
+ track_order_by_frame_and_size,
40
+ )
41
+ from itasc.napari.correction._correction_commit import (
42
+ prepare_committed_labels,
43
+ remove_unvalidated_from_data,
44
+ )
45
+ from itasc.napari.correction._correction_layer_lifecycle import (
46
+ CorrectionViewStateMixin,
47
+ LayerViewState,
48
+ detach_higher_dim_stacks,
49
+ hide_all_layers,
50
+ reattach_layers,
51
+ )
52
+ from itasc.napari.correction._correction_layer_loader import (
53
+ add_correction_image_layer,
54
+ add_tracked_labels_and_track_layer,
55
+ remove_other_correction_label_layers,
56
+ )
57
+ from itasc.napari.correction._correction_protection import (
58
+ protected_cell_ids_at_frame,
59
+ protected_cell_mask,
60
+ )
61
+ from itasc.napari.correction._correction_validation import (
62
+ correction_for_label_frame,
63
+ corrections_for_label_frames,
64
+ selected_correction_target,
65
+ )
66
+ from itasc.napari._paths import NucleusWorkspace
67
+ from itasc.core.label_store import (
68
+ read_full_tracked_stack,
69
+ write_full_tracked_stack,
70
+ )
71
+ from itasc.tracking_ultrack.validation_state import (
72
+ add_anchor,
73
+ add_correction,
74
+ add_corrections,
75
+ invalidate_track,
76
+ is_track_validated,
77
+ read_corrections,
78
+ read_validated_frames,
79
+ read_validated_tracks,
80
+ remap_validated_tracks,
81
+ write_corrections,
82
+ )
83
+ from itasc.napari.ui_style import (
84
+ stage_header_label,
85
+ )
86
+ from itasc.napari.validated_overlay_controller import (
87
+ ValidatedOverlayController,
88
+ )
89
+ from itasc.napari.track_path_controller import AllTracksController
90
+ from itasc.napari.lineage_canvas_controller import LineageCanvasController
91
+ from itasc.napari.candidate_gallery_controller import CandidateGalleryController
92
+ from itasc.napari.correction._correction_takeover import (
93
+ hide_native_docks,
94
+ restore_native_docks,
95
+ )
96
+ from itasc.napari.correction._correction_ui_nucleus import build_nucleus_correction_ui
97
+ from itasc.napari.correction._correction_paint import paint_assignments
98
+ from itasc.napari.correction._correction_events import CorrectionEvents
99
+ from itasc.napari.correction._correction_keymap import HeldKeyRepeater
100
+ from itasc.napari.correction._correction_navigation import center_viewer_on_cell
101
+ from itasc.napari.correction._correction_playback import (
102
+ nav_repeat_interval_ms,
103
+ playback_loops,
104
+ )
105
+ from itasc.napari.correction._correction_ui import (
106
+ CollapsiblePane,
107
+ confirm_unsaved_before_deactivate,
108
+ set_checked_without_signal,
109
+ )
110
+ from itasc.tracking_ultrack.corrections import (
111
+ Correction,
112
+ corrections_from_validated_tracks,
113
+ )
114
+ from itasc.tracking_ultrack.config import TrackingConfig
115
+ from itasc.tracking_ultrack.db_build import (
116
+ annotate_database_from_corrections as _annotate_database_from_corrections,
117
+ )
118
+ from itasc.tracking_ultrack.extend import extend_track_from_db as _extend_track_from_db
119
+ from itasc.tracking_ultrack.swap_candidate import (
120
+ SwapCandidate as _SwapCandidate,
121
+ _SwapCursor,
122
+ list_swap_candidates,
123
+ cycle_index as _cycle_index,
124
+ nearest_area_index as _nearest_area_index,
125
+ )
126
+ from itasc.tracking_ultrack.retracker import retrack_frame_constrained
127
+
128
+ logger = logging.getLogger(__name__)
129
+
130
+ _TRACKED_LAYER = "Tracked: Nucleus"
131
+ _CORRECTION_TRACKED_LAYER = "[Correction] Nucleus Labels"
132
+ _CORRECTION_CELL_ZAVG_LAYER = "[Correction] Cell z-avg"
133
+ _CORRECTION_NUC_ZAVG_LAYER = "[Correction] Nucleus z-avg"
134
+ _NUCLEUS_TRACK_COLOR_SCALE = 1.0
135
+ # Filled (by-ID) view draws cells solid; outline view keeps them semi-transparent
136
+ # so the reference images underneath stay visible through the contours.
137
+ _FILLED_LABEL_OPACITY = 1.0
138
+ _OUTLINE_LABEL_OPACITY = 0.7
139
+ # Width a collapsed workspace pane (gallery / accordion) shrinks to — must match
140
+ # the slim show-tab in CollapsiblePane.
141
+ _PANEL_STRIP_W = 24
142
+
143
+ _DEFAULT_DEPENDENCIES = {
144
+ "annotate_database_from_corrections": _annotate_database_from_corrections,
145
+ "extend_track_from_db": _extend_track_from_db,
146
+ "read_corrections": lambda *args, **kwargs: read_corrections(*args, **kwargs),
147
+ "read_validated_tracks": (
148
+ lambda *args, **kwargs: read_validated_tracks(*args, **kwargs)
149
+ ),
150
+ "thread_worker": _thread_worker,
151
+ }
152
+
153
+
154
+ class NucleusCorrectionWidget(CorrectionViewStateMixin, QWidget):
155
+ """Coordinator for the nucleus tracking-correction workflow.
156
+
157
+ This class is intentionally a **hub, not a god object**: it owns the controls
158
+ and orchestrates them, but the actual work lives in focused collaborators.
159
+ Its size reflects a wide *coordination* surface (many controls, many refresh
160
+ paths) rather than logic that belongs elsewhere — the heavy logic has been
161
+ pushed out to the pieces below, and splitting the remaining glue further only
162
+ relocates ``self.collaborator.…`` calls without reducing the coupling.
163
+
164
+ Where the work actually lives
165
+ -----------------------------
166
+ * Drawing / contour editing — the embedded :class:`CorrectionWidget`
167
+ (``self.correction_widget``): brush, redraw, new-shape painting, selection.
168
+ * Display subsystems, each its own controller:
169
+ ``self._validated_overlay`` (:class:`ValidatedOverlayController`),
170
+ ``self._all_tracks`` (:class:`AllTracksController`, the track-path comet),
171
+ ``self._lineage_canvas`` (:class:`LineageCanvasController`, the accordion +
172
+ film strip) and ``self._candidate_gallery``
173
+ (:class:`CandidateGalleryController`).
174
+ * Stateless helpers in sibling modules: control assembly
175
+ (:mod:`_correction_ui_nucleus`), label painting (:mod:`_correction_paint`),
176
+ camera framing (:mod:`_correction_navigation`), playback queries
177
+ (:mod:`_correction_playback`), held-key auto-repeat
178
+ (:mod:`_correction_keymap`), label colormaps (:mod:`_correction_centroids`)
179
+ and the shared view-state / owned-layer lifecycle
180
+ (:class:`CorrectionViewStateMixin`, also used by the cell widget).
181
+ * Tracking algorithms — :mod:`itasc.tracking_ultrack` (extend, swap
182
+ candidates, retrack, DB annotation, validation/commit). The widget only
183
+ orchestrates these: read the layer, call the algorithm, paint the result,
184
+ fan out the refreshes, set status.
185
+
186
+ What stays here (the coordinator's own job)
187
+ -------------------------------------------
188
+ Building + wiring the controls, the correction-mode lifecycle
189
+ (activate/deactivate, view-state capture/restore, owned-layer teardown, the
190
+ focus-mode workspace dock), and translating user actions — mask edits, track
191
+ selection, navigation, keyboard shortcuts — into calls on the collaborators
192
+ above followed by the appropriate layer repaint + refresh fan-out.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ viewer,
198
+ *,
199
+ edit_callback=None,
200
+ workspace_provider: Callable[[], NucleusWorkspace | None] | None = None,
201
+ ultrack_config_provider: Callable[[], TrackingConfig] | None = None,
202
+ dependencies: dict[str, Callable] | None = None,
203
+ focus_takeover_callback: Callable[[bool], None] | None = None,
204
+ files_widget_refresh_callback: Callable[[], None] | None = None,
205
+ finalize_btn: QWidget | None = None,
206
+ finalize_header_btn: QWidget | None = None,
207
+ parent: QWidget | None = None,
208
+ ) -> None:
209
+ super().__init__(parent)
210
+ self.viewer = viewer
211
+ # Display collaborators subscribe to these; operations emit them instead
212
+ # of hand-calling each controller's refresh (see _wire_events).
213
+ self.events = CorrectionEvents()
214
+ self._workspace_provider = workspace_provider
215
+ # Optional host-owned "Finalize" buttons (the handler lives on the host
216
+ # workflow widget, which owns the promote paths). ``_finalize_btn`` rides
217
+ # at the tail of the correction toolbar (visible only in active mode);
218
+ # ``_finalize_header_btn`` sits beside the on/off toggle in the top bar
219
+ # (always visible). Either may be None → that slot has no finalize action.
220
+ self._finalize_btn = finalize_btn
221
+ self._finalize_header_btn = finalize_header_btn
222
+ self._ultrack_config_provider = ultrack_config_provider
223
+ # Called with True once correction-mode activation succeeds and False on
224
+ # deactivate, so the host can hide its other workflow sections for a
225
+ # full-window focus mode (the widget deliberately holds no sibling refs).
226
+ self._focus_takeover = focus_takeover_callback or (lambda _on: None)
227
+ # Called after the working tracked_labels.tif is written (save or commit)
228
+ # so the host repaints the section dot + catalog rail (mirrors the cell
229
+ # correction widget). No-op when the host wires nothing.
230
+ self._files_widget_refresh = files_widget_refresh_callback or (lambda: None)
231
+ self._local_workspace: NucleusWorkspace | None = None
232
+ self._dependencies = {**_DEFAULT_DEPENDENCIES, **(dependencies or {})}
233
+ self._edit_callback = edit_callback or self._on_cells_edited
234
+ self._correction_owned_layers: set[str] = set()
235
+ self._correction_view_state: LayerViewState | None = None
236
+ self._correction_dirty: bool = False
237
+ # True only while a lineage-canvas click is driving the selection, so the
238
+ # selection listener does not scroll the lineage canvas back onto the row
239
+ # the user just clicked there (image-viewer clicks should recenter it;
240
+ # lineage clicks should not yank the canvas around).
241
+ self._navigating_from_lineage: bool = False
242
+ self._swap_cursor: _SwapCursor | None = None
243
+ self._native_dock_state: dict[str, bool] = {}
244
+ # The single right-side workspace dock, its container (top bar + reveal
245
+ # area + body splitter), and the horizontal body splitter it hosts
246
+ # (toolbar · candidate gallery · accordion); all None while correction
247
+ # mode is off. The top bar + reveal area are reparented out of the plugin
248
+ # dock into the container on activate. ``_workspace_splitter`` doubles as
249
+ # the "focus mode is docked" sentinel the refresh paths gate on.
250
+ self._workspace_dock = None
251
+ self._workspace_splitter: QSplitter | None = None
252
+ self._workspace_container: QWidget | None = None
253
+ # Collapsible wrappers around the candidate gallery and the accordion;
254
+ # each carries a ✕ hide button and collapses to a slim show-tab. None
255
+ # until the body splitter is built on activate.
256
+ self._gallery_pane: CollapsiblePane | None = None
257
+ self._accordion_pane: CollapsiblePane | None = None
258
+ # Higher-dim stacks (e.g. a raw T,Z,Y,X z-stack) removed on activate so
259
+ # the viewer collapses to a single frame slider, kept verbatim here to
260
+ # re-append on deactivate. The plugin dock + its pre-correction width are
261
+ # captured so the dock, blown up to half-window for focus mode, can be
262
+ # shrunk back when correction turns off.
263
+ self._detached_stack_layers: list = []
264
+ self._pre_correction_dock = None
265
+ self._pre_correction_dock_width: int | None = None
266
+ self._validated_overlay = ValidatedOverlayController(
267
+ self.viewer,
268
+ tracked_layer_provider=self._correction_tracked_layer,
269
+ pos_dir_provider=lambda: self._pos_dir,
270
+ owned_layers=self._correction_owned_layers,
271
+ )
272
+ self._setup_ui()
273
+ self._all_tracks = AllTracksController(
274
+ self.viewer,
275
+ tracked_layer_provider=self._correction_tracked_layer,
276
+ selected_label_provider=lambda: int(
277
+ getattr(self.correction_widget, "_selected_label", 0) or 0
278
+ ),
279
+ enabled_provider=self.track_path_btn.isChecked,
280
+ current_t_provider=self._current_t,
281
+ status_callback=self._correction_status,
282
+ owned_layers=self._correction_owned_layers,
283
+ )
284
+ self._lineage_canvas = LineageCanvasController(
285
+ self.viewer,
286
+ tracked_data_provider=self._ensure_tracked_layer_data,
287
+ tracked_layer_provider=self._correction_tracked_layer,
288
+ intensity_layer_provider=self._film_strip_intensity_layer,
289
+ selected_label_provider=lambda: int(
290
+ getattr(self.correction_widget, "_selected_label", 0) or 0
291
+ ),
292
+ current_t_provider=self._current_t,
293
+ on_activate=self._navigate_to_error,
294
+ pos_dir_provider=lambda: self._pos_dir,
295
+ )
296
+ self._candidate_gallery = CandidateGalleryController(
297
+ self.viewer,
298
+ tracked_data_provider=self._ensure_tracked_layer_data,
299
+ tracked_layer_provider=self._correction_tracked_layer,
300
+ intensity_layer_provider=self._film_strip_intensity_layer,
301
+ selected_label_provider=lambda: int(
302
+ getattr(self.correction_widget, "_selected_label", 0) or 0
303
+ ),
304
+ current_t_provider=self._current_t,
305
+ db_path_provider=self._ultrack_db_path,
306
+ protected_mask_provider=self._gallery_protected_mask,
307
+ extend_kwargs_provider=self._extend_kwargs,
308
+ apply_swap=self._apply_swap_candidate,
309
+ apply_extend=self._apply_extend_candidate,
310
+ status_callback=self._correction_status,
311
+ )
312
+ self._wire_events()
313
+
314
+ def _wire_events(self) -> None:
315
+ """Subscribe the display collaborators to correction-domain events.
316
+
317
+ A label edit fans out to the label colormap, the validated overlay, the
318
+ focused track's comet, the lineage canvas and the candidate gallery —
319
+ each self-gating through the providers it already holds. The emitter
320
+ (:meth:`_on_cells_edited`) no longer needs to know any of these listeners
321
+ exist; the subscriber map lives here, in one greppable place.
322
+
323
+ (Prototype: only ``labels_edited`` is event-driven so far; the other
324
+ refresh paths still fan out directly until they are migrated too.)
325
+ """
326
+ e = self.events
327
+ e.labels_edited.connect(self._refresh_correction_label_visuals_for_edit)
328
+ e.labels_edited.connect(self._apply_overlay_edit)
329
+ e.labels_edited.connect(self._apply_track_path_edit)
330
+ e.labels_edited.connect(lambda *_: self._refresh_lineage_canvas_if_shown())
331
+ e.labels_edited.connect(lambda *_: self._refresh_candidate_gallery_if_shown())
332
+ e.tracks_rebuilt.connect(self._apply_track_path_rebuilt)
333
+ e.tracks_rebuilt.connect(self._refresh_lineage_canvas_if_shown)
334
+ e.tracks_rebuilt.connect(self._refresh_candidate_gallery_if_shown)
335
+ e.validation_changed.connect(self._refresh_validated_overlay)
336
+ e.validation_changed.connect(self._refresh_validation_counter)
337
+ e.validation_changed.connect(self._refresh_lineage_canvas_status_if_shown)
338
+ e.swap_stepped.connect(self._apply_track_path_rebuilt)
339
+ e.swap_stepped.connect(self._refresh_lineage_detail_if_shown)
340
+ e.swap_stepped.connect(self._refresh_candidate_gallery_if_shown)
341
+ e.stack_relabeled.connect(self._refresh_correction_label_visuals)
342
+ e.stack_relabeled.connect(self._refresh_validated_overlay)
343
+ e.stack_relabeled.connect(self._refresh_validation_counter)
344
+ e.stack_relabeled.connect(self._refresh_lineage_canvas_if_shown)
345
+
346
+ @property
347
+ def _workspace(self) -> NucleusWorkspace | None:
348
+ if self._workspace_provider is not None:
349
+ return self._workspace_provider()
350
+ return self._local_workspace
351
+
352
+ @_workspace.setter
353
+ def _workspace(self, value: NucleusWorkspace | None) -> None:
354
+ self._local_workspace = value
355
+
356
+ @property
357
+ def _pos_dir(self):
358
+ """The nucleus annotation/store directory (validation JSONs live here).
359
+
360
+ Retained under this name because the validation API and child
361
+ controllers thread it straight through; it now resolves from the active
362
+ :class:`NucleusWorkspace` rather than a position directory.
363
+ """
364
+ ws = self._workspace
365
+ return ws.nucleus_dir if ws is not None else None
366
+
367
+ def _dependency(self, name: str):
368
+ return self._dependencies[name]
369
+
370
+ def _setup_ui(self) -> None:
371
+ # All one-time control assembly + signal wiring lives in the builder
372
+ # module so this widget stays focused on behaviour.
373
+ build_nucleus_correction_ui(self)
374
+
375
+ @staticmethod
376
+ def _set_checked_without_signal(button, checked: bool) -> None:
377
+ set_checked_without_signal(button, checked)
378
+
379
+ def _sync_correction_panel_visibility(self) -> None:
380
+ show_params = self.params_btn.isChecked()
381
+ show_shortcuts = self.shortcuts_btn.isChecked()
382
+ show_active = self._correction_active_content_visible
383
+
384
+ self.extend_retrack_params_section.setVisible(show_params)
385
+ self.shortcuts_section.setVisible(show_shortcuts)
386
+ self.toolbar.setVisible(show_active)
387
+ # The title is always shown: as a "Tracking Correction" stage pill next
388
+ # to the on/off button in the inactive plugin dock, and as a full-size
389
+ # workspace title once correction mode is active. The shortcuts / params
390
+ # toggles and the view toggles still only appear when active.
391
+ self.header_lbl.setVisible(True)
392
+ self._apply_header_title_style(show_active)
393
+ self.shortcuts_btn.setVisible(show_active)
394
+ self.params_btn.setVisible(show_active)
395
+ self.track_path_btn.setVisible(show_active)
396
+ self.spotlight_btn.setVisible(show_active)
397
+ self.filled_view_btn.setVisible(show_active)
398
+ # The status line is only meaningful inside the active workspace; hide it
399
+ # when inactive so it never wraps the plugin-dock title pill to two rows.
400
+ if not show_active:
401
+ self.status_lbl.setVisible(False)
402
+
403
+ # Collapse the reveal area whenever both params and shortcuts are closed
404
+ # (even while active): it only holds those two panels, so leaving it
405
+ # expanded just pins dead vertical space and stops the body (gallery /
406
+ # tracking overview) from growing back to full height.
407
+ if show_params or show_shortcuts:
408
+ self.section.expand()
409
+ else:
410
+ self.section.collapse()
411
+
412
+ def _apply_header_title_style(self, active: bool) -> None:
413
+ """Style the title as a workspace heading (active) or stage pill (idle)."""
414
+ if active:
415
+ self.header_lbl.setProperty("itasc_stage_key", None)
416
+ self.header_lbl.setStyleSheet("font-weight: bold; font-size: 12pt;")
417
+ else:
418
+ stage_header_label(self.header_lbl, "nucleus")
419
+
420
+ def _on_correction_params_button_toggled(self, checked: bool) -> None:
421
+ # 📖 and ⚙ are independent: both can be open at once — the reveal area
422
+ # simply grows to fit. No mutual exclusion.
423
+ self.extend_retrack_params_section._toggle.setChecked(checked)
424
+ self._sync_correction_panel_visibility()
425
+
426
+ def _on_correction_shortcuts_button_toggled(self, checked: bool) -> None:
427
+ self.shortcuts_section._toggle.setChecked(checked)
428
+ self._sync_correction_panel_visibility()
429
+
430
+ def _tracked_path(self):
431
+ ws = self._workspace
432
+ return ws.tracked if ws is not None else None
433
+
434
+ def _foreground_path(self):
435
+ ws = self._workspace
436
+ return ws.foreground if ws is not None else None
437
+
438
+ def _ultrack_workdir(self):
439
+ ws = self._workspace
440
+ return ws.ultrack_workdir if ws is not None else None
441
+
442
+ def _ultrack_db_path(self):
443
+ ws = self._workspace
444
+ return ws.ultrack_db if ws is not None else None
445
+
446
+ def _ultrack_config_from_controls(self):
447
+ if self._ultrack_config_provider is not None:
448
+ return self._ultrack_config_provider()
449
+ return TrackingConfig()
450
+
451
+ def _ensure_tracked_layer_data(self) -> np.ndarray | None:
452
+ layer = self._correction_tracked_layer()
453
+ if layer is not None:
454
+ return np.asarray(layer.data)
455
+ tracked_path = self._tracked_path()
456
+ if tracked_path is None or not tracked_path.exists():
457
+ return None
458
+ labels = np.asarray(tifffile.imread(str(tracked_path)), dtype=np.uint32)
459
+ if labels.ndim == 4 and labels.shape[1] == 1:
460
+ labels = labels[:, 0]
461
+ return labels
462
+
463
+
464
+ def _cell_foreground_path(self):
465
+ ws = self._workspace
466
+ return ws.cell_foreground if ws is not None else None
467
+
468
+ def _nucleus_foreground_path(self):
469
+ # The nucleus foreground *is* the workspace foreground; named for the
470
+ # load site, where "nucleus" disambiguates it from the cell foreground.
471
+ return self._foreground_path()
472
+
473
+
474
+ def _current_cell_ids(self, t: int) -> set[int]:
475
+ layer = self._correction_tracked_layer()
476
+ if layer is None:
477
+ return set()
478
+ frame = frame_view_2d(np.asarray(layer.data), t)
479
+ if frame is None:
480
+ return set()
481
+ return set(int(value) for value in np.unique(frame)) - {0}
482
+
483
+ def _correction_tracked_layer(self):
484
+ if _CORRECTION_TRACKED_LAYER in self.viewer.layers:
485
+ return self.viewer.layers[_CORRECTION_TRACKED_LAYER]
486
+ if _TRACKED_LAYER in self.viewer.layers:
487
+ return self.viewer.layers[_TRACKED_LAYER]
488
+ return None
489
+
490
+
491
+ def _add_correction_image_layer(self, data: np.ndarray, name: str, colormap: str) -> None:
492
+ add_correction_image_layer(
493
+ self.viewer,
494
+ data,
495
+ name=name,
496
+ colormap=colormap,
497
+ owned_layer_names=self._correction_owned_layers,
498
+ )
499
+
500
+
501
+ def _on_save_tracked(self) -> None:
502
+ tracked_path = self._tracked_path()
503
+ if tracked_path is None:
504
+ self._correction_status("No project open."); return
505
+ layer = self._correction_tracked_layer()
506
+ if layer is None:
507
+ self._correction_status("No tracked layer to save."); return
508
+ if layer.data.ndim != 3:
509
+ self._correction_status("Tracked layer is not a 3D stack."); return
510
+ n = layer.data.shape[0]
511
+ write_full_tracked_stack(tracked_path, np.asarray(layer.data))
512
+ self._correction_dirty = False
513
+ self._correction_status(f"Saved {n} frame(s) to {tracked_path.name}.")
514
+ self._files_widget_refresh()
515
+
516
+ def _refresh_tracked_layer_from_disk(self) -> None:
517
+ """Overwrite the 'Tracked: Nucleus' layer data from the saved TIFF.
518
+
519
+ Called when correction mode is deactivated so the pipeline widget's
520
+ re-solve reads the latest saved state rather than stale in-memory data.
521
+ Does nothing if the file does not exist or if the layer is absent.
522
+ """
523
+ tracked_path = self._tracked_path()
524
+ if tracked_path is None or not tracked_path.exists():
525
+ return
526
+ if _TRACKED_LAYER not in self.viewer.layers:
527
+ return
528
+ try:
529
+ from itasc.core.label_store import read_full_tracked_stack
530
+ data = np.asarray(read_full_tracked_stack(tracked_path), dtype=np.uint32)
531
+ self.viewer.layers[_TRACKED_LAYER].data = data
532
+ except Exception:
533
+ pass
534
+
535
+ def _load_correction_layers_from_disk(self) -> bool:
536
+ tracked_path = self._tracked_path()
537
+ if tracked_path is None or not tracked_path.exists():
538
+ self._correction_status("No tracked labels file found.")
539
+ return False
540
+
541
+ self._remove_correction_owned_layers()
542
+ self._remove_other_correction_prefix_layers()
543
+ stack = read_full_tracked_stack(tracked_path)
544
+
545
+ # The nucleus reads as "bop purple" on its own, but when the cell z-avg is
546
+ # also shown (a grey channel beneath it) "I Purple" separates better.
547
+ cell_path = self._cell_foreground_path()
548
+ cell_present = cell_path is not None and cell_path.exists()
549
+ nucleus_cmap = "I Purple" if cell_present else "bop purple"
550
+
551
+ for data, name, cmap in (
552
+ (
553
+ cell_path,
554
+ _CORRECTION_CELL_ZAVG_LAYER,
555
+ "gray",
556
+ ),
557
+ (
558
+ self._nucleus_foreground_path(),
559
+ _CORRECTION_NUC_ZAVG_LAYER,
560
+ nucleus_cmap,
561
+ ),
562
+ ):
563
+ if data is not None and data.exists():
564
+ add_correction_image_layer(
565
+ self.viewer,
566
+ np.asarray(tifffile.imread(str(data)), dtype=np.float32),
567
+ name=name,
568
+ colormap=cmap,
569
+ owned_layer_names=self._correction_owned_layers,
570
+ )
571
+
572
+ add_tracked_labels_and_track_layer(
573
+ self.viewer,
574
+ stack,
575
+ labels_layer_name=_CORRECTION_TRACKED_LAYER,
576
+ owned_layer_names=self._correction_owned_layers,
577
+ color_scale=_NUCLEUS_TRACK_COLOR_SCALE,
578
+ )
579
+ # Build the live all-tracks overlay from the labels just loaded (a no-op
580
+ # while the "Track path" toggle is off).
581
+ self._all_tracks.refresh()
582
+
583
+ self._correction_status(f"Loaded tracked stack {stack.shape} into correction mode.")
584
+ return True
585
+
586
+ def _remove_other_correction_prefix_layers(self) -> None:
587
+ remove_other_correction_label_layers(
588
+ self.viewer,
589
+ owned_layer_names=self._correction_owned_layers,
590
+ label_layer_type=napari.layers.Labels,
591
+ )
592
+
593
+ def _on_load_tracked(self) -> None:
594
+ self._load_correction_layers_from_disk()
595
+
596
+ def _validated_track_ids(self) -> list[int]:
597
+ """Validated track IDs, used to group reassign priority."""
598
+ if self._pos_dir is None:
599
+ return []
600
+ read_validated_tracks_fn = self._dependency("read_validated_tracks")
601
+ validated_tracks = read_validated_tracks_fn(self._pos_dir) or {}
602
+ return sorted(int(cid) for cid in validated_tracks)
603
+
604
+ def _on_reassign_ids(self) -> None:
605
+ layer = self._correction_tracked_layer()
606
+ if layer is None:
607
+ self._correction_status("No tracked layer loaded."); return
608
+ stack = np.asarray(layer.data)
609
+ validated = self._validated_track_ids()
610
+ self._correction_status("Reassigning cell IDs...")
611
+
612
+ @self._dependency("thread_worker")(connect={
613
+ "returned": self._on_reassign_ids_done,
614
+ "errored": self._on_correction_worker_error,
615
+ })
616
+ def _worker():
617
+ order = track_order_by_frame_and_size(stack, validated)
618
+ return reassign_ids_ordered(stack, order)
619
+
620
+ _worker()
621
+
622
+ def _on_reassign_ids_done(self, result: tuple) -> None:
623
+ remapped, n_cells, old_to_new = result
624
+ layer = self._correction_tracked_layer()
625
+ if layer is not None:
626
+ layer.data = remapped
627
+ if self._pos_dir is not None and old_to_new:
628
+ remap_validated_tracks(self._pos_dir, old_to_new)
629
+ self.events.stack_relabeled.emit()
630
+ self._correction_status(
631
+ f"Reassigned {n_cells} cell IDs to range 1-{n_cells}. Unsaved."
632
+ )
633
+
634
+ def _commit_reassign_ids(self, layer) -> int:
635
+ stack = np.asarray(layer.data)
636
+ order = track_order_by_frame_and_size(stack, self._validated_track_ids())
637
+ remapped, n_cells, old_to_new = reassign_ids_ordered(stack, order)
638
+ layer.data = remapped
639
+ self._refresh_correction_label_visuals()
640
+ if self._pos_dir is not None and old_to_new:
641
+ remap_validated_tracks(self._pos_dir, old_to_new)
642
+ return int(n_cells)
643
+
644
+ def _selected_correction_target(self) -> tuple[int, int, float, float] | None:
645
+ if self._pos_dir is None:
646
+ self._correction_status("No project open."); return None
647
+ layer = self._correction_tracked_layer()
648
+ if layer is None:
649
+ self._correction_status("No tracked layer loaded."); return None
650
+ cell_id = int(getattr(self.correction_widget, "_selected_label", 0) or 0)
651
+ if cell_id == 0:
652
+ self._correction_status("No cell selected (left-click first)."); return None
653
+ t = self._current_t()
654
+ target = selected_correction_target(
655
+ np.asarray(layer.data),
656
+ cell_id=cell_id,
657
+ frame=t,
658
+ )
659
+ if target is None:
660
+ self._correction_status(f"Cell {cell_id} not present at t={t}."); return None
661
+ return target.cell_id, target.frame, target.y, target.x
662
+
663
+ def _validated_correction_for_frame(
664
+ self, cell_id: int, t: int, data: np.ndarray
665
+ ) -> Correction | None:
666
+ return correction_for_label_frame(
667
+ data,
668
+ cell_id=cell_id,
669
+ frame=t,
670
+ )
671
+
672
+ def _on_validate_track(self) -> None:
673
+ if self._pos_dir is None:
674
+ self._correction_status("No project open."); return
675
+ layer = self._correction_tracked_layer()
676
+ if layer is None:
677
+ self._correction_status("No tracked layer loaded."); return
678
+ cell_id = int(getattr(self.correction_widget, "_selected_label", 0) or 0)
679
+ if cell_id == 0:
680
+ self._correction_status("No cell selected (left-click first)."); return
681
+ data = np.asarray(layer.data)
682
+ frames = self._frames_with_cell(cell_id)
683
+ if not frames:
684
+ self._correction_status(f"Cell {cell_id} not present in tracked labels."); return
685
+ add_corrections(
686
+ self._pos_dir,
687
+ corrections_for_label_frames(
688
+ data,
689
+ cell_id=cell_id,
690
+ frames=frames,
691
+ ),
692
+ )
693
+ self.events.validation_changed.emit()
694
+ self._correction_status(
695
+ f"Validated track {cell_id} across {len(frames)} frame(s)."
696
+ )
697
+
698
+ def _on_anchor_here(self) -> None:
699
+ target = self._selected_correction_target()
700
+ if target is None or self._pos_dir is None:
701
+ return
702
+ cell_id, t, y, x = target
703
+ corrections = read_corrections(self._pos_dir)
704
+ removal = without_anchor_correction(corrections, cell_id=cell_id, frame=t)
705
+ if removal.removed:
706
+ write_corrections(self._pos_dir, removal.remaining)
707
+ self.events.stack_relabeled.emit()
708
+ self._correction_status(f"Unanchored cell {cell_id} at t={t}.")
709
+ return
710
+ layer = self._correction_tracked_layer()
711
+ if layer is None:
712
+ add_correction(
713
+ self._pos_dir,
714
+ anchor_correction(cell_id=cell_id, frame=t, y=y, x=x),
715
+ )
716
+ self.events.stack_relabeled.emit()
717
+ self._correction_status(f"Anchored cell {cell_id} at t={t}.")
718
+ return
719
+ filled = add_anchor(
720
+ self._pos_dir,
721
+ cell_id=cell_id,
722
+ t=t,
723
+ y=y,
724
+ x=x,
725
+ tracked_labels=np.asarray(layer.data),
726
+ )
727
+ self.events.stack_relabeled.emit()
728
+ suffix = f" (gap-filled {filled} frame(s))" if filled else ""
729
+ self._correction_status(f"Anchored cell {cell_id} at t={t}.{suffix}")
730
+
731
+ def _on_annotate_database(self) -> None:
732
+ pos_dir = self._pos_dir
733
+ if pos_dir is None:
734
+ self._correction_status("No project open."); return
735
+ db_path = self._ultrack_db_path()
736
+ if db_path is None or not db_path.exists():
737
+ self._correction_status("data.db not found — run DB Generation first."); return
738
+ score_path = self._foreground_path()
739
+ if score_path is None or not score_path.exists():
740
+ self._correction_status(
741
+ "Missing: nucleus_foreground.tif — build divergence maps first."
742
+ ); return
743
+ working_dir = self._ultrack_workdir()
744
+ if working_dir is None:
745
+ self._correction_status("No Ultrack working directory."); return
746
+
747
+ read_corrections_fn = self._dependency("read_corrections")
748
+ read_validated_tracks_fn = self._dependency("read_validated_tracks")
749
+ corrections = list(read_corrections_fn(pos_dir))
750
+ validated_tracks = read_validated_tracks_fn(pos_dir) or None
751
+ tracked_labels = self._ensure_tracked_layer_data()
752
+ if validated_tracks and tracked_labels is None:
753
+ self._correction_status(
754
+ "Annotation from validated tracks requires tracked_labels.tif "
755
+ "(layer not loaded and file not on disk)."
756
+ ); return
757
+ if corrections and validated_tracks and tracked_labels is not None:
758
+ existing = {
759
+ (int(c.cell_id), int(c.t))
760
+ for c in corrections
761
+ if getattr(c, "kind", None) == "validated"
762
+ }
763
+ corrections = list(corrections) + [
764
+ c for c in corrections_from_validated_tracks(validated_tracks, tracked_labels)
765
+ if (int(c.cell_id), int(c.t)) not in existing
766
+ ]
767
+ validated_tracks = None
768
+
769
+ cfg = self._ultrack_config_from_controls()
770
+ self._correction_status("Annotating Ultrack database...")
771
+
772
+ @self._dependency("thread_worker")(connect={
773
+ "returned": self._on_annotate_database_done,
774
+ "errored": self._on_correction_worker_error,
775
+ })
776
+ def _worker():
777
+ return self._dependency("annotate_database_from_corrections")(
778
+ working_dir=working_dir,
779
+ cfg=cfg,
780
+ score_signal_path=score_path,
781
+ corrections=corrections,
782
+ validated_tracks=validated_tracks,
783
+ tracked_labels=tracked_labels,
784
+ )
785
+
786
+ _worker()
787
+
788
+ def _on_annotate_database_done(self, report) -> None:
789
+ fake_nodes = int(getattr(report, "fake_nodes", 0) or 0)
790
+ anchor_nodes = int(getattr(report, "anchor_nodes", 0) or 0)
791
+ anchor_links = int(getattr(report, "anchor_links", 0) or 0)
792
+ scored_nodes = int(getattr(report, "scored_nodes", 0) or 0)
793
+ inserted_nodes = int(getattr(report, "injected_homemade_anchors", 0) or 0)
794
+ inserted_links = int(getattr(report, "anchor_incident_links_inserted", 0) or 0)
795
+ self._correction_status(
796
+ "Annotated DB: "
797
+ f"{fake_nodes} FAKE, {anchor_nodes} REAL, "
798
+ f"{anchor_links + inserted_links} link(s), "
799
+ f"{inserted_nodes} inserted node(s), {scored_nodes} scored."
800
+ )
801
+
802
+ def _on_extend_backward(self) -> None:
803
+ self._on_extend(direction="backward")
804
+
805
+ def _on_extend_forward(self) -> None:
806
+ self._on_extend(direction="forward")
807
+
808
+ def _on_extend(self, direction: str) -> None:
809
+ layer = self._correction_tracked_layer()
810
+ if layer is None:
811
+ self._correction_status("No tracked layer loaded."); return
812
+ db_path = self._ultrack_db_path()
813
+ if db_path is None or not db_path.exists():
814
+ self._correction_status("Extend: data.db not found - run DB Generation first."); return
815
+ source_id = self.correction_widget._selected_label
816
+ if not source_id:
817
+ self._correction_status("Extend: no cell selected (left-click first)."); return
818
+
819
+ t = self._current_t()
820
+ tracked = np.asarray(layer.data)
821
+ T = tracked.shape[0]
822
+
823
+ target_frame = t + (1 if direction == "forward" else -1)
824
+ if direction == "forward" and t >= T - 1:
825
+ self._correction_status("Already at last frame."); return
826
+ if direction == "backward" and t <= 0:
827
+ self._correction_status("Already at first frame."); return
828
+ if not np.any(tracked[t] == source_id):
829
+ self._correction_status(f"Cell {source_id} not present at t={t}."); return
830
+
831
+ read_validated_tracks_fn = self._dependency("read_validated_tracks")
832
+ read_corrections_fn = self._dependency("read_corrections")
833
+ validated_tracks = (
834
+ read_validated_tracks_fn(self._pos_dir) if self._pos_dir is not None else {}
835
+ )
836
+ result = self._dependency("extend_track_from_db")(
837
+ source_id=source_id, source_frame=t, direction=direction,
838
+ tracked_labels=tracked, db_path=db_path,
839
+ )
840
+
841
+ if result is None:
842
+ self._correction_status(
843
+ f"No DB link to extend cell {source_id} to t={target_frame}."
844
+ ); return
845
+
846
+ assignments = result.assignments or ()
847
+ if not assignments:
848
+ assignments = (SimpleNamespace(cell_id=source_id, mask_2d=result.mask_2d),)
849
+
850
+ frame = layer.data[result.target_frame]
851
+ corrections = (
852
+ read_corrections_fn(self._pos_dir) if self._pos_dir is not None else []
853
+ )
854
+ protected_ids_at_target = protected_cell_ids_at_frame(
855
+ validated_tracks,
856
+ corrections,
857
+ frame=result.target_frame,
858
+ )
859
+ protected_mask = protected_cell_mask(frame, protected_ids_at_target)
860
+
861
+ assignments = tuple(
862
+ a for a in assignments
863
+ if int(a.cell_id) not in protected_ids_at_target
864
+ )
865
+ if not assignments:
866
+ self._correction_status(
867
+ f"Extend skipped: cell {source_id} is protected at t={result.target_frame}."
868
+ )
869
+ return
870
+
871
+ changed_ids = paint_assignments(
872
+ frame, assignments, protected_mask,
873
+ greedy=self.extend_greedy_overwrite_check.isChecked(),
874
+ )
875
+ layer.refresh()
876
+ self._refresh_correction_label_visuals_for_edit(
877
+ result.target_frame,
878
+ changed_ids,
879
+ )
880
+
881
+ step = list(self.viewer.dims.current_step)
882
+ step[0] = result.target_frame
883
+ self.viewer.dims.current_step = tuple(step)
884
+
885
+ self.events.tracks_rebuilt.emit()
886
+ self._correction_status(
887
+ f"Extended cell {source_id} -> t={result.target_frame} "
888
+ f"(link weight={result.weight:.2f})"
889
+ )
890
+
891
+ def _on_swap_step(self, direction: str) -> None:
892
+ layer = self._correction_tracked_layer()
893
+ if layer is None:
894
+ self._correction_status("No tracked layer loaded."); return
895
+ db_path = self._ultrack_db_path()
896
+ if db_path is None or not db_path.exists():
897
+ self._correction_status("data.db not found - run DB Generation first."); return
898
+ source_id = self.correction_widget._selected_label
899
+ if not source_id:
900
+ self._correction_status("Swap: no cell selected (left-click first)."); return
901
+
902
+ t = self._current_t()
903
+ tracked = np.asarray(layer.data)
904
+ source_mask = tracked[t] == source_id
905
+ if not source_mask.any():
906
+ self._correction_status(f"Cell {source_id} not present at t={t}."); return
907
+
908
+ validated_tracks = (
909
+ read_validated_tracks(self._pos_dir) if self._pos_dir is not None else {}
910
+ )
911
+ if source_id in validated_tracks:
912
+ self._correction_status("Cannot swap a validated cell."); return
913
+
914
+ if self._swap_cursor is not None and (
915
+ self._swap_cursor.source_id != source_id
916
+ or self._swap_cursor.frame != t
917
+ ):
918
+ self._swap_cursor = None
919
+
920
+ if self._swap_cursor is None:
921
+ from skimage.measure import regionprops as _regionprops
922
+ props = _regionprops(source_mask.astype(np.uint8))
923
+ if not props:
924
+ self._correction_status("Cannot compute area for source cell."); return
925
+ src_area = int(props[0].area)
926
+
927
+ if self._pos_dir is not None:
928
+ corrections = read_corrections(self._pos_dir)
929
+ else:
930
+ corrections = []
931
+ protected_ids = protected_cell_ids_at_frame(
932
+ validated_tracks,
933
+ corrections,
934
+ frame=t,
935
+ exclude_cell_id=source_id,
936
+ )
937
+ protected_mask = protected_cell_mask(tracked[t], protected_ids)
938
+
939
+ candidates = list_swap_candidates(
940
+ db_path=db_path,
941
+ frame=t,
942
+ source_mask=source_mask,
943
+ frame_shape=tuple(tracked.shape[1:]),
944
+ protected_mask=protected_mask,
945
+ )
946
+ if not candidates:
947
+ self._correction_status(
948
+ "No swap candidates in the segmentation hierarchy."
949
+ ); return
950
+
951
+ self._swap_cursor = _SwapCursor(
952
+ source_id=source_id,
953
+ frame=t,
954
+ candidates=tuple(candidates),
955
+ index=_nearest_area_index(candidates, src_area),
956
+ baseline_frame=tracked[t].copy(),
957
+ )
958
+
959
+ cursor = self._swap_cursor
960
+ if len(cursor.candidates) <= 1:
961
+ self._correction_status(
962
+ "Only one node in this lattice branch — nothing to cycle."
963
+ ); return
964
+
965
+ idx = _cycle_index(
966
+ len(cursor.candidates), cursor.index, larger=(direction != "smaller")
967
+ )
968
+
969
+ candidate = cursor.candidates[idx]
970
+ validated_tracks_full = (
971
+ read_validated_tracks(self._pos_dir) if self._pos_dir is not None else {}
972
+ )
973
+ self._apply_swap(layer, t, source_id, candidate, validated_tracks_full)
974
+ cursor.index = idx
975
+ self.events.swap_stepped.emit()
976
+ self._correction_status(
977
+ f"Swapped cell {source_id} -> candidate {idx + 1}/{len(cursor.candidates)}"
978
+ f" (area={candidate.area} px)"
979
+ )
980
+
981
+ def _apply_swap(self, layer, t: int, source_id: int, candidate: _SwapCandidate, validated_tracks: dict) -> None:
982
+ frame = layer.data[t]
983
+ before = frame.copy()
984
+
985
+ cursor = self._swap_cursor
986
+ if (
987
+ cursor is not None
988
+ and cursor.baseline_frame is not None
989
+ and cursor.source_id == source_id
990
+ and cursor.frame == t
991
+ and cursor.baseline_frame.shape == frame.shape
992
+ ):
993
+ frame[:] = cursor.baseline_frame
994
+
995
+ if self._pos_dir is not None:
996
+ corrections = read_corrections(self._pos_dir)
997
+ else:
998
+ corrections = []
999
+ protected_ids = protected_cell_ids_at_frame(
1000
+ validated_tracks,
1001
+ corrections,
1002
+ frame=t,
1003
+ exclude_cell_id=source_id,
1004
+ )
1005
+ protected_mask = protected_cell_mask(frame, protected_ids)
1006
+
1007
+ frame[frame == source_id] = 0
1008
+ paintable = candidate.mask_2d & ~protected_mask
1009
+ frame[paintable] = source_id
1010
+
1011
+ self.correction_widget._record_history(layer, t, before)
1012
+ layer.refresh()
1013
+
1014
+ # ── candidate gallery (clickable extend / swap thumbnails) ─────────────────
1015
+
1016
+ def _extend_kwargs(self) -> dict:
1017
+ """Extra kwargs for list_extend_candidates (none: extend is LinkDB-driven)."""
1018
+ return {}
1019
+
1020
+ def _gallery_protected_mask(self, t: int) -> np.ndarray | None:
1021
+ """Protected-pixel mask at ``t`` for swap candidates (excludes the source)."""
1022
+ pos_dir = self._pos_dir
1023
+ layer = self._correction_tracked_layer()
1024
+ if pos_dir is None or layer is None:
1025
+ return None
1026
+ frame = frame_view_2d(np.asarray(layer.data), t)
1027
+ if frame is None:
1028
+ return None
1029
+ source_id = int(getattr(self.correction_widget, "_selected_label", 0) or 0)
1030
+ validated_tracks = read_validated_tracks(pos_dir)
1031
+ corrections = read_corrections(pos_dir)
1032
+ protected_ids = protected_cell_ids_at_frame(
1033
+ validated_tracks,
1034
+ corrections,
1035
+ frame=t,
1036
+ exclude_cell_id=source_id,
1037
+ )
1038
+ return protected_cell_mask(frame, protected_ids)
1039
+
1040
+ def _gallery_is_shown(self) -> bool:
1041
+ """True while the candidate gallery pane is built and not collapsed."""
1042
+ return self._gallery_pane is not None and not self._gallery_pane.is_collapsed()
1043
+
1044
+ def _refresh_candidate_gallery_if_shown(self) -> None:
1045
+ # Debounced: (re)start the timer so a burst of frame/selection changes
1046
+ # collapses into a single rebuild once things settle (see the timer
1047
+ # set-up in _setup_ui). The fired handler re-checks visibility.
1048
+ if self._gallery_is_shown():
1049
+ self._candidate_refresh_timer.start()
1050
+ else:
1051
+ self._candidate_refresh_timer.stop()
1052
+
1053
+ def _refresh_candidate_gallery_now(self) -> None:
1054
+ """Do the actual (expensive) gallery rebuild — only via the debounce timer."""
1055
+ if self._gallery_is_shown():
1056
+ self._candidate_gallery.refresh()
1057
+
1058
+ def _apply_swap_candidate(self, candidate) -> None:
1059
+ """Apply a swap candidate picked from the gallery (mirrors Z/C apply)."""
1060
+ layer = self._correction_tracked_layer()
1061
+ if layer is None:
1062
+ self._correction_status("No tracked layer loaded."); return
1063
+ source_id = int(getattr(self.correction_widget, "_selected_label", 0) or 0)
1064
+ if not source_id:
1065
+ self._correction_status("Swap: no cell selected (left-click first)."); return
1066
+ t = self._current_t()
1067
+ validated_tracks = (
1068
+ read_validated_tracks(self._pos_dir) if self._pos_dir is not None else {}
1069
+ )
1070
+ if source_id in validated_tracks:
1071
+ self._correction_status("Cannot swap a validated cell."); return
1072
+ # A direct gallery pick is independent of any in-flight Z/C cursor.
1073
+ self._swap_cursor = None
1074
+ self._apply_swap(layer, t, source_id, candidate, validated_tracks)
1075
+ self.events.swap_stepped.emit()
1076
+ self._correction_status(
1077
+ f"Swapped cell {source_id} -> candidate (area={int(candidate.area)} px). Unsaved."
1078
+ )
1079
+
1080
+ def _apply_extend_candidate(self, which: str, target_frame: int, assignment) -> None:
1081
+ """Apply an extend candidate picked from the gallery (mirrors A/D apply)."""
1082
+ layer = self._correction_tracked_layer()
1083
+ if layer is None:
1084
+ self._correction_status("No tracked layer loaded."); return
1085
+ if target_frame < 0 or target_frame >= layer.data.shape[0]:
1086
+ self._correction_status("Extend target frame out of range."); return
1087
+ changed_ids = self._paint_extend_assignment(
1088
+ layer,
1089
+ target_frame,
1090
+ assignment,
1091
+ greedy=self.extend_greedy_overwrite_check.isChecked(),
1092
+ )
1093
+ if not changed_ids:
1094
+ self._correction_status(
1095
+ f"Extend skipped: cell {int(assignment.cell_id)} is protected "
1096
+ f"at t={target_frame}."
1097
+ )
1098
+ return
1099
+ step = list(self.viewer.dims.current_step)
1100
+ if step:
1101
+ step[0] = int(target_frame)
1102
+ self.viewer.dims.current_step = tuple(step)
1103
+ self.events.tracks_rebuilt.emit()
1104
+ self._correction_status(
1105
+ f"Extended cell {int(assignment.cell_id)} -> t={target_frame} "
1106
+ f"(link weight={float(assignment.weight):.2f}). Unsaved."
1107
+ )
1108
+
1109
+ def _paint_extend_assignment(
1110
+ self, layer, target_frame: int, assignment, *, greedy: bool
1111
+ ) -> set[int]:
1112
+ """Paint one extend assignment at ``target_frame`` honoring protection.
1113
+
1114
+ Returns the set of changed cell ids (empty when the source is protected
1115
+ at the target, or nothing changed).
1116
+ """
1117
+ frame = layer.data[target_frame]
1118
+ validated_tracks = (
1119
+ read_validated_tracks(self._pos_dir) if self._pos_dir is not None else {}
1120
+ )
1121
+ corrections = (
1122
+ read_corrections(self._pos_dir) if self._pos_dir is not None else []
1123
+ )
1124
+ protected_ids = protected_cell_ids_at_frame(
1125
+ validated_tracks, corrections, frame=target_frame
1126
+ )
1127
+ if int(assignment.cell_id) in protected_ids:
1128
+ return set()
1129
+ protected_mask = protected_cell_mask(frame, protected_ids)
1130
+ changed_ids = paint_assignments(
1131
+ frame, (assignment,), protected_mask, greedy=greedy
1132
+ )
1133
+ if not changed_ids:
1134
+ return set()
1135
+ layer.refresh()
1136
+ self._refresh_correction_label_visuals_for_edit(target_frame, changed_ids)
1137
+ return changed_ids
1138
+
1139
+ def _on_retrack(self, direction: str) -> None:
1140
+ if self._pos_dir is None:
1141
+ self._correction_status("No project open."); return
1142
+ layer = self._correction_tracked_layer()
1143
+ if layer is None:
1144
+ self._correction_status("No tracked layer loaded."); return
1145
+ if layer.data.ndim != 3 or layer.data.shape[0] < 2:
1146
+ self._correction_status("Need >= 2 frames to retrack."); return
1147
+ t0 = int(self.viewer.dims.current_step[0])
1148
+ if direction == "forward" and t0 >= layer.data.shape[0] - 1:
1149
+ self._correction_status("Already at last frame."); return
1150
+ if direction == "backward" and t0 <= 0:
1151
+ self._correction_status("Already at first frame."); return
1152
+
1153
+ before = np.asarray(layer.data).copy()
1154
+ validated_tracks = read_validated_tracks(self._pos_dir)
1155
+ result = retrack_stack_direction(
1156
+ before,
1157
+ start_frame=t0,
1158
+ direction=direction,
1159
+ fully_validated_frames=read_validated_frames(self._pos_dir),
1160
+ validated_cells_at_frame=lambda t: {
1161
+ cid for cid, frames in validated_tracks.items() if t in frames
1162
+ },
1163
+ retrack_frame=retrack_frame_constrained,
1164
+ max_dist_px=float(self.retrack_max_dist_spin.value()),
1165
+ reserved_ids=set(validated_tracks),
1166
+ area_weight=float(self.extend_area_weight_spin.value()),
1167
+ iou_weight=float(self.extend_iou_weight_spin.value()),
1168
+ distance_weight=float(self.extend_distance_weight_spin.value()),
1169
+ )
1170
+ layer.data = result.stack
1171
+ self._refresh_correction_label_visuals_for_changed_frames(before, result.stack)
1172
+ self.events.tracks_rebuilt.emit()
1173
+ self._correction_status(
1174
+ f"Retracked {direction} from t={result.first_target_frame}: "
1175
+ f"{result.n_retracked} updated, "
1176
+ f"{result.n_skipped} validated skipped. Unsaved."
1177
+ )
1178
+
1179
+ def _on_retrack_forward(self) -> None:
1180
+ self._on_retrack("forward")
1181
+
1182
+ def _on_retrack_backward(self) -> None:
1183
+ self._on_retrack("backward")
1184
+
1185
+ def _on_remove_unvalidated_labels(self) -> None:
1186
+ if self._pos_dir is None:
1187
+ self._correction_status("No project open."); return
1188
+ layer = self._correction_tracked_layer()
1189
+ if layer is None:
1190
+ self._correction_status("No tracked layer loaded."); return
1191
+ data = np.asarray(layer.data)
1192
+ if data.ndim < 2:
1193
+ self._correction_status("Tracked layer has no image data."); return
1194
+
1195
+ validated_tracks = read_validated_tracks(self._pos_dir)
1196
+ try:
1197
+ result = remove_unvalidated_from_data(
1198
+ data,
1199
+ validated_tracks,
1200
+ )
1201
+ except ValueError as exc:
1202
+ self._correction_status(str(exc)); return
1203
+
1204
+ if not result.changed_pixels:
1205
+ self._correction_status("No unvalidated labels found."); return
1206
+ layer.refresh()
1207
+ self._deselect_if_selection_gone()
1208
+ self.events.stack_relabeled.emit()
1209
+ self._correction_status(
1210
+ f"Removed unvalidated labels in {result.changed_frames} frame(s), "
1211
+ f"{result.changed_pixels} px changed. Unsaved."
1212
+ )
1213
+
1214
+ def _deselect_if_selection_gone(self) -> None:
1215
+ """Clear the selection when the selected cell no longer exists at the
1216
+ current frame (a relabel / removal may have dropped it)."""
1217
+ sel = self.correction_widget._selected_label
1218
+ if sel:
1219
+ ct = self._current_t()
1220
+ if sel not in self._current_cell_ids(ct):
1221
+ self.correction_widget.select_label(ct, 0)
1222
+
1223
+ def _remove_unvalidated_from_layer(self, layer) -> tuple[int, int]:
1224
+ if self._pos_dir is None:
1225
+ return 0, 0
1226
+ data = np.asarray(layer.data)
1227
+ validated_tracks = read_validated_tracks(self._pos_dir)
1228
+ result = remove_unvalidated_from_data(
1229
+ data,
1230
+ validated_tracks,
1231
+ )
1232
+ if result.changed_pixels:
1233
+ layer.refresh()
1234
+ self._refresh_correction_label_visuals()
1235
+ if self.correction_widget._selected_label:
1236
+ ct = self._current_t()
1237
+ if self.correction_widget._selected_label not in self._current_cell_ids(ct):
1238
+ self.correction_widget.select_label(ct, 0)
1239
+ self._refresh_validated_overlay()
1240
+ self._refresh_validation_counter()
1241
+ return result.changed_frames, result.changed_pixels
1242
+
1243
+ def _on_commit(self) -> None:
1244
+ tracked_path = self._tracked_path()
1245
+ if tracked_path is None:
1246
+ self._correction_status("No project open."); return
1247
+ layer = self._correction_tracked_layer()
1248
+ if layer is None:
1249
+ self._correction_status("No tracked layer to commit."); return
1250
+ if layer.data.ndim != 3:
1251
+ self._correction_status("Tracked layer is not a 3D stack."); return
1252
+ try:
1253
+ result = prepare_committed_labels(
1254
+ np.asarray(layer.data),
1255
+ read_validated_tracks(self._pos_dir),
1256
+ )
1257
+ except Exception as exc:
1258
+ self._on_correction_worker_error(exc)
1259
+ return
1260
+ layer.data = result.stack
1261
+ if result.old_to_new:
1262
+ remap_validated_tracks(self._pos_dir, result.old_to_new)
1263
+ if result.changed_pixels:
1264
+ layer.refresh()
1265
+ self._deselect_if_selection_gone()
1266
+ self.events.stack_relabeled.emit()
1267
+ write_full_tracked_stack(tracked_path, np.asarray(layer.data))
1268
+ self._correction_status(
1269
+ f"Committed {result.n_cells} cell(s); removed {result.changed_pixels} px in "
1270
+ f"{result.changed_frames} frame(s); saved to {tracked_path.name}."
1271
+ )
1272
+ self._files_widget_refresh()
1273
+
1274
+ def _on_correction_worker_error(self, exc: Exception) -> None:
1275
+ self._correction_status(f"Error: {exc}")
1276
+ logger.exception("Correction worker error", exc_info=exc)
1277
+
1278
+ def _install_correction_shortcuts(self) -> None:
1279
+ # Correction hotkeys are bound on the active tracked Labels layer via
1280
+ # napari's keymap (see _bind_correction_keys), NOT Qt QShortcuts. The
1281
+ # single-letter keys collide with napari's built-in keybindings
1282
+ # (Z=pan_zoom, E=erase, B=preserve_labels, C=auto_contrast, S=select,
1283
+ # A=select_all, D=direct_mode). A Qt ApplicationShortcut only wins that
1284
+ # race intermittently — once it goes ambiguous (e.g. the widget is
1285
+ # recreated and its app-global QShortcuts linger) the keypress falls
1286
+ # through to napari, which flips the layer mode instead of swapping.
1287
+ # layer.bind_key(overwrite=True) is the only mechanism that
1288
+ # deterministically takes precedence. "V" is intentionally omitted: it
1289
+ # is already bound at the viewer level (NucleusWorkflowWidget) and
1290
+ # surfaces here while the tracked layer is active.
1291
+ # ``(key, slot, repeat)``: ``repeat`` keys keep firing while held (the
1292
+ # navigation arrows, where holding to scrub is natural); the edit / save
1293
+ # keys fire once per press only.
1294
+ self._correction_key_specs = [
1295
+ ("A", lambda: self._on_extend(direction="backward"), False),
1296
+ ("D", lambda: self._on_extend(direction="forward"), False),
1297
+ ("Q", self._on_retrack_backward, False),
1298
+ ("E", self._on_retrack_forward, False),
1299
+ ("B", self._on_anchor_here, False),
1300
+ ("S", self._on_save_tracked, False),
1301
+ ("Z", lambda: self._on_swap_step(direction="smaller"), False),
1302
+ ("C", lambda: self._on_swap_step(direction="larger"), False),
1303
+ ("Space", self._toggle_movie_playback, False),
1304
+ # Arrow keys navigate the selected track's film strip as laid out:
1305
+ # Left/Right walk the thumbnails in reading order (columns), Up/Down
1306
+ # jump a whole wrapped row. Shift+Up/Down switch tracks instead,
1307
+ # walking the global track list and recentering like a track click.
1308
+ # All six auto-repeat while held (see HeldKeyRepeater).
1309
+ ("Left", lambda: self._step_film_frame(dx=-1), True),
1310
+ ("Right", lambda: self._step_film_frame(dx=1), True),
1311
+ ("Up", lambda: self._step_film_frame(dy=-1), True),
1312
+ ("Down", lambda: self._step_film_frame(dy=1), True),
1313
+ ("Shift-Up", lambda: self._step_track(-1), True),
1314
+ ("Shift-Down", lambda: self._step_track(1), True),
1315
+ ]
1316
+ self._bound_correction_keys: list[str] = []
1317
+ self._correction_keys_layer = None
1318
+
1319
+ # Auto-repeat for held navigation keys, paced to the viewer's playback
1320
+ # fps (see HeldKeyRepeater); napari only auto-repeats bare arrows itself.
1321
+ self._key_repeater = HeldKeyRepeater(
1322
+ QTimer(self),
1323
+ interval_provider=lambda: nav_repeat_interval_ms(self.viewer),
1324
+ )
1325
+
1326
+ def _bind_correction_keys(self) -> None:
1327
+ """Bind correction hotkeys on the active tracked Labels layer.
1328
+
1329
+ Layer-level keymap entries take precedence over both napari's built-in
1330
+ class keybindings and the viewer keymap, so this overrides the
1331
+ conflicting defaults (Z, E, B, …) only while correction is active.
1332
+ """
1333
+ self._unbind_correction_keys()
1334
+ layer = self._correction_tracked_layer()
1335
+ if layer is None:
1336
+ return
1337
+ for key, slot, repeat in self._correction_key_specs:
1338
+ if repeat:
1339
+ handler = self._key_repeater.key_handler(key, slot)
1340
+ else:
1341
+ def handler(_layer, _slot=slot):
1342
+ _slot()
1343
+
1344
+ layer.bind_key(key, handler, overwrite=True)
1345
+ self._bound_correction_keys.append(key)
1346
+ self._correction_keys_layer = layer
1347
+
1348
+ def _unbind_correction_keys(self) -> None:
1349
+ self._key_repeater.stop()
1350
+ layer = self._correction_keys_layer
1351
+ for key in self._bound_correction_keys:
1352
+ try:
1353
+ if layer is not None:
1354
+ layer.bind_key(key, None)
1355
+ except Exception:
1356
+ pass
1357
+ self._bound_correction_keys = []
1358
+ self._correction_keys_layer = None
1359
+
1360
+ def _correction_data_available(self) -> bool:
1361
+ """True when a tracked nucleus stack exists on disk to correct."""
1362
+ tp = self._tracked_path()
1363
+ return tp is not None and tp.exists()
1364
+
1365
+ def _on_correction_active_button_toggled(self, active: bool) -> None:
1366
+ if active:
1367
+ if not self._correction_data_available():
1368
+ self._correction_status("No tracked labels available to correct.")
1369
+ self._set_checked_without_signal(self.active_btn, False)
1370
+ self._sync_correction_panel_visibility()
1371
+ return
1372
+ # Capture the plugin dock + width now, before the focus takeover
1373
+ # reparents the header out of it, so its width can be restored later.
1374
+ self._pre_correction_dock = self._find_plugin_dock()
1375
+ self._pre_correction_dock_width = (
1376
+ self._pre_correction_dock.width()
1377
+ if self._pre_correction_dock is not None
1378
+ else None
1379
+ )
1380
+ self._capture_correction_view_state()
1381
+ hide_all_layers(self.viewer.layers)
1382
+
1383
+ if not self._load_correction_layers_from_disk():
1384
+ self._restore_correction_view_state()
1385
+ old = self.active_btn.blockSignals(True)
1386
+ try:
1387
+ self.active_btn.setChecked(False)
1388
+ finally:
1389
+ self.active_btn.blockSignals(old)
1390
+ self.correction_widget.deactivate()
1391
+ self._correction_active_content_visible = False
1392
+ self._sync_correction_panel_visibility()
1393
+ return
1394
+ layer = self.viewer.layers[_CORRECTION_TRACKED_LAYER]
1395
+ layer.visible = True
1396
+ self.viewer.layers.selection.active = layer
1397
+ self.correction_widget.activate_layer(layer)
1398
+ # Start every session in the default outline view: neutral-coloured
1399
+ # outlines + tracks over the visible reference images.
1400
+ self._set_checked_without_signal(self.filled_view_btn, False)
1401
+ self._apply_label_view_mode(filled=False)
1402
+ # Sync the inner widget's selection indicator to the (possibly
1403
+ # persisted) spotlight toggle so they never drift apart.
1404
+ self.correction_widget.set_highlight_style(
1405
+ "spotlight" if self.spotlight_btn.isChecked() else "border"
1406
+ )
1407
+ # Drop any pre-loaded higher-rank stack (e.g. a raw T,Z,Y,X z-stack)
1408
+ # so the viewer collapses to one frame slider that matches the
1409
+ # correction frames; re-added verbatim on deactivate. Same-rank
1410
+ # layers are left alone so nothing essential is removed.
1411
+ self._detached_stack_layers = detach_higher_dim_stacks(
1412
+ self.viewer.layers,
1413
+ max_ndim=int(np.asarray(layer.data).ndim),
1414
+ keep_names=self._correction_owned_layers,
1415
+ )
1416
+ # Focus mode: hand the correction panels the right column by hiding
1417
+ # napari's native layer-list / layer-controls docks.
1418
+ self._native_dock_state = hide_native_docks(self.viewer)
1419
+ self._set_checked_without_signal(self.params_btn, False)
1420
+ self._set_checked_without_signal(self.shortcuts_btn, False)
1421
+ self.extend_retrack_params_section._toggle.setChecked(False)
1422
+ self.shortcuts_section._toggle.setChecked(False)
1423
+ self._correction_active_content_visible = True
1424
+ self._sync_correction_panel_visibility()
1425
+ # The accordion is the always-on main surface; the candidate gallery
1426
+ # is the action surface, both opened by default (each collapsible via
1427
+ # its own ✕). Render both explicitly so the body splitter is built once.
1428
+ self._lineage_canvas.refresh()
1429
+ self._candidate_gallery.refresh()
1430
+ # Build the whole-stack validated / anchor overlays once now; they're
1431
+ # only rebuilt on validation or label changes afterwards (not per frame).
1432
+ self._refresh_validated_overlay()
1433
+ self._refresh_validation_counter()
1434
+ # Hand the whole window to the workspace: reparent the top bar +
1435
+ # reveal area out of the plugin dock, hide the now-empty plugin dock,
1436
+ # and lay the body panels out as one splitter so they resize
1437
+ # independently.
1438
+ self._focus_takeover(True)
1439
+ self._arrange_workspace_docks()
1440
+ # Open the workspace at half the window width; the body splitter sizes
1441
+ # its toolbar · gallery · accordion strips. Deferred so the dock is
1442
+ # laid out before resizeDocks runs.
1443
+ QTimer.singleShot(0, self._size_workspace_dock)
1444
+ return
1445
+
1446
+ if not self._confirm_deactivate_with_unsaved_changes():
1447
+ self._set_checked_without_signal(self.active_btn, True)
1448
+ self._correction_active_content_visible = True
1449
+ self._sync_correction_panel_visibility()
1450
+ return
1451
+
1452
+ self._set_checked_without_signal(self.active_btn, False)
1453
+ self._correction_active_content_visible = False
1454
+ # Reset the single-cell focus presentation before tearing layers down so
1455
+ # the show_selected_label flag and overview visibility don't leak.
1456
+ self._apply_focus_presentation(0)
1457
+ self.correction_widget.deactivate()
1458
+ self._unbind_correction_keys()
1459
+ # Stop any in-progress movie playback before the layers are torn down.
1460
+ self._stop_movie_playback()
1461
+ # Refresh the main Tracked layer from disk so a subsequent re-solve
1462
+ # picks up any corrections the user saved during this session.
1463
+ self._refresh_tracked_layer_from_disk()
1464
+ self._remove_correction_owned_layers()
1465
+ # Reparent the correction header + controls back into the plugin dock and
1466
+ # show it again *before* removing the now-empty workspace strip (so those
1467
+ # widgets aren't deleted with the strip's container), then tear panels down.
1468
+ self._focus_takeover(False)
1469
+ self._teardown_workspace_controls_dock()
1470
+ self._teardown_focus_panels()
1471
+ restore_native_docks(self.viewer, self._native_dock_state)
1472
+ self._native_dock_state = {}
1473
+ # Put back any higher-dim stack removed on activate (data/contrast/
1474
+ # colormap intact) before restoring visibility/selection over it.
1475
+ reattach_layers(self.viewer.layers, self._detached_stack_layers)
1476
+ self._detached_stack_layers = []
1477
+ self._restore_correction_view_state()
1478
+ self._restore_pre_correction_dock_width()
1479
+ self._set_checked_without_signal(self.params_btn, False)
1480
+ self._set_checked_without_signal(self.shortcuts_btn, False)
1481
+ self.extend_retrack_params_section._toggle.setChecked(False)
1482
+ self.shortcuts_section._toggle.setChecked(False)
1483
+ self._sync_correction_panel_visibility()
1484
+
1485
+ def _confirm_deactivate_with_unsaved_changes(self) -> bool:
1486
+ if not self._correction_dirty:
1487
+ return True
1488
+ action = confirm_unsaved_before_deactivate(self, save_noun="tracked labels")
1489
+ if action == "cancel":
1490
+ return False
1491
+ if action == "save":
1492
+ self._on_save_tracked()
1493
+ self._correction_dirty = False
1494
+ return True
1495
+
1496
+ def _on_correction_mode_toggled(self, active: bool) -> None:
1497
+ if not active:
1498
+ self._swap_cursor = None
1499
+ self._clear_track_path_overlay()
1500
+ self._lineage_canvas.teardown()
1501
+ self._candidate_gallery.teardown()
1502
+ if active:
1503
+ self._bind_correction_keys()
1504
+ else:
1505
+ self._unbind_correction_keys()
1506
+
1507
+ def _kb_toggle_cell_validation(self, _viewer) -> None:
1508
+ if self._pos_dir is None:
1509
+ return
1510
+ sel = self.correction_widget._selected_label
1511
+ if not sel:
1512
+ self._correction_status(
1513
+ "Validation toggle: no cell selected (left-click first)."
1514
+ ); return
1515
+ t = self._current_t()
1516
+ if sel not in self._current_cell_ids(t):
1517
+ self._correction_status(f"Cell {sel} not present at t={t}."); return
1518
+ frames = self._frames_with_cell(sel)
1519
+ if not frames:
1520
+ return
1521
+ if is_track_validated(self._pos_dir, sel):
1522
+ invalidate_track(self._pos_dir, sel)
1523
+ self._correction_status(
1524
+ f"Cell {sel} invalidated across {len(frames)} frame(s)."
1525
+ )
1526
+ else:
1527
+ layer = self._correction_tracked_layer()
1528
+ if layer is None:
1529
+ return
1530
+ data = np.asarray(layer.data)
1531
+ for correction in corrections_for_label_frames(
1532
+ data,
1533
+ cell_id=sel,
1534
+ frames=frames,
1535
+ ):
1536
+ add_correction(self._pos_dir, correction)
1537
+ self._correction_status(
1538
+ f"Cell {sel} validated across {len(frames)} frame(s)."
1539
+ )
1540
+ # Flag-only change, so a light status recolour (was a full lineage
1541
+ # rebuild here — inconsistent with _on_validate_track, which the
1542
+ # event normalises to the lighter, freeze-free path).
1543
+ self.events.validation_changed.emit()
1544
+
1545
+ def _toggle_movie_playback(self) -> None:
1546
+ """Space: start/stop animating the frame (time) slider like a movie."""
1547
+ try:
1548
+ qt_dims = self.viewer.window._qt_viewer.dims
1549
+ except AttributeError:
1550
+ return
1551
+ if qt_dims.is_playing:
1552
+ qt_dims.stop()
1553
+ else:
1554
+ # Time is axis 0 (see _current_t); fps=None uses napari's setting.
1555
+ qt_dims.play(axis=0)
1556
+
1557
+ def _stop_movie_playback(self) -> None:
1558
+ try:
1559
+ qt_dims = self.viewer.window._qt_viewer.dims
1560
+ except AttributeError:
1561
+ return
1562
+ if qt_dims.is_playing:
1563
+ qt_dims.stop()
1564
+
1565
+ def on_dims_step_changed(self) -> None:
1566
+ self._swap_cursor = None
1567
+ # The validated / anchor overlays are whole-stack masks (built on
1568
+ # activation and on each validation/label change), so napari already
1569
+ # shows the right slice — no per-frame rebuild here.
1570
+ # Keep the focused track's tip cross on the cell in the new frame.
1571
+ self._all_tracks.set_current_frame(self._current_t())
1572
+ if self._workspace_splitter is not None:
1573
+ self._lineage_canvas.set_current_frame(self._current_t())
1574
+ # Candidates are frame-specific (the swap lattice + adjacent-frame extend),
1575
+ # so a frame change invalidates them — recompute for the new frame.
1576
+ self._refresh_candidate_gallery_if_shown()
1577
+
1578
+ def _refresh_validated_overlay(self) -> None:
1579
+ self._validated_overlay.refresh_overlay(frame_view_2d)
1580
+
1581
+ def _add_validated_overlay(self, data: np.ndarray) -> None:
1582
+ self._validated_overlay.add_overlay(data)
1583
+
1584
+ def _place_validated_overlay_below_spotlight(self) -> None:
1585
+ self._validated_overlay.place_below_spotlight()
1586
+
1587
+ # ── Whole-track temporal overlay ("comet") ─────────────────────────────────
1588
+
1589
+ def _on_toggle_track_path(self, checked: bool) -> None:
1590
+ if checked:
1591
+ self._refresh_track_path_overlay()
1592
+ else:
1593
+ self._clear_track_path_overlay()
1594
+ self._refresh_track_path_spotlight()
1595
+
1596
+ def _on_toggle_spotlight(self, checked: bool) -> None:
1597
+ """Spotlight on → dim outside the selection; off → plain yellow border."""
1598
+ self.correction_widget.set_highlight_style(
1599
+ "spotlight" if checked else "border"
1600
+ )
1601
+
1602
+ def _on_track_selection_changed(self, _t: int, _lab: int) -> None:
1603
+ """Recolour the all-tracks layer / accordion when selection changes."""
1604
+ self._apply_focus_presentation(_lab)
1605
+ if self._workspace_splitter is not None:
1606
+ # Selection drives the accordion: the picked track expands inline.
1607
+ self._lineage_canvas.set_selection(_lab)
1608
+ # Recenter on the selected track's film strip (the thumbnail band,
1609
+ # not the bar row) when the selection came from the image viewer; an
1610
+ # accordion click already shows the row.
1611
+ if not self._navigating_from_lineage:
1612
+ self._lineage_canvas.center_on_strip(_lab)
1613
+ self._refresh_candidate_gallery_if_shown()
1614
+
1615
+ def _apply_focus_presentation(self, lab: int) -> None:
1616
+ """Focus a single cell in the all-tracks layer.
1617
+
1618
+ The selected track is recoloured bright viridis-by-time and gets a
1619
+ current-frame tip cross while every other track fades to a faint grey;
1620
+ ``lab == 0`` restores the overview. The label spotlight (dimming the
1621
+ other cells' masks) is handled by the inner correction widget; this only
1622
+ drives the nucleus-side track overlay.
1623
+ """
1624
+ self._all_tracks.set_focus(int(lab or 0))
1625
+
1626
+ def _apply_track_path_rebuilt(self) -> None:
1627
+ """``tracks_rebuilt`` listener: redraw the comet + spotlight when on.
1628
+
1629
+ The all-tracks overlay's geometry follows the selected track, so an
1630
+ extend/retrack that reshaped it must repaint the comet (and re-run the
1631
+ spotlight so its mask provider is consulted) — but only while the
1632
+ track-path overlay is toggled on.
1633
+ """
1634
+ if self.track_path_btn.isChecked():
1635
+ self._refresh_track_path_overlay()
1636
+ self._refresh_track_path_spotlight()
1637
+
1638
+ def _refresh_lineage_detail_if_shown(self) -> None:
1639
+ """``swap_stepped`` listener: refresh only the selected track's detail
1640
+ strip — not the whole-stack lineage build, which froze the GUI when fired
1641
+ on every swap keystroke (the overview catches up on the next full
1642
+ refresh)."""
1643
+ if self._workspace_splitter is not None:
1644
+ self._lineage_canvas.refresh_detail()
1645
+
1646
+ def _refresh_lineage_canvas_if_shown(self) -> None:
1647
+ """Rebuild the accordion (bars + expanded band) after a label change.
1648
+
1649
+ The accordion is the always-on main surface for the whole time focus
1650
+ mode is active, so the rebuild is gated on the workspace being docked;
1651
+ otherwise the bars go stale after a label change (reassign IDs, remove
1652
+ unvalidated, validate, …).
1653
+ """
1654
+ if self._workspace_splitter is not None:
1655
+ self._lineage_canvas.refresh()
1656
+
1657
+ def _refresh_lineage_canvas_status_if_shown(self) -> None:
1658
+ """Lightweight lineage refresh for flag-only changes (validate/anchor).
1659
+
1660
+ Validation changes per-frame status, not topology, so this recolours the
1661
+ cached lanes instead of re-running the whole-stack lineage build that
1662
+ :meth:`_refresh_lineage_canvas_if_shown` does (which froze the GUI for
1663
+ seconds when validating a long track).
1664
+ """
1665
+ if self._workspace_splitter is not None:
1666
+ self._lineage_canvas.refresh_status()
1667
+
1668
+ def _track_path_spotlight_mask(self, _t: int, lab: int, _default_mask):
1669
+ return self._all_tracks.spotlight_mask(_t, lab, _default_mask)
1670
+
1671
+ def _refresh_track_path_spotlight(self) -> None:
1672
+ """Re-run the inner highlight so the spotlight mask provider is consulted."""
1673
+ cw = self.correction_widget
1674
+ lab = int(getattr(cw, "_selected_label", 0) or 0)
1675
+ if not lab:
1676
+ return
1677
+ try:
1678
+ cw._update_highlight(self._current_t(), lab, notify=False)
1679
+ except Exception:
1680
+ logger.exception("track path spotlight refresh failed")
1681
+
1682
+ def _refresh_track_path_overlay(self) -> None:
1683
+ self._all_tracks.refresh()
1684
+
1685
+ def _clear_track_path_overlay(self) -> None:
1686
+ self._all_tracks.clear()
1687
+
1688
+ def _correction_intensity_frame(self, t: int):
1689
+ """2-D nucleus frame at time *t* used to snap spawned cells to signal."""
1690
+ if _CORRECTION_NUC_ZAVG_LAYER not in self.viewer.layers:
1691
+ return None
1692
+ layer = self.viewer.layers[_CORRECTION_NUC_ZAVG_LAYER]
1693
+ return frame_view_2d(np.asarray(layer.data), int(t))
1694
+
1695
+ def _film_strip_intensity_layer(self):
1696
+ """Best raw layer to crop tiles from (nucleus, then cell)."""
1697
+ for name in (
1698
+ _CORRECTION_NUC_ZAVG_LAYER,
1699
+ _CORRECTION_CELL_ZAVG_LAYER,
1700
+ ):
1701
+ if name in self.viewer.layers:
1702
+ return self.viewer.layers[name]
1703
+ return None
1704
+
1705
+ def _on_workspace_panel_toggled(self, pane, collapsed: bool) -> None:
1706
+ """A pane's ✕ / show-tab flipped: keep ≥1 open and resize the splitter.
1707
+
1708
+ Hiding one of the gallery / accordion panes while the other is already
1709
+ collapsed re-opens that other one, so the workspace never goes blank.
1710
+ Re-showing the gallery also repopulates it for the current selection.
1711
+ """
1712
+ gallery = self._gallery_pane
1713
+ accordion = self._accordion_pane
1714
+ if (
1715
+ collapsed
1716
+ and gallery is not None
1717
+ and accordion is not None
1718
+ and gallery.is_collapsed()
1719
+ and accordion.is_collapsed()
1720
+ ):
1721
+ other = accordion if pane is gallery else gallery
1722
+ other.set_collapsed(False) # re-enters here with one pane open
1723
+ return
1724
+ self._apply_workspace_panel_sizes()
1725
+ if not collapsed and self._gallery_is_shown():
1726
+ self._refresh_candidate_gallery_if_shown()
1727
+
1728
+ # ── label / track view mode (outline-neutral ↔ filled-by-id) ──────────────
1729
+
1730
+ def _filled_view_active(self) -> bool:
1731
+ return self.filled_view_btn.isChecked()
1732
+
1733
+ def _on_toggle_filled_view(self, checked: bool) -> None:
1734
+ self._apply_label_view_mode(filled=checked)
1735
+
1736
+ def _apply_label_view_mode(self, *, filled: bool) -> None:
1737
+ """Switch between the outline (neutral) and filled (by-id) viewer modes.
1738
+
1739
+ Outline mode shows the cell + nucleus reference images and draws the
1740
+ labels as outlines and the tracks overview in one neutral colour. Filled
1741
+ mode hides those images and draws the labels filled + opaque and the
1742
+ tracks overview coloured by ID.
1743
+ """
1744
+ for name in (_CORRECTION_CELL_ZAVG_LAYER, _CORRECTION_NUC_ZAVG_LAYER):
1745
+ if name in self.viewer.layers:
1746
+ self.viewer.layers[name].visible = not filled
1747
+ layer = self._correction_tracked_layer()
1748
+ if layer is not None:
1749
+ try:
1750
+ layer.contour = 0 if filled else 2
1751
+ layer.opacity = (
1752
+ _FILLED_LABEL_OPACITY if filled else _OUTLINE_LABEL_OPACITY
1753
+ )
1754
+ except Exception:
1755
+ pass
1756
+ if filled:
1757
+ refresh_label_colormap(
1758
+ layer,
1759
+ np.asarray(layer.data),
1760
+ color_scale=_NUCLEUS_TRACK_COLOR_SCALE,
1761
+ )
1762
+ else:
1763
+ apply_neutral_label_colormap(layer)
1764
+ # In filled-by-ID mode the green/anchor wash would hide the per-ID
1765
+ # colours, so draw validated/anchor cells as a coloured border instead.
1766
+ self._validated_overlay.set_border_mode(filled)
1767
+ self._all_tracks.set_filled_mode(filled)
1768
+
1769
+ def _navigate_to_error(self, t: int, cell_id: int) -> None:
1770
+ """Lineage-canvas click handler: jump to frame ``t``, select and center.
1771
+
1772
+ The clicked row is already in view, so the canvas recenter is suppressed
1773
+ (``from_lineage=True``) while the image viewer is still panned onto the
1774
+ cell.
1775
+ """
1776
+ self._navigate_to_cell(int(t), int(cell_id), from_lineage=True)
1777
+
1778
+ def _navigate_to_cell(self, t: int, cell_id: int, *, from_lineage: bool) -> None:
1779
+ """Jump to frame ``t``, select ``cell_id`` and center both views on it.
1780
+
1781
+ Besides stepping to the frame and selecting the cell, this pans the
1782
+ image-viewer camera so the cell sits in the middle of the canvas. With
1783
+ ``from_lineage=False`` (keyboard track stepping) the resulting selection
1784
+ callback also recenters the lineage canvas on the track; a lineage click
1785
+ passes ``from_lineage=True`` because the clicked row is already shown.
1786
+ """
1787
+ try:
1788
+ step = list(self.viewer.dims.current_step)
1789
+ if step:
1790
+ step[0] = int(t)
1791
+ self.viewer.dims.current_step = tuple(step)
1792
+ except Exception:
1793
+ logger.exception("focus-mode navigation: frame jump failed")
1794
+ # When the track is absent at the target frame (e.g. an empty placeholder
1795
+ # thumbnail in an incomplete track's film strip), still step to the frame
1796
+ # but keep the current selection: re-selecting would find no mask there
1797
+ # and clear the track. The frame jump above already moved the canvas
1798
+ # guide via on_dims_step_changed.
1799
+ if int(cell_id) and int(cell_id) not in self._current_cell_ids(int(t)):
1800
+ return
1801
+ self._navigating_from_lineage = from_lineage
1802
+ try:
1803
+ self.correction_widget.select_label(int(t), int(cell_id))
1804
+ except Exception:
1805
+ logger.exception("focus-mode navigation: cell select failed")
1806
+ finally:
1807
+ self._navigating_from_lineage = False
1808
+ self._center_viewer_on_cell(int(t), int(cell_id))
1809
+
1810
+ def _step_film_frame(self, *, dx: int = 0, dy: int = 0) -> None:
1811
+ """Step the current frame across the selected track's film-strip grid.
1812
+
1813
+ Bound to the bare arrow keys in focus mode: Left/Right move one thumbnail
1814
+ in reading order, Up/Down jump a wrapped row. Running off the end wraps
1815
+ back when the viewer's playback loop mode is on. Delegates to the lineage
1816
+ canvas, which owns the band geometry and the frame-jump path.
1817
+ """
1818
+ if self._workspace_splitter is None:
1819
+ return
1820
+ self._lineage_canvas.step_film_frame(
1821
+ dx=dx, dy=dy, wrap=playback_loops(self.viewer)
1822
+ )
1823
+
1824
+ def _step_track(self, direction: int) -> None:
1825
+ """Select the next/previous track in the global list and recenter on it.
1826
+
1827
+ Bound to Shift+Up / Shift+Down in focus mode. Unlike a frame-local
1828
+ scan, this walks the sorted list of every track ID in the stack and
1829
+ navigates to the chosen track exactly as a lineage-canvas click would —
1830
+ landing on a frame where it exists, selecting it, and recentering both
1831
+ the lineage canvas and the image viewer.
1832
+ """
1833
+ layer = self._correction_tracked_layer()
1834
+ if layer is None:
1835
+ return
1836
+ data = np.asarray(layer.data)
1837
+ all_ids = sorted(set(int(v) for v in np.unique(data)) - {0})
1838
+ if not all_ids:
1839
+ self._correction_status("No cells in any frame.")
1840
+ return
1841
+ cur = int(getattr(self.correction_widget, "_selected_label", 0) or 0)
1842
+ if cur in all_ids:
1843
+ nxt = all_ids[(all_ids.index(cur) + direction) % len(all_ids)]
1844
+ else:
1845
+ nxt = all_ids[0] if direction > 0 else all_ids[-1]
1846
+ # Stay on the current frame if the track is present there; otherwise jump
1847
+ # to the first frame that contains it so it can be selected and centered.
1848
+ t = self._current_t()
1849
+ if nxt not in self._current_cell_ids(t):
1850
+ frames = [i for i in range(data.shape[0]) if np.any(data[i] == nxt)]
1851
+ if frames:
1852
+ t = frames[0]
1853
+ self._navigate_to_cell(t, nxt, from_lineage=False)
1854
+
1855
+ def _center_viewer_on_cell(self, t: int, cell_id: int) -> None:
1856
+ """Frame the selected track in the viewer (camera math lives in
1857
+ :func:`itasc.napari.correction._correction_navigation.center_viewer_on_cell`)."""
1858
+ center_viewer_on_cell(
1859
+ self.viewer, self._correction_tracked_layer(), t, cell_id
1860
+ )
1861
+
1862
+ def _find_plugin_dock(self):
1863
+ """The QDockWidget hosting the correction header, walking up from it.
1864
+
1865
+ Resolved from ``self.header`` (still parented into the plugin dock at
1866
+ activation start, before the focus takeover reparents it out).
1867
+ """
1868
+ from qtpy.QtWidgets import QDockWidget
1869
+
1870
+ widget = self.header.parentWidget()
1871
+ while widget is not None:
1872
+ if isinstance(widget, QDockWidget):
1873
+ return widget
1874
+ widget = widget.parentWidget()
1875
+ return None
1876
+
1877
+ def _main_window(self):
1878
+ return getattr(getattr(self.viewer, "window", None), "_qt_window", None)
1879
+
1880
+ def _size_workspace_dock(self) -> None:
1881
+ """Open the workspace at half the window width; size the body splitter.
1882
+
1883
+ The dock opens at half the window; ``_apply_workspace_panel_sizes`` then
1884
+ distributes the toolbar · gallery · accordion strips (honouring any
1885
+ already-collapsed pane).
1886
+ """
1887
+ win = self._main_window()
1888
+ dock = self._workspace_dock
1889
+ splitter = self._workspace_splitter
1890
+ if win is None or dock is None or splitter is None:
1891
+ return
1892
+ try:
1893
+ target = max(int(win.width()) // 2, 1)
1894
+ win.resizeDocks([dock], [target], Qt.Horizontal)
1895
+ self._apply_workspace_panel_sizes()
1896
+ except Exception:
1897
+ logger.exception("could not size the correction workspace dock")
1898
+
1899
+ def _restore_pre_correction_dock_width(self) -> None:
1900
+ """Shrink the plugin dock back to its pre-correction (focus-mode) width."""
1901
+ dock = self._pre_correction_dock
1902
+ width = self._pre_correction_dock_width
1903
+ self._pre_correction_dock = None
1904
+ self._pre_correction_dock_width = None
1905
+ win = self._main_window()
1906
+ if dock is None or width is None or win is None:
1907
+ return
1908
+ # Deferred: the plugin dock has only just been re-shown, so let Qt lay it
1909
+ # back out before forcing its width.
1910
+ QTimer.singleShot(
1911
+ 0,
1912
+ lambda: self._apply_dock_width(win, dock, int(width)),
1913
+ )
1914
+
1915
+ @staticmethod
1916
+ def _apply_dock_width(win, dock, width: int) -> None:
1917
+ try:
1918
+ win.resizeDocks([dock], [width], Qt.Horizontal)
1919
+ except Exception:
1920
+ logger.exception("could not restore the plugin dock width")
1921
+
1922
+ def _teardown_workspace_controls_dock(self) -> None:
1923
+ """Remove the workspace dock (top bar / reveal already reparented out).
1924
+
1925
+ The toolbar is rescued back onto ``self`` first — it is built once and
1926
+ must survive the dock teardown to be re-used on a later activate.
1927
+ Destroying the dock then deletes the container, the body splitter and the
1928
+ embedded panels (candidate gallery, accordion); the controllers drop
1929
+ their stale references in their own ``teardown``.
1930
+ """
1931
+ self.toolbar.setParent(self)
1932
+ self.toolbar.setVisible(False)
1933
+ if self._workspace_dock is not None:
1934
+ try:
1935
+ self.viewer.window.remove_dock_widget(self._workspace_dock)
1936
+ except Exception:
1937
+ logger.exception("could not remove the correction workspace dock")
1938
+ self._workspace_dock = None
1939
+ self._workspace_splitter = None
1940
+ self._workspace_container = None
1941
+ self._gallery_pane = None
1942
+ self._accordion_pane = None
1943
+
1944
+ def _arrange_workspace_docks(self) -> None:
1945
+ """Build the workspace dock once.
1946
+
1947
+ The dock holds one container: the full-width top bar and reveal area
1948
+ stacked over a body row — a fixed-width toolbar column beside a
1949
+ horizontal splitter (candidate gallery · accordion). The gallery and
1950
+ accordion are each wrapped in a ``CollapsiblePane`` so they hide/show via
1951
+ their own ✕ / show-tab; the accordion is the main surface but can be
1952
+ collapsed just the same.
1953
+ """
1954
+ self._ensure_workspace_splitter()
1955
+
1956
+ def _ensure_workspace_splitter(self) -> QSplitter | None:
1957
+ """Create the body splitter, the container + dock once (or return None).
1958
+
1959
+ Reparents the top bar (``self.header``) and reveal area (``self.section``)
1960
+ out of the plugin dock into the container, with the body splitter beneath
1961
+ them. The reparent reuses every existing button + signal as-is.
1962
+ """
1963
+ if self._workspace_splitter is not None:
1964
+ return self._workspace_splitter
1965
+
1966
+ splitter = QSplitter(Qt.Horizontal)
1967
+ splitter.setChildrenCollapsible(False)
1968
+ # Default handles are a ~3 px sliver and near-invisible on the dark
1969
+ # theme. Widen them and paint a centred grip so they're easy to grab.
1970
+ # Explicit greys (not palette roles, which napari leaves unset — they
1971
+ # resolved to white): a mid-grey grip inset against the dark dock bg.
1972
+ splitter.setHandleWidth(10)
1973
+ splitter.setStyleSheet(
1974
+ "QSplitter::handle:horizontal {"
1975
+ " background: #5a606b;"
1976
+ " border-left: 4px solid #2e3440;"
1977
+ " border-right: 4px solid #2e3440;"
1978
+ " }"
1979
+ "QSplitter::handle:horizontal:hover { background: #7a828f; }"
1980
+ )
1981
+ # candidate gallery · accordion, each wrapped in a collapsible pane; the
1982
+ # accordion pane is the stretch panel that absorbs outer-width changes.
1983
+ self._gallery_pane = CollapsiblePane(
1984
+ self._candidate_gallery.widget(), title="Candidate gallery"
1985
+ )
1986
+ self._accordion_pane = CollapsiblePane(
1987
+ self._lineage_canvas.panel(), title="Tracking overview"
1988
+ )
1989
+ self._gallery_pane.collapsed_changed.connect(
1990
+ lambda collapsed: self._on_workspace_panel_toggled(
1991
+ self._gallery_pane, collapsed
1992
+ )
1993
+ )
1994
+ self._accordion_pane.collapsed_changed.connect(
1995
+ lambda collapsed: self._on_workspace_panel_toggled(
1996
+ self._accordion_pane, collapsed
1997
+ )
1998
+ )
1999
+ splitter.addWidget(self._gallery_pane)
2000
+ splitter.addWidget(self._accordion_pane)
2001
+ splitter.setStretchFactor(0, 0)
2002
+ splitter.setStretchFactor(1, 1)
2003
+
2004
+ # The toolbar is a fixed-width icon column, not a resizable pane: pin it
2005
+ # beside the splitter in a plain row (no drag handle) so it always hugs
2006
+ # its icons and can't be stretched.
2007
+ self.toolbar.setVisible(True)
2008
+ self.toolbar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
2009
+ body = QWidget()
2010
+ body.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
2011
+ body_lay = QHBoxLayout(body)
2012
+ body_lay.setContentsMargins(0, 0, 0, 0)
2013
+ body_lay.setSpacing(4)
2014
+ body_lay.addWidget(self.toolbar)
2015
+ body_lay.addWidget(splitter, stretch=1)
2016
+
2017
+ container = QWidget()
2018
+ # The body row carries the stretch so the splitter (gallery · accordion)
2019
+ # grows to the full dock height; an explicit Expanding policy keeps the
2020
+ # container itself from shrinking to its hint in packaged builds.
2021
+ container.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
2022
+ lay = QVBoxLayout(container)
2023
+ lay.setContentsMargins(0, 0, 0, 0)
2024
+ lay.setSpacing(4)
2025
+ lay.addWidget(self.header)
2026
+ lay.addWidget(self.section)
2027
+ lay.addWidget(body, stretch=1)
2028
+ self._workspace_container = container
2029
+
2030
+ try:
2031
+ self._workspace_dock = self.viewer.window.add_dock_widget(
2032
+ container, name="Tracking Correction", area="right"
2033
+ )
2034
+ except Exception:
2035
+ logger.exception("could not dock the correction workspace")
2036
+ self._workspace_dock = None
2037
+ self._workspace_container = None
2038
+ self._gallery_pane = None
2039
+ self._accordion_pane = None
2040
+ return None
2041
+ self._workspace_splitter = splitter
2042
+ return splitter
2043
+
2044
+ def _apply_workspace_panel_sizes(self) -> None:
2045
+ """Size the body splitter from the panes' collapsed state.
2046
+
2047
+ The toolbar is a fixed column outside the splitter; here only the two
2048
+ panes are sized: a collapsed pane shrinks to its slim show-tab, and the
2049
+ expanded pane(s) share the remainder (the accordion favoured when both
2050
+ are open).
2051
+ """
2052
+ splitter = self._workspace_splitter
2053
+ if splitter is None or self._gallery_pane is None or self._accordion_pane is None:
2054
+ return
2055
+ rest = max(splitter.size().width(), 300)
2056
+ strip = _PANEL_STRIP_W
2057
+ gallery_collapsed = self._gallery_pane.is_collapsed()
2058
+ accordion_collapsed = self._accordion_pane.is_collapsed()
2059
+ if gallery_collapsed:
2060
+ gallery_w, accordion_w = strip, rest - strip
2061
+ elif accordion_collapsed:
2062
+ gallery_w, accordion_w = rest - strip, strip
2063
+ else:
2064
+ gallery_w = max(rest // 3, 1)
2065
+ accordion_w = rest - gallery_w
2066
+ splitter.setSizes([gallery_w, accordion_w])
2067
+
2068
+ def _teardown_focus_panels(self) -> None:
2069
+ """Undock the focus-mode panels (accordion, candidate gallery)."""
2070
+ self._lineage_canvas.teardown()
2071
+ self._candidate_gallery.teardown()
2072
+
2073
+ def _refresh_validation_counter(self) -> None:
2074
+ self._validated_overlay.refresh_counter(self.validation_counter_lbl)
2075
+
2076
+ def _on_cells_edited(self, t: int, changed_ids: set[int]) -> None:
2077
+ """A hand mask edit (draw / merge / relabel / redraw / fill / split)
2078
+ landed — announce it; the wired listeners repaint themselves."""
2079
+ self.events.labels_edited.emit(t, changed_ids)
2080
+
2081
+ def _apply_overlay_edit(self, t: int, changed_ids: set[int]) -> None:
2082
+ """``labels_edited`` listener: refresh the validated overlay + counter."""
2083
+ self._validated_overlay.on_cells_edited(
2084
+ t,
2085
+ changed_ids,
2086
+ frame_view_2d=frame_view_2d,
2087
+ counter_label=self.validation_counter_lbl,
2088
+ )
2089
+
2090
+ def _apply_track_path_edit(self, t: int, changed_ids: set[int]) -> None:
2091
+ """``labels_edited`` listener: rebuild the comet only when the *focused*
2092
+ track's pixels changed and the track-path overlay is on."""
2093
+ sel = int(getattr(self.correction_widget, "_selected_label", 0) or 0)
2094
+ if sel and sel in {int(v) for v in changed_ids} and self.track_path_btn.isChecked():
2095
+ self._refresh_track_path_overlay()
2096
+ self._refresh_track_path_spotlight()
2097
+
2098
+ def _frames_with_cell(self, cell_id: int) -> list[int]:
2099
+ return self._validated_overlay.frames_with_cell(cell_id)
2100
+
2101
+ def _manual_correction_protected_mask(
2102
+ self,
2103
+ t: int,
2104
+ frame: np.ndarray,
2105
+ ) -> np.ndarray:
2106
+ pos_dir = self._pos_dir
2107
+ if pos_dir is None:
2108
+ return np.zeros_like(frame, dtype=bool)
2109
+ validated_tracks = self._dependency("read_validated_tracks")(pos_dir)
2110
+ corrections = self._dependency("read_corrections")(pos_dir)
2111
+ protected_ids = protected_cell_ids_at_frame(
2112
+ validated_tracks,
2113
+ corrections,
2114
+ frame=t,
2115
+ )
2116
+ return protected_cell_mask(frame, protected_ids)
2117
+
2118
+ def _refresh_correction_label_visuals_for_edit(
2119
+ self,
2120
+ t: int,
2121
+ changed_ids: set[int],
2122
+ ) -> None:
2123
+ if _CORRECTION_TRACKED_LAYER not in self.viewer.layers:
2124
+ return
2125
+ layer = self.viewer.layers[_CORRECTION_TRACKED_LAYER]
2126
+ # In the neutral (outline) view the single-colour colormap already covers
2127
+ # any new ids via its ``None`` default, so only extend the per-id map in
2128
+ # the filled (by-id) view.
2129
+ if self._filled_view_active():
2130
+ ensure_label_colormap_entries(
2131
+ layer,
2132
+ changed_ids,
2133
+ color_scale=_NUCLEUS_TRACK_COLOR_SCALE,
2134
+ )
2135
+ try:
2136
+ self.viewer.layers.selection.active = layer
2137
+ except Exception:
2138
+ pass
2139
+
2140
+ def _refresh_correction_label_visuals_for_changed_frames(
2141
+ self,
2142
+ before: np.ndarray,
2143
+ after: np.ndarray,
2144
+ ) -> None:
2145
+ before_arr = np.asarray(before)
2146
+ after_arr = np.asarray(after)
2147
+ if before_arr.shape != after_arr.shape or before_arr.ndim != 3:
2148
+ self._refresh_correction_label_visuals()
2149
+ return
2150
+
2151
+ for t in range(after_arr.shape[0]):
2152
+ before_frame = before_arr[t]
2153
+ after_frame = after_arr[t]
2154
+ if np.array_equal(before_frame, after_frame):
2155
+ continue
2156
+ changed_mask = before_frame != after_frame
2157
+ changed_ids = {
2158
+ int(value)
2159
+ for value in np.unique(np.concatenate(
2160
+ [before_frame[changed_mask], after_frame[changed_mask]]
2161
+ ))
2162
+ if int(value) != 0
2163
+ }
2164
+ self._refresh_correction_label_visuals_for_edit(t, changed_ids)
2165
+
2166
+ def _refresh_correction_label_visuals(self) -> None:
2167
+ if _CORRECTION_TRACKED_LAYER not in self.viewer.layers:
2168
+ return
2169
+ layer = self.viewer.layers[_CORRECTION_TRACKED_LAYER]
2170
+ data = np.asarray(layer.data)
2171
+ if self._filled_view_active():
2172
+ refresh_label_colormap(
2173
+ layer,
2174
+ data,
2175
+ color_scale=_NUCLEUS_TRACK_COLOR_SCALE,
2176
+ )
2177
+ else:
2178
+ apply_neutral_label_colormap(layer)
2179
+ # The all-tracks overlay's geometry follows the labels — rebuild it so a
2180
+ # reassign / remove-unvalidated / retrack reshapes the trajectories too.
2181
+ self._all_tracks.refresh()
2182
+ try:
2183
+ self.viewer.layers.selection.active = layer
2184
+ except Exception:
2185
+ pass