Skip to content

Commit fa6744b

Browse files
committed
feat(action): add Mecka hand pose dataset
Signed-off-by: Hans Yang <hayang@nvidia.com>
1 parent 24300b4 commit fa6744b

5 files changed

Lines changed: 390 additions & 5 deletions

File tree

cosmos_framework/data/vfm/action/datasets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from cosmos_framework.data.vfm.action.datasets.base_dataset import ActionBaseDataset
1313
from cosmos_framework.data.vfm.action.datasets.bridge_orig_lerobot_dataset import BridgeOrigLeRobotDataset
1414
from cosmos_framework.data.vfm.action.datasets.droid_lerobot_dataset import DROIDLeRobotDataset
15+
from cosmos_framework.data.vfm.action.datasets.mecka_hand_pose_lerobot_dataset import MeckaHandPoseLeRobotDataset
1516
from cosmos_framework.data.vfm.action.datasets.robomind_franka_dataset import RoboMINDFrankaDataset
1617
from cosmos_framework.data.vfm.action.datasets.umi_lerobot_dataset import UMILeRobotDataset
1718

@@ -20,6 +21,7 @@
2021
"AgiBotWorldBetaLeRobotDataset",
2122
"BridgeOrigLeRobotDataset",
2223
"DROIDLeRobotDataset",
24+
"MeckaHandPoseLeRobotDataset",
2325
"RoboMINDFrankaDataset",
2426
"UMILeRobotDataset",
2527
]
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: OpenMDW-1.1
3+
4+
"""Mecka bimanual human hand-pose dataset in LeRobot v3 format."""
5+
6+
from __future__ import annotations
7+
8+
import random
9+
from pathlib import Path
10+
from typing import Any, Literal
11+
12+
import numpy as np
13+
import pyarrow.parquet as pq
14+
import torch
15+
from lerobot.datasets.video_utils import decode_video_frames
16+
17+
from cosmos_framework.data.vfm.action.action_spec import ActionSpec, Pos, Rot, build_action_spec
18+
from cosmos_framework.data.vfm.action.datasets.base_dataset import ActionBaseDataset
19+
from cosmos_framework.data.vfm.action.pose_utils import build_abs_pose_from_components, pose_abs_to_rel
20+
21+
PoseConvention = Literal["backward_framewise"]
22+
Viewpoint = Literal["ego_view"]
23+
24+
_HAND_RIGHT_POSITION_KEY = "observation.state.hand_right_cam"
25+
_HAND_RIGHT_ROTATION_KEY = "observation.state.hand_right_cam_rotation"
26+
_HAND_LEFT_POSITION_KEY = "observation.state.hand_left_cam"
27+
_HAND_LEFT_ROTATION_KEY = "observation.state.hand_left_cam_rotation"
28+
_CAM_POSITION_KEY = "observation.state.camera_position"
29+
_CAM_ROTATION_KEY = "observation.state.camera_rotation"
30+
_IMAGE_FEATURE = "observation.images.main"
31+
32+
_NUM_JOINTS = 21
33+
_WRIST_JOINT_IDX = 0
34+
_FINGERTIP_JOINT_IDXS = (4, 8, 12, 16, 20)
35+
_RAW_ACTION_DIM = 57
36+
_NORMALIZER_PATH = Path(__file__).parent / "stats/mecka_hand_pose_lerobot_stats.json"
37+
38+
# Rotate the source wrist frames into the unified convention:
39+
# X = thumb-to-pinky, Y = outward palm normal, Z = wrist-to-fingertips.
40+
_WRIST_FRAME_ALIGNMENT = np.array(
41+
[[0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]],
42+
dtype=np.float32,
43+
)
44+
45+
46+
class MeckaHandPoseLeRobotDataset(ActionBaseDataset):
47+
"""Bimanual human hand-pose forward-dynamics data.
48+
49+
The 57D action layout is
50+
``[camera(9), right_wrist(9), right_fingertips(15),
51+
left_wrist(9), left_fingertips(15)]``. Camera and wrist poses use
52+
framewise 3D translation plus rot6d deltas. Each hand's five fingertip
53+
positions are expressed in that frame's aligned wrist coordinate system.
54+
55+
Source video and pose annotations are sampled at 30 FPS by default and
56+
decoded at 15 FPS for Cosmos3-Nano, yielding 17 video frames and 16 action
57+
transitions for the default chunk.
58+
"""
59+
60+
def __init__(
61+
self,
62+
root: str,
63+
fps: float = 15.0,
64+
chunk_length: int = 16,
65+
mode: str = "forward_dynamics",
66+
pose_convention: PoseConvention = "backward_framewise",
67+
tolerance_s: float = 2e-4,
68+
viewpoint: Viewpoint = "ego_view",
69+
action_normalization: str | None = "quantile",
70+
sample_stride: int = 1,
71+
image_key: str = _IMAGE_FEATURE,
72+
) -> None:
73+
if viewpoint != "ego_view":
74+
raise NotImplementedError("Mecka hand-pose data only supports ego_view.")
75+
super().__init__(
76+
root=root,
77+
domain_name="hand_pose",
78+
fps=fps,
79+
chunk_length=chunk_length,
80+
mode=mode,
81+
pose_convention=pose_convention,
82+
tolerance_s=tolerance_s,
83+
viewpoint=viewpoint,
84+
action_normalization=action_normalization,
85+
sample_stride=sample_stride,
86+
)
87+
source_fps = float(self._info["fps"])
88+
source_stride = source_fps / self._fps
89+
if not source_stride.is_integer():
90+
raise ValueError(f"Source FPS {source_fps} must be an integer multiple of target FPS {self._fps}.")
91+
self._source_stride = int(source_stride)
92+
self._image_key = image_key
93+
required_source_steps = self._source_stride * self._chunk_length
94+
self._valid_starts: list[int] = []
95+
episode_start = 0
96+
while episode_start < len(self._rows):
97+
episode_index = int(self._rows[episode_start]["episode_index"])
98+
episode_end = episode_start + 1
99+
while episode_end < len(self._rows) and int(self._rows[episode_end]["episode_index"]) == episode_index:
100+
episode_end += 1
101+
self._valid_starts.extend(
102+
range(episode_start, max(episode_start, episode_end - required_source_steps), self._sample_stride)
103+
)
104+
episode_start = episode_end
105+
subtasks_path = self._root / "meta" / "subtasks.parquet"
106+
self._subtasks = (
107+
{int(row["subtask_index"]): str(row["subtask"]) for row in pq.read_table(subtasks_path).to_pylist()}
108+
if subtasks_path.exists()
109+
else {}
110+
)
111+
112+
@property
113+
def action_dim(self) -> int:
114+
return _RAW_ACTION_DIM
115+
116+
def _action_spec(self) -> ActionSpec:
117+
return build_action_spec(
118+
Pos(prefix="camera"),
119+
Rot("rot6d", prefix="camera"),
120+
Pos(prefix="right_wrist"),
121+
Rot("rot6d", prefix="right_wrist"),
122+
Pos(dim=15, prefix="right_fingertip"),
123+
Pos(prefix="left_wrist"),
124+
Rot("rot6d", prefix="left_wrist"),
125+
Pos(dim=15, prefix="left_fingertip"),
126+
)
127+
128+
@classmethod
129+
def _stats_path(cls) -> Path:
130+
return _NORMALIZER_PATH
131+
132+
def __len__(self) -> int:
133+
return len(self._valid_starts)
134+
135+
def __getitem__(self, idx: int) -> dict[str, Any]:
136+
mode = self._choose_mode()
137+
start = self._valid_starts[int(idx)]
138+
stop = start + self._source_stride * self._chunk_length + 1
139+
rows = self._rows[start : stop : self._source_stride]
140+
if len(rows) != self._chunk_length + 1:
141+
raise IndexError(f"Incomplete hand-pose window at index {idx}.")
142+
episode_index = int(rows[0]["episode_index"])
143+
if any(int(row["episode_index"]) != episode_index for row in rows):
144+
raise IndexError(f"Hand-pose window at index {idx} crosses an episode boundary.")
145+
146+
episode = self._episodes[episode_index]
147+
video = self._load_video(episode, rows)
148+
raw_action = self._build_raw_action(rows)
149+
subtask_index = int(rows[0].get("subtask_index", -1))
150+
task = self._tasks[int(rows[0]["task_index"])]
151+
caption = self._subtasks.get(subtask_index, task)
152+
ai_caption = random.choice([part.strip() for part in caption.split(" | ") if part.strip()] or [caption])
153+
154+
result = self._build_result(mode=mode, video=video, action=raw_action, ai_caption=ai_caption)
155+
if self.action_normalization is not None:
156+
result["action"] = result["action"].clamp(-1.0, 1.0)
157+
return result
158+
159+
def _load_video(self, episode: dict[str, Any], rows: list[dict[str, Any]]) -> torch.Tensor:
160+
timestamps = [float(row["timestamp"]) for row in rows]
161+
from_timestamp = float(episode.get(f"videos/{self._image_key}/from_timestamp", 0.0))
162+
return decode_video_frames(
163+
self._video_path(episode, self._image_key),
164+
[from_timestamp + timestamp for timestamp in timestamps],
165+
self._tolerance_s,
166+
)
167+
168+
@staticmethod
169+
def _finger_positions_in_wrist_frame(position_data: np.ndarray, wrist_poses: np.ndarray) -> np.ndarray:
170+
future_positions = position_data[1:].reshape(-1, _NUM_JOINTS, 3)
171+
fingertips = future_positions[:, _FINGERTIP_JOINT_IDXS, :]
172+
fingertips_h = np.concatenate(
173+
[fingertips, np.ones((*fingertips.shape[:-1], 1), dtype=np.float32)],
174+
axis=-1,
175+
)
176+
wrist_inv = np.linalg.inv(wrist_poses[1:])
177+
fingertips_wrist = np.einsum("tij,tnj->tni", wrist_inv, fingertips_h)[..., :3]
178+
return fingertips_wrist.reshape(len(future_positions), -1)
179+
180+
def _build_raw_action(self, rows: list[dict[str, Any]]) -> torch.Tensor:
181+
def values(key: str) -> np.ndarray:
182+
return np.asarray([row[key] for row in rows], dtype=np.float32)
183+
184+
camera_pose = build_abs_pose_from_components(values(_CAM_POSITION_KEY), values(_CAM_ROTATION_KEY), "quat_xyzw")
185+
186+
right_positions = values(_HAND_RIGHT_POSITION_KEY)
187+
right_rotations = values(_HAND_RIGHT_ROTATION_KEY).reshape(-1, _NUM_JOINTS, 4)
188+
left_positions = values(_HAND_LEFT_POSITION_KEY)
189+
left_rotations = values(_HAND_LEFT_ROTATION_KEY).reshape(-1, _NUM_JOINTS, 4)
190+
191+
right_wrist_camera = (
192+
build_abs_pose_from_components(right_positions[:, :3], right_rotations[:, _WRIST_JOINT_IDX], "quat_xyzw")
193+
@ _WRIST_FRAME_ALIGNMENT
194+
)
195+
left_wrist_camera = (
196+
build_abs_pose_from_components(left_positions[:, :3], left_rotations[:, _WRIST_JOINT_IDX], "quat_xyzw")
197+
@ _WRIST_FRAME_ALIGNMENT
198+
)
199+
200+
right_wrist_world = camera_pose @ right_wrist_camera
201+
left_wrist_world = camera_pose @ left_wrist_camera
202+
action = np.concatenate(
203+
[
204+
pose_abs_to_rel(camera_pose, rotation_format="rot6d", pose_convention=self._pose_convention),
205+
pose_abs_to_rel(right_wrist_world, rotation_format="rot6d", pose_convention=self._pose_convention),
206+
self._finger_positions_in_wrist_frame(right_positions, right_wrist_camera),
207+
pose_abs_to_rel(left_wrist_world, rotation_format="rot6d", pose_convention=self._pose_convention),
208+
self._finger_positions_in_wrist_frame(left_positions, left_wrist_camera),
209+
],
210+
axis=-1,
211+
)
212+
if action.shape != (self._chunk_length, _RAW_ACTION_DIM):
213+
raise ValueError(
214+
f"Expected hand-pose action shape {(self._chunk_length, _RAW_ACTION_DIM)}, got {action.shape}."
215+
)
216+
return torch.from_numpy(action).float()
217+
218+
219+
__all__ = ["MeckaHandPoseLeRobotDataset"]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: OpenMDW-1.1
3+
4+
import numpy as np
5+
6+
from cosmos_framework.data.vfm.action.datasets.mecka_hand_pose_lerobot_dataset import (
7+
MeckaHandPoseLeRobotDataset,
8+
)
9+
10+
11+
def _row(frame_index: int) -> dict:
12+
positions = np.zeros((21, 3), dtype=np.float32)
13+
positions[[4, 8, 12, 16, 20], 2] = np.arange(1, 6, dtype=np.float32) * 0.01
14+
rotations = np.zeros((21, 4), dtype=np.float32)
15+
rotations[:, 3] = 1.0
16+
return {
17+
"observation.state.camera_position": np.zeros(3, dtype=np.float32),
18+
"observation.state.camera_rotation": np.array([0, 0, 0, 1], dtype=np.float32),
19+
"observation.state.hand_right_cam": positions.reshape(-1),
20+
"observation.state.hand_right_cam_rotation": rotations.reshape(-1),
21+
"observation.state.hand_left_cam": positions.reshape(-1),
22+
"observation.state.hand_left_cam_rotation": rotations.reshape(-1),
23+
"frame_index": frame_index,
24+
}
25+
26+
27+
def test_static_hand_pose_builds_57d_action() -> None:
28+
dataset = object.__new__(MeckaHandPoseLeRobotDataset)
29+
dataset._chunk_length = 2
30+
dataset._pose_convention = "backward_framewise"
31+
32+
action = dataset._build_raw_action([_row(0), _row(1), _row(2)]).numpy()
33+
34+
assert action.shape == (2, 57)
35+
assert np.isfinite(action).all()
36+
np.testing.assert_allclose(action[:, :3], 0.0)
37+
np.testing.assert_allclose(action[:, 9:12], 0.0)
38+
np.testing.assert_allclose(action[:, 33:36], 0.0)
39+
40+
41+
def test_action_spec_matches_released_width() -> None:
42+
dataset = object.__new__(MeckaHandPoseLeRobotDataset)
43+
44+
assert dataset.action_dim == 57
45+
assert dataset._action_spec().dim == dataset.action_dim

0 commit comments

Comments
 (0)