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,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
|
+
|