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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A folder of Batch Pose videos opens in Session Review.** Videos tracked by
Batch Pose (a video beside its `DLC_` CSV) are now sessions of a cohort, as
pup-tracked videos already were, and Open recording folder on a folder that
is no single recording opens every session in it instead of reporting "no
GLIDER recording". A video that already has a session (its ethogram, its pup
tracking, or the recording that filmed it) is not listed twice.
- **Batch Pose follows the animal, not an object the model also detects.** A
rat model over a novel-object arena also scored each dish as a small rat, and
the single-animal path kept whichever detection was most confident. The track
Expand Down
42 changes: 39 additions & 3 deletions src/glider/analysis/cohort.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Session Review opens a folder as a cohort. A cohort mixes three kinds of session:
apply-run ethograms (``<output>/<video stem>/ethogram_raw.csv``), live
recordings (a folder of GLIDER tracking/events/data CSVs) and videos, found
by the ``_pups.npz`` the pup pass leaves beside them. An ethogram usually
by the ``_pups.npz`` the pup pass or the ``DLC_`` pose CSV the batch pose
pass leaves beside them. An ethogram usually
sits beneath the recording it was scored from, and a pup video inside one,
so each *claims* that recording, and only unclaimed recordings become
sessions of their own -- otherwise one animal would appear twice in every
Expand Down Expand Up @@ -179,9 +180,44 @@ def discover_sessions(root: Path) -> tuple[list[SessionSource], str | None]:
pup_videos.append(SessionSource(video, recording, session_id_for(recording or video)))
# Only folders that hold a CSV are classified, so a tree of raw videos is
# not header-sniffed file by file.
recordings: set[Path] = set()
for folder in sorted({p.parent.resolve() for p in root.rglob("*.csv")}):
if folder not in claimed and is_recording(folder):
sources.append(SessionSource(folder, folder, session_id_for(folder)))
if is_recording(folder):
recordings.add(folder)
if folder not in claimed:
sources.append(SessionSource(folder, folder, session_id_for(folder)))
sources += pup_videos
sources += _pose_videos(root, sources, recordings)
groups, warning = project_groups(root)
return [replace(s, group=groups.get(s.session_id, "")) for s in sources], warning


def _pose_videos(
root: Path, found: list[SessionSource], recordings: set[Path]
) -> list[SessionSource]:
"""Videos the batch pose pass tracked that no other session already shows.

A folder of videos run through Batch Pose holds nothing else -- no
ethogram, no recording -- so without these it opened as an empty cohort.
A video that already has a session is left to it: an ethogram scored from
it, a pup session, or the GLIDER recording that filmed it, whose tracking
the recording session reads.
"""
from glider.vision.pose.batch import find_pose_csv

shown = {s.path.resolve() for s in found if s.path.is_file()}
for s in found:
if s.path.name == "ethogram_raw.csv" and (video := find_session_video(s.path)):
shown.add(Path(video).resolve())
sources = []
for folder in sorted({p.parent for p in root.rglob("*DLC_*.csv")}):
filmed_here = folder.resolve() in recordings
for video in sorted(folder.iterdir()):
if not is_video(video) or video.resolve() in shown:
continue
if filmed_here and recording_filmed(folder, video):
continue
if find_pose_csv(video) is not None:
shown.add(video.resolve())
sources.append(SessionSource(video.resolve(), None, session_id_for(video)))
return sources
14 changes: 12 additions & 2 deletions src/glider/gui/behavior/analysis_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,16 @@ def open_path(self, path: Path) -> None:
if len(ethograms) == 1:
self.load(ethograms[0])
return
# A folder of sessions -- a batch of tracked videos, several ethograms
# -- is what "open this folder" means: all of them, as a cohort.
if problem is None:
try:
sessions = discover_sessions(path)[0]
except (OSError, ValueError):
sessions = []
if sessions:
self.load_folder(path)
return
found = "no ethogram_raw.csv" if not ethograms else f"{len(ethograms)} ethograms"
more = " Use Open folder as cohort to load them all." if ethograms else ""
recording = (
Expand Down Expand Up @@ -972,8 +982,8 @@ def load_folder(self, root: Path) -> None:
QMessageBox.warning(
self,
"Open cohort",
f"No sessions found under {root}.\n\nLooked for ethogram_raw.csv files "
"and folders of GLIDER recording CSVs.",
f"No sessions found under {root}.\n\nLooked for ethogram_raw.csv files, "
"folders of GLIDER recording CSVs, and videos with pose or pup tracking.",
)
return
loaded, ids, groups, failed = [], [], [], []
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/analysis/test_cohort.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,37 @@ def test_a_video_with_pups_is_a_session_named_by_its_stem(tmp_path):
]


def test_a_video_tracked_by_batch_pose_is_a_session(tmp_path):
"""A folder of videos run through Batch Pose holds nothing else to find."""
folder = tmp_path / "rats"
folder.mkdir()
for stem in ("JP01_OF1", "JP02_OF2"):
(folder / f"{stem}.avi").write_bytes(b"")
for name in (f"{stem}DLC_rat-1.csv", f"{stem}DLC_rat-1_raw.csv"):
(folder / name).write_text("scorer\n", encoding="utf-8")
(folder / "untracked.avi").write_bytes(b"")
(folder / "JP01_OF1_zones").mkdir()
(folder / "JP01_OF1_zones" / "zone_occupancy.csv").write_text("zone\n", encoding="utf-8")
sources, _ = discover_sessions(tmp_path)
assert [(s.path.name, s.recording, s.session_id) for s in sources] == [
("JP01_OF1.avi", None, "JP01_OF1"),
("JP02_OF2.avi", None, "JP02_OF2"),
]


def test_a_pose_tracked_video_with_another_session_is_not_listed_twice(tmp_path):
"""Its ethogram, its pups, or the recording that filmed it already show it."""
recording = _tracking(tmp_path / "rec", video_stem="mouse01")
(recording / "mouse01DLC_m.csv").write_text("scorer\n", encoding="utf-8")
pups = tmp_path / "pups"
pups.mkdir()
(pups / "litter.avi").write_bytes(b"")
(pups / "litter_pups.npz").write_bytes(b"")
(pups / "litterDLC_m.csv").write_text("scorer\n", encoding="utf-8")
sources, _ = discover_sessions(tmp_path)
assert sorted(s.path.name for s in sources) == ["litter.avi", "rec"]


def test_session_id_of_a_video_is_its_stem(tmp_path):
video = tmp_path / "litter1_pre.avi"
video.write_bytes(b"")
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/gui/behavior/test_analysis_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -2851,6 +2851,17 @@ def test_open_video_loads_a_bare_video(self, qtbot, tmp_path, monkeypatch):
assert len(win._cohort) == 1
assert win._cohort[0][1].pups is not None

def test_a_folder_of_pose_tracked_videos_opens_them_all(self, qtbot, tmp_path):
"""Batch Pose output: videos and their DLC CSVs, no recording, no ethogram."""
for stem in ("rat01", "rat02"):
_scorable(tmp_path / "batch", stem)
(tmp_path / "batch" / f"{stem}_pups.npz").unlink()
win = AnalysisWindow()
qtbot.addWidget(win)
win.open_path(tmp_path / "batch")
assert win._ids == ["rat01", "rat02"]
assert all(view.pose_path is not None for _, view in win._cohort)

def test_open_path_takes_a_video_file(self, qtbot, tmp_path):
win = AnalysisWindow()
qtbot.addWidget(win)
Expand Down
Loading