#!/usr/bin/env python3 """Build the 3035-sample StreamDelta OVO-Bench evaluation dataset. The official annotation contains 1640 rows. REC, SSR, and CRR contain several evaluation timestamps per row, so the production protocol expands them into 3035 samples. The script also extracts one frame per second and gives ASI samples their full source video, matching the current production run. """ from __future__ import annotations import argparse import collections import concurrent.futures import hashlib import json import math import os import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Sequence, Tuple EXPECTED_COUNTS = { "EPM": 297, "HLD": 186, "ASI": 148, "STU": 178, "OJR": 184, "ATR": 116, "FPD": 101, "OCR": 149, "SSR": 629, "ACR": 109, "CRR": 240, "REC": 698, } FORWARD_TASKS = {"REC", "SSR", "CRR"} def read_json(path: Path) -> Any: with path.open(encoding="utf-8") as handle: return json.load(handle) def write_json_atomic(path: Path, value: Any, *, pretty: bool = False) -> None: path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False, ) as handle: json.dump( value, handle, ensure_ascii=False, indent=2 if pretty else None, ) handle.write("\n") temporary = Path(handle.name) os.replace(temporary, path) def yes_no(value: Any) -> str: return "Yes" if int(value) else "No" def expand_annotations( annotations: Sequence[Dict[str, Any]], regular_frames_root: Path, ) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]: """Expand the official 1640 rows into the production 3035-row protocol.""" output: List[Dict[str, Any]] = [] source_by_eval_id: Dict[str, Dict[str, Any]] = {} for source in annotations: task = str(source["task"]) source_id = str(source["id"]) rows: List[Dict[str, Any]] = [] if task == "REC": for index, test in enumerate(source["test_info"]): query_time = int(test["realtime"]) rows.append({ "id": f"{source_id}_{index}", "task": task, "activity": source["activity"], "query_time": query_time, # This field is consumed as a question-time fallback by the # frozen runner. It intentionally is not the event ledger. "start_times": [query_time], "gt": str(test["count"]), }) elif task == "SSR": for index, test in enumerate(source["test_info"]): rows.append({ "id": f"{source_id}_{index}", "task": task, "all_steps": test["step"], "step_start_time": int(test["realtime"]), "disable_ssr_expand": True, "gt": yes_no(test["type"]), }) elif task == "CRR": for index, test in enumerate(source["test_info"]): rows.append({ "id": f"{source_id}_{index}", "task": task, "question": source["question"], "ask_time": int(test["realtime"]), "gt": yes_no(test["type"]), }) else: row = { "id": source_id, "task": task, "question": source["question"], "options": source["options"], "realtime": int(source["realtime"]), "gt": chr(ord("A") + int(source["gt"])), } if task == "ASI": row["ask_time"] = int(source["realtime"]) // 2 rows.append(row) for row in rows: eval_id = str(row["id"]) row["video"] = str((regular_frames_root / eval_id).resolve()) source_by_eval_id[eval_id] = source output.append(row) validate_schema(output) return output, source_by_eval_id def validate_schema(samples: Sequence[Dict[str, Any]]) -> None: ids = [str(sample["id"]) for sample in samples] counts = collections.Counter(str(sample["task"]) for sample in samples) if len(samples) != 3035 or len(set(ids)) != 3035: raise ValueError( f"expected 3035 unique samples, got rows={len(samples)}, " f"unique={len(set(ids))}" ) if dict(counts) != EXPECTED_COUNTS: raise ValueError( f"unexpected task counts: {dict(counts)}; " f"expected {EXPECTED_COUNTS}" ) def resolve_ffmpeg(explicit: str | None) -> str: if explicit: path = shutil.which(explicit) or explicit if Path(path).is_file(): return path raise FileNotFoundError(f"ffmpeg not found: {explicit}") system = shutil.which("ffmpeg") if system: return system try: import imageio_ffmpeg return imageio_ffmpeg.get_ffmpeg_exe() except (ImportError, RuntimeError) as exc: raise RuntimeError( "ffmpeg is required; install ffmpeg or imageio-ffmpeg" ) from exc def source_video(data_root: Path, source: Dict[str, Any]) -> Path: path = data_root / "src_videos" / str(source["video"]) if not path.is_file(): raise FileNotFoundError(f"missing source video: {path}") return path def cutoff_seconds(sample: Dict[str, Any], source: Dict[str, Any]) -> int: task = str(sample["task"]) if task == "REC": value = sample["query_time"] elif task == "SSR": value = sample["step_start_time"] elif task == "CRR": value = sample["ask_time"] else: value = source["realtime"] return max(1, int(math.ceil(float(value)))) def run_command(command: Sequence[str], label: str) -> None: result = subprocess.run( command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, ) if result.returncode: tail = result.stderr[-2000:].strip() raise RuntimeError(f"{label} failed (rc={result.returncode}): {tail}") def create_clip( *, ffmpeg: str, source: Path, output: Path, duration: int, force: bool, ) -> None: if output.is_file() and output.stat().st_size > 0 and not force: return output.parent.mkdir(parents=True, exist_ok=True) temporary = output.with_name(f".{output.stem}.{os.getpid()}.tmp.mp4") temporary.unlink(missing_ok=True) command = [ ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(source), "-t", str(duration), "-map", "0:v:0", "-map", "0:a?", "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "aac", "-movflags", "+faststart", str(temporary), ] try: run_command(command, f"clip {output.name}") if not temporary.is_file() or temporary.stat().st_size == 0: raise RuntimeError(f"ffmpeg produced an empty clip: {temporary}") os.replace(temporary, output) finally: temporary.unlink(missing_ok=True) def frame_marker(directory: Path) -> Path: return ( directory.parent / ".streamdelta_complete" / f"{directory.name}.json" ) def existing_frame_count(directory: Path) -> int: marker = frame_marker(directory) first = directory / "000000.jpg" if not marker.is_file() or not first.is_file(): return 0 try: count = int(read_json(marker)["frames"]) except (KeyError, TypeError, ValueError, json.JSONDecodeError): return 0 if count < 1 or not (directory / f"{count - 1:06d}.jpg").is_file(): return 0 return count def extract_frames( *, ffmpeg: str, source: Path, output: Path, force: bool, ) -> int: count = existing_frame_count(output) if count and not force: return count output.parent.mkdir(parents=True, exist_ok=True) temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp") if temporary.exists(): shutil.rmtree(temporary) temporary.mkdir(parents=True) command = [ ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(source), "-vf", "fps=1", "-start_number", "0", "-q:v", "2", str(temporary / "%06d.jpg"), ] try: run_command(command, f"frames {output.name}") frames = sorted(temporary.glob("*.jpg")) if not frames or frames[0].name != "000000.jpg": raise RuntimeError(f"no valid 1 fps frames generated for {source}") if output.exists(): shutil.rmtree(output) os.replace(temporary, output) write_json_atomic( frame_marker(output), { "frames": len(frames), "source": str(source.resolve()), "source_size": source.stat().st_size, }, pretty=True, ) return len(frames) finally: if temporary.exists(): shutil.rmtree(temporary) def run_parallel( jobs: Iterable[Tuple[str, Callable[[], Any]]], workers: int, ) -> Dict[str, Any]: job_list = list(jobs) results: Dict[str, Any] = {} with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: futures = { pool.submit(function): name for name, function in job_list } completed = 0 for future in concurrent.futures.as_completed(futures): name = futures[future] results[name] = future.result() completed += 1 if completed == 1 or completed % 50 == 0 or completed == len(job_list): print(f"[prepare] completed {completed}/{len(job_list)}: {name}") return results def replace_symlink(link: Path, target: Path) -> None: link.parent.mkdir(parents=True, exist_ok=True) relative = os.path.relpath(target.resolve(), link.parent.resolve()) if link.is_symlink() and os.readlink(link) == relative and link.exists(): return if link.exists() or link.is_symlink(): if link.is_dir() and not link.is_symlink(): raise IsADirectoryError(f"refusing to replace directory: {link}") link.unlink() temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp") temporary.unlink(missing_ok=True) os.symlink(relative, temporary) os.replace(temporary, link) def audit_prepared(data_root: Path, dataset_path: Path) -> Dict[str, Any]: samples = read_json(dataset_path) validate_schema(samples) missing_frames: List[str] = [] missing_audio: List[str] = [] for sample in samples: sample_id = str(sample["id"]) frame_dir = Path(str(sample["video"])) if not frame_dir.is_dir() or not next(frame_dir.glob("*.jpg"), None): missing_frames.append(sample_id) audio = data_root / "chunked_videos_asi_full" / f"{sample_id}.mp4" if not audio.is_file(): missing_audio.append(sample_id) report = { "dataset": str(dataset_path.resolve()), "rows": len(samples), "task_counts": dict(collections.Counter( str(sample["task"]) for sample in samples )), "missing_frame_dirs": missing_frames, "missing_audio_videos": missing_audio, "passed": not missing_frames and not missing_audio, } if not report["passed"]: raise RuntimeError( f"media audit failed: missing_frames={len(missing_frames)}, " f"missing_audio={len(missing_audio)}" ) return report def main() -> None: repository = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser() parser.add_argument( "--data-root", type=Path, default=repository / "data" / "OVO-Bench", ) parser.add_argument("--annotation", type=Path) parser.add_argument( "--video-mode", choices=("prechunked", "source"), default="prechunked", help=( "Use official pre-chunked videos, or rebuild all clips from " "src_videos" ), ) parser.add_argument("--jobs", type=int, default=8) parser.add_argument("--ffmpeg") parser.add_argument("--skip-clips", action="store_true") parser.add_argument("--skip-frames", action="store_true") parser.add_argument("--force", action="store_true") parser.add_argument("--check-only", action="store_true") args = parser.parse_args() data_root = args.data_root.resolve() annotation = ( args.annotation.resolve() if args.annotation else data_root / "ovo_bench_new.json" ) final_json = data_root / "ovobench_streamo_eval_asi_full.json" audit_json = data_root / "streamdelta_media_audit.json" if args.check_only: report = audit_prepared(data_root, final_json) write_json_atomic(audit_json, report, pretty=True) print(json.dumps(report, ensure_ascii=False)) return if args.jobs < 1: parser.error("--jobs must be positive") if not annotation.is_file(): parser.error(f"annotation not found: {annotation}") official = read_json(annotation) regular_frames = data_root / "ovobench_streamo_frames" asi_frames = data_root / "ovobench_streamo_frames_asi_full" regular_clips = data_root / "chunked_videos" audio_clips = data_root / "chunked_videos_asi_full" samples, source_by_eval_id = expand_annotations(official, regular_frames) ffmpeg = resolve_ffmpeg(args.ffmpeg) if not args.skip_clips and args.video_mode == "source": clip_jobs = [] for sample in samples: eval_id = str(sample["id"]) raw = source_by_eval_id[eval_id] src = source_video(data_root, raw) output = regular_clips / f"{eval_id}.mp4" duration = cutoff_seconds(sample, raw) clip_jobs.append(( eval_id, lambda src=src, output=output, duration=duration: create_clip( ffmpeg=ffmpeg, source=src, output=output, duration=duration, force=args.force, ), )) run_parallel(clip_jobs, args.jobs) missing_regular = [ str(sample["id"]) for sample in samples if not (regular_clips / f"{sample['id']}.mp4").is_file() ] if missing_regular: raise FileNotFoundError( f"missing {len(missing_regular)} regular clips under " f"{regular_clips}; first={missing_regular[:10]}. Extract the " "official pre-chunked archive or use --video-mode source." ) if not args.skip_frames: frame_jobs = [] for sample in samples: eval_id = str(sample["id"]) clip = regular_clips / f"{eval_id}.mp4" output = regular_frames / eval_id frame_jobs.append(( eval_id, lambda clip=clip, output=output: extract_frames( ffmpeg=ffmpeg, source=clip, output=output, force=args.force, ), )) run_parallel(frame_jobs, args.jobs) regular_json = data_root / "ovobench_streamo_eval.json" write_json_atomic(regular_json, samples) final_samples = [dict(sample) for sample in samples] asi_jobs = [] for sample in final_samples: eval_id = str(sample["id"]) raw = source_by_eval_id[eval_id] regular_clip = regular_clips / f"{eval_id}.mp4" if sample["task"] == "ASI": src = source_video(data_root, raw) output = asi_frames / eval_id if args.skip_frames: count = existing_frame_count(output) if not count: raise FileNotFoundError( f"missing completed ASI frame directory: {output}" ) sample["_full_frames"] = count else: asi_jobs.append(( eval_id, src, output, sample, )) replace_symlink(audio_clips / f"{eval_id}.mp4", src) else: replace_symlink( audio_clips / f"{eval_id}.mp4", regular_clip, ) if asi_jobs: extracted = run_parallel( [ ( eval_id, lambda src=src, output=output: extract_frames( ffmpeg=ffmpeg, source=src, output=output, force=args.force, ), ) for eval_id, src, output, _sample in asi_jobs ], args.jobs, ) for eval_id, _src, _output, sample in asi_jobs: sample["_full_frames"] = int(extracted[eval_id]) for sample in final_samples: if sample["task"] != "ASI": continue eval_id = str(sample["id"]) count = int(sample["_full_frames"]) sample["video"] = str((asi_frames / eval_id).resolve()) sample["ask_time"] = max(0, count - 1) sample["_asi_full_rebuilt"] = True validate_schema(final_samples) write_json_atomic(final_json, final_samples) report = audit_prepared(data_root, final_json) report["annotation_sha256"] = hashlib.sha256( annotation.read_bytes() ).hexdigest() report["video_mode"] = args.video_mode write_json_atomic(audit_json, report, pretty=True) print( f"[prepare] complete: dataset={final_json}, rows={len(final_samples)}, " f"frames=3035/3035, audio=3035/3035" ) if __name__ == "__main__": main()