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,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()
|