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,351 @@
1
+ """Unified-accordion state — assembles the track bars + selected-track band.
2
+
3
+ Owns the correction accordion panel: it builds the per-track swimlane bars
4
+ (present runs from :func:`~itasc.segmentation.lineage.build_lineage`, status
5
+ frames from the project's validation records) and, for the *selected* track only,
6
+ the per-frame thumbnail band (via
7
+ :func:`~itasc.napari.correction._correction_track_path.build_track_film_strip`). Building
8
+ crops for a single track keeps refresh cheap no matter how many tracks exist.
9
+
10
+ The bar assembly and crops are pure/testable; this is the glue that reads layers,
11
+ owns the single :class:`~itasc.napari.correction._correction_track_accordion.TrackAccordionPanel`
12
+ (embedded by the host into its workspace splitter), and turns a bar/thumbnail
13
+ click into a viewer jump + cell selection via ``on_activate``.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from pathlib import Path
19
+ from collections.abc import Callable
20
+
21
+ import numpy as np
22
+
23
+ from itasc.tracking_ultrack.validation_state import read_corrections, read_validated_tracks
24
+ from itasc.napari.correction._correction_track_accordion import LaneView, TrackAccordionPanel
25
+ from itasc.napari.correction._correction_track_path import (
26
+ TrackFilmStrip,
27
+ build_track_film_strip,
28
+ )
29
+ from itasc.core.lineage import build_lineage
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _NODE_OUTLINE = (0.75, 0.75, 0.75) # fallback neutral cell outline for the detail
34
+
35
+
36
+ class LineageCanvasController:
37
+ """Own the unified track-accordion panel for a correction session."""
38
+
39
+ def __init__(
40
+ self,
41
+ viewer,
42
+ *,
43
+ tracked_data_provider: Callable[[], np.ndarray | None],
44
+ tracked_layer_provider: Callable[[], object | None],
45
+ intensity_layer_provider: Callable[[], object | None],
46
+ selected_label_provider: Callable[[], int],
47
+ current_t_provider: Callable[[], int],
48
+ on_activate: Callable[[int, int], None],
49
+ pos_dir_provider: Callable[[], Path | None] | None = None,
50
+ validated_tracks_provider: Callable[[], dict[int, set[int]]] | None = None,
51
+ ) -> None:
52
+ self.viewer = viewer
53
+ self._tracked_data_provider = tracked_data_provider
54
+ self._tracked_layer_provider = tracked_layer_provider
55
+ self._intensity_layer_provider = intensity_layer_provider
56
+ self._selected_label_provider = selected_label_provider
57
+ self._current_t_provider = current_t_provider
58
+ self._on_activate = on_activate
59
+ self._pos_dir_provider = pos_dir_provider
60
+ # DB-free hosts (no project directory to hold validated_cells.json) hand
61
+ # in their own in-memory {cell_id: {frames}} store instead; anchors are
62
+ # an Ultrack-workflow concept and stay empty in that case.
63
+ self._validated_tracks_provider = validated_tracks_provider
64
+ # The unified accordion panel is embedded as a bare widget into the
65
+ # host's workspace splitter; the controller no longer owns napari docks.
66
+ self._panel: TrackAccordionPanel | None = None
67
+ # Cached from the last refresh so a selection can rebuild only the detail.
68
+ self._occupied: dict[int, list[int]] = {}
69
+ self._validated_map: dict[int, set[int]] = {}
70
+ self._anchored_map: dict[int, set[int]] = {}
71
+ # Structural lanes (cell_id, column, segments) from the last full assemble,
72
+ # so a flag-only change can recolour without re-running build_lineage.
73
+ self._lane_structure: list[tuple[int, int, tuple[tuple[int, int], ...]]] | None = None
74
+ self._n_frames: int = 0
75
+
76
+ def refresh(self) -> None:
77
+ """Rebuild the track bars and the selected track's band."""
78
+ tracked = self._tracked_data_provider()
79
+ if tracked is None:
80
+ if self._panel is not None:
81
+ self._panel.set_overview([], n_frames=0)
82
+ return
83
+ try:
84
+ lanes, n_frames = self._assemble(np.asarray(tracked))
85
+ except Exception:
86
+ logger.exception("lineage overview assembly failed")
87
+ return
88
+ self._ensure_panel()
89
+ self._panel.set_overview(
90
+ lanes, n_frames=n_frames, title=self._overview_title(lanes),
91
+ )
92
+ self.set_current_frame(self._current_t_provider())
93
+ self.set_selection(int(self._selected_label_provider() or 0))
94
+
95
+ def _assemble(self, tracked: np.ndarray) -> tuple[list[LaneView], int]:
96
+ model = build_lineage(tracked)
97
+ self._validated_map, self._anchored_map = self._validated_anchored_maps()
98
+ self._occupied = {}
99
+ lanes: list[LaneView] = []
100
+ for column, lane in enumerate(model.lanes):
101
+ cid = int(lane.cell_id)
102
+ segments = tuple((int(s.start), int(s.end)) for s in lane.segments)
103
+ self._occupied[cid] = [
104
+ f for s, e in segments for f in range(s, e + 1)
105
+ ]
106
+ lanes.append(LaneView(
107
+ cell_id=cid,
108
+ column=column,
109
+ segments=segments,
110
+ validated=frozenset(self._validated_map.get(cid, ())),
111
+ anchored=frozenset(self._anchored_map.get(cid, ())),
112
+ ))
113
+ self._lane_structure = [
114
+ (ln.cell_id, ln.column, ln.segments) for ln in lanes
115
+ ]
116
+ self._n_frames = model.n_frames
117
+ return lanes, model.n_frames
118
+
119
+ def set_selection(self, cell_id: int) -> None:
120
+ """Mark the selected track and rebuild only its expanded thumbnail band."""
121
+ cell_id = int(cell_id or 0)
122
+ if self._panel is None:
123
+ return
124
+ self._panel.set_selection(cell_id)
125
+ self._panel.set_strip(
126
+ self._build_detail(cell_id), title=self._detail_title(cell_id)
127
+ )
128
+ self._panel.set_current_frame(self._current_t_provider())
129
+
130
+ def set_current_frame(self, frame: int) -> None:
131
+ """Move the shared frame guide / tile highlight without rebuilding."""
132
+ frame = int(frame)
133
+ if self._panel is not None:
134
+ self._panel.set_current_frame(frame)
135
+
136
+ def center_on_track(self, cell_id: int) -> None:
137
+ """Scroll the panel so ``cell_id``'s bar row is vertically centered."""
138
+ if self._panel is not None:
139
+ self._panel.center_on_track(int(cell_id or 0))
140
+
141
+ def center_on_strip(self, cell_id: int | None = None) -> None:
142
+ """Scroll the panel so the selected track's film strip is centered.
143
+
144
+ Falls back to centering on the bar row when the band is not built yet
145
+ (no selection / empty strip).
146
+ """
147
+ if self._panel is None:
148
+ return
149
+ if not self._panel.center_on_strip() and cell_id is not None:
150
+ self._panel.center_on_track(int(cell_id or 0))
151
+
152
+ def step_film_frame(self, *, dx: int = 0, dy: int = 0, wrap: bool = False) -> None:
153
+ """Move the current frame to the neighbouring thumbnail in the band.
154
+
155
+ ``dx`` walks the strip left/right (reading order); ``dy`` jumps a whole
156
+ wrapped row up/down. ``wrap`` (the viewer's loop mode) makes an off-the-end
157
+ step return to the opposite end. Navigation reuses the same frame-jump
158
+ path a thumbnail click takes (select the frame on the selected track).
159
+ """
160
+ if self._panel is None:
161
+ return
162
+ target = self._panel.grid_neighbor_frame(
163
+ int(self._current_t_provider()), dx=dx, dy=dy, wrap=wrap
164
+ )
165
+ if target is not None:
166
+ self._on_film_frame_clicked(int(target))
167
+
168
+ def refresh_status(self) -> None:
169
+ """Recolour validated/anchored flags without rescanning the stack.
170
+
171
+ Validation and anchoring change only per-frame *status*, never track
172
+ topology, so the expensive whole-stack ``build_lineage`` in
173
+ :meth:`refresh` is unnecessary. This re-reads the validation records and
174
+ re-applies them to the lanes cached by the last full refresh, keeping
175
+ the GUI responsive on long tracks (the per-frame ``build_lineage`` froze
176
+ it for seconds). Falls back to a full :meth:`refresh` when no structure
177
+ has been cached yet.
178
+ """
179
+ if self._panel is None:
180
+ return
181
+ if self._lane_structure is None:
182
+ self.refresh()
183
+ return
184
+ self._validated_map, self._anchored_map = self._validated_anchored_maps()
185
+ lanes = [
186
+ LaneView(
187
+ cell_id=cid,
188
+ column=column,
189
+ segments=segments,
190
+ validated=frozenset(self._validated_map.get(cid, ())),
191
+ anchored=frozenset(self._anchored_map.get(cid, ())),
192
+ )
193
+ for cid, column, segments in self._lane_structure
194
+ ]
195
+ self._panel.set_overview(
196
+ lanes, n_frames=self._n_frames, title=self._overview_title(lanes),
197
+ )
198
+ self.set_current_frame(self._current_t_provider())
199
+ self.set_selection(int(self._selected_label_provider() or 0))
200
+
201
+ @staticmethod
202
+ def _overview_title(lanes) -> str:
203
+ """Overview header: total tracks plus how many carry any validation.
204
+
205
+ This is the home for the validation summary now that the top status bar
206
+ is just the one-line save/action status — the count tracks every
207
+ validate / anchor / edit through the panel's own refresh.
208
+ """
209
+ n_validated = sum(1 for lane in lanes if lane.validated)
210
+ return f"{len(lanes)} track(s), {n_validated} validated"
211
+
212
+ def refresh_detail(self) -> None:
213
+ """Rebuild only the selected track's detail strip (no overview rescan).
214
+
215
+ Used after a rapid live edit (stepping swap candidates with Z / C): it
216
+ re-crops just the selected track from the cached frame set, so the strip
217
+ reflects the new pixels *without* re-running the whole-stack lineage
218
+ build that the full :meth:`refresh` does (that froze the GUI when fired
219
+ on every keystroke). The overview is left as-is until the next
220
+ full refresh (selection change, validate/anchor, reload).
221
+ """
222
+ if self._panel is None:
223
+ return
224
+ self.set_selection(int(self._selected_label_provider() or 0))
225
+
226
+ def panel(self) -> TrackAccordionPanel:
227
+ """The unified accordion widget (created on first access) to embed."""
228
+ self._ensure_panel()
229
+ return self._panel
230
+
231
+ def teardown(self) -> None:
232
+ """Drop the reference to the panel for deactivate.
233
+
234
+ The panel is embedded as a bare widget in the host's workspace splitter
235
+ and is deleted when that dock is torn down; here we just drop our
236
+ reference so a later re-activate recreates it.
237
+ """
238
+ self._panel = None
239
+ self._lane_structure = None
240
+
241
+ # -- assembly helpers ---------------------------------------------------
242
+ def _validated_anchored_maps(
243
+ self,
244
+ ) -> tuple[dict[int, set[int]], dict[int, set[int]]]:
245
+ """Per-track validated / anchored frame sets, read once per refresh."""
246
+ if self._validated_tracks_provider is not None:
247
+ try:
248
+ validated = {
249
+ int(cell_id): {int(f) for f in frames}
250
+ for cell_id, frames in (self._validated_tracks_provider() or {}).items()
251
+ }
252
+ except Exception:
253
+ logger.exception("could not read in-memory validated tracks for the canvas")
254
+ validated = {}
255
+ return validated, {}
256
+ pos_dir = self._pos_dir_provider() if self._pos_dir_provider else None
257
+ if pos_dir is None:
258
+ return {}, {}
259
+ try:
260
+ validated = {
261
+ int(cell_id): {int(f) for f in frames}
262
+ for cell_id, frames in (read_validated_tracks(pos_dir) or {}).items()
263
+ }
264
+ anchored: dict[int, set[int]] = {}
265
+ for corr in read_corrections(pos_dir):
266
+ if getattr(corr, "kind", None) == "anchor":
267
+ anchored.setdefault(int(corr.cell_id), set()).add(int(corr.t))
268
+ except Exception:
269
+ logger.exception("could not read validated/anchored frames for the canvas")
270
+ return {}, {}
271
+ return validated, anchored
272
+
273
+ def _build_detail(self, cell_id: int) -> TrackFilmStrip:
274
+ tracked = self._tracked_data_provider()
275
+ intensity_layer = self._intensity_layer_provider()
276
+ if not cell_id or tracked is None or intensity_layer is None:
277
+ return TrackFilmStrip(tiles=())
278
+ try:
279
+ return build_track_film_strip(
280
+ np.asarray(tracked),
281
+ np.asarray(intensity_layer.data),
282
+ cell_id,
283
+ colormap=self._intensity_colormap(intensity_layer),
284
+ outline_color=self._track_outline_color(cell_id),
285
+ frames=self._occupied.get(cell_id),
286
+ validated_frames=self._validated_map.get(cell_id),
287
+ anchored_frames=self._anchored_map.get(cell_id),
288
+ # Pad incomplete tracks to the full timeline so frames the track
289
+ # skips render as empty placeholder thumbnails.
290
+ total_frames=self._n_frames,
291
+ )
292
+ except Exception:
293
+ logger.exception("detail film strip build failed")
294
+ return TrackFilmStrip(tiles=())
295
+
296
+ def _detail_title(self, cell_id: int) -> str:
297
+ if not cell_id:
298
+ return "No track selected"
299
+ return f"Track {cell_id} — {len(self._occupied.get(cell_id, ()))} frame(s)"
300
+
301
+ def _track_outline_color(self, cell_id: int):
302
+ """RGB (0..1) the tracked labels layer paints ``cell_id`` with, or None."""
303
+ layer = self._tracked_layer_provider()
304
+ color_dict = getattr(getattr(layer, "colormap", None), "color_dict", None)
305
+ try:
306
+ raw = color_dict.get(int(cell_id)) if color_dict is not None else None
307
+ except Exception:
308
+ raw = None
309
+ if raw is None or isinstance(raw, str):
310
+ return _NODE_OUTLINE
311
+ rgba = np.asarray(raw, dtype=float).ravel()
312
+ if rgba.size < 3:
313
+ return _NODE_OUTLINE
314
+ return (float(rgba[0]), float(rgba[1]), float(rgba[2]))
315
+
316
+ @staticmethod
317
+ def _intensity_colormap(layer):
318
+ """Adapt the intensity layer's colormap (e.g. 'bop purple') to (h,w)->RGB."""
319
+ cmap = getattr(layer, "colormap", None)
320
+ if cmap is None or not hasattr(cmap, "map"):
321
+ return None
322
+
323
+ def _map(values: np.ndarray) -> np.ndarray:
324
+ flat = np.asarray(values, dtype=float).ravel()
325
+ mapped = np.asarray(cmap.map(flat), dtype=float)
326
+ return mapped.reshape(values.shape + (mapped.shape[-1],))
327
+
328
+ return _map
329
+
330
+ def _ensure_panel(self) -> None:
331
+ """Create the unified accordion panel as a bare widget (idempotent)."""
332
+ if self._panel is None:
333
+ panel = TrackAccordionPanel(tile_px=72)
334
+ panel.node_activated.connect(self._on_node_activated)
335
+ panel.frame_clicked.connect(self._on_film_frame_clicked)
336
+ self._panel = panel
337
+
338
+ def _on_node_activated(self, frame: int, cell_id: int) -> None:
339
+ try:
340
+ self._on_activate(int(frame), int(cell_id))
341
+ except Exception:
342
+ logger.exception("lineage canvas navigation failed")
343
+
344
+ def _on_film_frame_clicked(self, frame: int) -> None:
345
+ cell_id = int(self._selected_label_provider() or 0)
346
+ if not cell_id:
347
+ return
348
+ self._on_node_activated(int(frame), cell_id)
349
+
350
+
351
+ __all__ = ["LineageCanvasController"]