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,1213 @@
1
+ """Per-frame correction primitives for Ultrack solves and exports."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import numpy as np
10
+
11
+ from itasc.tracking_ultrack.config import TrackingConfig
12
+
13
+ LOG = logging.getLogger(__name__)
14
+
15
+
16
+ CorrectionKind = Literal["validated", "anchor"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Correction:
21
+ cell_id: int
22
+ t: int
23
+ kind: CorrectionKind
24
+ y: float
25
+ x: float
26
+
27
+ def __post_init__(self) -> None:
28
+ if self.kind not in {"validated", "anchor"}:
29
+ raise ValueError(f"Unknown correction kind: {self.kind!r}")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class CorrectionDatabaseReport:
34
+ fake_nodes: int = 0
35
+ anchor_nodes: int = 0
36
+ anchor_links: int = 0
37
+ anchor_overlaps_pruned: int = 0
38
+ unmatched_anchors: tuple[Correction, ...] = ()
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AnchorIncidentLinkReport:
43
+ inserted: int = 0
44
+ anchors_processed: int = 0
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class AnchorTailLinkReport:
49
+ annotated: int = 0
50
+ skipped_no_anchor: int = 0
51
+ skipped_no_link: int = 0
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class PostSolveCorrectionReport:
56
+ remapped_anchor_tracks: int = 0
57
+ stamped_anchors: int = 0
58
+ pasted_validated: int = 0
59
+
60
+
61
+ def _distance(y0: float, x0: float, y1: float, x1: float) -> float:
62
+ return float(np.hypot(float(y0) - float(y1), float(x0) - float(x1)))
63
+
64
+
65
+ def corrections_from_validated_tracks(
66
+ validated_tracks: dict[int, set[int]],
67
+ tracked_labels: np.ndarray,
68
+ ) -> list[Correction]:
69
+ """Convert the legacy validated-track store to validated corrections."""
70
+ corrections: list[Correction] = []
71
+ labels = np.asarray(tracked_labels)
72
+ for raw_cell_id, frames in sorted(validated_tracks.items()):
73
+ cell_id = int(raw_cell_id)
74
+ for raw_t in sorted(frames):
75
+ t = int(raw_t)
76
+ if t < 0 or t >= labels.shape[0]:
77
+ continue
78
+ centroid = _frame_centroid(np.asarray(labels[t] == cell_id))
79
+ if centroid is None:
80
+ continue
81
+ y, x = centroid
82
+ corrections.append(Correction(cell_id=cell_id, t=t, kind="validated", y=y, x=x))
83
+ return corrections
84
+
85
+
86
+ def apply_corrections_to_database(
87
+ working_dir: str | Path,
88
+ corrections: list[Correction],
89
+ cfg: TrackingConfig,
90
+ *,
91
+ annotate_anchor_links: bool = True,
92
+ tracked_labels: np.ndarray | None = None,
93
+ ) -> CorrectionDatabaseReport:
94
+ """Apply solve-time correction annotations to ``data.db``.
95
+
96
+ Validated frames mark nearby candidates ``FAKE``. Anchors mark the nearest
97
+ surviving candidate ``REAL`` and mark consecutive anchor links ``REAL``.
98
+ In Ultrack 0.6.x these are hard annotations when solve uses
99
+ ``use_annotations=True``.
100
+
101
+ ``tracked_labels`` enables IoU-based candidate matching for anchors. When
102
+ provided, the mask of the anchored cell in ``tracked_labels`` is compared
103
+ against NodeDB candidate masks, and the candidate with the highest IoU is
104
+ chosen. This correctly identifies the intended candidate even when two
105
+ hierarchical candidates (e.g. parent vs child) have nearly identical
106
+ centroids. Falls back to centroid-distance matching when no IoU > 0 is
107
+ found.
108
+ """
109
+ import sqlalchemy as sqla
110
+ from sqlalchemy.orm import Session
111
+ from ultrack.core.database import LinkDB, NodeDB, OverlapDB, VarAnnotation
112
+
113
+ if not corrections:
114
+ return CorrectionDatabaseReport()
115
+
116
+ radius = float(cfg.anchor_radius_px)
117
+ engine = sqla.create_engine(f"sqlite:///{Path(working_dir) / 'data.db'}")
118
+ fake_node_ids: set[int] = set()
119
+ resolved_anchor_nodes: dict[tuple[int, int], int] = {}
120
+ unmatched_anchor_list: list[Correction] = []
121
+ anchor_nodes = 0
122
+ anchor_links = 0
123
+ anchor_overlaps_pruned = 0
124
+
125
+ with Session(engine) as session:
126
+ for correction in corrections:
127
+ if correction.kind != "validated":
128
+ continue
129
+ rows = session.query(NodeDB.id, NodeDB.y, NodeDB.x).where(
130
+ NodeDB.t == int(correction.t)
131
+ )
132
+ for node_id, y, x in rows:
133
+ if _distance(y, x, correction.y, correction.x) <= radius:
134
+ fake_node_ids.add(int(node_id))
135
+
136
+ if fake_node_ids:
137
+ session.query(NodeDB).where(NodeDB.id.in_(sorted(fake_node_ids))).update(
138
+ {NodeDB.node_annot: VarAnnotation.FAKE},
139
+ synchronize_session=False,
140
+ )
141
+
142
+ labels_arr = np.asarray(tracked_labels) if tracked_labels is not None else None
143
+
144
+ for correction in corrections:
145
+ if correction.kind != "anchor":
146
+ continue
147
+
148
+ nearest: tuple[float, int] | None = None
149
+
150
+ # IoU-based matching: find the candidate whose mask best overlaps
151
+ # the cell the user currently has in tracked_labels. This is the
152
+ # correct approach when two hierarchical candidates (e.g. parent
153
+ # mask vs smaller child mask) share nearly the same centroid —
154
+ # centroid distance cannot distinguish them, but IoU can.
155
+ if labels_arr is not None:
156
+ t_idx = int(correction.t)
157
+ if 0 <= t_idx < labels_arr.shape[0]:
158
+ t_frame = labels_arr[t_idx]
159
+ raw_mask = (
160
+ (t_frame == int(correction.cell_id)).any(axis=0)
161
+ if t_frame.ndim == 3
162
+ else (t_frame == int(correction.cell_id))
163
+ )
164
+ if raw_mask.any():
165
+ from itasc.tracking_ultrack._node_geometry import (
166
+ node_bbox_and_mask as _nbm,
167
+ raw_iou as _raw_iou,
168
+ )
169
+ rows_nz = np.flatnonzero(raw_mask.any(axis=1))
170
+ cols_nz = np.flatnonzero(raw_mask.any(axis=0))
171
+ y0, y1 = int(rows_nz[0]), int(rows_nz[-1]) + 1
172
+ x0, x1 = int(cols_nz[0]), int(cols_nz[-1]) + 1
173
+ anchor_bbox = (y0, x0, y1, x1)
174
+ anchor_crop = np.ascontiguousarray(raw_mask[y0:y1, x0:x1], dtype=bool)
175
+ broad = radius * 4
176
+ best_iou = 0.0
177
+ best_iou_node: int | None = None
178
+ for node_id, y, x, node_pickle in session.query(
179
+ NodeDB.id, NodeDB.y, NodeDB.x, NodeDB.pickle
180
+ ).where(
181
+ NodeDB.t == t_idx,
182
+ NodeDB.node_annot != VarAnnotation.FAKE,
183
+ ):
184
+ if _distance(y, x, correction.y, correction.x) > broad:
185
+ continue
186
+ cand_bbox, cand_mask = _nbm(int(node_id), node_pickle)
187
+ iou = _raw_iou(anchor_bbox, anchor_crop, cand_bbox, cand_mask)
188
+ if iou > best_iou:
189
+ best_iou = iou
190
+ best_iou_node = int(node_id)
191
+ if best_iou_node is not None:
192
+ nearest = (0.0, best_iou_node)
193
+
194
+ # Centroid-distance fallback (used when tracked_labels unavailable
195
+ # or the cell has no mask at this frame in tracked_labels).
196
+ if nearest is None:
197
+ for node_id, y, x in session.query(NodeDB.id, NodeDB.y, NodeDB.x).where(
198
+ NodeDB.t == int(correction.t),
199
+ NodeDB.node_annot != VarAnnotation.FAKE,
200
+ ):
201
+ dist = _distance(y, x, correction.y, correction.x)
202
+ if dist <= radius and (nearest is None or dist < nearest[0]):
203
+ nearest = (dist, int(node_id))
204
+
205
+ if nearest is None:
206
+ unmatched_anchor_list.append(correction)
207
+ continue
208
+ _dist, node_id = nearest
209
+ session.query(NodeDB).where(NodeDB.id == node_id).update(
210
+ {NodeDB.node_annot: VarAnnotation.REAL},
211
+ synchronize_session=False,
212
+ )
213
+ resolved_anchor_nodes[(int(correction.cell_id), int(correction.t))] = node_id
214
+ anchor_nodes += 1
215
+
216
+ # Prune OverlapDB rows where both endpoints are anchor-forced REAL nodes.
217
+ # Two anchors at the same frame can land on hierarchical siblings; without
218
+ # this the ILP is infeasible (nodes_X + nodes_Y <= 1 vs both x >= 1).
219
+ anchor_real_ids = set(resolved_anchor_nodes.values())
220
+ if len(anchor_real_ids) >= 2:
221
+ anchor_overlaps_pruned = (
222
+ session.query(OverlapDB)
223
+ .where(
224
+ OverlapDB.node_id.in_(anchor_real_ids),
225
+ OverlapDB.ancestor_id.in_(anchor_real_ids),
226
+ )
227
+ .delete(synchronize_session=False)
228
+ )
229
+
230
+ if annotate_anchor_links:
231
+ for cell_id, t in sorted(resolved_anchor_nodes):
232
+ source_id = resolved_anchor_nodes[(cell_id, t)]
233
+ target_id = resolved_anchor_nodes.get((cell_id, t + 1))
234
+ if target_id is None:
235
+ continue
236
+ link = session.query(LinkDB).where(
237
+ LinkDB.source_id == source_id,
238
+ LinkDB.target_id == target_id,
239
+ ).one_or_none()
240
+ if link is None:
241
+ session.add(
242
+ LinkDB(
243
+ source_id=source_id,
244
+ target_id=target_id,
245
+ weight=0.0,
246
+ annotation=VarAnnotation.REAL,
247
+ )
248
+ )
249
+ else:
250
+ link.annotation = VarAnnotation.REAL
251
+ anchor_links += 1
252
+
253
+ session.commit()
254
+
255
+ engine.dispose()
256
+ return CorrectionDatabaseReport(
257
+ fake_nodes=len(fake_node_ids),
258
+ anchor_nodes=anchor_nodes,
259
+ anchor_links=anchor_links,
260
+ anchor_overlaps_pruned=int(anchor_overlaps_pruned or 0),
261
+ unmatched_anchors=tuple(unmatched_anchor_list),
262
+ )
263
+
264
+
265
+ def ensure_anchor_incident_links(
266
+ working_dir: str | Path,
267
+ cfg: TrackingConfig,
268
+ ) -> AnchorIncidentLinkReport:
269
+ """Insert missing LinkDB rows for edges incident to anchor (REAL) nodes.
270
+
271
+ The linker keeps only the top ``cfg.max_neighbors`` source-side edges per
272
+ target. A node the user selects as an anchor after solving may have been
273
+ pruned out of LinkDB entirely for some — or all — adjacent-frame
274
+ candidates, leaving the solver no way to extend the track from it. This
275
+ fills those gaps for anchor-incident edges, using the same per-pair weight
276
+ formula as the active linker mode.
277
+ """
278
+ import sqlalchemy as sqla
279
+ from sqlalchemy.orm import Session
280
+ from ultrack.core.database import LinkDB, NodeDB, VarAnnotation
281
+
282
+ from itasc.tracking_ultrack.linking import compute_edge_weight
283
+
284
+ engine = sqla.create_engine(f"sqlite:///{Path(working_dir) / 'data.db'}")
285
+
286
+ inserted = 0
287
+ anchors_processed = 0
288
+
289
+ with Session(engine) as session:
290
+ duplicate_groups: dict[tuple[int, int], list[LinkDB]] = {}
291
+ for link in session.query(LinkDB).order_by(
292
+ LinkDB.source_id,
293
+ LinkDB.target_id,
294
+ LinkDB.id,
295
+ ):
296
+ duplicate_groups.setdefault(
297
+ (int(link.source_id), int(link.target_id)),
298
+ [],
299
+ ).append(link)
300
+ for links in duplicate_groups.values():
301
+ if len(links) <= 1:
302
+ continue
303
+ keep = max(
304
+ links,
305
+ key=lambda link: (
306
+ 2
307
+ if link.annotation == VarAnnotation.REAL
308
+ else 1
309
+ if link.annotation == VarAnnotation.FAKE
310
+ else 0,
311
+ float(link.weight) if link.weight is not None else float("-inf"),
312
+ -int(link.id),
313
+ ),
314
+ )
315
+ for link in links:
316
+ if int(link.id) == int(keep.id):
317
+ continue
318
+ session.delete(link)
319
+ session.flush()
320
+
321
+ anchor_rows = session.query(
322
+ NodeDB.id, NodeDB.t, NodeDB.y, NodeDB.x, NodeDB.pickle,
323
+ ).where(NodeDB.node_annot == VarAnnotation.REAL).all()
324
+
325
+ if not anchor_rows:
326
+ session.commit()
327
+ engine.dispose()
328
+ return AnchorIncidentLinkReport()
329
+
330
+ max_distance = float(cfg.max_distance)
331
+ existing_pairs = {
332
+ (int(source_id), int(target_id))
333
+ for source_id, target_id in session.query(
334
+ LinkDB.source_id, LinkDB.target_id
335
+ )
336
+ }
337
+ new_rows: list[LinkDB] = []
338
+
339
+ for anchor_id, anchor_t, anchor_y, anchor_x, anchor_pickle in anchor_rows:
340
+ anchor_id = int(anchor_id)
341
+ anchor_t = int(anchor_t)
342
+ anchor_node = anchor_pickle
343
+ anchors_processed += 1
344
+
345
+ for direction in (-1, +1):
346
+ neighbor_t = anchor_t + direction
347
+ neighbor_rows = session.query(
348
+ NodeDB.id, NodeDB.y, NodeDB.x, NodeDB.pickle,
349
+ ).where(NodeDB.t == neighbor_t).all()
350
+ if not neighbor_rows:
351
+ continue
352
+
353
+ for neigh_id, neigh_y, neigh_x, neigh_pickle in neighbor_rows:
354
+ neigh_id = int(neigh_id)
355
+ dist = _distance(anchor_y, anchor_x, neigh_y, neigh_x)
356
+ if dist > max_distance:
357
+ continue
358
+
359
+ if direction == +1:
360
+ source_id, target_id = anchor_id, neigh_id
361
+ source_node, target_node = anchor_node, neigh_pickle
362
+ else:
363
+ source_id, target_id = neigh_id, anchor_id
364
+ source_node, target_node = neigh_pickle, anchor_node
365
+
366
+ pair = (int(source_id), int(target_id))
367
+ if pair in existing_pairs:
368
+ continue
369
+
370
+ weight = compute_edge_weight(source_node, target_node, dist, cfg)
371
+ if weight is None:
372
+ continue
373
+
374
+ new_rows.append(
375
+ LinkDB(
376
+ source_id=source_id,
377
+ target_id=target_id,
378
+ weight=float(weight),
379
+ )
380
+ )
381
+ existing_pairs.add(pair)
382
+
383
+ if new_rows:
384
+ session.add_all(new_rows)
385
+ inserted = len(new_rows)
386
+ session.commit()
387
+
388
+ engine.dispose()
389
+ return AnchorIncidentLinkReport(
390
+ inserted=inserted,
391
+ anchors_processed=anchors_processed,
392
+ )
393
+
394
+
395
+ def _tracked_mask_bbox(
396
+ tracked_labels: np.ndarray,
397
+ t: int,
398
+ cell_id: int,
399
+ ) -> tuple[tuple[int, int, int, int], np.ndarray] | None:
400
+ if t < 0 or t >= tracked_labels.shape[0]:
401
+ return None
402
+ frame = np.asarray(tracked_labels[t])
403
+ mask = (
404
+ (frame == int(cell_id)).any(axis=0)
405
+ if frame.ndim == 3
406
+ else (frame == int(cell_id))
407
+ )
408
+ if not mask.any():
409
+ return None
410
+ rows = np.flatnonzero(mask.any(axis=1))
411
+ cols = np.flatnonzero(mask.any(axis=0))
412
+ y0, y1 = int(rows[0]), int(rows[-1]) + 1
413
+ x0, x1 = int(cols[0]), int(cols[-1]) + 1
414
+ return (
415
+ (y0, x0, y1, x1),
416
+ np.ascontiguousarray(mask[y0:y1, x0:x1], dtype=bool),
417
+ )
418
+
419
+
420
+ def _best_real_anchor_node_id(
421
+ session,
422
+ correction: Correction,
423
+ cfg: TrackingConfig,
424
+ tracked_labels: np.ndarray | None,
425
+ ) -> int | None:
426
+ from ultrack.core.database import NodeDB, VarAnnotation
427
+
428
+ labels_arr = np.asarray(tracked_labels) if tracked_labels is not None else None
429
+ mask_record = (
430
+ _tracked_mask_bbox(labels_arr, int(correction.t), int(correction.cell_id))
431
+ if labels_arr is not None
432
+ else None
433
+ )
434
+
435
+ best_iou: tuple[float, float, int] | None = None
436
+ if mask_record is not None:
437
+ from itasc.tracking_ultrack._node_geometry import (
438
+ node_bbox_and_mask as _node_bbox_and_mask,
439
+ raw_iou as _raw_iou,
440
+ )
441
+
442
+ anchor_bbox, anchor_crop = mask_record
443
+ broad_radius = float(cfg.anchor_radius_px) * 4.0
444
+ rows = session.query(NodeDB.id, NodeDB.y, NodeDB.x, NodeDB.pickle).where(
445
+ NodeDB.t == int(correction.t),
446
+ NodeDB.node_annot == VarAnnotation.REAL,
447
+ )
448
+ for node_id, y, x, node_pickle in rows:
449
+ dist = _distance(y, x, correction.y, correction.x)
450
+ if dist > broad_radius:
451
+ continue
452
+ cand_bbox, cand_mask = _node_bbox_and_mask(int(node_id), node_pickle)
453
+ iou = _raw_iou(anchor_bbox, anchor_crop, cand_bbox, cand_mask)
454
+ candidate = (float(iou), -float(dist), int(node_id))
455
+ if best_iou is None or candidate > best_iou:
456
+ best_iou = candidate
457
+ if best_iou is not None and best_iou[0] > 0:
458
+ return best_iou[2]
459
+
460
+ nearest: tuple[float, int] | None = None
461
+ rows = session.query(NodeDB.id, NodeDB.y, NodeDB.x).where(
462
+ NodeDB.t == int(correction.t),
463
+ NodeDB.node_annot == VarAnnotation.REAL,
464
+ )
465
+ for node_id, y, x in rows:
466
+ dist = _distance(y, x, correction.y, correction.x)
467
+ if dist <= float(cfg.anchor_radius_px) and (
468
+ nearest is None or dist < nearest[0]
469
+ ):
470
+ nearest = (float(dist), int(node_id))
471
+ return None if nearest is None else nearest[1]
472
+
473
+
474
+ def _correction_protected_mask(
475
+ corrections: list[Correction],
476
+ tracked_labels: np.ndarray | None,
477
+ *,
478
+ t: int,
479
+ source_cell_id: int,
480
+ ) -> np.ndarray | None:
481
+ if tracked_labels is None:
482
+ return None
483
+ labels = np.asarray(tracked_labels)
484
+ if t < 0 or t >= labels.shape[0]:
485
+ return None
486
+
487
+ protected_ids = {
488
+ int(c.cell_id)
489
+ for c in corrections
490
+ if int(c.t) == int(t) and int(c.cell_id) != int(source_cell_id)
491
+ }
492
+ if not protected_ids:
493
+ return None
494
+
495
+ frame = labels[t]
496
+ mask = np.isin(frame, list(protected_ids))
497
+ if mask.ndim == 3:
498
+ mask = mask.any(axis=0)
499
+ return np.asarray(mask, dtype=bool)
500
+
501
+
502
+ def annotate_anchor_tail_links(
503
+ working_dir: str | Path,
504
+ corrections: list[Correction],
505
+ cfg: TrackingConfig,
506
+ *,
507
+ tracked_labels: np.ndarray | None = None,
508
+ ) -> AnchorTailLinkReport:
509
+ """Force best predecessor and successor links around anchored tracks.
510
+
511
+ Consecutive anchor links constrain only the user-confirmed frames. This
512
+ helper walks the best positive same-linker chain backward from the first
513
+ anchor and forward from the last anchor of each cell, mirroring the greedy
514
+ extender while still letting the ILP handle conflicts with other annotated
515
+ cells.
516
+ """
517
+ import sqlalchemy as sqla
518
+ from sqlalchemy.orm import Session
519
+ from ultrack.core.database import LinkDB, NodeDB, OverlapDB, VarAnnotation
520
+
521
+ anchor_by_cell: dict[int, list[Correction]] = {}
522
+ for correction in corrections:
523
+ if correction.kind == "anchor":
524
+ anchor_by_cell.setdefault(int(correction.cell_id), []).append(correction)
525
+ if not anchor_by_cell:
526
+ return AnchorTailLinkReport()
527
+
528
+ engine = sqla.create_engine(f"sqlite:///{Path(working_dir) / 'data.db'}")
529
+ annotated = skipped_no_anchor = skipped_no_link = 0
530
+ labels_arr = np.asarray(tracked_labels) if tracked_labels is not None else None
531
+
532
+ with Session(engine) as session:
533
+ real_source_ids: set[int] = set()
534
+ real_target_ids: set[int] = set()
535
+ forced_node_ids_by_t: dict[int, set[int]] = {}
536
+ for node_id, t in session.query(NodeDB.id, NodeDB.t).where(
537
+ NodeDB.node_annot == VarAnnotation.REAL
538
+ ):
539
+ forced_node_ids_by_t.setdefault(int(t), set()).add(int(node_id))
540
+ for source_id, target_id in session.query(
541
+ LinkDB.source_id,
542
+ LinkDB.target_id,
543
+ ).where(LinkDB.annotation == VarAnnotation.REAL):
544
+ source_id = int(source_id)
545
+ target_id = int(target_id)
546
+ real_source_ids.add(source_id)
547
+ real_target_ids.add(target_id)
548
+ for node_id, t in session.query(NodeDB.id, NodeDB.t).where(
549
+ NodeDB.id.in_([source_id, target_id])
550
+ ):
551
+ forced_node_ids_by_t.setdefault(int(t), set()).add(int(node_id))
552
+
553
+ for cell_id, cell_anchors in sorted(anchor_by_cell.items()):
554
+ head = min(cell_anchors, key=lambda item: int(item.t))
555
+ source_id = _best_real_anchor_node_id(session, head, cfg, labels_arr)
556
+ if source_id is None:
557
+ skipped_no_anchor += 1
558
+ else:
559
+ seen_sources: set[int] = set()
560
+ annotated_for_cell = 0
561
+ while source_id not in seen_sources:
562
+ seen_sources.add(int(source_id))
563
+ source = (
564
+ session.query(NodeDB)
565
+ .where(NodeDB.id == source_id)
566
+ .one_or_none()
567
+ )
568
+ if source is None:
569
+ if annotated_for_cell == 0:
570
+ skipped_no_anchor += 1
571
+ break
572
+ if int(source_id) in real_target_ids:
573
+ break
574
+ predecessor_t = int(source.t) - 1
575
+ protected_mask = _correction_protected_mask(
576
+ corrections,
577
+ labels_arr,
578
+ t=predecessor_t,
579
+ source_cell_id=cell_id,
580
+ )
581
+
582
+ candidates = []
583
+ rows = (
584
+ session.query(LinkDB, NodeDB)
585
+ .join(NodeDB, NodeDB.id == LinkDB.source_id)
586
+ .where(
587
+ LinkDB.target_id == int(source_id),
588
+ NodeDB.t == predecessor_t,
589
+ NodeDB.node_annot != VarAnnotation.FAKE,
590
+ )
591
+ .all()
592
+ )
593
+ for link, predecessor in rows:
594
+ if link.weight is None or float(link.weight) <= 0:
595
+ continue
596
+ if int(predecessor.id) in real_source_ids:
597
+ continue
598
+ forced_ids = forced_node_ids_by_t.get(
599
+ int(predecessor.t),
600
+ set(),
601
+ )
602
+ if forced_ids:
603
+ overlap = (
604
+ session.query(OverlapDB.node_id)
605
+ .where(
606
+ sqla.or_(
607
+ sqla.and_(
608
+ OverlapDB.node_id == int(predecessor.id),
609
+ OverlapDB.ancestor_id.in_(forced_ids),
610
+ ),
611
+ sqla.and_(
612
+ OverlapDB.ancestor_id
613
+ == int(predecessor.id),
614
+ OverlapDB.node_id.in_(forced_ids),
615
+ ),
616
+ )
617
+ )
618
+ .first()
619
+ )
620
+ if overlap is not None:
621
+ continue
622
+ if protected_mask is not None:
623
+ from itasc.tracking_ultrack._node_geometry import (
624
+ node_bbox_and_mask as _node_bbox_and_mask,
625
+ )
626
+
627
+ (y0, x0, y1, x1), predecessor_crop = _node_bbox_and_mask(
628
+ int(predecessor.id),
629
+ predecessor.pickle,
630
+ )
631
+ if np.any(
632
+ predecessor_crop & protected_mask[y0:y1, x0:x1]
633
+ ):
634
+ continue
635
+ candidates.append(
636
+ (
637
+ float(link.weight),
638
+ -_distance(
639
+ source.y,
640
+ source.x,
641
+ predecessor.y,
642
+ predecessor.x,
643
+ ),
644
+ int(predecessor.id),
645
+ link,
646
+ )
647
+ )
648
+
649
+ if not candidates:
650
+ if annotated_for_cell == 0:
651
+ skipped_no_link += 1
652
+ break
653
+
654
+ _weight, _neg_dist, next_source_id, best_link = max(
655
+ candidates, key=lambda item: (item[0], item[1])
656
+ )
657
+ best_link.annotation = VarAnnotation.REAL
658
+ real_source_ids.add(int(next_source_id))
659
+ real_target_ids.add(int(source_id))
660
+ forced_node_ids_by_t.setdefault(predecessor_t, set()).add(
661
+ int(next_source_id)
662
+ )
663
+ annotated += 1
664
+ annotated_for_cell += 1
665
+ source_id = next_source_id
666
+
667
+ tail = max(cell_anchors, key=lambda item: int(item.t))
668
+ source_id = _best_real_anchor_node_id(session, tail, cfg, labels_arr)
669
+ if source_id is None:
670
+ skipped_no_anchor += 1
671
+ continue
672
+
673
+ seen_sources: set[int] = set()
674
+ annotated_for_cell = 0
675
+ while source_id not in seen_sources:
676
+ seen_sources.add(int(source_id))
677
+ source = (
678
+ session.query(NodeDB).where(NodeDB.id == source_id).one_or_none()
679
+ )
680
+ if source is None:
681
+ if annotated_for_cell == 0:
682
+ skipped_no_anchor += 1
683
+ break
684
+ if int(source_id) in real_source_ids:
685
+ break
686
+ target_t = int(source.t) + 1
687
+ protected_mask = _correction_protected_mask(
688
+ corrections,
689
+ labels_arr,
690
+ t=target_t,
691
+ source_cell_id=cell_id,
692
+ )
693
+
694
+ candidates = []
695
+ rows = (
696
+ session.query(LinkDB, NodeDB)
697
+ .join(NodeDB, NodeDB.id == LinkDB.target_id)
698
+ .where(
699
+ LinkDB.source_id == int(source_id),
700
+ NodeDB.t == target_t,
701
+ NodeDB.node_annot != VarAnnotation.FAKE,
702
+ )
703
+ .all()
704
+ )
705
+ for link, target in rows:
706
+ if link.weight is None or float(link.weight) <= 0:
707
+ continue
708
+ if int(target.id) in real_target_ids:
709
+ continue
710
+ forced_ids = forced_node_ids_by_t.get(int(target.t), set())
711
+ if forced_ids:
712
+ overlap = (
713
+ session.query(OverlapDB.node_id)
714
+ .where(
715
+ sqla.or_(
716
+ sqla.and_(
717
+ OverlapDB.node_id == int(target.id),
718
+ OverlapDB.ancestor_id.in_(forced_ids),
719
+ ),
720
+ sqla.and_(
721
+ OverlapDB.ancestor_id == int(target.id),
722
+ OverlapDB.node_id.in_(forced_ids),
723
+ ),
724
+ )
725
+ )
726
+ .first()
727
+ )
728
+ if overlap is not None:
729
+ continue
730
+ if protected_mask is not None:
731
+ from itasc.tracking_ultrack._node_geometry import (
732
+ node_bbox_and_mask as _node_bbox_and_mask,
733
+ )
734
+
735
+ (y0, x0, y1, x1), target_crop = _node_bbox_and_mask(
736
+ int(target.id), target.pickle
737
+ )
738
+ if np.any(target_crop & protected_mask[y0:y1, x0:x1]):
739
+ continue
740
+ candidates.append(
741
+ (
742
+ float(link.weight),
743
+ -_distance(source.y, source.x, target.y, target.x),
744
+ int(target.id),
745
+ link,
746
+ )
747
+ )
748
+
749
+ if not candidates:
750
+ if annotated_for_cell == 0:
751
+ skipped_no_link += 1
752
+ break
753
+
754
+ _weight, _neg_dist, next_source_id, best_link = max(
755
+ candidates, key=lambda item: (item[0], item[1])
756
+ )
757
+ best_link.annotation = VarAnnotation.REAL
758
+ real_source_ids.add(int(source_id))
759
+ real_target_ids.add(int(next_source_id))
760
+ forced_node_ids_by_t.setdefault(target_t, set()).add(
761
+ int(next_source_id)
762
+ )
763
+ annotated += 1
764
+ annotated_for_cell += 1
765
+ source_id = next_source_id
766
+
767
+ session.commit()
768
+
769
+ engine.dispose()
770
+ return AnchorTailLinkReport(
771
+ annotated=annotated,
772
+ skipped_no_anchor=skipped_no_anchor,
773
+ skipped_no_link=skipped_no_link,
774
+ )
775
+
776
+
777
+ @dataclass(frozen=True)
778
+ class HomemadeAnchorInjectionReport:
779
+ injected: int = 0
780
+ skipped_no_mask: int = 0
781
+ skipped_overflow: int = 0
782
+
783
+
784
+ def inject_unmatched_anchor_nodes(
785
+ working_dir: str | Path,
786
+ unmatched_anchors: tuple[Correction, ...],
787
+ tracked_labels: np.ndarray,
788
+ cfg: TrackingConfig,
789
+ ) -> HomemadeAnchorInjectionReport:
790
+ """Insert REAL NodeDB rows for anchor corrections that had no existing candidate.
791
+
792
+ Called when ``apply_corrections_to_database`` found no NodeDB node within
793
+ ``anchor_radius_px`` for one or more anchor corrections. This happens when
794
+ the user anchors a manually-drawn cell that the segmenter never produced a
795
+ candidate for. We extract the cell's mask from ``tracked_labels`` and
796
+ insert a synthetic NodeDB row marked REAL so the ILP is forced to include
797
+ it, then add OverlapDB rows with any spatially overlapping existing nodes
798
+ so the ILP cannot simultaneously select conflicting candidates.
799
+ """
800
+ import sqlalchemy as sqla
801
+ from sqlalchemy.orm import Session
802
+ from ultrack.core.database import NodeDB, OverlapDB, VarAnnotation
803
+
804
+ from itasc.tracking_ultrack._node_geometry import (
805
+ intersects as _intersects,
806
+ make_node_pickle,
807
+ node_bbox_and_mask,
808
+ node_pickle_ndim,
809
+ )
810
+
811
+ if not unmatched_anchors:
812
+ return HomemadeAnchorInjectionReport()
813
+
814
+ labels = np.asarray(tracked_labels)
815
+ engine = sqla.create_engine(f"sqlite:///{Path(working_dir) / 'data.db'}")
816
+ injected = 0
817
+ skipped_no_mask = 0
818
+ skipped_overflow = 0
819
+
820
+ with Session(engine) as session:
821
+ sample = session.query(NodeDB.pickle).limit(1).scalar()
822
+ ndim = node_pickle_ndim(sample) if sample is not None else 2
823
+
824
+ # Track per-frame next t_node_id so multiple injections at the same
825
+ # frame don't collide.
826
+ next_t_node_id: dict[int, int] = {}
827
+
828
+ new_nodes: list[tuple[int, int, int, int, int, int, int]] = []
829
+ new_nodes_crop: dict[int, tuple[tuple[int, int, int, int], np.ndarray]] = {}
830
+
831
+ for correction in unmatched_anchors:
832
+ t = int(correction.t)
833
+ cell_id = int(correction.cell_id)
834
+
835
+ if t < 0 or t >= labels.shape[0]:
836
+ skipped_no_mask += 1
837
+ continue
838
+
839
+ frame = np.asarray(labels[t])
840
+ mask_2d = (frame == cell_id).any(axis=0) if frame.ndim == 3 else (frame == cell_id)
841
+ if not mask_2d.any():
842
+ skipped_no_mask += 1
843
+ continue
844
+
845
+ rows_nz = np.flatnonzero(mask_2d.any(axis=1))
846
+ cols_nz = np.flatnonzero(mask_2d.any(axis=0))
847
+ y0, y1 = int(rows_nz[0]), int(rows_nz[-1]) + 1
848
+ x0, x1 = int(cols_nz[0]), int(cols_nz[-1]) + 1
849
+ crop = np.ascontiguousarray(mask_2d[y0:y1, x0:x1], dtype=bool)
850
+ ys, xs = np.nonzero(crop)
851
+ area = int(crop.sum())
852
+ y_centroid = float(y0 + ys.mean())
853
+ x_centroid = float(x0 + xs.mean())
854
+ bbox_arr = np.array([y0, x0, y1, x1], dtype=np.int32)
855
+
856
+ if t not in next_t_node_id:
857
+ max_id = session.query(sqla.func.max(NodeDB.t_node_id)).where(NodeDB.t == t).scalar()
858
+ next_t_node_id[t] = int(max_id or 0) + 1
859
+
860
+ t_node_id = next_t_node_id[t]
861
+ if t_node_id >= cfg.max_segments_per_time:
862
+ skipped_overflow += 1
863
+ continue
864
+ next_t_node_id[t] += 1
865
+
866
+ node_id = t_node_id + (t + 1) * cfg.max_segments_per_time
867
+ node_pickle = make_node_pickle(t, crop, bbox_arr, node_id, ndim=ndim)
868
+
869
+ session.add(
870
+ NodeDB(
871
+ id=node_id,
872
+ t=t,
873
+ t_node_id=t_node_id,
874
+ t_hier_id=0,
875
+ z=0,
876
+ y=y_centroid,
877
+ x=x_centroid,
878
+ area=area,
879
+ pickle=node_pickle,
880
+ node_prob=1.0,
881
+ node_annot=VarAnnotation.REAL,
882
+ )
883
+ )
884
+ new_nodes.append((node_id, t, y0, x0, y1, x1))
885
+ new_nodes_crop[node_id] = ((y0, x0, y1, x1), crop)
886
+ injected += 1
887
+
888
+ session.flush()
889
+
890
+ # For each injected node add OverlapDB rows with spatially conflicting
891
+ # existing nodes so the ILP cannot select both.
892
+ for node_id, t, y0, x0, y1, x1 in new_nodes:
893
+ bbox_new = (y0, x0, y1, x1)
894
+ _, crop_new = new_nodes_crop.get(node_id, (None, None))
895
+ if crop_new is None:
896
+ continue
897
+ candidate_rows = (
898
+ session.query(NodeDB.id, NodeDB.pickle)
899
+ .where(NodeDB.t == t, NodeDB.id != node_id)
900
+ .all()
901
+ )
902
+ for cand_id, cand_pickle in candidate_rows:
903
+ cand_id = int(cand_id)
904
+ cand_bbox, cand_mask = node_bbox_and_mask(cand_id, cand_pickle)
905
+ if not _intersects(bbox_new, crop_new, cand_bbox, cand_mask):
906
+ continue
907
+ pair_node = max(node_id, cand_id)
908
+ pair_anc = min(node_id, cand_id)
909
+ exists = session.query(OverlapDB.node_id).where(
910
+ OverlapDB.node_id == pair_node,
911
+ OverlapDB.ancestor_id == pair_anc,
912
+ ).first()
913
+ if exists is None:
914
+ session.add(OverlapDB(node_id=pair_node, ancestor_id=pair_anc))
915
+
916
+ session.commit()
917
+
918
+ engine.dispose()
919
+ return HomemadeAnchorInjectionReport(
920
+ injected=injected,
921
+ skipped_no_mask=skipped_no_mask,
922
+ skipped_overflow=skipped_overflow,
923
+ )
924
+
925
+
926
+ def _frame_centroid(mask: np.ndarray) -> tuple[float, float] | None:
927
+ if mask.ndim == 3:
928
+ mask = mask.any(axis=0)
929
+ ys, xs = np.nonzero(mask)
930
+ if ys.size == 0:
931
+ return None
932
+ return float(ys.mean()), float(xs.mean())
933
+
934
+
935
+ def _track_centroid_at(labels: np.ndarray, t: int, track_id: int) -> tuple[float, float] | None:
936
+ if t < 0 or t >= labels.shape[0]:
937
+ return None
938
+ return _frame_centroid(np.asarray(labels[t] == track_id))
939
+
940
+
941
+ def _track_lifetime(labels: np.ndarray, track_id: int) -> int:
942
+ return int(sum(np.asarray(labels[t] == track_id).any() for t in range(labels.shape[0])))
943
+
944
+
945
+ def _matching_tracks_at_anchor(
946
+ labels: np.ndarray,
947
+ correction: Correction,
948
+ radius: float,
949
+ ) -> list[tuple[float, int, int]]:
950
+ frame = labels[int(correction.t)]
951
+ matches: list[tuple[float, int, int]] = []
952
+ for raw_track_id in np.unique(frame):
953
+ track_id = int(raw_track_id)
954
+ if track_id == 0:
955
+ continue
956
+ centroid = _track_centroid_at(labels, int(correction.t), track_id)
957
+ if centroid is None:
958
+ continue
959
+ y, x = centroid
960
+ dist = _distance(y, x, correction.y, correction.x)
961
+ if dist <= radius:
962
+ matches.append((dist, -_track_lifetime(labels, track_id), track_id))
963
+ matches.sort()
964
+ return matches
965
+
966
+
967
+ def _next_fresh_id(labels: np.ndarray, reserved_ids: set[int], used_ids: set[int]) -> int:
968
+ next_id = int(labels.max()) + 1
969
+ while next_id in reserved_ids or next_id in used_ids or next_id == 0:
970
+ next_id += 1
971
+ used_ids.add(next_id)
972
+ return next_id
973
+
974
+
975
+ def _anchor_lineage_track_remaps(
976
+ working_dir: str | Path | None,
977
+ corrections: list[Correction],
978
+ cfg: TrackingConfig,
979
+ ) -> dict[int, int]:
980
+ """Return raw exported track IDs that should inherit anchored cell IDs.
981
+
982
+ Ultrack may split a selected parent chain into multiple exported tracklets
983
+ at branch points. REAL links express the intended identity path, so any
984
+ selected node reachable from an anchor through REAL links should be remapped
985
+ to that anchor's cell ID even if it received a new exported track_id.
986
+ """
987
+ if working_dir is None:
988
+ return {}
989
+
990
+ anchor_corrections = [c for c in corrections if c.kind == "anchor"]
991
+ if not anchor_corrections:
992
+ return {}
993
+
994
+ try:
995
+ import sqlalchemy as sqla
996
+ from sqlalchemy.orm import Session
997
+ from ultrack.core.database import LinkDB, VarAnnotation
998
+ from ultrack.core.export import to_tracks_layer
999
+
1000
+ from itasc.tracking_ultrack.ingest import _build_ultrack_config
1001
+ except Exception:
1002
+ return {}
1003
+
1004
+ working_dir = Path(working_dir)
1005
+ db_path = working_dir / "data.db"
1006
+ if not db_path.exists():
1007
+ return {}
1008
+
1009
+ try:
1010
+ tracks_df, _graph = to_tracks_layer(_build_ultrack_config(cfg, working_dir))
1011
+ except Exception:
1012
+ return {}
1013
+ if tracks_df.empty or "id" not in tracks_df:
1014
+ return {}
1015
+
1016
+ tracks_by_node = tracks_df.set_index("id", drop=False)
1017
+ selected_node_ids = {int(node_id) for node_id in tracks_by_node.index}
1018
+ if not selected_node_ids:
1019
+ return {}
1020
+
1021
+ remaps: dict[int, int] = {}
1022
+ engine = sqla.create_engine(f"sqlite:///{db_path}")
1023
+ try:
1024
+ with Session(engine) as session:
1025
+ real_links: dict[int, list[int]] = {}
1026
+ for source_id, target_id in session.query(
1027
+ LinkDB.source_id, LinkDB.target_id
1028
+ ).where(LinkDB.annotation == VarAnnotation.REAL):
1029
+ source_id = int(source_id)
1030
+ target_id = int(target_id)
1031
+ if source_id in selected_node_ids and target_id in selected_node_ids:
1032
+ real_links.setdefault(source_id, []).append(target_id)
1033
+ real_links.setdefault(target_id, []).append(source_id)
1034
+
1035
+ for correction in anchor_corrections:
1036
+ t = int(correction.t)
1037
+ frame_rows = tracks_df[tracks_df["t"] == t]
1038
+ nearest: tuple[float, int] | None = None
1039
+ for row in frame_rows.itertuples(index=False):
1040
+ dist = _distance(row.y, row.x, correction.y, correction.x)
1041
+ if dist <= float(cfg.anchor_radius_px) and (
1042
+ nearest is None or dist < nearest[0]
1043
+ ):
1044
+ nearest = (float(dist), int(row.id))
1045
+ if nearest is None:
1046
+ continue
1047
+
1048
+ queue = [nearest[1]]
1049
+ seen: set[int] = set()
1050
+ while queue:
1051
+ node_id = queue.pop(0)
1052
+ if node_id in seen:
1053
+ continue
1054
+ seen.add(node_id)
1055
+ if node_id not in tracks_by_node.index:
1056
+ continue
1057
+ solver_track_id = int(tracks_by_node.loc[node_id, "track_id"])
1058
+ remaps.setdefault(solver_track_id, int(correction.cell_id))
1059
+ queue.extend(real_links.get(node_id, ()))
1060
+ finally:
1061
+ engine.dispose()
1062
+
1063
+ return remaps
1064
+
1065
+
1066
+ def _stamp_disk(labels: np.ndarray, t: int, y: float, x: float, radius: float, cell_id: int) -> None:
1067
+ if t < 0 or t >= labels.shape[0]:
1068
+ return
1069
+ frame = labels[t]
1070
+ yy, xx = np.ogrid[: frame.shape[-2], : frame.shape[-1]]
1071
+ disk = (yy - float(y)) ** 2 + (xx - float(x)) ** 2 <= float(radius) ** 2
1072
+ if frame.ndim == 2:
1073
+ frame[disk] = int(cell_id)
1074
+ elif frame.ndim == 3:
1075
+ frame[:, disk] = int(cell_id)
1076
+ else:
1077
+ raise ValueError(f"Expected exported frame to be 2D or 3D, got {frame.shape}")
1078
+
1079
+
1080
+ def _paste_validated_mask(labels: np.ndarray, tracked_labels: np.ndarray, correction: Correction) -> bool:
1081
+ t = int(correction.t)
1082
+ cell_id = int(correction.cell_id)
1083
+ if t < 0 or t >= labels.shape[0] or t >= tracked_labels.shape[0]:
1084
+ return False
1085
+ mask = np.asarray(tracked_labels[t] == cell_id)
1086
+ if not mask.any():
1087
+ return False
1088
+ frame = labels[t]
1089
+ if mask.ndim == frame.ndim:
1090
+ frame[mask] = cell_id
1091
+ elif mask.ndim == 2 and frame.ndim == 3:
1092
+ frame[:, mask] = cell_id
1093
+ elif mask.ndim == 3 and frame.ndim == 2:
1094
+ frame[mask.any(axis=0)] = cell_id
1095
+ else:
1096
+ raise ValueError(
1097
+ "Validated mask shape is incompatible with exported labels: "
1098
+ f"mask={mask.shape}, exported={frame.shape}"
1099
+ )
1100
+ return True
1101
+
1102
+
1103
+ def apply_post_solve_corrections(
1104
+ exported_labels: np.ndarray,
1105
+ corrections: list[Correction],
1106
+ tracked_labels: np.ndarray,
1107
+ cfg: TrackingConfig,
1108
+ *,
1109
+ working_dir: str | Path | None = None,
1110
+ ) -> tuple[np.ndarray, PostSolveCorrectionReport]:
1111
+ """Apply anchor remap/stamp and validated paste-back to exported labels."""
1112
+ if not corrections:
1113
+ return exported_labels, PostSolveCorrectionReport()
1114
+
1115
+ labels = exported_labels
1116
+ radius = float(cfg.anchor_radius_px)
1117
+ anchor_corrections = [c for c in corrections if c.kind == "anchor"]
1118
+ validated_corrections = [c for c in corrections if c.kind == "validated"]
1119
+ reserved_ids = {int(c.cell_id) for c in corrections}
1120
+ used_ids = {int(v) for v in np.unique(labels)}
1121
+
1122
+ claims: dict[int, tuple[float, int, Correction]] = {}
1123
+ unsatisfied_anchors: list[Correction] = []
1124
+ for correction in anchor_corrections:
1125
+ matches = _matching_tracks_at_anchor(labels, correction, radius)
1126
+ if not matches:
1127
+ unsatisfied_anchors.append(correction)
1128
+ continue
1129
+ dist, neg_lifetime, track_id = matches[0]
1130
+ current = claims.get(track_id)
1131
+ if current is None or (dist, neg_lifetime) < (current[0], current[1]):
1132
+ if current is not None:
1133
+ unsatisfied_anchors.append(current[2])
1134
+ claims[track_id] = (dist, neg_lifetime, correction)
1135
+ else:
1136
+ unsatisfied_anchors.append(correction)
1137
+
1138
+ # solver_id → cell_id for matched anchors. After this disambiguation pass
1139
+ # the only solver pixels carrying a reserved cell_id are those owned by
1140
+ # the matched anchor (when solver_id == cell_id); every other collision
1141
+ # is an unrelated solver track that happens to share the numeric ID, and
1142
+ # gets relabeled to a fresh ID so that subsequent stamps/pastes do not
1143
+ # produce two disjoint regions sharing the same cell_id.
1144
+ solver_track_remap: dict[int, int] = {
1145
+ int(sid): int(corr.cell_id)
1146
+ for sid, (_d, _l, corr) in claims.items()
1147
+ }
1148
+ matched_anchor_target_to_solver: dict[int, int] = {
1149
+ target_id: solver_id for solver_id, target_id in solver_track_remap.items()
1150
+ }
1151
+ lineage_track_remaps = _anchor_lineage_track_remaps(working_dir, corrections, cfg)
1152
+ for solver_id, cell_id in sorted(lineage_track_remaps.items()):
1153
+ if solver_id in solver_track_remap and solver_track_remap[solver_id] != cell_id:
1154
+ LOG.warning(
1155
+ "Solver track %d claimed by multiple anchor lineages; "
1156
+ "keeping cell %d and skipping cell %d",
1157
+ solver_id,
1158
+ solver_track_remap[solver_id],
1159
+ cell_id,
1160
+ )
1161
+ continue
1162
+ solver_track_remap[solver_id] = cell_id
1163
+ if solver_id == cell_id:
1164
+ matched_anchor_target_to_solver[cell_id] = solver_id
1165
+ else:
1166
+ matched_anchor_target_to_solver.setdefault(cell_id, solver_id)
1167
+
1168
+ for cell_id in sorted(reserved_ids):
1169
+ owning_solver = matched_anchor_target_to_solver.get(cell_id)
1170
+ if owning_solver == cell_id:
1171
+ # Solver already labeled the owning track with the correct ID;
1172
+ # all pixels labeled cell_id belong to that one track.
1173
+ continue
1174
+ if solver_track_remap.get(cell_id, cell_id) != cell_id:
1175
+ # The pixels at value cell_id are a solver track that is itself
1176
+ # scheduled to be remapped elsewhere (cell_id -> other) by the loop
1177
+ # below; they are the *source* of that remap and must not be
1178
+ # scattered to a fresh ID here. Otherwise the later remap finds
1179
+ # nothing and the anchored cell loses its guaranteed identity.
1180
+ # Mirrors the ~src_mask guard in reseed.py.
1181
+ continue
1182
+ collision_mask = np.asarray(labels == cell_id)
1183
+ if collision_mask.any():
1184
+ labels[collision_mask] = _next_fresh_id(labels, reserved_ids, used_ids)
1185
+
1186
+ remapped = 0
1187
+ for solver_id, target_id in sorted(solver_track_remap.items()):
1188
+ if solver_id != target_id:
1189
+ labels[labels == solver_id] = target_id
1190
+ remapped += 1
1191
+
1192
+ stamped = 0
1193
+ for correction in unsatisfied_anchors:
1194
+ _stamp_disk(
1195
+ labels,
1196
+ int(correction.t),
1197
+ float(correction.y),
1198
+ float(correction.x),
1199
+ float(cfg.anchor_stamp_radius_px),
1200
+ int(correction.cell_id),
1201
+ )
1202
+ stamped += 1
1203
+
1204
+ pasted = 0
1205
+ for correction in validated_corrections:
1206
+ if _paste_validated_mask(labels, tracked_labels, correction):
1207
+ pasted += 1
1208
+
1209
+ return labels, PostSolveCorrectionReport(
1210
+ remapped_anchor_tracks=remapped,
1211
+ stamped_anchors=stamped,
1212
+ pasted_validated=pasted,
1213
+ )