Untitled

Anonymous
plain_text
03/25/2026 2:40 PM
13.8 KB
12
Indexable
#!/usr/bin/env python3
"""
Convert EgoDex test subset to a LeRobot dataset using only:
  - egocentric video
  - 48D hand actions

48D action layout per frame:
  left hand:  wrist pos (3) + wrist rot6d (6) + 5 fingertips xyz (15) = 24
  right hand: wrist pos (3) + wrist rot6d (6) + 5 fingertips xyz (15) = 24
  total = 48

Expected EgoDex layout:
  <egodex_root>/test/<task_name>/<episode_id>.mp4
  <egodex_root>/test/<task_name>/<episode_id>.hdf5

Example:
  python convert_egodex_test_to_lerobot.py \
      --egodex-root /data/egodex \
      --output-root /data/egodex_lerobot \
      --repo-id local/egodex_test_48d \
      --fps 30
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Iterator

import cv2
import h5py
import numpy as np


FINGERTIP_JOINTS = {
    "left": [
        "leftThumbTip",
        "leftIndexFingerTip",
        "leftMiddleFingerTip",
        "leftRingFingerTip",
        "leftLittleFingerTip",
    ],
    "right": [
        "rightThumbTip",
        "rightIndexFingerTip",
        "rightMiddleFingerTip",
        "rightRingFingerTip",
        "rightLittleFingerTip",
    ],
}


def rotation_matrix_to_6d(rot: np.ndarray) -> np.ndarray:
    """
    Convert a 3x3 rotation matrix to the 6D representation used in the H_RDT example:
    first two columns concatenated.
    """
    rot = np.asarray(rot, dtype=np.float32)
    return np.concatenate([rot[:, 0], rot[:, 1]], axis=0).astype(np.float32)


def construct_48d_action(transforms_group: h5py.Group, frame_idx: int) -> np.ndarray:
    """
    Build the 48D action vector for a single frame from EgoDex transforms.
    """
    action_vector: list[np.ndarray] = []

    for hand_side in ("left", "right"):
        hand_key = f"{hand_side}Hand"
        hand_transform = np.asarray(transforms_group[hand_key][frame_idx], dtype=np.float32)  # (4, 4)

        wrist_pos = hand_transform[:3, 3]
        wrist_rot6d = rotation_matrix_to_6d(hand_transform[:3, :3])

        action_vector.append(wrist_pos)
        action_vector.append(wrist_rot6d)

        for fingertip in FINGERTIP_JOINTS[hand_side]:
            fingertip_transform = np.asarray(transforms_group[fingertip][frame_idx], dtype=np.float32)
            fingertip_pos = fingertip_transform[:3, 3]
            action_vector.append(fingertip_pos)

    out = np.concatenate(action_vector, axis=0).astype(np.float32)
    if out.shape != (48,):
        raise ValueError(f"Expected action shape (48,), got {out.shape}")
    return out


def iter_video_frames_bgr(video_path: Path) -> Iterator[np.ndarray]:
    """
    Yield video frames as HxWx3 uint8 RGB arrays.
    """
    cap = cv2.VideoCapture(str(video_path))
    if not cap.isOpened():
        raise RuntimeError(f"Failed to open video: {video_path}")

    try:
        while True:
            ok, frame_bgr = cap.read()
            if not ok:
                break
            frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
            yield frame_rgb
    finally:
        cap.release()


def get_video_info(video_path: Path) -> tuple[int, int, int, float]:
    cap = cv2.VideoCapture(str(video_path))
    if not cap.isOpened():
        raise RuntimeError(f"Failed to open video: {video_path}")
    try:
        frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = float(cap.get(cv2.CAP_PROP_FPS))
        return frame_count, height, width, fps
    finally:
        cap.release()


def safe_hdf5_attr_str(f: h5py.File, key: str, default: str = "") -> str:
    if key not in f.attrs:
        return default
    value = f.attrs[key]
    if isinstance(value, bytes):
        return value.decode("utf-8", errors="replace")
    return str(value)


def get_instruction(f: h5py.File, task_name: str) -> str:
    """
    Pick the best available language string from EgoDex metadata.
    """
    llm_description = safe_hdf5_attr_str(f, "llm_description", "")
    llm_description2 = safe_hdf5_attr_str(f, "llm_description2", "")
    which = safe_hdf5_attr_str(f, "which_llm_description", "")

    if which == "2" and llm_description2:
        return llm_description2
    if llm_description:
        return llm_description
    if llm_description2:
        return llm_description2
    return task_name.replace("_", " ")


def collect_test_episodes(test_root: Path) -> list[tuple[str, Path, Path]]:
    """
    Returns a list of:
      (task_name, mp4_path, hdf5_path)
    """
    episodes: list[tuple[str, Path, Path]] = []

    if not test_root.exists():
        raise FileNotFoundError(f"Test root not found: {test_root}")

    for task_dir in sorted(p for p in test_root.iterdir() if p.is_dir()):
        task_name = task_dir.name
        mp4s = sorted(task_dir.glob("*.mp4"))

        for mp4_path in mp4s:
            hdf5_path = mp4_path.with_suffix(".hdf5")
            if not hdf5_path.exists():
                print(f"[WARN] Missing HDF5 for {mp4_path}, skipping")
                continue
            episodes.append((task_name, mp4_path, hdf5_path))

    return episodes


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--egodex-root", type=Path, required=True, help="Root containing the EgoDex 'test' directory")
    parser.add_argument("--output-root", type=Path, required=True, help="Local output root for LeRobot dataset")
    parser.add_argument("--repo-id", type=str, required=True, help="LeRobot dataset repo_id, e.g. local/egodex_test_48d")
    parser.add_argument("--fps", type=float, default=30.0, help="Target FPS in LeRobot metadata")
    parser.add_argument("--robot-type", type=str, default="egodex_hands", help="Robot type string for LeRobot metadata")
    parser.add_argument("--video-key", type=str, default="observation.images.ego", help="Feature key for ego video")
    parser.add_argument("--action-key", type=str, default="action", help="Feature key for 48D actions")
    parser.add_argument("--task-as-frame-field", action="store_true",
                        help="Also include frame['task']=... for compatibility with some LeRobot installs")
    args = parser.parse_args()

    test_root = args.egodex_root / "test"
    episodes = collect_test_episodes(test_root)

    if not episodes:
        raise RuntimeError(f"No test episodes found under {test_root}")

    args.output_root.mkdir(parents=True, exist_ok=True)

    # Import here so the script can still be inspected without LeRobot installed.
    from lerobot.datasets.lerobot_dataset import LeRobotDataset

    # Use the first video to define image shape.
    _, first_mp4, first_h5 = episodes[0]
    _, height, width, detected_fps = get_video_info(first_mp4)

    print(f"Found {len(episodes)} episodes")
    print(f"Video shape: {height}x{width}x3")
    print(f"First-video FPS: {detected_fps:.3f}")
    print(f"LeRobot metadata FPS: {args.fps}")

    features = {
        args.video_key: {
            "dtype": "video",
            "shape": (height, width, 3),
            "names": ["height", "width", "channel"],
        },
        args.action_key: {
            "dtype": "float32",
            "shape": (48,),
            "names": [f"a_{i}" for i in range(48)],
        },
        # Optional but useful for alignment/debugging.
        "timestamp": {
            "dtype": "float32",
            "shape": (1,),
            "names": None,
        },
    }

    dataset = LeRobotDataset.create(
        repo_id=args.repo_id,
        root=args.output_root,
        fps=args.fps,
        robot_type=args.robot_type,
        features=features,
        use_videos=True,
    )

    summary = {
        "repo_id": args.repo_id,
        "num_episodes": 0,
        "episodes": [],
    }

    for episode_idx, (task_name, mp4_path, hdf5_path) in enumerate(episodes):
        print(f"[{episode_idx + 1}/{len(episodes)}] {task_name} / {mp4_path.name}")

        with h5py.File(hdf5_path, "r") as f:
            if "transforms" not in f:
                print(f"[WARN] Missing 'transforms' in {hdf5_path}, skipping")
                continue

            transforms_group = f["transforms"]
            instruction = get_instruction(f, task_name)

            # Determine HDF5 length from one transform dataset.
            first_key = next(iter(transforms_group.keys()))
            hdf5_num_frames = int(transforms_group[first_key].shape[0])

            video_frames = list(iter_video_frames_bgr(mp4_path))
            video_num_frames = len(video_frames)

            if video_num_frames == 0:
                print(f"[WARN] Empty video: {mp4_path}, skipping")
                continue

            num_frames = min(video_num_frames, hdf5_num_frames)
            if num_frames != video_num_frames or num_frames != hdf5_num_frames:
                print(
                    f"[WARN] Frame mismatch for {mp4_path.name}: "
                    f"video={video_num_frames}, hdf5={hdf5_num_frames}, using {num_frames}"
                )

            for frame_idx in range(num_frames):
                image = video_frames[frame_idx]
                action = construct_48d_action(transforms_group, frame_idx)
                timestamp = np.array([frame_idx / float(args.fps)], dtype=np.float32)

                frame = {
                    args.video_key: image,
                    args.action_key: action,
                    "timestamp": timestamp,
                }

                if args.task_as_frame_field:
                    frame["task"] = instruction

                # Some LeRobot versions accept task as a kwarg, which is the cleaner route.
                try:
                    dataset.add_frame(frame, task=instruction)
                except TypeError:
                    dataset.add_frame(frame)

            dataset.save_episode()

            summary["num_episodes"] += 1
            summary["episodes"].append(
                {
                    "episode_index": episode_idx,
                    "task_name": task_name,
                    "video": str(mp4_path),
                    "hdf5": str(hdf5_path),
                    "frames_used": num_frames,
                    "instruction": instruction,
                }
            )

    dataset.finalize()

    with open(args.output_root / "conversion_summary.json", "w", encoding="utf-8") as fp:
        json.dump(summary, fp, indent=2, ensure_ascii=False)

    print("\nDone.")
    print(f"LeRobot dataset written to: {args.output_root}")
    print(f"Episodes saved: {summary['num_episodes']}")


if __name__ == "__main__":
    main()
Editor is loading...
Leave a Comment