A Unitree G1 humanoid that picks a soccer ball up off a table and then acts like a goalkeeper: turns around, watches for a person, and throws the ball straight ahead once no one is around.
This file is the canonical project overview — architecture, current state, how the
pieces fit together, and what's still TODO. It's written for AI coding agents picking
up this repo cold as much as for humans. For Cursor-Cloud-specific dev-environment
setup (uv sync flags, CPU-only sim notes, lint/test commands) see AGENTS.md
— that file is the "how do I get a shell working here" doc; this one is the "what is
this project and why" doc. Don't duplicate between them: environment mechanics go in
AGENTS.md, product/architecture decisions go here.
Two phases, run back to back on the real robot:
- Learned pickup (VLA policy). The robot sees a ball on a table of fixed height and picks it up. This is the only part that's learned from demonstrations.
- Scripted goalkeeper sequence (hardcoded, no learning involved). Once the pickup
finishes (a fixed number of timesteps after phase 1 completes):
- Turn 180°.
- Watch for a person using the depth-sense camera.
- While a person is detected, shuffle side to side (like a goalkeeper guarding a goal).
- Once no person has been detected for 6 continuous seconds, throw the ball straight ahead. The throw motion itself is a fixed, hardcoded trajectory — it is not learned or planned at runtime.
Phase 1 and phase 2 are deliberately separate systems (see Repo layout below) — phase 2 has no VLA/model dependency at all.
- Robot: Unitree G1 humanoid.
- Learned action space: arms only, 14-D (
training/embodiment_g1.py) — 7 DOF per arm (shoulder pitch/roll/yaw, elbow, wrist roll/pitch/yaw) × left/right. No waist, no legs, no hand/finger joints in the learned policy. - Hands: the robot physically has BrainCo Revo 2 hands, but there is no learned
grasping. The hands are not part of the GR00T action space. Picking the ball up
is achieved through arm positioning alone (the hand is held in a fixed pose); the
earlier prototype that gave the hands 6 active DOF each (26-D total embodiment,
see
training/embodiment_g1_revo2.pyin git history) was deliberately reverted — see commit75d3330. - Camera: single RGB-D camera, dual purpose. The same physical camera feeds RGB
frames to the GR00T policy during pickup and provides the depth stream used for
human detection during the scripted phase. There is no separate dedicated
depth-sense sensor. (LeRobot has existing RealSense driver support —
thirdparty/lerobot/src/lerobot/cameras/realsense/— and dataset-level depth-map handling, which is the natural fit here, though the exact camera model/driver wiring is not yet chosen.)
┌─────────────────────────┐
100 real demo │ training/ (Modal) │
episodes ───────▶ │ fine-tune GR00T N1.7 │──▶ checkpoint (HF Hub / Modal volume)
(TODO, see below) │ on G1 arms-only 14-D │
└─────────────────────────┘
│
▼
┌─────────────────────────┐ ┌──────────────────────────┐
robot sensors ───▶ │ local-vla-inference/ │───▶│ scripted-behavior/ │
(RGB-D camera, │ loads checkpoint, runs │ │ hardcoded FSM: turn, │
arm state) │ GR00T on real G1 arms │ │ detect, shuffle, throw │
│ for the pickup phase │ │ (no model, no learning) │
└─────────────────────────┘ └──────────────────────────┘
The hard boundary between local-vla-inference/ and scripted-behavior/ is intentional
and was an explicit design decision (not just a naming convenience): the pickup
policy and the goalkeeper sequence must stay decoupled so the hardcoded phase never
depends on the model, and vice versa.
This is a uv workspace (see root pyproject.toml, [tool.uv.workspace] members).
| Member | Status | Purpose |
|---|---|---|
training/ |
Implemented, tested, validated end-to-end on Modal | CLI that submits LeRobot/GR00T/MolmoAct2 fine-tuning jobs to Modal GPUs. See Training pipeline below. |
thirdparty/lerobot/ |
Vendored (Hugging Face LeRobot) | Essentially all real ML functionality (datasets, policies, training loop, GR00T/MolmoAct2 implementations) lives here. See thirdparty/lerobot/AGENTS.md for its own architecture notes. Treat as upstream — don't hand-edit unless necessary, and if you do, note it clearly since it complicates future upstream syncs. |
local-vla-inference/ |
Stub only (main.py just prints hello), plus a working install.sh |
Where the real-time control loop belongs: load the fine-tuned GR00T checkpoint, run it against live camera/arm-state input, drive the G1's arms via unitree_sdk2py during the pickup phase. The control loop itself isn't built yet, but install.sh already handles the tricky part of its dependency chain — see Local inference setup below. (Renamed from vla-inference/.) |
scripted-behavior/ |
Stub only (main.py just prints hello), newly scaffolded |
The hardcoded post-pickup FSM: turn 180°, depth-based human detection, side-to-side shuffle, 6s-timeout-triggered throw. Deliberately kept out of local-vla-inference/ — no model dependency, pure scripted robot control. Depends on unitree_sdk2py; will likely need a depth-frame human-detection method (simple depth-based presence/proximity check vs. a person-detection model — not yet decided). |
soccerbot/ |
Empty stub, unclear future role | src/soccerbot/main.py and README.md are both empty. Original root orchestrator idea from the very first commit ("create core orchestrator"); nothing built on it since. Don't assume it has a settled purpose — check with the user before building on it. |
datasets/lerobot_dummy_1episode.zip |
Synthetic fixture, not real robot data | A 1-episode, 60-frame LeRobot v2.1-format dataset with a 6-DOF SO-100 arm layout (task: "pick up the red cube"). Used only to smoke-test the training pipeline (see below) — it has no relationship to the G1's action space and should never be mistaken for real task data. |
training/main.py is a Modal launcher for lerobot-train. It:
- Bakes the vendored
thirdparty/lerobotsource into a Modal container image and installs it with the extras needed per-policy (dataset,training,groot,molmoact2). - Resolves
--datasetas either a local directory (uploads it to a Modal Volume) or a Hugging Face Hub id. - Normalizes friendly GPU names (
h200,a100-80gb,b100,H100:2, ...) into Modal GPU shortcodes. - Translates CLI flags into
lerobot-trainargs, forwardsHF_TOKEN/WANDB_*from the local environment as Modal secrets, and runs training on a GPU worker.
Supported --policy values include groot and molmoact2 (the two that matter for
this project) plus LeRobot's other stock policies (act, diffusion, smolvla,
pi0, etc. — see KNOWN_POLICIES in training/main.py).
modal run training/main.py --dataset ./g1_demos --gpu B200 --policy groot --steps 5000When --policy groot is used, training/main.py automatically:
- Points at
nvidia/GR00T-N1.7-3B(training/embodiment_g1.py: BASE_MODEL_PATH) with--policy.embodiment_tag=new_embodiment. - Freezes the VLM backbone and vision tower (
tune_llm=false,tune_visual=false,tune_top_llm_layers=0); trains the DiT action head, projector, and VLM layernorms (tune_projector=true,tune_diffusion_model=true,tune_vlln=true). No LoRA. - Uses bf16 (
use_bf16=true) and relative actions (use_relative_actions=true). - Validates the local dataset's
meta/info.jsonagainst the 14-D arms-only layout before submitting (validate_dataset_layoutinembodiment_g1.py) — fails fast locally rather than burning GPU time on a mismatched dataset. This only runs for local dataset paths, not Hub ids.
GR00T defaults from LeRobot's own config (thirdparty/lerobot/src/lerobot/policies/groot/configuration_groot.py):
chunk_size=40, n_action_steps=40, max_state_dim=132/max_action_dim=132
(padded — our 14-D state/action fits well inside), default batch_size=32.
--onlyactionexpertft is accepted for groot as a no-op (GR00T already trains
action-expert-only by default; the flag exists so the same CLI surface works for
both policies below).
MolmoAct2 support was built and validated end-to-end in an earlier session: a
LoRA-VLM fine-tune (500 steps, batch 8, H100) was run against the dummy SO-100
dataset above and pushed to AmoghShrivastava1/soccerbot-molmoact2-dummy-smoketest
on Hugging Face. That run proved the Modal → LeRobot → MolmoAct2 training pipeline
works, but it is not G1-relevant — wrong dataset, wrong embodiment, wrong task,
purely a pipeline smoke test. Default mode is LoRA-VLM fine-tuning
(--policy.enable_lora_vlm=true); pass --onlyactionexpertft to instead freeze the
VLM and train only the action expert (requires continuous action mode).
GR00T is the intended production path for this project; MolmoAct2 is kept around as an alternative worth revisiting if GR00T doesn't pan out.
These came up fine-tuning on Modal from a Windows machine — may not all apply from Linux/Mac, but the first two are Modal-specific and apply everywhere:
modal.Image.add_local_dirmust be the last build step, unlesscopy=True. Our image needs touv pip install -ethe locally-mounted LeRobot source after adding it, soadd_local_dir(..., copy=True)is required — otherwise Modal raisesInvalidErrorat image-build time.- LeRobot dataset format v3.0 is required; older exports are v2.1. If you hit
BackwardCompatibilityError, convert with LeRobot's ownpython -m lerobot.scripts.convert_dataset_v21_to_v30 --repo-id=<id> --root=<path> --push-to-hub=false(the CLI's--push-to-hubdefaults totruewith no way to fully suppress the tag/delete calls on the Hub side if you don't passfalseand don't have write access — pass it explicitly). - Policies using
QUANTILESnormalization (GR00T, MolmoAct2) needq01/q99dataset stats. Older/hand-built datasets often don't have them. Fix withlerobot.scripts.augment_dataset_quantile_stats— but note its CLI unconditionally callsdataset.push_to_hub()with no opt-out flag. If you don't want a Hub push (e.g. no token, or a purely local dataset), callcompute_quantile_stats_for_dataset+write_statsdirectly instead of using the script'smain(). - On Windows specifically:
pathlib.Path("/outputs")/Path("/data")resolve toWindowsPathlocally, so remote-side path strings built on the submitting machine come out backslash-separated and break on the Linux Modal worker — usePurePosixPathfor anything that becomes a remote path string (already fixed intraining/main.py). Also, Git Bash/MSYS silently rewrites POSIX-looking CLI args (e.g.--dataset.root=/data/...) into Windows paths before your process ever sees them — prefix commands withMSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL="*"to stop it. And Modal's CLI prints Unicode (✓) that breaks on Windows' defaultcp1252console encoding when output is redirected — setPYTHONIOENCODING=utf-8 PYTHONUTF8=1. modal volume gethas hit permission/directory-download bugs on Windows when pulling a directory back to local disk. Workaround used successfully: don't download at all — point--dataset.root(or any file op) directly at the Modal Volume path from a one-offmodal runscript that does the work inside the container (e.g. pushing a checkpoint straight to the Hub from within the container instead of downloading it locally first).
Not started. Plan is ~100 real-world episodes of the G1 picking a ball up off a
table of fixed height, in LeRobot dataset format (v3.0). No collection tooling,
protocol, or storage location has been decided yet — this is the next concrete
blocker before a real GR00T fine-tune can happen. Do not confuse this with
datasets/lerobot_dummy_1episode.zip, which is synthetic SO-100-arm fixture data
used only for pipeline testing (see table above).
Open questions once collection starts: teleop method, camera calibration/mounting,
episode length/task-string convention, and where the resulting dataset lives (local
disk uploaded per-run via training/main.py --dataset ./path, or pushed to the Hub
first).
- Modal + LeRobot training pipeline (
training/) — built, tested, validated end-to-end (both GR00T CLI wiring and a real MolmoAct2 training run). - G1 arms-only 14-D embodiment definition + dataset-layout validation
(
training/embodiment_g1.py). - Collect ~100 real pickup episodes.
- Fine-tune GR00T on real G1 data (currently only wired, never run against real data).
- Build
local-vla-inference/: real-time control loop running the fine-tuned GR00T checkpoint against live sensors, driving the G1 viaunitree_sdk2py. - Build
scripted-behavior/: turn-180 → depth-based human detection → shuffle → 6s-timeout throw FSM. Human-detection method not yet chosen. - Design and hardcode the actual throw trajectory.
- Decide camera hardware/driver (RealSense is the natural fit given existing LeRobot support, not confirmed).
- Decide what, if anything,
soccerbot/is for.
See AGENTS.md for environment mechanics (uv sync flags, Python
version pinning, CPU-only sim caveats, lint/test commands). In short:
uv sync --locked --all-packages --extra dev --extra test --extra pusht -p 3.13unitree_sdk2py depends on CycloneDDS, which needs a local C build —
local-vla-inference/install.sh handles this: clones and builds CycloneDDS
(releases/0.10.x branch) to ~/cyclonedds/install (override with
CYCLONE_SRC/CYCLONE_PREFIX/CYCLONE_BRANCH env vars), then runs
uv sync -p 3.13 --all-packages. CYCLONEDDS_HOME must stay set whenever
importing unitree_sdk2py. This is a real build step (needs cmake), separate
from — and unrelated to — the Modal-side training setup below.
For Modal training runs you'll additionally need modal setup (or
modal token set --token-id ... --token-secret ...) to authenticate, and optionally
HF_TOKEN in the environment if pushing trained policies to the Hub.