diff --git a/src/glider/analysis/_io.py b/src/glider/analysis/_io.py index 9f6e8958..9f50fec5 100644 --- a/src/glider/analysis/_io.py +++ b/src/glider/analysis/_io.py @@ -8,7 +8,11 @@ - ``__events.csv`` — DeviceEventLogger output (pin edges + output writes + flow_marker rows) - ``_.mp4`` — raw video - - ``__annotated.mp4`` — annotated video (tracking overlays) + - ``_annotated_.mp4`` — annotated video (tracking overlays), as + VideoRecorder names it; a renamed + ``__annotated.mp4`` counts too + - ``__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 @@ -24,6 +28,7 @@ from __future__ import annotations import io +import re from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -54,6 +59,39 @@ class Artifacts: annotated_video: Path | None = None +# VideoRecorder names the overlay video ``_annotated__``: +# 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: ``_tracking.csv`` + sits beside ``_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. @@ -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 diff --git a/src/glider/analysis/behavior/session_view.py b/src/glider/analysis/behavior/session_view.py index 1aef3033..cfb8df10 100644 --- a/src/glider/analysis/behavior/session_view.py +++ b/src/glider/analysis/behavior/session_view.py @@ -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: @@ -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) @@ -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): diff --git a/src/glider/analysis/cohort.py b/src/glider/analysis/cohort.py index e56bcfec..b65612ef 100644 --- a/src/glider/analysis/cohort.py +++ b/src/glider/analysis/cohort.py @@ -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, @@ -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 diff --git a/src/glider/analysis/session.py b/src/glider/analysis/session.py index 3076e8b9..c922332a 100644 --- a/src/glider/analysis/session.py +++ b/src/glider/analysis/session.py @@ -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 diff --git a/src/glider/gui/behavior/analysis_window.py b/src/glider/gui/behavior/analysis_window.py index 24225319..6a12282d 100644 --- a/src/glider/gui/behavior/analysis_window.py +++ b/src/glider/gui/behavior/analysis_window.py @@ -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( @@ -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. diff --git a/src/glider/vision/pose/batch.py b/src/glider/vision/pose/batch.py index 6c24e63b..29bb85fd 100644 --- a/src/glider/vision/pose/batch.py +++ b/src/glider/vision/pose/batch.py @@ -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 ``