Files

411 lines
17 KiB
Python

"""Memory-mapped reader that turns native-rate packs into model-ready batches.
Shards hold decoded source media: faces and labels at the native frame rate,
unnormalized 16 kHz audio. Everything MuVAP+ specific happens here - sampling
onto the 25 Hz model timeline, loudness normalization, chunking, augmentation -
so the same shards can serve a model with different conventions.
"""
import json
import math
import random
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
import numpy as np
import torch
import torch.nn.functional as F
from torch.utils.data import Dataset, Sampler
from data.alignment import model_frame_count, source_indices
from data.augment import AugmentConfig, augment_visual, mix_audio
from data.media import WaveformStats, apply_normalization
from preprocess.asd.schema import FORMAT_NAME, MODEL_FPS, SAMPLE_RATE, SUPPORTED_VERSIONS
def _clip_id(sample_id: str) -> str:
"""Strip the entity suffix so same-recording samples share a key.
AVA and WASD both name tracks `<clip>_<start>_<end>:<entity>`, where every
entity of a clip may share one audio recording.
"""
return sample_id.rsplit(":", 1)[0]
@dataclass(frozen=True)
class _Entry:
root: Path
shard: str
metadata: dict
class PackedASDDataset(Dataset):
"""Random-access dataset backed by OS-page-cached NumPy memmaps."""
def __init__(
self,
roots: str | Path | Sequence[str | Path],
projection_window=None,
window: int | None = None,
context: int = 0,
jitter: bool = False,
augment: AugmentConfig | None = None,
model_fps: float = MODEL_FPS,
normalize: bool = True,
):
"""Read packed shards, optionally as fixed-length training chunks.
`window` splits every track longer than it into near-equal chunks, so
one epoch still covers every frame while batches stay uniform enough to
pack tightly.
`context` frames are read *before* each chunk and marked invalid in the
returned mask. The model is causal, so a frame near the start of a chunk
would otherwise be asked to predict from history the encoder was never
shown. Feeding that history as unscored context costs a little compute
and no storage, where overlapping the chunks would duplicate the data
and still train on the truncated copies. A track's real first frames get
no prefix: there the missing history is genuine, and the model has to
cope with it at inference too.
`jitter` slides the chunk grid within its track on every access, which
varies the boundaries across epochs at no extra cost. All spans are
counted in model frames, not source frames.
"""
if isinstance(roots, (str, Path)):
roots = [roots]
self.projection_window = projection_window
self.window = window
self.context = max(0, context)
self.jitter = jitter
self.augment = augment
self.model_fps = model_fps
self.normalize = normalize
self.samples_per_frame = round(SAMPLE_RATE / model_fps)
self.entries: list[_Entry] = []
self._arrays: dict[
tuple[Path, str], tuple[np.ndarray, np.ndarray, np.ndarray]
] = {}
for raw_root in roots:
root = Path(raw_root)
with (root / "dataset.json").open() as handle:
manifest = json.load(handle)
if (
manifest.get("format") != FORMAT_NAME
or manifest.get("version") not in SUPPORTED_VERSIONS
):
raise ValueError(f"unsupported packed dataset format: {root}")
if manifest.get("timeline") != "native":
raise ValueError(f"pack is not on the native timeline: {root}")
if manifest.get("audio_sample_rate") != SAMPLE_RATE:
raise ValueError(f"unexpected audio sample rate: {root}")
if manifest.get("audio_normalization") is not None:
raise ValueError(f"pack has baked-in audio normalization: {root}")
for shard_dir in sorted(root.glob("shard-*")):
with (shard_dir / "index.json").open() as handle:
for metadata in json.load(handle):
self.entries.append(_Entry(root, shard_dir.name, metadata))
self.model_frames = np.array(
[
model_frame_count(
entry.metadata["source_frames"],
entry.metadata["source_fps"],
model_fps,
)
for entry in self.entries
],
dtype=np.int64,
)
self.chunks = self._build_chunks()
self._clips = [_clip_id(entry.metadata["sample_id"]) for entry in self.entries]
@property
def predict_window(self) -> int | None:
"""Frames scored per chunk; the context prefix is read on top of this."""
if self.window is None:
return None
return max(1, self.window - self.context)
def _build_chunks(self) -> list[tuple[int, int, int]]:
"""Split tracks into near-equal *scored* spans.
A chunk is identified by the span it predicts. Whatever context the
loader prepends is read on top and never scored, so the spans still tile
the track exactly and every frame is predicted once per epoch.
"""
chunks: list[tuple[int, int, int]] = []
span = self.predict_window
for index in range(len(self.entries)):
frames = int(self.model_frames[index])
if frames == 0:
continue
if span is None or frames <= span:
chunks.append((index, 0, frames))
continue
edges = np.linspace(
0, frames, math.ceil(frames / span) + 1
).round().astype(int)
for start, end in zip(edges[:-1], edges[1:]):
chunks.append((index, int(start), int(end - start)))
return chunks
def _fed_length(self, start: int, length: int) -> int:
return length + min(self.context, start)
def chunk_lengths(self) -> np.ndarray:
"""Frames actually fed per chunk - what bounds memory and batch size."""
return np.fromiter(
(self._fed_length(start, length) for _, start, length in self.chunks),
dtype=np.int64,
count=len(self.chunks),
)
def __len__(self) -> int:
return len(self.chunks)
def _open(self, entry: _Entry):
key = (entry.root, entry.shard)
arrays = self._arrays.get(key)
if arrays is None:
shard_dir = entry.root / entry.shard
faces = np.load(shard_dir / "faces.npy", mmap_mode="r", allow_pickle=False)
audio = np.load(shard_dir / "audio.npy", mmap_mode="r", allow_pickle=False)
labels = np.load(shard_dir / "labels.npy", mmap_mode="r", allow_pickle=False)
arrays = faces, audio, labels
self._arrays[key] = arrays
return arrays
def _stats(self, entry: _Entry) -> WaveformStats:
return WaveformStats(
mean=entry.metadata["audio_mean"],
rms=entry.metadata["audio_rms"],
peak=entry.metadata["audio_peak"],
)
def _read_audio(self, entry: _Entry, start: int, length: int) -> torch.Tensor:
"""Read one model-frame span and restore the recording's own gain."""
_, audio, _ = self._open(entry)
offset = entry.metadata["audio_offset"] + start * self.samples_per_frame
available = entry.metadata["audio_samples"] - start * self.samples_per_frame
wanted = length * self.samples_per_frame
block = np.array(audio[offset : offset + min(wanted, max(available, 0))], copy=True)
waveform = torch.from_numpy(block).float().div_(32768.0).unsqueeze(0)
if waveform.shape[-1] < wanted:
waveform = F.pad(waveform, (0, wanted - waveform.shape[-1]))
if self.normalize:
waveform = apply_normalization(waveform, self._stats(entry))
return waveform
def _donor_audio(self, clip: str, frames: int, rng) -> np.ndarray:
"""Read audio from a different clip than `clip`.
Drawing from the whole dataset rather than the mini-batch matters here:
every face track in a WASD clip shares one clip-level recording, and the
batch sampler groups by length, which puts those tracks together. A
batch-local donor would often be the very same waveform.
"""
for _ in range(8):
index = rng.randrange(len(self.entries))
if self._clips[index] == clip:
continue
entry = self.entries[index]
span = int(self.model_frames[index])
start = rng.randrange(max(span - frames, 0) + 1) if span > frames else 0
return self._read_audio(entry, start, min(frames, max(span, 1)))[0].numpy()
return np.zeros(frames * self.samples_per_frame, dtype=np.float32)
def __getitem__(self, index: int):
entry_index, predict_start, predict_length = self.chunks[index]
entry = self.entries[entry_index]
faces, _, labels = self._open(entry)
frames = int(self.model_frames[entry_index])
if self.jitter and predict_length < frames:
predict_start = random.randint(0, frames - predict_length)
# Read a history prefix where one exists; at a track's real start there
# is none, and that truncation is genuine rather than an artefact.
prefix = min(self.context, predict_start)
start = predict_start - prefix
length = prefix + predict_length
scored = torch.zeros(length, dtype=torch.bool)
scored[prefix:] = True
# One shared mapping puts faces and labels on the model timeline, which
# is what keeps a label attached to the face it describes.
rows = source_indices(
entry.metadata["source_frames"], entry.metadata["source_fps"], self.model_fps
)
span = rows[start : start + length]
face_block = np.asarray(faces[entry.metadata["face_offset"] + span])
# Labels are one byte a frame, so the whole track is cheap to read. The
# projection window needs 50 frames of history and 15 of future; taking
# them from the track rather than the chunk keeps a chunk boundary from
# fabricating silence in the targets.
track_labels = np.asarray(
labels[entry.metadata["label_offset"] + rows], dtype=np.uint8
).astype(np.float32)
waveform = self._read_audio(entry, start, length)
if self.augment is not None:
# The module RNG is what Lightning seeds per worker, so augmentation
# is reproducible from the run seed.
rng = random
clip = self._clips[entry_index]
audio_block = waveform[0].numpy()
if rng.random() < self.augment.substitute_audio_prob:
audio_block = self._donor_audio(clip, length, rng)
track_labels = np.zeros_like(track_labels)
elif rng.random() < self.augment.audio_mix_prob:
audio_block = mix_audio(
audio_block,
self._donor_audio(clip, length, rng),
rng.uniform(*self.augment.audio_snr_db),
)
waveform = torch.from_numpy(np.ascontiguousarray(audio_block)).unsqueeze(0)
if self.augment.visual:
face_block = augment_visual(face_block, self.augment, rng)
visual = torch.from_numpy(np.ascontiguousarray(face_block)).float()
track_vad = torch.from_numpy(track_labels)
vad = track_vad[start : start + length]
if self.projection_window is None:
return waveform, visual, vad, scored, entry.metadata
with torch.no_grad():
vap = self.projection_window.get_labels(track_vad.view(1, 1, frames))
return (
waveform,
visual,
vap[0, start : start + length],
vad,
scored,
entry.metadata,
)
class ASDCollator:
"""Bring a batch to one length, by cropping or by padding.
Training crops to the shortest member. That keeps the model free of a
padding mask, and it is affordable because chunking bounds the spread of
lengths and the batch sampler groups similar lengths together; measured on
AVA plus WASD the pair loses 0.01% of frames per epoch, against 64% for
random batches.
Evaluation cannot crop: the official export needs every frame of every
track, which is why it would otherwise run one track at a time. `pad=True`
right-pads to the longest member instead and marks the filler unscored.
Every stage of the model is causal - the CPC and Mimi frontends, the visual
TCN, and both transformer stacks - so appended frames cannot change any
earlier output, and the visual frontend's batch norm is on running
statistics in eval mode. A test pins both properties.
"""
def __init__(
self, samples_per_frame: int = SAMPLE_RATE // int(MODEL_FPS), pad: bool = False
):
self.samples_per_frame = samples_per_frame
self.pad = pad
@staticmethod
def _to_length(tensor: torch.Tensor, length: int, axis: int) -> torch.Tensor:
"""Crop or right-pad one stream along `axis` (counted from the end)."""
current = tensor.shape[-axis]
if current == length:
return tensor
if current > length:
index = [slice(None)] * tensor.ndim
index[-axis] = slice(0, length)
return tensor[tuple(index)]
padding = [0, 0] * (axis - 1) + [0, length - current]
return F.pad(tensor, padding)
def __call__(self, batch):
lengths = [item[1].shape[0] for item in batch]
frames = max(lengths) if self.pad else min(lengths)
samples = frames * self.samples_per_frame
audio = torch.stack([self._to_length(item[0], samples, 1) for item in batch])
visual = torch.stack([self._to_length(item[1], frames, 3) for item in batch])
vap = torch.stack([self._to_length(item[2], frames, 2) for item in batch])
vad = torch.stack([self._to_length(item[3], frames, 1) for item in batch])
scored = torch.stack([self._to_length(item[4], frames, 1) for item in batch])
return audio, visual, vap, vad, scored, [item[5] for item in batch]
class FrameBudgetBatchSampler(Sampler):
"""Group similar-length chunks into batches of roughly constant frame count.
Holding `batch * frames` near a budget keeps three things stable that a
fixed batch size does not: activation memory, the `B*T` population the
visual frontend's batch norm sees, and the per-frame gradient noise of the
mean-reduced losses. The batch size is clamped because the contrastive term
is a `B`-way classification whose difficulty would otherwise swing with the
chunk length.
"""
def __init__(
self,
lengths,
frame_budget: int,
min_batch: int = 4,
max_batch: int = 16,
shuffle: bool = True,
drop_last: bool = False,
seed: int = 0,
num_replicas: int = 1,
rank: int = 0,
):
if min_batch < 1 or max_batch < min_batch:
raise ValueError("min_batch must be positive and not exceed max_batch")
self.lengths = np.asarray(lengths, dtype=np.int64)
self.frame_budget = frame_budget
self.min_batch = min_batch
self.max_batch = max_batch
self.shuffle = shuffle
self.drop_last = drop_last
self.seed = seed
self.num_replicas = num_replicas
self.rank = rank
self.epoch = 0
self._batches = self._build()
def set_epoch(self, epoch: int) -> None:
self.epoch = epoch
self._batches = self._build()
def _build(self) -> list[list[int]]:
order = np.argsort(-self.lengths, kind="stable")
batches = []
cursor = 0
while cursor < len(order):
longest = int(self.lengths[order[cursor]])
size = min(
max(self.frame_budget // max(longest, 1), self.min_batch), self.max_batch
)
batch = order[cursor : cursor + size]
cursor += size
if self.drop_last and len(batch) < self.min_batch:
continue
batches.append([int(index) for index in batch])
if self.shuffle:
rng = random.Random(self.seed + self.epoch)
rng.shuffle(batches)
if self.num_replicas > 1:
# Every rank must run the same number of steps or DDP deadlocks.
remainder = len(batches) % self.num_replicas
if remainder:
batches += batches[: self.num_replicas - remainder]
batches = batches[self.rank :: self.num_replicas]
return batches
def __iter__(self):
return iter(self._batches)
def __len__(self) -> int:
return len(self._batches)