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.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- 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)
|