itasc 0.2.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. itasc/__init__.py +16 -0
  2. itasc/cellpose/__init__.py +31 -0
  3. itasc/cellpose/cellpose_runner.py +334 -0
  4. itasc/cellpose/divergence_maps.py +380 -0
  5. itasc/cellpose/flow_following.py +283 -0
  6. itasc/cellpose/joint.py +98 -0
  7. itasc/cellpose/napari.yaml +10 -0
  8. itasc/cellpose/native_masks.py +313 -0
  9. itasc/cellpose/retrack.py +232 -0
  10. itasc/cellpose/shape.py +51 -0
  11. itasc/cellpose/track_laptrack.py +279 -0
  12. itasc/contact_analysis/__init__.py +61 -0
  13. itasc/contact_analysis/_provenance.py +29 -0
  14. itasc/contact_analysis/catalog.py +393 -0
  15. itasc/contact_analysis/config.py +155 -0
  16. itasc/contact_analysis/contacts/__init__.py +24 -0
  17. itasc/contact_analysis/contacts/batch.py +158 -0
  18. itasc/contact_analysis/contacts/build.py +837 -0
  19. itasc/contact_analysis/contacts/contact_labels.py +136 -0
  20. itasc/contact_analysis/contacts/neighborhood.py +146 -0
  21. itasc/contact_analysis/contacts/reader.py +58 -0
  22. itasc/contact_analysis/contacts/signed_contact_length.py +143 -0
  23. itasc/contact_analysis/curation.py +185 -0
  24. itasc/contact_analysis/dynamics/__init__.py +43 -0
  25. itasc/contact_analysis/dynamics/collective.py +206 -0
  26. itasc/contact_analysis/dynamics/kinematics.py +278 -0
  27. itasc/contact_analysis/dynamics/msd.py +165 -0
  28. itasc/contact_analysis/dynamics/store.py +251 -0
  29. itasc/contact_analysis/dynamics/trajectories.py +100 -0
  30. itasc/contact_analysis/frame_interval.py +85 -0
  31. itasc/contact_analysis/napari.yaml +10 -0
  32. itasc/contact_analysis/pipeline.py +313 -0
  33. itasc/contact_analysis/pixel_size.py +166 -0
  34. itasc/contact_analysis/quantifier.py +204 -0
  35. itasc/contact_analysis/quantifiers/__init__.py +7 -0
  36. itasc/contact_analysis/quantifiers/_contacts_derived.py +42 -0
  37. itasc/contact_analysis/quantifiers/cell_density.py +65 -0
  38. itasc/contact_analysis/quantifiers/cell_dynamics.py +81 -0
  39. itasc/contact_analysis/quantifiers/cell_shape.py +32 -0
  40. itasc/contact_analysis/quantifiers/contacts.py +72 -0
  41. itasc/contact_analysis/quantifiers/neighbor_count.py +26 -0
  42. itasc/contact_analysis/quantifiers/nucleus_dynamics.py +77 -0
  43. itasc/contact_analysis/quantifiers/nucleus_shape.py +35 -0
  44. itasc/contact_analysis/quantifiers/shape_relational.py +34 -0
  45. itasc/contact_analysis/quantifiers/signed_contact_length.py +44 -0
  46. itasc/contact_analysis/records.py +129 -0
  47. itasc/contact_analysis/shape/__init__.py +19 -0
  48. itasc/contact_analysis/shape/core.py +141 -0
  49. itasc/contact_analysis/shape/relational.py +134 -0
  50. itasc/contact_analysis/shape_tables.py +387 -0
  51. itasc/core/__init__.py +0 -0
  52. itasc/core/cancellation.py +15 -0
  53. itasc/core/commit.py +61 -0
  54. itasc/core/imageops.py +34 -0
  55. itasc/core/label_store.py +53 -0
  56. itasc/core/lineage.py +113 -0
  57. itasc/core/logging.py +51 -0
  58. itasc/core/paths.py +34 -0
  59. itasc/core/tiff.py +13 -0
  60. itasc/correction/__init__.py +25 -0
  61. itasc/correction/labels.py +983 -0
  62. itasc/napari/__init__.py +7 -0
  63. itasc/napari/_experiments_panel.py +1091 -0
  64. itasc/napari/_flow_layout.py +98 -0
  65. itasc/napari/_icons.py +126 -0
  66. itasc/napari/_napari_compat.py +118 -0
  67. itasc/napari/_paths.py +231 -0
  68. itasc/napari/_preview_cache.py +60 -0
  69. itasc/napari/_spotlight.py +46 -0
  70. itasc/napari/_stage_loader.py +146 -0
  71. itasc/napari/_stage_status.py +110 -0
  72. itasc/napari/_standalone_paths.py +89 -0
  73. itasc/napari/_state.py +146 -0
  74. itasc/napari/_status_rail.py +128 -0
  75. itasc/napari/_track_render.py +114 -0
  76. itasc/napari/_widget_helpers.py +236 -0
  77. itasc/napari/aggregate_widget.py +339 -0
  78. itasc/napari/candidate_gallery_controller.py +285 -0
  79. itasc/napari/cell_workflow_widget.py +1405 -0
  80. itasc/napari/cellpose_segment_track_widget.py +1253 -0
  81. itasc/napari/cellpose_widget.py +1034 -0
  82. itasc/napari/contact_analysis/__init__.py +6 -0
  83. itasc/napari/contact_analysis/curation_controller.py +94 -0
  84. itasc/napari/contact_analysis/plugins/__init__.py +102 -0
  85. itasc/napari/contact_analysis/plugins/catalog_summary.py +56 -0
  86. itasc/napari/contact_analysis/plugins/curation.py +247 -0
  87. itasc/napari/contact_analysis/plugins/visualize_contacts.py +64 -0
  88. itasc/napari/contact_analysis_widget.py +951 -0
  89. itasc/napari/contact_visualization.py +895 -0
  90. itasc/napari/correction/__init__.py +8 -0
  91. itasc/napari/correction/_correction_anchor.py +54 -0
  92. itasc/napari/correction/_correction_candidate_panel.py +259 -0
  93. itasc/napari/correction/_correction_candidates.py +169 -0
  94. itasc/napari/correction/_correction_centroids.py +132 -0
  95. itasc/napari/correction/_correction_commit.py +71 -0
  96. itasc/napari/correction/_correction_events.py +39 -0
  97. itasc/napari/correction/_correction_keymap.py +80 -0
  98. itasc/napari/correction/_correction_layer_lifecycle.py +137 -0
  99. itasc/napari/correction/_correction_layer_loader.py +86 -0
  100. itasc/napari/correction/_correction_navigation.py +97 -0
  101. itasc/napari/correction/_correction_paint.py +46 -0
  102. itasc/napari/correction/_correction_playback.py +68 -0
  103. itasc/napari/correction/_correction_protection.py +44 -0
  104. itasc/napari/correction/_correction_takeover.py +64 -0
  105. itasc/napari/correction/_correction_track_accordion.py +566 -0
  106. itasc/napari/correction/_correction_track_path.py +388 -0
  107. itasc/napari/correction/_correction_ui.py +367 -0
  108. itasc/napari/correction/_correction_ui_nucleus.py +393 -0
  109. itasc/napari/correction/_correction_utils.py +204 -0
  110. itasc/napari/correction/_correction_validation.py +83 -0
  111. itasc/napari/correction/cell_correction_widget.py +1190 -0
  112. itasc/napari/correction/correction_widget.py +1363 -0
  113. itasc/napari/correction/nucleus_correction_widget.py +2185 -0
  114. itasc/napari/divergence_maps_widget.py +795 -0
  115. itasc/napari/lineage_canvas_controller.py +351 -0
  116. itasc/napari/main_widget.py +1184 -0
  117. itasc/napari/nucleus_atom_extraction_widget.py +648 -0
  118. itasc/napari/nucleus_db_browser_widget.py +1442 -0
  119. itasc/napari/nucleus_pipeline_widget.py +579 -0
  120. itasc/napari/nucleus_segmentation_inputs_widget.py +21 -0
  121. itasc/napari/nucleus_tracking_inputs_widget.py +248 -0
  122. itasc/napari/nucleus_workflow_widget.py +963 -0
  123. itasc/napari/track_path_controller.py +387 -0
  124. itasc/napari/ui_gate.py +266 -0
  125. itasc/napari/ui_style.py +503 -0
  126. itasc/napari/validated_overlay_controller.py +254 -0
  127. itasc/napari/widgets.py +616 -0
  128. itasc/napari.yaml +45 -0
  129. itasc/segmentation/__init__.py +60 -0
  130. itasc/segmentation/cell_divergence_segmentation.py +433 -0
  131. itasc/segmentation/cell_label_icm.py +637 -0
  132. itasc/segmentation/contour_filtering.py +128 -0
  133. itasc/segmentation/lineage.py +18 -0
  134. itasc/segmentation/nucleus_segmentation.py +35 -0
  135. itasc/tracking_ultrack/__init__.py +12 -0
  136. itasc/tracking_ultrack/_node_geometry.py +191 -0
  137. itasc/tracking_ultrack/atoms.py +470 -0
  138. itasc/tracking_ultrack/config.py +76 -0
  139. itasc/tracking_ultrack/corrections.py +1213 -0
  140. itasc/tracking_ultrack/db_build.py +492 -0
  141. itasc/tracking_ultrack/db_query.py +952 -0
  142. itasc/tracking_ultrack/export.py +133 -0
  143. itasc/tracking_ultrack/extend.py +243 -0
  144. itasc/tracking_ultrack/ingest.py +83 -0
  145. itasc/tracking_ultrack/linking.py +286 -0
  146. itasc/tracking_ultrack/multi_threshold.py +46 -0
  147. itasc/tracking_ultrack/napari.yaml +10 -0
  148. itasc/tracking_ultrack/reseed.py +456 -0
  149. itasc/tracking_ultrack/retracker.py +153 -0
  150. itasc/tracking_ultrack/scoring.py +74 -0
  151. itasc/tracking_ultrack/seed_prior.py +175 -0
  152. itasc/tracking_ultrack/solve.py +52 -0
  153. itasc/tracking_ultrack/swap_candidate.py +171 -0
  154. itasc/tracking_ultrack/track_quality.py +142 -0
  155. itasc/tracking_ultrack/validation_nodes.py +315 -0
  156. itasc/tracking_ultrack/validation_state.py +430 -0
  157. itasc-0.2.0.dev0.data/data/itasc/napari.yaml +45 -0
  158. itasc-0.2.0.dev0.dist-info/METADATA +186 -0
  159. itasc-0.2.0.dev0.dist-info/RECORD +162 -0
  160. itasc-0.2.0.dev0.dist-info/WHEEL +4 -0
  161. itasc-0.2.0.dev0.dist-info/entry_points.txt +2 -0
  162. itasc-0.2.0.dev0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,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
+ )