birbirll/g1-inspire-turn-page-v21
g1-inspire-turn-page-v21 A LeRobot v2.1 (per-episode) conversion of the public MLeggiero/g1-inspire-turn-page-twist2, which is LeRobot v3.0. Nothing was added, removed or resampled: same 38 episodes, same 20,990 frames, same numbers. Only the file layout, the video codec and the column split differ. Teleoperated Unitree G1 (29-DoF) + Inspire RH56DFTP hands, task turn the page of the notebook. 38 episodes / 20,990 frames @ 60 fps (5.8 min), one head camera at 1280×720, the left… See the full description on the dataset page: https://huggingface.co/datasets/birbirll/g1-inspire-turn-page-v21.
g1-inspire-turn-page-v21
A LeRobot v2.1 (per-episode) conversion of the public `MLeggiero/g1-inspire-turn-page-twist2`, which is LeRobot v3.0. Nothing was added, removed or resampled: same 38 episodes, same 20,990 frames, same numbers. Only the file layout, the video codec and the column split differ.
Teleoperated Unitree G1 (29-DoF) + Inspire RH56DFTP hands, task turn the page of the notebook. 38 episodes / 20,990 frames @ 60 fps (5.8 min), one head camera at 1280×720, the left hand does the work.
Three policies were fine-tuned on exactly this copy:
Why this conversion exists
Three concrete blockers stopped the v3.0 source from loading in our stacks:
- Layout. v3.0 consolidates every episode into a few large files (
data/chunk-000/file-000.parquet,meta/episodes/chunk-000/file-000.parquet,meta/tasks.parquet, and videos with several episodes concatenated per mp4). The GR00TLeRobotSingleDataset/LeRobotEpisodeLoader, the starVLA fork (pinned tolerobot_versionv2.0) and openpi's LeRobot path all expect one parquet and one mp4 per episode. - AV1 video. The source videos are AV1.
cv2/decordcould not decode them on our machines (systemffmpegwithlibdav1dcan), so training would have failed at the first frame. - No `modality.json`. GR00T-family loaders need the state/action vectors split into named groups.
What changed, exactly
Frame counts, episode boundaries and every numeric channel are byte-identical to the source (the converter asserts this: it re-reads each written parquet and compares the passthrough columns).
Layout
data/chunk-000/episode_000000.parquet … episode_000037.parquet
videos/chunk-000/observation.images.head_rgb/episode_000000.mp4 … episode_000037.mp4
meta/info.json meta/episodes.jsonl meta/episodes_stats.jsonl
meta/tasks.jsonl meta/modality.json meta/stats.json meta/source_provenance.jsonColumns
The recorder's two flat vectors are split so that each named group is addressable:
The 29 body joints are in canonical TWIST2 order: left_leg 0–5, right_leg 6–11, waist 12–14, left_arm 15–21, right_arm 22–28. Per limb: legs hip_pitch, hip_roll, hip_yaw, knee, ankle_pitch, ankle_roll; waist yaw, roll, pitch; arms shoulder_pitch, shoulder_roll, shoulder_yaw, elbow, wrist_roll, wrist_pitch, wrist_yaw. Hands (both): pinky, ring, middle, index, thumb_bend, thumb_rot.
The split from the source's flat vectors is index-for-index:
state[48] -> observation.state = state[0:34] (body 29 + imu 5)
observation.state.hand_left = state[34:40]
observation.state.hand_right = state[40:46]
observation.state.neck = state[46:48]
action[49] -> action = action[0:35] (body 29 + root 6)
action.hand_left = action[35:41]
action.hand_right = action[41:47]
action.neck = action[47:49]meta/modality.json names the groups the GR00T-family loaders read:
{"state": {"left_leg":[0,6], "right_leg":[6,12], "waist":[12,15], "left_arm":[15,22],
"right_arm":[22,29], "imu_ang_vel":[29,32], "imu_rp":[32,34],
"hand_left":[0,6] -> observation.state.hand_left,
"hand_right":[0,6] -> observation.state.hand_right},
"action": {"left_leg":[0,6], "right_leg":[6,12], "waist":[12,15], "left_arm":[15,22],
"right_arm":[22,29], "root_lin_vel_xy":[29,31], "root_height":[31,32],
"root_rp":[32,34], "root_yaw_rate":[34,35],
"hand_left":[0,6] -> action.hand_left,
"hand_right":[0,6] -> action.hand_right,
"neck":[0,2] -> action.neck},
"video": {"head": -> observation.images.head_rgb}}(written with real start/end integers and original_key strings; shown compressed here.)
Reproducing the conversion
The converter is a script in a private repository, so here is precisely what it does. Any equivalent implementation reproduces this dataset from the public source.
- Pull the source:
huggingface-cli download MLeggiero/g1-inspire-turn-page-twist2 --repo-type dataset --local-dir <SRC>. - Read
<SRC>/meta/info.json(features, fps),meta/episodes/chunk-000/file-000.parquet(per-episodelength,dataset_from_index/dataset_to_index, and per-streamfrom_timestamp/to_timestamp), andmeta/tasks.parquet. Note: newer LeRobot writestasks.parquetwith the task string as the index and a singletask_indexcolumn — not ataskcolumn. Handle both. - De-consolidate the parquet: slice rows
[dataset_from_index, dataset_to_index)per episode, split the flat vectors into the columns above, rewriteepisode_index/indexfor the new numbering, and writedata/chunk-000/episode_%06d.parquet. - Slice + re-encode the video, frame-exact: seek to
(start_frame - 0.5) / fpsand take exactlylengthframes with-frames:v, encoding-c:v libx264 -crf 18 -preset fast -pix_fmt yuv420p. Seeking to the rawfrom_timestampis not safe: it is a rounded decimal (e.g.27.616667for frame 1657 at 60 fps) that lands after the target frame's presentation time, so ffmpeg silently starts one frame late. The half-frame-earlier seek lands unambiguously in the gap before it. (Validated by pixel-comparing output frame 0 against source frames n−3…n+3: MSE 5.1 for the intended frame versus 1034+ for its neighbours.) - Write the meta files:
info.jsonwithcodebase_version: v2.1and the per-episodedata_path/video_pathtemplates,episodes.jsonl,tasks.jsonl,modality.json, andstats.jsonrecomputed over the frames actually written. - Add `episodes_stats.jsonl` (openpi's LeRobot loader requires it for a v2.1 dataset): run the official LeRobot v2.0→v2.1 stats conversion, which computes per-episode statistics including sampled video frames.
Verify with the loader you intend to train with. For GR00T:
from gr00t.data.dataset.lerobot_episode_loader import LeRobotEpisodeLoader
ds = LeRobotEpisodeLoader(dataset_path="<DST>", modality_configs=cfg, video_backend="torchcodec")
# 38 episodes, lengths equal to meta/episodes.jsonl, one video group "head"Read this before training
- Hand proprioception is dead.
observation.state.hand_left/rightare constant within every episode (recorder readback failure). Drop them; do not feed them. - Only two hand dimensions move. In
action.hand_left, four of the six registers sit at 1000 (open) for the entire dataset; onlythumb_bendandthumb_rotvary. Under min/max normalisation the constant dims are masked to 0, which is the safe behaviour; under a percentile normalisation they are not defined. - The neck is constant at 0, and
observation.forceis mostly dead. - Tactile is byte-swapped exactly as the recorder wrote it. Decode before use:
v = np.asarray(v, np.uint16); ((v & 0xFF) << 8) | (v >> 8)→ 0–4095. The left hand is the live one (316/1062 taxels ever fire, peak 1957, against 91/1062 and peak 91 on the right). - The right arm is idle: per-episode joint std 0.02–0.08 rad against 0.20–0.39 on the left.
- One camera. The source recording had two wrist cameras that were deliberately not carried.
- Success labels are the recorder's. All 38 episodes are labelled successful and we did not independently review them. On a sibling capture from the same rig, an equivalent claim held for only 29 of 41 episodes once reviewed frame by frame. Treat the labels as unverified.
- Episode lengths vary a lot: 327 to 1755 frames, median 485.
Provenance and credit
The recording, the task and every number are MLeggiero's, captured 2026-08-28 with the TWIST2 converter (--action_mode high_level) and published as MLeggiero/g1-inspire-turn-page-twist2 under MIT. This repository is a format conversion of it, made 2026-09-09, and carries the same licence. meta/source_provenance.json records the source repository and commit this copy was built from.
