stzhao's picture
download
raw
19.2 kB
#!/usr/bin/env python3
"""
BridgeData v2 轨迹 → MP4 视频还原
将 scripted_raw 目录中的每条机器人轨迹(图像序列 + 时间戳)编码为 MP4 视频。
支持基于真实时间戳的 VFR → CFR 转换,多相机任务,并行编码。
Usage:
# 预览模式
python data_processing/traj_to_mp4.py --dry-run
# 编码单个任务(测试用)
python data_processing/traj_to_mp4.py \\
--task-filter "*rigid_objects*" --workers 1 \\
--output-dir /path/to/output
# 全量编码
python data_processing/traj_to_mp4.py \\
--output-dir /path/to/output --workers 8
"""
from __future__ import annotations
import argparse
import fnmatch
import logging
import os
import pickle
import re
import subprocess
import sys
import tempfile
import time
from collections.abc import Iterator
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Optional
import numpy as np
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEFAULT_INPUT_DIR = "/home/sz128/scratch_sz128/datasets/bridgedatav2/raw"
DEFAULT_FFMPEG_BIN = "/home/sz128/ffmpeg-n7.1-latest-linux64-gpl-7.1/bin/ffmpeg"
DEFAULT_CRF = 23
DEFAULT_WORKERS = 8
DEFAULT_FFMPEG_THREADS = 2
IMAGE_DIR_PATTERN = re.compile(r"^images(\d+)$")
DEPTH_DIR_PATTERN = re.compile(r"^depth_images(\d+)$")
logger = logging.getLogger("traj_to_mp4")
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def discover_trajectories(input_dir: Path) -> list[Path]:
"""Find all trajectory directories under the input root.
Recursively locates every ``raw/traj_group0/traj{N}/`` directory,
regardless of how many intermediate directory levels exist above it.
Uses ``os.walk`` with pruning to skip image directories for speed.
Works with both layouts::
# Flat (scripted_raw)
{input_dir}/{task}/{episode}/raw/traj_group0/traj{N}/
# Deep (raw)
{input_dir}/{dataset}/{collection}/.../{episode}/raw/traj_group0/traj{N}/
Returns a list of absolute paths to each ``traj{N}`` directory.
"""
traj_dirs: list[Path] = []
for root, dirs, _files in os.walk(input_dir):
# Prune image directories — they are leaf dirs that never contain
# traj_group0, and skipping them avoids scanning millions of files.
dirs[:] = [
d
for d in dirs
if not (d.startswith("images") or d.startswith("depth_images"))
]
if os.path.basename(root) == "traj_group0" and os.path.basename(
os.path.dirname(root)
) == "raw":
for traj_name in sorted(dirs):
if traj_name.startswith("traj"):
traj_dirs.append((Path(root) / traj_name).resolve())
return traj_dirs
def filter_trajectories(
traj_dirs: list[Path],
task_filter: Optional[str] = None,
input_dir: Optional[Path] = None,
) -> list[Path]:
"""Filter trajectory list by optional glob against the relative path.
The filter is matched against the trajectory's path relative to
``input_dir``, so any path segment can be targeted (e.g.
``*toykitchen1*``, ``*bridge_data_v2*``, ``*sweep_12-03*``).
If *task_filter* contains no glob characters (``*?[]``), it is
automatically wrapped with ``*...*`` for substring matching.
"""
if not task_filter:
return traj_dirs
has_glob = any(c in task_filter for c in "*?[]")
filtered: list[Path] = []
for p in traj_dirs:
if input_dir is not None:
try:
rel = str(p.relative_to(input_dir))
except ValueError:
rel = str(p)
else:
rel = str(p)
pattern = task_filter if has_glob else f"*{task_filter}*"
if fnmatch.fnmatch(rel, pattern):
filtered.append(p)
return filtered
# ---------------------------------------------------------------------------
# Camera / image helpers
# ---------------------------------------------------------------------------
def list_camera_dirs(traj_dir: Path, include_depth: bool = False) -> list[str]:
"""Return sorted list of camera directory names (e.g. ``images0``)."""
cameras: list[str] = []
for entry in sorted(traj_dir.iterdir()):
if not entry.is_dir():
continue
if IMAGE_DIR_PATTERN.match(entry.name):
cameras.append(entry.name)
elif include_depth and DEPTH_DIR_PATTERN.match(entry.name):
cameras.append(entry.name)
return cameras
def collect_image_paths(traj_dir: Path, camera_dir: str) -> list[Path]:
"""Return sorted list of absolute image paths from a camera directory.
Images are sorted by the numeric index extracted from ``im_{N}.{ext}``.
"""
cam_path = traj_dir / camera_dir
if not cam_path.is_dir():
return []
images: list[tuple[int, Path]] = []
for f in cam_path.iterdir():
if not f.is_file():
continue
m = re.match(r"im_(\d+)\.", f.name)
if m:
images.append((int(m.group(1)), f.resolve()))
images.sort(key=lambda x: x[0])
return [p for _, p in images]
# ---------------------------------------------------------------------------
# Timestamp extraction
# ---------------------------------------------------------------------------
def extract_timestamps(traj_dir: Path) -> np.ndarray:
"""Load ``obs_dict.pkl`` and return the ``time_stamp`` array (seconds)."""
pkl_path = traj_dir / "obs_dict.pkl"
if not pkl_path.is_file():
raise FileNotFoundError(f"obs_dict.pkl not found in {traj_dir}")
with open(pkl_path, "rb") as f:
obs = pickle.load(f)
ts = np.asarray(obs["time_stamp"], dtype=np.float64)
return ts
# ---------------------------------------------------------------------------
# FFmpeg concat file
# ---------------------------------------------------------------------------
def write_concat_file(
image_paths: list[Path],
timestamps: np.ndarray,
concat_path: Path,
) -> float:
"""Write an FFmpeg concat demuxer file with per-frame durations.
Returns the mean frame interval (seconds), which can be used as a
fallback FPS suggestion.
"""
n_images = len(image_paths)
n_ts = len(timestamps)
if n_images != n_ts:
logger.warning(
"Image count (%d) != timestamp count (%d), using min", n_images, n_ts
)
n_frames = min(n_images, n_ts)
else:
n_frames = n_images
# Compute frame durations from timestamps
if n_frames >= 2:
durations = np.diff(timestamps[:n_frames])
# Replace negative or zero durations with a small default
durations = np.clip(durations, 0.001, None)
mean_interval = float(np.mean(durations))
else:
durations = np.array([])
mean_interval = 0.2 # fallback
lines = ["ffconcat version 1.0\n"]
for i in range(n_frames):
lines.append(f"file '{image_paths[i]}'\n")
if i < n_frames - 1:
lines.append(f"duration {durations[i]:.6f}\n")
concat_path.write_text("".join(lines), encoding="utf-8")
return mean_interval
# ---------------------------------------------------------------------------
# FFmpeg encoding
# ---------------------------------------------------------------------------
def build_ffmpeg_cmd(
concat_file: Path,
output_path: Path,
*,
target_fps: Optional[float],
crf: int,
ffmpeg_bin: str,
ffmpeg_threads: int,
) -> list[str]:
"""Build the FFmpeg command line for concat → MP4 encoding."""
cmd = [
ffmpeg_bin,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_file),
"-c:v",
"libx264",
"-crf",
str(crf),
"-preset",
"fast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-threads",
str(ffmpeg_threads),
]
if target_fps is not None and target_fps > 0:
cmd.extend(["-vf", f"fps={target_fps}"])
cmd.append(str(output_path))
return cmd
def encode_traj(
traj_dir: Path,
output_dir: Path,
*,
target_fps: Optional[float],
crf: int,
ffmpeg_bin: str,
ffmpeg_threads: int,
include_depth: bool = False,
skip_existing: bool = False,
) -> tuple[str, str]:
"""Encode one trajectory to MP4.
Returns ``(status, message)`` where status is ``"ok"``, ``"skipped"``,
or ``"error"``.
"""
traj_name = traj_dir.name
# Flat output: all videos go directly under output_dir
# Filename: {task}_{episode}_{traj_name}[_{camera}].mp4
episode_dir = traj_dir.parent.parent.parent # traj_group0/raw/episode
task_dir = episode_dir.parent
prefix = f"{task_dir.name}_{episode_dir.name}_{traj_name}"
out_parent = output_dir
out_parent.mkdir(parents=True, exist_ok=True)
# Load timestamps once
try:
timestamps = extract_timestamps(traj_dir)
except Exception as e:
return ("error", f"{traj_dir}: failed to read timestamps: {e}")
cameras = list_camera_dirs(traj_dir, include_depth=include_depth)
if not cameras:
return ("error", f"{traj_dir}: no image directories found")
for cam in cameras:
# Determine output filename
if len(cameras) == 1 and cam == "images0":
out_name = f"{prefix}.mp4"
else:
out_name = f"{prefix}_{cam}.mp4"
out_path = out_parent / out_name
if skip_existing and out_path.is_file():
continue
# Collect images
image_paths = collect_image_paths(traj_dir, cam)
if not image_paths:
return ("error", f"{traj_dir}/{cam}: no images found")
# Write concat file
concat_fd, concat_path = tempfile.mkstemp(
suffix=".txt", prefix="ffconcat_"
)
try:
mean_interval = write_concat_file(image_paths, timestamps, Path(concat_path))
# Determine FPS
fps = target_fps
if fps is None:
# Auto: use rounded mean interval to get ~4-5 FPS
fps = round(1.0 / mean_interval) if mean_interval > 0 else 4
fps = max(1, min(fps, 60))
# Build and run FFmpeg
cmd = build_ffmpeg_cmd(
Path(concat_path),
out_path,
target_fps=fps,
crf=crf,
ffmpeg_bin=ffmpeg_bin,
ffmpeg_threads=ffmpeg_threads,
)
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=300,
check=False,
)
if result.returncode != 0:
stderr_tail = result.stderr.strip().split("\n")[-5:]
return (
"error",
f"{traj_dir}/{cam}: ffmpeg failed:\n" + "\n".join(stderr_tail),
)
if not out_path.is_file():
return ("error", f"{traj_dir}/{cam}: output file missing after ffmpeg")
finally:
os.close(concat_fd)
try:
os.unlink(concat_path)
except OSError:
pass
return ("ok", f"{traj_dir}: {len(cameras)} camera(s) encoded")
# ---------------------------------------------------------------------------
# Worker entry point (module-level for ProcessPoolExecutor)
# ---------------------------------------------------------------------------
def _encode_worker(args: tuple) -> tuple[str, str]:
"""Worker function (must be picklable)."""
(
traj_dir,
output_dir,
target_fps,
crf,
ffmpeg_bin,
ffmpeg_threads,
include_depth,
skip_existing,
) = args
return encode_traj(
Path(traj_dir),
Path(output_dir),
target_fps=target_fps,
crf=crf,
ffmpeg_bin=ffmpeg_bin,
ffmpeg_threads=ffmpeg_threads,
include_depth=include_depth,
skip_existing=skip_existing,
)
# ---------------------------------------------------------------------------
# Main orchestrator
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="BridgeData v2 轨迹 → MP4 视频还原",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--input-dir",
default=DEFAULT_INPUT_DIR,
help=f"数据根目录 (default: {DEFAULT_INPUT_DIR})",
)
parser.add_argument(
"--output-dir",
default="/home/sz128/scratch_sz128/datasets/bridgedatav2_video/data/raw",
)
parser.add_argument(
"--fps",
default="auto",
help="目标 FPS: 'auto' (自动从时间戳计算), 'vfr' (保留可变帧率), 或具体数值如 5",
)
parser.add_argument(
"--crf",
type=int,
default=DEFAULT_CRF,
help=f"H.264 CRF 值, 越小质量越高 (default: {DEFAULT_CRF})",
)
parser.add_argument(
"--workers",
type=int,
default=DEFAULT_WORKERS,
help=f"并行 worker 数 (default: {DEFAULT_WORKERS})",
)
parser.add_argument(
"--ffmpeg-bin",
default=DEFAULT_FFMPEG_BIN,
help=f"FFmpeg 可执行文件路径 (default: {DEFAULT_FFMPEG_BIN})",
)
parser.add_argument(
"--ffmpeg-threads",
type=int,
default=DEFAULT_FFMPEG_THREADS,
help=f"每个 FFmpeg 进程的线程数 (default: {DEFAULT_FFMPEG_THREADS})",
)
parser.add_argument(
"--skip-existing",
action="store_true",
help="跳过已存在的输出文件",
)
parser.add_argument(
"--include-depth",
action="store_true",
help="同时编码深度图(depth_images*)为视频",
)
parser.add_argument(
"--task-filter",
default=None,
help="只处理匹配的任务目录名(glob pattern),如 '*rigid_objects*'",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="只统计不编码",
)
args = parser.parse_args()
# Parse FPS
if args.fps == "auto":
target_fps: Optional[float] = None
elif args.fps.lower() == "vfr":
target_fps = None # Will be handled as "no -vf fps="
# Actually VFR means don't convert: we'll use a sentinel
target_fps = -1.0 # negative = skip fps filter
else:
try:
target_fps = float(args.fps)
if target_fps <= 0:
raise ValueError
except ValueError:
parser.error(f"Invalid --fps value: {args.fps}")
# Resolve paths
input_dir = Path(args.input_dir).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
if not input_dir.is_dir():
print(f"ERROR: input directory not found: {input_dir}", file=sys.stderr)
sys.exit(1)
# Verify ffmpeg
ffmpeg_bin = args.ffmpeg_bin
try:
subprocess.run(
[ffmpeg_bin, "-version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
print(f"ERROR: ffmpeg not found at {ffmpeg_bin}", file=sys.stderr)
sys.exit(1)
# Discovery
print("Discovering trajectories...")
all_trajs = discover_trajectories(input_dir)
trajs = filter_trajectories(all_trajs, args.task_filter, input_dir=input_dir)
print(f" Found {len(all_trajs)} total, {len(trajs)} after filtering")
if not trajs:
print("No trajectories to process.")
sys.exit(0)
# Count cameras and images for estimation
total_cameras = 0
total_frames = 0
for t in trajs[:10]: # sample first 10 for quick estimate
cams = list_camera_dirs(t, include_depth=args.include_depth)
if cams:
imgs = collect_image_paths(t, cams[0])
total_cameras += len(cams)
total_frames += len(imgs) * len(cams)
avg_cameras = max(1, total_cameras / min(10, len(trajs)))
avg_frames_per_cam = total_frames / max(1, total_cameras)
est_videos = int(len(trajs) * avg_cameras)
print(f" Estimated videos: ~{est_videos}")
print(f" Estimated frames/video: ~{avg_frames_per_cam:.0f}")
print(f" Output dir: {output_dir}")
if args.dry_run:
print("\n[Dry run] Encoding would start now. Remove --dry-run to execute.")
sys.exit(0)
# Prepare worker arguments
worker_args = [
(
str(t),
str(output_dir),
target_fps,
args.crf,
ffmpeg_bin,
args.ffmpeg_threads,
args.include_depth,
args.skip_existing,
)
for t in trajs
]
# Progress callback
success_count = 0
skip_count = 0
fail_count = 0
failed_items: list[str] = []
started_at = time.perf_counter()
print(f"\nEncoding with {args.workers} workers...")
with ProcessPoolExecutor(max_workers=args.workers) as pool:
futures = {
pool.submit(_encode_worker, wa): wa[0] for wa in worker_args
}
for i, future in enumerate(as_completed(futures), 1):
traj_path = futures[future]
try:
status, message = future.result()
except Exception as e:
status, message = "error", f"{traj_path}: worker exception: {e}"
if status == "ok":
success_count += 1
elif status == "skipped":
skip_count += 1
else:
fail_count += 1
failed_items.append(message)
# Progress
pct = i / len(futures) * 100
elapsed = time.perf_counter() - started_at
rate = i / max(1, elapsed)
eta = (len(futures) - i) / max(0.001, rate)
print(
f"\r[{i}/{len(futures)} {pct:.0f}%] "
f"ok={success_count} skip={skip_count} fail={fail_count} "
f"| {rate:.1f}/s ETA {eta:.0f}s ",
end="",
flush=True,
)
elapsed = time.perf_counter() - started_at
print(f"\n\nDone in {elapsed:.0f}s.")
# Write failure log
if failed_items:
fail_log = output_dir / "failed_trajs.log"
fail_log.write_text("\n".join(failed_items), encoding="utf-8")
print(f"Failures written to {fail_log}")
print(
f"Summary: {success_count} ok, {skip_count} skipped, {fail_count} failed "
f"(total {len(trajs)})"
)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
19.2 kB
·
Xet hash:
2fb69fc3d72b303bc90c0266f3a5719488d87e5b377c2f563e5e2ab0cc13778a

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.