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