Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/glider/analysis/_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
- ``<name>_<ts>_events.csv`` — DeviceEventLogger output (pin edges +
output writes + flow_marker rows)
- ``<name>_<ts>.mp4`` — raw video
- ``<name>_<ts>_annotated.mp4`` — annotated video (tracking overlays)
- ``<name>_annotated_<ts>.mp4`` — annotated video (tracking overlays), as
VideoRecorder names it; a renamed
``<name>_<ts>_annotated.mp4`` counts too
- ``<name>_<ts>_keypoints.csv`` — the pose model's keypoints, logged live
beside the tracking CSV (no header marker)

Discovery is via the ``# GLIDER ...`` header marker on row 1 of each
CSV rather than filename suffix, so renamed/copied files still classify
Expand All @@ -24,6 +28,7 @@
from __future__ import annotations

import io
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
Expand Down Expand Up @@ -54,6 +59,39 @@ class Artifacts:
annotated_video: Path | None = None


# VideoRecorder names the overlay video ``<name>_annotated_<YYYYMMDD>_<HHMMSS>``:
# the timestamp comes after the marker, so a plain ``endswith`` never sees it.
_ANNOTATED_STAMPED = re.compile(r"_annotated(?=_\d{8}_\d{6}$)")


def is_annotated_video(path: Path) -> bool:
"""Whether a video is the tracking-overlay copy rather than the raw recording."""
stem = Path(path).stem
return stem.endswith("_annotated") or _ANNOTATED_STAMPED.search(stem) is not None


def raw_video_stem(path: Path) -> str:
"""A video's stem with any annotated marker removed: the recording's own name."""
return _ANNOTATED_STAMPED.sub("", Path(path).stem).removesuffix("_annotated")


def keypoints_beside(tracking_csv: Path | None) -> Path | None:
"""The live keypoints CSV written beside a tracking CSV, if there is one.

Same rule TrackingDataLogger._keypoints_path writes by: ``<base>_tracking.csv``
sits beside ``<base>_keypoints.csv``. Keyed on the tracking CSV rather than
the video because the two loggers stamp their names separately, a second
apart as often as not.
"""
if tracking_csv is None:
return None
name = Path(tracking_csv).name
suffix = "_tracking.csv"
base = name[: -len(suffix)] if name.endswith(suffix) else Path(tracking_csv).stem
candidate = Path(tracking_csv).with_name(f"{base}_keypoints.csv")
return candidate if candidate.is_file() else None


def discover(directory: Path) -> Artifacts:
"""Scan a directory and classify each CSV by its header marker.

Expand Down Expand Up @@ -89,7 +127,7 @@ def discover(directory: Path) -> Artifacts:

# Video discovery by stem suffix.
for mp4_path in sorted(directory.glob("*.mp4")):
if mp4_path.stem.endswith("_annotated"):
if is_annotated_video(mp4_path):
if (
art.annotated_video is None
or mp4_path.stat().st_mtime > art.annotated_video.stat().st_mtime
Expand Down
68 changes: 64 additions & 4 deletions src/glider/analysis/behavior/session_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,11 @@ def from_recording(cls, session) -> SessionView:
"""A live recording as a view, so a rig run opens without an ethogram.

The tracking CSV already carries what the viewer and the range stats
need: a behaviour per frame (``behavioral_state``), a position
(``center_x``/``center_y``, used as a one-keypoint pose) and, on a
calibrated rig, the distance moved (``distance_mm``).
need: a behaviour per frame (``behavioral_state``), a position and, on
a calibrated rig, the distance moved (``distance_mm``). The pose is the
keypoints the rig's pose model logged live beside it, when it ran one;
otherwise the tracked centre (``center_x``/``center_y``) stands in as a
one-keypoint pose.
"""
tracking = session.tracking
if tracking.empty or "frame" not in tracking.columns:
Expand Down Expand Up @@ -451,7 +453,13 @@ def from_recording(cls, session) -> SessionView:
source=Path(session.directory),
first_video_frame=1 if len(every_frame) and every_frame.min() >= 1 else 0,
)
view._load_centroid(rows)
from glider.analysis._io import keypoints_beside

keypoints_csv = keypoints_beside(getattr(session, "tracking_path", None))
if keypoints_csv is not None:
view._load_live_keypoints(keypoints_csv, rows)
if view.xy is None:
view._load_centroid(rows)
video = session.raw_video_path or session.annotated_video_path
if video is not None:
view._load_video(Path(session.directory), video)
Expand Down Expand Up @@ -496,6 +504,58 @@ def from_video(cls, video: Path | str, *, pose_csv: Path | str | None = None) ->
logger.warning("pups not loaded: %s", e)
return view

def _load_live_keypoints(self, csv_path: Path, rows: pd.DataFrame) -> None:
"""The pose model's keypoints, as the rig logged them live, by frame.

