#!/usr/bin/env python3 from __future__ import annotations import argparse from collections import defaultdict import json import math import os from pathlib import Path import sys import cv2 import numpy as np import pandas as pd from tqdm import tqdm from various_speed.core import compute_replay_metrics LIBERO_DUMMY_ACTION = [0.0] * 6 + [-1.0] LIBERO_ENV_RESOLUTION = 256 LIBERO_VIDEO_FPS = 10 def _episode_paths(dataset_root: Path, limit: int | None) -> list[Path]: paths = sorted((dataset_root / "data").glob("chunk-*/episode_*.parquet")) if limit is not None: paths = paths[:limit] if not paths: raise FileNotFoundError(f"No parquet episodes found under {dataset_root / 'data'}") return paths def _stack_column(df: pd.DataFrame, name: str) -> np.ndarray: return np.stack(df[name].to_numpy()).astype(np.float32) def _first_existing_column(df: pd.DataFrame, candidates: list[str]) -> str: for name in candidates: if name in df.columns: return name raise KeyError(f"Expected one of {candidates}, got columns {list(df.columns)}") def _source_episode_path(src_root: Path, episode_index: int, chunks_size: int) -> Path: return src_root / "data" / f"chunk-{episode_index // chunks_size:03d}" / f"episode_{episode_index:06d}.parquet" def _load_info(root: Path) -> dict: with (root / "meta" / "info.json").open() as f: return json.load(f) def _mean(rows: list[dict], key: str) -> float: vals = [float(r[key]) for r in rows if key in r and r[key] is not None] return float(np.mean(vals)) if vals else float("nan") def _summarize(rows: list[dict]) -> dict: by_speed: dict[str, list[dict]] = defaultdict(list) for row in rows: by_speed[str(row.get("speed_label", row.get("speed", "unknown")))].append(row) summary = {"overall": {}, "by_speed": {}} keys = [ "target_speed", "source_steps", "replay_steps", "actual_speed", "speed_error", "integrated_translation_l2_error", "integrated_rotation_l2_error", "translation_path_replay", "rotation_path_replay", "translation_path_ratio", "rotation_path_ratio", "gripper_switch_delta", "padded_ratio", ] for key in keys: summary["overall"][key] = _mean(rows, key) if "success" in rows[0]: summary["overall"]["success_rate"] = _mean(rows, "success") summary["overall"]["sim_steps"] = _mean(rows, "sim_steps") summary["overall"]["hit_max_steps_rate"] = _mean(rows, "hit_max_steps") if "source_success" in rows[0]: summary["overall"]["source_success_rate"] = _mean(rows, "source_success") summary["overall"]["source_sim_steps"] = _mean(rows, "source_sim_steps") for speed_label, items in sorted(by_speed.items()): summary["by_speed"][speed_label] = {key: _mean(items, key) for key in keys} if "success" in items[0]: summary["by_speed"][speed_label]["success_rate"] = _mean(items, "success") summary["by_speed"][speed_label]["sim_steps"] = _mean(items, "sim_steps") summary["by_speed"][speed_label]["hit_max_steps_rate"] = _mean(items, "hit_max_steps") if "source_success" in items[0]: summary["by_speed"][speed_label]["source_success_rate"] = _mean(items, "source_success") summary["by_speed"][speed_label]["source_sim_steps"] = _mean(items, "source_sim_steps") summary["by_speed"][speed_label]["episodes"] = len(items) return summary def offline_replay(args: argparse.Namespace) -> list[dict]: dataset_root = Path(args.dataset).resolve() src_root = Path(args.source_dataset).resolve() if args.source_dataset else None info = _load_info(dataset_root) chunks_size = int(info.get("chunks_size", 1000)) src_chunks_size = int(_load_info(src_root).get("chunks_size", 1000)) if src_root else chunks_size rows: list[dict] = [] for path in tqdm(_episode_paths(dataset_root, args.max_episodes), desc="offline replay"): episode_df = pd.read_parquet(path) action_col = _first_existing_column(episode_df, ["action", "actions"]) replay_actions = _stack_column(episode_df, action_col) speed = float(episode_df["speed"].iloc[0]) if "speed" in episode_df else None speed_label = str(episode_df["speed_label"].iloc[0]) if "speed_label" in episode_df else str(speed) if src_root is not None and "source_episode_index" in episode_df: src_episode_index = int(episode_df["source_episode_index"].iloc[0]) src_path = _source_episode_path(src_root, src_episode_index, src_chunks_size) src_df = pd.read_parquet(src_path) source_action_col = _first_existing_column(src_df, ["action", "actions"]) source_actions = _stack_column(src_df, source_action_col) metrics = compute_replay_metrics(source_actions, replay_actions, speed) else: metrics = compute_replay_metrics(replay_actions, replay_actions, speed) metrics.update( { "episode_index": int(episode_df["episode_index"].iloc[0]), "source_episode_index": int(episode_df["source_episode_index"].iloc[0]) if "source_episode_index" in episode_df else None, "task_index": int(episode_df["task_index"].iloc[0]), "speed": speed, "speed_label": speed_label, "padded_frames": int(episode_df["is_padded"].sum()) if "is_padded" in episode_df else 0, "padded_ratio": float(episode_df["is_padded"].mean()) if "is_padded" in episode_df else 0.0, } ) rows.append(metrics) return rows def _quat2axisangle(quat: np.ndarray) -> np.ndarray: quat = np.asarray(quat, dtype=np.float32).copy() quat[3] = np.clip(quat[3], -1.0, 1.0) den = math.sqrt(max(1.0 - float(quat[3] * quat[3]), 0.0)) if math.isclose(den, 0.0): return np.zeros(3, dtype=np.float32) return (quat[:3] * 2.0 * math.acos(float(quat[3]))) / den def _get_libero_env(task, resolution: int, seed: int): from libero.libero import get_libero_path from libero.libero.envs import OffScreenRenderEnv task_description = task.language task_bddl_file = Path(get_libero_path("bddl_files")) / task.problem_folder / task.bddl_file env = OffScreenRenderEnv( bddl_file_name=task_bddl_file, camera_heights=resolution, camera_widths=resolution, ) env.seed(seed) return env, task_description def _write_mp4(frames: list[np.ndarray], out_path: Path, fps: int) -> None: if not frames: return out_path.parent.mkdir(parents=True, exist_ok=True) first_frame = np.asarray(frames[0]) if first_frame.ndim == 2: height, width = first_frame.shape else: height, width = first_frame.shape[:2] writer = cv2.VideoWriter( str(out_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height), ) if not writer.isOpened(): raise RuntimeError(f"Failed to open video writer for {out_path}") try: for frame in frames: frame_array = np.asarray(frame) if frame_array.ndim == 2: frame_bgr = cv2.cvtColor(frame_array.astype(np.uint8), cv2.COLOR_GRAY2BGR) else: frame_rgb = frame_array[..., :3].astype(np.uint8) frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR) writer.write(frame_bgr) finally: writer.release() def _rollout_actions( task, initial_state: np.ndarray, actions: np.ndarray, args: argparse.Namespace, video_path: Path | None = None, ) -> dict: env = None done = False reward = 0.0 sim_steps = 0 exception = None frames: list[np.ndarray] = [] try: env, _task_description = _get_libero_env(task, LIBERO_ENV_RESOLUTION, args.seed) env.reset() _obs = env.set_init_state(initial_state) frames.append(np.asarray(env.render())) for _ in range(args.num_steps_wait): _obs, reward, done, _info = env.step(LIBERO_DUMMY_ACTION) frames.append(np.asarray(env.render())) max_actions = actions[: args.max_controller_steps] for action in max_actions: _obs, reward, done, _info = env.step(action.tolist()) frames.append(np.asarray(env.render())) sim_steps += 1 if done: break except Exception as exc: exception = repr(exc) finally: if video_path is not None and frames: _write_mp4(frames, video_path, LIBERO_VIDEO_FPS) if env is not None: env.close() return { "success": float(bool(done)), "sim_steps": sim_steps, "hit_max_steps": float( not done and exception is None and sim_steps >= min(len(actions), args.max_controller_steps) ), "final_reward": float(reward), "exception": exception, } def sim_replay(args: argparse.Namespace, rows: list[dict]) -> list[dict]: os.environ.setdefault("NUMBA_DISABLE_JIT", "1") os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib") if args.libero_package: sys.path.insert(0, str(Path(args.libero_package).resolve())) from libero.libero import benchmark dataset_root = Path(args.dataset).resolve() src_root = Path(args.source_dataset).resolve() if args.source_dataset else None if args.compare_source and src_root is None: raise ValueError("--compare-source requires --source-dataset") src_chunks_size = int(_load_info(src_root).get("chunks_size", 1000)) if src_root else 1000 benchmark_dict = benchmark.get_benchmark_dict() task_suite = benchmark_dict[args.task_suite_name]() paths = _episode_paths(dataset_root, args.max_sim_episodes or args.max_episodes) video_dir = Path(args.out).resolve() / "sim_videos" row_by_episode = {int(r["episode_index"]): r for r in rows} for path in tqdm(paths, desc="sim replay"): episode_df = pd.read_parquet(path) episode_index = int(episode_df["episode_index"].iloc[0]) task_index = int(episode_df["task_index"].iloc[0]) task = task_suite.get_task(task_index) initial_states = task_suite.get_task_init_states(task_index) source_episode_index = ( int(episode_df["source_episode_index"].iloc[0]) if "source_episode_index" in episode_df else 0 ) init_idx = source_episode_index % len(initial_states) initial_state = initial_states[init_idx] row = row_by_episode[episode_index] if args.compare_source and src_root is not None: src_path = _source_episode_path(src_root, source_episode_index, src_chunks_size) src_df = pd.read_parquet(src_path) source_action_col = _first_existing_column(src_df, ["action", "actions"]) source_actions = _stack_column(src_df, source_action_col) source_video_path = video_dir / f"episode_{episode_index:06d}_source.mp4" source_result = _rollout_actions(task, initial_state, source_actions, args, source_video_path) for key, value in source_result.items(): row[f"source_{key}"] = value row["source_video_path"] = str(source_video_path) action_col = _first_existing_column(episode_df, ["action", "actions"]) actions = _stack_column(episode_df, action_col) video_path = video_dir / f"episode_{episode_index:06d}.mp4" result = _rollout_actions(task, initial_state, actions, args, video_path) row.update(result) row["video_path"] = str(video_path) row["init_state_index"] = int(init_idx) return rows def write_outputs(args: argparse.Namespace, rows: list[dict]) -> None: out_dir = Path(args.out).resolve() out_dir.mkdir(parents=True, exist_ok=True) with (out_dir / "replay_metrics.jsonl").open("w") as f: for row in rows: f.write(json.dumps(row) + "\n") summary = _summarize(rows) with (out_dir / "replay_summary.json").open("w") as f: json.dump(summary, f, indent=4) f.write("\n") print(json.dumps(summary, indent=2)) print(f"Wrote replay metrics to {out_dir}") video_dir = out_dir / "sim_videos" if video_dir.exists(): print(f"Wrote sim videos to {video_dir}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Replay/check variable-speed LIBERO datasets.") parser.add_argument("--dataset", required=True, help="Processed speed dataset root") parser.add_argument("--source-dataset", default=None, help="Original source dataset root") parser.add_argument("--out", required=True, help="Output directory for replay metrics") parser.add_argument("--max-episodes", type=int, default=None) parser.add_argument("--sim", action="store_true", help="Also replay actions in LIBERO sim") parser.add_argument( "--compare-source", action="store_true", help="Replay source actions before each processed episode" ) parser.add_argument("--max-sim-episodes", type=int, default=None) parser.add_argument("--task-suite-name", default="libero_spatial") parser.add_argument( "--libero-package", default=None, help="Optional path containing the libero Python package", ) parser.add_argument("--seed", type=int, default=7) parser.add_argument("--num-steps-wait", type=int, default=10) parser.add_argument("--max-controller-steps", type=int, default=1000) return parser.parse_args() if __name__ == "__main__": parsed = parse_args() replay_rows = offline_replay(parsed) if parsed.sim: replay_rows = sim_replay(parsed, replay_rows) write_outputs(parsed, replay_rows)