Add causal person fusion and MuVAP stage-3 baselines

This commit is contained in:
Xin Wang
2026-09-10 06:54:22 +08:00
parent 0534c9d3d0
commit d426b4740a
225 changed files with 27716 additions and 119 deletions
+1
View File
@@ -0,0 +1 @@
"""Canonical active-speaker preprocessing for MuVAP+."""
+112
View File
@@ -0,0 +1,112 @@
"""Dataset-specific metadata adapters.
Adapters do not normalize signals. They only convert source layouts into
`SourceRecord`; the shared writer owns every media transformation.
"""
import ast
import csv
import json
from pathlib import Path
from typing import Iterable
from preprocess.asd.schema import SAMPLE_RATE, SourceRecord
def _labels(text: str) -> list[int]:
values = ast.literal_eval(text)
if not isinstance(values, (list, tuple)):
raise ValueError("labels must be a list")
result = [int(value) for value in values]
if any(value not in (0, 1) for value in result):
raise ValueError("labels must contain only 0 and 1")
return result
def _existing(path: Path, sample_id: str) -> Path:
if not path.exists():
raise FileNotFoundError(f"{sample_id}: missing {path}")
return path
def clip_directory_records(
loader_path: Path,
visual_root: Path,
audio_root: Path,
split: str,
dataset: str = "ava",
skip_missing: bool = False,
) -> Iterable[SourceRecord]:
"""Read the AVA-style loader TSV and nested JPEG/WAV layout.
AVA and WASD ship the identical layout - `clips_videos/<split>/<clip>/
<entity>/<timestamp>.jpg` beside `clips_audios/<split>/<clip>/<entity>.wav`
- and the identical five-column loader TSV, so one reader serves both.
"""
face_by_id = {
path.name: path for path in (visual_root / split).glob("*/*") if path.is_dir()
}
audio_by_id = {path.stem: path for path in (audio_root / split).glob("*/*.wav")}
with loader_path.open(newline="") as handle:
for row_number, row in enumerate(csv.reader(handle, delimiter="\t"), start=1):
if len(row) < 4:
raise ValueError(
f"{loader_path}:{row_number}: expected at least 4 columns"
)
sample_id, _, fps, label_text = row[:4]
face_path = face_by_id.get(sample_id)
audio_path = audio_by_id.get(sample_id)
if face_path is None or audio_path is None:
if skip_missing:
continue
raise FileNotFoundError(
f"{sample_id}: missing face directory or clip-local WAV"
)
yield SourceRecord(
dataset=dataset,
sample_id=sample_id,
visual_path=face_path,
audio_path=audio_path,
source_fps=float(fps),
labels=_labels(label_text),
visual_kind="jpeg_directory",
)
def manifest_records(
manifest_path: Path,
dataset: str,
root: Path | None = None,
default_npy_audio_sample_rate: int = SAMPLE_RATE,
) -> Iterable[SourceRecord]:
"""Read the canonical TSV adapter used for AVCC and future datasets.
Required header fields: sample_id, visual_path, audio_path, source_fps,
labels. Optional: start_sec, visual_kind, audio_sample_rate.
"""
root = root or manifest_path.parent
with manifest_path.open(newline="") as handle:
reader = csv.DictReader(handle, delimiter="\t")
required = {"sample_id", "visual_path", "audio_path", "source_fps", "labels"}
missing = required.difference(reader.fieldnames or [])
if missing:
raise ValueError(f"{manifest_path}: missing columns {sorted(missing)}")
for row_number, row in enumerate(reader, start=2):
try:
yield SourceRecord(
dataset=dataset,
sample_id=row["sample_id"],
visual_path=_existing(root / row["visual_path"], row["sample_id"]),
audio_path=_existing(root / row["audio_path"], row["sample_id"]),
source_fps=float(row["source_fps"]),
labels=json.loads(row["labels"]),
start_sec=float(row.get("start_sec") or 0.0),
npy_audio_sample_rate=int(
row.get("audio_sample_rate") or default_npy_audio_sample_rate
),
visual_kind=row.get("visual_kind") or "npy",
)
except (TypeError, ValueError, json.JSONDecodeError) as error:
raise ValueError(
f"{manifest_path}:{row_number}: invalid row"
) from error
+270
View File
@@ -0,0 +1,270 @@
"""Crop raw MSDWild videos into aligned 30-second speaker-track samples."""
import argparse
import csv
import shutil
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from data.utils import load_audio # noqa: E402
from preprocess.asd.video import crop_face, load_visual # noqa: E402
from preprocess.asd.writer import ShardWriter # noqa: E402
@dataclass(frozen=True)
class Detection:
bbox: tuple[float, float, float, float]
def read_annotations(path: Path) -> dict[int, dict[str, Detection]]:
annotations: dict[int, dict[str, Detection]] = defaultdict(dict)
with path.open(newline="") as handle:
for row_number, row in enumerate(csv.reader(handle), start=1):
if len(row) < 8:
raise ValueError(f"{path}:{row_number}: expected 8 columns")
try:
frame = int(row[0])
speaker_id = row[2]
bbox = tuple(float(value) for value in row[3:7])
int(row[7])
except ValueError as error:
raise ValueError(f"{path}:{row_number}: invalid annotation") from error
annotations[frame][speaker_id] = Detection(bbox)
return dict(annotations)
def read_rttm(path: Path) -> dict[str, dict[str, list[tuple[float, float]]]]:
activity: dict[str, dict[str, list[tuple[float, float]]]] = defaultdict(
lambda: defaultdict(list)
)
with path.open() as handle:
for line_number, line in enumerate(handle, start=1):
parts = line.split()
if not parts:
continue
if len(parts) < 8 or parts[0] != "SPEAKER":
raise ValueError(f"{path}:{line_number}: invalid RTTM row")
try:
start = float(parts[3])
duration = float(parts[4])
except ValueError as error:
raise ValueError(f"{path}:{line_number}: invalid time") from error
activity[parts[1]][parts[7]].append((start, start + duration))
return {
video_id: {speaker: sorted(intervals) for speaker, intervals in speakers.items()}
for video_id, speakers in activity.items()
}
def retained_speakers(
annotations: dict[int, dict[str, Detection]],
start_frame: int,
frame_count: int,
min_face_ratio: float,
) -> list[str]:
counts: dict[str, int] = defaultdict(int)
for frame in range(start_frame, start_frame + frame_count):
for speaker_id in annotations.get(frame, {}):
counts[speaker_id] += 1
minimum = min_face_ratio * frame_count
return sorted(speaker_id for speaker_id, count in counts.items() if count >= minimum)
def clip_starts(first_face_frame: int, last_face_frame: int, stride_frames: int):
return range(first_face_frame, last_face_frame + 1, stride_frames)
def video_metadata(path: Path) -> tuple[float, int]:
cap = cv2.VideoCapture(str(path))
if not cap.isOpened():
raise FileNotFoundError(f"could not open video: {path}")
fps = float(cap.get(cv2.CAP_PROP_FPS))
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
if fps <= 0 or frame_count <= 0:
raise ValueError(f"invalid video metadata: {path}")
return fps, frame_count
def process_video(
video_path: Path,
annotation_path: Path,
activity: dict[str, list[tuple[float, float]]],
writer: ShardWriter,
clip_seconds: float,
overlap_seconds: float,
min_face_ratio: float,
min_clip_seconds: float,
) -> int:
annotations = read_annotations(annotation_path)
if not annotations:
return 0
fps, video_frames = video_metadata(video_path)
clip_frames = round(clip_seconds * fps)
stride_frames = round((clip_seconds - overlap_seconds) * fps)
min_clip_frames = max(1, round(min_clip_seconds * fps))
if clip_frames <= 0 or stride_frames <= 0:
raise ValueError("clip and stride must each contain at least one frame")
waveform, _ = load_audio(video_path)
first_face = min(annotations)
last_face = min(max(annotations), video_frames - 1)
written = 0
for segment_id, start_frame in enumerate(
clip_starts(first_face, last_face, stride_frames)
):
# Take whatever is left at the tail. Windows used to be dropped unless
# they were complete, which discarded every video shorter than one clip
# - about a third of MSDWild. The loader pads and chunks, so a short
# sample costs nothing.
window_frames = min(clip_frames, video_frames - start_frame)
if window_frames < min_clip_frames:
break
speakers = retained_speakers(
annotations, start_frame, window_frames, min_face_ratio
)
if not speakers:
continue
frames, decoded_fps = load_visual(video_path, start_frame, window_frames)
if not np.isclose(decoded_fps, fps):
raise ValueError(f"frame-rate changed while decoding {video_path}")
if len(frames) < min_clip_frames:
continue
window_frames = len(frames)
start_time = start_frame / fps
start_sample = round(start_time * 16_000)
target_samples = round(window_frames / fps * 16_000)
audio = waveform[:, start_sample : start_sample + target_samples]
for speaker_id in speakers:
faces = []
labels = np.zeros(window_frames, dtype=np.uint8)
for offset, frame in enumerate(frames):
detection = annotations.get(start_frame + offset, {}).get(speaker_id)
faces.append(crop_face(frame, detection.bbox if detection else None))
intervals = activity.get(speaker_id, [])
for offset in range(window_frames):
timestamp = start_time + (offset + 0.5) / fps
labels[offset] = any(start <= timestamp < end for start, end in intervals)
faces = np.stack(faces).astype(np.uint8, copy=False)
sample_id = f"{video_path.stem}_{segment_id:05d}_{speaker_id}"
writer.add_arrays(
dataset="msdwild",
sample_id=sample_id,
faces=faces,
waveform=audio,
labels=labels,
source_fps=fps,
start_sec=start_time,
)
written += 1
return written
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--rttm", type=Path)
parser.add_argument(
"--output",
type=Path,
help="defaults to <dataset root>/packed/<split>, beside the source media",
)
parser.add_argument(
"--split",
default="all",
help="official split to pack: all, few.train, few.val, many.val",
)
parser.add_argument("--clip-seconds", type=float, default=30.0)
# Windows no longer overlap: the loader computes projection targets over a
# whole track before chunking, so duplicated frames buy nothing and would
# both inflate the pack and over-weight this corpus in an epoch.
parser.add_argument("--overlap-seconds", type=float, default=0.0)
parser.add_argument("--min-clip-seconds", type=float, default=2.0)
parser.add_argument("--min-face-ratio", type=float, default=0.9)
parser.add_argument("--max-shard-gb", type=float, default=1.0)
parser.add_argument("--overwrite", action="store_true")
return parser
def main() -> None:
args = build_parser().parse_args()
if args.clip_seconds <= 0:
raise ValueError("clip-seconds must be positive")
if not 0 <= args.overlap_seconds < args.clip_seconds:
raise ValueError("overlap-seconds must be in [0, clip-seconds)")
if not 0 < args.min_face_ratio <= 1:
raise ValueError("min-face-ratio must be in (0, 1]")
if args.max_shard_gb <= 0:
raise ValueError("max-shard-gb must be positive")
if not 0 < args.min_clip_seconds <= args.clip_seconds:
raise ValueError("min-clip-seconds must be in (0, clip-seconds]")
if args.output is None:
args.output = args.input.parent / "packed" / args.split
print(f"packing into {args.output}")
if args.output.exists():
if not args.overwrite and any(args.output.iterdir()):
raise FileExistsError(f"output directory is not empty: {args.output}")
if args.overwrite:
shutil.rmtree(args.output)
args.output.mkdir(parents=True)
videos = sorted(args.input.glob("*.mp4"))
if not videos:
raise FileNotFoundError(f"no MP4 files found in {args.input}")
rttm_path = args.rttm or args.input.parent / "all.rttm"
if not rttm_path.exists():
raise FileNotFoundError(f"missing RTTM labels: {rttm_path}")
activity = read_rttm(rttm_path)
if args.split != "all":
# The official split lists are themselves RTTMs; their video ids are
# the split membership.
split_path = args.input.parent / f"{args.split}.rttm"
if not split_path.exists():
raise FileNotFoundError(f"missing split list: {split_path}")
wanted = set(read_rttm(split_path))
videos = [video for video in videos if video.stem in wanted]
if not videos:
raise ValueError(f"no videos matched split {args.split}")
print(f"{args.split}: {len(videos)} videos")
total = 0
writer = ShardWriter(
args.output, max_shard_bytes=round(args.max_shard_gb * 1_000_000_000)
)
try:
for index, video_path in enumerate(videos, start=1):
annotation_path = video_path.with_suffix(".csv")
if not annotation_path.exists():
raise FileNotFoundError(f"missing annotations: {annotation_path}")
total += process_video(
video_path,
annotation_path,
activity.get(video_path.stem, {}),
writer,
args.clip_seconds,
args.overlap_seconds,
args.min_face_ratio,
args.min_clip_seconds,
)
print(f"[{index}/{len(videos)}] {video_path.stem}: {total} samples")
finally:
writer.close()
print(f"Wrote {total} samples to {args.output}")
if __name__ == "__main__":
main()
+93
View File
@@ -0,0 +1,93 @@
"""Build the canonical fast-training format for AVA, WASD, and AVCC."""
import argparse
from pathlib import Path
from preprocess.asd.adapters import clip_directory_records, manifest_records
from preprocess.asd.writer import write_records
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("dataset", choices=["ava", "wasd", "msdwild", "avcc"])
parser.add_argument(
"--loader", type=Path, required=True, help="source TSV/manifest"
)
parser.add_argument(
"--output",
type=Path,
help="defaults to <dataset root>/packed/<split>, beside the source media",
)
parser.add_argument("--visual-root", type=Path)
parser.add_argument("--audio-root", type=Path)
parser.add_argument("--root", type=Path, help="base for AVCC manifest paths")
parser.add_argument("--split", default="train")
parser.add_argument("--audio-sample-rate", type=int, default=16_000)
parser.add_argument("--max-shard-gb", type=float, default=1.0)
parser.add_argument(
"--skip-missing",
action="store_true",
help="development subsets only; production preprocessing should fail on missing files",
)
return parser
def require_roots(args: argparse.Namespace) -> None:
if args.visual_root is None or args.audio_root is None:
raise ValueError(f"{args.dataset} requires --visual-root and --audio-root")
def default_output(args: argparse.Namespace) -> Path:
"""Keep each corpus's pack beside that corpus.
`<root>/clips_videos/<split>` becomes `<root>/packed/<split>`, so a dataset
stays self-contained and can be copied to fast local storage on its own.
"""
if args.visual_root is not None:
return args.visual_root.parent / "packed" / args.split
if args.root is not None:
return args.root / "packed" / args.split
raise ValueError("--output is required when no dataset root is given")
def main() -> None:
args = build_parser().parse_args()
if args.output is None:
args.output = default_output(args)
print(f"packing into {args.output}")
if args.output.exists() and any(args.output.iterdir()):
raise FileExistsError(f"output directory is not empty: {args.output}")
if args.dataset in ("ava", "wasd"):
require_roots(args)
records = clip_directory_records(
args.loader,
args.visual_root,
args.audio_root,
args.split,
dataset=args.dataset,
skip_missing=args.skip_missing,
)
elif args.dataset == "msdwild":
raise SystemExit(
"MSDWild is packed from raw media by preprocess/asd/prep_msdwild.py, "
"which needs all.rttm for labels; there is no loader TSV to read here"
)
else:
records = manifest_records(
args.loader,
dataset="avcc",
root=args.root,
default_npy_audio_sample_rate=args.audio_sample_rate,
)
count = write_records(
records,
args.output,
max_shard_bytes=round(args.max_shard_gb * 1_000_000_000),
)
print(f"Wrote {count} {args.dataset} samples to {args.output}")
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
"""Shared source record and packed-shard schema.
The packed format is a *decoded mirror* of the source corpus, not a
model-ready tensor set. Faces, labels, and audio are stored on the native
timeline with no gain applied, so any codebase can consume a pack without
inheriting MuVAP+'s 25 Hz model timeline or its loudness convention. Every
model-specific transform lives in `data.dataloaders.packed` instead.
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
FORMAT_NAME = "muvap-asd-mmap"
FORMAT_VERSION = 4
SUPPORTED_VERSIONS = (4,)
# The model timeline. It belongs to the loader, not to the stored data, and is
# repeated here only because preprocessing reports frame counts against it.
MODEL_FPS = 25.0
SAMPLE_RATE = 16_000
FACE_SIZE = 112
@dataclass(frozen=True)
class SourceRecord:
dataset: str
sample_id: str
visual_path: Path
audio_path: Path
source_fps: float
labels: Sequence[int]
start_sec: float = 0.0
npy_audio_sample_rate: int | None = None
visual_kind: str = "npy"
def __post_init__(self) -> None:
if self.source_fps <= 0:
raise ValueError(f"{self.sample_id}: source_fps must be positive")
if self.start_sec < 0:
raise ValueError(f"{self.sample_id}: start_sec cannot be negative")
if self.visual_kind not in {"npy", "jpeg_directory"}:
raise ValueError(f"unsupported visual_kind: {self.visual_kind}")
+93
View File
@@ -0,0 +1,93 @@
"""Validate packed samples and report per-dataset signal statistics."""
import argparse
import json
import math
from collections import defaultdict
from pathlib import Path
from typing import Sequence
import numpy as np
from data.alignment import model_frame_count
from data.dataloaders.packed import PackedASDDataset
from data.media import WaveformStats, normalization_gain
from preprocess.asd.schema import MODEL_FPS
from preprocess.asd.writer import native_audio_samples
def percentile(values: list[float], points=(0, 1, 50, 95, 99, 100)) -> dict:
return {str(point): float(np.percentile(values, point)) for point in points}
def validate(roots: Path | Sequence[Path]) -> None:
dataset = PackedASDDataset(roots)
grouped = defaultdict(lambda: defaultdict(list))
for index in range(len(dataset)):
audio, visual, vad, _, metadata = dataset[index]
sample_id = metadata["sample_id"]
expected = model_frame_count(
metadata["source_frames"], metadata["source_fps"], MODEL_FPS
)
frames = visual.shape[0]
if frames == 0:
raise ValueError(f"{sample_id}: empty sample")
if frames != expected:
raise ValueError(
f"{sample_id}: {metadata['source_frames']} source frames at "
f"{metadata['source_fps']} fps imply {expected} model frames, "
f"loader produced {frames}"
)
if vad.shape[0] != frames or audio.shape[-1] != frames * 640:
raise ValueError(f"{sample_id}: stream-length mismatch")
if visual.shape[1:] != (112, 112) or visual.min() < 0 or visual.max() > 255:
raise ValueError(f"{sample_id}: invalid visual tensor")
if not np.isfinite(metadata["audio_peak"]):
raise ValueError(f"{sample_id}: invalid audio statistics")
if metadata["audio_samples"] != native_audio_samples(
metadata["source_frames"], metadata["source_fps"]
):
raise ValueError(f"{sample_id}: stored audio does not span the faces")
stats = grouped[metadata["dataset"]]
stats["source_audio_peak"].append(metadata["audio_peak"])
stats["source_audio_rms"].append(metadata["audio_rms"])
stats["source_audio_mean"].append(metadata["audio_mean"])
stats["speaking_ratio"].append(metadata["speaking_ratio"])
stats["normalized_rms"].append(
audio.square().mean().sqrt().item()
)
stats["normalized_peak"].append(audio.abs().max().item())
gain = normalization_gain(
WaveformStats(
mean=metadata["audio_mean"],
rms=metadata["audio_rms"],
peak=metadata["audio_peak"],
)
)
stats["gain_db"].append(20 * math.log10(max(gain, 1e-8)))
report = {"samples": len(dataset), "datasets": {}}
for name, stats in grouped.items():
report["datasets"][name] = {
"samples": len(stats["source_audio_peak"]),
"source_audio_peak_percentiles": percentile(stats["source_audio_peak"]),
"source_audio_rms_percentiles": percentile(stats["source_audio_rms"]),
"normalized_rms_percentiles": percentile(stats["normalized_rms"]),
"normalized_peak_percentiles": percentile(stats["normalized_peak"]),
"gain_db_percentiles": percentile(stats["gain_db"]),
"max_abs_source_mean": max(abs(v) for v in stats["source_audio_mean"]),
"speaking_ratio_percentiles": percentile(stats["speaking_ratio"]),
}
print(json.dumps(report, indent=2))
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("roots", type=Path, nargs="+")
validate(parser.parse_args().roots)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""Reading face crops off a source video.
Shared by the dataset adapters that pack from video rather than from
pre-extracted frames.
"""
import cv2
import numpy as np
def crop_face(frame, bbox, gray=True, dim=(112, 112)):
if bbox is None:
return np.full(dim if gray else (dim[1], dim[0], 3), 128, dtype=np.uint8)
x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])
if x1 >= x2 or y1 >= y2:
return np.full(dim if gray else (dim[1], dim[0], 3), 128, dtype=np.uint8)
h, w = frame.shape[:2]
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
size = max(x2 - x1, y2 - y1)
half = size // 2
x1s, y1s = cx - half, cy - half
x2s, y2s = x1s + size, y1s + size
x1c, y1c = max(0, x1s), max(0, y1s)
x2c, y2c = min(w, x2s), min(h, y2s)
if x1c >= x2c or y1c >= y2c:
return np.full(dim if gray else (dim[1], dim[0], 3), 128, dtype=np.uint8)
cropped = frame[y1c:y2c, x1c:x2c]
if gray:
cropped = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY)
border_val = 128
else:
cropped = cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB)
border_val = (128, 128, 128)
pad_top, pad_left = y1c - y1s, x1c - x1s
pad_bottom, pad_right = y2s - y2c, x2s - x2c
if pad_top > 0 or pad_bottom > 0 or pad_left > 0 or pad_right > 0:
cropped = cv2.copyMakeBorder(
cropped,
pad_top,
pad_bottom,
pad_left,
pad_right,
cv2.BORDER_CONSTANT,
value=border_val,
)
return cv2.resize(cropped, dim)
def load_visual(video_path, start_frame=0, target_frames=None):
"""Decode a native-FPS video interval without changing its timeline."""
if start_frame < 0:
raise ValueError("start_frame cannot be negative")
if target_frames is not None and target_frames < 0:
raise ValueError("target_frames cannot be negative")
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
raise FileNotFoundError(f"could not open video: {video_path}")
fps = float(cap.get(cv2.CAP_PROP_FPS))
if fps <= 0:
cap.release()
raise ValueError(f"video has an invalid frame rate: {video_path}")
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
frames = []
while target_frames is None or len(frames) < target_frames:
ok, frame = cap.read()
if not ok:
break
frames.append(frame)
cap.release()
return frames, fps
+204
View File
@@ -0,0 +1,204 @@
"""Pack decoded native-rate samples into memory-mapped NumPy shards.
Nothing here is specific to the model that will read the shards. Faces and
labels keep the source frame rate, audio keeps its full duration and its
original gain, and the per-recording level statistics needed to reproduce a
normalization travel in the index. Resampling onto a model timeline and
applying loudness normalization are the reader's job.
"""
import json
from pathlib import Path
from typing import Iterable
import cv2
import numpy as np
import torch
from data.alignment import model_frame_count
from data.media import (
AudioSpec,
load_waveform,
slice_audio,
validate_visual,
waveform_stats,
)
from preprocess.asd.schema import (
FACE_SIZE,
FORMAT_NAME,
FORMAT_VERSION,
MODEL_FPS,
SAMPLE_RATE,
SourceRecord,
)
def _load_faces(record: SourceRecord, face_size: int = FACE_SIZE) -> np.ndarray:
if record.visual_kind == "npy":
return validate_visual(np.load(record.visual_path, allow_pickle=False))
paths = sorted(record.visual_path.glob("*.jpg"), key=lambda path: float(path.stem))
if not paths:
raise FileNotFoundError(f"{record.sample_id}: no JPEG faces")
frames = []
for path in paths:
frame = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
if frame is None:
raise ValueError(f"{record.sample_id}: could not decode {path}")
frames.append(
cv2.resize(frame, (face_size, face_size), interpolation=cv2.INTER_LINEAR)
)
return np.stack(frames).astype(np.uint8, copy=False)
def _pcm16(waveform: torch.Tensor) -> np.ndarray:
values = waveform.squeeze(0).clamp(-1.0, 1.0)
return (values * 32767.0).round().to(torch.int16).cpu().numpy()
def native_audio_samples(source_frames: int, source_fps: float) -> int:
"""Audio length covering exactly the span of the stored face frames."""
return round(source_frames / source_fps * SAMPLE_RATE)
class ShardWriter:
def __init__(self, output_dir: Path, max_shard_bytes: int = 1_000_000_000):
self.output_dir = output_dir
self.max_shard_bytes = max_shard_bytes
self.shard_index = 0
self.samples_written = 0
self._reset()
output_dir.mkdir(parents=True, exist_ok=True)
def _reset(self) -> None:
self.faces: list[np.ndarray] = []
self.audio: list[np.ndarray] = []
self.labels: list[np.ndarray] = []
self.entries: list[dict] = []
self.byte_count = 0
self.face_count = 0
self.audio_count = 0
self.label_count = 0
def add_arrays(
self,
*,
dataset: str,
sample_id: str,
faces: np.ndarray,
waveform: torch.Tensor,
labels,
source_fps: float,
start_sec: float = 0.0,
) -> None:
faces = validate_visual(faces)
labels = np.asarray(labels, dtype=np.uint8)
if len(faces) != len(labels):
raise ValueError(
f"{sample_id}: {len(faces)} faces but {len(labels)} labels"
)
source_frames = len(labels)
if source_frames == 0:
raise ValueError(f"{sample_id}: sample is empty")
waveform = slice_audio(
waveform,
SAMPLE_RATE,
target_samples=native_audio_samples(source_frames, source_fps),
)
stats = waveform_stats(waveform)
audio = _pcm16(waveform)
sample_bytes = faces.nbytes + audio.nbytes + labels.nbytes
if self.entries and self.byte_count + sample_bytes > self.max_shard_bytes:
self.flush()
self.entries.append(
{
"dataset": dataset,
"sample_id": sample_id,
"source_fps": source_fps,
"source_frames": source_frames,
"start_sec": start_sec,
# Reported for convenience; the loader recomputes it.
"model_frames": model_frame_count(source_frames, source_fps, MODEL_FPS),
"face_offset": self.face_count,
"audio_offset": self.audio_count,
"audio_samples": len(audio),
"label_offset": self.label_count,
"audio_mean": stats.mean,
"audio_rms": stats.rms,
"audio_peak": stats.peak,
"speaking_ratio": float(labels.mean()),
}
)
self.faces.append(faces)
self.audio.append(audio)
self.labels.append(labels)
self.byte_count += sample_bytes
self.face_count += len(faces)
self.audio_count += len(audio)
self.label_count += len(labels)
def add(self, record: SourceRecord) -> None:
faces = _load_faces(record)
waveform, sample_rate = load_waveform(
record.audio_path,
AudioSpec(sample_rate=SAMPLE_RATE, mono=True),
npy_sample_rate=record.npy_audio_sample_rate,
)
waveform = slice_audio(waveform, sample_rate, start_time=record.start_sec)
self.add_arrays(
dataset=record.dataset,
sample_id=record.sample_id,
faces=faces,
waveform=waveform,
labels=record.labels,
source_fps=record.source_fps,
start_sec=record.start_sec,
)
def flush(self) -> None:
if not self.entries:
return
shard_dir = self.output_dir / f"shard-{self.shard_index:06d}"
shard_dir.mkdir(parents=False, exist_ok=False)
np.save(shard_dir / "faces.npy", np.concatenate(self.faces), allow_pickle=False)
np.save(shard_dir / "audio.npy", np.concatenate(self.audio), allow_pickle=False)
np.save(
shard_dir / "labels.npy", np.concatenate(self.labels), allow_pickle=False
)
with (shard_dir / "index.json").open("w") as handle:
json.dump(self.entries, handle, separators=(",", ":"))
self.samples_written += len(self.entries)
self.shard_index += 1
self._reset()
def close(self) -> None:
self.flush()
manifest = {
"format": FORMAT_NAME,
"version": FORMAT_VERSION,
"timeline": "native",
"audio_sample_rate": SAMPLE_RATE,
"audio_normalization": None,
"face_shape": [FACE_SIZE, FACE_SIZE],
"face_color": "grayscale",
"face_dtype": "uint8",
"audio_dtype": "int16",
"label_dtype": "uint8",
"shards": self.shard_index,
"samples": self.samples_written,
}
with (self.output_dir / "dataset.json").open("w") as handle:
json.dump(manifest, handle, indent=2)
def write_records(
records: Iterable[SourceRecord], output_dir: Path, max_shard_bytes: int
) -> int:
writer = ShardWriter(output_dir, max_shard_bytes)
for record in records:
writer.add(record)
writer.close()
return writer.samples_written