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