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,1363 @@
1
+ """Label correction widget for ITASC v2."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ from collections.abc import Callable
7
+
8
+ import napari
9
+ import napari.layers
10
+ import numpy as np
11
+ from napari.utils.notifications import show_error
12
+ from qtpy.QtCore import Qt
13
+ from qtpy.QtWidgets import (
14
+ QCheckBox,
15
+ QComboBox,
16
+ QFrame,
17
+ QGridLayout,
18
+ QGroupBox,
19
+ QHBoxLayout,
20
+ QLabel,
21
+ QPushButton,
22
+ QSizePolicy,
23
+ QSpinBox,
24
+ QVBoxLayout,
25
+ QWidget,
26
+ )
27
+ from itasc.napari._spotlight import spotlight_rgba as _spotlight_rgba
28
+ from itasc.correction.labels import (
29
+ _label_at,
30
+ add_cell,
31
+ carve_into_selected,
32
+ draw_cell_path,
33
+ erase_cell,
34
+ clean_stranded_pixels,
35
+ fill_label_holes,
36
+ fix_label_semiholes,
37
+ merge_cells,
38
+ relabel_cell,
39
+ split_draw,
40
+ swap_labels,
41
+ )
42
+ from itasc.napari.ui_style import (
43
+ action_button,
44
+ add_block_pair_row,
45
+ block_grid,
46
+ checked_success_button,
47
+ danger_button,
48
+ muted_label,
49
+ status_label,
50
+ )
51
+ from itasc.napari._widget_helpers import islider as _islider
52
+
53
+ log = logging.getLogger("itasc.correction")
54
+ if os.environ.get("ITASC_DEBUG"):
55
+ log.setLevel(logging.DEBUG)
56
+ if not log.handlers:
57
+ _h = logging.StreamHandler()
58
+ _h.setFormatter(logging.Formatter("[itasc.correction] %(levelname)s %(message)s"))
59
+ log.addHandler(_h)
60
+
61
+ _DRAW_LAYER = "[Correction] CorrectionDraw"
62
+ _SPOTLIGHT_LAYER = "[Correction] CellSpotlight"
63
+
64
+
65
+ class CorrectionWidget(QWidget):
66
+ """Dock widget for interactive label correction."""
67
+
68
+ def __init__(
69
+ self,
70
+ viewer: napari.Viewer,
71
+ parent: QWidget | None = None,
72
+ *,
73
+ show_activate_btn: bool = True,
74
+ show_shortcuts: bool = True,
75
+ inspector_first: bool = False,
76
+ show_cleanup: bool = True,
77
+ show_inspector: bool = True,
78
+ show_spawn_controls: bool = True,
79
+ contour_only: bool = False,
80
+ highlight_style: str = "spotlight",
81
+ ) -> None:
82
+ super().__init__(parent)
83
+ self.viewer = viewer
84
+ self._show_activate_btn = show_activate_btn
85
+ self._show_shortcuts = show_shortcuts
86
+ self._inspector_first = inspector_first
87
+ self._show_cleanup = show_cleanup
88
+ # The "Inspect cell" group is redundant where a lineage canvas provides
89
+ # navigation; the spinbox/label still back the goto + Shift-±/step logic
90
+ # even when the group is not added to the layout.
91
+ self._show_inspector = show_inspector
92
+ # The cell-correction panel is tied to the nucleus label set, so it must
93
+ # not create / delete / renumber cells. ``contour_only`` restricts the
94
+ # mouse toolkit to select + extend (Shift-left) + carve (Shift-right) and
95
+ # hides the spawn radius control; ``highlight_style="border"`` outlines
96
+ # the selection instead of dimming everything else.
97
+ self._show_spawn_controls = show_spawn_controls
98
+ self._contour_only = contour_only
99
+ self._highlight_style = highlight_style
100
+
101
+ self._layer: napari.layers.Labels | None = None
102
+
103
+ self._selected_label: int = 0
104
+ self._selected_pos = None
105
+ self._selected_t: int = -1
106
+ self._swap_first_pos = None
107
+ self._swap_first_t: int = -1
108
+
109
+ self._drag_callbacks: list = []
110
+ self._bound_keys: list = []
111
+
112
+ self._in_deactivate: bool = False
113
+
114
+ self._saved_viewer_drag_cbs: list = []
115
+ self._saved_layer_mode: str = "pan_zoom"
116
+ self._saved_layer_contour: int = 0
117
+
118
+ self._edit_callback: Callable[[int, set[int]], None] | None = None
119
+ self._selection_callback: Callable[[int, int], None] | None = None
120
+ self._selection_listeners: list[Callable[[int, int], None]] = []
121
+ # Optional override for the spotlight cutout: given (t, lab, default_mask)
122
+ # it may return a different boolean mask to highlight (e.g. the union of a
123
+ # whole track's masks) or None to keep the default per-frame behavior.
124
+ self._spotlight_mask_provider: (
125
+ Callable[[int, int, np.ndarray], np.ndarray | None] | None
126
+ ) = None
127
+ self._protected_mask_callback: (
128
+ Callable[[int, np.ndarray], np.ndarray | None] | None
129
+ ) = None
130
+ # Optional provider of a 2-D intensity frame (e.g. the nucleus image)
131
+ # used to snap spawned cells to the underlying signal. None → always
132
+ # stamp a plain disk.
133
+ self._intensity_frame_callback: (
134
+ Callable[[int], np.ndarray | None] | None
135
+ ) = None
136
+
137
+ self._setup_ui()
138
+
139
+ # ── UI ────────────────────────────────────────────────────────────────────
140
+
141
+ def _setup_ui(self) -> None:
142
+ root = QVBoxLayout(self)
143
+ root.setContentsMargins(2, 2, 2, 2)
144
+ root.setSpacing(6)
145
+
146
+ self._activate_btn = QPushButton("Activate on selected layer")
147
+ self._activate_btn.setCheckable(True)
148
+ self._activate_btn.setToolTip(
149
+ "Enable interactive mouse callbacks for merging/splitting."
150
+ )
151
+ action_button(self._activate_btn, expand=True)
152
+ checked_success_button(self._activate_btn)
153
+ self._activate_btn.clicked.connect(self._toggle_active)
154
+ if self._show_activate_btn:
155
+ root.addWidget(self._activate_btn)
156
+
157
+ attrib = QLabel(
158
+ "Correction tools adapted from "
159
+ '<a href="https://github.com/Image-Analysis-Hub/Epicure">Epicure</a>.'
160
+ "<br>If you use these tools, please cite:<br>"
161
+ '<a href="https://doi.org/10.64898/2026.03.27.714683">'
162
+ "doi:10.64898/2026.03.27.714683</a>"
163
+ )
164
+ attrib.setOpenExternalLinks(True)
165
+ attrib.setWordWrap(True)
166
+ muted_label(attrib, size_pt=9)
167
+
168
+ self._outline_btn = QCheckBox("Show outlines only")
169
+ self._outline_btn.setEnabled(False)
170
+ action_button(self._outline_btn, expand=True)
171
+ self._outline_btn.toggled.connect(self._toggle_outline)
172
+ root.addWidget(self._outline_btn)
173
+
174
+ self._reset_mode_btn = QPushButton("⚠ Restore correction mode")
175
+ self._reset_mode_btn.setVisible(False)
176
+ action_button(self._reset_mode_btn, expand=True)
177
+ danger_button(self._reset_mode_btn)
178
+ self._reset_mode_btn.clicked.connect(self._reset_tool_mode)
179
+ root.addWidget(self._reset_mode_btn)
180
+
181
+ # ── cleanup section (wrapped in container) ────────────── # ← CHANGED
182
+ self._cleanup_container = QWidget() # ← NEW
183
+ _clay = QVBoxLayout(self._cleanup_container) # ← NEW
184
+ _clay.setContentsMargins(0, 0, 0, 0) # ← NEW
185
+ _clay.setSpacing(6) # ← NEW
186
+
187
+ cleanup_label = QLabel("Artifact cleanup")
188
+ muted_label(cleanup_label, size_pt=9)
189
+ _clay.addWidget(cleanup_label) # ← CHANGED (was root)
190
+
191
+ cleanup_grid = block_grid(horizontal_spacing=12)
192
+ self._cleanup_scope_combo = QComboBox()
193
+ self._cleanup_scope_combo.addItems(["Current frame", "All frames"])
194
+ self._cleanup_scope_combo.setToolTip(
195
+ "Choose whether cleanup applies to the visible frame or the full label stack."
196
+ )
197
+ self._hole_radius_spin = _islider(0, 999, 5)
198
+ self._hole_radius_spin.setToolTip(
199
+ "Maximum pixel distance for filling enclosed background gaps. Set to 0 to skip gap filling."
200
+ )
201
+ self._semihole_opening_spin = _islider(0, 999, 3)
202
+ self._semihole_opening_spin.setToolTip(
203
+ "Maximum border contact, in pixels, for semihole repair. Set to 0 to skip semihole repair."
204
+ )
205
+ add_block_pair_row(
206
+ cleanup_grid,
207
+ 0,
208
+ "Scope:",
209
+ self._cleanup_scope_combo,
210
+ field_width=150,
211
+ )
212
+ add_block_pair_row(
213
+ cleanup_grid,
214
+ 1,
215
+ "Hole radius:",
216
+ self._hole_radius_spin,
217
+ "Max opening:",
218
+ self._semihole_opening_spin,
219
+ )
220
+ _clay.addLayout(cleanup_grid)
221
+
222
+ self._fill_holes_btn = QPushButton("Fill Holes")
223
+ self._fill_holes_btn.setEnabled(False)
224
+ self._fill_holes_btn.setToolTip("Fill enclosed background gaps using the configured hole radius.")
225
+ action_button(self._fill_holes_btn, expand=True)
226
+ self._fill_holes_btn.clicked.connect(self._fill_holes)
227
+ _clay.addWidget(self._fill_holes_btn) # ← CHANGED
228
+
229
+ self._fix_semiholes_btn = QPushButton("Fix Semiholes")
230
+ self._fix_semiholes_btn.setEnabled(False)
231
+ self._fix_semiholes_btn.setToolTip(
232
+ "Repair narrow border-connected gaps using the radius and max opening controls."
233
+ )
234
+ action_button(self._fix_semiholes_btn, expand=True)
235
+ self._fix_semiholes_btn.clicked.connect(self._fix_semiholes)
236
+ _clay.addWidget(self._fix_semiholes_btn) # ← CHANGED
237
+
238
+ self._clean_fragments_btn = QPushButton("Clean Fragments")
239
+ self._clean_fragments_btn.setEnabled(False)
240
+ self._clean_fragments_btn.setToolTip("Remove disconnected same-label fragments without filling background holes.")
241
+ action_button(self._clean_fragments_btn, expand=True)
242
+ self._clean_fragments_btn.clicked.connect(self._clean_fragments)
243
+ _clay.addWidget(self._clean_fragments_btn) # ← CHANGED
244
+
245
+ if self._show_cleanup:
246
+ root.addWidget(self._cleanup_container)
247
+
248
+ # ── spawn-cell controls ───────────────────────────────────────────────
249
+ # Middle-clicking empty space spawns a cell; this sets its fallback size
250
+ # when the nucleus signal is too weak to snap to. Wrapped in a container
251
+ # so an embedder (e.g. the nucleus widget) can relocate it into its own
252
+ # parameter panel; shown here when not relocated.
253
+ self._cell_radius_spin = _islider(1, 999, 6)
254
+ self._cell_radius_spin.setToolTip(
255
+ "Radius (px) of a cell spawned by middle-clicking empty space. "
256
+ "Used as a fallback size when the nucleus signal is too weak to snap to."
257
+ )
258
+ self._spawn_controls = QWidget()
259
+ spawn_lay = QVBoxLayout(self._spawn_controls)
260
+ spawn_lay.setContentsMargins(0, 0, 0, 0)
261
+ spawn_lay.setSpacing(6)
262
+ spawn_grid = block_grid(horizontal_spacing=12)
263
+ add_block_pair_row(
264
+ spawn_grid,
265
+ 0,
266
+ "Cell radius:",
267
+ self._cell_radius_spin,
268
+ )
269
+ spawn_lay.addLayout(spawn_grid)
270
+ # Hidden in contour-only mode (no spawning), but kept alive as a member
271
+ # so any code reading ``_cell_radius_spin`` still finds a live widget.
272
+ if self._show_spawn_controls:
273
+ root.addWidget(self._spawn_controls)
274
+
275
+ self._status = QLabel("Inactive")
276
+ self._status.setAlignment(Qt.AlignmentFlag.AlignCenter)
277
+ status_label(self._status, italic=True, muted=True)
278
+ root.addWidget(self._status)
279
+
280
+ # Hold a reference even when the group is not added to the layout
281
+ # (show_inspector=False): otherwise Python GCs the QGroupBox and its
282
+ # children, deleting the C++ QSpinBox that goto/Shift-± still drive.
283
+ inspect_group = QGroupBox("Inspect cell")
284
+ self._inspect_group = inspect_group
285
+ inspect_lay = QVBoxLayout(inspect_group)
286
+
287
+ id_row = QHBoxLayout()
288
+ id_row.addWidget(QLabel("Cell ID:"))
289
+ self._goto_cell_id = QSpinBox()
290
+ self._goto_cell_id.setRange(0, 999_999)
291
+ self._goto_cell_id.setValue(0)
292
+ self._goto_cell_id.setSpecialValueText("—")
293
+ self._goto_cell_id.setEnabled(False)
294
+ self._goto_cell_id.valueChanged.connect(self._goto_cell)
295
+ id_row.addWidget(self._goto_cell_id)
296
+ inspect_lay.addLayout(id_row)
297
+
298
+ self._inspect_frames_label = QLabel("")
299
+ self._inspect_frames_label.setWordWrap(True)
300
+ muted_label(self._inspect_frames_label, size_pt=9)
301
+ inspect_lay.addWidget(self._inspect_frames_label)
302
+
303
+ ref_group = self.build_shortcuts_widget()
304
+
305
+ if self._inspector_first:
306
+ if self._show_inspector:
307
+ root.addWidget(inspect_group)
308
+ if self._show_shortcuts:
309
+ root.addWidget(ref_group)
310
+ else:
311
+ if self._show_shortcuts:
312
+ root.addWidget(ref_group)
313
+ if self._show_inspector:
314
+ root.addWidget(inspect_group)
315
+
316
+ self._attrib_lbl = attrib
317
+ if self._show_activate_btn:
318
+ root.addWidget(self._attrib_lbl)
319
+ root.addStretch()
320
+
321
+ def build_shortcuts_widget(self) -> QWidget:
322
+ group = QGroupBox("Correction shortcuts")
323
+ grid = QGridLayout(group)
324
+ grid.setContentsMargins(8, 6, 8, 6)
325
+ grid.setHorizontalSpacing(10)
326
+ grid.setVerticalSpacing(2)
327
+ row = 0
328
+ row = self._add_shortcut_group(
329
+ grid,
330
+ "Selection",
331
+ [
332
+ ("Left-click", "Select / highlight cell"),
333
+ ],
334
+ start_row=row,
335
+ is_first=True,
336
+ )
337
+ if self._contour_only:
338
+ # Cell labels are tied to the nuclei: only contour edits are allowed.
339
+ manual_rows = [
340
+ ("Shift+Left-drag", "Extend the selected cell's contour"),
341
+ ("Shift+Right-drag", "Draw a line through a neighbour to cut it and merge the near piece into the selected cell"),
342
+ ]
343
+ else:
344
+ manual_rows = [
345
+ ("Middle-click empty space", "Spawn new cell"),
346
+ ("Middle-click on cell or Delete", "Erase cell"),
347
+ ("Ctrl+Left-click", "Merge with the clicked cell (same frame)"),
348
+ ("Ctrl+Middle-click", "Grow / link selected track here"),
349
+ ("Ctrl+Right-click", "Swap with the clicked cell, or attach it to the selected track (other frame)"),
350
+ ("Shift+Left-drag", "Draw / extend cell path"),
351
+ ("Shift+Right-drag", "Split by drawn line"),
352
+ ]
353
+ row = self._add_shortcut_group(
354
+ grid,
355
+ "Contour Edits" if self._contour_only else "Manual Labels",
356
+ manual_rows,
357
+ start_row=row,
358
+ )
359
+ row = self._add_shortcut_group(
360
+ grid, "History", [("Ctrl+Z", "Undo")], start_row=row
361
+ )
362
+ grid.setColumnStretch(1, 1)
363
+ return group
364
+
365
+ @staticmethod
366
+ def _add_shortcut_group(
367
+ grid: QGridLayout,
368
+ title: str,
369
+ rows: list[tuple[str, str]],
370
+ *,
371
+ start_row: int = 0,
372
+ is_first: bool = False,
373
+ ) -> int:
374
+ row = start_row
375
+ if not is_first:
376
+ grid.setRowMinimumHeight(row, 6)
377
+ row += 1
378
+ title_lbl = QLabel(title)
379
+ title_lbl.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
380
+ grid.addWidget(title_lbl, row, 0, 1, 2)
381
+ row += 1
382
+ sep = QFrame()
383
+ sep.setFrameShape(QFrame.Shape.HLine)
384
+ sep.setFrameShadow(QFrame.Shadow.Plain)
385
+ sep.setFixedHeight(1)
386
+ sep.setStyleSheet("background-color: rgba(255, 255, 255, 0.25); border: none;")
387
+ grid.addWidget(sep, row, 0, 1, 2)
388
+ row += 1
389
+ for key, desc in rows:
390
+ key_lbl = QLabel(key)
391
+ key_lbl.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
392
+ key_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
393
+ desc_lbl = QLabel(desc)
394
+ desc_lbl.setWordWrap(True)
395
+ desc_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
396
+ sp = QSizePolicy(
397
+ QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding
398
+ )
399
+ sp.setHeightForWidth(True)
400
+ desc_lbl.setSizePolicy(sp)
401
+ grid.addWidget(key_lbl, row, 0)
402
+ grid.addWidget(desc_lbl, row, 1)
403
+ row += 1
404
+ return row
405
+
406
+ # ── activation ────────────────────────────────────────────────────────────
407
+
408
+ def _toggle_active(self, checked: bool) -> None:
409
+ if checked:
410
+ layer = self.viewer.layers.selection.active
411
+ if layer is None:
412
+ self._activate_btn.setChecked(False)
413
+ self._set_status("Select a Labels layer first", error=True)
414
+ return
415
+ if not isinstance(layer, napari.layers.Labels):
416
+ self._activate_btn.setChecked(False)
417
+ self._set_status("Not a Labels layer", error=True)
418
+ return
419
+ self._activate(layer)
420
+ else:
421
+ self._deactivate()
422
+
423
+ def _activate(self, layer: napari.layers.Labels) -> None:
424
+ log.debug("activate: layer='%s' shape=%s", layer.name, layer.data.shape)
425
+ self._layer = layer
426
+ self._selected_label = 0
427
+ self._selected_pos = None
428
+ self._selected_t = -1
429
+ self._swap_first_pos = None
430
+ self._swap_first_t = -1
431
+
432
+ if hasattr(self.viewer, "mouse_drag_callbacks"):
433
+ self._saved_viewer_drag_cbs = list(self.viewer.mouse_drag_callbacks)
434
+ self.viewer.mouse_drag_callbacks.clear()
435
+ else:
436
+ self._saved_viewer_drag_cbs = []
437
+
438
+ self._saved_layer_mode = layer.mode
439
+ self._saved_layer_contour = int(layer.contour)
440
+ layer.mode = "pan_zoom"
441
+
442
+ self.viewer.layers.selection.active = layer
443
+ self._get_draw_layer()
444
+ self._get_spotlight_layer()
445
+
446
+ self.viewer.dims.events.current_step.connect(self._on_dims_change)
447
+ layer.events.data.connect(self._on_layer_data_changed)
448
+ layer.events.paint.connect(self._on_layer_data_changed)
449
+ self.viewer.layers.events.removed.connect(self._on_layer_removed)
450
+ layer.events.mode.connect(self._on_layer_mode_change)
451
+
452
+ self._register_callbacks()
453
+ self._activate_btn.setText("Deactivate")
454
+ self._outline_btn.setEnabled(True)
455
+ self._set_cleanup_enabled(True)
456
+ self._outline_btn.setChecked(False)
457
+ self._toggle_outline(False)
458
+ self._goto_cell_id.setEnabled(True)
459
+ self._set_status(f"Active on '{layer.name}'")
460
+
461
+ def _deactivate(self) -> None:
462
+ if self._in_deactivate:
463
+ return
464
+ self._in_deactivate = True
465
+ try:
466
+ self._deactivate_impl()
467
+ finally:
468
+ self._in_deactivate = False
469
+
470
+ def _deactivate_impl(self) -> None:
471
+ log.debug("deactivate: layer='%s'", self._layer.name if self._layer else None)
472
+ if self._layer is not None:
473
+ self._remove_callbacks()
474
+
475
+ for disconnect in [
476
+ lambda: self.viewer.dims.events.current_step.disconnect(self._on_dims_change),
477
+ lambda: self.viewer.layers.events.removed.disconnect(self._on_layer_removed),
478
+ lambda: self._layer.events.data.disconnect(self._on_layer_data_changed),
479
+ lambda: self._layer.events.paint.disconnect(self._on_layer_data_changed),
480
+ lambda: self._layer.events.mode.disconnect(self._on_layer_mode_change),
481
+ ]:
482
+ try:
483
+ disconnect()
484
+ except Exception:
485
+ pass
486
+
487
+ try:
488
+ self._layer.mode = self._saved_layer_mode
489
+ except Exception:
490
+ pass
491
+ try:
492
+ self._layer.contour = self._saved_layer_contour
493
+ except Exception:
494
+ pass
495
+
496
+ if hasattr(self.viewer, "mouse_drag_callbacks"):
497
+ self.viewer.mouse_drag_callbacks.clear()
498
+ for cb in self._saved_viewer_drag_cbs:
499
+ self.viewer.mouse_drag_callbacks.append(cb)
500
+
501
+ self._layer = None
502
+ self._selected_label = 0
503
+ self._selected_pos = None
504
+ self._selected_t = -1
505
+ self._swap_first_pos = None
506
+ self._swap_first_t = -1
507
+ self._saved_viewer_drag_cbs = []
508
+
509
+ self._activate_btn.setText("Activate on selected layer")
510
+ self._activate_btn.setChecked(False)
511
+ self._outline_btn.setChecked(False)
512
+ self._outline_btn.setEnabled(False)
513
+ self._set_cleanup_enabled(False)
514
+ self._goto_cell_id.setEnabled(False)
515
+ self._goto_cell_id.setValue(0)
516
+ self._inspect_frames_label.setText("")
517
+ self._set_status("Inactive")
518
+ self._cleanup_draw_layer()
519
+ self._cleanup_spotlight_layer()
520
+
521
+ def activate_layer(self, layer: napari.layers.Labels) -> None:
522
+ """Activate correction on a specific Labels layer (bypasses the UI button)."""
523
+ if self._layer is not None:
524
+ self._deactivate()
525
+ self._activate(layer)
526
+ self._activate_btn.setChecked(True)
527
+
528
+ def deactivate(self) -> None:
529
+ """Deactivate correction (public API)."""
530
+ self._deactivate()
531
+
532
+ def _set_status(self, msg: str, error: bool = False) -> None:
533
+ self._status.setText(msg)
534
+ if error:
535
+ self._status.setStyleSheet("color: #b00020; font-style: italic;")
536
+ else:
537
+ status_label(self._status, italic=True, muted=True)
538
+
539
+ def _set_cleanup_enabled(self, enabled: bool) -> None:
540
+ for button in (
541
+ self._fill_holes_btn,
542
+ self._fix_semiholes_btn,
543
+ self._clean_fragments_btn,
544
+ ):
545
+ button.setEnabled(enabled)
546
+
547
+ def set_edit_callback(self, fn: Callable[[int, set[int]], None] | None) -> None:
548
+ self._edit_callback = fn
549
+
550
+ def set_selection_callback(self, fn: Callable[[int, int], None] | None) -> None:
551
+ self._selection_callback = fn
552
+
553
+ def add_selection_listener(self, fn: Callable[[int, int], None]) -> None:
554
+ """Register an extra selection callback that survives ``set_selection_callback``.
555
+
556
+ ``set_selection_callback`` is a single slot owned by the workflow widget,
557
+ so registering there would clobber (or be clobbered by) it. Listeners
558
+ accumulate instead, letting independent features (e.g. the track-path
559
+ comet) react to selection changes without fighting over that slot.
560
+ """
561
+ if fn not in self._selection_listeners:
562
+ self._selection_listeners.append(fn)
563
+
564
+ def set_spotlight_mask_provider(
565
+ self, fn: Callable[[int, int, np.ndarray], np.ndarray | None] | None
566
+ ) -> None:
567
+ self._spotlight_mask_provider = fn
568
+
569
+ def set_highlight_style(self, style: str) -> None:
570
+ """Switch the selection indicator between the dimming spotlight and a
571
+ plain yellow border, re-rendering the current selection."""
572
+ if style not in ("spotlight", "border"):
573
+ raise ValueError(f"unknown highlight_style: {style!r}")
574
+ if style == self._highlight_style:
575
+ return
576
+ self._highlight_style = style
577
+ if self._selected_label and self._layer is not None and self._selected_t >= 0:
578
+ self._update_highlight(
579
+ self._selected_t, self._selected_label, notify=False
580
+ )
581
+
582
+ def set_protected_mask_callback(
583
+ self,
584
+ fn: Callable[[int, np.ndarray], np.ndarray | None] | None,
585
+ ) -> None:
586
+ self._protected_mask_callback = fn
587
+
588
+ def set_intensity_frame_callback(
589
+ self,
590
+ fn: Callable[[int], np.ndarray | None] | None,
591
+ ) -> None:
592
+ """Provide a per-frame 2-D intensity image to guide spawned cells.
593
+
594
+ ``fn(t)`` returns the nucleus/intensity frame for time ``t`` (matching
595
+ the label frame shape) or None. When unset, middle-clicking empty space
596
+ stamps a plain disk instead of snapping to the signal.
597
+ """
598
+ self._intensity_frame_callback = fn
599
+
600
+ def _intensity_frame(self, t: int) -> np.ndarray | None:
601
+ if self._intensity_frame_callback is None:
602
+ return None
603
+ try:
604
+ frame = self._intensity_frame_callback(t)
605
+ except Exception:
606
+ return None
607
+ return None if frame is None else np.asarray(frame)
608
+
609
+ def select_label(self, t: int, label: int, *, notify: bool = True) -> None:
610
+ self._update_highlight(t, label, notify=notify)
611
+
612
+ def _cleanup_frame_indices(self) -> list[int]:
613
+ if self._layer is None:
614
+ return []
615
+ if self._layer.data.ndim < 3:
616
+ return [0]
617
+ if self._cleanup_scope_combo.currentText() == "All frames":
618
+ return list(range(int(self._layer.data.shape[0])))
619
+ return [int(self.viewer.dims.current_step[0])]
620
+
621
+ def _run_artifact_cleanup(
622
+ self,
623
+ operation_name: str,
624
+ no_change_message: str,
625
+ operation: Callable[[np.ndarray], None],
626
+ ) -> None:
627
+ if self._layer is None:
628
+ self._set_status("No active labels layer", error=True)
629
+ return
630
+ try:
631
+ changed_frames = 0
632
+ changed_pixels = 0
633
+ for t in self._cleanup_frame_indices():
634
+ seg2d = self._frame_view(self._layer, t)
635
+ before = seg2d.copy()
636
+ operation(seg2d)
637
+ changed = int(np.sum(before != seg2d))
638
+ if not changed:
639
+ continue
640
+ changed_frames += 1
641
+ changed_pixels += changed
642
+ self._record_history(self._layer, t, before)
643
+
644
+ if changed_pixels:
645
+ self._layer.refresh()
646
+ current_t = (
647
+ int(self.viewer.dims.current_step[0])
648
+ if self._layer.data.ndim >= 3
649
+ else 0
650
+ )
651
+ if self._selected_label:
652
+ self._update_highlight(current_t, self._selected_label)
653
+ self._set_status(
654
+ f"{operation_name} in {changed_frames} frame(s), {changed_pixels} px changed. Unsaved."
655
+ )
656
+ else:
657
+ self._set_status(no_change_message)
658
+ except Exception as exc:
659
+ show_error(f"cleanup error: {exc}")
660
+
661
+ def _fill_holes(self) -> None:
662
+ radius = int(self._hole_radius_spin.value())
663
+ self._run_artifact_cleanup(
664
+ "Filled holes",
665
+ "No holes found",
666
+ lambda seg2d: np.copyto(seg2d, fill_label_holes(seg2d, radius=radius)),
667
+ )
668
+
669
+ def _fix_semiholes(self) -> None:
670
+ radius = int(self._hole_radius_spin.value())
671
+ max_opening = int(self._semihole_opening_spin.value())
672
+ self._run_artifact_cleanup(
673
+ "Fixed semiholes",
674
+ "No semiholes found",
675
+ lambda seg2d: np.copyto(
676
+ seg2d,
677
+ fix_label_semiholes(seg2d, radius=radius, max_opening=max_opening),
678
+ ),
679
+ )
680
+
681
+ def _clean_fragments(self) -> None:
682
+ self._run_artifact_cleanup(
683
+ "Cleaned fragments",
684
+ "No fragments found",
685
+ lambda seg2d: clean_stranded_pixels(seg2d),
686
+ )
687
+
688
+ @staticmethod
689
+ def _frame_view(layer, t: int) -> np.ndarray:
690
+ if layer.data.ndim == 2:
691
+ return layer.data
692
+ v = layer.data[t]
693
+ while v.ndim > 2:
694
+ if v.shape[0] != 1:
695
+ raise ValueError(f"non-singleton dim in frame slice: shape={v.shape}")
696
+ v = v[0]
697
+ return v
698
+
699
+ def _next_free_label(self) -> int:
700
+ if self._layer is None:
701
+ return 1
702
+ return int(np.max(self._layer.data)) + 1
703
+
704
+ def _record_history(self, layer, t: int, before: np.ndarray) -> None:
705
+ after = self._frame_view(layer, t)
706
+ changed = np.where(before != after)
707
+ if not changed[0].size:
708
+ return
709
+ n = changed[0].size
710
+ extra = layer.data.ndim - 1 - 2
711
+ parts = [np.full(n, t, dtype=layer.data.dtype)]
712
+ parts.extend(np.zeros(n, dtype=layer.data.dtype) for _ in range(extra))
713
+ parts.extend(changed)
714
+ layer._save_history((tuple(parts), before[changed], after[changed]))
715
+ if self._edit_callback is not None:
716
+ ids = set(int(v) for v in before[changed]) | set(int(v) for v in after[changed])
717
+ ids.discard(0)
718
+ if ids:
719
+ try:
720
+ self._edit_callback(t, ids)
721
+ except Exception:
722
+ import logging as _logging
723
+ _logging.getLogger("itasc.correction").exception("edit_callback failed")
724
+
725
+ def _protected_mask(self, t: int, seg2d: np.ndarray) -> np.ndarray | None:
726
+ if self._protected_mask_callback is None:
727
+ return None
728
+ mask = self._protected_mask_callback(t, seg2d)
729
+ if mask is None:
730
+ return None
731
+ mask = np.asarray(mask, dtype=bool)
732
+ if mask.shape != seg2d.shape:
733
+ raise ValueError(
734
+ f"protected mask shape {mask.shape} does not match frame shape {seg2d.shape}"
735
+ )
736
+ return mask
737
+
738
+ # ── draw layer ────────────────────────────────────────────────────────────
739
+
740
+ def _get_draw_layer(self):
741
+ if _DRAW_LAYER in self.viewer.layers:
742
+ return self.viewer.layers[_DRAW_LAYER]
743
+ dl = self.viewer.add_shapes(
744
+ name=_DRAW_LAYER,
745
+ ndim=2,
746
+ edge_color="yellow",
747
+ edge_width=1,
748
+ face_color="transparent",
749
+ )
750
+ dl.visible = False
751
+ if self._layer is not None:
752
+ self.viewer.layers.selection.active = self._layer
753
+ return dl
754
+
755
+ def _cleanup_draw_layer(self) -> None:
756
+ if _DRAW_LAYER in self.viewer.layers:
757
+ self.viewer.layers.remove(self.viewer.layers[_DRAW_LAYER])
758
+
759
+ # ── selection spotlight ──────────────────────────────────────────────────
760
+
761
+ def _get_spotlight_layer(self):
762
+ if _SPOTLIGHT_LAYER in self.viewer.layers:
763
+ return self.viewer.layers[_SPOTLIGHT_LAYER]
764
+ spotlight = self.viewer.add_image(
765
+ np.zeros((1, 1, 4), dtype=np.float32),
766
+ name=_SPOTLIGHT_LAYER,
767
+ rgb=True,
768
+ blending="translucent",
769
+ )
770
+ spotlight.visible = False
771
+ if self._layer is not None:
772
+ self.viewer.layers.selection.active = self._layer
773
+ return spotlight
774
+
775
+ def _notify_selection_changed(self, t: int, lab: int, previous_label: int) -> None:
776
+ if lab == previous_label:
777
+ return
778
+ import logging as _logging
779
+ if self._selection_callback is not None:
780
+ try:
781
+ self._selection_callback(t, lab)
782
+ except Exception:
783
+ _logging.getLogger("itasc.correction").exception(
784
+ "selection_callback failed"
785
+ )
786
+ for listener in list(self._selection_listeners):
787
+ try:
788
+ listener(t, lab)
789
+ except Exception:
790
+ _logging.getLogger("itasc.correction").exception(
791
+ "selection_listener failed"
792
+ )
793
+
794
+ def _update_highlight(self, t: int, lab: int, *, notify: bool = True) -> None:
795
+ """Spotlight the selected cell (or clear it when nothing is selected).
796
+
797
+ The spotlight is the sole selection indicator: full brightness inside
798
+ the selected cell's mask (or a provider-widened mask, e.g. a whole
799
+ track's union), uniformly dimmed everywhere outside.
800
+ """
801
+ previous_label = self._selected_label
802
+ self._selected_label = lab
803
+ self._selected_t = t if lab != 0 else -1
804
+ old = self._goto_cell_id.blockSignals(True)
805
+ try:
806
+ self._goto_cell_id.setValue(int(lab))
807
+ finally:
808
+ self._goto_cell_id.blockSignals(old)
809
+ if lab == 0 or self._layer is None:
810
+ self._clear_spotlight()
811
+ if notify:
812
+ self._notify_selection_changed(t, lab, previous_label)
813
+ return
814
+ seg2d = self._frame_view(self._layer, t)
815
+ mask = seg2d == lab
816
+ if not mask.any():
817
+ self._selected_label = 0
818
+ self._selected_t = -1
819
+ self._clear_spotlight()
820
+ if notify:
821
+ self._notify_selection_changed(t, 0, previous_label)
822
+ return
823
+ # The border style outlines just the selected cell; only the dimming
824
+ # spotlight widens to a provider mask (e.g. a whole track's union).
825
+ if self._highlight_style == "border":
826
+ highlight_mask = mask
827
+ else:
828
+ highlight_mask = self._resolve_spotlight_mask(t, lab, mask)
829
+ self._update_spotlight(highlight_mask)
830
+ self.viewer.layers.selection.active = self._layer
831
+ if notify:
832
+ self._notify_selection_changed(t, lab, previous_label)
833
+
834
+ def _resolve_spotlight_mask(
835
+ self, t: int, lab: int, default_mask: np.ndarray
836
+ ) -> np.ndarray:
837
+ """Let an optional provider widen the spotlight cutout (e.g. to a whole
838
+ track's union), falling back to the per-frame ``default_mask``."""
839
+ provider = self._spotlight_mask_provider
840
+ if provider is None:
841
+ return default_mask
842
+ try:
843
+ override = provider(t, lab, default_mask)
844
+ except Exception:
845
+ import logging as _logging
846
+ _logging.getLogger("itasc.correction").exception(
847
+ "spotlight_mask_provider failed"
848
+ )
849
+ return default_mask
850
+ if override is None:
851
+ return default_mask
852
+ override = np.asarray(override, dtype=bool)
853
+ if override.shape != default_mask.shape or not override.any():
854
+ return default_mask
855
+ return override
856
+
857
+ def _update_spotlight(self, mask: np.ndarray) -> None:
858
+ # The mask is the selected cell, or the whole track's union when a
859
+ # provider widens it. Two render styles:
860
+ # "spotlight" — full brightness inside the mask (alpha 0), uniformly
861
+ # dimmed everywhere outside it.
862
+ # "border" — a bright opaque ring around the mask, transparent
863
+ # elsewhere (leaves the rest of the frame untouched).
864
+ spotlight = self._get_spotlight_layer()
865
+ border = self._highlight_style == "border"
866
+ data = _spotlight_rgba(mask, dim=not border, border=border)
867
+ spotlight.data = data
868
+ spotlight.visible = True
869
+ if self._layer is not None:
870
+ self.viewer.layers.selection.active = self._layer
871
+
872
+ def _clear_spotlight(self) -> None:
873
+ if _SPOTLIGHT_LAYER in self.viewer.layers:
874
+ spotlight = self.viewer.layers[_SPOTLIGHT_LAYER]
875
+ spotlight.data = np.zeros((1, 1, 4), dtype=np.float32)
876
+ spotlight.visible = False
877
+
878
+ def _cleanup_spotlight_layer(self) -> None:
879
+ if _SPOTLIGHT_LAYER in self.viewer.layers:
880
+ self.viewer.layers.remove(self.viewer.layers[_SPOTLIGHT_LAYER])
881
+
882
+ def _on_dims_change(self, event=None) -> None:
883
+ if not (self._selected_label and self._layer is not None):
884
+ return
885
+ step = self.viewer.dims.current_step
886
+ if self._layer.data.ndim < 3 or len(step) < self._layer.data.ndim:
887
+ return
888
+ t = int(step[0])
889
+ if t >= self._layer.data.shape[0]:
890
+ return
891
+ selected_label = self._selected_label
892
+ selected_pos = self._selected_pos
893
+ selected_t = self._selected_t
894
+ self._update_highlight(t, selected_label, notify=False)
895
+ self._selected_label = selected_label
896
+ self._selected_pos = selected_pos
897
+ self._selected_t = selected_t
898
+
899
+ def _on_layer_data_changed(self, event=None) -> None:
900
+ if not (self._selected_label and self._layer is not None):
901
+ return
902
+ step = self.viewer.dims.current_step
903
+ if self._layer.data.ndim < 3 or len(step) < self._layer.data.ndim:
904
+ return
905
+ t = int(step[0])
906
+ if t >= self._layer.data.shape[0]:
907
+ return
908
+ self._update_highlight(t, self._selected_label)
909
+
910
+ def _on_layer_mode_change(self, event=None) -> None:
911
+ if self._layer is None:
912
+ return
913
+ mode = getattr(event, "value", None) or self._layer.mode
914
+ log.debug("_on_layer_mode_change: mode=%s", mode)
915
+ if mode != "pan_zoom":
916
+ self._reset_mode_btn.setVisible(True)
917
+ self._set_status("Tool mode changed — corrections disabled", error=True)
918
+ else:
919
+ self._reset_mode_btn.setVisible(False)
920
+ if self._layer is not None:
921
+ self._set_status(f"Active on '{self._layer.name}'")
922
+
923
+ def _on_layer_removed(self, event=None) -> None:
924
+ removed = getattr(event, "value", None)
925
+ removed_name = getattr(removed, "name", None)
926
+ helper_names = {_DRAW_LAYER, _SPOTLIGHT_LAYER}
927
+ if removed is self._layer or removed_name in helper_names:
928
+ log.debug("_on_layer_removed: '%s' removed, deactivating", removed_name)
929
+ self._deactivate()
930
+
931
+ def _reset_tool_mode(self) -> None:
932
+ if self._layer is not None:
933
+ self._layer.mode = "pan_zoom"
934
+
935
+ # ── inspect cell ──────────────────────────────────────────────────────────
936
+
937
+ def _goto_cell(self) -> None:
938
+ lab = self._goto_cell_id.value()
939
+ if lab == 0:
940
+ step = self.viewer.dims.current_step
941
+ t = int(step[0]) if (self._layer is not None and self._layer.data.ndim >= 3 and len(step) >= 1) else 0
942
+ self._update_highlight(t, 0)
943
+ self._inspect_frames_label.setText("")
944
+ return
945
+ if self._layer is None:
946
+ return
947
+ data = self._layer.data
948
+ frames = [i for i in range(data.shape[0]) if np.any(data[i] == lab)]
949
+ if not frames:
950
+ self._inspect_frames_label.setText(f"Cell {lab} not found in any frame.")
951
+ step = self.viewer.dims.current_step
952
+ t = int(step[0]) if len(step) >= 1 else 0
953
+ self._update_highlight(t, 0)
954
+ return
955
+ _MAX = 20
956
+ if len(frames) <= _MAX:
957
+ frames_str = ", ".join(str(f) for f in frames)
958
+ else:
959
+ shown = ", ".join(str(f) for f in frames[:_MAX])
960
+ frames_str = f"{shown}, … ({len(frames)} frames total)"
961
+ self._inspect_frames_label.setText(f"Frames: {frames_str}")
962
+ step = self.viewer.dims.current_step
963
+ t = int(step[0]) if len(step) >= 1 else 0
964
+ self._update_highlight(t, lab)
965
+
966
+ # ── selection-agnostic track edits ────────────────────────────────────────
967
+
968
+ def _selected_label_pos(self, seg2d: np.ndarray) -> tuple[float, float] | None:
969
+ """A representative ``(y, x)`` of the selected label within *seg2d*.
970
+
971
+ The swap edits need a click point for the *selected* cell. Deriving it
972
+ from the label itself lets them work no matter how the cell became
973
+ selected — image click, lineage canvas, gallery, or the goto box. Only
974
+ the image-click path ever recorded a click position, so depending on
975
+ that stored position made these edits silently no-op for every other
976
+ selection source. Returns ``None`` when the selected label is absent
977
+ from this frame (so the caller can fall back to a link/relabel instead
978
+ of a same-frame swap). The returned pixel is the one nearest the
979
+ label's centroid, so it always lands inside the mask.
980
+ """
981
+ lab = self._selected_label
982
+ if not lab:
983
+ return None
984
+ ys, xs = np.where(seg2d == lab)
985
+ if ys.size == 0:
986
+ return None
987
+ cy, cx = ys.mean(), xs.mean()
988
+ i = int(np.argmin((ys - cy) ** 2 + (xs - cx) ** 2))
989
+ return (float(ys[i]), float(xs[i]))
990
+
991
+ def _spawn_into_selected(self, seg2d, pos, t: int, _layer) -> None:
992
+ """Ctrl+middle-click: spawn a cell at *pos* carrying the selected ID.
993
+
994
+ The new blob takes the selected cell's label, so it either *merges* into
995
+ the selected cell (when that cell already lives in this frame) or *links*
996
+ the selected track into this frame (when the selected cell is on another
997
+ frame — typically the previous one the user just stepped from). The
998
+ mechanics are identical — paint a fresh region with the selected ID —
999
+ only the status wording differs.
1000
+ """
1001
+ sel = self._selected_label
1002
+ if not sel:
1003
+ self._set_status("Ctrl+middle-click: select a cell first")
1004
+ return
1005
+ if _label_at(seg2d, pos) != 0:
1006
+ self._set_status(
1007
+ "Ctrl+middle-click empty space to grow / link the selected cell"
1008
+ )
1009
+ return
1010
+ present_here = bool(np.any(seg2d == sel))
1011
+ before = seg2d.copy()
1012
+ ok = add_cell(
1013
+ seg2d,
1014
+ pos,
1015
+ new_label=sel,
1016
+ radius=int(self._cell_radius_spin.value()),
1017
+ image=self._intensity_frame(t),
1018
+ protected_mask=self._protected_mask(t, seg2d),
1019
+ )
1020
+ if not ok:
1021
+ self._set_status("Spawn failed — no room here")
1022
+ return
1023
+ self._record_history(_layer, t, before)
1024
+ _layer.refresh()
1025
+ self._update_highlight(t, sel)
1026
+ if present_here:
1027
+ self._set_status(f"Merged into cell {sel} — Active on '{_layer.name}'")
1028
+ else:
1029
+ self._set_status(f"Linked cell {sel} → t={t} — Active on '{_layer.name}'")
1030
+
1031
+ def _ctrl_right_click_swap(self, seg2d, pos, t: int, _layer) -> None:
1032
+ """Ctrl+right-click: swap, attach to a track, or run a two-click swap.
1033
+
1034
+ The selected cell's position is derived from its label, so the action
1035
+ fires however the cell was selected — not only after a left-click on it.
1036
+ Context picks the operation:
1037
+
1038
+ * a pending two-click swap → this click is its second cell;
1039
+ * selected cell present in this frame → swap it with the clicked cell;
1040
+ * selected cell on another frame → attach the clicked cell to its track;
1041
+ * nothing usable selected → arm a two-click swap (this is the first cell;
1042
+ a following Ctrl+right-click picks the second).
1043
+ """
1044
+ lab = _label_at(seg2d, pos)
1045
+ if lab == 0:
1046
+ self._set_status("Swap — click on a cell (not background)")
1047
+ return
1048
+
1049
+ # Finish a pending two-click swap.
1050
+ if self._swap_first_pos is not None:
1051
+ if t != self._swap_first_t:
1052
+ self._swap_first_pos = None
1053
+ self._swap_first_t = -1
1054
+ self._set_status("Frame changed — swap cancelled")
1055
+ return
1056
+ before = seg2d.copy()
1057
+ ok = swap_labels(seg2d, self._swap_first_pos, pos)
1058
+ self._swap_first_pos = None
1059
+ self._swap_first_t = -1
1060
+ if ok:
1061
+ self._record_history(_layer, t, before)
1062
+ _layer.refresh()
1063
+ self._set_status(f"Swapped — Active on '{_layer.name}'")
1064
+ else:
1065
+ self._set_status("Swap failed — click on two different cells")
1066
+ return
1067
+
1068
+ sel_pos = self._selected_label_pos(seg2d)
1069
+ if self._selected_label != 0 and sel_pos is not None:
1070
+ # Selected cell is in this frame → swap it with the clicked cell.
1071
+ if lab == self._selected_label:
1072
+ return
1073
+ before = seg2d.copy()
1074
+ ok = swap_labels(seg2d, sel_pos, pos)
1075
+ if ok:
1076
+ self._record_history(_layer, t, before)
1077
+ _layer.refresh()
1078
+ # Keep the track selected after the swap (it now lives where the
1079
+ # clicked cell was), matching the attach-to-track path so the
1080
+ # selection survives whether or not the frame was occupied.
1081
+ self._update_highlight(t, self._selected_label)
1082
+ self._set_status(f"Swapped — Active on '{_layer.name}'")
1083
+ else:
1084
+ self._set_status("Swap failed — click on two different cells")
1085
+ return
1086
+
1087
+ if self._selected_label != 0 and sel_pos is None:
1088
+ # Selected cell lives on another frame → attach the clicked cell to
1089
+ # its track by relabelling it into the selected ID.
1090
+ before = seg2d.copy()
1091
+ ok = relabel_cell(seg2d, pos, self._selected_label)
1092
+ if ok:
1093
+ self._record_history(_layer, t, before)
1094
+ _layer.refresh()
1095
+ self._update_highlight(t, self._selected_label)
1096
+ self._set_status(
1097
+ f"Attached to track {self._selected_label} — "
1098
+ f"Active on '{_layer.name}'"
1099
+ )
1100
+ else:
1101
+ self._set_status("Attach failed — click on a different cell")
1102
+ return
1103
+
1104
+ # No usable selection → arm a two-click swap.
1105
+ self._swap_first_pos = pos
1106
+ self._swap_first_t = t
1107
+ self._set_status(
1108
+ f"Swap — label {lab} selected, Ctrl+right-click second cell"
1109
+ )
1110
+
1111
+ # ── callback registration ─────────────────────────────────────────────────
1112
+
1113
+ def _register_callbacks(self) -> None:
1114
+ layer = self._layer
1115
+
1116
+ def key_delete(_layer):
1117
+ try:
1118
+ if self._selected_label == 0:
1119
+ self._set_status("No cell selected — left-click a cell first")
1120
+ return
1121
+ t = int(self.viewer.dims.current_step[0])
1122
+ seg2d = self._frame_view(_layer, t)
1123
+ before = seg2d.copy()
1124
+ if erase_cell(seg2d, label=self._selected_label):
1125
+ self._record_history(_layer, t, before)
1126
+ _layer.refresh()
1127
+ self._update_highlight(t, 0)
1128
+ self._set_status(f"Erased — Active on '{_layer.name}'")
1129
+ except Exception as exc:
1130
+ show_error(f"delete error: {exc}")
1131
+
1132
+ # Contour-only mode forbids erasing a cell (it would desync from the
1133
+ # nuclei), so the Delete shortcut is not bound.
1134
+ key_bindings = [] if self._contour_only else [("Delete", key_delete)]
1135
+ for key, fn in key_bindings:
1136
+ layer.bind_key(key, fn, overwrite=True)
1137
+ self._bound_keys.append(key)
1138
+
1139
+ def on_drag(_caller, event):
1140
+ _layer = self._layer
1141
+ if _layer is None:
1142
+ return
1143
+ try:
1144
+ if event.type != "mouse_press":
1145
+ return
1146
+
1147
+ t = int(self.viewer.dims.current_step[0])
1148
+ btn = event.button
1149
+ mods = {m.name for m in event.modifiers}
1150
+
1151
+ seg2d = self._frame_view(_layer, t)
1152
+ pos = _layer.world_to_data(event.position)
1153
+ log.debug(
1154
+ "on_drag: btn=%s mods=%s t=%d selected=%s",
1155
+ btn, mods, t, self._selected_label,
1156
+ )
1157
+
1158
+ if self._contour_only and not (
1159
+ (btn == 1 and mods in (set(), {"Shift"}, {"Control", "Shift"}))
1160
+ or (btn == 2 and mods == {"Shift"})
1161
+ ):
1162
+ # Cell labels are tied to the nuclei — only select (left-click),
1163
+ # extend (Shift+left-drag) and carve (Shift+right-drag) are
1164
+ # allowed. Everything else would create/delete/renumber a cell.
1165
+ return
1166
+
1167
+ if btn == 3 and mods == {"Control"}:
1168
+ self._spawn_into_selected(seg2d, pos, t, _layer)
1169
+ return
1170
+
1171
+ if btn == 3 and not mods:
1172
+ lab = _label_at(seg2d, pos)
1173
+ if lab == 0:
1174
+ before = seg2d.copy()
1175
+ ok = add_cell(
1176
+ seg2d,
1177
+ pos,
1178
+ new_label=self._next_free_label(),
1179
+ radius=int(self._cell_radius_spin.value()),
1180
+ image=self._intensity_frame(t),
1181
+ protected_mask=self._protected_mask(t, seg2d),
1182
+ )
1183
+ if ok:
1184
+ self._record_history(_layer, t, before)
1185
+ _layer.refresh()
1186
+ self._set_status(f"Added cell — Active on '{_layer.name}'")
1187
+ else:
1188
+ self._set_status("Add cell failed — no room here")
1189
+ return
1190
+ before = seg2d.copy()
1191
+ if erase_cell(seg2d, label=lab):
1192
+ self._record_history(_layer, t, before)
1193
+ _layer.refresh()
1194
+ if lab == self._selected_label:
1195
+ self._update_highlight(t, 0)
1196
+ self._set_status(f"Erased — Active on '{_layer.name}'")
1197
+ return
1198
+
1199
+ if btn == 2 and mods == {"Control"}:
1200
+ self._ctrl_right_click_swap(seg2d, pos, t, _layer)
1201
+ return
1202
+
1203
+ if btn == 1 and mods == {"Control"}:
1204
+ lab = _label_at(seg2d, pos)
1205
+ if lab == 0:
1206
+ self._set_status("Click on a cell, not background")
1207
+ return
1208
+ if self._selected_label == 0 or lab == self._selected_label:
1209
+ return
1210
+ if not np.any(seg2d == self._selected_label):
1211
+ # Selected cell is on another frame → there is nothing
1212
+ # here to merge with; attaching the clicked cell to its
1213
+ # track is the Ctrl+right-click action instead.
1214
+ self._set_status(
1215
+ "Selected cell is on another frame — Ctrl+right-click "
1216
+ "to attach the clicked cell to its track"
1217
+ )
1218
+ return
1219
+ # Selected cell lives in this frame → merge the two cells.
1220
+ before = seg2d.copy()
1221
+ ok = merge_cells(
1222
+ seg2d, pos, pos,
1223
+ label_a=lab, label_b=self._selected_label,
1224
+ )
1225
+ self._set_status(
1226
+ f"Merged — Active on '{_layer.name}'"
1227
+ if ok else "Merge failed"
1228
+ )
1229
+ if ok:
1230
+ self._record_history(_layer, t, before)
1231
+ _layer.refresh()
1232
+ self._selected_label = 0
1233
+ self._selected_pos = None
1234
+ self._selected_t = -1
1235
+ self._update_highlight(t, _label_at(seg2d, pos))
1236
+ return
1237
+
1238
+ if btn == 1 and not mods:
1239
+ self._swap_first_pos = None
1240
+ self._swap_first_t = -1
1241
+ lab = _label_at(seg2d, pos)
1242
+ self._selected_pos = pos if lab != 0 else None
1243
+ self._selected_t = t if lab != 0 else -1
1244
+ self._update_highlight(t, lab)
1245
+ if lab:
1246
+ self._set_status(f"Selected label {lab} — Active on '{_layer.name}'")
1247
+ else:
1248
+ self._set_status(f"Active on '{_layer.name}'")
1249
+ return
1250
+
1251
+ if mods == {"Shift"} and btn == 2:
1252
+ dl = self._get_draw_layer()
1253
+ dl.data = []
1254
+ dl.visible = True
1255
+ pos_list = [_layer.world_to_data(event.position)]
1256
+ yield
1257
+ while event.type == "mouse_move":
1258
+ pos_list.append(_layer.world_to_data(event.position))
1259
+ if len(pos_list) % 3 == 0:
1260
+ dl.data = [np.array([[p[-2], p[-1]] for p in pos_list])]
1261
+ dl.shape_type = ["path"]
1262
+ yield
1263
+ pos_list.append(_layer.world_to_data(event.position))
1264
+ dl.data = []
1265
+ dl.visible = False
1266
+ self.viewer.layers.selection.active = _layer
1267
+ curlabel = self._selected_label if self._selected_label else None
1268
+ before = seg2d.copy()
1269
+ if self._contour_only:
1270
+ if not self._selected_label:
1271
+ self._set_status("Carve — select a cell first")
1272
+ return
1273
+ ok = carve_into_selected(
1274
+ seg2d, pos_list, selected_label=self._selected_label,
1275
+ )
1276
+ self._set_status(
1277
+ f"Carved into cell {self._selected_label} — "
1278
+ f"Active on '{_layer.name}'"
1279
+ if ok else
1280
+ "Carve failed — draw a line all the way through a neighbour"
1281
+ )
1282
+ else:
1283
+ ok = split_draw(
1284
+ seg2d,
1285
+ pos_list,
1286
+ curlabel=curlabel,
1287
+ new_label=self._next_free_label(),
1288
+ )
1289
+ self._set_status(
1290
+ f"Split — Active on '{_layer.name}'"
1291
+ if ok else "Split draw failed — line did not divide the cell"
1292
+ )
1293
+ if ok:
1294
+ self._record_history(_layer, t, before)
1295
+ _layer.refresh()
1296
+ self._update_highlight(t, self._selected_label)
1297
+ return
1298
+
1299
+ if btn == 1 and mods in ({"Shift"}, {"Control", "Shift"}):
1300
+ dl = self._get_draw_layer()
1301
+ dl.data = []
1302
+ dl.visible = True
1303
+ pos_list = [_layer.world_to_data(event.position)]
1304
+ yield
1305
+ while event.type == "mouse_move":
1306
+ pos_list.append(_layer.world_to_data(event.position))
1307
+ if len(pos_list) % 3 == 0:
1308
+ dl.data = [np.array([[p[-2], p[-1]] for p in pos_list])]
1309
+ dl.shape_type = ["path"]
1310
+ yield
1311
+ pos_list.append(_layer.world_to_data(event.position))
1312
+ dl.data = []
1313
+ dl.visible = False
1314
+ self.viewer.layers.selection.active = _layer
1315
+ curlabel = self._selected_label if self._selected_label else None
1316
+ before = seg2d.copy()
1317
+ ok = draw_cell_path(
1318
+ seg2d,
1319
+ pos_list,
1320
+ curlabel=curlabel,
1321
+ new_label=self._next_free_label(),
1322
+ protected_mask=self._protected_mask(t, seg2d),
1323
+ )
1324
+ self._set_status(
1325
+ f"Drew cell path — Active on '{_layer.name}'"
1326
+ if ok else "Draw failed — stroke too short"
1327
+ )
1328
+ if ok:
1329
+ self._record_history(_layer, t, before)
1330
+ _layer.refresh()
1331
+ self._update_highlight(t, self._selected_label)
1332
+ return
1333
+
1334
+ except Exception as exc:
1335
+ import traceback
1336
+ show_error(f"Correction error: {exc}\n{traceback.format_exc()}")
1337
+
1338
+ self.viewer.mouse_drag_callbacks.append(on_drag)
1339
+ self._drag_callbacks.append(on_drag)
1340
+
1341
+ def _remove_callbacks(self) -> None:
1342
+ for fn in self._drag_callbacks:
1343
+ try:
1344
+ self.viewer.mouse_drag_callbacks.remove(fn)
1345
+ except (ValueError, AttributeError):
1346
+ pass
1347
+ self._drag_callbacks.clear()
1348
+ for key in self._bound_keys:
1349
+ try:
1350
+ if self._layer is not None:
1351
+ self._layer.bind_key(key, None)
1352
+ except Exception:
1353
+ pass
1354
+ self._bound_keys.clear()
1355
+
1356
+ def _toggle_outline(self, checked: bool) -> None:
1357
+ if self._layer is None:
1358
+ self._outline_btn.setChecked(False)
1359
+ return
1360
+ self._layer.contour = 2 if checked else 0
1361
+
1362
+ # ── helpers ───────────────────────────────────────────────────────────────
1363
+