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