multisense_df / src /data /datasets.py
vijay-2132's picture
Clean deployment without large checkpoints
a94bd47
Raw
History Blame Contribute Delete
20.1 kB
"""
MultiSense-DF β€” PyTorch Dataset Implementations
================================================
Provides:
- MultiModalDataset : generic real/fake folder structure (real/ + fake/ subfolders)
- DFDCDataset : DFDC Preview dataset with metadata.json labels
Both datasets return a dict:
{
'frames': (T, 3, 224, 224) float32,
'waveform': (sr*duration,) float32,
'mouth_crops': (T, 3, 96, 96) float32,
'mel_specs': (T, 1, 80, W) float32,
'label': int (0=real, 1=fake),
'video_path': str,
}
"""
from __future__ import annotations
import json
import os
import random
import shutil
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
import torch
from torch.utils.data import Dataset
from .preprocessing import (
extract_frames,
extract_audio_waveform,
extract_mel_spectrogram,
extract_mouth_crops,
VISUAL_TRAIN_TRANSFORM,
VISUAL_VAL_TRANSFORM,
)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
_VIDEO_EXTS: frozenset = frozenset({".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv"})
def _scan_videos(directory: Path) -> List[Path]:
"""Recursively collect all video files under *directory*."""
return sorted(
p for p in directory.rglob("*") if p.suffix.lower() in _VIDEO_EXTS
)
# ---------------------------------------------------------------------------
# MultiModalDataset
# ---------------------------------------------------------------------------
class MultiModalDataset(Dataset):
"""
Generic dataset for any folder that contains 'real' and 'fake' subfolders.
Expected layout::
root_dir/
real/
video001.mp4
video002.mp4
...
fake/
video003.mp4
video004.mp4
...
Parameters
----------
root_dir : str | Path
Root directory containing 'real' and 'fake' sub-folders.
split : str
One of {'train', 'val', 'test'}. Controls augmentation and which
cached split is used when *cache_dir* is set.
num_frames : int
Number of frames to uniformly sample per video.
sr : int
Target audio sample rate in Hz.
duration : float
Clip duration in seconds (audio is truncated/padded to sr*duration).
transform : callable, optional
Override the default visual frame transform. If *None*, the standard
ImageNet-normalised train/val transform is used based on *split*.
cache_dir : str | Path, optional
If provided, preprocessed tensors are saved to ``<cache_dir>/<split>/``
as ``.pt`` files and re-used on subsequent runs to avoid re-processing.
"""
LABELS: Dict[str, int] = {"real": 0, "fake": 1}
def __init__(
self,
root_dir: str | Path,
split: str = "train",
num_frames: int = 125,
sr: int = 16_000,
duration: float = 5.0,
transform: Optional[Callable] = None,
cache_dir: Optional[str | Path] = None,
) -> None:
self.root_dir = Path(root_dir)
self.split = split
self.num_frames = num_frames
self.sr = sr
self.duration = duration
self.transform = transform or (
VISUAL_TRAIN_TRANSFORM if split == "train" else VISUAL_VAL_TRANSFORM
)
self.cache_dir = Path(cache_dir) / split if cache_dir else None
if self.cache_dir:
self.cache_dir.mkdir(parents=True, exist_ok=True)
# Gather samples as (path, label)
self.samples: List[Tuple[Path, int]] = []
for cls_name, label in self.LABELS.items():
cls_dir = self.root_dir / cls_name
if not cls_dir.is_dir():
continue
for vid in _scan_videos(cls_dir):
self.samples.append((vid, label))
if not self.samples:
raise FileNotFoundError(
f"No video files found under {self.root_dir}. "
"Expected sub-folders named 'real' and 'fake'."
)
# Shuffle deterministically (same seed for same split)
rng = random.Random({"train": 42, "val": 43, "test": 44}.get(split, 42))
rng.shuffle(self.samples)
# ------------------------------------------------------------------
# Dataset interface
# ------------------------------------------------------------------
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> Dict[str, object]:
video_path, label = self.samples[idx]
# ── Caching ──────────────────────────────────────────────────
if self.cache_dir is not None:
cache_file = self.cache_dir / f"{video_path.stem}_{idx}.pt"
if cache_file.exists():
try:
sample = torch.load(cache_file, map_location="cpu", weights_only=True)
sample["video_path"] = str(video_path)
sample["label"] = label
return sample
except Exception:
# Corrupt cache β€” re-process and overwrite
pass
# ── Preprocessing ─────────────────────────────────────────────
sample = self._preprocess(video_path, label)
if self.cache_dir is not None:
# Save without label/path keys so the cache is label-agnostic
payload = {k: v for k, v in sample.items()
if k not in ("label", "video_path")}
torch.save(payload, cache_file)
return sample
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _preprocess(self, video_path: Path, label: int) -> Dict[str, object]:
"""Run the full preprocessing pipeline for a single video."""
frames = extract_frames(str(video_path), self.num_frames, self.transform)
waveform = extract_audio_waveform(str(video_path), self.sr, self.duration)
mel_specs = extract_mel_spectrogram(
waveform, self.sr, num_frames=self.num_frames
)
mouth_crops = extract_mouth_crops(str(video_path), self.num_frames)
return {
"frames": frames, # (T, 3, 224, 224)
"waveform": waveform, # (sr*duration,)
"mouth_crops": mouth_crops, # (T, 3, 96, 96)
"mel_specs": mel_specs, # (T, 1, 80, W)
"label": label,
"video_path": str(video_path),
}
# ------------------------------------------------------------------
# Class-level utilities
# ------------------------------------------------------------------
@classmethod
def create_splits(
cls,
src_dir: str | Path,
dst_dir: str | Path,
train_ratio: float = 0.70,
val_ratio: float = 0.15,
seed: int = 42,
copy: bool = False,
) -> Dict[str, "MultiModalDataset"]:
"""
Split a flat folder of real/fake videos into train/val/test sub-splits.
The source directory must contain 'real' and 'fake' sub-folders.
Creates::
dst_dir/
train/real/, train/fake/
val/real/, val/fake/
test/real/, test/fake/
Parameters
----------
src_dir : path-like
Source directory with 'real' and 'fake' folders.
dst_dir : path-like
Destination for the split folders.
train_ratio : float
Fraction of data to use for training.
val_ratio : float
Fraction for validation (remainder β†’ test).
seed : int
Random seed.
copy : bool
If *True*, files are **copied** (not symlinked / moved).
Use on Windows where symlinks may require elevated permissions.
Returns
-------
dict
``{'train': MultiModalDataset, 'val': ..., 'test': ...}``
"""
src_dir = Path(src_dir)
dst_dir = Path(dst_dir)
rng = random.Random(seed)
for cls_name in ("real", "fake"):
videos = _scan_videos(src_dir / cls_name)
rng.shuffle(videos)
n_total = len(videos)
n_train = int(n_total * train_ratio)
n_val = int(n_total * val_ratio)
splits = {
"train": videos[:n_train],
"val": videos[n_train : n_train + n_val],
"test": videos[n_train + n_val :],
}
for split_name, vids in splits.items():
split_cls_dir = dst_dir / split_name / cls_name
split_cls_dir.mkdir(parents=True, exist_ok=True)
for v in vids:
dst = split_cls_dir / v.name
if not dst.exists():
if copy:
shutil.copy2(v, dst)
else:
# Use relative symlink when possible; fall back to copy
try:
os.symlink(v.resolve(), dst)
except (OSError, NotImplementedError):
shutil.copy2(v, dst)
return {
split: cls(dst_dir / split, split=split)
for split in ("train", "val", "test")
}
def class_counts(self) -> Dict[str, int]:
"""Return {class_name: count} for logging / weighted sampling."""
real = sum(1 for _, lbl in self.samples if lbl == 0)
fake = sum(1 for _, lbl in self.samples if lbl == 1)
return {"real": real, "fake": fake}
def __repr__(self) -> str:
counts = self.class_counts()
return (
f"MultiModalDataset(split={self.split!r}, "
f"total={len(self)}, real={counts['real']}, fake={counts['fake']})"
)
# ---------------------------------------------------------------------------
# DFDCDataset
# ---------------------------------------------------------------------------
class DFDCDataset(Dataset):
"""
DFDC Preview dataset loader.
Expects the DFDC folder structure produced by Kaggle download::
dfdc_dir/
metadata.json ← {filename: {"label": "FAKE"|"REAL", ...}, ...}
aaa.mp4
bbb.mp4
...
The DFDC Preview dataset may also be organised in part-folders (00–49).
This class handles both flat and nested layouts by searching recursively
for ``metadata.json`` files and their sibling videos.
Parameters
----------
dfdc_dir : str | Path
Root directory of (part of) the DFDC Preview dataset.
split : str
One of ``{'train', 'val', 'test'}``. A deterministic 70/15/15
split is computed from the video list when no external split
configuration is supplied.
split_indices : list[int], optional
If provided, only these integer indices into the full sample list are
used. Useful for passing pre-defined train/val/test index lists.
num_frames : int
Number of frames to uniformly sample per video.
sr : int
Target audio sample rate in Hz.
duration : float
Clip duration in seconds.
transform : callable, optional
Override the default visual frame transform.
cache_dir : str | Path, optional
Directory to cache preprocessed ``.pt`` tensors.
"""
def __init__(
self,
dfdc_dir: str | Path,
split: str = "train",
split_indices: Optional[List[int]] = None,
num_frames: int = 125,
sr: int = 16_000,
duration: float = 5.0,
transform: Optional[Callable] = None,
cache_dir: Optional[str | Path] = None,
) -> None:
self.dfdc_dir = Path(dfdc_dir)
self.split = split
self.num_frames = num_frames
self.sr = sr
self.duration = duration
self.transform = transform or (
VISUAL_TRAIN_TRANSFORM if split == "train" else VISUAL_VAL_TRANSFORM
)
self.cache_dir = Path(cache_dir) / split if cache_dir else None
if self.cache_dir:
self.cache_dir.mkdir(parents=True, exist_ok=True)
# ── Parse metadata.json(s) ────────────────────────────────────
all_samples: List[Tuple[Path, int]] = []
meta_files = list(self.dfdc_dir.rglob("metadata.json"))
if not meta_files:
raise FileNotFoundError(
f"No metadata.json found under {self.dfdc_dir}."
)
for meta_file in sorted(meta_files):
part_dir = meta_file.parent
with open(meta_file, "r", encoding="utf-8") as fh:
meta: Dict[str, dict] = json.load(fh)
for filename, info in meta.items():
vid_path = part_dir / filename
if not vid_path.exists():
continue
raw_label = info.get("label", "REAL")
# DFDC uses "FAKE"/"REAL" strings
label = 1 if str(raw_label).upper() == "FAKE" else 0
all_samples.append((vid_path, label))
if not all_samples:
raise RuntimeError(
f"metadata.json found but no matching video files located under "
f"{self.dfdc_dir}. Check your dataset download."
)
# ── Split ──────────────────────────────────────────────────────
if split_indices is not None:
self.samples = [all_samples[i] for i in split_indices]
else:
self.samples = self._auto_split(all_samples, split)
# ------------------------------------------------------------------
# Dataset interface
# ------------------------------------------------------------------
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> Dict[str, object]:
video_path, label = self.samples[idx]
# ── Caching ──────────────────────────────────────────────────
if self.cache_dir is not None:
safe_stem = video_path.stem.replace("/", "_")
cache_file = self.cache_dir / f"{safe_stem}_{idx}.pt"
if cache_file.exists():
try:
sample = torch.load(cache_file, map_location="cpu", weights_only=True)
sample["video_path"] = str(video_path)
sample["label"] = label
return sample
except Exception:
pass # corrupt cache; re-process
# ── Preprocessing ─────────────────────────────────────────────
sample = self._preprocess(video_path, label)
if self.cache_dir is not None:
payload = {k: v for k, v in sample.items()
if k not in ("label", "video_path")}
torch.save(payload, cache_file)
return sample
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _preprocess(self, video_path: Path, label: int) -> Dict[str, object]:
"""Full preprocessing pipeline for one DFDC video."""
frames = extract_frames(str(video_path), self.num_frames, self.transform)
waveform = extract_audio_waveform(str(video_path), self.sr, self.duration)
mel_specs = extract_mel_spectrogram(
waveform, self.sr, num_frames=self.num_frames
)
mouth_crops = extract_mouth_crops(str(video_path), self.num_frames)
return {
"frames": frames,
"waveform": waveform,
"mouth_crops": mouth_crops,
"mel_specs": mel_specs,
"label": label,
"video_path": str(video_path),
}
@staticmethod
def _auto_split(
samples: List[Tuple[Path, int]],
split: str,
train_ratio: float = 0.70,
val_ratio: float = 0.15,
seed: int = 42,
) -> List[Tuple[Path, int]]:
"""Deterministically split *samples* into train/val/test."""
rng = random.Random(seed)
samples = list(samples)
rng.shuffle(samples)
n = len(samples)
n_train = int(n * train_ratio)
n_val = int(n * val_ratio)
if split == "train":
return samples[:n_train]
elif split == "val":
return samples[n_train : n_train + n_val]
elif split == "test":
return samples[n_train + n_val :]
else:
raise ValueError(f"Unknown split: {split!r}. Choose 'train', 'val', or 'test'.")
def class_counts(self) -> Dict[str, int]:
"""Return {class_name: count}."""
real = sum(1 for _, lbl in self.samples if lbl == 0)
fake = sum(1 for _, lbl in self.samples if lbl == 1)
return {"real": real, "fake": fake}
def get_weighted_sampler(self) -> "torch.utils.data.WeightedRandomSampler":
"""
Build a WeightedRandomSampler to address class imbalance.
Usage::
sampler = dataset.get_weighted_sampler()
loader = DataLoader(dataset, batch_size=8, sampler=sampler)
"""
from torch.utils.data import WeightedRandomSampler
counts = self.class_counts()
n_real, n_fake = counts["real"], counts["fake"]
n_total = n_real + n_fake
w_real = n_total / (2.0 * n_real) if n_real > 0 else 0.0
w_fake = n_total / (2.0 * n_fake) if n_fake > 0 else 0.0
weights = [
w_real if lbl == 0 else w_fake
for _, lbl in self.samples
]
return WeightedRandomSampler(
weights=weights,
num_samples=len(weights),
replacement=True,
)
def __repr__(self) -> str:
counts = self.class_counts()
return (
f"DFDCDataset(split={self.split!r}, "
f"total={len(self)}, real={counts['real']}, fake={counts['fake']}, "
f"root={self.dfdc_dir})"
)
# ---------------------------------------------------------------------------
# Quick self-test
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python datasets.py <path_to_dataset_root> [dfdc|multimodal]")
print(" dfdc β†’ expects metadata.json in the root")
print(" multimodal β†’ expects real/ and fake/ sub-folders")
sys.exit(0)
root = sys.argv[1]
mode = sys.argv[2] if len(sys.argv) > 2 else "multimodal"
if mode == "dfdc":
ds = DFDCDataset(root, split="train", num_frames=8)
else:
ds = MultiModalDataset(root, split="train", num_frames=8)
print(ds)
sample = ds[0]
print(f" frames : {sample['frames'].shape}")
print(f" waveform : {sample['waveform'].shape}")
print(f" mouth_crops : {sample['mouth_crops'].shape}")
print(f" mel_specs : {sample['mel_specs'].shape}")
print(f" label : {sample['label']}")
print(f" video_path : {sample['video_path']}")