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,963 @@
1
+ """Nucleus workflow widget for candidate generation and tracking in ITASC.
2
+
3
+ Simplified workflow layout with action buttons grouped into their owning
4
+ sections: segmentation inputs, tracking/Ultrack, database browser, correction.
5
+
6
+ Stages:
7
+ 1. Cellpose maps → ``nucleus_contours.tif`` / ``nucleus_foreground.tif``
8
+ 2. Source sweep preview → in-memory napari layers
9
+ 3. Ultrack database + solve → ``data.db`` / ``tracked_labels.tif``
10
+ 4. Correction (load / save / extend / retrack / reassign / remove unvalidated)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from pathlib import Path
16
+
17
+ import napari
18
+ import numpy as np
19
+ from qtpy.QtCore import Qt, QSettings, QTimer
20
+ from qtpy.QtWidgets import (
21
+ QVBoxLayout,
22
+ QSizePolicy,
23
+ QWidget,
24
+ )
25
+
26
+ from itasc.correction.labels import best_overlapping_label
27
+ from itasc.napari.correction.nucleus_correction_widget import NucleusCorrectionWidget
28
+ from itasc.napari.nucleus_atom_extraction_widget import (
29
+ NucleusAtomExtractionMixin,
30
+ NucleusAtomExtractionWidget,
31
+ )
32
+ from itasc.napari.nucleus_db_browser_widget import (
33
+ NucleusUltrackDbBrowserMixin,
34
+ )
35
+ from itasc.napari.nucleus_pipeline_widget import NucleusPipelineWidget
36
+ from itasc.napari.nucleus_segmentation_inputs_widget import (
37
+ NucleusSegmentationInputsWidget,
38
+ )
39
+ from itasc.napari.nucleus_tracking_inputs_widget import NucleusTrackingInputsWidget
40
+ from itasc.napari._standalone_paths import StandalonePathsMixin
41
+ from itasc.napari.ui_gate import ControlClass, UiGate
42
+ from itasc.napari._paths import NucleusArtifactPaths, NucleusWorkspace
43
+ from itasc.napari._widget_helpers import tool_btn as _tool_btn
44
+ from itasc.core.commit import commit_state, promote_labels
45
+ from itasc.napari._state import dump_state, load_state
46
+ from itasc.napari.widgets import (
47
+ CollapsibleSection,
48
+ PipelineFilesWidget,
49
+ make_pipeline_files_header,
50
+ )
51
+ from itasc.tracking_ultrack.extend import extend_track_from_db # noqa: F401
52
+ from itasc.tracking_ultrack.ingest import _select_solver
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ # ── Layer name constants ──────────────────────────────────────────────────────
57
+ _TRACKED_LAYER = "Tracked: Nucleus"
58
+
59
+ _NUCLEUS_PIPELINE_FILE_GROUPS = [
60
+ ("Inputs", [
61
+ ("1_cellpose/nucleus_prob.tif", "Nucleus prob 3D+t"),
62
+ ("1_cellpose/nucleus_dp.tif", "Nucleus dp 3D+t"),
63
+ ("1_cellpose/nucleus_contours.tif", "Nucleus contours"),
64
+ ("1_cellpose/nucleus_foreground.tif", "Nucleus foreground"),
65
+ ]),
66
+ ("Intermediates", [
67
+ ("2_nucleus/atoms.tif", "Atoms"),
68
+ ("2_nucleus/ultrack_workdir/data.db", "Ultrack database"),
69
+ ]),
70
+ ("Output", [
71
+ ("2_nucleus/tracked_labels.tif", "Tracked labels"),
72
+ ]),
73
+ ]
74
+
75
+
76
+ # ══════════════════════════════════════════════════════════════════════════════
77
+
78
+
79
+ class NucleusWorkflowWidget(
80
+ NucleusUltrackDbBrowserMixin, NucleusAtomExtractionMixin, StandalonePathsMixin, QWidget
81
+ ):
82
+ """Nucleus candidate generation and tracking — flat action-button layout."""
83
+
84
+ def __init__(
85
+ self,
86
+ viewer: napari.Viewer,
87
+ parent: QWidget | None = None,
88
+ gate: UiGate | None = None,
89
+ standalone: bool = False,
90
+ ) -> None:
91
+ super().__init__(parent)
92
+ self.viewer = viewer
93
+ #: Standalone mode: the piece runs on its own (own working-directory
94
+ #: picker + config), instead of being driven by the orchestrator.
95
+ self._standalone = standalone
96
+ #: App-wide UI gate. A private gate is created when none is injected so
97
+ #: the widget still works standalone (tests, isolated use).
98
+ self.gate = gate if gate is not None else UiGate(self)
99
+ #: Position directory (orchestrated mode) — drives the staged files panel.
100
+ self._pos_dir: Path | None = None
101
+ #: The active nucleus workspace (artifacts + annotation store). Derived
102
+ #: from ``_pos_dir`` in orchestrated mode; set directly to a flat working
103
+ #: directory by the standalone entry point.
104
+ self._workspace: NucleusWorkspace | None = None
105
+ self._stop_flag: bool = False
106
+ self._dims_step_refresh_pending: bool = False
107
+
108
+ self._init_ultrack_db_browser_state()
109
+ self._init_atom_extraction_state()
110
+
111
+ self._setup_ui()
112
+ self._connect_signals()
113
+ self._register_gate_controls()
114
+
115
+ if self._standalone:
116
+ self._load_standalone_settings()
117
+
118
+ # ================================================================
119
+ # UI
120
+ # ================================================================
121
+ def _setup_ui(self) -> None:
122
+ root = QVBoxLayout(self)
123
+ root.setContentsMargins(2, 2, 2, 2)
124
+ root.setSpacing(6)
125
+ # Embedded in the full app, the widget lives inside main_widget's
126
+ # AlignTop scroll layout, which pins it to the top; a Maximum vertical
127
+ # policy keeps each section compact. Standalone, napari docks the widget
128
+ # directly with no such wrapper — a Maximum policy then lets the dock
129
+ # centre the widget vertically once a section collapse shrinks its
130
+ # sizeHint below the dock height. So standalone instead fills the dock
131
+ # (Preferred) and pins content to the top with a trailing stretch.
132
+ if self._standalone:
133
+ root.setAlignment(Qt.AlignTop)
134
+ self.setSizePolicy(
135
+ QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred
136
+ )
137
+ else:
138
+ self.setSizePolicy(
139
+ QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
140
+ )
141
+
142
+ # ── Standalone input/output path pickers ──────────────────────
143
+ # Only shown when the piece runs on its own; the orchestrator drives the
144
+ # workspace through refresh()/set_context() instead. Three explicit
145
+ # fields: the two input files (any name/location) and an output dir.
146
+ self._paths_container = QWidget()
147
+ paths_col = QVBoxLayout(self._paths_container)
148
+ paths_col.setContentsMargins(0, 0, 0, 0)
149
+ paths_col.setSpacing(2)
150
+
151
+ self._foreground_edit = self._add_path_row(
152
+ paths_col,
153
+ "Foreground:",
154
+ "Foreground probability/intensity .tif",
155
+ self._on_browse_foreground,
156
+ self._on_paths_edited,
157
+ )
158
+ self._contours_edit = self._add_path_row(
159
+ paths_col,
160
+ "Contours:",
161
+ "Contour/boundary .tif",
162
+ self._on_browse_contours,
163
+ self._on_paths_edited,
164
+ )
165
+ self._output_dir_edit = self._add_path_row(
166
+ paths_col,
167
+ "Output dir:",
168
+ "Folder for outputs (defaults to foreground's folder)",
169
+ self._on_browse_output_dir,
170
+ self._on_paths_edited,
171
+ )
172
+ root.addWidget(self._paths_container)
173
+ self._paths_container.setVisible(self._standalone)
174
+
175
+ # ── Pipeline files (single deduplicated panel) ────────────────
176
+ self._files_widget = PipelineFilesWidget(
177
+ _NUCLEUS_PIPELINE_FILE_GROUPS,
178
+ viewer=self.viewer,
179
+ )
180
+ self._pipeline_files_section = CollapsibleSection(
181
+ "Pipeline Files",
182
+ self._files_widget,
183
+ expanded=False,
184
+ )
185
+ (
186
+ self.pipeline_files_header,
187
+ self.pipeline_files_header_lbl,
188
+ self.pipeline_files_toggle_btn,
189
+ ) = make_pipeline_files_header(
190
+ self._pipeline_files_section,
191
+ stage_key="nucleus",
192
+ parent=self,
193
+ )
194
+ root.addWidget(self.pipeline_files_header)
195
+ root.addWidget(self._pipeline_files_section)
196
+ # The staged files panel lists 1_cellpose/2_nucleus paths that don't
197
+ # exist in the standalone flat layout.
198
+ self.pipeline_files_header.setVisible(not self._standalone)
199
+ self._pipeline_files_section.setVisible(not self._standalone)
200
+
201
+ # ── Atom Extraction ──────────────────────────────────────────
202
+ self._build_atom_extraction_section(root)
203
+
204
+ # ── Workflow sections ────────────────────────────────────────
205
+ self._build_segmentation_inputs_section(root)
206
+ self._build_tracking_ultrack_section(root)
207
+
208
+ # ── Ultrack Database Browser ─────────────────────────────────
209
+ self._build_db_browser_section(root)
210
+
211
+ # ── Correction (group box) ───────────────────────────────────
212
+ self._build_correction_section(root)
213
+
214
+ # Standalone fills the dock, so absorb any slack at the bottom to keep
215
+ # the sections top-aligned (see the size-policy note above).
216
+ if self._standalone:
217
+ root.addStretch(1)
218
+
219
+ # -- Parameters --------------------------------------------------------
220
+
221
+ def _build_segmentation_inputs_section(self, root: QVBoxLayout) -> None:
222
+ self.nucleus_segmentation_inputs_widget = NucleusSegmentationInputsWidget(self)
223
+ segmentation_inputs = self.nucleus_segmentation_inputs_widget
224
+ self.segmentation_inputs_parameters_section = segmentation_inputs.section
225
+ self.segmentation_inputs_section = segmentation_inputs.section
226
+ self.nucleus_segmentation_inputs_widget.hide()
227
+
228
+ def _build_tracking_ultrack_section(self, root: QVBoxLayout) -> None:
229
+ self.nucleus_tracking_inputs_widget = NucleusTrackingInputsWidget(self)
230
+ self._alias_tracking_inputs_controls()
231
+
232
+ self.nucleus_pipeline_widget = NucleusPipelineWidget(
233
+ self.viewer,
234
+ workspace_provider=lambda: self._workspace,
235
+ seg_inputs_provider=lambda: self.nucleus_segmentation_inputs_widget,
236
+ tracking_inputs_provider=lambda: self.nucleus_tracking_inputs_widget,
237
+ refresh_files_callback=lambda: self._files_widget.refresh(self._pos_dir),
238
+ refresh_db_browser_callback=lambda: self._refresh_ultrack_db_browser(),
239
+ sync_viewer_activity_callback=lambda: self.gate.recompute(),
240
+ parent=self,
241
+ )
242
+ self._alias_pipeline_controls()
243
+ self.nucleus_tracking_inputs_widget.hide()
244
+ self.nucleus_pipeline_widget.hide()
245
+
246
+ # Hide the built-in section headers — the stage-row ⚙ buttons drive
247
+ # expand/collapse instead.
248
+ self.tracking_db_section.set_header_visible(False)
249
+ self.tracking_solve_section.set_header_visible(False)
250
+
251
+ # Collapse inline params blocks by default.
252
+ self.tracking_db_section.collapse()
253
+ self.tracking_solve_section.collapse()
254
+
255
+ pipeline_block = self.nucleus_pipeline_widget.build_pipeline_block(
256
+ db_section=self.tracking_db_section,
257
+ solve_section=self.tracking_solve_section,
258
+ )
259
+ root.addWidget(pipeline_block)
260
+ root.addWidget(self.pipeline_status_lbl)
261
+ root.addWidget(self.pipeline_progress_bar)
262
+
263
+ # ── Finalize: promote tracked labels → nucleus_labels.tif ─────
264
+ # Distinct from the correction widget's "commit" (which writes correction
265
+ # edits back into the working tracked_labels.tif); this promotes the
266
+ # working file to the downstream-stable final output.
267
+ # Two Finalize buttons, both promoting the working tracked labels to
268
+ # nucleus_labels.tif: one at the tail of the correction toolbar (visible
269
+ # in active correction mode) and one beside the on/off toggle in the
270
+ # correction top bar (always visible). Both are placed by the correction
271
+ # widget (see _build_correction_section); the handler stays here because
272
+ # the promote paths live on this workflow widget.
273
+ _finalize_tip = (
274
+ "Finalize: promote tracked labels to nucleus_labels.tif "
275
+ "(final, downstream-stable output)."
276
+ )
277
+ self.finalize_btn = _tool_btn("✔", _finalize_tip)
278
+ self.finalize_header_btn = _tool_btn("✔", _finalize_tip)
279
+ for button in (self.finalize_btn, self.finalize_header_btn):
280
+ button.clicked.connect(self._on_finalize)
281
+ self._refresh_finalize_btn()
282
+
283
+ def _alias_tracking_inputs_controls(self) -> None:
284
+ ti = self.nucleus_tracking_inputs_widget
285
+ self.tracking_db_section = ti.db_section
286
+ self.tracking_solve_section = ti.solve_section
287
+ self.db_gen_min_area_spin = ti.db_gen_min_area_spin
288
+ self.db_gen_max_area_spin = ti.db_gen_max_area_spin
289
+ self.db_gen_min_frontier_spin = ti.db_gen_min_frontier_spin
290
+ self.db_gen_ws_hierarchy_combo = ti.db_gen_ws_hierarchy_combo
291
+ self.db_gen_n_workers_spin = ti.db_gen_n_workers_spin
292
+ self.atom_union_max_area_spin = ti.atom_union_max_area_spin
293
+ self.atom_overlap_budget_spin = ti.atom_overlap_budget_spin
294
+ self.db_gen_max_dist_spin = ti.db_gen_max_dist_spin
295
+ self.db_gen_max_neighbors_spin = ti.db_gen_max_neighbors_spin
296
+ self.db_gen_linking_mode_combo = ti.db_gen_linking_mode_combo
297
+ self.db_gen_area_weight_spin = ti.db_gen_area_weight_spin
298
+ self.db_gen_iou_weight_spin = ti.db_gen_iou_weight_spin
299
+ self.db_gen_distance_weight_spin = ti.db_gen_distance_weight_spin
300
+ self.db_gen_quality_weight_spin = ti.db_gen_quality_weight_spin
301
+ self.db_gen_quality_exp_spin = ti.db_gen_quality_exp_spin
302
+ self.db_gen_circularity_weight_spin = ti.db_gen_circularity_weight_spin
303
+ self.ultrack_max_partitions_spin = ti.ultrack_max_partitions_spin
304
+ self.ultrack_n_frames_spin = ti.ultrack_n_frames_spin
305
+ self.ultrack_appear_spin = ti.ultrack_appear_spin
306
+ self.ultrack_disappear_spin = ti.ultrack_disappear_spin
307
+ self.ultrack_division_spin = ti.ultrack_division_spin
308
+ self.ultrack_power_spin = ti.ultrack_power_spin
309
+ self.ultrack_bias_spin = ti.ultrack_bias_spin
310
+ self.ultrack_solver_lbl = ti.ultrack_solver_lbl
311
+
312
+ def _alias_pipeline_controls(self) -> None:
313
+ pl = self.nucleus_pipeline_widget
314
+ self.seg_params_btn = pl.seg_params_btn
315
+ self.seg_run_btn = pl.seg_run_btn
316
+ self.db_params_btn = pl.db_params_btn
317
+ self.db_run_btn = pl.db_run_btn
318
+ self.solve_params_btn = pl.solve_params_btn
319
+ self.solve_run_btn = pl.solve_run_btn
320
+ self.pipeline_status_lbl = pl.pipeline_status_lbl
321
+ self.pipeline_progress_bar = pl.pipeline_progress_bar
322
+ for name in (
323
+ "_on_build_segmentation_inputs",
324
+ "_on_run_db_generation",
325
+ "_on_db_gen_done",
326
+ "_on_db_gen_worker_error",
327
+ "_on_run_ultrack",
328
+ "_on_ultrack_progress",
329
+ "_on_run_ultrack_done",
330
+ "_on_ultrack_worker_error",
331
+ "_on_cancel",
332
+ "_status",
333
+ "_progress",
334
+ "_on_progress",
335
+ "_clear_progress",
336
+ "_set_running_stage",
337
+ ):
338
+ setattr(self, name, getattr(pl, name))
339
+
340
+ # -- Ultrack Database Browser ------------------------------------------
341
+
342
+ # -- Correction --------------------------------------------------------
343
+
344
+ def _build_correction_section(self, root: QVBoxLayout) -> None:
345
+ self.nucleus_correction_widget = NucleusCorrectionWidget(
346
+ self.viewer,
347
+ workspace_provider=lambda: self._workspace,
348
+ ultrack_config_provider=lambda: self._ultrack_config_from_controls(),
349
+ focus_takeover_callback=self._set_correction_focus_takeover,
350
+ files_widget_refresh_callback=lambda: self._files_widget.refresh(self._pos_dir),
351
+ finalize_btn=self.finalize_btn,
352
+ finalize_header_btn=self.finalize_header_btn,
353
+ parent=self,
354
+ )
355
+ self._alias_correction_controls()
356
+ # NucleusCorrectionWidget acts as a controller here. Its visible controls
357
+ # are reparented into this layout below, so keep the owner widget hidden
358
+ # to prevent its default geometry from intercepting header clicks.
359
+ self.nucleus_correction_widget.hide()
360
+ root.addWidget(self.ultrack_db_browser_header)
361
+ root.addWidget(self.ultrack_db_browser_section)
362
+ root.addWidget(self.correction_header)
363
+ root.addWidget(self.correction_mode_section)
364
+
365
+ def _plugin_dock(self):
366
+ """The napari QDockWidget hosting this whole workflow panel, if any."""
367
+ from qtpy.QtWidgets import QDockWidget
368
+
369
+ widget = self.parentWidget()
370
+ while widget is not None:
371
+ if isinstance(widget, QDockWidget):
372
+ return widget
373
+ widget = widget.parentWidget()
374
+ return None
375
+
376
+ def _set_correction_focus_takeover(self, on: bool) -> None:
377
+ """Hand the whole window to the correction workspace, or give it back.
378
+
379
+ Driven by NucleusCorrectionWidget once activation succeeds (and again,
380
+ with ``False``, on deactivate). In focus mode the correction controls are
381
+ reparented out into their own workspace dock, so we hide this entire
382
+ plugin dock; deactivate re-inserts the correction header + section into
383
+ this layout (their original trailing position) and shows the dock again.
384
+ """
385
+ layout = self.layout()
386
+ dock = self._plugin_dock()
387
+ if on:
388
+ if dock is not None:
389
+ dock.setVisible(False)
390
+ return
391
+ if layout is not None:
392
+ for widget in (
393
+ getattr(self, "correction_header", None),
394
+ getattr(self, "correction_mode_section", None),
395
+ ):
396
+ if widget is not None:
397
+ self._add_before_trailing_stretch(layout, widget)
398
+ if dock is not None:
399
+ dock.setVisible(True)
400
+
401
+ @staticmethod
402
+ def _add_before_trailing_stretch(layout, widget) -> None:
403
+ """Append *widget*, keeping any trailing stretch(es) last.
404
+
405
+ Both the standalone layout and napari's ``add_dock_widget`` append a
406
+ trailing stretch to top-anchor the content, so a docked standalone widget
407
+ carries *two* spacers at the tail. A plain ``addWidget`` would drop the
408
+ re-inserted controls (the correction header + section returning from focus
409
+ takeover) below them and shove the pill to the bottom; inserting before
410
+ only the last spacer would still strand it between the two. Walk back over
411
+ every trailing spacer and insert before the first one.
412
+ """
413
+ index = layout.count()
414
+ while index > 0 and layout.itemAt(index - 1).spacerItem() is not None:
415
+ index -= 1
416
+ layout.insertWidget(index, widget)
417
+
418
+ def _alias_correction_controls(self) -> None:
419
+ correction = self.nucleus_correction_widget
420
+ self.correction_header = correction.header
421
+ self.correction_header_lbl = correction.header_lbl
422
+ self.correction_shortcuts_btn = correction.shortcuts_btn
423
+ self.correction_params_btn = correction.params_btn
424
+ self.correction_active_btn = correction.active_btn
425
+ self.correction_toolbar = correction.toolbar
426
+ self.save_tracked_btn = correction.save_tracked_btn
427
+ self.extend_back_btn = correction.extend_back_btn
428
+ self.extend_fwd_btn = correction.extend_fwd_btn
429
+ self.retrack_back_btn = correction.retrack_back_btn
430
+ self.retrack_fwd_btn = correction.retrack_fwd_btn
431
+ self.swap_smaller_btn = correction.swap_smaller_btn
432
+ self.swap_larger_btn = correction.swap_larger_btn
433
+ self.reassign_ids_btn = correction.reassign_ids_btn
434
+ self.validate_track_btn = correction.validate_track_btn
435
+ self.anchor_here_btn = correction.anchor_here_btn
436
+ self.annotate_db_btn = correction.annotate_db_btn
437
+ self.remove_unvalidated_btn = correction.remove_unvalidated_btn
438
+ self.commit_btn = correction.commit_btn
439
+ self.correction_status_lbl = correction.status_lbl
440
+ self.validation_counter_lbl = correction.validation_counter_lbl
441
+ self.extend_area_weight_spin = correction.extend_area_weight_spin
442
+ self.extend_iou_weight_spin = correction.extend_iou_weight_spin
443
+ self.extend_distance_weight_spin = correction.extend_distance_weight_spin
444
+ self.extend_greedy_overwrite_check = correction.extend_greedy_overwrite_check
445
+ self.retrack_max_dist_spin = correction.retrack_max_dist_spin
446
+ self.extend_retrack_params_section = correction.extend_retrack_params_section
447
+ self.extend_params_section = correction.extend_params_section
448
+ self.retrack_params_section = correction.retrack_params_section
449
+ self.correction_widget = correction.correction_widget
450
+ self.correction_shortcuts_section = correction.shortcuts_section
451
+ self.correction_mode_section = correction.section
452
+ self._correction_owned_layers = correction._correction_owned_layers
453
+ self._validated_overlay = correction._validated_overlay
454
+ for name in (
455
+ "_correction_tracked_layer",
456
+ "_correction_status",
457
+ "_capture_correction_view_state",
458
+ "_restore_correction_view_state",
459
+ "_remove_correction_owned_layers",
460
+ "_add_correction_image_layer",
461
+ "_on_save_tracked",
462
+ "_load_correction_layers_from_disk",
463
+ "_remove_other_correction_prefix_layers",
464
+ "_on_load_tracked",
465
+ "_on_reassign_ids",
466
+ "_on_reassign_ids_done",
467
+ "_commit_reassign_ids",
468
+ "_on_commit",
469
+ "_selected_correction_target",
470
+ "_validated_correction_for_frame",
471
+ "_on_validate_track",
472
+ "_on_anchor_here",
473
+ "_on_annotate_database",
474
+ "_on_annotate_database_done",
475
+ "_on_extend_backward",
476
+ "_on_extend_forward",
477
+ "_on_extend",
478
+ "_on_swap_step",
479
+ "_apply_swap",
480
+ "_on_retrack_forward",
481
+ "_on_retrack_backward",
482
+ "_remove_unvalidated_from_layer",
483
+ "_on_remove_unvalidated_labels",
484
+ "_on_correction_worker_error",
485
+ "_install_correction_shortcuts",
486
+ "_on_correction_active_button_toggled",
487
+ "_on_correction_mode_toggled",
488
+ "_kb_toggle_cell_validation",
489
+ "_refresh_validated_overlay",
490
+ "_add_validated_overlay",
491
+ "_place_validated_overlay_below_spotlight",
492
+ "_refresh_validation_counter",
493
+ "_on_cells_edited",
494
+ "_frames_with_cell",
495
+ ):
496
+ setattr(self, name, getattr(correction, name))
497
+
498
+ # -- Atom Extraction ---------------------------------------------------
499
+
500
+ def _build_atom_extraction_section(self, root: QVBoxLayout) -> None:
501
+ self.atom_extraction_widget = NucleusAtomExtractionWidget(self)
502
+ self.atom_extraction_section = self.atom_extraction_widget.section
503
+ self._alias_atom_extraction_controls()
504
+ root.addWidget(self.atom_extraction_widget.header)
505
+ root.addWidget(self.atom_extraction_widget.section)
506
+
507
+ def _atom_fg_path(self):
508
+ return self._workspace.foreground if self._workspace else None
509
+
510
+ def _atom_contour_path(self):
511
+ return self._workspace.contours if self._workspace else None
512
+
513
+ def _atom_output_path(self):
514
+ return self._workspace.atoms if self._workspace else None
515
+
516
+ # ================================================================
517
+ # Signals
518
+ # ================================================================
519
+ def _connect_signals(self) -> None:
520
+ # DB Browser
521
+ self.ultrack_db_active_btn.toggled.connect(
522
+ self._on_guarded_ultrack_db_activate
523
+ )
524
+ try:
525
+ self.correction_active_btn.toggled.disconnect(
526
+ self.nucleus_correction_widget._on_correction_active_button_toggled
527
+ )
528
+ except (TypeError, RuntimeError):
529
+ pass
530
+ self.correction_active_btn.toggled.connect(
531
+ self._on_guarded_correction_active_button_toggled
532
+ )
533
+ self.ultrack_db_source_slider.valueChanged.connect(
534
+ self._on_ultrack_db_source_changed
535
+ )
536
+ self.ultrack_db_hierarchy_slider.valueChanged.connect(
537
+ self._on_ultrack_db_slider_changed
538
+ )
539
+ self.ultrack_db_prob_alpha_check.toggled.connect(
540
+ self._refresh_ultrack_db_browser
541
+ )
542
+ self.ultrack_db_connected_focus_check.toggled.connect(
543
+ self._refresh_ultrack_db_browser
544
+ )
545
+ self.ultrack_db_annotation_check.toggled.connect(
546
+ self._refresh_ultrack_db_browser
547
+ )
548
+
549
+ # Viewer events & keyboard
550
+ self.viewer.dims.events.current_step.connect(self._on_dims_step_changed)
551
+ self.viewer.bind_key("V", self._kb_toggle_cell_validation, overwrite=True)
552
+ self.set_selection_callback(None)
553
+
554
+ # Initial state
555
+ solver = _select_solver()
556
+ solver_display = "Gurobi (licensed)" if solver == "GUROBI" else "CBC"
557
+ self.ultrack_solver_lbl.setText(solver_display)
558
+ # Initial enablement is applied by ``_register_gate_controls`` (called
559
+ # right after ``_connect_signals`` in ``__init__``).
560
+
561
+ # ================================================================
562
+ # Viewer activity guard (driven by the shared UI gate)
563
+ # ================================================================
564
+ def _register_gate_controls(self) -> None:
565
+ """Register this section's controls with the app-wide UI gate.
566
+
567
+ Correction and the database browser are mutually-exclusive viewer
568
+ owners; the pipeline run/params buttons rebuild the data those owners
569
+ view, so they are blocked while either owner is active. All enablement
570
+ flows from :class:`~itasc.napari.ui_gate.UiGate` from here on.
571
+ """
572
+ g = self.gate
573
+ g.register_owner(
574
+ "correction:nucleus",
575
+ "correction mode",
576
+ exit_fn=lambda: self.correction_active_btn.setChecked(False),
577
+ )
578
+ g.register_owner(
579
+ "db_browser",
580
+ "database browser mode",
581
+ exit_fn=lambda: self.ultrack_db_active_btn.setChecked(False),
582
+ )
583
+ g.register(
584
+ self.correction_active_btn,
585
+ ControlClass.VIEWER_OWNER,
586
+ owner_token="correction:nucleus",
587
+ )
588
+ g.register(
589
+ self.ultrack_db_active_btn,
590
+ ControlClass.VIEWER_OWNER,
591
+ owner_token="db_browser",
592
+ )
593
+ pl = self.nucleus_pipeline_widget
594
+ run_stage_of = {
595
+ self.seg_run_btn: "seg",
596
+ self.db_run_btn: "db",
597
+ self.solve_run_btn: "ultrack",
598
+ }
599
+ for button, stage in run_stage_of.items():
600
+ g.register(
601
+ button,
602
+ ControlClass.RUN_VIEWER,
603
+ when=lambda s=stage: pl._running_stage in (None, s),
604
+ )
605
+ # ⚙ params just open/close a parameter panel — no viewer/context side
606
+ # effect — so they stay available during modes and runs.
607
+ for button in (self.seg_params_btn, self.db_params_btn, self.solve_params_btn):
608
+ g.register(button, ControlClass.HARMLESS)
609
+ self._register_atom_gate_controls()
610
+ g.recompute()
611
+
612
+ def _on_guarded_ultrack_db_activate(self, checked: bool) -> None:
613
+ self._on_ultrack_db_activate(checked)
614
+ if checked:
615
+ self.gate.claim_viewer("db_browser")
616
+ else:
617
+ self.gate.release_viewer("db_browser")
618
+
619
+ def _on_guarded_correction_active_button_toggled(self, checked: bool) -> None:
620
+ self._on_correction_active_button_toggled(checked)
621
+ # Activation can bail out — e.g. no tracked data on disk yet — reverting
622
+ # the button to unchecked from inside the handler. Claim/release the
623
+ # viewer from the *resulting* button state, not the incoming toggle, so a
624
+ # bailed-out activation never leaves the gate owning the viewer with the
625
+ # button already off (banner + disabled controls and no way to release).
626
+ if self.correction_active_btn.isChecked():
627
+ self.gate.claim_viewer("correction:nucleus")
628
+ else:
629
+ self.gate.release_viewer("correction:nucleus")
630
+
631
+ # ================================================================
632
+ # Path helpers
633
+ # ================================================================
634
+ def _ultrack_db_path(self) -> Path | None:
635
+ # Required by NucleusUltrackDbBrowserMixin.
636
+ return self._workspace.ultrack_db if self._workspace else None
637
+
638
+ def _nucleus_foreground_path(self) -> Path | None:
639
+ # Required by NucleusUltrackDbBrowserMixin.
640
+ return self._workspace.foreground if self._workspace else None
641
+
642
+ # These delegate to the pipeline widget so that tests that call path helpers
643
+ # on the workflow widget (legacy seam tests) continue to pass.
644
+ def _contours_path(self) -> Path | None:
645
+ return self.nucleus_pipeline_widget._contours_path()
646
+
647
+
648
+ # ================================================================
649
+ # Public API
650
+ # ================================================================
651
+ def refresh(self, pos_dir: Path | None) -> None:
652
+ """Orchestrated seam: drive the piece from a staged position directory."""
653
+ self._pos_dir = pos_dir
654
+ workspace = (
655
+ NucleusWorkspace.staged(pos_dir) if pos_dir is not None else None
656
+ )
657
+ self._apply_workspace(workspace, files_root=pos_dir)
658
+
659
+ # ── Commit contract: promote working labels → nucleus_labels.tif ───
660
+ def _working_labels_path(self) -> Path | None:
661
+ return self._workspace.tracked if self._workspace else None
662
+
663
+ def _committed_labels_path(self) -> Path | None:
664
+ if self._pos_dir is not None:
665
+ return NucleusArtifactPaths(self._pos_dir).nucleus_labels
666
+ if self._workspace is not None:
667
+ return self._workspace.nucleus_dir / "nucleus_labels.tif"
668
+ return None
669
+
670
+ def _set_finalize_status(self, msg: str) -> None:
671
+ self.pipeline_status_lbl.setText(msg)
672
+ self.pipeline_status_lbl.setVisible(bool(msg))
673
+
674
+ def _on_finalize(self) -> None:
675
+ working = self._working_labels_path()
676
+ committed = self._committed_labels_path()
677
+ if working is None or committed is None or not working.exists():
678
+ self._set_finalize_status("No tracked labels to finalize — run tracking first.")
679
+ return
680
+ try:
681
+ promote_labels(working, committed)
682
+ except (FileNotFoundError, ValueError) as exc:
683
+ self._set_finalize_status(f"Finalize failed: {exc}")
684
+ return
685
+ self._set_finalize_status(f"Finalized nucleus labels → {committed.name}.")
686
+ self._refresh_finalize_btn()
687
+ # Surface the committed file (and let the host repaint the stage dots).
688
+ self._files_widget.refresh(self._pos_dir)
689
+
690
+ def _finalize_btns(self) -> tuple:
691
+ return tuple(
692
+ b
693
+ for b in (
694
+ getattr(self, "finalize_btn", None),
695
+ getattr(self, "finalize_header_btn", None),
696
+ )
697
+ if b is not None
698
+ )
699
+
700
+ def _refresh_finalize_btn(self) -> None:
701
+ buttons = self._finalize_btns()
702
+ if not buttons:
703
+ return
704
+ working = self._working_labels_path()
705
+ committed = self._committed_labels_path()
706
+ if working is None or committed is None:
707
+ for button in buttons:
708
+ button.setEnabled(False)
709
+ return
710
+ state = commit_state(working, committed)
711
+ tooltip = (
712
+ "Working labels are newer than the finalized nucleus_labels.tif — re-finalize to update."
713
+ if state == "stale"
714
+ else "Finalize: promote tracked labels to nucleus_labels.tif (final, downstream-stable output)."
715
+ )
716
+ for button in buttons:
717
+ button.setEnabled(state != "missing")
718
+ button.setToolTip(tooltip)
719
+
720
+ def set_context(
721
+ self,
722
+ *,
723
+ foreground: Path | str | None = None,
724
+ contours: Path | str | None = None,
725
+ output_dir: Path | str | None = None,
726
+ work_dir: Path | str | None = None,
727
+ ) -> None:
728
+ """Standalone seam: drive the piece from explicit input/output paths.
729
+
730
+ Two forms are accepted:
731
+
732
+ * ``foreground`` + ``contours`` (+ optional ``output_dir``): the two
733
+ input files may have any name/location; every output (``data.db``,
734
+ ``tracked_labels.tif``, ``atoms.tif``, the ``*.json`` annotations) is
735
+ written under ``output_dir`` (defaulting to the foreground's folder).
736
+ * ``work_dir``: a flat working directory holding ``foreground.tif`` +
737
+ ``contours.tif`` that also receives every output. Retained for
738
+ convenience and backward compatibility.
739
+
740
+ There is no staged ``2_nucleus`` subfolder in either form.
741
+ """
742
+ self._pos_dir = None
743
+ if work_dir:
744
+ workspace = NucleusWorkspace.flat(work_dir)
745
+ elif foreground and contours:
746
+ workspace = NucleusWorkspace.files(
747
+ foreground=foreground,
748
+ contours=contours,
749
+ output_dir=output_dir or None,
750
+ )
751
+ else:
752
+ workspace = None
753
+ if self._standalone:
754
+ self._sync_path_fields(workspace)
755
+ self._save_standalone_settings()
756
+ # The staged files panel is meaningless without a position dir.
757
+ self._apply_workspace(workspace, files_root=None)
758
+
759
+ def _apply_workspace(
760
+ self, workspace: NucleusWorkspace | None, *, files_root: Path | None
761
+ ) -> None:
762
+ self._workspace = workspace
763
+ self._refresh_finalize_btn()
764
+ self._files_widget.refresh(files_root)
765
+ # The atom-extraction controls gate on inputs being present
766
+ # (``when=ready`` → ``_atom_fg_path() is not None``); re-derive the gate
767
+ # now that the workspace — and therefore input availability — changed.
768
+ self.gate.recompute()
769
+ if workspace is None:
770
+ if self.correction_active_btn.isChecked():
771
+ self.correction_active_btn.setChecked(False)
772
+ else:
773
+ self.correction_widget.deactivate()
774
+ self._remove_correction_owned_layers()
775
+ return
776
+ self._refresh_validated_overlay()
777
+ self._refresh_validation_counter()
778
+
779
+ # ── Standalone helpers ────────────────────────────────────────────────────
780
+ # Row building / browse plumbing / QSettings come from StandalonePathsMixin;
781
+ # the apply step is nucleus-specific (it builds a NucleusWorkspace through
782
+ # set_context rather than tracking flat _pos_dir paths).
783
+ def _apply_paths_from_fields(self) -> None:
784
+ self.set_context(
785
+ foreground=self._foreground_edit.text().strip() or None,
786
+ contours=self._contours_edit.text().strip() or None,
787
+ output_dir=self._output_dir_edit.text().strip() or None,
788
+ )
789
+
790
+ def _on_browse_foreground(self) -> None:
791
+ self._browse_file_into(
792
+ self._foreground_edit, "Select foreground image", self._apply_paths_from_fields
793
+ )
794
+
795
+ def _on_browse_contours(self) -> None:
796
+ self._browse_file_into(
797
+ self._contours_edit, "Select contours image", self._apply_paths_from_fields
798
+ )
799
+
800
+ def _on_browse_output_dir(self) -> None:
801
+ self._browse_dir_into(
802
+ self._output_dir_edit, "Select output directory", self._apply_paths_from_fields
803
+ )
804
+
805
+ def _on_paths_edited(self) -> None:
806
+ self._apply_paths_from_fields()
807
+
808
+ def _sync_path_fields(self, workspace: NucleusWorkspace | None) -> None:
809
+ """Reflect the active workspace back into the three input fields.
810
+
811
+ Guards against editingFinished re-entrancy by blocking signals while the
812
+ text is updated programmatically. The flat ``work_dir`` form resolves to
813
+ ``<dir>/foreground.tif`` etc., so reading the workspace fields covers
814
+ both forms uniformly.
815
+ """
816
+ if workspace is None:
817
+ fg = ct = out = ""
818
+ else:
819
+ fg = str(workspace.foreground)
820
+ ct = str(workspace.contours)
821
+ out = str(workspace.nucleus_dir)
822
+ for edit, value in (
823
+ (self._foreground_edit, fg),
824
+ (self._contours_edit, ct),
825
+ (self._output_dir_edit, out),
826
+ ):
827
+ blocked = edit.blockSignals(True)
828
+ edit.setText(value)
829
+ edit.blockSignals(blocked)
830
+
831
+ def _settings(self) -> QSettings:
832
+ return QSettings("itasc", "itasc_tracking")
833
+
834
+ def _load_standalone_settings(self) -> None:
835
+ s = self._settings()
836
+ foreground = s.value("foreground", "", type=str)
837
+ contours = s.value("contours", "", type=str)
838
+ output_dir = s.value("output_dir", "", type=str)
839
+ if foreground and contours:
840
+ self.set_context(
841
+ foreground=foreground,
842
+ contours=contours,
843
+ output_dir=output_dir or None,
844
+ )
845
+
846
+ def _save_standalone_settings(self) -> None:
847
+ s = self._settings()
848
+ s.setValue("foreground", self._foreground_edit.text().strip())
849
+ s.setValue("contours", self._contours_edit.text().strip())
850
+ s.setValue("output_dir", self._output_dir_edit.text().strip())
851
+
852
+ def get_state(self) -> dict:
853
+ return dump_state(self)
854
+
855
+ def set_state(self, state: dict) -> None:
856
+ load_state(self, state)
857
+
858
+
859
+ def set_selection_callback(self, fn) -> None:
860
+ def composed(t, label):
861
+ self.nucleus_correction_widget._swap_cursor = None
862
+ if fn is not None:
863
+ fn(t, label)
864
+ self.correction_widget.set_selection_callback(composed)
865
+
866
+ def select_matching_nucleus_label(
867
+ self, t: int, source_label: int,
868
+ *, source_labels: np.ndarray | None = None,
869
+ ) -> None:
870
+ if _TRACKED_LAYER not in self.viewer.layers:
871
+ return
872
+ if source_labels is None:
873
+ if "Tracked: Cell" not in self.viewer.layers:
874
+ return
875
+ source_labels = np.asarray(self.viewer.layers["Tracked: Cell"].data)
876
+ target = np.asarray(self.viewer.layers[_TRACKED_LAYER].data)
877
+ matched = best_overlapping_label(target, source_labels, t, source_label)
878
+ self.correction_widget.select_label(t, matched, notify=False)
879
+
880
+ # ================================================================
881
+ # Viewer helpers (correction-owned)
882
+ # ================================================================
883
+ def _current_t(self) -> int:
884
+ step = self.viewer.dims.current_step
885
+ return int(step[0]) if len(step) >= 1 else 0
886
+
887
+ @staticmethod
888
+ def _frame_view_2d(arr: np.ndarray, t: int) -> np.ndarray | None:
889
+ if arr.ndim < 3 or t < 0 or t >= arr.shape[0]:
890
+ return None
891
+ v = arr[t]
892
+ while v.ndim > 2:
893
+ if v.shape[0] != 1:
894
+ return None
895
+ v = v[0]
896
+ return v
897
+
898
+ def _current_cell_ids(self, t: int) -> set[int]:
899
+ layer = self._correction_tracked_layer()
900
+ if layer is None:
901
+ return set()
902
+ frame = self._frame_view_2d(layer.data, t)
903
+ if frame is None:
904
+ return set()
905
+ return set(int(v) for v in np.unique(frame)) - {0}
906
+
907
+ def _set_viewer_frame(self, t: int) -> None:
908
+ step = list(self.viewer.dims.current_step)
909
+ if not step:
910
+ return
911
+ step[0] = int(t)
912
+ self.viewer.dims.current_step = tuple(step)
913
+
914
+ # ================================================================
915
+ # Backward-compat delegates (tests call these on the workflow widget)
916
+ # ================================================================
917
+ def _db_gen_config_from_controls(self):
918
+ return self.nucleus_pipeline_widget._db_gen_config_from_controls()
919
+
920
+ def _ultrack_config_from_controls(self):
921
+ return self.nucleus_pipeline_widget._ultrack_config_from_controls()
922
+
923
+ # Pipeline handler methods are owned by NucleusPipelineWidget and aliased
924
+ # onto this instance by _alias_pipeline_controls() during __init__.
925
+
926
+
927
+ # ================================================================
928
+ # Correction / DB browser coordination
929
+ # ================================================================
930
+ def _on_dims_step_changed(self, event=None) -> None:
931
+ if not self._dims_step_refresh_pending:
932
+ self._dims_step_refresh_pending = True
933
+ QTimer.singleShot(0, self._refresh_after_dims_step_changed)
934
+ if self.ultrack_db_browser_section.is_expanded:
935
+ QTimer.singleShot(0, self._refresh_ultrack_db_browser)
936
+ if getattr(self, "_atom_preview_active", False):
937
+ QTimer.singleShot(0, self._refresh_atom_preview)
938
+
939
+ def _refresh_after_dims_step_changed(self) -> None:
940
+ self._dims_step_refresh_pending = False
941
+ self.nucleus_correction_widget.on_dims_step_changed()
942
+
943
+
944
+ def make_nucleus_tracking_widget(napari_viewer=None):
945
+ """napari plugin factory for the standalone nucleus tracking/correction piece.
946
+
947
+ Patches the napari layer-controls delegate (best-effort) and returns the
948
+ workflow widget in standalone mode, with its own working-directory picker
949
+ and config.
950
+ """
951
+ try:
952
+ from itasc.napari._napari_compat import patch_napari_layer_delegate
953
+
954
+ patch_napari_layer_delegate()
955
+ except Exception:
956
+ pass
957
+ # napari does not inject the viewer into function-based widget factories
958
+ # (only into class-based callables / magicgui types), so ``napari_viewer``
959
+ # arrives as ``None``. The widget needs a live viewer (dims events, key
960
+ # bindings), so fall back to the active one.
961
+ if napari_viewer is None:
962
+ napari_viewer = napari.current_viewer()
963
+ return NucleusWorkflowWidget(viewer=napari_viewer, standalone=True)