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,470 @@
1
+ # src/itasc/tracking_ultrack/atoms.py
2
+ """Atom extraction: residual-conditioned foreground split by contour ridges.
3
+
4
+ Stage ① of the atom-based candidate pipeline. Pure, deterministic functions
5
+ shared by the interactive preview and the full-stack ``atoms.tif`` writer.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import heapq
11
+ import itertools
12
+ import json
13
+ import numpy as np
14
+ from collections import defaultdict
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path
17
+ from scipy import ndimage as ndi
18
+ from skimage.segmentation import watershed
19
+
20
+ import tifffile
21
+
22
+ from itasc.core.imageops import residual
23
+
24
+ _PARAMS_KEY = "itasc_atom_params"
25
+ _FINGERPRINT_KEY = "itasc_atom_fingerprint"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AtomParams:
30
+ """The knobs that fully determine an atom segmentation."""
31
+ fg_window: int = 51
32
+ fg_cutoff: float = 0.002
33
+ fg_strength: float = 1.0
34
+ contour_window: int = 51
35
+ contour_floor: float = 0.01
36
+ contour_strength: float = 1.0
37
+ atom_min_area: int = 100
38
+
39
+
40
+ def extract_atoms_frame(
41
+ residual_contour: np.ndarray,
42
+ territory: np.ndarray,
43
+ contour_floor: float,
44
+ atom_min_area: int,
45
+ ) -> tuple[np.ndarray, np.ndarray]:
46
+ """Split ``territory`` into atoms along the cleaned contour ridge.
47
+
48
+ ``ridge`` is where the residual contour exceeds ``contour_floor`` (a noise
49
+ cutoff). Cores (ridge-free territory) seed a watershed that floods the
50
+ residual-contour elevation, so a broken faint ridge still meets at the crest.
51
+ Atoms smaller than ``atom_min_area`` are merged into the neighbouring atom
52
+ they share the longest border with (see ``_merge_small_atoms``); an atom that
53
+ touches no other label is kept as-is, so no territory pixel is ever blanked.
54
+
55
+ Returns ``(atoms, ridge)``: ``atoms`` is the ``int32`` label image; ``ridge``
56
+ is the boolean wall ``residual_contour > contour_floor`` returned as ``uint8``
57
+ ``(Y, X)`` — the exact array the watershed carves out of territory, surfaced
58
+ so it can be tuned against directly. Small-atom merging only relabels pixels,
59
+ so it does not move the ridge.
60
+ """
61
+ residual_contour = np.asarray(residual_contour, dtype=np.float32)
62
+ territory = np.asarray(territory, dtype=bool)
63
+ ridge = residual_contour > contour_floor
64
+ cores = territory & ~ridge
65
+ markers, _ = ndi.label(cores)
66
+ atoms = watershed(residual_contour, markers=markers, mask=territory)
67
+ if atom_min_area > 0:
68
+ atoms = _merge_small_atoms(atoms, atom_min_area)
69
+ return atoms.astype(np.int32), ridge.astype(np.uint8)
70
+
71
+
72
+ def _merge_small_atoms(atoms: np.ndarray, atom_min_area: int) -> np.ndarray:
73
+ """Fold each atom below ``atom_min_area`` into the neighbour it shares the
74
+ longest border with, repeating until no undersized atom touches another label.
75
+
76
+ Smallest-first: the most undersized atom that has a neighbour is merged into
77
+ its longest-shared-border neighbour (ties broken by larger neighbour area,
78
+ then smaller id), its area and adjacency folded into the survivor, and the
79
+ process re-run. An atom with no neighbouring label is left in place — so an
80
+ isolated small territory keeps its label rather than being removed. Merging
81
+ only relabels pixels, leaving no holes.
82
+ """
83
+ ids, counts = np.unique(atoms, return_counts=True)
84
+ area = {int(i): int(c) for i, c in zip(ids.tolist(), counts.tolist()) if i != 0}
85
+ if len(area) < 2:
86
+ return atoms
87
+
88
+ # Shared 4-connected border length between each adjacent pair of labels.
89
+ border: dict[int, dict[int, int]] = defaultdict(lambda: defaultdict(int))
90
+ for a, b in ((atoms[:-1, :], atoms[1:, :]), (atoms[:, :-1], atoms[:, 1:])):
91
+ m = (a != b) & (a != 0) & (b != 0)
92
+ for u, v in zip(a[m].tolist(), b[m].tolist()):
93
+ border[u][v] += 1
94
+ border[v][u] += 1
95
+
96
+ remap: dict[int, int] = {}
97
+ while True:
98
+ candidates = [lbl for lbl, ar in area.items()
99
+ if ar < atom_min_area and border[lbl]]
100
+ if not candidates:
101
+ break
102
+ small = min(candidates, key=lambda l: (area[l], l))
103
+ target = max(border[small],
104
+ key=lambda n: (border[small][n], area[n], -n))
105
+ remap[small] = target
106
+ area[target] += area.pop(small)
107
+ for n, length in border.pop(small).items():
108
+ border[n].pop(small, None)
109
+ if n != target:
110
+ border[target][n] += length
111
+ border[n][target] += length
112
+ border[target].pop(small, None)
113
+
114
+ if not remap:
115
+ return atoms
116
+
117
+ def resolve(label: int) -> int:
118
+ while label in remap:
119
+ label = remap[label]
120
+ return label
121
+
122
+ lut = np.arange(int(atoms.max()) + 1, dtype=atoms.dtype)
123
+ for label in remap:
124
+ lut[label] = resolve(label)
125
+ return lut[atoms]
126
+
127
+
128
+ def extract_atoms_stack_with_maps(
129
+ fg: np.ndarray, contour: np.ndarray, params: AtomParams
130
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
131
+ """(atoms, territory, residual_foreground, residual_contour, ridge) stacks, each (T, Y, X).
132
+
133
+ ``ridge`` is the per-frame ``residual_contour > contour_floor`` wall returned
134
+ by ``extract_atoms_frame``, accumulated as a ``uint8`` stack.
135
+ """
136
+ fg = np.asarray(fg, dtype=np.float32)
137
+ contour = np.asarray(contour, dtype=np.float32)
138
+ atoms_out = np.zeros(fg.shape, dtype=np.int32)
139
+ territory_out = np.zeros(fg.shape, dtype=np.uint8)
140
+ rf_out = np.zeros(fg.shape, dtype=np.float32)
141
+ rc_out = np.zeros(fg.shape, dtype=np.float32)
142
+ ridge_out = np.zeros(fg.shape, dtype=np.uint8)
143
+ for t in range(fg.shape[0]):
144
+ rf = residual(fg[t], params.fg_window, params.fg_strength)
145
+ territory = rf > params.fg_cutoff
146
+ rc = residual(contour[t], params.contour_window, params.contour_strength)
147
+ atoms_out[t], ridge_out[t] = extract_atoms_frame(
148
+ rc, territory, params.contour_floor, params.atom_min_area
149
+ )
150
+ territory_out[t] = territory.astype(np.uint8)
151
+ rf_out[t] = rf
152
+ rc_out[t] = rc
153
+ return atoms_out, territory_out, rf_out, rc_out, ridge_out
154
+
155
+
156
+ def extract_atoms_stack(
157
+ fg: np.ndarray, contour: np.ndarray, params: AtomParams
158
+ ) -> np.ndarray:
159
+ """Atom label stack for a (T, Y, X) foreground + contour pair.
160
+
161
+ Per frame: residual the fg map and threshold it into territory, residual the
162
+ contour map into the watershed elevation, then ``extract_atoms_frame``.
163
+ Returns the atom labels only — the residual maps are internal.
164
+ """
165
+ atoms, *_ = extract_atoms_stack_with_maps(fg, contour, params)
166
+ return atoms
167
+
168
+
169
+ def params_fingerprint(params: AtomParams) -> str:
170
+ """Stable SHA-1 of the params, used to detect stale atoms.tif in DB-Gen."""
171
+ payload = json.dumps(asdict(params), sort_keys=True).encode("utf-8")
172
+ return hashlib.sha1(payload, usedforsecurity=False).hexdigest()
173
+
174
+
175
+ def write_atoms_tif(path, atoms: np.ndarray, params: AtomParams) -> None:
176
+ """Write the atom label stack with the params + fingerprint embedded in the
177
+ TIFF ImageDescription, so DB-Gen (②) can read what produced it."""
178
+ description = json.dumps(
179
+ {_PARAMS_KEY: asdict(params), _FINGERPRINT_KEY: params_fingerprint(params)}
180
+ )
181
+ tifffile.imwrite(
182
+ str(path), np.asarray(atoms, dtype=np.int32), description=description
183
+ )
184
+
185
+
186
+ def atom_adjacency(atoms: np.ndarray) -> dict[int, set[int]]:
187
+ """Region-adjacency graph of a label image: labels sharing a 4-connected border."""
188
+ adj: dict[int, set[int]] = {}
189
+ pairs: set[tuple[int, int]] = set()
190
+ for a, b in (
191
+ (atoms[:-1, :], atoms[1:, :]),
192
+ (atoms[:, :-1], atoms[:, 1:]),
193
+ ):
194
+ m = (a != b) & (a != 0) & (b != 0)
195
+ for u, v in zip(a[m].tolist(), b[m].tolist()):
196
+ pairs.add((u, v) if u < v else (v, u))
197
+ for lbl in np.unique(atoms):
198
+ if lbl != 0:
199
+ adj[int(lbl)] = set()
200
+ for u, v in pairs:
201
+ adj[u].add(v)
202
+ adj[v].add(u)
203
+ return adj
204
+
205
+
206
+ def atom_adjacency_weighted(
207
+ atoms: np.ndarray, residual_contour: np.ndarray
208
+ ) -> tuple[dict[int, set[int]], dict[tuple[int, int], float]]:
209
+ """Atom RAG plus the mean ridge strength on each shared border.
210
+
211
+ Returns ``(adj, weights)`` where ``adj`` is the same region-adjacency graph
212
+ as :func:`atom_adjacency`, and ``weights`` maps each ``(u, v)`` edge
213
+ (``u < v``) to the mean ``residual_contour`` value on the 4-connected wall
214
+ separating the two atoms — the saliency of that wall. This is the per-edge
215
+ ridge weight the merge tree merges across and the branch admission orders by.
216
+
217
+ The wall value of a border pixel-pair ``(p ∈ u, q ∈ v)`` is the mean of the
218
+ residual-contour at ``p`` and ``q`` (the elevation the watershed floods to
219
+ join them); the edge weight is the mean over all such pairs.
220
+ """
221
+ residual_contour = np.asarray(residual_contour, dtype=np.float32)
222
+ adj: dict[int, set[int]] = {int(lbl): set() for lbl in np.unique(atoms) if lbl != 0}
223
+ wsum: dict[tuple[int, int], float] = defaultdict(float)
224
+ wcnt: dict[tuple[int, int], int] = defaultdict(int)
225
+ for la, lb, ca, cb in (
226
+ (atoms[:-1, :], atoms[1:, :], residual_contour[:-1, :], residual_contour[1:, :]),
227
+ (atoms[:, :-1], atoms[:, 1:], residual_contour[:, :-1], residual_contour[:, 1:]),
228
+ ):
229
+ m = (la != lb) & (la != 0) & (lb != 0)
230
+ wall = (ca[m] + cb[m]) * 0.5
231
+ for u, v, w in zip(la[m].tolist(), lb[m].tolist(), wall.tolist()):
232
+ key = (u, v) if u < v else (v, u)
233
+ wsum[key] += w
234
+ wcnt[key] += 1
235
+ weights = {k: wsum[k] / wcnt[k] for k in wsum}
236
+ for u, v in weights:
237
+ adj[u].add(v)
238
+ adj[v].add(u)
239
+ return adj, weights
240
+
241
+
242
+ def build_atom_merge_tree(
243
+ adj: dict[int, set[int]],
244
+ weights: dict[tuple[int, int], float],
245
+ areas: dict[int, int],
246
+ *,
247
+ min_area: int,
248
+ max_area: int,
249
+ min_frontier: float,
250
+ ) -> list[frozenset[int]]:
251
+ """Backbone watershed-style merge tree over the atom RAG (always built).
252
+
253
+ A binary partition tree by altitude: atoms are leaves, and edges (walls) are
254
+ consumed in ascending ridge weight — weakest wall first — each merge creating
255
+ an internal node = the union of the two regions it joins. Per connected
256
+ component the leaves are the atoms and the root is the maximal connected
257
+ merge; every node is a candidate. Because the structure is a tree, any two
258
+ nodes are nested or disjoint, so overlaps among backbone nodes are
259
+ ancestor↔descendant only (linear, not quadratic).
260
+
261
+ This is the same shape of hierarchy ultrack builds at the pixel level
262
+ (``hg.watershed_hierarchy_by_*`` + ``min_area`` / ``max_area`` /
263
+ ``min_frontier`` pruning), but with atoms — the agreed stage-① primitive — as
264
+ leaves, implemented here without a higra dependency on the candidate path.
265
+
266
+ Pruning: singletons (atoms) are always kept; an internal node is kept only
267
+ when ``min_area ≤ area ≤ max_area`` and the wall joining it to its sibling
268
+ (its parent merge's frontier) is ``≥ min_frontier`` — a sub-threshold wall is
269
+ a non-salient split, collapsed into the parent. Returns the kept candidate
270
+ members as frozensets, singletons first.
271
+ """
272
+ kept: list[frozenset[int]] = []
273
+ seen: set[frozenset[int]] = set()
274
+ for a in sorted(areas):
275
+ fs = frozenset((a,))
276
+ seen.add(fs)
277
+ kept.append(fs)
278
+
279
+ parent = {a: a for a in areas}
280
+ comp_fs = {a: frozenset((a,)) for a in areas}
281
+ comp_area = {a: areas[a] for a in areas}
282
+
283
+ def find(x: int) -> int:
284
+ root = x
285
+ while parent[root] != root:
286
+ root = parent[root]
287
+ while parent[x] != root:
288
+ parent[x], x = root, parent[x]
289
+ return root
290
+
291
+ parent_frontier: dict[frozenset[int], float] = {}
292
+ internal: list[tuple[frozenset[int], int]] = []
293
+ for (u, v), w in sorted(weights.items(), key=lambda kv: (kv[1], kv[0])):
294
+ ru, rv = find(u), find(v)
295
+ if ru == rv:
296
+ continue
297
+ child_u, child_v = comp_fs[ru], comp_fs[rv]
298
+ merged = child_u | child_v
299
+ marea = comp_area[ru] + comp_area[rv]
300
+ parent_frontier[child_u] = float(w)
301
+ parent_frontier[child_v] = float(w)
302
+ parent[rv] = ru
303
+ comp_fs[ru] = merged
304
+ comp_area[ru] = marea
305
+ internal.append((merged, marea))
306
+
307
+ inf = float("inf")
308
+ for fs, marea in internal:
309
+ if fs in seen:
310
+ continue
311
+ if marea < min_area or marea > max_area:
312
+ continue
313
+ if parent_frontier.get(fs, inf) < min_frontier:
314
+ continue
315
+ seen.add(fs)
316
+ kept.append(fs)
317
+ return kept
318
+
319
+
320
+ @dataclass(frozen=True)
321
+ class BranchReport:
322
+ """How branch admission used the per-frame overlap budget."""
323
+ admitted: int = 0
324
+ skipped: int = 0
325
+ budget_hit: bool = False
326
+
327
+
328
+ def branch_unions(
329
+ adj: dict[int, set[int]],
330
+ weights: dict[tuple[int, int], float],
331
+ areas: dict[int, int],
332
+ backbone: list[frozenset[int]] | set[frozenset[int]],
333
+ *,
334
+ max_area: int,
335
+ overlap_budget: int,
336
+ ) -> tuple[list[frozenset[int]], BranchReport]:
337
+ """Alternative connected unions the backbone did not take, admitted
338
+ most-ambiguous-first up to the per-frame overlap budget.
339
+
340
+ Best-first growth over the atom RAG enumerates connected unions
341
+ (area ≤ ``max_area``) in ascending *bottleneck wall weight* — the strongest
342
+ wall that must be crossed to form the union, i.e. how near-tied its weakest
343
+ internal split is. A near-tie is a coin-flip the tree may have gotten wrong;
344
+ a strong wall is a confident separation. Singletons and backbone nodes are
345
+ skipped (they are already candidates) but still expanded.
346
+
347
+ Budget: admitting a candidate over atoms ``S`` adds ``Σ_{a∈S} k_a`` new
348
+ overlap pairs, where ``k_a`` is how many already-kept candidates contain atom
349
+ ``a`` (initialised from the backbone, so branch↔backbone overlaps are
350
+ charged; the backbone↔backbone floor is free). A running total is kept and
351
+ admission stops the moment the next candidate would cross ``overlap_budget``
352
+ — most-ambiguous-first, no overshoot. ``overlap_budget`` → 0 admits nothing
353
+ (pure backbone); ``overlap_budget`` → large exhausts the lattice.
354
+
355
+ Returns ``(admitted_members, report)``.
356
+ """
357
+ backbone_set = set(backbone)
358
+ k = {a: 0 for a in areas}
359
+ for fs in backbone_set:
360
+ for a in fs:
361
+ k[a] += 1
362
+
363
+ def edge_w(a: int, b: int) -> float | None:
364
+ return weights.get((a, b) if a < b else (b, a))
365
+
366
+ neg = float("-inf")
367
+ counter = itertools.count()
368
+ heap: list[tuple[float, int, frozenset[int], int]] = []
369
+ for a in sorted(areas):
370
+ heapq.heappush(heap, (neg, next(counter), frozenset((a,)), areas[a]))
371
+
372
+ finalized: set[frozenset[int]] = set()
373
+ admitted: list[frozenset[int]] = []
374
+ running = 0
375
+ budget_hit = False
376
+ skipped = 0
377
+ while heap:
378
+ key, _, members, area = heapq.heappop(heap)
379
+ if members in finalized:
380
+ continue
381
+ finalized.add(members)
382
+
383
+ if len(members) > 1 and members not in backbone_set:
384
+ cost = sum(k[a] for a in members)
385
+ if running + cost > overlap_budget:
386
+ budget_hit = True
387
+ skipped = len(heap) + 1 # remaining pending (lower bound)
388
+ break
389
+ admitted.append(members)
390
+ running += cost
391
+ for a in members:
392
+ k[a] += 1
393
+
394
+ nbrs: set[int] = set()
395
+ for a in members:
396
+ nbrs |= adj[a]
397
+ nbrs -= members
398
+ for w in nbrs:
399
+ na = area + areas[w]
400
+ if na > max_area:
401
+ continue
402
+ new_members = members | {w}
403
+ if new_members in finalized:
404
+ continue
405
+ walls = [edge_w(a, w) for a in members if w in adj[a]]
406
+ ew = min(x for x in walls if x is not None)
407
+ new_key = key if key > ew else ew # max(key, ew); seeds at -inf
408
+ heapq.heappush(heap, (new_key, next(counter), new_members, na))
409
+
410
+ return admitted, BranchReport(
411
+ admitted=len(admitted), skipped=skipped, budget_hit=budget_hit
412
+ )
413
+
414
+
415
+ def enum_connected_unions(
416
+ adj: dict[int, set[int]],
417
+ areas: dict[int, int],
418
+ max_atoms: int,
419
+ max_area: int,
420
+ ) -> list[frozenset[int]]:
421
+ """All connected atom-subsets of size 1..max_atoms with total area ≤ max_area.
422
+
423
+ BFS growth with a global ``seen`` set — each subset is reached exactly once,
424
+ deduped by frozenset. This is the full lattice (the ``overlap_budget`` → large
425
+ limit of :func:`build_atom_merge_tree` + :func:`branch_unions`); it explodes
426
+ on dense frames, so it is retained as the equivalence reference for tests, not
427
+ the default candidate-generation path.
428
+ """
429
+ seen: set[frozenset[int]] = set()
430
+ out: list[frozenset[int]] = []
431
+ frontier: list[tuple[frozenset[int], int]] = []
432
+ for v, ar in areas.items():
433
+ fs = frozenset((v,))
434
+ seen.add(fs)
435
+ out.append(fs)
436
+ if ar <= max_area:
437
+ frontier.append((fs, ar))
438
+ while frontier:
439
+ fs, area = frontier.pop()
440
+ if len(fs) >= max_atoms:
441
+ continue
442
+ nbrs: set[int] = set()
443
+ for v in fs:
444
+ nbrs |= adj[v]
445
+ nbrs -= fs
446
+ for w in nbrs:
447
+ na = area + areas[w]
448
+ if na > max_area:
449
+ continue
450
+ nfs = fs | {w}
451
+ if nfs in seen:
452
+ continue
453
+ seen.add(nfs)
454
+ out.append(nfs)
455
+ frontier.append((nfs, na))
456
+ return out
457
+
458
+
459
+ def read_atoms_params(path) -> tuple[dict | None, str | None]:
460
+ """Return ``(params_dict, fingerprint)`` embedded by ``write_atoms_tif``, or
461
+ ``(None, None)`` if the file has no atom metadata."""
462
+ if not Path(path).exists():
463
+ return None, None
464
+ with tifffile.TiffFile(str(path)) as tf:
465
+ description = tf.pages[0].description or ""
466
+ try:
467
+ meta = json.loads(description)
468
+ except (json.JSONDecodeError, TypeError):
469
+ return None, None
470
+ return meta.get(_PARAMS_KEY), meta.get(_FINGERPRINT_KEY)
@@ -0,0 +1,76 @@
1
+ """Pydantic configuration model for the Ultrack-based tracking stage."""
2
+ from __future__ import annotations
3
+
4
+ from multiprocessing import cpu_count
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class TrackingConfig(BaseModel):
10
+ # Node area filters (applied before NodeDB insert)
11
+ min_area: int = 100
12
+ max_area: int = 1_000_000
13
+
14
+ # ID scheme — must match ultrack._generate_id
15
+ max_segments_per_time: int = 1_000_000
16
+
17
+ # Atom extraction (stage ①) — see atoms.AtomParams
18
+ fg_window: int = 51
19
+ fg_cutoff: float = 0.002
20
+ fg_strength: float = 1.0
21
+ contour_window: int = 51
22
+ contour_floor: float = 0.01
23
+ contour_strength: float = 1.0
24
+ atom_min_area: int = 100
25
+
26
+ # Atom-union candidate generation (stage ②)
27
+ # Backbone merge tree (always built) + branch candidates admitted
28
+ # most-ambiguous-first up to a single per-frame overlap-pair budget.
29
+ # The budget is the one branching knob: 0 → nested hierarchy, large →
30
+ # full lattice. ``atom_union_max_area`` stays a hard physical cap on any
31
+ # candidate union. ``atom_union_max_atoms`` is removed — union depth is
32
+ # governed by the area cap and the maximal-merge-always guarantee.
33
+ atom_union_max_area: int = 8000
34
+ atom_overlap_budget: int = 300_000
35
+
36
+ # Linking
37
+ # Ultrack's multiprocessing_apply activates a Pool only when n_workers > 1,
38
+ # and batch_index_range uses n_workers as a window stride (0 would div-zero).
39
+ # Default to min(cpu_count(), 8) for automatic parallelism up to 8 threads.
40
+ max_distance: float = 15.0
41
+ max_neighbors: int = 5
42
+ distance_weight: float = 0.05
43
+ link_n_workers: int = min(cpu_count(), 8)
44
+ linking_mode: str = "default" # "default" or "shape"
45
+ area_weight: float = 1.0
46
+ iou_weight: float = 1.0
47
+ min_link_iou: float = 0.1
48
+ min_area_ratio: float = 0.3
49
+
50
+ # Solver / ILP
51
+ appear_weight: float = -0.001
52
+ disappear_weight: float = -0.001
53
+ division_weight: float = -0.001
54
+ link_function: str = "power"
55
+ power: int = 4
56
+ bias: float = 0.0
57
+ solution_gap: float = 0.001
58
+ time_limit: int = 36000
59
+ window_size: int = 0 # 0 = solve all at once
60
+
61
+ # Segmentation (ultrack.segment / ultrack.core.segmentation.processing.segment)
62
+ seg_min_area: int = 300
63
+ seg_max_area: int = 100_000
64
+ seg_foreground_threshold: float = 0.5
65
+ seg_min_frontier: float = 0.0
66
+ seg_ws_hierarchy: str = "area" # "area", "dynamics", or "volume"
67
+ seg_n_workers: int = 1
68
+
69
+ # Resolve-from-validated node prior
70
+ quality_weight: float = 1.0
71
+ quality_exponent: float = 8.0
72
+ circularity_weight: float = 0.25
73
+
74
+ # Per-frame correction primitives
75
+ anchor_radius_px: float = 15.0
76
+ anchor_stamp_radius_px: float = 15.0