The CSV is long -- one row per (frame, object_id, keypoint) -- with an
undetected keypoint written as blank x/y, which reads as NaN. Only the
subject's rows are kept: the (frame, object_id) pairs _subject_rows
chose from the tracking CSV, so a second animal never mixes in.
Keypoints keep the labels the logger wrote, in its order: the
operator's bodypart names, or the model's indices when none were set.
Leaves ``xy`` unset when nothing usable is there, so the caller falls
back to the tracked centre.
"""
try:
kp = pd.read_csv(csv_path)
except (OSError, ValueError) as e: # pandas' parse errors are ValueErrors
logger.info("live keypoints in %s are not readable: %s", csv_path.name, e)
return
if kp.empty or not {"frame", "keypoint", "x", "y"}.issubset(kp.columns):
return
if not len(self.frames):
return
kp = kp.assign(frame=pd.to_numeric(kp["frame"], errors="coerce"))
kp = kp.dropna(subset=["frame"])
if "object_id" in kp.columns and "object_id" in rows.columns:
subject = pd.DataFrame(
{
"frame": pd.to_numeric(rows["frame"], errors="coerce"),
"object_id": pd.to_numeric(rows["object_id"], errors="coerce"),
}
).dropna()
kp = kp.assign(object_id=pd.to_numeric(kp["object_id"], errors="coerce"))
kp = kp.dropna(subset=["object_id"]).astype({"frame": "int64", "object_id": "int64"})
kp = kp.merge(subject.astype("int64"), on=["frame", "object_id"], how="inner")
n = int(self.frames.max()) + 1
frame = kp["frame"].to_numpy(dtype="int64")
kp = kp[(frame >= 0) & (frame < n)]
if kp.empty:
return
labels = kp["keypoint"].astype(str)
names = list(dict.fromkeys(labels))
index = labels.map({name: i for i, name in enumerate(names)}).to_numpy(dtype="int64")
xy = np.full((n, len(names), 2), np.nan)
rows_at = kp["frame"].to_numpy(dtype="int64")
xy[rows_at, index, 0] = pd.to_numeric(kp["x"], errors="coerce").to_numpy(float)
xy[rows_at, index, 1] = pd.to_numeric(kp["y"], errors="coerce").to_numpy(float)
if not np.isfinite(xy).any():
return
self.xy = xy
self.keypoint_names = names
self.body_axis = (0, len(names) - 1)
self.pose_path = Path(csv_path)

def _load_centroid(self, rows: pd.DataFrame) -> None:
"""The tracked centre as a one-keypoint pose, indexed by frame."""
if not {"center_x", "center_y"}.issubset(rows.columns) or not len(self.frames):
Expand Down
4 changes: 2 additions & 2 deletions src/glider/analysis/cohort.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from dataclasses import dataclass, replace
from pathlib import Path

from glider.analysis._io import discover
from glider.analysis._io import discover, raw_video_stem
from glider.analysis.behavior.session_view import (
_SEARCH_LEVELS,
VIDEO_SUFFIXES,
Expand Down Expand Up @@ -99,7 +99,7 @@ def session_id_for(path: Path) -> str:
return folder.name
video = artifacts.video or artifacts.annotated_video
if video is not None:
return video.stem.removesuffix("_annotated")
return raw_video_stem(video)
return folder.name


Expand Down
5 changes: 5 additions & 0 deletions src/glider/analysis/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ def video_path(self) -> Path | None:
def raw_video_path(self) -> Path | None:
return self._artifacts.video

@property
def tracking_path(self) -> Path | None:
"""The tracking CSV this session was read from."""
return self._artifacts.tracking

@property
def annotated_video_path(self) -> Path | None:
return self._artifacts.annotated_video
Expand Down
12 changes: 7 additions & 5 deletions src/glider/gui/behavior/analysis_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -1140,8 +1140,8 @@ def _adopt(self, path: Path, view: SessionView, timeline) -> None:
self._apply_trail()
self._inspector.clear_range()
self._set_frame(self._bar.frame_bounds()[0])
# A recording has no pose sidecar to repair -- its poses are the
# tracked centroid, and its resolution comes from the rig, not a CSV.
# A recording has no pose sidecar to repair -- its poses are what the
# rig logged live, and its resolution comes from the rig, not a CSV.
is_recording = Path(path).is_dir()
self._pick_poses.setVisible(view.xy is None and not is_recording)
self._fix_resolution.setVisible(
Expand Down Expand Up @@ -1178,10 +1178,12 @@ def _show_session_text(self, path: Path, view: SessionView) -> None:
has_video = view.video_path is not None
found = f" Poses: {view.pose_path.name}." if view.pose_path else " No poses found."
if is_recording:
found = (
" Live recording: labels are the rig's behavioral_state; "
"position is the tracked centroid." + found
position = (
"position is the tracked centroid"
if view.keypoint_names == ["centroid"]
else "poses are the keypoints the rig's pose model logged live"
)
found = f" Live recording: labels are the rig's behavioral_state; {position}." + found
if has_video:
found += f" Video: {view.video_path.name}."
# An empty ethogram has no last frame to compare against.
Expand Down
23 changes: 21 additions & 2 deletions src/glider/vision/pose/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,24 @@ def _drop_orphaned_animal_slots(out_dir: Path, kept_slots: set[int]) -> None:
)


def _is_glider_recording_csv(path: Path) -> bool:
"""Whether a CSV is one of GLIDER's own recording files, not poses.

