Skip to content

feat(gui): a hardware raster in Session Review - #178

Merged
gbradham merged 12 commits into
mainfrom
claude/session-review-multicam-hardware-cf5a01
Sep 10, 2026
Merged

gbradham merged 12 commits into
mainfrom
claude/session-review-multicam-hardware-cf5a01

Conversation

@gbradham

Copy link
Copy Markdown
Member

Session Review has only ever opened a behaviour ethogram. Run an experiment that drives hardware and there was nowhere in GLIDER to open the result and see what the rig did.

The event log already held everything a raster needs — every pin edge and every commanded write, timestamped, with all recorders sharing one session epoch so elapsed_ms is joinable across files. Nothing had ever drawn it.

This is P1 of three. The ethogram becomes one lane; the hardware becomes lanes below it; they share one time axis and one playhead.

What changed

  • glider/analysis/timeline.py (new, Qt-free) — FrameMap (frame ↔ flow-relative ms), Segment/Marker/Lane built by zero-order hold, BehaviorLane, Timeline, and the builders.
  • glider/gui/widgets/timeline_bar.py (new) — the widget. Also now owns behavior_qcolor / behavior_order, which analysis_window re-exports.
  • glider/gui/behavior/analysis_window.py — EthogramBar deleted, TimelineBar swapped in, timeline resolved and fed in _adopt.

Tests: 5138 → 5168. ruff and black clean.

Design decisions

The axis. The widget works internally in time, but selection_changed still emits frames — every table below it consumes a frame range (zone_rows, cohort_rows, segment_stats), so converting at the signal boundary leaves the whole panel untouched. Hardware events are timestamped in ms and their frame cell is empty for anything before the first camera frame, which is exactly where device-init writes live.

The frame↔ms map resolves in order: the tracking CSV's own frame/elapsed_ms pairs (empirical, stays correct across dropped frames, which a nominal-fps calculation does not) → frame_rate × index → neither, in which case there is no ms axis.

t=0 is flow start. The axis spans everything drawable, so pre-flow setup writes stay visible rather than being clipped — a device already in the wrong state before the run began is the most common question a hardware session raises.

Lanes. One per device_id. Each event sets that device's value and it holds until the device's next event, so one renderer covers digital, PWM and servo. Bar height normalises against the pin type's full scale (DIGITAL 1, PWM 255, SERVO 180, ANALOG 1023), falling back to the observed maximum where a lane exceeds it — a 12-bit board reads ANALOG to 4095, and clipping at 1023 would draw the session as one saturated row.

Flow markers are vertical rules across every lane, not a lane. Behaviour lanes come from both sources when both exist — the classifier ethogram and the tracking CSV's behavioral_state are different things at different quality, and one lane per tracked subject, since collapsing them silently showed only object 0.

Reviewer notes — two known issues

Both are small and validated, left for this review rather than a further round:

  1. _paint_behavior fabricates behaviour outside the frame map's ms range. A 5 s pre-flow lead-in paints the first frame's label across ~200 px; a camera that stops before the flow tears down paints the last label across the final third. Same class as a clamp already fixed here, reached through the ms clamp instead. A 3-line fix (gating the hi <= lo branch on the ms range as well as the frame range) was prototyped and passed 579/579.
  2. The recording cache is never cleared. A cached None poisons a directory for the window's lifetime — load before the recording exists, add it, and the same window still shows nothing. self._recordings.clear() in load() and load_many().

Also noted, not blocking: the bout picker now lists tracking-only labels the ethogram lacks (stepping to one is a no-op); _timeline_for falls through to an ancestor recording if build_timeline raises for the true folder, where it should stop; sizeHint gives the ethogram-only bar 48 px where the docstring promises 46.

Not in P1

Multi-camera anything, session-folder unification (sessions/<id>/), offset calibration, and sensor traces from data.csv. Those are P2 and P3.

There are three independent file-resolution mechanisms in the tree today — core.Session/core.Project (a finished four-phase subsystem no GUI code imports), SessionView, and _io.discover. P2 collapses them into one, which is what makes multi-cam grouping fall out for free rather than needing to be solved.

Adds TimelineBar, the widget that replaces the single-lane ethogram bar
with a stack of lanes sharing one time axis and one playhead: the
ethogram becomes one lane, the hardware the rig drove becomes the lanes
below it. The widget's internal axis is time, but selection_changed
still emits frames so every stats table below it keeps working
unmodified.

Moves behavior_qcolor and behavior_order out of analysis_window.py into
timeline_bar.py (analysis_window imports them back) to avoid a circular
import once analysis_window pulls in TimelineBar in a later task.
_paint_behavior kept the ethogram bar's frame-linear column edges, which
only agree with _x_of_ms when the ms bounds exactly match the frame map's
range. The axis deliberately spans pre-flow device writes, so they usually
do not: a session with 30s of device init ahead of the camera drew the
ethogram across the whole bar while the camera occupied its last quarter,
and the playhead sat on a band showing the wrong behaviour. Columns are
now sliced in milliseconds and converted through the frame map; the
frame-linear path stays for the ethogram-only case, where the axis really
is frames.

frame_bounds now falls back to the behaviour lanes' own frame range rather
than to self._view. build_timeline(None, view) yields no frame map and no
ms bounds, so set_timeline without a companion set_view reported (0, 0),
collapsing every column onto frame 0 and flattening the ethogram to one
colour.

The selection overlay goes back to shading what is EXCLUDED, with the
comment recording why: tinting the selection dragged every behaviour
inside it toward the accent, and the usual selection is the whole session.
Its right edge is _x_of(end + 1), which the accent version cut a column
short.

Both pixel tests now assert actual colours; each was verified to fail with
its painter stubbed to a no-op.
Session Review loaded `ethogram_csv.parent` as the recording directory,
which is off by exactly one level: an apply run writes the ethogram into
`<output>/<video stem>/`, and the canonical layout buries it under
`sessions/<id>/analysis/` with the CSVs a level above. Discovery found no
`# GLIDER ...` markers in either shape, so no GLIDER path ever drew a
hardware lane at all.

Rather than a fourth path resolver, ask what the session loader already
resolved: the video it found (via run.json where there is one) sits in
the recording folder, and otherwise walk up the same three levels
session_view searches. The first candidate that actually holds an
artifact wins; none does, and the timeline is ethogram-only as before.
Each folder is parsed once and cached, so stepping through a cohort no
longer re-reads the same recording on the GUI thread per click, and
build_timeline moved inside the guard — a KeyError from an events CSV
missing a column was reaching the GUI.

Three more the raster brought with it:

- Behaviour lanes painted colour outside their own frames. The clamp for
  a column falling between two rows is right for an interior column and
  wrong past either end, where it handed over the first or last label: a
  201-frame scored window in a 600-frame recording coloured the whole
  bar. Out-of-range columns now show the background.
- The bout table's chips disagreed with the bar's stripes. The bar pools
  labels across every behaviour lane; the table re-derived an order from
  the ethogram alone, so with a tracking lane present a bout wore two
  different colours. The bar now exposes its pooled order and the table
  and the bout picker use it.
- sizeHint ignored the gap _rows() advances by, so hardware rows were
  short two pixels per behaviour lane. The vertical policy is Fixed, so
  at seven behaviour lanes to one device the raster was given zero
  height and vanished silently.

And FrameMap.frame_at documented itself as "nearest" when searchsorted
makes it a ceiling. Docstring only; downstream depends on the ceiling.
@gbradham
gbradham merged commit 1493d7f into main Sep 10, 2026
4 checks passed
@gbradham
gbradham deleted the claude/session-review-multicam-hardware-cf5a01 branch September 10, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant