Skip to content

Fix GR00T N1.7 relative-action decoding and the ALOHA right-arm client - #25

Open
hungho77 wants to merge 3 commits into
mainfrom
fix/gr00t-n1d7-relative-actions
Open

hungho77 wants to merge 3 commits into
mainfrom
fix/gr00t-n1d7-relative-actions

Conversation

@hungho77

@hungho77 hungho77 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

What

Run GR00T N1.7 checkpoints trained on relative actions correctly, and make the ALOHA client usable with a right-arm policy.

  • Relative actions in the eval client. A modality listed in meta/relative_stats.json is decoded as an offset from the observed state, target[t] = state_t0 + delta[t], with per-chunk-step statistics passed through the new --rel-stats-json.
  • Min/max for relative keys. Those offsets are un-normalised with the relative stats' min/max, not q01/q99, even when the checkpoint sets use_percentiles.
  • Modality layout from the checkpoint. The GR00T N1.7 client no longer hardcodes the EEF names (x, y, z, roll, pitch, yaw, gripper). Joint-space embodiments (single_arm, gripper) now work. Checkpoints that ship the full EEF set keep the old order and decode bit-identically.
  • scripts/gen_gr00t_relative_stats.py regenerates relative_stats.json from the source LeRobot datasets, mirroring gr00t/data/stats.py::load_relative_actions.
  • Converter reports use_relative_action from the per-modality rep of the finetuned embodiment.
  • ALOHA client. --arm-side left|right, --front-topic, --wrist-topic, --state-topic. Camera defaults are now image_rect_raw. The "Waiting for data" log is throttled to once a second.
  • CUDA build. CUDA_RESOLVE_DEVICE_SYMBOLS on both CUDA libraries so each device-links inside its own archive.

Why

Relative actions were treated as absolute. The engine returns the raw chunk and the client decoded it as joint targets. On an ALOHA right-arm checkpoint the first commanded pose landed 1.52 rad from the arm's real position, a violent jerk on hardware.

q01/q99 under-scales the offsets. StateActionProcessor.set_statistics replaces the whole norm_params entry for a relative key with the raw relative_stats dict. That runs after the use_percentiles branch, so relative keys always normalise with min/max. Using q01/q99 shrinks every offset by about 2x on average and 3x on wrist_rotate. The arm covers a fraction of each motion, the next chunk repeats the same command, and the joint drifts one way. A trace on the robot showed wrist_rotate walking at -0.053 rad/s to -1.83 rad, past the -1.25 rad the training data ever reached, while every joint tracked its command with correlation +0.9996.

The converter read the wrong flag. use_relative_action lives in experiment_cfg/, not config.json, so relative checkpoints converted as absolute. The global flag is also true on checkpoints whose modalities are all ABSOLUTE, such as LIBERO-4suite, so the per-modality rep is the reliable signal.

The ALOHA client could not see the cameras. No topic named image_raw exists on the robot, so the node waited forever. Every topic also pointed at follower_left.

The CUDA build failed on CUDA 12.8. vla_core nvlinked both libraries together. Only bitvla_cuda_kernels builds with --use_fast_math, and nvlink rejects the mix with Cicc option values for '-ftz' do not match.

Verified

  • Builds clean under -Wall -Wextra (first-party code). CUDA build on CUDA 12.8, 0 first-party warnings.
  • ctest passes, 8/8.
  • Numeric output unchanged (vla_predict_check diff). The engine is untouched. The CMake change gives byte-identical vla_predict_check output with separable compilation on and off.

Decoding checked against ground-truth actions, 12 frames from 4 episodes across both source datasets (aloha_placing_kitchen_lerobot, aloha_placing_drawer_lerobot), mean |pred − gt| in rad:

Decoding mean wrist_rotate
q01/q99 0.065 0.093
min/max (this PR) 0.027 0.038

Tensor check: every action-head projector in the converted GGUF (act_dec, act_enc, state_enc, proj_out), including the per-embodiment slices, is bit-identical to the source safetensors.

Relative detection in the converter: ALOHA-RightArm, SO101-Multitask and UR10E-CUP report relative. LIBERO-4suite reports absolute.

Not yet done: a closed-loop run on the robot with the min/max decoding.

Archs and backends tested: GR00T N1.7 on CUDA (RTX 4070 Ti SUPER, CUDA 12.8) and CPU. ALOHA right-arm follower with ROS 2 Humble.

A CUDA build failed at link time on CUDA 12.x:

  nvlink fatal : Cicc option values for '-ftz' do not match

vla_core nvlinks the relocatable device code of bitvla_cuda_kernels and
vla_cuda_ops together, but only bitvla compiles with --use_fast_math, which
implies -ftz=true. nvlink rejects the mixed inputs.

Setting CUDA_RESOLVE_DEVICE_SYMBOLS on both libraries makes each resolve its
own device symbols inside its archive, with its own flags, and removes the
combined link from vla_core. Relocatable device code stays enabled.

Verified numerically neutral: tests/vla_predict_check on a GR00T N1.7
checkpoint gives byte-identical output with separable compilation on and off.
GR00T N1.7 finetunes can declare an action modality RELATIVE, meaning the
model predicts an offset from the observed state rather than an absolute
joint target. Every step of the chunk is an offset from the same reference,
the state of the observation that produced it, so the caller reconstructs

    target[t] = state_t0 + delta[t]

The client had no such path and treated the chunk as absolute. On an ALOHA
right-arm checkpoint that put the first commanded pose 1.52 rad from where the
arm actually was; with the reconstruction it sits 0.06 rad away.

Those offsets carry their own per-chunk-step statistics in
meta/relative_stats.json, which ships separately from dataset_statistics.json.
The new --rel-stats-json feeds them in. A modality is treated as relative iff
it appears in that file, which is exactly the set GR00T writes there.

Un-normalise those offsets with min/max, never q01/q99, even when the
checkpoint sets use_percentiles. GR00T swaps the whole norm_params entry for a
relative key with the raw relative_stats dict, which bypasses the branch that
would otherwise substitute the percentiles. Using q01/q99 scales every offset
down by ~2x on average and 3x on wrist_rotate, so the arm covers a fraction of
the intended motion, never arrives, and the next chunk repeats the same
command: a steady one-way drift of 0.05 rad/s that walked wrist_rotate to
-1.83 rad, past the -1.25 the training data ever saw.

Measured against ground-truth actions over 12 frames from both source
datasets, mean |pred - gt| falls from 0.065 to 0.027 rad; wrist_rotate, the
worst dimension, from 0.093 to 0.038.

Also drop the hardcoded EEF modality names. The layout now comes from the
checkpoint's own statistics, so joint-space embodiments (single_arm, gripper)
work alongside the EEF ones. Checkpoints that ship the full EEF set keep the
historical order and decode bit-identically to before.

scripts/gen_gr00t_relative_stats.py regenerates the statistics from the source
LeRobot datasets, mirroring gr00t/data/stats.py::load_relative_actions.

The converter now reports use_relative_action from the per-modality rep of the
finetuned embodiment. The global flag in experiment_cfg is set on checkpoints
whose modalities are all ABSOLUTE too, so reading it alone marks every
checkpoint relative.
Three things kept the node from running against a right-arm policy.

The camera topics were wrong. The node subscribed to image_raw, but the
RealSense driver on the ALOHA publishes image_rect_raw; nothing named
image_raw exists on the graph, so no frame ever arrived and the node sat in
"Waiting for data" forever. --front-topic and --wrist-topic now carry the
working defaults and can be overridden.

The arm was hardcoded. Joint states, the wrist camera and the joint commands
all pointed at follower_left, so a right-arm checkpoint read the wrong arm and
would have driven the wrong one. --arm-side picks the follower; it defaults to
left, so existing setups are unchanged.

The observation carried only the EEF-style state names. It now also publishes
state.single_arm, the same six numbers grouped the way a joint-space
checkpoint declares them, and the client takes whichever its statistics name.

Also pass --rel-stats-json through, and throttle the "Waiting for data" line
to once a second: the control loop retries at full rate, so an unthrottled log
buried every other message under thousands of identical lines in the same
millisecond.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant