diff --git a/src/glider/analysis/timeline.py b/src/glider/analysis/timeline.py new file mode 100644 index 00000000..becdd7e7 --- /dev/null +++ b/src/glider/analysis/timeline.py @@ -0,0 +1,445 @@ +"""One session as lanes on a single time axis. + +The event log has always held everything a hardware raster needs — every +pin edge and every commanded write, timestamped against a session epoch +the other recorders share. Nothing had drawn it. This builds the lanes; +:mod:`glider.gui.widgets.timeline_bar` draws them. + +Qt-free on purpose, in the same way :mod:`glider.analysis.behavior.session_view` +is: the axis arithmetic and the lane building are the parts worth testing, +and neither needs a display. + +Everything here is in **flow-relative milliseconds** — t=0 is +StartExperiment, matching what an analyst already reasons in. Events that +predate flow start (device initialisation, which is exactly when a rig is +most likely to be left in the wrong state) carry negative times and are +drawn rather than clipped. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from glider.analysis.behavior.session_view import SessionView + from glider.analysis.session import Session + +__all__ = [ + "BehaviorLane", + "FrameMap", + "Lane", + "Marker", + "Segment", + "Timeline", + "build_frame_map", + "build_timeline", + "hardware_lanes", +] + + +@dataclass(frozen=True) +class FrameMap: + """Frame index to flow-relative milliseconds, and back. + + Args: + frames: Frame indices, sorted ascending, one per known sample. + ms: The flow-relative time of each of those frames. + source: ``"tracking"`` for the empirical per-frame map, + ``"frame_rate"`` for the nominal-rate estimate. + """ + + frames: np.ndarray + ms: np.ndarray + source: str + + def ms_of(self, frame: int) -> float: + """When ``frame`` happened. Clamps outside the known range.""" + return float(np.interp(float(frame), self.frames, self.ms)) + + def frame_at(self, ms: float) -> int: + """The first frame at or after ``ms``. Clamps outside the known range. + + A ceiling, not a rounding: ``searchsorted`` picks the first sample + whose time is >= ``ms``, so a time landing between two frames resolves + to the later one. Callers depend on that — the bar's column edges are + half-open spans, and rounding to the nearer frame would let two + adjacent columns claim the same one. + """ + if len(self.frames) == 0: + return 0 + idx = int(np.searchsorted(self.ms, float(ms))) + idx = max(0, min(idx, len(self.frames) - 1)) + return int(self.frames[idx]) + + +def build_frame_map(session: Session, flow_offset_ms: float = 0.0) -> FrameMap | None: + """The frame/time mapping for a session, or None if it has neither. + + Resolved in order: + + 1. The tracking CSV's own ``frame`` and ``elapsed_ms`` columns. This is + an empirical map and stays correct across dropped frames, which a + nominal-rate calculation does not — a dropped frame shifts every + later frame by one in the nominal version and by nothing here. + 2. :attr:`Session.frame_rate` against the frame index, for a recording + whose tracking is too thin for (1). + 3. Neither: ``None``. The caller draws no hardware lanes, which is + correct rather than degraded — an ethogram-only session has no + hardware data to place on a time axis. + + Args: + session: The loaded recording. + flow_offset_ms: Session-elapsed ms of flow start, subtracted from + every time so the result is flow-relative. + """ + tracking = session.tracking + if not tracking.empty and {"frame", "elapsed_ms"}.issubset(tracking.columns): + pairs = tracking[["frame", "elapsed_ms"]].dropna() + # Several objects in one frame share a timestamp; one row per frame. + pairs = pairs.drop_duplicates(subset="frame").sort_values("frame") + if len(pairs) >= 2: + return FrameMap( + frames=pairs["frame"].to_numpy(dtype=float), + ms=pairs["elapsed_ms"].to_numpy(dtype=float) - flow_offset_ms, + source="tracking", + ) + + fps = session.frame_rate + if fps: + last = 1.0 + if not tracking.empty and "frame" in tracking.columns: + last = max(1.0, float(tracking["frame"].max())) + frames = np.array([0.0, last]) + return FrameMap( + frames=frames, + ms=frames / fps * 1000.0 - flow_offset_ms, + source="frame_rate", + ) + + return None + + +#: Full-scale value per pin type, for turning a written value into a bar +#: height. Normalising by each lane's own observed maximum instead would be +#: marginally cheaper and would draw a PWM that never exceeded 10 as full +#: brightness — same amount of code, wrong picture. +_PIN_FULL_SCALE = { + "DIGITAL": 1.0, + "PWM": 255.0, + "SERVO": 180.0, + "ANALOG": 1023.0, +} + + +@dataclass(frozen=True) +class Segment: + """A device holding one value over a span of time.""" + + start_ms: float + end_ms: float + value: float + level: float # 0.0-1.0, for bar height + + +@dataclass(frozen=True) +class Marker: + """An instant with no level — a non-numeric event value.""" + + at_ms: float + label: str + + +@dataclass(frozen=True) +class Lane: + """One row of the raster.""" + + key: str + label: str + board_id: str + segments: list[Segment] + markers: list[Marker] + + +def _cell(value) -> str: + """A CSV cell as text. + + Two shapes have to be flattened. Empty cells arrive as NaN rather than + "". And a column that mixes blanks with numbers — `pin` does, because + flow_marker rows leave it empty — is inferred as float64, so pin 7 + arrives as 7.0 and would otherwise name a lane "board0:pin7.0". + """ + if value is None: + return "" + if isinstance(value, float): + if value != value: # NaN + return "" + if value.is_integer(): + return str(int(value)) + text = str(value) + return "" if text in ("nan", "NaN", "", "None") else text.strip() + + +def _full_scale(pin_types: list[str], observed_max: float) -> float: + """The value that should draw as a full-height bar. + + The pin type's known range when there is one, except where the lane + actually exceeded it — a 12-bit board reads an ANALOG pin to 4095, and + clipping that at the 10-bit 1023 would draw the whole session as one + saturated row. + """ + for pin_type in pin_types: + full = _PIN_FULL_SCALE.get(pin_type.upper()) + if full is not None: + return full if observed_max <= full else observed_max + return observed_max if observed_max > 0 else 1.0 + + +def hardware_lanes( + session: Session, + flow_offset_ms: float = 0.0, + end_ms: float | None = None, +) -> list[Lane]: + """One lane per device, from the event log. + + Each event sets its device's value and that value holds until the + device's next event — a zero-order hold. One rule covers digital, PWM + and servo: a digital pin gives full-height blocks and a PWM ramp gives + stepped ones, without a branch per pin type. + + Args: + session: The loaded recording. + flow_offset_ms: Session-elapsed ms of flow start, subtracted from + every event time. + end_ms: Where the last held value stops. Defaults to the last + event's own time, which draws it as zero-width. + """ + events = session.events + if events.empty: + return [] + + rows = events[events["source"] != "flow_marker"].copy() + if rows.empty: + return [] + + rows["_ms"] = rows["elapsed_ms"].astype(float) - flow_offset_ms + rows["_device"] = [_cell(v) for v in rows["device_id"]] + rows["_board"] = [_cell(v) for v in rows["board_id"]] + rows["_pin"] = [_cell(v) for v in rows["pin"]] + rows["_pin_type"] = [_cell(v) for v in rows["pin_type"]] + # A board-level write with no resolved device still deserves a row. + rows["_key"] = [ + device or f"{board}:pin{pin}" + for device, board, pin in zip(rows["_device"], rows["_board"], rows["_pin"], strict=True) + ] + rows = rows.sort_values("_ms", kind="stable") + + tail = end_ms if end_ms is not None else float(rows["_ms"].max()) + + lanes: list[Lane] = [] + for key, group in rows.groupby("_key", sort=False): + times = group["_ms"].to_numpy(dtype=float) + markers: list[Marker] = [] + levels: list[tuple[float, float]] = [] # (ms, numeric value) + + for ms, raw in zip(times, group["value"], strict=True): + text = _cell(raw) + if not text: + # The event logger writes "" for a None value, not a + # missing cell — neither a level nor a marker for that. + continue + try: + levels.append((float(ms), float(text))) + except ValueError: + markers.append(Marker(at_ms=float(ms), label=text)) + + observed_max = max((v for _, v in levels), default=0.0) + full = _full_scale(list(dict.fromkeys(group["_pin_type"])), observed_max) + + segments = [ + Segment( + start_ms=ms, + # The last segment ends at `tail` unless that end would + # precede its own start — a session can legitimately end + # before its last event (camera stops before the flow + # tears down), and a negative span draws as an inverted + # or invisible rect. + end_ms=(levels[i + 1][0] if i + 1 < len(levels) else max(tail, ms)), + value=value, + level=max(0.0, min(1.0, value / full)), + ) + for i, (ms, value) in enumerate(levels) + ] + + label = _cell(group["_device"].iloc[0]) or str(key) + lanes.append( + Lane( + key=str(key), + label=label, + board_id=_cell(group["_board"].iloc[0]), + segments=segments, + markers=markers, + ) + ) + + lanes.sort(key=lambda lane: (lane.board_id, lane.key)) + return lanes + + +@dataclass(frozen=True) +class BehaviorLane: + """Per-frame behaviour labels, and where they came from. + + Deliberately not converted to :class:`Segment` runs. The bar resolves + behaviour per *pixel column* by majority rather than drawing one rect + per run, because a five-minute session holds ~9000 rows against ~2000 + pixels and sub-pixel rects blend by coverage — which is what made + every colour on the old bar an average of several behaviours. + """ + + source: str # "ethogram" | "tracking" + labels: list[str] + frames: np.ndarray + + +@dataclass(frozen=True) +class Timeline: + """Everything drawable about one session, on one flow-relative axis.""" + + lanes: list[Lane] + behavior: list[BehaviorLane] + frame_map: FrameMap | None + flow_start_ms: float | None + flow_end_ms: float | None + start_ms: float + end_ms: float + + +def _flow_elapsed(session: Session, marker: str) -> float | None: + """Session-elapsed ms of a flow marker, or None if it never fired.""" + events = session.events + if events.empty: + return None + hit = events[(events["source"] == "flow_marker") & (events["value"] == marker)] + if hit.empty: + return None + return float(hit["elapsed_ms"].iloc[0]) + + +def build_timeline(session: Session | None, view: SessionView | None = None) -> Timeline: + """Assemble a session into lanes on one axis. + + Args: + session: A loaded recording, or None for an ethogram-only view. + view: An optional :class:`~glider.analysis.behavior.session_view.SessionView`, + contributing the classifier ethogram as its own behaviour lane. + + A live recording carries ``behavioral_state`` in its tracking CSV and a + behaviour apply-run produces a classifier ethogram. These are different + things at different quality, so when both are present they stay two + lanes. Collapsing them would misrepresent provenance. + """ + behavior: list[BehaviorLane] = [] + if view is not None and len(view.labels): + behavior.append( + BehaviorLane(source="ethogram", labels=list(view.labels), frames=view.frames) + ) + + if session is None: + return Timeline( + lanes=[], + behavior=behavior, + frame_map=None, + flow_start_ms=None, + flow_end_ms=None, + start_ms=0.0, + end_ms=0.0, + ) + + flow_start_elapsed = _flow_elapsed(session, "start") + flow_end_elapsed = _flow_elapsed(session, "end") + offset = flow_start_elapsed or 0.0 + + frame_map = build_frame_map(session, offset) + + tracking = session.tracking + if not tracking.empty and "behavioral_state" in tracking.columns: + # A multi-subject recording has one tracking row per object per + # frame, all sharing a frame number. Deduping across objects (as a + # single global drop_duplicates would) silently keeps whichever + # object's row happens to sort first and discards the rest — so + # group by object_id *before* deduping, and only dedup within one + # object's own rows, where it is safe because a single object has + # exactly one row per frame. An older/hand-made CSV without an + # object_id column at all falls back to one lane, unchanged — and + # so does one whose object_id column is present but entirely + # empty, since there is then nothing to group by. + if "object_id" in tracking.columns: + object_ids = sorted(tracking["object_id"].dropna().unique()) + else: + object_ids = [] + if not object_ids: + object_ids = [None] + + # "multi_object" is decided by how many objects actually produced + # a lane, not by how many distinct object_id values exist. + # tracking_logger.py writes object_id=-1 with a blank + # behavioral_state for motion-only/heartbeat rows; that id never + # survives the per-object dropna() below, but counting raw ids + # would still see it and wrongly rename a single real subject's + # lane to "tracking[0]". Counting surviving lanes instead needs no + # knowledge of sentinel values, so a future sentinel id — or a + # second object whose behavioral_state is entirely blank — can't + # reintroduce the same bug. + per_object_frames = [] + for object_id in object_ids: + obj_tracking = ( + tracking if object_id is None else tracking[tracking["object_id"] == object_id] + ) + per_frame = obj_tracking[["frame", "behavioral_state"]].dropna() + per_frame = per_frame.drop_duplicates(subset="frame").sort_values("frame") + if len(per_frame): + per_object_frames.append((object_id, per_frame)) + + multi_object = len(per_object_frames) > 1 + for object_id, per_frame in per_object_frames: + source = "tracking" if not multi_object else f"tracking[{int(object_id)}]" + behavior.append( + BehaviorLane( + source=source, + labels=[str(v) for v in per_frame["behavioral_state"]], + frames=per_frame["frame"].to_numpy(dtype=int), + ) + ) + + # The axis spans everything drawable. A session with device-init writes + # 30s before flow start shows those 30s: unlike a windowed ethogram's + # empty lead-in, a pre-flow region holds content, and it answers the + # most common question a hardware session raises — was a device already + # in the wrong state before the run began. + candidates_start = [0.0] + candidates_end = [0.0] + if frame_map is not None and len(frame_map.ms): + candidates_start.append(float(frame_map.ms[0])) + candidates_end.append(float(frame_map.ms[-1])) + if not session.events.empty: + event_ms = session.events["elapsed_ms"].astype(float) - offset + candidates_start.append(float(event_ms.min())) + candidates_end.append(float(event_ms.max())) + if flow_end_elapsed is not None: + candidates_end.append(flow_end_elapsed - offset) + + start_ms = min(candidates_start) + end_ms = max(candidates_end) + + return Timeline( + lanes=hardware_lanes(session, offset, end_ms), + behavior=behavior, + frame_map=frame_map, + flow_start_ms=None if flow_start_elapsed is None else 0.0, + flow_end_ms=None if flow_end_elapsed is None else flow_end_elapsed - offset, + start_ms=start_ms, + end_ms=end_ms, + ) diff --git a/src/glider/gui/behavior/analysis_window.py b/src/glider/gui/behavior/analysis_window.py index 6f21c735..cf069a84 100644 --- a/src/glider/gui/behavior/analysis_window.py +++ b/src/glider/gui/behavior/analysis_window.py @@ -21,7 +21,7 @@ from pathlib import Path import numpy as np -from PyQt6.QtCore import QPointF, QRectF, Qt, QTimer, pyqtSignal +from PyQt6.QtCore import QPointF, QRectF, Qt, QTimer from PyQt6.QtGui import QBrush, QColor, QIcon, QImage, QPainter, QPen, QPixmap from PyQt6.QtWidgets import ( QCheckBox, @@ -44,8 +44,14 @@ QWidget, ) -from glider.analysis.behavior.session_view import SessionView, SessionViewError +from glider.analysis.behavior.session_view import ( + _SEARCH_LEVELS, + SessionView, + SessionViewError, +) +from glider.analysis.timeline import build_timeline from glider.gui.styles import colors +from glider.gui.widgets.timeline_bar import TimelineBar, behavior_order, behavior_qcolor from glider.gui.widgets.tool_ui import ( CARD_GAP, GUTTER, @@ -114,6 +120,20 @@ def _behavior_item(name: str, order: list[str]) -> QTableWidgetItem: return item +def _recording_or_none(folder: Path): + """The GLIDER recording in *folder*, or None if there isn't one. + + ``Session.load`` returns an empty session for any readable directory at + all, so "did it load" answers nothing — "did discovery find an artifact" + is the question, and an empty frame is how a missing one arrives. + """ + from glider.analysis import Session + + session = Session.load(folder) + found = any(not frame.empty for frame in (session.tracking, session.data, session.events)) + return session if found else None + + def _dress_table(table: QTableWidget) -> None: """Make a results table read as a table rather than a grid in a box. @@ -154,256 +174,6 @@ def _vrule() -> QFrame: return line -def behavior_qcolor(name: str, order: list[str] | None = None) -> QColor: - """The colour the annotated video would have drawn this behaviour in. - - Shared with the overlay so a bout looks the same wherever it is shown; - blank (unscored) frames read as background rather than a colour. - - ``order`` is the behaviours present, which is what makes the colours - reliably *different*. Without it the palette slot comes from a hash of the - name, and a hash has no reason to avoid collisions: two behaviours in one - session could land on the same colour, and neighbouring ones routinely - landed on adjacent hues. Given the session's own label set, the first N - palette entries are handed out in order, and N distinct behaviours get N - distinct colours. - """ - if not name: - return QColor(colors.BORDER) - from glider.analysis.behavior.classify.overlay import color_for_behavior - - b, g, r = color_for_behavior(name, order) - return QColor(r, g, b) - - -def behavior_order(labels) -> list[str]: - """The behaviours present, in a stable order. - - Sorted rather than first-appearance: the same cohort scored twice must - colour the same behaviour the same way, and first-appearance makes that - depend on which animal happened to groom first. - """ - return sorted({label for label in labels if label}) - - -class EthogramBar(QWidget): - """The ethogram as a timeline: bands to read, and the scrubber to drag. - - Clicking or dragging with the left button scrubs; dragging with shift (or - the right button) selects a window. Selection and playhead are separate so - a chosen window survives scrubbing around inside it. - """ - - scrubbed = pyqtSignal(int) # frame - selection_changed = pyqtSignal(int, int) # start, end frame - - def __init__(self, parent=None): - super().__init__(parent) - self.setMinimumHeight(_BAR_HEIGHT) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.setCursor(Qt.CursorShape.PointingHandCursor) - self._view: SessionView | None = None - self._order: list[str] = [] - self._codes: np.ndarray | None = None - self._lane: QPixmap | None = None - self._frame = 0 - self._selection: tuple[int, int] | None = None - self._drag_anchor: int | None = None - - def set_view(self, view: SessionView | None) -> None: - self._view = view - # Computed once per session rather than per band: the colour a - # behaviour gets depends on which behaviours this session contains, - # and the per-column majority below needs the labels as integers. - self._order = behavior_order(view.labels) if view is not None else [] - if view is None: - self._codes = None - else: - slot = {name: i + 1 for i, name in enumerate(self._order)} # 0 = unscored - self._codes = np.array([slot.get(label, 0) for label in view.labels], dtype=np.int64) - self._lane = None - self._frame = 0 - self._selection = None - self.update() - - def set_frame(self, frame: int) -> None: - self._frame = int(frame) - self.update() - - def resizeEvent(self, event): - # The bands are resolved per pixel column, so a different width is a - # different image. - self._lane = None - super().resizeEvent(event) - - def _lane_pixmap(self) -> QPixmap: - """The behaviour bands, drawn once per session and size.""" - if self._lane is None: - self._lane = QPixmap(self.size()) - self._lane.fill(QColor(colors.BASE)) - lane_painter = QPainter(self._lane) - try: - self._paint_lane(lane_painter, self._view.labels, 0.0, float(self.height())) - finally: - lane_painter.end() - return self._lane - - def selection(self) -> tuple[int, int] | None: - return self._selection - - def set_selection(self, start: int, end: int) -> None: - self._selection = (int(min(start, end)), int(max(start, end))) - self.update() - self.selection_changed.emit(*self._selection) - - # ------------------------------------------------------------------ - - def frame_bounds(self) -> tuple[int, int]: - """``(first, last)`` frame the ethogram actually covers. - - A windowed run scores minutes two to seven, so its ethogram starts at - frame 3600 — and a timeline drawn from zero would spend its first - eighth showing nothing, with a playhead that scrubs through frames no - one scored. The timeline is the ethogram, so it starts where the - ethogram starts. - """ - if self._view is None or self._view.n_rows == 0: - return 0, 0 - return int(self._view.frames[0]), int(self._view.frames[-1]) - - def _span(self) -> int: - first, last = self.frame_bounds() - return max(0, last - first + 1) - - def _frame_at(self, x: float) -> int: - first, last = self.frame_bounds() - span = self._span() - if span == 0 or self.width() <= 0: - return first - return max(first, min(last, first + int(x / self.width() * span))) - - def _x_of(self, frame: int) -> float: - first, _last = self.frame_bounds() - span = self._span() - return 0.0 if span == 0 else (frame - first) / span * self.width() - - def paintEvent(self, _event): - painter = QPainter(self) - painter.fillRect(self.rect(), QColor(colors.BASE)) - if self._view is None or self._span() == 0: - painter.setPen(QPen(QColor(colors.TEXT_MUTED))) - painter.drawText( - self.rect(), Qt.AlignmentFlag.AlignCenter, "Load a session to see its ethogram" - ) - return - - # One lane, because there is one behaviour per frame. Freezing and - # darting are values of it, not a parallel track: a second lane would - # be drawing the same frames twice. - # - # Cached: the bands only change when the session or the width does, - # while the playhead moves thirty times a second during playback, and - # resolving nine thousand rows into columns on every one of those - # frames is a fifth of the frame budget spent redrawing the same image. - painter.drawPixmap(0, 0, self._lane_pixmap()) - - if self._selection is not None: - start, end = self._selection - x0, x1 = self._x_of(start), self._x_of(end + 1) - # Shade what is EXCLUDED, not what is chosen. Tinting the selection - # blue meant every behaviour inside it was drawn 28% toward the - # accent — and since the usual selection is the whole session, that - # was every colour on the bar, all of them dragged toward the same - # hue. Shading the outside leaves the data at full strength and - # says the same thing. - scrim = QBrush(colors.qcolor_with_alpha(QColor(colors.BASE), 0.72)) - painter.fillRect(QRectF(0, 0, max(0.0, x0), self.height()), scrim) - painter.fillRect(QRectF(x1, 0, max(0.0, self.width() - x1), self.height()), scrim) - painter.setPen(QPen(QColor(colors.ACCENT), 2)) - painter.drawLine(QPointF(x0, 0), QPointF(x0, self.height())) - painter.drawLine(QPointF(x1, 0), QPointF(x1, self.height())) - - painter.setPen(QPen(QColor(colors.TEXT_PRIMARY), 2)) - x = self._x_of(self._frame) - painter.drawLine(QPointF(x, 0), QPointF(x, self.height())) - - def _paint_lane(self, painter, labels, top: float, height: float) -> None: - """One band per *pixel column*, coloured by what dominates it. - - Not one rect per run, which is the obvious thing and was wrong. A - five-minute session holds around nine thousand scored rows and the - timeline is at most a couple of thousand pixels wide, so a typical run - is a fraction of a pixel: Qt drew each as a sub-pixel rectangle and - blended it with its neighbours by coverage. Every colour on the bar was - therefore an average of several behaviours — a bright yellow, a green - and a blue arriving on screen as one flat olive. No palette can survive - that, and it is why the bar looked washed out however distinct the - colours themselves were. - - Resolving to whole columns first makes every pixel one behaviour's - actual colour. It also means a run shorter than a column is not drawn, - which is honest — the bar shows proportions, and a pixel cannot show a - three-frame dart without overstating it. The bout stepper is how those - are reached. - """ - if height <= 0 or not labels or self._codes is None: - return - width = self.width() - span = self._span() - if width <= 0 or span == 0: - return - - first, _last = self.frame_bounds() - frames = self._view.frames - # Which row each column starts at: the columns are equal slices of the - # frame axis, and the rows are already sorted by frame. - edges = first + np.arange(width + 1, dtype=np.int64) * span // width - starts = np.searchsorted(frames, edges, side="left") - - n_codes = len(self._order) + 1 # + the unscored bucket - for x in range(width): - lo, hi = int(starts[x]), int(starts[x + 1]) - if hi <= lo: - # More pixels than rows: this column falls between two rows, so - # it takes the row to its left rather than a gap in the bar. - lo, hi = max(0, min(lo, len(frames) - 1)), max(0, min(lo, len(frames) - 1)) + 1 - counts = np.bincount(self._codes[lo:hi], minlength=n_codes) - code = int(counts.argmax()) - painter.fillRect( - QRectF(x, top, 1.0, height), - behavior_qcolor(self._order[code - 1] if code else "", self._order), - ) - - # ------------------------------------------------------------------ - - def mousePressEvent(self, event): - if self._view is None: - return - frame = self._frame_at(event.position().x()) - selecting = ( - event.button() == Qt.MouseButton.RightButton - or event.modifiers() & Qt.KeyboardModifier.ShiftModifier - ) - if selecting: - self._drag_anchor = frame - self.set_selection(frame, frame) - else: - self._drag_anchor = None - self.scrubbed.emit(frame) - - def mouseMoveEvent(self, event): - if self._view is None: - return - frame = self._frame_at(event.position().x()) - if self._drag_anchor is not None: - self.set_selection(self._drag_anchor, frame) - elif event.buttons() & Qt.MouseButton.LeftButton: - self.scrubbed.emit(frame) - - def mouseReleaseEvent(self, _event): - self._drag_anchor = None - - class KeypointCanvas(QWidget): """The animal drawn from its poses, with a trailing centroid track. @@ -690,6 +460,10 @@ def __init__(self, parent=None): self._cohort: list[tuple[Path, SessionView]] = [] # (key, rows) for the cohort table — see cohort_rows. self._cohort_cache: tuple[tuple, list[dict]] | None = None + # Recording folder -> its loaded Session (None: nothing GLIDER wrote + # there). Clicking through a cohort re-adopts a session per click, and + # a recording's CSVs are megabytes parsed on the GUI thread. + self._recordings: dict[Path, object] = {} central = QWidget() central.setObjectName("ToolPage") @@ -797,7 +571,7 @@ def __init__(self, parent=None): self._canvas = KeypointCanvas() viewer_body.addWidget(self._canvas, 1) - self._bar = EthogramBar() + self._bar = TimelineBar() self._bar.scrubbed.connect(self._set_frame) self._bar.selection_changed.connect(self._on_selection) viewer_body.addWidget(self._bar) @@ -1045,7 +819,7 @@ def _refresh_bout_filter(self) -> None: self._bout_filter.blockSignals(True) self._bout_filter.clear() self._bout_filter.addItem("Any change", None) - for name in behavior_order(self._view.labels if self._view else []): + for name in self._bar.behavior_order(): self._bout_filter.addItem(name, name) index = self._bout_filter.findData(previous) # A behaviour the new session does not contain falls back to "any" @@ -1108,6 +882,51 @@ def load(self, ethogram_csv: Path, *, pose_csv: Path | None = None) -> None: self._sessions.setVisible(False) self._adopt(Path(ethogram_csv), view) + def _recording_folders(self, ethogram_csv: Path, view: SessionView) -> list[Path]: + """Where this ethogram's recording might be, nearest first. + + Deliberately not a fourth path resolver: every candidate is one the + session loader already resolved or already searches. An apply run + writes ``/