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,388 @@
1
+ """Pure geometry/colour helpers for correction-mode track rendering.
2
+
3
+ Two concerns live here, both Qt/napari-free so they unit-test on their own:
4
+
5
+ * :func:`build_all_tracks_data` feeds a single napari ``Tracks`` layer that draws
6
+ *every* track as the overview; focus mode then slices out the selected track's
7
+ vertices (by ``row_index``) and colours them by time.
8
+ * :func:`build_track_film_strip` and the crop helpers build the per-frame film
9
+ strip used by the lineage canvas / candidate gallery.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+
17
+ import numpy as np
18
+
19
+ from itasc.napari._track_render import (
20
+ _nucleus_centroids_by_track,
21
+ )
22
+
23
+
24
+ def build_all_tracks_data(
25
+ tracked_stack: np.ndarray,
26
+ ) -> tuple[np.ndarray, dict[str, np.ndarray], dict[int, np.ndarray]]:
27
+ """Build napari ``Tracks`` ``data`` + ``properties`` for *every* track.
28
+
29
+ ``tracked_stack`` is a ``(T, H, W)`` label array (a bare ``(H, W)`` plane is
30
+ treated as a single frame). Returns ``(data, properties, row_index)``:
31
+
32
+ * ``data`` — ``(N, 4)`` float rows ``[track_id, t, y, x]`` through each
33
+ track's per-frame nucleus centroids, grouped by track and time-ascending.
34
+ * ``properties`` — ``track_id`` (per vertex) for the overview colouring and
35
+ ``time`` (per-track normalised 0→1 oldest→newest) for the focused track's
36
+ viridis time gradient.
37
+ * ``row_index`` — ``{track_id: row positions into data}`` so focus can slice
38
+ out a single track's vertices without rescanning the stack.
39
+
40
+ Every returned array shares one row order, so ``row_index`` indexes both
41
+ ``data`` and the property arrays.
42
+ """
43
+ centroids = _nucleus_centroids_by_track(tracked_stack)
44
+
45
+ rows: list[tuple[float, float, float, float]] = []
46
+ track_ids: list[float] = []
47
+ times: list[float] = []
48
+ row_index: dict[int, np.ndarray] = {}
49
+
50
+ cursor = 0
51
+ for track_id in sorted(centroids):
52
+ points = sorted(centroids[track_id]) # by frame (already, but be explicit)
53
+ n = len(points)
54
+ row_index[int(track_id)] = np.arange(cursor, cursor + n)
55
+ cursor += n
56
+
57
+ frames = np.asarray([p[0] for p in points], dtype=float)
58
+ if n > 1 and frames.max() > frames.min():
59
+ norm = (frames - frames.min()) / (frames.max() - frames.min())
60
+ else:
61
+ norm = np.zeros(n, dtype=float)
62
+
63
+ for (t, y, x), nt in zip(points, norm):
64
+ rows.append((float(track_id), float(t), float(y), float(x)))
65
+ track_ids.append(float(track_id))
66
+ times.append(float(nt))
67
+
68
+ if not rows:
69
+ empty = np.empty(0, dtype=float)
70
+ return (
71
+ np.empty((0, 4), dtype=float),
72
+ {"track_id": empty, "time": empty},
73
+ {},
74
+ )
75
+
76
+ properties = {
77
+ "track_id": np.asarray(track_ids, dtype=float),
78
+ "time": np.asarray(times, dtype=float),
79
+ }
80
+ return np.asarray(rows, dtype=float), properties, row_index
81
+
82
+
83
+ def _viridis_colors(n: int) -> np.ndarray:
84
+ """``n`` RGBA viridis samples from dark (0.0) to yellow (1.0).
85
+
86
+ A single frame maps to the dark end so the mapping stays deterministic.
87
+ """
88
+ if n <= 0:
89
+ return np.empty((0, 4), dtype=float)
90
+ from matplotlib import colormaps
91
+
92
+ positions = np.linspace(0.0, 1.0, n)
93
+ return np.asarray(colormaps["viridis"](positions), dtype=float)
94
+
95
+
96
+ def _mask_outline(mask: np.ndarray) -> np.ndarray:
97
+ """Boundary pixels of ``mask``: in the mask with a non-mask 4-neighbor.
98
+
99
+ Pixels on the array edge count as boundary (they have an off-image
100
+ neighbor), so a mask touching the border still gets a closed outline.
101
+ """
102
+ if not mask.any():
103
+ return mask
104
+ interior = np.ones_like(mask)
105
+ interior[1:, :] &= mask[:-1, :]
106
+ interior[:-1, :] &= mask[1:, :]
107
+ interior[:, 1:] &= mask[:, :-1]
108
+ interior[:, :-1] &= mask[:, 1:]
109
+ interior[0, :] = False
110
+ interior[-1, :] = False
111
+ interior[:, 0] = False
112
+ interior[:, -1] = False
113
+ return mask & ~interior
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class FilmStripTile:
118
+ """One frame's panel in the film strip: an RGB crop with the mask outlined.
119
+
120
+ ``validated`` / ``anchored`` flag whether this frame is a validated or an
121
+ anchored frame for the track; the view draws a coloured marker strip for
122
+ each. They are view metadata, not baked into ``rgb``.
123
+
124
+ ``placeholder`` marks a frame the track does *not* occupy: an empty (blank)
125
+ tile emitted only to keep an incomplete track's strip aligned to the movie
126
+ timeline, so the gaps read as missing frames rather than a shorter strip.
127
+ """
128
+
129
+ frame: int # source frame index
130
+ rgb: np.ndarray # (h, w, 3) uint8, raw crop with the mask edge drawn on
131
+ validated: bool = False
132
+ anchored: bool = False
133
+ placeholder: bool = False
134
+
135
+ @property
136
+ def height(self) -> int:
137
+ return self.rgb.shape[0]
138
+
139
+ @property
140
+ def width(self) -> int:
141
+ return self.rgb.shape[1]
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class TrackFilmStrip:
146
+ """Per-frame crops of one track, ready for a Qt dock to blit side by side.
147
+
148
+ Every tile is a fixed-size square window *centered on that frame's nucleus
149
+ centroid*, so the nucleus stays put in the middle of every tile and you read
150
+ the surroundings sweeping past. Tiles are ordered oldest-first.
151
+ """
152
+
153
+ tiles: tuple[FilmStripTile, ...]
154
+
155
+ def is_empty(self) -> bool:
156
+ return len(self.tiles) == 0
157
+
158
+ @property
159
+ def frames(self) -> tuple[int, ...]:
160
+ return tuple(tile.frame for tile in self.tiles)
161
+
162
+
163
+ def _binary_dilate(mask: np.ndarray, iterations: int) -> np.ndarray:
164
+ """8-connected binary dilation by ``iterations`` (no scipy dependency)."""
165
+ out = mask
166
+ for _ in range(max(iterations, 0)):
167
+ d = out.copy()
168
+ d[1:, :] |= out[:-1, :]
169
+ d[:-1, :] |= out[1:, :]
170
+ d[:, 1:] |= out[:, :-1]
171
+ d[:, :-1] |= out[:, 1:]
172
+ d[1:, 1:] |= out[:-1, :-1]
173
+ d[1:, :-1] |= out[:-1, 1:]
174
+ d[:-1, 1:] |= out[1:, :-1]
175
+ d[:-1, :-1] |= out[1:, 1:]
176
+ out = d
177
+ return out
178
+
179
+
180
+ def _centered_crop(arr: np.ndarray, cy: int, cx: int, size: int) -> np.ndarray:
181
+ """``size``x``size`` crop of ``arr`` centered on (cy, cx), zero-padded at edges."""
182
+ out = np.zeros((size, size), dtype=arr.dtype)
183
+ half = size // 2
184
+ top, left = cy - half, cx - half
185
+ h, w = arr.shape
186
+ y0, y1 = max(top, 0), min(top + size, h)
187
+ x0, x1 = max(left, 0), min(left + size, w)
188
+ if y0 < y1 and x0 < x1:
189
+ out[y0 - top : y1 - top, x0 - left : x1 - left] = arr[y0:y1, x0:x1]
190
+ return out
191
+
192
+
193
+ def _apply_colormap(normalized: np.ndarray, colormap) -> np.ndarray:
194
+ """Map a (h, w) array in [0, 1] to (h, w, 3) RGB float; grayscale if None."""
195
+ if colormap is None:
196
+ return np.repeat(normalized[:, :, np.newaxis], 3, axis=2)
197
+ mapped = np.asarray(colormap(normalized), dtype=float)
198
+ # ascontiguousarray guarantees a writable copy (the builder mutates rgb).
199
+ return np.ascontiguousarray(mapped[..., :3], dtype=float)
200
+
201
+
202
+ def render_crop_tile(
203
+ intensity_2d: np.ndarray,
204
+ mask: np.ndarray,
205
+ cy: int,
206
+ cx: int,
207
+ size: int,
208
+ *,
209
+ lo: float,
210
+ hi: float,
211
+ colormap=None,
212
+ outline_color: tuple[float, float, float],
213
+ outline_width: int = 2,
214
+ spotlight_dim: float = 0.35,
215
+ spotlight_dilation: int = 2,
216
+ ) -> np.ndarray:
217
+ """Render one spotlighted, outlined crop centered on ``(cy, cx)``.
218
+
219
+ Crops ``intensity_2d`` to a ``size``x``size`` window, contrast-stretches it
220
+ against the shared ``[lo, hi]`` range, colors it through ``colormap``
221
+ (grayscale if ``None``), dims everything outside ``mask`` by ``spotlight_dim``,
222
+ and draws a ``outline_width``-thick ``outline_color`` border on the inner edge
223
+ of the bright region. Returns ``(size, size, 3)`` uint8.
224
+
225
+ Shared by the per-frame film strip and the per-candidate gallery so both read
226
+ identically; callers own the window ``size``, contrast range, and outline
227
+ colour (e.g. the film strip falls back to a per-frame viridis colour).
228
+ """
229
+ crop = _centered_crop(intensity_2d, cy, cx, size).astype(float)
230
+ norm = np.zeros_like(crop) if hi <= lo else np.clip((crop - lo) / (hi - lo), 0, 1)
231
+ rgb = _apply_colormap(norm, colormap)
232
+
233
+ mask_crop = _centered_crop(mask, cy, cx, size)
234
+ spotlight = _binary_dilate(mask_crop, spotlight_dilation)
235
+ rgb[~spotlight] *= spotlight_dim
236
+
237
+ # Border on the inner edge of the bright spotlight, so the coloured contour
238
+ # lands exactly on the bright/dim boundary (no bright ring left outside it).
239
+ border = _binary_dilate(_mask_outline(spotlight), outline_width - 1) & spotlight
240
+ rgb[border] = outline_color
241
+ return (np.clip(rgb, 0.0, 1.0) * 255.0).round().astype(np.uint8)
242
+
243
+
244
+ def build_track_film_strip(
245
+ tracked_stack: np.ndarray,
246
+ intensity_stack: np.ndarray,
247
+ track_id: int,
248
+ *,
249
+ margin: int = 6,
250
+ colormap=None,
251
+ outline_width: int = 2,
252
+ outline_color: tuple[float, float, float] | None = None,
253
+ spotlight_dim: float = 0.35,
254
+ spotlight_dilation: int = 2,
255
+ validated_frames: set[int] | None = None,
256
+ anchored_frames: set[int] | None = None,
257
+ frames: Sequence[int] | None = None,
258
+ total_frames: int | None = None,
259
+ ) -> TrackFilmStrip:
260
+ """Build per-frame, nucleus-centered intensity crops for ``track_id``.
261
+
262
+ ``tracked_stack`` and ``intensity_stack`` are matching ``(T, H, W)`` arrays
263
+ (bare ``(H, W)`` planes are treated as a single frame). For each occupied
264
+ frame the intensity is cropped to a fixed square window centered on the
265
+ nucleus, contrast-stretched against the track's own nucleus pixels, colored
266
+ through ``colormap`` (e.g. the layer's "bop purple"; grayscale if ``None``),
267
+ and dimmed outside the nucleus by ``spotlight_dim`` for a spotlight effect.
268
+
269
+ A ``outline_width``-thick border is drawn at the *inner edge of the bright
270
+ spotlight region*, so the coloured contour coincides with the bright/dim
271
+ boundary instead of leaving a bright ring outside it. The border uses
272
+ ``outline_color`` (the label layer's colour for this track, RGB in 0..1);
273
+ when ``None`` it falls back to the frame's viridis time colour.
274
+
275
+ ``validated_frames`` / ``anchored_frames`` (sets of frame indices) flag each
276
+ tile so the view can mark validated/anchored frames. ``margin`` pads the
277
+ window around the largest nucleus.
278
+
279
+ ``frames`` optionally restricts the scan to a known set of occupied frame
280
+ indices (e.g. supplied by the lineage graph), so callers building strips for
281
+ many tracks at once avoid re-scanning every empty frame per track.
282
+
283
+ ``total_frames``, when given, pads the strip to one tile per movie frame in
284
+ ``range(total_frames)``: frames the track does not occupy get a blank
285
+ ``placeholder`` tile, so an *incomplete* track's strip stays aligned to the
286
+ timeline with its missing frames shown as empty thumbnails.
287
+ """
288
+ tracked = np.asarray(tracked_stack)
289
+ intensity = np.asarray(intensity_stack)
290
+ if tracked.ndim == 2:
291
+ tracked = tracked[np.newaxis, ...]
292
+ if intensity.ndim == 2:
293
+ intensity = intensity[np.newaxis, ...]
294
+ if tracked.ndim != 3 or intensity.ndim != 3:
295
+ raise ValueError("tracked_stack and intensity_stack must be 2D or 3D")
296
+ if tracked.shape != intensity.shape:
297
+ raise ValueError(
298
+ f"shape mismatch: tracked {tracked.shape} vs intensity {intensity.shape}"
299
+ )
300
+
301
+ track_id = int(track_id)
302
+
303
+ if frames is None:
304
+ scan = range(tracked.shape[0])
305
+ else:
306
+ scan = [int(f) for f in frames if 0 <= int(f) < tracked.shape[0]]
307
+
308
+ occupied: list[int] = []
309
+ masks: list[np.ndarray] = []
310
+ centroids: list[tuple[int, int]] = []
311
+ extents: list[int] = []
312
+ for t in scan:
313
+ mask = tracked[t] == track_id
314
+ if not mask.any():
315
+ continue
316
+ ys, xs = np.nonzero(mask)
317
+ occupied.append(t)
318
+ masks.append(mask)
319
+ centroids.append((int(round(ys.mean())), int(round(xs.mean()))))
320
+ extents.append(max(ys.max() - ys.min() + 1, xs.max() - xs.min() + 1))
321
+ if not occupied:
322
+ return TrackFilmStrip(tiles=())
323
+
324
+ # One square window big enough for the largest nucleus plus margin, used for
325
+ # every tile so the strip is uniform and the nucleus is always centered.
326
+ size = int(max(extents)) + 2 * margin
327
+
328
+ # Contrast-stretch against the track's own nucleus pixels (good cell contrast
329
+ # regardless of background), shared across tiles for comparability.
330
+ nucleus_values = np.concatenate(
331
+ [intensity[t][mask] for t, mask in zip(occupied, masks)]
332
+ ).astype(float)
333
+ if nucleus_values.size:
334
+ lo = float(np.percentile(nucleus_values, 2.0))
335
+ hi = float(np.percentile(nucleus_values, 98.0))
336
+ else: # pragma: no cover - occupied implies non-empty masks
337
+ lo, hi = 0.0, 1.0
338
+
339
+ validated = {int(f) for f in (validated_frames or set())}
340
+ anchored = {int(f) for f in (anchored_frames or set())}
341
+
342
+ colors = _viridis_colors(len(occupied))
343
+ rendered: dict[int, FilmStripTile] = {}
344
+ for t, mask, (cy, cx), color in zip(
345
+ occupied, masks, centroids, colors, strict=True
346
+ ):
347
+ # Per-frame outline colour falls back to the viridis time colour; the
348
+ # bright/dim spotlight and inner-edge border are handled by the renderer.
349
+ rgb = render_crop_tile(
350
+ intensity[t],
351
+ mask,
352
+ cy,
353
+ cx,
354
+ size,
355
+ lo=lo,
356
+ hi=hi,
357
+ colormap=colormap,
358
+ outline_color=outline_color if outline_color is not None else tuple(color[:3]),
359
+ outline_width=outline_width,
360
+ spotlight_dim=spotlight_dim,
361
+ spotlight_dilation=spotlight_dilation,
362
+ )
363
+ rendered[t] = FilmStripTile(
364
+ frame=t,
365
+ rgb=rgb,
366
+ validated=t in validated,
367
+ anchored=t in anchored,
368
+ )
369
+
370
+ # Without ``total_frames`` the strip is just the occupied tiles (oldest-first);
371
+ # with it, every movie frame gets a tile so missing frames show as empty
372
+ # placeholders and the strip stays aligned to the timeline.
373
+ if total_frames is not None and int(total_frames) > 0:
374
+ display = range(int(total_frames))
375
+ else:
376
+ display = occupied
377
+ tiles = [
378
+ rendered[t]
379
+ if t in rendered
380
+ else FilmStripTile(
381
+ frame=int(t),
382
+ rgb=np.zeros((size, size, 3), dtype=np.uint8),
383
+ placeholder=True,
384
+ )
385
+ for t in display
386
+ ]
387
+
388
+ return TrackFilmStrip(tiles=tuple(tiles))