WBC (Whole-Body-Control)¶
WBCPolicy
wraps NVIDIA's
GR00T Whole-Body-Control
(SONIC / decoupled-WBC) ONNX controllers for deploy-grade humanoid locomotion
on the Unitree G1. It runs in the same process through ONNX Runtime on CPU:
no sidecar, no network round-trip, no GPU.
It is a non-VLA locomotion controller: it reads its goal from the well-known
locomotion **kwargs (target_velocity), ignores camera frames
(requires_images = False), and never parses the instruction string. It drives
the 15 leg+waist DOFs of the G1 and holds the arms at their nominal defaults;
to drive the arms as well, layer a manipulation policy on top with
CompositePolicy.
Install¶
pip install "strands-robots[wbc]" # onnxruntime only - light, no torch
pip install "strands-robots[wbc,sim-mujoco]" # + MuJoCo to drive the G1 in sim
No weights are bundled and there is no default download: a bare
create_policy("wbc") raises instead of fetching the wrong model family. The
two decoupled-WBC G1 controllers are 1.8 MB each inside the 4.6 GB git-LFS tree
of NVlabs/GR00T-WholeBodyControl.
media.githubusercontent.com serves the LFS content of a public repository, so
fetch the two files (3.6 MB of ONNX) instead of cloning everything around them:
mkdir -p /path/to/grootwbc-g1 && cd /path/to/grootwbc-g1
url=https://media.githubusercontent.com/media/NVlabs/GR00T-WholeBodyControl/main/decoupled_wbc/sim2mujoco/resources/robots/g1/policy
curl -LO "$url/GR00T-WholeBodyControl-Balance.onnx" # main (Balance) policy
curl -LO "$url/GR00T-WholeBodyControl-Walk.onnx" # optional walk policy
The canonical GR00T-WholeBodyControl-Balance.onnx / -Walk.onnx filenames are
accepted verbatim; policy.onnx / walk_policy.onnx also work and take
precedence when both are present:
/path/to/grootwbc-g1/
GR00T-WholeBodyControl-Balance.onnx # main (Balance) policy
GR00T-WholeBodyControl-Walk.onnx # optional walk policy
config.json # optional
The HuggingFace repo nvidia/GEAR-SONIC
is the SONIC VLA inference stack, not this Balance/Walk family; passing it as
a checkpoint raises.
Quickstart¶
from strands_robots.policies import create_policy
policy = create_policy(
"wbc", # shorthand: "sonic"
checkpoint="/path/to/grootwbc-g1", # dir with policy.onnx (+ walk_policy.onnx)
walk=True,
)
actions = policy.get_actions_sync(
observation_dict={"observation.state": [0.0] * 29}, # G1 joint positions
instruction="walk forward", # ignored by the controller
target_velocity=[0.5, 0.0, 0.0], # [vx, vy, omega] (m/s, m/s, rad/s)
)
# actions == [{"left_hip_pitch_joint": .., ..., "waist_pitch_joint": ..}]
# one per-tick dict of 15 leg+waist joint targets (closed-loop, not a chunk)
target_velocity is held to a locomotion envelope of ±2.0 m/s per linear
component and ±2.0 rad/s for omega, at the policy and again on the mesh
before dispatch (one definition, strands_robots.locomotion_envelope). A
component past it is refused with a reason, never clamped. A faster platform
raises the bound with STRANDS_MAX_TARGET_LINEAR_VELOCITY_MPS /
STRANDS_MAX_TARGET_ANGULAR_VELOCITY_RPS (positive floats, read on every
call).
Parameters¶
WBCPolicy(
checkpoint="/path/to/grootwbc-g1", # dir with policy.onnx, a direct .onnx path, or an HF id
config=None, # WBCConfig | path | dict | None (None -> config.json in checkpoint)
walk=True, # load + prefer walk_policy.onnx for locomotion
target_velocity=None, # constructor-time default [vx, vy, omega] (per-call kwarg overrides)
allow_missing_models=False, # test seam: skip eager ONNX load (inject a stub session)
)
A missing onnxruntime or a missing checkpoint raises RuntimeError at
construction - WBC never falls back to silent zero torques.
walk and allow_missing_models each select a posture, so both are checked
rather than read by truthiness: a non-boolean raises ValueError naming the
parameter. A string such as "false" - the spelling a JSON policy_config
reaches for - is truthy, and before the check allow_missing_models="false"
selected the test seam, skipping the eager load and deferring the missing
checkpoint to the first get_actions call.
Config value domain¶
WBCConfig refuses an unusable value at construction, because every numeric
field is read verbatim into the PD law that writes data.ctrl or into the
observation, so an unusable one becomes a wrong torque rather than an error:
| Field | Accepted | Why |
|---|---|---|
action_scale |
finite > 0 |
The only path from the network to the joint targets. 0 (or False) makes target_q == default_angles, discarding the policy; a negative value inverts every offset. |
kps, kds |
finite >= 0, per component |
kp = 0 with kd > 0 is a pure-damping joint and stays valid; a negative gain makes (target_q - q) * kp drive the joint away from its target. |
default_angles, cmd_scale, rpy_cmd |
finite, per component | Signed quantities (a stance angle, a yaw rate, a roll target), so only finiteness is constrained. |
cmd_scale, obs_scales (arity) |
stated, or empty to mean the upstream default | An EMPTY cmd_scale means "not stated" and is completed with (2.0, 2.0, 0.5) at construction, so it cannot scale the velocity differently from omitting the argument. A wrong NON-empty length is still refused by name. |
obs_scales values, height_cmd, freq_cmd |
finite | A non-finite scale poisons the observation frame the network is given. |
A nan/inf or a None anywhere surfaces as a ValueError naming the field
and component at construction, not as a non-finite torque mid-rollout.
Goal kwargs¶
WBC reads locomotion commands from **kwargs, sharing the non-VLA goal
vocabulary so a command can flow through run_policy / mesh tell() without
coupling to a backend:
| Key | Type | Accepted | Meaning |
|---|---|---|---|
target_velocity |
list[float] |
numeric, >= 3 entries, every component finite | Locomotion command [vx, vy, omega] (m/s, m/s, rad/s). Scaled by cmd_scale ([2.0, 2.0, 0.5]) into the observation's command block. |
target_orientation |
list[float] |
numeric, >= 3 entries, every component finite | Target base [roll, pitch, yaw] (rad), written to command slots [4:7]. Defaults to the config rpy_cmd ([0,0,0]). |
height |
float |
finite | Target base height (m), written to command slot [3]. Defaults to the config height_cmd (0.74). |
A per-call target_velocity overrides the constructor-time default; with no
command the controller holds a standing balance, and None means "not
supplied". target_velocity is one of the issue #300 well-known goal keys, so
the mesh path forwards it the same way it forwards a planner's goal:
mesh.tell(peer, "walk forward", policy_provider="wbc", target_velocity=[0.5, 0.0, 0.0]).
target_orientation and height are WBC's own kwargs. Each key keeps the
domain WBCConfig enforces for the field it overrides; the vector keys need at
least three components because the command block is zero-initialised, so a
shorter one would silently command zero for an unmentioned axis (a longer one is
truncated).
Control contract¶
WBC reproduces the upstream GearWbcController loop (NVlabs/GR00T-WholeBodyControl
decoupled_wbc/sim2mujoco, run_mujoco_gear_wbc.py + g1_gear_wbc.yaml):
- Two ONNX sessions -
policy.onnxand an optionalwalk_policy.onnx, loaded once. When the raw velocity-command norm is<= 0.05the main (standing) policy runs; above that the walk policy (whenwalk=True). - Observation - an 86-dim frame stacked over
obs_history_len(default 6, network input86 * 6 = 516): command[0:7]=[vx*2.0, vy*2.0, omega*0.5, height, roll, pitch, yaw], base angular velocity[7:10](ang_vel_scale=0.5), projected gravity[10:13], joint positions[13:28](minusdefault_angles,dof_pos_scale), joint velocities[28:43](dof_vel_scale=0.05), previous action[43:58], reserved zero tail[58:86]. The upstream YAML's flat scale keys are normalised into the nestedobs_scalesmap, and a config states only the scales it changes; the rest keep the upstream defaults. An emptycmd_scalemeans "not stated" and resolves to(2.0, 2.0, 0.5), never to a bare1.0- which would command a yaw rate double the one asked for. - Action - a 15-dim joint-position offset; the policy forms
target_q = default_angles + action_scale * raw, keyed by actuator name. For torque-actuated MuJoCo, convert withpolicy.compute_torques(target, q, dq).
Actuator mapping¶
WBC output index i drives WBC_G1_LEG_WAIST_JOINTS[i] - an explicit table.
set_robot_state_keys validates that the robot's first 15 joints match this
order and raises otherwise:
left_hip_pitch_joint, left_hip_roll_joint, left_hip_yaw_joint,
left_knee_joint, left_ankle_pitch_joint, left_ankle_roll_joint,
right_hip_pitch_joint, right_hip_roll_joint, right_hip_yaw_joint,
right_knee_joint, right_ankle_pitch_joint, right_ankle_roll_joint,
waist_yaw_joint, waist_roll_joint, waist_pitch_joint
In simulation¶
from strands_robots import Robot
sim = Robot("unitree_g1") # sim-by-default; CPU ONNX, no GPU needed
sim.run_policy(
robot_name="unitree_g1",
instruction="walk forward", # ignored by the controller
policy_provider="wbc",
policy_config={"checkpoint": "/path/to/grootwbc-g1", "walk": True},
policy_kwargs={"target_velocity": [0.5, 0.0, 0.0]}, # per-call locomotion goal
duration=10.0,
control_frequency=50.0,
action_horizon=1, # WBC is closed-loop per tick
)
The stock Menagerie G1 ships position-servo actuators with a uniform kp=500
that overrides SONIC's tuned PD, so writing targets to them directly makes the
robot fall. run_policy therefore detects a WBCPolicy on a position-servo
scene and installs the torque shim (WBCTorqueController, PD->torque) for the
call, restoring the actuators afterwards so a second call behaves like the
first. That install is the MuJoCo engine's: the shim is written against a
compiled MjModel, so on any other backend (newton, isaac) a WBC rollout is
refused up front - naming backend="mujoco" and the opt-out below - instead of
running without it. Unrefused it reported status="success" while the pelvis
sank from 0.793 m to 0.074 m in one second. The shim's physics_substeps_per_control (upstream control_decimation=4
at 0.005 s = one inference per 20 ms) must be a positive integer, because the
gait clock integrates at the declared period. With the real weights and
target_velocity = [0.5, 0, 0] the base advances ~1.9 m over 5 s at pelvis
height ~0.75 m. Opt out to drive a torque scene directly:
A static velocity can be set once via
policy_config={"checkpoint": ..., "target_velocity": [0.5, 0.0, 0.0]}; that is
also the way to evaluate WBC at a fixed velocity, since policy_kwargs is wired
on the control path (run_policy / start_policy / tell()), not on
eval_policy.
Recording it¶
run_policy(video={...}) records from the scene's default camera unless told
otherwise, and that view is a fixed function of the compiled model - its pose
does not move while the robot does. On the stock G1 scene the pelvis crosses the
right edge of the 640x480 default view after 1.5 m of forward walk (under 4 s at
0.4 m/s) and the rest of the clip is empty floor. Add a camera first and name it
in video. Mounted on the pelvis it rides with the robot and turns with it -
position and target are then in the pelvis frame, x forward:
sim = Robot("unitree_g1")
sim.add_camera(
name="follow",
parent_body="unitree_g1/pelvis",
position=[-2.6, -1.6, 1.1], # behind and to the right, a little above
target=[0.4, 0.0, -0.3], # looking just ahead of the base
fov=45,
width=1280,
height=720,
)
sim.run_policy(
robot_name="unitree_g1",
policy_provider="wbc",
policy_config={"checkpoint": "/path/to/grootwbc-g1", "walk": True},
policy_kwargs={"target_velocity": [0.5, 0.0, 0.3]},
duration=6.0,
control_frequency=50.0,
action_horizon=1,
video={"path": "/tmp/g1_follow.mp4", "fps": 30, "camera": "follow", "width": 1280, "height": 720},
)
The mount holds the base at one pixel for the whole rollout, so a longer walk
needs no re-placement.
examples/microduck/eval_rl_policy.py
records a walking robot this way: a chase camera mounted on
microduck/trunk_base, added before the rollout and named in its video.
A fixed camera works when the path is known -
examples/kimodo/kimodo_g1_walking.py
calls add_camera with position=[3.0, 0.0, 1.2], target=[0.0, 0.0, 0.8] to
face the G1 where it starts. Either way the camera has to be added before the
rollout; add_camera is refused while a policy is running.
Watching it walk (torque-control deploy)¶
examples/wbc/wbc_g1_torque_deploy.py
reproduces the upstream deploy loop directly - torque motors,
policy.compute_torques(...) at control_decimation=4, whole-body observation
with real joint velocities + base IMU:
python examples/wbc/wbc_g1_torque_deploy.py --checkpoint /path/to/grootwbc-g1 \
--duration 5 --vx 0.5 --mp4 /tmp/g1_walk.mp4
With the real weights this produces a stable forward walk (~0.38 m/s for a
0.5 m/s command); --vx 0 holds balance in place.
WBCPolicy (GR00T-WBC SONIC, walk_policy.onnx)
commanded at vx = 0.5 m/s — the torque-PD deploy loop in MuJoCo (headless).
The base advances ~2.3 m over 6 s (~0.38 m/s) while holding pelvis height ~0.75 m and
staying upright. Produced by examples/wbc/wbc_g1_torque_deploy.py --vx 0.5 --mp4
(MP4).Composing an upper body (manipulation on top of WBC)¶
To layer a manipulation policy on the arms while WBC keeps the robot balanced
and walking, wrap both in CompositePolicy: the lower
policy owns legs+waist, the upper policy owns the arms, and the composite queries
both each tick and merges their action dicts by joint name.
from strands_robots.policies import CompositePolicy, create_policy
from strands_robots.policies.wbc import WBC_G1_ALL_JOINTS, WBC_G1_LEG_WAIST_JOINTS
ARM_JOINTS = WBC_G1_ALL_JOINTS[len(WBC_G1_LEG_WAIST_JOINTS):] # the 14 arm DOFs
lower = create_policy("wbc", checkpoint="/path/to/grootwbc-g1")
upper = create_policy("groot", port=5555) # or pi0 / MolmoAct / any Policy
policy = CompositePolicy(
lower=lower,
upper=upper,
lower_joints=WBC_G1_LEG_WAIST_JOINTS, # legs + waist
upper_joints=ARM_JOINTS, # both arms
)
Each child contributes only its own joint group; a genuine ownership conflict
is raised, never silently resolved, and the merged chunk length is the shorter
of the two so the per-tick controller is never starved. An explicit group is
exclusive either way round - the child it names is the only one allowed to
command those joints, so a whole-body manipulation policy paired with
lower_joints=WBC_G1_LEG_WAIST_JOINTS is refused rather than allowed to drive
the waist on the ticks the balance controller happens not to command it. lower_obs_keys /
upper_obs_keys optionally narrow what each child is queried with, and a subset
sharing no key with the observation is refused - a balance controller reading
nothing runs open-loop. Run the composite like a bare policy; the torque shim is
installed for the WBCPolicy inside it (or inside a PersistentPolicy) and
runs a light PD (kp = 100, kd = 0.5) on each arm joint toward the upper
policy's target:
sim.run_policy(
robot_name="unitree_g1",
policy_object=policy,
policy_kwargs={"target_velocity": [0.5, 0.0, 0.0]},
control_frequency=50.0,
n_steps=500,
)
examples/wbc/wbc_g1_composite.py
runs the composite in the torque-deploy loop with a scripted arm-wave as the
upper body (--upper-port for a real GR00T server):
python examples/wbc/wbc_g1_composite.py --checkpoint /path/to/grootwbc-g1 \
--duration 5 --vx 0.4 --mp4 /tmp/g1_composite.mp4
CompositePolicy: WBCPolicy (GR00T-WBC SONIC)
drives the legs+waist for a vx = 0.4 m/s walk while the upper-body policy drives the
arms - the base advances ~1.55 m over 5 s while the arms move, in MuJoCo (headless).
Produced by examples/wbc/wbc_g1_composite.py --vx 0.4 --mp4
(MP4).Gait-clock variant¶
NVIDIA's reference repo ships a second G1 controller - a single-policy
gait-clock variant (95-dim observation, an 8-wide command with a
freq_cmd step-frequency slot, and a 2-dim bipedal phase clock). It is
implemented by WBCGaitPolicy (provider wbc_gait). See
WBC gait-clock variant.
See also¶
- Policy overview
- GR00T - ZMQ service VLA (manipulation upper body).
- Custom policies - implement the non-VLA goal-kwargs contract.
- GR00T-WholeBodyControl