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,1363 @@
|
|
|
1
|
+
"""Label correction widget for ITASC v2."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import napari
|
|
9
|
+
import napari.layers
|
|
10
|
+
import numpy as np
|
|
11
|
+
from napari.utils.notifications import show_error
|
|
12
|
+
from qtpy.QtCore import Qt
|
|
13
|
+
from qtpy.QtWidgets import (
|
|
14
|
+
QCheckBox,
|
|
15
|
+
QComboBox,
|
|
16
|
+
QFrame,
|
|
17
|
+
QGridLayout,
|
|
18
|
+
QGroupBox,
|
|
19
|
+
QHBoxLayout,
|
|
20
|
+
QLabel,
|
|
21
|
+
QPushButton,
|
|
22
|
+
QSizePolicy,
|
|
23
|
+
QSpinBox,
|
|
24
|
+
QVBoxLayout,
|
|
25
|
+
QWidget,
|
|
26
|
+
)
|
|
27
|
+
from itasc.napari._spotlight import spotlight_rgba as _spotlight_rgba
|
|
28
|
+
from itasc.correction.labels import (
|
|
29
|
+
_label_at,
|
|
30
|
+
add_cell,
|
|
31
|
+
carve_into_selected,
|
|
32
|
+
draw_cell_path,
|
|
33
|
+
erase_cell,
|
|
34
|
+
clean_stranded_pixels,
|
|
35
|
+
fill_label_holes,
|
|
36
|
+
fix_label_semiholes,
|
|
37
|
+
merge_cells,
|
|
38
|
+
relabel_cell,
|
|
39
|
+
split_draw,
|
|
40
|
+
swap_labels,
|
|
41
|
+
)
|
|
42
|
+
from itasc.napari.ui_style import (
|
|
43
|
+
action_button,
|
|
44
|
+
add_block_pair_row,
|
|
45
|
+
block_grid,
|
|
46
|
+
checked_success_button,
|
|
47
|
+
danger_button,
|
|
48
|
+
muted_label,
|
|
49
|
+
status_label,
|
|
50
|
+
)
|
|
51
|
+
from itasc.napari._widget_helpers import islider as _islider
|
|
52
|
+
|
|
53
|
+
log = logging.getLogger("itasc.correction")
|
|
54
|
+
if os.environ.get("ITASC_DEBUG"):
|
|
55
|
+
log.setLevel(logging.DEBUG)
|
|
56
|
+
if not log.handlers:
|
|
57
|
+
_h = logging.StreamHandler()
|
|
58
|
+
_h.setFormatter(logging.Formatter("[itasc.correction] %(levelname)s %(message)s"))
|
|
59
|
+
log.addHandler(_h)
|
|
60
|
+
|
|
61
|
+
_DRAW_LAYER = "[Correction] CorrectionDraw"
|
|
62
|
+
_SPOTLIGHT_LAYER = "[Correction] CellSpotlight"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CorrectionWidget(QWidget):
|
|
66
|
+
"""Dock widget for interactive label correction."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
viewer: napari.Viewer,
|
|
71
|
+
parent: QWidget | None = None,
|
|
72
|
+
*,
|
|
73
|
+
show_activate_btn: bool = True,
|
|
74
|
+
show_shortcuts: bool = True,
|
|
75
|
+
inspector_first: bool = False,
|
|
76
|
+
show_cleanup: bool = True,
|
|
77
|
+
show_inspector: bool = True,
|
|
78
|
+
show_spawn_controls: bool = True,
|
|
79
|
+
contour_only: bool = False,
|
|
80
|
+
highlight_style: str = "spotlight",
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(parent)
|
|
83
|
+
self.viewer = viewer
|
|
84
|
+
self._show_activate_btn = show_activate_btn
|
|
85
|
+
self._show_shortcuts = show_shortcuts
|
|
86
|
+
self._inspector_first = inspector_first
|
|
87
|
+
self._show_cleanup = show_cleanup
|
|
88
|
+
# The "Inspect cell" group is redundant where a lineage canvas provides
|
|
89
|
+
# navigation; the spinbox/label still back the goto + Shift-±/step logic
|
|
90
|
+
# even when the group is not added to the layout.
|
|
91
|
+
self._show_inspector = show_inspector
|
|
92
|
+
# The cell-correction panel is tied to the nucleus label set, so it must
|
|
93
|
+
# not create / delete / renumber cells. ``contour_only`` restricts the
|
|
94
|
+
# mouse toolkit to select + extend (Shift-left) + carve (Shift-right) and
|
|
95
|
+
# hides the spawn radius control; ``highlight_style="border"`` outlines
|
|
96
|
+
# the selection instead of dimming everything else.
|
|
97
|
+
self._show_spawn_controls = show_spawn_controls
|
|
98
|
+
self._contour_only = contour_only
|
|
99
|
+
self._highlight_style = highlight_style
|
|
100
|
+
|
|
101
|
+
self._layer: napari.layers.Labels | None = None
|
|
102
|
+
|
|
103
|
+
self._selected_label: int = 0
|
|
104
|
+
self._selected_pos = None
|
|
105
|
+
self._selected_t: int = -1
|
|
106
|
+
self._swap_first_pos = None
|
|
107
|
+
self._swap_first_t: int = -1
|
|
108
|
+
|
|
109
|
+
self._drag_callbacks: list = []
|
|
110
|
+
self._bound_keys: list = []
|
|
111
|
+
|
|
112
|
+
self._in_deactivate: bool = False
|
|
113
|
+
|
|
114
|
+
self._saved_viewer_drag_cbs: list = []
|
|
115
|
+
self._saved_layer_mode: str = "pan_zoom"
|
|
116
|
+
self._saved_layer_contour: int = 0
|
|
117
|
+
|
|
118
|
+
self._edit_callback: Callable[[int, set[int]], None] | None = None
|
|
119
|
+
self._selection_callback: Callable[[int, int], None] | None = None
|
|
120
|
+
self._selection_listeners: list[Callable[[int, int], None]] = []
|
|
121
|
+
# Optional override for the spotlight cutout: given (t, lab, default_mask)
|
|
122
|
+
# it may return a different boolean mask to highlight (e.g. the union of a
|
|
123
|
+
# whole track's masks) or None to keep the default per-frame behavior.
|
|
124
|
+
self._spotlight_mask_provider: (
|
|
125
|
+
Callable[[int, int, np.ndarray], np.ndarray | None] | None
|
|
126
|
+
) = None
|
|
127
|
+
self._protected_mask_callback: (
|
|
128
|
+
Callable[[int, np.ndarray], np.ndarray | None] | None
|
|
129
|
+
) = None
|
|
130
|
+
# Optional provider of a 2-D intensity frame (e.g. the nucleus image)
|
|
131
|
+
# used to snap spawned cells to the underlying signal. None → always
|
|
132
|
+
# stamp a plain disk.
|
|
133
|
+
self._intensity_frame_callback: (
|
|
134
|
+
Callable[[int], np.ndarray | None] | None
|
|
135
|
+
) = None
|
|
136
|
+
|
|
137
|
+
self._setup_ui()
|
|
138
|
+
|
|
139
|
+
# ── UI ────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def _setup_ui(self) -> None:
|
|
142
|
+
root = QVBoxLayout(self)
|
|
143
|
+
root.setContentsMargins(2, 2, 2, 2)
|
|
144
|
+
root.setSpacing(6)
|
|
145
|
+
|
|
146
|
+
self._activate_btn = QPushButton("Activate on selected layer")
|
|
147
|
+
self._activate_btn.setCheckable(True)
|
|
148
|
+
self._activate_btn.setToolTip(
|
|
149
|
+
"Enable interactive mouse callbacks for merging/splitting."
|
|
150
|
+
)
|
|
151
|
+
action_button(self._activate_btn, expand=True)
|
|
152
|
+
checked_success_button(self._activate_btn)
|
|
153
|
+
self._activate_btn.clicked.connect(self._toggle_active)
|
|
154
|
+
if self._show_activate_btn:
|
|
155
|
+
root.addWidget(self._activate_btn)
|
|
156
|
+
|
|
157
|
+
attrib = QLabel(
|
|
158
|
+
"Correction tools adapted from "
|
|
159
|
+
'<a href="https://github.com/Image-Analysis-Hub/Epicure">Epicure</a>.'
|
|
160
|
+
"<br>If you use these tools, please cite:<br>"
|
|
161
|
+
'<a href="https://doi.org/10.64898/2026.03.27.714683">'
|
|
162
|
+
"doi:10.64898/2026.03.27.714683</a>"
|
|
163
|
+
)
|
|
164
|
+
attrib.setOpenExternalLinks(True)
|
|
165
|
+
attrib.setWordWrap(True)
|
|
166
|
+
muted_label(attrib, size_pt=9)
|
|
167
|
+
|
|
168
|
+
self._outline_btn = QCheckBox("Show outlines only")
|
|
169
|
+
self._outline_btn.setEnabled(False)
|
|
170
|
+
action_button(self._outline_btn, expand=True)
|
|
171
|
+
self._outline_btn.toggled.connect(self._toggle_outline)
|
|
172
|
+
root.addWidget(self._outline_btn)
|
|
173
|
+
|
|
174
|
+
self._reset_mode_btn = QPushButton("⚠ Restore correction mode")
|
|
175
|
+
self._reset_mode_btn.setVisible(False)
|
|
176
|
+
action_button(self._reset_mode_btn, expand=True)
|
|
177
|
+
danger_button(self._reset_mode_btn)
|
|
178
|
+
self._reset_mode_btn.clicked.connect(self._reset_tool_mode)
|
|
179
|
+
root.addWidget(self._reset_mode_btn)
|
|
180
|
+
|
|
181
|
+
# ── cleanup section (wrapped in container) ────────────── # ← CHANGED
|
|
182
|
+
self._cleanup_container = QWidget() # ← NEW
|
|
183
|
+
_clay = QVBoxLayout(self._cleanup_container) # ← NEW
|
|
184
|
+
_clay.setContentsMargins(0, 0, 0, 0) # ← NEW
|
|
185
|
+
_clay.setSpacing(6) # ← NEW
|
|
186
|
+
|
|
187
|
+
cleanup_label = QLabel("Artifact cleanup")
|
|
188
|
+
muted_label(cleanup_label, size_pt=9)
|
|
189
|
+
_clay.addWidget(cleanup_label) # ← CHANGED (was root)
|
|
190
|
+
|
|
191
|
+
cleanup_grid = block_grid(horizontal_spacing=12)
|
|
192
|
+
self._cleanup_scope_combo = QComboBox()
|
|
193
|
+
self._cleanup_scope_combo.addItems(["Current frame", "All frames"])
|
|
194
|
+
self._cleanup_scope_combo.setToolTip(
|
|
195
|
+
"Choose whether cleanup applies to the visible frame or the full label stack."
|
|
196
|
+
)
|
|
197
|
+
self._hole_radius_spin = _islider(0, 999, 5)
|
|
198
|
+
self._hole_radius_spin.setToolTip(
|
|
199
|
+
"Maximum pixel distance for filling enclosed background gaps. Set to 0 to skip gap filling."
|
|
200
|
+
)
|
|
201
|
+
self._semihole_opening_spin = _islider(0, 999, 3)
|
|
202
|
+
self._semihole_opening_spin.setToolTip(
|
|
203
|
+
"Maximum border contact, in pixels, for semihole repair. Set to 0 to skip semihole repair."
|
|
204
|
+
)
|
|
205
|
+
add_block_pair_row(
|
|
206
|
+
cleanup_grid,
|
|
207
|
+
0,
|
|
208
|
+
"Scope:",
|
|
209
|
+
self._cleanup_scope_combo,
|
|
210
|
+
field_width=150,
|
|
211
|
+
)
|
|
212
|
+
add_block_pair_row(
|
|
213
|
+
cleanup_grid,
|
|
214
|
+
1,
|
|
215
|
+
"Hole radius:",
|
|
216
|
+
self._hole_radius_spin,
|
|
217
|
+
"Max opening:",
|
|
218
|
+
self._semihole_opening_spin,
|
|
219
|
+
)
|
|
220
|
+
_clay.addLayout(cleanup_grid)
|
|
221
|
+
|
|
222
|
+
self._fill_holes_btn = QPushButton("Fill Holes")
|
|
223
|
+
self._fill_holes_btn.setEnabled(False)
|
|
224
|
+
self._fill_holes_btn.setToolTip("Fill enclosed background gaps using the configured hole radius.")
|
|
225
|
+
action_button(self._fill_holes_btn, expand=True)
|
|
226
|
+
self._fill_holes_btn.clicked.connect(self._fill_holes)
|
|
227
|
+
_clay.addWidget(self._fill_holes_btn) # ← CHANGED
|
|
228
|
+
|
|
229
|
+
self._fix_semiholes_btn = QPushButton("Fix Semiholes")
|
|
230
|
+
self._fix_semiholes_btn.setEnabled(False)
|
|
231
|
+
self._fix_semiholes_btn.setToolTip(
|
|
232
|
+
"Repair narrow border-connected gaps using the radius and max opening controls."
|
|
233
|
+
)
|
|
234
|
+
action_button(self._fix_semiholes_btn, expand=True)
|
|
235
|
+
self._fix_semiholes_btn.clicked.connect(self._fix_semiholes)
|
|
236
|
+
_clay.addWidget(self._fix_semiholes_btn) # ← CHANGED
|
|
237
|
+
|
|
238
|
+
self._clean_fragments_btn = QPushButton("Clean Fragments")
|
|
239
|
+
self._clean_fragments_btn.setEnabled(False)
|
|
240
|
+
self._clean_fragments_btn.setToolTip("Remove disconnected same-label fragments without filling background holes.")
|
|
241
|
+
action_button(self._clean_fragments_btn, expand=True)
|
|
242
|
+
self._clean_fragments_btn.clicked.connect(self._clean_fragments)
|
|
243
|
+
_clay.addWidget(self._clean_fragments_btn) # ← CHANGED
|
|
244
|
+
|
|
245
|
+
if self._show_cleanup:
|
|
246
|
+
root.addWidget(self._cleanup_container)
|
|
247
|
+
|
|
248
|
+
# ── spawn-cell controls ───────────────────────────────────────────────
|
|
249
|
+
# Middle-clicking empty space spawns a cell; this sets its fallback size
|
|
250
|
+
# when the nucleus signal is too weak to snap to. Wrapped in a container
|
|
251
|
+
# so an embedder (e.g. the nucleus widget) can relocate it into its own
|
|
252
|
+
# parameter panel; shown here when not relocated.
|
|
253
|
+
self._cell_radius_spin = _islider(1, 999, 6)
|
|
254
|
+
self._cell_radius_spin.setToolTip(
|
|
255
|
+
"Radius (px) of a cell spawned by middle-clicking empty space. "
|
|
256
|
+
"Used as a fallback size when the nucleus signal is too weak to snap to."
|
|
257
|
+
)
|
|
258
|
+
self._spawn_controls = QWidget()
|
|
259
|
+
spawn_lay = QVBoxLayout(self._spawn_controls)
|
|
260
|
+
spawn_lay.setContentsMargins(0, 0, 0, 0)
|
|
261
|
+
spawn_lay.setSpacing(6)
|
|
262
|
+
spawn_grid = block_grid(horizontal_spacing=12)
|
|
263
|
+
add_block_pair_row(
|
|
264
|
+
spawn_grid,
|
|
265
|
+
0,
|
|
266
|
+
"Cell radius:",
|
|
267
|
+
self._cell_radius_spin,
|
|
268
|
+
)
|
|
269
|
+
spawn_lay.addLayout(spawn_grid)
|
|
270
|
+
# Hidden in contour-only mode (no spawning), but kept alive as a member
|
|
271
|
+
# so any code reading ``_cell_radius_spin`` still finds a live widget.
|
|
272
|
+
if self._show_spawn_controls:
|
|
273
|
+
root.addWidget(self._spawn_controls)
|
|
274
|
+
|
|
275
|
+
self._status = QLabel("Inactive")
|
|
276
|
+
self._status.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
277
|
+
status_label(self._status, italic=True, muted=True)
|
|
278
|
+
root.addWidget(self._status)
|
|
279
|
+
|
|
280
|
+
# Hold a reference even when the group is not added to the layout
|
|
281
|
+
# (show_inspector=False): otherwise Python GCs the QGroupBox and its
|
|
282
|
+
# children, deleting the C++ QSpinBox that goto/Shift-± still drive.
|
|
283
|
+
inspect_group = QGroupBox("Inspect cell")
|
|
284
|
+
self._inspect_group = inspect_group
|
|
285
|
+
inspect_lay = QVBoxLayout(inspect_group)
|
|
286
|
+
|
|
287
|
+
id_row = QHBoxLayout()
|
|
288
|
+
id_row.addWidget(QLabel("Cell ID:"))
|
|
289
|
+
self._goto_cell_id = QSpinBox()
|
|
290
|
+
self._goto_cell_id.setRange(0, 999_999)
|
|
291
|
+
self._goto_cell_id.setValue(0)
|
|
292
|
+
self._goto_cell_id.setSpecialValueText("—")
|
|
293
|
+
self._goto_cell_id.setEnabled(False)
|
|
294
|
+
self._goto_cell_id.valueChanged.connect(self._goto_cell)
|
|
295
|
+
id_row.addWidget(self._goto_cell_id)
|
|
296
|
+
inspect_lay.addLayout(id_row)
|
|
297
|
+
|
|
298
|
+
self._inspect_frames_label = QLabel("")
|
|
299
|
+
self._inspect_frames_label.setWordWrap(True)
|
|
300
|
+
muted_label(self._inspect_frames_label, size_pt=9)
|
|
301
|
+
inspect_lay.addWidget(self._inspect_frames_label)
|
|
302
|
+
|
|
303
|
+
ref_group = self.build_shortcuts_widget()
|
|
304
|
+
|
|
305
|
+
if self._inspector_first:
|
|
306
|
+
if self._show_inspector:
|
|
307
|
+
root.addWidget(inspect_group)
|
|
308
|
+
if self._show_shortcuts:
|
|
309
|
+
root.addWidget(ref_group)
|
|
310
|
+
else:
|
|
311
|
+
if self._show_shortcuts:
|
|
312
|
+
root.addWidget(ref_group)
|
|
313
|
+
if self._show_inspector:
|
|
314
|
+
root.addWidget(inspect_group)
|
|
315
|
+
|
|
316
|
+
self._attrib_lbl = attrib
|
|
317
|
+
if self._show_activate_btn:
|
|
318
|
+
root.addWidget(self._attrib_lbl)
|
|
319
|
+
root.addStretch()
|
|
320
|
+
|
|
321
|
+
def build_shortcuts_widget(self) -> QWidget:
|
|
322
|
+
group = QGroupBox("Correction shortcuts")
|
|
323
|
+
grid = QGridLayout(group)
|
|
324
|
+
grid.setContentsMargins(8, 6, 8, 6)
|
|
325
|
+
grid.setHorizontalSpacing(10)
|
|
326
|
+
grid.setVerticalSpacing(2)
|
|
327
|
+
row = 0
|
|
328
|
+
row = self._add_shortcut_group(
|
|
329
|
+
grid,
|
|
330
|
+
"Selection",
|
|
331
|
+
[
|
|
332
|
+
("Left-click", "Select / highlight cell"),
|
|
333
|
+
],
|
|
334
|
+
start_row=row,
|
|
335
|
+
is_first=True,
|
|
336
|
+
)
|
|
337
|
+
if self._contour_only:
|
|
338
|
+
# Cell labels are tied to the nuclei: only contour edits are allowed.
|
|
339
|
+
manual_rows = [
|
|
340
|
+
("Shift+Left-drag", "Extend the selected cell's contour"),
|
|
341
|
+
("Shift+Right-drag", "Draw a line through a neighbour to cut it and merge the near piece into the selected cell"),
|
|
342
|
+
]
|
|
343
|
+
else:
|
|
344
|
+
manual_rows = [
|
|
345
|
+
("Middle-click empty space", "Spawn new cell"),
|
|
346
|
+
("Middle-click on cell or Delete", "Erase cell"),
|
|
347
|
+
("Ctrl+Left-click", "Merge with the clicked cell (same frame)"),
|
|
348
|
+
("Ctrl+Middle-click", "Grow / link selected track here"),
|
|
349
|
+
("Ctrl+Right-click", "Swap with the clicked cell, or attach it to the selected track (other frame)"),
|
|
350
|
+
("Shift+Left-drag", "Draw / extend cell path"),
|
|
351
|
+
("Shift+Right-drag", "Split by drawn line"),
|
|
352
|
+
]
|
|
353
|
+
row = self._add_shortcut_group(
|
|
354
|
+
grid,
|
|
355
|
+
"Contour Edits" if self._contour_only else "Manual Labels",
|
|
356
|
+
manual_rows,
|
|
357
|
+
start_row=row,
|
|
358
|
+
)
|
|
359
|
+
row = self._add_shortcut_group(
|
|
360
|
+
grid, "History", [("Ctrl+Z", "Undo")], start_row=row
|
|
361
|
+
)
|
|
362
|
+
grid.setColumnStretch(1, 1)
|
|
363
|
+
return group
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def _add_shortcut_group(
|
|
367
|
+
grid: QGridLayout,
|
|
368
|
+
title: str,
|
|
369
|
+
rows: list[tuple[str, str]],
|
|
370
|
+
*,
|
|
371
|
+
start_row: int = 0,
|
|
372
|
+
is_first: bool = False,
|
|
373
|
+
) -> int:
|
|
374
|
+
row = start_row
|
|
375
|
+
if not is_first:
|
|
376
|
+
grid.setRowMinimumHeight(row, 6)
|
|
377
|
+
row += 1
|
|
378
|
+
title_lbl = QLabel(title)
|
|
379
|
+
title_lbl.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
380
|
+
grid.addWidget(title_lbl, row, 0, 1, 2)
|
|
381
|
+
row += 1
|
|
382
|
+
sep = QFrame()
|
|
383
|
+
sep.setFrameShape(QFrame.Shape.HLine)
|
|
384
|
+
sep.setFrameShadow(QFrame.Shadow.Plain)
|
|
385
|
+
sep.setFixedHeight(1)
|
|
386
|
+
sep.setStyleSheet("background-color: rgba(255, 255, 255, 0.25); border: none;")
|
|
387
|
+
grid.addWidget(sep, row, 0, 1, 2)
|
|
388
|
+
row += 1
|
|
389
|
+
for key, desc in rows:
|
|
390
|
+
key_lbl = QLabel(key)
|
|
391
|
+
key_lbl.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
|
392
|
+
key_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
|
393
|
+
desc_lbl = QLabel(desc)
|
|
394
|
+
desc_lbl.setWordWrap(True)
|
|
395
|
+
desc_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
|
396
|
+
sp = QSizePolicy(
|
|
397
|
+
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding
|
|
398
|
+
)
|
|
399
|
+
sp.setHeightForWidth(True)
|
|
400
|
+
desc_lbl.setSizePolicy(sp)
|
|
401
|
+
grid.addWidget(key_lbl, row, 0)
|
|
402
|
+
grid.addWidget(desc_lbl, row, 1)
|
|
403
|
+
row += 1
|
|
404
|
+
return row
|
|
405
|
+
|
|
406
|
+
# ── activation ────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
def _toggle_active(self, checked: bool) -> None:
|
|
409
|
+
if checked:
|
|
410
|
+
layer = self.viewer.layers.selection.active
|
|
411
|
+
if layer is None:
|
|
412
|
+
self._activate_btn.setChecked(False)
|
|
413
|
+
self._set_status("Select a Labels layer first", error=True)
|
|
414
|
+
return
|
|
415
|
+
if not isinstance(layer, napari.layers.Labels):
|
|
416
|
+
self._activate_btn.setChecked(False)
|
|
417
|
+
self._set_status("Not a Labels layer", error=True)
|
|
418
|
+
return
|
|
419
|
+
self._activate(layer)
|
|
420
|
+
else:
|
|
421
|
+
self._deactivate()
|
|
422
|
+
|
|
423
|
+
def _activate(self, layer: napari.layers.Labels) -> None:
|
|
424
|
+
log.debug("activate: layer='%s' shape=%s", layer.name, layer.data.shape)
|
|
425
|
+
self._layer = layer
|
|
426
|
+
self._selected_label = 0
|
|
427
|
+
self._selected_pos = None
|
|
428
|
+
self._selected_t = -1
|
|
429
|
+
self._swap_first_pos = None
|
|
430
|
+
self._swap_first_t = -1
|
|
431
|
+
|
|
432
|
+
if hasattr(self.viewer, "mouse_drag_callbacks"):
|
|
433
|
+
self._saved_viewer_drag_cbs = list(self.viewer.mouse_drag_callbacks)
|
|
434
|
+
self.viewer.mouse_drag_callbacks.clear()
|
|
435
|
+
else:
|
|
436
|
+
self._saved_viewer_drag_cbs = []
|
|
437
|
+
|
|
438
|
+
self._saved_layer_mode = layer.mode
|
|
439
|
+
self._saved_layer_contour = int(layer.contour)
|
|
440
|
+
layer.mode = "pan_zoom"
|
|
441
|
+
|
|
442
|
+
self.viewer.layers.selection.active = layer
|
|
443
|
+
self._get_draw_layer()
|
|
444
|
+
self._get_spotlight_layer()
|
|
445
|
+
|
|
446
|
+
self.viewer.dims.events.current_step.connect(self._on_dims_change)
|
|
447
|
+
layer.events.data.connect(self._on_layer_data_changed)
|
|
448
|
+
layer.events.paint.connect(self._on_layer_data_changed)
|
|
449
|
+
self.viewer.layers.events.removed.connect(self._on_layer_removed)
|
|
450
|
+
layer.events.mode.connect(self._on_layer_mode_change)
|
|
451
|
+
|
|
452
|
+
self._register_callbacks()
|
|
453
|
+
self._activate_btn.setText("Deactivate")
|
|
454
|
+
self._outline_btn.setEnabled(True)
|
|
455
|
+
self._set_cleanup_enabled(True)
|
|
456
|
+
self._outline_btn.setChecked(False)
|
|
457
|
+
self._toggle_outline(False)
|
|
458
|
+
self._goto_cell_id.setEnabled(True)
|
|
459
|
+
self._set_status(f"Active on '{layer.name}'")
|
|
460
|
+
|
|
461
|
+
def _deactivate(self) -> None:
|
|
462
|
+
if self._in_deactivate:
|
|
463
|
+
return
|
|
464
|
+
self._in_deactivate = True
|
|
465
|
+
try:
|
|
466
|
+
self._deactivate_impl()
|
|
467
|
+
finally:
|
|
468
|
+
self._in_deactivate = False
|
|
469
|
+
|
|
470
|
+
def _deactivate_impl(self) -> None:
|
|
471
|
+
log.debug("deactivate: layer='%s'", self._layer.name if self._layer else None)
|
|
472
|
+
if self._layer is not None:
|
|
473
|
+
self._remove_callbacks()
|
|
474
|
+
|
|
475
|
+
for disconnect in [
|
|
476
|
+
lambda: self.viewer.dims.events.current_step.disconnect(self._on_dims_change),
|
|
477
|
+
lambda: self.viewer.layers.events.removed.disconnect(self._on_layer_removed),
|
|
478
|
+
lambda: self._layer.events.data.disconnect(self._on_layer_data_changed),
|
|
479
|
+
lambda: self._layer.events.paint.disconnect(self._on_layer_data_changed),
|
|
480
|
+
lambda: self._layer.events.mode.disconnect(self._on_layer_mode_change),
|
|
481
|
+
]:
|
|
482
|
+
try:
|
|
483
|
+
disconnect()
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
self._layer.mode = self._saved_layer_mode
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
try:
|
|
492
|
+
self._layer.contour = self._saved_layer_contour
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
if hasattr(self.viewer, "mouse_drag_callbacks"):
|
|
497
|
+
self.viewer.mouse_drag_callbacks.clear()
|
|
498
|
+
for cb in self._saved_viewer_drag_cbs:
|
|
499
|
+
self.viewer.mouse_drag_callbacks.append(cb)
|
|
500
|
+
|
|
501
|
+
self._layer = None
|
|
502
|
+
self._selected_label = 0
|
|
503
|
+
self._selected_pos = None
|
|
504
|
+
self._selected_t = -1
|
|
505
|
+
self._swap_first_pos = None
|
|
506
|
+
self._swap_first_t = -1
|
|
507
|
+
self._saved_viewer_drag_cbs = []
|
|
508
|
+
|
|
509
|
+
self._activate_btn.setText("Activate on selected layer")
|
|
510
|
+
self._activate_btn.setChecked(False)
|
|
511
|
+
self._outline_btn.setChecked(False)
|
|
512
|
+
self._outline_btn.setEnabled(False)
|
|
513
|
+
self._set_cleanup_enabled(False)
|
|
514
|
+
self._goto_cell_id.setEnabled(False)
|
|
515
|
+
self._goto_cell_id.setValue(0)
|
|
516
|
+
self._inspect_frames_label.setText("")
|
|
517
|
+
self._set_status("Inactive")
|
|
518
|
+
self._cleanup_draw_layer()
|
|
519
|
+
self._cleanup_spotlight_layer()
|
|
520
|
+
|
|
521
|
+
def activate_layer(self, layer: napari.layers.Labels) -> None:
|
|
522
|
+
"""Activate correction on a specific Labels layer (bypasses the UI button)."""
|
|
523
|
+
if self._layer is not None:
|
|
524
|
+
self._deactivate()
|
|
525
|
+
self._activate(layer)
|
|
526
|
+
self._activate_btn.setChecked(True)
|
|
527
|
+
|
|
528
|
+
def deactivate(self) -> None:
|
|
529
|
+
"""Deactivate correction (public API)."""
|
|
530
|
+
self._deactivate()
|
|
531
|
+
|
|
532
|
+
def _set_status(self, msg: str, error: bool = False) -> None:
|
|
533
|
+
self._status.setText(msg)
|
|
534
|
+
if error:
|
|
535
|
+
self._status.setStyleSheet("color: #b00020; font-style: italic;")
|
|
536
|
+
else:
|
|
537
|
+
status_label(self._status, italic=True, muted=True)
|
|
538
|
+
|
|
539
|
+
def _set_cleanup_enabled(self, enabled: bool) -> None:
|
|
540
|
+
for button in (
|
|
541
|
+
self._fill_holes_btn,
|
|
542
|
+
self._fix_semiholes_btn,
|
|
543
|
+
self._clean_fragments_btn,
|
|
544
|
+
):
|
|
545
|
+
button.setEnabled(enabled)
|
|
546
|
+
|
|
547
|
+
def set_edit_callback(self, fn: Callable[[int, set[int]], None] | None) -> None:
|
|
548
|
+
self._edit_callback = fn
|
|
549
|
+
|
|
550
|
+
def set_selection_callback(self, fn: Callable[[int, int], None] | None) -> None:
|
|
551
|
+
self._selection_callback = fn
|
|
552
|
+
|
|
553
|
+
def add_selection_listener(self, fn: Callable[[int, int], None]) -> None:
|
|
554
|
+
"""Register an extra selection callback that survives ``set_selection_callback``.
|
|
555
|
+
|
|
556
|
+
``set_selection_callback`` is a single slot owned by the workflow widget,
|
|
557
|
+
so registering there would clobber (or be clobbered by) it. Listeners
|
|
558
|
+
accumulate instead, letting independent features (e.g. the track-path
|
|
559
|
+
comet) react to selection changes without fighting over that slot.
|
|
560
|
+
"""
|
|
561
|
+
if fn not in self._selection_listeners:
|
|
562
|
+
self._selection_listeners.append(fn)
|
|
563
|
+
|
|
564
|
+
def set_spotlight_mask_provider(
|
|
565
|
+
self, fn: Callable[[int, int, np.ndarray], np.ndarray | None] | None
|
|
566
|
+
) -> None:
|
|
567
|
+
self._spotlight_mask_provider = fn
|
|
568
|
+
|
|
569
|
+
def set_highlight_style(self, style: str) -> None:
|
|
570
|
+
"""Switch the selection indicator between the dimming spotlight and a
|
|
571
|
+
plain yellow border, re-rendering the current selection."""
|
|
572
|
+
if style not in ("spotlight", "border"):
|
|
573
|
+
raise ValueError(f"unknown highlight_style: {style!r}")
|
|
574
|
+
if style == self._highlight_style:
|
|
575
|
+
return
|
|
576
|
+
self._highlight_style = style
|
|
577
|
+
if self._selected_label and self._layer is not None and self._selected_t >= 0:
|
|
578
|
+
self._update_highlight(
|
|
579
|
+
self._selected_t, self._selected_label, notify=False
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def set_protected_mask_callback(
|
|
583
|
+
self,
|
|
584
|
+
fn: Callable[[int, np.ndarray], np.ndarray | None] | None,
|
|
585
|
+
) -> None:
|
|
586
|
+
self._protected_mask_callback = fn
|
|
587
|
+
|
|
588
|
+
def set_intensity_frame_callback(
|
|
589
|
+
self,
|
|
590
|
+
fn: Callable[[int], np.ndarray | None] | None,
|
|
591
|
+
) -> None:
|
|
592
|
+
"""Provide a per-frame 2-D intensity image to guide spawned cells.
|
|
593
|
+
|
|
594
|
+
``fn(t)`` returns the nucleus/intensity frame for time ``t`` (matching
|
|
595
|
+
the label frame shape) or None. When unset, middle-clicking empty space
|
|
596
|
+
stamps a plain disk instead of snapping to the signal.
|
|
597
|
+
"""
|
|
598
|
+
self._intensity_frame_callback = fn
|
|
599
|
+
|
|
600
|
+
def _intensity_frame(self, t: int) -> np.ndarray | None:
|
|
601
|
+
if self._intensity_frame_callback is None:
|
|
602
|
+
return None
|
|
603
|
+
try:
|
|
604
|
+
frame = self._intensity_frame_callback(t)
|
|
605
|
+
except Exception:
|
|
606
|
+
return None
|
|
607
|
+
return None if frame is None else np.asarray(frame)
|
|
608
|
+
|
|
609
|
+
def select_label(self, t: int, label: int, *, notify: bool = True) -> None:
|
|
610
|
+
self._update_highlight(t, label, notify=notify)
|
|
611
|
+
|
|
612
|
+
def _cleanup_frame_indices(self) -> list[int]:
|
|
613
|
+
if self._layer is None:
|
|
614
|
+
return []
|
|
615
|
+
if self._layer.data.ndim < 3:
|
|
616
|
+
return [0]
|
|
617
|
+
if self._cleanup_scope_combo.currentText() == "All frames":
|
|
618
|
+
return list(range(int(self._layer.data.shape[0])))
|
|
619
|
+
return [int(self.viewer.dims.current_step[0])]
|
|
620
|
+
|
|
621
|
+
def _run_artifact_cleanup(
|
|
622
|
+
self,
|
|
623
|
+
operation_name: str,
|
|
624
|
+
no_change_message: str,
|
|
625
|
+
operation: Callable[[np.ndarray], None],
|
|
626
|
+
) -> None:
|
|
627
|
+
if self._layer is None:
|
|
628
|
+
self._set_status("No active labels layer", error=True)
|
|
629
|
+
return
|
|
630
|
+
try:
|
|
631
|
+
changed_frames = 0
|
|
632
|
+
changed_pixels = 0
|
|
633
|
+
for t in self._cleanup_frame_indices():
|
|
634
|
+
seg2d = self._frame_view(self._layer, t)
|
|
635
|
+
before = seg2d.copy()
|
|
636
|
+
operation(seg2d)
|
|
637
|
+
changed = int(np.sum(before != seg2d))
|
|
638
|
+
if not changed:
|
|
639
|
+
continue
|
|
640
|
+
changed_frames += 1
|
|
641
|
+
changed_pixels += changed
|
|
642
|
+
self._record_history(self._layer, t, before)
|
|
643
|
+
|
|
644
|
+
if changed_pixels:
|
|
645
|
+
self._layer.refresh()
|
|
646
|
+
current_t = (
|
|
647
|
+
int(self.viewer.dims.current_step[0])
|
|
648
|
+
if self._layer.data.ndim >= 3
|
|
649
|
+
else 0
|
|
650
|
+
)
|
|
651
|
+
if self._selected_label:
|
|
652
|
+
self._update_highlight(current_t, self._selected_label)
|
|
653
|
+
self._set_status(
|
|
654
|
+
f"{operation_name} in {changed_frames} frame(s), {changed_pixels} px changed. Unsaved."
|
|
655
|
+
)
|
|
656
|
+
else:
|
|
657
|
+
self._set_status(no_change_message)
|
|
658
|
+
except Exception as exc:
|
|
659
|
+
show_error(f"cleanup error: {exc}")
|
|
660
|
+
|
|
661
|
+
def _fill_holes(self) -> None:
|
|
662
|
+
radius = int(self._hole_radius_spin.value())
|
|
663
|
+
self._run_artifact_cleanup(
|
|
664
|
+
"Filled holes",
|
|
665
|
+
"No holes found",
|
|
666
|
+
lambda seg2d: np.copyto(seg2d, fill_label_holes(seg2d, radius=radius)),
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _fix_semiholes(self) -> None:
|
|
670
|
+
radius = int(self._hole_radius_spin.value())
|
|
671
|
+
max_opening = int(self._semihole_opening_spin.value())
|
|
672
|
+
self._run_artifact_cleanup(
|
|
673
|
+
"Fixed semiholes",
|
|
674
|
+
"No semiholes found",
|
|
675
|
+
lambda seg2d: np.copyto(
|
|
676
|
+
seg2d,
|
|
677
|
+
fix_label_semiholes(seg2d, radius=radius, max_opening=max_opening),
|
|
678
|
+
),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
def _clean_fragments(self) -> None:
|
|
682
|
+
self._run_artifact_cleanup(
|
|
683
|
+
"Cleaned fragments",
|
|
684
|
+
"No fragments found",
|
|
685
|
+
lambda seg2d: clean_stranded_pixels(seg2d),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
@staticmethod
|
|
689
|
+
def _frame_view(layer, t: int) -> np.ndarray:
|
|
690
|
+
if layer.data.ndim == 2:
|
|
691
|
+
return layer.data
|
|
692
|
+
v = layer.data[t]
|
|
693
|
+
while v.ndim > 2:
|
|
694
|
+
if v.shape[0] != 1:
|
|
695
|
+
raise ValueError(f"non-singleton dim in frame slice: shape={v.shape}")
|
|
696
|
+
v = v[0]
|
|
697
|
+
return v
|
|
698
|
+
|
|
699
|
+
def _next_free_label(self) -> int:
|
|
700
|
+
if self._layer is None:
|
|
701
|
+
return 1
|
|
702
|
+
return int(np.max(self._layer.data)) + 1
|
|
703
|
+
|
|
704
|
+
def _record_history(self, layer, t: int, before: np.ndarray) -> None:
|
|
705
|
+
after = self._frame_view(layer, t)
|
|
706
|
+
changed = np.where(before != after)
|
|
707
|
+
if not changed[0].size:
|
|
708
|
+
return
|
|
709
|
+
n = changed[0].size
|
|
710
|
+
extra = layer.data.ndim - 1 - 2
|
|
711
|
+
parts = [np.full(n, t, dtype=layer.data.dtype)]
|
|
712
|
+
parts.extend(np.zeros(n, dtype=layer.data.dtype) for _ in range(extra))
|
|
713
|
+
parts.extend(changed)
|
|
714
|
+
layer._save_history((tuple(parts), before[changed], after[changed]))
|
|
715
|
+
if self._edit_callback is not None:
|
|
716
|
+
ids = set(int(v) for v in before[changed]) | set(int(v) for v in after[changed])
|
|
717
|
+
ids.discard(0)
|
|
718
|
+
if ids:
|
|
719
|
+
try:
|
|
720
|
+
self._edit_callback(t, ids)
|
|
721
|
+
except Exception:
|
|
722
|
+
import logging as _logging
|
|
723
|
+
_logging.getLogger("itasc.correction").exception("edit_callback failed")
|
|
724
|
+
|
|
725
|
+
def _protected_mask(self, t: int, seg2d: np.ndarray) -> np.ndarray | None:
|
|
726
|
+
if self._protected_mask_callback is None:
|
|
727
|
+
return None
|
|
728
|
+
mask = self._protected_mask_callback(t, seg2d)
|
|
729
|
+
if mask is None:
|
|
730
|
+
return None
|
|
731
|
+
mask = np.asarray(mask, dtype=bool)
|
|
732
|
+
if mask.shape != seg2d.shape:
|
|
733
|
+
raise ValueError(
|
|
734
|
+
f"protected mask shape {mask.shape} does not match frame shape {seg2d.shape}"
|
|
735
|
+
)
|
|
736
|
+
return mask
|
|
737
|
+
|
|
738
|
+
# ── draw layer ────────────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
def _get_draw_layer(self):
|
|
741
|
+
if _DRAW_LAYER in self.viewer.layers:
|
|
742
|
+
return self.viewer.layers[_DRAW_LAYER]
|
|
743
|
+
dl = self.viewer.add_shapes(
|
|
744
|
+
name=_DRAW_LAYER,
|
|
745
|
+
ndim=2,
|
|
746
|
+
edge_color="yellow",
|
|
747
|
+
edge_width=1,
|
|
748
|
+
face_color="transparent",
|
|
749
|
+
)
|
|
750
|
+
dl.visible = False
|
|
751
|
+
if self._layer is not None:
|
|
752
|
+
self.viewer.layers.selection.active = self._layer
|
|
753
|
+
return dl
|
|
754
|
+
|
|
755
|
+
def _cleanup_draw_layer(self) -> None:
|
|
756
|
+
if _DRAW_LAYER in self.viewer.layers:
|
|
757
|
+
self.viewer.layers.remove(self.viewer.layers[_DRAW_LAYER])
|
|
758
|
+
|
|
759
|
+
# ── selection spotlight ──────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
def _get_spotlight_layer(self):
|
|
762
|
+
if _SPOTLIGHT_LAYER in self.viewer.layers:
|
|
763
|
+
return self.viewer.layers[_SPOTLIGHT_LAYER]
|
|
764
|
+
spotlight = self.viewer.add_image(
|
|
765
|
+
np.zeros((1, 1, 4), dtype=np.float32),
|
|
766
|
+
name=_SPOTLIGHT_LAYER,
|
|
767
|
+
rgb=True,
|
|
768
|
+
blending="translucent",
|
|
769
|
+
)
|
|
770
|
+
spotlight.visible = False
|
|
771
|
+
if self._layer is not None:
|
|
772
|
+
self.viewer.layers.selection.active = self._layer
|
|
773
|
+
return spotlight
|
|
774
|
+
|
|
775
|
+
def _notify_selection_changed(self, t: int, lab: int, previous_label: int) -> None:
|
|
776
|
+
if lab == previous_label:
|
|
777
|
+
return
|
|
778
|
+
import logging as _logging
|
|
779
|
+
if self._selection_callback is not None:
|
|
780
|
+
try:
|
|
781
|
+
self._selection_callback(t, lab)
|
|
782
|
+
except Exception:
|
|
783
|
+
_logging.getLogger("itasc.correction").exception(
|
|
784
|
+
"selection_callback failed"
|
|
785
|
+
)
|
|
786
|
+
for listener in list(self._selection_listeners):
|
|
787
|
+
try:
|
|
788
|
+
listener(t, lab)
|
|
789
|
+
except Exception:
|
|
790
|
+
_logging.getLogger("itasc.correction").exception(
|
|
791
|
+
"selection_listener failed"
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
def _update_highlight(self, t: int, lab: int, *, notify: bool = True) -> None:
|
|
795
|
+
"""Spotlight the selected cell (or clear it when nothing is selected).
|
|
796
|
+
|
|
797
|
+
The spotlight is the sole selection indicator: full brightness inside
|
|
798
|
+
the selected cell's mask (or a provider-widened mask, e.g. a whole
|
|
799
|
+
track's union), uniformly dimmed everywhere outside.
|
|
800
|
+
"""
|
|
801
|
+
previous_label = self._selected_label
|
|
802
|
+
self._selected_label = lab
|
|
803
|
+
self._selected_t = t if lab != 0 else -1
|
|
804
|
+
old = self._goto_cell_id.blockSignals(True)
|
|
805
|
+
try:
|
|
806
|
+
self._goto_cell_id.setValue(int(lab))
|
|
807
|
+
finally:
|
|
808
|
+
self._goto_cell_id.blockSignals(old)
|
|
809
|
+
if lab == 0 or self._layer is None:
|
|
810
|
+
self._clear_spotlight()
|
|
811
|
+
if notify:
|
|
812
|
+
self._notify_selection_changed(t, lab, previous_label)
|
|
813
|
+
return
|
|
814
|
+
seg2d = self._frame_view(self._layer, t)
|
|
815
|
+
mask = seg2d == lab
|
|
816
|
+
if not mask.any():
|
|
817
|
+
self._selected_label = 0
|
|
818
|
+
self._selected_t = -1
|
|
819
|
+
self._clear_spotlight()
|
|
820
|
+
if notify:
|
|
821
|
+
self._notify_selection_changed(t, 0, previous_label)
|
|
822
|
+
return
|
|
823
|
+
# The border style outlines just the selected cell; only the dimming
|
|
824
|
+
# spotlight widens to a provider mask (e.g. a whole track's union).
|
|
825
|
+
if self._highlight_style == "border":
|
|
826
|
+
highlight_mask = mask
|
|
827
|
+
else:
|
|
828
|
+
highlight_mask = self._resolve_spotlight_mask(t, lab, mask)
|
|
829
|
+
self._update_spotlight(highlight_mask)
|
|
830
|
+
self.viewer.layers.selection.active = self._layer
|
|
831
|
+
if notify:
|
|
832
|
+
self._notify_selection_changed(t, lab, previous_label)
|
|
833
|
+
|
|
834
|
+
def _resolve_spotlight_mask(
|
|
835
|
+
self, t: int, lab: int, default_mask: np.ndarray
|
|
836
|
+
) -> np.ndarray:
|
|
837
|
+
"""Let an optional provider widen the spotlight cutout (e.g. to a whole
|
|
838
|
+
track's union), falling back to the per-frame ``default_mask``."""
|
|
839
|
+
provider = self._spotlight_mask_provider
|
|
840
|
+
if provider is None:
|
|
841
|
+
return default_mask
|
|
842
|
+
try:
|
|
843
|
+
override = provider(t, lab, default_mask)
|
|
844
|
+
except Exception:
|
|
845
|
+
import logging as _logging
|
|
846
|
+
_logging.getLogger("itasc.correction").exception(
|
|
847
|
+
"spotlight_mask_provider failed"
|
|
848
|
+
)
|
|
849
|
+
return default_mask
|
|
850
|
+
if override is None:
|
|
851
|
+
return default_mask
|
|
852
|
+
override = np.asarray(override, dtype=bool)
|
|
853
|
+
if override.shape != default_mask.shape or not override.any():
|
|
854
|
+
return default_mask
|
|
855
|
+
return override
|
|
856
|
+
|
|
857
|
+
def _update_spotlight(self, mask: np.ndarray) -> None:
|
|
858
|
+
# The mask is the selected cell, or the whole track's union when a
|
|
859
|
+
# provider widens it. Two render styles:
|
|
860
|
+
# "spotlight" — full brightness inside the mask (alpha 0), uniformly
|
|
861
|
+
# dimmed everywhere outside it.
|
|
862
|
+
# "border" — a bright opaque ring around the mask, transparent
|
|
863
|
+
# elsewhere (leaves the rest of the frame untouched).
|
|
864
|
+
spotlight = self._get_spotlight_layer()
|
|
865
|
+
border = self._highlight_style == "border"
|
|
866
|
+
data = _spotlight_rgba(mask, dim=not border, border=border)
|
|
867
|
+
spotlight.data = data
|
|
868
|
+
spotlight.visible = True
|
|
869
|
+
if self._layer is not None:
|
|
870
|
+
self.viewer.layers.selection.active = self._layer
|
|
871
|
+
|
|
872
|
+
def _clear_spotlight(self) -> None:
|
|
873
|
+
if _SPOTLIGHT_LAYER in self.viewer.layers:
|
|
874
|
+
spotlight = self.viewer.layers[_SPOTLIGHT_LAYER]
|
|
875
|
+
spotlight.data = np.zeros((1, 1, 4), dtype=np.float32)
|
|
876
|
+
spotlight.visible = False
|
|
877
|
+
|
|
878
|
+
def _cleanup_spotlight_layer(self) -> None:
|
|
879
|
+
if _SPOTLIGHT_LAYER in self.viewer.layers:
|
|
880
|
+
self.viewer.layers.remove(self.viewer.layers[_SPOTLIGHT_LAYER])
|
|
881
|
+
|
|
882
|
+
def _on_dims_change(self, event=None) -> None:
|
|
883
|
+
if not (self._selected_label and self._layer is not None):
|
|
884
|
+
return
|
|
885
|
+
step = self.viewer.dims.current_step
|
|
886
|
+
if self._layer.data.ndim < 3 or len(step) < self._layer.data.ndim:
|
|
887
|
+
return
|
|
888
|
+
t = int(step[0])
|
|
889
|
+
if t >= self._layer.data.shape[0]:
|
|
890
|
+
return
|
|
891
|
+
selected_label = self._selected_label
|
|
892
|
+
selected_pos = self._selected_pos
|
|
893
|
+
selected_t = self._selected_t
|
|
894
|
+
self._update_highlight(t, selected_label, notify=False)
|
|
895
|
+
self._selected_label = selected_label
|
|
896
|
+
self._selected_pos = selected_pos
|
|
897
|
+
self._selected_t = selected_t
|
|
898
|
+
|
|
899
|
+
def _on_layer_data_changed(self, event=None) -> None:
|
|
900
|
+
if not (self._selected_label and self._layer is not None):
|
|
901
|
+
return
|
|
902
|
+
step = self.viewer.dims.current_step
|
|
903
|
+
if self._layer.data.ndim < 3 or len(step) < self._layer.data.ndim:
|
|
904
|
+
return
|
|
905
|
+
t = int(step[0])
|
|
906
|
+
if t >= self._layer.data.shape[0]:
|
|
907
|
+
return
|
|
908
|
+
self._update_highlight(t, self._selected_label)
|
|
909
|
+
|
|
910
|
+
def _on_layer_mode_change(self, event=None) -> None:
|
|
911
|
+
if self._layer is None:
|
|
912
|
+
return
|
|
913
|
+
mode = getattr(event, "value", None) or self._layer.mode
|
|
914
|
+
log.debug("_on_layer_mode_change: mode=%s", mode)
|
|
915
|
+
if mode != "pan_zoom":
|
|
916
|
+
self._reset_mode_btn.setVisible(True)
|
|
917
|
+
self._set_status("Tool mode changed — corrections disabled", error=True)
|
|
918
|
+
else:
|
|
919
|
+
self._reset_mode_btn.setVisible(False)
|
|
920
|
+
if self._layer is not None:
|
|
921
|
+
self._set_status(f"Active on '{self._layer.name}'")
|
|
922
|
+
|
|
923
|
+
def _on_layer_removed(self, event=None) -> None:
|
|
924
|
+
removed = getattr(event, "value", None)
|
|
925
|
+
removed_name = getattr(removed, "name", None)
|
|
926
|
+
helper_names = {_DRAW_LAYER, _SPOTLIGHT_LAYER}
|
|
927
|
+
if removed is self._layer or removed_name in helper_names:
|
|
928
|
+
log.debug("_on_layer_removed: '%s' removed, deactivating", removed_name)
|
|
929
|
+
self._deactivate()
|
|
930
|
+
|
|
931
|
+
def _reset_tool_mode(self) -> None:
|
|
932
|
+
if self._layer is not None:
|
|
933
|
+
self._layer.mode = "pan_zoom"
|
|
934
|
+
|
|
935
|
+
# ── inspect cell ──────────────────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
def _goto_cell(self) -> None:
|
|
938
|
+
lab = self._goto_cell_id.value()
|
|
939
|
+
if lab == 0:
|
|
940
|
+
step = self.viewer.dims.current_step
|
|
941
|
+
t = int(step[0]) if (self._layer is not None and self._layer.data.ndim >= 3 and len(step) >= 1) else 0
|
|
942
|
+
self._update_highlight(t, 0)
|
|
943
|
+
self._inspect_frames_label.setText("")
|
|
944
|
+
return
|
|
945
|
+
if self._layer is None:
|
|
946
|
+
return
|
|
947
|
+
data = self._layer.data
|
|
948
|
+
frames = [i for i in range(data.shape[0]) if np.any(data[i] == lab)]
|
|
949
|
+
if not frames:
|
|
950
|
+
self._inspect_frames_label.setText(f"Cell {lab} not found in any frame.")
|
|
951
|
+
step = self.viewer.dims.current_step
|
|
952
|
+
t = int(step[0]) if len(step) >= 1 else 0
|
|
953
|
+
self._update_highlight(t, 0)
|
|
954
|
+
return
|
|
955
|
+
_MAX = 20
|
|
956
|
+
if len(frames) <= _MAX:
|
|
957
|
+
frames_str = ", ".join(str(f) for f in frames)
|
|
958
|
+
else:
|
|
959
|
+
shown = ", ".join(str(f) for f in frames[:_MAX])
|
|
960
|
+
frames_str = f"{shown}, … ({len(frames)} frames total)"
|
|
961
|
+
self._inspect_frames_label.setText(f"Frames: {frames_str}")
|
|
962
|
+
step = self.viewer.dims.current_step
|
|
963
|
+
t = int(step[0]) if len(step) >= 1 else 0
|
|
964
|
+
self._update_highlight(t, lab)
|
|
965
|
+
|
|
966
|
+
# ── selection-agnostic track edits ────────────────────────────────────────
|
|
967
|
+
|
|
968
|
+
def _selected_label_pos(self, seg2d: np.ndarray) -> tuple[float, float] | None:
|
|
969
|
+
"""A representative ``(y, x)`` of the selected label within *seg2d*.
|
|
970
|
+
|
|
971
|
+
The swap edits need a click point for the *selected* cell. Deriving it
|
|
972
|
+
from the label itself lets them work no matter how the cell became
|
|
973
|
+
selected — image click, lineage canvas, gallery, or the goto box. Only
|
|
974
|
+
the image-click path ever recorded a click position, so depending on
|
|
975
|
+
that stored position made these edits silently no-op for every other
|
|
976
|
+
selection source. Returns ``None`` when the selected label is absent
|
|
977
|
+
from this frame (so the caller can fall back to a link/relabel instead
|
|
978
|
+
of a same-frame swap). The returned pixel is the one nearest the
|
|
979
|
+
label's centroid, so it always lands inside the mask.
|
|
980
|
+
"""
|
|
981
|
+
lab = self._selected_label
|
|
982
|
+
if not lab:
|
|
983
|
+
return None
|
|
984
|
+
ys, xs = np.where(seg2d == lab)
|
|
985
|
+
if ys.size == 0:
|
|
986
|
+
return None
|
|
987
|
+
cy, cx = ys.mean(), xs.mean()
|
|
988
|
+
i = int(np.argmin((ys - cy) ** 2 + (xs - cx) ** 2))
|
|
989
|
+
return (float(ys[i]), float(xs[i]))
|
|
990
|
+
|
|
991
|
+
def _spawn_into_selected(self, seg2d, pos, t: int, _layer) -> None:
|
|
992
|
+
"""Ctrl+middle-click: spawn a cell at *pos* carrying the selected ID.
|
|
993
|
+
|
|
994
|
+
The new blob takes the selected cell's label, so it either *merges* into
|
|
995
|
+
the selected cell (when that cell already lives in this frame) or *links*
|
|
996
|
+
the selected track into this frame (when the selected cell is on another
|
|
997
|
+
frame — typically the previous one the user just stepped from). The
|
|
998
|
+
mechanics are identical — paint a fresh region with the selected ID —
|
|
999
|
+
only the status wording differs.
|
|
1000
|
+
"""
|
|
1001
|
+
sel = self._selected_label
|
|
1002
|
+
if not sel:
|
|
1003
|
+
self._set_status("Ctrl+middle-click: select a cell first")
|
|
1004
|
+
return
|
|
1005
|
+
if _label_at(seg2d, pos) != 0:
|
|
1006
|
+
self._set_status(
|
|
1007
|
+
"Ctrl+middle-click empty space to grow / link the selected cell"
|
|
1008
|
+
)
|
|
1009
|
+
return
|
|
1010
|
+
present_here = bool(np.any(seg2d == sel))
|
|
1011
|
+
before = seg2d.copy()
|
|
1012
|
+
ok = add_cell(
|
|
1013
|
+
seg2d,
|
|
1014
|
+
pos,
|
|
1015
|
+
new_label=sel,
|
|
1016
|
+
radius=int(self._cell_radius_spin.value()),
|
|
1017
|
+
image=self._intensity_frame(t),
|
|
1018
|
+
protected_mask=self._protected_mask(t, seg2d),
|
|
1019
|
+
)
|
|
1020
|
+
if not ok:
|
|
1021
|
+
self._set_status("Spawn failed — no room here")
|
|
1022
|
+
return
|
|
1023
|
+
self._record_history(_layer, t, before)
|
|
1024
|
+
_layer.refresh()
|
|
1025
|
+
self._update_highlight(t, sel)
|
|
1026
|
+
if present_here:
|
|
1027
|
+
self._set_status(f"Merged into cell {sel} — Active on '{_layer.name}'")
|
|
1028
|
+
else:
|
|
1029
|
+
self._set_status(f"Linked cell {sel} → t={t} — Active on '{_layer.name}'")
|
|
1030
|
+
|
|
1031
|
+
def _ctrl_right_click_swap(self, seg2d, pos, t: int, _layer) -> None:
|
|
1032
|
+
"""Ctrl+right-click: swap, attach to a track, or run a two-click swap.
|
|
1033
|
+
|
|
1034
|
+
The selected cell's position is derived from its label, so the action
|
|
1035
|
+
fires however the cell was selected — not only after a left-click on it.
|
|
1036
|
+
Context picks the operation:
|
|
1037
|
+
|
|
1038
|
+
* a pending two-click swap → this click is its second cell;
|
|
1039
|
+
* selected cell present in this frame → swap it with the clicked cell;
|
|
1040
|
+
* selected cell on another frame → attach the clicked cell to its track;
|
|
1041
|
+
* nothing usable selected → arm a two-click swap (this is the first cell;
|
|
1042
|
+
a following Ctrl+right-click picks the second).
|
|
1043
|
+
"""
|
|
1044
|
+
lab = _label_at(seg2d, pos)
|
|
1045
|
+
if lab == 0:
|
|
1046
|
+
self._set_status("Swap — click on a cell (not background)")
|
|
1047
|
+
return
|
|
1048
|
+
|
|
1049
|
+
# Finish a pending two-click swap.
|
|
1050
|
+
if self._swap_first_pos is not None:
|
|
1051
|
+
if t != self._swap_first_t:
|
|
1052
|
+
self._swap_first_pos = None
|
|
1053
|
+
self._swap_first_t = -1
|
|
1054
|
+
self._set_status("Frame changed — swap cancelled")
|
|
1055
|
+
return
|
|
1056
|
+
before = seg2d.copy()
|
|
1057
|
+
ok = swap_labels(seg2d, self._swap_first_pos, pos)
|
|
1058
|
+
self._swap_first_pos = None
|
|
1059
|
+
self._swap_first_t = -1
|
|
1060
|
+
if ok:
|
|
1061
|
+
self._record_history(_layer, t, before)
|
|
1062
|
+
_layer.refresh()
|
|
1063
|
+
self._set_status(f"Swapped — Active on '{_layer.name}'")
|
|
1064
|
+
else:
|
|
1065
|
+
self._set_status("Swap failed — click on two different cells")
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
sel_pos = self._selected_label_pos(seg2d)
|
|
1069
|
+
if self._selected_label != 0 and sel_pos is not None:
|
|
1070
|
+
# Selected cell is in this frame → swap it with the clicked cell.
|
|
1071
|
+
if lab == self._selected_label:
|
|
1072
|
+
return
|
|
1073
|
+
before = seg2d.copy()
|
|
1074
|
+
ok = swap_labels(seg2d, sel_pos, pos)
|
|
1075
|
+
if ok:
|
|
1076
|
+
self._record_history(_layer, t, before)
|
|
1077
|
+
_layer.refresh()
|
|
1078
|
+
# Keep the track selected after the swap (it now lives where the
|
|
1079
|
+
# clicked cell was), matching the attach-to-track path so the
|
|
1080
|
+
# selection survives whether or not the frame was occupied.
|
|
1081
|
+
self._update_highlight(t, self._selected_label)
|
|
1082
|
+
self._set_status(f"Swapped — Active on '{_layer.name}'")
|
|
1083
|
+
else:
|
|
1084
|
+
self._set_status("Swap failed — click on two different cells")
|
|
1085
|
+
return
|
|
1086
|
+
|
|
1087
|
+
if self._selected_label != 0 and sel_pos is None:
|
|
1088
|
+
# Selected cell lives on another frame → attach the clicked cell to
|
|
1089
|
+
# its track by relabelling it into the selected ID.
|
|
1090
|
+
before = seg2d.copy()
|
|
1091
|
+
ok = relabel_cell(seg2d, pos, self._selected_label)
|
|
1092
|
+
if ok:
|
|
1093
|
+
self._record_history(_layer, t, before)
|
|
1094
|
+
_layer.refresh()
|
|
1095
|
+
self._update_highlight(t, self._selected_label)
|
|
1096
|
+
self._set_status(
|
|
1097
|
+
f"Attached to track {self._selected_label} — "
|
|
1098
|
+
f"Active on '{_layer.name}'"
|
|
1099
|
+
)
|
|
1100
|
+
else:
|
|
1101
|
+
self._set_status("Attach failed — click on a different cell")
|
|
1102
|
+
return
|
|
1103
|
+
|
|
1104
|
+
# No usable selection → arm a two-click swap.
|
|
1105
|
+
self._swap_first_pos = pos
|
|
1106
|
+
self._swap_first_t = t
|
|
1107
|
+
self._set_status(
|
|
1108
|
+
f"Swap — label {lab} selected, Ctrl+right-click second cell"
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
# ── callback registration ─────────────────────────────────────────────────
|
|
1112
|
+
|
|
1113
|
+
def _register_callbacks(self) -> None:
|
|
1114
|
+
layer = self._layer
|
|
1115
|
+
|
|
1116
|
+
def key_delete(_layer):
|
|
1117
|
+
try:
|
|
1118
|
+
if self._selected_label == 0:
|
|
1119
|
+
self._set_status("No cell selected — left-click a cell first")
|
|
1120
|
+
return
|
|
1121
|
+
t = int(self.viewer.dims.current_step[0])
|
|
1122
|
+
seg2d = self._frame_view(_layer, t)
|
|
1123
|
+
before = seg2d.copy()
|
|
1124
|
+
if erase_cell(seg2d, label=self._selected_label):
|
|
1125
|
+
self._record_history(_layer, t, before)
|
|
1126
|
+
_layer.refresh()
|
|
1127
|
+
self._update_highlight(t, 0)
|
|
1128
|
+
self._set_status(f"Erased — Active on '{_layer.name}'")
|
|
1129
|
+
except Exception as exc:
|
|
1130
|
+
show_error(f"delete error: {exc}")
|
|
1131
|
+
|
|
1132
|
+
# Contour-only mode forbids erasing a cell (it would desync from the
|
|
1133
|
+
# nuclei), so the Delete shortcut is not bound.
|
|
1134
|
+
key_bindings = [] if self._contour_only else [("Delete", key_delete)]
|
|
1135
|
+
for key, fn in key_bindings:
|
|
1136
|
+
layer.bind_key(key, fn, overwrite=True)
|
|
1137
|
+
self._bound_keys.append(key)
|
|
1138
|
+
|
|
1139
|
+
def on_drag(_caller, event):
|
|
1140
|
+
_layer = self._layer
|
|
1141
|
+
if _layer is None:
|
|
1142
|
+
return
|
|
1143
|
+
try:
|
|
1144
|
+
if event.type != "mouse_press":
|
|
1145
|
+
return
|
|
1146
|
+
|
|
1147
|
+
t = int(self.viewer.dims.current_step[0])
|
|
1148
|
+
btn = event.button
|
|
1149
|
+
mods = {m.name for m in event.modifiers}
|
|
1150
|
+
|
|
1151
|
+
seg2d = self._frame_view(_layer, t)
|
|
1152
|
+
pos = _layer.world_to_data(event.position)
|
|
1153
|
+
log.debug(
|
|
1154
|
+
"on_drag: btn=%s mods=%s t=%d selected=%s",
|
|
1155
|
+
btn, mods, t, self._selected_label,
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
if self._contour_only and not (
|
|
1159
|
+
(btn == 1 and mods in (set(), {"Shift"}, {"Control", "Shift"}))
|
|
1160
|
+
or (btn == 2 and mods == {"Shift"})
|
|
1161
|
+
):
|
|
1162
|
+
# Cell labels are tied to the nuclei — only select (left-click),
|
|
1163
|
+
# extend (Shift+left-drag) and carve (Shift+right-drag) are
|
|
1164
|
+
# allowed. Everything else would create/delete/renumber a cell.
|
|
1165
|
+
return
|
|
1166
|
+
|
|
1167
|
+
if btn == 3 and mods == {"Control"}:
|
|
1168
|
+
self._spawn_into_selected(seg2d, pos, t, _layer)
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
if btn == 3 and not mods:
|
|
1172
|
+
lab = _label_at(seg2d, pos)
|
|
1173
|
+
if lab == 0:
|
|
1174
|
+
before = seg2d.copy()
|
|
1175
|
+
ok = add_cell(
|
|
1176
|
+
seg2d,
|
|
1177
|
+
pos,
|
|
1178
|
+
new_label=self._next_free_label(),
|
|
1179
|
+
radius=int(self._cell_radius_spin.value()),
|
|
1180
|
+
image=self._intensity_frame(t),
|
|
1181
|
+
protected_mask=self._protected_mask(t, seg2d),
|
|
1182
|
+
)
|
|
1183
|
+
if ok:
|
|
1184
|
+
self._record_history(_layer, t, before)
|
|
1185
|
+
_layer.refresh()
|
|
1186
|
+
self._set_status(f"Added cell — Active on '{_layer.name}'")
|
|
1187
|
+
else:
|
|
1188
|
+
self._set_status("Add cell failed — no room here")
|
|
1189
|
+
return
|
|
1190
|
+
before = seg2d.copy()
|
|
1191
|
+
if erase_cell(seg2d, label=lab):
|
|
1192
|
+
self._record_history(_layer, t, before)
|
|
1193
|
+
_layer.refresh()
|
|
1194
|
+
if lab == self._selected_label:
|
|
1195
|
+
self._update_highlight(t, 0)
|
|
1196
|
+
self._set_status(f"Erased — Active on '{_layer.name}'")
|
|
1197
|
+
return
|
|
1198
|
+
|
|
1199
|
+
if btn == 2 and mods == {"Control"}:
|
|
1200
|
+
self._ctrl_right_click_swap(seg2d, pos, t, _layer)
|
|
1201
|
+
return
|
|
1202
|
+
|
|
1203
|
+
if btn == 1 and mods == {"Control"}:
|
|
1204
|
+
lab = _label_at(seg2d, pos)
|
|
1205
|
+
if lab == 0:
|
|
1206
|
+
self._set_status("Click on a cell, not background")
|
|
1207
|
+
return
|
|
1208
|
+
if self._selected_label == 0 or lab == self._selected_label:
|
|
1209
|
+
return
|
|
1210
|
+
if not np.any(seg2d == self._selected_label):
|
|
1211
|
+
# Selected cell is on another frame → there is nothing
|
|
1212
|
+
# here to merge with; attaching the clicked cell to its
|
|
1213
|
+
# track is the Ctrl+right-click action instead.
|
|
1214
|
+
self._set_status(
|
|
1215
|
+
"Selected cell is on another frame — Ctrl+right-click "
|
|
1216
|
+
"to attach the clicked cell to its track"
|
|
1217
|
+
)
|
|
1218
|
+
return
|
|
1219
|
+
# Selected cell lives in this frame → merge the two cells.
|
|
1220
|
+
before = seg2d.copy()
|
|
1221
|
+
ok = merge_cells(
|
|
1222
|
+
seg2d, pos, pos,
|
|
1223
|
+
label_a=lab, label_b=self._selected_label,
|
|
1224
|
+
)
|
|
1225
|
+
self._set_status(
|
|
1226
|
+
f"Merged — Active on '{_layer.name}'"
|
|
1227
|
+
if ok else "Merge failed"
|
|
1228
|
+
)
|
|
1229
|
+
if ok:
|
|
1230
|
+
self._record_history(_layer, t, before)
|
|
1231
|
+
_layer.refresh()
|
|
1232
|
+
self._selected_label = 0
|
|
1233
|
+
self._selected_pos = None
|
|
1234
|
+
self._selected_t = -1
|
|
1235
|
+
self._update_highlight(t, _label_at(seg2d, pos))
|
|
1236
|
+
return
|
|
1237
|
+
|
|
1238
|
+
if btn == 1 and not mods:
|
|
1239
|
+
self._swap_first_pos = None
|
|
1240
|
+
self._swap_first_t = -1
|
|
1241
|
+
lab = _label_at(seg2d, pos)
|
|
1242
|
+
self._selected_pos = pos if lab != 0 else None
|
|
1243
|
+
self._selected_t = t if lab != 0 else -1
|
|
1244
|
+
self._update_highlight(t, lab)
|
|
1245
|
+
if lab:
|
|
1246
|
+
self._set_status(f"Selected label {lab} — Active on '{_layer.name}'")
|
|
1247
|
+
else:
|
|
1248
|
+
self._set_status(f"Active on '{_layer.name}'")
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
if mods == {"Shift"} and btn == 2:
|
|
1252
|
+
dl = self._get_draw_layer()
|
|
1253
|
+
dl.data = []
|
|
1254
|
+
dl.visible = True
|
|
1255
|
+
pos_list = [_layer.world_to_data(event.position)]
|
|
1256
|
+
yield
|
|
1257
|
+
while event.type == "mouse_move":
|
|
1258
|
+
pos_list.append(_layer.world_to_data(event.position))
|
|
1259
|
+
if len(pos_list) % 3 == 0:
|
|
1260
|
+
dl.data = [np.array([[p[-2], p[-1]] for p in pos_list])]
|
|
1261
|
+
dl.shape_type = ["path"]
|
|
1262
|
+
yield
|
|
1263
|
+
pos_list.append(_layer.world_to_data(event.position))
|
|
1264
|
+
dl.data = []
|
|
1265
|
+
dl.visible = False
|
|
1266
|
+
self.viewer.layers.selection.active = _layer
|
|
1267
|
+
curlabel = self._selected_label if self._selected_label else None
|
|
1268
|
+
before = seg2d.copy()
|
|
1269
|
+
if self._contour_only:
|
|
1270
|
+
if not self._selected_label:
|
|
1271
|
+
self._set_status("Carve — select a cell first")
|
|
1272
|
+
return
|
|
1273
|
+
ok = carve_into_selected(
|
|
1274
|
+
seg2d, pos_list, selected_label=self._selected_label,
|
|
1275
|
+
)
|
|
1276
|
+
self._set_status(
|
|
1277
|
+
f"Carved into cell {self._selected_label} — "
|
|
1278
|
+
f"Active on '{_layer.name}'"
|
|
1279
|
+
if ok else
|
|
1280
|
+
"Carve failed — draw a line all the way through a neighbour"
|
|
1281
|
+
)
|
|
1282
|
+
else:
|
|
1283
|
+
ok = split_draw(
|
|
1284
|
+
seg2d,
|
|
1285
|
+
pos_list,
|
|
1286
|
+
curlabel=curlabel,
|
|
1287
|
+
new_label=self._next_free_label(),
|
|
1288
|
+
)
|
|
1289
|
+
self._set_status(
|
|
1290
|
+
f"Split — Active on '{_layer.name}'"
|
|
1291
|
+
if ok else "Split draw failed — line did not divide the cell"
|
|
1292
|
+
)
|
|
1293
|
+
if ok:
|
|
1294
|
+
self._record_history(_layer, t, before)
|
|
1295
|
+
_layer.refresh()
|
|
1296
|
+
self._update_highlight(t, self._selected_label)
|
|
1297
|
+
return
|
|
1298
|
+
|
|
1299
|
+
if btn == 1 and mods in ({"Shift"}, {"Control", "Shift"}):
|
|
1300
|
+
dl = self._get_draw_layer()
|
|
1301
|
+
dl.data = []
|
|
1302
|
+
dl.visible = True
|
|
1303
|
+
pos_list = [_layer.world_to_data(event.position)]
|
|
1304
|
+
yield
|
|
1305
|
+
while event.type == "mouse_move":
|
|
1306
|
+
pos_list.append(_layer.world_to_data(event.position))
|
|
1307
|
+
if len(pos_list) % 3 == 0:
|
|
1308
|
+
dl.data = [np.array([[p[-2], p[-1]] for p in pos_list])]
|
|
1309
|
+
dl.shape_type = ["path"]
|
|
1310
|
+
yield
|
|
1311
|
+
pos_list.append(_layer.world_to_data(event.position))
|
|
1312
|
+
dl.data = []
|
|
1313
|
+
dl.visible = False
|
|
1314
|
+
self.viewer.layers.selection.active = _layer
|
|
1315
|
+
curlabel = self._selected_label if self._selected_label else None
|
|
1316
|
+
before = seg2d.copy()
|
|
1317
|
+
ok = draw_cell_path(
|
|
1318
|
+
seg2d,
|
|
1319
|
+
pos_list,
|
|
1320
|
+
curlabel=curlabel,
|
|
1321
|
+
new_label=self._next_free_label(),
|
|
1322
|
+
protected_mask=self._protected_mask(t, seg2d),
|
|
1323
|
+
)
|
|
1324
|
+
self._set_status(
|
|
1325
|
+
f"Drew cell path — Active on '{_layer.name}'"
|
|
1326
|
+
if ok else "Draw failed — stroke too short"
|
|
1327
|
+
)
|
|
1328
|
+
if ok:
|
|
1329
|
+
self._record_history(_layer, t, before)
|
|
1330
|
+
_layer.refresh()
|
|
1331
|
+
self._update_highlight(t, self._selected_label)
|
|
1332
|
+
return
|
|
1333
|
+
|
|
1334
|
+
except Exception as exc:
|
|
1335
|
+
import traceback
|
|
1336
|
+
show_error(f"Correction error: {exc}\n{traceback.format_exc()}")
|
|
1337
|
+
|
|
1338
|
+
self.viewer.mouse_drag_callbacks.append(on_drag)
|
|
1339
|
+
self._drag_callbacks.append(on_drag)
|
|
1340
|
+
|
|
1341
|
+
def _remove_callbacks(self) -> None:
|
|
1342
|
+
for fn in self._drag_callbacks:
|
|
1343
|
+
try:
|
|
1344
|
+
self.viewer.mouse_drag_callbacks.remove(fn)
|
|
1345
|
+
except (ValueError, AttributeError):
|
|
1346
|
+
pass
|
|
1347
|
+
self._drag_callbacks.clear()
|
|
1348
|
+
for key in self._bound_keys:
|
|
1349
|
+
try:
|
|
1350
|
+
if self._layer is not None:
|
|
1351
|
+
self._layer.bind_key(key, None)
|
|
1352
|
+
except Exception:
|
|
1353
|
+
pass
|
|
1354
|
+
self._bound_keys.clear()
|
|
1355
|
+
|
|
1356
|
+
def _toggle_outline(self, checked: bool) -> None:
|
|
1357
|
+
if self._layer is None:
|
|
1358
|
+
self._outline_btn.setChecked(False)
|
|
1359
|
+
return
|
|
1360
|
+
self._layer.contour = 2 if checked else 0
|
|
1361
|
+
|
|
1362
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
1363
|
+
|