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,1190 @@
|
|
|
1
|
+
"""Correction section widget for the cell workflow."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import tifffile
|
|
10
|
+
from napari.utils.colormaps import direct_colormap
|
|
11
|
+
from qtpy.QtWidgets import (
|
|
12
|
+
QComboBox,
|
|
13
|
+
QHBoxLayout,
|
|
14
|
+
QLabel,
|
|
15
|
+
QSizePolicy,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from itasc.correction.labels import (
|
|
21
|
+
clean_stranded_pixels,
|
|
22
|
+
fill_label_holes,
|
|
23
|
+
)
|
|
24
|
+
from itasc.core.tiff import imwrite_grayscale
|
|
25
|
+
from itasc.core.label_store import read_full_tracked_stack
|
|
26
|
+
from itasc.napari.correction._correction_utils import frame_view_2d, remove_unvalidated_labels
|
|
27
|
+
from itasc.napari.lineage_canvas_controller import LineageCanvasController
|
|
28
|
+
from itasc.napari._widget_helpers import (
|
|
29
|
+
make_status as _make_status,
|
|
30
|
+
tool_btn as _tool_btn,
|
|
31
|
+
)
|
|
32
|
+
from itasc.napari.correction._correction_layer_lifecycle import (
|
|
33
|
+
CorrectionViewStateMixin,
|
|
34
|
+
LayerViewState,
|
|
35
|
+
hide_all_layers,
|
|
36
|
+
)
|
|
37
|
+
from itasc.napari.correction._correction_ui import (
|
|
38
|
+
build_correction_toolbar,
|
|
39
|
+
confirm_unsaved_before_deactivate,
|
|
40
|
+
set_checked_without_signal,
|
|
41
|
+
)
|
|
42
|
+
from itasc.napari.correction.correction_widget import CorrectionWidget
|
|
43
|
+
from itasc.napari.ui_style import (
|
|
44
|
+
add_block_pair_row,
|
|
45
|
+
block_grid,
|
|
46
|
+
stage_header_action_button,
|
|
47
|
+
stage_header_label,
|
|
48
|
+
)
|
|
49
|
+
from itasc.napari.widgets import CollapsibleSection
|
|
50
|
+
from itasc.napari._widget_helpers import (
|
|
51
|
+
dslider as _dslider,
|
|
52
|
+
islider as _islider,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Layers created by the cell correction widget itself carry a [Correction]
|
|
58
|
+
# prefix so they can be cleanly removed on deactivate without clobbering the
|
|
59
|
+
# regular pipeline layers (Tracked: Cell, Cell z-avg, …) shown elsewhere.
|
|
60
|
+
_TRACKED_CELL_LAYER = "[Correction] Cell Labels"
|
|
61
|
+
_CELL_ZAVG_LAYER = "[Correction] Cell z-avg"
|
|
62
|
+
_NUC_ZAVG_LAYER = "[Correction] Nucleus z-avg"
|
|
63
|
+
# Validated-cell overlay (full-editing only): an opaque green border around
|
|
64
|
+
# every cell in every frame it's validated for. Standalone analogue of
|
|
65
|
+
# ValidatedOverlayController's border mode, drawn from the in-memory
|
|
66
|
+
# ``self._validated_tracks`` store instead of validated_cells.json.
|
|
67
|
+
_VALIDATED_OVERLAY_LAYER = "[Correction] Validated: Cell"
|
|
68
|
+
_VALIDATED_OVERLAY_COLOR = "#00ff00"
|
|
69
|
+
_VALIDATED_OVERLAY_CONTOUR = 2
|
|
70
|
+
|
|
71
|
+
# Pipeline-side (non-correction) layer names — used as fallbacks where the
|
|
72
|
+
# user may have loaded layers manually, and as the target for the on-disk
|
|
73
|
+
# Tracked: Cell refresh when correction mode is exited.
|
|
74
|
+
_PIPELINE_TRACKED_CELL_LAYER = "Tracked: Cell"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CellCorrectionWidget(CorrectionViewStateMixin, QWidget):
|
|
78
|
+
"""Qt controls for cell tracking correction workflows.
|
|
79
|
+
|
|
80
|
+
Owns the correction group-box UI, all correction-scoped button handlers,
|
|
81
|
+
and the inline CorrectionWidget. Does not hold a back-reference to the
|
|
82
|
+
parent workflow widget.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
viewer:
|
|
87
|
+
The napari viewer instance.
|
|
88
|
+
pos_dir_provider:
|
|
89
|
+
Zero-argument callable that returns the current position directory
|
|
90
|
+
(or None). When None, the widget falls back to a locally stored
|
|
91
|
+
``_local_pos_dir``.
|
|
92
|
+
files_widget_refresh_callback:
|
|
93
|
+
Called with the current ``pos_dir`` after save operations so that the
|
|
94
|
+
parent workflow can refresh its file status panel.
|
|
95
|
+
labels_path_provider:
|
|
96
|
+
Optional zero-argument callable returning the tracked-cell-labels file to
|
|
97
|
+
load and save. When given it overrides the default position-dir path
|
|
98
|
+
(``<pos_dir>/3_cell/tracked_labels.tif``), letting a standalone tool point
|
|
99
|
+
the corrector at a flat output file. Defaults to None → unchanged app
|
|
100
|
+
behaviour.
|
|
101
|
+
cell_ref_path_provider, nucleus_ref_path_provider:
|
|
102
|
+
Optional overrides for the cell / nucleus reference-image backdrops
|
|
103
|
+
(default ``<pos_dir>/1_cellpose/*_foreground.tif``). None → app default.
|
|
104
|
+
active_labels_layer_provider:
|
|
105
|
+
Optional zero-argument callable returning the napari **Labels** layer to
|
|
106
|
+
correct right now (or None when the active layer is not a Labels layer).
|
|
107
|
+
When given, the corrector runs in *active-layer mode*: it edits that
|
|
108
|
+
layer's data in place — no on-disk label file, no backdrop loading, no
|
|
109
|
+
layer hiding — and the user saves it via napari. Defaults to None →
|
|
110
|
+
unchanged disk-based app behaviour.
|
|
111
|
+
intensity_frame_provider:
|
|
112
|
+
Optional callable ``(t) -> 2D array | None`` giving the signal frame that
|
|
113
|
+
middle-click spawn snaps a new cell to (see ``add_cell``'s ``image``
|
|
114
|
+
argument); ``None`` (or a returned ``None``) falls back to a plain disk.
|
|
115
|
+
Defaults to None → spawn always stamps a disk (unchanged app behaviour).
|
|
116
|
+
parent:
|
|
117
|
+
Optional Qt parent.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
viewer,
|
|
123
|
+
*,
|
|
124
|
+
pos_dir_provider: Callable[[], Path | None] | None = None,
|
|
125
|
+
files_widget_refresh_callback: Callable[[Path | None], None] | None = None,
|
|
126
|
+
labels_path_provider: Callable[[], Path | None] | None = None,
|
|
127
|
+
cell_ref_path_provider: Callable[[], Path | None] | None = None,
|
|
128
|
+
nucleus_ref_path_provider: Callable[[], Path | None] | None = None,
|
|
129
|
+
active_labels_layer_provider: Callable[[], object] | None = None,
|
|
130
|
+
intensity_frame_provider: Callable[[int], np.ndarray | None] | None = None,
|
|
131
|
+
full_editing: bool = False,
|
|
132
|
+
finalize_btn: QWidget | None = None,
|
|
133
|
+
finalize_header_btn: QWidget | None = None,
|
|
134
|
+
parent: QWidget | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
super().__init__(parent)
|
|
137
|
+
self.viewer = viewer
|
|
138
|
+
# Optional host-owned "Finalize" buttons (the handler lives on the host
|
|
139
|
+
# workflow widget, which owns the promote paths). ``_finalize_btn`` rides
|
|
140
|
+
# at the tail of the correction toolbar (visible only in active mode);
|
|
141
|
+
# ``_finalize_header_btn`` sits beside the on/off toggle in the top bar
|
|
142
|
+
# (always visible). Either may be None → that slot has no finalize action.
|
|
143
|
+
self._finalize_btn = finalize_btn
|
|
144
|
+
self._finalize_header_btn = finalize_header_btn
|
|
145
|
+
# Opt-in (standalone segment+track only): unlock the full DB-free editing
|
|
146
|
+
# toolkit — spawn / erase / merge / swap / split (mouse + Delete), plus a
|
|
147
|
+
# greedy retracker on Q/E. The integrated app never passes this, so its
|
|
148
|
+
# contour-only cell corrector is byte-for-byte unchanged.
|
|
149
|
+
self._full_editing = full_editing
|
|
150
|
+
self._pos_dir_provider = pos_dir_provider
|
|
151
|
+
self._local_pos_dir: Path | None = None
|
|
152
|
+
self._files_widget_refresh = files_widget_refresh_callback or (lambda _pd: None)
|
|
153
|
+
self._labels_path_provider = labels_path_provider
|
|
154
|
+
self._cell_ref_path_provider = cell_ref_path_provider
|
|
155
|
+
self._nucleus_ref_path_provider = nucleus_ref_path_provider
|
|
156
|
+
self._active_labels_layer_provider = active_labels_layer_provider
|
|
157
|
+
self._intensity_frame_provider = intensity_frame_provider
|
|
158
|
+
self._active_bound_layer = None
|
|
159
|
+
self._retrack_keys_layer = None
|
|
160
|
+
self._correction_view_state: LayerViewState | None = None
|
|
161
|
+
self._correction_owned_layers: set[str] = set()
|
|
162
|
+
self._correction_dirty: bool = False
|
|
163
|
+
# DB-free validation store (full-editing/standalone only): there is no
|
|
164
|
+
# project directory to persist validated_cells.json into, so validation
|
|
165
|
+
# lives only for the session, keyed the same as the disk-backed store
|
|
166
|
+
# ({cell_id: {validated frames}}) so remove_unvalidated_labels and the
|
|
167
|
+
# lineage canvas need no adaptation.
|
|
168
|
+
self._validated_tracks: dict[int, set[int]] = {}
|
|
169
|
+
self._lineage_canvas: LineageCanvasController | None = None
|
|
170
|
+
self._setup_ui()
|
|
171
|
+
self._connect_signals()
|
|
172
|
+
# Active-layer mode has no on-disk target — saving is napari's job, so
|
|
173
|
+
# the in-widget disk-save button is hidden.
|
|
174
|
+
if self._active_labels_layer_provider is not None:
|
|
175
|
+
self.save_labels_btn.setVisible(False)
|
|
176
|
+
self._sync_active_btn_enabled()
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------ #
|
|
179
|
+
# pos_dir property — delegate to provider if available #
|
|
180
|
+
# ------------------------------------------------------------------ #
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def _pos_dir(self) -> Path | None:
|
|
184
|
+
if self._pos_dir_provider is not None:
|
|
185
|
+
return self._pos_dir_provider()
|
|
186
|
+
return self._local_pos_dir
|
|
187
|
+
|
|
188
|
+
@_pos_dir.setter
|
|
189
|
+
def _pos_dir(self, value: Path | None) -> None:
|
|
190
|
+
self._local_pos_dir = value
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------ #
|
|
193
|
+
# UI construction #
|
|
194
|
+
# ------------------------------------------------------------------ #
|
|
195
|
+
|
|
196
|
+
def _setup_ui(self) -> None:
|
|
197
|
+
root = QVBoxLayout(self)
|
|
198
|
+
root.setContentsMargins(0, 0, 0, 0)
|
|
199
|
+
root.setSpacing(0)
|
|
200
|
+
|
|
201
|
+
inner = QWidget(self)
|
|
202
|
+
group_lay = QVBoxLayout(inner)
|
|
203
|
+
group_lay.setContentsMargins(0, 0, 0, 0)
|
|
204
|
+
group_lay.setSpacing(6)
|
|
205
|
+
|
|
206
|
+
self.active_btn = _tool_btn(
|
|
207
|
+
"⏻",
|
|
208
|
+
"Activate correction mode and show correction controls.",
|
|
209
|
+
checkable=True,
|
|
210
|
+
)
|
|
211
|
+
self.active_btn.setToolTip(
|
|
212
|
+
"Activate correction mode and show correction controls."
|
|
213
|
+
)
|
|
214
|
+
self.params_btn = _tool_btn(
|
|
215
|
+
"⚙", "Show correction parameters.", checkable=True
|
|
216
|
+
)
|
|
217
|
+
self.shortcuts_btn = _tool_btn(
|
|
218
|
+
"📖", "Show correction shortcuts.", checkable=True
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# ── Action toolbar buttons (icon tool-buttons in the body) ────
|
|
222
|
+
# Mirror the nucleus correction widget: the main actions read as a thin
|
|
223
|
+
# vertical icon toolbar beside the editing surface (plain enlarged
|
|
224
|
+
# glyphs, no stage pill) rather than a stack of text buttons.
|
|
225
|
+
# ``outline_btn`` is the visible driver for the embedded correction
|
|
226
|
+
# widget's (now hidden) "Show outlines only" checkbox; the rest are
|
|
227
|
+
# momentary actions.
|
|
228
|
+
self.outline_btn = _tool_btn(
|
|
229
|
+
"◻",
|
|
230
|
+
"Show outlines only — draw labels as contours so the reference "
|
|
231
|
+
"images stay visible underneath.",
|
|
232
|
+
checkable=True,
|
|
233
|
+
)
|
|
234
|
+
self.save_labels_btn = _tool_btn(
|
|
235
|
+
"💾", "Save tracked cell labels to disk."
|
|
236
|
+
)
|
|
237
|
+
self.fill_holes_btn = _tool_btn(
|
|
238
|
+
"🪣",
|
|
239
|
+
"Fill background holes fully enclosed within individual labels.",
|
|
240
|
+
)
|
|
241
|
+
self.cleanup_btn = _tool_btn(
|
|
242
|
+
"🧹",
|
|
243
|
+
"Remove disconnected same-label fragments (honours the Scope selector).",
|
|
244
|
+
)
|
|
245
|
+
# Retracker (standalone full-editing only): re-link every later frame to
|
|
246
|
+
# the current one by greedy geometric similarity (area + centroid IoU -
|
|
247
|
+
# distance). DB-free — there is no validation/locking, just re-linking.
|
|
248
|
+
if self._full_editing:
|
|
249
|
+
self.retrack_back_btn = _tool_btn(
|
|
250
|
+
"↶", "Retrack labels backward from the current frame (Q)."
|
|
251
|
+
)
|
|
252
|
+
self.retrack_fwd_btn = _tool_btn(
|
|
253
|
+
"↷", "Retrack labels forward from the current frame (E)."
|
|
254
|
+
)
|
|
255
|
+
# Validation is DB-free here too: it just flags cell/frame pairs in
|
|
256
|
+
# memory for this session (no on-disk project to persist into).
|
|
257
|
+
self.validate_btn = _tool_btn(
|
|
258
|
+
"✓",
|
|
259
|
+
"Toggle validation for the selected cell across its frames (V).",
|
|
260
|
+
)
|
|
261
|
+
self.remove_unvalidated_btn = _tool_btn(
|
|
262
|
+
"🗑",
|
|
263
|
+
"Remove cell label pixels not marked validated for their frame.",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# ── Scope selector (always visible in the active panel) ───────
|
|
267
|
+
# Scope drives Fill Holes / Remove Stranded Fragments, so it lives
|
|
268
|
+
# inline rather than behind the ⚙ params reveal.
|
|
269
|
+
self.scope_row = QWidget(self)
|
|
270
|
+
scope_row = QHBoxLayout(self.scope_row)
|
|
271
|
+
scope_row.setContentsMargins(0, 0, 0, 0)
|
|
272
|
+
scope_lbl = QLabel("Scope:")
|
|
273
|
+
scope_lbl.setToolTip(
|
|
274
|
+
"Applies to Fill Holes and Remove Stranded Fragments."
|
|
275
|
+
)
|
|
276
|
+
scope_row.addWidget(scope_lbl)
|
|
277
|
+
self.correction_scope_combo = QComboBox()
|
|
278
|
+
self.correction_scope_combo.addItems(["Current frame", "All frames"])
|
|
279
|
+
self.correction_scope_combo.setToolTip(
|
|
280
|
+
"Applies to Fill Holes and Remove Stranded Fragments."
|
|
281
|
+
)
|
|
282
|
+
scope_row.addWidget(self.correction_scope_combo)
|
|
283
|
+
scope_row.addStretch(1)
|
|
284
|
+
|
|
285
|
+
# ── Correction parameters (collapsible) ───────────────────────
|
|
286
|
+
params_inner = QWidget(self)
|
|
287
|
+
params_lay = QVBoxLayout(params_inner)
|
|
288
|
+
params_lay.setContentsMargins(0, 0, 0, 0)
|
|
289
|
+
params_lay.setSpacing(6)
|
|
290
|
+
|
|
291
|
+
g = block_grid(horizontal_spacing=12)
|
|
292
|
+
self.hole_radius_spin = _islider(
|
|
293
|
+
0, 999, 5,
|
|
294
|
+
tooltip="Max hole size (pixels) for the Fill Holes operation.",
|
|
295
|
+
)
|
|
296
|
+
add_block_pair_row(g, 0, "Hole radius:", self.hole_radius_spin)
|
|
297
|
+
if self._full_editing:
|
|
298
|
+
# Cell radius backs middle-click spawning; max dist gates retrack
|
|
299
|
+
# matches. The spawn spinbox lives inside the inner widget (created
|
|
300
|
+
# below) and is relocated here once it exists.
|
|
301
|
+
self.retrack_max_dist_spin = _dslider(
|
|
302
|
+
0, 500, 50.0, 1.0, 1,
|
|
303
|
+
tooltip="Max centroid distance (px) for a retrack (Q/E) match.",
|
|
304
|
+
)
|
|
305
|
+
add_block_pair_row(
|
|
306
|
+
g, 1, "Retrack max dist:", self.retrack_max_dist_spin
|
|
307
|
+
)
|
|
308
|
+
params_lay.addLayout(g)
|
|
309
|
+
|
|
310
|
+
self.correction_params_section = CollapsibleSection(
|
|
311
|
+
"Correction Parameters",
|
|
312
|
+
params_inner,
|
|
313
|
+
expanded=False,
|
|
314
|
+
|
|
315
|
+
)
|
|
316
|
+
self.correction_params_section.set_header_visible(False)
|
|
317
|
+
self.correction_params_section.setVisible(False)
|
|
318
|
+
group_lay.addWidget(self.correction_params_section)
|
|
319
|
+
|
|
320
|
+
# ── Inline CorrectionWidget ───────────────────────────────────
|
|
321
|
+
# App default: cell labels are tied to the nuclei, so restrict to contour
|
|
322
|
+
# edits only (select + Shift-left extend + Shift-right carve), border
|
|
323
|
+
# highlight, no spawn-radius control.
|
|
324
|
+
# ``full_editing`` (standalone segment+track only) drops ``contour_only``
|
|
325
|
+
# so the whole DB-free toolkit is live — spawn / erase / merge / swap /
|
|
326
|
+
# split (mouse + Delete) — and the shortcuts panel auto-lists them.
|
|
327
|
+
self.correction_widget = CorrectionWidget(
|
|
328
|
+
self.viewer,
|
|
329
|
+
show_activate_btn=False,
|
|
330
|
+
show_shortcuts=False,
|
|
331
|
+
inspector_first=True,
|
|
332
|
+
show_cleanup=False,
|
|
333
|
+
show_spawn_controls=False,
|
|
334
|
+
contour_only=not self._full_editing,
|
|
335
|
+
highlight_style="border",
|
|
336
|
+
)
|
|
337
|
+
# Inline contour edits (extend / carve / split) flow through the inner
|
|
338
|
+
# widget's own status label, so they never hit ``_correction_status`` and
|
|
339
|
+
# its "Unsaved" dirty-tracking. Mark the panel dirty on every edit so the
|
|
340
|
+
# exit confirm fires, just like the nucleus correction widget.
|
|
341
|
+
self.correction_widget.set_edit_callback(self._on_correction_edit)
|
|
342
|
+
if self._intensity_frame_provider is not None:
|
|
343
|
+
self.correction_widget.set_intensity_frame_callback(
|
|
344
|
+
self._intensity_frame_provider
|
|
345
|
+
)
|
|
346
|
+
# The outline view is driven from the toolbar ``outline_btn`` toggle now,
|
|
347
|
+
# so hide the embedded checkbox and keep the two in sync.
|
|
348
|
+
self.correction_widget._outline_btn.setVisible(False)
|
|
349
|
+
if self._full_editing:
|
|
350
|
+
# Relocate the inner widget's spawn-radius spinbox into our params
|
|
351
|
+
# panel (it is created but unparented to a layout when
|
|
352
|
+
# show_spawn_controls=False), so middle-click spawn size is tunable.
|
|
353
|
+
cell_radius_grid = block_grid(horizontal_spacing=12)
|
|
354
|
+
add_block_pair_row(
|
|
355
|
+
cell_radius_grid, 0, "Cell radius:",
|
|
356
|
+
self.correction_widget._cell_radius_spin,
|
|
357
|
+
)
|
|
358
|
+
params_lay.addLayout(cell_radius_grid)
|
|
359
|
+
|
|
360
|
+
# Thin vertical icon toolbar (nucleus styling: enlarged glyphs, ruled
|
|
361
|
+
# groups, no pill) shown to the left of the editing surface.
|
|
362
|
+
toolbar_groups = [
|
|
363
|
+
(self.outline_btn,),
|
|
364
|
+
(self.save_labels_btn,),
|
|
365
|
+
(self.fill_holes_btn, self.cleanup_btn),
|
|
366
|
+
]
|
|
367
|
+
if self._full_editing:
|
|
368
|
+
toolbar_groups.append((self.retrack_back_btn, self.retrack_fwd_btn))
|
|
369
|
+
toolbar_groups.append((self.validate_btn, self.remove_unvalidated_btn))
|
|
370
|
+
# The host workflow widget's Finalize button rides at the tail of the
|
|
371
|
+
# toolbar (its own ruled group) — finalizing is the natural last step
|
|
372
|
+
# once the labels are corrected.
|
|
373
|
+
if self._finalize_btn is not None:
|
|
374
|
+
toolbar_groups.append((self._finalize_btn,))
|
|
375
|
+
self.toolbar = build_correction_toolbar(self, toolbar_groups)
|
|
376
|
+
self.toolbar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
|
377
|
+
|
|
378
|
+
self.correction_shortcuts_section = CollapsibleSection(
|
|
379
|
+
"Correction Shortcuts",
|
|
380
|
+
self.correction_widget.build_shortcuts_widget(),
|
|
381
|
+
expanded=False,
|
|
382
|
+
|
|
383
|
+
)
|
|
384
|
+
self.correction_shortcuts_section.set_header_visible(False)
|
|
385
|
+
self.correction_shortcuts_section.setVisible(False)
|
|
386
|
+
group_lay.addWidget(self.correction_shortcuts_section)
|
|
387
|
+
|
|
388
|
+
self.active_content = QWidget(self)
|
|
389
|
+
active_lay = QVBoxLayout(self.active_content)
|
|
390
|
+
active_lay.setContentsMargins(0, 0, 0, 0)
|
|
391
|
+
active_lay.setSpacing(6)
|
|
392
|
+
|
|
393
|
+
body_row = QHBoxLayout()
|
|
394
|
+
body_row.setContentsMargins(0, 0, 0, 0)
|
|
395
|
+
body_row.setSpacing(6)
|
|
396
|
+
body_row.addWidget(self.toolbar)
|
|
397
|
+
body_row.addWidget(self.correction_widget, stretch=1)
|
|
398
|
+
active_lay.addLayout(body_row)
|
|
399
|
+
|
|
400
|
+
self.correction_status_lbl = _make_status()
|
|
401
|
+
active_lay.addWidget(self.correction_status_lbl)
|
|
402
|
+
|
|
403
|
+
active_lay.addWidget(self.scope_row)
|
|
404
|
+
active_lay.addWidget(self.correction_widget._attrib_lbl)
|
|
405
|
+
|
|
406
|
+
# ── Track-list navigator (full-editing only) ──────────────────
|
|
407
|
+
# Reuses the nucleus workflow's swimlane accordion: it needs a plain
|
|
408
|
+
# (T, Y, X)-ish label stack, not an Ultrack DB, so it is directly
|
|
409
|
+
# portable. There is no intensity backdrop to crop thumbnails from in
|
|
410
|
+
# this standalone tool (the active layer is whatever the user bound),
|
|
411
|
+
# so the film-strip band stays empty; the per-track presence/validated
|
|
412
|
+
# bars and click-to-jump navigation still work fully.
|
|
413
|
+
if self._full_editing:
|
|
414
|
+
self._lineage_canvas = LineageCanvasController(
|
|
415
|
+
self.viewer,
|
|
416
|
+
tracked_data_provider=self._lineage_tracked_data,
|
|
417
|
+
tracked_layer_provider=self._correction_tracked_layer,
|
|
418
|
+
intensity_layer_provider=lambda: None,
|
|
419
|
+
selected_label_provider=lambda: int(
|
|
420
|
+
getattr(self.correction_widget, "_selected_label", 0) or 0
|
|
421
|
+
),
|
|
422
|
+
current_t_provider=self._current_t,
|
|
423
|
+
on_activate=self._navigate_to_cell_from_lineage,
|
|
424
|
+
validated_tracks_provider=lambda: self._validated_tracks,
|
|
425
|
+
)
|
|
426
|
+
panel = self._lineage_canvas.panel()
|
|
427
|
+
panel.setMinimumHeight(140)
|
|
428
|
+
active_lay.addWidget(panel, stretch=1)
|
|
429
|
+
|
|
430
|
+
self.active_content.setVisible(False)
|
|
431
|
+
group_lay.addWidget(self.active_content)
|
|
432
|
+
|
|
433
|
+
self.header = self._build_correction_header()
|
|
434
|
+
|
|
435
|
+
self.section = CollapsibleSection(
|
|
436
|
+
"Correction",
|
|
437
|
+
inner,
|
|
438
|
+
expanded=False,
|
|
439
|
+
|
|
440
|
+
)
|
|
441
|
+
self.section.set_header_visible(False)
|
|
442
|
+
self.section._toggle.setEnabled(False)
|
|
443
|
+
root.addWidget(self.header)
|
|
444
|
+
root.addWidget(self.section)
|
|
445
|
+
|
|
446
|
+
self.header_lbl = self.correction_header_lbl
|
|
447
|
+
self.correction_active_btn = self.active_btn
|
|
448
|
+
self.correction_params_btn = self.params_btn
|
|
449
|
+
self.correction_shortcuts_btn = self.shortcuts_btn
|
|
450
|
+
self.correction_mode_section = self.section
|
|
451
|
+
|
|
452
|
+
def _build_correction_header(self) -> QWidget:
|
|
453
|
+
header = QWidget(self)
|
|
454
|
+
row = QHBoxLayout(header)
|
|
455
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
456
|
+
row.setSpacing(4)
|
|
457
|
+
|
|
458
|
+
self.correction_header_lbl = QLabel("Correction")
|
|
459
|
+
stage_header_label(self.correction_header_lbl, "cell")
|
|
460
|
+
header_btns = [self.shortcuts_btn, self.params_btn, self.active_btn]
|
|
461
|
+
# Finalize sits right beside the on/off toggle so the labels can be
|
|
462
|
+
# promoted without entering correction mode.
|
|
463
|
+
if self._finalize_header_btn is not None:
|
|
464
|
+
header_btns.append(self._finalize_header_btn)
|
|
465
|
+
for button in header_btns:
|
|
466
|
+
stage_header_action_button(button, "cell")
|
|
467
|
+
row.addWidget(self.correction_header_lbl)
|
|
468
|
+
row.addWidget(self.shortcuts_btn)
|
|
469
|
+
row.addWidget(self.params_btn)
|
|
470
|
+
row.addWidget(self.active_btn)
|
|
471
|
+
if self._finalize_header_btn is not None:
|
|
472
|
+
row.addWidget(self._finalize_header_btn)
|
|
473
|
+
row.addStretch(1)
|
|
474
|
+
return header
|
|
475
|
+
|
|
476
|
+
# ------------------------------------------------------------------ #
|
|
477
|
+
# Signal wiring #
|
|
478
|
+
# ------------------------------------------------------------------ #
|
|
479
|
+
|
|
480
|
+
def _connect_signals(self) -> None:
|
|
481
|
+
self.save_labels_btn.clicked.connect(self._on_save_labels)
|
|
482
|
+
self.fill_holes_btn.clicked.connect(self._on_fill_holes)
|
|
483
|
+
self.cleanup_btn.clicked.connect(self._on_cleanup)
|
|
484
|
+
self.outline_btn.toggled.connect(self._on_outline_toggled)
|
|
485
|
+
self.correction_widget._outline_btn.toggled.connect(
|
|
486
|
+
self._on_embedded_outline_toggled
|
|
487
|
+
)
|
|
488
|
+
self.active_btn.toggled.connect(self._on_active_button_toggled)
|
|
489
|
+
self.params_btn.toggled.connect(self._on_params_button_toggled)
|
|
490
|
+
self.shortcuts_btn.toggled.connect(self._on_shortcuts_button_toggled)
|
|
491
|
+
if self._full_editing:
|
|
492
|
+
self.retrack_back_btn.clicked.connect(self._on_retrack_backward)
|
|
493
|
+
self.retrack_fwd_btn.clicked.connect(self._on_retrack_forward)
|
|
494
|
+
self.validate_btn.clicked.connect(self._on_toggle_validation)
|
|
495
|
+
self.remove_unvalidated_btn.clicked.connect(
|
|
496
|
+
self._on_remove_unvalidated_labels
|
|
497
|
+
)
|
|
498
|
+
self.correction_widget.add_selection_listener(
|
|
499
|
+
self._on_track_selection_changed
|
|
500
|
+
)
|
|
501
|
+
self.viewer.dims.events.current_step.connect(
|
|
502
|
+
self._on_dims_changed_for_lineage
|
|
503
|
+
)
|
|
504
|
+
if self._active_layer_mode():
|
|
505
|
+
# Standalone tools have no project directory to poll — readiness
|
|
506
|
+
# here is "is the viewer's active layer a Labels layer", which
|
|
507
|
+
# only changes on a selection change.
|
|
508
|
+
active_events = getattr(
|
|
509
|
+
getattr(getattr(self.viewer.layers, "selection", None), "events", None),
|
|
510
|
+
"active", None,
|
|
511
|
+
)
|
|
512
|
+
if active_events is not None:
|
|
513
|
+
active_events.connect(lambda *_a, **_k: self._sync_active_btn_enabled())
|
|
514
|
+
self.active_btn.toggled.connect(lambda _c: self._sync_active_btn_enabled())
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _set_checked_without_signal(button, checked: bool) -> None:
|
|
518
|
+
set_checked_without_signal(button, checked)
|
|
519
|
+
|
|
520
|
+
def _sync_correction_panel_visibility(self) -> None:
|
|
521
|
+
show_params = self.params_btn.isChecked()
|
|
522
|
+
show_shortcuts = self.shortcuts_btn.isChecked()
|
|
523
|
+
show_active = self.active_btn.isChecked()
|
|
524
|
+
|
|
525
|
+
self.correction_params_section.setVisible(show_params)
|
|
526
|
+
self.correction_shortcuts_section.setVisible(show_shortcuts)
|
|
527
|
+
self.active_content.setVisible(show_active)
|
|
528
|
+
|
|
529
|
+
if show_params or show_shortcuts or show_active:
|
|
530
|
+
self.section.expand()
|
|
531
|
+
else:
|
|
532
|
+
self.section.collapse()
|
|
533
|
+
|
|
534
|
+
def _active_layer_mode(self) -> bool:
|
|
535
|
+
"""True when bound to the viewer's active Labels layer (standalone)."""
|
|
536
|
+
return self._active_labels_layer_provider is not None
|
|
537
|
+
|
|
538
|
+
def _correction_data_available(self) -> bool:
|
|
539
|
+
"""True when there is a cell-label stack to correct.
|
|
540
|
+
|
|
541
|
+
Active-layer mode: the provider yields a Labels layer (None otherwise).
|
|
542
|
+
Disk mode: a tracked cell-label file exists on disk.
|
|
543
|
+
"""
|
|
544
|
+
if self._active_layer_mode():
|
|
545
|
+
return self._active_labels_layer_provider() is not None
|
|
546
|
+
lp = self._cell_labels_path()
|
|
547
|
+
return lp is not None and lp.exists()
|
|
548
|
+
|
|
549
|
+
def _sync_active_btn_enabled(self) -> None:
|
|
550
|
+
"""Locally gate the activate button in active-layer mode.
|
|
551
|
+
|
|
552
|
+
Not routed through the parent widget's ``UiGate`` — this widget is
|
|
553
|
+
reused in disk-mode contexts (the app) where this precondition doesn't
|
|
554
|
+
apply. Stays enabled while already checked so an active correction
|
|
555
|
+
session can always be turned back off even if the viewer's active-layer
|
|
556
|
+
selection has since moved off the bound Labels layer.
|
|
557
|
+
"""
|
|
558
|
+
if not self._active_layer_mode():
|
|
559
|
+
return
|
|
560
|
+
available = self._correction_data_available() or self.active_btn.isChecked()
|
|
561
|
+
self.active_btn.setEnabled(available)
|
|
562
|
+
self.active_btn.setToolTip(
|
|
563
|
+
"Activate correction mode and show correction controls."
|
|
564
|
+
if available
|
|
565
|
+
else "Select a Labels layer to correct — the active layer is not one."
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def _toggle_active_layer_correction(self, active: bool) -> None:
|
|
569
|
+
"""Bind/unbind the corrector to the viewer's active Labels layer.
|
|
570
|
+
|
|
571
|
+
Standalone mode: there is no on-disk label file — the corrector edits the
|
|
572
|
+
currently-active napari Labels layer in place and the user saves it via
|
|
573
|
+
napari. Activating does not capture/hide other layers or load backdrops;
|
|
574
|
+
deactivating loses nothing (edits already live in the layer), so there is
|
|
575
|
+
no unsaved-changes prompt.
|
|
576
|
+
"""
|
|
577
|
+
if active:
|
|
578
|
+
layer = self._active_labels_layer_provider()
|
|
579
|
+
if layer is None:
|
|
580
|
+
self._correction_status(
|
|
581
|
+
"Select a Labels layer to correct — the active layer is not one."
|
|
582
|
+
)
|
|
583
|
+
self._set_checked_without_signal(self.active_btn, False)
|
|
584
|
+
self._sync_correction_panel_visibility()
|
|
585
|
+
return
|
|
586
|
+
self._active_bound_layer = layer
|
|
587
|
+
self.viewer.layers.selection.active = layer
|
|
588
|
+
self.correction_widget.activate_layer(layer)
|
|
589
|
+
self._bind_full_editing_keys(layer)
|
|
590
|
+
if self._full_editing:
|
|
591
|
+
# A fresh binding starts a fresh validation session — stale
|
|
592
|
+
# entries from a previous layer/segment run would otherwise
|
|
593
|
+
# collide with unrelated cell ids by coincidence.
|
|
594
|
+
self._validated_tracks = {}
|
|
595
|
+
self._lineage_canvas.refresh()
|
|
596
|
+
self._refresh_validated_overlay()
|
|
597
|
+
shape = tuple(np.asarray(layer.data).shape)
|
|
598
|
+
self._correction_status(f"Correcting '{layer.name}' {shape}.")
|
|
599
|
+
self._sync_correction_panel_visibility()
|
|
600
|
+
return
|
|
601
|
+
self._set_checked_without_signal(self.active_btn, False)
|
|
602
|
+
self._unbind_full_editing_keys()
|
|
603
|
+
self.correction_widget.deactivate()
|
|
604
|
+
self._active_bound_layer = None
|
|
605
|
+
self._correction_dirty = False
|
|
606
|
+
self._sync_correction_panel_visibility()
|
|
607
|
+
|
|
608
|
+
def _on_active_button_toggled(self, active: bool) -> None:
|
|
609
|
+
if self._active_layer_mode():
|
|
610
|
+
self._toggle_active_layer_correction(active)
|
|
611
|
+
return
|
|
612
|
+
if active:
|
|
613
|
+
if not self._correction_data_available():
|
|
614
|
+
self._correction_status("No cell labels available to correct.")
|
|
615
|
+
self._set_checked_without_signal(self.active_btn, False)
|
|
616
|
+
self._sync_correction_panel_visibility()
|
|
617
|
+
return
|
|
618
|
+
self._capture_correction_view_state()
|
|
619
|
+
hide_all_layers(self.viewer.layers)
|
|
620
|
+
|
|
621
|
+
if not self._load_correction_layers_from_disk():
|
|
622
|
+
self._restore_correction_view_state()
|
|
623
|
+
self._set_checked_without_signal(self.active_btn, False)
|
|
624
|
+
self._sync_correction_panel_visibility()
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
layer = self.viewer.layers[_TRACKED_CELL_LAYER]
|
|
628
|
+
layer.visible = True
|
|
629
|
+
for name in (_CELL_ZAVG_LAYER, _NUC_ZAVG_LAYER):
|
|
630
|
+
if name in self.viewer.layers:
|
|
631
|
+
self.viewer.layers[name].visible = True
|
|
632
|
+
self.viewer.layers.selection.active = layer
|
|
633
|
+
self.correction_widget.activate_layer(layer)
|
|
634
|
+
self._sync_correction_panel_visibility()
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
if not self._confirm_deactivate_with_unsaved_changes():
|
|
638
|
+
self._set_checked_without_signal(self.active_btn, True)
|
|
639
|
+
self._sync_correction_panel_visibility()
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
self._set_checked_without_signal(self.active_btn, False)
|
|
643
|
+
self.correction_widget.deactivate()
|
|
644
|
+
self._refresh_tracked_layer_from_disk()
|
|
645
|
+
self._remove_correction_owned_layers()
|
|
646
|
+
self._restore_correction_view_state()
|
|
647
|
+
self._sync_correction_panel_visibility()
|
|
648
|
+
|
|
649
|
+
def _on_correction_edit(self, t: int, changed_ids: set[int]) -> None:
|
|
650
|
+
"""An inline contour edit leaves the cell labels unsaved."""
|
|
651
|
+
self._correction_dirty = True
|
|
652
|
+
|
|
653
|
+
def _on_outline_toggled(self, checked: bool) -> None:
|
|
654
|
+
"""Drive the embedded widget's outline view from the toolbar toggle."""
|
|
655
|
+
self.correction_widget._outline_btn.setChecked(checked)
|
|
656
|
+
|
|
657
|
+
def _on_embedded_outline_toggled(self, checked: bool) -> None:
|
|
658
|
+
"""Mirror the embedded outline checkbox onto the toolbar toggle.
|
|
659
|
+
|
|
660
|
+
``activate_layer`` ticks the embedded checkbox on, so this keeps the
|
|
661
|
+
toolbar toggle in step without re-entering ``_on_outline_toggled``.
|
|
662
|
+
"""
|
|
663
|
+
self._set_checked_without_signal(self.outline_btn, checked)
|
|
664
|
+
|
|
665
|
+
def _confirm_deactivate_with_unsaved_changes(self) -> bool:
|
|
666
|
+
if not self._correction_dirty:
|
|
667
|
+
return True
|
|
668
|
+
action = confirm_unsaved_before_deactivate(self, save_noun="cell labels")
|
|
669
|
+
if action == "cancel":
|
|
670
|
+
return False
|
|
671
|
+
if action == "save":
|
|
672
|
+
self._on_save_labels()
|
|
673
|
+
self._correction_dirty = False
|
|
674
|
+
return True
|
|
675
|
+
|
|
676
|
+
def _correction_tracked_layer(self):
|
|
677
|
+
"""Return the layer being corrected, or None if absent.
|
|
678
|
+
|
|
679
|
+
Active-layer mode: the bound active Labels layer. Disk mode: the
|
|
680
|
+
``[Correction] Cell Labels`` layer loaded from disk.
|
|
681
|
+
"""
|
|
682
|
+
if self._active_layer_mode():
|
|
683
|
+
return self._active_bound_layer
|
|
684
|
+
if _TRACKED_CELL_LAYER in self.viewer.layers:
|
|
685
|
+
return self.viewer.layers[_TRACKED_CELL_LAYER]
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
def _load_correction_layers_from_disk(self) -> bool:
|
|
689
|
+
lp = self._cell_labels_path()
|
|
690
|
+
if lp is None or not lp.exists():
|
|
691
|
+
self._correction_status("No cell labels file found.")
|
|
692
|
+
return False
|
|
693
|
+
try:
|
|
694
|
+
labels = read_full_tracked_stack(lp)
|
|
695
|
+
except Exception as exc:
|
|
696
|
+
self._correction_status(f"Error reading cell labels: {exc}")
|
|
697
|
+
return False
|
|
698
|
+
czp, nzp = self._cell_foreground_path(), self._nuc_foreground_path()
|
|
699
|
+
cz = (
|
|
700
|
+
np.asarray(tifffile.imread(str(czp)), dtype=np.float32)
|
|
701
|
+
if czp and czp.exists() else None
|
|
702
|
+
)
|
|
703
|
+
nz = (
|
|
704
|
+
np.asarray(tifffile.imread(str(nzp)), dtype=np.float32)
|
|
705
|
+
if nzp and nzp.exists() else None
|
|
706
|
+
)
|
|
707
|
+
self._apply_loaded_layers(labels, cz, nz)
|
|
708
|
+
self._correction_status(f"Loaded cell label stack {labels.shape}.")
|
|
709
|
+
return True
|
|
710
|
+
|
|
711
|
+
def _refresh_tracked_layer_from_disk(self) -> None:
|
|
712
|
+
"""Reload the pipeline-side Tracked: Cell layer from disk on deactivate.
|
|
713
|
+
|
|
714
|
+
Mirrors NucleusCorrectionWidget so a subsequent re-solve picks up any
|
|
715
|
+
corrections saved during the session. The in-correction layer
|
|
716
|
+
(``[Correction] Cell Labels``) is torn down separately via
|
|
717
|
+
``_remove_correction_owned_layers``.
|
|
718
|
+
"""
|
|
719
|
+
lp = self._cell_labels_path()
|
|
720
|
+
if lp is None or not lp.exists():
|
|
721
|
+
return
|
|
722
|
+
if _PIPELINE_TRACKED_CELL_LAYER not in self.viewer.layers:
|
|
723
|
+
return
|
|
724
|
+
try:
|
|
725
|
+
data = np.asarray(read_full_tracked_stack(lp), dtype=np.uint32)
|
|
726
|
+
self.viewer.layers[_PIPELINE_TRACKED_CELL_LAYER].data = data
|
|
727
|
+
except Exception:
|
|
728
|
+
pass
|
|
729
|
+
|
|
730
|
+
def _on_params_button_toggled(self, checked: bool) -> None:
|
|
731
|
+
self.correction_params_section._toggle.setChecked(checked)
|
|
732
|
+
if checked:
|
|
733
|
+
self._set_checked_without_signal(self.shortcuts_btn, False)
|
|
734
|
+
self.correction_shortcuts_section._toggle.setChecked(False)
|
|
735
|
+
self._sync_correction_panel_visibility()
|
|
736
|
+
|
|
737
|
+
def _on_shortcuts_button_toggled(self, checked: bool) -> None:
|
|
738
|
+
self.correction_shortcuts_section._toggle.setChecked(checked)
|
|
739
|
+
if checked:
|
|
740
|
+
self._set_checked_without_signal(self.params_btn, False)
|
|
741
|
+
self.correction_params_section._toggle.setChecked(False)
|
|
742
|
+
self._sync_correction_panel_visibility()
|
|
743
|
+
|
|
744
|
+
# ------------------------------------------------------------------ #
|
|
745
|
+
# Status helper #
|
|
746
|
+
# ------------------------------------------------------------------ #
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ------------------------------------------------------------------ #
|
|
750
|
+
# Path helpers (delegate to _pos_dir) #
|
|
751
|
+
# ------------------------------------------------------------------ #
|
|
752
|
+
|
|
753
|
+
def _p(self, *parts: str) -> Path | None:
|
|
754
|
+
return self._pos_dir.joinpath(*parts) if self._pos_dir else None
|
|
755
|
+
|
|
756
|
+
def _cell_labels_path(self) -> Path | None:
|
|
757
|
+
if self._labels_path_provider is not None:
|
|
758
|
+
return self._labels_path_provider()
|
|
759
|
+
return self._p("3_cell", "tracked_labels.tif")
|
|
760
|
+
|
|
761
|
+
def _cell_foreground_path(self) -> Path | None:
|
|
762
|
+
if self._cell_ref_path_provider is not None:
|
|
763
|
+
return self._cell_ref_path_provider()
|
|
764
|
+
return self._p("1_cellpose", "cell_foreground.tif")
|
|
765
|
+
|
|
766
|
+
def _nuc_foreground_path(self) -> Path | None:
|
|
767
|
+
if self._nucleus_ref_path_provider is not None:
|
|
768
|
+
return self._nucleus_ref_path_provider()
|
|
769
|
+
return self._p("1_cellpose", "nucleus_foreground.tif")
|
|
770
|
+
|
|
771
|
+
# ------------------------------------------------------------------ #
|
|
772
|
+
# Frame helpers #
|
|
773
|
+
# ------------------------------------------------------------------ #
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _correction_frame_indices(self, layer) -> list[int]:
|
|
777
|
+
"""Return frame indices based on the correction scope combo."""
|
|
778
|
+
if layer.data.ndim < 3:
|
|
779
|
+
return [0]
|
|
780
|
+
if self.correction_scope_combo.currentText() == "All frames":
|
|
781
|
+
return list(range(int(layer.data.shape[0])))
|
|
782
|
+
return [self._current_t()]
|
|
783
|
+
|
|
784
|
+
# ------------------------------------------------------------------ #
|
|
785
|
+
# Load labels #
|
|
786
|
+
# ------------------------------------------------------------------ #
|
|
787
|
+
|
|
788
|
+
@staticmethod
|
|
789
|
+
def _broadcast_ref(image, shape):
|
|
790
|
+
if image is None:
|
|
791
|
+
return None
|
|
792
|
+
if image.ndim == 2 and len(shape) >= 3:
|
|
793
|
+
return np.broadcast_to(image[np.newaxis], (shape[0],) + image.shape).copy()
|
|
794
|
+
return image
|
|
795
|
+
|
|
796
|
+
def _show_layer(self, name, data, kwargs, adder):
|
|
797
|
+
if name in self.viewer.layers:
|
|
798
|
+
try:
|
|
799
|
+
self.viewer.layers[name].data = data
|
|
800
|
+
except Exception:
|
|
801
|
+
self.viewer.layers.remove(self.viewer.layers[name])
|
|
802
|
+
adder(data, name=name, **kwargs)
|
|
803
|
+
else:
|
|
804
|
+
adder(data, name=name, **kwargs)
|
|
805
|
+
|
|
806
|
+
def _apply_loaded_layers(self, labels, cz, nz) -> None:
|
|
807
|
+
# Add the reference images first so the labels layer ends up at the
|
|
808
|
+
# top of the layer stack (otherwise the z-avg images render over it).
|
|
809
|
+
for img, name, cmap in (
|
|
810
|
+
(self._broadcast_ref(cz, labels.shape), _CELL_ZAVG_LAYER, "gray"),
|
|
811
|
+
(self._broadcast_ref(nz, labels.shape), _NUC_ZAVG_LAYER, "I Purple"),
|
|
812
|
+
):
|
|
813
|
+
if img is None:
|
|
814
|
+
continue
|
|
815
|
+
if name in self.viewer.layers:
|
|
816
|
+
self.viewer.layers[name].data = img
|
|
817
|
+
self.viewer.layers[name].colormap = cmap
|
|
818
|
+
self.viewer.layers[name].blending = "minimum"
|
|
819
|
+
else:
|
|
820
|
+
self.viewer.add_image(img, name=name, colormap=cmap, blending="minimum")
|
|
821
|
+
self._correction_owned_layers.add(name)
|
|
822
|
+
self._show_layer(_TRACKED_CELL_LAYER, labels, {}, self.viewer.add_labels)
|
|
823
|
+
self._correction_owned_layers.add(_TRACKED_CELL_LAYER)
|
|
824
|
+
|
|
825
|
+
# ------------------------------------------------------------------ #
|
|
826
|
+
# Save labels #
|
|
827
|
+
# ------------------------------------------------------------------ #
|
|
828
|
+
|
|
829
|
+
def _on_save_labels(self) -> None:
|
|
830
|
+
if self._active_layer_mode():
|
|
831
|
+
self._correction_status(
|
|
832
|
+
"Save the layer via napari (File ▸ Save Selected Layers)."
|
|
833
|
+
)
|
|
834
|
+
return
|
|
835
|
+
lp = self._cell_labels_path()
|
|
836
|
+
if lp is None:
|
|
837
|
+
self._correction_status("No project open."); return
|
|
838
|
+
layer = self._correction_tracked_layer()
|
|
839
|
+
if layer is None:
|
|
840
|
+
self._correction_status("No labels layer."); return
|
|
841
|
+
data = np.asarray(layer.data)
|
|
842
|
+
if data.ndim != 3:
|
|
843
|
+
self._correction_status("Labels not 3D."); return
|
|
844
|
+
lp.parent.mkdir(parents=True, exist_ok=True)
|
|
845
|
+
imwrite_grayscale(lp, data.astype(np.uint32, copy=False), compression="zlib")
|
|
846
|
+
self._files_widget_refresh(self._pos_dir)
|
|
847
|
+
self._correction_dirty = False
|
|
848
|
+
self._correction_status(f"Saved {data.shape[0]} frames → {lp.name}.")
|
|
849
|
+
|
|
850
|
+
# ------------------------------------------------------------------ #
|
|
851
|
+
# Fill Holes #
|
|
852
|
+
# ------------------------------------------------------------------ #
|
|
853
|
+
|
|
854
|
+
def _on_fill_holes(self) -> None:
|
|
855
|
+
layer = self._correction_tracked_layer()
|
|
856
|
+
if layer is None:
|
|
857
|
+
self._correction_status("No cell labels loaded."); return
|
|
858
|
+
radius = int(self.hole_radius_spin.value())
|
|
859
|
+
frames = self._correction_frame_indices(layer)
|
|
860
|
+
|
|
861
|
+
changed_frames = 0
|
|
862
|
+
changed_pixels = 0
|
|
863
|
+
for t in frames:
|
|
864
|
+
seg2d = self.correction_widget._frame_view(layer, t)
|
|
865
|
+
before = seg2d.copy()
|
|
866
|
+
result = fill_label_holes(seg2d, radius=radius)
|
|
867
|
+
np.copyto(seg2d, result)
|
|
868
|
+
diff = int(np.sum(before != seg2d))
|
|
869
|
+
if diff:
|
|
870
|
+
changed_frames += 1
|
|
871
|
+
changed_pixels += diff
|
|
872
|
+
self.correction_widget._record_history(layer, t, before)
|
|
873
|
+
|
|
874
|
+
if changed_pixels:
|
|
875
|
+
layer.refresh()
|
|
876
|
+
if self.correction_widget._selected_label:
|
|
877
|
+
t_now = self._current_t()
|
|
878
|
+
self.correction_widget._update_highlight(
|
|
879
|
+
t_now, self.correction_widget._selected_label,
|
|
880
|
+
)
|
|
881
|
+
self._correction_status(
|
|
882
|
+
f"Filled holes in {changed_frames} frame(s), "
|
|
883
|
+
f"{changed_pixels} px changed. Unsaved."
|
|
884
|
+
)
|
|
885
|
+
else:
|
|
886
|
+
self._correction_status("No interior holes found.")
|
|
887
|
+
|
|
888
|
+
# ------------------------------------------------------------------ #
|
|
889
|
+
# Remove Stranded Fragments #
|
|
890
|
+
# ------------------------------------------------------------------ #
|
|
891
|
+
|
|
892
|
+
def _on_cleanup(self) -> None:
|
|
893
|
+
"""Drop disconnected same-label fragments on the scoped frame(s).
|
|
894
|
+
|
|
895
|
+
Cells stay tied to the nuclei, so this only removes stray pixels that
|
|
896
|
+
broke off a label (e.g. after a carve); it never renumbers or resyncs.
|
|
897
|
+
Per-frame so each change is undoable via the layer history.
|
|
898
|
+
"""
|
|
899
|
+
layer = self._correction_tracked_layer()
|
|
900
|
+
if layer is None:
|
|
901
|
+
self._correction_status("No cell labels loaded."); return
|
|
902
|
+
frames = self._correction_frame_indices(layer)
|
|
903
|
+
|
|
904
|
+
changed_frames = 0
|
|
905
|
+
changed_pixels = 0
|
|
906
|
+
for t in frames:
|
|
907
|
+
seg2d = self.correction_widget._frame_view(layer, t)
|
|
908
|
+
before = seg2d.copy()
|
|
909
|
+
clean_stranded_pixels(seg2d) # mutates seg2d in place
|
|
910
|
+
diff = int(np.sum(before != seg2d))
|
|
911
|
+
if diff:
|
|
912
|
+
changed_frames += 1
|
|
913
|
+
changed_pixels += diff
|
|
914
|
+
self.correction_widget._record_history(layer, t, before)
|
|
915
|
+
|
|
916
|
+
if changed_pixels:
|
|
917
|
+
layer.refresh()
|
|
918
|
+
if self.correction_widget._selected_label:
|
|
919
|
+
self.correction_widget._update_highlight(
|
|
920
|
+
self._current_t(), self.correction_widget._selected_label,
|
|
921
|
+
)
|
|
922
|
+
self._correction_status(
|
|
923
|
+
f"Removed stranded fragments in {changed_frames} frame(s), "
|
|
924
|
+
f"{changed_pixels} px changed. Unsaved."
|
|
925
|
+
)
|
|
926
|
+
else:
|
|
927
|
+
self._correction_status("No stranded fragments found.")
|
|
928
|
+
|
|
929
|
+
# ------------------------------------------------------------------ #
|
|
930
|
+
# Retracker (standalone full-editing only) — DB-free #
|
|
931
|
+
# ------------------------------------------------------------------ #
|
|
932
|
+
|
|
933
|
+
def _bind_full_editing_keys(self, layer) -> None:
|
|
934
|
+
"""Bind the Q/E retrack + V validation hotkeys (full-editing only).
|
|
935
|
+
|
|
936
|
+
Layer-level keymap entries take precedence over napari's defaults, so
|
|
937
|
+
these reliably drive the DB-free toolkit while correction is active.
|
|
938
|
+
The inner CorrectionWidget already binds Delete (erase) when not
|
|
939
|
+
contour-only.
|
|
940
|
+
"""
|
|
941
|
+
if not self._full_editing:
|
|
942
|
+
return
|
|
943
|
+
self._unbind_full_editing_keys()
|
|
944
|
+
for key, slot in (
|
|
945
|
+
("Q", self._on_retrack_backward),
|
|
946
|
+
("E", self._on_retrack_forward),
|
|
947
|
+
("V", self._on_toggle_validation),
|
|
948
|
+
):
|
|
949
|
+
def handler(_layer, _slot=slot):
|
|
950
|
+
_slot()
|
|
951
|
+
|
|
952
|
+
layer.bind_key(key, handler, overwrite=True)
|
|
953
|
+
self._retrack_keys_layer = layer
|
|
954
|
+
|
|
955
|
+
def _unbind_full_editing_keys(self) -> None:
|
|
956
|
+
layer = self._retrack_keys_layer
|
|
957
|
+
if layer is None:
|
|
958
|
+
return
|
|
959
|
+
for key in ("Q", "E", "V"):
|
|
960
|
+
try:
|
|
961
|
+
layer.bind_key(key, None)
|
|
962
|
+
except Exception:
|
|
963
|
+
pass
|
|
964
|
+
self._retrack_keys_layer = None
|
|
965
|
+
|
|
966
|
+
def _on_retrack_forward(self) -> None:
|
|
967
|
+
self._on_retrack("forward")
|
|
968
|
+
|
|
969
|
+
def _on_retrack_backward(self) -> None:
|
|
970
|
+
self._on_retrack("backward")
|
|
971
|
+
|
|
972
|
+
def _on_retrack(self, direction: str) -> None:
|
|
973
|
+
"""Re-link the tracked stack from the current frame outward (DB-free).
|
|
974
|
+
|
|
975
|
+
Greedy geometric similarity to the already-retracked neighbour toward the
|
|
976
|
+
current frame; no validation/locking, so it is the pure standalone case
|
|
977
|
+
of the nucleus widget's Q/E retracker.
|
|
978
|
+
"""
|
|
979
|
+
from itasc.cellpose.retrack import retrack_stack
|
|
980
|
+
|
|
981
|
+
layer = self._correction_tracked_layer()
|
|
982
|
+
if layer is None:
|
|
983
|
+
self._correction_status("No labels layer to retrack."); return
|
|
984
|
+
data = np.asarray(layer.data)
|
|
985
|
+
if data.ndim != 3 or data.shape[0] < 2:
|
|
986
|
+
self._correction_status(
|
|
987
|
+
"Retrack needs a 3D time-first stack (>= 2 frames)."
|
|
988
|
+
)
|
|
989
|
+
return
|
|
990
|
+
t0 = self._current_t()
|
|
991
|
+
if direction == "forward" and t0 >= data.shape[0] - 1:
|
|
992
|
+
self._correction_status("Already at the last frame."); return
|
|
993
|
+
if direction == "backward" and t0 <= 0:
|
|
994
|
+
self._correction_status("Already at the first frame."); return
|
|
995
|
+
try:
|
|
996
|
+
out = retrack_stack(
|
|
997
|
+
data,
|
|
998
|
+
start_frame=t0,
|
|
999
|
+
direction=direction,
|
|
1000
|
+
max_dist_px=float(self.retrack_max_dist_spin.value()),
|
|
1001
|
+
)
|
|
1002
|
+
except ValueError as exc:
|
|
1003
|
+
self._correction_status(str(exc)); return
|
|
1004
|
+
layer.data = out
|
|
1005
|
+
layer.refresh()
|
|
1006
|
+
self._correction_dirty = True
|
|
1007
|
+
if self.correction_widget._selected_label:
|
|
1008
|
+
self.correction_widget._update_highlight(
|
|
1009
|
+
t0, self.correction_widget._selected_label,
|
|
1010
|
+
)
|
|
1011
|
+
self._lineage_canvas.refresh()
|
|
1012
|
+
self._refresh_validated_overlay()
|
|
1013
|
+
self._correction_status(f"Retracked {direction} from t={t0}. Unsaved.")
|
|
1014
|
+
|
|
1015
|
+
# ------------------------------------------------------------------ #
|
|
1016
|
+
# Validation + track-list navigator (standalone full-editing only) #
|
|
1017
|
+
# ------------------------------------------------------------------ #
|
|
1018
|
+
|
|
1019
|
+
def _lineage_tracked_data(self) -> np.ndarray | None:
|
|
1020
|
+
layer = self._correction_tracked_layer()
|
|
1021
|
+
return np.asarray(layer.data) if layer is not None else None
|
|
1022
|
+
|
|
1023
|
+
def _current_cell_ids(self, t: int) -> set[int]:
|
|
1024
|
+
layer = self._correction_tracked_layer()
|
|
1025
|
+
if layer is None:
|
|
1026
|
+
return set()
|
|
1027
|
+
frame = frame_view_2d(np.asarray(layer.data), t)
|
|
1028
|
+
if frame is None:
|
|
1029
|
+
return set()
|
|
1030
|
+
return set(int(v) for v in np.unique(frame)) - {0}
|
|
1031
|
+
|
|
1032
|
+
def _frames_with_cell(self, cell_id: int) -> list[int]:
|
|
1033
|
+
layer = self._correction_tracked_layer()
|
|
1034
|
+
if layer is None:
|
|
1035
|
+
return []
|
|
1036
|
+
data = np.asarray(layer.data)
|
|
1037
|
+
if data.ndim < 3:
|
|
1038
|
+
return [0] if int(cell_id) in np.unique(data) else []
|
|
1039
|
+
frames = []
|
|
1040
|
+
for t in range(data.shape[0]):
|
|
1041
|
+
frame = frame_view_2d(data, t)
|
|
1042
|
+
if frame is not None and int(cell_id) in frame:
|
|
1043
|
+
frames.append(t)
|
|
1044
|
+
return frames
|
|
1045
|
+
|
|
1046
|
+
def _deselect_if_selection_gone(self) -> None:
|
|
1047
|
+
"""Clear the selection when the selected cell no longer exists at the
|
|
1048
|
+
current frame (a relabel / removal may have dropped it)."""
|
|
1049
|
+
sel = self.correction_widget._selected_label
|
|
1050
|
+
if sel:
|
|
1051
|
+
ct = self._current_t()
|
|
1052
|
+
if sel not in self._current_cell_ids(ct):
|
|
1053
|
+
self.correction_widget.select_label(ct, 0)
|
|
1054
|
+
|
|
1055
|
+
def _on_toggle_validation(self) -> None:
|
|
1056
|
+
"""Toggle validation for the selected cell across all its frames (V).
|
|
1057
|
+
|
|
1058
|
+
DB-free: flips membership in the in-memory ``self._validated_tracks``
|
|
1059
|
+
store rather than writing ``validated_cells.json`` (there is no project
|
|
1060
|
+
directory in the standalone active-layer tool).
|
|
1061
|
+
"""
|
|
1062
|
+
layer = self._correction_tracked_layer()
|
|
1063
|
+
if layer is None:
|
|
1064
|
+
self._correction_status("No labels layer."); return
|
|
1065
|
+
sel = self.correction_widget._selected_label
|
|
1066
|
+
if not sel:
|
|
1067
|
+
self._correction_status(
|
|
1068
|
+
"Validation toggle: no cell selected (left-click first)."
|
|
1069
|
+
); return
|
|
1070
|
+
t = self._current_t()
|
|
1071
|
+
if sel not in self._current_cell_ids(t):
|
|
1072
|
+
self._correction_status(f"Cell {sel} not present at t={t}."); return
|
|
1073
|
+
frames = self._frames_with_cell(sel)
|
|
1074
|
+
if not frames:
|
|
1075
|
+
return
|
|
1076
|
+
if sel in self._validated_tracks:
|
|
1077
|
+
del self._validated_tracks[sel]
|
|
1078
|
+
self._correction_status(
|
|
1079
|
+
f"Cell {sel} invalidated across {len(frames)} frame(s)."
|
|
1080
|
+
)
|
|
1081
|
+
else:
|
|
1082
|
+
self._validated_tracks[sel] = set(frames)
|
|
1083
|
+
self._correction_status(
|
|
1084
|
+
f"Cell {sel} validated across {len(frames)} frame(s)."
|
|
1085
|
+
)
|
|
1086
|
+
# Flag-only change (no pixels touched) — recolour without rescanning.
|
|
1087
|
+
self._lineage_canvas.refresh_status()
|
|
1088
|
+
self._refresh_validated_overlay()
|
|
1089
|
+
|
|
1090
|
+
def _on_remove_unvalidated_labels(self) -> None:
|
|
1091
|
+
"""Zero out cell label pixels not validated for their frame."""
|
|
1092
|
+
layer = self._correction_tracked_layer()
|
|
1093
|
+
if layer is None:
|
|
1094
|
+
self._correction_status("No cell labels loaded."); return
|
|
1095
|
+
data = np.asarray(layer.data)
|
|
1096
|
+
# remove_unvalidated_labels expects a time-first stack of 2D frames;
|
|
1097
|
+
# squeeze singleton extra dims the same way _frame_view tolerates.
|
|
1098
|
+
squeezed = data
|
|
1099
|
+
while squeezed.ndim > 3:
|
|
1100
|
+
if squeezed.shape[1] != 1:
|
|
1101
|
+
self._correction_status(
|
|
1102
|
+
"Labels layer has a non-singleton extra dimension."
|
|
1103
|
+
)
|
|
1104
|
+
return
|
|
1105
|
+
squeezed = squeezed[:, 0]
|
|
1106
|
+
before = squeezed.copy()
|
|
1107
|
+
try:
|
|
1108
|
+
changed_frames, changed_pixels = remove_unvalidated_labels(
|
|
1109
|
+
squeezed, self._validated_tracks
|
|
1110
|
+
)
|
|
1111
|
+
except ValueError as exc:
|
|
1112
|
+
self._correction_status(str(exc)); return
|
|
1113
|
+
if not changed_pixels:
|
|
1114
|
+
self._correction_status("No unvalidated labels found."); return
|
|
1115
|
+
frame_range = range(squeezed.shape[0]) if squeezed.ndim >= 3 else [0]
|
|
1116
|
+
for t in frame_range:
|
|
1117
|
+
frame_before = before[t] if before.ndim >= 3 else before
|
|
1118
|
+
frame_after = squeezed[t] if squeezed.ndim >= 3 else squeezed
|
|
1119
|
+
if not np.array_equal(frame_before, frame_after):
|
|
1120
|
+
self.correction_widget._record_history(layer, t, frame_before)
|
|
1121
|
+
layer.refresh()
|
|
1122
|
+
self._deselect_if_selection_gone()
|
|
1123
|
+
self._lineage_canvas.refresh()
|
|
1124
|
+
self._refresh_validated_overlay()
|
|
1125
|
+
self._correction_status(
|
|
1126
|
+
f"Removed unvalidated labels in {changed_frames} frame(s), "
|
|
1127
|
+
f"{changed_pixels} px changed. Unsaved."
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
def _refresh_validated_overlay(self) -> None:
|
|
1131
|
+
"""Draw an opaque green border around every validated cell.
|
|
1132
|
+
|
|
1133
|
+
Standalone counterpart of ``ValidatedOverlayController``'s border
|
|
1134
|
+
mode: same visual (a napari Labels layer, contour-only, fully
|
|
1135
|
+
opaque), but masked from the in-memory ``self._validated_tracks``
|
|
1136
|
+
store instead of ``validated_cells.json`` (no project directory
|
|
1137
|
+
here to read one from).
|
|
1138
|
+
"""
|
|
1139
|
+
layer = self._correction_tracked_layer()
|
|
1140
|
+
data = np.asarray(layer.data) if layer is not None else None
|
|
1141
|
+
if data is None or data.ndim < 3 or not self._validated_tracks:
|
|
1142
|
+
self._remove_validated_overlay()
|
|
1143
|
+
return
|
|
1144
|
+
mask = np.zeros(data.shape, dtype=np.uint8)
|
|
1145
|
+
n_frames = data.shape[0]
|
|
1146
|
+
for cell_id, frames in self._validated_tracks.items():
|
|
1147
|
+
for t in frames:
|
|
1148
|
+
if 0 <= t < n_frames:
|
|
1149
|
+
mask[t][data[t] == int(cell_id)] = 1
|
|
1150
|
+
colormap = direct_colormap({None: (0, 0, 0, 0), 1: _VALIDATED_OVERLAY_COLOR})
|
|
1151
|
+
if _VALIDATED_OVERLAY_LAYER in self.viewer.layers:
|
|
1152
|
+
overlay = self.viewer.layers[_VALIDATED_OVERLAY_LAYER]
|
|
1153
|
+
overlay.data = mask
|
|
1154
|
+
overlay.colormap = colormap
|
|
1155
|
+
else:
|
|
1156
|
+
overlay = self.viewer.add_labels(
|
|
1157
|
+
mask, name=_VALIDATED_OVERLAY_LAYER, opacity=1.0, colormap=colormap,
|
|
1158
|
+
)
|
|
1159
|
+
self._correction_owned_layers.add(_VALIDATED_OVERLAY_LAYER)
|
|
1160
|
+
overlay.contour = _VALIDATED_OVERLAY_CONTOUR
|
|
1161
|
+
overlay.opacity = 1.0
|
|
1162
|
+
self.viewer.layers.selection.active = layer
|
|
1163
|
+
|
|
1164
|
+
def _remove_validated_overlay(self) -> None:
|
|
1165
|
+
if _VALIDATED_OVERLAY_LAYER in self.viewer.layers:
|
|
1166
|
+
self.viewer.layers.remove(self.viewer.layers[_VALIDATED_OVERLAY_LAYER])
|
|
1167
|
+
self._correction_owned_layers.discard(_VALIDATED_OVERLAY_LAYER)
|
|
1168
|
+
|
|
1169
|
+
def _on_track_selection_changed(self, _t: int, cell_id: int) -> None:
|
|
1170
|
+
"""Keep the accordion's expanded track in step with the image selection."""
|
|
1171
|
+
self._lineage_canvas.set_selection(int(cell_id or 0))
|
|
1172
|
+
|
|
1173
|
+
def _on_dims_changed_for_lineage(self, _event=None) -> None:
|
|
1174
|
+
self._lineage_canvas.set_current_frame(self._current_t())
|
|
1175
|
+
|
|
1176
|
+
def _navigate_to_cell_from_lineage(self, t: int, cell_id: int) -> None:
|
|
1177
|
+
"""Accordion click: jump to frame ``t`` and select ``cell_id``."""
|
|
1178
|
+
try:
|
|
1179
|
+
step = list(self.viewer.dims.current_step)
|
|
1180
|
+
if step:
|
|
1181
|
+
step[0] = int(t)
|
|
1182
|
+
self.viewer.dims.current_step = tuple(step)
|
|
1183
|
+
except Exception:
|
|
1184
|
+
logger.exception("track-list navigation: frame jump failed")
|
|
1185
|
+
if int(cell_id) and int(cell_id) not in self._current_cell_ids(int(t)):
|
|
1186
|
+
return
|
|
1187
|
+
try:
|
|
1188
|
+
self.correction_widget.select_label(int(t), int(cell_id))
|
|
1189
|
+
except Exception:
|
|
1190
|
+
logger.exception("track-list navigation: cell select failed")
|