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,456 @@
|
|
|
1
|
+
"""Re-solve with validated tracks using annotated Ultrack seed nodes.
|
|
2
|
+
|
|
3
|
+
The validate-and-resolve loop lets the user lock in confirmed cell tracks
|
|
4
|
+
(validated_tracks), then re-solve the rest of the stack — possibly with
|
|
5
|
+
tweaked parameters — without losing the validated work.
|
|
6
|
+
|
|
7
|
+
The contract for `validated_tracks` is `dict[int, set[int]]` where keys are
|
|
8
|
+
cell IDs and values are the set of frames at which that cell is validated.
|
|
9
|
+
This is independent of the on-disk JSON shape (which is managed separately by
|
|
10
|
+
`itasc.tracking_ultrack.validation_state`).
|
|
11
|
+
|
|
12
|
+
`prune_validated_overlaps` and `_build_frame_masks` are kept for existing
|
|
13
|
+
unit tests and ad hoc use. `merge_validated_into_export` is called at the
|
|
14
|
+
end of `resolve_with_canonical_segment` to paste validated IDs back.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import pickle
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
LOG = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Helper: build per-frame validated-mask index
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def _build_frame_masks(
|
|
33
|
+
validated_tracks: dict[int, set[int]],
|
|
34
|
+
tracked_labels: np.ndarray,
|
|
35
|
+
) -> dict[int, list[tuple[int, np.ndarray, tuple[int, int, int, int]]]]:
|
|
36
|
+
"""Build a per-frame index of validated masks.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
dict mapping frame index ``t`` to a list of
|
|
41
|
+
``(cell_id, bool_mask_crop, (y0, x0, y1, x1))`` tuples, one per validated
|
|
42
|
+
cell at that frame. Both ``bool_mask_crop`` and the bbox refer to the
|
|
43
|
+
cropped region (exclusive-max convention).
|
|
44
|
+
|
|
45
|
+
Only frames where at least one cell is validated are included.
|
|
46
|
+
The 2D spatial slice is taken from ``tracked_labels[t]`` (shape Y×X).
|
|
47
|
+
For a 4-D input ``(T, Z, Y, X)`` the z-axis is max-projected to give a
|
|
48
|
+
2D footprint for intersection tests (conservative: any z-slice counts).
|
|
49
|
+
"""
|
|
50
|
+
frame_index: dict[int, list[tuple[int, np.ndarray, tuple[int, int, int, int]]]] = {}
|
|
51
|
+
|
|
52
|
+
for cell_id, frames in validated_tracks.items():
|
|
53
|
+
for t in frames:
|
|
54
|
+
t = int(t)
|
|
55
|
+
frame_vol = tracked_labels[t] # (Y, X) or (Z, Y, X)
|
|
56
|
+
|
|
57
|
+
# Project to 2D for intersection (max over Z if 3D)
|
|
58
|
+
if frame_vol.ndim == 3:
|
|
59
|
+
frame_2d = (frame_vol == cell_id).any(axis=0)
|
|
60
|
+
else:
|
|
61
|
+
frame_2d = (frame_vol == cell_id)
|
|
62
|
+
|
|
63
|
+
if not frame_2d.any():
|
|
64
|
+
# Cell absent at this frame — skip silently
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Tight bbox around the mask
|
|
68
|
+
rows = np.where(frame_2d.any(axis=1))[0]
|
|
69
|
+
cols = np.where(frame_2d.any(axis=0))[0]
|
|
70
|
+
y0, y1 = int(rows[0]), int(rows[-1]) + 1
|
|
71
|
+
x0, x1 = int(cols[0]), int(cols[-1]) + 1
|
|
72
|
+
mask_crop = frame_2d[y0:y1, x0:x1]
|
|
73
|
+
|
|
74
|
+
if t not in frame_index:
|
|
75
|
+
frame_index[t] = []
|
|
76
|
+
frame_index[t].append((cell_id, mask_crop, (y0, x0, y1, x1)))
|
|
77
|
+
|
|
78
|
+
return frame_index
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# 1. prune_validated_overlaps
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def prune_validated_overlaps(
|
|
86
|
+
working_dir: str | Path,
|
|
87
|
+
validated_tracks: dict[int, set[int]],
|
|
88
|
+
tracked_labels: np.ndarray,
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Delete NodeDB rows that overlap any validated mask and cascade to OverlapDB.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
working_dir:
|
|
95
|
+
Ultrack working directory containing ``data.db``.
|
|
96
|
+
validated_tracks:
|
|
97
|
+
``{cell_id: {frames}}`` — which frames each validated cell spans.
|
|
98
|
+
tracked_labels:
|
|
99
|
+
Current corrected labelmap, shape ``(T, Y, X)`` or ``(T, Z, Y, X)``.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
int
|
|
104
|
+
Number of NodeDB rows deleted.
|
|
105
|
+
|
|
106
|
+
Notes
|
|
107
|
+
-----
|
|
108
|
+
Conflict criterion: **any pixel intersection** between a candidate node's
|
|
109
|
+
2D footprint and any validated cell mask at the same frame counts as a
|
|
110
|
+
conflict. The node (and all OverlapDB rows referencing it) is deleted.
|
|
111
|
+
|
|
112
|
+
The 3D node masks stored in NodeDB are stored as ``(1, h, w)`` crops
|
|
113
|
+
(the ingest convention in ``ingest.py``). We squeeze the Z=1 dimension
|
|
114
|
+
for the 2D intersection test. True 3D (Z > 1) is currently not supported;
|
|
115
|
+
the bbox test gracefully falls back to a bounding-box-only check in that
|
|
116
|
+
case.
|
|
117
|
+
|
|
118
|
+
Both NodeDB and referencing OverlapDB rows are deleted in a single
|
|
119
|
+
SQLite transaction to preserve DB consistency.
|
|
120
|
+
"""
|
|
121
|
+
import sqlalchemy as sqla
|
|
122
|
+
from sqlalchemy.orm import Session
|
|
123
|
+
from ultrack.core.database import NodeDB, OverlapDB
|
|
124
|
+
|
|
125
|
+
if not validated_tracks:
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
working_dir = Path(working_dir)
|
|
129
|
+
db_url = f"sqlite:///{working_dir / 'data.db'}"
|
|
130
|
+
engine = sqla.create_engine(db_url)
|
|
131
|
+
|
|
132
|
+
frame_masks = _build_frame_masks(validated_tracks, tracked_labels)
|
|
133
|
+
if not frame_masks:
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
total_deleted = 0
|
|
137
|
+
|
|
138
|
+
with Session(engine) as session:
|
|
139
|
+
for t, val_cells in frame_masks.items():
|
|
140
|
+
# Query all nodes at this frame (id + pickle blob)
|
|
141
|
+
rows = (
|
|
142
|
+
session.query(NodeDB.id, NodeDB.pickle)
|
|
143
|
+
.where(NodeDB.t == t)
|
|
144
|
+
.all()
|
|
145
|
+
)
|
|
146
|
+
if not rows:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
conflict_ids: list[int] = []
|
|
150
|
+
|
|
151
|
+
for node_id_val, node_blob in rows:
|
|
152
|
+
# Deserialize; NodeDB.pickle is a MaybePickleType that
|
|
153
|
+
# returns a Node object on read.
|
|
154
|
+
if isinstance(node_blob, (bytes, memoryview)):
|
|
155
|
+
try:
|
|
156
|
+
node = pickle.loads(bytes(node_blob))
|
|
157
|
+
except Exception:
|
|
158
|
+
LOG.warning(
|
|
159
|
+
"Could not unpickle node id=%s at t=%s — skipping",
|
|
160
|
+
node_id_val, t,
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
else:
|
|
164
|
+
# SQLAlchemy MaybePickleType already deserialized it
|
|
165
|
+
node = node_blob
|
|
166
|
+
|
|
167
|
+
bbox = np.asarray(node.bbox)
|
|
168
|
+
ndim = len(bbox) // 2
|
|
169
|
+
if ndim == 3:
|
|
170
|
+
# bbox = [z0, y0, x0, z1, y1, x1]
|
|
171
|
+
ny0, nx0, ny1, nx1 = int(bbox[1]), int(bbox[2]), int(bbox[4]), int(bbox[5])
|
|
172
|
+
node_mask = getattr(node, "mask", None)
|
|
173
|
+
if node_mask is not None:
|
|
174
|
+
node_mask = np.asarray(node_mask, dtype=bool)
|
|
175
|
+
if node_mask.shape[0] == 1:
|
|
176
|
+
node_mask_2d = node_mask[0] # (h, w)
|
|
177
|
+
else:
|
|
178
|
+
# Multi-Z: project
|
|
179
|
+
node_mask_2d = node_mask.any(axis=0)
|
|
180
|
+
else:
|
|
181
|
+
node_mask_2d = None
|
|
182
|
+
elif ndim == 2:
|
|
183
|
+
# bbox = [y0, x0, y1, x1]
|
|
184
|
+
ny0, nx0, ny1, nx1 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])
|
|
185
|
+
node_mask = getattr(node, "mask", None)
|
|
186
|
+
if node_mask is not None:
|
|
187
|
+
node_mask_2d = np.asarray(node_mask, dtype=bool)
|
|
188
|
+
if node_mask_2d.ndim == 3 and node_mask_2d.shape[0] == 1:
|
|
189
|
+
node_mask_2d = node_mask_2d[0]
|
|
190
|
+
else:
|
|
191
|
+
node_mask_2d = None
|
|
192
|
+
else:
|
|
193
|
+
LOG.warning(
|
|
194
|
+
"Unexpected bbox ndim=%s for node id=%s — skipping",
|
|
195
|
+
ndim, node_id_val,
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# Check against every validated cell at this frame
|
|
200
|
+
is_conflict = False
|
|
201
|
+
for _cell_id, val_crop, (vy0, vx0, vy1, vx1) in val_cells:
|
|
202
|
+
# Fast bbox intersection test first
|
|
203
|
+
if ny1 <= vy0 or vy1 <= ny0 or nx1 <= vx0 or vx1 <= nx0:
|
|
204
|
+
continue # bboxes don't overlap → no conflict
|
|
205
|
+
|
|
206
|
+
# Compute the overlap region in image coordinates
|
|
207
|
+
oy0, oy1 = max(ny0, vy0), min(ny1, vy1)
|
|
208
|
+
ox0, ox1 = max(nx0, vx0), min(nx1, vx1)
|
|
209
|
+
|
|
210
|
+
if node_mask_2d is None:
|
|
211
|
+
# No mask data — treat bbox overlap as conflict
|
|
212
|
+
is_conflict = True
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
# Crop both masks to the overlap region
|
|
216
|
+
nm_crop = node_mask_2d[
|
|
217
|
+
oy0 - ny0: oy1 - ny0,
|
|
218
|
+
ox0 - nx0: ox1 - nx0,
|
|
219
|
+
]
|
|
220
|
+
vm_crop = val_crop[
|
|
221
|
+
oy0 - vy0: oy1 - vy0,
|
|
222
|
+
ox0 - vx0: ox1 - vx0,
|
|
223
|
+
]
|
|
224
|
+
if nm_crop.shape != vm_crop.shape:
|
|
225
|
+
# Shape mismatch due to off-by-one — treat as conflict
|
|
226
|
+
is_conflict = True
|
|
227
|
+
break
|
|
228
|
+
if np.any(nm_crop & vm_crop):
|
|
229
|
+
is_conflict = True
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
if is_conflict:
|
|
233
|
+
conflict_ids.append(int(node_id_val))
|
|
234
|
+
|
|
235
|
+
if not conflict_ids:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Delete OverlapDB rows referencing any conflict node, then nodes —
|
|
239
|
+
# all in one transaction (the session is autocommit=False by default).
|
|
240
|
+
(
|
|
241
|
+
session.query(OverlapDB)
|
|
242
|
+
.where(
|
|
243
|
+
sqla.or_(
|
|
244
|
+
OverlapDB.node_id.in_(conflict_ids),
|
|
245
|
+
OverlapDB.ancestor_id.in_(conflict_ids),
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
.delete(synchronize_session=False)
|
|
249
|
+
)
|
|
250
|
+
(
|
|
251
|
+
session.query(NodeDB)
|
|
252
|
+
.where(NodeDB.id.in_(conflict_ids))
|
|
253
|
+
.delete(synchronize_session=False)
|
|
254
|
+
)
|
|
255
|
+
total_deleted += len(conflict_ids)
|
|
256
|
+
|
|
257
|
+
session.commit()
|
|
258
|
+
|
|
259
|
+
LOG.info("prune_validated_overlaps: deleted %d NodeDB rows", total_deleted)
|
|
260
|
+
return total_deleted
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# 2. merge_validated_into_export
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def merge_validated_into_export(
|
|
268
|
+
exported_labels: np.ndarray,
|
|
269
|
+
validated_tracks: dict[int, set[int]],
|
|
270
|
+
tracked_labels: np.ndarray,
|
|
271
|
+
) -> tuple[np.ndarray, dict[int, int]]:
|
|
272
|
+
"""Paste validated masks back onto the solver's exported labelmap.
|
|
273
|
+
|
|
274
|
+
Validated ``cell_id`` values are reserved and pasted back with their
|
|
275
|
+
original IDs so the validation store remains stable across resolve runs.
|
|
276
|
+
If the solver independently used a reserved validated ID elsewhere, those
|
|
277
|
+
solver pixels are moved to a fresh ID before the validated mask is pasted.
|
|
278
|
+
Validated pixels overwrite any conflicting content in the exported labelmap.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
exported_labels:
|
|
283
|
+
Labelmap produced by Ultrack export, shape ``(T, Y, X)`` or
|
|
284
|
+
``(T, Z, Y, X)``. Modified **in place** and returned.
|
|
285
|
+
validated_tracks:
|
|
286
|
+
``{cell_id: {frames}}`` from the validation store.
|
|
287
|
+
tracked_labels:
|
|
288
|
+
Current corrected labelmap (same spatial shape as ``exported_labels``).
|
|
289
|
+
The source of ground-truth masks.
|
|
290
|
+
|
|
291
|
+
Returns
|
|
292
|
+
-------
|
|
293
|
+
tuple[np.ndarray, dict[int, int]]
|
|
294
|
+
``(merged_labelmap, id_map)`` where ``id_map`` maps original validated
|
|
295
|
+
IDs to changed output IDs. The normal path preserves validated IDs, so
|
|
296
|
+
``id_map`` is usually empty. The labelmap is the same array as
|
|
297
|
+
``exported_labels``, modified in place.
|
|
298
|
+
"""
|
|
299
|
+
if not validated_tracks:
|
|
300
|
+
return exported_labels, {}
|
|
301
|
+
|
|
302
|
+
id_map: dict[int, int] = {}
|
|
303
|
+
reserved_ids = {int(cell_id) for cell_id, frames in validated_tracks.items() if frames}
|
|
304
|
+
used_fresh_ids = set(int(v) for v in np.unique(exported_labels))
|
|
305
|
+
|
|
306
|
+
def _next_fresh_id() -> int:
|
|
307
|
+
next_id = int(exported_labels.max()) + 1
|
|
308
|
+
while next_id in reserved_ids or next_id in used_fresh_ids:
|
|
309
|
+
next_id += 1
|
|
310
|
+
used_fresh_ids.add(next_id)
|
|
311
|
+
return next_id
|
|
312
|
+
|
|
313
|
+
# Build validated_masks: per cell_id, list of (t, mask) pairs present in tracked_labels
|
|
314
|
+
validated_masks: dict[int, list[tuple[int, np.ndarray]]] = {}
|
|
315
|
+
for cell_id, frames in validated_tracks.items():
|
|
316
|
+
cell_id = int(cell_id)
|
|
317
|
+
if not frames:
|
|
318
|
+
continue
|
|
319
|
+
present: list[tuple[int, np.ndarray]] = []
|
|
320
|
+
for t in sorted(frames):
|
|
321
|
+
t = int(t)
|
|
322
|
+
if t < 0 or t >= tracked_labels.shape[0] or t >= exported_labels.shape[0]:
|
|
323
|
+
continue
|
|
324
|
+
frame_src = tracked_labels[t]
|
|
325
|
+
mask = np.asarray(frame_src == cell_id)
|
|
326
|
+
if mask.any():
|
|
327
|
+
present.append((t, mask))
|
|
328
|
+
if present:
|
|
329
|
+
validated_masks[cell_id] = present
|
|
330
|
+
|
|
331
|
+
# Build solver_track_remap: solver_id -> validated cell_id
|
|
332
|
+
# Inspect each validated frame to find the dominant solver track covering that mask region,
|
|
333
|
+
# then propagate that solver track's ID to the validated cell_id everywhere in the export.
|
|
334
|
+
solver_track_remap: dict[int, int] = {}
|
|
335
|
+
for cell_id in sorted(validated_masks.keys()):
|
|
336
|
+
present_masks = validated_masks[cell_id]
|
|
337
|
+
dominant_solver_id: int | None = None
|
|
338
|
+
best_count = 0
|
|
339
|
+
for t, mask in present_masks:
|
|
340
|
+
if exported_labels.ndim == 4:
|
|
341
|
+
# (T, Z, Y, X): project mask to 3D for indexing
|
|
342
|
+
frame_exp = exported_labels[t]
|
|
343
|
+
if mask.ndim == 2:
|
|
344
|
+
solver_pixels = frame_exp[:, mask]
|
|
345
|
+
elif mask.ndim == 3:
|
|
346
|
+
solver_pixels = frame_exp[mask]
|
|
347
|
+
else:
|
|
348
|
+
continue
|
|
349
|
+
else:
|
|
350
|
+
frame_exp = exported_labels[t]
|
|
351
|
+
if mask.ndim == frame_exp.ndim:
|
|
352
|
+
solver_pixels = frame_exp[mask]
|
|
353
|
+
elif mask.ndim == 2 and frame_exp.ndim == 3:
|
|
354
|
+
solver_pixels = frame_exp[:, mask]
|
|
355
|
+
elif mask.ndim == 3 and frame_exp.ndim == 2:
|
|
356
|
+
solver_pixels = frame_exp[mask.any(axis=0)]
|
|
357
|
+
else:
|
|
358
|
+
continue
|
|
359
|
+
nonzero = solver_pixels[solver_pixels != 0]
|
|
360
|
+
if nonzero.size == 0:
|
|
361
|
+
continue
|
|
362
|
+
unique_ids, counts = np.unique(nonzero, return_counts=True)
|
|
363
|
+
t_best_idx = int(np.argmax(counts))
|
|
364
|
+
t_best_id = int(unique_ids[t_best_idx])
|
|
365
|
+
t_best_count = int(counts[t_best_idx])
|
|
366
|
+
if t_best_count > best_count:
|
|
367
|
+
best_count = t_best_count
|
|
368
|
+
dominant_solver_id = t_best_id
|
|
369
|
+
|
|
370
|
+
if dominant_solver_id is None or dominant_solver_id == 0:
|
|
371
|
+
continue
|
|
372
|
+
if dominant_solver_id == cell_id:
|
|
373
|
+
# Solver already used the correct ID — no remap needed, but still handle
|
|
374
|
+
# collision below where the solver used cell_id for unrelated pixels
|
|
375
|
+
pass
|
|
376
|
+
elif dominant_solver_id in solver_track_remap:
|
|
377
|
+
LOG.warning(
|
|
378
|
+
"Solver track %d claimed by multiple validated cells; "
|
|
379
|
+
"keeping first claim (cell %d), skipping cell %d",
|
|
380
|
+
dominant_solver_id,
|
|
381
|
+
solver_track_remap[dominant_solver_id],
|
|
382
|
+
cell_id,
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
else:
|
|
386
|
+
solver_track_remap[dominant_solver_id] = cell_id
|
|
387
|
+
|
|
388
|
+
# Resolve collisions: if any existing pixels have value == target cell_id
|
|
389
|
+
# but DON'T belong to the source solver track, move them to a fresh ID.
|
|
390
|
+
# This is the same as the original collision handling, now generalized to
|
|
391
|
+
# cover both the remap targets and direct-match cases.
|
|
392
|
+
for solver_id, target_cell_id in list(solver_track_remap.items()):
|
|
393
|
+
if solver_id == target_cell_id:
|
|
394
|
+
continue
|
|
395
|
+
# Pixels currently carrying target_cell_id that are NOT from this solver track
|
|
396
|
+
collision_mask = np.asarray(exported_labels == target_cell_id)
|
|
397
|
+
# Remove pixels belonging to the source solver track from collision set
|
|
398
|
+
src_mask = np.asarray(exported_labels == solver_id)
|
|
399
|
+
# Exclude src_mask locations — those will become target_cell_id after remap anyway
|
|
400
|
+
collision_mask = collision_mask & ~src_mask
|
|
401
|
+
if collision_mask.any():
|
|
402
|
+
exported_labels[collision_mask] = _next_fresh_id()
|
|
403
|
+
|
|
404
|
+
# Handle cells that are not remapped (dominant_solver_id == cell_id or no dominant found)
|
|
405
|
+
# — original collision logic for reserved IDs used by solver for unrelated pixels.
|
|
406
|
+
remapped_sources = set(solver_track_remap.keys())
|
|
407
|
+
remapped_targets = set(solver_track_remap.values())
|
|
408
|
+
for cell_id, present_masks in validated_masks.items():
|
|
409
|
+
if cell_id in remapped_targets and cell_id not in remapped_sources:
|
|
410
|
+
# Already handled in collision resolution above
|
|
411
|
+
continue
|
|
412
|
+
if cell_id in remapped_sources:
|
|
413
|
+
# This cell IS the source solver track being remapped — no separate collision needed
|
|
414
|
+
continue
|
|
415
|
+
# No remap found: solver either didn't have this cell or already used correct ID.
|
|
416
|
+
# Run original collision resolution for the reserved cell_id.
|
|
417
|
+
solver_collision = np.asarray(exported_labels == cell_id)
|
|
418
|
+
for t, mask in present_masks:
|
|
419
|
+
frame_collision = solver_collision[t]
|
|
420
|
+
if mask.ndim == frame_collision.ndim:
|
|
421
|
+
frame_collision[mask] = False
|
|
422
|
+
elif mask.ndim == 2 and frame_collision.ndim == 3:
|
|
423
|
+
frame_collision[:, mask] = False
|
|
424
|
+
elif mask.ndim == 3 and frame_collision.ndim == 2:
|
|
425
|
+
frame_collision[mask.any(axis=0)] = False
|
|
426
|
+
else:
|
|
427
|
+
raise ValueError(
|
|
428
|
+
"Validated mask shape is incompatible with exported labels: "
|
|
429
|
+
f"mask={mask.shape}, exported={frame_collision.shape}"
|
|
430
|
+
)
|
|
431
|
+
if solver_collision.any():
|
|
432
|
+
exported_labels[solver_collision] = _next_fresh_id()
|
|
433
|
+
|
|
434
|
+
# Apply solver track remap: rename solver track IDs to validated cell IDs
|
|
435
|
+
for solver_id, target_cell_id in solver_track_remap.items():
|
|
436
|
+
if solver_id == target_cell_id:
|
|
437
|
+
continue
|
|
438
|
+
exported_labels[exported_labels == solver_id] = target_cell_id
|
|
439
|
+
|
|
440
|
+
# Paste validated masks (overrides geometry at validated frames)
|
|
441
|
+
for cell_id, present_masks in validated_masks.items():
|
|
442
|
+
for t, mask in present_masks:
|
|
443
|
+
frame_out = exported_labels[t]
|
|
444
|
+
if mask.ndim == frame_out.ndim:
|
|
445
|
+
frame_out[mask] = cell_id
|
|
446
|
+
elif mask.ndim == 2 and frame_out.ndim == 3:
|
|
447
|
+
frame_out[:, mask] = cell_id
|
|
448
|
+
elif mask.ndim == 3 and frame_out.ndim == 2:
|
|
449
|
+
frame_out[mask.any(axis=0)] = cell_id
|
|
450
|
+
else:
|
|
451
|
+
raise ValueError(
|
|
452
|
+
"Validated mask shape is incompatible with exported labels: "
|
|
453
|
+
f"mask={mask.shape}, exported={frame_out.shape}"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return exported_labels, id_map
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Constrained greedy retracker using the shared linker similarity score.
|
|
2
|
+
|
|
3
|
+
Each unlocked target cell is matched to a reference cell by the same
|
|
4
|
+
``scoring.similarity_score`` (area ratio + centroid-corrected IoU - distance)
|
|
5
|
+
used by the linker and the Extend tool, gated only by centroid distance.
|
|
6
|
+
|
|
7
|
+
Matching is greedy best-first rather than a global minimum-cost assignment: a
|
|
8
|
+
target always takes its highest-scoring still-free reference. A global
|
|
9
|
+
``linear_sum_assignment`` minimises the *total* cost and will trade a cell's
|
|
10
|
+
obvious best match away to lower another cell's cost, which in practice handed
|
|
11
|
+
cells a far, worse reference; best-first never does that.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy.spatial.distance import cdist
|
|
17
|
+
from skimage.measure import regionprops
|
|
18
|
+
|
|
19
|
+
from itasc.tracking_ultrack import scoring
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _label_props(
|
|
23
|
+
labels: np.ndarray, keep_ids: list[int]
|
|
24
|
+
) -> dict[int, tuple[np.ndarray, float, np.ndarray]]:
|
|
25
|
+
"""Return {label_id: (centroid_yx, area, coords)} for labels in *keep_ids*.
|
|
26
|
+
|
|
27
|
+
Labels outside *keep_ids* (locked / reserved) are masked out before
|
|
28
|
+
``regionprops`` so they cost no centroid, area, or coordinate extraction.
|
|
29
|
+
"""
|
|
30
|
+
if not keep_ids:
|
|
31
|
+
return {}
|
|
32
|
+
keep = np.where(np.isin(labels, keep_ids), labels, 0)
|
|
33
|
+
props: dict[int, tuple[np.ndarray, float, np.ndarray]] = {}
|
|
34
|
+
for region in regionprops(keep):
|
|
35
|
+
props[int(region.label)] = (
|
|
36
|
+
np.asarray(region.centroid, dtype=np.float32),
|
|
37
|
+
float(region.area),
|
|
38
|
+
region.coords.astype(np.float32),
|
|
39
|
+
)
|
|
40
|
+
return props
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def retrack_frame_constrained(
|
|
44
|
+
ref_labels: np.ndarray,
|
|
45
|
+
target_labels: np.ndarray,
|
|
46
|
+
locked_target_ids: set[int],
|
|
47
|
+
max_dist_px: float = 50.0,
|
|
48
|
+
reserved_ids: set[int] | None = None,
|
|
49
|
+
*,
|
|
50
|
+
area_weight: float = 1.0,
|
|
51
|
+
iou_weight: float = 1.0,
|
|
52
|
+
distance_weight: float = 0.05,
|
|
53
|
+
) -> np.ndarray:
|
|
54
|
+
"""Remap target IDs by linker similarity without changing locked targets.
|
|
55
|
+
|
|
56
|
+
Target cells whose ID is in ``locked_target_ids`` keep their existing IDs.
|
|
57
|
+
Those IDs, plus any ``reserved_ids``, are protected from assignment to
|
|
58
|
+
unlocked target cells even if a matching reference cell exists.
|
|
59
|
+
|
|
60
|
+
Unlocked target cells are matched to available reference cells greedily in
|
|
61
|
+
descending ``similarity_score`` order: each target takes its best still-free
|
|
62
|
+
reference. A pair is only eligible when its centroid distance is
|
|
63
|
+
``<= max_dist_px``; area ratio and centroid-corrected IoU contribute as soft
|
|
64
|
+
score terms (no hard gate), matching the Extend tool's behaviour.
|
|
65
|
+
"""
|
|
66
|
+
locked_target_ids = set(locked_target_ids)
|
|
67
|
+
reserved_ids = set(reserved_ids or set())
|
|
68
|
+
blocked_ids = locked_target_ids | reserved_ids
|
|
69
|
+
|
|
70
|
+
result = np.zeros_like(target_labels)
|
|
71
|
+
|
|
72
|
+
tgt_ids = {int(i) for i in np.unique(target_labels) if i != 0}
|
|
73
|
+
for lid in locked_target_ids:
|
|
74
|
+
if lid in tgt_ids:
|
|
75
|
+
result[target_labels == lid] = lid
|
|
76
|
+
|
|
77
|
+
unlocked_tgt_ids = [tid for tid in tgt_ids if tid not in locked_target_ids]
|
|
78
|
+
if not unlocked_tgt_ids:
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
ref_ids = {int(i) for i in np.unique(ref_labels) if i != 0}
|
|
82
|
+
available_ref_ids = [rid for rid in ref_ids if rid not in blocked_ids]
|
|
83
|
+
|
|
84
|
+
max_existing = max(
|
|
85
|
+
int(ref_labels.max()) if ref_labels.size and ref_labels.max() > 0 else 0,
|
|
86
|
+
int(target_labels.max()) if target_labels.size and target_labels.max() > 0 else 0,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _assign_fresh(remap: dict[int, int]) -> dict[int, int]:
|
|
90
|
+
next_id = max_existing + 1
|
|
91
|
+
for tid in unlocked_tgt_ids:
|
|
92
|
+
if tid not in remap:
|
|
93
|
+
while next_id in blocked_ids:
|
|
94
|
+
next_id += 1
|
|
95
|
+
remap[tid] = next_id
|
|
96
|
+
next_id += 1
|
|
97
|
+
return remap
|
|
98
|
+
|
|
99
|
+
if not available_ref_ids:
|
|
100
|
+
remap = _assign_fresh({})
|
|
101
|
+
for tid, new_id in remap.items():
|
|
102
|
+
result[target_labels == tid] = new_id
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
tgt_props = _label_props(target_labels, unlocked_tgt_ids)
|
|
106
|
+
ref_props = _label_props(ref_labels, available_ref_ids)
|
|
107
|
+
|
|
108
|
+
tgt_centroids = np.array([tgt_props[t][0] for t in unlocked_tgt_ids])
|
|
109
|
+
ref_centroids = np.array([ref_props[r][0] for r in available_ref_ids])
|
|
110
|
+
dist = cdist(tgt_centroids, ref_centroids)
|
|
111
|
+
gate = dist <= max_dist_px
|
|
112
|
+
|
|
113
|
+
# Score every distance-eligible (target, reference) pair.
|
|
114
|
+
n_tgt, _ = dist.shape
|
|
115
|
+
scored: list[tuple[float, float, int, int]] = []
|
|
116
|
+
for ti in range(n_tgt):
|
|
117
|
+
t_centroid, t_area, t_coords = tgt_props[unlocked_tgt_ids[ti]]
|
|
118
|
+
for ri in np.nonzero(gate[ti])[0]:
|
|
119
|
+
r_centroid, r_area, r_coords = ref_props[available_ref_ids[ri]]
|
|
120
|
+
area_ratio = min(t_area, r_area) / max(t_area, r_area)
|
|
121
|
+
iou = scoring.centroid_corrected_iou_from_coords(
|
|
122
|
+
r_coords, r_centroid, t_coords, t_centroid
|
|
123
|
+
)
|
|
124
|
+
score = scoring.similarity_score(
|
|
125
|
+
area_ratio=area_ratio,
|
|
126
|
+
centroid_corrected_iou=iou,
|
|
127
|
+
distance=float(dist[ti, ri]),
|
|
128
|
+
area_weight=area_weight,
|
|
129
|
+
iou_weight=iou_weight,
|
|
130
|
+
distance_weight=distance_weight,
|
|
131
|
+
)
|
|
132
|
+
scored.append((score, float(dist[ti, ri]), ti, int(ri)))
|
|
133
|
+
|
|
134
|
+
# Greedy best-first: highest score wins, nearer centroid breaks ties. Each
|
|
135
|
+
# target and reference is consumed once, so a target always lands on its
|
|
136
|
+
# best still-available reference instead of being traded away by a global
|
|
137
|
+
# min-cost solver.
|
|
138
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
139
|
+
|
|
140
|
+
remap: dict[int, int] = {}
|
|
141
|
+
used_refs: set[int] = set()
|
|
142
|
+
for _score, _d, ti, ri in scored:
|
|
143
|
+
tid = unlocked_tgt_ids[ti]
|
|
144
|
+
if tid in remap or ri in used_refs:
|
|
145
|
+
continue
|
|
146
|
+
remap[tid] = available_ref_ids[ri]
|
|
147
|
+
used_refs.add(ri)
|
|
148
|
+
|
|
149
|
+
remap = _assign_fresh(remap)
|
|
150
|
+
for tid, new_id in remap.items():
|
|
151
|
+
result[target_labels == tid] = new_id
|
|
152
|
+
|
|
153
|
+
return result
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Shared similarity scoring used by both the linker and the greedy retracker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _rasterize(coords: np.ndarray, mins: np.ndarray, shape: tuple) -> np.ndarray:
|
|
8
|
+
canvas = np.zeros(shape, dtype=bool)
|
|
9
|
+
idx = np.rint(coords - mins).astype(int)
|
|
10
|
+
valid = np.ones(len(idx), dtype=bool)
|
|
11
|
+
for axis, size in enumerate(shape):
|
|
12
|
+
valid &= (idx[:, axis] >= 0) & (idx[:, axis] < size)
|
|
13
|
+
idx = idx[valid]
|
|
14
|
+
if idx.size:
|
|
15
|
+
canvas[tuple(idx.T)] = True
|
|
16
|
+
return canvas
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def centroid_corrected_iou_from_coords(
|
|
20
|
+
src_coords: np.ndarray,
|
|
21
|
+
src_centroid: np.ndarray,
|
|
22
|
+
target_coords: np.ndarray,
|
|
23
|
+
target_centroid: np.ndarray,
|
|
24
|
+
) -> float:
|
|
25
|
+
"""IoU after shifting target so its centroid matches source's."""
|
|
26
|
+
if len(src_coords) == 0 or len(target_coords) == 0:
|
|
27
|
+
return 0.0
|
|
28
|
+
shifted = target_coords + (src_centroid - target_centroid)
|
|
29
|
+
all_coords = np.vstack([src_coords, shifted])
|
|
30
|
+
mins = np.floor(all_coords.min(axis=0)).astype(int) - 1
|
|
31
|
+
maxs = np.ceil(all_coords.max(axis=0)).astype(int) + 1
|
|
32
|
+
shape = tuple((maxs - mins + 1).tolist())
|
|
33
|
+
if any(dim <= 0 for dim in shape):
|
|
34
|
+
return 0.0
|
|
35
|
+
mins_f = mins.astype(np.float32)
|
|
36
|
+
src_canvas = _rasterize(src_coords, mins_f, shape)
|
|
37
|
+
tgt_canvas = _rasterize(shifted, mins_f, shape)
|
|
38
|
+
union = np.logical_or(src_canvas, tgt_canvas).sum()
|
|
39
|
+
if union == 0:
|
|
40
|
+
return 0.0
|
|
41
|
+
return float(np.logical_and(src_canvas, tgt_canvas).sum() / union)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def centroid_corrected_iou(mask_a: np.ndarray, mask_b: np.ndarray) -> float:
|
|
45
|
+
"""Convenience wrapper for full-frame boolean masks."""
|
|
46
|
+
coords_a = np.argwhere(mask_a).astype(np.float32)
|
|
47
|
+
coords_b = np.argwhere(mask_b).astype(np.float32)
|
|
48
|
+
if len(coords_a) == 0 or len(coords_b) == 0:
|
|
49
|
+
return 0.0
|
|
50
|
+
centroid_a = coords_a.mean(axis=0)
|
|
51
|
+
centroid_b = coords_b.mean(axis=0)
|
|
52
|
+
return centroid_corrected_iou_from_coords(coords_a, centroid_a, coords_b, centroid_b)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def similarity_score(
|
|
56
|
+
*,
|
|
57
|
+
area_ratio: float,
|
|
58
|
+
centroid_corrected_iou: float,
|
|
59
|
+
distance: float,
|
|
60
|
+
area_weight: float,
|
|
61
|
+
iou_weight: float,
|
|
62
|
+
distance_weight: float,
|
|
63
|
+
) -> float:
|
|
64
|
+
"""Additive similarity score (higher = more preferred).
|
|
65
|
+
|
|
66
|
+
Shape terms are positive rewards in [0, 1]; distance is a raw penalty in
|
|
67
|
+
pixels. Result can go negative for far candidates, matching the default
|
|
68
|
+
Ultrack linker's convention so ILP appear/disappear weights stay calibrated.
|
|
69
|
+
"""
|
|
70
|
+
return (
|
|
71
|
+
area_weight * area_ratio
|
|
72
|
+
+ iou_weight * centroid_corrected_iou
|
|
73
|
+
- distance_weight * distance
|
|
74
|
+
)
|