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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions src/glider/analysis/cohort.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"is_recording",
"project_groups",
"recording_candidates",
"recording_filmed",
"session_id_for",
]

Expand Down Expand Up @@ -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``.

Expand Down Expand Up @@ -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)))
Expand All @@ -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)))
Expand Down
10 changes: 8 additions & 2 deletions src/glider/gui/behavior/analysis_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions src/glider/vision/pose/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions src/glider/vision/pose/body_size.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading