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