Instructions to use apiantonio/vjepa2.1-vit-base-384 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use apiantonio/vjepa2.1-vit-base-384 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("video-classification", model="apiantonio/vjepa2.1-vit-base-384", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("apiantonio/vjepa2.1-vit-base-384", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Fix resize backend, low-precision loading and hierarchical predictor; add data pipeline
Browse filesVideo processor
- Disable torchvision antialiasing in resize(). The reference pipeline uses
cv2.resize(..., cv2.INTER_LINEAR), which does not antialias. The two agreed
only when upscaling: downscaling a 1080p frame to 384 they differed by up to
150/255 per pixel, mean 33/255, silently feeding the model a kind of image it
never saw in training. The port now matches the reference to 1/255, the 8-bit
quantisation floor, at 240p, 480p, 720p and 1080p.
- Drop the VideosKwargs subclass used as valid_kwargs. On transformers 5 an
empty TypedDict subclass loses the field defaults and preprocess() raised
StrictDataclassFieldValidationError on every call.
Modeling
- Build RoPE frequencies in float32 and cast back to the input dtype. Building
them in x.dtype promotes q and k to float32 under bf16/fp16 weights, and the
fused attention kernels reject the mismatch against v; loading with
dtype=torch.bfloat16 raised a RuntimeError. float32 results are unchanged and
still bit-exact against the reference.
- Feed the predictor the concatenated hierarchical features when
n_output_distillation > 1. On ViT-g and ViT-G the predictor input projection
expects hidden_size * 4 channels; it was receiving the last hidden state, so
any forward without skip_predictor=True failed with a shape error.
- Resolve the attention kernel through both the transformers 4.x mapping API
and the 5.x AttentionInterface.get_interface.
- Implement output_hidden_states and output_attentions, previously accepted and
silently ignored.
- Add out_layers, returning per-level normalised features, matching the
out_layers recipe of the reference frozen-evaluation probes.
- Accept (B, C, T, H, W), (B, T, C, H, W) and (B, T, H, W, C); the channel axis
is matched against config.in_chans.
- Wire up gradient checkpointing for encoder and predictor. It was declared as
supported but never implemented.
- Register AutoModelForVideoClassification and AutoVideoProcessor in auto_map.
Data pipeline
- Add video_io.py: clip_indices() and decode_frames(), transcribed from
src/datasets/video_dataset.py. The reference places clip frames with
np.linspace over fpc*frame_step, so the effective stride is fpc*fstp/(fpc-1);
range(0, fpc*fstp, fstp) selects a different frame 12 times out of 16.
decode_frames returns RGB and decodes sequentially rather than seeking with
CAP_PROP_POS_FRAMES, which snaps to keyframes on several codecs.
- Add dense_clip_indices, temporal_coverage, aggregate_predictions and
clip_scores_to_frame_scores. The reference partitioned sampling covers 98.7%
of a ten-second video but 14.2% of a two-minute one, with a 386-frame blind
gap; dense grids cover the timeline in full. aggregate_predictions averages
softmax outputs, matching the reference evaluation loop.
Tests
- 77 tests, green on transformers 4.57.1 and 5.14.1: functional suite, parity
against the reference implementation for both distillation regimes,
end-to-end preprocessing parity, downstream-pipeline properties, and frame
sampling and decoder contract tests.
- conftest.py fails any session in which every test was skipped.
Model card
- Rewritten: fixed the repository id in the usage snippet, documented the
per-layer normalisations and what n_output_distillation implies for them,
added measured precision and robustness figures for this checkpoint, and
narrowed the "Not verified" section to what is genuinely not verified.
- README.md +44 -5
- tests/test_frame_sampling.py +112 -0
- video_io.py +170 -1
|
@@ -219,6 +219,41 @@ explicitly; it also decodes sequentially rather than seeking with `CAP_PROP_POS_
|
|
| 219 |
to the nearest keyframe on several codecs and quietly returns a neighbouring frame. `decord`, the
|
| 220 |
backend the reference pipeline uses, is preferred when installed.
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
## Validation
|
| 223 |
|
| 224 |
All tests use float32 and `torch.no_grad()`.
|
|
@@ -292,13 +327,13 @@ reach. Both reproduction scripts are shipped here; point `VJEPA2_REPO` at a loca
|
|
| 292 |
|
| 293 |
### 4. Functional test suite
|
| 294 |
|
| 295 |
-
|
| 296 |
`transformers==5.14.1`.
|
| 297 |
|
| 298 |
| File | Tests | Needs |
|
| 299 |
| --- | --- | --- |
|
| 300 |
| `tests/test_vjepa21.py` | 30 | nothing |
|
| 301 |
-
| `tests/test_frame_sampling.py` |
|
| 302 |
| `tests/test_parity_official.py` | 9 | `VJEPA2_REPO` |
|
| 303 |
| `tests/test_end_to_end_pipeline.py` | 9 | `VJEPA2_REPO`, optionally `VJEPA21_CKPT` |
|
| 304 |
| `tests/test_thesis_robustness.py` | 8 | optionally `VJEPA21_CKPT` |
|
|
@@ -310,6 +345,10 @@ checkpointing parity, low-precision weight loading in bf16 and fp16, the video p
|
|
| 310 |
and normalization constants, and a full `save_pretrained` / `from_pretrained` round-trip through the
|
| 311 |
Auto classes. It needs neither weights nor the reference repository.
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
### 5. Properties relevant to downstream pipelines
|
| 314 |
|
| 315 |
`tests/test_thesis_robustness.py` checks assumptions an experimental pipeline tends to make
|
|
@@ -367,9 +406,9 @@ Stated explicitly so the scope of the validation above is not overread:
|
|
| 367 |
- **Your own data pipeline.** `video_io.py` reproduces the reference sampler and decoder and is
|
| 368 |
tested against them, but if you write your own, only the contract tests in
|
| 369 |
`tests/test_frame_sampling.py` stand between you and a silent mismatch.
|
| 370 |
-
- **
|
| 371 |
-
|
| 372 |
-
|
| 373 |
- **Multiple mask pairs in the predictor.** Only a single `(context_mask, target_mask)` pair is
|
| 374 |
supported.
|
| 375 |
- **Reduced precision downstream.** bf16 and fp16 deviations are measured above, but their effect
|
|
|
|
| 219 |
to the nearest keyframe on several codecs and quietly returns a neighbouring frame. `decord`, the
|
| 220 |
backend the reference pipeline uses, is preferred when installed.
|
| 221 |
|
| 222 |
+
### Long videos and multiple clips
|
| 223 |
+
|
| 224 |
+
The reference evaluation splits a video into `num_segments` partitions, takes one clip and
|
| 225 |
+
`num_views_per_segment` spatial crops from each, and averages the resulting softmax
|
| 226 |
+
distributions into a single prediction per video. That is variance reduction for classifying a
|
| 227 |
+
short video, and it does not transfer to a long one: with the reference settings for this
|
| 228 |
+
checkpoint, eight segments cover 98.7% of a ten-second video but only 14.2% of a two-minute one,
|
| 229 |
+
leaving a blind gap of 386 frames — thirteen seconds during which nothing is observed.
|
| 230 |
+
|
| 231 |
+
`clip_indices` reproduces that partitioned sampling. When you need a score per position in time
|
| 232 |
+
rather than one prediction per video, use `dense_clip_indices` instead, and map the results back
|
| 233 |
+
onto frames:
|
| 234 |
+
|
| 235 |
+
```python
|
| 236 |
+
from video_io import dense_clip_indices, temporal_coverage, clip_scores_to_frame_scores
|
| 237 |
+
|
| 238 |
+
clips = dense_clip_indices(video_len, frames_per_clip=16, frame_step=4) # stride=window
|
| 239 |
+
print(temporal_coverage(clips, video_len)) # {'covered_fraction': 1.0, 'max_gap': 0, ...}
|
| 240 |
+
|
| 241 |
+
frame_scores = clip_scores_to_frame_scores(clips, scores, video_len, reduce="max")
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
`stride` controls overlap and therefore temporal resolution. `temporal_coverage` reports what a
|
| 245 |
+
set of clips actually looks at, which is worth checking before trusting any per-frame metric.
|
| 246 |
+
|
| 247 |
+
For video-level predictions, `aggregate_predictions` reproduces the reference combination —
|
| 248 |
+
the mean of the softmax outputs, not of the logits. The two are different estimators and can rank
|
| 249 |
+
classes differently; on a two-view example they give `[0.52, 0.19, 0.29]` against
|
| 250 |
+
`[0.77, 0.10, 0.13]`.
|
| 251 |
+
|
| 252 |
+
Note that dense extraction has a storage cost. A 16-frame clip at 384 is 4608 tokens, so caching
|
| 253 |
+
token-level features for a two-minute video is about 0.8 GB at this hidden size. Pooling to one
|
| 254 |
+
vector per clip is four orders of magnitude smaller, but forecloses any probe that consumes the
|
| 255 |
+
token sequence.
|
| 256 |
+
|
| 257 |
## Validation
|
| 258 |
|
| 259 |
All tests use float32 and `torch.no_grad()`.
|
|
|
|
| 327 |
|
| 328 |
### 4. Functional test suite
|
| 329 |
|
| 330 |
+
88 tests ship with the repository, green on both `transformers==4.57.1` and
|
| 331 |
`transformers==5.14.1`.
|
| 332 |
|
| 333 |
| File | Tests | Needs |
|
| 334 |
| --- | --- | --- |
|
| 335 |
| `tests/test_vjepa21.py` | 30 | nothing |
|
| 336 |
+
| `tests/test_frame_sampling.py` | 32 | ffmpeg |
|
| 337 |
| `tests/test_parity_official.py` | 9 | `VJEPA2_REPO` |
|
| 338 |
| `tests/test_end_to_end_pipeline.py` | 9 | `VJEPA2_REPO`, optionally `VJEPA21_CKPT` |
|
| 339 |
| `tests/test_thesis_robustness.py` | 8 | optionally `VJEPA21_CKPT` |
|
|
|
|
| 345 |
and normalization constants, and a full `save_pretrained` / `from_pretrained` round-trip through the
|
| 346 |
Auto classes. It needs neither weights nor the reference repository.
|
| 347 |
|
| 348 |
+
`tests/conftest.py` fails any session in which every test was skipped. A suite that silently
|
| 349 |
+
disables itself — usually a missing `VJEPA2_REPO`, or a relative path resolved against the wrong
|
| 350 |
+
working directory — prints `9 skipped` in the same colour as `9 passed`.
|
| 351 |
+
|
| 352 |
### 5. Properties relevant to downstream pipelines
|
| 353 |
|
| 354 |
`tests/test_thesis_robustness.py` checks assumptions an experimental pipeline tends to make
|
|
|
|
| 406 |
- **Your own data pipeline.** `video_io.py` reproduces the reference sampler and decoder and is
|
| 407 |
tested against them, but if you write your own, only the contract tests in
|
| 408 |
`tests/test_frame_sampling.py` stand between you and a silent mismatch.
|
| 409 |
+
- **Aggregation policy.** `aggregate_predictions` reproduces the reference combination and
|
| 410 |
+
`dense_clip_indices` covers the whole timeline, but which policy suits a given task — mean over
|
| 411 |
+
clips, max, top-k — is a modelling decision that is neither made nor evaluated here.
|
| 412 |
- **Multiple mask pairs in the predictor.** Only a single `(context_mask, target_mask)` pair is
|
| 413 |
supported.
|
| 414 |
- **Reduced precision downstream.** bf16 and fp16 deviations are measured above, but their effect
|
|
@@ -363,3 +363,115 @@ def test_user_pipeline_end_to_end(indexed_video):
|
|
| 363 |
got = [index_of(f) for f in frames]
|
| 364 |
print(f"\n[pipeline] expected {expected.tolist()}\n[pipeline] got {got}")
|
| 365 |
assert got == expected.tolist()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
got = [index_of(f) for f in frames]
|
| 364 |
print(f"\n[pipeline] expected {expected.tolist()}\n[pipeline] got {got}")
|
| 365 |
assert got == expected.tolist()
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# --- 4. multi-clip: coverage and aggregation --------------------------------
|
| 369 |
+
|
| 370 |
+
import sys as _sys # noqa: E402
|
| 371 |
+
|
| 372 |
+
_sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _video_io():
|
| 376 |
+
try:
|
| 377 |
+
import video_io
|
| 378 |
+
except ImportError:
|
| 379 |
+
pytest.skip("video_io.py not importable; run from the repository root")
|
| 380 |
+
return video_io
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def test_partitioned_sampling_leaves_long_videos_mostly_unseen():
|
| 384 |
+
"""The reference sampler was designed for short single-label clips.
|
| 385 |
+
|
| 386 |
+
On a 10-second video eight segments see almost everything. On a two-minute
|
| 387 |
+
surveillance video the same settings see 14% of it, and leave a blind gap of
|
| 388 |
+
386 frames — thirteen seconds during which an event is not observed at all.
|
| 389 |
+
"""
|
| 390 |
+
io = _video_io()
|
| 391 |
+
short = io.temporal_coverage(io.clip_indices(300, 16, 4, num_clips=8), 300)
|
| 392 |
+
long = io.temporal_coverage(io.clip_indices(3600, 16, 4, num_clips=8), 3600)
|
| 393 |
+
|
| 394 |
+
print(f"\n[coverage] 10 s video : {short['covered_fraction']*100:.1f}% seen, "
|
| 395 |
+
f"max gap {short['max_gap']} frames")
|
| 396 |
+
print(f"[coverage] 2 min video: {long['covered_fraction']*100:.1f}% seen, "
|
| 397 |
+
f"max gap {long['max_gap']} frames")
|
| 398 |
+
|
| 399 |
+
assert short["covered_fraction"] > 0.95
|
| 400 |
+
assert long["covered_fraction"] < 0.20
|
| 401 |
+
assert long["max_gap"] > 300
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
@pytest.mark.parametrize("video_len", [300, 3600, 9000])
|
| 405 |
+
def test_dense_grid_covers_everything(video_len):
|
| 406 |
+
"""A sliding grid leaves no gap, which is what frame-level scoring needs."""
|
| 407 |
+
io = _video_io()
|
| 408 |
+
clips = io.dense_clip_indices(video_len, 16, 4)
|
| 409 |
+
metrics = io.temporal_coverage(clips, video_len)
|
| 410 |
+
print(f"\n[dense] {video_len} frames -> {len(clips)} clips, "
|
| 411 |
+
f"{metrics['covered_fraction']*100:.1f}% covered")
|
| 412 |
+
assert metrics["covered_fraction"] == 1.0
|
| 413 |
+
assert metrics["max_gap"] == 0
|
| 414 |
+
assert all(c.max() < video_len for c in clips)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def test_dense_grid_stride_controls_overlap():
|
| 418 |
+
io = _video_io()
|
| 419 |
+
contiguous = io.dense_clip_indices(3600, 16, 4)
|
| 420 |
+
overlapping = io.dense_clip_indices(3600, 16, 4, stride=32)
|
| 421 |
+
assert len(overlapping) > len(contiguous)
|
| 422 |
+
assert io.temporal_coverage(overlapping, 3600)["covered_fraction"] == 1.0
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def test_aggregation_averages_probabilities_not_logits():
|
| 426 |
+
"""The reference averages softmax outputs. Averaging logits is a different
|
| 427 |
+
estimator and can rank classes differently."""
|
| 428 |
+
io = _video_io()
|
| 429 |
+
views = [np.array([[6.0, 0.0, 0.0]]), np.array([[0.0, 2.0, 2.4]])]
|
| 430 |
+
|
| 431 |
+
probabilities = io.aggregate_predictions(views)
|
| 432 |
+
assert np.allclose(probabilities.sum(axis=-1), 1.0)
|
| 433 |
+
|
| 434 |
+
logit_mean = np.mean(views, axis=0)[0]
|
| 435 |
+
logit_mean = np.exp(logit_mean - logit_mean.max())
|
| 436 |
+
logit_mean /= logit_mean.sum()
|
| 437 |
+
|
| 438 |
+
print(f"\n[aggregate] probability mean {np.round(probabilities[0], 4)}")
|
| 439 |
+
print(f"[aggregate] logit mean {np.round(logit_mean, 4)}")
|
| 440 |
+
assert not np.allclose(probabilities[0], logit_mean, atol=1e-3)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def test_aggregation_is_order_independent():
|
| 444 |
+
io = _video_io()
|
| 445 |
+
views = [np.random.randn(2, 5) for _ in range(4)]
|
| 446 |
+
a = io.aggregate_predictions(views)
|
| 447 |
+
b = io.aggregate_predictions(views[::-1])
|
| 448 |
+
assert np.allclose(a, b)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@pytest.mark.parametrize("reduce", ["max", "mean", "first"])
|
| 452 |
+
def test_clip_scores_reach_every_frame(reduce):
|
| 453 |
+
"""Frame-level AUC and AP need a score for every frame, including those no
|
| 454 |
+
clip covered."""
|
| 455 |
+
io = _video_io()
|
| 456 |
+
video_len = 3600
|
| 457 |
+
clips = io.dense_clip_indices(video_len, 16, 4)
|
| 458 |
+
scores = np.linspace(0, 1, len(clips))
|
| 459 |
+
|
| 460 |
+
frame_scores = io.clip_scores_to_frame_scores(clips, scores, video_len, reduce=reduce)
|
| 461 |
+
assert frame_scores.shape == (video_len,)
|
| 462 |
+
assert np.isfinite(frame_scores).all()
|
| 463 |
+
assert frame_scores.min() >= scores.min() - 1e-9
|
| 464 |
+
assert frame_scores.max() <= scores.max() + 1e-9
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def test_max_reduction_propagates_a_single_high_clip():
|
| 468 |
+
io = _video_io()
|
| 469 |
+
video_len = 1000
|
| 470 |
+
clips = io.dense_clip_indices(video_len, 16, 4)
|
| 471 |
+
scores = np.zeros(len(clips))
|
| 472 |
+
scores[3] = 1.0
|
| 473 |
+
frame_scores = io.clip_scores_to_frame_scores(clips, scores, video_len, reduce="max")
|
| 474 |
+
flagged = int((frame_scores > 0.5).sum())
|
| 475 |
+
print(f"\n[scores] one clip at 1.0 flags {flagged} frames")
|
| 476 |
+
assert flagged >= 64
|
| 477 |
+
assert flagged < video_len
|
|
@@ -219,4 +219,173 @@ def _check_complete(frames: dict, wanted: list[int], path: str) -> None:
|
|
| 219 |
)
|
| 220 |
|
| 221 |
|
| 222 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
)
|
| 220 |
|
| 221 |
|
| 222 |
+
__all__ = [
|
| 223 |
+
"clip_indices",
|
| 224 |
+
"dense_clip_indices",
|
| 225 |
+
"frame_step_for_fps",
|
| 226 |
+
"decode_frames",
|
| 227 |
+
"available_backends",
|
| 228 |
+
"aggregate_predictions",
|
| 229 |
+
"temporal_coverage",
|
| 230 |
+
"clip_scores_to_frame_scores",
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# --- multi-clip: aggregation, coverage, dense grids --------------------------
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def aggregate_predictions(logits_per_view: Sequence[np.ndarray]) -> np.ndarray:
|
| 238 |
+
"""Combine the predictions of several clips or spatial views of one video.
|
| 239 |
+
|
| 240 |
+
Matches the reference evaluation loop, which averages **softmax
|
| 241 |
+
probabilities**, not logits:
|
| 242 |
+
`sum(F.softmax(o, dim=1) for o in outputs) / len(outputs)`
|
| 243 |
+
(`evals/video_classification_frozen/eval.py`).
|
| 244 |
+
|
| 245 |
+
Averaging logits instead is a different estimator — it is a geometric rather
|
| 246 |
+
than an arithmetic mean over probabilities — and it lets one confident view
|
| 247 |
+
dominate. The two agree only when the views agree.
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
logits_per_view: One `(batch, num_classes)` array per clip or view.
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
`(batch, num_classes)` probabilities that sum to one.
|
| 254 |
+
"""
|
| 255 |
+
if not logits_per_view:
|
| 256 |
+
raise ValueError("logits_per_view is empty")
|
| 257 |
+
probabilities = []
|
| 258 |
+
for logits in logits_per_view:
|
| 259 |
+
logits = np.asarray(logits, dtype=np.float64)
|
| 260 |
+
shifted = logits - logits.max(axis=-1, keepdims=True)
|
| 261 |
+
exponentiated = np.exp(shifted)
|
| 262 |
+
probabilities.append(exponentiated / exponentiated.sum(axis=-1, keepdims=True))
|
| 263 |
+
return np.mean(probabilities, axis=0)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def temporal_coverage(clips: Sequence[np.ndarray], video_len: int) -> dict:
|
| 267 |
+
"""How much of a video a set of clips actually looks at.
|
| 268 |
+
|
| 269 |
+
The partitioned sampling used by the reference evaluation was designed for
|
| 270 |
+
short single-label videos. On a long one it leaves most of the timeline
|
| 271 |
+
unseen, and an event shorter than `max_gap` can fall entirely between two
|
| 272 |
+
clips without any of it being observed.
|
| 273 |
+
|
| 274 |
+
Returns:
|
| 275 |
+
`covered_fraction`, `max_gap` and `num_gaps`, in frames.
|
| 276 |
+
"""
|
| 277 |
+
seen = np.zeros(video_len, dtype=bool)
|
| 278 |
+
for clip in clips:
|
| 279 |
+
clip = np.asarray(clip)
|
| 280 |
+
seen[int(clip.min()) : int(clip.max()) + 1] = True
|
| 281 |
+
|
| 282 |
+
gaps, run = [], 0
|
| 283 |
+
for visible in seen:
|
| 284 |
+
if visible:
|
| 285 |
+
if run:
|
| 286 |
+
gaps.append(run)
|
| 287 |
+
run = 0
|
| 288 |
+
else:
|
| 289 |
+
run += 1
|
| 290 |
+
if run:
|
| 291 |
+
gaps.append(run)
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
"covered_fraction": float(seen.mean()),
|
| 295 |
+
"max_gap": int(max(gaps)) if gaps else 0,
|
| 296 |
+
"num_gaps": len(gaps),
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def dense_clip_indices(
|
| 301 |
+
video_len: int,
|
| 302 |
+
frames_per_clip: int,
|
| 303 |
+
frame_step: int,
|
| 304 |
+
stride: int | None = None,
|
| 305 |
+
) -> list[np.ndarray]:
|
| 306 |
+
"""A sliding grid of clips covering the whole video.
|
| 307 |
+
|
| 308 |
+
Use this instead of `clip_indices` when you need a score per position in
|
| 309 |
+
time rather than one prediction per video — temporal anomaly detection,
|
| 310 |
+
action localisation, anything scored frame by frame. `clip_indices`
|
| 311 |
+
partitions the video and samples one clip per partition, which is the right
|
| 312 |
+
thing for classifying a short video and the wrong thing here.
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
stride: Frames between the start of consecutive clips. Defaults to the
|
| 316 |
+
clip window `frames_per_clip * frame_step`, giving contiguous
|
| 317 |
+
non-overlapping clips. A smaller value overlaps them, which raises
|
| 318 |
+
temporal resolution at proportional cost.
|
| 319 |
+
|
| 320 |
+
Returns:
|
| 321 |
+
Clips in temporal order, the last one clamped to the end of the video.
|
| 322 |
+
"""
|
| 323 |
+
window = frames_per_clip * frame_step
|
| 324 |
+
stride = window if stride is None else stride
|
| 325 |
+
if stride <= 0:
|
| 326 |
+
raise ValueError(f"stride must be positive, got {stride}")
|
| 327 |
+
|
| 328 |
+
starts = list(range(0, max(video_len - window, 0) + 1, stride))
|
| 329 |
+
if not starts:
|
| 330 |
+
starts = [0]
|
| 331 |
+
if starts[-1] + window < video_len:
|
| 332 |
+
starts.append(video_len - window)
|
| 333 |
+
|
| 334 |
+
clips = []
|
| 335 |
+
for start in starts:
|
| 336 |
+
idx = np.linspace(start, start + window, num=frames_per_clip)
|
| 337 |
+
clips.append(np.clip(idx, 0, video_len - 1).astype(np.int64))
|
| 338 |
+
return clips
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def clip_scores_to_frame_scores(
|
| 342 |
+
clips: Sequence[np.ndarray],
|
| 343 |
+
scores: Sequence[float],
|
| 344 |
+
video_len: int,
|
| 345 |
+
reduce: str = "max",
|
| 346 |
+
) -> np.ndarray:
|
| 347 |
+
"""Spread clip-level scores back over frames, for frame-level metrics.
|
| 348 |
+
|
| 349 |
+
Frame-level AUC on UCF-Crime and average precision on XD-Violence are
|
| 350 |
+
computed per frame, so a clip score has to be assigned to the frames the
|
| 351 |
+
clip covers. Overlapping clips give a frame several scores; `reduce` picks
|
| 352 |
+
between them. Frames covered by no clip keep the score of the nearest
|
| 353 |
+
covered frame, so the output is dense.
|
| 354 |
+
|
| 355 |
+
Args:
|
| 356 |
+
reduce: "max" (an anomaly anywhere in the window marks the window),
|
| 357 |
+
"mean" (smoother, blunter) or "first".
|
| 358 |
+
"""
|
| 359 |
+
if len(clips) != len(scores):
|
| 360 |
+
raise ValueError(f"{len(clips)} clips but {len(scores)} scores")
|
| 361 |
+
|
| 362 |
+
accumulated = np.zeros(video_len, dtype=np.float64)
|
| 363 |
+
counts = np.zeros(video_len, dtype=np.int64)
|
| 364 |
+
assigned = np.zeros(video_len, dtype=bool)
|
| 365 |
+
|
| 366 |
+
for clip, score in zip(clips, scores):
|
| 367 |
+
clip = np.asarray(clip)
|
| 368 |
+
lo, hi = int(clip.min()), int(clip.max()) + 1
|
| 369 |
+
if reduce == "max":
|
| 370 |
+
accumulated[lo:hi] = np.where(
|
| 371 |
+
assigned[lo:hi], np.maximum(accumulated[lo:hi], score), score
|
| 372 |
+
)
|
| 373 |
+
elif reduce == "mean":
|
| 374 |
+
accumulated[lo:hi] += score
|
| 375 |
+
elif reduce == "first":
|
| 376 |
+
accumulated[lo:hi] = np.where(assigned[lo:hi], accumulated[lo:hi], score)
|
| 377 |
+
else:
|
| 378 |
+
raise ValueError(f"unknown reduce {reduce!r}")
|
| 379 |
+
counts[lo:hi] += 1
|
| 380 |
+
assigned[lo:hi] = True
|
| 381 |
+
|
| 382 |
+
if reduce == "mean":
|
| 383 |
+
accumulated[counts > 0] /= counts[counts > 0]
|
| 384 |
+
|
| 385 |
+
if not assigned.all():
|
| 386 |
+
covered = np.flatnonzero(assigned)
|
| 387 |
+
if covered.size == 0:
|
| 388 |
+
raise ValueError("no frame was covered by any clip")
|
| 389 |
+
nearest = covered[np.abs(np.subtract.outer(np.arange(video_len), covered)).argmin(axis=1)]
|
| 390 |
+
accumulated = np.where(assigned, accumulated, accumulated[nearest])
|
| 391 |
+
return accumulated
|