A live recording's DataRecorder output is named ``<video stem>.csv`` --
exactly the hand-placed pose name -- and opening that video in Session
Review handed its sensor traces to the pose loader. Every such file opens
with a ``# GLIDER`` header line, which no pose CSV does.
"""
try:
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
if line.strip():
return line.lstrip().startswith("# GLIDER")
except OSError:
return False
return False


def find_pose_csv(video: Path | str, search_dir: Path | str | None = None) -> Path | None:
"""The pose CSV belonging to *video*, or ``None`` if there isn't one.

Expand All @@ -363,7 +381,8 @@ def find_pose_csv(video: Path | str, search_dir: Path | str | None = None) -> Pa

``<stem>.csv``
A hand-placed file, or one exported from DeepLabCut proper. This is
what the annotate and train paths have always expected.
what the annotate and train paths have always expected. A GLIDER
recording's own data CSV shares the name and is skipped.
``<stem>DLC_<model>.csv``
What :func:`run_batch` writes. Companions that share this prefix but
are not pose data are skipped (see
Expand Down Expand Up @@ -413,7 +432,7 @@ def find_pose_csv(video: Path | str, search_dir: Path | str | None = None) -> Pa
return None

exact = directory / f"{video.stem}.csv"
if exact.exists():
if exact.exists() and not _is_glider_recording_csv(exact):
return exact

matches = [
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/analysis/behavior/test_session_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,21 @@ def _recording(folder, rows, *, calibrated=True, fps=30.0):
return folder


def _keypoints(folder, rows, *, names=("0", "1", "2")):
"""The live keypoints CSV beside rec_tracking.csv, as TrackingDataLogger writes it.

rows: (frame, object_id, [(x, y) or None per keypoint]); None is an
undetected keypoint, which the logger writes as blank x/y.
"""
with open(folder / "rec_keypoints.csv", "w", encoding="utf-8") as f:
f.write("frame,timestamp,elapsed_ms,flow_elapsed_ms,object_id,keypoint,x,y,confidence\n")
for frame, obj, points in rows:
for name, point in zip(names, points, strict=True):
x, y = ("", "") if point is None else point
f.write(f"{frame},t,{frame * 33.3:.1f},,{obj},{name},{x},{y},0.9\n")
return folder


def _walk(n, *, start=1, obj=0, state="walk"):
return [(f, obj, state, 100.0 + f, 200.0, 0.5) for f in range(start, start + n)]

Expand Down Expand Up @@ -631,6 +646,59 @@ def test_the_centroid_is_a_one_keypoint_pose(self, tmp_path):
assert view.xy[1, 0].tolist() == [101.0, 200.0]
assert view.keypoint_names == ["centroid"]

def test_the_live_keypoints_are_the_pose(self, tmp_path):
"""A rig that ran a pose model logged its keypoints beside the tracking
CSV; Session Review used to ignore them and say no poses were found."""
folder = _recording(tmp_path / "r", _walk(3))
_keypoints(
folder, [(f, 0, [(10.0 + f, 20.0), (30.0, 40.0), (50.0, 60.0)]) for f in (1, 2, 3)]
)
view = _open(folder)
assert view.keypoint_names == ["0", "1", "2"]
assert view.xy.shape == (4, 3, 2)
assert view.xy[2, 0].tolist() == [12.0, 20.0]
assert view.pose_path == folder / "rec_keypoints.csv"
assert view.body_axis == (0, 2)

def test_bodypart_names_are_kept_in_logged_order(self, tmp_path):
folder = _recording(tmp_path / "r", _walk(2))
names = ("nose", "neck", "tail_base")
_keypoints(folder, [(f, 0, [(1.0, 1.0)] * 3) for f in (1, 2)], names=names)
assert _open(folder).keypoint_names == list(names)

def test_an_undetected_keypoint_is_a_gap(self, tmp_path):
folder = _recording(tmp_path / "r", _walk(2))
_keypoints(folder, [(1, 0, [(1.0, 2.0), None, (5.0, 6.0)]), (2, 0, [(1.0, 2.0)] * 3)])
view = _open(folder)
assert np.isnan(view.xy[1, 1]).all()
assert view.xy[1, 2].tolist() == [5.0, 6.0]

def test_only_the_subjects_keypoints_are_kept(self, tmp_path):
"""Two animals in one frame: the keypoints follow the object the
tracking rows chose, never a mix of both."""
rows = _walk(3, obj=1, state="b") + _walk(3, obj=0, state="a")
folder = _recording(tmp_path / "r", rows)
_keypoints(
folder,
[(f, 0, [(1.0, 1.0)] * 3) for f in (1, 2, 3)]
+ [(f, 1, [(99.0, 99.0)] * 3) for f in (1, 2, 3)],
)
view = _open(folder)
assert set(view.labels) == {"a"}
assert view.xy[2, 0].tolist() == [1.0, 1.0]

def test_an_unreadable_keypoints_file_falls_back_to_the_centroid(self, tmp_path):
folder = _recording(tmp_path / "r", _walk(3))
(folder / "rec_keypoints.csv").write_text("not,a,keypoints,file\n", encoding="utf-8")
view = _open(folder)
assert view.keypoint_names == ["centroid"]
assert view.pose_path is None

def test_keypoints_with_no_coordinates_fall_back_to_the_centroid(self, tmp_path):
folder = _recording(tmp_path / "r", _walk(2))
_keypoints(folder, [(f, 0, [None, None, None]) for f in (1, 2)])
assert _open(folder).keypoint_names == ["centroid"]

def test_a_dropped_frame_is_nan_not_shifted(self, tmp_path):
rows = [r for r in _walk(10) if r[0] != 5]
view = _open(_recording(tmp_path / "r", rows))
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/analysis/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,47 @@ def test_extra_events_round_trip_device_columns(tmp_path: Path):
assert row["pin"] == 5.0
assert row["pin_type"] == "DIGITAL"
assert str(row["value"]) == "1"


def test_discover_knows_the_recorders_annotated_naming(tmp_path: Path):
"""VideoRecorder writes <name>_annotated_<ts>.mp4. Read as a raw video,
the overlay copy -- written last, so newest -- won, and Session Review
played the boxes-and-skeleton video as if it were the recording."""
rec_dir = tmp_path / "rec"
write_synthetic_recording(rec_dir, RecordingSpec())
raw = rec_dir / "test_experiment_20260525_140030.mp4"
annotated = rec_dir / "test_experiment_annotated_20260525_140031.mp4"
raw.write_bytes(b"")
annotated.write_bytes(b"")

art = discover(rec_dir)
assert art.video == raw
assert art.annotated_video == annotated


@pytest.mark.parametrize(
"name, raw",
[
("Maimu_RTPP_annotated_20261020_163943.mp4", "Maimu_RTPP_20261020_163943"),
("rec_20260525_140030_annotated.mp4", "rec_20260525_140030"),
("rec_20260525_140030.mp4", "rec_20260525_140030"),
("annotated_study_20260525_140030.mp4", "annotated_study_20260525_140030"),
],
)
def test_raw_video_stem(name, raw):
from glider.analysis._io import is_annotated_video, raw_video_stem

assert raw_video_stem(Path(name)) == raw
assert is_annotated_video(Path(name)) == (raw != Path(name).stem)


def test_keypoints_beside_a_tracking_csv(tmp_path: Path):
from glider.analysis._io import keypoints_beside

tracking = tmp_path / "run_20261020_172145_tracking.csv"
tracking.write_text("# GLIDER Tracking Data\n", encoding="utf-8")
assert keypoints_beside(tracking) is None
keypoints = tmp_path / "run_20261020_172145_keypoints.csv"
keypoints.write_text("frame\n", encoding="utf-8")
assert keypoints_beside(tracking) == keypoints
assert keypoints_beside(None) is None
Loading
Loading