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,837 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from collections.abc import Callable
8
+ from collections.abc import Iterable
9
+
10
+ import h5py
11
+ import numpy as np
12
+ import tifffile
13
+ from skimage.measure import regionprops
14
+
15
+ from itasc.contact_analysis._provenance import (
16
+ itasc_version as _itasc_version,
17
+ report_progress as _report_progress,
18
+ )
19
+
20
+
21
+ NULL_INT = -1
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class EdgeRecord:
26
+ frame: int
27
+ pair: tuple[int, int]
28
+ kind: str
29
+ edge_label: str
30
+ length: float
31
+ midpoint_y: float
32
+ midpoint_x: float
33
+ coordinates: np.ndarray
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class T1Record:
38
+ t1_event_id: int
39
+ frame: int
40
+ edge_id: int
41
+ losing_pair: tuple[int, int]
42
+ gaining_pair: tuple[int, int]
43
+ location_y: float
44
+ location_x: float
45
+
46
+ @property
47
+ def losing_cell_a(self) -> int:
48
+ return self.losing_pair[0]
49
+
50
+ @property
51
+ def losing_cell_b(self) -> int:
52
+ return self.losing_pair[1]
53
+
54
+ @property
55
+ def gaining_cell_a(self) -> int:
56
+ return self.gaining_pair[0]
57
+
58
+ @property
59
+ def gaining_cell_b(self) -> int:
60
+ return self.gaining_pair[1]
61
+
62
+
63
+ def build_contacts(
64
+ *,
65
+ cell_labels_path: str | Path,
66
+ output_path: str | Path,
67
+ nucleus_labels_path: str | Path | None = None,
68
+ source_path: str | Path | None = None,
69
+ edge_extraction_params: dict | None = None,
70
+ progress_cb: Callable[[int, int, str], None] | None = None,
71
+ ) -> Path:
72
+ """Build the canonical contact-analysis HDF5 file from label stacks.
73
+
74
+ Position-agnostic core: takes an explicit cell-label TIFF (2D+t) and an
75
+ optional nucleus-label TIFF, writing the HDF5 to ``output_path``.
76
+ ``source_path`` is recorded as provenance only. When ``nucleus_labels_path``
77
+ is given the ``cell_id == nucleus_id`` invariant is enforced; otherwise
78
+ nucleus validation is skipped.
79
+ """
80
+ total = 6
81
+ cell_labels_path = Path(cell_labels_path)
82
+ output_path = Path(output_path)
83
+ nucleus_labels_path = Path(nucleus_labels_path) if nucleus_labels_path else None
84
+ params = dict(edge_extraction_params or {})
85
+
86
+ cell_stack = _read_label_stack(cell_labels_path)
87
+ nucleus_stack = (
88
+ _read_label_stack(nucleus_labels_path) if nucleus_labels_path is not None else None
89
+ )
90
+ _report_progress(progress_cb, 1, total, "read labels")
91
+ if nucleus_stack is not None:
92
+ _validate_cell_nucleus_identity(cell_stack, nucleus_stack)
93
+ _report_progress(progress_cb, 2, total, "validate IDs")
94
+
95
+ cell_columns = _extract_cell_columns(cell_stack)
96
+ _report_progress(progress_cb, 3, total, "extract cells")
97
+ edge_records = _extract_edges(cell_stack)
98
+ _report_progress(progress_cb, 4, total, "extract edges")
99
+ assignments, t1_events = _assign_ids_to_records(edge_records)
100
+ _report_progress(progress_cb, 5, total, "assign edge IDs/T1")
101
+ edge_columns, coord_y, coord_x = _extract_edge_columns(edge_records, assignments, t1_events)
102
+
103
+ output_path.parent.mkdir(parents=True, exist_ok=True)
104
+ with h5py.File(output_path, "w") as h5:
105
+ provenance = h5.create_group("provenance")
106
+ provenance.attrs["source_position_path"] = str(source_path) if source_path else ""
107
+ provenance.attrs["cell_tracked_labels_path"] = str(cell_labels_path)
108
+ provenance.attrs["nucleus_tracked_labels_path"] = (
109
+ str(nucleus_labels_path) if nucleus_labels_path is not None else ""
110
+ )
111
+ provenance.attrs["edge_extraction_params_json"] = json.dumps(
112
+ params, sort_keys=True, separators=(",", ":")
113
+ )
114
+ provenance.attrs["created_at"] = datetime.now(timezone.utc).isoformat()
115
+ provenance.attrs["itasc_version"] = _itasc_version()
116
+
117
+ _write_column_group(h5.create_group("cells/table", track_order=True), cell_columns)
118
+ h5.create_group("cells/measurements")
119
+
120
+ edges_group = h5.create_group("edges/table", track_order=True)
121
+ _write_column_group(edges_group, edge_columns)
122
+ edges_group["t1_event_id"].attrs["null_sentinel"] = NULL_INT
123
+
124
+ coords = h5.create_group("edges/coordinates", track_order=True)
125
+ coords.create_dataset("y", data=coord_y)
126
+ coords.create_dataset("x", data=coord_x)
127
+ h5.create_group("edges/measurements")
128
+
129
+ _write_t1_table(h5.create_group("t1_events/table", track_order=True), t1_events)
130
+ _report_progress(progress_cb, 6, total, "write HDF5")
131
+
132
+ return output_path
133
+
134
+
135
+ def ensure_contacts(
136
+ *,
137
+ cell_labels_path: str | Path,
138
+ output_path: str | Path,
139
+ nucleus_labels_path: str | Path | None = None,
140
+ overwrite: bool = False,
141
+ edge_extraction_params: dict | None = None,
142
+ progress_cb: Callable[[int, int, str], None] | None = None,
143
+ ) -> tuple[Path, bool]:
144
+ """Build the contact-analysis HDF5 only when it is missing (or ``overwrite``).
145
+
146
+ The ``.h5`` is a pure derived artifact of the label inputs, so callers that
147
+ only need it present (the visualizer, the batch runner) should go through
148
+ this guard rather than rebuilding unconditionally. Returns
149
+ ``(output_path, built)`` where ``built`` is ``False`` when an existing file
150
+ was reused.
151
+ """
152
+ output_path = Path(output_path)
153
+ if output_path.exists() and not overwrite:
154
+ return output_path, False
155
+ cell_labels_path = Path(cell_labels_path)
156
+ build_contacts(
157
+ cell_labels_path=cell_labels_path,
158
+ output_path=output_path,
159
+ nucleus_labels_path=nucleus_labels_path,
160
+ source_path=cell_labels_path.parent,
161
+ edge_extraction_params=edge_extraction_params,
162
+ progress_cb=progress_cb,
163
+ )
164
+ return output_path, True
165
+
166
+
167
+ def build_position_contacts(
168
+ position_path: str | Path,
169
+ output_path: str | Path,
170
+ *,
171
+ cell_tracked_labels_path: str | Path | None = None,
172
+ nucleus_tracked_labels_path: str | Path | None = None,
173
+ edge_extraction_params: dict | None = None,
174
+ progress_cb: Callable[[int, int, str], None] | None = None,
175
+ ) -> Path:
176
+ """Staged-layout wrapper around :func:`build_contacts`.
177
+
178
+ Resolves the *committed* ``<position>/cell_labels.tif`` and
179
+ ``<position>/nucleus_labels.tif`` labels (what finalize/"commit" writes to the
180
+ position base folder) when explicit paths are not supplied, then delegates to
181
+ the position-agnostic core. These are the downstream-stable inputs, not the
182
+ pre-commit working ``3_cell`` / ``2_nucleus`` tracked labels. Retained for the
183
+ orchestrator and out-of-repo consumers that pass a position directory.
184
+ """
185
+ position_path = Path(position_path)
186
+ cell_labels_path = (
187
+ Path(cell_tracked_labels_path)
188
+ if cell_tracked_labels_path
189
+ else position_path / "cell_labels.tif"
190
+ )
191
+ nucleus_labels_path = (
192
+ Path(nucleus_tracked_labels_path)
193
+ if nucleus_tracked_labels_path
194
+ else position_path / "nucleus_labels.tif"
195
+ )
196
+ return build_contacts(
197
+ cell_labels_path=cell_labels_path,
198
+ output_path=output_path,
199
+ nucleus_labels_path=nucleus_labels_path,
200
+ source_path=position_path,
201
+ edge_extraction_params=edge_extraction_params,
202
+ progress_cb=progress_cb,
203
+ )
204
+
205
+
206
+ def assign_persistent_edge_ids(
207
+ frame_edges: list[Iterable[tuple[int, int]]],
208
+ ) -> tuple[list[dict[tuple[int, int], int]], list[T1Record]]:
209
+ """Assign deterministic edge IDs, linking losing/gaining pairs through T1s."""
210
+ assignments: list[dict[tuple[int, int], int]] = []
211
+ pair_to_id: dict[tuple[int, int], int] = {}
212
+ events: list[T1Record] = []
213
+ next_edge_id = 1
214
+
215
+ normalized_frames = [
216
+ {tuple(sorted((int(a), int(b)))) for a, b in pairs}
217
+ for pairs in frame_edges
218
+ ]
219
+
220
+ for frame_idx, pairs in enumerate(normalized_frames):
221
+ if frame_idx > 0:
222
+ prev = normalized_frames[frame_idx - 1]
223
+ for losing, gaining in _detect_t1_pairs(prev, pairs):
224
+ edge_id = pair_to_id.get(losing)
225
+ if edge_id is None:
226
+ edge_id = next_edge_id
227
+ next_edge_id += 1
228
+ pair_to_id[losing] = edge_id
229
+ pair_to_id[gaining] = edge_id
230
+ events.append(
231
+ T1Record(
232
+ t1_event_id=len(events) + 1,
233
+ frame=frame_idx - 1,
234
+ edge_id=edge_id,
235
+ losing_pair=losing,
236
+ gaining_pair=gaining,
237
+ location_y=np.nan,
238
+ location_x=np.nan,
239
+ )
240
+ )
241
+
242
+ frame_assignment: dict[tuple[int, int], int] = {}
243
+ for pair in sorted(pairs):
244
+ if pair not in pair_to_id:
245
+ pair_to_id[pair] = next_edge_id
246
+ next_edge_id += 1
247
+ frame_assignment[pair] = pair_to_id[pair]
248
+ assignments.append(frame_assignment)
249
+
250
+ return assignments, events
251
+
252
+
253
+ def _read_label_stack(path: Path) -> np.ndarray:
254
+ if not path.exists():
255
+ raise FileNotFoundError(path)
256
+ arr = np.asarray(tifffile.imread(path))
257
+ if arr.ndim == 2:
258
+ arr = arr[np.newaxis, ...]
259
+ if arr.ndim != 3:
260
+ raise ValueError(f"Expected a 2-D or 3-D tracked label TIFF at {path}, got shape {arr.shape}")
261
+ return arr.astype(np.int64, copy=False)
262
+
263
+
264
+ def _validate_cell_nucleus_identity(cell_stack: np.ndarray, nucleus_stack: np.ndarray) -> None:
265
+ if cell_stack.shape != nucleus_stack.shape:
266
+ raise ValueError(
267
+ "cell_id == nucleus_id invariant requires matching cell and nucleus stack shapes"
268
+ )
269
+ for frame_idx in range(cell_stack.shape[0]):
270
+ cell_ids = set(np.unique(cell_stack[frame_idx]).astype(int))
271
+ nucleus_ids = set(np.unique(nucleus_stack[frame_idx]).astype(int))
272
+ cell_ids.discard(0)
273
+ nucleus_ids.discard(0)
274
+ if cell_ids != nucleus_ids:
275
+ cell_only = sorted(int(label) for label in cell_ids - nucleus_ids)
276
+ nucleus_only = sorted(int(label) for label in nucleus_ids - cell_ids)
277
+ raise ValueError(
278
+ "cell_id == nucleus_id invariant failed for frame "
279
+ f"{frame_idx}: cell labels present only in cell stack: {cell_only}; "
280
+ f"nucleus labels present only in nucleus stack: {nucleus_only}"
281
+ )
282
+
283
+
284
+ def _extract_cell_columns(label_stack: np.ndarray) -> dict[str, np.ndarray]:
285
+ rows: list[dict[str, int | float | str]] = []
286
+ for frame_idx, frame in enumerate(label_stack):
287
+ for prop in sorted(regionprops(frame), key=lambda item: item.label):
288
+ min_y, min_x, max_y, max_x = prop.bbox
289
+ rows.append(
290
+ {
291
+ "frame": frame_idx,
292
+ "cell_id": int(prop.label),
293
+ "area": float(prop.area),
294
+ "centroid_y": float(prop.centroid[0]),
295
+ "centroid_x": float(prop.centroid[1]),
296
+ "perimeter": float(prop.perimeter),
297
+ "bbox_min_y": int(min_y),
298
+ "bbox_min_x": int(min_x),
299
+ "bbox_max_y": int(max_y),
300
+ "bbox_max_x": int(max_x),
301
+ }
302
+ )
303
+ return _columns_from_rows(
304
+ rows,
305
+ [
306
+ "frame",
307
+ "cell_id",
308
+ "area",
309
+ "centroid_y",
310
+ "centroid_x",
311
+ "perimeter",
312
+ "bbox_min_y",
313
+ "bbox_min_x",
314
+ "bbox_max_y",
315
+ "bbox_max_x",
316
+ ],
317
+ )
318
+
319
+
320
+ def _extract_edges(label_stack: np.ndarray) -> list[EdgeRecord]:
321
+ records: list[EdgeRecord] = []
322
+ for frame_idx, frame in enumerate(label_stack):
323
+ records.extend(_extract_frame_cell_edges(frame, frame_idx))
324
+ records.extend(_extract_frame_border_edges(frame, frame_idx))
325
+ return sorted(records, key=lambda row: (row.frame, row.kind != "cell_cell", row.pair))
326
+
327
+
328
+ def _extract_frame_cell_edges(frame: np.ndarray, frame_idx: int) -> list[EdgeRecord]:
329
+ points_by_pair: dict[tuple[int, int], list[tuple[float, float]]] = {}
330
+
331
+ left = frame[:, :-1]
332
+ right = frame[:, 1:]
333
+ ys, xs = np.where((left != right) & (left > 0) & (right > 0))
334
+ for y, x in zip(ys, xs):
335
+ pair = tuple(sorted((int(left[y, x]), int(right[y, x]))))
336
+ points_by_pair.setdefault(pair, []).append((float(y), float(x) + 0.5))
337
+
338
+ top = frame[:-1, :]
339
+ bottom = frame[1:, :]
340
+ ys, xs = np.where((top != bottom) & (top > 0) & (bottom > 0))
341
+ for y, x in zip(ys, xs):
342
+ pair = tuple(sorted((int(top[y, x]), int(bottom[y, x]))))
343
+ points_by_pair.setdefault(pair, []).append((float(y) + 0.5, float(x)))
344
+
345
+ rows = []
346
+ for pair in sorted(points_by_pair):
347
+ for segment in _coordinate_segments(np.asarray(points_by_pair[pair], dtype=float)):
348
+ coords = _order_coordinates(segment)
349
+ rows.append(_edge_record(frame_idx, pair, "cell_cell", "", coords))
350
+ return rows
351
+
352
+
353
+ def _extract_frame_border_edges(frame: np.ndarray, frame_idx: int) -> list[EdgeRecord]:
354
+ points_by_cell = _border_points_by_cell(frame)
355
+
356
+ rows = []
357
+ for cell_id in sorted(points_by_cell):
358
+ for segment in _coordinate_segments(np.asarray(points_by_cell[cell_id], dtype=float)):
359
+ coords = _order_coordinates(segment)
360
+ rows.append(_edge_record(frame_idx, (cell_id, 0), "border", "border", coords))
361
+ return rows
362
+
363
+
364
+ def _border_points_by_cell(frame: np.ndarray) -> dict[int, np.ndarray]:
365
+ """Per-cell half-pixel boundary points against the image edge and background.
366
+
367
+ Vectorised replacement for the per-pixel double loop: for each of the four
368
+ image borders and the four background-adjacency directions, the relevant
369
+ foreground pixels and their half-pixel offset points are collected with
370
+ array ops, then grouped by cell id. Downstream consumers deduplicate, so
371
+ point order within a cell is irrelevant.
372
+ """
373
+ h, w = frame.shape
374
+ fg = frame > 0
375
+
376
+ cids: list[np.ndarray] = []
377
+ ys: list[np.ndarray] = []
378
+ xs: list[np.ndarray] = []
379
+
380
+ def _add(mask: np.ndarray, dy: float, dx: float) -> None:
381
+ yy, xx = np.nonzero(mask)
382
+ if yy.size == 0:
383
+ return
384
+ cids.append(frame[yy, xx])
385
+ ys.append(yy.astype(float) + dy)
386
+ xs.append(xx.astype(float) + dx)
387
+
388
+ # Image-border pixels (offset half a pixel outward).
389
+ if h > 0:
390
+ top = np.zeros_like(fg)
391
+ top[0] = fg[0]
392
+ _add(top, -0.5, 0.0)
393
+ bottom = np.zeros_like(fg)
394
+ bottom[h - 1] = fg[h - 1]
395
+ _add(bottom, 0.5, 0.0)
396
+ if w > 0:
397
+ left = np.zeros_like(fg)
398
+ left[:, 0] = fg[:, 0]
399
+ _add(left, 0.0, -0.5)
400
+ right = np.zeros_like(fg)
401
+ right[:, w - 1] = fg[:, w - 1]
402
+ _add(right, 0.0, 0.5)
403
+
404
+ # Foreground pixels with a background (0) 4-neighbour.
405
+ up = np.zeros_like(fg)
406
+ up[1:] = fg[1:] & (frame[:-1] == 0)
407
+ _add(up, -0.5, 0.0)
408
+ down = np.zeros_like(fg)
409
+ down[:-1] = fg[:-1] & (frame[1:] == 0)
410
+ _add(down, 0.5, 0.0)
411
+ left_bg = np.zeros_like(fg)
412
+ left_bg[:, 1:] = fg[:, 1:] & (frame[:, :-1] == 0)
413
+ _add(left_bg, 0.0, -0.5)
414
+ right_bg = np.zeros_like(fg)
415
+ right_bg[:, :-1] = fg[:, :-1] & (frame[:, 1:] == 0)
416
+ _add(right_bg, 0.0, 0.5)
417
+
418
+ if not cids:
419
+ return {}
420
+
421
+ cid_all = np.concatenate(cids)
422
+ pts = np.column_stack([np.concatenate(ys), np.concatenate(xs)])
423
+ order = np.argsort(cid_all, kind="stable")
424
+ cid_sorted = cid_all[order]
425
+ pts = pts[order]
426
+ uniq, starts = np.unique(cid_sorted, return_index=True)
427
+ bounds = np.append(starts, cid_sorted.size)
428
+ return {
429
+ int(cell_id): pts[starts[i]:bounds[i + 1]]
430
+ for i, cell_id in enumerate(uniq)
431
+ }
432
+
433
+
434
+ def _edge_record(
435
+ frame_idx: int,
436
+ pair: tuple[int, int],
437
+ kind: str,
438
+ edge_label: str,
439
+ coords: np.ndarray,
440
+ ) -> EdgeRecord:
441
+ midpoint = coords[len(coords) // 2] if len(coords) else np.array([np.nan, np.nan])
442
+ return EdgeRecord(
443
+ frame=frame_idx,
444
+ pair=pair,
445
+ kind=kind,
446
+ edge_label=edge_label,
447
+ length=_path_length(coords),
448
+ midpoint_y=float(midpoint[0]),
449
+ midpoint_x=float(midpoint[1]),
450
+ coordinates=coords,
451
+ )
452
+
453
+
454
+ def _assign_ids_to_records(
455
+ records: list[EdgeRecord],
456
+ ) -> tuple[list[dict[tuple[int, int], int]], list[T1Record]]:
457
+ max_frame = max((record.frame for record in records), default=-1)
458
+ # Group records by frame in a single pass instead of rescanning the full
459
+ # record list per frame (and, below, per frame*border-pair).
460
+ frame_cell_edges: list[set[tuple[int, int]]] = [set() for _ in range(max_frame + 1)]
461
+ frame_border_pairs: list[set[tuple[int, int]]] = [set() for _ in range(max_frame + 1)]
462
+ for record in records:
463
+ if record.kind == "cell_cell":
464
+ frame_cell_edges[record.frame].add(record.pair)
465
+ elif record.kind == "border":
466
+ frame_border_pairs[record.frame].add(record.pair)
467
+
468
+ assignments, events = assign_persistent_edge_ids(frame_cell_edges)
469
+ border_next_id = (
470
+ max((edge_id for frame in assignments for edge_id in frame.values()), default=0) + 1
471
+ )
472
+ border_ids: dict[tuple[int, int], int] = {}
473
+ for record in records:
474
+ if record.kind != "border" or record.pair in border_ids:
475
+ continue
476
+ border_ids[record.pair] = border_next_id
477
+ border_next_id += 1
478
+ merged = []
479
+ for frame_idx, frame_assignment in enumerate(assignments):
480
+ full = dict(frame_assignment)
481
+ for pair in frame_border_pairs[frame_idx]:
482
+ full[pair] = border_ids[pair]
483
+ merged.append(full)
484
+
485
+ records_by_key = {(record.frame, record.pair): record for record in records}
486
+ resolved_events = []
487
+ for event in events:
488
+ rec = records_by_key.get((event.frame, event.losing_pair))
489
+ if rec is None:
490
+ resolved_events.append(event)
491
+ continue
492
+ resolved_events.append(
493
+ T1Record(
494
+ t1_event_id=event.t1_event_id,
495
+ frame=event.frame,
496
+ edge_id=event.edge_id,
497
+ losing_pair=event.losing_pair,
498
+ gaining_pair=event.gaining_pair,
499
+ location_y=rec.midpoint_y,
500
+ location_x=rec.midpoint_x,
501
+ )
502
+ )
503
+ return merged, resolved_events
504
+
505
+
506
+ def _extract_edge_columns(
507
+ records: list[EdgeRecord],
508
+ assignments: list[dict[tuple[int, int], int]],
509
+ t1_events: list[T1Record],
510
+ ) -> tuple[dict[str, np.ndarray], np.ndarray, np.ndarray]:
511
+ rows = []
512
+ coord_y: list[float] = []
513
+ coord_x: list[float] = []
514
+ t1_by_key = {(event.frame, event.losing_pair): event for event in t1_events}
515
+
516
+ for record in records:
517
+ offset = len(coord_y)
518
+ coords = record.coordinates
519
+ coord_y.extend(coords[:, 0].astype(float).tolist())
520
+ coord_x.extend(coords[:, 1].astype(float).tolist())
521
+ event = t1_by_key.get((record.frame, record.pair))
522
+ cell_a, cell_b = record.pair
523
+ rows.append(
524
+ {
525
+ "frame": record.frame,
526
+ "edge_id": assignments[record.frame][record.pair],
527
+ "cell_a": int(cell_a),
528
+ "cell_b": int(cell_b),
529
+ "kind": record.kind,
530
+ "edge_label": record.edge_label,
531
+ "is_t1_frame": event is not None,
532
+ "t1_event_id": event.t1_event_id if event is not None else NULL_INT,
533
+ "length": record.length,
534
+ "midpoint_y": record.midpoint_y,
535
+ "midpoint_x": record.midpoint_x,
536
+ "coord_offset": offset,
537
+ "coord_count": len(coords),
538
+ }
539
+ )
540
+ return (
541
+ _columns_from_rows(
542
+ rows,
543
+ [
544
+ "frame",
545
+ "edge_id",
546
+ "cell_a",
547
+ "cell_b",
548
+ "kind",
549
+ "edge_label",
550
+ "is_t1_frame",
551
+ "t1_event_id",
552
+ "length",
553
+ "midpoint_y",
554
+ "midpoint_x",
555
+ "coord_offset",
556
+ "coord_count",
557
+ ],
558
+ ),
559
+ np.asarray(coord_y, dtype=float),
560
+ np.asarray(coord_x, dtype=float),
561
+ )
562
+
563
+
564
+ def _detect_t1_pairs(
565
+ prev_edges: set[tuple[int, int]],
566
+ next_edges: set[tuple[int, int]],
567
+ ) -> list[tuple[tuple[int, int], tuple[int, int]]]:
568
+ removed = sorted(prev_edges - next_edges)
569
+ added = sorted(next_edges - prev_edges)
570
+ events = []
571
+ used_removed = set()
572
+ used_added = set()
573
+ for lost in removed:
574
+ for gained in added:
575
+ if lost in used_removed or gained in used_added:
576
+ continue
577
+ if _is_valid_t1(lost, gained, prev_edges, next_edges):
578
+ events.append((lost, gained))
579
+ used_removed.add(lost)
580
+ used_added.add(gained)
581
+ return events
582
+
583
+
584
+ def _is_valid_t1(
585
+ lost: tuple[int, int],
586
+ gained: tuple[int, int],
587
+ prev_edges: set[tuple[int, int]],
588
+ next_edges: set[tuple[int, int]],
589
+ ) -> bool:
590
+ all_cells = set(lost) | set(gained)
591
+ if len(all_cells) != 4:
592
+ return False
593
+ lost_cells = tuple(sorted(lost))
594
+ gained_cells = tuple(sorted(gained))
595
+ connectors = [
596
+ tuple(sorted((lost_cells[0], gained_cells[0]))),
597
+ tuple(sorted((lost_cells[0], gained_cells[1]))),
598
+ tuple(sorted((lost_cells[1], gained_cells[0]))),
599
+ tuple(sorted((lost_cells[1], gained_cells[1]))),
600
+ ]
601
+ return all(edge in prev_edges and edge in next_edges for edge in connectors)
602
+
603
+
604
+ def _order_coordinates(coords: np.ndarray) -> np.ndarray:
605
+ if len(coords) <= 2:
606
+ return coords[np.lexsort((coords[:, 1], coords[:, 0]))]
607
+
608
+ coords = np.unique(np.asarray(coords, dtype=float), axis=0)
609
+ neighbors = _coordinate_neighbors(coords)
610
+ endpoints = [idx for idx, adjacent in enumerate(neighbors) if len(adjacent) == 1]
611
+ start = min(endpoints or range(len(coords)), key=lambda idx: (coords[idx, 0], coords[idx, 1]))
612
+
613
+ ordered_indices = [start]
614
+ visited = {start}
615
+ prev_idx: int | None = None
616
+ current_idx = start
617
+ while len(visited) < len(coords):
618
+ candidates = [idx for idx in neighbors[current_idx] if idx not in visited]
619
+ if candidates:
620
+ next_idx = min(
621
+ candidates,
622
+ key=lambda idx: (
623
+ _turn_cost(coords[prev_idx], coords[current_idx], coords[idx])
624
+ if prev_idx is not None
625
+ else 0.0,
626
+ coords[idx, 0],
627
+ coords[idx, 1],
628
+ ),
629
+ )
630
+ else:
631
+ remaining = [idx for idx in range(len(coords)) if idx not in visited]
632
+ next_idx = min(
633
+ remaining,
634
+ key=lambda idx: (
635
+ float(np.linalg.norm(coords[current_idx] - coords[idx])),
636
+ coords[idx, 0],
637
+ coords[idx, 1],
638
+ ),
639
+ )
640
+ ordered_indices.append(next_idx)
641
+ visited.add(next_idx)
642
+ prev_idx = current_idx
643
+ current_idx = next_idx
644
+ return coords[np.asarray(ordered_indices, dtype=np.intp)]
645
+
646
+
647
+ def _coordinate_neighbors(coords: np.ndarray) -> list[list[int]]:
648
+ scaled = np.rint(coords * 2.0).astype(np.int64)
649
+ point_to_idx = {tuple(point): idx for idx, point in enumerate(scaled)}
650
+ neighbor_offsets = [
651
+ (dy, dx)
652
+ for dy in range(-2, 3)
653
+ for dx in range(-2, 3)
654
+ if (dy or dx) and (dy * dy + dx * dx) <= 4
655
+ ]
656
+ neighbors: list[list[int]] = []
657
+ for y, x in scaled:
658
+ adjacent = []
659
+ for dy, dx in neighbor_offsets:
660
+ idx = point_to_idx.get((int(y + dy), int(x + dx)))
661
+ if idx is not None:
662
+ adjacent.append(idx)
663
+ neighbors.append(sorted(adjacent, key=lambda idx: (coords[idx, 0], coords[idx, 1])))
664
+ return neighbors
665
+
666
+
667
+ def _turn_cost(prev: np.ndarray, current: np.ndarray, candidate: np.ndarray) -> float:
668
+ incoming = current - prev
669
+ outgoing = candidate - current
670
+ incoming_norm = float(np.linalg.norm(incoming))
671
+ outgoing_norm = float(np.linalg.norm(outgoing))
672
+ if incoming_norm == 0.0 or outgoing_norm == 0.0:
673
+ return 0.0
674
+ return float(1.0 - np.dot(incoming, outgoing) / (incoming_norm * outgoing_norm))
675
+
676
+
677
+ def _coordinate_segments(coords: np.ndarray) -> list[np.ndarray]:
678
+ if len(coords) <= 1:
679
+ return [coords]
680
+
681
+ coords = np.unique(np.asarray(coords, dtype=float), axis=0)
682
+ neighbors = _coordinate_neighbors(coords)
683
+ unused_edges = {
684
+ tuple(sorted((idx, neighbor_idx)))
685
+ for idx, adjacent in enumerate(neighbors)
686
+ for neighbor_idx in adjacent
687
+ if idx != neighbor_idx
688
+ }
689
+ segments: list[np.ndarray] = []
690
+ covered: set[int] = set()
691
+
692
+ while unused_edges:
693
+ start_idx = _next_trail_start(coords, neighbors, unused_edges)
694
+ next_idx = _next_unused_neighbor(coords, start_idx, None, neighbors, unused_edges)
695
+ if next_idx is None:
696
+ unused_edges = {edge for edge in unused_edges if start_idx not in edge}
697
+ continue
698
+
699
+ path = [start_idx]
700
+ prev_idx = start_idx
701
+ current_idx = next_idx
702
+ unused_edges.remove(tuple(sorted((prev_idx, current_idx))))
703
+ path.append(current_idx)
704
+
705
+ while len(neighbors[current_idx]) == 2:
706
+ next_idx = _next_unused_neighbor(
707
+ coords, current_idx, prev_idx, neighbors, unused_edges
708
+ )
709
+ if next_idx is None:
710
+ break
711
+ unused_edges.remove(tuple(sorted((current_idx, next_idx))))
712
+ path.append(next_idx)
713
+ prev_idx, current_idx = current_idx, next_idx
714
+
715
+ segments.append(coords[np.asarray(path, dtype=np.intp)])
716
+ covered.update(path)
717
+
718
+ # Points with no incident edges (crossings that are not adjacent to any
719
+ # other crossing of the same pair) are never walked above. Emit each as
720
+ # its own single-point segment, mirroring the len(coords) <= 1 branch, so
721
+ # a contact touching only at scattered points is not silently dropped.
722
+ for idx in range(len(coords)):
723
+ if idx not in covered:
724
+ segments.append(coords[np.asarray([idx], dtype=np.intp)])
725
+
726
+ return sorted(segments, key=lambda segment: (segment[0, 0], segment[0, 1], len(segment)))
727
+
728
+
729
+ def _next_trail_start(
730
+ coords: np.ndarray,
731
+ neighbors: list[list[int]],
732
+ unused_edges: set[tuple[int, int]],
733
+ ) -> int:
734
+ incident = {idx for edge in unused_edges for idx in edge}
735
+ preferred = [idx for idx in incident if len(neighbors[idx]) != 2]
736
+ return min(preferred or incident, key=lambda idx: (coords[idx, 0], coords[idx, 1]))
737
+
738
+
739
+ def _next_unused_neighbor(
740
+ coords: np.ndarray,
741
+ current_idx: int,
742
+ prev_idx: int | None,
743
+ neighbors: list[list[int]],
744
+ unused_edges: set[tuple[int, int]],
745
+ ) -> int | None:
746
+ candidates = [
747
+ idx
748
+ for idx in neighbors[current_idx]
749
+ if tuple(sorted((current_idx, idx))) in unused_edges
750
+ ]
751
+ if not candidates:
752
+ return None
753
+ return min(
754
+ candidates,
755
+ key=lambda idx: (
756
+ _turn_cost(coords[prev_idx], coords[current_idx], coords[idx])
757
+ if prev_idx is not None
758
+ else 0.0,
759
+ coords[idx, 0],
760
+ coords[idx, 1],
761
+ ),
762
+ )
763
+
764
+
765
+ def _path_length(coords: np.ndarray) -> float:
766
+ if len(coords) < 2:
767
+ return 0.0
768
+ diffs = np.diff(coords, axis=0)
769
+ return float(np.sqrt(np.sum(diffs * diffs, axis=1)).sum())
770
+
771
+
772
+ def _columns_from_rows(rows: list[dict], names: list[str]) -> dict[str, np.ndarray]:
773
+ columns = {}
774
+ for name in names:
775
+ values = [row[name] for row in rows]
776
+ if name in {"kind", "edge_label"}:
777
+ columns[name] = np.asarray(values, dtype=object)
778
+ elif name == "is_t1_frame":
779
+ columns[name] = np.asarray(values, dtype=bool)
780
+ elif name in {
781
+ "area",
782
+ "centroid_y",
783
+ "centroid_x",
784
+ "perimeter",
785
+ "length",
786
+ "midpoint_y",
787
+ "midpoint_x",
788
+ "location_y",
789
+ "location_x",
790
+ }:
791
+ columns[name] = np.asarray(values, dtype=float)
792
+ else:
793
+ columns[name] = np.asarray(values, dtype=np.int64)
794
+ return columns
795
+
796
+
797
+ def _write_column_group(group: h5py.Group, columns: dict[str, np.ndarray]) -> None:
798
+ string_dtype = h5py.string_dtype(encoding="utf-8")
799
+ for name, values in columns.items():
800
+ if values.dtype == object:
801
+ group.create_dataset(name, data=values, dtype=string_dtype)
802
+ else:
803
+ group.create_dataset(name, data=values)
804
+
805
+
806
+ def _write_t1_table(group: h5py.Group, events: list[T1Record]) -> None:
807
+ rows = [
808
+ {
809
+ "t1_event_id": event.t1_event_id,
810
+ "frame": event.frame,
811
+ "edge_id": event.edge_id,
812
+ "losing_cell_a": event.losing_cell_a,
813
+ "losing_cell_b": event.losing_cell_b,
814
+ "gaining_cell_a": event.gaining_cell_a,
815
+ "gaining_cell_b": event.gaining_cell_b,
816
+ "location_y": event.location_y,
817
+ "location_x": event.location_x,
818
+ }
819
+ for event in events
820
+ ]
821
+ columns = _columns_from_rows(
822
+ rows,
823
+ [
824
+ "t1_event_id",
825
+ "frame",
826
+ "edge_id",
827
+ "losing_cell_a",
828
+ "losing_cell_b",
829
+ "gaining_cell_a",
830
+ "gaining_cell_b",
831
+ "location_y",
832
+ "location_x",
833
+ ],
834
+ )
835
+ _write_column_group(group, columns)
836
+
837
+