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,566 @@
1
+ """Unified correction accordion — track bars that expand inline to thumbnails.
2
+
3
+ This merges what used to be two separate panels (the swimlane *overview* and the
4
+ per-track *film strip*) into one ``QGraphicsView`` + ``QGraphicsScene``. Each
5
+ track gets one thin **bar** row, laid top-down on a shared global time axis; the
6
+ **selected** track keeps its bar as a header and grows a **wrapped thumbnail
7
+ band** of that track's per-frame crops directly beneath it (one track expanded at
8
+ a time, driven by selection).
9
+
10
+ Geometry is width-derived: ``cell_w = (viewport_width − left_gutter) / n_frames``
11
+ is recomputed on every resize, so the whole global time axis always fits the
12
+ panel width with no horizontal scroll. A single vertical guide marks the current
13
+ frame across every row; a horizontal cursor marks the selected row.
14
+
15
+ Interaction:
16
+
17
+ * click a **bar** → :attr:`node_activated` ``(frame, cell_id)`` (snapping into the
18
+ nearest present frame when the click lands in a gap);
19
+ * click a **thumbnail** → :attr:`frame_clicked` ``(frame)``;
20
+ * **Ctrl+wheel** is region-aware (hit-test the cursor): over the expanded
21
+ thumbnail band it resizes the **tiles**; over a bar / elsewhere it changes the
22
+ **bar height** only (``cell_w`` is width-derived, so bars never widen);
23
+ * **plain wheel** scrolls the panel vertically through the track list.
24
+
25
+ The panel is a pure renderer: the controller hands it ready ``LaneView`` structs
26
+ and the selected track's :class:`~itasc.napari.correction._correction_track_path.TrackFilmStrip`.
27
+ Only ``rgb_to_qimage`` is import-safe without a running QApplication.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field
32
+
33
+ import numpy as np
34
+ from qtpy.QtCore import QRectF, Qt, Signal
35
+ from qtpy.QtGui import QColor, QImage, QPainter, QPen, QPixmap
36
+ from qtpy.QtWidgets import (
37
+ QGraphicsScene,
38
+ QGraphicsView,
39
+ QLabel,
40
+ QVBoxLayout,
41
+ QWidget,
42
+ )
43
+
44
+ from itasc.core.lineage import _segments_from_frames
45
+ from itasc.napari.correction._correction_track_path import TrackFilmStrip
46
+
47
+ _CLICK_SLOP = 6 # max drag (px) still treated as a click, not a scroll
48
+
49
+ # Bar geometry. ``lane_h`` is Ctrl+wheel adjustable (bar height only); ``cell_w``
50
+ # is always derived from the viewport width so the time axis fits exactly.
51
+ _LANE_H = 14.0
52
+ _LANE_H_MIN = 4.0
53
+ _LANE_H_MAX = 80.0
54
+ _LANE_H_STEP = 2.0
55
+ _LANE_PAD = 1.5 # vertical padding inside a bar
56
+ _LEFT_GUTTER = 30.0 # left margin reserved for the per-row track-id label
57
+ _ROW_GAP = 2.0 # gap below a (collapsed) bar before the next row
58
+ _BAND_GAP = 4.0 # gap between a selected bar and its thumbnail band
59
+
60
+ _PRESENT = QColor(95, 95, 95) # a present frame with nothing flagged
61
+ _VALIDATED = QColor("#00ff00")
62
+ _ANCHOR = QColor("#ff8c00")
63
+ _FRAME_GUIDE = QColor(255, 210, 70) # the current-frame vertical cursor
64
+ _COL_SELECT = QColor(255, 210, 70) # selected-track horizontal cursor line
65
+ _LABEL = QColor(160, 160, 160)
66
+
67
+ # Thumbnail tiles (mirrors the old film-strip vocabulary).
68
+ _TILE_PX = 64
69
+ _TILE_PX_MIN = 20
70
+ _TILE_PX_MAX = 512
71
+ _TILE_ZOOM_STEP = 8 # px added/removed from the tile size per Ctrl+wheel notch
72
+ _TILE_GAP = 2 # gap between thumbnails within a band row
73
+ _TILE_ROW_GAP = 4 # gap between wrapped thumbnail rows
74
+ _DEFAULT_WIDTH = 600 # fallback panel width before the view has a real size
75
+
76
+ _CURRENT_FRAME_BORDER = QColor("#ffffff")
77
+ _VALIDATED_STRIP_COLOR = "#00ff00"
78
+ _ANCHOR_STRIP_COLOR = "#b39400"
79
+ _MARKER_STRIP_PX = 4
80
+
81
+
82
+ def rgb_to_qimage(rgb: np.ndarray) -> QImage:
83
+ """Convert an ``(h, w, 3)`` uint8 array to an owned RGB888 ``QImage``.
84
+
85
+ The returned image owns its buffer (``.copy()``), so the source array is
86
+ free to be garbage-collected. Relocated here from the retired film-strip
87
+ module, which this panel replaces.
88
+ """
89
+ arr = np.ascontiguousarray(rgb, dtype=np.uint8)
90
+ if arr.ndim != 3 or arr.shape[2] != 3:
91
+ raise ValueError(f"expected (h, w, 3) uint8, got {arr.shape}")
92
+ height, width, _ = arr.shape
93
+ image = QImage(arr.data, width, height, 3 * width, QImage.Format_RGB888)
94
+ return image.copy()
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class LaneView:
99
+ """One track's row: present runs plus the frames flagged in each state."""
100
+
101
+ cell_id: int
102
+ column: int
103
+ segments: tuple[tuple[int, int], ...] # inclusive [start, end] present runs
104
+ validated: frozenset[int] = field(default_factory=frozenset)
105
+ anchored: frozenset[int] = field(default_factory=frozenset)
106
+
107
+ def present(self, frame: int) -> bool:
108
+ return any(s <= frame <= e for s, e in self.segments)
109
+
110
+ def nearest_present(self, frame: int) -> int:
111
+ """The present frame closest to ``frame`` (for jumping into a gap)."""
112
+ best, best_d = self.segments[0][0], None
113
+ for s, e in self.segments:
114
+ cand = min(max(frame, s), e)
115
+ d = abs(cand - frame)
116
+ if best_d is None or d < best_d:
117
+ best, best_d = cand, d
118
+ return best
119
+
120
+
121
+ class TrackAccordionPanel(QWidget):
122
+ """Track bars on a shared time axis; the selected one expands to thumbnails."""
123
+
124
+ node_activated = Signal(int, int) # (frame, cell_id) — a bar click
125
+ frame_clicked = Signal(int) # (frame) — a thumbnail click
126
+
127
+ def __init__(self, parent: QWidget | None = None, *, tile_px: int = _TILE_PX) -> None:
128
+ super().__init__(parent)
129
+ self._lanes: list[LaneView] = []
130
+ self._lanes_by_cell: dict[int, LaneView] = {}
131
+ self._n_frames = 0
132
+ self._selected = 0
133
+ self._current_frame = 0
134
+ self._lane_h = float(_LANE_H)
135
+ self._tile_px = int(tile_px)
136
+ self._strip = TrackFilmStrip(tiles=())
137
+ self._strip_title = ""
138
+ self._title_text = ""
139
+
140
+ # Geometry recorded by the last layout, for hit-testing.
141
+ self._cell_w = 0.0
142
+ self._bar_rows: list[tuple[float, float, int]] = [] # (y_top, y_bottom, cell_id)
143
+ self._band_range: tuple[float, float] | None = None # y span of the open band
144
+ self._tile_rects: dict[int, QRectF] = {}
145
+ self._guide_item = None
146
+ self._col_item = None
147
+ self._border_item = None
148
+
149
+ outer = QVBoxLayout(self)
150
+ outer.setContentsMargins(0, 0, 0, 0)
151
+ outer.setSpacing(2)
152
+
153
+ self._title = QLabel("No lineage")
154
+ self._title.setContentsMargins(6, 2, 6, 2)
155
+ outer.addWidget(self._title)
156
+
157
+ self._scene = QGraphicsScene(self)
158
+ self._view = _AccordionView(self)
159
+ outer.addWidget(self._view)
160
+
161
+ # -- public API ---------------------------------------------------------
162
+ def set_overview(
163
+ self, lanes: list[LaneView], *, n_frames: int, title: str = ""
164
+ ) -> None:
165
+ """Replace the set of track bars (the selected track's band is kept)."""
166
+ self._lanes = list(lanes)
167
+ self._lanes_by_cell = {int(ln.cell_id): ln for ln in self._lanes}
168
+ self._n_frames = int(n_frames)
169
+ self._title_text = title or f"{len(self._lanes)} track(s)"
170
+ self._relayout()
171
+
172
+ def set_strip(self, strip: TrackFilmStrip, title: str = "") -> None:
173
+ """Set the selected track's per-frame thumbnails (its expanded band)."""
174
+ self._strip = strip if strip is not None else TrackFilmStrip(tiles=())
175
+ self._strip_title = title
176
+ self._relayout()
177
+
178
+ def set_selection(self, cell_id: int) -> None:
179
+ """Mark ``cell_id`` as the selected/expanded track and re-lay out."""
180
+ self._selected = int(cell_id or 0)
181
+ self._relayout()
182
+
183
+ def set_current_frame(self, frame: int) -> None:
184
+ """Move the shared current-frame guide + the open band's tile border."""
185
+ self._current_frame = int(frame)
186
+ self._apply_frame_guide()
187
+ self._apply_current_border()
188
+
189
+ def center_on_track(self, cell_id: int) -> None:
190
+ """Scroll vertically so ``cell_id``'s bar row is centered."""
191
+ for top, bottom, cid in self._bar_rows:
192
+ if cid == int(cell_id or 0):
193
+ y = (top + bottom) / 2.0
194
+ vc = self._view.mapToScene(self._view.viewport().rect().center())
195
+ self._view.centerOn(vc.x(), y)
196
+ return
197
+
198
+ def center_on_strip(self) -> bool:
199
+ """Scroll vertically so the open thumbnail band is centered.
200
+
201
+ Returns ``True`` when a band was present to center on, ``False`` when the
202
+ selected track has no expanded film strip yet (so the caller can fall
203
+ back to centering on the bar row).
204
+ """
205
+ if self._band_range is None:
206
+ return False
207
+ top, bottom = self._band_range
208
+ y = (top + bottom) / 2.0
209
+ vc = self._view.mapToScene(self._view.viewport().rect().center())
210
+ self._view.centerOn(vc.x(), y)
211
+ return True
212
+
213
+ def grid_neighbor_frame(
214
+ self, current_frame: int, *, dx: int = 0, dy: int = 0, wrap: bool = False
215
+ ) -> int | None:
216
+ """Frame of the tile ``dx`` columns / ``dy`` rows from ``current_frame``.
217
+
218
+ The thumbnails wrap row-major across the band width, so this reconstructs
219
+ that grid from the laid-out tile rects: ``dx`` walks the band in reading
220
+ order (left/right, wrapping at row ends), while ``dy`` jumps a whole row
221
+ up/down keeping the column position (clamped on a shorter last row).
222
+ With ``wrap`` (the viewer's loop mode), running off either end returns to
223
+ the opposite end instead of stopping; otherwise the edge returns ``None``.
224
+ """
225
+ if not self._tile_rects:
226
+ return None
227
+ ordered = sorted(
228
+ self._tile_rects.items(), key=lambda kv: (round(kv[1].y(), 1), kv[1].x())
229
+ )
230
+ rows: list[list[int]] = []
231
+ last_y: float | None = None
232
+ for frame, rect in ordered:
233
+ ry = round(rect.y(), 1)
234
+ if last_y is None or ry != last_y:
235
+ rows.append([])
236
+ last_y = ry
237
+ rows[-1].append(int(frame))
238
+
239
+ cur = int(current_frame)
240
+ loc = next(
241
+ ((r, row.index(cur)) for r, row in enumerate(rows) if cur in row), None
242
+ )
243
+ if loc is None:
244
+ # Off-band (e.g. the viewer is on a frame the track skips): step in.
245
+ return rows[0][0] if rows and rows[0] else None
246
+ r, c = loc
247
+ if dx:
248
+ flat = [f for row in rows for f in row]
249
+ j = flat.index(cur) + dx
250
+ if 0 <= j < len(flat):
251
+ return flat[j]
252
+ return flat[j % len(flat)] if wrap and flat else None
253
+ if dy:
254
+ nr = r + dy
255
+ if 0 <= nr < len(rows):
256
+ row = rows[nr]
257
+ return row[min(c, len(row) - 1)]
258
+ if wrap and rows:
259
+ row = rows[nr % len(rows)]
260
+ return row[min(c, len(row) - 1)]
261
+ return None
262
+ return None
263
+
264
+ # -- layout -------------------------------------------------------------
265
+ def _row_width(self) -> float:
266
+ vw = self._view.viewport().width()
267
+ return float(vw if vw > _LEFT_GUTTER + 8 else _DEFAULT_WIDTH)
268
+
269
+ def _relayout(self) -> None:
270
+ """Lay every bar top-down, growing the selected one into a band."""
271
+ self._scene.clear()
272
+ self._guide_item = None
273
+ self._col_item = None
274
+ self._border_item = None
275
+ self._bar_rows = []
276
+ self._band_range = None
277
+ self._tile_rects = {}
278
+ self._title.setText(getattr(self, "_title_text", "") or "No lineage")
279
+
280
+ if not self._lanes or self._n_frames <= 0:
281
+ self._scene.setSceneRect(0, 0, self._row_width(), 1)
282
+ return
283
+
284
+ row_width = self._row_width()
285
+ self._cell_w = max((row_width - _LEFT_GUTTER) / self._n_frames, 0.5)
286
+ bar_h = self._lane_h - 2 * _LANE_PAD
287
+
288
+ y = 0.0
289
+ for lane in sorted(self._lanes, key=lambda x: x.column):
290
+ self._draw_label(y, lane.cell_id)
291
+ self._draw_bar(lane, y, bar_h)
292
+ self._bar_rows.append((y, y + self._lane_h, int(lane.cell_id)))
293
+ y += self._lane_h
294
+ if int(lane.cell_id) == self._selected and not self._strip.is_empty():
295
+ y += _BAND_GAP
296
+ band_top = y
297
+ y = self._draw_band(y, row_width)
298
+ self._band_range = (band_top, y)
299
+ y += _ROW_GAP
300
+
301
+ self._scene.setSceneRect(0, 0, row_width, max(y, 1.0))
302
+ self._apply_column_highlight()
303
+ self._apply_frame_guide()
304
+ self._apply_current_border()
305
+
306
+ def _draw_label(self, y: float, cell_id: int) -> None:
307
+ text = self._scene.addText(str(int(cell_id)))
308
+ text.setDefaultTextColor(_LABEL)
309
+ font = text.font()
310
+ font.setPointSizeF(max(6.0, self._lane_h - 6.0))
311
+ text.setFont(font)
312
+ text.setPos(0, y - 2)
313
+
314
+ def _draw_bar(self, lane: LaneView, y: float, bar_h: float) -> None:
315
+ top = y + _LANE_PAD
316
+ for s, e in lane.segments:
317
+ self._fill_run(top, bar_h, s, e, _PRESENT)
318
+ # Coalesce flagged frames into contiguous runs before drawing: with a
319
+ # fractional ``cell_w`` and no antialiasing, painting each frame as its
320
+ # own snapped rect leaves sub-pixel seams that read as a ragged
321
+ # "indentation" against the single-rect present bar. One rect per run
322
+ # rounds once and stays flush with the grey bar beneath it.
323
+ for s, e in _runs(lane.validated):
324
+ self._fill_run(top, bar_h, s, e, _VALIDATED)
325
+ for s, e in _runs(lane.anchored):
326
+ self._fill_run(top, bar_h, s, e, _ANCHOR)
327
+
328
+ def _fill_run(
329
+ self, top: float, bar_h: float, start: int, end: int, color: QColor
330
+ ) -> None:
331
+ self._scene.addRect(
332
+ _LEFT_GUTTER + start * self._cell_w, top,
333
+ (end - start + 1) * self._cell_w, bar_h, _no_pen(), color,
334
+ )
335
+
336
+ def _draw_band(self, y: float, row_width: float) -> float:
337
+ """Lay the selected track's thumbnails row-major, wrapping to the width.
338
+
339
+ Returns the ``y`` immediately below the band.
340
+ """
341
+ x = float(_LEFT_GUTTER)
342
+ row_h = 0.0
343
+ row_first = True
344
+ for tile in self._strip.tiles:
345
+ pm = self._tile_pixmap(tile)
346
+ w, h = pm.width(), pm.height()
347
+ if not row_first and x + w > row_width:
348
+ y += row_h + _TILE_ROW_GAP
349
+ x = float(_LEFT_GUTTER)
350
+ row_h = 0.0
351
+ item = self._scene.addPixmap(pm)
352
+ item.setOffset(x, y)
353
+ item.setData(0, int(tile.frame))
354
+ item.setToolTip(self._tile_tooltip(tile))
355
+ self._tile_rects[int(tile.frame)] = QRectF(x, y, w, h)
356
+ x += w + _TILE_GAP
357
+ row_h = max(row_h, h)
358
+ row_first = False
359
+ return y + row_h
360
+
361
+ def _tile_pixmap(self, tile) -> QPixmap:
362
+ pm = QPixmap.fromImage(rgb_to_qimage(tile.rgb)).scaledToHeight(
363
+ self._tile_px, Qt.FastTransformation
364
+ )
365
+ if getattr(tile, "placeholder", False):
366
+ _draw_placeholder(pm)
367
+ return pm
368
+ _draw_marker_strips(
369
+ pm,
370
+ validated=getattr(tile, "validated", False),
371
+ anchored=getattr(tile, "anchored", False),
372
+ )
373
+ return pm
374
+
375
+ # -- markers ------------------------------------------------------------
376
+ def _apply_column_highlight(self) -> None:
377
+ if self._col_item is not None:
378
+ self._scene.removeItem(self._col_item)
379
+ self._col_item = None
380
+ for top, bottom, cid in self._bar_rows:
381
+ if cid == self._selected:
382
+ y = (top + bottom) / 2.0
383
+ self._col_item = self._scene.addLine(
384
+ _LEFT_GUTTER, y, _LEFT_GUTTER + self._n_frames * self._cell_w, y,
385
+ QPen(_COL_SELECT, 1.5),
386
+ )
387
+ self._col_item.setZValue(2)
388
+ return
389
+
390
+ def _apply_frame_guide(self) -> None:
391
+ if self._guide_item is not None:
392
+ self._scene.removeItem(self._guide_item)
393
+ self._guide_item = None
394
+ if not self._bar_rows or self._n_frames <= 0:
395
+ return
396
+ x = _LEFT_GUTTER + self._current_frame * self._cell_w + self._cell_w / 2.0
397
+ bottom = self._scene.sceneRect().height()
398
+ self._guide_item = self._scene.addLine(x, 0, x, bottom, QPen(_FRAME_GUIDE, 1.5))
399
+ self._guide_item.setZValue(3)
400
+
401
+ def _apply_current_border(self) -> None:
402
+ if self._border_item is not None:
403
+ self._scene.removeItem(self._border_item)
404
+ self._border_item = None
405
+ rect = self._tile_rects.get(self._current_frame)
406
+ if rect is None:
407
+ return
408
+ self._border_item = self._scene.addRect(rect, QPen(_CURRENT_FRAME_BORDER, 2))
409
+ self._border_item.setZValue(4)
410
+
411
+ @staticmethod
412
+ def _tile_tooltip(tile) -> str:
413
+ if getattr(tile, "placeholder", False):
414
+ return f"Frame {tile.frame} — track absent (no nucleus)"
415
+ tags = []
416
+ if getattr(tile, "validated", False):
417
+ tags.append("validated")
418
+ if getattr(tile, "anchored", False):
419
+ tags.append("anchored")
420
+ suffix = f" ({', '.join(tags)})" if tags else ""
421
+ return f"Frame {tile.frame}{suffix} — click to jump"
422
+
423
+ # -- Ctrl+wheel region helpers ------------------------------------------
424
+ def _over_band(self, scene_y: float) -> bool:
425
+ """True when ``scene_y`` is inside the open thumbnail band."""
426
+ if self._band_range is None:
427
+ return False
428
+ top, bottom = self._band_range
429
+ return top <= scene_y <= bottom
430
+
431
+ def _zoom_tiles(self, delta: int) -> None:
432
+ tile_px = max(_TILE_PX_MIN, min(self._tile_px + delta, _TILE_PX_MAX))
433
+ if tile_px == self._tile_px:
434
+ return
435
+ self._tile_px = tile_px
436
+ self._relayout()
437
+
438
+ def _zoom_lane_height(self, delta: float) -> None:
439
+ lane_h = max(_LANE_H_MIN, min(self._lane_h + delta, _LANE_H_MAX))
440
+ if lane_h == self._lane_h:
441
+ return
442
+ self._lane_h = lane_h
443
+ self._relayout()
444
+
445
+ def _ctrl_wheel_zoom(self, *, up: bool, scene_y: float) -> None:
446
+ """Region-aware Ctrl+wheel: resize tiles over the band, else bar height."""
447
+ if self._over_band(scene_y):
448
+ self._zoom_tiles(_TILE_ZOOM_STEP if up else -_TILE_ZOOM_STEP)
449
+ else:
450
+ self._zoom_lane_height(_LANE_H_STEP if up else -_LANE_H_STEP)
451
+
452
+ # -- hit-testing --------------------------------------------------------
453
+ def _activate_at(self, scene_x: float, scene_y: float) -> None:
454
+ """Translate a click into a thumbnail jump or a bar (frame, cell) jump."""
455
+ if self._over_band(scene_y):
456
+ for frame, rect in self._tile_rects.items():
457
+ if rect.contains(scene_x, scene_y):
458
+ self.frame_clicked.emit(int(frame))
459
+ return
460
+ return
461
+ for top, bottom, cell_id in self._bar_rows:
462
+ if top <= scene_y <= bottom:
463
+ lane = self._lanes_by_cell.get(cell_id)
464
+ if lane is None or self._cell_w <= 0:
465
+ return
466
+ frame = int((scene_x - _LEFT_GUTTER) // self._cell_w)
467
+ frame = min(max(frame, 0), max(self._n_frames - 1, 0))
468
+ if not lane.present(frame):
469
+ frame = lane.nearest_present(frame)
470
+ self.node_activated.emit(int(frame), int(cell_id))
471
+ return
472
+
473
+
474
+ def _no_pen() -> QPen:
475
+ return QPen(Qt.NoPen)
476
+
477
+
478
+ def _runs(frames: frozenset[int]) -> tuple[tuple[int, int], ...]:
479
+ """Collapse a frame set into inclusive ``(start, end)`` contiguous runs."""
480
+ if not frames:
481
+ return ()
482
+ return tuple(
483
+ (seg.start, seg.end) for seg in _segments_from_frames(sorted(frames))
484
+ )
485
+
486
+
487
+ _PLACEHOLDER_FILL = QColor(48, 50, 56) # empty-tile body (a touch above bg)
488
+ _PLACEHOLDER_BORDER = QColor(90, 96, 107) # dashed frame marking a missing frame
489
+
490
+
491
+ def _draw_placeholder(pixmap: QPixmap) -> None:
492
+ """Paint a tile for a frame the track skips: a dim body with a dashed frame.
493
+
494
+ The blank ``rgb`` would otherwise read as a hole in the band on the dark
495
+ scene; this gives missing frames a clearly-empty, dashed-outline thumbnail.
496
+ """
497
+ painter = QPainter(pixmap)
498
+ w, h = pixmap.width(), pixmap.height()
499
+ painter.fillRect(0, 0, w, h, _PLACEHOLDER_FILL)
500
+ pen = QPen(_PLACEHOLDER_BORDER, 1, Qt.DashLine)
501
+ painter.setPen(pen)
502
+ painter.drawRect(0, 0, w - 1, h - 1)
503
+ painter.end()
504
+
505
+
506
+ def _draw_marker_strips(pixmap: QPixmap, *, validated: bool, anchored: bool) -> None:
507
+ if not (validated or anchored):
508
+ return
509
+ painter = QPainter(pixmap)
510
+ width = pixmap.width()
511
+ height = _MARKER_STRIP_PX
512
+ if validated and anchored:
513
+ half = width // 2
514
+ painter.fillRect(0, 0, half, height, QColor(_VALIDATED_STRIP_COLOR))
515
+ painter.fillRect(half, 0, width - half, height, QColor(_ANCHOR_STRIP_COLOR))
516
+ elif validated:
517
+ painter.fillRect(0, 0, width, height, QColor(_VALIDATED_STRIP_COLOR))
518
+ else:
519
+ painter.fillRect(0, 0, width, height, QColor(_ANCHOR_STRIP_COLOR))
520
+ painter.end()
521
+
522
+
523
+ class _AccordionView(QGraphicsView):
524
+ """Plain-wheel scrolls; Ctrl+wheel zooms region-aware; a click reports it."""
525
+
526
+ def __init__(self, panel: TrackAccordionPanel) -> None:
527
+ super().__init__(panel._scene, panel)
528
+ self._panel = panel
529
+ self._press_pos = None
530
+ self.setAlignment(Qt.AlignTop | Qt.AlignLeft)
531
+ self.setDragMode(QGraphicsView.NoDrag)
532
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
533
+
534
+ def wheelEvent(self, event) -> None:
535
+ if not (event.modifiers() & Qt.ControlModifier):
536
+ super().wheelEvent(event) # plain wheel scrolls vertically
537
+ return
538
+ point = event.position().toPoint() if hasattr(event, "position") else event.pos()
539
+ self._panel._ctrl_wheel_zoom(
540
+ up=event.angleDelta().y() > 0,
541
+ scene_y=self.mapToScene(point).y(),
542
+ )
543
+ event.accept()
544
+
545
+ def resizeEvent(self, event) -> None:
546
+ super().resizeEvent(event)
547
+ # cell_w and band wrapping are width-derived, so re-lay on every resize.
548
+ self._panel._relayout()
549
+
550
+ def mousePressEvent(self, event) -> None:
551
+ self._press_pos = event.pos()
552
+ super().mousePressEvent(event)
553
+
554
+ def mouseReleaseEvent(self, event) -> None:
555
+ super().mouseReleaseEvent(event)
556
+ if self._press_pos is None:
557
+ return
558
+ moved = (event.pos() - self._press_pos).manhattanLength()
559
+ self._press_pos = None
560
+ if moved > _CLICK_SLOP:
561
+ return # a scroll-drag, not a click
562
+ pt = self.mapToScene(event.pos())
563
+ self._panel._activate_at(pt.x(), pt.y())
564
+
565
+
566
+ __all__ = ["LaneView", "TrackAccordionPanel", "rgb_to_qimage"]