itasc 0.2.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itasc/__init__.py +16 -0
- itasc/cellpose/__init__.py +31 -0
- itasc/cellpose/cellpose_runner.py +334 -0
- itasc/cellpose/divergence_maps.py +380 -0
- itasc/cellpose/flow_following.py +283 -0
- itasc/cellpose/joint.py +98 -0
- itasc/cellpose/napari.yaml +10 -0
- itasc/cellpose/native_masks.py +313 -0
- itasc/cellpose/retrack.py +232 -0
- itasc/cellpose/shape.py +51 -0
- itasc/cellpose/track_laptrack.py +279 -0
- itasc/contact_analysis/__init__.py +61 -0
- itasc/contact_analysis/_provenance.py +29 -0
- itasc/contact_analysis/catalog.py +393 -0
- itasc/contact_analysis/config.py +155 -0
- itasc/contact_analysis/contacts/__init__.py +24 -0
- itasc/contact_analysis/contacts/batch.py +158 -0
- itasc/contact_analysis/contacts/build.py +837 -0
- itasc/contact_analysis/contacts/contact_labels.py +136 -0
- itasc/contact_analysis/contacts/neighborhood.py +146 -0
- itasc/contact_analysis/contacts/reader.py +58 -0
- itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
- itasc/contact_analysis/curation.py +185 -0
- itasc/contact_analysis/dynamics/__init__.py +43 -0
- itasc/contact_analysis/dynamics/collective.py +206 -0
- itasc/contact_analysis/dynamics/kinematics.py +278 -0
- itasc/contact_analysis/dynamics/msd.py +165 -0
- itasc/contact_analysis/dynamics/store.py +251 -0
- itasc/contact_analysis/dynamics/trajectories.py +100 -0
- itasc/contact_analysis/frame_interval.py +85 -0
- itasc/contact_analysis/napari.yaml +10 -0
- itasc/contact_analysis/pipeline.py +313 -0
- itasc/contact_analysis/pixel_size.py +166 -0
- itasc/contact_analysis/quantifier.py +204 -0
- itasc/contact_analysis/quantifiers/__init__.py +7 -0
- itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
- itasc/contact_analysis/quantifiers/cell_density.py +65 -0
- itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
- itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
- itasc/contact_analysis/quantifiers/contacts.py +72 -0
- itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
- itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
- itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
- itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
- itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
- itasc/contact_analysis/records.py +129 -0
- itasc/contact_analysis/shape/__init__.py +19 -0
- itasc/contact_analysis/shape/core.py +141 -0
- itasc/contact_analysis/shape/relational.py +134 -0
- itasc/contact_analysis/shape_tables.py +387 -0
- itasc/core/__init__.py +0 -0
- itasc/core/cancellation.py +15 -0
- itasc/core/commit.py +61 -0
- itasc/core/imageops.py +34 -0
- itasc/core/label_store.py +53 -0
- itasc/core/lineage.py +113 -0
- itasc/core/logging.py +51 -0
- itasc/core/paths.py +34 -0
- itasc/core/tiff.py +13 -0
- itasc/correction/__init__.py +25 -0
- itasc/correction/labels.py +983 -0
- itasc/napari/__init__.py +7 -0
- itasc/napari/_experiments_panel.py +1091 -0
- itasc/napari/_flow_layout.py +98 -0
- itasc/napari/_icons.py +126 -0
- itasc/napari/_napari_compat.py +118 -0
- itasc/napari/_paths.py +231 -0
- itasc/napari/_preview_cache.py +60 -0
- itasc/napari/_spotlight.py +46 -0
- itasc/napari/_stage_loader.py +146 -0
- itasc/napari/_stage_status.py +110 -0
- itasc/napari/_standalone_paths.py +89 -0
- itasc/napari/_state.py +146 -0
- itasc/napari/_status_rail.py +128 -0
- itasc/napari/_track_render.py +114 -0
- itasc/napari/_widget_helpers.py +236 -0
- itasc/napari/aggregate_widget.py +339 -0
- itasc/napari/candidate_gallery_controller.py +285 -0
- itasc/napari/cell_workflow_widget.py +1405 -0
- itasc/napari/cellpose_segment_track_widget.py +1253 -0
- itasc/napari/cellpose_widget.py +1034 -0
- itasc/napari/contact_analysis/__init__.py +6 -0
- itasc/napari/contact_analysis/curation_controller.py +94 -0
- itasc/napari/contact_analysis/plugins/__init__.py +102 -0
- itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
- itasc/napari/contact_analysis/plugins/curation.py +247 -0
- itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
- itasc/napari/contact_analysis_widget.py +951 -0
- itasc/napari/contact_visualization.py +895 -0
- itasc/napari/correction/__init__.py +8 -0
- itasc/napari/correction/_correction_anchor.py +54 -0
- itasc/napari/correction/_correction_candidate_panel.py +259 -0
- itasc/napari/correction/_correction_candidates.py +169 -0
- itasc/napari/correction/_correction_centroids.py +132 -0
- itasc/napari/correction/_correction_commit.py +71 -0
- itasc/napari/correction/_correction_events.py +39 -0
- itasc/napari/correction/_correction_keymap.py +80 -0
- itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
- itasc/napari/correction/_correction_layer_loader.py +86 -0
- itasc/napari/correction/_correction_navigation.py +97 -0
- itasc/napari/correction/_correction_paint.py +46 -0
- itasc/napari/correction/_correction_playback.py +68 -0
- itasc/napari/correction/_correction_protection.py +44 -0
- itasc/napari/correction/_correction_takeover.py +64 -0
- itasc/napari/correction/_correction_track_accordion.py +566 -0
- itasc/napari/correction/_correction_track_path.py +388 -0
- itasc/napari/correction/_correction_ui.py +367 -0
- itasc/napari/correction/_correction_ui_nucleus.py +393 -0
- itasc/napari/correction/_correction_utils.py +204 -0
- itasc/napari/correction/_correction_validation.py +83 -0
- itasc/napari/correction/cell_correction_widget.py +1190 -0
- itasc/napari/correction/correction_widget.py +1363 -0
- itasc/napari/correction/nucleus_correction_widget.py +2185 -0
- itasc/napari/divergence_maps_widget.py +795 -0
- itasc/napari/lineage_canvas_controller.py +351 -0
- itasc/napari/main_widget.py +1184 -0
- itasc/napari/nucleus_atom_extraction_widget.py +648 -0
- itasc/napari/nucleus_db_browser_widget.py +1442 -0
- itasc/napari/nucleus_pipeline_widget.py +579 -0
- itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
- itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
- itasc/napari/nucleus_workflow_widget.py +963 -0
- itasc/napari/track_path_controller.py +387 -0
- itasc/napari/ui_gate.py +266 -0
- itasc/napari/ui_style.py +503 -0
- itasc/napari/validated_overlay_controller.py +254 -0
- itasc/napari/widgets.py +616 -0
- itasc/napari.yaml +45 -0
- itasc/segmentation/__init__.py +60 -0
- itasc/segmentation/cell_divergence_segmentation.py +433 -0
- itasc/segmentation/cell_label_icm.py +637 -0
- itasc/segmentation/contour_filtering.py +128 -0
- itasc/segmentation/lineage.py +18 -0
- itasc/segmentation/nucleus_segmentation.py +35 -0
- itasc/tracking_ultrack/__init__.py +12 -0
- itasc/tracking_ultrack/_node_geometry.py +191 -0
- itasc/tracking_ultrack/atoms.py +470 -0
- itasc/tracking_ultrack/config.py +76 -0
- itasc/tracking_ultrack/corrections.py +1213 -0
- itasc/tracking_ultrack/db_build.py +492 -0
- itasc/tracking_ultrack/db_query.py +952 -0
- itasc/tracking_ultrack/export.py +133 -0
- itasc/tracking_ultrack/extend.py +243 -0
- itasc/tracking_ultrack/ingest.py +83 -0
- itasc/tracking_ultrack/linking.py +286 -0
- itasc/tracking_ultrack/multi_threshold.py +46 -0
- itasc/tracking_ultrack/napari.yaml +10 -0
- itasc/tracking_ultrack/reseed.py +456 -0
- itasc/tracking_ultrack/retracker.py +153 -0
- itasc/tracking_ultrack/scoring.py +74 -0
- itasc/tracking_ultrack/seed_prior.py +175 -0
- itasc/tracking_ultrack/solve.py +52 -0
- itasc/tracking_ultrack/swap_candidate.py +171 -0
- itasc/tracking_ultrack/track_quality.py +142 -0
- itasc/tracking_ultrack/validation_nodes.py +315 -0
- itasc/tracking_ultrack/validation_state.py +430 -0
- itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
- itasc-0.2.0.dev0.dist-info/METADATA +186 -0
- itasc-0.2.0.dev0.dist-info/RECORD +162 -0
- itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
- itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,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
|