Files
Xin WangandCursor bde72487bb Initial commit: Omni-only turn-taking experiment scaffold.
Add EasyCom prep, Qwen2.5-Omni feature extraction, streaming turn model prototype, and docs for the ICASSP 2027 ego-speak study.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 00:02:58 +08:00

322 lines
11 KiB
Python

#!/usr/bin/env python3
"""Build and validate an EgoSpeak-compatible EasyCom raw-media manifest.
EasyCom stores each session as approximately one-minute MP4 segments named by
their session-relative start time (``mm-ss-SSS.mp4``). This script preserves
that global time origin and optionally checks EgoSpeak ``target_perframe``
arrays at 5 Hz.
Example:
python easycom_manifest.py \
--dataset-root /data1/yfzx/wangx/Datasets/easycom/extracted_easycom \
--target-root /path/to/EasyCom/target_perframe \
--output easycom_manifest.json
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
from fractions import Fraction
from pathlib import Path
from typing import Any
TASK_FPS = 5
TRAIN_SESSIONS = (
"Session_7",
"Session_11",
"Session_10",
"Session_6",
"Session_9",
"Session_4",
"Session_8",
"Session_5",
"Session_12",
)
TEST_SESSIONS = ("Session_3", "Session_2", "Session_1")
ALL_SESSIONS = TRAIN_SESSIONS + TEST_SESSIONS
TIMECODE_RE = re.compile(r"^(\d{2})-(\d{2})-(\d{3})$")
def parse_timecode(stem: str) -> float:
match = TIMECODE_RE.fullmatch(stem)
if match is None:
raise ValueError(f"Expected mm-ss-SSS filename, got {stem!r}")
minutes, seconds, milliseconds = (int(value) for value in match.groups())
if seconds >= 60:
raise ValueError(f"Invalid seconds in timecode {stem!r}")
return minutes * 60.0 + seconds + milliseconds / 1000.0
def probe_video(path: Path) -> dict[str, Any]:
completed = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,avg_frame_rate,nb_frames,duration:format=duration",
"-of",
"json",
str(path),
],
check=True,
capture_output=True,
text=True,
)
payload = json.loads(completed.stdout)
streams = payload.get("streams", [])
if not streams:
raise RuntimeError(f"No video stream found in {path}")
stream = streams[0]
rate_text = stream.get("avg_frame_rate", "0/0")
if rate_text in ("0/0", "N/A"):
raise RuntimeError(f"No usable video frame rate in {path}")
fps = float(Fraction(rate_text))
frame_text = stream.get("nb_frames")
if frame_text not in (None, "N/A"):
num_frames = int(frame_text)
else:
duration_text = stream.get("duration") or payload.get("format", {}).get("duration")
if duration_text is None:
raise RuntimeError(f"No frame count or duration in {path}")
num_frames = round(float(duration_text) * fps)
# EgoSpeak processes EasyCom at 20 video frames/s and groups four frames
# per 5 Hz task step. The released MP4s are nominally already 20 FPS.
duration = num_frames / fps
frames_at_20hz = round(duration * 20.0)
width = int(stream["width"])
height = int(stream["height"])
return {
"width": width,
"height": height,
"fps": fps,
"num_frames": num_frames,
"video_duration_seconds": duration,
"frames_at_20hz": frames_at_20hz,
# EasyCom videos may contain an ID banner to the left of the 1920px
# video. EgoSpeak crops it and retains the rightmost video region.
"egospeak_crop": {
"x": max(0, width - 1920),
"y": 0,
"width": min(1920, width),
"height": height,
},
}
def find_target(target_root: Path, session: str) -> Path | None:
direct = target_root / f"{session}.npy"
if direct.is_file():
return direct
matches = sorted(target_root.rglob(f"{session}.npy"))
if len(matches) > 1:
raise RuntimeError(
f"Multiple targets found for {session}: "
+ ", ".join(str(path) for path in matches)
)
return matches[0] if matches else None
def target_metadata(path: Path) -> dict[str, Any]:
try:
import numpy as np
except ImportError as exc:
raise RuntimeError("Install numpy to inspect target_perframe arrays") from exc
target = np.load(path, mmap_mode="r")
if target.ndim not in (1, 2):
raise ValueError(f"Unexpected target shape in {path}: {target.shape}")
if target.ndim == 2 and target.shape[1] != 3:
raise ValueError(f"Expected three classes in {path}, got {target.shape}")
return {
"path": str(path.resolve()),
"shape": list(target.shape),
"num_steps": int(target.shape[0]),
"duration_seconds_at_5hz": float(target.shape[0] / TASK_FPS),
}
def build_manifest(
dataset_root: Path,
*,
target_root: Path | None,
probe_media: bool,
) -> dict[str, Any]:
dataset_root = dataset_root.resolve()
video_root = dataset_root / "Main" / "Video_Compressed"
if not video_root.is_dir():
raise FileNotFoundError(f"EasyCom video directory not found: {video_root}")
sessions: list[dict[str, Any]] = []
all_issues: list[str] = []
all_discontinuities: list[str] = []
for session in ALL_SESSIONS:
session_dir = video_root / session
if not session_dir.is_dir():
all_issues.append(f"missing session directory: {session_dir}")
continue
clip_paths = sorted(
session_dir.glob("*.mp4"), key=lambda path: parse_timecode(path.stem)
)
if not clip_paths:
all_issues.append(f"no MP4 clips in {session_dir}")
continue
clips: list[dict[str, Any]] = []
session_issues: list[str] = []
session_discontinuities: list[str] = []
previous_source_end: float | None = None
concatenated_20hz_frames = 0
for path in clip_paths:
source_start = parse_timecode(path.stem)
media = probe_video(path) if probe_media else None
duration = media["video_duration_seconds"] if media is not None else None
source_end = source_start + duration if duration is not None else None
if previous_source_end is not None:
delta = source_start - previous_source_end
if abs(delta) > 0.05:
kind = "gap" if delta > 0 else "overlap"
session_discontinuities.append(
f"{kind} of {abs(delta):.3f}s before {path.name}"
)
if source_end is not None:
previous_source_end = source_end
record: dict[str, Any] = {
"path": str(path.resolve()),
"timecode": path.stem,
"source_session_start_seconds": source_start,
}
if media is not None and source_end is not None:
concat_start_frame = concatenated_20hz_frames
concatenated_20hz_frames += int(media["frames_at_20hz"])
record.update(
{
**media,
"source_session_end_seconds": source_end,
"egospeak_concat_start_seconds": concat_start_frame / 20.0,
"egospeak_concat_end_seconds": concatenated_20hz_frames / 20.0,
"target_start_index": concat_start_frame // 4,
"target_end_index_exclusive": concatenated_20hz_frames // 4,
}
)
clips.append(record)
source_session_end = (
max(float(clip["source_session_end_seconds"]) for clip in clips)
if probe_media
else None
)
split = "train" if session in TRAIN_SESSIONS else "test"
session_record: dict[str, Any] = {
"session": session,
"split": split,
"clips": clips,
"issues": session_issues,
"source_timeline_discontinuities": session_discontinuities,
}
if source_session_end is not None:
session_record["source_session_end_seconds"] = source_session_end
session_record["egospeak_concat_duration_seconds"] = (
concatenated_20hz_frames / 20.0
)
session_record["expected_5hz_steps"] = concatenated_20hz_frames // 4
if target_root is not None:
target_path = find_target(target_root.resolve(), session)
if target_path is None:
session_issues.append(f"target_perframe missing for {session}")
else:
target = target_metadata(target_path)
session_record["target"] = target
if source_session_end is not None:
difference = target["num_steps"] - (concatenated_20hz_frames // 4)
session_record["target_minus_media_steps"] = difference
# Small encoder/container rounding differences are normal;
# anything beyond one task step needs manual inspection.
if abs(difference) > 1:
session_issues.append(
f"target/media length mismatch: {difference:+d} steps"
)
all_issues.extend(f"{session}: {issue}" for issue in session_issues)
all_discontinuities.extend(
f"{session}: {item}" for item in session_discontinuities
)
sessions.append(session_record)
return {
"dataset": "EasyCom",
"dataset_root": str(dataset_root),
"video_root": str(video_root),
"task_fps": TASK_FPS,
"prediction_step_seconds": 1.0 / TASK_FPS,
"class_names": [
"background",
"camera wearer speaking",
"other people speaking",
],
"future_target_offsets": list(range(1, 11)),
"train_sessions": list(TRAIN_SESSIONS),
"test_sessions": list(TEST_SESSIONS),
"sessions": sessions,
"source_timeline_discontinuities": all_discontinuities,
"issues": all_issues,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dataset-root", type=Path, required=True)
parser.add_argument("--target-root", type=Path)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--no-probe",
action="store_true",
help="Skip ffprobe duration and continuity checks.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
manifest = build_manifest(
args.dataset_root,
target_root=args.target_root,
probe_media=not args.no_probe,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(manifest, indent=2) + "\n")
num_clips = sum(len(session["clips"]) for session in manifest["sessions"])
print(
f"Saved {len(manifest['sessions'])} sessions / {num_clips} clips "
f"to {args.output}"
)
if manifest["issues"]:
print(f"Found {len(manifest['issues'])} issue(s):")
for issue in manifest["issues"]:
print(f" - {issue}")
else:
print("No missing-session or target-length issues found.")
discontinuities = manifest["source_timeline_discontinuities"]
if discontinuities:
print(
f"Observed {len(discontinuities)} source-timeline discontinuity(ies); "
"EgoSpeak removes these when concatenating retained clips."
)
if __name__ == "__main__":
main()