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