itasc 0.2.0.dev0__py3-none-any.whl

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