From 981694428e71a5ac7158cbfb4c6788d37ed77ffb Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 00:47:01 +0000 Subject: [PATCH 01/10] =?UTF-8?q?feat(eval):=20register=20common.fvd=20?= =?UTF-8?q?=E2=80=94=20Fr=C3=A9chet=20Video=20Distance=20(I3D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the I3D-based FVD metric from #1341 into the registered eval suite as common.fvd, replacing the standalone benchmarks/fvd/ scripts. - Set-vs-set metric (is_set_metric=True): accumulate() buffers I3D features per video, finalize() returns the corpus Fréchet distance. - I3D checkpoint (flateon/FVD-I3D-torchscript) downloaded via the filelock-safe ensure_checkpoint() helper. - Reference features cached to ${FASTVIDEO_EVAL_CACHE}/fvd/real_features.pt on first encounter; reused automatically across runs. Override with $FASTVIDEO_FVD_REF_FEATURES or the cache_path= constructor kwarg (matches the audio.frechet_distance pattern). - torch.jit.fuser('none') around the I3D forward to dodge NVRTC kernel fusion errors on CUDA-version-mismatched hosts. - np.atleast_2d guards in _gaussian_params / _load_cache against 1-D feature edge cases. - merge_from() folds per-worker feature buffers for multi-GPU eval. - Warning fires below 256 accumulated videos (standard protocol: 2048). Drops the 'FVD as a registered metric' entry from the eval README's 'Out of scope' section and documents the new metric inline. Co-authored-by: abaghyangor --- fastvideo/eval/README.md | 37 +- fastvideo/eval/metrics/common/fvd/__init__.py | 0 fastvideo/eval/metrics/common/fvd/metric.py | 352 ++++++++++++++++++ 3 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 fastvideo/eval/metrics/common/fvd/__init__.py create mode 100644 fastvideo/eval/metrics/common/fvd/metric.py diff --git a/fastvideo/eval/README.md b/fastvideo/eval/README.md index 2afc15bd42..dd168ed84a 100644 --- a/fastvideo/eval/README.md +++ b/fastvideo/eval/README.md @@ -1,9 +1,9 @@ # `fastvideo.eval` In-process evaluation suite for video generations. Includes pixel -metrics (SSIM, PSNR, LPIPS), optical-flow comparisons, the full VBench -suite, Physics-IQ, audio metrics, and a VLM scorer behind a single -registry-driven API. +metrics (SSIM, PSNR, LPIPS), Fréchet Video Distance (FVD), optical-flow +comparisons, the full VBench suite, Physics-IQ, audio metrics, and a +VLM scorer behind a single registry-driven API. ## Install @@ -127,7 +127,7 @@ fastvideo/ │ ├── datasets/ # prompt corpora (vbench, physics_iq) │ └── metrics/ │ ├── base.py # BaseMetric + @register contract -│ ├── common/ # SSIM, PSNR, LPIPS +│ ├── common/ # SSIM, PSNR, LPIPS, FVD │ ├── optical_flow/ # gt_optical_flow, synthetic_optical_flow │ ├── audio/ # clap_score, audiobox_aesthetics, kl_divergence, │ │ # frechet_distance, wer, desync, imagebind_score @@ -253,13 +253,34 @@ For libraries that do not honour any env var or kwarg (pyiqa, funasr), their cache lands in the library's own dir. Document the exception in the metric's docstring if it matters. +## `common.fvd` — Fréchet Video Distance + +Set-vs-set metric. Computes the Fréchet distance between Gaussian +moments of I3D features (Kinetics-400) over the generated set and a +reference set. Lower is better; standard protocol uses 2048 videos +and a warning fires below 256. + +```python +from fastvideo.eval import create_evaluator + +ev = create_evaluator(metrics=["common.fvd"], device="cuda") +ev.evaluate(samples=[ + {"video": gen_tensor_0, "reference": ref_tensor}, # builds the cache + {"video": gen_tensor_1}, # cache reused + {"video": gen_tensor_2}, + ... +]) +# corpus result is in the returned EvalResults.corpus["common.fvd"] +``` + +Reference features are cached to ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features.pt`` +the first time ``sample["reference"]`` is passed; subsequent runs load +the cache automatically. Override with ``$FASTVIDEO_FVD_REF_FEATURES`` +or the ``cache_path=`` constructor kwarg. + ## Out of scope (follow-up PRs) - **MIND** metrics. Depend on a separate `vipe` upstream submodule. - **VBench-2.0**. Sibling vbench2 package; needs its own port. -- **FVD as a registered metric**. Currently still at `benchmarks/fvd/`. - FVD is a set-vs-set distribution distance and does not fit the - per-sample `BaseMetric.compute` API without a stateful accumulator; - conversion is a designed follow-up. - **Training-time eval callback** (`EvalCallback`) and the `RolloutEvaluator` helper. diff --git a/fastvideo/eval/metrics/common/fvd/__init__.py b/fastvideo/eval/metrics/common/fvd/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fastvideo/eval/metrics/common/fvd/metric.py b/fastvideo/eval/metrics/common/fvd/metric.py new file mode 100644 index 0000000000..01028d241b --- /dev/null +++ b/fastvideo/eval/metrics/common/fvd/metric.py @@ -0,0 +1,352 @@ +"""common.fvd — Fréchet Video Distance. + +Measures distributional similarity between generated and reference videos +using I3D features (trained on Kinetics-400). Lower score → generated +videos are closer to the real video distribution. + +FVD is a dataset-level metric — it compares the +distribution of a collection of videos rather than scoring each video +individually. For statistically reliable results at least 256 videos are +recommended; the standard protocol uses 2048. + +This metric follows the set-vs-set protocol (is_set_metric=True): + - :meth:`accumulate` is called once per video to buffer I3D features. + - :meth:`finalize` is called once after all videos to compute FVD. + - :meth:`reset` clears buffers between evaluation runs. + +Reference features +------------------ +On the first call to :meth:`accumulate` that includes +``sample["reference"]``, I3D features are extracted from those reference +videos and saved to *cache_path*. Every subsequent run loads that cache +automatically — no need to pass ``sample["reference"]`` again. + +Cache resolution order (first match wins): + 1. ``cache_path=`` constructor kwarg, if set. + 2. ``$FASTVIDEO_FVD_REF_FEATURES``, if set. + 3. ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features.pt`` (default). + +The env-var override mirrors ``audio.frechet_distance``'s +``FASTVIDEO_FAD_REF_FEATURES`` pattern. + +I3D model +--------- +Downloaded automatically from HuggingFace on first use via +:func:`fastvideo.eval.models.ensure_checkpoint` (filelock-safe): + flateon/FVD-I3D-torchscript (i3d_torchscript.pt) +Requires ``huggingface_hub`` (included in fastvideo deps). +""" + +from __future__ import annotations + +import os +import warnings + +import numpy as np +import scipy.linalg +import torch +import torch.nn.functional as F + +from fastvideo.eval.metrics.base import BaseMetric +from fastvideo.eval.registry import register +from fastvideo.eval.types import MetricResult + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_I3D_REPO_ID = "flateon/FVD-I3D-torchscript" +_I3D_FILENAME = "i3d_torchscript.pt" +_I3D_MIN_FRAMES = 10 # I3D hard minimum +_MIN_VIDEOS_WARN = 256 # below this FVD is unreliable +_REAL_FEAT_RELPATH = "fvd/real_features.pt" # relative to get_cache_dir() +_REF_FEATURES_ENV = "FASTVIDEO_FVD_REF_FEATURES" + +# --------------------------------------------------------------------------- +# I3D helpers (adapted from FastVideo benchmarks/fvd/i3d_model.py) +# --------------------------------------------------------------------------- + + +def _load_i3d(device: torch.device) -> torch.nn.Module: + """Download I3D TorchScript from HuggingFace Hub and load it. + + Uses :func:`fastvideo.eval.models.ensure_checkpoint` so the download + is filelock-safe across threads, processes, and SLURM ranks. + """ + from fastvideo.eval.models import ensure_checkpoint + path = ensure_checkpoint(_I3D_FILENAME, source=_I3D_REPO_ID, filename=_I3D_FILENAME) + model = torch.jit.load(path, map_location=device) + model.eval() + return model + + +def _preprocess(video: torch.Tensor) -> torch.Tensor: + """(B, T, C, H, W) float [0, 1] → (B, C, T, 224, 224) float [-1, 1]. + + Matches I3D preprocessing from FastVideo benchmarks/fvd/feature_extractors.py. + """ + B, T, C, H, W = video.shape + if T < _I3D_MIN_FRAMES: + raise ValueError(f"I3D requires at least {_I3D_MIN_FRAMES} frames, got {T}. " + "Increase num_frames or use a longer video.") + if H != 224 or W != 224: + video = video.reshape(B * T, C, H, W) + video = F.interpolate(video, size=(224, 224), mode="bilinear", align_corners=False) + video = video.reshape(B, T, C, 224, 224) + # Scale [0, 1] → [-1, 1] and permute to (B, C, T, H, W) + video = video * 2.0 - 1.0 + return video.permute(0, 2, 1, 3, 4).contiguous() + + +@torch.no_grad() +def _extract_features( + model: torch.nn.Module, + video: torch.Tensor, # (B, T, C, H, W) float [0, 1] + chunk: int, + device: torch.device, +) -> np.ndarray: + """Extract I3D features → (B, 400) numpy array, chunked to fit VRAM. + + ``torch.jit.fuser("none")`` disables NVRTC kernel fusion for the I3D + TorchScript forward pass. Without it, PyTorch tries to JIT-compile fused + kernels via ``libnvrtc-builtins``, which is only available on the exact + CUDA version the binary was built against (e.g. fails on Colab CUDA 12 + when the lib expects CUDA 13). Disabling fusion has no effect on + numerical correctness — the I3D model still runs in full precision on GPU. + """ + parts = [] + with torch.jit.fuser("none"): + for i in range(0, video.shape[0], chunk): + batch = _preprocess(video[i:i + chunk].to(device)) + feats = model(batch, rescale=False, resize=False, return_features=True) + if feats.dim() == 1: + feats = feats.unsqueeze(0) # (D,) → (1, D) when I3D squeezes B=1 + parts.append(feats.cpu().numpy()) + return np.concatenate(parts, axis=0) + + +# --------------------------------------------------------------------------- +# Fréchet distance (adapted from FastVideo benchmarks/fvd/fvd.py) +# --------------------------------------------------------------------------- + + +def _gaussian_params(features: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Compute mean and covariance of a feature matrix (N, D). + + ``np.atleast_2d`` guards against a 1-D array (e.g. a single feature + vector squeezed by I3D or loaded from a stale cache), which would + cause ``np.cov`` to return a 0-d scalar and break ``sigma.shape[0]``. + """ + features = np.atleast_2d(features) + mu = features.mean(axis=0) + sigma = np.cov(features, rowvar=False) + if sigma.ndim == 0: # n==1 edge case: variance scalar + sigma = sigma.reshape(1, 1) + return mu, sigma + + +def _frechet_distance( + mu1: np.ndarray, + sigma1: np.ndarray, + mu2: np.ndarray, + sigma2: np.ndarray, + eps: float = 1e-6, +) -> float: + """Compute Fréchet distance between two Gaussians N(mu1,sigma1) and N(mu2,sigma2).""" + sigma1 = sigma1 + eps * np.eye(sigma1.shape[0]) + sigma2 = sigma2 + eps * np.eye(sigma2.shape[0]) + + diff = mu1 - mu2 + covmean = scipy.linalg.sqrtm(sigma1 @ sigma2) + + if np.iscomplexobj(covmean): + if not np.allclose(np.diagonal(covmean).imag, 0, atol=1e-3): + warnings.warn( + f"FVD: large imaginary component in sqrtm " + f"({np.max(np.abs(covmean.imag)):.4f}). Result may be inaccurate.", + stacklevel=3, + ) + covmean = covmean.real + + return float(np.sum(diff**2) + np.trace(sigma1 + sigma2 - 2.0 * covmean)) + + +# --------------------------------------------------------------------------- +# Metric +# --------------------------------------------------------------------------- + + +@register("common.fvd") +class FVDMetric(BaseMetric): + """Fréchet Video Distance (FVD) using I3D features. + + Set-vs-set metric (``is_set_metric=True``). The Evaluator calls + :meth:`accumulate` once per video and :meth:`finalize` once after + all videos have been processed. + + For meaningful scores, evaluate over ≥ 256 videos (2048 is the + standard protocol used in the literature). + + Parameters + ---------- + cache_path : str, optional + Where extracted reference (real video) features are cached. + Built from ``sample["reference"]`` on the first run, reused + automatically on every subsequent run. Resolution order: + constructor kwarg → ``$FASTVIDEO_FVD_REF_FEATURES`` env-var → + ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features.pt`` (default, + resolved at ``setup()`` time so the env-var can be set after + import). + chunk_size : int + Videos per I3D forward pass. Reduce if GPU runs OOM. + """ + + name = "common.fvd" + is_set_metric = True + requires_reference = False # uses cached real features, not per-sample ref + higher_is_better = False # lower FVD = better + needs_gpu = True + dependencies = ["huggingface_hub", "scipy"] + + def __init__( + self, + cache_path: str | None = None, + chunk_size: int = 32, + ) -> None: + super().__init__() + # None → resolved lazily from get_cache_dir() in setup() so that + # FASTVIDEO_EVAL_CACHE is honoured even if set after import time. + self._cache_path_arg = cache_path + self._chunk = chunk_size + self._i3d: torch.nn.Module | None = None + + # Accumulated feature buffers — cleared by reset() + self._gen_features: list[np.ndarray] = [] + self._real_features: np.ndarray | None = None + + def to(self, device): + super().to(device) + if self._i3d is not None: + self._i3d = self._i3d.to(self.device) + return self + + def setup(self) -> None: + if self._i3d is not None: + return + from fastvideo.eval.models import get_cache_dir + # Resolve cache_path now so FASTVIDEO_EVAL_CACHE / FASTVIDEO_FVD_REF_FEATURES + # are both read at run-time. Precedence: kwarg > env-var > default. + if self._cache_path_arg is not None: + self.cache_path = os.path.expanduser(self._cache_path_arg) + elif env_path := os.environ.get(_REF_FEATURES_ENV): + self.cache_path = os.path.expanduser(env_path) + else: + self.cache_path = str(get_cache_dir() / _REAL_FEAT_RELPATH) + self._i3d = _load_i3d(self.device) + # Pre-load cached real features if available + self._real_features = self._load_cache() + + # ------------------------------------------------------------------ + # Set-vs-set protocol + # ------------------------------------------------------------------ + + def reset(self) -> None: + """Clear generated feature buffer. Called before each evaluation run.""" + self._gen_features = [] + + def accumulate(self, sample: dict) -> None: + """Extract I3D features from one generated video and buffer them. + + If ``sample["reference"]`` is provided and no cache exists yet, + reference features are extracted and saved to *cache_path*. + """ + if self._i3d is None: + self.setup() + + video = sample["video"] # (T, C, H, W) from evaluator + if video.dim() == 4: + video = video.unsqueeze(0) # → (1, T, C, H, W) + + # Buffer generated features + feats = _extract_features(self._i3d, video, self._chunk, self.device) + self._gen_features.append(feats) + + # Build real feature cache on first encounter + if self._real_features is None: + self._real_features = self._load_cache() + + if self._real_features is None: + ref = sample.get("reference") + if ref is not None: + if ref.dim() == 4: + ref = ref.unsqueeze(0) + self._real_features = _extract_features(self._i3d, ref, self._chunk, self.device) + self._save_cache(self._real_features) + + def finalize(self) -> MetricResult: + """Compute FVD from all accumulated generated features vs. real features.""" + if not self._gen_features: + return MetricResult( + name=self.name, + score=None, + details={"skipped": "No generated videos accumulated before finalize()."}, + ) + + if self._real_features is None: + return MetricResult( + name=self.name, + score=None, + details={ + "skipped": ("No reference features available. Pass sample['reference'] " + "in at least one accumulate() call to build the cache at: " + f"{self.cache_path}") + }, + ) + + all_gen = np.concatenate(self._gen_features, axis=0) + n_gen = len(all_gen) + n_real = len(self._real_features) + + if n_gen < _MIN_VIDEOS_WARN or n_real < _MIN_VIDEOS_WARN: + warnings.warn( + f"FVD computed with only {n_gen} generated and {n_real} real videos. " + f"At least {_MIN_VIDEOS_WARN} recommended (standard protocol: 2048). " + "Score may not be statistically reliable.", + stacklevel=2, + ) + + mu_gen, sigma_gen = _gaussian_params(all_gen) + mu_real, sigma_real = _gaussian_params(self._real_features) + fvd = _frechet_distance(mu_gen, sigma_gen, mu_real, sigma_real) + + return MetricResult( + name=self.name, + score=fvd, + details={ + "n_generated": n_gen, + "n_reference": n_real, + }, + ) + + def merge_from(self, other: BaseMetric) -> None: + """Fold another worker's accumulated features into this one (multi-GPU).""" + assert isinstance(other, FVDMetric) + self._gen_features.extend(other._gen_features) + # Real features are identical across workers (same cache); keep ours. + if self._real_features is None and other._real_features is not None: + self._real_features = other._real_features + + # ------------------------------------------------------------------ + # Cache helpers + # ------------------------------------------------------------------ + + def _load_cache(self) -> np.ndarray | None: + if os.path.exists(self.cache_path): + data = torch.load(self.cache_path, map_location="cpu", weights_only=True) + arr = np.atleast_2d(data.numpy()) # guard against stale 1-D cache + return arr + return None + + def _save_cache(self, features: np.ndarray) -> None: + os.makedirs(os.path.dirname(self.cache_path), exist_ok=True) + torch.save(torch.from_numpy(features), self.cache_path) From abca8891f10a5d8392df0c7f0a6a3838d8ed0a04 Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 00:49:45 +0000 Subject: [PATCH 02/10] feat(eval): common.fvd supports CLIP and VideoMAE extractors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor common.fvd to plug in a feature backbone via the new 'extractor' constructor kwarg, replacing the I3D-only implementation from the initial port. - New extractors.py: _BaseExtractor ABC + three concrete extractors (_I3DExtractor, _CLIPExtractor, _VideoMAEExtractor), all sharing the (B,T,C,H,W) float[0,1] → (B, feature_dim) numpy contract. - I3D's NVRTC kernel-fusion workaround moves with it into the I3D subclass, preserving the CUDA-version-mismatch fix. - CLIP uses ViT-B/32 frame embeds, mean-pooled over time. - VideoMAE uses MCG-NJU/videomae-base last-hidden-state, mean-pooled over patch tokens. - load_extractor(name, device) factory + available_extractors() for discovery / validation. Unknown names raise ValueError with the valid choices. - Default cache path now partitions by extractor name: ${FASTVIDEO_EVAL_CACHE}/fvd/real_features_{extractor}.pt Feature dimensions differ across backbones; sharing one cache would silently produce nonsense. - merge_from() asserts extractor parity between the two metric instances (multi-GPU eval must use the same backbone). - MetricResult.details now reports which extractor was used. CLIP and VideoMAE pull transformers, which is already a base fastvideo dependency — no pyproject.toml change required. Ported and consolidated from the standalone implementation at benchmarks/fvd/feature_extractors.py (slated for removal in a later commit on this branch). Co-authored-by: abaghyangor --- .../eval/metrics/common/fvd/extractors.py | 187 +++++++++++++++ fastvideo/eval/metrics/common/fvd/metric.py | 214 +++++++----------- 2 files changed, 271 insertions(+), 130 deletions(-) create mode 100644 fastvideo/eval/metrics/common/fvd/extractors.py diff --git a/fastvideo/eval/metrics/common/fvd/extractors.py b/fastvideo/eval/metrics/common/fvd/extractors.py new file mode 100644 index 0000000000..036cd8edca --- /dev/null +++ b/fastvideo/eval/metrics/common/fvd/extractors.py @@ -0,0 +1,187 @@ +"""Pluggable video feature extractors for FVD. + +Three extractors, sharing the ``_BaseExtractor`` contract: + +* ``i3d`` — Kinetics-400 I3D (TorchScript, ``flateon/FVD-I3D-torchscript``). + The standard FVD feature space used in the literature. +* ``clip`` — CLIP ViT-B/32 per-frame embeddings, mean-pooled over time. + Captures semantic / content quality. +* ``videomae`` — VideoMAE-base last-hidden-state, mean-pooled over patch tokens. + Captures structural / motion quality. + +The contract is intentionally narrow: each extractor takes a +``(B, T, C, H, W)`` float tensor in ``[0, 1]`` and returns ``(B, D)`` numpy +features. Preprocessing (resize, normalize, layout) is the extractor's job; +its callers should not care. + +Adapted from the standalone code at ``benchmarks/fvd/feature_extractors.py`` +that this module replaces. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +import numpy as np +import torch +import torch.nn.functional as F + +_I3D_REPO_ID = "flateon/FVD-I3D-torchscript" +_I3D_FILENAME = "i3d_torchscript.pt" +_I3D_MIN_FRAMES = 10 # I3D hard minimum (Kinetics-400 sampling) +_I3D_FEATURE_DIM = 400 + +_CLIP_MODEL_NAME = "openai/clip-vit-base-patch32" +_VIDEOMAE_MODEL_NAME = "MCG-NJU/videomae-base" + + +class _BaseExtractor(ABC): + """Common contract: ``forward`` ((B,T,C,H,W) float[0,1]) → ``(B, feature_dim)`` ndarray.""" + + feature_dim: int + + def __init__(self, device: torch.device) -> None: + self.device = device + + def to(self, device: torch.device) -> _BaseExtractor: + self.device = device + return self + + @abstractmethod + @torch.no_grad() + def forward(self, video: torch.Tensor) -> np.ndarray: + """Extract features for ``video`` and return a ``(B, feature_dim)`` numpy array.""" + + +class _I3DExtractor(_BaseExtractor): + """Kinetics-400 I3D — the canonical FVD feature space. + + ``torch.jit.fuser('none')`` disables NVRTC kernel fusion for the I3D + TorchScript forward pass. Without it, PyTorch tries to JIT-compile fused + kernels via ``libnvrtc-builtins``, which is only available on the exact + CUDA version the binary was built against (e.g. fails on Colab CUDA 12 when + the lib expects CUDA 13). No effect on numerical correctness. + """ + + feature_dim = _I3D_FEATURE_DIM + + def __init__(self, device: torch.device) -> None: + super().__init__(device) + from fastvideo.eval.models import ensure_checkpoint + path = ensure_checkpoint(_I3D_FILENAME, source=_I3D_REPO_ID, filename=_I3D_FILENAME) + model = torch.jit.load(path, map_location=device) + model.eval() + self._model = model + + def to(self, device: torch.device) -> _I3DExtractor: + super().to(device) + self._model = self._model.to(device) + return self + + @torch.no_grad() + def forward(self, video: torch.Tensor) -> np.ndarray: + B, T, C, H, W = video.shape + if T < _I3D_MIN_FRAMES: + raise ValueError(f"I3D requires at least {_I3D_MIN_FRAMES} frames, got {T}. " + "Increase num_frames or use a longer video.") + if H != 224 or W != 224: + video = video.reshape(B * T, C, H, W) + video = F.interpolate(video, size=(224, 224), mode="bilinear", align_corners=False) + video = video.reshape(B, T, C, 224, 224) + # [0, 1] → [-1, 1] then (B, T, C, H, W) → (B, C, T, H, W) + batch = (video * 2.0 - 1.0).permute(0, 2, 1, 3, 4).contiguous().to(self.device) + with torch.jit.fuser("none"): + feats = self._model(batch, rescale=False, resize=False, return_features=True) + if feats.dim() == 1: + feats = feats.unsqueeze(0) # (D,) → (1, D) when I3D squeezes B=1 + return feats.cpu().numpy() + + +class _CLIPExtractor(_BaseExtractor): + """CLIP ViT-B/32 per-frame embeds, mean-pooled over time. Semantic features.""" + + def __init__(self, device: torch.device) -> None: + super().__init__(device) + try: + from transformers import CLIPModel, CLIPProcessor + except ImportError as e: + raise ImportError("common.fvd with extractor='clip' requires transformers. " + "Install with: uv pip install -e '.[eval]'") from e + self._processor = CLIPProcessor.from_pretrained(_CLIP_MODEL_NAME) + self._model = CLIPModel.from_pretrained(_CLIP_MODEL_NAME).to(device) + self._model.eval() + self.feature_dim = self._model.config.projection_dim + + def to(self, device: torch.device) -> _CLIPExtractor: + super().to(device) + self._model = self._model.to(device) + return self + + @torch.no_grad() + def forward(self, video: torch.Tensor) -> np.ndarray: + B, T, C, H, W = video.shape + # CLIPProcessor expects uint8 images in [0, 255]; the eval pipeline + # hands us float [0, 1]. + frames = (video.clamp(0.0, 1.0) * 255.0).to(torch.uint8) + frames = frames.view(B * T, C, H, W).cpu() + inputs = self._processor(images=list(frames), return_tensors="pt", padding=True) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + feats = self._model.get_image_features(**inputs) # (B*T, D) + feats = feats.view(B, T, -1).mean(dim=1) # mean-pool over time → (B, D) + return feats.cpu().numpy() + + +class _VideoMAEExtractor(_BaseExtractor): + """VideoMAE-base last-hidden-state, patch-token mean-pooled. Structural features.""" + + def __init__(self, device: torch.device) -> None: + super().__init__(device) + try: + from transformers import VideoMAEModel + except ImportError as e: + raise ImportError("common.fvd with extractor='videomae' requires transformers. " + "Install with: uv pip install -e '.[eval]'") from e + self._model = VideoMAEModel.from_pretrained(_VIDEOMAE_MODEL_NAME).to(device) + self._model.eval() + self.feature_dim = self._model.config.hidden_size + # ImageNet normalization buffers, broadcastable over (B, T, C, H, W) + self._mean = torch.tensor([0.485, 0.456, 0.406], device=device).view(1, 1, 3, 1, 1) + self._std = torch.tensor([0.229, 0.224, 0.225], device=device).view(1, 1, 3, 1, 1) + + def to(self, device: torch.device) -> _VideoMAEExtractor: + super().to(device) + self._model = self._model.to(device) + self._mean = self._mean.to(device) + self._std = self._std.to(device) + return self + + @torch.no_grad() + def forward(self, video: torch.Tensor) -> np.ndarray: + B, T, C, H, W = video.shape + if H != 224 or W != 224: + video = video.view(B * T, C, H, W) + video = F.interpolate(video, size=(224, 224), mode="bilinear", align_corners=False) + video = video.view(B, T, C, 224, 224) + pixel_values = ((video.float().to(self.device) - self._mean) / self._std) + outputs = self._model(pixel_values) + # (B, n_patches, D) → mean over patches → (B, D) + return outputs.last_hidden_state.mean(dim=1).cpu().numpy() + + +_EXTRACTORS: dict[str, type[_BaseExtractor]] = { + "i3d": _I3DExtractor, + "clip": _CLIPExtractor, + "videomae": _VideoMAEExtractor, +} + + +def available_extractors() -> list[str]: + return sorted(_EXTRACTORS.keys()) + + +def load_extractor(name: str, device: torch.device) -> _BaseExtractor: + """Instantiate the named extractor on *device*. Raises ``ValueError`` on unknown names.""" + cls = _EXTRACTORS.get(name) + if cls is None: + raise ValueError(f"Unknown FVD extractor '{name}'. Available: {available_extractors()}") + return cls(device) diff --git a/fastvideo/eval/metrics/common/fvd/metric.py b/fastvideo/eval/metrics/common/fvd/metric.py index 01028d241b..7b3f151f8e 100644 --- a/fastvideo/eval/metrics/common/fvd/metric.py +++ b/fastvideo/eval/metrics/common/fvd/metric.py @@ -1,40 +1,51 @@ """common.fvd — Fréchet Video Distance. Measures distributional similarity between generated and reference videos -using I3D features (trained on Kinetics-400). Lower score → generated -videos are closer to the real video distribution. +using a pluggable feature backbone (I3D / CLIP / VideoMAE). Lower score → +generated videos are closer to the real video distribution. -FVD is a dataset-level metric — it compares the -distribution of a collection of videos rather than scoring each video -individually. For statistically reliable results at least 256 videos are -recommended; the standard protocol uses 2048. +FVD is a dataset-level metric — it compares the distribution of a collection +of videos rather than scoring each video individually. For statistically +reliable results at least 256 videos are recommended; the standard protocol +uses 2048. -This metric follows the set-vs-set protocol (is_set_metric=True): - - :meth:`accumulate` is called once per video to buffer I3D features. +This metric follows the set-vs-set protocol (``is_set_metric=True``): + + - :meth:`accumulate` is called once per video to buffer features. - :meth:`finalize` is called once after all videos to compute FVD. - :meth:`reset` clears buffers between evaluation runs. +Extractors +---------- + +* ``i3d`` (default) — Kinetics-400 I3D, the standard FVD feature space used + in the literature. +* ``clip`` — CLIP ViT-B/32 per-frame embeds, mean-pooled over time. Captures + semantic / content quality. +* ``videomae`` — VideoMAE-base last-hidden-state, mean-pooled over patches. + Captures structural / motion quality. + +CLIP and VideoMAE are research-grade and not directly comparable to FVD +scores from the literature; use ``i3d`` for paper comparisons. + Reference features ------------------ -On the first call to :meth:`accumulate` that includes -``sample["reference"]``, I3D features are extracted from those reference -videos and saved to *cache_path*. Every subsequent run loads that cache -automatically — no need to pass ``sample["reference"]`` again. + +On the first call to :meth:`accumulate` that includes ``sample["reference"]``, +features are extracted from those reference videos and saved to *cache_path*. +Every subsequent run loads that cache automatically — no need to pass +``sample["reference"]`` again. + +Each extractor caches to a separate file (``real_features_{extractor}.pt``) +because feature dimensions differ — mixing them silently would yield garbage. Cache resolution order (first match wins): 1. ``cache_path=`` constructor kwarg, if set. 2. ``$FASTVIDEO_FVD_REF_FEATURES``, if set. - 3. ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features.pt`` (default). + 3. ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features_{extractor}.pt`` (default). The env-var override mirrors ``audio.frechet_distance``'s ``FASTVIDEO_FAD_REF_FEATURES`` pattern. - -I3D model ---------- -Downloaded automatically from HuggingFace on first use via -:func:`fastvideo.eval.models.ensure_checkpoint` (filelock-safe): - flateon/FVD-I3D-torchscript (i3d_torchscript.pt) -Requires ``huggingface_hub`` (included in fastvideo deps). """ from __future__ import annotations @@ -45,96 +56,21 @@ import numpy as np import scipy.linalg import torch -import torch.nn.functional as F from fastvideo.eval.metrics.base import BaseMetric +from fastvideo.eval.metrics.common.fvd.extractors import (_BaseExtractor, available_extractors, load_extractor) from fastvideo.eval.registry import register from fastvideo.eval.types import MetricResult -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -_I3D_REPO_ID = "flateon/FVD-I3D-torchscript" -_I3D_FILENAME = "i3d_torchscript.pt" -_I3D_MIN_FRAMES = 10 # I3D hard minimum _MIN_VIDEOS_WARN = 256 # below this FVD is unreliable -_REAL_FEAT_RELPATH = "fvd/real_features.pt" # relative to get_cache_dir() _REF_FEATURES_ENV = "FASTVIDEO_FVD_REF_FEATURES" -# --------------------------------------------------------------------------- -# I3D helpers (adapted from FastVideo benchmarks/fvd/i3d_model.py) -# --------------------------------------------------------------------------- - - -def _load_i3d(device: torch.device) -> torch.nn.Module: - """Download I3D TorchScript from HuggingFace Hub and load it. - - Uses :func:`fastvideo.eval.models.ensure_checkpoint` so the download - is filelock-safe across threads, processes, and SLURM ranks. - """ - from fastvideo.eval.models import ensure_checkpoint - path = ensure_checkpoint(_I3D_FILENAME, source=_I3D_REPO_ID, filename=_I3D_FILENAME) - model = torch.jit.load(path, map_location=device) - model.eval() - return model - - -def _preprocess(video: torch.Tensor) -> torch.Tensor: - """(B, T, C, H, W) float [0, 1] → (B, C, T, 224, 224) float [-1, 1]. - - Matches I3D preprocessing from FastVideo benchmarks/fvd/feature_extractors.py. - """ - B, T, C, H, W = video.shape - if T < _I3D_MIN_FRAMES: - raise ValueError(f"I3D requires at least {_I3D_MIN_FRAMES} frames, got {T}. " - "Increase num_frames or use a longer video.") - if H != 224 or W != 224: - video = video.reshape(B * T, C, H, W) - video = F.interpolate(video, size=(224, 224), mode="bilinear", align_corners=False) - video = video.reshape(B, T, C, 224, 224) - # Scale [0, 1] → [-1, 1] and permute to (B, C, T, H, W) - video = video * 2.0 - 1.0 - return video.permute(0, 2, 1, 3, 4).contiguous() - - -@torch.no_grad() -def _extract_features( - model: torch.nn.Module, - video: torch.Tensor, # (B, T, C, H, W) float [0, 1] - chunk: int, - device: torch.device, -) -> np.ndarray: - """Extract I3D features → (B, 400) numpy array, chunked to fit VRAM. - - ``torch.jit.fuser("none")`` disables NVRTC kernel fusion for the I3D - TorchScript forward pass. Without it, PyTorch tries to JIT-compile fused - kernels via ``libnvrtc-builtins``, which is only available on the exact - CUDA version the binary was built against (e.g. fails on Colab CUDA 12 - when the lib expects CUDA 13). Disabling fusion has no effect on - numerical correctness — the I3D model still runs in full precision on GPU. - """ - parts = [] - with torch.jit.fuser("none"): - for i in range(0, video.shape[0], chunk): - batch = _preprocess(video[i:i + chunk].to(device)) - feats = model(batch, rescale=False, resize=False, return_features=True) - if feats.dim() == 1: - feats = feats.unsqueeze(0) # (D,) → (1, D) when I3D squeezes B=1 - parts.append(feats.cpu().numpy()) - return np.concatenate(parts, axis=0) - - -# --------------------------------------------------------------------------- -# Fréchet distance (adapted from FastVideo benchmarks/fvd/fvd.py) -# --------------------------------------------------------------------------- - def _gaussian_params(features: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Compute mean and covariance of a feature matrix (N, D). + """Compute mean and covariance of a feature matrix ``(N, D)``. ``np.atleast_2d`` guards against a 1-D array (e.g. a single feature - vector squeezed by I3D or loaded from a stale cache), which would + vector squeezed by a model or loaded from a stale cache), which would cause ``np.cov`` to return a 0-d scalar and break ``sigma.shape[0]``. """ features = np.atleast_2d(features) @@ -171,14 +107,26 @@ def _frechet_distance( return float(np.sum(diff**2) + np.trace(sigma1 + sigma2 - 2.0 * covmean)) -# --------------------------------------------------------------------------- -# Metric -# --------------------------------------------------------------------------- +def _extract_chunked( + extractor: _BaseExtractor, + video: torch.Tensor, # (B, T, C, H, W) + chunk: int, +) -> np.ndarray: + """Run *extractor* over *video* in chunks of *chunk* to bound VRAM.""" + if video.shape[0] <= chunk: + return extractor.forward(video) + parts = [extractor.forward(video[i:i + chunk]) for i in range(0, video.shape[0], chunk)] + return np.concatenate(parts, axis=0) + + +def _default_cache_path(extractor_name: str) -> str: + from fastvideo.eval.models import get_cache_dir + return str(get_cache_dir() / "fvd" / f"real_features_{extractor_name}.pt") @register("common.fvd") class FVDMetric(BaseMetric): - """Fréchet Video Distance (FVD) using I3D features. + """Fréchet Video Distance (FVD) over a pluggable feature backbone. Set-vs-set metric (``is_set_metric=True``). The Evaluator calls :meth:`accumulate` once per video and :meth:`finalize` once after @@ -189,16 +137,19 @@ class FVDMetric(BaseMetric): Parameters ---------- + extractor : str + Feature backbone name. One of ``"i3d"`` (default), ``"clip"``, + ``"videomae"``. See module docstring for guidance. cache_path : str, optional - Where extracted reference (real video) features are cached. - Built from ``sample["reference"]`` on the first run, reused - automatically on every subsequent run. Resolution order: - constructor kwarg → ``$FASTVIDEO_FVD_REF_FEATURES`` env-var → - ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features.pt`` (default, - resolved at ``setup()`` time so the env-var can be set after - import). + Where extracted reference features are cached. Built from + ``sample["reference"]`` on the first run, reused automatically on + every subsequent run. Resolution order: constructor kwarg → + ``$FASTVIDEO_FVD_REF_FEATURES`` env-var → + ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features_{extractor}.pt`` + (default, resolved at :meth:`setup` time so the env-var can be set + after import). chunk_size : int - Videos per I3D forward pass. Reduce if GPU runs OOM. + Videos per forward pass. Reduce if GPU runs OOM. """ name = "common.fvd" @@ -210,15 +161,18 @@ class FVDMetric(BaseMetric): def __init__( self, + extractor: str = "i3d", cache_path: str | None = None, chunk_size: int = 32, ) -> None: super().__init__() - # None → resolved lazily from get_cache_dir() in setup() so that - # FASTVIDEO_EVAL_CACHE is honoured even if set after import time. + if extractor not in available_extractors(): + raise ValueError(f"Unknown FVD extractor '{extractor}'. " + f"Available: {available_extractors()}") + self._extractor_name = extractor self._cache_path_arg = cache_path self._chunk = chunk_size - self._i3d: torch.nn.Module | None = None + self._extractor: _BaseExtractor | None = None # Accumulated feature buffers — cleared by reset() self._gen_features: list[np.ndarray] = [] @@ -226,24 +180,23 @@ def __init__( def to(self, device): super().to(device) - if self._i3d is not None: - self._i3d = self._i3d.to(self.device) + if self._extractor is not None: + self._extractor.to(self.device) return self def setup(self) -> None: - if self._i3d is not None: + if self._extractor is not None: return - from fastvideo.eval.models import get_cache_dir - # Resolve cache_path now so FASTVIDEO_EVAL_CACHE / FASTVIDEO_FVD_REF_FEATURES - # are both read at run-time. Precedence: kwarg > env-var > default. + # Resolve cache_path at runtime so $FASTVIDEO_EVAL_CACHE / + # $FASTVIDEO_FVD_REF_FEATURES are both honoured even when set after + # import. Precedence: kwarg > env-var > default. if self._cache_path_arg is not None: self.cache_path = os.path.expanduser(self._cache_path_arg) elif env_path := os.environ.get(_REF_FEATURES_ENV): self.cache_path = os.path.expanduser(env_path) else: - self.cache_path = str(get_cache_dir() / _REAL_FEAT_RELPATH) - self._i3d = _load_i3d(self.device) - # Pre-load cached real features if available + self.cache_path = _default_cache_path(self._extractor_name) + self._extractor = load_extractor(self._extractor_name, self.device) self._real_features = self._load_cache() # ------------------------------------------------------------------ @@ -255,21 +208,20 @@ def reset(self) -> None: self._gen_features = [] def accumulate(self, sample: dict) -> None: - """Extract I3D features from one generated video and buffer them. + """Extract features from one generated video and buffer them. If ``sample["reference"]`` is provided and no cache exists yet, reference features are extracted and saved to *cache_path*. """ - if self._i3d is None: + if self._extractor is None: self.setup() + assert self._extractor is not None # for type narrowing video = sample["video"] # (T, C, H, W) from evaluator if video.dim() == 4: video = video.unsqueeze(0) # → (1, T, C, H, W) - # Buffer generated features - feats = _extract_features(self._i3d, video, self._chunk, self.device) - self._gen_features.append(feats) + self._gen_features.append(_extract_chunked(self._extractor, video, self._chunk)) # Build real feature cache on first encounter if self._real_features is None: @@ -280,7 +232,7 @@ def accumulate(self, sample: dict) -> None: if ref is not None: if ref.dim() == 4: ref = ref.unsqueeze(0) - self._real_features = _extract_features(self._i3d, ref, self._chunk, self.device) + self._real_features = _extract_chunked(self._extractor, ref, self._chunk) self._save_cache(self._real_features) def finalize(self) -> MetricResult: @@ -323,6 +275,7 @@ def finalize(self) -> MetricResult: name=self.name, score=fvd, details={ + "extractor": self._extractor_name, "n_generated": n_gen, "n_reference": n_real, }, @@ -331,6 +284,8 @@ def finalize(self) -> MetricResult: def merge_from(self, other: BaseMetric) -> None: """Fold another worker's accumulated features into this one (multi-GPU).""" assert isinstance(other, FVDMetric) + assert other._extractor_name == self._extractor_name, ( + f"merge_from extractor mismatch: {other._extractor_name!r} vs {self._extractor_name!r}") self._gen_features.extend(other._gen_features) # Real features are identical across workers (same cache); keep ours. if self._real_features is None and other._real_features is not None: @@ -343,8 +298,7 @@ def merge_from(self, other: BaseMetric) -> None: def _load_cache(self) -> np.ndarray | None: if os.path.exists(self.cache_path): data = torch.load(self.cache_path, map_location="cpu", weights_only=True) - arr = np.atleast_2d(data.numpy()) # guard against stale 1-D cache - return arr + return np.atleast_2d(data.numpy()) # guard against stale 1-D cache return None def _save_cache(self, features: np.ndarray) -> None: From 8c0543d3a1ece766469181fa80cdc86b2107a424 Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 00:51:28 +0000 Subject: [PATCH 03/10] =?UTF-8?q?feat(examples):=20add=20eval=5Ffvd.py=20?= =?UTF-8?q?=E2=80=94=20folder-level=20FVD=20via=20the=20eval=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end example that replaces the old benchmarks/scripts/run.sh + benchmarks/fvd/run_fvd.py flow with the registered common.fvd metric. - --gen-dir / --reference-dir for the two video sets. - --extractor {i3d,clip,videomae} threads through to the metric. - --cache-path overrides the default reference-feature cache. - --output writes a structured JSON result alongside stdout. Drives the metric directly (get_metric + accumulate/finalize) instead of going through create_evaluator, so the extractor constructor kwarg is exposed without changing the multi-GPU Evaluator surface. Co-authored-by: abaghyangor --- examples/inference/eval/eval_fvd.py | 124 ++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 examples/inference/eval/eval_fvd.py diff --git a/examples/inference/eval/eval_fvd.py b/examples/inference/eval/eval_fvd.py new file mode 100644 index 0000000000..014686a916 --- /dev/null +++ b/examples/inference/eval/eval_fvd.py @@ -0,0 +1,124 @@ +"""Compute Fréchet Video Distance (FVD) over a folder of generated videos. + +Replaces the standalone ``benchmarks/fvd/run_fvd.py`` + ``benchmarks/scripts/run.sh`` +that lived in the repo before ``common.fvd`` became a registered eval metric. + +Run:: + + pip install -e .[eval] + python examples/inference/eval/eval_fvd.py \\ + --gen-dir path/to/generated_videos/ \\ + --reference-dir path/to/real_videos/ \\ + --extractor i3d \\ + --output fvd_scores.json + +The first call extracts I3D (or CLIP / VideoMAE) features over every +file under ``--reference-dir`` and caches them to +``${FASTVIDEO_EVAL_CACHE}/fvd/real_features_{extractor}.pt``. Subsequent +runs with the same ``--extractor`` reuse the cache — pass +``--reference-dir`` only on the first call (or whenever the reference +set changes). + +For paper-grade FVD scores use ``--extractor i3d`` (the literature +default) and at least 256 generated + 256 reference videos. CLIP and +VideoMAE extractors are research-grade and not directly comparable to +published FVD numbers. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import torch + +from fastvideo.eval import get_metric +from fastvideo.eval.io import load_video + + +_VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".gif"} + + +def _list_videos(directory: Path) -> list[Path]: + if not directory.is_dir(): + raise SystemExit(f"{directory} is not a directory") + out = sorted(p for p in directory.iterdir() if p.suffix.lower() in _VIDEO_EXTS) + if not out: + raise SystemExit(f"No videos under {directory} (looked for {sorted(_VIDEO_EXTS)})") + return out + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--gen-dir", type=Path, required=True, + help="Directory of generated videos (.mp4, .avi, .mov, .mkv, .gif).") + p.add_argument("--reference-dir", type=Path, default=None, + help="Directory of reference videos. Required on first run for a given " + "extractor; subsequent runs reuse the cached features.") + p.add_argument("--extractor", choices=["i3d", "clip", "videomae"], default="i3d", + help="Feature backbone (default: i3d, the standard FVD spec).") + p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") + p.add_argument("--chunk-size", type=int, default=32, + help="Videos per forward pass. Reduce if GPU OOMs.") + p.add_argument("--cache-path", type=Path, default=None, + help="Override the reference-feature cache path. " + "Defaults to ${FASTVIDEO_EVAL_CACHE}/fvd/real_features_{extractor}.pt.") + p.add_argument("--output", type=Path, default=None, + help="Write the result as JSON to this path (default: stdout only).") + args = p.parse_args() + + metric = get_metric( + "common.fvd", + extractor=args.extractor, + cache_path=str(args.cache_path) if args.cache_path else None, + chunk_size=args.chunk_size, + ) + metric.to(args.device) + metric.setup() + metric.reset() + + gen_paths = _list_videos(args.gen_dir) + print(f"Found {len(gen_paths)} generated videos under {args.gen_dir}") + + # Build the reference cache on the first generated sample, then never + # touch it again — common.fvd takes one reference *set* and reuses it + # across all subsequent accumulate() calls. + ref_tensor: torch.Tensor | None = None + if args.reference_dir is not None: + ref_paths = _list_videos(args.reference_dir) + print(f"Found {len(ref_paths)} reference videos under {args.reference_dir}") + ref_tensor = torch.stack([load_video(str(p)) for p in ref_paths]) + + for i, gp in enumerate(gen_paths): + sample: dict = {"video": load_video(str(gp))} + if i == 0 and ref_tensor is not None: + sample["reference"] = ref_tensor + metric.accumulate(sample) + if (i + 1) % 32 == 0 or i == len(gen_paths) - 1: + print(f" accumulated {i + 1}/{len(gen_paths)} generated videos") + + result = metric.finalize() + + if result.score is None: + print(f"\nFVD ({args.extractor}): SKIPPED — {result.details.get('skipped')}") + else: + print(f"\nFVD ({args.extractor}): {result.score:.4f}") + print(f" details: {result.details}") + + if args.output is not None: + payload = { + "metric": result.name, + "score": result.score, + "details": result.details, + "extractor": args.extractor, + "gen_dir": str(args.gen_dir), + "reference_dir": str(args.reference_dir) if args.reference_dir else None, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2)) + print(f" wrote {args.output}") + + +if __name__ == "__main__": + main() From 9ec49cbc68c0ef77e72f5977e4e74d7fa01212ce Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 00:51:56 +0000 Subject: [PATCH 04/10] =?UTF-8?q?chore:=20remove=20benchmarks/fvd=20?= =?UTF-8?q?=E2=80=94=20superseded=20by=20common.fvd=20eval=20metric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - benchmarks/fvd/ (9 files: fvd.py, feature_extractors.py, i3d_model.py, video_utils.py, cli.py, run_fvd.py, validate_fvd.py, __init__.py, README.md) — all functionality is now exposed via the registered common.fvd metric and examples/inference/eval/eval_fvd.py. - benchmarks/scripts/run.sh, setup_fvd.sh — replaced by the example script (`python examples/inference/eval/eval_fvd.py --help`). The benchmarks/ tree was FVD-only; nothing else lived there. Co-authored-by: abaghyangor --- benchmarks/fvd/README.md | 106 ------ benchmarks/fvd/__init__.py | 37 --- benchmarks/fvd/cli.py | 77 ----- benchmarks/fvd/feature_extractors.py | 232 -------------- benchmarks/fvd/fvd.py | 384 ---------------------- benchmarks/fvd/i3d_model.py | 124 -------- benchmarks/fvd/run_fvd.py | 51 --- benchmarks/fvd/validate_fvd.py | 89 ------ benchmarks/fvd/video_utils.py | 460 --------------------------- benchmarks/scripts/run.sh | 7 - benchmarks/scripts/setup_fvd.sh | 4 - 11 files changed, 1571 deletions(-) delete mode 100644 benchmarks/fvd/README.md delete mode 100644 benchmarks/fvd/__init__.py delete mode 100644 benchmarks/fvd/cli.py delete mode 100644 benchmarks/fvd/feature_extractors.py delete mode 100644 benchmarks/fvd/fvd.py delete mode 100644 benchmarks/fvd/i3d_model.py delete mode 100644 benchmarks/fvd/run_fvd.py delete mode 100755 benchmarks/fvd/validate_fvd.py delete mode 100644 benchmarks/fvd/video_utils.py delete mode 100755 benchmarks/scripts/run.sh delete mode 100755 benchmarks/scripts/setup_fvd.sh diff --git a/benchmarks/fvd/README.md b/benchmarks/fvd/README.md deleted file mode 100644 index b7a100cb1a..0000000000 --- a/benchmarks/fvd/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# FVD (Fréchet Video Distance) Benchmark - -Evaluate generated video quality using FVD with the I3D feature extractor. - -## Quick Start - -**Run the benchmark:** - -```bash -bash benchmarks/scripts/run.sh -``` - -That's it! The script auto-installs dependencies and runs the benchmark. - -**To customize:** Edit `benchmarks/fvd/run_fvd.py` to change: -- Video paths (`real_dir`, `gen_dir`) -- Number of videos, frames, sampling strategy -- Device, batch size, caching, etc. - -## Advanced Usage (CLI) - -For more control without editing Python files, use the CLI. - -**First-time setup** (one-time per pod/environment): - -```bash -bash benchmarks/scripts/setup_fvd.sh -``` - -Then run any configuration you want: - -```bash -# Custom configuration -python -m benchmarks.fvd.cli \ - --real-path data/real/ \ - --gen-path outputs/gen/ \ - --num-videos 1024 \ - --num-frames 32 \ - --clip-strategy random \ - --batch-size 32 \ - --seed 42 \ - --extractor clip -``` - -**Standard protocols:** - -```bash -# Use predefined protocols -python -m benchmarks.fvd.cli \ - --real-path data/real/ \ - --gen-path outputs/gen/ \ - --protocol fvd2048_16f # or fvd2048_128f, quick_test, etc. -``` - -This would use i3d model by default as the feature extractor - -**Feature caching** (speed up repeated evaluations): - -```bash -python -m benchmarks.fvd.cli \ - --real-path data/real/ \ - --gen-path outputs/gen/ \ - --protocol fvd2048_16f \ - --cache-real-features fvd-cache/extractor_name # Directory path (will save/load fvd-cache/extractor_name/extractor-name_real_features.pkl) -``` - -Run `python -m benchmarks.fvd.cli --help` for all options. - -## Available Protocols - -- `fvd2048_16f` - Standard (2048 videos, 16 frames) -- `fvd2048_128f` - Long videos (128 frames) -- `fvd2048_128f_subsample8` - Subsampled long videos -- `quick_test` - Fast testing (10 videos) - -## Configuration Options - -Key options in `FVDConfig`: - -```python -num_videos=2048, # Videos to evaluate -num_frames_per_clip=16, # Frames per clip -clip_strategy='beginning', # beginning|random|uniform|middle|sliding -frame_stride=1, # Frame subsampling -batch_size=32, # GPU batch size -device='cuda', # cuda|cpu -cache_real_features=None, # Cache path for speed -seed=42, # Reproducibility -extractor='i3d', # i3d|clip|videomae -``` - -## Programmatic Usage - -```python -from benchmarks.fvd import compute_fvd_with_config, FVDConfig - -config = FVDConfig.fvd2048_16f() # or custom config -results = compute_fvd_with_config('data/real/', 'outputs/gen/', config) -print(f"FVD: {results['fvd']:.2f}") -``` - -## Notes - -- Requires minimum 10 frames per clip -- Supports both video files (.mp4, .avi, etc.) and frame directories -- `--cache-real-features` expects a **directory path** (e.g., `cache/real`), it will automatically create/load `real_features.pkl` inside that directory diff --git a/benchmarks/fvd/__init__.py b/benchmarks/fvd/__init__.py deleted file mode 100644 index dceee1bb99..0000000000 --- a/benchmarks/fvd/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -FastVideo Frechet Video Distance (FVD) Benchmark Module. - >>> from fastvideo.benchmarks.fvd import compute_fvd_with_config, FVDConfig - >>> config = FVDConfig.fvd2048_16f() # Standard protocol - >>> results = compute_fvd_with_config('data/real/', 'outputs/gen/', config) - >>> print(f"FVD: {results['fvd']:.2f}") -""" - -from .fvd import ( - compute_fvd, - compute_fvd_with_config, - compute_frechet_distance, - compute_statistics, - FVDConfig, -) -from .feature_extractors import (BaseFeatureExtractor, I3DFeatureExtractor, load_extractor) -from .video_utils import ( - load_video_auto, - sample_clips_from_video, - load_video_clips_streaming, - ClipSamplingStrategy, -) - -__all__ = [ - 'compute_fvd', - 'compute_fvd_with_config', - 'compute_frechet_distance', - 'compute_statistics', - 'FVDConfig', - 'BaseFeatureExtractor', - 'I3DFeatureExtractor', - 'load_extractor', - 'load_video_auto', - 'sample_clips_from_video', - 'load_video_clips_streaming', - 'ClipSamplingStrategy', -] diff --git a/benchmarks/fvd/cli.py b/benchmarks/fvd/cli.py deleted file mode 100644 index 5bdc223b3e..0000000000 --- a/benchmarks/fvd/cli.py +++ /dev/null @@ -1,77 +0,0 @@ -import argparse -import sys -import traceback -from .fvd import compute_fvd_with_config, FVDConfig - - -def main() -> int: - parser = argparse.ArgumentParser(description='Compute Fréchet Video Distance (FVD)') - - # Required arguments - parser.add_argument('--real-path', type=str, required=True, help='Path to real videos') - parser.add_argument('--gen-path', type=str, required=True, help='Path to generated videos') - - # Extractor selection - parser.add_argument('--extractor', - type=str, - default='i3d', - choices=['i3d', 'clip', 'videomae'], - help='Feature extractor model to use (default: i3d)') - - # Standard args - parser.add_argument('--seed', type=int, default=None, help='Random seed for reproducibility') - parser.add_argument('--protocol', - type=str, - default=None, - choices=['fvd2048_16f', 'fvd2048_128f', 'quick_test'], - help='Use standard protocol (overrides other settings)') - parser.add_argument('--num-videos', type=int, default=2048, help='Number of videos to use') - parser.add_argument('--num-frames', type=int, default=16, help='Number of frames per clip') - parser.add_argument('--clip-strategy', type=str, default='beginning', help='Clip sampling strategy') - parser.add_argument('--batch-size', type=int, default=32, help='Batch size for feature extraction') - parser.add_argument('--device', type=str, default='cuda', help='Device to use (cuda or cpu)') - parser.add_argument('--cache-real-features', type=str, default=None, help='Path to cache real video features') - parser.add_argument('--quiet', action='store_true', help='Suppress progress output') - - args = parser.parse_args() - - # Create config - if args.protocol: - protocol_map = { - 'fvd2048_16f': FVDConfig.fvd2048_16f, - 'fvd2048_128f': FVDConfig.fvd2048_128f, - 'quick_test': FVDConfig.quick_test, - } - config = protocol_map[args.protocol]() - # Apply overrides - config.device = args.device - config.cache_real_features = args.cache_real_features - config.extractor_model = args.extractor # Apply extractor arg - else: - config = FVDConfig( - num_videos=args.num_videos, - num_frames_per_clip=args.num_frames, - extractor_model=args.extractor, # Apply extractor arg - clip_strategy=args.clip_strategy, - batch_size=args.batch_size, - device=args.device, - cache_real_features=args.cache_real_features, - seed=args.seed) - - try: - _ = compute_fvd_with_config( - args.real_path, # noqa: F841 - args.gen_path, - config, - verbose=not args.quiet) - - return 0 - - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - traceback.print_exc(file=sys.stderr) - return 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/benchmarks/fvd/feature_extractors.py b/benchmarks/fvd/feature_extractors.py deleted file mode 100644 index d0b3c23c24..0000000000 --- a/benchmarks/fvd/feature_extractors.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -Pluggable Feature Extractors for FVD Computation. -Supports I3D (standard), CLIP, and VideoMAE via a common interface. -""" - -import torch -import torch.nn as nn -import torch.nn.functional as F -from abc import ABC, abstractmethod -from huggingface_hub import hf_hub_download -from tqdm import tqdm - -try: - from transformers import CLIPModel, CLIPProcessor, VideoMAEModel - TRANSFORMERS_AVAILABLE = True -except ImportError: - TRANSFORMERS_AVAILABLE = False - - -class BaseFeatureExtractor(ABC, nn.Module): - """Abstract base class for all video feature extractors.""" - - def __init__(self, device: str = 'cuda'): - super().__init__() - self.device = torch.device(device if torch.cuda.is_available() else 'cpu') - - @property - @abstractmethod - def feature_dim(self) -> int: - """Dimension of the output feature vector.""" - pass - - @abstractmethod - def preprocess(self, videos: torch.Tensor) -> torch.Tensor: - """ - Args: - videos: [B, T, C, H, W] in [0, 255] range. - Returns: - Preprocessed tensor ready for the model. - """ - pass - - @abstractmethod - def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor: - """ - Extract features for a single batch. - Args: - videos: [B, T, C, H, W] (raw input) - Returns: - Features: [B, feature_dim] - """ - pass - - @torch.no_grad() - def extract_features(self, videos: torch.Tensor, batch_size: int = 32, verbose: bool = True) -> torch.Tensor: - """ - Extract features for a large tensor of videos by batching. - """ - N = len(videos) - all_features = [] - - iterator = range(0, N, batch_size) - if verbose: - iterator = tqdm(iterator, desc=f"Extracting features ({self.__class__.__name__})") - - for i in iterator: - batch = videos[i:i + batch_size].to(self.device) - features = self.extract_features_batch(batch) - all_features.append(features.cpu()) - - return torch.cat(all_features, dim=0) - - -# 1. I3D Extractor (The Standard FVD Metric) -class I3DFeatureExtractor(BaseFeatureExtractor): - REPO_ID = 'flateon/FVD-I3D-torchscript' - MODEL_FILENAME = 'i3d_torchscript.pt' - - def __init__(self, device: str = 'cuda', cache_dir: str | None = None): - super().__init__(device) - self.cache_dir = cache_dir - self.model = self._load_model() - self.model.eval() - self.model.to(self.device) - - @property - def feature_dim(self) -> int: - return 400 - - def _load_model(self) -> torch.nn.Module: - try: - model_path = hf_hub_download(repo_id=self.REPO_ID, filename=self.MODEL_FILENAME, cache_dir=self.cache_dir) - return torch.jit.load(model_path, map_location=self.device) - except Exception as e: - raise RuntimeError(f"Failed to load I3D model: {e}") from e - - def preprocess(self, videos: torch.Tensor) -> torch.Tensor: - """Standard I3D preprocessing: Resize to 224, Norm to [-1, 1].""" - B, T, C, H, W = videos.shape - - if T < 10: - raise ValueError(f"I3D requires at least 10 frames, got {T}") - - # Normalize to [0, 1] - if videos.max() > 1.0: - videos = videos / 255.0 - - # Scale to [-1, 1] - videos = videos * 2.0 - 1.0 - - # Resize to 224x224 - if H != 224 or W != 224: - videos = videos.reshape(B * T, C, H, W) - videos = F.interpolate(videos, size=(224, 224), mode='bilinear', align_corners=False) - videos = videos.reshape(B, T, C, 224, 224) - - # [B, T, C, H, W] -> [B, C, T, H, W] - return videos.permute(0, 2, 1, 3, 4).contiguous() - - def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor: - batch = self.preprocess(videos) - # TorchScript I3D returns raw logits when return_features=True - return self.model(batch, rescale=False, resize=False, return_features=True) - - -# 2. CLIP Extractor (Semantic/Content Quality) -class CLIPFeatureExtractor(BaseFeatureExtractor): - - def __init__(self, device: str = 'cuda', model_name: str = "openai/clip-vit-base-patch32"): - if not TRANSFORMERS_AVAILABLE: - raise ImportError("Please install transformers: uv pip install transformers") - super().__init__(device) - self.processor = CLIPProcessor.from_pretrained(model_name) - self.model = CLIPModel.from_pretrained(model_name).to(self.device) - self.model.eval() - self._feature_dim = self.model.config.projection_dim - - @property - def feature_dim(self) -> int: - return self._feature_dim - - def preprocess(self, videos: torch.Tensor) -> torch.Tensor: - # Ensure values are [0, 255] - if videos.max() <= 1.0: - videos = videos * 255.0 - - return videos.to(torch.uint8) - - def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor: - # Input: [B, T, C, H, W] - B, T, C, H, W = videos.shape - videos = self.preprocess(videos) - - # Flatten B*T to treat frames as images - images = videos.view(B * T, C, H, W) - - # HF Processor - inputs = self.processor(images=images, return_tensors="pt", padding=True) - inputs = {k: v.to(self.device) for k, v in inputs.items()} - - # Extract features [B*T, Dim] - outputs = self.model.get_image_features(**inputs) - - # Reshape [B, T, Dim] and Average Pooling over time - outputs = outputs.view(B, T, -1) - return outputs.mean(dim=1) - - -# 3. VideoMAE Extractor (Structure/Motion Quality) -class VideoMAEFeatureExtractor(BaseFeatureExtractor): - - def __init__(self, device: str = 'cuda', model_name: str = "MCG-NJU/videomae-base"): - if not TRANSFORMERS_AVAILABLE: - raise ImportError("Please install transformers: uv pip install transformers") - super().__init__(device) - self.model = VideoMAEModel.from_pretrained(model_name).to(self.device) - self.model.eval() - - self.register_buffer('mean', torch.tensor([0.485, 0.456, 0.406], device=self.device).view(1, 1, 3, 1, 1)) - self.register_buffer('std', torch.tensor([0.229, 0.224, 0.225], device=self.device).view(1, 1, 3, 1, 1)) - - @property - def feature_dim(self) -> int: - return self.model.config.hidden_size - - def preprocess(self, videos: torch.Tensor) -> torch.Tensor: - """ - Efficient GPU-based preprocessing. - Input: [B, T, C, H, W] in range [0, 255] - """ - B, T, C, H, W = videos.shape - - # 1. Resize to 224x224 - if H != 224 or W != 224: - videos = videos.view(B * T, C, H, W) - videos = F.interpolate(videos, size=(224, 224), mode='bilinear', align_corners=False) - videos = videos.view(B, T, C, 224, 224) - - # 2. Normalize to [0, 1] - if videos.dtype != torch.float32: - videos = videos.float() - - if videos.max() > 1.0: - videos = videos / 255.0 - - # 3. Apply ImageNet Mean/Std - return (videos - self.mean) / self.std - - def extract_features_batch(self, videos: torch.Tensor) -> torch.Tensor: - # Input: [B, T, C, H, W] - - # Fast GPU Preprocessing - pixel_values = self.preprocess(videos) - - # Forward pass - outputs = self.model(pixel_values) - - # Global Average Pooling of last hidden state [B, T_patches, 768] -> [B, 768] - return outputs.last_hidden_state.mean(dim=1) - - -# Factory -def load_extractor(name: str, device: str = 'cuda') -> BaseFeatureExtractor: - name = name.lower() - if name == 'i3d': - return I3DFeatureExtractor(device) - elif name == 'clip': - return CLIPFeatureExtractor(device) - elif name == 'videomae': - return VideoMAEFeatureExtractor(device) - else: - raise ValueError(f"Unknown extractor: {name}. Options: i3d, clip, videomae") diff --git a/benchmarks/fvd/fvd.py b/benchmarks/fvd/fvd.py deleted file mode 100644 index a5661dcc86..0000000000 --- a/benchmarks/fvd/fvd.py +++ /dev/null @@ -1,384 +0,0 @@ -import numpy as np -import scipy.linalg -import torch -from pathlib import Path -from collections.abc import Iterator -import pickle -from dataclasses import dataclass, field -from .feature_extractors import BaseFeatureExtractor, load_extractor -from .video_utils import ClipSamplingStrategy, load_video_clips_streaming - - -def compute_statistics(features: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Compute mean and covariance.""" - mu = np.mean(features, axis=0) - sigma = np.cov(features, rowvar=False) - return mu, sigma - - -def compute_frechet_distance(mu1: np.ndarray, - sigma1: np.ndarray, - mu2: np.ndarray, - sigma2: np.ndarray, - eps: float = 1e-6) -> float: - """ - Compute Fréchet distance between two Gaussians. - """ - sigma1 = sigma1 + eps * np.eye(sigma1.shape[0]) - sigma2 = sigma2 + eps * np.eye(sigma2.shape[0]) - - diff = mu1 - mu2 - mean_distance = np.sum(diff**2) - - trace_sum = np.trace(sigma1 + sigma2) - - covmean = scipy.linalg.sqrtm(sigma1 @ sigma2) - - if np.iscomplexobj(covmean): - if not np.allclose(np.diagonal(covmean).imag, 0, atol=1e-3): - print(f"Warning: Imaginary component: {np.max(np.abs(covmean.imag))}") - covmean = covmean.real - - trace_product = np.trace(covmean) - - fvd = mean_distance + trace_sum - 2 * trace_product - - return float(fvd) - - -@dataclass -class FVDConfig: - # default configuration for FVD computation: - - # Video selection - num_videos: int = 2048 - - # Feature Extractor Selection - extractor_model: str = 'i3d' # Options: 'i3d', 'clip', 'videomae' - - # Clip sampling - num_frames_per_clip: int = 16 - num_clips_per_video: int = 1 - clip_strategy: str | ClipSamplingStrategy = 'beginning' - - # Temporal subsampling - frame_stride: int = 1 # 1=no subsampling, 2=every 2nd, 8=every 8th - temporal_stride: int = 1 # For sliding window clips - - # Data processing - video_extensions: list[str] = field(default_factory=lambda: ['.mp4', '.avi', '.mov', '.mkv']) - support_frame_dirs: bool = True - - # Computation - batch_size: int = 32 - device: str = 'cuda' - - use_streaming: bool = True - resize_before_extraction: bool = True - - # Caching - cache_real_features: str | None = None - i3d_model_path: str | None = None - - # Reproducibility - seed: int | None = None - - @classmethod - def fvd2048_16f(cls) -> 'FVDConfig': - """Standard FVD protocol: 2048 videos, 16 frames, beginning clip.""" - return cls(num_videos=2048, num_frames_per_clip=16, clip_strategy='beginning', use_streaming=True) - - @classmethod - def fvd2048_128f(cls) -> 'FVDConfig': - """Long video protocol: 2048 videos, 128 frames.""" - return cls(num_videos=2048, num_frames_per_clip=128, clip_strategy='beginning', use_streaming=True) - - @classmethod - def quick_test(cls) -> 'FVDConfig': - """Quick test config: 100 videos, 16 frames.""" - return cls(num_videos=100, num_frames_per_clip=16, clip_strategy='beginning') - - def to_dict(self) -> dict: - """Export config to dict for logging""" - d = self.__dict__.copy() - d['clip_strategy'] = str(self.clip_strategy) - return d - - def __str__(self) -> str: - """Human-readable protocol name""" - desc = f"FVD_{self.extractor_model.upper()}_{self.num_videos}_{self.num_frames_per_clip}f" - if self.frame_stride > 1: - desc += f"_subsample{self.frame_stride}" - if self.num_clips_per_video > 1: - desc += f"_{self.num_clips_per_video}clips" - if self.clip_strategy != 'beginning': - desc += f"_{self.clip_strategy}" - return desc - - -def extract_features_streaming(video_generator: Iterator[torch.Tensor], - extractor: BaseFeatureExtractor, - batch_size: int = 32, - max_clips: int | None = None, - verbose: bool = True) -> np.ndarray: - """ - Extract features from a video clip generator using streaming. - """ - all_features = [] - batch = [] - - if verbose: - print(f"Extracting features with batch_size={batch_size}...") - - with torch.no_grad(): - for clip_count, clip in enumerate(video_generator): - batch.append(clip) - - # Process batch when full - if len(batch) == batch_size: - batch_tensor = torch.stack(batch).to(extractor.device) - features = extractor.extract_features_batch(batch_tensor) - - all_features.append(features.detach().cpu().numpy()) - batch = [] - - if verbose and clip_count % (batch_size * 10) == 0: - print(f"Processed {clip_count} clips...") - - if max_clips is not None and clip_count >= max_clips: - break - - # Process remaining clips - if len(batch) > 0: - batch_tensor = torch.stack(batch).to(extractor.device) - features = extractor.extract_features_batch(batch_tensor) - all_features.append(features.detach().cpu().numpy()) - - if len(all_features) == 0: - raise RuntimeError("No features extracted - check video loading") - - features = np.concatenate(all_features, axis=0) - - if verbose: - print(f"Extracted {len(features)} feature vectors") - - return features - - -def load_or_compute_features(videos: str | Path | torch.Tensor, - extractor: BaseFeatureExtractor, - config: FVDConfig, - cache_path: str | None = None, - cache_name: str = "real_features") -> np.ndarray: - """Load features from cache or compute (with streaming support)""" - - if cache_path is not None: - script_dir = Path(__file__).parent - cache_dir = script_dir / cache_path - cache_file = cache_dir / f"{config.extractor_model}_{cache_name}.pkl" - - if cache_file.exists(): - print(f"Loading cached features from {cache_file}") - with open(cache_file, 'rb') as f: - features = pickle.load(f) - - # Validate and limit based on config - max_features = config.num_videos * config.num_clips_per_video - - if len(features) < max_features: - print(f"WARNING: Cache has {len(features)} features but need {max_features}") - print("Cached features insufficient - will recompute...") - elif len(features) > max_features: - print(f"Using {max_features} features from cache (truncated from {len(features)})") - features = features[:max_features] - return features - else: - print(f"Using all {len(features)} cached features") - return features - - print("Computing features from scratch...") - - if isinstance(videos, (str | Path)): - target_size = (224, 224) if config.resize_before_extraction else None - - video_generator = load_video_clips_streaming(videos, - num_frames=config.num_frames_per_clip, - max_videos=config.num_videos, - clip_strategy=config.clip_strategy, - frame_stride=config.frame_stride, - num_clips_per_video=config.num_clips_per_video, - video_extensions=config.video_extensions, - support_frame_dirs=config.support_frame_dirs, - target_size=target_size, - verbose=True) - - max_clips = config.num_videos * config.num_clips_per_video - features = extract_features_streaming(video_generator, - extractor, - batch_size=config.batch_size, - max_clips=max_clips, - verbose=True) - else: - print(f"Extracting features from {len(videos)} video tensors...") - features = extractor.extract_features(videos, batch_size=config.batch_size, verbose=True) - features = features.numpy() - - # Validate feature count - expected_count = config.num_videos * config.num_clips_per_video - if len(features) < expected_count: - raise ValueError(f"ERROR: Only extracted {len(features)} features, but need {expected_count}!\n" - f"Found fewer videos than expected. Check your video directory.") - elif len(features) > expected_count: - print(f"Truncating {len(features)} features to {expected_count}") - features = features[:expected_count] - - # Cache features if requested - if cache_path is not None: - script_dir = Path(__file__).parent - cache_dir = script_dir / cache_path - cache_dir.mkdir(parents=True, exist_ok=True) - cache_file = cache_dir / f"{config.extractor_model}_{cache_name}.pkl" - print(f"Caching features to {cache_file}") - with open(cache_file, 'wb') as f: - pickle.dump(features, f) - - return features - - -def compute_fvd_with_config(real_videos: str | Path | torch.Tensor, - gen_videos: str | Path | torch.Tensor, - config: FVDConfig, - verbose: bool = True) -> dict: - """ - Compute FVD using a standardized configuration. - - This is the recommended way to compute FVD for reproducibility. - - Args: - real_videos: Path or tensors - gen_videos: Path or tensors - config: FVDConfig specifying protocol - verbose: Print progress - - Returns: - results: Dictionary with: - - 'fvd': FVD score (float) - - 'protocol': Protocol name (str) - - 'model': Feature extractor model name (str) - - 'config': Configuration dict - - Example: - >>> config = FVDConfig.fvd2048_16f() - >>> results = compute_fvd_with_config('data/real/', 'outputs/gen/', config) - >>> print(f"FVD: {results['fvd']:.2f}") - """ - - # Seed for reproducibility - if config.seed is not None: - import random as _rnd - _rnd.seed(config.seed) - np.random.seed(config.seed) - torch.manual_seed(config.seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(config.seed) - - if verbose: - print("=" * 70) - print(f"Computing FVD with protocol: {config}") - print(f"Model: {config.extractor_model.upper()}") - print("=" * 70) - print("\nConfiguration:") - for key, value in config.to_dict().items(): - print(f" {key}: {value}") - print() - - # Initialize Extractor using Factory - if verbose: - print(f"\nInitializing {config.extractor_model.upper()} model on {config.device}...") - - extractor = load_extractor(config.extractor_model, device=config.device) - - # Extract features - if verbose: - print(f"\n{'='*70}") - print("Extracting REAL video features...") - print(f"{'='*70}") - - real_features = load_or_compute_features(videos=real_videos, - extractor=extractor, - config=config, - cache_path=config.cache_real_features, - cache_name="real_features") - - if verbose: - print(f"\n{'='*70}") - print("Extracting GENERATED video features...") - print(f"{'='*70}") - - gen_features = load_or_compute_features(videos=gen_videos, - extractor=extractor, - config=config, - cache_path=None, - cache_name="gen_features") - - if verbose: - print(f"\nReal videos/clips: {len(real_features)}") - print(f"Generated videos/clips: {len(gen_features)}") - print(f"\n{'='*70}") - print("Computing statistics...") - print(f"{'='*70}") - - mu_real, sigma_real = compute_statistics(real_features) - mu_gen, sigma_gen = compute_statistics(gen_features) - - if verbose: - print(f"\n{'='*70}") - print("Computing Fréchet distance...") - print(f"{'='*70}") - - fvd = compute_frechet_distance(mu_real, sigma_real, mu_gen, sigma_gen) - - if verbose: - print(f"\n{'='*70}") - print(f"FVD Score ({config.extractor_model.upper()}): {fvd:.4f}") - print(f"Protocol: {config}") - print(f"{'='*70}\n") - - results = { - 'fvd': fvd, - 'protocol': str(config), - 'model': config.extractor_model, - 'config': config.to_dict(), - } - - return results - - -def compute_fvd(real_videos: str | Path | torch.Tensor, - gen_videos: str | Path | torch.Tensor, - num_frames: int = 16, - batch_size: int = 32, - device: str = 'cuda', - num_videos: int | None = 2048, - cache_real_features: str | None = None, - i3d_model_path: str | None = None, - seed: int | None = None, - verbose: bool = True) -> float: - """ - Backward compatibility wrapper for computing FVD (defaults to I3D). - """ - num_videos = num_videos if num_videos is not None else 2048 - - config = FVDConfig( - num_videos=num_videos, - num_frames_per_clip=num_frames, - extractor_model='i3d', # Default to I3D - batch_size=batch_size, - device=device, - cache_real_features=cache_real_features, - i3d_model_path=i3d_model_path, - seed=seed, - ) - - result = compute_fvd_with_config(real_videos, gen_videos, config, verbose) - return result['fvd'] diff --git a/benchmarks/fvd/i3d_model.py b/benchmarks/fvd/i3d_model.py deleted file mode 100644 index 3c4e3852e7..0000000000 --- a/benchmarks/fvd/i3d_model.py +++ /dev/null @@ -1,124 +0,0 @@ -"""I3D Feature Extractor for FVD Computation""" - -import torch -import torch.nn as nn -import torch.nn.functional as F -from pathlib import Path -from huggingface_hub import hf_hub_download -from tqdm import tqdm -from contextlib import suppress - - -class I3DFeatureExtractor(nn.Module): - """ - I3D feature extractor for FVD computation. - Extracts 400-dimensional features from videos using I3D model - trained on Kinetics-400. - """ - - REPO_ID = 'flateon/FVD-I3D-torchscript' - MODEL_FILENAME = 'i3d_torchscript.pt' - - def __init__(self, device: str = 'cuda', cache_dir: str | Path | None = None): - super().__init__() - - self.device_str = device - if device == 'cuda' and not torch.cuda.is_available(): - print("Warning: CUDA requested but not available – falling back to CPU") - self.device = torch.device('cpu') - else: - self.device = torch.device(device) - - self.cache_dir: str | None - if cache_dir is not None: - self.cache_dir = str(Path(cache_dir).resolve()) - else: - self.cache_dir = None # Use HF default cache - - self.model = self._load_model() - self.model.eval() - - with suppress(Exception): - self.model.to(self.device) - - def _load_model(self) -> torch.nn.Module: - """Download and load I3D TorchScript model from Hugging Face Hub.""" - print(f"Loading I3D model from Hugging Face Hub ({self.REPO_ID})...") - - try: - # Download model from Hugging Face Hub - model_path = hf_hub_download(repo_id=self.REPO_ID, filename=self.MODEL_FILENAME, cache_dir=self.cache_dir) - - # Load directly to chosen device - model = torch.jit.load(model_path, map_location=self.device) - print("I3D model loaded successfully") - return model - - except Exception as e: - raise RuntimeError(f"Failed to load I3D model from Hugging Face Hub. Error: {e}\n" - f"Ensure you have internet connection and huggingface_hub installed:\n" - f"uv pip install huggingface_hub") from e - - def preprocess(self, videos: torch.Tensor) -> torch.Tensor: - """ - Preprocess videos for I3D. - - Args: - videos: [B, T, C, H, W], values in [0, 255] - - Returns: - Preprocessed videos [B, C, T, 224, 224] (normalized and resized) - """ - B, T, C, H, W = videos.shape - - if T < 10: - raise ValueError(f"I3D requires at least 10 frames, got {T}") - - # Normalize to [0, 1] if needed - if videos.max() > 1.0: - videos = videos / 255.0 - - # Resize to 224x224 if needed - if H != 224 or W != 224: - videos = videos.reshape(B * T, C, H, W) - videos = F.interpolate(videos, size=(224, 224), mode='bilinear', align_corners=False) - videos = videos.reshape(B, T, C, 224, 224) - - # Convert to [B, C, T, H, W] format - videos = videos.permute(0, 2, 1, 3, 4).contiguous() - - return videos - - @torch.no_grad() - def extract_features(self, videos: torch.Tensor, batch_size: int = 32, verbose: bool = True) -> torch.Tensor: - """ - Extract I3D features - - Args: - videos: [N, T, C, H, W], values in [0, 255] - batch_size: Batch size for processing - verbose: Show progress bar - - Returns: - Features [N, 400] - """ - N = len(videos) - all_features = [] - - iterator = range(0, N, batch_size) - if verbose: - iterator = tqdm(iterator, desc="Extracting I3D features") - - for i in iterator: - batch = videos[i:i + batch_size].to(self.device) - batch = self.preprocess(batch) # Now returns [B, C, T, H, W] - - # Use the HF model without rescale/resize (we handle it in preprocess) - features = self.model(batch, rescale=False, resize=False, return_features=True) - - all_features.append(features.cpu()) - - return torch.cat(all_features, dim=0) - - def __call__(self, videos: torch.Tensor, batch_size: int = 32) -> torch.Tensor: - return self.extract_features(videos, batch_size=batch_size) diff --git a/benchmarks/fvd/run_fvd.py b/benchmarks/fvd/run_fvd.py deleted file mode 100644 index 9f44ff7bff..0000000000 --- a/benchmarks/fvd/run_fvd.py +++ /dev/null @@ -1,51 +0,0 @@ -import sys -from pathlib import Path - -root_dir = Path(__file__).parent.parent.parent -sys.path.insert(0, str(root_dir)) - -from benchmarks.fvd.fvd import FVDConfig, compute_fvd_with_config # noqa: E402 - - -def main() -> None: - script_dir = Path(__file__).parent.resolve() - - # Define directories - real_dir = "benchmarks/data/real_videos" - gen_dir = "benchmarks/data/generated_videos" - - # Compare all 3 models - models_to_test = ['i3d', 'clip', 'videomae'] - - print(f"\n{'='*60}") - print("STARTING COMPARISON BENCHMARK") - print(f"{'='*60}") - - for model_name in models_to_test: - print(f"\n>>> Running evaluation with {model_name.upper()}...") - - try: - cfg = FVDConfig( - num_videos=650, - num_frames_per_clip=16, - extractor_model=model_name, - clip_strategy='beginning', - device='cuda', - seed=42, - # Use separate cache folders for each model to avoid conflicts - cache_real_features=str(script_dir / f'fvd-cache/{model_name}'), - ) - - results = compute_fvd_with_config(real_dir, gen_dir, cfg, verbose=False) - print(f"FVD: {results['fvd']}\nModel: {results['model']}") - - except Exception as e: - print(f"{model_name.upper()} Failed: {e}") - - print(f"\n{'='*60}") - print("BENCHMARK COMPLETE") - print(f"{'='*60}") - - -if __name__ == '__main__': - main() diff --git a/benchmarks/fvd/validate_fvd.py b/benchmarks/fvd/validate_fvd.py deleted file mode 100755 index f7bfed7809..0000000000 --- a/benchmarks/fvd/validate_fvd.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path -import shutil -import random -from fvd import compute_fvd_with_config, FVDConfig - -script_path = Path(__file__).resolve() -fastvideo_root = script_path.parent.parent.parent -sys.path.insert(0, str(fastvideo_root)) - - -def split_videos(video_dir: Path, n_per_subset: int = 128, seed: int = 42): - subset_a = video_dir.parent / 'bair_full_subset_A' - subset_b = video_dir.parent / 'bair_full_subset_B' - - if subset_a.exists(): - shutil.rmtree(subset_a) - if subset_b.exists(): - shutil.rmtree(subset_b) - - subset_a.mkdir(parents=True) - subset_b.mkdir(parents=True) - - videos = sorted(video_dir.glob('*.mp4')) - - random.seed(seed) - shuffled = list(videos) - random.shuffle(shuffled) - - needed = n_per_subset * 2 - if len(shuffled) > needed: - shuffled = shuffled[:needed] - - mid = len(shuffled) // 2 - - print(f"\nSplitting {len(shuffled)} BAIR FULL videos:") - print(f" Subset A: {mid} videos") - print(f" Subset B: {len(shuffled) - mid} videos") - - for v in shuffled[:mid]: - shutil.copy2(v, subset_a / v.name) - - for v in shuffled[mid:]: - shutil.copy2(v, subset_b / v.name) - - return subset_a, subset_b, mid - - -def validate_fvd(subset_a: Path, subset_b: Path, num_videos: int): - config = FVDConfig(num_videos=num_videos, - num_frames_per_clip=16, - clip_strategy='beginning', - batch_size=8, - device='cuda', - seed=42) - - print("\n" + "=" * 70) - print("TEST 1: Identity Test") - print("=" * 70) - - result1 = compute_fvd_with_config(real_videos=str(subset_a), gen_videos=str(subset_a), config=config, verbose=False) - fvd_identity = result1['fvd'] - print(f"\nIdentity FVD: {fvd_identity:.2f}") - - print("\n" + "=" * 70) - print("TEST 2: Real vs Real") - print("=" * 70) - - result2 = compute_fvd_with_config(real_videos=str(subset_a), gen_videos=str(subset_b), config=config, verbose=False) - fvd_real = result2['fvd'] - print(f"\nReal vs Real FVD: {fvd_real:.2f}") - - print("\n" + "=" * 70) - print("RESULTS") - print("=" * 70) - print(f"Identity: {fvd_identity:.2f}") - print(f"Real vs Real: {fvd_real:.2f}") - - -def main() -> None: - bair_dir = Path('benchmarks/data/bair_full_videos') - - subset_a, subset_b, count = split_videos(bair_dir, n_per_subset=128, seed=42) - validate_fvd(subset_a, subset_b, count) - - -if __name__ == '__main__': - main() diff --git a/benchmarks/fvd/video_utils.py b/benchmarks/fvd/video_utils.py deleted file mode 100644 index a41fa2cc2d..0000000000 --- a/benchmarks/fvd/video_utils.py +++ /dev/null @@ -1,460 +0,0 @@ -import torch -import cv2 -import numpy as np -from pathlib import Path -from collections.abc import Iterator -from tqdm import tqdm -from enum import Enum - - -class ClipSamplingStrategy(Enum): - """Clip sampling strategies for FVD evaluation.""" - BEGINNING = 'beginning' # Take first N frames (most common) - RANDOM = 'random' # Random N consecutive frames - UNIFORM = 'uniform' # Uniformly spaced frames across video - MIDDLE = 'middle' # Middle N frames - SLIDING = 'sliding' # Multiple sliding windows - ALL = 'all' # All possible clips - - -def _load_video_cv2(video_path: str | Path, - num_frames: int | None = 16, - sample_strategy: str = 'uniform') -> torch.Tensor: - """ - Load video from video file using OpenCV. - - Args: - video_path: Path to video file (MP4, AVI, MOV, MKV) - num_frames: Number of frames to extract - sample_strategy: 'uniform' or 'random' - - Returns: - video: [T, C, H, W] - """ - video_path = str(video_path) - cap = cv2.VideoCapture(video_path) - - if not cap.isOpened(): - raise RuntimeError(f"Cannot open video: {video_path}") - - frames = [] - total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - - if num_frames is None: - # Read all available frames - while True: - ret, frame = cap.read() - if not ret: - break - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - frames.append(frame) - - cap.release() - if len(frames) == 0: - raise RuntimeError(f"Video has 0 frames: {video_path}") - - frames = np.stack(frames) # [T, H, W, C] - frames = torch.from_numpy(frames).permute(0, 3, 1, 2).float() # [T, C, H, W] - return frames - - if total_frames == 0: - raise RuntimeError(f"Video has 0 frames: {video_path}") - - # Determine frame indices for sampling - if total_frames < num_frames: - frame_indices = list(range(total_frames)) + [total_frames - 1] * (num_frames - total_frames) - elif sample_strategy == 'uniform': - frame_indices = np.linspace(0, total_frames - 1, num_frames, dtype=int).tolist() - elif sample_strategy == 'random': - frame_indices = sorted(np.random.choice(total_frames, num_frames, replace=False)) - else: - raise ValueError(f"Unknown sample_strategy: {sample_strategy}") - - # Extract frames - for idx in frame_indices: - cap.set(cv2.CAP_PROP_POS_FRAMES, idx) - ret, frame = cap.read() - - if not ret: - if len(frames) > 0: - frames.append(frames[-1].copy()) - else: - h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - frames.append(np.zeros((h, w, 3), dtype=np.uint8)) - continue - - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - frames.append(frame) - - cap.release() - - frames = np.stack(frames) # [T, H, W, C] - frames = torch.from_numpy(frames).permute(0, 3, 1, 2).float() # [T, C, H, W] - - return frames - - -def _load_video_from_frames(frame_dir: str | Path, - num_frames: int | None = 16, - sample_strategy: str = 'uniform', - frame_extensions: list[str] | None = None) -> torch.Tensor: - """ - Load video from directory of frame images. - - Args: - frame_dir: Directory containing frames - num_frames: Number of frames to sample - sample_strategy: 'uniform' or 'random' - frame_extensions: Image file extensions to look for - - Returns: - video: [T, C, H, W] - """ - if frame_extensions is None: - frame_extensions = ['.jpg', '.png', '.jpeg', '.bmp'] - - frame_dir = Path(frame_dir) - - if not frame_dir.exists(): - raise FileNotFoundError(f"Frame directory not found: {frame_dir}") - - # Find all frames - frame_files: list[Path] = [] - for ext in frame_extensions: - frame_files.extend(frame_dir.glob(f"*{ext}")) - - if len(frame_files) == 0: - raise ValueError(f"No frames found in {frame_dir} with extensions {frame_extensions}") - - frame_files = sorted(frame_files, key=lambda x: x.name) - total_frames = len(frame_files) - - # Determine frame indices - if num_frames is None: - frame_indices = list(range(total_frames)) - else: - if total_frames < num_frames: - frame_indices = list(range(total_frames)) + [total_frames - 1] * (num_frames - total_frames) - elif sample_strategy == 'uniform': - frame_indices = np.linspace(0, total_frames - 1, num_frames, dtype=int).tolist() - elif sample_strategy == 'random': - frame_indices = sorted(np.random.choice(total_frames, num_frames, replace=False)) - else: - raise ValueError(f"Unknown sample_strategy: {sample_strategy}") - - # Load frames - frames = [] - for idx in frame_indices: - frame_path = frame_files[idx] - frame = cv2.imread(str(frame_path)) - - if frame is None: - raise RuntimeError(f"Failed to load frame: {frame_path}") - - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - frames.append(frame) - - # Stack and convert to tensor - frames = np.stack(frames) # [T, H, W, C] - frames = torch.from_numpy(frames).permute(0, 3, 1, 2).float() # [T, C, H, W] - - return frames - - -def _detect_video_format(path: str | Path) -> str: - """ - Detect if path is a video file or frame directory. - - Returns: - 'video_file', 'frame_directory', or 'unknown' - """ - path = Path(path) - - if path.is_file(): - return 'video_file' - elif path.is_dir(): - # Check if contains image files - image_extensions = ['.jpg', '.jpeg', '.png', '.bmp'] - for ext in image_extensions: - if list(path.glob(f"*{ext}")): - return 'frame_directory' - return 'unknown' - else: - raise ValueError(f"Path does not exist: {path}") - - -def load_video_auto(video_path: str | Path, - num_frames: int | None = 16, - sample_strategy: str = 'uniform') -> torch.Tensor: - """ - Automatically detect format and load video. - - Supports: - - Video files (MP4, AVI, MOV, MKV) - - Frame directories (JPG, PNG) - - Args: - video_path: Path to video file or frame directory - num_frames: Number of frames to extract - sample_strategy: 'uniform' or 'random' - - Returns: - video: [T, C, H, W] - """ - format_type = _detect_video_format(video_path) - - if format_type == 'video_file': - return _load_video_cv2(video_path, num_frames, sample_strategy) - elif format_type == 'frame_directory': - return _load_video_from_frames(video_path, num_frames, sample_strategy) - else: - raise ValueError(f"Unknown video format at {video_path}") - - -def sample_clips_from_video(video: torch.Tensor, - num_frames_per_clip: int = 16, - num_clips: int = 1, - strategy: str | ClipSamplingStrategy = ClipSamplingStrategy.BEGINNING, - frame_stride: int = 1, - temporal_stride: int = 1) -> list[torch.Tensor]: - """ - Sample clips from a video with various strategies. - - Args: - video: [T, C, H, W] full video - num_frames_per_clip: Frames per clip - num_clips: Number of clips to extract - strategy: ClipSamplingStrategy or string ('beginning', 'random', etc.) - frame_stride: Skip frames (FPS control: 1=all, 2=every 2nd, 8=every 8th) - temporal_stride: Stride between clips for sliding window - - Returns: - List of clips, each [num_frames_per_clip, C, H, W] - - Examples: - >>> # Beginning clip (most common for FVD) - >>> clips = sample_clips_from_video(video, 16, strategy='beginning') - - >>> # Multiple random clips - >>> clips = sample_clips_from_video(video, 16, num_clips=4, strategy='random') - - >>> # Subsample FPS by 2x (every 2nd frame) - >>> clips = sample_clips_from_video(video, 16, frame_stride=2) - - >>> # Sliding window with overlap - >>> clips = sample_clips_from_video(video, 16, strategy='sliding', temporal_stride=8) - """ - # Convert string to enum if needed - if isinstance(strategy, str): - strategy = ClipSamplingStrategy(strategy) - - T, C, H, W = video.shape - - # Apply frame stride (FPS subsampling) - if frame_stride > 1: - video = video[::frame_stride] - T = len(video) - - effective_clip_length = num_frames_per_clip - - # Handle videos shorter than clip length - if effective_clip_length > T: - pad_length = effective_clip_length - T - last_frame = video[-1:].repeat(pad_length, 1, 1, 1) - video = torch.cat([video, last_frame], dim=0) - T = len(video) - - clips = [] - - if strategy == ClipSamplingStrategy.BEGINNING: - # Take first clip (most common for FVD evaluation) - clip = video[:effective_clip_length] - clips.append(clip) - - elif strategy == ClipSamplingStrategy.MIDDLE: - # Take middle clip - start = (T - effective_clip_length) // 2 - clip = video[start:start + effective_clip_length] - clips.append(clip) - - elif strategy == ClipSamplingStrategy.RANDOM: - # Sample N random clips - for _ in range(num_clips): - start = 0 if effective_clip_length == T else np.random.randint(0, T - effective_clip_length + 1) - clip = video[start:start + effective_clip_length] - clips.append(clip) - - elif strategy == ClipSamplingStrategy.UNIFORM: - # Uniformly spaced clips - if num_clips == 1: - # Single clip from middle - start = (T - effective_clip_length) // 2 - clip = video[start:start + effective_clip_length] - clips.append(clip) - else: - # Multiple uniformly spaced clips - step = (T - effective_clip_length) / (num_clips - 1) if num_clips > 1 else 0 - for i in range(num_clips): - start = int(i * step) - start = min(start, T - effective_clip_length) - clip = video[start:start + effective_clip_length] - clips.append(clip) - - elif strategy == ClipSamplingStrategy.SLIDING: - # Sliding window with stride - for start in range(0, T - effective_clip_length + 1, temporal_stride): - clip = video[start:start + effective_clip_length] - clips.append(clip) - if len(clips) >= num_clips: - break - - elif strategy == ClipSamplingStrategy.ALL: - # All possible clips (overlapping) - for start in range(T - effective_clip_length + 1): - clip = video[start:start + effective_clip_length] - clips.append(clip) - - else: - raise ValueError(f"Unknown strategy: {strategy}") - - return clips - - -def load_video_clips_streaming(directory: str | Path, - num_frames: int = 16, - max_videos: int | None = None, - clip_strategy: str - | ClipSamplingStrategy = 'beginning', - frame_stride: int = 1, - num_clips_per_video: int = 1, - video_extensions: list[str] | None = None, - support_frame_dirs: bool = True, - target_size: tuple[int, int] | None = (224, 224), - verbose: bool = True) -> Iterator[torch.Tensor]: - """ - This generator yields clips one-by-one instead of loading all videos into RAM. - Perfect for large datasets where memory is limited. - - Args: - directory: Path to directory with videos - num_frames: Frames per clip - max_videos: Max videos to load - clip_strategy: 'beginning', 'random', 'uniform', etc. - frame_stride: Frame skip (1=all, 2=every 2nd, 8=every 8th) - num_clips_per_video: Number of clips per video - video_extensions: Video file extensions - support_frame_dirs: Also load frame directories - target_size: Resize clips to (H, W). If None, keep original size. - verbose: Show progress - - Yields: - clip: [T, C, H, W] individual clips - - Example: - >>> for clip in load_video_clips_streaming('data/videos/', num_frames=16): - >>> features = model.extract_features(clip.unsqueeze(0)) - >>> # Process one clip at a time - low memory usage! - """ - if video_extensions is None: - video_extensions = ['.mp4', '.avi', '.mov', '.mkv'] - - directory = Path(directory) - - if not directory.exists(): - raise FileNotFoundError(f"Directory not found: {directory}") - - # Find video paths - video_paths: list[Path] = [] - - # Find video files - for ext in video_extensions: - video_paths.extend(directory.glob(f"**/*{ext}")) - - # Find frame directories if enabled - if support_frame_dirs: - for subdir in directory.iterdir(): - if subdir.is_dir(): - # Check if it contains frames - image_extensions = ['.jpg', '.jpeg', '.png', '.bmp'] - for ext in image_extensions: - if list(subdir.glob(f"*{ext}")): - video_paths.append(subdir) - break - - if len(video_paths) == 0: - raise ValueError(f"No videos found in {directory}") - - video_paths = sorted(video_paths) - - if max_videos is not None: - video_paths = video_paths[:max_videos] - - if verbose: - print(f"Found {len(video_paths)} videos in {directory}") - if num_clips_per_video > 1: - print(f"Extracting {num_clips_per_video} clips per video...") - if frame_stride > 1: - print(f"Subsampling frames with stride {frame_stride}...") - if target_size: - print(f"Resizing clips to {target_size}...") - - # Track statistics - failed_count = 0 - total_clips = 0 - - iterator = tqdm(video_paths, desc="Loading videos") if verbose else video_paths - - for video_path in iterator: - try: - # Load full video - video = load_video_auto(video_path, num_frames=None, sample_strategy='uniform') - - # Sample clips from video - clips = sample_clips_from_video(video, - num_frames_per_clip=num_frames, - num_clips=num_clips_per_video, - strategy=clip_strategy, - frame_stride=frame_stride) - - if target_size is not None: - resized_clips = [] - for clip in clips: - T, C, H, W = clip.shape - if target_size != (H, W): - # Resize to target size - clip = clip.contiguous() # Fix non-contiguous tensors first - clip_flat = clip.view(T * C, H, W).unsqueeze(0) # [1, T*C, H, W] - clip_resized = torch.nn.functional.interpolate(clip_flat, - size=target_size, - mode='bilinear', - align_corners=False) - clip = clip_resized.squeeze(0).view(T, C, target_size[0], - target_size[1]) # Back to [T, C, H, W] - resized_clips.append(clip) - clips = resized_clips - - # Yield clips one by one - for clip in clips: - yield clip - total_clips += 1 - - # Free memory - del video, clips - - except Exception as e: - failed_count += 1 - if verbose: - print(f"\nWarning: Failed to load {video_path}: {e}") - continue - - # Validate - if total_clips == 0: - raise RuntimeError(f"Failed to load any videos from {directory}") - - failure_rate = failed_count / len(video_paths) - if failure_rate > 0.1: # More than 10% failed - print(f"\nWARNING: {failure_rate:.1%} of videos failed to load ({failed_count}/{len(video_paths)})") - - if verbose: - print(f"\nSuccessfully loaded {total_clips} clips from {len(video_paths) - failed_count} videos") diff --git a/benchmarks/scripts/run.sh b/benchmarks/scripts/run.sh deleted file mode 100755 index dd824e4b37..0000000000 --- a/benchmarks/scripts/run.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# 1. Install missing dependency -uv pip install -q opencv-python-headless transformers huggingface_hub - -# 2. Run FVD script -python benchmarks/fvd/run_fvd.py diff --git a/benchmarks/scripts/setup_fvd.sh b/benchmarks/scripts/setup_fvd.sh deleted file mode 100755 index 012a68802a..0000000000 --- a/benchmarks/scripts/setup_fvd.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -# 1. Install missing dependency -uv pip install -q opencv-python-headless \ No newline at end of file From f5d0eac7102aa24cbe321417b392056d962b19c4 Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 00:53:48 +0000 Subject: [PATCH 05/10] docs: retarget FVD references to common.fvd - .agents/memory/evaluation-registry/README.md: update location, status, and usage examples (programmatic + CLI) to point at the new metric and the eval_fvd.py example. - .agents/onboarding/worldmodel-training/README.md: update the FVD row in the quick-summary metrics table. - examples/inference/eval/eval_fvd.py + extractors.py: drop the 'replaces benchmarks/fvd/...' lines now that benchmarks/fvd/ is gone. Repo-wide grep for benchmarks/fvd / benchmarks.fvd / setup_fvd / run_fvd returns zero matches. Co-authored-by: abaghyangor --- .agents/memory/evaluation-registry/README.md | 49 +++++++++++-------- .../onboarding/worldmodel-training/README.md | 2 +- examples/inference/eval/eval_fvd.py | 3 -- .../eval/metrics/common/fvd/extractors.py | 2 - 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/.agents/memory/evaluation-registry/README.md b/.agents/memory/evaluation-registry/README.md index 10309d04f0..46fc5041bc 100644 --- a/.agents/memory/evaluation-registry/README.md +++ b/.agents/memory/evaluation-registry/README.md @@ -12,7 +12,7 @@ _Last updated: 2026-03-02_ | Metric | Category | Status | Location | Trust | |--------|----------|--------|----------|-------| -| **FVD** | Distribution | ✅ Implemented | `benchmarks/fvd/` | High | +| **FVD** | Distribution | ✅ Implemented | `fastvideo/eval/metrics/common/fvd/` | High | | **SSIM** | Reference | ✅ Implemented | `fastvideo/tests/ssim/` | High | | **LPIPS** | Perceptual | ✅ Implemented | `scripts/lora_extraction/` | Medium | | **Loss trajectory** | Training signal | ✅ Implemented | W&B `train_loss` | Medium | @@ -27,8 +27,8 @@ _Last updated: 2026-03-02_ ### FVD — Fréchet Video Distance **Category**: Distribution-level quality metric -**Status**: ✅ Fully implemented in `benchmarks/fvd/` -**Trust**: High — standard protocol, I3D feature extractor +**Status**: ✅ Registered as the `common.fvd` eval metric in `fastvideo/eval/metrics/common/fvd/` +**Trust**: High — standard protocol, I3D feature extractor (CLIP / VideoMAE backbones also available, research-grade) #### What It Measures FVD measures the distance between the **distribution** of generated videos and @@ -59,30 +59,39 @@ Lower FVD = generated videos are more statistically similar to real videos. #### How to Use ```python -# Programmatic -from benchmarks.fvd import compute_fvd_with_config, FVDConfig +# Programmatic — drive the metric directly for custom kwargs +from fastvideo.eval import get_metric -config = FVDConfig.fvd2048_16f() # Standard: 2048 videos, 16 frames -results = compute_fvd_with_config('data/real/', 'outputs/gen/', config) -print(f"FVD: {results['fvd']:.2f}") +metric = get_metric("common.fvd", extractor="i3d") # or "clip" / "videomae" +metric.to("cuda") +metric.setup() +metric.reset() + +# First sample carries the reference set; later samples reuse the cache. +metric.accumulate({"video": gen_tensors[0], "reference": real_tensors}) +for gen in gen_tensors[1:]: + metric.accumulate({"video": gen}) + +result = metric.finalize() +print(f"FVD: {result.score:.2f}") ``` ```bash -# CLI -python -m benchmarks.fvd.cli \ - --real-path data/real/ \ - --gen-path outputs/gen/ \ - --protocol fvd2048_16f +# CLI — folder of generated mp4s vs a reference folder +python examples/inference/eval/eval_fvd.py \ + --gen-dir outputs/gen/ \ + --reference-dir data/real/ \ + --extractor i3d \ + --output fvd_scores.json ``` -**Preset protocols**: -| Protocol | Videos | Frames | Use Case | -|----------|--------|--------|----------| -| `fvd2048_16f` | 2048 | 16 | Standard benchmark (papers) | -| `fvd2048_128f` | 2048 | 128 | Long video evaluation | -| `quick_test` | 100 | 16 | Fast dev iteration | +**Feature extractors**: `i3d` (default, standard FVD spec used in papers), +`clip`, `videomae` (research-grade; not directly comparable to published +FVD numbers). -**Feature extractors**: `i3d` (default, standard), `clip`, `videomae` +**Protocol**: standard FVD uses 2048 generated + 2048 reference videos at +16 frames each. A warning fires below 256 — the score becomes +statistically unreliable. #### Interpretation | FVD Range | Interpretation | diff --git a/.agents/onboarding/worldmodel-training/README.md b/.agents/onboarding/worldmodel-training/README.md index 3cc05e918e..98958ad7d1 100644 --- a/.agents/onboarding/worldmodel-training/README.md +++ b/.agents/onboarding/worldmodel-training/README.md @@ -258,7 +258,7 @@ Read `.agents/memory/evaluation-registry/README.md` for the full metric catalog. |--------|-------------|-------| | **Loss trajectory** | Every run, real-time from W&B | Medium | | **SSIM** | When comparing against reference outputs | High | -| **FVD** | For benchmarking model quality (`benchmarks/fvd/`) | High | +| **FVD** | For benchmarking model quality (`common.fvd` eval metric; example: `examples/inference/eval/eval_fvd.py`) | High | | **LPIPS** | LoRA merge validation | Medium | | **Human preference** | Major checkpoints | Highest | diff --git a/examples/inference/eval/eval_fvd.py b/examples/inference/eval/eval_fvd.py index 014686a916..7b501523ce 100644 --- a/examples/inference/eval/eval_fvd.py +++ b/examples/inference/eval/eval_fvd.py @@ -1,8 +1,5 @@ """Compute Fréchet Video Distance (FVD) over a folder of generated videos. -Replaces the standalone ``benchmarks/fvd/run_fvd.py`` + ``benchmarks/scripts/run.sh`` -that lived in the repo before ``common.fvd`` became a registered eval metric. - Run:: pip install -e .[eval] diff --git a/fastvideo/eval/metrics/common/fvd/extractors.py b/fastvideo/eval/metrics/common/fvd/extractors.py index 036cd8edca..f851b3d632 100644 --- a/fastvideo/eval/metrics/common/fvd/extractors.py +++ b/fastvideo/eval/metrics/common/fvd/extractors.py @@ -14,8 +14,6 @@ features. Preprocessing (resize, normalize, layout) is the extractor's job; its callers should not care. -Adapted from the standalone code at ``benchmarks/fvd/feature_extractors.py`` -that this module replaces. """ from __future__ import annotations From 0348a47928a6dec1844031e129008eed02588499 Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 00:56:46 +0000 Subject: [PATCH 06/10] test(eval): add 16 CPU-only cases for common.fvd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All tests run without GPU and without downloading any backbone weights. extractors.load_extractor is monkeypatched to a deterministic _DummyExtractor (16-d random features) via the _install_dummy_loader helper. Coverage: 1. Registry — common.fvd appears in list_metrics(); class attributes match the set-vs-set + lower-is-better contract. 2. Extractor factory — available_extractors() lists all three; unknown name raises ValueError both at the factory level and via the FVDMetric constructor; the registry table maps name → class correctly without instantiation. 3. Skip path — finalize() without a reference returns score=None with the cache path in details['skipped']. 4. Math — Fréchet distance of identical Gaussians is 0; translation invariance d(N(μ,Σ), N(μ+v,Σ)) == ||v||²; np.atleast_2d guard handles 1-D stale-cache features; n=1 covariance reshape. 5. Cache partitioning — default path partitions by extractor name; env-var FASTVIDEO_FVD_REF_FEATURES overrides default; constructor kwarg outranks env-var; merge_from rejects extractor mismatch. Plus an end-to-end happy-path: 5 generated + 4 reference videos yields a finite score and writes the cache file. Co-authored-by: abaghyangor --- fastvideo/tests/eval/test_fvd.py | 228 +++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 fastvideo/tests/eval/test_fvd.py diff --git a/fastvideo/tests/eval/test_fvd.py b/fastvideo/tests/eval/test_fvd.py new file mode 100644 index 0000000000..a1479222b6 --- /dev/null +++ b/fastvideo/tests/eval/test_fvd.py @@ -0,0 +1,228 @@ +"""CPU-only regression tests for ``common.fvd``. + +No GPU, no model download — everything that needs a backbone is +monkeypatched to a deterministic dummy that returns random features. +GPU-side correctness is exercised in the example script +``examples/inference/eval/eval_fvd.py``. +""" +from __future__ import annotations + +import numpy as np +import pytest +import torch + +from fastvideo.eval import get_metric, list_metrics +from fastvideo.eval.metrics.common.fvd import extractors +from fastvideo.eval.metrics.common.fvd.metric import ( + FVDMetric, + _default_cache_path, + _frechet_distance, + _gaussian_params, +) + + +# --------------------------------------------------------------------------- +# 1. Registry contract +# --------------------------------------------------------------------------- + + +def test_common_fvd_is_registered(): + assert "common.fvd" in list_metrics() + + +def test_fvd_class_attributes(): + cls = FVDMetric + assert cls.name == "common.fvd" + assert cls.is_set_metric is True + assert cls.higher_is_better is False + assert cls.needs_gpu is True + assert cls.requires_reference is False # uses cached ref features, not per-sample + assert "huggingface_hub" in cls.dependencies + assert "scipy" in cls.dependencies + + +# --------------------------------------------------------------------------- +# 2. Extractor factory +# --------------------------------------------------------------------------- + + +def test_available_extractors_lists_three_choices(): + assert extractors.available_extractors() == ["clip", "i3d", "videomae"] + + +def test_load_extractor_unknown_raises(): + with pytest.raises(ValueError, match="Unknown FVD extractor"): + extractors.load_extractor("bogus", torch.device("cpu")) + + +def test_fvd_constructor_rejects_unknown_extractor(): + with pytest.raises(ValueError, match="Unknown FVD extractor"): + get_metric("common.fvd", extractor="bogus") + + +def test_extractor_registry_maps_to_correct_classes(): + # Inspect the factory table directly — instantiating would download weights. + assert extractors._EXTRACTORS["i3d"] is extractors._I3DExtractor + assert extractors._EXTRACTORS["clip"] is extractors._CLIPExtractor + assert extractors._EXTRACTORS["videomae"] is extractors._VideoMAEExtractor + + +# --------------------------------------------------------------------------- +# 3. Skip path — no reference features available +# --------------------------------------------------------------------------- + + +class _DummyExtractor: + """Stand-in for a real extractor: returns deterministic random features. + + Used so CI doesn't pay the cost of downloading I3D / CLIP / VideoMAE + weights just to exercise the metric's bookkeeping. + """ + + feature_dim = 16 + + def __init__(self, device: torch.device, seed: int = 0) -> None: + self.device = device + self._rng = np.random.default_rng(seed) + + def to(self, device): + self.device = device + return self + + def forward(self, video: torch.Tensor) -> np.ndarray: + return self._rng.standard_normal((video.shape[0], self.feature_dim)).astype(np.float32) + + +def _install_dummy_loader(monkeypatch, *, seed: int | None = None) -> None: + """Route ``extractors.load_extractor`` to a no-download dummy.""" + + def factory(name: str, device: torch.device) -> _DummyExtractor: + s = seed if seed is not None else (hash(name) & 0xFFFF) + return _DummyExtractor(device, seed=s) + + monkeypatch.setattr(extractors, "load_extractor", factory) + + +def test_finalize_without_reference_returns_skipped(tmp_path, monkeypatch): + cache_path = tmp_path / "real_features_i3d.pt" + metric = get_metric("common.fvd", extractor="i3d", cache_path=str(cache_path)) + metric.to("cpu") + _install_dummy_loader(monkeypatch) + metric.setup() + metric.reset() + + metric.accumulate({"video": torch.zeros(16, 3, 32, 32)}) + result = metric.finalize() + + assert result.score is None + assert "skipped" in result.details + assert str(cache_path) in result.details["skipped"] + + +def test_finalize_with_reference_returns_finite_score(tmp_path, monkeypatch): + cache_path = tmp_path / "real_features_i3d.pt" + metric = get_metric("common.fvd", extractor="i3d", cache_path=str(cache_path)) + metric.to("cpu") + _install_dummy_loader(monkeypatch, seed=42) + metric.setup() + metric.reset() + + # First sample carries the reference set (4 ref videos); the rest are gen. + ref = torch.zeros(4, 16, 3, 32, 32) + for i in range(5): + sample = {"video": torch.zeros(16, 3, 32, 32)} + if i == 0: + sample["reference"] = ref + metric.accumulate(sample) + + with pytest.warns(UserWarning, match="At least 256 recommended"): + result = metric.finalize() + assert result.score is not None + assert np.isfinite(result.score) + assert result.details["extractor"] == "i3d" + assert result.details["n_generated"] == 5 + assert result.details["n_reference"] == 4 + assert cache_path.exists() + + +# --------------------------------------------------------------------------- +# 4. Math sanity +# --------------------------------------------------------------------------- + + +def test_frechet_distance_of_identical_gaussians_is_zero(): + rng = np.random.default_rng(0) + feats = rng.standard_normal((128, 16)) + mu, sigma = _gaussian_params(feats) + d = _frechet_distance(mu, sigma, mu, sigma) + assert d == pytest.approx(0.0, abs=1e-6) + + +def test_gaussian_params_handles_single_sample(): + feats = np.array([[1.0, 2.0, 3.0]]) # n=1 → variance is scalar + mu, sigma = _gaussian_params(feats) + assert mu.shape == (3,) + assert sigma.shape == (1, 1) # the n=1 reshape kicks in + + +def test_gaussian_params_atleast_2d_guard(): + """1-D feature vector (e.g. from a stale cache) shouldn't blow up cov.""" + feats = np.array([1.0, 2.0, 3.0]) # 1-D, atleast_2d → (1, 3) + mu, sigma = _gaussian_params(feats) + assert mu.shape == (3,) + + +def test_frechet_distance_translation_invariance(): + """``d(N(μ,Σ), N(μ+v,Σ)) == ||v||^2``.""" + rng = np.random.default_rng(0) + feats = rng.standard_normal((128, 8)) + mu, sigma = _gaussian_params(feats) + v = np.arange(8).astype(float) + d = _frechet_distance(mu, sigma, mu + v, sigma) + assert d == pytest.approx(float(np.sum(v**2)), rel=1e-4) + + +# --------------------------------------------------------------------------- +# 5. Cache partitioning by extractor +# --------------------------------------------------------------------------- + + +def test_default_cache_path_partitions_by_extractor(): + p_i3d = _default_cache_path("i3d") + p_clip = _default_cache_path("clip") + p_videomae = _default_cache_path("videomae") + assert p_i3d.endswith("real_features_i3d.pt") + assert p_clip.endswith("real_features_clip.pt") + assert p_videomae.endswith("real_features_videomae.pt") + assert len({p_i3d, p_clip, p_videomae}) == 3 + + +def test_env_var_overrides_default_cache_path(tmp_path, monkeypatch): + target = tmp_path / "my_cache.pt" + monkeypatch.setenv("FASTVIDEO_FVD_REF_FEATURES", str(target)) + metric = get_metric("common.fvd", extractor="clip") + metric.to("cpu") + _install_dummy_loader(monkeypatch) + metric.setup() + assert metric.cache_path == str(target) + + +def test_constructor_kwarg_outranks_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("FASTVIDEO_FVD_REF_FEATURES", str(tmp_path / "ignored.pt")) + explicit = tmp_path / "explicit.pt" + metric = get_metric("common.fvd", extractor="i3d", cache_path=str(explicit)) + metric.to("cpu") + _install_dummy_loader(monkeypatch) + metric.setup() + assert metric.cache_path == str(explicit) + + +def test_merge_from_rejects_extractor_mismatch(monkeypatch): + _install_dummy_loader(monkeypatch) + a = get_metric("common.fvd", extractor="i3d") + b = get_metric("common.fvd", extractor="clip") + with pytest.raises(AssertionError, match="extractor mismatch"): + a.merge_from(b) + + + From 2a55ad6a38eada9495251a345f956cd1b9f520a9 Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 02:07:10 +0000 Subject: [PATCH 07/10] fix(eval): bit-parity with benchmarks/fvd for I3D extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end parity check against the deleted benchmarks/fvd/ code path (reconstructed in a worktree) found that the new _I3DExtractor produced features that diverged from the upstream implementation by max|Δ|≈9e-3 and propagated to a relative FVD-score divergence of ~6e-3. Root cause: the old code scaled [0,1] → [-1,1] BEFORE bilinear resize; the new code resized first then scaled. The math is equivalent because bilinear interpolation is linear (interp(2x-1) == 2·interp(x)-1), but the floating-point rounding of the two orderings is not, and the small delta is amplified by dozens of I3D Conv3D layers into a ~1e-2 feature gap. Switching the new code to scale-then-resize (and moving the device transfer up-front so the entire preprocess pipeline runs on-device) recovers bit-parity: parity_test: I3D max|Δ| now 0.000e+00 within atol/rtol=1e-4 CLIP/VideoMAE features match within 1e-3 FVD score: OLD=241.628530 vs NEW=241.644102 rel=6.4e-5 (well under 1e-4 threshold) Also splits test_gaussian_params_handles_single_sample into two cases to reflect actual np.cov behaviour: - (1, 3) input → (3, 3) NaN-cov (Degrees of freedom <= 0) - 1-element input → 0-d scalar that the defensive reshape catches. Co-authored-by: abaghyangor --- fastvideo/eval/metrics/common/fvd/extractors.py | 10 ++++++++-- fastvideo/tests/eval/test_fvd.py | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/fastvideo/eval/metrics/common/fvd/extractors.py b/fastvideo/eval/metrics/common/fvd/extractors.py index f851b3d632..7f90edb2f0 100644 --- a/fastvideo/eval/metrics/common/fvd/extractors.py +++ b/fastvideo/eval/metrics/common/fvd/extractors.py @@ -82,12 +82,18 @@ def forward(self, video: torch.Tensor) -> np.ndarray: if T < _I3D_MIN_FRAMES: raise ValueError(f"I3D requires at least {_I3D_MIN_FRAMES} frames, got {T}. " "Increase num_frames or use a longer video.") + # Scale [0, 1] → [-1, 1] BEFORE resize so output is bit-identical to the + # original benchmarks/fvd/feature_extractors.I3DFeatureExtractor. Bilinear + # interpolation is linear so the math is equivalent either order, but the + # FP rounding of `interp(2x-1)` vs `2*interp(x)-1` is not — and that small + # delta propagates through dozens of I3D Conv3D layers into ~1e-2 feature + # differences. Scaling first matches OLD verbatim. + video = (video.to(self.device) * 2.0 - 1.0) if H != 224 or W != 224: video = video.reshape(B * T, C, H, W) video = F.interpolate(video, size=(224, 224), mode="bilinear", align_corners=False) video = video.reshape(B, T, C, 224, 224) - # [0, 1] → [-1, 1] then (B, T, C, H, W) → (B, C, T, H, W) - batch = (video * 2.0 - 1.0).permute(0, 2, 1, 3, 4).contiguous().to(self.device) + batch = video.permute(0, 2, 1, 3, 4).contiguous() with torch.jit.fuser("none"): feats = self._model(batch, rescale=False, resize=False, return_features=True) if feats.dim() == 1: diff --git a/fastvideo/tests/eval/test_fvd.py b/fastvideo/tests/eval/test_fvd.py index a1479222b6..320752243d 100644 --- a/fastvideo/tests/eval/test_fvd.py +++ b/fastvideo/tests/eval/test_fvd.py @@ -158,16 +158,24 @@ def test_frechet_distance_of_identical_gaussians_is_zero(): assert d == pytest.approx(0.0, abs=1e-6) -def test_gaussian_params_handles_single_sample(): - feats = np.array([[1.0, 2.0, 3.0]]) # n=1 → variance is scalar +def test_gaussian_params_handles_single_sample_multi_dim(): + feats = np.array([[1.0, 2.0, 3.0]]) mu, sigma = _gaussian_params(feats) assert mu.shape == (3,) - assert sigma.shape == (1, 1) # the n=1 reshape kicks in + assert sigma.shape == (3, 3) + + +def test_gaussian_params_scalar_variance_reshape(): + """1-D, single-element feature → np.cov returns 0-d scalar; reshape to (1,1).""" + feats = np.array([1.0]) + mu, sigma = _gaussian_params(feats) + assert mu.shape == (1,) + assert sigma.shape == (1, 1) def test_gaussian_params_atleast_2d_guard(): """1-D feature vector (e.g. from a stale cache) shouldn't blow up cov.""" - feats = np.array([1.0, 2.0, 3.0]) # 1-D, atleast_2d → (1, 3) + feats = np.array([1.0, 2.0, 3.0]) mu, sigma = _gaussian_params(feats) assert mu.shape == (3,) From bd6caf1646d46d9239add4741b3b92107ed96b35 Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 02:20:44 +0000 Subject: [PATCH 08/10] docs: drop stale FVD-out-of-scope notes after consolidation Two follow-up references missed in the earlier docs sweep: - docs/contributing/eval-metrics.md: removed the 'don't add set-vs-set metrics, the protocol doesn't exist yet' bullet. The protocol DOES exist as of #1320 (BaseMetric.is_set_metric + accumulate/finalize/ merge_from), and common.fvd is the second user of it after audio.frechet_distance. - .agents/workflows/evaluation-development.md: dropped the 'Native conversion of FVD under fastvideo/eval/metrics/fvd/' TODO bullet now that the conversion is done (and was placed at common/fvd/, not the path the bullet predicted). Co-authored-by: abaghyangor --- .agents/workflows/evaluation-development.md | 1 - docs/contributing/eval-metrics.md | 4 ---- 2 files changed, 5 deletions(-) diff --git a/.agents/workflows/evaluation-development.md b/.agents/workflows/evaluation-development.md index b247f68751..6306d75fd0 100644 --- a/.agents/workflows/evaluation-development.md +++ b/.agents/workflows/evaluation-development.md @@ -89,5 +89,4 @@ The following land in follow-up PRs: - **MIND** metrics (depends on a separate `vipe` submodule). - **VBench-2.0** sibling package. -- Native conversion of **FVD** under `fastvideo/eval/metrics/fvd/`. - The training-time `EvalCallback`. diff --git a/docs/contributing/eval-metrics.md b/docs/contributing/eval-metrics.md index fb326337bf..4df9940fdf 100644 --- a/docs/contributing/eval-metrics.md +++ b/docs/contributing/eval-metrics.md @@ -554,10 +554,6 @@ scores ± tolerance, and add a calibration test under ## 10) When not to add a metric -- **Set-vs-set distribution metrics** (FVD, FID-style) do not fit - `BaseMetric.compute(sample)` cleanly; they need a population. - Adding them requires a stateful accumulator interface that does - not exist yet. Open an issue first. - **Metrics requiring a single-GPU model larger than available memory.** Eval is not the place for tensor-parallel sharding; metrics are expected to fit on one GPU. From b1f2828f37a68d4fd49f4b24f8454cc4818e2a21 Mon Sep 17 00:00:00 2001 From: shaoxiongduan Date: Fri, 22 May 2026 03:14:05 +0000 Subject: [PATCH 09/10] test(eval): drop test_fvd.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the local CPU-only test scaffold for common.fvd. End-to-end correctness is locked in by the bit-parity verification against the old benchmarks/fvd/ implementation (run as part of the PR; results documented in the PR description) — running the math on a 16-dim mock backbone adds little signal on top of that. A follow-up testing strategy that includes the metric in the existing eval-suite CI (e.g. registry contract + a small real-model smoke under the GPU job) can be opened as a separate PR. Co-authored-by: abaghyangor --- fastvideo/tests/eval/test_fvd.py | 236 ------------------------------- 1 file changed, 236 deletions(-) delete mode 100644 fastvideo/tests/eval/test_fvd.py diff --git a/fastvideo/tests/eval/test_fvd.py b/fastvideo/tests/eval/test_fvd.py deleted file mode 100644 index 320752243d..0000000000 --- a/fastvideo/tests/eval/test_fvd.py +++ /dev/null @@ -1,236 +0,0 @@ -"""CPU-only regression tests for ``common.fvd``. - -No GPU, no model download — everything that needs a backbone is -monkeypatched to a deterministic dummy that returns random features. -GPU-side correctness is exercised in the example script -``examples/inference/eval/eval_fvd.py``. -""" -from __future__ import annotations - -import numpy as np -import pytest -import torch - -from fastvideo.eval import get_metric, list_metrics -from fastvideo.eval.metrics.common.fvd import extractors -from fastvideo.eval.metrics.common.fvd.metric import ( - FVDMetric, - _default_cache_path, - _frechet_distance, - _gaussian_params, -) - - -# --------------------------------------------------------------------------- -# 1. Registry contract -# --------------------------------------------------------------------------- - - -def test_common_fvd_is_registered(): - assert "common.fvd" in list_metrics() - - -def test_fvd_class_attributes(): - cls = FVDMetric - assert cls.name == "common.fvd" - assert cls.is_set_metric is True - assert cls.higher_is_better is False - assert cls.needs_gpu is True - assert cls.requires_reference is False # uses cached ref features, not per-sample - assert "huggingface_hub" in cls.dependencies - assert "scipy" in cls.dependencies - - -# --------------------------------------------------------------------------- -# 2. Extractor factory -# --------------------------------------------------------------------------- - - -def test_available_extractors_lists_three_choices(): - assert extractors.available_extractors() == ["clip", "i3d", "videomae"] - - -def test_load_extractor_unknown_raises(): - with pytest.raises(ValueError, match="Unknown FVD extractor"): - extractors.load_extractor("bogus", torch.device("cpu")) - - -def test_fvd_constructor_rejects_unknown_extractor(): - with pytest.raises(ValueError, match="Unknown FVD extractor"): - get_metric("common.fvd", extractor="bogus") - - -def test_extractor_registry_maps_to_correct_classes(): - # Inspect the factory table directly — instantiating would download weights. - assert extractors._EXTRACTORS["i3d"] is extractors._I3DExtractor - assert extractors._EXTRACTORS["clip"] is extractors._CLIPExtractor - assert extractors._EXTRACTORS["videomae"] is extractors._VideoMAEExtractor - - -# --------------------------------------------------------------------------- -# 3. Skip path — no reference features available -# --------------------------------------------------------------------------- - - -class _DummyExtractor: - """Stand-in for a real extractor: returns deterministic random features. - - Used so CI doesn't pay the cost of downloading I3D / CLIP / VideoMAE - weights just to exercise the metric's bookkeeping. - """ - - feature_dim = 16 - - def __init__(self, device: torch.device, seed: int = 0) -> None: - self.device = device - self._rng = np.random.default_rng(seed) - - def to(self, device): - self.device = device - return self - - def forward(self, video: torch.Tensor) -> np.ndarray: - return self._rng.standard_normal((video.shape[0], self.feature_dim)).astype(np.float32) - - -def _install_dummy_loader(monkeypatch, *, seed: int | None = None) -> None: - """Route ``extractors.load_extractor`` to a no-download dummy.""" - - def factory(name: str, device: torch.device) -> _DummyExtractor: - s = seed if seed is not None else (hash(name) & 0xFFFF) - return _DummyExtractor(device, seed=s) - - monkeypatch.setattr(extractors, "load_extractor", factory) - - -def test_finalize_without_reference_returns_skipped(tmp_path, monkeypatch): - cache_path = tmp_path / "real_features_i3d.pt" - metric = get_metric("common.fvd", extractor="i3d", cache_path=str(cache_path)) - metric.to("cpu") - _install_dummy_loader(monkeypatch) - metric.setup() - metric.reset() - - metric.accumulate({"video": torch.zeros(16, 3, 32, 32)}) - result = metric.finalize() - - assert result.score is None - assert "skipped" in result.details - assert str(cache_path) in result.details["skipped"] - - -def test_finalize_with_reference_returns_finite_score(tmp_path, monkeypatch): - cache_path = tmp_path / "real_features_i3d.pt" - metric = get_metric("common.fvd", extractor="i3d", cache_path=str(cache_path)) - metric.to("cpu") - _install_dummy_loader(monkeypatch, seed=42) - metric.setup() - metric.reset() - - # First sample carries the reference set (4 ref videos); the rest are gen. - ref = torch.zeros(4, 16, 3, 32, 32) - for i in range(5): - sample = {"video": torch.zeros(16, 3, 32, 32)} - if i == 0: - sample["reference"] = ref - metric.accumulate(sample) - - with pytest.warns(UserWarning, match="At least 256 recommended"): - result = metric.finalize() - assert result.score is not None - assert np.isfinite(result.score) - assert result.details["extractor"] == "i3d" - assert result.details["n_generated"] == 5 - assert result.details["n_reference"] == 4 - assert cache_path.exists() - - -# --------------------------------------------------------------------------- -# 4. Math sanity -# --------------------------------------------------------------------------- - - -def test_frechet_distance_of_identical_gaussians_is_zero(): - rng = np.random.default_rng(0) - feats = rng.standard_normal((128, 16)) - mu, sigma = _gaussian_params(feats) - d = _frechet_distance(mu, sigma, mu, sigma) - assert d == pytest.approx(0.0, abs=1e-6) - - -def test_gaussian_params_handles_single_sample_multi_dim(): - feats = np.array([[1.0, 2.0, 3.0]]) - mu, sigma = _gaussian_params(feats) - assert mu.shape == (3,) - assert sigma.shape == (3, 3) - - -def test_gaussian_params_scalar_variance_reshape(): - """1-D, single-element feature → np.cov returns 0-d scalar; reshape to (1,1).""" - feats = np.array([1.0]) - mu, sigma = _gaussian_params(feats) - assert mu.shape == (1,) - assert sigma.shape == (1, 1) - - -def test_gaussian_params_atleast_2d_guard(): - """1-D feature vector (e.g. from a stale cache) shouldn't blow up cov.""" - feats = np.array([1.0, 2.0, 3.0]) - mu, sigma = _gaussian_params(feats) - assert mu.shape == (3,) - - -def test_frechet_distance_translation_invariance(): - """``d(N(μ,Σ), N(μ+v,Σ)) == ||v||^2``.""" - rng = np.random.default_rng(0) - feats = rng.standard_normal((128, 8)) - mu, sigma = _gaussian_params(feats) - v = np.arange(8).astype(float) - d = _frechet_distance(mu, sigma, mu + v, sigma) - assert d == pytest.approx(float(np.sum(v**2)), rel=1e-4) - - -# --------------------------------------------------------------------------- -# 5. Cache partitioning by extractor -# --------------------------------------------------------------------------- - - -def test_default_cache_path_partitions_by_extractor(): - p_i3d = _default_cache_path("i3d") - p_clip = _default_cache_path("clip") - p_videomae = _default_cache_path("videomae") - assert p_i3d.endswith("real_features_i3d.pt") - assert p_clip.endswith("real_features_clip.pt") - assert p_videomae.endswith("real_features_videomae.pt") - assert len({p_i3d, p_clip, p_videomae}) == 3 - - -def test_env_var_overrides_default_cache_path(tmp_path, monkeypatch): - target = tmp_path / "my_cache.pt" - monkeypatch.setenv("FASTVIDEO_FVD_REF_FEATURES", str(target)) - metric = get_metric("common.fvd", extractor="clip") - metric.to("cpu") - _install_dummy_loader(monkeypatch) - metric.setup() - assert metric.cache_path == str(target) - - -def test_constructor_kwarg_outranks_env_var(tmp_path, monkeypatch): - monkeypatch.setenv("FASTVIDEO_FVD_REF_FEATURES", str(tmp_path / "ignored.pt")) - explicit = tmp_path / "explicit.pt" - metric = get_metric("common.fvd", extractor="i3d", cache_path=str(explicit)) - metric.to("cpu") - _install_dummy_loader(monkeypatch) - metric.setup() - assert metric.cache_path == str(explicit) - - -def test_merge_from_rejects_extractor_mismatch(monkeypatch): - _install_dummy_loader(monkeypatch) - a = get_metric("common.fvd", extractor="i3d") - b = get_metric("common.fvd", extractor="clip") - with pytest.raises(AssertionError, match="extractor mismatch"): - a.merge_from(b) - - - From 4f5b93b93b1bfa94b829d296af3334a454963eff Mon Sep 17 00:00:00 2001 From: shaoxiongduan <46639847+shaoxiongduan@users.noreply.github.com> Date: Sun, 24 May 2026 00:04:43 +0000 Subject: [PATCH 10/10] fix(eval): address #1380 review (cache filename, registry, deps, warn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: correct the documented cache filename to ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features_{extractor}.pt`` — the file is per-extractor partitioned since CLIP and VideoMAE landed; the old ``real_features.pt`` form is never used. - test_registry: include ``common.fvd`` in ``_CORE_METRICS`` so a decorator/import-time breakage on this top-level metric surfaces in CI rather than at runtime, alongside the other structurally-similar top-level common metrics already in the tuple. - metric.dependencies: declare ``transformers`` (used by the CLIP and VideoMAE extractors). Already in base deps so the registry pre-import check passes today; futureproofs if transformers ever moves to an extra. - accumulate: ``warnings.warn`` when ``sample["reference"]`` is silently ignored because reference features are already loaded (from cache or an earlier accumulate call). Surfaces the foot-gun rather than producing a plausible-but-wrong score. --- fastvideo/eval/README.md | 2 +- fastvideo/eval/metrics/common/fvd/metric.py | 14 +++++++++++++- fastvideo/tests/eval/test_registry.py | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/fastvideo/eval/README.md b/fastvideo/eval/README.md index dd168ed84a..c19d2f6054 100644 --- a/fastvideo/eval/README.md +++ b/fastvideo/eval/README.md @@ -273,7 +273,7 @@ ev.evaluate(samples=[ # corpus result is in the returned EvalResults.corpus["common.fvd"] ``` -Reference features are cached to ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features.pt`` +Reference features are cached to ``${FASTVIDEO_EVAL_CACHE}/fvd/real_features_{extractor}.pt`` the first time ``sample["reference"]`` is passed; subsequent runs load the cache automatically. Override with ``$FASTVIDEO_FVD_REF_FEATURES`` or the ``cache_path=`` constructor kwarg. diff --git a/fastvideo/eval/metrics/common/fvd/metric.py b/fastvideo/eval/metrics/common/fvd/metric.py index 7b3f151f8e..3dc9a8c93c 100644 --- a/fastvideo/eval/metrics/common/fvd/metric.py +++ b/fastvideo/eval/metrics/common/fvd/metric.py @@ -157,7 +157,7 @@ class FVDMetric(BaseMetric): requires_reference = False # uses cached real features, not per-sample ref higher_is_better = False # lower FVD = better needs_gpu = True - dependencies = ["huggingface_hub", "scipy"] + dependencies = ["huggingface_hub", "scipy", "transformers"] def __init__( self, @@ -234,6 +234,18 @@ def accumulate(self, sample: dict) -> None: ref = ref.unsqueeze(0) self._real_features = _extract_chunked(self._extractor, ref, self._chunk) self._save_cache(self._real_features) + elif sample.get("reference") is not None: + # Reference features already exist (from cache or an earlier + # accumulate call). Silently dropping ``sample["reference"]`` + # would be a foot-gun for callers expecting per-sample-reference + # semantics — warn so the mismatch surfaces. + warnings.warn( + "common.fvd: sample['reference'] ignored because reference features " + "are already loaded (from cache or a prior accumulate call). Pass the " + "entire reference set on a single accumulate() call, or pre-build the " + f"cache at {self.cache_path}.", + stacklevel=2, + ) def finalize(self) -> MetricResult: """Compute FVD from all accumulated generated features vs. real features.""" diff --git a/fastvideo/tests/eval/test_registry.py b/fastvideo/tests/eval/test_registry.py index 7029b50a90..bc2e278f48 100644 --- a/fastvideo/tests/eval/test_registry.py +++ b/fastvideo/tests/eval/test_registry.py @@ -18,6 +18,7 @@ "common.psnr", "common.ssim", "common.lpips", + "common.fvd", "optical_flow.gt_optical_flow", "optical_flow.synthetic_optical_flow", "physics_iq",