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,387 @@
|
|
|
1
|
+
"""Single all-tracks overlay for the nucleus correction workflow.
|
|
2
|
+
|
|
3
|
+
This owns one in-canvas napari ``Tracks`` layer that draws *every* track's
|
|
4
|
+
trajectory through the per-frame nucleus centroids, plus a small "tip" Points
|
|
5
|
+
layer that marks the focused track's position in the current frame with a cross.
|
|
6
|
+
|
|
7
|
+
Two presentation states share the one tracks layer:
|
|
8
|
+
|
|
9
|
+
* **overview** (nothing selected) — all tracks coloured by id, moderately
|
|
10
|
+
transparent;
|
|
11
|
+
* **focus** (a cell selected) — the selected track recoloured bright
|
|
12
|
+
viridis-by-time while every other track fades to a faint translucent grey.
|
|
13
|
+
|
|
14
|
+
The focus switch only rewrites the layer's ``properties``/``color_by`` (an
|
|
15
|
+
O(track) write), never its geometry, so selecting cells stays cheap. The
|
|
16
|
+
geometry is rebuilt (:meth:`refresh`) only when the label stack itself changes.
|
|
17
|
+
|
|
18
|
+
The pure geometry/colour maths lives in
|
|
19
|
+
:mod:`itasc.napari.correction._correction_track_path`; this controller is the
|
|
20
|
+
layer-lifecycle glue.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable, Mapping
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from itasc.napari.correction._correction_track_path import build_all_tracks_data
|
|
29
|
+
from itasc.napari._track_render import _track_label_color_styling
|
|
30
|
+
from itasc.napari.correction._correction_centroids import (
|
|
31
|
+
FOCUS_CROSS_COLOR,
|
|
32
|
+
NEUTRAL_OVERLAY_COLOR,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
TRACK_LAYER = "[Correction] Nucleus tracks"
|
|
36
|
+
TRACK_TIP_LAYER = "[Correction] Track Tip"
|
|
37
|
+
TRACK_TAIL_WIDTH = 4
|
|
38
|
+
# Trailing history shown behind the current frame, in frames. Short so the
|
|
39
|
+
# overview reads as a comet around the current frame instead of the whole path.
|
|
40
|
+
TRACK_TAIL_LENGTH = 15
|
|
41
|
+
OVERVIEW_COLORMAP = "hsv"
|
|
42
|
+
FOCUS_COLORMAP = "viridis"
|
|
43
|
+
# The overview is drawn fully opaque in both view modes so the tracks read as
|
|
44
|
+
# strongly as the labels they're coloured to match.
|
|
45
|
+
OVERVIEW_OPACITY = 1.0
|
|
46
|
+
FILLED_OVERVIEW_OPACITY = 1.0
|
|
47
|
+
FOCUS_OPACITY = 1.0
|
|
48
|
+
TIP_CROSS_SIZE = 5
|
|
49
|
+
|
|
50
|
+
_NEUTRAL_TRACKS_COLORMAP = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _neutral_tracks_colormap():
|
|
54
|
+
"""A single-colour colormap so every track vertex reads :data:`NEUTRAL_OVERLAY_COLOR`.
|
|
55
|
+
|
|
56
|
+
A ``Tracks`` layer always colours by a property through a colormap; mapping
|
|
57
|
+
every value to one colour (two identical stops) yields a uniform neutral
|
|
58
|
+
overview regardless of ``color_by``. Built lazily and cached.
|
|
59
|
+
"""
|
|
60
|
+
global _NEUTRAL_TRACKS_COLORMAP
|
|
61
|
+
if _NEUTRAL_TRACKS_COLORMAP is None:
|
|
62
|
+
from napari.utils.colormaps import Colormap
|
|
63
|
+
|
|
64
|
+
rgba = list(NEUTRAL_OVERLAY_COLOR)
|
|
65
|
+
_NEUTRAL_TRACKS_COLORMAP = Colormap([rgba, rgba], name="neutral_overview")
|
|
66
|
+
return _NEUTRAL_TRACKS_COLORMAP
|
|
67
|
+
|
|
68
|
+
_CORRECTION_TRACKS_CLS = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _correction_tracks_cls():
|
|
72
|
+
"""A ``Tracks`` subclass whose temporal tail fade can be forced on/off.
|
|
73
|
+
|
|
74
|
+
napari's ``Tracks.use_fade`` is read-only (it fades whenever the time axis
|
|
75
|
+
is hidden, i.e. always in a T/Y/X viewer). We need the focused track to be
|
|
76
|
+
fully opaque end-to-end (``use_fade`` off → the shader pins every vertex's
|
|
77
|
+
alpha to 1.0) while the overview keeps its fading comet, so the layer carries
|
|
78
|
+
an override flag the controller toggles per focus state. Built lazily so the
|
|
79
|
+
module stays import-light. ``use_fade`` is read back by the vispy layer on
|
|
80
|
+
the appearance events (tail/head/color_by) the controller fires anyway.
|
|
81
|
+
"""
|
|
82
|
+
global _CORRECTION_TRACKS_CLS
|
|
83
|
+
if _CORRECTION_TRACKS_CLS is None:
|
|
84
|
+
from napari.layers import Tracks
|
|
85
|
+
|
|
86
|
+
class _CorrectionTracks(Tracks):
|
|
87
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
88
|
+
self._fade_override: bool | None = None
|
|
89
|
+
super().__init__(*args, **kwargs)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def use_fade(self) -> bool:
|
|
93
|
+
if self._fade_override is not None:
|
|
94
|
+
return self._fade_override
|
|
95
|
+
return 0 in self._slice_input.not_displayed
|
|
96
|
+
|
|
97
|
+
@use_fade.setter
|
|
98
|
+
def use_fade(self, value: bool | None) -> None:
|
|
99
|
+
self._fade_override = None if value is None else bool(value)
|
|
100
|
+
|
|
101
|
+
_CORRECTION_TRACKS_CLS = _CorrectionTracks
|
|
102
|
+
return _CORRECTION_TRACKS_CLS
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AllTracksController:
|
|
106
|
+
"""Own the single all-tracks layer and its current-frame tip cross."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
viewer,
|
|
111
|
+
*,
|
|
112
|
+
tracked_layer_provider: Callable[[], object | None],
|
|
113
|
+
selected_label_provider: Callable[[], int],
|
|
114
|
+
enabled_provider: Callable[[], bool],
|
|
115
|
+
current_t_provider: Callable[[], int],
|
|
116
|
+
status_callback: Callable[[str], None],
|
|
117
|
+
owned_layers: set[str],
|
|
118
|
+
) -> None:
|
|
119
|
+
self.viewer = viewer
|
|
120
|
+
self._tracked_layer_provider = tracked_layer_provider
|
|
121
|
+
self._selected_label_provider = selected_label_provider
|
|
122
|
+
self._enabled_provider = enabled_provider
|
|
123
|
+
self._current_t_provider = current_t_provider
|
|
124
|
+
self._status = status_callback
|
|
125
|
+
self._owned_layers = owned_layers
|
|
126
|
+
# Cached from the last refresh so focusing a track can slice its vertices
|
|
127
|
+
# out of the full data without rescanning the stack. All share one order.
|
|
128
|
+
self._data: np.ndarray = np.empty((0, 4), dtype=float)
|
|
129
|
+
self._properties: dict[str, np.ndarray] = {}
|
|
130
|
+
self._row_index: dict[int, np.ndarray] = {}
|
|
131
|
+
self._span: int = 1
|
|
132
|
+
# False = default outline view (neutral single colour, translucent);
|
|
133
|
+
# True = filled view (per-id hsv colour, opaque). Only changes the
|
|
134
|
+
# *overview* presentation — a focused track stays viridis-by-time.
|
|
135
|
+
self._filled_mode: bool = False
|
|
136
|
+
|
|
137
|
+
# ── lifecycle ──────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def refresh(self) -> None:
|
|
140
|
+
"""Rebuild the tracks layer from the current stack, then re-apply focus."""
|
|
141
|
+
if not self._enabled_provider():
|
|
142
|
+
self.clear()
|
|
143
|
+
return
|
|
144
|
+
layer = self._tracked_layer_provider()
|
|
145
|
+
if layer is None:
|
|
146
|
+
self.clear()
|
|
147
|
+
return
|
|
148
|
+
data = np.asarray(layer.data)
|
|
149
|
+
rows, properties, row_index = build_all_tracks_data(data)
|
|
150
|
+
# Per-vertex position in [0, 1] used to colour each track by its label
|
|
151
|
+
# colour (see _apply_overview_coloring). Colour-independent, so it is
|
|
152
|
+
# cached once with the geometry; the colormap is rebuilt per view mode.
|
|
153
|
+
styling = _track_label_color_styling(properties.get("track_id", []), {})
|
|
154
|
+
if styling is not None:
|
|
155
|
+
properties = {**properties, "label_pos": styling[1]}
|
|
156
|
+
self._data = rows
|
|
157
|
+
self._properties = properties
|
|
158
|
+
self._row_index = row_index
|
|
159
|
+
if len(rows) == 0:
|
|
160
|
+
self.clear()
|
|
161
|
+
return
|
|
162
|
+
self._span = max(int(data.shape[0]) if data.ndim == 3 else 1, 1)
|
|
163
|
+
self._ensure_layer()
|
|
164
|
+
self.set_focus(int(self._selected_label_provider() or 0))
|
|
165
|
+
self._status(f"Tracks: {len(row_index)} track(s).")
|
|
166
|
+
|
|
167
|
+
def clear(self) -> None:
|
|
168
|
+
"""Remove the tracks + tip layers from the viewer."""
|
|
169
|
+
for name in (TRACK_LAYER, TRACK_TIP_LAYER):
|
|
170
|
+
if name in self.viewer.layers:
|
|
171
|
+
self.viewer.layers.remove(self.viewer.layers[name])
|
|
172
|
+
self._owned_layers.discard(name)
|
|
173
|
+
self._data = np.empty((0, 4), dtype=float)
|
|
174
|
+
self._properties = {}
|
|
175
|
+
self._row_index = {}
|
|
176
|
+
|
|
177
|
+
# ── view mode (outline-neutral ↔ filled-by-id overview) ───────────────────
|
|
178
|
+
|
|
179
|
+
def set_filled_mode(self, filled: bool) -> None:
|
|
180
|
+
"""Switch the overview between neutral (outline view) and by-id (filled view).
|
|
181
|
+
|
|
182
|
+
Re-applies the current focus so the live layer picks up the new overview
|
|
183
|
+
colour/opacity; a focused single track is unaffected (still by time).
|
|
184
|
+
"""
|
|
185
|
+
self._filled_mode = bool(filled)
|
|
186
|
+
if self._tracks_layer() is not None:
|
|
187
|
+
self.set_focus(int(self._selected_label_provider() or 0))
|
|
188
|
+
|
|
189
|
+
def _overview_opacity(self) -> float:
|
|
190
|
+
return FILLED_OVERVIEW_OPACITY if self._filled_mode else OVERVIEW_OPACITY
|
|
191
|
+
|
|
192
|
+
def _label_color_dict(self):
|
|
193
|
+
"""The active labels layer's per-id colour dict, or None."""
|
|
194
|
+
layer = self._tracked_layer_provider()
|
|
195
|
+
raw = getattr(getattr(layer, "colormap", None), "color_dict", None)
|
|
196
|
+
return raw if isinstance(raw, Mapping) else None
|
|
197
|
+
|
|
198
|
+
def _apply_overview_coloring(self, layer) -> None:
|
|
199
|
+
"""Colour the overview by mode: per-id label colours (filled) or one neutral colour.
|
|
200
|
+
|
|
201
|
+
A ``Tracks`` layer's ``colormap`` is a *registered name*, so custom
|
|
202
|
+
colours are supplied through ``colormaps_dict`` (keyed by the ``color_by``
|
|
203
|
+
property, which bypasses the name registry and the property
|
|
204
|
+
normalisation). Assigning ``color_by`` re-runs the recolour.
|
|
205
|
+
|
|
206
|
+
Filled mode colours each track to match its cell label by reproducing the
|
|
207
|
+
labels layer's colour dict through a step colormap over ``label_pos``
|
|
208
|
+
(same technique as the contact-analysis tracks). Outline mode keeps the
|
|
209
|
+
single neutral colour that matches the neutral label outlines.
|
|
210
|
+
"""
|
|
211
|
+
styling = None
|
|
212
|
+
if self._filled_mode and "label_pos" in self._properties:
|
|
213
|
+
color_dict = self._label_color_dict()
|
|
214
|
+
if color_dict:
|
|
215
|
+
styling = _track_label_color_styling(
|
|
216
|
+
self._properties["track_id"], color_dict
|
|
217
|
+
)
|
|
218
|
+
if styling is not None:
|
|
219
|
+
layer.colormaps_dict = {"label_pos": styling[0]}
|
|
220
|
+
layer.color_by = "label_pos"
|
|
221
|
+
else:
|
|
222
|
+
layer.colormaps_dict = {"track_id": _neutral_tracks_colormap()}
|
|
223
|
+
layer.color_by = "track_id"
|
|
224
|
+
|
|
225
|
+
# ── focus / current-frame presentation ───────────────────────────────────
|
|
226
|
+
|
|
227
|
+
def set_focus(self, lab: int) -> None:
|
|
228
|
+
"""Switch the layer between the overview and a single focused track.
|
|
229
|
+
|
|
230
|
+
Focus mode draws *only* the selected track (the surrounding tracks are
|
|
231
|
+
dropped from the layer entirely — a Tracks layer has no per-track
|
|
232
|
+
visibility) coloured by time and fully opaque, image-like. ``lab == 0``
|
|
233
|
+
(or an absent track) restores the all-tracks overview and hides the tip
|
|
234
|
+
cross. A no-op when the tracks layer is not present (toggle off).
|
|
235
|
+
"""
|
|
236
|
+
lab = int(lab or 0)
|
|
237
|
+
layer = self._tracks_layer()
|
|
238
|
+
if layer is None:
|
|
239
|
+
return
|
|
240
|
+
if not lab or lab not in self._row_index:
|
|
241
|
+
# Overview: every track, a short trailing comet that fades out behind
|
|
242
|
+
# the current frame (no forward head), all dimmed together. Park
|
|
243
|
+
# color_by on the always-present 'track_id' before the data swap so
|
|
244
|
+
# napari doesn't warn about the old key vanishing.
|
|
245
|
+
layer.use_fade = None # default (fade on, scaled by tail_length)
|
|
246
|
+
layer.color_by = "track_id"
|
|
247
|
+
layer.data = self._data
|
|
248
|
+
layer.properties = self._properties
|
|
249
|
+
layer.color_by = "track_id"
|
|
250
|
+
self._apply_overview_coloring(layer)
|
|
251
|
+
layer.tail_length = min(TRACK_TAIL_LENGTH, self._span)
|
|
252
|
+
layer.head_length = 0
|
|
253
|
+
layer.opacity = self._overview_opacity()
|
|
254
|
+
self._hide_tip()
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Focus: keep only the selected track's vertices, coloured by its time
|
|
258
|
+
# gradient. use_fade off pins every vertex's alpha to 1.0 so the whole
|
|
259
|
+
# trajectory reads fully opaque (image-like) on every frame; set it before
|
|
260
|
+
# the tail/head/color_by writes the vispy layer reads use_fade back from.
|
|
261
|
+
rows = self._row_index[lab]
|
|
262
|
+
layer.use_fade = False
|
|
263
|
+
# Park color_by on 'track_id' (always present) across the data swap so
|
|
264
|
+
# napari doesn't warn about the previous key vanishing, then colour by time.
|
|
265
|
+
layer.color_by = "track_id"
|
|
266
|
+
layer.data = self._data[rows]
|
|
267
|
+
layer.properties = {
|
|
268
|
+
"track_id": self._properties["track_id"][rows],
|
|
269
|
+
"time": self._properties["time"][rows],
|
|
270
|
+
}
|
|
271
|
+
layer.color_by = "time"
|
|
272
|
+
try:
|
|
273
|
+
layer.colormap = FOCUS_COLORMAP
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
layer.tail_length = self._span
|
|
277
|
+
layer.head_length = self._span
|
|
278
|
+
layer.opacity = FOCUS_OPACITY
|
|
279
|
+
self.set_current_frame(int(self._current_t_provider()))
|
|
280
|
+
self._restore_active_layer()
|
|
281
|
+
|
|
282
|
+
def set_current_frame(self, t: int) -> None:
|
|
283
|
+
"""Move/show the tip cross at the focused track's centroid in frame ``t``."""
|
|
284
|
+
lab = int(self._selected_label_provider() or 0)
|
|
285
|
+
if not self._enabled_provider() or not lab:
|
|
286
|
+
self._hide_tip()
|
|
287
|
+
return
|
|
288
|
+
layer = self._tracked_layer_provider()
|
|
289
|
+
if layer is None:
|
|
290
|
+
self._hide_tip()
|
|
291
|
+
return
|
|
292
|
+
data = np.asarray(layer.data)
|
|
293
|
+
if data.ndim != 3 or not (0 <= int(t) < data.shape[0]):
|
|
294
|
+
self._hide_tip()
|
|
295
|
+
return
|
|
296
|
+
mask = data[int(t)] == lab
|
|
297
|
+
if not mask.any():
|
|
298
|
+
self._hide_tip()
|
|
299
|
+
return
|
|
300
|
+
ys, xs = np.nonzero(mask)
|
|
301
|
+
self._update_tip(np.asarray([[float(ys.mean()), float(xs.mean())]]))
|
|
302
|
+
|
|
303
|
+
def spotlight_mask(self, _t: int, lab: int, _default_mask):
|
|
304
|
+
"""Widen the inner widget's spotlight to the selected track's whole union."""
|
|
305
|
+
if not self._enabled_provider() or not lab:
|
|
306
|
+
return None
|
|
307
|
+
layer = self._tracked_layer_provider()
|
|
308
|
+
if layer is None:
|
|
309
|
+
return None
|
|
310
|
+
data = np.asarray(layer.data)
|
|
311
|
+
if data.ndim != 3:
|
|
312
|
+
return None
|
|
313
|
+
union = np.any(data == int(lab), axis=0)
|
|
314
|
+
return union if union.any() else None
|
|
315
|
+
|
|
316
|
+
# ── layer plumbing ───────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
def _tracks_layer(self):
|
|
319
|
+
from napari.layers import Tracks
|
|
320
|
+
|
|
321
|
+
if TRACK_LAYER in self.viewer.layers:
|
|
322
|
+
layer = self.viewer.layers[TRACK_LAYER]
|
|
323
|
+
if isinstance(layer, Tracks):
|
|
324
|
+
return layer
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
def _ensure_layer(self) -> None:
|
|
328
|
+
"""Create the tracks layer if absent; ``set_focus`` sets its data/colours.
|
|
329
|
+
|
|
330
|
+
Built from the cached full data with overview defaults so there's no
|
|
331
|
+
flash before the ``set_focus`` call that follows every refresh.
|
|
332
|
+
"""
|
|
333
|
+
if self._tracks_layer() is not None:
|
|
334
|
+
return
|
|
335
|
+
if TRACK_LAYER in self.viewer.layers:
|
|
336
|
+
self.viewer.layers.remove(TRACK_LAYER)
|
|
337
|
+
# Build the fade-controllable Tracks subclass directly (rather than
|
|
338
|
+
# viewer.add_tracks, which always makes a plain Tracks) so focus mode can
|
|
339
|
+
# pin the focused track fully opaque.
|
|
340
|
+
layer = _correction_tracks_cls()(
|
|
341
|
+
self._data,
|
|
342
|
+
name=TRACK_LAYER,
|
|
343
|
+
properties=self._properties,
|
|
344
|
+
color_by="track_id",
|
|
345
|
+
colormap=OVERVIEW_COLORMAP,
|
|
346
|
+
tail_width=TRACK_TAIL_WIDTH,
|
|
347
|
+
tail_length=min(TRACK_TAIL_LENGTH, self._span),
|
|
348
|
+
head_length=0,
|
|
349
|
+
blending="translucent",
|
|
350
|
+
opacity=self._overview_opacity(),
|
|
351
|
+
)
|
|
352
|
+
self.viewer.add_layer(layer)
|
|
353
|
+
# Construction takes a registered colormap *name*; apply the mode's real
|
|
354
|
+
# overview colouring (the neutral colormaps_dict, etc.) now. The
|
|
355
|
+
# ``set_focus`` that follows ``refresh`` re-applies it for the live state.
|
|
356
|
+
self._apply_overview_coloring(layer)
|
|
357
|
+
self._owned_layers.add(TRACK_LAYER)
|
|
358
|
+
self._restore_active_layer()
|
|
359
|
+
|
|
360
|
+
def _update_tip(self, points: np.ndarray) -> None:
|
|
361
|
+
if TRACK_TIP_LAYER in self.viewer.layers:
|
|
362
|
+
layer = self.viewer.layers[TRACK_TIP_LAYER]
|
|
363
|
+
layer.data = points
|
|
364
|
+
layer.visible = True
|
|
365
|
+
else:
|
|
366
|
+
self.viewer.add_points(
|
|
367
|
+
points,
|
|
368
|
+
name=TRACK_TIP_LAYER,
|
|
369
|
+
ndim=2,
|
|
370
|
+
symbol="cross",
|
|
371
|
+
size=TIP_CROSS_SIZE,
|
|
372
|
+
face_color=[FOCUS_CROSS_COLOR],
|
|
373
|
+
border_color=[FOCUS_CROSS_COLOR],
|
|
374
|
+
opacity=1.0,
|
|
375
|
+
)
|
|
376
|
+
self._owned_layers.add(TRACK_TIP_LAYER)
|
|
377
|
+
self._restore_active_layer()
|
|
378
|
+
|
|
379
|
+
def _hide_tip(self) -> None:
|
|
380
|
+
if TRACK_TIP_LAYER in self.viewer.layers:
|
|
381
|
+
self.viewer.layers[TRACK_TIP_LAYER].visible = False
|
|
382
|
+
|
|
383
|
+
def _restore_active_layer(self) -> None:
|
|
384
|
+
try:
|
|
385
|
+
self.viewer.layers.selection.active = self._tracked_layer_provider()
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
itasc/napari/ui_gate.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Application-wide UI gating for the ITASC napari plugin.
|
|
2
|
+
|
|
3
|
+
Historically each widget rolled its own ``setEnabled`` logic from local
|
|
4
|
+
booleans, and a handful of overlapping guards (the nucleus "viewer activity"
|
|
5
|
+
guard, the main-widget position lock, per-widget running/preview flags) tried
|
|
6
|
+
to coordinate without knowing about each other. The result was incoherent:
|
|
7
|
+
config-load could move the position out from under an in-progress correction,
|
|
8
|
+
harmless ⚙ params buttons got disabled, and the nucleus section was guarded
|
|
9
|
+
while the cell section was not.
|
|
10
|
+
|
|
11
|
+
``UiGate`` replaces all of that with a single source of truth. The one
|
|
12
|
+
contended resource is **write/auto-update ownership of the viewer**: a
|
|
13
|
+
correction session, the database browser, and every live preview are all
|
|
14
|
+
*viewer owners*, and only one may be active at a time. Headless jobs (writing
|
|
15
|
+
to disk only) run independently of ownership.
|
|
16
|
+
|
|
17
|
+
Every gated control is registered once with a :class:`ControlClass`; on each
|
|
18
|
+
state transition :meth:`UiGate.recompute` derives ``enabled`` (and a tooltip)
|
|
19
|
+
for every control from a single policy table. Widgets never call ``setEnabled``
|
|
20
|
+
on gated controls themselves — they call :meth:`claim_viewer`,
|
|
21
|
+
:meth:`release_viewer`, :meth:`set_task`, or :meth:`recompute` and let the gate
|
|
22
|
+
apply the policy.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import Enum, auto
|
|
29
|
+
|
|
30
|
+
from qtpy.QtCore import QObject, Signal
|
|
31
|
+
from qtpy.QtWidgets import QMessageBox, QWidget
|
|
32
|
+
|
|
33
|
+
ReasonSource = str | Callable[[], str] | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ControlClass(Enum):
|
|
37
|
+
"""How a control relates to viewer ownership and the data context.
|
|
38
|
+
|
|
39
|
+
The class — not the widget it lives in — determines when a control is
|
|
40
|
+
enabled. See the policy table in :meth:`UiGate._enabled_for`.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
#: A checkable control that *enters* an exclusive viewer-owner state
|
|
44
|
+
#: (correction, db-browser, a live preview). Mutually exclusive: enabled
|
|
45
|
+
#: only when nothing else owns the viewer, or this control owns it.
|
|
46
|
+
VIEWER_OWNER = auto()
|
|
47
|
+
|
|
48
|
+
#: Launches a job that mutates state an active viewer owner depends on
|
|
49
|
+
#: (live previews, pipeline rebuilds of the data being viewed). Blocked
|
|
50
|
+
#: while any viewer owner is active.
|
|
51
|
+
RUN_VIEWER = auto()
|
|
52
|
+
|
|
53
|
+
#: Launches a disk-only job that no active owner is viewing. Independent of
|
|
54
|
+
#: viewer ownership; gated only by its own ``when`` predicate.
|
|
55
|
+
RUN_HEADLESS = auto()
|
|
56
|
+
|
|
57
|
+
#: Swaps the underlying data (position, project directory, Load Config).
|
|
58
|
+
#: Always *enabled*; the danger is guarded at click time via
|
|
59
|
+
#: :meth:`UiGate.confirm_context_change`, which offers to exit the owner
|
|
60
|
+
#: first.
|
|
61
|
+
CONTEXT_CHANGING = auto()
|
|
62
|
+
|
|
63
|
+
#: No viewer/context side effect (⚙ params dialogs, Save Config, param
|
|
64
|
+
#: spinboxes). Always enabled.
|
|
65
|
+
HARMLESS = auto()
|
|
66
|
+
|
|
67
|
+
#: Only meaningful inside one owner (correction cleanup/outline/goto).
|
|
68
|
+
#: Enabled only while that owner is active and its ``when`` holds.
|
|
69
|
+
MODE_LOCAL = auto()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class _Registration:
|
|
74
|
+
control: object
|
|
75
|
+
klass: ControlClass
|
|
76
|
+
owner_token: str | None = None
|
|
77
|
+
when: Callable[[], bool] | None = None
|
|
78
|
+
reason: ReasonSource = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class _Owner:
|
|
83
|
+
label: str
|
|
84
|
+
exit_fn: Callable[[], None]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class _GateState:
|
|
89
|
+
owner: str | None = None
|
|
90
|
+
tasks: set[str] = field(default_factory=set)
|
|
91
|
+
owners: dict[str, _Owner] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UiGate(QObject):
|
|
95
|
+
"""Single source of truth for control enablement across the plugin."""
|
|
96
|
+
|
|
97
|
+
changed = Signal()
|
|
98
|
+
|
|
99
|
+
def __init__(self, parent: QObject | None = None) -> None:
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self._state = _GateState()
|
|
102
|
+
self._registrations: list[_Registration] = []
|
|
103
|
+
self._gated_disabled: set[int] = set()
|
|
104
|
+
self._idle_tooltips: dict[int, str] = {}
|
|
105
|
+
#: Overridable in tests; returns True if the user confirms exiting the
|
|
106
|
+
#: active owner. Defaults to a modal yes/no dialog.
|
|
107
|
+
self.confirm_handler: Callable[[QWidget | None, str], bool] = (
|
|
108
|
+
self._default_confirm
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ── Registration ──────────────────────────────────────────────────────
|
|
112
|
+
def register(
|
|
113
|
+
self,
|
|
114
|
+
control,
|
|
115
|
+
klass: ControlClass,
|
|
116
|
+
*,
|
|
117
|
+
owner_token: str | None = None,
|
|
118
|
+
when: Callable[[], bool] | None = None,
|
|
119
|
+
reason: ReasonSource = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Register *control* under *klass*.
|
|
122
|
+
|
|
123
|
+
``owner_token`` ties a ``VIEWER_OWNER``/``MODE_LOCAL`` control to a
|
|
124
|
+
viewer-owner token. ``when`` is an optional zero-arg predicate for
|
|
125
|
+
additional readiness (e.g. inputs present, stage not running);
|
|
126
|
+
``reason`` is the tooltip shown while the control is gate-disabled.
|
|
127
|
+
"""
|
|
128
|
+
self._registrations.append(
|
|
129
|
+
_Registration(control, klass, owner_token, when, reason)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def register_owner(
|
|
133
|
+
self, token: str, label: str, exit_fn: Callable[[], None]
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Describe a viewer-owner token: a human label and how to leave it."""
|
|
136
|
+
self._state.owners[token] = _Owner(label, exit_fn)
|
|
137
|
+
|
|
138
|
+
# ── State transitions ─────────────────────────────────────────────────
|
|
139
|
+
@property
|
|
140
|
+
def owner(self) -> str | None:
|
|
141
|
+
return self._state.owner
|
|
142
|
+
|
|
143
|
+
def owner_label(self) -> str | None:
|
|
144
|
+
"""Human label of the active viewer owner, or ``None`` when idle."""
|
|
145
|
+
owner = self._state.owner
|
|
146
|
+
info = self._state.owners.get(owner) if owner else None
|
|
147
|
+
return info.label if info else None
|
|
148
|
+
|
|
149
|
+
def claim_viewer(self, token: str) -> None:
|
|
150
|
+
"""Mark *token* as the active viewer owner and recompute."""
|
|
151
|
+
if self._state.owner == token:
|
|
152
|
+
return
|
|
153
|
+
self._state.owner = token
|
|
154
|
+
self.recompute()
|
|
155
|
+
|
|
156
|
+
def release_viewer(self, token: str) -> None:
|
|
157
|
+
"""Clear ownership if *token* currently owns the viewer."""
|
|
158
|
+
if self._state.owner != token:
|
|
159
|
+
return
|
|
160
|
+
self._state.owner = None
|
|
161
|
+
self.recompute()
|
|
162
|
+
|
|
163
|
+
def set_task(self, token: str, running: bool) -> None:
|
|
164
|
+
"""Track a running job (headless or viewer-writing) and recompute."""
|
|
165
|
+
before = len(self._state.tasks)
|
|
166
|
+
if running:
|
|
167
|
+
self._state.tasks.add(token)
|
|
168
|
+
else:
|
|
169
|
+
self._state.tasks.discard(token)
|
|
170
|
+
if len(self._state.tasks) != before or running:
|
|
171
|
+
self.recompute()
|
|
172
|
+
|
|
173
|
+
# ── Context-change guard ──────────────────────────────────────────────
|
|
174
|
+
def can_change_context(self) -> bool:
|
|
175
|
+
"""True when no viewer owner is active, so a context change is safe."""
|
|
176
|
+
return self._state.owner is None
|
|
177
|
+
|
|
178
|
+
def confirm_context_change(
|
|
179
|
+
self, parent, action: Callable[[], None]
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""Run *action*, first offering to exit the active owner if any.
|
|
182
|
+
|
|
183
|
+
Returns ``True`` if *action* ran, ``False`` if the user declined.
|
|
184
|
+
"""
|
|
185
|
+
owner = self._state.owner
|
|
186
|
+
if owner is None:
|
|
187
|
+
action()
|
|
188
|
+
return True
|
|
189
|
+
info = self._state.owners.get(owner)
|
|
190
|
+
label = info.label if info else "the current mode"
|
|
191
|
+
if not self.confirm_handler(parent, label):
|
|
192
|
+
return False
|
|
193
|
+
if info is not None:
|
|
194
|
+
info.exit_fn()
|
|
195
|
+
action()
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _default_confirm(parent, label: str) -> bool:
|
|
200
|
+
reply = QMessageBox.question(
|
|
201
|
+
parent,
|
|
202
|
+
f"Exit {label}?",
|
|
203
|
+
f"This will exit {label}. Continue?",
|
|
204
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
205
|
+
QMessageBox.No,
|
|
206
|
+
)
|
|
207
|
+
return reply == QMessageBox.Yes
|
|
208
|
+
|
|
209
|
+
# ── Policy ────────────────────────────────────────────────────────────
|
|
210
|
+
def _predicate(self, reg: _Registration) -> bool:
|
|
211
|
+
if reg.when is None:
|
|
212
|
+
return True
|
|
213
|
+
try:
|
|
214
|
+
return bool(reg.when())
|
|
215
|
+
except Exception:
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
def _enabled_for(self, reg: _Registration) -> bool:
|
|
219
|
+
owner = self._state.owner
|
|
220
|
+
klass = reg.klass
|
|
221
|
+
if klass is ControlClass.HARMLESS or klass is ControlClass.CONTEXT_CHANGING:
|
|
222
|
+
return True
|
|
223
|
+
if klass is ControlClass.VIEWER_OWNER:
|
|
224
|
+
return (owner is None or owner == reg.owner_token) and self._predicate(reg)
|
|
225
|
+
if klass is ControlClass.RUN_VIEWER:
|
|
226
|
+
return owner is None and self._predicate(reg)
|
|
227
|
+
if klass is ControlClass.RUN_HEADLESS:
|
|
228
|
+
return self._predicate(reg)
|
|
229
|
+
if klass is ControlClass.MODE_LOCAL:
|
|
230
|
+
return owner == reg.owner_token and self._predicate(reg)
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
def _reason_text(self, reg: _Registration) -> str:
|
|
234
|
+
if callable(reg.reason):
|
|
235
|
+
try:
|
|
236
|
+
return reg.reason()
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
elif isinstance(reg.reason, str):
|
|
240
|
+
return reg.reason
|
|
241
|
+
owner = self._state.owner
|
|
242
|
+
info = self._state.owners.get(owner) if owner else None
|
|
243
|
+
if info is not None:
|
|
244
|
+
if reg.klass is ControlClass.VIEWER_OWNER:
|
|
245
|
+
return f"Exit {info.label} first."
|
|
246
|
+
return f"Unavailable while {info.label} is active."
|
|
247
|
+
return "Currently unavailable."
|
|
248
|
+
|
|
249
|
+
def recompute(self) -> None:
|
|
250
|
+
"""Apply the policy to every registered control."""
|
|
251
|
+
for reg in self._registrations:
|
|
252
|
+
control = reg.control
|
|
253
|
+
key = id(control)
|
|
254
|
+
enabled = self._enabled_for(reg)
|
|
255
|
+
if enabled:
|
|
256
|
+
control.setEnabled(True)
|
|
257
|
+
if key in self._gated_disabled:
|
|
258
|
+
control.setToolTip(self._idle_tooltips.pop(key, control.toolTip()))
|
|
259
|
+
self._gated_disabled.discard(key)
|
|
260
|
+
else:
|
|
261
|
+
if key not in self._gated_disabled:
|
|
262
|
+
self._idle_tooltips[key] = control.toolTip()
|
|
263
|
+
self._gated_disabled.add(key)
|
|
264
|
+
control.setEnabled(False)
|
|
265
|
+
control.setToolTip(self._reason_text(reg))
|
|
266
|
+
self.changed.emit()
|