From e363f858f854ec7d5e72a2c7f5b6600294e89c3b Mon Sep 17 00:00:00 2001 From: gbradham Date: Mon, 2 Nov 2026 16:59:18 -0600 Subject: [PATCH 1/2] fix(review): only attach a recording that filmed the session's video A folder holding a stray live recording beside tracked videos lent every one of them that recording's timeline, so an 11-minute video could be scrubbed for only the stray's 54 seconds. recording_filmed() rules out a recording whose own video is a different one; Session Review and cohort discovery both use it. --- src/glider/analysis/cohort.py | 32 ++++++++++++++++++++-- src/glider/gui/behavior/analysis_window.py | 10 +++++-- tests/unit/analysis/test_cohort.py | 25 ++++++++++++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/glider/analysis/cohort.py b/src/glider/analysis/cohort.py index b65612ef..0c920930 100644 --- a/src/glider/analysis/cohort.py +++ b/src/glider/analysis/cohort.py @@ -31,6 +31,7 @@ "is_recording", "project_groups", "recording_candidates", + "recording_filmed", "session_id_for", ] @@ -73,6 +74,26 @@ def recording_candidates(ethogram_csv: Path, video_path: Path | None = None) -> return list(dict.fromkeys(f.resolve() for f in folders)) +def recording_filmed(folder: Path, video: Path | None) -> bool: + """Whether the recording in ``folder`` could be the one ``video`` belongs to. + + A folder search finds *a* recording, not *this* session's: a Downloads + folder holding one stray live recording beside a batch of tracked videos + lent every one of them that recording's timeline, clamping an 11-minute + video to the stray's 54 seconds. A recording that kept a video of its own + names its session, so a different video rules it out. One with no video, + or a session with none, cannot be told apart and is kept, as before. + """ + if video is None: + return True + try: + artifacts = discover(Path(folder)) + except OSError: + return False + own = {raw_video_stem(v) for v in (artifacts.video, artifacts.annotated_video) if v} + return not own or raw_video_stem(video) in own + + def session_id_for(path: Path) -> str: """The name a session goes by in tables, exports and ``glider_project.json``. @@ -129,8 +150,11 @@ def discover_sessions(root: Path) -> tuple[list[SessionSource], str | None]: sources: list[SessionSource] = [] claimed: set[Path] = set() for ethogram in sorted(root.rglob("ethogram_raw.csv")): - candidates = recording_candidates(ethogram, find_session_video(ethogram)) - recording = next((f for f in candidates if is_recording(f)), None) + video = find_session_video(ethogram) + candidates = recording_candidates(ethogram, video) + recording = next( + (f for f in candidates if is_recording(f) and recording_filmed(f, video)), None + ) if recording is not None: claimed.add(recording) sources.append(SessionSource(ethogram, recording, session_id_for(ethogram))) @@ -147,7 +171,9 @@ def discover_sessions(root: Path) -> tuple[list[SessionSource], str | None]: if video is None: continue candidates = recording_candidates(video, video) - recording = next((f for f in candidates if is_recording(f)), None) + recording = next( + (f for f in candidates if is_recording(f) and recording_filmed(f, video)), None + ) if recording is not None: claimed.add(recording) pup_videos.append(SessionSource(video, recording, session_id_for(recording or video))) diff --git a/src/glider/gui/behavior/analysis_window.py b/src/glider/gui/behavior/analysis_window.py index bd0f0c9b..c52cbf71 100644 --- a/src/glider/gui/behavior/analysis_window.py +++ b/src/glider/gui/behavior/analysis_window.py @@ -65,7 +65,12 @@ setup_path, ) from glider.analysis.behavior.session_view import SessionView, SessionViewError, is_video -from glider.analysis.cohort import discover_sessions, recording_candidates, session_id_for +from glider.analysis.cohort import ( + discover_sessions, + recording_candidates, + recording_filmed, + session_id_for, +) from glider.analysis.epochs import ( CURRENT, Epoch, @@ -1179,7 +1184,8 @@ def _timeline_for(self, path: Path, view: SessionView): return build_timeline(None, view), None for folder in recording_candidates(path, view.video_path): session = self._recording(folder) - if session is None: + # Another video's recording would lend this session its timeline. + if session is None or not recording_filmed(folder, view.video_path): continue try: return build_timeline(session, view), session diff --git a/tests/unit/analysis/test_cohort.py b/tests/unit/analysis/test_cohort.py index 2c1a00df..9bc34cd2 100644 --- a/tests/unit/analysis/test_cohort.py +++ b/tests/unit/analysis/test_cohort.py @@ -5,7 +5,7 @@ import json from pathlib import Path -from glider.analysis.cohort import discover_sessions, session_id_for +from glider.analysis.cohort import discover_sessions, recording_filmed, session_id_for def _tracking(folder: Path, video_stem: str | None = None) -> Path: @@ -168,3 +168,26 @@ def test_a_recording_without_pups_is_still_its_own_session(tmp_path): ("m2.avi", None), ("rec", recording.resolve()), ] + + +def test_a_video_does_not_claim_another_videos_recording(tmp_path): + """A stray recording in a folder of tracked videos is not theirs: it stays its own.""" + folder = _tracking(tmp_path / "downloads", video_stem="other_mouse") + (folder / "rat01.avi").write_bytes(b"") + (folder / "rat01_pups.npz").write_bytes(b"") + sources, _ = discover_sessions(tmp_path) + assert sorted((s.path.name, s.recording) for s in sources) == [ + ("downloads", folder.resolve()), + ("rat01.avi", None), + ] + + +def test_recording_filmed(tmp_path): + own = _tracking(tmp_path / "own", video_stem="mouse01") + bare = _tracking(tmp_path / "bare") + assert recording_filmed(own, own / "mouse01.mp4") + assert recording_filmed(own, own / "mouse01_annotated.mp4") + assert not recording_filmed(own, tmp_path / "rat01.avi") + # Nothing to compare: kept, as before. + assert recording_filmed(bare, tmp_path / "rat01.avi") + assert recording_filmed(own, None) From d0fa106316cc09283a93e477aca1627fabe0f351 Mon Sep 17 00:00:00 2001 From: gbradham Date: Mon, 2 Nov 2026 16:59:18 -0600 Subject: [PATCH 2/2] fix(pose): follow the animal, not an object the model also detects A rat model over a novel-object arena scores each dish as a small rat, and the single-animal path kept the most confident detection, so the track sat on a dish before the rat was placed and jumped to one whenever it outscored the rat. infer_video now keeps every candidate and, once the animal's typical span is measured from the run, prefers one at least 40% of it. run_batch blanks frames left with only an undersized skeleton, keeping them in _raw, and the sidecar records the body_size block. --- CHANGELOG.md | 15 +++ src/glider/vision/pose/batch.py | 29 ++++- src/glider/vision/pose/body_size.py | 130 +++++++++++++++++++++++ src/glider/vision/pose/core.py | 103 ++++++++++++------ src/glider/vision/pose/dlc.py | 4 + tests/unit/vision/pose/test_body_size.py | 98 +++++++++++++++++ 6 files changed, 346 insertions(+), 33 deletions(-) create mode 100644 src/glider/vision/pose/body_size.py create mode 100644 tests/unit/vision/pose/test_body_size.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 85f26438..2377ac3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -373,6 +373,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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 + sat on a dish before the rat was placed and jumped to one whenever it + outscored the rat. Each frame now prefers a detection at least 40% of the + animal's typical size, measured from the video itself, and frames with only an + undersized detection are blanked, with the `_raw` companion keeping them. On + one session this recovered about 1,700 frames of the rat and removed 254 jumps. + `min_body_fraction=None` turns it off. +- **Session Review no longer lends a video another recording's timeline.** A + folder holding a stray live recording beside tracked videos attached that + recording to every one of them, so an 11-minute video could be scrubbed for + only the recording's 54 seconds. A recording is now used only when its own + video is the session's. + - **Multi-animal batch tracking keeps an animal across a tracker id change.** A new id that overlapped the old one for a few frames, filled a hole in an id the tracker later gave back, or jumped at the change was refused by every diff --git a/src/glider/vision/pose/batch.py b/src/glider/vision/pose/batch.py index 29bb85fd..36d1dde8 100644 --- a/src/glider/vision/pose/batch.py +++ b/src/glider/vision/pose/batch.py @@ -29,6 +29,8 @@ from glider.vision.pose.tracks import PoseTracks from glider.vision.zones import ZoneConfiguration +from glider.vision.pose.core import DEFAULT_MIN_BODY_FRACTION + logger = logging.getLogger(__name__) #: Video containers both halves of the pipeline accept. Single-sourced on @@ -630,6 +632,7 @@ def run_batch( zone_keypoint: str = "body_center", arenas: Mapping[Path, ArenaCalibration] | None = None, gate: ArenaGateSettings | None = None, + min_body_fraction: float | None = DEFAULT_MIN_BODY_FRACTION, n_animals: int = 1, max_travel_px_per_frame: float = 40.0, min_fragment_frames: int = 5, @@ -661,6 +664,13 @@ def run_batch( Detections that left that arena are blanked before the primary CSV is written and before zones are scored, and the ``_raw`` companion holds the ungated inference. A video with no arena is untouched. + min_body_fraction + Single animal only. Inference prefers, on each frame, a candidate at + least this fraction of the animal's typical span over a more confident + smaller one (an object in the arena), and frames left with only an + undersized skeleton -- the objects before the animal is placed -- are + blanked before the primary CSV is written, with the ``_raw`` companion + keeping them. ``None`` turns both off. infer Injection point for tests; defaults to :func:`glider.vision.pose.core.infer_video`. @@ -891,15 +901,28 @@ def pup_pass(video: Path, index: int) -> bool: cancel_cb=cancel_cb, arena=arena, gate_settings=gate, + min_body_fraction=min_body_fraction, ) gating = gate is not None and arena is not None + sized, size_report = pose, None + if min_body_fraction is not None: + from glider.vision.pose.body_size import gate_body_size + + sized, size_report = gate_body_size(pose, min_body_fraction) + sized.metadata["body_size"] = { + **pose.metadata.get("body_size", {}), + **asdict(size_report), + } + sizing = size_report is not None and size_report.frames_blanked > 0 # _raw is the "what did the model actually say" file, so it # must be pre-gate as well as pre-filter — and it must exist - # whenever either is active, or gating discards data with no - # companion. - if gating or filtering is not None: + # whenever anything blanks or smooths, or data is discarded + # with no companion. A size gate that blanked nothing changed + # nothing, and leaves no _raw. + if gating or sizing or filtering is not None: to_dlc_csv(pose, raw_output_path(video, model_path)) + pose = sized if gating: from glider.vision.arena_gate import gate_to_arena diff --git a/src/glider/vision/pose/body_size.py b/src/glider/vision/pose/body_size.py new file mode 100644 index 00000000..15bdb125 --- /dev/null +++ b/src/glider/vision/pose/body_size.py @@ -0,0 +1,130 @@ +"""Telling the animal from a still object the pose model also calls an animal. + +A rat model run over a novel-object arena finds the rat *and* each object: the +objects come back as small, confident skeletons a fifth of the rat's length. +Picking by box confidence alone hands the track to an object whenever it edges +out the rat, and before the rat is placed the objects are all there is, so the +session opens with a minute of "rat" flicking between two dishes. + +Size separates them where confidence does not. Offline we have the whole video, +so the animal's size is measured from the run itself -- a high percentile of the +largest skeleton per frame, which holds as long as the animal is in view for a +tenth of the video -- and anything far smaller is not the animal. + +Pure numpy: no ultralytics, no video, no I/O. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np + +from glider.vision.pose.core import DEFAULT_MIN_BODY_FRACTION, PoseData + +__all__ = [ + "DEFAULT_MIN_BODY_FRACTION", + "BodySizeReport", + "body_span", + "gate_body_size", + "pick_by_size", + "reference_span", +] + +# DEFAULT_MIN_BODY_FRACTION (0.4): a candidate shorter than that fraction of +# the animal's typical span is not the animal. Measured on seven rat sessions, +# the objects sit at 0.15-0.35 and the rat, curled and grooming included, above. + +#: The percentile of per-frame spans taken as the animal's full length. High +#: enough that frames without the animal (an empty arena, an object) do not set +#: it, low enough that one stretched-out outlier does not either. +_REFERENCE_PERCENTILE = 90 + + +def body_span(xy: np.ndarray) -> np.ndarray: + """Diagonal of the keypoints' bounding box, in pixels, over the last two axes. + + ``xy`` is ``(..., K, 2)``. Keypoints at exactly (0, 0) are how Ultralytics + reports one it could not place, so they are left out with the NaNs; fewer + than two placed keypoints give NaN, which means "unknown", not "small". + """ + xy = np.asarray(xy, dtype=float) + placed = np.isfinite(xy).all(axis=-1) & (xy != 0).any(axis=-1) + lo = np.where(placed[..., None], xy, np.inf).min(axis=-2) + hi = np.where(placed[..., None], xy, -np.inf).max(axis=-2) + span = np.hypot(*np.moveaxis(hi - lo, -1, 0)) + return np.where(placed.sum(axis=-1) >= 2, span, np.nan) + + +def reference_span(spans: np.ndarray) -> float | None: + """The animal's typical full span from per-frame spans, or None if unknown.""" + spans = np.asarray(spans, dtype=float) + finite = spans[np.isfinite(spans)] + if finite.size == 0: + return None + return float(np.percentile(finite, _REFERENCE_PERCENTILE)) + + +def pick_by_size( + candidates: Sequence[Sequence[np.ndarray]], + min_fraction: float = DEFAULT_MIN_BODY_FRACTION, +) -> tuple[list[int], float | None]: + """For each frame, the first candidate big enough to be the animal. + + ``candidates[f]`` is frame *f*'s keypoint arrays ``(K, 2)``, best first by + whatever ranking came before (confidence, arena). A re-ranking, never a + filter: a frame whose candidates are all undersized keeps its first, and + blanking it is :func:`gate_body_size`'s job. Returns the chosen index per + frame (0 for a frame with no candidates) and the reference span used. + """ + spans = [body_span(np.stack(c)) if len(c) else np.empty(0) for c in candidates] + widest = np.array([np.nanmax(s) if np.isfinite(s).any() else np.nan for s in spans]) + reference = reference_span(widest) + if reference is None: + return [0] * len(candidates), None + floor = min_fraction * reference + chosen = [] + for s in spans: + ok = np.flatnonzero(~(s < floor)) # NaN is unknown, so it is kept + chosen.append(int(ok[0]) if ok.size else 0) + return chosen, reference + + +@dataclass(frozen=True) +class BodySizeReport: + """What :func:`gate_body_size` blanked, for the CSV's metadata.""" + + frames_total: int + frames_blanked: int + reference_span_px: float | None + min_fraction: float + + @property + def blanked_fraction(self) -> float: + return self.frames_blanked / self.frames_total if self.frames_total else 0.0 + + +def gate_body_size( + pose: PoseData, min_fraction: float = DEFAULT_MIN_BODY_FRACTION +) -> tuple[PoseData, BodySizeReport]: + """Blank the frames whose skeleton is far too small to be the animal. + + What is left after :func:`pick_by_size` -- frames with no animal-sized + candidate at all, such as an arena with only its objects in it. Returns a + copy; the input is untouched. + """ + out = pose.copy() + spans = body_span(out.xy) + reference = reference_span(spans) + small = np.zeros(len(spans), dtype=bool) + if reference is not None: + small = spans < min_fraction * reference # NaN compares False: kept + out.xy[small] = np.nan + out.confidence[small] = 0.0 + return out, BodySizeReport( + frames_total=len(spans), + frames_blanked=int(small.sum()), + reference_span_px=None if reference is None else round(reference, 1), + min_fraction=min_fraction, + ) diff --git a/src/glider/vision/pose/core.py b/src/glider/vision/pose/core.py index e15de91f..f01d481e 100644 --- a/src/glider/vision/pose/core.py +++ b/src/glider/vision/pose/core.py @@ -42,6 +42,12 @@ class PoseCancelledError(RuntimeError): """ +#: Default for ``infer_video(min_body_fraction=...)``; the reasoning is in +#: :mod:`glider.vision.pose.body_size`. Defined here because that module +#: imports this one. +DEFAULT_MIN_BODY_FRACTION = 0.4 + + @dataclass class PoseData: """Frame-aligned pose container (single animal).""" @@ -534,6 +540,7 @@ def infer_video( cancel_cb: Callable[[], bool] | None = None, arena: ArenaCalibration | None = None, gate_settings: ArenaGateSettings | None = None, + min_body_fraction: float | None = DEFAULT_MIN_BODY_FRACTION, ) -> PoseData: """Run an Ultralytics YOLO-pose model over a video and return PoseData. @@ -587,6 +594,13 @@ def infer_video( An arena whose corners do not describe a usable quad is reported once and ignored, leaving plain ``argmax``: a calibration problem must not cost a run of inference. + min_body_fraction + After the pass, a frame's candidates shorter than this fraction of the + animal's typical span (measured from the run) give way to the next + candidate that is not -- so a confident object in the arena cannot + take the track from the animal. See + :func:`glider.vision.pose.body_size.pick_by_size`. Also a re-ranking, + not a filter. ``None`` keeps the confidence/arena ranking alone. Returns ------- @@ -601,7 +615,7 @@ def infer_video( ----- Frames where no animal is detected are written as NaN xy with confidence 0. For multi-detection frames, the highest box-confidence detection is kept - (single-animal v1). + unless it is too small to be the animal (``min_body_fraction``). """ from glider.vision.pose.spec import PoseModelError, identify_pose_model @@ -711,6 +725,9 @@ def infer_video( xy_rows: list[np.ndarray] = [] conf_rows: list[np.ndarray] = [] + # Every frame's ranked candidates, for the size pass after the loop. + candidates_xy: list[list[np.ndarray]] = [] + candidates_cf: list[list[np.ndarray]] = [] for r in results_iter: # Checked before decoding so a cancel doesn't pay for one more frame. @@ -724,35 +741,69 @@ def infer_video( ): xy_rows.append(np.full((n_kpts, 2), np.nan)) conf_rows.append(np.zeros(n_kpts)) + candidates_xy.append([]) + candidates_cf.append([]) else: - # Highest-confidence detection on this frame — preferring one - # inside the arena, when there is an arena to prefer. + # Every detection, best first by confidence -- preferring one + # inside the arena, when there is an arena to prefer. Which one is + # the animal is decided after the loop, once its size is known. if r.boxes is not None and r.boxes.conf is not None: confidences = r.boxes.conf.cpu().numpy() - best = _pick_candidate(r, confidences, arena, gate_settings, resolution) + order = _rank_candidates(r, confidences, arena, gate_settings, resolution) else: - best = 0 - - xy = r.keypoints.xy[best].cpu().numpy() - if r.keypoints.conf is not None: - cf = r.keypoints.conf[best].cpu().numpy() - else: - cf = np.ones(n_kpts) - - if xy.shape[0] != n_kpts: - raise ValueError( - f"model emitted {xy.shape[0]} keypoints but {n_kpts} names " - f"were supplied — check `keypoint_names` ordering against " - f"the training data.yaml" - ) - - xy_rows.append(np.asarray(xy, dtype=float)) - conf_rows.append(np.asarray(cf, dtype=float)) + order = [0] + if min_body_fraction is None: + order = order[:1] + + frame_xy, frame_cf = [], [] + for index in order: + xy = r.keypoints.xy[index].cpu().numpy() + if r.keypoints.conf is not None: + cf = r.keypoints.conf[index].cpu().numpy() + else: + cf = np.ones(n_kpts) + if xy.shape[0] != n_kpts: + raise ValueError( + f"model emitted {xy.shape[0]} keypoints but {n_kpts} names " + f"were supplied — check `keypoint_names` ordering against " + f"the training data.yaml" + ) + frame_xy.append(np.asarray(xy, dtype=float)) + frame_cf.append(np.asarray(cf, dtype=float)) + candidates_xy.append(frame_xy) + candidates_cf.append(frame_cf) + xy_rows.append(frame_xy[0]) + conf_rows.append(frame_cf[0]) # Single loop tail: undetected frames count toward progress too. if progress_cb is not None: progress_cb(len(xy_rows), total_frames) + metadata = { + "model_path": str(model_path), + "video_path": str(video_path), + "conf_threshold": conf, + "device": resolved_device, + # Pose coordinates are pixels; a viewer without the video needs + # the canvas they were measured on to place them. + "resolution": resolution, + } + if min_body_fraction is not None: + from glider.vision.pose.body_size import pick_by_size + + chosen, reference = pick_by_size(candidates_xy, min_body_fraction) + reranked = 0 + for f, index in enumerate(chosen): + if index: + xy_rows[f] = candidates_xy[f][index] + conf_rows[f] = candidates_cf[f][index] + reranked += 1 + metadata["body_size"] = { + "reference_span_px": None if reference is None else round(reference, 1), + "min_fraction": min_body_fraction, + "frames_reranked": reranked, + } + xy_arr = np.stack(xy_rows, axis=0) if xy_rows else np.zeros((0, n_kpts, 2)) conf_arr = np.stack(conf_rows, axis=0) if conf_rows else np.zeros((0, n_kpts)) @@ -762,13 +813,5 @@ def infer_video( keypoint_names=keypoint_names, fps=fps, source=source, - metadata={ - "model_path": str(model_path), - "video_path": str(video_path), - "conf_threshold": conf, - "device": resolved_device, - # Pose coordinates are pixels; a viewer without the video needs - # the canvas they were measured on to place them. - "resolution": resolution, - }, + metadata=metadata, ) diff --git a/src/glider/vision/pose/dlc.py b/src/glider/vision/pose/dlc.py index 71080ad5..7c900a74 100644 --- a/src/glider/vision/pose/dlc.py +++ b/src/glider/vision/pose/dlc.py @@ -122,6 +122,10 @@ def write_pose_meta(pose: PoseData, csv_path: str | Path) -> Path: gate = pose.metadata.get("arena_gate") if pose.metadata else None if gate: payload["arena_gate"] = gate + # Same footing: which frames were blanked as too small to be the animal. + size = pose.metadata.get("body_size") if pose.metadata else None + if size: + payload["body_size"] = size return _write_meta_payload(payload, csv_path) diff --git a/tests/unit/vision/pose/test_body_size.py b/tests/unit/vision/pose/test_body_size.py new file mode 100644 index 00000000..ac1d5441 --- /dev/null +++ b/tests/unit/vision/pose/test_body_size.py @@ -0,0 +1,98 @@ +"""Telling the animal from a small object the model also calls an animal.""" + +from __future__ import annotations + +import numpy as np + +from glider.vision.pose import core +from glider.vision.pose.body_size import body_span, gate_body_size, pick_by_size + +from .conftest import FakeResult, fake_yolo_streaming + +NAMES = ["nose", "body", "tail"] +RAT = np.array([[0.0, 0.0], [0.0, 100.0], [0.0, 200.0]]) # 200 px long +DISH = np.array([[0.0, 0.0], [20.0, 10.0], [40.0, 0.0]]) # ~40 px across + + +def _at(shape, x, y): + return shape + [x, y] + + +def test_body_span_is_the_bounding_box_diagonal(): + assert body_span(_at(RAT, 50, 50)) == 200.0 + assert np.isclose(body_span(np.array([[0, 0], [30, 0], [30, 40]], dtype=float) + 1), 50.0) + + +def test_unplaced_keypoints_do_not_count(): + """Ultralytics writes a keypoint it could not place as (0, 0).""" + xy = np.array([[0.0, 0.0], [100.0, 100.0], [110.0, 100.0]]) + assert body_span(xy) == 10.0 + assert np.isnan(body_span(np.array([[0.0, 0.0], [5.0, 5.0], [np.nan, np.nan]]))) + + +def test_a_confident_object_gives_way_to_the_animal(): + rat, dish = _at(RAT, 300, 300), _at(DISH, 600, 300) + frames = [[rat]] * 20 + [[dish, rat]] * 5 + [[dish]] * 3 + [[]] + chosen, reference = pick_by_size(frames) + assert reference == 200.0 + assert chosen[20:25] == [1] * 5 # the rat, though the dish ranked first + assert chosen[25:28] == [0] * 3 # nothing better: re-ranked, never dropped + assert chosen[:20] == [0] * 20 and chosen[28] == 0 + + +def test_the_gate_blanks_only_frames_too_small_to_be_the_animal(synthetic_pose): + pose = synthetic_pose.copy() + rest = pose.xy[10].copy() + pose.xy[10] = rest[0] + (rest - rest[0]) * 0.1 # shrunk to a tenth + gated, report = gate_body_size(pose) + assert report.frames_blanked == 1 + assert np.isnan(gated.xy[10]).all() and (gated.confidence[10] == 0).all() + assert np.isfinite(gated.xy[np.arange(len(gated.xy)) != 10]).all() + assert np.isfinite(pose.xy[10]).all() # the input is untouched + + +def _infer(tmp_path, stub_ultralytics, results, **kwargs): + stub_ultralytics.YOLO = fake_yolo_streaming(results) + return core.infer_video( + tmp_path / "model.pt", + tmp_path / "video.mp4", + NAMES, + progress=False, + echo_device=False, + **kwargs, + ) + + +def test_infer_video_keeps_the_animal_over_a_more_confident_object(stub_ultralytics, tmp_path): + rat, dish = _at(RAT, 300, 300), _at(DISH, 600, 300) + both = FakeResult(np.stack([dish, rat]), boxes_conf=[0.9, 0.6]) + results = [FakeResult(rat[None], boxes_conf=[0.9])] * 10 + [both] * 3 + pose = _infer(tmp_path, stub_ultralytics, results) + assert np.allclose(pose.xy[10:], rat) + assert pose.metadata["body_size"]["frames_reranked"] == 3 + + by_confidence = _infer(tmp_path, stub_ultralytics, results, min_body_fraction=None) + assert np.allclose(by_confidence.xy[10:], dish) + assert "body_size" not in by_confidence.metadata + + +def test_run_batch_blanks_the_objects_and_keeps_them_in_raw(tmp_path, monkeypatch): + import glider.vision.pose.device as device_mod + from glider.vision.pose import batch + from glider.vision.pose.dlc import from_dlc_csv, read_pose_meta + + monkeypatch.setattr(device_mod, "resolve_device", lambda d, require_gpu=False: "cpu") + video = tmp_path / "rat.avi" + video.write_bytes(b"") + xy = np.stack([_at(DISH, 600, 300)] * 5 + [_at(RAT, 300, 300)] * 20) + + def infer(**kwargs): + return core.PoseData(xy=xy, confidence=np.ones(xy.shape[:2]), keypoint_names=NAMES) + + batch.run_batch([video], tmp_path / "rat-1.pt", NAMES, infer=infer) + primary = from_dlc_csv(batch.dlc_output_path(video, tmp_path / "rat-1.pt")) + raw = from_dlc_csv(batch.raw_output_path(video, tmp_path / "rat-1.pt")) + assert np.isnan(primary.xy[:5]).all() and np.isfinite(primary.xy[5:]).all() + assert np.allclose(raw.xy, xy) + meta = read_pose_meta(batch.dlc_output_path(video, tmp_path / "rat-1.pt")) + assert meta["body_size"]["frames_blanked"] == 5