GR00T Policy Inference
Hierarchical control architecture — how a trained GR00T manipulation policy runs on the real Unitree G1.
The trained GR00T manipulation policy is a chunked / receding-horizon policy, not a single-step controller. Inference runs at a slow outer rate (a heavy VLM + diffusion forward pass), but it emits an action chunk of H future actions (default H = 16) that are streamed out one-by-one at the 50 Hz control rate. The existing WBC stack stays unchanged underneath — the policy simply replaces the Pico VR teleop input.
1 Block diagram
flowchart TD
subgraph SENSORS["Sensors (live, 50 Hz)"]
CAM["Cameras (camera_host:5555)"]
STATE["Robot state via state_processor.py (DDS subscriber)"]
LANG["Task prompt (text, e.g. 'pour water from jug')"]
end
subgraph POLICY["GR00T Policy (outer loop, ~3 Hz)"]
OBS["prepare_observation_data : tokenize images/state, build parsed_obs"]
VLM["Eagle VLM backbone : vision + language -> vl_embs, sa_embs"]
DIT["Action DiT (diffusion transformer)
TensorRT engine via TensorRTDiTWrapper
iterative denoising"]
CHUNK["action_chunk : H=16 future actions"]
OBS --> VLM --> DIT --> CHUNK
end
subgraph STREAM["Action streaming (inner loop, 50 Hz)"]
DISPATCH["Dispatch one action per tick
(replaces pico_streamer.py output)"]
end
subgraph WBC["WBC Stack (unchanged, 50 Hz)"]
TELEOP["run_teleop_policy_loop.py
IK + InterpolationPolicy + DecoupledWBC"]
CTRL["run_g1_control_loop.py
ONNX Balance/Walk RL policy
(switch threshold = 0.05)"]
DDS["command_sender.py -> rt/lowcmd DDS"]
MOTORS["35 motors on Unitree G1"]
TELEOP --> CTRL --> DDS --> MOTORS
end
CAM --> OBS
STATE --> OBS
LANG --> OBS
CHUNK --> DISPATCH
DISPATCH --> TELEOP
2 Control-block diagram
The diagram above shows data flow through the software stack. This one is drawn in classical control-block style: signals are named, dimensions and update rates are annotated, and the physical feedback loop closes through the robot itself (motors move the body → IMU/encoders re-read the new state).
All sizes and key names below are taken from the trained checkpoint's experiment_cfg/conf.yaml and dataset_statistics.json (embodiment unitree_g1_full_body_with_waist_height_nav_cmd).
2.1 Signal legend
Each signal carries a specific physical quantity — position, velocity, torque, or something else. The table also makes explicit which fields are actually consumed at each stage (e.g. GR00T reads positions only, the motor cmd is full PD+feedforward).
| Signal | Quantity type | Contents | Dim | Rate |
|---|---|---|---|---|
I_t | image | RGB frame, ego_view camera (head cam, 192.168.123.164:5555) | 256x256x3 (cropped from 230x230) | 50 Hz capture, sampled ~3 Hz |
L | text | Task prompt, annotation.human.task_description (constant per episode) | — | 1× per episode |
q | position (rad) | All 35 joint positions from motor_state[i].q | 35 | 50 Hz |
dq | velocity (rad/s) | All 35 joint velocities from motor_state[i].dq | 35 | 50 Hz |
tau_est | torque estimate (Nm) | Measured torque from motor_state[i].tau_est | 35 | 50 Hz |
ddq | acceleration (rad/s²) | Joint accelerations | 35 | 50 Hz |
| IMU | orientation + ang.vel + lin.acc | imu_state.quaternion (w,x,y,z) + gyroscope + accelerometer + torso IMU | 4 + 3 + 3 + 7 | 50 Hz |
| base odom | position + linear velocity | odo_state.position + linear_velocity | 3 + 3 | 50 Hz |
s_t (GR00T input) | positions only | Subset of q, grouped: left_leg(6) + right_leg(6) + waist(3) + left_arm(7) + right_arm(7) + left_hand + right_hand. No dq, no tau. state_history_length = 1 | ~36 + hands (padded to max_state_dim=132) | sampled ~3 Hz |
a_chunk (GR00T output) | target positions + setpoints | 16 future steps × 7 groups: left_arm(7) + right_arm(7) + left_hand + right_hand + waist(3) → target joint angles; base_height_command(1) → target height (m); navigate_command(3) → base velocity setpoint (vx, vy, vyaw) | padded to max_action_dim=132 × H=16 | ~3 Hz |
a_t | one slice of a_chunk | Single-tick target positions + base setpoints | same per-group breakdown | 50 Hz |
| Balance/Walk ONNX output | delta around default pose | 15 leg joints, then target_q = action * 0.25 + default_angles | 15 | 50 Hz |
u_motor (per motor on rt/lowcmd) | PD + feedforward | cmd.q (target position) + cmd.dq (target velocity, usually 0 for upper body) + cmd.tau (feedforward torque, usually 0 for upper body) + cmd.kp (pos gain) + cmd.kd (vel gain). Motor onboard computes tau_applied = kp*(cmd.q − q) + kd*(cmd.dq − dq) + cmd.tau | 5 fields × 35 motors | 50 Hz |
Per-stage cheat sheet — what each consumer actually reads/writes:
| Stage | Reads | Writes |
|---|---|---|
state_processor.py (DDS subscriber) | raw q, dq, tau_est, ddq, IMU, odometry | concatenated obs_vector |
| → GR00T policy | positions q only + image + text prompt | target positions + base height + (vx, vy, vyaw) |
| → IK + InterpolationPolicy | GR00T upper-body targets | target q for arm/hand joints |
| → Balance/Walk ONNX | leg q, dq, IMU, base velocity setpoint | delta-from-default → target q for legs |
→ command_sender.py | all combined targets | per motor: q, dq, tau, kp, kd (full PD + feedforward) onto rt/lowcmd |
Important: GR00T is not a torque controller. It outputs target joint positions; the low-level PD loop in each motor does the actual force production. The "torque" you see on
rt/lowcmd(cmd.tau) is a feedforward term, typically zero in this stack except where the RL policy populates it.
2.2 Block diagram
%%{init: {"flowchart": {"useMaxWidth": false, "htmlLabels": true}, "themeVariables": {"fontSize": "20px"}}}%%
flowchart LR
subgraph PLANT["PLANT - Unitree G1 + environment"]
BODY["Rigid body, 35 actuated DOF
Hands + arms + waist + legs
onboard motor PD: tau = kp(q*-q) + kd(dq*-dq) + tau_ff"]
SENS["IMU + joint encoders + cameras
measures: q, dq, tau_est, ddq, quat, gyro, accel"]
BODY -- "physical motion" --> SENS
end
subgraph PERCEPT["Perception (50 Hz)"]
STATE_PROC["state_processor.py
DDS rt/lowstate -> obs_vector
[q, dq, tau_est, ddq, IMU]"]
CAMSRV["camera_server
(192.168.123.164:5555)
-> I_t (256x256x3)"]
end
subgraph POLICY["GR00T Policy (outer ~3 Hz, on dGPU)"]
BUILDOBS["prepare_observation_data
tokenize I_t, normalize s_t (positions only), embed L
state_history_length = 1"]
VLM["Eagle VLM backbone
(Cosmos-Reason2-2B, qwen)
-> vl_embs, sa_embs"]
DIT["Action DiT (TensorRT)
num_inference_timesteps = 4
action_horizon = 40, use first 16"]
BUILDOBS --> VLM --> DIT
end
subgraph BUFFER["Action buffer (FIFO, ~3 Hz refill / 50 Hz drain)"]
QUE["a_chunk = [a_t, ..., a_t+15]
target positions for arms/hands/waist
+ base_height (m) + (vx, vy, vyaw)"]
end
subgraph WBC["WBC stack (50 Hz)"]
IK["run_teleop_policy_loop.py
IK + InterpolationPolicy
target arm q* -> joint refs"]
SWITCH{"|nav_cmd| < 0.05 ?"}
BAL["Balance ONNX
in: leg q, dq, IMU
out: 15 leg deltas"]
WALK["Walk ONNX
in: leg q, dq, IMU, vel setpt
out: 15 leg deltas"]
MERGE["DecoupledWBC
upper-body refs + leg targets
q* = action * 0.25 + default_angles"]
IK --> MERGE
SWITCH -- "yes (stand)" --> BAL --> MERGE
SWITCH -- "no (walk)" --> WALK --> MERGE
end
SENDER["command_sender.py
writes per-motor (q*, dq*, tau_ff, kp, kd)
DDS publish rt/lowcmd @ 50 Hz"]
L[/"Task prompt L
(constant per episode)"/] -- "text" --> BUILDOBS
CAMSRV -- "I_t (RGB image)" --> BUILDOBS
STATE_PROC -- "s_t = joint positions q only" --> BUILDOBS
DIT -- "a_chunk every ~330 ms
(target q + base setpoints)" --> QUE
QUE -- "a_t @ 50 Hz
upper-body target q*" --> IK
QUE -- "navigate_command
(vx, vy, vyaw)" --> SWITCH
STATE_PROC -- "leg q, dq + IMU" --> BAL
STATE_PROC -- "leg q, dq + IMU" --> WALK
MERGE -- "combined target q* (35 joints)" --> SENDER
SENDER -- "rt/lowcmd DDS
q*, dq*, tau_ff, kp, kd per motor" --> BODY
SENS -- "q, dq, tau_est, ddq, IMU
rt/lowstate DDS @ 50 Hz" --> STATE_PROC
SENS -- "RGB frames" --> CAMSRV
2.3 Why this is the right way to draw feedback
The only real feedback in the system is the closed-loop through the plant:
policy -> action -> motors -> physical robot moves -> sensors observe new state -> back into policy
There is no shortcut wire that sends state back into the policy bypassing the robot. Each outer tick the policy re-reads s_t because the robot has physically changed pose since the last tick.
2.4 Per-block update rates and latencies
| Block | Rate | Latency contribution |
|---|---|---|
| Camera capture | ~50 Hz | depends on camera; sampled by policy at ~3 Hz |
state_processor.py (DDS) | 50 Hz | < 1 ms |
| Observation tokenize/normalize | ~3 Hz | tens of ms (CPU, prefetched async) |
| Eagle VLM backbone | ~3 Hz | dominant cost on dGPU |
| Action DiT (TensorRT, 4 denoising steps) | ~3 Hz | reduced ~3-5x vs PyTorch |
| Action buffer drain | 50 Hz | 1 tick = 20 ms |
| IK + DecoupledWBC | 50 Hz | a few ms |
| Balance/Walk ONNX | 50 Hz | ~1 ms each (per g1_gear_wbc_policy.py) |
command_sender.py (DDS publish) | 50 Hz | < 1 ms |
So end-to-end camera-to-motor latency for a fresh policy decision is roughly one outer-tick period (~330 ms) plus a few ms of WBC, but because actions are streamed from a chunk, the motor command updates every 20 ms — only one in 16 of those motor commands rides on a brand-new policy decision.
2.5 Comparison with the original Pico teleop path
Same diagram, but the GR00T block is replaced by pico_streamer.py:
| Source of upper-body targets + nav_cmd | Pico teleop | GR00T inference |
|---|---|---|
| Producer | pico_streamer.py (VR headset) | policy.get_action() (GR00T) |
| Inputs to producer | Human pose + joystick | I_t, s_t, L |
| Rate of producer | 50 Hz (VR pose stream) | ~3 Hz (chunk) → 50 Hz drain |
| Downstream consumers | IK + DecoupledWBC + Balance/Walk ONNX | identical |
Everything from the FIFO downward is unchanged between teleop and autonomous inference.
3 Two clocks
- Outer (policy) clock — ~3 Hz: one heavy forward pass every
Hcontrol ticks. This is what TensorRT accelerates by swapping the PyTorch DiT for a TRT engine (replace_dit_with_tensorrt,TensorRTDiTWrapper). - Inner (control) clock — 50 Hz: the
H = 16actions from the latest chunk are dispatched one per tick into the WBC teleop input. The WBC stack (IK + RL standing/walking policies) keeps producing motor commands at 50 Hz regardless.
4 The inference loop (heart of it)
From scripts/deployment/standalone_inference_script.py (run_single_trajectory, line 371):
# Setup once
policy = Gr00tPolicy(checkpoint_path, embodiment_tag, modality_configs, ...)
policy = replace_dit_with_tensorrt(policy, trt_engine_path) # swap DiT for TRT engine
executor = ThreadPoolExecutor(max_workers=1)
future_obs = executor.submit(prepare_observation_data, ...) # prefetch step 0
for step_idx, step_count in enumerate(step_counts): # stride = action_horizon
parsed_obs = future_obs.result() # block until obs ready
# Prefetch next obs on CPU while GPU runs inference on current one
if step_idx + 1 < len(step_counts):
future_obs = executor.submit(prepare_observation_data, ..., next_step_count, ...)
# >>> the actual inference call <<<
action_chunk, _ = policy.get_action(parsed_obs) # VLM + DiT forward
# Stream H future actions at the control rate
action_chunk = parse_action_gr00t(action_chunk)
for j in range(action_horizon):
send_or_record(concat_per_key(action_chunk, j))
Note:
standalone_inference_script.pyruns against a recorded LeRobot dataset (offline replay/eval). On the real robot,loader[traj_id]is replaced by live sensors, and the innersend_or_record(...)becomes publish to the WBC teleop input (the same ROS topicpico_streamer.pycurrently writes to).
5 What policy.get_action() does internally
- Image branch: camera frames → vision encoder (Eagle VLM backbone).
- State branch: proprioceptive state vector → state encoder.
- Language branch: task prompt → tokenizer (cached if the prompt is constant).
- Backbone fusion: Eagle VLM produces
vl_embs(vision+language) andsa_embs(state+action) embeddings. - Action DiT: a diffusion transformer denoises a noise tensor over several timesteps, conditioned on
vl_embs+sa_embs, producing the action chunk. This is the part swapped out for a TensorRT engine (TensorRTDiTWrapper.__call__, line 132 — called once per denoising step). - Output: dict of per-modality action chunks → flattened into per-joint trajectories.
6 Real-robot loop (pseudocode)
while task_running:
parsed_obs = build_obs_from(live_cameras, live_state, task_prompt)
action_chunk, _ = policy.get_action(parsed_obs) # ~1x per H ticks (TRT)
for a in action_chunk: # stream at 50 Hz
publish_to_teleop_input(a) # where pico_streamer used to write
wait_until_next_control_tick()
publish_to_teleop_input(a)lands on the same ROS topic / streamer hook inrun_teleop_policy_loop.pythat the Pico headset normally feeds.run_g1_control_loop.pykeeps running unchanged below, consuming those targets and producing motor commands at 50 Hz via DDS.
7 TL;DR — the hierarchy
[outer ~3 Hz] policy.get_action(obs) -> 16-step action chunk (GR00T VLM + DiT, TRT)
[inner 50 Hz] stream chunk action-by-action to WBC teleop input
[inner 50 Hz] run_teleop_policy_loop.py : IK + DecoupledWBC
[inner 50 Hz] run_g1_control_loop.py : ONNX Balance/Walk RL -> rt/lowcmd DDS -> motors