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/.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/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 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. diff --git a/examples/inference/eval/eval_fvd.py b/examples/inference/eval/eval_fvd.py new file mode 100644 index 0000000000..7b501523ce --- /dev/null +++ b/examples/inference/eval/eval_fvd.py @@ -0,0 +1,121 @@ +"""Compute Fréchet Video Distance (FVD) over a folder of generated videos. + +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() diff --git a/fastvideo/eval/README.md b/fastvideo/eval/README.md index 2afc15bd42..c19d2f6054 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_{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. + ## 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/extractors.py b/fastvideo/eval/metrics/common/fvd/extractors.py new file mode 100644 index 0000000000..7f90edb2f0 --- /dev/null +++ b/fastvideo/eval/metrics/common/fvd/extractors.py @@ -0,0 +1,191 @@ +"""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. + +""" + +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.") + # 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) + 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: + 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 new file mode 100644 index 0000000000..3dc9a8c93c --- /dev/null +++ b/fastvideo/eval/metrics/common/fvd/metric.py @@ -0,0 +1,318 @@ +"""common.fvd — Fréchet Video Distance. + +Measures distributional similarity between generated and reference videos +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. + +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"]``, +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_{extractor}.pt`` (default). + +The env-var override mirrors ``audio.frechet_distance``'s +``FASTVIDEO_FAD_REF_FEATURES`` pattern. +""" + +from __future__ import annotations + +import os +import warnings + +import numpy as np +import scipy.linalg +import torch + +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 + +_MIN_VIDEOS_WARN = 256 # below this FVD is unreliable +_REF_FEATURES_ENV = "FASTVIDEO_FVD_REF_FEATURES" + + +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 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) + 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)) + + +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) 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 + all videos have been processed. + + For meaningful scores, evaluate over ≥ 256 videos (2048 is the + standard protocol used in the literature). + + Parameters + ---------- + extractor : str + Feature backbone name. One of ``"i3d"`` (default), ``"clip"``, + ``"videomae"``. See module docstring for guidance. + cache_path : str, optional + 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 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", "transformers"] + + def __init__( + self, + extractor: str = "i3d", + cache_path: str | None = None, + chunk_size: int = 32, + ) -> None: + super().__init__() + 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._extractor: _BaseExtractor | 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._extractor is not None: + self._extractor.to(self.device) + return self + + def setup(self) -> None: + if self._extractor is not None: + return + # 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 = _default_cache_path(self._extractor_name) + self._extractor = load_extractor(self._extractor_name, self.device) + 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 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._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) + + self._gen_features.append(_extract_chunked(self._extractor, video, self._chunk)) + + # 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_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.""" + 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={ + "extractor": self._extractor_name, + "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) + 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: + 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) + return np.atleast_2d(data.numpy()) # guard against stale 1-D cache + 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) 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",