Step-by-step guide for reproducing upper-body teleoperation of the Unitree G1 robot from recorded video.
Pipeline: Video → PromptHMR (3D pose) → GMR (retarget) → GR00T-WBC (sim/hardware)
Check the Glossary for definitions of technical terms used in this guide.
Imagine watching a video of someone waving their arms and having a robot copy those exact movements in real time. That is what this pipeline does. It takes an ordinary video of a person, figures out how their body is moving in 3D space, translates those movements into commands a robot can understand, and then replays the motion on a simulated Unitree G1 humanoid robot.
The technical term for controlling a robot by demonstrating motions yourself is teleoperation. Traditional teleoperation usually requires special suits, sensors, or joysticks strapped to the operator’s body. This project replaces all of that hardware with a single video camera. You record yourself (or anyone) performing a motion, and the pipeline handles the rest.
The pipeline is split into three stages because each one solves a fundamentally different problem. Stage 1 (PromptHMR) watches the video and reconstructs a detailed 3D model of the human body — skeleton positions, joint angles, even finger poses — frame by frame. Stage 2 (GMR) takes that human skeleton and “retargets” it onto the robot’s skeleton, which has different proportions, fewer joints, and mechanical limits that a human body does not have. Stage 3 (GR00T-WBC) loads the retargeted motion into a physics simulator, where a balance controller keeps the robot standing upright while its arms replay the recorded motion. If everything looks good in simulation, the same motion can be sent to a real robot.
You do not need a background in machine learning or robotics to follow this guide. Each stage is wrapped in a single command, and the setup script handles the heavy lifting of installing dependencies. That said, a basic comfort level with the Linux terminal (running commands, editing files) will make the process smoother.
gcloud CLI with access to gs://io-robotica (or SMPL-X credentials as fallback)Disk space and time estimates:
| Component | Disk Space | Notes |
|---|---|---|
| PromptHMR checkpoints | ~8 GB | Downloaded by setup.sh |
| SMPL-X body models | ~1 GB | Pulled from GCS; symlinked between PromptHMR and GMR |
| GR00T-WBC Docker image | ~31 GB | Pulled during setup |
| Per-video results | ~50 MB | Stages 1 + 2 combined |
| Total minimum | ~50 GB | Free disk before starting |
| Pipeline Stage | Time per Video |
|---|---|
| Stage 1 (PromptHMR) | ~10-30 min depending on video length and GPU |
| Stage 2 (GMR retarget) | ~1-2 min |
| Stage 3 (GR00T sim) | Real-time (interactive) |
# Install uv if not present
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
# Install system dependencies
sudo apt update && sudo apt install -y git git-lfs curl ffmpeg
# Verify NVIDIA driver
nvidia-smi
Stage 3 (GR00T-WBC) runs inside a Docker container that needs GPU access. If you already have docker --version and nvidia-container-cli info working, skip this section.
# Install Docker (official method)
curl -fsSL https://get.docker.com | sh
# Allow your user to run Docker without sudo (requires logout/login after)
sudo usermod -aG docker $USER
newgrp docker # apply group change in current shell
# Install NVIDIA Container Toolkit (GPU access inside Docker)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt update && sudo apt install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# Verify GPU is visible inside Docker
docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu22.04 nvidia-smi
Common issue: If you see “permission denied” when running
docker, you need to log out and back in (or reboot) after theusermodcommand for the group change to take effect.
The fastest way to get body models and checkpoints is from our GCS bucket. If you don’t have gcloud, setup.sh will fall back to manual downloads (slower, requires SMPL-X registration).
# Install the gcloud CLI
curl https://sdk.cloud.google.com | bash
exec -l $SHELL # restart shell to pick up gcloud
# Log in and verify bucket access
gcloud auth login
gcloud storage ls gs://io-robotica/ # should list bucket contents
If you cannot get GCS access, setup.sh will automatically fall back to:
cd ~/Projects
git clone https://github.com/bushuyeu/iolani-g1-workshop && cd iolani-g1-workshop
bash setup.sh # clones sub-repos, creates venvs, installs deps, sets up symlinks
just check # verify everything is set up (should show all [OK])
The setup.sh script is idempotent — safe to re-run if something fails partway through.
setup.sh doesdata/videos/, results/, and .env from templateWhy separate venvs? PromptHMR, GMR, and GR00T-WBC have irreconcilable dependency trees — different PyTorch builds, different Python versions (3.12 vs 3.10), and conflicting transitive dependencies. A single environment cannot satisfy all three.
Why symlink body models? The SMPL-X body model files are ~1 GB. Both PromptHMR and GMR need them, so
setup.shsymlinks them from PromptHMR into GMR rather than downloading or storing a second copy.
Place your video file in data/videos/:
cp /path/to/your/video.mp4 data/videos/
Video requirements:
Videos from the team pool: https://drive.google.com/drive/folders/11I9UZfqr_JanmgzVx3qM0zNF3YzqaEuW
just phmr-run data/videos/your_video.mp4
This runs the full PromptHMR pipeline:
Runtime: ~10-30 minutes per video depending on length and GPU.
Why headless batch mode? PromptHMR can run without a display, making it suitable for batch processing over SSH. The
just phmr-batchrecipe processes all videos indata/videos/sequentially.
Output: results/<video_name>/phmr_results.pkl
Also saved in PromptHMR’s own results dir:
PromptHMR/results/<video_name>/results.pkl — full results dict (joblib-compressed)PromptHMR/results/<video_name>/world4d.glb — viewable in BlenderPromptHMR/results/<video_name>/world4d.mcs — viewable at meshcapade.com/editorPromptHMR/results/<video_name>/subject-*.smpl — per-person SMPL codecA successful run prints progress for each pipeline step (SPEC calibration, SAM2 tracking, DROID-SLAM, ViTPose, PromptHMR inference, world conversion, optimization) and finishes without errors. The final lines will show the post-optimization completing and results being saved.
Expected files and sizes (reference: PXL_20260114_220015872, a ~14s video):
| File | Location | Approx. Size |
|---|---|---|
phmr_results.pkl |
results/<video_name>/ |
~1.7 MB |
results.pkl |
PromptHMR/results/<video_name>/ |
~1.7 MB |
world4d.glb |
PromptHMR/results/<video_name>/ |
~65 MB |
world4d.mcs |
PromptHMR/results/<video_name>/ |
~167 KB |
subject-1.smpl |
PromptHMR/results/<video_name>/ |
~110 KB |
How to verify the output:
world4d.glb in Blender — you should see a 3D human mesh replaying the motions from the video in world coordinates.world4d.mcs to meshcapade.com/editor for a quick browser-based preview.phmr_results.pkl file should be loadable with joblib.load() and contain SMPL-X parameters (body pose, global orientation, translation) for each frame.# Process all videos in data/videos/
just phmr-batch
just gmr-retarget data/videos/your_video.mp4
This maps the 55-joint SMPL-X human skeleton to the Unitree G1’s joint space.
Why headless retargeting? GMR supports headless mode (no MuJoCo viewer window), which allows batch processing over SSH without a display. The Justfile recipe uses this automatically.
Output: results/<video_name>/retarget_unitree_g1.pkl
The output contains:
fps: frame rate (typically 30)dof_pos: joint angles (T, 29) — 29 degrees of freedomroot_pos: root position (T, 3)root_rot: root rotation quaternion (T, 4)The retargeting runs in under 2 minutes. Terminal output shows GMR loading the SMPL-X body model, reading the PromptHMR results, performing the joint-space optimization, and saving the retarget pickle. No errors or warnings should appear.
Expected files and sizes (reference: PXL_20260114_220015872):
| File | Location | Approx. Size |
|---|---|---|
retarget_unitree_g1.pkl |
results/<video_name>/ |
~123 KB |
How to verify the output:
import pickle
with open("results/<video_name>/retarget_unitree_g1.pkl", "rb") as f:
data = pickle.load(f)
print(data.keys()) # dict_keys(['fps', 'root_pos', 'root_rot', 'dof_pos'])
print(data["fps"]) # 30
print(data["root_pos"].shape) # (T, 3) — e.g. (428, 3)
print(data["root_rot"].shape) # (T, 4) — e.g. (428, 4)
print(data["dof_pos"].shape) # (T, 29) — e.g. (428, 29)
T is the number of frames (428 for a ~14 second video at 30 fps).dof_pos values should be in radians, typically in the range [-3.14, 3.14].root_rot should be unit quaternions (norm close to 1.0).# Retarget with visualization (default)
just gmr-retarget data/videos/your_video.mp4
# Specify a different robot
just gmr-retarget data/videos/your_video.mp4 unitree_h1
# Batch retarget all videos
just gmr-batch
This stage runs inside Docker and requires two terminals.
Why Docker for GR00T-WBC? The GR00T whole-body controller requires ROS2 Humble, a specific MuJoCo version (3.2.6), NVIDIA toolkit integration, and Python 3.10 — a stack that is difficult to install natively alongside the other repos’ Python 3.12 environments. Docker encapsulates this entirely.
The MuJoCo viewer needs to open a graphical window. On Linux, this uses X11 — a system that manages display output. Docker containers don’t have their own display, so you need to share yours with the container.
Working over SSH? You need X11 forwarding enabled: connect with
ssh -X user@host. Alternatively, use a remote desktop (VNC, NoMachine) for better performance. Without a display server, the MuJoCo viewer cannot open.
On the host (not inside Docker), find your display and allow Docker access:
# Find your display number
echo $DISPLAY
# Example output: ":2" — if blank, you may not have a display server running
# Allow Docker to access your display
xhost +local:docker
The Docker container mounts the directory it is launched from — ~/Projects/iolani-g1-workshop/GR00T-WholeBodyControl per Section 5.3. Copy results there:
mkdir -p ~/Projects/iolani-g1-workshop/GR00T-WholeBodyControl/resources/poses/
cp results/<video_name>/retarget_unitree_g1.pkl \
~/Projects/iolani-g1-workshop/GR00T-WholeBodyControl/resources/poses/<video_name>.pkl
cd ~/Projects/iolani-g1-workshop/GR00T-WholeBodyControl
./docker/run_docker.sh --root
# (enter sudo password if prompted)
# Inside the container:
python gr00t_wbc/control/main/teleop/run_g1_control_loop.py
A MuJoCo viewer window should open showing the G1 robot.
If you get Failed to open display :0:
export DISPLAY=:2 # match your host display
python gr00t_wbc/control/main/teleop/run_g1_control_loop.py
Open a new terminal on the host, then attach to the same container:
docker exec -it gr00t_wbc-bash-root /bin/bash
# Inside the container:
python gr00t_wbc/control/main/teleop/publish_upper_body_from_results.py --results resources/poses/<video_name>.pkl --loop --teleop-frequency 30 --hand-mode zero --speed 0.25 --initial-pose-seconds 10.0 --upper-body-only
Why
--speed 0.25? Playing back arm motions at full speed (1.0) generates torques that exceed what the balance policy can compensate for, causing the robot to fall. Quarter speed keeps motions within the stability envelope.
In Terminal 1 (the terminal running the control loop, NOT the viewer window):
] — activates the balance policy. You should see Use policy action: True printed.Control loop (Terminal 1):
], the terminal prints Use policy action: True.Publisher (Terminal 2):
--speed 0.25, the motion replays at quarter speed for stability.--loop flag causes the motion to repeat indefinitely.In the MuJoCo viewer:
9 to release the elastic band after the initial pose settles).How to verify it is working correctly:
upper_body_joint_speed is 5.0, not 1000, in configs.py).Why the elastic band? The simulated robot starts suspended in the air by a virtual elastic band. This gives the balance policy time to initialize and reach a stable standing pose before gravity takes full effect. Releasing the band too early (before the policy activates) causes the robot to collapse.
Why keyboard in the terminal, not the viewer? The control loop uses the
sshkeyboardlibrary, which reads keypresses from terminal stdin. The MuJoCo viewer is a separate window with its own input handling (e.g.,win the viewer toggles floor tiles). Robot commands only work in the terminal whererun_g1_control_loop.pyis running.
| Key | Action |
|---|---|
] |
Activate policy |
o |
Deactivate policy |
9 |
Toggle elastic band (release/hold robot) |
w/s |
Forward/backward velocity (+/- 0.2) |
a/d |
Strafe left/right |
q/e |
Rotate left/right |
z |
Zero all navigation commands |
1/2 |
Raise/lower base height |
` |
Emergency stop |
9 in the terminal (not viewer) to release it. Wait for the initial pose to settle first.upper_body_joint_speed is set to 5.0 (not 1000) in configs.py. Use --speed 0.25 for the publisher. The value must stay below the 6.0 rad/s arm velocity safety limit.--speed 0.1) or restart the control loop.--sim-sync-mode crashes: Known issue. Use default async mode.groups: cannot find name for group ID 994: Harmless Docker warning — can be safely ignored..pkl not found inside container: The Docker container mounts the directory it was launched from (~/Projects/iolani-g1-workshop/GR00T-WholeBodyControl per Section 5.3). Make sure you copied the .pkl to that directory’s resources/poses/ subdirectory (see Section 5.2).# Stage 1 + 2
just pipeline data/videos/my_video.mp4
# Stage 3 (manual — see Section 5)
just groot-sim data/videos/my_video.mp4
just check # verify envs and dependencies
just results # list all processed outputs
results/
└── <video_name>/
├── phmr_results.pkl # Stage 1: 3D pose (SMPL-X)
└── retarget_unitree_g1.pkl # Stage 2: Robot joint angles
Each sub-repo has its own virtual environment due to irreconcilable dependencies:
| Repo | Python | PyTorch | Notes |
|---|---|---|---|
| PromptHMR | 3.12 | 2.6.0+cu126 | Custom wheels, xformers |
| GMR | 3.12 | (via mujoco) | MuJoCo, smplx |
| GR00T-WBC | 3.10 (Docker) | 2.6.0+cu124 | ROS2 Humble, MuJoCo 3.2.6 |
Body models (SMPL-X) are symlinked from PromptHMR → GMR to avoid duplication.
GR00T-WBC runs exclusively inside Docker (nvgear/gr00t_wbc:latest, ~31GB image).