Video features#
A pretrained network turns each frame of a video into a vector: what the animal looks like it is doing, which pose keypoints alone do not capture. The network is an extractor, chosen by name; every extractor writes the same kind of file, and downstream nothing cares which one made it.
|
Kind |
What one frame’s feature is |
Docs |
|---|---|---|---|
|
clip-wise |
the |
|
|
frame-wise |
the frame on its own, embedded by an image backbone — DINOv2 [Oquab et al., 2024] ViT-B/14 ( |
Both follow the recipes of v-iashin/video_features [Iashin, 2020], which documents each network and links on to its weights; our config exposes only what changes the features, so read there for the rest.
A frame-wise feature carries no motion; the temporal model downstream reads
it off the sequence. A clip-wise feature carries the window’s motion but
needs the window to be long enough for the network. Which is better for a
given behaviour is an empirical question — the point of one registry is that
the comparison is a config line and a compare_runs.
They are expensive — a forward pass per frame — so they are computed once into a sidecar file per video and merged into your sessions afterwards.
Important
Every temporal setting is in seconds and resolved against each video’s own
rate. analysis_fps says how many frames per second the network sees (frames
are skipped, never interpolated up); it is the one cost lever, and at 200 fps
you will want it. S3D’s stack_s needs at least 13 frames at that rate,
so its 0.5 s default works down to 26 fps but fails below it — the error
names the shortest window that works.
Starting from a folder of videos#
No config, no session, no alignment — just videos in, sidecars out:
import ethograph as eto
eto.segment.extract_videos(["/data/videos"], "/data/features", stack_s=0.5) # S3D (default)
eto.segment.extract_videos(["/data/videos"], "/data/features", extractor="timm", analysis_fps=25) # timm / DINOv2
The first argument takes any mix of files, folders (searched recursively) and
globs. Each video becomes /data/features/{video stem}_{extractor}.nc,
holding a (time_video, {extractor}_dims) array on the video’s own clock
(frame 0 at t=0), with the resolved plan and the model in its attrs. The
written paths are returned. Videos that already have a sidecar are skipped
unless you pass overwrite=True, so re-running after adding footage is cheap.
Parameter |
Meaning |
|---|---|
|
|
|
|
|
|
|
Rate the network sees; |
|
A pixel box cut from every frame before the network sees it ( |
|
Regular expressions; keep only videos whose path matches one. |
|
Re-extract videos that already have a sidecar (default |
A setting that belongs to the other extractor is refused by name — stack_s
means nothing to a frame-wise model, model_name nothing to S3D — rather than
silently ignored. Those are the only settings that change the features. Batch
size, decode chunk, fp16 and the device live on the extractor’s own config
(S3DConfig,
TimmConfig).
Cropping to the animal#
Resolution on the animal matters more than resolution on the arena. crop
cuts one rectangle from every decoded frame before the resize, in the same
numbers the GUI’s crop tool reports (Tools ▸ Video: Pick a crop for a config…), so they
copy straight across:
video_features:
crop: {x0: 240, y0: 80, x1: 880, y1: 720}
The network takes a square, and you choose which one. Every extractor has a fixed square input — 224×224 for S3D, the model’s own side for a timm backbone (518 for DINOv2) — and gets there by scaling the box’s shorter side to it and taking the centre square, the Kinetics / ImageNet evaluation transform. So:
No
crop: the centre square of the full frame, scaled. The default, and fine when the animal sits mid-frame.A square
crop: exactly what you drew, only scaled. The crop tool’s Square option (ticked by default) grows the dragged rectangle to a square about its centre, so the numbers it prints are the numbers the network sees. Draw it 224×224 for S3D and there is no resampling at all.A non-square
crop: the long side is cut off — a 203×164 box loses 19 % of its width. The extraction logs a warning saying how much; it does not refuse.
One box per video, applied to every trial’s video alike. A box that follows an individual — the per-individual feature a multi-animal recording needs — is the planned extension; the sidecar already records the crop in its attrs so that step needs no new format.
Taking one camera out of a folder#
Two cameras pointed at the same arena give nearly identical features, so
extracting both is an hour of GPU time for nothing. include narrows what is
found:
eto.segment.extract_videos(["/data/videos"], "/data/features", include=["cam-1"])
Each pattern is a regular expression matched against the whole path with
re.search, so a plain substring works, and it finds the camera wherever it
sits in the layout — cam-1/trial003.mp4 and trial003_cam-1.mp4 alike.
Several patterns are a union (["cam-1", "cam-3"]).
Note
A filter that matches nothing raises rather than extracting zero videos — silently doing nothing looks too much like success. The message says how many videos were found and shows a few, so you can see what to match against.
Working from a config instead? Use video_features.camera — the alignment
already knows which stream is which, so there is nothing to pattern-match.
Starting from a config#
If your sessions already have an alignment naming each trial’s video, name
each session’s video_dir and let the alignment resolve the file within it:
sessions:
- source: ../sub-01/ses-01/behav/Trial_data.nc
labels_path: ../sub-01/ses-01/behav/Trial_data_labels.tsv
video_dir: /data/videos
video_features:
extractor: s3d # or timm
analysis_fps: 25
camera: cam-1 # regex pattern so video features only for camera 1
project = eto.segment.Project("project.yaml")
project.video_features() # extract only
project.video_features(merge=True) # extract, then merge into the sessions
Sidecars go to {root}/video_features/; the paths written are returned, and
overwrite=True re-extracts. Any setting can also be overridden without
editing the file:
eto.segment.Project("project.yaml", "video_features.extractor=timm", "video_features.analysis_fps=25").
Merging into a session#
Extraction alone does not make the embedding a feature — the sidecar is on
the video’s clock, and the pipeline reads features from the session. Merging
samples the sidecar onto each trial’s own time axis (nearest neighbour,
applying that trial’s video offset) and writes a session copy carrying a
variable named after the extractor, s3d (time, s3d_dims) or
timm (time, timm_dims):
project.video_features(merge=True)
# → /data/sub-01/ses-01/behav/Trial_data_s3d.nc
The merge never overwrites your session file: it writes a sibling
{stem}_{extractor}.nc and logs the path — point your config’s sessions: at
it (or call merge_video_features()
yourself with in_place=True if you would rather overwrite). Merging is
xarray-only; for pynapple and NWB sessions the sidecar exists but you carry it
in yourself. Sidecars written before the registry existed (time_s3d) still
merge: the time dim is found by name.
Then name it like any other feature:
features:
columns:
s3d: {s3d_dims: [0, 1, 2, 3]} # or a shortlist you selected
speed: {keypoint: [beakTip]}
Choosing which dimensions to keep#
1024 (S3D) or 768 (DINOv2 ViT-B) columns is a lot next to a handful of
kinematic ones, and they dominate the input — in practice a small, well-chosen
subset does better than all of them. Two tools, both leaning on
kind="video_feature", which every extractor
stamps for you.
Is the whole group pulling its weight? That is a question about the trained model, so it takes two runs — the same materialised dataset, one extra fit:
eto.segment.Project("project.yaml", "train.run_name=full").train()
eto.segment.Project(
"project.yaml",
"train.run_name=no_video",
"train.drop_kinds=[video_feature]",
).train()
print(eto.segment.Project("project.yaml").compare())
Important
compare() reads each run’s test_metrics.yaml, which is written only when
the split leaves something in test. With train.split.test_fraction: 0 the
table comes back empty — and an ablation judged on a random trial split is
flattered anyway, so the honest version of this benchmark is
cross_validate() with and without the video columns.
Which extractor? The same two-run shape: merge both sidecars into the
session, list one or the other under features.columns, compare.
Which individual features are stereotypic? Rank them by Cohen’s d: for each feature and each behaviour class, how far apart the feature’s distribution is during that behaviour versus outside it, in pooled standard deviations. A feature with a large d for some class is one the model can act on; a feature with a small d everywhere is noise the model has to learn to ignore.
ranking, names = project.rank_video_features()
# the 20 most discriminating dims
print([names[i] for i in ranking.top(20)] )
This reads the materialised dataset, so it ranks exactly the columns a
model would see and costs no re-extraction. It picks them out by
kind="video_feature", and raises if no column declares it — describe your
features when you build the session (describe()),
then materialise again. min_frames drops a class that barely occurs in a
trial.
The library call underneath takes the trials directly, for a bank that is not in a project yet:
from ethograph.video_features import rank_features
ranking = rank_features(trials) # [(values (T, F), labels (T,)), ...]
labels are dense per-frame class ids with 0 = background; background is
excluded from the comparison by default (background=0). Scores are averaged
over trials.
In the GUI the same ranking is a heatmap — Model ▸ Video features: rank by
Cohen’s d… — of classes against the top-k features, so you can see which
behaviours each feature actually separates, and copy the top-k straight into
your config as a features.columns line, keyed by the feature and its own
dim:
features:
columns:
s3d: {s3d_dims: [492, 734, 671, 640, 585, ...]}
One video, no ethograph at all#
from ethograph.video_features import S3DConfig, build_extractor, extract_s3d
da = extract_s3d("clip.mp4", S3DConfig(stack_s=0.5)) # (time_video, s3d_dims)
da.to_netcdf("clip_s3d.nc")
da = build_extractor("timm").extract("clip.mp4") # (time_video, timm_dims)
Adding an extractor#
An extractor is an entry in ethograph.video_features.EXTRACTORS — a name
mapped to a class with name, plan(video_fps) and extract(video) — whose
extract returns to_dataarray(...). Its package is pip-installed, never
copied into the tree (notes/adr/0009-extractors-pip-installed-not-vendored.md); if the package cannot share the GUI
environment, the extractor runs it by subprocess and reads the file back.