#!/usr/bin/env python3 """Prepare EgoSpeak's Ego4D split as exact 2 FPS audio-video clips. The 5 Hz target arrays are the timing authority. For a target with T rows, the prepared clip has duration T / 5 seconds and exactly ceil(2T / 5) video frames. Short source audio is padded with silence and all audio is trimmed to the target duration. Processing is intentionally sequential. A single libx264 process uses all available CPU cores on the intended extraction machine, so launching several encoders normally creates CPU and disk contention instead of improving total throughput. """ from __future__ import annotations import argparse import csv import json import math import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path import numpy as np TARGET_FPS = 5 VIDEO_FPS = 2 EXPECTED_TRAIN_CLIPS = 346 EXPECTED_TEST_CLIPS = 87 @dataclass(frozen=True) class ClipSpec: split: str uid: str steps: int duration: float video_frames: int source: Path target: Path def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Prepare the 433 clips in EgoSpeak's EGO4DSHUFFLE split as " "strict fixed-grid 2 FPS MP4 files." ) ) parser.add_argument( "--data-root", type=Path, default=Path("/data/wangx/Datasets/ego4d"), help="Ego4D dataset root (default: %(default)s)", ) parser.add_argument( "--split-json", type=Path, help="EgoSpeak LSTR data_info.json; defaults to DATA_ROOT/egospeak_data_info.json", ) parser.add_argument( "--video-root", type=Path, help="Raw clip directory; defaults to DATA_ROOT/v1/clips", ) parser.add_argument( "--target-root", type=Path, help="Target directory; defaults to DATA_ROOT/ego4d_target_frame", ) parser.add_argument( "--output-root", type=Path, help="Prepared video directory; defaults to DATA_ROOT/omni_prepared_2fps", ) parser.add_argument( "--manifest", type=Path, help="Output TSV; defaults to DATA_ROOT/ego4d_egospeak.tsv", ) parser.add_argument( "--split", choices=("all", "train", "test"), default="all", help="Prepare all, train, or test clips (default: %(default)s)", ) parser.add_argument( "--uid", action="append", help="Prepare only this split UID; repeat to select several clips", ) parser.add_argument( "--limit", type=int, help="Prepare only the first N selected clips, for testing", ) parser.add_argument( "--crf", type=int, default=18, help="libx264 constant-rate-factor value (default: %(default)s)", ) parser.add_argument( "--preset", default="medium", help="libx264 preset (default: %(default)s)", ) parser.add_argument( "--force", action="store_true", help="Rebuild outputs even when their frame counts are already correct", ) parser.add_argument( "--dry-run", action="store_true", help="Validate inputs and print work without running ffmpeg", ) args = parser.parse_args() if args.limit is not None and args.limit <= 0: parser.error("--limit must be positive") if not 0 <= args.crf <= 51: parser.error("--crf must be between 0 and 51") return args def resolve_paths(args: argparse.Namespace) -> None: args.data_root = args.data_root.expanduser().resolve() args.split_json = ( args.split_json or args.data_root / "egospeak_data_info.json" ).expanduser().resolve() args.video_root = ( args.video_root or args.data_root / "v1" / "clips" ).expanduser().resolve() args.target_root = ( args.target_root or args.data_root / "ego4d_target_frame" ).expanduser().resolve() args.output_root = ( args.output_root or args.data_root / "omni_prepared_2fps" ).expanduser().resolve() args.manifest = ( args.manifest or args.data_root / "ego4d_egospeak.tsv" ).expanduser().resolve() def require_file(path: Path, description: str) -> None: if not path.is_file(): raise FileNotFoundError(f"Missing {description}: {path}") def load_specs(args: argparse.Namespace) -> list[ClipSpec]: require_file(args.split_json, "EgoSpeak split JSON") if not args.video_root.is_dir(): raise FileNotFoundError(f"Missing raw-video directory: {args.video_root}") if not args.target_root.is_dir(): raise FileNotFoundError(f"Missing target directory: {args.target_root}") with args.split_json.open() as file: payload = json.load(file) try: split_info = payload["EGO4DSHUFFLE"] train_uids = split_info["train_session_set"] test_uids = split_info["test_session_set"] except KeyError as error: raise RuntimeError( f"{args.split_json} is not EgoSpeak's LSTR data_info.json: " f"missing {error}" ) from error if len(train_uids) != EXPECTED_TRAIN_CLIPS: raise RuntimeError( f"Expected {EXPECTED_TRAIN_CLIPS} EGO4DSHUFFLE train clips, " f"found {len(train_uids)}" ) if len(test_uids) != EXPECTED_TEST_CLIPS: raise RuntimeError( f"Expected {EXPECTED_TEST_CLIPS} EGO4DSHUFFLE test clips, " f"found {len(test_uids)}" ) specs: list[ClipSpec] = [] seen: set[str] = set() for split, uids in (("train", train_uids), ("test", test_uids)): for uid in uids: if uid in seen: raise RuntimeError(f"Duplicate UID in EGO4DSHUFFLE: {uid}") seen.add(uid) source = args.video_root / f"{uid}.mp4" target_path = args.target_root / f"{uid}.npy" require_file(source, "source video") require_file(target_path, "target array") target = np.load(target_path, mmap_mode="r", allow_pickle=False) if target.ndim != 2 or target.shape[1] != 3: raise RuntimeError( f"Expected target shape [T, 3], found {target.shape}: " f"{target_path}" ) steps = int(target.shape[0]) duration = steps / TARGET_FPS video_frames = math.ceil(steps * VIDEO_FPS / TARGET_FPS) specs.append( ClipSpec( split=split, uid=uid, steps=steps, duration=duration, video_frames=video_frames, source=source, target=target_path, ) ) return specs def write_manifest(specs: list[ClipSpec], path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".partial") with temporary.open("w", newline="") as file: writer = csv.writer(file, delimiter="\t") writer.writerow( ["split", "uid", "steps", "duration", "video_frames_2fps"] ) for spec in specs: writer.writerow( [ spec.split, spec.uid, spec.steps, f"{spec.duration:.6f}", spec.video_frames, ] ) temporary.replace(path) def select_specs(args: argparse.Namespace, specs: list[ClipSpec]) -> list[ClipSpec]: selected = specs if args.split != "all": selected = [spec for spec in selected if spec.split == args.split] if args.uid: requested = set(args.uid) known = {spec.uid for spec in specs} missing = sorted(requested - known) if missing: raise RuntimeError( "Requested UID(s) are not in EGO4DSHUFFLE: " + ", ".join(missing) ) selected = [spec for spec in selected if spec.uid in requested] if args.limit is not None: selected = selected[: args.limit] if not selected: raise RuntimeError("No clips selected") return selected def count_video_frames(path: Path) -> int | None: if not path.is_file() or path.stat().st_size == 0: return None command = [ "ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=nb_read_frames", "-of", "default=noprint_wrappers=1:nokey=1", str(path), ] completed = subprocess.run(command, text=True, capture_output=True) if completed.returncode != 0: return None try: return int(completed.stdout.strip()) except ValueError: return None def prepare_one( args: argparse.Namespace, spec: ClipSpec, index: int, total: int, ) -> str: output = args.output_root / f"{spec.uid}_2fps.mp4" actual = count_video_frames(output) prefix = f"[{index}/{total}] [{spec.split}] {spec.uid}" if not args.force and actual == spec.video_frames: print(f"{prefix}: reuse ({actual} frames)", flush=True) return "reused" if actual is None: reason = "missing" else: reason = f"actual={actual}, expected={spec.video_frames}" print( f"{prefix}: prepare ({reason}; {spec.steps} states, " f"{spec.duration:.1f}s, {spec.video_frames} frames)", flush=True, ) if args.dry_run: return "dry-run" temporary = args.output_root / f".{spec.uid}.partial.mp4" duration = f"{spec.duration:.6f}" video_filter = ( f"[0:v:0]trim=start=0:end={duration},setpts=PTS-STARTPTS," f"fps=fps={VIDEO_FPS}:start_time=0:round=down," "tpad=stop_mode=clone:stop=2," f"trim=start_frame=0:end_frame={spec.video_frames}," f"setpts=N/({VIDEO_FPS}*TB),format=yuv420p[vout]" ) audio_filter = ( f"[0:a:0]apad,atrim=start=0:end={duration}," "asetpts=PTS-STARTPTS[aout]" ) command = [ "ffmpeg", "-hide_banner", "-loglevel", "warning", "-stats", "-y", "-i", str(spec.source), "-filter_complex", f"{video_filter};{audio_filter}", "-map", "[vout]", "-map", "[aout]", "-map_metadata", "-1", "-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf), "-c:a", "aac", "-ar", "16000", "-ac", "1", "-b:a", "64k", "-movflags", "+faststart", str(temporary), ] completed = subprocess.run(command) if completed.returncode != 0: raise RuntimeError( f"ffmpeg failed for {spec.uid} with exit code {completed.returncode}; " f"partial output was left at {temporary}" ) actual = count_video_frames(temporary) if actual != spec.video_frames: raise RuntimeError( f"Prepared frame count is {actual}, expected {spec.video_frames}: " f"{temporary}" ) temporary.replace(output) print(f"{prefix}: OK ({actual} frames) -> {output}", flush=True) return "prepared" def main() -> None: args = parse_args() resolve_paths(args) for executable in ("ffmpeg", "ffprobe"): if shutil.which(executable) is None: raise RuntimeError(f"Required executable is not on PATH: {executable}") specs = load_specs(args) write_manifest(specs, args.manifest) selected = select_specs(args, specs) args.output_root.mkdir(parents=True, exist_ok=True) total_steps = sum(spec.steps for spec in specs) print( f"Validated EGO4DSHUFFLE: {len(specs)} clips " f"({EXPECTED_TRAIN_CLIPS} train, {EXPECTED_TEST_CLIPS} test), " f"{total_steps} target states" ) print(f"Manifest: {args.manifest}") print(f"Output: {args.output_root}") print(f"Selected: {len(selected)} clips; sequential ffmpeg execution") counts = {"prepared": 0, "reused": 0, "dry-run": 0} for index, spec in enumerate(selected, start=1): result = prepare_one(args, spec, index, len(selected)) counts[result] += 1 print( "Complete: " + ", ".join(f"{key}={value}" for key, value in counts.items() if value) ) if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\nInterrupted; rerun the same command to resume.", file=sys.stderr) raise SystemExit(130) except Exception as error: print(f"ERROR: {error}", file=sys.stderr) raise SystemExit(1)