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,895 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import tifffile
|
|
9
|
+
|
|
10
|
+
from itasc.napari._track_render import (
|
|
11
|
+
_UNLABELED_COLOR,
|
|
12
|
+
_nucleus_centroids_by_track,
|
|
13
|
+
_track_label_color_styling,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"build_cell_centroid_points",
|
|
18
|
+
"build_edge_shapes",
|
|
19
|
+
"build_nucleus_track_shapes",
|
|
20
|
+
"build_t1_edge_shapes",
|
|
21
|
+
"build_t1_points",
|
|
22
|
+
"add_contact_analysis_layers",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_BORDER_EDGE_COLOR = np.array([0.6, 0.6, 0.6, 1.0], dtype=float)
|
|
26
|
+
_CELL_EDGE_COLOR = np.array([0.12156863, 0.46666667, 0.70588235, 1.0], dtype=float)
|
|
27
|
+
_T1_EDGE_COLOR = np.array([0.0, 1.0, 0.9, 1.0], dtype=float)
|
|
28
|
+
_MIN_TRACK_ALPHA = 0.12
|
|
29
|
+
_DEFAULT_TRACK_TAIL = 50
|
|
30
|
+
_TRACK_RGB_FLOOR = 0.05
|
|
31
|
+
_TRACK_ALPHA_FLOOR = 0.15
|
|
32
|
+
# Match the nucleus correction widget's track-overview styling so the two views
|
|
33
|
+
# read the same (itasc.napari.track_path_controller.TRACK_TAIL_WIDTH /
|
|
34
|
+
# TRACK_TAIL_LENGTH): a short, moderately wide comet rather than a long thin tail.
|
|
35
|
+
_TRACK_TAIL_WIDTH = 4
|
|
36
|
+
_TRACK_TAIL_LENGTH = 15
|
|
37
|
+
_TRACK_HEAD_LENGTH = 0
|
|
38
|
+
_TRACK_OPACITY = 0.9
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# =====================================================================
|
|
42
|
+
# Public shape-building functions (preserved for external callers)
|
|
43
|
+
# =====================================================================
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_cell_centroid_points(
|
|
47
|
+
contact_analysis: Any,
|
|
48
|
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
49
|
+
cells = _section(contact_analysis, "cells")
|
|
50
|
+
frame = _column(cells, "frame").astype(float, copy=False)
|
|
51
|
+
y = _column(cells, "centroid_y").astype(float, copy=False)
|
|
52
|
+
x = _column(cells, "centroid_x").astype(float, copy=False)
|
|
53
|
+
points = _stack_points(frame, y, x)
|
|
54
|
+
cell_id = _column(cells, "cell_id")
|
|
55
|
+
features = {
|
|
56
|
+
"frame": _column(cells, "frame"),
|
|
57
|
+
"cell_id": cell_id,
|
|
58
|
+
"area": _column(cells, "area").astype(float, copy=False),
|
|
59
|
+
}
|
|
60
|
+
return points, features
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_edge_shapes(
|
|
64
|
+
contact_analysis: Any,
|
|
65
|
+
*,
|
|
66
|
+
hide_border_edges: bool = False,
|
|
67
|
+
color_by_id: bool = False,
|
|
68
|
+
color_by_label: bool = False,
|
|
69
|
+
) -> tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]:
|
|
70
|
+
edges = _section(contact_analysis, "edges")
|
|
71
|
+
coord_y = np.asarray(_value(contact_analysis, "coord_y"), dtype=float)
|
|
72
|
+
coord_x = np.asarray(_value(contact_analysis, "coord_x"), dtype=float)
|
|
73
|
+
|
|
74
|
+
frame = _column(edges, "frame")
|
|
75
|
+
edge_id = _column(edges, "edge_id")
|
|
76
|
+
cell_a = _column(edges, "cell_a")
|
|
77
|
+
cell_b = _column(edges, "cell_b")
|
|
78
|
+
kind = _column(edges, "kind")
|
|
79
|
+
edge_label = _column(edges, "edge_label")
|
|
80
|
+
length = _column(edges, "length").astype(float, copy=False)
|
|
81
|
+
is_t1_frame = _column(edges, "is_t1_frame").astype(bool, copy=False)
|
|
82
|
+
coord_offset = _column(edges, "coord_offset")
|
|
83
|
+
coord_count = _column(edges, "coord_count")
|
|
84
|
+
|
|
85
|
+
lines: list[np.ndarray] = []
|
|
86
|
+
keep: list[int] = []
|
|
87
|
+
for idx in range(len(frame)):
|
|
88
|
+
if hide_border_edges and str(kind[idx]) == "border":
|
|
89
|
+
continue
|
|
90
|
+
start = int(coord_offset[idx])
|
|
91
|
+
count = int(coord_count[idx])
|
|
92
|
+
if count < 2:
|
|
93
|
+
continue
|
|
94
|
+
stop = start + count
|
|
95
|
+
ys = coord_y[start:stop]
|
|
96
|
+
xs = coord_x[start:stop]
|
|
97
|
+
lines.append(
|
|
98
|
+
_stack_points(np.full(len(ys), frame[idx], dtype=float), ys, xs)
|
|
99
|
+
)
|
|
100
|
+
keep.append(idx)
|
|
101
|
+
|
|
102
|
+
mask = np.asarray(keep, dtype=np.intp)
|
|
103
|
+
if color_by_label:
|
|
104
|
+
colors = _categorical_colors(edge_label[mask])
|
|
105
|
+
elif color_by_id:
|
|
106
|
+
colors = _categorical_colors(edge_id[mask])
|
|
107
|
+
else:
|
|
108
|
+
colors = (
|
|
109
|
+
np.array(
|
|
110
|
+
[_edge_color_for_kind(item) for item in kind[mask]], dtype=float
|
|
111
|
+
)
|
|
112
|
+
if len(mask)
|
|
113
|
+
else np.empty((0, 4), dtype=float)
|
|
114
|
+
)
|
|
115
|
+
features = {
|
|
116
|
+
"frame": frame[mask],
|
|
117
|
+
"edge_id": edge_id[mask],
|
|
118
|
+
"cell_a": cell_a[mask],
|
|
119
|
+
"cell_b": cell_b[mask],
|
|
120
|
+
"kind": kind[mask],
|
|
121
|
+
"edge_label": edge_label[mask],
|
|
122
|
+
"length": length[mask],
|
|
123
|
+
"is_t1_frame": is_t1_frame[mask],
|
|
124
|
+
"coord_offset": coord_offset[mask],
|
|
125
|
+
"coord_count": coord_count[mask],
|
|
126
|
+
}
|
|
127
|
+
color_array = np.asarray(colors, dtype=float)
|
|
128
|
+
if color_array.size == 0:
|
|
129
|
+
color_array = np.empty((0, 4), dtype=float)
|
|
130
|
+
return lines, color_array, features
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_t1_points(
|
|
134
|
+
contact_analysis: Any,
|
|
135
|
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
136
|
+
t1_events = _section(contact_analysis, "t1_events")
|
|
137
|
+
frame = _column(t1_events, "frame").astype(float, copy=False)
|
|
138
|
+
y = _column(t1_events, "location_y").astype(float, copy=False)
|
|
139
|
+
x = _column(t1_events, "location_x").astype(float, copy=False)
|
|
140
|
+
points = _stack_points(frame, y, x)
|
|
141
|
+
features = {
|
|
142
|
+
"t1_event_id": _column(t1_events, "t1_event_id"),
|
|
143
|
+
"frame": _column(t1_events, "frame"),
|
|
144
|
+
"edge_id": _column(t1_events, "edge_id"),
|
|
145
|
+
"losing_cell_a": _column(t1_events, "losing_cell_a"),
|
|
146
|
+
"losing_cell_b": _column(t1_events, "losing_cell_b"),
|
|
147
|
+
"gaining_cell_a": _column(t1_events, "gaining_cell_a"),
|
|
148
|
+
"gaining_cell_b": _column(t1_events, "gaining_cell_b"),
|
|
149
|
+
"location_y": _column(t1_events, "location_y").astype(float, copy=False),
|
|
150
|
+
"location_x": _column(t1_events, "location_x").astype(float, copy=False),
|
|
151
|
+
}
|
|
152
|
+
return points, features
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_t1_edge_shapes(
|
|
156
|
+
contact_analysis: Any,
|
|
157
|
+
) -> tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]:
|
|
158
|
+
edges = _section(contact_analysis, "edges")
|
|
159
|
+
t1_events = _section(contact_analysis, "t1_events")
|
|
160
|
+
coord_y = np.asarray(_value(contact_analysis, "coord_y"), dtype=float)
|
|
161
|
+
coord_x = np.asarray(_value(contact_analysis, "coord_x"), dtype=float)
|
|
162
|
+
|
|
163
|
+
edge_frame = _column(edges, "frame")
|
|
164
|
+
edge_id = _column(edges, "edge_id")
|
|
165
|
+
coord_offset = _column(edges, "coord_offset")
|
|
166
|
+
coord_count = _column(edges, "coord_count")
|
|
167
|
+
|
|
168
|
+
event_ids = _column(t1_events, "t1_event_id")
|
|
169
|
+
event_frame = _column(t1_events, "frame")
|
|
170
|
+
event_edge_id = _column(t1_events, "edge_id")
|
|
171
|
+
losing_cell_a = _column(t1_events, "losing_cell_a")
|
|
172
|
+
losing_cell_b = _column(t1_events, "losing_cell_b")
|
|
173
|
+
gaining_cell_a = _column(t1_events, "gaining_cell_a")
|
|
174
|
+
gaining_cell_b = _column(t1_events, "gaining_cell_b")
|
|
175
|
+
location_y = _column(t1_events, "location_y").astype(float, copy=False)
|
|
176
|
+
location_x = _column(t1_events, "location_x").astype(float, copy=False)
|
|
177
|
+
|
|
178
|
+
lines: list[np.ndarray] = []
|
|
179
|
+
feature_rows: list[dict[str, Any]] = []
|
|
180
|
+
for event_idx in range(len(event_ids)):
|
|
181
|
+
transition_frame = int(event_frame[event_idx])
|
|
182
|
+
transition_edge_id = int(event_edge_id[event_idx])
|
|
183
|
+
for side, frame in (
|
|
184
|
+
("before", transition_frame),
|
|
185
|
+
("after", transition_frame + 1),
|
|
186
|
+
):
|
|
187
|
+
row_idx = _find_edge_row(
|
|
188
|
+
edge_frame, edge_id, frame, transition_edge_id
|
|
189
|
+
)
|
|
190
|
+
if row_idx is None:
|
|
191
|
+
continue
|
|
192
|
+
start = int(coord_offset[row_idx])
|
|
193
|
+
count = int(coord_count[row_idx])
|
|
194
|
+
if count < 2:
|
|
195
|
+
continue
|
|
196
|
+
stop = start + count
|
|
197
|
+
ys = coord_y[start:stop]
|
|
198
|
+
xs = coord_x[start:stop]
|
|
199
|
+
lines.append(
|
|
200
|
+
_stack_points(np.full(len(ys), frame, dtype=float), ys, xs)
|
|
201
|
+
)
|
|
202
|
+
feature_rows.append(
|
|
203
|
+
{
|
|
204
|
+
"t1_event_id": event_ids[event_idx],
|
|
205
|
+
"frame": frame,
|
|
206
|
+
"transition_frame": transition_frame,
|
|
207
|
+
"transition_side": side,
|
|
208
|
+
"edge_id": transition_edge_id,
|
|
209
|
+
"losing_cell_a": losing_cell_a[event_idx],
|
|
210
|
+
"losing_cell_b": losing_cell_b[event_idx],
|
|
211
|
+
"gaining_cell_a": gaining_cell_a[event_idx],
|
|
212
|
+
"gaining_cell_b": gaining_cell_b[event_idx],
|
|
213
|
+
"location_y": location_y[event_idx],
|
|
214
|
+
"location_x": location_x[event_idx],
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
colors = np.tile(_T1_EDGE_COLOR, (len(lines), 1))
|
|
219
|
+
return lines, colors, _feature_columns(
|
|
220
|
+
feature_rows,
|
|
221
|
+
[
|
|
222
|
+
"t1_event_id",
|
|
223
|
+
"frame",
|
|
224
|
+
"transition_frame",
|
|
225
|
+
"transition_side",
|
|
226
|
+
"edge_id",
|
|
227
|
+
"losing_cell_a",
|
|
228
|
+
"losing_cell_b",
|
|
229
|
+
"gaining_cell_a",
|
|
230
|
+
"gaining_cell_b",
|
|
231
|
+
"location_y",
|
|
232
|
+
"location_x",
|
|
233
|
+
],
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_nucleus_track_shapes(
|
|
238
|
+
contact_analysis: Any,
|
|
239
|
+
nucleus_labels: np.ndarray,
|
|
240
|
+
*,
|
|
241
|
+
current_frame: int,
|
|
242
|
+
) -> tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]:
|
|
243
|
+
"""Return past-only nucleus centroid track segments for one viewer frame."""
|
|
244
|
+
centroids = _nucleus_centroids_by_track(nucleus_labels)
|
|
245
|
+
color_map = _cell_color_map(contact_analysis)
|
|
246
|
+
segments_by_end = _index_track_segments(centroids)
|
|
247
|
+
return _build_track_shapes_for_frame(
|
|
248
|
+
color_map,
|
|
249
|
+
current_frame=current_frame,
|
|
250
|
+
segments_by_end=segments_by_end,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# =====================================================================
|
|
255
|
+
# add_contact_analysis_layers — rasterised, zero callbacks
|
|
256
|
+
# =====================================================================
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def add_contact_analysis_layers(
|
|
260
|
+
viewer: Any,
|
|
261
|
+
contact_analysis: Any,
|
|
262
|
+
prefix: str = "[Contact Analysis] ",
|
|
263
|
+
*,
|
|
264
|
+
color_edges_by_id: bool = False,
|
|
265
|
+
color_edges_by_label: bool = False,
|
|
266
|
+
hide_border_edges: bool = False,
|
|
267
|
+
cell_labels: np.ndarray | None = None,
|
|
268
|
+
nucleus_labels: np.ndarray | None = None,
|
|
269
|
+
nucleus_track_centroids: dict | None = None,
|
|
270
|
+
track_tail_length: int = _TRACK_TAIL_LENGTH,
|
|
271
|
+
) -> list[Any]:
|
|
272
|
+
"""Add contact-analysis overlays as napari-native layers.
|
|
273
|
+
|
|
274
|
+
Cells and nuclei are label images; nucleus tracks use a GPU ``Tracks``
|
|
275
|
+
layer (native temporal tail-fade, no per-frame rebuild); cell-cell edges
|
|
276
|
+
and T1 transition edges are drawn as antialiased ``path`` shapes for the
|
|
277
|
+
*current* frame only, swapped on the time slider so the Shapes layer never
|
|
278
|
+
holds more than one frame's worth of polylines.
|
|
279
|
+
"""
|
|
280
|
+
# --- edges + T1 edges as per-frame path shapes (added after labels) ------
|
|
281
|
+
edge_cache = _frame_shape_cache(
|
|
282
|
+
*build_edge_shapes(
|
|
283
|
+
contact_analysis,
|
|
284
|
+
hide_border_edges=hide_border_edges,
|
|
285
|
+
color_by_id=color_edges_by_id,
|
|
286
|
+
color_by_label=color_edges_by_label,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
t1_cache = _frame_shape_cache(*build_t1_edge_shapes(contact_analysis))
|
|
290
|
+
|
|
291
|
+
# --- label images --------------------------------------------------------
|
|
292
|
+
if cell_labels is None:
|
|
293
|
+
cell_labels = _read_label_image(
|
|
294
|
+
_contact_analysis_label_path(contact_analysis, "cell_tracked_labels_path")
|
|
295
|
+
)
|
|
296
|
+
if nucleus_labels is None:
|
|
297
|
+
nucleus_labels = _read_label_image(
|
|
298
|
+
_contact_analysis_label_path(contact_analysis, "nucleus_tracked_labels_path")
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
cell_color_dict = _cell_color_map(contact_analysis)
|
|
302
|
+
cell_kwargs: dict[str, Any] = {}
|
|
303
|
+
nucleus_kwargs: dict[str, Any] = {}
|
|
304
|
+
try:
|
|
305
|
+
from napari.utils.colormaps import DirectLabelColormap
|
|
306
|
+
except Exception: # pragma: no cover – napari compatibility
|
|
307
|
+
pass
|
|
308
|
+
else:
|
|
309
|
+
cell_cmap = DirectLabelColormap(color_dict=cell_color_dict)
|
|
310
|
+
cell_kwargs["colormap"] = cell_cmap
|
|
311
|
+
nucleus_kwargs["colormap"] = cell_cmap
|
|
312
|
+
|
|
313
|
+
# --- nucleus tracks as a native GPU Tracks layer -------------------------
|
|
314
|
+
track_centroids = (
|
|
315
|
+
nucleus_track_centroids
|
|
316
|
+
if nucleus_track_centroids is not None
|
|
317
|
+
else _nucleus_centroids_by_track(nucleus_labels)
|
|
318
|
+
)
|
|
319
|
+
tracks_data, tracks_props = _nucleus_tracks_data(track_centroids)
|
|
320
|
+
tail = max(1, int(track_tail_length))
|
|
321
|
+
track_styling = _track_label_color_styling(
|
|
322
|
+
tracks_props["track_id"], cell_color_dict
|
|
323
|
+
)
|
|
324
|
+
track_color_kwargs: dict[str, Any] = {"color_by": "track_id"}
|
|
325
|
+
if track_styling is not None:
|
|
326
|
+
track_cmap, label_pos = track_styling
|
|
327
|
+
tracks_props = {**tracks_props, "label_pos": label_pos}
|
|
328
|
+
track_color_kwargs = {
|
|
329
|
+
"color_by": "label_pos",
|
|
330
|
+
"colormaps_dict": {"label_pos": track_cmap},
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
layers: list[Any] = [
|
|
334
|
+
viewer.add_labels(
|
|
335
|
+
cell_labels,
|
|
336
|
+
name=f"{prefix}Cell labels",
|
|
337
|
+
opacity=0.55,
|
|
338
|
+
blending="translucent",
|
|
339
|
+
**cell_kwargs,
|
|
340
|
+
),
|
|
341
|
+
viewer.add_labels(
|
|
342
|
+
nucleus_labels,
|
|
343
|
+
name=f"{prefix}Nucleus labels",
|
|
344
|
+
opacity=0.65,
|
|
345
|
+
blending="translucent",
|
|
346
|
+
**nucleus_kwargs,
|
|
347
|
+
),
|
|
348
|
+
]
|
|
349
|
+
# napari's Tracks layer rejects empty data, so only add it when present.
|
|
350
|
+
if len(tracks_data):
|
|
351
|
+
layers.append(
|
|
352
|
+
viewer.add_tracks(
|
|
353
|
+
tracks_data,
|
|
354
|
+
name=f"{prefix}Nucleus tracks",
|
|
355
|
+
properties=tracks_props,
|
|
356
|
+
tail_width=_TRACK_TAIL_WIDTH,
|
|
357
|
+
tail_length=tail,
|
|
358
|
+
head_length=_TRACK_HEAD_LENGTH,
|
|
359
|
+
blending="translucent",
|
|
360
|
+
opacity=_TRACK_OPACITY,
|
|
361
|
+
**track_color_kwargs,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# A per-frame Shapes layer whose cache is empty across *every* frame would
|
|
366
|
+
# render as a permanent blank ("ghost") entry in the layer list that never
|
|
367
|
+
# shows anything, so skip it. A layer that merely has no shapes in the
|
|
368
|
+
# current frame is still added — it fills in as the time slider moves.
|
|
369
|
+
for name, cache in (
|
|
370
|
+
(f"{prefix}Edges", edge_cache),
|
|
371
|
+
(f"{prefix}T1 edges", t1_cache),
|
|
372
|
+
):
|
|
373
|
+
layer = _add_frame_shape_layer(viewer, name, cache)
|
|
374
|
+
if layer is not None:
|
|
375
|
+
layers.append(layer)
|
|
376
|
+
return layers
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# =====================================================================
|
|
380
|
+
# Per-frame path-shape layers (edges & T1 edges)
|
|
381
|
+
# =====================================================================
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _current_frame(viewer: Any) -> int:
|
|
385
|
+
step = getattr(getattr(viewer, "dims", None), "current_step", ())
|
|
386
|
+
if not step:
|
|
387
|
+
return 0
|
|
388
|
+
try:
|
|
389
|
+
return int(step[0])
|
|
390
|
+
except Exception:
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _edge_color_kwargs(colors: np.ndarray) -> dict[str, np.ndarray]:
|
|
395
|
+
if len(colors) == 0:
|
|
396
|
+
return {}
|
|
397
|
+
return {"edge_color": colors}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _nucleus_tracks_data(
|
|
401
|
+
centroids: dict[int, list[tuple[int, float, float]]],
|
|
402
|
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
403
|
+
"""Build napari ``Tracks`` ``data`` (``[track_id, t, y, x]``) + properties.
|
|
404
|
+
|
|
405
|
+
Rows are sorted by track then time, as the Tracks layer requires.
|
|
406
|
+
"""
|
|
407
|
+
rows: list[tuple[float, float, float, float]] = []
|
|
408
|
+
for cell_id in sorted(centroids):
|
|
409
|
+
for frame, y, x in sorted(centroids[cell_id]):
|
|
410
|
+
rows.append((float(int(cell_id)), float(frame), float(y), float(x)))
|
|
411
|
+
if not rows:
|
|
412
|
+
empty = np.empty((0, 4), dtype=float)
|
|
413
|
+
return empty, {"track_id": np.empty(0, dtype=int), "time": np.empty(0, dtype=float)}
|
|
414
|
+
data = np.asarray(rows, dtype=float)
|
|
415
|
+
return data, {"track_id": data[:, 0].astype(int), "time": data[:, 1]}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _frame_shape_cache(
|
|
419
|
+
lines: list[np.ndarray],
|
|
420
|
+
colors: np.ndarray,
|
|
421
|
+
features: dict[str, np.ndarray],
|
|
422
|
+
) -> dict[int, tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]]:
|
|
423
|
+
"""Index path shapes by frame so the Shapes layer holds one frame at a time."""
|
|
424
|
+
frames = np.asarray(
|
|
425
|
+
features.get("frame", np.asarray([], dtype=int))
|
|
426
|
+
).astype(int, copy=False)
|
|
427
|
+
cache: dict[int, tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]] = {}
|
|
428
|
+
for frame in sorted(set(frames.tolist())):
|
|
429
|
+
indexes = np.flatnonzero(frames == int(frame))
|
|
430
|
+
cache[int(frame)] = (
|
|
431
|
+
[lines[int(idx)] for idx in indexes],
|
|
432
|
+
colors[indexes] if len(colors) else np.empty((0, 4), dtype=float),
|
|
433
|
+
{name: values[indexes] for name, values in features.items()},
|
|
434
|
+
)
|
|
435
|
+
return cache
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _empty_frame_shapes(
|
|
439
|
+
frame_cache: dict[int, tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]],
|
|
440
|
+
) -> tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]:
|
|
441
|
+
feature_names: list[str] = []
|
|
442
|
+
for _lines, _colors, features in frame_cache.values():
|
|
443
|
+
feature_names = list(features)
|
|
444
|
+
break
|
|
445
|
+
return (
|
|
446
|
+
[],
|
|
447
|
+
np.empty((0, 4), dtype=float),
|
|
448
|
+
{name: np.asarray([], dtype=object) for name in feature_names},
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _cached_frame_shapes(
|
|
453
|
+
frame_cache: dict[int, tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]],
|
|
454
|
+
frame: int,
|
|
455
|
+
) -> tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]:
|
|
456
|
+
return frame_cache.get(int(frame), _empty_frame_shapes(frame_cache))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _set_path_shapes(layer: Any, lines: list[np.ndarray]) -> None:
|
|
460
|
+
"""Replace a Shapes layer's contents with ``path`` shapes for *lines*.
|
|
461
|
+
|
|
462
|
+
napari drops the ``"path"`` shape type when an *emptied* Shapes layer is
|
|
463
|
+
repopulated through the ``data`` setter — the new shapes fall back to the
|
|
464
|
+
``"polygon"`` default, which closes each contour and joins its endpoints with
|
|
465
|
+
a straight segment. Per-frame edge/T1 layers are empty on the frames between
|
|
466
|
+
events, so they routinely hit this path; re-adding with an explicit
|
|
467
|
+
``shape_type`` keeps them open polylines. Falls back to a plain ``data``
|
|
468
|
+
assignment for layer stand-ins (e.g. tests) that have no ``add`` method.
|
|
469
|
+
"""
|
|
470
|
+
adder = getattr(layer, "add", None)
|
|
471
|
+
if lines and callable(adder):
|
|
472
|
+
layer.data = []
|
|
473
|
+
adder(lines, shape_type="path")
|
|
474
|
+
else:
|
|
475
|
+
layer.data = lines
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _add_frame_shape_layer(
|
|
479
|
+
viewer: Any,
|
|
480
|
+
name: str,
|
|
481
|
+
frame_cache: dict[int, tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]],
|
|
482
|
+
) -> Any | None:
|
|
483
|
+
"""Add a per-frame ``path`` Shapes layer, or skip a globally-empty cache.
|
|
484
|
+
|
|
485
|
+
Returns the created layer, or ``None`` when the cache holds no shapes in any
|
|
486
|
+
frame — adding it would leave a permanent blank entry in the layer list. The
|
|
487
|
+
layer is seeded with the current frame's shapes and swapped on the slider.
|
|
488
|
+
"""
|
|
489
|
+
if not frame_cache:
|
|
490
|
+
return None
|
|
491
|
+
lines, colors, features = _cached_frame_shapes(frame_cache, _current_frame(viewer))
|
|
492
|
+
layer = viewer.add_shapes(
|
|
493
|
+
lines,
|
|
494
|
+
ndim=3,
|
|
495
|
+
name=name,
|
|
496
|
+
shape_type="path",
|
|
497
|
+
features=features,
|
|
498
|
+
edge_width=1,
|
|
499
|
+
face_color="transparent",
|
|
500
|
+
blending="translucent",
|
|
501
|
+
**_edge_color_kwargs(colors),
|
|
502
|
+
)
|
|
503
|
+
_connect_frame_shape_layer_to_dims(viewer, layer, frame_cache=frame_cache)
|
|
504
|
+
return layer
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _connect_frame_shape_layer_to_dims(
|
|
508
|
+
viewer: Any,
|
|
509
|
+
layer: Any,
|
|
510
|
+
*,
|
|
511
|
+
frame_cache: dict[int, tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]],
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Swap ``layer.data`` to the current frame on every time-slider change.
|
|
514
|
+
|
|
515
|
+
The connection is torn down when the layer is removed; a ``cleanup`` closure
|
|
516
|
+
is stashed on the layer so external clears can disconnect it eagerly.
|
|
517
|
+
"""
|
|
518
|
+
dims = getattr(viewer, "dims", None)
|
|
519
|
+
events = getattr(dims, "events", None)
|
|
520
|
+
current_step_event = getattr(events, "current_step", None)
|
|
521
|
+
connect = getattr(current_step_event, "connect", None)
|
|
522
|
+
if not callable(connect):
|
|
523
|
+
return
|
|
524
|
+
current_disconnect = getattr(current_step_event, "disconnect", None)
|
|
525
|
+
|
|
526
|
+
def _update(_event=None) -> None:
|
|
527
|
+
viewer_layers = getattr(viewer, "layers", None)
|
|
528
|
+
if viewer_layers is not None:
|
|
529
|
+
try:
|
|
530
|
+
if layer not in viewer_layers:
|
|
531
|
+
return
|
|
532
|
+
except Exception:
|
|
533
|
+
pass
|
|
534
|
+
lines, colors, features = _cached_frame_shapes(
|
|
535
|
+
frame_cache, _current_frame(viewer)
|
|
536
|
+
)
|
|
537
|
+
_set_path_shapes(layer, lines)
|
|
538
|
+
layer.features = features
|
|
539
|
+
layer.edge_color = colors if len(colors) else "transparent"
|
|
540
|
+
refresh = getattr(layer, "refresh", None)
|
|
541
|
+
if callable(refresh):
|
|
542
|
+
refresh()
|
|
543
|
+
|
|
544
|
+
def _disconnect() -> None:
|
|
545
|
+
if callable(current_disconnect):
|
|
546
|
+
try:
|
|
547
|
+
current_disconnect(_update)
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
550
|
+
if callable(removed_disconnect):
|
|
551
|
+
try:
|
|
552
|
+
removed_disconnect(_on_removed)
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
try:
|
|
556
|
+
frame_cache.clear()
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
def _on_removed(event=None) -> None:
|
|
561
|
+
removed = getattr(event, "value", None)
|
|
562
|
+
if removed is layer or getattr(removed, "name", None) == getattr(
|
|
563
|
+
layer, "name", None
|
|
564
|
+
):
|
|
565
|
+
_disconnect()
|
|
566
|
+
|
|
567
|
+
layers = getattr(viewer, "layers", None)
|
|
568
|
+
layer_events = getattr(layers, "events", None)
|
|
569
|
+
removed_event = getattr(layer_events, "removed", None)
|
|
570
|
+
removed_connect = getattr(removed_event, "connect", None)
|
|
571
|
+
removed_disconnect = getattr(removed_event, "disconnect", None)
|
|
572
|
+
|
|
573
|
+
connect(_update)
|
|
574
|
+
if callable(removed_connect):
|
|
575
|
+
removed_connect(_on_removed)
|
|
576
|
+
try:
|
|
577
|
+
layer._itasc_frame_shape_update = _update
|
|
578
|
+
layer._itasc_frame_shape_cleanup = _disconnect
|
|
579
|
+
except Exception:
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# =====================================================================
|
|
584
|
+
# Rasterisation helpers
|
|
585
|
+
# =====================================================================
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _line_pixels(
|
|
589
|
+
y0: int, x0: int, y1: int, x1: int, *, width: int = 2,
|
|
590
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
591
|
+
"""Return integer (ys, xs) along a line from (y0,x0) to (y1,x1)."""
|
|
592
|
+
n = max(abs(y1 - y0), abs(x1 - x0), 1) + 1
|
|
593
|
+
center_ys = np.rint(np.linspace(y0, y1, n)).astype(np.intp)
|
|
594
|
+
center_xs = np.rint(np.linspace(x0, x1, n)).astype(np.intp)
|
|
595
|
+
if int(width) <= 1:
|
|
596
|
+
return center_ys, center_xs
|
|
597
|
+
|
|
598
|
+
# expand perpendicular to the line direction
|
|
599
|
+
dy = y1 - y0
|
|
600
|
+
dx = x1 - x0
|
|
601
|
+
if abs(dy) >= abs(dx):
|
|
602
|
+
# mostly vertical — expand horizontally
|
|
603
|
+
ys = np.concatenate([center_ys, center_ys])
|
|
604
|
+
xs = np.concatenate([center_xs, center_xs + 1])
|
|
605
|
+
else:
|
|
606
|
+
# mostly horizontal — expand vertically
|
|
607
|
+
ys = np.concatenate([center_ys, center_ys + 1])
|
|
608
|
+
xs = np.concatenate([center_xs, center_xs])
|
|
609
|
+
|
|
610
|
+
return ys, xs
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# =====================================================================
|
|
614
|
+
# Track rasterisation (fade-to-black RGBA image)
|
|
615
|
+
# =====================================================================
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _rasterize_track_image(
|
|
619
|
+
centroids: dict[int, list[tuple[int, float, float]]],
|
|
620
|
+
color_map: dict[int | None, tuple[float, float, float, float] | str],
|
|
621
|
+
shape: tuple[int, int, int],
|
|
622
|
+
*,
|
|
623
|
+
tail_length: int = _DEFAULT_TRACK_TAIL,
|
|
624
|
+
line_width: int = 1,
|
|
625
|
+
) -> np.ndarray:
|
|
626
|
+
"""Rasterise nucleus tracks into a ``(T, H, W, 4)`` uint8 RGBA stack.
|
|
627
|
+
|
|
628
|
+
Recent segments appear in the cell's colour; older segments transition
|
|
629
|
+
toward black and then transparent via per-frame exponential decay.
|
|
630
|
+
"""
|
|
631
|
+
T, H, W = shape
|
|
632
|
+
segments_by_end = _index_track_segments(centroids)
|
|
633
|
+
|
|
634
|
+
tail_length = max(1, int(tail_length))
|
|
635
|
+
rgb_decay = float(_TRACK_RGB_FLOOR ** (1.0 / tail_length))
|
|
636
|
+
alpha_decay = float(_TRACK_ALPHA_FLOOR ** (1.0 / tail_length))
|
|
637
|
+
|
|
638
|
+
# resolve cell colours once (skip string/"transparent" entries)
|
|
639
|
+
draw_colors: dict[int, np.ndarray] = {}
|
|
640
|
+
for cell_id in centroids:
|
|
641
|
+
raw = color_map.get(int(cell_id), _UNLABELED_COLOR)
|
|
642
|
+
if isinstance(raw, str):
|
|
643
|
+
continue
|
|
644
|
+
c = np.asarray(raw, dtype=np.float32).copy()
|
|
645
|
+
c[3] = 1.0
|
|
646
|
+
draw_colors[int(cell_id)] = c
|
|
647
|
+
|
|
648
|
+
output = np.zeros((T, H, W, 4), dtype=np.uint8)
|
|
649
|
+
current = np.zeros((H, W, 4), dtype=np.float32)
|
|
650
|
+
|
|
651
|
+
for t in range(T):
|
|
652
|
+
# decay existing pixels: rgb fades fast, alpha fades slower
|
|
653
|
+
current[:, :, :3] *= rgb_decay
|
|
654
|
+
current[:, :, 3] *= alpha_decay
|
|
655
|
+
|
|
656
|
+
# draw new segments that end at this frame
|
|
657
|
+
segments = segments_by_end.get(t)
|
|
658
|
+
if segments is not None:
|
|
659
|
+
for cell_id, _sf, sy, sx, _ef, ey, ex in segments:
|
|
660
|
+
color = draw_colors.get(cell_id)
|
|
661
|
+
if color is None:
|
|
662
|
+
continue
|
|
663
|
+
py, px = _line_pixels(
|
|
664
|
+
int(round(sy)),
|
|
665
|
+
int(round(sx)),
|
|
666
|
+
int(round(ey)),
|
|
667
|
+
int(round(ex)),
|
|
668
|
+
width=line_width,
|
|
669
|
+
)
|
|
670
|
+
valid = (py >= 0) & (py < H) & (px >= 0) & (px < W)
|
|
671
|
+
current[py[valid], px[valid]] = color
|
|
672
|
+
|
|
673
|
+
# write uint8 frame
|
|
674
|
+
output[t] = (current * 255.0).astype(np.uint8)
|
|
675
|
+
|
|
676
|
+
return output
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# =====================================================================
|
|
680
|
+
# Track-segment indexing & shape building (for public API)
|
|
681
|
+
# =====================================================================
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _index_track_segments(
|
|
685
|
+
centroids: dict[int, list[tuple[int, float, float]]],
|
|
686
|
+
) -> dict[int, list[tuple[int, int, float, float, int, float, float]]]:
|
|
687
|
+
"""Index consecutive track segments by *end* frame.
|
|
688
|
+
|
|
689
|
+
Each value is a list of
|
|
690
|
+
``(cell_id, start_frame, start_y, start_x, end_frame, end_y, end_x)``.
|
|
691
|
+
"""
|
|
692
|
+
by_end: dict[int, list[tuple[int, int, float, float, int, float, float]]] = {}
|
|
693
|
+
for cell_id, rows in centroids.items():
|
|
694
|
+
for prev, curr in zip(rows[:-1], rows[1:]):
|
|
695
|
+
sf, sy, sx = prev
|
|
696
|
+
ef, ey, ex = curr
|
|
697
|
+
if int(ef) != int(sf) + 1:
|
|
698
|
+
continue
|
|
699
|
+
by_end.setdefault(int(ef), []).append(
|
|
700
|
+
(int(cell_id), int(sf), float(sy), float(sx), int(ef), float(ey), float(ex))
|
|
701
|
+
)
|
|
702
|
+
return by_end
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _build_track_shapes_for_frame(
|
|
706
|
+
color_map: dict[int | None, tuple[float, float, float, float] | str],
|
|
707
|
+
*,
|
|
708
|
+
current_frame: int,
|
|
709
|
+
segments_by_end: dict[int, list[tuple[int, int, float, float, int, float, float]]],
|
|
710
|
+
) -> tuple[list[np.ndarray], np.ndarray, dict[str, np.ndarray]]:
|
|
711
|
+
"""Build vector track shapes for a single frame (alpha-fade variant)."""
|
|
712
|
+
current_frame = max(0, int(current_frame))
|
|
713
|
+
max_age = max(current_frame, 1)
|
|
714
|
+
|
|
715
|
+
lines: list[np.ndarray] = []
|
|
716
|
+
colors: list[np.ndarray] = []
|
|
717
|
+
feature_rows: list[dict[str, Any]] = []
|
|
718
|
+
|
|
719
|
+
for end_frame in range(current_frame + 1):
|
|
720
|
+
segments = segments_by_end.get(end_frame)
|
|
721
|
+
if segments is None:
|
|
722
|
+
continue
|
|
723
|
+
age = current_frame - end_frame
|
|
724
|
+
alpha = max(_MIN_TRACK_ALPHA, 1.0 - (age / max_age))
|
|
725
|
+
for cell_id, sf, sy, sx, ef, ey, ex in segments:
|
|
726
|
+
color = np.asarray(
|
|
727
|
+
color_map.get(cell_id, _UNLABELED_COLOR), dtype=float
|
|
728
|
+
).copy()
|
|
729
|
+
color[3] = alpha
|
|
730
|
+
lines.append(
|
|
731
|
+
_stack_points(
|
|
732
|
+
np.asarray([current_frame, current_frame], dtype=float),
|
|
733
|
+
np.asarray([sy, ey], dtype=float),
|
|
734
|
+
np.asarray([sx, ex], dtype=float),
|
|
735
|
+
)
|
|
736
|
+
)
|
|
737
|
+
colors.append(color)
|
|
738
|
+
feature_rows.append(
|
|
739
|
+
{"cell_id": cell_id, "start_frame": sf, "end_frame": ef, "age": age}
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
color_array = (
|
|
743
|
+
np.asarray(colors, dtype=float) if colors else np.empty((0, 4), dtype=float)
|
|
744
|
+
)
|
|
745
|
+
return lines, color_array, _feature_columns(
|
|
746
|
+
feature_rows, ["cell_id", "start_frame", "end_frame", "age"]
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
# =====================================================================
|
|
751
|
+
# Efficient nucleus-centroid extraction (single pass per frame)
|
|
752
|
+
# =====================================================================
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# =====================================================================
|
|
756
|
+
# Colour maps
|
|
757
|
+
# =====================================================================
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _cell_color_map(
|
|
761
|
+
contact_analysis: Any,
|
|
762
|
+
) -> dict[int | None, tuple[float, float, float, float] | str]:
|
|
763
|
+
cells = _section(contact_analysis, "cells")
|
|
764
|
+
cell_ids = np.asarray(sorted(set(_column(cells, "cell_id").astype(int))))
|
|
765
|
+
cell_colors = _categorical_colors(cell_ids)
|
|
766
|
+
cmap: dict[int | None, tuple[float, float, float, float] | str] = {
|
|
767
|
+
None: "transparent",
|
|
768
|
+
0: "transparent",
|
|
769
|
+
}
|
|
770
|
+
for cid, color in zip(cell_ids, cell_colors, strict=True):
|
|
771
|
+
cmap[int(cid)] = tuple(float(c) for c in color)
|
|
772
|
+
return cmap
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# =====================================================================
|
|
776
|
+
# Data-access helpers
|
|
777
|
+
# =====================================================================
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _section(contact_analysis: Any, name: str) -> Any:
|
|
781
|
+
if isinstance(contact_analysis, Mapping):
|
|
782
|
+
return contact_analysis[name]
|
|
783
|
+
return getattr(contact_analysis, name)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _value(contact_analysis: Any, name: str) -> Any:
|
|
787
|
+
if isinstance(contact_analysis, Mapping):
|
|
788
|
+
return contact_analysis[name]
|
|
789
|
+
return getattr(contact_analysis, name)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _contact_analysis_label_path(contact_analysis: Any, name: str) -> Path:
|
|
793
|
+
return Path(_value(contact_analysis, name))
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _read_label_image(path: Path) -> np.ndarray:
|
|
797
|
+
return np.asarray(tifffile.imread(path))
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _column(table: Any, name: str) -> np.ndarray:
|
|
801
|
+
if isinstance(table, Mapping):
|
|
802
|
+
value = table[name]
|
|
803
|
+
else:
|
|
804
|
+
value = getattr(table, name)
|
|
805
|
+
return np.asarray(value)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
# =====================================================================
|
|
809
|
+
# Low-level helpers
|
|
810
|
+
# =====================================================================
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _find_edge_row(
|
|
814
|
+
frame: np.ndarray,
|
|
815
|
+
edge_id: np.ndarray,
|
|
816
|
+
target_frame: int,
|
|
817
|
+
target_edge_id: int,
|
|
818
|
+
) -> int | None:
|
|
819
|
+
matches = np.flatnonzero(
|
|
820
|
+
(frame.astype(int, copy=False) == target_frame)
|
|
821
|
+
& (edge_id.astype(int, copy=False) == target_edge_id)
|
|
822
|
+
)
|
|
823
|
+
if len(matches) == 0:
|
|
824
|
+
return None
|
|
825
|
+
return int(matches[0])
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _feature_columns(
|
|
829
|
+
rows: list[dict[str, Any]], names: list[str],
|
|
830
|
+
) -> dict[str, np.ndarray]:
|
|
831
|
+
if not rows:
|
|
832
|
+
return {name: np.asarray([], dtype=object) for name in names}
|
|
833
|
+
return {name: np.asarray([row[name] for row in rows]) for name in names}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _stack_points(
|
|
837
|
+
frame: np.ndarray, y: np.ndarray, x: np.ndarray,
|
|
838
|
+
) -> np.ndarray:
|
|
839
|
+
if len(frame) == 0:
|
|
840
|
+
return np.empty((0, 3), dtype=float)
|
|
841
|
+
return np.column_stack(
|
|
842
|
+
(
|
|
843
|
+
frame.astype(float, copy=False),
|
|
844
|
+
y.astype(float, copy=False),
|
|
845
|
+
x.astype(float, copy=False),
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _edge_color_for_kind(kind: Any) -> np.ndarray:
|
|
851
|
+
if str(kind) == "border":
|
|
852
|
+
return _BORDER_EDGE_COLOR
|
|
853
|
+
return _CELL_EDGE_COLOR
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _categorical_colors(values: np.ndarray) -> np.ndarray:
|
|
857
|
+
values = np.asarray(values)
|
|
858
|
+
if len(values) == 0:
|
|
859
|
+
return np.empty((0, 4), dtype=float)
|
|
860
|
+
keys = [str(v) for v in values]
|
|
861
|
+
palette = {
|
|
862
|
+
key: _palette_color(idx)
|
|
863
|
+
for idx, key in enumerate(sorted({k for k in keys if k != ""}))
|
|
864
|
+
}
|
|
865
|
+
colors = np.empty((len(values), 4), dtype=float)
|
|
866
|
+
for idx, key in enumerate(keys):
|
|
867
|
+
colors[idx] = _UNLABELED_COLOR if key == "" else palette[key]
|
|
868
|
+
return colors
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _palette_color(index: int) -> np.ndarray:
|
|
872
|
+
hue = (index * 0.618033988749895) % 1.0
|
|
873
|
+
return np.asarray((*_hsv_to_rgb(hue, 0.65, 0.9), 1.0), dtype=float)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _hsv_to_rgb(
|
|
877
|
+
hue: float, saturation: float, value: float,
|
|
878
|
+
) -> tuple[float, float, float]:
|
|
879
|
+
sector = int(hue * 6.0)
|
|
880
|
+
fraction = hue * 6.0 - sector
|
|
881
|
+
p = value * (1.0 - saturation)
|
|
882
|
+
q = value * (1.0 - fraction * saturation)
|
|
883
|
+
t = value * (1.0 - (1.0 - fraction) * saturation)
|
|
884
|
+
sector %= 6
|
|
885
|
+
if sector == 0:
|
|
886
|
+
return value, t, p
|
|
887
|
+
if sector == 1:
|
|
888
|
+
return q, value, p
|
|
889
|
+
if sector == 2:
|
|
890
|
+
return p, value, t
|
|
891
|
+
if sector == 3:
|
|
892
|
+
return p, q, value
|
|
893
|
+
if sector == 4:
|
|
894
|
+
return t, p, value
|
|
895
|
+
return value, p, q
|