itasc 0.2.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,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)
|