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,393 @@
1
+ """Qt construction for :class:`NucleusCorrectionWidget`.
2
+
3
+ The widget is large enough that its one-time control assembly — buttons,
4
+ parameter sliders, the embedded :class:`CorrectionWidget`, the reveal sections,
5
+ the toolbar and the signal wiring — is kept here so the widget module is about
6
+ behaviour rather than scaffolding. :func:`build_nucleus_correction_ui` populates
7
+ the passed widget in place (setting the same ``w.<name>`` attributes the rest of
8
+ the class reads), mirroring how the shared header / toolbar builders in
9
+ :mod:`itasc.napari.correction._correction_ui` already work.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from qtpy.QtCore import QTimer
15
+ from qtpy.QtWidgets import (
16
+ QCheckBox,
17
+ QHBoxLayout,
18
+ QLabel,
19
+ QVBoxLayout,
20
+ QWidget,
21
+ )
22
+
23
+ from itasc.napari._widget_helpers import (
24
+ btn as _btn,
25
+ dslider as _dslider,
26
+ heading as _heading,
27
+ make_status as _make_status,
28
+ tool_btn as _tool_btn,
29
+ )
30
+ from itasc.napari.correction._correction_ui import (
31
+ build_correction_header,
32
+ build_correction_toolbar,
33
+ build_shortcuts_widget,
34
+ flatten_embedded_section,
35
+ )
36
+ from itasc.napari.ui_style import danger_button
37
+ from itasc.napari.widgets import CollapsibleSection
38
+ from itasc.napari.correction.correction_widget import CorrectionWidget
39
+
40
+
41
+ def build_nucleus_correction_ui(w) -> None:
42
+ """Build and wire all of ``w``'s correction controls (run once from init)."""
43
+ root = QVBoxLayout(w)
44
+ root.setContentsMargins(0, 0, 0, 0)
45
+ root.setSpacing(0)
46
+
47
+ _init_candidate_refresh_timer(w)
48
+
49
+ inner = QWidget(w)
50
+ group_lay = QVBoxLayout(inner)
51
+ group_lay.setContentsMargins(0, 0, 0, 0)
52
+ group_lay.setSpacing(6)
53
+
54
+ _build_action_buttons(w)
55
+ _build_status_labels(w)
56
+ _build_correction_subwidget(w)
57
+ _build_extend_retrack_section(w)
58
+ _build_shortcuts_section(w)
59
+ _build_toolbar(w)
60
+ _build_view_toggle_buttons(w)
61
+ _assemble_reveal_area(w, group_lay)
62
+
63
+ # The full-width top bar carries the title, the activate / shortcuts /
64
+ # params toggles, the checkable view toggles, and a single one-line
65
+ # status (the save/action status only) right-aligned. The track /
66
+ # validated summary lives in the tracking-overview panel instead.
67
+ w.header, w.header_lbl = build_correction_header(
68
+ w,
69
+ shortcuts_btn=w.shortcuts_btn,
70
+ params_btn=w.params_btn,
71
+ active_btn=w.active_btn,
72
+ finalize_btn=getattr(w, "_finalize_header_btn", None),
73
+ view_toggle_btns=(
74
+ w.track_path_btn,
75
+ w.spotlight_btn,
76
+ w.filled_view_btn,
77
+ ),
78
+ status_lbl=w.status_lbl,
79
+ )
80
+ # ``section`` is the full-width reveal area (params + shortcuts) shown
81
+ # below the top bar; both it and the header are reparented between the
82
+ # plugin dock (inactive) and the workspace dock (active).
83
+ w.section = CollapsibleSection("Correction", inner, expanded=False)
84
+ w.section._toggle.setVisible(False)
85
+ w.section._toggle.setEnabled(False)
86
+
87
+ w.correction_active_btn = w.active_btn
88
+ w.correction_shortcuts_btn = w.shortcuts_btn
89
+ w.correction_status_lbl = w.status_lbl
90
+ w.correction_mode_section = w.section
91
+ w._correction_active_content_visible = False
92
+ _connect_signals(w)
93
+ # Start collapsed: the inactive plugin-dock entry point shows only the
94
+ # on/off button (title + toggles appear once correction is active).
95
+ w._sync_correction_panel_visibility()
96
+
97
+
98
+ def _init_candidate_refresh_timer(w) -> None:
99
+ # Searching + rendering the candidate gallery is expensive, so debounce
100
+ # it: every refresh request (re)starts this timer and the actual rebuild
101
+ # only fires once the frame has been still for the interval — fast
102
+ # scrubbing no longer recomputes candidates for every frame it sweeps.
103
+ w._candidate_refresh_timer = QTimer(w)
104
+ w._candidate_refresh_timer.setSingleShot(True)
105
+ w._candidate_refresh_timer.setInterval(200)
106
+ w._candidate_refresh_timer.timeout.connect(w._refresh_candidate_gallery_now)
107
+
108
+
109
+ def _build_action_buttons(w) -> None:
110
+ w.active_btn = _tool_btn(
111
+ "⏻",
112
+ "Activate correction mode and show correction layers and controls.",
113
+ checkable=True,
114
+ )
115
+ w.active_btn.setToolTip(
116
+ "Activate correction mode and show correction layers and controls."
117
+ )
118
+ w.params_btn = _tool_btn(
119
+ "⚙", "Show correction parameters.", checkable=True
120
+ )
121
+ w.shortcuts_btn = _tool_btn(
122
+ "📖", "Show correction shortcuts.", checkable=True
123
+ )
124
+
125
+ w.save_tracked_btn = _tool_btn(
126
+ "💾", "Save corrected tracked nucleus labels to disk (S)."
127
+ )
128
+ w.extend_back_btn = _tool_btn(
129
+ "◀", "Extend selected track one frame backward (A)."
130
+ )
131
+ w.extend_fwd_btn = _tool_btn(
132
+ "▶", "Extend selected track one frame forward (D)."
133
+ )
134
+ w.retrack_back_btn = _tool_btn(
135
+ "↶", "Retrack all labels backward from current frame (Q)."
136
+ )
137
+ w.retrack_fwd_btn = _tool_btn(
138
+ "↷", "Retrack all labels forward from current frame (E)."
139
+ )
140
+ w.swap_smaller_btn = _tool_btn(
141
+ "⮂", "Swap selected cell with the next smaller candidate fragment (Z)."
142
+ )
143
+ w.swap_larger_btn = _tool_btn(
144
+ "⮀", "Swap selected cell with the next larger candidate fragment (C)."
145
+ )
146
+ w.reassign_ids_btn = _tool_btn(
147
+ "#",
148
+ "Reassign cell IDs to contiguous range 1-N (validated tracks first, "
149
+ "then by earliest start frame, then by longest track).",
150
+ )
151
+ w.validate_track_btn = _tool_btn(
152
+ "✓", "Lock selected cell geometry in every frame where it appears (V)."
153
+ )
154
+ w.anchor_here_btn = _tool_btn(
155
+ "⚓", "Anchor selected cell identity at the current frame (B)."
156
+ )
157
+ w.annotate_db_btn = _tool_btn(
158
+ "✎", "Apply saved validations and anchors to the Ultrack database."
159
+ )
160
+ w.remove_unvalidated_btn = _tool_btn(
161
+ "🗑",
162
+ "Remove nucleus label pixels not marked validated for their frame.",
163
+ )
164
+ danger_button(w.remove_unvalidated_btn)
165
+
166
+ w.commit_btn = _btn(
167
+ "Commit",
168
+ "Reassign cell IDs, remove unvalidated labels, and save tracked labels.",
169
+ )
170
+
171
+
172
+ def _build_status_labels(w) -> None:
173
+ w.status_lbl = _make_status()
174
+ # Drop the smaller status font so the status line reads at the same
175
+ # size as the rest of the controls column. Keep it to a single line so
176
+ # it never wraps and pushes the top-bar title pill onto two rows.
177
+ w.status_lbl.setStyleSheet("")
178
+ w.status_lbl.setWordWrap(False)
179
+
180
+ # The track / validated summary now lives in the tracking-overview title
181
+ # (see LineageCanvasController). This label is kept as a hidden sink so
182
+ # the existing counter-refresh plumbing keeps working without surfacing
183
+ # a second status line in the top bar; it is parented (never shown) so
184
+ # toggling its visibility can't spawn a stray top-level window.
185
+ w.validation_counter_lbl = QLabel("", w)
186
+ w.validation_counter_lbl.setVisible(False)
187
+
188
+
189
+ def _build_correction_subwidget(w) -> None:
190
+ w.correction_widget = CorrectionWidget(
191
+ w.viewer,
192
+ show_activate_btn=False,
193
+ show_shortcuts=False,
194
+ inspector_first=True,
195
+ show_cleanup=False,
196
+ # The lineage canvas is the navigation surface now — drop the
197
+ # redundant "Inspect cell" group from the correction column.
198
+ show_inspector=False,
199
+ )
200
+ w.correction_widget.set_edit_callback(w._edit_callback)
201
+ w.correction_widget.set_protected_mask_callback(
202
+ w._manual_correction_protected_mask
203
+ )
204
+ w.correction_widget.set_intensity_frame_callback(
205
+ w._correction_intensity_frame
206
+ )
207
+ # Use an additive listener, not set_selection_callback: the workflow
208
+ # widget owns that single slot and would otherwise clobber the comet's
209
+ # rebuild-on-selection (so it would only build on first checkbox tick).
210
+ w.correction_widget.add_selection_listener(
211
+ w._on_track_selection_changed
212
+ )
213
+ w.correction_widget.set_spotlight_mask_provider(
214
+ w._track_path_spotlight_mask
215
+ )
216
+ w.correction_widget._status.setVisible(False)
217
+
218
+
219
+ def _build_extend_retrack_section(w) -> None:
220
+ # Wide-and-short reveal area: each remaining parameter gets its own
221
+ # column so the sliders spread horizontally across the full-width panel
222
+ # rather than stacking. Greedy overwrite and the outline view are now
223
+ # always-on defaults, so neither is surfaced as a control here.
224
+ extend_retrack_inner = QWidget(w)
225
+ extend_retrack_lay = QVBoxLayout(extend_retrack_inner)
226
+ extend_retrack_lay.setContentsMargins(0, 0, 0, 0)
227
+ extend_retrack_lay.setSpacing(6)
228
+
229
+ columns = QHBoxLayout()
230
+ columns.setContentsMargins(0, 0, 0, 0)
231
+ columns.setSpacing(16)
232
+
233
+ def _column(*items) -> QWidget:
234
+ col = QWidget()
235
+ lay = QVBoxLayout(col)
236
+ lay.setContentsMargins(0, 0, 0, 0)
237
+ lay.setSpacing(4)
238
+ for item in items:
239
+ if isinstance(item, QWidget):
240
+ lay.addWidget(item)
241
+ else:
242
+ lay.addLayout(item)
243
+ lay.addStretch(1)
244
+ return col
245
+
246
+ # Greedy overwrite is now the always-on default: keep the (hidden)
247
+ # checkbox checked so the extend/retrack paint paths that read its state
248
+ # still behave greedily, but drop it from the UI.
249
+ w.extend_greedy_overwrite_check = QCheckBox("Greedy overwrite")
250
+ w.extend_greedy_overwrite_check.setChecked(True)
251
+
252
+ w.retrack_max_dist_spin = _dslider(0, 500, 20.0, 1.0, 1)
253
+ # Scoring weights for the retrack frame matcher. Kept under the
254
+ # ``extend_*`` attribute names for settings back-compat; extend no longer
255
+ # uses them.
256
+ w.extend_area_weight_spin = _dslider(0, 10, 1.0, 0.1, 2)
257
+ w.extend_iou_weight_spin = _dslider(0, 10, 1.0, 0.1, 2)
258
+ w.extend_distance_weight_spin = _dslider(0, 10, 0.05, 0.01, 3)
259
+
260
+ # Each retrack weight + the cell-radius control gets its own column
261
+ # (stretch=1) so they fan out across the wide reveal area. The cell-radius
262
+ # slider (middle-click cell creation) is reused straight from the embedded
263
+ # correction widget, dropped under a plain "Cell Radius" heading so it
264
+ # matches the other sliders rather than carrying its own inline label.
265
+ for heading_text, widget in (
266
+ ("Max distance", w.retrack_max_dist_spin),
267
+ ("Area weight", w.extend_area_weight_spin),
268
+ ("IoU weight", w.extend_iou_weight_spin),
269
+ ("Distance weight", w.extend_distance_weight_spin),
270
+ ("Cell Radius", w.correction_widget._cell_radius_spin),
271
+ ):
272
+ columns.addWidget(_column(_heading(heading_text), widget), stretch=1)
273
+ extend_retrack_lay.addLayout(columns)
274
+
275
+ w.extend_retrack_params_section = CollapsibleSection(
276
+ "Extend / Retrack Parameters",
277
+ extend_retrack_inner,
278
+ expanded=False,
279
+ )
280
+ flatten_embedded_section(w.extend_retrack_params_section)
281
+ w.extend_retrack_params_section.setVisible(False)
282
+ w.extend_params_section = w.extend_retrack_params_section
283
+ w.retrack_params_section = w.extend_retrack_params_section
284
+
285
+
286
+ def _build_shortcuts_section(w) -> None:
287
+ # The disclaimer / attribution label rides at the bottom of the wide
288
+ # shortcuts panel now (reparented out of the embedded correction widget).
289
+ w.shortcuts_section = CollapsibleSection(
290
+ "Correction Shortcuts",
291
+ build_shortcuts_widget(w.correction_widget._attrib_lbl),
292
+ expanded=False,
293
+ )
294
+ flatten_embedded_section(w.shortcuts_section)
295
+ w.shortcuts_section.setVisible(False)
296
+ w.correction_widget.setVisible(False)
297
+
298
+
299
+ def _build_toolbar(w) -> None:
300
+ # Extend / swap are driven by clicking into the candidate gallery now,
301
+ # so they're dropped from the toolbar (the A/D/Z/C shortcuts still work).
302
+ groups = [
303
+ (w.save_tracked_btn,),
304
+ (w.retrack_back_btn, w.retrack_fwd_btn),
305
+ (w.validate_track_btn, w.anchor_here_btn),
306
+ (w.annotate_db_btn,),
307
+ (w.reassign_ids_btn, w.remove_unvalidated_btn),
308
+ ]
309
+ # The host workflow widget's Finalize button rides at the tail of the
310
+ # toolbar (its own ruled group) — finalizing is the natural last step
311
+ # once the tracks are corrected.
312
+ if getattr(w, "_finalize_btn", None) is not None:
313
+ groups.append((w._finalize_btn,))
314
+ w.toolbar = build_correction_toolbar(w, groups)
315
+ w.toolbar.setVisible(False)
316
+
317
+
318
+ def _build_view_toggle_buttons(w) -> None:
319
+ # View toggles ride in the top bar as checkable icon tool-buttons; the
320
+ # old "Lineage canvas" toggle is gone — the accordion is the always-on
321
+ # main surface. Tooltips carry the longer descriptions the old check
322
+ # captions spelled out.
323
+ w.track_path_btn = _tool_btn(
324
+ "👁",
325
+ "Track path: paint the selected track's whole trajectory as a fading "
326
+ "comet (viridis, oldest→newest) with a frame number in each mask.",
327
+ checkable=True,
328
+ )
329
+ w.spotlight_btn = _tool_btn(
330
+ "🔦",
331
+ "Spotlight: dim everything outside the selected cell. Off = mark the "
332
+ "selection with a plain yellow border instead (the rest of the frame "
333
+ "stays at full brightness).",
334
+ checkable=True,
335
+ )
336
+ # Spotlight is the default selection indicator, so the toggle starts on.
337
+ w.spotlight_btn.setChecked(True)
338
+ w.filled_view_btn = _tool_btn(
339
+ "🎨",
340
+ "Filled labels (by ID): hide the cell + nucleus images and draw the "
341
+ "labels and tracks opaque and filled (not outlines), coloured by ID. "
342
+ "Off = the default outline view in one neutral colour.",
343
+ checkable=True,
344
+ )
345
+ # The candidate gallery is shown/hidden by its own ✕ button + slim
346
+ # show-tab (see CollapsiblePane), not a top-bar toggle.
347
+ for button in (w.track_path_btn, w.spotlight_btn, w.filled_view_btn):
348
+ button.setVisible(False)
349
+ w.validation_counter_lbl.setVisible(False)
350
+
351
+
352
+ def _assemble_reveal_area(w, group_lay: QVBoxLayout) -> None:
353
+ # The section body is the full-width reveal area below the top bar: the
354
+ # params and shortcuts panels, openable independently. The toolbar, the
355
+ # view toggles and the status now live elsewhere (the toolbar in the body
356
+ # splitter's thin left column, the toggles + status in the top bar). The
357
+ # embedded correction widget is a logic holder only — its visible bits
358
+ # (outline + spawn controls, attribution label) were reparented into the
359
+ # params / shortcuts panels — so it is kept hidden here to stay owned.
360
+ group_lay.addWidget(w.extend_retrack_params_section)
361
+ group_lay.addWidget(w.shortcuts_section)
362
+ w.correction_widget.setVisible(False)
363
+ group_lay.addWidget(w.correction_widget)
364
+
365
+
366
+ def _connect_signals(w) -> None:
367
+ w.save_tracked_btn.clicked.connect(w._on_save_tracked)
368
+ w.reassign_ids_btn.clicked.connect(w._on_reassign_ids)
369
+ w.validate_track_btn.clicked.connect(w._on_validate_track)
370
+ w.anchor_here_btn.clicked.connect(w._on_anchor_here)
371
+ w.annotate_db_btn.clicked.connect(w._on_annotate_database)
372
+ w.extend_back_btn.clicked.connect(w._on_extend_backward)
373
+ w.extend_fwd_btn.clicked.connect(w._on_extend_forward)
374
+ w.retrack_back_btn.clicked.connect(w._on_retrack_backward)
375
+ w.retrack_fwd_btn.clicked.connect(w._on_retrack_forward)
376
+ w.swap_smaller_btn.clicked.connect(
377
+ lambda: w._on_swap_step(direction="smaller")
378
+ )
379
+ w.swap_larger_btn.clicked.connect(
380
+ lambda: w._on_swap_step(direction="larger")
381
+ )
382
+ w.remove_unvalidated_btn.clicked.connect(w._on_remove_unvalidated_labels)
383
+ w.commit_btn.clicked.connect(w._on_commit)
384
+ w.track_path_btn.toggled.connect(w._on_toggle_track_path)
385
+ w.spotlight_btn.toggled.connect(w._on_toggle_spotlight)
386
+ w.filled_view_btn.toggled.connect(w._on_toggle_filled_view)
387
+ w.params_btn.toggled.connect(w._on_correction_params_button_toggled)
388
+ w.shortcuts_btn.toggled.connect(w._on_correction_shortcuts_button_toggled)
389
+ w.active_btn.toggled.connect(w._on_correction_active_button_toggled)
390
+ w.correction_widget._activate_btn.toggled.connect(
391
+ w._on_correction_mode_toggled
392
+ )
393
+ w._install_correction_shortcuts()
@@ -0,0 +1,204 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable, Mapping
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ import numpy as np
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RetrackDirectionResult:
13
+ stack: np.ndarray
14
+ n_retracked: int
15
+ n_skipped: int
16
+ first_target_frame: int
17
+
18
+
19
+ def frame_view_2d(arr: np.ndarray, t: int) -> np.ndarray | None:
20
+ """Return frame *t* as a 2D view when the stack shape is unambiguous."""
21
+ if arr.ndim < 3 or t < 0 or t >= arr.shape[0]:
22
+ return None
23
+ view = arr[t]
24
+ while view.ndim > 2:
25
+ if view.shape[0] != 1:
26
+ return None
27
+ view = view[0]
28
+ return view
29
+
30
+
31
+ def reassign_ids_ordered(
32
+ stack: np.ndarray, order: list[int]
33
+ ) -> tuple[np.ndarray, int, dict[int, int]]:
34
+ """Relabel non-zero IDs to contiguous IDs from 1, following *order*.
35
+
36
+ Old IDs listed in *order* are assigned new IDs first (best → 1, next → 2,
37
+ …); any present IDs not in *order* follow in ascending order. Passing an
38
+ empty *order* reproduces plain numeric compaction.
39
+ """
40
+ unique_ids = np.unique(stack)
41
+ unique_ids = unique_ids[unique_ids != 0]
42
+ if unique_ids.size == 0:
43
+ return stack, 0, {}
44
+ present = {int(x) for x in unique_ids}
45
+ seen: set[int] = set()
46
+ ordered_ids: list[int] = []
47
+ for old_id in order:
48
+ old_id = int(old_id)
49
+ if old_id in present and old_id not in seen:
50
+ ordered_ids.append(old_id)
51
+ seen.add(old_id)
52
+ ordered_ids.extend(sorted(present - seen))
53
+ lut = np.zeros(int(unique_ids.max()) + 1, dtype=np.uint32)
54
+ old_to_new: dict[int, int] = {}
55
+ for new_id, old_id in enumerate(ordered_ids, start=1):
56
+ lut[old_id] = new_id
57
+ old_to_new[old_id] = new_id
58
+ return lut[stack], len(ordered_ids), old_to_new
59
+
60
+
61
+ def track_order_by_frame_and_size(
62
+ stack: np.ndarray, priority_ids: Iterable[int] = ()
63
+ ) -> list[int]:
64
+ """Order present track IDs for ID reassignment.
65
+
66
+ Tracks listed in *priority_ids* (e.g. validated tracks) form the first
67
+ group and the remaining present tracks the second; the two groups stay
68
+ separate so validated tracks always receive the lower IDs. Within each
69
+ group, IDs are sorted by starting frame (earliest first), then by track
70
+ length in frames (longest first), with the numeric ID as a final tiebreaker
71
+ for determinism.
72
+ """
73
+ stack = np.asarray(stack)
74
+ frames = stack if stack.ndim >= 3 else stack[None, ...]
75
+ start_frame: dict[int, int] = {}
76
+ length: dict[int, int] = {}
77
+ for t, frame in enumerate(frames):
78
+ for cid in np.unique(frame):
79
+ cid = int(cid)
80
+ if cid == 0:
81
+ continue
82
+ start_frame.setdefault(cid, t)
83
+ length[cid] = length.get(cid, 0) + 1
84
+
85
+ def sort_key(cid: int) -> tuple[int, int, int]:
86
+ return (start_frame[cid], -length[cid], cid)
87
+
88
+ present = set(start_frame)
89
+ priority = {int(cid) for cid in priority_ids} & present
90
+ return sorted(priority, key=sort_key) + sorted(present - priority, key=sort_key)
91
+
92
+
93
+ def reassign_ids_stack(stack: np.ndarray) -> tuple[np.ndarray, int, dict[int, int]]:
94
+ """Compact non-zero label IDs in a stack to contiguous IDs from 1."""
95
+ return reassign_ids_ordered(stack, [])
96
+
97
+
98
+ def reorder_stack_by_quality(
99
+ stack: np.ndarray,
100
+ scores: Mapping[int, float],
101
+ pos_dir: str | Path | None = None,
102
+ ) -> tuple[np.ndarray, dict[int, int]]:
103
+ """Relabel a tracked stack so track IDs follow quality order, best → 1.
104
+
105
+ *scores* maps track_id -> quality score (see
106
+ :func:`itasc.tracking_ultrack.track_quality.track_quality_scores`). When
107
+ *pos_dir* is given, existing validations/anchors are remapped onto the new
108
+ IDs so prior work stays attached. Returns ``(relabeled_stack, old_to_new)``;
109
+ a no-op (empty mapping, original array) when there is nothing to reorder.
110
+ """
111
+ from itasc.tracking_ultrack.track_quality import quality_order
112
+
113
+ stack = np.asarray(stack)
114
+ order = quality_order(scores) if scores else []
115
+ if not order:
116
+ return stack, {}
117
+ relabeled, _n, old_to_new = reassign_ids_ordered(
118
+ stack.astype(np.uint32, copy=False), order
119
+ )
120
+ if old_to_new and pos_dir is not None:
121
+ from itasc.tracking_ultrack.validation_state import remap_validated_tracks
122
+
123
+ remap_validated_tracks(Path(pos_dir), old_to_new)
124
+ return relabeled, old_to_new
125
+
126
+
127
+ def remove_unvalidated_labels(
128
+ data: np.ndarray,
129
+ validated_tracks: dict[int, set[int]],
130
+ ) -> tuple[int, int]:
131
+ """Remove labels not validated for their frame from a 2D or time-first stack."""
132
+ frame_count = int(data.shape[0]) if data.ndim >= 3 else 1
133
+ changed_pixels = changed_frames = 0
134
+ for t in range(frame_count):
135
+ frame = data[t] if data.ndim >= 3 else data
136
+ if frame.ndim != 2:
137
+ raise ValueError("Tracked layer must be a time-first stack.")
138
+ validated_ids = {
139
+ cid for cid, frames in validated_tracks.items() if t in frames
140
+ }
141
+ remove_mask = frame != 0
142
+ if validated_ids:
143
+ remove_mask &= ~np.isin(frame, list(validated_ids))
144
+ n_remove = int(np.count_nonzero(remove_mask))
145
+ if not n_remove:
146
+ continue
147
+ frame[remove_mask] = 0
148
+ changed_pixels += n_remove
149
+ changed_frames += 1
150
+ return changed_frames, changed_pixels
151
+
152
+
153
+ def retrack_stack_direction(
154
+ stack: np.ndarray,
155
+ *,
156
+ start_frame: int,
157
+ direction: Literal["forward", "backward"],
158
+ fully_validated_frames: set[int],
159
+ validated_cells_at_frame: Callable[[int], set[int]],
160
+ retrack_frame: Callable[..., np.ndarray],
161
+ max_dist_px: float,
162
+ reserved_ids: set[int],
163
+ area_weight: float = 1.0,
164
+ iou_weight: float = 1.0,
165
+ distance_weight: float = 0.05,
166
+ ) -> RetrackDirectionResult:
167
+ """Retrack a time-first stack in one direction, skipping validated frames."""
168
+ if stack.ndim != 3 or stack.shape[0] < 2:
169
+ raise ValueError("Tracked layer must be a 3D time-first stack.")
170
+
171
+ out = stack.copy()
172
+ n_retracked = n_skipped = 0
173
+ if direction == "forward":
174
+ frame_range = range(start_frame + 1, out.shape[0])
175
+ previous_frame = lambda t: out[t - 1]
176
+ elif direction == "backward":
177
+ frame_range = range(start_frame - 1, -1, -1)
178
+ previous_frame = lambda t: out[t + 1]
179
+ else:
180
+ raise ValueError(f"Unknown retrack direction: {direction!r}")
181
+
182
+ first_target_frame = next(iter(frame_range), start_frame)
183
+ for t in frame_range:
184
+ if t in fully_validated_frames:
185
+ n_skipped += 1
186
+ continue
187
+ out[t] = retrack_frame(
188
+ previous_frame(t),
189
+ out[t],
190
+ validated_cells_at_frame(t),
191
+ max_dist_px=max_dist_px,
192
+ reserved_ids=reserved_ids,
193
+ area_weight=area_weight,
194
+ iou_weight=iou_weight,
195
+ distance_weight=distance_weight,
196
+ )
197
+ n_retracked += 1
198
+
199
+ return RetrackDirectionResult(
200
+ stack=out,
201
+ n_retracked=n_retracked,
202
+ n_skipped=n_skipped,
203
+ first_target_frame=first_target_frame,
204
+ )
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from itasc.napari.correction._correction_utils import frame_view_2d
8
+ from itasc.tracking_ultrack.corrections import Correction
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SelectedCorrectionTarget:
13
+ cell_id: int
14
+ frame: int
15
+ y: float
16
+ x: float
17
+
18
+
19
+ def selected_correction_target(
20
+ data: np.ndarray,
21
+ *,
22
+ cell_id: int,
23
+ frame: int,
24
+ ) -> SelectedCorrectionTarget | None:
25
+ """Return the selected cell centroid at *frame* when present."""
26
+ frame_view = _label_frame(data, frame)
27
+ if frame_view is None or not np.any(frame_view == cell_id):
28
+ return None
29
+ y, x = _label_centroid(frame_view, cell_id)
30
+ return SelectedCorrectionTarget(
31
+ cell_id=int(cell_id),
32
+ frame=int(frame),
33
+ y=y,
34
+ x=x,
35
+ )
36
+
37
+
38
+ def correction_for_label_frame(
39
+ data: np.ndarray,
40
+ *,
41
+ cell_id: int,
42
+ frame: int,
43
+ ) -> Correction | None:
44
+ """Build a validated correction for *cell_id* at *frame* when present."""
45
+ target = selected_correction_target(data, cell_id=cell_id, frame=frame)
46
+ if target is None:
47
+ return None
48
+ return Correction(
49
+ cell_id=target.cell_id,
50
+ t=target.frame,
51
+ kind="validated",
52
+ y=target.y,
53
+ x=target.x,
54
+ )
55
+
56
+
57
+ def corrections_for_label_frames(
58
+ data: np.ndarray,
59
+ *,
60
+ cell_id: int,
61
+ frames: list[int] | tuple[int, ...],
62
+ ) -> list[Correction]:
63
+ """Build validated corrections for frames where *cell_id* is present."""
64
+ corrections: list[Correction] = []
65
+ for frame in frames:
66
+ correction = correction_for_label_frame(
67
+ data,
68
+ cell_id=cell_id,
69
+ frame=int(frame),
70
+ )
71
+ if correction is not None:
72
+ corrections.append(correction)
73
+ return corrections
74
+
75
+
76
+ def _label_frame(data: np.ndarray, frame: int) -> np.ndarray | None:
77
+ arr = np.asarray(data)
78
+ return frame_view_2d(arr, frame) if arr.ndim >= 3 else arr
79
+
80
+
81
+ def _label_centroid(frame: np.ndarray, cell_id: int) -> tuple[float, float]:
82
+ yy, xx = np.nonzero(frame == cell_id)
83
+ return float(np.mean(yy)), float(np.mean(xx))