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,1442 @@
1
+ """Ultrack database browser section for the nucleus workflow widget."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from qtpy.QtCore import Qt, QTimer
10
+ from qtpy.QtWidgets import (
11
+ QCheckBox,
12
+ QHBoxLayout,
13
+ QLabel,
14
+ QSizePolicy,
15
+ QSlider,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ from itasc.napari._widget_helpers import (
21
+ add_slider_step_buttons as _add_slider_step_buttons,
22
+ tool_btn as _tool_btn,
23
+ )
24
+ from itasc.napari.ui_style import (
25
+ stage_header_action_button as _stage_header_action_button,
26
+ stage_header_label as _stage_header_label,
27
+ )
28
+ from itasc.napari.widgets import CollapsibleSection
29
+ from itasc.tracking_ultrack.db_query import (
30
+ annotation_name as _ultrack_db_annotation_name,
31
+ link_annotation_counts as _query_ultrack_db_link_annotation_counts,
32
+ node_annotation_metadata as _ultrack_db_node_annotation_metadata,
33
+ node_mask_and_bbox as _node_mask_and_bbox,
34
+ node_preview_metadata as _ultrack_db_node_preview_metadata,
35
+ paint_nodes as _paint_ultrack_db_nodes,
36
+ query_connected_nodes as _query_ultrack_db_connected_nodes,
37
+ query_frame_range as _query_db_frame_range,
38
+ query_middle_frame as _query_ultrack_db_middle_frame,
39
+ query_node_edges as _query_ultrack_db_node_edges,
40
+ query_union_color_classes as _query_union_color_classes,
41
+ query_union_sizes as _query_union_sizes,
42
+ render_union_partition as _render_union_partition,
43
+ summary_text as _ultrack_db_summary_text,
44
+ )
45
+
46
+ # pyqtgraph backs the optional local node-link panel. It is a pure-Python
47
+ # dependency, but guard the import so a missing install degrades to a
48
+ # "panel unavailable" hint instead of breaking the whole browser widget.
49
+ try:
50
+ import pyqtgraph as pg
51
+
52
+ _HAS_PYQTGRAPH = True
53
+ except Exception: # pragma: no cover - exercised only when pyqtgraph is absent
54
+ pg = None
55
+ _HAS_PYQTGRAPH = False
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ _DATABASE_PREFIX = "[Database]"
60
+ _ULTRACK_DB_PREVIEW_LAYER = f"{_DATABASE_PREFIX} Ultrack DB Preview"
61
+ _ULTRACK_DB_SELECTION_LAYER = f"{_DATABASE_PREFIX} Ultrack DB Selection"
62
+ _ULTRACK_DB_ANNOTATION_LAYER = f"{_DATABASE_PREFIX} Ultrack DB Annotations"
63
+ _ULTRACK_DB_NUC_LAYER = f"{_DATABASE_PREFIX} Cellpose nucleus prob"
64
+ _ULTRACK_DB_ANNOTATION_COLORS = {
65
+ 0: np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32),
66
+ 1: np.array([0.0, 0.75, 0.0, 0.75], dtype=np.float32),
67
+ 2: np.array([1.0, 0.0, 0.0, 0.75], dtype=np.float32),
68
+ 3: np.array([0.5, 0.5, 0.5, 0.45], dtype=np.float32),
69
+ }
70
+ # Annotation name -> _ULTRACK_DB_ANNOTATION_COLORS key, so the node-graph panel
71
+ # fills match the in-canvas annotation overlay.
72
+ _ULTRACK_DB_ANNOTATION_KEY = {"REAL": 1, "FAKE": 2, "UNKNOWN": 3}
73
+ # Node-graph panel encoding ranges.
74
+ _NODE_GRAPH_MAX_PER_SIDE = 20
75
+ _NODE_GRAPH_EDGE_WIDTH = (1.0, 6.0)
76
+ _NODE_GRAPH_NODE_DIAMETER = (10.0, 24.0)
77
+ # Perceptual-ish low->high weight ramp (viridis endpoints), RGB 0-255.
78
+ _NODE_GRAPH_WEIGHT_LOW = np.array([59, 82, 139], dtype=float)
79
+ _NODE_GRAPH_WEIGHT_HIGH = np.array([253, 231, 37], dtype=float)
80
+ _NODE_GRAPH_SELECTED_OUTLINE = (0, 255, 255) # cyan, matching the selection layer
81
+ _NODE_GRAPH_CAME_FROM_OUTLINE = (255, 170, 0) # amber, the node you navigated from
82
+
83
+
84
+ class NucleusUltrackDbBrowserWidget(QWidget):
85
+ """Qt controls for browsing a generated Ultrack database."""
86
+
87
+ def __init__(self, parent: QWidget | None = None) -> None:
88
+ super().__init__(parent)
89
+
90
+ self.header = QWidget(parent)
91
+ header_lay = QHBoxLayout(self.header)
92
+ header_lay.setContentsMargins(0, 0, 0, 0)
93
+ header_lay.setSpacing(4)
94
+ self.header_lbl = QLabel("Database Browser")
95
+ _stage_header_label(self.header_lbl, "nucleus")
96
+ self.active_btn = _tool_btn(
97
+ "⏻",
98
+ "Activate database browser.",
99
+ checkable=True,
100
+ )
101
+ self.active_btn.setChecked(False)
102
+ _stage_header_action_button(self.active_btn, "nucleus")
103
+ header_lay.addWidget(self.header_lbl)
104
+ header_lay.addWidget(self.active_btn)
105
+ header_lay.addStretch(1)
106
+
107
+ lay = QVBoxLayout(self)
108
+ lay.setContentsMargins(0, 0, 0, 0)
109
+ lay.setSpacing(4)
110
+
111
+ self.info_lbl = QLabel("—")
112
+ self.info_lbl.setAlignment(
113
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
114
+ )
115
+ self.info_lbl.setWordWrap(True)
116
+ self.info_lbl.setSizePolicy(
117
+ QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding,
118
+ )
119
+ lay.addWidget(self.info_lbl)
120
+
121
+ # Vertical axis (primary): atom-union size — how many atoms are merged
122
+ # into each candidate. 0 = individual atoms, higher = coarser merges.
123
+ self.hierarchy_slider = QSlider(Qt.Horizontal)
124
+ self.hierarchy_slider.setRange(0, 0)
125
+ self.hierarchy_slider.setValue(0)
126
+ self.hierarchy_slider.setToolTip(
127
+ "Union size: number of atoms merged per candidate "
128
+ "(lowest = individual atoms, higher = coarser merges)"
129
+ )
130
+ self.hierarchy_slider.setEnabled(False)
131
+ self.height_lbl = QLabel("—")
132
+ self.height_lbl.setFixedWidth(64)
133
+ self.slider_row = QWidget()
134
+ slider_lay = QHBoxLayout(self.slider_row)
135
+ slider_lay.setContentsMargins(0, 0, 0, 0)
136
+ slider_lay.setSpacing(2)
137
+ _add_slider_step_buttons(slider_lay, self.hierarchy_slider)
138
+ slider_lay.addWidget(self.height_lbl)
139
+ lay.addWidget(self.slider_row)
140
+
141
+ # Horizontal axis: which merge — at the chosen union size, scan the
142
+ # non-overlapping merge groups (color classes) that together show every
143
+ # candidate at least once.
144
+ self.source_slider = QSlider(Qt.Horizontal)
145
+ self.source_slider.setRange(0, 0)
146
+ self.source_slider.setValue(0)
147
+ self.source_slider.setToolTip(
148
+ "Merge view: scan the different non-overlapping merge groups "
149
+ "at the current union size"
150
+ )
151
+ self.source_slider.setEnabled(False)
152
+ self.source_lbl = QLabel("—")
153
+ self.source_lbl.setFixedWidth(64)
154
+ self.source_slider_row = QWidget()
155
+ source_slider_lay = QHBoxLayout(self.source_slider_row)
156
+ source_slider_lay.setContentsMargins(0, 0, 0, 0)
157
+ source_slider_lay.setSpacing(2)
158
+ _add_slider_step_buttons(source_slider_lay, self.source_slider)
159
+ source_slider_lay.addWidget(self.source_lbl)
160
+ lay.addWidget(self.source_slider_row)
161
+
162
+ self.prob_alpha_check = QCheckBox("Node prob transparency")
163
+ self.prob_alpha_check.setToolTip("Modulate label opacity by node probability")
164
+ self.prob_alpha_check.setEnabled(False)
165
+ self.connected_focus_check = QCheckBox("Connected focus")
166
+ self.connected_focus_check.setToolTip(
167
+ "Focus the DB preview on a selected node and its temporal neighbors"
168
+ )
169
+ self.connected_focus_check.setEnabled(False)
170
+ self.annotation_check = QCheckBox("Show DB annotations")
171
+ self.annotation_check.setToolTip("Overlay DB REAL and FAKE annotations")
172
+ self.annotation_check.setEnabled(False)
173
+ for cb in (
174
+ self.prob_alpha_check,
175
+ self.connected_focus_check,
176
+ self.annotation_check,
177
+ ):
178
+ lay.addWidget(cb)
179
+
180
+ # Local node-link panel: predecessors / selected / successors with raw
181
+ # per-edge weights. Collapsible so it never crowds the controls.
182
+ self.node_graph_section = CollapsibleSection(
183
+ "Local node graph",
184
+ self._build_node_graph_panel(),
185
+ expanded=False,
186
+ )
187
+ lay.addWidget(self.node_graph_section)
188
+
189
+ self.status_lbl = QLabel("")
190
+ self.status_lbl.setWordWrap(True)
191
+ self.status_lbl.setVisible(False)
192
+ lay.addWidget(self.status_lbl)
193
+
194
+ self.section: CollapsibleSection | None = None
195
+
196
+ def _build_node_graph_panel(self) -> QWidget:
197
+ """Build the (optional) pyqtgraph node-link panel inner widget.
198
+
199
+ Stores ``node_graph_*`` attributes used by the browser mixin. If
200
+ pyqtgraph is unavailable, only a hint label is shown.
201
+ """
202
+ panel = QWidget()
203
+ panel_lay = QVBoxLayout(panel)
204
+ panel_lay.setContentsMargins(0, 0, 0, 0)
205
+ panel_lay.setSpacing(2)
206
+
207
+ self.node_graph_hint = QLabel("Click a preview node")
208
+ self.node_graph_hint.setWordWrap(True)
209
+ panel_lay.addWidget(self.node_graph_hint)
210
+
211
+ # Per-render scratch state shared with the mixin behavior.
212
+ self.node_graph_text_items: list = []
213
+
214
+ if not _HAS_PYQTGRAPH:
215
+ self.node_graph_widget = None
216
+ self.node_graph_view = None
217
+ self.node_graph_item = None
218
+ self.node_graph_hint.setText(
219
+ "Local node graph unavailable (pyqtgraph not installed)."
220
+ )
221
+ return panel
222
+
223
+ self.node_graph_widget = pg.GraphicsLayoutWidget()
224
+ self.node_graph_widget.setMinimumHeight(180)
225
+ self.node_graph_view = self.node_graph_widget.addViewBox()
226
+ self.node_graph_view.setAspectLocked(True)
227
+ self.node_graph_view.setMouseEnabled(x=True, y=True)
228
+ self.node_graph_view.invertY(True) # match image (y grows downward)
229
+ self.node_graph_item = pg.GraphItem()
230
+ self.node_graph_view.addItem(self.node_graph_item)
231
+ panel_lay.addWidget(self.node_graph_widget)
232
+ return panel
233
+
234
+
235
+ class NucleusUltrackDbBrowserMixin:
236
+ """Behavior for the nucleus Ultrack database browser section."""
237
+
238
+ def _init_ultrack_db_browser_state(self) -> None:
239
+ self._ultrack_db_preview_cache: dict = {}
240
+ self._ultrack_db_summary_cache: dict[tuple, str] = {}
241
+ self._ultrack_db_size_values_cache: dict[tuple, tuple[int, ...]] = {}
242
+ self._ultrack_db_color_class_cache: dict[tuple, tuple[tuple[int, ...], ...]] = {}
243
+ self._ultrack_db_frames_cache: dict[tuple, tuple[int, ...]] = {}
244
+ self._ultrack_db_browser_active: bool = False
245
+ self._ultrack_db_frame_initialized: bool = False
246
+ self._ultrack_db_selected_node_id: int | None = None
247
+ self._ultrack_db_selected_frame: int | None = None
248
+ self._ultrack_db_label_to_node_id: dict[int, int] = {}
249
+ self._ultrack_db_node_id_to_label: dict[int, int] = {}
250
+ self._ultrack_db_node_annotations: dict[int, str] = {}
251
+ self._ultrack_db_label_probabilities: dict[int, float] = {}
252
+ self._ultrack_db_preview_labels: np.ndarray | None = None
253
+ self._ultrack_db_preview_mouse_callback = None
254
+ self._ultrack_db_preview_view_state: dict | None = None
255
+ # Node-graph panel scratch state: rows of the GraphItem aligned with the
256
+ # selected/neighbor node ids, and each node's frame for panel->canvas.
257
+ self._ultrack_db_node_graph_node_ids: list[int] = []
258
+ self._ultrack_db_node_graph_edge_count: int = 0
259
+ self._ultrack_db_node_graph_neighbor_t: dict[int, int] = {}
260
+ # Navigation history for the panel: the node we just navigated away from
261
+ # (set on a panel click, consumed by the next rebuild) and the node id
262
+ # currently marked as "came from" in the displayed graph.
263
+ self._ultrack_db_node_graph_pending_prev: int | None = None
264
+ self._ultrack_db_node_graph_came_from: int | None = None
265
+ self._ultrack_db_refresh_timer = QTimer(self)
266
+ self._ultrack_db_refresh_timer.setSingleShot(True)
267
+ self._ultrack_db_refresh_timer.setInterval(150)
268
+ self._ultrack_db_refresh_timer.timeout.connect(self._refresh_ultrack_db_browser)
269
+
270
+ def _build_db_browser_section(self, root: QVBoxLayout) -> None:
271
+ self.ultrack_db_browser_widget = NucleusUltrackDbBrowserWidget(self)
272
+ self.ultrack_db_browser_widget.section = CollapsibleSection(
273
+ "Database Browser",
274
+ self.ultrack_db_browser_widget,
275
+ expanded=False,
276
+ )
277
+ self.ultrack_db_browser_widget.section._toggle.setVisible(False)
278
+ self.ultrack_db_browser_widget.section._toggle.setEnabled(False)
279
+ self._alias_ultrack_db_browser_controls()
280
+
281
+ def _alias_ultrack_db_browser_controls(self) -> None:
282
+ browser = self.ultrack_db_browser_widget
283
+ self.ultrack_db_browser_header = browser.header
284
+ self.ultrack_db_browser_header_lbl = browser.header_lbl
285
+ self.ultrack_db_browser_section = browser.section
286
+ self.ultrack_db_info_lbl = browser.info_lbl
287
+ self.ultrack_db_source_slider = browser.source_slider
288
+ self.ultrack_db_source_lbl = browser.source_lbl
289
+ self._ultrack_db_source_slider_row = browser.source_slider_row
290
+ self.ultrack_db_hierarchy_slider = browser.hierarchy_slider
291
+ self.ultrack_db_height_lbl = browser.height_lbl
292
+ self._ultrack_db_slider_row = browser.slider_row
293
+ self.ultrack_db_active_btn = browser.active_btn
294
+ self.ultrack_db_prob_alpha_check = browser.prob_alpha_check
295
+ self.ultrack_db_connected_focus_check = browser.connected_focus_check
296
+ self.ultrack_db_annotation_check = browser.annotation_check
297
+ self.ultrack_db_section_status_lbl = browser.status_lbl
298
+ self.ultrack_db_node_graph_section = browser.node_graph_section
299
+ self.ultrack_db_node_graph_widget = browser.node_graph_widget
300
+ self.ultrack_db_node_graph_view = browser.node_graph_view
301
+ self.ultrack_db_node_graph_item = browser.node_graph_item
302
+ self.ultrack_db_node_graph_hint = browser.node_graph_hint
303
+ if browser.node_graph_item is not None:
304
+ # GraphItem draws its nodes via an inner ScatterPlotItem whose
305
+ # sigClicked carries the clicked spots back to us.
306
+ browser.node_graph_item.scatter.sigClicked.connect(
307
+ self._on_node_graph_node_clicked
308
+ )
309
+
310
+ def _set_ultrack_db_status(self, msg: str) -> None:
311
+ self.ultrack_db_section_status_lbl.setText(msg)
312
+ self.ultrack_db_section_status_lbl.setVisible(bool(msg))
313
+ logger.info(msg)
314
+
315
+
316
+ def _schedule_ultrack_db_browser_refresh(self) -> None:
317
+ self._ultrack_db_refresh_timer.start()
318
+
319
+ def _on_ultrack_db_source_changed(self, value: int) -> None:
320
+ """Horizontal axis: pick which merge group (color class) to display."""
321
+ if not self._ultrack_db_browser_active:
322
+ return
323
+ max_merge = self.ultrack_db_source_slider.maximum()
324
+ self._set_ultrack_db_merge_label(value, max_merge + 1)
325
+ self._schedule_ultrack_db_browser_refresh()
326
+
327
+ def _on_ultrack_db_slider_changed(self, value: int) -> None:
328
+ """Vertical axis: pick the union size (number of atoms merged)."""
329
+ if not self._ultrack_db_browser_active:
330
+ return
331
+ db_path = self._ultrack_db_path()
332
+ sizes: tuple[int, ...] = ()
333
+ if db_path is not None and db_path.exists():
334
+ try:
335
+ mtime_ns = db_path.stat().st_mtime_ns
336
+ sizes = self._query_union_sizes(db_path, mtime_ns, self._current_t())
337
+ except Exception:
338
+ sizes = ()
339
+ if sizes:
340
+ index = min(max(int(value), 0), len(sizes) - 1)
341
+ self._set_ultrack_db_size_label(index, sizes[index], len(sizes))
342
+ else:
343
+ self.ultrack_db_height_lbl.setText("—")
344
+ self._schedule_ultrack_db_browser_refresh()
345
+
346
+ def _on_ultrack_db_activate(self, checked: bool) -> None:
347
+ if checked:
348
+ db_path = self._ultrack_db_path()
349
+ if db_path is None or not db_path.exists():
350
+ self._ultrack_db_browser_active = False
351
+ old = self.ultrack_db_active_btn.blockSignals(True)
352
+ try:
353
+ self.ultrack_db_active_btn.setChecked(False)
354
+ finally:
355
+ self.ultrack_db_active_btn.blockSignals(old)
356
+ self._set_ultrack_db_controls_enabled(False)
357
+ self.ultrack_db_browser_section.collapse()
358
+ self._set_ultrack_db_status(
359
+ "data.db not found — run DB Generation first."
360
+ )
361
+ return
362
+
363
+ self._ultrack_db_browser_active = checked
364
+ self._set_ultrack_db_controls_enabled(checked)
365
+ if checked:
366
+ self._capture_ultrack_db_preview_view_state()
367
+ self._hide_non_ultrack_db_preview_layers()
368
+ self.ultrack_db_browser_section.expand()
369
+ self._load_db_browser_nucleus_image()
370
+ self._ultrack_db_frame_initialized = False
371
+ self._refresh_ultrack_db_browser()
372
+ else:
373
+ self._remove_ultrack_db_browser_layers()
374
+ self._restore_ultrack_db_preview_view_state()
375
+ self.ultrack_db_browser_section.collapse()
376
+
377
+ def _set_ultrack_db_controls_enabled(self, enabled: bool) -> None:
378
+ self.ultrack_db_active_btn.setToolTip(
379
+ "Deactivate database browser." if enabled else "Activate database browser."
380
+ )
381
+ self.ultrack_db_source_slider.setEnabled(enabled)
382
+ self.ultrack_db_hierarchy_slider.setEnabled(enabled)
383
+ self.ultrack_db_prob_alpha_check.setEnabled(enabled)
384
+ self.ultrack_db_connected_focus_check.setEnabled(enabled)
385
+ self.ultrack_db_annotation_check.setEnabled(enabled)
386
+ self.ultrack_db_node_graph_section.setEnabled(enabled)
387
+ if not enabled and self._node_graph_available():
388
+ self._clear_node_graph("Click a preview node")
389
+
390
+ def _remove_ultrack_db_browser_layers(self) -> None:
391
+ self._remove_ultrack_db_preview_selector()
392
+ for layer in list(self.viewer.layers):
393
+ if self._is_ultrack_db_preview_layer(layer.name):
394
+ self.viewer.layers.remove(layer)
395
+ self._remove_db_browser_nucleus_image()
396
+ self.ultrack_db_info_lbl.setText("—")
397
+ self._set_ultrack_db_status("")
398
+
399
+ @staticmethod
400
+ def _is_ultrack_db_preview_layer(name: str) -> bool:
401
+ return name.startswith(f"{_DATABASE_PREFIX} ")
402
+
403
+ def _capture_ultrack_db_preview_view_state(self) -> None:
404
+ selected = [layer.name for layer in self.viewer.layers.selection]
405
+ active = self.viewer.layers.selection.active
406
+ self._ultrack_db_preview_view_state = {
407
+ "visibility": {
408
+ layer.name: bool(layer.visible) for layer in self.viewer.layers
409
+ },
410
+ "active": active.name if active is not None else None,
411
+ "selected": selected,
412
+ }
413
+
414
+ def _hide_non_ultrack_db_preview_layers(self) -> None:
415
+ for layer in self.viewer.layers:
416
+ if not self._is_ultrack_db_preview_layer(layer.name):
417
+ layer.visible = False
418
+
419
+ def _restore_ultrack_db_preview_view_state(self) -> None:
420
+ state = self._ultrack_db_preview_view_state or {}
421
+ for name, visible in state.get("visibility", {}).items():
422
+ if name in self.viewer.layers:
423
+ self.viewer.layers[name].visible = bool(visible)
424
+ self.viewer.layers.selection.clear()
425
+ for name in state.get("selected", ()):
426
+ if name in self.viewer.layers:
427
+ self.viewer.layers.selection.add(self.viewer.layers[name])
428
+ active_name = state.get("active")
429
+ if active_name in self.viewer.layers:
430
+ self.viewer.layers.selection.active = self.viewer.layers[active_name]
431
+ self._ultrack_db_preview_view_state = None
432
+
433
+ def _load_db_browser_nucleus_image(self) -> None:
434
+ if _ULTRACK_DB_NUC_LAYER in self.viewer.layers:
435
+ return
436
+ nuc_path = self._nucleus_foreground_path()
437
+ if nuc_path is None or not nuc_path.exists():
438
+ return
439
+ import tifffile
440
+ data = np.asarray(tifffile.imread(str(nuc_path)), dtype=np.float32)
441
+ limits = np.percentile(data, [0.05, 99.5]) if data.size > 0 else None
442
+ kwargs = {
443
+ "name": _ULTRACK_DB_NUC_LAYER,
444
+ "colormap": "bop orange",
445
+ "blending": "minimum",
446
+ }
447
+ if limits is not None and limits[1] > limits[0]:
448
+ kwargs["contrast_limits"] = [float(limits[0]), float(limits[1])]
449
+ self.viewer.add_image(data, **kwargs)
450
+
451
+ def _remove_db_browser_nucleus_image(self) -> None:
452
+ if _ULTRACK_DB_NUC_LAYER in self.viewer.layers:
453
+ self.viewer.layers.remove(_ULTRACK_DB_NUC_LAYER)
454
+
455
+ def _ultrack_db_middle_frame(self, db_path: Path) -> int | None:
456
+ return _query_ultrack_db_middle_frame(db_path)
457
+
458
+ def _viewer_has_time_axis(self) -> bool:
459
+ for layer in self.viewer.layers:
460
+ data = getattr(layer, "data", None)
461
+ if isinstance(data, np.ndarray) and data.ndim >= 3:
462
+ return True
463
+ return False
464
+
465
+ def _query_db_frames(self, db_path: Path, mtime_ns: int) -> tuple[int, ...]:
466
+ key = (str(db_path.resolve()), mtime_ns, "frames")
467
+ cached = self._ultrack_db_frames_cache.get(key)
468
+ if cached is not None:
469
+ return cached
470
+ result = _query_db_frame_range(db_path)
471
+ self._ultrack_db_frames_cache[key] = result
472
+ return result
473
+
474
+ def _resolve_ultrack_db_partition(self, db_path: Path, mtime_ns: int, frame: int):
475
+ """Configure both sliders for ``frame`` and return the normalized preview
476
+ tuple for the current (union size, merge group), or ``None`` if the frame
477
+ has no candidates.
478
+
479
+ Vertical slider → union size (atoms merged per candidate); horizontal slider
480
+ → which non-overlapping merge group (color class) of that size to paint.
481
+ """
482
+ sizes = self._configure_ultrack_db_size_slider(db_path, mtime_ns, frame)
483
+ if not sizes:
484
+ return None
485
+ size_index = min(int(self.ultrack_db_hierarchy_slider.value()), len(sizes) - 1)
486
+ union_size = sizes[size_index]
487
+ classes = self._configure_ultrack_db_merge_slider(
488
+ db_path, mtime_ns, frame, union_size
489
+ )
490
+ if not classes:
491
+ return None
492
+ color_index = min(int(self.ultrack_db_source_slider.value()), len(classes) - 1)
493
+ color_node_ids = classes[color_index]
494
+ key = self._ultrack_db_preview_cache_key(
495
+ db_path, mtime_ns, frame, union_size, color_index,
496
+ )
497
+ cached = self._ultrack_db_preview_cache.get(key)
498
+ if cached is None:
499
+ cached = self._render_union_partition(
500
+ db_path, frame, color_node_ids, union_size
501
+ )
502
+ self._ultrack_db_preview_cache[key] = cached
503
+ return self._normalize_ultrack_db_preview(cached)
504
+
505
+ def _load_full_db_stack(self, db_path: Path) -> None:
506
+ """Create a 3D DB preview stack and render only the initial frame."""
507
+ try:
508
+ mtime_ns = db_path.stat().st_mtime_ns
509
+ frames = self._query_db_frames(db_path, mtime_ns)
510
+ if not frames:
511
+ self._set_ultrack_db_status("No frames in database.")
512
+ return
513
+
514
+ mid_frame = frames[len(frames) // 2]
515
+ self.ultrack_db_info_lbl.setText(
516
+ self._cached_ultrack_db_summary_text(db_path, mtime_ns, mid_frame)
517
+ )
518
+ resolved = self._resolve_ultrack_db_partition(db_path, mtime_ns, mid_frame)
519
+ if resolved is None:
520
+ self._set_ultrack_db_status(f"No candidates for frame {mid_frame}.")
521
+ return
522
+ labels, _status, prob_dict, l2n, n2l, annots = resolved
523
+ self._ultrack_db_label_to_node_id = l2n
524
+ self._ultrack_db_node_id_to_label = n2l
525
+ self._ultrack_db_node_annotations = annots
526
+ self._ultrack_db_label_probabilities = prob_dict
527
+
528
+ n_frames = frames[-1] + 1
529
+ base_h, base_w = self._viewer_plane_shape()
530
+ max_h = max(base_h, labels.shape[0], 1)
531
+ max_w = max(base_w, labels.shape[1], 1)
532
+ stack = np.zeros((n_frames, max_h, max_w), dtype=np.uint32)
533
+ h, w = labels.shape
534
+ stack[mid_frame, :h, :w] = labels
535
+
536
+ self._update_labels_layer(_ULTRACK_DB_PREVIEW_LAYER, stack)
537
+ self._ultrack_db_preview_labels = stack[mid_frame] if mid_frame < n_frames else stack[0]
538
+ self._install_ultrack_db_preview_selector()
539
+ self._restore_ultrack_db_preview_active()
540
+ self._set_viewer_frame(mid_frame)
541
+ self._set_ultrack_db_status(
542
+ f"Loaded frame {mid_frame}/{frames[-1]} from database; "
543
+ "other frames render when visited."
544
+ )
545
+ except Exception as e:
546
+ self._set_ultrack_db_status(f"DB read error: {e}")
547
+ logger.warning("DB browser stack load error: %s", e)
548
+
549
+ def _update_ultrack_db_stack_frame(self, frame: int, labels: np.ndarray) -> bool:
550
+ """Update a single frame slice in a 3D preview stack. Returns True if stack mode is active."""
551
+ if _ULTRACK_DB_PREVIEW_LAYER not in self.viewer.layers:
552
+ return False
553
+ from napari.layers import Labels
554
+ layer = self.viewer.layers[_ULTRACK_DB_PREVIEW_LAYER]
555
+ if not isinstance(layer, Labels) or layer.data.ndim != 3:
556
+ return False
557
+ stack = layer.data
558
+ t = int(frame)
559
+ if t < 0 or t >= stack.shape[0]:
560
+ return False
561
+ if labels.shape[0] > stack.shape[1] or labels.shape[1] > stack.shape[2]:
562
+ expanded = np.zeros(
563
+ (
564
+ stack.shape[0],
565
+ max(stack.shape[1], labels.shape[0]),
566
+ max(stack.shape[2], labels.shape[1]),
567
+ ),
568
+ dtype=stack.dtype,
569
+ )
570
+ expanded[:, : stack.shape[1], : stack.shape[2]] = stack
571
+ stack = expanded
572
+ lh = labels.shape[0]
573
+ lw = labels.shape[1]
574
+ stack[t] = 0
575
+ stack[t, :lh, :lw] = labels
576
+ layer.data = stack
577
+ return True
578
+
579
+ def _refresh_ultrack_db_browser(self) -> None:
580
+ if not self._ultrack_db_browser_active:
581
+ return
582
+ self.ultrack_db_info_lbl.setText("—")
583
+ db_path = self._ultrack_db_path()
584
+ if db_path is None or not db_path.exists():
585
+ self._set_ultrack_db_status("data.db not found — run DB Generation first.")
586
+ return
587
+ frame = self._current_t()
588
+ if not self._ultrack_db_frame_initialized:
589
+ self._ultrack_db_frame_initialized = True
590
+ if not self._viewer_has_time_axis():
591
+ # No movie open — load the whole database stack so napari gets
592
+ # a time dimension and the user can navigate all frames.
593
+ self._load_full_db_stack(db_path)
594
+ return
595
+ if frame == 0:
596
+ mid = self._ultrack_db_middle_frame(db_path)
597
+ if mid is not None and mid > 0:
598
+ frame = mid
599
+ self._set_viewer_frame(frame)
600
+ try:
601
+ mtime_ns = db_path.stat().st_mtime_ns
602
+ self.ultrack_db_info_lbl.setText(
603
+ self._cached_ultrack_db_summary_text(db_path, mtime_ns, frame)
604
+ )
605
+ resolved = self._resolve_ultrack_db_partition(db_path, mtime_ns, frame)
606
+ if resolved is None:
607
+ labels = self._empty_ultrack_db_preview()
608
+ self._update_labels_layer(_ULTRACK_DB_PREVIEW_LAYER, labels)
609
+ self._set_ultrack_db_status(f"No candidates for frame {frame}.")
610
+ return
611
+ labels, status, prob_dict, label_to_node_id, node_id_to_label, node_annotations = (
612
+ resolved
613
+ )
614
+ self._ultrack_db_label_to_node_id = label_to_node_id
615
+ self._ultrack_db_node_id_to_label = node_id_to_label
616
+ self._ultrack_db_node_annotations = node_annotations
617
+ self._ultrack_db_label_probabilities = prob_dict
618
+ alpha_dict: dict[int, float] = {}
619
+ if self.ultrack_db_connected_focus_check.isChecked():
620
+ labels, status, alpha_dict = self._render_ultrack_db_connected_focus(
621
+ db_path, frame, labels, status, prob_dict,
622
+ label_to_node_id, node_id_to_label,
623
+ )
624
+ self._ultrack_db_preview_labels = labels.astype(np.uint32, copy=False)
625
+ if not self._update_ultrack_db_stack_frame(frame, self._ultrack_db_preview_labels):
626
+ self._update_ultrack_db_preview_layer(
627
+ self._ultrack_db_preview_labels, prob_dict, alpha_dict,
628
+ )
629
+ self._refresh_ultrack_db_annotation_visualization(
630
+ self._ultrack_db_preview_labels, label_to_node_id, node_annotations,
631
+ )
632
+ status = self._append_ultrack_db_visible_annotation_status(
633
+ status,
634
+ self._ultrack_db_preview_labels,
635
+ label_to_node_id,
636
+ node_annotations,
637
+ )
638
+ self._install_ultrack_db_preview_selector()
639
+ if not self.ultrack_db_connected_focus_check.isChecked():
640
+ status = self._refresh_ultrack_db_selection_highlight(
641
+ self._ultrack_db_preview_labels, status, node_id_to_label, frame,
642
+ )
643
+ self._restore_ultrack_db_preview_active()
644
+ self._set_ultrack_db_status(status)
645
+ except Exception as e:
646
+ self._set_ultrack_db_status(f"DB read error: {e}")
647
+ logger.warning("DB browser error: %s", e)
648
+
649
+ @staticmethod
650
+ def _normalize_ultrack_db_preview(cached):
651
+ if len(cached) == 2:
652
+ labels, status = cached
653
+ return labels, status, {}, {}, {}, {}
654
+ if len(cached) == 3:
655
+ labels, status, prob_dict = cached
656
+ return labels, status, prob_dict, {}, {}, {}
657
+ if len(cached) == 5:
658
+ labels, status, prob_dict, l2n, n2l = cached
659
+ return labels, status, prob_dict, l2n, n2l, {}
660
+ labels, status, prob_dict, l2n, n2l, annots = cached
661
+ return labels, status, prob_dict, l2n, n2l, annots
662
+
663
+ def _update_ultrack_db_preview_layer(self, labels, prob_dict, alpha_dict=None):
664
+ if alpha_dict:
665
+ data = self._ultrack_db_alpha_rgba(labels, alpha_dict)
666
+ self._update_image_layer(_ULTRACK_DB_PREVIEW_LAYER, data, rgb=True)
667
+ return
668
+ if self.ultrack_db_prob_alpha_check.isChecked() and prob_dict:
669
+ data = self._ultrack_db_probability_rgba(labels, prob_dict)
670
+ self._update_image_layer(_ULTRACK_DB_PREVIEW_LAYER, data, rgb=True)
671
+ return
672
+ self._update_labels_layer(_ULTRACK_DB_PREVIEW_LAYER, labels)
673
+
674
+ def _update_ultrack_db_annotation_layer(self, labels, label_to_node_id, node_annotations):
675
+ overlay = np.zeros_like(labels, dtype=np.uint8)
676
+ for lid, nid in label_to_node_id.items():
677
+ annot = node_annotations.get(int(nid), "UNKNOWN")
678
+ if annot == "REAL":
679
+ overlay[labels == int(lid)] = 1
680
+ elif annot == "FAKE":
681
+ overlay[labels == int(lid)] = 2
682
+ else:
683
+ overlay[labels == int(lid)] = 3
684
+ if not np.any(overlay):
685
+ if _ULTRACK_DB_ANNOTATION_LAYER in self.viewer.layers:
686
+ self.viewer.layers.remove(_ULTRACK_DB_ANNOTATION_LAYER)
687
+ return
688
+ self._update_labels_layer(
689
+ _ULTRACK_DB_ANNOTATION_LAYER,
690
+ overlay,
691
+ colormap=self._ultrack_db_annotation_colormap(),
692
+ )
693
+
694
+ def _refresh_ultrack_db_annotation_visualization(
695
+ self, labels, label_to_node_id, node_annotations
696
+ ) -> None:
697
+ if not self.ultrack_db_annotation_check.isChecked():
698
+ if _ULTRACK_DB_ANNOTATION_LAYER in self.viewer.layers:
699
+ self.viewer.layers.remove(_ULTRACK_DB_ANNOTATION_LAYER)
700
+ return
701
+ self._update_ultrack_db_annotation_layer(
702
+ labels, label_to_node_id, node_annotations,
703
+ )
704
+
705
+ @classmethod
706
+ def _ultrack_db_visible_annotation_counts(
707
+ cls, labels, label_to_node_id, node_annotations
708
+ ) -> dict[str, int]:
709
+ counts = {"REAL": 0, "FAKE": 0, "UNKNOWN": 0}
710
+ if labels is None:
711
+ return counts
712
+ visible_labels = set(int(v) for v in np.unique(labels) if int(v) != 0)
713
+ for display_label in visible_labels:
714
+ node_id = label_to_node_id.get(display_label)
715
+ if node_id is None:
716
+ counts["UNKNOWN"] += 1
717
+ continue
718
+ annotation = cls._ultrack_db_annotation_name(
719
+ node_annotations.get(int(node_id), "UNKNOWN")
720
+ )
721
+ counts[annotation] += 1
722
+ return counts
723
+
724
+ def _append_ultrack_db_visible_annotation_status(
725
+ self, status, labels, label_to_node_id, node_annotations
726
+ ) -> str:
727
+ if not self.ultrack_db_annotation_check.isChecked():
728
+ return status
729
+ counts = self._ultrack_db_visible_annotation_counts(
730
+ labels, label_to_node_id, node_annotations,
731
+ )
732
+ return (
733
+ f"{status} Visible annotations: REAL {counts['REAL']}, "
734
+ f"FAKE {counts['FAKE']}, UNKNOWN {counts['UNKNOWN']}."
735
+ )
736
+
737
+ @staticmethod
738
+ def _ultrack_db_annotation_colormap():
739
+ from napari.utils.colormaps import DirectLabelColormap
740
+
741
+ return DirectLabelColormap(
742
+ color_dict=defaultdict(
743
+ lambda: _ULTRACK_DB_ANNOTATION_COLORS[3],
744
+ _ULTRACK_DB_ANNOTATION_COLORS,
745
+ ),
746
+ )
747
+
748
+ def _update_labels_layer(self, name: str, data: np.ndarray, *, colormap=None) -> None:
749
+ from napari.layers import Labels
750
+ if name in self.viewer.layers and isinstance(self.viewer.layers[name], Labels):
751
+ if colormap is not None:
752
+ self.viewer.layers[name].colormap = colormap
753
+ self.viewer.layers[name].data = data
754
+ return
755
+ if name in self.viewer.layers:
756
+ self.viewer.layers.remove(name)
757
+ kwargs = {"colormap": colormap} if colormap is not None else {}
758
+ self.viewer.add_labels(data, name=name, **kwargs)
759
+
760
+ def _update_image_layer(self, name: str, data: np.ndarray, *, rgb: bool = False) -> None:
761
+ from napari.layers import Image
762
+ if name in self.viewer.layers and isinstance(self.viewer.layers[name], Image):
763
+ self.viewer.layers[name].data = data
764
+ return
765
+ if name in self.viewer.layers:
766
+ self.viewer.layers.remove(name)
767
+ self.viewer.add_image(data, name=name, rgb=rgb, blending="translucent")
768
+
769
+ @staticmethod
770
+ def _ultrack_db_probability_rgba(labels, prob_dict):
771
+ from napari.utils.colormaps import label_colormap
772
+ rgba = np.zeros(labels.shape + (4,), dtype=np.float32)
773
+ if labels.size == 0 or not prob_dict:
774
+ return rgba
775
+ probs = [float(v) for v in prob_dict.values()]
776
+ min_p, max_p = min(probs), max(probs)
777
+ denom = max(max_p - min_p, 1e-9)
778
+ cmap = label_colormap(max(prob_dict.keys()) + 1)
779
+ for lid, prob in prob_dict.items():
780
+ mask = labels == int(lid)
781
+ if not np.any(mask):
782
+ continue
783
+ color = np.asarray(cmap.map(int(lid)), dtype=np.float32)
784
+ alpha = 0.15 + 0.85 * (float(prob) - min_p) / denom
785
+ color[3] = float(np.clip(alpha, 0.15, 1.0))
786
+ rgba[mask] = color
787
+ return rgba
788
+
789
+ @staticmethod
790
+ def _ultrack_db_alpha_rgba(labels, alpha_dict):
791
+ from napari.utils.colormaps import label_colormap
792
+ rgba = np.zeros(labels.shape + (4,), dtype=np.float32)
793
+ if labels.size == 0 or not alpha_dict:
794
+ return rgba
795
+ cmap = label_colormap(max(alpha_dict.keys()) + 1)
796
+ for lid, alpha in alpha_dict.items():
797
+ mask = labels == int(lid)
798
+ if not np.any(mask):
799
+ continue
800
+ color = np.asarray(cmap.map(int(lid)), dtype=np.float32)
801
+ color[3] = float(np.clip(alpha, 0.0, 1.0))
802
+ rgba[mask] = color
803
+ return rgba
804
+
805
+ def _install_ultrack_db_preview_selector(self) -> None:
806
+ if _ULTRACK_DB_PREVIEW_LAYER not in self.viewer.layers:
807
+ return
808
+ layer = self.viewer.layers[_ULTRACK_DB_PREVIEW_LAYER]
809
+ self._remove_ultrack_db_preview_selector()
810
+
811
+ def _on_drag(_layer, event):
812
+ if getattr(event, "type", None) != "mouse_press":
813
+ return
814
+ if getattr(event, "button", None) != 1:
815
+ return
816
+ if getattr(event, "modifiers", set()):
817
+ return
818
+ labels = self._ultrack_db_preview_labels
819
+ if labels is None or labels.size == 0:
820
+ return
821
+ pos = _layer.world_to_data(event.position)
822
+ y, x = int(round(float(pos[-2]))), int(round(float(pos[-1])))
823
+ if y < 0 or x < 0 or y >= labels.shape[-2] or x >= labels.shape[-1]:
824
+ return
825
+ display_label = int(labels[y, x])
826
+ if display_label == 0:
827
+ self._deselect_ultrack_db_node()
828
+ yield
829
+ return
830
+ self._select_ultrack_db_preview_label(display_label, frame=self._current_t())
831
+ yield
832
+
833
+ layer.mouse_drag_callbacks.append(_on_drag)
834
+ self._ultrack_db_preview_mouse_callback = _on_drag
835
+
836
+ def _remove_ultrack_db_preview_selector(self) -> None:
837
+ cb = self._ultrack_db_preview_mouse_callback
838
+ if cb is None or _ULTRACK_DB_PREVIEW_LAYER not in self.viewer.layers:
839
+ self._ultrack_db_preview_mouse_callback = None
840
+ return
841
+ layer = self.viewer.layers[_ULTRACK_DB_PREVIEW_LAYER]
842
+ try:
843
+ layer.mouse_drag_callbacks.remove(cb)
844
+ except ValueError:
845
+ pass
846
+ self._ultrack_db_preview_mouse_callback = None
847
+
848
+ def _select_ultrack_db_preview_label(self, display_label, *, frame=None):
849
+ node_id = self._ultrack_db_label_to_node_id.get(int(display_label))
850
+ if node_id is None:
851
+ self._set_ultrack_db_status(f"No DB node mapped to label {display_label}.")
852
+ self._clear_ultrack_db_highlight()
853
+ return
854
+ selected_frame = self._current_t() if frame is None else int(frame)
855
+ self._ultrack_db_selected_node_id = int(node_id)
856
+ self._ultrack_db_selected_frame = selected_frame
857
+ self._update_ultrack_db_highlight(self._ultrack_db_preview_labels, int(display_label))
858
+ annot = self._ultrack_db_node_annotations.get(int(node_id), "UNKNOWN")
859
+ annot_suffix = "" if annot == "UNKNOWN" else f" [{annot}]"
860
+ parts = [f"Selected node {node_id}{annot_suffix} at t={selected_frame}"]
861
+ probability = self._ultrack_db_label_probabilities.get(int(display_label))
862
+ if probability is not None:
863
+ parts.append(f"p={float(probability):.3f}")
864
+ db_path = self._ultrack_db_path()
865
+ if db_path is not None and db_path.exists():
866
+ link_counts = self._query_ultrack_db_link_annotation_counts(db_path, int(node_id))
867
+ parts.append(
868
+ f"links REAL {link_counts['REAL']}, "
869
+ f"FAKE {link_counts['FAKE']}, UNKNOWN {link_counts['UNKNOWN']}"
870
+ )
871
+ self._set_ultrack_db_status(" | ".join(parts) + ".")
872
+ self._refresh_node_graph_panel()
873
+ if self.ultrack_db_connected_focus_check.isChecked():
874
+ self._refresh_ultrack_db_browser()
875
+
876
+ def _deselect_ultrack_db_node(self) -> None:
877
+ if self._ultrack_db_selected_node_id is None:
878
+ return
879
+ self._ultrack_db_selected_node_id = None
880
+ self._ultrack_db_selected_frame = None
881
+ self._clear_ultrack_db_highlight()
882
+ self._set_ultrack_db_status("")
883
+ self._refresh_node_graph_panel()
884
+ if self.ultrack_db_connected_focus_check.isChecked():
885
+ self._refresh_ultrack_db_browser()
886
+
887
+ def _refresh_ultrack_db_selection_highlight(self, labels, status, node_id_to_label, frame):
888
+ sel = self._ultrack_db_selected_node_id
889
+ if sel is None:
890
+ self._clear_ultrack_db_highlight()
891
+ return status
892
+ dl = node_id_to_label.get(int(sel))
893
+ if dl is None:
894
+ self._clear_ultrack_db_highlight()
895
+ annot = self._query_ultrack_db_node_annotation_for_status(node_id_to_label, sel)
896
+ if annot in {"REAL", "FAKE"}:
897
+ return (
898
+ f"{status} Selected node {sel} [{annot}] is hidden "
899
+ f"by annotation filter at frame {frame}."
900
+ )
901
+ return (
902
+ f"{status} Selected node {sel} is hidden "
903
+ f"at frame {frame} and the current union size / merge group."
904
+ )
905
+ self._update_ultrack_db_highlight(labels, int(dl))
906
+ return status
907
+
908
+ def _query_ultrack_db_node_annotation_for_status(self, node_id_to_label, selected_node_id):
909
+ return self._ultrack_db_node_annotations.get(int(selected_node_id), "UNKNOWN")
910
+
911
+ def _get_ultrack_db_highlight_layer(self):
912
+ if _ULTRACK_DB_SELECTION_LAYER in self.viewer.layers:
913
+ return self.viewer.layers[_ULTRACK_DB_SELECTION_LAYER]
914
+ layer = self.viewer.add_shapes(
915
+ name=_ULTRACK_DB_SELECTION_LAYER, ndim=2,
916
+ edge_color="cyan", edge_width=2, face_color="transparent",
917
+ )
918
+ layer.visible = False
919
+ # Adding a layer makes it the active selection in napari, which
920
+ # would steal mouse events from the preview layer. Restore the
921
+ # preview layer as active so its mouse_drag_callbacks keep firing.
922
+ self._restore_ultrack_db_preview_active()
923
+ return layer
924
+
925
+ def _restore_ultrack_db_preview_active(self) -> None:
926
+ if _ULTRACK_DB_PREVIEW_LAYER not in self.viewer.layers:
927
+ return
928
+ try:
929
+ self.viewer.layers.selection.active = self.viewer.layers[_ULTRACK_DB_PREVIEW_LAYER]
930
+ except Exception:
931
+ pass
932
+
933
+ def _update_ultrack_db_highlight(self, labels, display_label):
934
+ layer = self._get_ultrack_db_highlight_layer()
935
+ if labels is None or display_label == 0:
936
+ layer.data = []
937
+ layer.visible = False
938
+ return
939
+ mask = (labels == int(display_label)).astype(np.uint8)
940
+ if not np.any(mask):
941
+ layer.data = []
942
+ layer.visible = False
943
+ return
944
+ from skimage.measure import find_contours
945
+ contours = find_contours(mask, level=0.5)
946
+ if not contours:
947
+ layer.data = []
948
+ layer.visible = False
949
+ return
950
+ layer.data = [max(contours, key=len)]
951
+ layer.shape_type = ["polygon"]
952
+ layer.visible = True
953
+
954
+ def _clear_ultrack_db_highlight(self) -> None:
955
+ if _ULTRACK_DB_SELECTION_LAYER not in self.viewer.layers:
956
+ return
957
+ layer = self.viewer.layers[_ULTRACK_DB_SELECTION_LAYER]
958
+ layer.data = []
959
+ layer.visible = False
960
+
961
+ def _query_ultrack_db_connected_nodes(self, db_path, selected_node_id):
962
+ return _query_ultrack_db_connected_nodes(db_path, selected_node_id)
963
+
964
+ def _query_ultrack_db_link_annotation_counts(self, db_path, selected_node_id):
965
+ return _query_ultrack_db_link_annotation_counts(db_path, selected_node_id)
966
+
967
+ # ── Local node-link panel ─────────────────────────────────────────────────
968
+
969
+ def _query_ultrack_db_node_edges(self, db_path, node_id):
970
+ return _query_ultrack_db_node_edges(db_path, node_id)
971
+
972
+ def _node_graph_available(self) -> bool:
973
+ return (
974
+ _HAS_PYQTGRAPH
975
+ and getattr(self, "ultrack_db_node_graph_item", None) is not None
976
+ )
977
+
978
+ def _set_node_graph_hint(self, text: str) -> None:
979
+ hint = getattr(self, "ultrack_db_node_graph_hint", None)
980
+ if hint is not None:
981
+ hint.setText(text)
982
+
983
+ def _clear_node_graph_text_items(self) -> None:
984
+ items = getattr(self.ultrack_db_browser_widget, "node_graph_text_items", None)
985
+ if not items:
986
+ return
987
+ view = self.ultrack_db_node_graph_view
988
+ for item in items:
989
+ try:
990
+ view.removeItem(item)
991
+ except Exception:
992
+ pass
993
+ items.clear()
994
+
995
+ def _clear_node_graph(self, hint: str) -> None:
996
+ self._clear_node_graph_text_items()
997
+ self._ultrack_db_node_graph_node_ids = []
998
+ self._ultrack_db_node_graph_edge_count = 0
999
+ self._ultrack_db_node_graph_neighbor_t = {}
1000
+ self._ultrack_db_node_graph_pending_prev = None
1001
+ self._ultrack_db_node_graph_came_from = None
1002
+ if self._node_graph_available():
1003
+ self.ultrack_db_node_graph_item.setData(
1004
+ pos=np.zeros((0, 2), dtype=float),
1005
+ adj=np.zeros((0, 2), dtype=int),
1006
+ )
1007
+ self._set_node_graph_hint(hint)
1008
+
1009
+ def _refresh_node_graph_panel(self) -> None:
1010
+ """Rebuild the node-link panel for the current selection (or clear it)."""
1011
+ if not self._node_graph_available():
1012
+ return
1013
+ sel = self._ultrack_db_selected_node_id
1014
+ if sel is None:
1015
+ self._clear_node_graph("Click a preview node")
1016
+ return
1017
+ db_path = self._ultrack_db_path()
1018
+ if db_path is None or not db_path.exists():
1019
+ self._clear_node_graph("No database available")
1020
+ return
1021
+ try:
1022
+ node_edges = self._query_ultrack_db_node_edges(db_path, int(sel))
1023
+ except Exception as exc: # pragma: no cover - defensive
1024
+ logger.warning("Node graph query failed: %s", exc)
1025
+ self._clear_node_graph("Could not query node edges")
1026
+ return
1027
+ self._build_node_graph(node_edges)
1028
+
1029
+ @staticmethod
1030
+ def _node_graph_pick_neighbors(mapping: dict) -> list[int]:
1031
+ """Neighbors heaviest-first (by their strongest link), capped per side.
1032
+
1033
+ Ties break by ascending id for determinism. The returned order is what
1034
+ :meth:`_node_graph_place_column` lays out center-outwards, so the
1035
+ strongest links land nearest the selected node's row.
1036
+ """
1037
+ by_weight = sorted(
1038
+ mapping,
1039
+ key=lambda nid: (-max(e.weight for e in mapping[nid]), nid),
1040
+ )
1041
+ return by_weight[:_NODE_GRAPH_MAX_PER_SIDE]
1042
+
1043
+ @staticmethod
1044
+ def _node_graph_place_column(pos, ids, index_of, x: float) -> None:
1045
+ # Center-out: ids[0] (heaviest) at y=0, then +1, -1, +2, -2, ... so the
1046
+ # strongest neighbors sit closest to the selected node at the origin.
1047
+ for k, nid in enumerate(ids):
1048
+ step = (k + 1) // 2
1049
+ y = float(step if k % 2 == 1 else -step)
1050
+ pos[index_of[nid]] = (x, y)
1051
+
1052
+ @staticmethod
1053
+ def _node_graph_weight_color(norm: float):
1054
+ rgb = (
1055
+ _NODE_GRAPH_WEIGHT_LOW
1056
+ + (_NODE_GRAPH_WEIGHT_HIGH - _NODE_GRAPH_WEIGHT_LOW) * float(norm)
1057
+ )
1058
+ return tuple(int(round(v)) for v in rgb)
1059
+
1060
+ def _build_node_graph(self, node_edges) -> None:
1061
+ self._clear_node_graph_text_items()
1062
+ edges = list(node_edges.edges)
1063
+
1064
+ preds: dict[int, list] = {}
1065
+ succs: dict[int, list] = {}
1066
+ for edge in edges:
1067
+ bucket = preds if edge.direction == "pred" else succs
1068
+ bucket.setdefault(edge.neighbor_id, []).append(edge)
1069
+
1070
+ pred_ids = self._node_graph_pick_neighbors(preds)
1071
+ succ_ids = self._node_graph_pick_neighbors(succs)
1072
+ kept = set(pred_ids) | set(succ_ids)
1073
+ n_total = len(preds) + len(succs)
1074
+ n_kept = len(pred_ids) + len(succ_ids)
1075
+
1076
+ node_ids = [node_edges.selected_id] + pred_ids + succ_ids
1077
+ index_of = {nid: i for i, nid in enumerate(node_ids)}
1078
+
1079
+ # "Came from": the node we navigated away from on the last panel click,
1080
+ # if it is visible as a neighbor here. Consumed (one-shot) on rebuild.
1081
+ pending = self._ultrack_db_node_graph_pending_prev
1082
+ self._ultrack_db_node_graph_pending_prev = None
1083
+ came_from = pending if pending in set(node_ids[1:]) else None
1084
+ self._ultrack_db_node_graph_came_from = came_from
1085
+
1086
+ # Frame + annotation + probability per node, for click routing & encoding.
1087
+ neighbor_t = {node_edges.selected_id: node_edges.selected_t}
1088
+ annot = {node_edges.selected_id: node_edges.selected_annot}
1089
+ prob = {node_edges.selected_id: node_edges.selected_prob}
1090
+ for edge in edges:
1091
+ neighbor_t[edge.neighbor_id] = edge.neighbor_t
1092
+ annot[edge.neighbor_id] = edge.neighbor_annot
1093
+ prob[edge.neighbor_id] = edge.neighbor_prob
1094
+ self._ultrack_db_node_graph_neighbor_t = neighbor_t
1095
+
1096
+ pos = np.zeros((len(node_ids), 2), dtype=float)
1097
+ pos[0] = (0.0, 0.0)
1098
+ self._node_graph_place_column(pos, pred_ids, index_of, x=-1.0)
1099
+ self._node_graph_place_column(pos, succ_ids, index_of, x=1.0)
1100
+
1101
+ # Node visuals: fill by annotation, size by node_prob, cyan outline for sel.
1102
+ probs = np.array([prob[nid] for nid in node_ids], dtype=float)
1103
+ pmin, pmax = float(probs.min()), float(probs.max())
1104
+ dmin, dmax = _NODE_GRAPH_NODE_DIAMETER
1105
+ if pmax > pmin:
1106
+ sizes = dmin + (dmax - dmin) * (probs - pmin) / (pmax - pmin)
1107
+ else:
1108
+ sizes = np.full(len(node_ids), (dmin + dmax) / 2.0)
1109
+
1110
+ brushes = []
1111
+ symbol_pens = []
1112
+ for nid in node_ids:
1113
+ key = _ULTRACK_DB_ANNOTATION_KEY.get(annot.get(nid, "UNKNOWN"), 3)
1114
+ r, g, b, a = (_ULTRACK_DB_ANNOTATION_COLORS[key] * 255.0).astype(int)
1115
+ brushes.append(pg.mkBrush(int(r), int(g), int(b), max(int(a), 200)))
1116
+ if nid == node_edges.selected_id:
1117
+ symbol_pens.append(pg.mkPen(_NODE_GRAPH_SELECTED_OUTLINE, width=3))
1118
+ elif nid == came_from:
1119
+ symbol_pens.append(pg.mkPen(_NODE_GRAPH_CAME_FROM_OUTLINE, width=3))
1120
+ else:
1121
+ symbol_pens.append(pg.mkPen(150, 150, 150, width=1))
1122
+
1123
+ # One adjacency row + pen per raw link (never collapsed). Order
1124
+ # deterministically (preds first, then by neighbor id, heaviest link
1125
+ # first) so duplicate links to the same neighbor stack stably.
1126
+ drawn = sorted(
1127
+ (e for e in edges if e.neighbor_id in kept),
1128
+ key=lambda e: (e.direction != "pred", e.neighbor_id, -e.weight),
1129
+ )
1130
+ self._ultrack_db_node_graph_node_ids = list(node_ids)
1131
+ self._ultrack_db_node_graph_edge_count = len(drawn)
1132
+
1133
+ if drawn:
1134
+ weights = np.array([e.weight for e in drawn], dtype=float)
1135
+ wmin, wmax = float(weights.min()), float(weights.max())
1136
+ adj = np.zeros((len(drawn), 2), dtype=int)
1137
+ pen = np.zeros(
1138
+ len(drawn),
1139
+ dtype=[
1140
+ ("red", np.ubyte), ("green", np.ubyte), ("blue", np.ubyte),
1141
+ ("alpha", np.ubyte), ("width", float),
1142
+ ],
1143
+ )
1144
+ wlo, whi = _NODE_GRAPH_EDGE_WIDTH
1145
+ seen_pairs: dict[tuple[int, int], int] = {}
1146
+ for i, edge in enumerate(drawn):
1147
+ j = index_of[edge.neighbor_id]
1148
+ adj[i] = (0, j)
1149
+ norm = (edge.weight - wmin) / (wmax - wmin) if wmax > wmin else 0.5
1150
+ r, g, b = self._node_graph_weight_color(norm)
1151
+ pen[i] = (r, g, b, 255, wlo + (whi - wlo) * norm)
1152
+ # Edge weight label: lift it off the line (perpendicular offset)
1153
+ # with a dark background pill so it stays readable over the edge.
1154
+ # Duplicate links to the same neighbor stack along the edge.
1155
+ d = pos[j] - pos[0]
1156
+ length = float(np.hypot(d[0], d[1])) or 1.0
1157
+ perp = np.array([-d[1], d[0]]) / length
1158
+ along = d / length
1159
+ dup = seen_pairs.get((0, j), 0)
1160
+ seen_pairs[(0, j)] = dup + 1
1161
+ lp = pos[0] + d * 0.5 + perp * 0.14 + along * (0.16 * dup)
1162
+ txt = f"{edge.weight:.2f}*" if edge.weight_is_null else f"{edge.weight:.2f}"
1163
+ label = pg.TextItem(
1164
+ txt, anchor=(0.5, 0.5), color=(235, 235, 235),
1165
+ fill=pg.mkBrush(20, 20, 20, 200),
1166
+ )
1167
+ label.setPos(float(lp[0]), float(lp[1]))
1168
+ self.ultrack_db_node_graph_view.addItem(label)
1169
+ self.ultrack_db_browser_widget.node_graph_text_items.append(label)
1170
+ self.ultrack_db_node_graph_item.setData(
1171
+ pos=pos, adj=adj, pen=pen, size=sizes, symbol="o",
1172
+ symbolBrush=brushes, symbolPen=symbol_pens, pxMode=True,
1173
+ data=np.array(node_ids),
1174
+ )
1175
+ else:
1176
+ self.ultrack_db_node_graph_item.setData(
1177
+ pos=pos, adj=np.zeros((0, 2), dtype=int), size=sizes, symbol="o",
1178
+ symbolBrush=brushes, symbolPen=symbol_pens, pxMode=True,
1179
+ data=np.array(node_ids),
1180
+ )
1181
+
1182
+ # Node id labels below each node; flag the "came from" node so it is easy
1183
+ # to click back to where you were.
1184
+ for nid in node_ids:
1185
+ p = pos[index_of[nid]]
1186
+ if nid == came_from:
1187
+ text = f"↩ {nid} (from)"
1188
+ color = _NODE_GRAPH_CAME_FROM_OUTLINE
1189
+ else:
1190
+ text = str(nid)
1191
+ color = (200, 200, 200)
1192
+ label = pg.TextItem(text, anchor=(0.5, 0.0), color=color)
1193
+ label.setPos(float(p[0]), float(p[1]) + 0.22)
1194
+ self.ultrack_db_node_graph_view.addItem(label)
1195
+ self.ultrack_db_browser_widget.node_graph_text_items.append(label)
1196
+
1197
+ if not drawn:
1198
+ self._set_node_graph_hint(f"Selected node {node_edges.selected_id}: no links")
1199
+ elif n_kept < n_total:
1200
+ self._set_node_graph_hint(
1201
+ f"Selected node {node_edges.selected_id}: "
1202
+ f"showing top {n_kept} of {n_total} neighbors"
1203
+ )
1204
+ else:
1205
+ self._set_node_graph_hint(
1206
+ f"Selected node {node_edges.selected_id}: "
1207
+ f"{len(pred_ids)} pred, {len(succ_ids)} succ"
1208
+ )
1209
+
1210
+ def _on_node_graph_node_clicked(self, _scatter, points) -> None:
1211
+ if points is None or len(points) == 0:
1212
+ return
1213
+ point = points[0]
1214
+ node_id = None
1215
+ try:
1216
+ node_id = point.data()
1217
+ except Exception:
1218
+ node_id = None
1219
+ if node_id is None:
1220
+ try:
1221
+ node_id = self._ultrack_db_node_graph_node_ids[point.index()]
1222
+ except Exception:
1223
+ return
1224
+ self._node_graph_select_node(int(node_id))
1225
+
1226
+ def _node_graph_select_node(self, node_id: int) -> None:
1227
+ node_id = int(node_id)
1228
+ if node_id == self._ultrack_db_selected_node_id:
1229
+ return
1230
+ # Remember where we came from so the rebuilt graph can mark it; consumed
1231
+ # by the next _build_node_graph triggered through the selection below.
1232
+ self._ultrack_db_node_graph_pending_prev = self._ultrack_db_selected_node_id
1233
+ target_t = self._ultrack_db_node_graph_neighbor_t.get(node_id)
1234
+ if target_t is not None and target_t != self._current_t():
1235
+ # Neighbor lives in an adjacent frame; move the slider there first so
1236
+ # the preview labels (and id->label map) match before selecting.
1237
+ self._set_viewer_frame(int(target_t))
1238
+ self._refresh_ultrack_db_browser()
1239
+ display_label = self._ultrack_db_node_id_to_label.get(node_id)
1240
+ if display_label is None:
1241
+ self._ultrack_db_node_graph_pending_prev = None
1242
+ self._set_ultrack_db_status(
1243
+ f"Node {node_id} is hidden at frame {self._current_t()}."
1244
+ )
1245
+ return
1246
+ self._select_ultrack_db_preview_label(display_label, frame=self._current_t())
1247
+
1248
+ def _render_ultrack_db_connected_focus(
1249
+ self, db_path, frame, labels, status, prob_dict, label_to_node_id, node_id_to_label,
1250
+ ):
1251
+ sel_nid = self._ultrack_db_selected_node_id
1252
+ sel_frame = self._ultrack_db_selected_frame
1253
+ if sel_nid is None or sel_frame is None:
1254
+ self._clear_ultrack_db_highlight()
1255
+ return labels, f"{status} Click a DB preview node to focus links.", {}
1256
+ predecessors, successors = self._query_ultrack_db_connected_nodes(db_path, sel_nid)
1257
+ if frame == sel_frame:
1258
+ relation = "selected"
1259
+ allowed = {sel_nid: 1.0}
1260
+ if int(sel_nid) not in node_id_to_label:
1261
+ self._clear_ultrack_db_highlight()
1262
+ empty = np.zeros_like(labels, dtype=np.uint32)
1263
+ annot = self._ultrack_db_node_annotations.get(int(sel_nid), "UNKNOWN")
1264
+ suf = "" if annot == "UNKNOWN" else f" [{annot}]"
1265
+ return empty, (
1266
+ f"Selected node {sel_nid}{suf} at t={sel_frame} is hidden."
1267
+ ), {}
1268
+ elif frame == sel_frame - 1:
1269
+ relation = "t-1"
1270
+ allowed = predecessors
1271
+ elif frame == sel_frame + 1:
1272
+ relation = "t+1"
1273
+ allowed = successors
1274
+ else:
1275
+ self._clear_ultrack_db_highlight()
1276
+ return np.zeros_like(labels, dtype=np.uint32), (
1277
+ f"Selected node {sel_nid} at t={sel_frame} | frame {frame}: outside focus."
1278
+ ), {}
1279
+
1280
+ focused = np.zeros_like(labels, dtype=np.uint32)
1281
+ alpha_dict: dict[int, float] = {}
1282
+ for lid, nid in label_to_node_id.items():
1283
+ li, ni = int(lid), int(nid)
1284
+ if ni not in allowed:
1285
+ continue
1286
+ focused[labels == li] = li
1287
+ if self.ultrack_db_prob_alpha_check.isChecked():
1288
+ alpha_dict[li] = (
1289
+ 1.0 if ni == sel_nid
1290
+ else self._ultrack_db_connected_alpha(li, prob_dict)
1291
+ )
1292
+
1293
+ sel_label = node_id_to_label.get(int(sel_nid))
1294
+ if frame == sel_frame and sel_label is not None:
1295
+ self._update_ultrack_db_highlight(focused, int(sel_label))
1296
+ else:
1297
+ self._clear_ultrack_db_highlight()
1298
+
1299
+ edge_vals = [
1300
+ float(v) for nid, v in allowed.items()
1301
+ if nid in node_id_to_label and nid != sel_nid
1302
+ ]
1303
+ edge_summary = (
1304
+ f" | edge range {min(edge_vals):.2f}-{max(edge_vals):.2f}" if edge_vals else ""
1305
+ )
1306
+ count = int(np.unique(focused[focused != 0]).size)
1307
+ annot = self._ultrack_db_node_annotations.get(int(sel_nid), "UNKNOWN")
1308
+ suf = "" if annot == "UNKNOWN" else f" [{annot}]"
1309
+ return focused, (
1310
+ f"Selected node {sel_nid}{suf} at t={sel_frame} | "
1311
+ f"{relation}: {count} connected{edge_summary}"
1312
+ ), alpha_dict
1313
+
1314
+ def _ultrack_db_connected_alpha(self, label_id, prob_dict):
1315
+ alpha = 1.0
1316
+ if self.ultrack_db_prob_alpha_check.isChecked() and prob_dict:
1317
+ probs = [float(v) for v in prob_dict.values()]
1318
+ min_p, max_p = min(probs), max(probs)
1319
+ denom = max(max_p - min_p, 1e-9)
1320
+ prob = float(prob_dict.get(int(label_id), 1.0))
1321
+ alpha *= 0.15 + 0.85 * (prob - min_p) / denom
1322
+ return float(np.clip(alpha, 0.05, 1.0))
1323
+
1324
+ def _ultrack_db_summary_text(self, db_path, frame):
1325
+ return _ultrack_db_summary_text(db_path, frame)
1326
+
1327
+ def _cached_ultrack_db_summary_text(self, db_path, mtime_ns, frame):
1328
+ key = (str(db_path.resolve()), mtime_ns, int(frame))
1329
+ cached = self._ultrack_db_summary_cache.get(key)
1330
+ if cached is not None:
1331
+ return cached
1332
+ text = self._ultrack_db_summary_text(db_path, frame)
1333
+ self._ultrack_db_summary_cache[key] = text
1334
+ return text
1335
+
1336
+ def _ultrack_db_preview_cache_key(self, db_path, mtime_ns, frame, union_size, color_index):
1337
+ return (
1338
+ str(db_path.resolve()), mtime_ns, int(frame),
1339
+ int(union_size), int(color_index),
1340
+ )
1341
+
1342
+ def _query_union_sizes(self, db_path, mtime_ns, frame):
1343
+ key = (str(db_path.resolve()), mtime_ns, int(frame))
1344
+ cached = self._ultrack_db_size_values_cache.get(key)
1345
+ if cached is not None:
1346
+ return cached
1347
+ sizes = _query_union_sizes(db_path, int(frame))
1348
+ self._ultrack_db_size_values_cache[key] = sizes
1349
+ return sizes
1350
+
1351
+ def _query_union_color_classes(self, db_path, mtime_ns, frame, union_size):
1352
+ key = (str(db_path.resolve()), mtime_ns, int(frame), int(union_size))
1353
+ cached = self._ultrack_db_color_class_cache.get(key)
1354
+ if cached is not None:
1355
+ return cached
1356
+ classes = _query_union_color_classes(db_path, int(frame), int(union_size))
1357
+ self._ultrack_db_color_class_cache[key] = classes
1358
+ return classes
1359
+
1360
+ def _configure_ultrack_db_size_slider(self, db_path, mtime_ns, frame):
1361
+ """Vertical axis. Range over the distinct union sizes present in ``frame``."""
1362
+ sizes = self._query_union_sizes(db_path, mtime_ns, frame)
1363
+ maximum = max(len(sizes) - 1, 0)
1364
+ value = min(max(int(self.ultrack_db_hierarchy_slider.value()), 0), maximum)
1365
+ old = self.ultrack_db_hierarchy_slider.blockSignals(True)
1366
+ try:
1367
+ self.ultrack_db_hierarchy_slider.setRange(0, maximum)
1368
+ self.ultrack_db_hierarchy_slider.setValue(value)
1369
+ finally:
1370
+ self.ultrack_db_hierarchy_slider.blockSignals(old)
1371
+ if sizes:
1372
+ self._set_ultrack_db_size_label(value, sizes[value], len(sizes))
1373
+ else:
1374
+ self.ultrack_db_height_lbl.setText("—")
1375
+ return sizes
1376
+
1377
+ def _configure_ultrack_db_merge_slider(self, db_path, mtime_ns, frame, union_size):
1378
+ """Horizontal axis. Range over the merge groups (color classes) of ``union_size``."""
1379
+ classes = self._query_union_color_classes(db_path, mtime_ns, frame, union_size)
1380
+ maximum = max(len(classes) - 1, 0)
1381
+ value = min(max(int(self.ultrack_db_source_slider.value()), 0), maximum)
1382
+ old = self.ultrack_db_source_slider.blockSignals(True)
1383
+ try:
1384
+ self.ultrack_db_source_slider.setRange(0, maximum)
1385
+ self.ultrack_db_source_slider.setValue(value)
1386
+ finally:
1387
+ self.ultrack_db_source_slider.blockSignals(old)
1388
+ if classes:
1389
+ self._set_ultrack_db_merge_label(value, len(classes))
1390
+ else:
1391
+ self.ultrack_db_source_lbl.setText("—")
1392
+ return classes
1393
+
1394
+ def _set_ultrack_db_size_label(self, index, union_size, total):
1395
+ self.ultrack_db_height_lbl.setText(
1396
+ f"N={int(union_size)} ({index + 1}/{total})"
1397
+ )
1398
+
1399
+ def _set_ultrack_db_merge_label(self, index, total):
1400
+ if total <= 0:
1401
+ self.ultrack_db_source_lbl.setText("—")
1402
+ return
1403
+ self.ultrack_db_source_lbl.setText(f"{index + 1}/{total}")
1404
+
1405
+ def _render_union_partition(self, db_path, frame, color_node_ids, union_size=None):
1406
+ return _render_union_partition(
1407
+ db_path,
1408
+ frame,
1409
+ color_node_ids,
1410
+ plane_shape=self._viewer_plane_shape(),
1411
+ union_size=union_size,
1412
+ ).as_tuple()
1413
+
1414
+
1415
+ @staticmethod
1416
+ def _ultrack_db_annotation_name(value):
1417
+ return _ultrack_db_annotation_name(value)
1418
+
1419
+ @staticmethod
1420
+ def _ultrack_db_node_preview_metadata(nodes):
1421
+ return _ultrack_db_node_preview_metadata(nodes)
1422
+
1423
+ @staticmethod
1424
+ def _ultrack_db_node_annotation_metadata(nodes):
1425
+ return _ultrack_db_node_annotation_metadata(nodes)
1426
+
1427
+ def _empty_ultrack_db_preview(self):
1428
+ return np.zeros(self._viewer_plane_shape(), dtype=np.uint32)
1429
+
1430
+ def _viewer_plane_shape(self):
1431
+ for layer in self.viewer.layers:
1432
+ data = getattr(layer, "data", None)
1433
+ if isinstance(data, np.ndarray) and data.ndim >= 2:
1434
+ return tuple(int(v) for v in data.shape[-2:])
1435
+ return (1, 1)
1436
+
1437
+ def _paint_ultrack_db_nodes(self, nodes):
1438
+ return _paint_ultrack_db_nodes(nodes, self._viewer_plane_shape())
1439
+
1440
+ @staticmethod
1441
+ def _node_mask_and_bbox(node):
1442
+ return _node_mask_and_bbox(node)