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,952 @@
|
|
|
1
|
+
"""Read-only Ultrack database helpers for napari preview tooling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pickle
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class HierarchyCutState:
|
|
14
|
+
node_ids: tuple[int, ...]
|
|
15
|
+
height: float | None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class UltrackDbPreview:
|
|
20
|
+
labels: np.ndarray
|
|
21
|
+
status: str
|
|
22
|
+
probabilities: dict[int, float]
|
|
23
|
+
label_to_node_id: dict[int, int]
|
|
24
|
+
node_id_to_label: dict[int, int]
|
|
25
|
+
node_annotations: dict[int, str]
|
|
26
|
+
|
|
27
|
+
def as_tuple(self):
|
|
28
|
+
return (
|
|
29
|
+
self.labels,
|
|
30
|
+
self.status,
|
|
31
|
+
self.probabilities,
|
|
32
|
+
self.label_to_node_id,
|
|
33
|
+
self.node_id_to_label,
|
|
34
|
+
self.node_annotations,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _engine(db_path: Path):
|
|
39
|
+
import sqlalchemy as sqla
|
|
40
|
+
|
|
41
|
+
return sqla.create_engine(
|
|
42
|
+
f"sqlite:///{db_path}", connect_args={"check_same_thread": False}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def query_middle_frame(db_path: Path) -> int | None:
|
|
47
|
+
from sqlalchemy.orm import Session
|
|
48
|
+
from ultrack.core.database import NodeDB
|
|
49
|
+
|
|
50
|
+
engine = _engine(db_path)
|
|
51
|
+
try:
|
|
52
|
+
with Session(engine) as session:
|
|
53
|
+
frames = sorted(int(r[0]) for r in session.query(NodeDB.t).distinct().all())
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
finally:
|
|
57
|
+
engine.dispose()
|
|
58
|
+
return frames[len(frames) // 2] if frames else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def query_frame_range(db_path: Path) -> tuple[int, ...]:
|
|
62
|
+
"""Return sorted distinct frame indices present in the database."""
|
|
63
|
+
from sqlalchemy.orm import Session
|
|
64
|
+
from ultrack.core.database import NodeDB
|
|
65
|
+
|
|
66
|
+
engine = _engine(db_path)
|
|
67
|
+
try:
|
|
68
|
+
with Session(engine) as session:
|
|
69
|
+
frames = sorted(int(r[0]) for r in session.query(NodeDB.t).distinct().all())
|
|
70
|
+
except Exception:
|
|
71
|
+
return ()
|
|
72
|
+
finally:
|
|
73
|
+
engine.dispose()
|
|
74
|
+
return tuple(frames)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def query_connected_nodes(
|
|
78
|
+
db_path: Path, selected_node_id: int
|
|
79
|
+
) -> tuple[dict[int, float], dict[int, float]]:
|
|
80
|
+
from sqlalchemy.orm import Session
|
|
81
|
+
from ultrack.core.database import LinkDB
|
|
82
|
+
|
|
83
|
+
engine = _engine(db_path)
|
|
84
|
+
predecessors: dict[int, float] = {}
|
|
85
|
+
successors: dict[int, float] = {}
|
|
86
|
+
try:
|
|
87
|
+
with Session(engine) as session:
|
|
88
|
+
rows = (
|
|
89
|
+
session.query(LinkDB.source_id, LinkDB.target_id, LinkDB.weight)
|
|
90
|
+
.filter(
|
|
91
|
+
(LinkDB.source_id == int(selected_node_id))
|
|
92
|
+
| (LinkDB.target_id == int(selected_node_id))
|
|
93
|
+
)
|
|
94
|
+
.all()
|
|
95
|
+
)
|
|
96
|
+
for src, tgt, weight in rows:
|
|
97
|
+
wf = float(weight if weight is not None else 1.0)
|
|
98
|
+
if int(tgt) == int(selected_node_id):
|
|
99
|
+
src_id = int(src)
|
|
100
|
+
predecessors[src_id] = predecessors.get(src_id, 1.0) * wf
|
|
101
|
+
if int(src) == int(selected_node_id):
|
|
102
|
+
tgt_id = int(tgt)
|
|
103
|
+
successors[tgt_id] = successors.get(tgt_id, 1.0) * wf
|
|
104
|
+
finally:
|
|
105
|
+
engine.dispose()
|
|
106
|
+
return predecessors, successors
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class NodeEdge:
|
|
111
|
+
neighbor_id: int
|
|
112
|
+
weight: float # raw LinkDB.weight (NOT a product)
|
|
113
|
+
annotation: str # annotation_name(LinkDB.annotation): REAL/FAKE/UNKNOWN
|
|
114
|
+
direction: str # "pred" (neighbor -> selected) or "succ" (selected -> neighbor)
|
|
115
|
+
neighbor_t: int # NodeDB.t of the neighbor
|
|
116
|
+
neighbor_prob: float # NodeDB.node_prob (1.0 if unset: NULL or ultrack's -1.0 sentinel)
|
|
117
|
+
neighbor_annot: str # annotation_name(NodeDB.node_annot)
|
|
118
|
+
weight_is_null: bool = False # raw LinkDB.weight was NULL (rendered 1.0)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class NodeEdges:
|
|
123
|
+
selected_id: int
|
|
124
|
+
selected_t: int
|
|
125
|
+
selected_prob: float
|
|
126
|
+
selected_annot: str
|
|
127
|
+
edges: tuple[NodeEdge, ...]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _node_prob_or_unknown(prob) -> float:
|
|
131
|
+
"""Map an unset node probability to 1.0.
|
|
132
|
+
|
|
133
|
+
Ultrack's ``NodeDB.node_prob`` is nullable but defaults to the sentinel
|
|
134
|
+
``-1.0`` for nodes that were never scored, so both NULL and any negative
|
|
135
|
+
value mean "no probability assigned" and read back as 1.0.
|
|
136
|
+
"""
|
|
137
|
+
if prob is None:
|
|
138
|
+
return 1.0
|
|
139
|
+
value = float(prob)
|
|
140
|
+
return 1.0 if value < 0.0 else value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def query_node_edges(db_path: Path, node_id: int) -> NodeEdges:
|
|
144
|
+
"""Return raw, un-collapsed per-link edges incident to ``node_id``.
|
|
145
|
+
|
|
146
|
+
Unlike :func:`query_connected_nodes` (which multiplies weights when a
|
|
147
|
+
neighbor pair shares multiple links), each ``LinkDB`` row becomes its own
|
|
148
|
+
:class:`NodeEdge`, so per-edge weights stay legible. Pure read.
|
|
149
|
+
"""
|
|
150
|
+
from sqlalchemy.orm import Session
|
|
151
|
+
from ultrack.core.database import LinkDB, NodeDB
|
|
152
|
+
|
|
153
|
+
node_id = int(node_id)
|
|
154
|
+
engine = _engine(db_path)
|
|
155
|
+
try:
|
|
156
|
+
with Session(engine) as session:
|
|
157
|
+
link_rows = (
|
|
158
|
+
session.query(
|
|
159
|
+
LinkDB.source_id, LinkDB.target_id, LinkDB.weight, LinkDB.annotation
|
|
160
|
+
)
|
|
161
|
+
.filter(
|
|
162
|
+
(LinkDB.source_id == node_id) | (LinkDB.target_id == node_id)
|
|
163
|
+
)
|
|
164
|
+
.all()
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Gather every node id we need metadata for (selected + neighbors).
|
|
168
|
+
wanted: set[int] = {node_id}
|
|
169
|
+
for src, tgt, _weight, _annot in link_rows:
|
|
170
|
+
wanted.add(int(src))
|
|
171
|
+
wanted.add(int(tgt))
|
|
172
|
+
node_rows = (
|
|
173
|
+
session.query(
|
|
174
|
+
NodeDB.id, NodeDB.t, NodeDB.node_prob, NodeDB.node_annot
|
|
175
|
+
)
|
|
176
|
+
.filter(NodeDB.id.in_(sorted(wanted)))
|
|
177
|
+
.all()
|
|
178
|
+
)
|
|
179
|
+
node_meta: dict[int, tuple[int, float, str]] = {}
|
|
180
|
+
for nid, t, prob, annot in node_rows:
|
|
181
|
+
node_meta[int(nid)] = (
|
|
182
|
+
int(t),
|
|
183
|
+
_node_prob_or_unknown(prob),
|
|
184
|
+
annotation_name(annot),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
edges: list[NodeEdge] = []
|
|
188
|
+
for src, tgt, weight, annot in link_rows:
|
|
189
|
+
src_id = int(src)
|
|
190
|
+
tgt_id = int(tgt)
|
|
191
|
+
if src_id == tgt_id:
|
|
192
|
+
continue # skip self / degenerate links
|
|
193
|
+
weight_is_null = weight is None
|
|
194
|
+
wf = float(weight if weight is not None else 1.0)
|
|
195
|
+
if tgt_id == node_id:
|
|
196
|
+
neighbor_id, direction = src_id, "pred"
|
|
197
|
+
else:
|
|
198
|
+
neighbor_id, direction = tgt_id, "succ"
|
|
199
|
+
n_t, n_prob, n_annot = node_meta.get(
|
|
200
|
+
neighbor_id, (0, 1.0, "UNKNOWN")
|
|
201
|
+
)
|
|
202
|
+
edges.append(
|
|
203
|
+
NodeEdge(
|
|
204
|
+
neighbor_id=neighbor_id,
|
|
205
|
+
weight=wf,
|
|
206
|
+
annotation=annotation_name(annot),
|
|
207
|
+
direction=direction,
|
|
208
|
+
neighbor_t=n_t,
|
|
209
|
+
neighbor_prob=n_prob,
|
|
210
|
+
neighbor_annot=n_annot,
|
|
211
|
+
weight_is_null=weight_is_null,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
sel_t, sel_prob, sel_annot = node_meta.get(node_id, (0, 1.0, "UNKNOWN"))
|
|
216
|
+
finally:
|
|
217
|
+
engine.dispose()
|
|
218
|
+
|
|
219
|
+
return NodeEdges(
|
|
220
|
+
selected_id=node_id,
|
|
221
|
+
selected_t=sel_t,
|
|
222
|
+
selected_prob=sel_prob,
|
|
223
|
+
selected_annot=sel_annot,
|
|
224
|
+
edges=tuple(edges),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _empty_annotation_counts() -> dict[str, int]:
|
|
229
|
+
return {"REAL": 0, "FAKE": 0, "UNKNOWN": 0}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _annotation_counts_from_rows(rows) -> dict[str, int]:
|
|
233
|
+
counts = _empty_annotation_counts()
|
|
234
|
+
for annotation, count in rows:
|
|
235
|
+
name = annotation_name(annotation)
|
|
236
|
+
counts[name] = counts.get(name, 0) + int(count or 0)
|
|
237
|
+
return counts
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def link_annotation_counts(
|
|
241
|
+
db_path: Path, selected_node_id: int | None = None
|
|
242
|
+
) -> dict[str, int]:
|
|
243
|
+
"""Return link annotation counts for the whole DB or one node's incident links."""
|
|
244
|
+
engine = _engine(db_path)
|
|
245
|
+
try:
|
|
246
|
+
from sqlalchemy import func
|
|
247
|
+
from sqlalchemy.orm import Session
|
|
248
|
+
from ultrack.core.database import LinkDB
|
|
249
|
+
|
|
250
|
+
with Session(engine) as session:
|
|
251
|
+
query = session.query(LinkDB.annotation, func.count(LinkDB.source_id))
|
|
252
|
+
if selected_node_id is not None:
|
|
253
|
+
node_id = int(selected_node_id)
|
|
254
|
+
query = query.filter(
|
|
255
|
+
(LinkDB.source_id == node_id) | (LinkDB.target_id == node_id)
|
|
256
|
+
)
|
|
257
|
+
rows = query.group_by(LinkDB.annotation).all()
|
|
258
|
+
return _annotation_counts_from_rows(rows)
|
|
259
|
+
except Exception:
|
|
260
|
+
return _empty_annotation_counts()
|
|
261
|
+
finally:
|
|
262
|
+
engine.dispose()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _finite_values(rows) -> list[float]:
|
|
266
|
+
values: list[float] = []
|
|
267
|
+
for row in rows:
|
|
268
|
+
try:
|
|
269
|
+
raw = row[0]
|
|
270
|
+
except (TypeError, KeyError, IndexError):
|
|
271
|
+
raw = row
|
|
272
|
+
if raw is None:
|
|
273
|
+
continue
|
|
274
|
+
try:
|
|
275
|
+
value = float(raw)
|
|
276
|
+
except (TypeError, ValueError):
|
|
277
|
+
continue
|
|
278
|
+
if np.isfinite(value):
|
|
279
|
+
values.append(value)
|
|
280
|
+
return values
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _stats_text(label: str, values: list[float]) -> str | None:
|
|
284
|
+
if not values:
|
|
285
|
+
return None
|
|
286
|
+
arr = np.asarray(values, dtype=np.float64)
|
|
287
|
+
return (
|
|
288
|
+
f"{label}: "
|
|
289
|
+
f"min {float(np.min(arr)):.3f}, "
|
|
290
|
+
f"median {float(np.median(arr)):.3f}, "
|
|
291
|
+
f"mean {float(np.mean(arr)):.3f}, "
|
|
292
|
+
f"max {float(np.max(arr)):.3f}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def summary_text(db_path: Path, frame: int) -> str:
|
|
297
|
+
from sqlalchemy import func
|
|
298
|
+
from sqlalchemy.orm import Session
|
|
299
|
+
from ultrack.core.database import LinkDB, NodeDB, VarAnnotation
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
from ultrack.core.database import OverlapDB
|
|
303
|
+
except (ImportError, AttributeError):
|
|
304
|
+
# Capability probe only: an older ultrack without OverlapDB. A runtime
|
|
305
|
+
# error must not be silently turned into "no overlaps".
|
|
306
|
+
OverlapDB = None
|
|
307
|
+
|
|
308
|
+
engine = _engine(db_path)
|
|
309
|
+
try:
|
|
310
|
+
with Session(engine) as session:
|
|
311
|
+
n_nodes = int(session.query(func.count(NodeDB.id)).scalar() or 0)
|
|
312
|
+
n_links = int(session.query(func.count(LinkDB.source_id)).scalar() or 0)
|
|
313
|
+
node_prob_values = _finite_values(
|
|
314
|
+
session.query(NodeDB.node_prob)
|
|
315
|
+
.filter(NodeDB.node_prob.isnot(None))
|
|
316
|
+
.all()
|
|
317
|
+
)
|
|
318
|
+
link_weight_values = _finite_values(
|
|
319
|
+
session.query(LinkDB.weight)
|
|
320
|
+
.filter(LinkDB.weight.isnot(None))
|
|
321
|
+
.all()
|
|
322
|
+
)
|
|
323
|
+
n_real = int(
|
|
324
|
+
session.query(func.count(NodeDB.id))
|
|
325
|
+
.filter(NodeDB.node_annot == VarAnnotation.REAL)
|
|
326
|
+
.scalar()
|
|
327
|
+
or 0
|
|
328
|
+
)
|
|
329
|
+
n_fake = int(
|
|
330
|
+
session.query(func.count(NodeDB.id))
|
|
331
|
+
.filter(NodeDB.node_annot == VarAnnotation.FAKE)
|
|
332
|
+
.scalar()
|
|
333
|
+
or 0
|
|
334
|
+
)
|
|
335
|
+
link_annotation_rows = (
|
|
336
|
+
session.query(LinkDB.annotation, func.count(LinkDB.source_id))
|
|
337
|
+
.group_by(LinkDB.annotation)
|
|
338
|
+
.all()
|
|
339
|
+
)
|
|
340
|
+
link_annotations = _annotation_counts_from_rows(link_annotation_rows)
|
|
341
|
+
frame_nodes = session.query(NodeDB).filter(NodeDB.t == frame).all()
|
|
342
|
+
selected = sum(1 for n in frame_nodes if getattr(n, "selected", False))
|
|
343
|
+
node_ids = [int(n.id) for n in frame_nodes]
|
|
344
|
+
overlaps = 0
|
|
345
|
+
if node_ids:
|
|
346
|
+
if OverlapDB is not None:
|
|
347
|
+
try:
|
|
348
|
+
overlaps = int(
|
|
349
|
+
session.query(func.count(OverlapDB.node_id))
|
|
350
|
+
.filter(
|
|
351
|
+
OverlapDB.node_id.in_(node_ids)
|
|
352
|
+
| OverlapDB.ancestor_id.in_(node_ids)
|
|
353
|
+
)
|
|
354
|
+
.scalar()
|
|
355
|
+
or 0
|
|
356
|
+
)
|
|
357
|
+
except Exception:
|
|
358
|
+
overlaps = 0
|
|
359
|
+
stats_parts = [
|
|
360
|
+
text
|
|
361
|
+
for text in (
|
|
362
|
+
_stats_text("node prob", node_prob_values),
|
|
363
|
+
_stats_text("edge weight", link_weight_values),
|
|
364
|
+
)
|
|
365
|
+
if text is not None
|
|
366
|
+
]
|
|
367
|
+
lines = [
|
|
368
|
+
f"Database: {n_nodes} nodes, {n_links} links",
|
|
369
|
+
f"Node annotations: REAL {n_real}, FAKE {n_fake}",
|
|
370
|
+
(
|
|
371
|
+
f"Link annotations: REAL links {link_annotations['REAL']}, "
|
|
372
|
+
f"FAKE links {link_annotations['FAKE']}"
|
|
373
|
+
),
|
|
374
|
+
f"Frame {frame}: {selected} selected, {overlaps} overlaps",
|
|
375
|
+
]
|
|
376
|
+
if stats_parts:
|
|
377
|
+
lines.extend(stats_parts)
|
|
378
|
+
return "\n".join(lines)
|
|
379
|
+
finally:
|
|
380
|
+
engine.dispose()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def query_distinct_heights(db_path: Path) -> tuple[float, ...]:
|
|
384
|
+
from sqlalchemy.orm import Session
|
|
385
|
+
from ultrack.core.database import NodeDB
|
|
386
|
+
|
|
387
|
+
engine = _engine(db_path)
|
|
388
|
+
try:
|
|
389
|
+
with Session(engine) as session:
|
|
390
|
+
return tuple(
|
|
391
|
+
float(r[0])
|
|
392
|
+
for r in session.query(NodeDB.height)
|
|
393
|
+
.distinct()
|
|
394
|
+
.order_by(NodeDB.height)
|
|
395
|
+
.all()
|
|
396
|
+
if r[0] is not None
|
|
397
|
+
)
|
|
398
|
+
finally:
|
|
399
|
+
engine.dispose()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def query_union_sizes(db_path: Path, frame: int) -> tuple[int, ...]:
|
|
403
|
+
"""Distinct atom-union sizes (``height`` = number of merged atoms) present in
|
|
404
|
+
``frame``, sorted ascending. Drives the vertical "union size" slider: index 0
|
|
405
|
+
is the finest (1 atom = individual atoms), higher indices merge more atoms.
|
|
406
|
+
"""
|
|
407
|
+
from sqlalchemy.orm import Session
|
|
408
|
+
from ultrack.core.database import NodeDB
|
|
409
|
+
|
|
410
|
+
engine = _engine(db_path)
|
|
411
|
+
try:
|
|
412
|
+
with Session(engine) as session:
|
|
413
|
+
rows = (
|
|
414
|
+
session.query(NodeDB.height)
|
|
415
|
+
.filter(NodeDB.t == frame)
|
|
416
|
+
.distinct()
|
|
417
|
+
.order_by(NodeDB.height)
|
|
418
|
+
.all()
|
|
419
|
+
)
|
|
420
|
+
finally:
|
|
421
|
+
engine.dispose()
|
|
422
|
+
return tuple(int(round(float(r[0]))) for r in rows if r[0] is not None)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def query_union_color_classes(
|
|
426
|
+
db_path: Path, frame: int, union_size: int
|
|
427
|
+
) -> tuple[tuple[int, ...], ...]:
|
|
428
|
+
"""Group the size-``union_size`` candidates of ``frame`` into color classes of
|
|
429
|
+
mutually non-overlapping candidates (a greedy graph coloring of the shared-atom
|
|
430
|
+
overlap graph from ``OverlapDB``).
|
|
431
|
+
|
|
432
|
+
Each returned class is a tuple of node ids that can be painted together in one
|
|
433
|
+
full-frame partition without conflict, so every candidate of this size is shown
|
|
434
|
+
in exactly one class while keeping the number of classes (= horizontal slider
|
|
435
|
+
positions) small. Returns ``()`` if no candidates of this size exist.
|
|
436
|
+
"""
|
|
437
|
+
from sqlalchemy.orm import Session
|
|
438
|
+
from ultrack.core.database import NodeDB
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
from ultrack.core.database import OverlapDB
|
|
442
|
+
except (ImportError, AttributeError):
|
|
443
|
+
# Capability probe only: an older ultrack without OverlapDB. A runtime
|
|
444
|
+
# error must not be silently turned into "no overlaps".
|
|
445
|
+
OverlapDB = None
|
|
446
|
+
|
|
447
|
+
engine = _engine(db_path)
|
|
448
|
+
try:
|
|
449
|
+
with Session(engine) as session:
|
|
450
|
+
node_ids = [
|
|
451
|
+
int(r[0])
|
|
452
|
+
for r in session.query(NodeDB.id)
|
|
453
|
+
.filter(NodeDB.t == frame)
|
|
454
|
+
.filter(NodeDB.height == float(union_size))
|
|
455
|
+
.order_by(NodeDB.id)
|
|
456
|
+
.all()
|
|
457
|
+
]
|
|
458
|
+
id_set = set(node_ids)
|
|
459
|
+
edges: list[tuple[int, int]] = []
|
|
460
|
+
if OverlapDB is not None and node_ids:
|
|
461
|
+
# OverlapDB stores node_id < ancestor_id, so every intra-set edge has
|
|
462
|
+
# its node_id endpoint in node_ids; filtering on that side is exact.
|
|
463
|
+
rows = (
|
|
464
|
+
session.query(OverlapDB.node_id, OverlapDB.ancestor_id)
|
|
465
|
+
.filter(OverlapDB.node_id.in_(node_ids))
|
|
466
|
+
.all()
|
|
467
|
+
)
|
|
468
|
+
edges = [(int(a), int(b)) for a, b in rows if int(b) in id_set]
|
|
469
|
+
finally:
|
|
470
|
+
engine.dispose()
|
|
471
|
+
|
|
472
|
+
return greedy_color_classes(node_ids, edges)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def greedy_color_classes(
|
|
476
|
+
node_ids: list[int], edges: Iterable[tuple[int, int]]
|
|
477
|
+
) -> tuple[tuple[int, ...], ...]:
|
|
478
|
+
"""Partition ``node_ids`` into classes with no intra-class edge (greedy graph
|
|
479
|
+
coloring). Welsh–Powell order (highest degree first) keeps the class count low;
|
|
480
|
+
ids preserve their original order within each class. Pure helper, no DB."""
|
|
481
|
+
if not node_ids:
|
|
482
|
+
return ()
|
|
483
|
+
adj: dict[int, set[int]] = {nid: set() for nid in node_ids}
|
|
484
|
+
id_set = set(node_ids)
|
|
485
|
+
for a, b in edges:
|
|
486
|
+
if a != b and a in id_set and b in id_set:
|
|
487
|
+
adj[a].add(b)
|
|
488
|
+
adj[b].add(a)
|
|
489
|
+
|
|
490
|
+
order = sorted(node_ids, key=lambda n: (-len(adj[n]), n))
|
|
491
|
+
colors: dict[int, int] = {}
|
|
492
|
+
for nid in order:
|
|
493
|
+
used = {colors[m] for m in adj[nid] if m in colors}
|
|
494
|
+
c = 0
|
|
495
|
+
while c in used:
|
|
496
|
+
c += 1
|
|
497
|
+
colors[nid] = c
|
|
498
|
+
|
|
499
|
+
n_colors = max(colors.values()) + 1
|
|
500
|
+
classes: list[list[int]] = [[] for _ in range(n_colors)]
|
|
501
|
+
for nid in node_ids: # stable id order within each class
|
|
502
|
+
classes[colors[nid]].append(nid)
|
|
503
|
+
return tuple(tuple(c) for c in classes)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _paint_union_partition(
|
|
507
|
+
union_nodes: list, fill_nodes: list, plane_shape: tuple[int, int]
|
|
508
|
+
) -> tuple[np.ndarray, list]:
|
|
509
|
+
"""Paint a full-frame partition: the merged ``union_nodes`` (the selected merge
|
|
510
|
+
group) take priority, then ``fill_nodes`` cover the leftover territory.
|
|
511
|
+
|
|
512
|
+
``fill_nodes`` should be ordered most-merged first; each is painted all-or-nothing
|
|
513
|
+
only if its whole mask is still free, so a leftover atom is shown inside the
|
|
514
|
+
largest union that still fits rather than dropping back to an individual atom.
|
|
515
|
+
|
|
516
|
+
Returns ``(labels, ordered_nodes)`` where ``ordered_nodes[i]`` is painted with
|
|
517
|
+
label ``i + 1`` — matching ``node_preview_metadata`` so click selection lines up.
|
|
518
|
+
"""
|
|
519
|
+
parsed: list[tuple] = []
|
|
520
|
+
max_y = max_x = 0
|
|
521
|
+
for node in union_nodes:
|
|
522
|
+
result = node_mask_and_bbox(node)
|
|
523
|
+
if result is None:
|
|
524
|
+
continue
|
|
525
|
+
(y0, x0, y1, x1), mask = result
|
|
526
|
+
max_y, max_x = max(max_y, y1), max(max_x, x1)
|
|
527
|
+
parsed.append((node, (y0, x0, y1, x1), mask, True))
|
|
528
|
+
for node in fill_nodes:
|
|
529
|
+
result = node_mask_and_bbox(node)
|
|
530
|
+
if result is None:
|
|
531
|
+
continue
|
|
532
|
+
(y0, x0, y1, x1), mask = result
|
|
533
|
+
max_y, max_x = max(max_y, y1), max(max_x, x1)
|
|
534
|
+
parsed.append((node, (y0, x0, y1, x1), mask, False))
|
|
535
|
+
|
|
536
|
+
base_y, base_x = plane_shape
|
|
537
|
+
labels = np.zeros((max(base_y, max_y, 1), max(base_x, max_x, 1)), dtype=np.uint32)
|
|
538
|
+
ordered: list = []
|
|
539
|
+
for node, (y0, x0, y1, x1), mask, priority in parsed:
|
|
540
|
+
region = labels[y0:y1, x0:x1]
|
|
541
|
+
if region.shape != mask.shape:
|
|
542
|
+
continue
|
|
543
|
+
if priority:
|
|
544
|
+
# Unions never overlap within a class, so they paint freely.
|
|
545
|
+
sub = mask
|
|
546
|
+
else:
|
|
547
|
+
# All-or-nothing: only paint a leftover union/atom if its whole footprint
|
|
548
|
+
# is still free, so larger unions win and nothing is split mid-region.
|
|
549
|
+
if (mask & (region != 0)).any():
|
|
550
|
+
continue
|
|
551
|
+
sub = mask
|
|
552
|
+
if not sub.any():
|
|
553
|
+
continue
|
|
554
|
+
region[sub] = len(ordered) + 1
|
|
555
|
+
ordered.append(node)
|
|
556
|
+
return labels, ordered
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def render_union_partition(
|
|
560
|
+
db_path: Path,
|
|
561
|
+
frame: int,
|
|
562
|
+
color_node_ids: Iterable[int],
|
|
563
|
+
*,
|
|
564
|
+
plane_shape: tuple[int, int],
|
|
565
|
+
union_size: int | None = None,
|
|
566
|
+
) -> UltrackDbPreview:
|
|
567
|
+
"""Full-frame partition for one horizontal slider position: the candidates in
|
|
568
|
+
``color_node_ids`` painted as merged regions, with the leftover territory filled
|
|
569
|
+
by the largest available unions (size ≤ ``union_size``) so regions outside this
|
|
570
|
+
merge group still show at their most-merged state instead of as raw atoms.
|
|
571
|
+
|
|
572
|
+
``union_size`` caps how merged the leftover fill may be; when ``None`` it is taken
|
|
573
|
+
from the selected candidates (their ``height``), defaulting to atoms only.
|
|
574
|
+
"""
|
|
575
|
+
from sqlalchemy.orm import Session
|
|
576
|
+
from ultrack.core.database import NodeDB
|
|
577
|
+
|
|
578
|
+
color_node_ids = tuple(int(n) for n in color_node_ids)
|
|
579
|
+
engine = _engine(db_path)
|
|
580
|
+
try:
|
|
581
|
+
with Session(engine) as session:
|
|
582
|
+
union_nodes: list = []
|
|
583
|
+
if color_node_ids:
|
|
584
|
+
rows = (
|
|
585
|
+
session.query(NodeDB)
|
|
586
|
+
.filter(NodeDB.t == frame)
|
|
587
|
+
.filter(NodeDB.id.in_(color_node_ids))
|
|
588
|
+
.all()
|
|
589
|
+
)
|
|
590
|
+
by_id = {int(n.id): n for n in rows}
|
|
591
|
+
union_nodes = [by_id[i] for i in color_node_ids if i in by_id]
|
|
592
|
+
if union_size is None:
|
|
593
|
+
heights = [
|
|
594
|
+
float(n.height) for n in union_nodes if n.height is not None
|
|
595
|
+
]
|
|
596
|
+
union_size = int(round(max(heights))) if heights else 1
|
|
597
|
+
# Leftover fill: every non-selected candidate up to the current level,
|
|
598
|
+
# most-merged first, so larger unions claim free territory before atoms.
|
|
599
|
+
fill_nodes = (
|
|
600
|
+
session.query(NodeDB)
|
|
601
|
+
.filter(NodeDB.t == frame)
|
|
602
|
+
.filter(NodeDB.height <= float(union_size))
|
|
603
|
+
.filter(NodeDB.id.notin_(color_node_ids) if color_node_ids else True)
|
|
604
|
+
.order_by(NodeDB.height.desc(), NodeDB.id)
|
|
605
|
+
.all()
|
|
606
|
+
)
|
|
607
|
+
finally:
|
|
608
|
+
engine.dispose()
|
|
609
|
+
|
|
610
|
+
labels, ordered = _paint_union_partition(union_nodes, fill_nodes, plane_shape)
|
|
611
|
+
if not ordered:
|
|
612
|
+
return _empty_preview(plane_shape, f"No candidates for frame {frame}.")
|
|
613
|
+
|
|
614
|
+
probabilities, label_to_node_id, node_id_to_label = node_preview_metadata(ordered)
|
|
615
|
+
annotations = node_annotation_metadata(ordered)
|
|
616
|
+
n_merged = len(union_nodes)
|
|
617
|
+
return UltrackDbPreview(
|
|
618
|
+
labels=labels,
|
|
619
|
+
status=(
|
|
620
|
+
f"Frame {frame}: {len(ordered)} region(s), "
|
|
621
|
+
f"{n_merged} merged candidate(s)."
|
|
622
|
+
),
|
|
623
|
+
probabilities=probabilities,
|
|
624
|
+
label_to_node_id=label_to_node_id,
|
|
625
|
+
node_id_to_label=node_id_to_label,
|
|
626
|
+
node_annotations=annotations,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def query_hierarchy_cut_states(
|
|
631
|
+
db_path: Path, frame: int, *, source_index: int | None = None
|
|
632
|
+
) -> tuple[HierarchyCutState, ...]:
|
|
633
|
+
from sqlalchemy.orm import Session
|
|
634
|
+
from ultrack.core.database import NodeDB
|
|
635
|
+
from ultrack.utils.constants import NO_PARENT
|
|
636
|
+
|
|
637
|
+
source_node_ids: tuple[int, ...] = ()
|
|
638
|
+
if source_index is not None:
|
|
639
|
+
try:
|
|
640
|
+
from itasc.tracking_ultrack.multi_threshold import query_source_node_ids
|
|
641
|
+
|
|
642
|
+
source_node_ids = query_source_node_ids(db_path, int(source_index))
|
|
643
|
+
except Exception:
|
|
644
|
+
source_node_ids = ()
|
|
645
|
+
|
|
646
|
+
engine = _engine(db_path)
|
|
647
|
+
try:
|
|
648
|
+
with Session(engine) as session:
|
|
649
|
+
query = (
|
|
650
|
+
session.query(NodeDB.id, NodeDB.hier_parent_id, NodeDB.height)
|
|
651
|
+
.filter(NodeDB.t == frame)
|
|
652
|
+
.order_by(NodeDB.height, NodeDB.id)
|
|
653
|
+
)
|
|
654
|
+
if source_index is not None:
|
|
655
|
+
query = query.filter(NodeDB.id.in_(source_node_ids))
|
|
656
|
+
rows = []
|
|
657
|
+
for nid, pid, height in query.all():
|
|
658
|
+
if height is None:
|
|
659
|
+
continue
|
|
660
|
+
parent_id = NO_PARENT if pid is None else int(pid)
|
|
661
|
+
rows.append((int(nid), parent_id, float(height)))
|
|
662
|
+
except Exception:
|
|
663
|
+
return tuple(HierarchyCutState((), float(h)) for h in query_distinct_heights(db_path))
|
|
664
|
+
finally:
|
|
665
|
+
engine.dispose()
|
|
666
|
+
|
|
667
|
+
if not rows:
|
|
668
|
+
return ()
|
|
669
|
+
|
|
670
|
+
node_ids = {nid for nid, _, _ in rows}
|
|
671
|
+
heights_by_id = {nid: height for nid, _, height in rows}
|
|
672
|
+
parent_by_id = {
|
|
673
|
+
nid: pid for nid, pid, _ in rows if pid != NO_PARENT and pid in node_ids
|
|
674
|
+
}
|
|
675
|
+
children: dict[int, set[int]] = {}
|
|
676
|
+
for child_id, parent_id in parent_by_id.items():
|
|
677
|
+
children.setdefault(parent_id, set()).add(child_id)
|
|
678
|
+
|
|
679
|
+
active = {nid for nid, _, _ in rows if nid not in children}
|
|
680
|
+
if not active:
|
|
681
|
+
active = set(node_ids)
|
|
682
|
+
|
|
683
|
+
states: list[HierarchyCutState] = []
|
|
684
|
+
seen: set[tuple[int, ...]] = set()
|
|
685
|
+
|
|
686
|
+
def append_state() -> None:
|
|
687
|
+
ordered = tuple(sorted(active, key=lambda n: (heights_by_id[n], n)))
|
|
688
|
+
if ordered in seen:
|
|
689
|
+
return
|
|
690
|
+
seen.add(ordered)
|
|
691
|
+
height = max((heights_by_id[n] for n in ordered), default=None)
|
|
692
|
+
states.append(HierarchyCutState(ordered, height))
|
|
693
|
+
|
|
694
|
+
append_state()
|
|
695
|
+
while True:
|
|
696
|
+
promotable = [
|
|
697
|
+
parent_id
|
|
698
|
+
for parent_id, child_ids in children.items()
|
|
699
|
+
if parent_id not in active and child_ids and child_ids.issubset(active)
|
|
700
|
+
]
|
|
701
|
+
if not promotable:
|
|
702
|
+
break
|
|
703
|
+
min_height = min(heights_by_id[parent_id] for parent_id in promotable)
|
|
704
|
+
for parent_id in sorted(
|
|
705
|
+
p for p in promotable if heights_by_id[p] == min_height
|
|
706
|
+
):
|
|
707
|
+
active.difference_update(children[parent_id])
|
|
708
|
+
active.add(parent_id)
|
|
709
|
+
append_state()
|
|
710
|
+
|
|
711
|
+
return tuple(states)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def render_hierarchy_cut(
|
|
715
|
+
db_path: Path,
|
|
716
|
+
frame: int,
|
|
717
|
+
height: float,
|
|
718
|
+
*,
|
|
719
|
+
plane_shape: tuple[int, int],
|
|
720
|
+
) -> UltrackDbPreview:
|
|
721
|
+
from sqlalchemy.orm import Session, aliased
|
|
722
|
+
from ultrack.core.database import NodeDB
|
|
723
|
+
from ultrack.utils.constants import NO_PARENT
|
|
724
|
+
|
|
725
|
+
engine = _engine(db_path)
|
|
726
|
+
try:
|
|
727
|
+
with Session(engine) as session:
|
|
728
|
+
parent = aliased(NodeDB)
|
|
729
|
+
child = aliased(NodeDB)
|
|
730
|
+
same_child = (
|
|
731
|
+
session.query(child.id)
|
|
732
|
+
.where(child.hier_parent_id == NodeDB.id)
|
|
733
|
+
.where(child.height == NodeDB.height)
|
|
734
|
+
.where(NodeDB.height == height)
|
|
735
|
+
.exists()
|
|
736
|
+
)
|
|
737
|
+
nodes = (
|
|
738
|
+
session.query(NodeDB)
|
|
739
|
+
.outerjoin(parent, NodeDB.hier_parent_id == parent.id)
|
|
740
|
+
.where(NodeDB.t == frame)
|
|
741
|
+
.where(NodeDB.height <= height)
|
|
742
|
+
.where(
|
|
743
|
+
(NodeDB.hier_parent_id == NO_PARENT)
|
|
744
|
+
| ((NodeDB.height < height) & (parent.height > height))
|
|
745
|
+
| ((NodeDB.height == height) & (parent.height >= height))
|
|
746
|
+
)
|
|
747
|
+
.where(~same_child)
|
|
748
|
+
.all()
|
|
749
|
+
)
|
|
750
|
+
finally:
|
|
751
|
+
engine.dispose()
|
|
752
|
+
return finalize_hierarchy_nodes(
|
|
753
|
+
nodes,
|
|
754
|
+
frame,
|
|
755
|
+
plane_shape=plane_shape,
|
|
756
|
+
empty_msg=f"No segments at this threshold for frame {frame}.",
|
|
757
|
+
status_suffix=f"at h={height:.2f}",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def render_hierarchy_cut_state(
|
|
762
|
+
db_path: Path,
|
|
763
|
+
frame: int,
|
|
764
|
+
state: HierarchyCutState,
|
|
765
|
+
*,
|
|
766
|
+
plane_shape: tuple[int, int],
|
|
767
|
+
) -> UltrackDbPreview:
|
|
768
|
+
if not state.node_ids:
|
|
769
|
+
return render_hierarchy_cut(
|
|
770
|
+
db_path,
|
|
771
|
+
frame,
|
|
772
|
+
float(state.height or 0.0),
|
|
773
|
+
plane_shape=plane_shape,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
from sqlalchemy.orm import Session
|
|
777
|
+
from ultrack.core.database import NodeDB
|
|
778
|
+
|
|
779
|
+
engine = _engine(db_path)
|
|
780
|
+
try:
|
|
781
|
+
with Session(engine) as session:
|
|
782
|
+
rows = (
|
|
783
|
+
session.query(NodeDB)
|
|
784
|
+
.where(NodeDB.t == frame)
|
|
785
|
+
.where(NodeDB.id.in_(state.node_ids))
|
|
786
|
+
.all()
|
|
787
|
+
)
|
|
788
|
+
finally:
|
|
789
|
+
engine.dispose()
|
|
790
|
+
|
|
791
|
+
by_id = {int(n.id): n for n in rows}
|
|
792
|
+
nodes = [by_id[nid] for nid in state.node_ids if nid in by_id]
|
|
793
|
+
height = "—" if state.height is None else f"{state.height:.2f}"
|
|
794
|
+
return finalize_hierarchy_nodes(
|
|
795
|
+
nodes,
|
|
796
|
+
frame,
|
|
797
|
+
plane_shape=plane_shape,
|
|
798
|
+
empty_msg=f"No hierarchy state segments for frame {frame}.",
|
|
799
|
+
status_suffix=f"at cut state h={height}",
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def finalize_hierarchy_nodes(
|
|
804
|
+
nodes: Iterable,
|
|
805
|
+
frame: int,
|
|
806
|
+
*,
|
|
807
|
+
plane_shape: tuple[int, int],
|
|
808
|
+
empty_msg: str,
|
|
809
|
+
status_suffix: str,
|
|
810
|
+
) -> UltrackDbPreview:
|
|
811
|
+
nodes = list(nodes)
|
|
812
|
+
if not nodes:
|
|
813
|
+
return _empty_preview(plane_shape, empty_msg)
|
|
814
|
+
|
|
815
|
+
labels = paint_nodes(nodes, plane_shape)
|
|
816
|
+
probabilities, label_to_node_id, node_id_to_label = node_preview_metadata(nodes)
|
|
817
|
+
annotations = node_annotation_metadata(nodes)
|
|
818
|
+
return UltrackDbPreview(
|
|
819
|
+
labels=labels,
|
|
820
|
+
status=f"Frame {frame}: {len(nodes)} segment(s) {status_suffix}.",
|
|
821
|
+
probabilities=probabilities,
|
|
822
|
+
label_to_node_id=label_to_node_id,
|
|
823
|
+
node_id_to_label=node_id_to_label,
|
|
824
|
+
node_annotations=annotations,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _empty_preview(plane_shape: tuple[int, int], status: str) -> UltrackDbPreview:
|
|
829
|
+
return UltrackDbPreview(
|
|
830
|
+
labels=np.zeros(plane_shape, dtype=np.uint32),
|
|
831
|
+
status=status,
|
|
832
|
+
probabilities={},
|
|
833
|
+
label_to_node_id={},
|
|
834
|
+
node_id_to_label={},
|
|
835
|
+
node_annotations={},
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def annotation_name(value) -> str:
|
|
840
|
+
if value is None:
|
|
841
|
+
return "UNKNOWN"
|
|
842
|
+
for raw in (getattr(value, "name", None), value, getattr(value, "value", None)):
|
|
843
|
+
if raw is None:
|
|
844
|
+
continue
|
|
845
|
+
if raw in {0, "0"}:
|
|
846
|
+
return "UNKNOWN"
|
|
847
|
+
if raw in {1, "1"}:
|
|
848
|
+
return "REAL"
|
|
849
|
+
if raw in {2, "2"}:
|
|
850
|
+
return "FAKE"
|
|
851
|
+
name = str(raw).split(".")[-1].upper()
|
|
852
|
+
if name in {"REAL", "FAKE", "UNKNOWN"}:
|
|
853
|
+
return name
|
|
854
|
+
return "UNKNOWN"
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def node_preview_metadata(nodes) -> tuple[dict[int, float], dict[int, int], dict[int, int]]:
|
|
858
|
+
probabilities: dict[int, float] = {}
|
|
859
|
+
label_to_node_id: dict[int, int] = {}
|
|
860
|
+
node_id_to_label: dict[int, int] = {}
|
|
861
|
+
for label, node in enumerate(nodes, start=1):
|
|
862
|
+
try:
|
|
863
|
+
probability = float(node.node_prob if node.node_prob is not None else 1.0)
|
|
864
|
+
except (TypeError, ValueError):
|
|
865
|
+
probability = 1.0
|
|
866
|
+
probabilities[label] = probability
|
|
867
|
+
try:
|
|
868
|
+
node_id = int(node.id)
|
|
869
|
+
except (TypeError, ValueError):
|
|
870
|
+
continue
|
|
871
|
+
label_to_node_id[label] = node_id
|
|
872
|
+
node_id_to_label[node_id] = label
|
|
873
|
+
return probabilities, label_to_node_id, node_id_to_label
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def node_annotation_metadata(nodes) -> dict[int, str]:
|
|
877
|
+
annotations: dict[int, str] = {}
|
|
878
|
+
for node in nodes:
|
|
879
|
+
try:
|
|
880
|
+
node_id = int(node.id)
|
|
881
|
+
except (TypeError, ValueError):
|
|
882
|
+
continue
|
|
883
|
+
annotations[node_id] = annotation_name(getattr(node, "node_annot", None))
|
|
884
|
+
return annotations
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def paint_nodes(nodes, plane_shape: tuple[int, int]) -> np.ndarray:
|
|
888
|
+
masks: list[tuple[int, tuple[int, int, int, int], np.ndarray]] = []
|
|
889
|
+
max_y = max_x = 0
|
|
890
|
+
for label, node in enumerate(nodes, start=1):
|
|
891
|
+
parsed = node_mask_and_bbox(node)
|
|
892
|
+
if parsed is None:
|
|
893
|
+
continue
|
|
894
|
+
bbox, mask = parsed
|
|
895
|
+
y0, x0, y1, x1 = bbox
|
|
896
|
+
max_y = max(max_y, y1)
|
|
897
|
+
max_x = max(max_x, x1)
|
|
898
|
+
masks.append((label, bbox, mask))
|
|
899
|
+
|
|
900
|
+
base_y, base_x = plane_shape
|
|
901
|
+
labels = np.zeros((max(base_y, max_y, 1), max(base_x, max_x, 1)), dtype=np.uint32)
|
|
902
|
+
for label, (y0, x0, y1, x1), mask in masks:
|
|
903
|
+
target = labels[y0:y1, x0:x1]
|
|
904
|
+
if target.shape != mask.shape:
|
|
905
|
+
continue
|
|
906
|
+
target[mask.astype(bool)] = label
|
|
907
|
+
return labels
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def node_mask_and_bbox(node):
|
|
911
|
+
try:
|
|
912
|
+
node_obj = node.pickle
|
|
913
|
+
if isinstance(node_obj, (bytes, memoryview)):
|
|
914
|
+
node_obj = pickle.loads(bytes(node_obj))
|
|
915
|
+
if node_obj is None:
|
|
916
|
+
return None
|
|
917
|
+
except Exception:
|
|
918
|
+
return None
|
|
919
|
+
|
|
920
|
+
if isinstance(node_obj, dict):
|
|
921
|
+
bbox, mask = node_obj.get("bbox"), node_obj.get("mask")
|
|
922
|
+
elif isinstance(node_obj, tuple) and len(node_obj) >= 2:
|
|
923
|
+
bbox, mask = node_obj[0], node_obj[1]
|
|
924
|
+
else:
|
|
925
|
+
bbox = getattr(node_obj, "bbox", None)
|
|
926
|
+
mask = getattr(node_obj, "mask", None)
|
|
927
|
+
if bbox is None or mask is None:
|
|
928
|
+
return None
|
|
929
|
+
|
|
930
|
+
bbox_array = np.asarray(bbox, dtype=int).ravel()
|
|
931
|
+
if bbox_array.size >= 6:
|
|
932
|
+
y0, x0, y1, x1 = (
|
|
933
|
+
int(bbox_array[1]),
|
|
934
|
+
int(bbox_array[2]),
|
|
935
|
+
int(bbox_array[4]),
|
|
936
|
+
int(bbox_array[5]),
|
|
937
|
+
)
|
|
938
|
+
elif bbox_array.size >= 4:
|
|
939
|
+
y0, x0, y1, x1 = (int(v) for v in bbox_array[:4])
|
|
940
|
+
else:
|
|
941
|
+
return None
|
|
942
|
+
|
|
943
|
+
mask_array = np.asarray(mask)
|
|
944
|
+
if mask_array.ndim == 3 and mask_array.shape[0] == 1:
|
|
945
|
+
mask_array = mask_array[0]
|
|
946
|
+
elif mask_array.ndim > 2:
|
|
947
|
+
mask_array = np.squeeze(mask_array)
|
|
948
|
+
if mask_array.ndim != 2:
|
|
949
|
+
return None
|
|
950
|
+
if mask_array.shape != (y1 - y0, x1 - x0):
|
|
951
|
+
return None
|
|
952
|
+
return (y0, x0, y1, x1), mask_array.astype(bool, copy=False)
|