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.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. 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
@@ -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()