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,492 @@
1
+ """Shared Ultrack database construction pipeline."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import tifffile
10
+
11
+ from itasc.core.cancellation import CancelledError
12
+ from itasc.tracking_ultrack.config import TrackingConfig
13
+ from itasc.tracking_ultrack.corrections import (
14
+ Correction,
15
+ annotate_anchor_tail_links,
16
+ apply_corrections_to_database,
17
+ corrections_from_validated_tracks,
18
+ ensure_anchor_incident_links,
19
+ inject_unmatched_anchor_nodes,
20
+ )
21
+ from itasc.tracking_ultrack.ingest import _build_ultrack_config
22
+ from itasc.tracking_ultrack.linking import run_linking
23
+ from itasc.tracking_ultrack.seed_prior import write_seed_prior_node_probs
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class UltrackDatabaseBuildReport:
28
+ """Result of candidate-building (segmentation + linking). No annotation state."""
29
+ pass
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AtomUnionDatabaseBuildReport:
34
+ """Result of atom-union candidate-building + linking (stage ②)."""
35
+ total_nodes: int = 0
36
+ total_overlaps: int = 0
37
+ n_frames: int = 0
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AnnotateAndScoreReport:
42
+ fake_nodes: int = 0
43
+ anchor_nodes: int = 0
44
+ anchor_links: int = 0
45
+ scored_nodes: int = 0
46
+ seed_nodes: int = 0
47
+ anchor_incident_links_inserted: int = 0
48
+ anchor_tail_links_annotated: int = 0
49
+ injected_homemade_anchors: int = 0
50
+
51
+
52
+ def _reset_annotations(working_dir: str | Path) -> None:
53
+ """Clear all NodeDB.node_annot and LinkDB.annotation back to UNKNOWN."""
54
+ import sqlalchemy as sqla
55
+ from sqlalchemy.orm import Session
56
+ from ultrack.core.database import LinkDB, NodeDB, VarAnnotation
57
+
58
+ engine = sqla.create_engine(f"sqlite:///{Path(working_dir) / 'data.db'}")
59
+ with Session(engine) as session:
60
+ session.query(NodeDB).update(
61
+ {NodeDB.node_annot: VarAnnotation.UNKNOWN},
62
+ synchronize_session=False,
63
+ )
64
+ session.query(LinkDB).update(
65
+ {LinkDB.annotation: VarAnnotation.UNKNOWN},
66
+ synchronize_session=False,
67
+ )
68
+ session.commit()
69
+
70
+
71
+ def apply_annotations_and_score(
72
+ working_dir: str | Path,
73
+ cfg: TrackingConfig,
74
+ *,
75
+ score_signal_path: str | Path,
76
+ corrections: list[Correction] | None = None,
77
+ validated_tracks: dict[int, set[int]] | None = None,
78
+ tracked_labels: np.ndarray | None = None,
79
+ progress_cb: Callable[[str], None] | None = None,
80
+ ) -> AnnotateAndScoreReport:
81
+ """Reset annotations on an existing ``data.db``, apply corrections, and rescore.
82
+
83
+ Runs after candidate-building (segmentation + linking) and before solve.
84
+ The candidate set itself is left untouched; only ``node_annot``,
85
+ ``link.annotation`` and ``node_prob`` change. Safe to call repeatedly when
86
+ the user toggles validations or anchors.
87
+ """
88
+ if corrections is None and validated_tracks:
89
+ if tracked_labels is None:
90
+ raise ValueError(
91
+ "validated_tracks requires tracked_labels for centroid derivation."
92
+ )
93
+ corrections = corrections_from_validated_tracks(
94
+ validated_tracks,
95
+ np.asarray(tracked_labels, dtype=np.uint32),
96
+ )
97
+ corrections = list(corrections or [])
98
+
99
+ _notify(progress_cb, "Resetting prior annotations...")
100
+ _reset_annotations(working_dir)
101
+
102
+ fake_nodes = anchor_nodes = anchor_links = 0
103
+ injected_homemade_anchors = 0
104
+ if corrections:
105
+ _notify(progress_cb, "Applying node annotations...")
106
+ pre = apply_corrections_to_database(
107
+ working_dir, corrections, cfg,
108
+ annotate_anchor_links=False,
109
+ tracked_labels=tracked_labels,
110
+ )
111
+ fake_nodes = int(pre.fake_nodes)
112
+ anchor_nodes = int(pre.anchor_nodes)
113
+ _notify(
114
+ progress_cb,
115
+ f"Marked {fake_nodes} FAKE node(s) and {anchor_nodes} anchor node(s).",
116
+ )
117
+
118
+ if pre.unmatched_anchors and tracked_labels is not None:
119
+ # Some anchor corrections had no NodeDB candidate — the user is
120
+ # anchoring a manually-drawn cell. Inject synthetic REAL nodes so
121
+ # the ILP is forced to include them.
122
+ _notify(
123
+ progress_cb,
124
+ f"Injecting {len(pre.unmatched_anchors)} homemade anchor node(s) into DB...",
125
+ )
126
+ inj = inject_unmatched_anchor_nodes(
127
+ working_dir, pre.unmatched_anchors, tracked_labels, cfg,
128
+ )
129
+ injected_homemade_anchors = inj.injected
130
+ _notify(
131
+ progress_cb,
132
+ f"Injected {inj.injected} homemade anchor node(s) "
133
+ f"({inj.skipped_no_mask} skipped: no mask in tracked_labels).",
134
+ )
135
+ elif pre.unmatched_anchors:
136
+ _notify(
137
+ progress_cb,
138
+ f"Warning: {len(pre.unmatched_anchors)} anchor correction(s) matched no "
139
+ f"NodeDB candidate and tracked_labels was not provided — these anchors "
140
+ f"will not affect the ILP solve.",
141
+ )
142
+
143
+ _notify(progress_cb, "Scoring node probabilities...")
144
+ score = write_seed_prior_node_probs(working_dir, score_signal_path, cfg)
145
+ scored_nodes = int(getattr(score, "scored", 0))
146
+ seed_nodes = int(getattr(score, "seeds", 0))
147
+ _notify(progress_cb, f"Scored {scored_nodes} node(s) using {seed_nodes} seed node(s).")
148
+
149
+ if corrections:
150
+ # Second pass: re-mark REAL anchor nodes (now including any injected
151
+ # homemade nodes that were inserted above) and add link annotations
152
+ # between consecutive REAL anchor nodes.
153
+ _notify(progress_cb, "Applying link annotations...")
154
+ post = apply_corrections_to_database(
155
+ working_dir, corrections, cfg,
156
+ annotate_anchor_links=True,
157
+ tracked_labels=tracked_labels,
158
+ )
159
+ anchor_links = int(post.anchor_links)
160
+ _notify(progress_cb, f"Marked {anchor_links} anchor link(s).")
161
+
162
+ _notify(progress_cb, "Filling anchor-incident links...")
163
+ incident = ensure_anchor_incident_links(working_dir, cfg)
164
+ anchor_incident_links_inserted = int(incident.inserted)
165
+ _notify(
166
+ progress_cb,
167
+ f"Inserted {anchor_incident_links_inserted} anchor-incident link(s) "
168
+ f"across {int(incident.anchors_processed)} anchor node(s).",
169
+ )
170
+
171
+ anchor_tail_links_annotated = 0
172
+ if corrections:
173
+ _notify(progress_cb, "Annotating anchor tail continuation links...")
174
+ tail = annotate_anchor_tail_links(
175
+ working_dir,
176
+ corrections,
177
+ cfg,
178
+ tracked_labels=tracked_labels,
179
+ )
180
+ anchor_tail_links_annotated = int(tail.annotated)
181
+ _notify(
182
+ progress_cb,
183
+ f"Annotated {anchor_tail_links_annotated} anchor tail link(s).",
184
+ )
185
+
186
+ return AnnotateAndScoreReport(
187
+ fake_nodes=fake_nodes,
188
+ anchor_nodes=anchor_nodes,
189
+ anchor_links=anchor_links,
190
+ scored_nodes=scored_nodes,
191
+ seed_nodes=seed_nodes,
192
+ anchor_incident_links_inserted=anchor_incident_links_inserted,
193
+ anchor_tail_links_annotated=anchor_tail_links_annotated,
194
+ injected_homemade_anchors=injected_homemade_anchors,
195
+ )
196
+
197
+
198
+ def annotate_database_from_corrections(
199
+ working_dir: str | Path,
200
+ cfg: TrackingConfig,
201
+ *,
202
+ score_signal_path: str | Path,
203
+ corrections: list[Correction] | None = None,
204
+ validated_tracks: dict[int, set[int]] | None = None,
205
+ tracked_labels: np.ndarray | None = None,
206
+ progress_cb: Callable[[str], None] | None = None,
207
+ ) -> AnnotateAndScoreReport:
208
+ """Apply correction-derived annotations to ``data.db`` and rescore nodes."""
209
+ return apply_annotations_and_score(
210
+ working_dir=working_dir,
211
+ cfg=cfg,
212
+ score_signal_path=score_signal_path,
213
+ corrections=corrections,
214
+ validated_tracks=validated_tracks,
215
+ tracked_labels=tracked_labels,
216
+ progress_cb=progress_cb,
217
+ )
218
+
219
+
220
+ def _notify(progress_cb: Callable[[str], None] | None, message: str) -> None:
221
+ if progress_cb is not None:
222
+ progress_cb(message)
223
+
224
+
225
+ def _check_cancel(cancel: Callable[[], bool] | None) -> None:
226
+ if cancel is not None and cancel():
227
+ raise CancelledError("Operation cancelled.")
228
+
229
+
230
+ # Rows are streamed to the DB in fixed-size chunks (committed per chunk) so peak
231
+ # memory stays flat regardless of how many candidates/overlaps a frame produces.
232
+ _INSERT_CHUNK = 50_000
233
+
234
+
235
+ def _stream_insert(Session, engine, rows: list) -> None:
236
+ """Add ``rows`` to the DB in ``_INSERT_CHUNK``-sized batches, one commit each."""
237
+ for start in range(0, len(rows), _INSERT_CHUNK):
238
+ with Session(engine) as session:
239
+ session.add_all(rows[start:start + _INSERT_CHUNK])
240
+ session.commit()
241
+
242
+
243
+ def build_atom_union_database(
244
+ atoms_path: str | Path,
245
+ working_dir: str | Path,
246
+ cfg: TrackingConfig,
247
+ progress_cb: Callable[[str], None] | None = None,
248
+ *,
249
+ contour_maps_path: str | Path | None = None,
250
+ cancel: Callable[[], bool] | None = None,
251
+ ) -> AtomUnionDatabaseBuildReport:
252
+ """Build candidate ``data.db`` from ``atoms.tif`` via a merge tree + branching.
253
+
254
+ Reads ``atoms.tif`` (written by stage ①). Per frame it builds a backbone
255
+ watershed-style merge tree over the atom RAG (atoms as leaves, walls weighted
256
+ by ridge strength) plus branch candidates admitted most-ambiguous-first up to
257
+ ``cfg.atom_overlap_budget`` overlap pairs — see ``atoms.build_atom_merge_tree``
258
+ / ``atoms.branch_unions``. Every candidate's total area is capped at
259
+ ``cfg.atom_union_max_area``. NodeDB + OverlapDB rows are stream-inserted in
260
+ chunks, then linking runs.
261
+
262
+ ``contour_maps_path`` supplies the ridge weights: the residual contour is
263
+ recomputed from it using the ``AtomParams`` embedded in ``atoms.tif`` (the
264
+ stage-① output contract is unchanged — ``atoms.tif`` remains the sole
265
+ artifact). When omitted, walls are treated as equally weighted, so the
266
+ backbone degrades to a deterministic area-ordered tree.
267
+ """
268
+ import pickle
269
+
270
+ import sqlalchemy as sqla
271
+ from scipy import ndimage as ndi
272
+ from sqlalchemy.orm import Session
273
+ from ultrack.core.database import Base, NodeDB, OverlapDB, clear_all_data
274
+ from ultrack.core.segmentation.node import Node
275
+
276
+ from itasc.core.imageops import residual
277
+ from itasc.tracking_ultrack.atoms import (
278
+ AtomParams,
279
+ atom_adjacency,
280
+ atom_adjacency_weighted,
281
+ branch_unions,
282
+ build_atom_merge_tree,
283
+ read_atoms_params,
284
+ )
285
+
286
+ atoms_path = Path(atoms_path)
287
+ working_dir = Path(working_dir)
288
+ working_dir.mkdir(parents=True, exist_ok=True)
289
+
290
+ _notify(progress_cb, "Reading atoms.tif...")
291
+ atoms_stack = tifffile.imread(str(atoms_path)).astype(np.int32)
292
+ if atoms_stack.ndim == 2:
293
+ atoms_stack = atoms_stack[np.newaxis]
294
+
295
+ # Ridge weights: recompute the residual contour from the contour map using
296
+ # the AtomParams stage ① embedded in atoms.tif (so weights match extraction).
297
+ contours = None
298
+ if contour_maps_path is not None and Path(contour_maps_path).exists():
299
+ _notify(progress_cb, "Reading contour maps for ridge weights...")
300
+ contours = np.asarray(tifffile.imread(str(contour_maps_path)), dtype=np.float32)
301
+ if contours.ndim == 4 and contours.shape[1] == 1:
302
+ contours = contours[:, 0]
303
+ if contours.ndim == 2:
304
+ contours = contours[np.newaxis]
305
+ stored_params, _fp = read_atoms_params(atoms_path)
306
+ atom_params = AtomParams(**stored_params) if stored_params else AtomParams()
307
+
308
+ ultrack_cfg = _build_ultrack_config(cfg, working_dir)
309
+ db_path = ultrack_cfg.data_config.database_path
310
+ clear_all_data(db_path)
311
+ engine = sqla.create_engine(db_path)
312
+ Base.metadata.create_all(engine)
313
+ ultrack_cfg.data_config.metadata_add({"shape": list(atoms_stack.shape)})
314
+
315
+ n_frames = atoms_stack.shape[0]
316
+ max_seg = cfg.max_segments_per_time
317
+ max_area = cfg.atom_union_max_area
318
+ min_area = cfg.min_area
319
+ min_frontier = cfg.seg_min_frontier
320
+ overlap_budget = cfg.atom_overlap_budget
321
+ total_nodes = total_overlaps = 0
322
+
323
+ for t in range(n_frames):
324
+ # Cancellation is cooperative and per-frame: a build over many frames is
325
+ # the bulk of the work, so polling here lets a Cancel click take effect
326
+ # within one frame instead of after the whole build.
327
+ _check_cancel(cancel)
328
+ _notify(progress_cb, f"Building candidates frame {t + 1}/{n_frames}...")
329
+ # _stream_insert commits each frame's rows, so a cancel between frames
330
+ # loses nothing; the engine pool is disposed once after the loop rather
331
+ # than per frame (which forced a full reconnect every iteration).
332
+ frame_atoms = atoms_stack[t]
333
+ n_atoms = int(frame_atoms.max())
334
+ if n_atoms == 0:
335
+ continue
336
+
337
+ slices = ndi.find_objects(frame_atoms)
338
+ ids, counts = np.unique(frame_atoms, return_counts=True)
339
+ areas = {int(i): int(c) for i, c in zip(ids, counts) if i != 0}
340
+
341
+ if contours is not None and t < contours.shape[0]:
342
+ rc = residual(contours[t], atom_params.contour_window, atom_params.contour_strength)
343
+ adj, weights = atom_adjacency_weighted(frame_atoms, rc)
344
+ else:
345
+ adj = atom_adjacency(frame_atoms)
346
+ weights = dict.fromkeys(
347
+ ((u, v) if u < v else (v, u) for u in adj for v in adj[u]), 0.0
348
+ )
349
+
350
+ backbone = build_atom_merge_tree(
351
+ adj, weights, areas,
352
+ min_area=min_area, max_area=max_area, min_frontier=min_frontier,
353
+ )
354
+ branches, report = branch_unions(
355
+ adj, weights, areas, backbone,
356
+ max_area=max_area, overlap_budget=overlap_budget,
357
+ )
358
+ unions = backbone + branches
359
+ if report.budget_hit:
360
+ _notify(
361
+ progress_cb,
362
+ f"frame {t}: overlap budget hit — admitted {report.admitted} "
363
+ f"branch candidate(s), ≥{report.skipped} skipped.",
364
+ )
365
+
366
+ nodes: list[NodeDB] = []
367
+ atom_to_node_ids: dict[int, list[int]] = {a: [] for a in areas}
368
+ index = 1
369
+ for members in unions:
370
+ mlist = sorted(members)
371
+ ys0 = min(slices[m - 1][0].start for m in mlist)
372
+ xs0 = min(slices[m - 1][1].start for m in mlist)
373
+ ys1 = max(slices[m - 1][0].stop for m in mlist)
374
+ xs1 = max(slices[m - 1][1].stop for m in mlist)
375
+ crop = frame_atoms[ys0:ys1, xs0:xs1]
376
+ mask = np.isin(crop, mlist)
377
+ bbox = np.array([ys0, xs0, ys1, xs1])
378
+ node = Node.from_mask(t, mask.astype(bool), bbox=bbox)
379
+ nid = index + (t + 1) * max_seg
380
+ node.id = nid
381
+ node.time = t
382
+ centroid = node.centroid
383
+ z, y, x = (0, *centroid) if len(centroid) == 2 else centroid
384
+ nodes.append(NodeDB(
385
+ id=nid, t_node_id=index, t_hier_id=1, t=t,
386
+ z=int(z), y=int(y), x=int(x), area=int(node.area),
387
+ frontier=-1.0, height=float(len(mlist)),
388
+ pickle=pickle.dumps(node),
389
+ ))
390
+ for a in mlist:
391
+ atom_to_node_ids[a].append(nid)
392
+ index += 1
393
+
394
+ # Two candidates overlap iff they share an atom (atoms are disjoint).
395
+ seen_pairs: set[tuple[int, int]] = set()
396
+ overlaps: list[OverlapDB] = []
397
+ for cand_ids in atom_to_node_ids.values():
398
+ for i in range(len(cand_ids)):
399
+ for j in range(i + 1, len(cand_ids)):
400
+ a, b = cand_ids[i], cand_ids[j]
401
+ key = (a, b) if a < b else (b, a)
402
+ if key not in seen_pairs:
403
+ seen_pairs.add(key)
404
+ overlaps.append(OverlapDB(node_id=key[0], ancestor_id=key[1]))
405
+
406
+ _stream_insert(Session, engine, nodes)
407
+ _stream_insert(Session, engine, overlaps)
408
+
409
+ total_nodes += len(nodes)
410
+ total_overlaps += len(overlaps)
411
+
412
+ engine.dispose()
413
+ _notify(
414
+ progress_cb,
415
+ f"Wrote {total_nodes} candidate nodes, {total_overlaps} overlap rows.",
416
+ )
417
+ _check_cancel(cancel)
418
+ _notify(progress_cb, "Linking candidates...")
419
+ for step, total, label in run_linking(working_dir, cfg):
420
+ _check_cancel(cancel)
421
+ _notify(progress_cb, f"[link {step}/{total}] {label}")
422
+
423
+ return AtomUnionDatabaseBuildReport(
424
+ total_nodes=total_nodes,
425
+ total_overlaps=total_overlaps,
426
+ n_frames=n_frames,
427
+ )
428
+
429
+
430
+ def _load_ultrack_inputs(
431
+ contour_maps_path: str | Path,
432
+ foreground_masks_path: str | Path,
433
+ ) -> tuple[np.ndarray, np.ndarray]:
434
+ contours = np.asarray(tifffile.imread(str(contour_maps_path)), dtype=np.float32)
435
+ foreground = np.asarray(tifffile.imread(str(foreground_masks_path)), dtype=np.float32)
436
+ if contours.ndim == 4 and contours.shape[1] == 1:
437
+ contours = contours[:, 0]
438
+ if foreground.ndim == 4 and foreground.shape[1] == 1:
439
+ foreground = foreground[:, 0]
440
+ return contours, foreground
441
+
442
+
443
+ def _run_ultrack_segment(
444
+ foreground: np.ndarray,
445
+ contours: np.ndarray,
446
+ ultrack_cfg,
447
+ cfg: TrackingConfig,
448
+ ) -> None:
449
+ try:
450
+ from ultrack.core.segmentation.processing import segment as ultrack_segment
451
+ except ImportError as exc:
452
+ raise ImportError(
453
+ "ultrack must be installed (conda env itasc) to build data.db"
454
+ ) from exc
455
+
456
+ ultrack_segment(
457
+ foreground,
458
+ contours,
459
+ ultrack_cfg,
460
+ max_segments_per_time=cfg.max_segments_per_time,
461
+ overwrite=True,
462
+ )
463
+
464
+
465
+ def build_ultrack_database(
466
+ contour_maps_path: str | Path,
467
+ foreground_masks_path: str | Path,
468
+ working_dir: str | Path,
469
+ cfg: TrackingConfig,
470
+ progress_cb: Callable[[str], None] | None = None,
471
+ ) -> UltrackDatabaseBuildReport:
472
+ """Build candidate ``data.db`` from canonical Ultrack segmentation + linking.
473
+
474
+ Produces NodeDB / LinkDB / OverlapDB rows with all annotations UNKNOWN and
475
+ no node-prob scores. Pair with ``apply_annotations_and_score`` before
476
+ ``run_solve`` to ingest validations/anchors.
477
+ """
478
+ working_dir = Path(working_dir)
479
+ working_dir.mkdir(parents=True, exist_ok=True)
480
+
481
+ _notify(progress_cb, "Loading contour maps and foreground masks...")
482
+ contours, foreground = _load_ultrack_inputs(contour_maps_path, foreground_masks_path)
483
+ ultrack_cfg = _build_ultrack_config(cfg, working_dir)
484
+
485
+ _notify(progress_cb, "Segmenting candidates (ultrack hierarchy)...")
486
+ _run_ultrack_segment(foreground, contours, ultrack_cfg, cfg)
487
+
488
+ _notify(progress_cb, "Linking candidates...")
489
+ for step, total, label in run_linking(working_dir, cfg):
490
+ _notify(progress_cb, f"[link {step}/{total}] {label}")
491
+
492
+ return UltrackDatabaseBuildReport()