GR00T whole-body VLA data: sinh data sim
Disclosure: Bài viết có thể chứa affiliate/referral links. Nếu bạn mua hoặc đăng ký qua các link đó, VnRobo có thể nhận commission hoặc credit. Nội dung kỹ thuật ưu tiên tính đúng và khả năng chạy được.
Phần 1 đã dùng open dataset để đi qua pipeline download -> verify -> fine-tune -> inference. Phần 2 chuyển sang data sim: sinh trajectory trong Isaac Lab / IsaacLab-Arena, đưa về GR00T-LeRobot, rồi train theo hai kiểu:
- Sim-only: chỉ dùng data sim.
- Mix sim + public: trộn data sim với public dataset để giảm overfit vào simulator.
Điểm cần nói rõ: tính đến ngày 2026-06-06, không có một lệnh universal kiểu "IsaacLab-Arena -> GR00T-LeRobot for every task" dùng được cho mọi embodiment. Có dataset public đã convert sẵn, ví dụ nvidia/Arena-G1-Loco-Manipulation-Task. Nếu bạn tự tạo task/env mới, bạn thường phải viết exporter hoặc converter riêng từ HDF5/trajectory buffer sang LeRobot v2 + meta/modality.json.
2.1 Sinh data sim
Mục tiêu
Bạn cần sinh được episodes có đủ:
- RGB camera:
observation.images.ego_view, có thể thêm wrist cameras. - State: joint positions, base/torso state, hand state, object/goal pose nếu task cần.
- Action: raw joint target, base velocity, hand pose, hoặc SONIC latent/action tùy controller.
- Language annotation: task prompt cho từng episode/frame.
- Episode metadata: success/fail, length, task id.
Với whole-body VLA kiểu G1 / GEAR-SONIC, đích tốt nhất là:
sim rollout
-> camera + robot state + task prompt + action
-> GR00T-LeRobot v2
-> train GR00T
-> PolicyServer
-> SONIC decoder / sim control loop
Yêu cầu môi trường
Máy sim:
- Ubuntu 22.04/24.04.
- NVIDIA GPU RTX 4090/RTX 6000 Ada/A6000/A100/H100. Isaac Sim/Isaac Lab cần GPU NVIDIA thực sự.
- VRAM:
- 16-24 GB: debug env nhỏ, ít camera, ít parallel env.
- 24-48 GB: thu data vừa phải với camera.
- 80 GB hoặc multi-GPU: batch rendering/parallel rollout lớn.
- Isaac Sim / Isaac Lab version khớp với IsaacLab-Arena bạn dùng.
ACCEPT_EULA=YvàPRIVACY_CONSENT=Ynếu Isaac/LeRobot env yêu cầu.
Máy train:
- Debug fine-tune: 1 GPU 48-80 GB tốt hơn 24 GB.
- Whole-body serious: 4+ GPU 80 GB nên chuẩn bị.
- Nếu chỉ verify loader: có thể CPU/GPU nhỏ, nhưng không đánh giá performance được.
Cách nhanh nhất: dùng dataset sim đã có
Trước khi tự sinh, hãy tải dataset sim đã convert sẵn:
cd Isaac-GR00T
mkdir -p datasets
uv run hf download nvidia/Arena-G1-Loco-Manipulation-Task \
--repo-type dataset \
--include "lerobot/**" \
--local-dir datasets/arena_g1_loco
export SIM_DATASET="$PWD/datasets/arena_g1_loco/lerobot"
uv run python tools/verify_groot_lerobot_dataset.py "$SIM_DATASET"
Dataset card của NVIDIA mô tả đây là G1 loco-manipulation task trong IsaacLab-Arena, có HDF5 source và thư mục lerobot đã convert sang GR00T-Lerobot. Đây là route tốt nhất để bắt đầu vì bạn không phải debug exporter ngay.
Load IsaacLab-Arena env để kiểm tra setup
Theo docs LeRobot/IsaacLab-Arena, bạn có thể tạo script random rollout để kiểm tra env load được. Tạo file:
cat > test_env_load_arena.py <<'PY'
import logging
from dataclasses import asdict
from pprint import pformat
import torch
import tqdm
from lerobot.configs import parser
from lerobot.configs.eval import EvalPipelineConfig
@parser.wrap()
def main(cfg: EvalPipelineConfig):
logging.info(pformat(asdict(cfg)))
from lerobot.envs import make_env
env_dict = make_env(cfg.env, n_envs=cfg.env.num_envs, trust_remote_code=True)
env = next(iter(env_dict.values()))[0]
obs, info = env.reset()
for _ in tqdm.tqdm(range(cfg.env.episode_length)):
with torch.inference_mode():
action = env.action_space.sample()
obs, reward, terminated, truncated, info = env.step(action)
if terminated.any() or truncated.any():
obs, info = env.reset()
env.close()
if __name__ == "__main__":
main()
PY
Chạy:
export ACCEPT_EULA=Y
export PRIVACY_CONSENT=Y
python test_env_load_arena.py \
--env.hub_path=nvidia/isaaclab-arena-envs \
--env.type=isaaclab_arena \
--env.environment=g1_locomanip_pnp \
--env.embodiment=gr1_pink \
--env.object=cracker_box \
--env.num_envs=4 \
--env.enable_cameras=true \
--env.headless=false \
--env.seed=1000 \
--env.video=true \
--env.video_length=10 \
--env.video_interval=15
Nếu headless:
python test_env_load_arena.py \
--env.hub_path=nvidia/isaaclab-arena-envs \
--env.type=isaaclab_arena \
--env.environment=g1_locomanip_pnp \
--env.embodiment=gr1_pink \
--env.object=cracker_box \
--env.num_envs=8 \
--env.enable_cameras=true \
--env.headless=true \
--env.video=true \
--env.video_length=10 \
--env.video_interval=15
Tiêu chí đúng:
- Env reset được.
- Camera render không lỗi.
- Có video eval trong
outputs/eval/.../videos/.... - Không có lỗi EULA/privacy.
- Nếu
CUDA out of memory, giảm--env.num_envs, tắt video hoặc giảm camera resolution.
Thu episodes trong sim
Có ba cách phổ biến:
| Cách | Khi nào dùng | Ghi chú |
|---|---|---|
| Teleop trong sim | Muốn human demonstration | VR/keyboard/gamepad; chất lượng tốt nhưng chậm. |
| Mimic/trajectory generation | Muốn scale nhanh | Cần source demo và task annotation. |
| Scripted policy/controller | Task đơn giản, cần baseline | Dễ bias, generalization kém nếu ít randomization. |
Ví dụ output HDF5 mong muốn:
sim_outputs/g1_pick_place_hdf5/
├── demo_000000.hdf5
├── demo_000001.hdf5
├── demo_000002.hdf5
└── manifest.json
Mỗi HDF5 nên có các keys:
/observations/state
/observations/images/ego_view
/observations/images/left_wrist
/observations/images/right_wrist
/actions
/timestamps
/language/task_description
/episode/success
Nếu pipeline của bạn dùng Isaac Lab Mimic hoặc một generator khác, tên key có thể khác. Điều quan trọng là converter phải map đúng về LeRobot columns và modality.json.
Domain randomization tối thiểu
Đừng sinh 1000 episode giống hệt nhau. Ít nhất randomize:
- Object pose, goal pose.
- Lighting, texture, table/floor material.
- Camera extrinsic nhỏ: yaw/pitch/height jitter.
- Robot initial base pose.
- Delay/noise trên action hoặc observation.
- Background distractors nếu camera dùng RGB.
Ví dụ config ý tưởng:
randomization:
object_xy: [-0.25, 0.25]
object_yaw_deg: [-180, 180]
goal_xy: [-0.20, 0.20]
camera_yaw_deg: [-3, 3]
camera_pitch_deg: [-2, 2]
light_intensity: [300, 900]
action_noise_std: 0.01
joint_state_noise_std: 0.002
Nếu task có locomotion, randomize quá mạnh ngay từ đầu có thể làm generator fail. Bắt đầu narrow, tăng dần khi success rate ổn.
2.2 Convert data sim sang GR00T-LeRobot
Mục tiêu
Từ HDF5/trajectory buffer:
sim_outputs/g1_pick_place_hdf5/
tạo:
datasets/g1_pick_place_lerobot/
├── meta/
│ ├── info.json
│ ├── episodes.jsonl
│ ├── tasks.jsonl
│ └── modality.json
├── data/
│ └── chunk-000/
│ ├── episode_000000.parquet
│ └── episode_000001.parquet
└── videos/
└── chunk-000/
└── observation.images.ego_view/
├── episode_000000.mp4
└── episode_000001.mp4
Converter mẫu
Đây là script mẫu để bạn chỉnh theo HDF5 keys thực tế. Nó không phải script chính thức NVIDIA; dùng để tạo pipeline rõ ràng và có thể kiểm thử.
mkdir -p tools
cat > tools/convert_sim_hdf5_to_groot_lerobot.py <<'PY'
import argparse
import json
from pathlib import Path
import h5py
import imageio.v3 as iio
import numpy as np
import pandas as pd
def write_jsonl(path, rows):
with path.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--input-dir", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--fps", type=int, default=20)
parser.add_argument("--task", default="pick up the object and place it at the goal")
args = parser.parse_args()
in_dir = Path(args.input_dir)
out = Path(args.output_dir)
meta = out / "meta"
data_dir = out / "data" / "chunk-000"
video_dir = out / "videos" / "chunk-000" / "observation.images.ego_view"
meta.mkdir(parents=True, exist_ok=True)
data_dir.mkdir(parents=True, exist_ok=True)
video_dir.mkdir(parents=True, exist_ok=True)
h5_files = sorted(in_dir.glob("*.hdf5"))
if not h5_files:
raise SystemExit(f"no .hdf5 files in {in_dir}")
episodes = []
tasks = [{"task_index": 0, "task": args.task}]
global_frame_index = 0
for ep_idx, h5_path in enumerate(h5_files):
with h5py.File(h5_path, "r") as h5:
state = np.asarray(h5["observations/state"], dtype=np.float32)
action = np.asarray(h5["actions"], dtype=np.float32)
images = np.asarray(h5["observations/images/ego_view"], dtype=np.uint8)
if len(state) != len(action) or len(state) != len(images):
raise SystemExit(f"length mismatch in {h5_path}")
rows = []
for t in range(len(state)):
rows.append({
"index": global_frame_index,
"episode_index": ep_idx,
"frame_index": t,
"timestamp": t / args.fps,
"task_index": 0,
"observation.state": state[t].astype(np.float32).tolist(),
"action": action[t].astype(np.float32).tolist(),
"annotation.human.action.task_description": 0,
})
global_frame_index += 1
pd.DataFrame(rows).to_parquet(data_dir / f"episode_{ep_idx:06d}.parquet", index=False)
iio.imwrite(video_dir / f"episode_{ep_idx:06d}.mp4", images, fps=args.fps)
episodes.append({"episode_index": ep_idx, "tasks": [0], "length": len(state)})
state_dim = int(np.asarray(rows[0]["observation.state"]).shape[0])
action_dim = int(np.asarray(rows[0]["action"]).shape[0])
(meta / "info.json").write_text(json.dumps({
"codebase_version": "v2.0",
"fps": args.fps,
"total_episodes": len(episodes),
"total_frames": sum(ep["length"] for ep in episodes),
"features": {
"index": {"dtype": "int64", "shape": [1]},
"episode_index": {"dtype": "int64", "shape": [1]},
"frame_index": {"dtype": "int64", "shape": [1]},
"timestamp": {"dtype": "float32", "shape": [1]},
"task_index": {"dtype": "int64", "shape": [1]},
"observation.state": {"dtype": "float32", "shape": [state_dim]},
"action": {"dtype": "float32", "shape": [action_dim]},
"observation.images.ego_view": {"dtype": "video", "shape": [480, 640, 3]},
},
}, indent=2), encoding="utf-8")
modality = {
"state": {
"whole_body": {"start": 0, "end": state_dim}
},
"action": {
"whole_body": {"start": 0, "end": action_dim}
},
"video": {
"ego_view": {"original_key": "observation.images.ego_view"}
},
"annotation": {
"human.action.task_description": {
"original_key": "annotation.human.action.task_description"
}
}
}
(meta / "modality.json").write_text(json.dumps(modality, indent=2), encoding="utf-8")
write_jsonl(meta / "episodes.jsonl", episodes)
write_jsonl(meta / "tasks.jsonl", tasks)
print(f"wrote {out}")
print(f"episodes={len(episodes)}")
print(f"state_dim={state_dim} action_dim={action_dim}")
if __name__ == "__main__":
main()
PY
Chạy:
uv pip install h5py pandas pyarrow imageio imageio-ffmpeg
uv run python tools/convert_sim_hdf5_to_groot_lerobot.py \
--input-dir sim_outputs/g1_pick_place_hdf5 \
--output-dir datasets/g1_pick_place_lerobot \
--fps 20 \
--task "pick up the object and place it at the goal"
Verify:
export SIM_DATASET="$PWD/datasets/g1_pick_place_lerobot"
uv run python tools/verify_groot_lerobot_dataset.py "$SIM_DATASET"
python -m json.tool "$SIM_DATASET/meta/modality.json"
Script converter này là template tối thiểu. Trước khi train lớn, so sánh meta/info.json, parquet columns và video layout với một public dataset đã load được trong cùng branch Isaac-GR00T/LeRobot của bạn. Nếu loader branch của bạn dùng LeRobot v2.1 dạng data/train-00000.parquet, giữ format đó thay vì ép về chunk/episode.
Sửa modality.json cho whole-body
Script trên dùng "whole_body" một block. Để training ổn hơn, bạn nên tách slicing theo ý nghĩa:
{
"state": {
"left_leg": { "start": 0, "end": 6 },
"right_leg": { "start": 6, "end": 12 },
"waist": { "start": 12, "end": 15 },
"left_arm": { "start": 15, "end": 22 },
"left_hand": { "start": 22, "end": 29 },
"right_arm": { "start": 29, "end": 36 },
"right_hand": { "start": 36, "end": 43 }
},
"action": {
"base": { "start": 0, "end": 3 },
"left_arm": { "start": 3, "end": 10 },
"left_hand": { "start": 10, "end": 17 },
"right_arm": { "start": 17, "end": 24 },
"right_hand": { "start": 24, "end": 31 }
}
}
Nếu dùng SONIC, action slicing phải khớp 64-dim latent + hands. Nếu bạn chỉ có raw joint/action, đừng đặt tên là SONIC.
2.3 Training với data sim
Nhánh A: train chỉ sim
Mục tiêu: kiểm tra model học được task trong distribution simulator.
cd Isaac-GR00T
export NUM_GPUS=1
export SIM_DATASET=/abs/path/to/datasets/g1_pick_place_lerobot
export OUT=/mnt/checkpoints/groot_g1_sim_only_debug
CUDA_VISIBLE_DEVICES=0 uv run python \
gr00t/experiment/launch_finetune.py \
--base-model-path nvidia/GR00T-N1.7-3B \
--dataset-path "$SIM_DATASET" \
--embodiment-tag NEW_EMBODIMENT \
--modality-config-path /abs/path/to/configs/g1_sim_config.py \
--num-gpus $NUM_GPUS \
--output-dir "$OUT" \
--save-total-limit 3 \
--save-steps 1000 \
--max-steps 5000 \
--global-batch-size 8 \
--dataloader-num-workers 2
Với UNITREE_G1_SONIC:
export NUM_GPUS=4
export SIM_DATASET=/abs/path/to/sonic_lerobot_dataset
export OUT=/mnt/checkpoints/groot_g1_sonic_sim_only
uv run torchrun --nproc_per_node=$NUM_GPUS --master_port=29500 \
gr00t/experiment/launch_finetune.py \
--base-model-path nvidia/GR00T-N1.7-3B \
--dataset-path "$SIM_DATASET" \
--embodiment-tag UNITREE_G1_SONIC \
--modality-config-path gr00t/configs/data/embodiment_configs.py \
--num-gpus $NUM_GPUS \
--output-dir "$OUT" \
--save-total-limit 5 \
--save-steps 5000 \
--max-steps 20000 \
--use-wandb \
--global-batch-size 32 \
--dataloader-num-workers 4
Tiêu chí đúng:
- Loader chạy qua nhiều episode.
- Loss không NaN.
- Checkpoint load được bằng
open_loop_eval.pyhoặcrun_gr00t_server.py. - Trong sim, policy không chỉ copy một pose trung bình.
Nhánh B: mix sim + public data
Mục tiêu: dùng public dataset để model bớt overfit vào texture/lighting/geometry của sim.
Có hai cách:
- Nếu launcher hỗ trợ multiple dataset path trong version của bạn: dùng flag tương ứng. Cần kiểm chứng bằng
--help. - Cách chắc chắn hơn: merge datasets thành một LeRobot dataset mới, reindex episode.
Kiểm tra launcher:
uv run python gr00t/experiment/launch_finetune.py --help | grep -i dataset
Nếu không thấy multi dataset support rõ ràng, dùng merge vật lý.
Merge vật lý dataset
Tạo manifest:
cat > datasets_mix.txt <<'TXT'
/abs/path/to/datasets/g1_pick_place_lerobot
/abs/path/to/datasets/arena_g1_loco/lerobot
TXT
Script merge mẫu:
cat > tools/merge_groot_lerobot_datasets.py <<'PY'
import argparse
import json
import shutil
from pathlib import Path
import pandas as pd
def read_jsonl(path):
rows = []
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def write_jsonl(path, rows):
with path.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dataset-list", required=True)
parser.add_argument("--output-dir", required=True)
args = parser.parse_args()
datasets = [
Path(line.strip())
for line in Path(args.dataset_list).read_text().splitlines()
if line.strip() and not line.strip().startswith("#")
]
out = Path(args.output_dir)
(out / "meta").mkdir(parents=True, exist_ok=True)
(out / "data" / "chunk-000").mkdir(parents=True, exist_ok=True)
(out / "videos" / "chunk-000").mkdir(parents=True, exist_ok=True)
first_modality = json.loads((datasets[0] / "meta" / "modality.json").read_text())
episodes_out = []
tasks_out = []
task_offset = 0
ep_out = 0
global_frame_index = 0
for ds in datasets:
modality = json.loads((ds / "meta" / "modality.json").read_text())
if modality != first_modality:
raise SystemExit(f"modality mismatch: {ds}. Merge only datasets with same schema.")
tasks = read_jsonl(ds / "meta" / "tasks.jsonl")
task_map = {}
for task in tasks:
old = task.get("task_index", len(task_map))
new = task_offset + len(task_map)
task_map[old] = new
task["task_index"] = new
tasks_out.append(task)
task_offset += len(task_map)
episodes = read_jsonl(ds / "meta" / "episodes.jsonl")
parquet_files = sorted((ds / "data").rglob("*.parquet"))
video_files = sorted((ds / "videos").rglob("*.mp4"))
if len(parquet_files) < len(episodes):
raise SystemExit(f"not enough parquet files in {ds}")
for i, ep in enumerate(episodes):
ep_new = dict(ep)
ep_new["episode_index"] = ep_out
ep_new["tasks"] = [task_map.get(t, t) for t in ep.get("tasks", [])]
episodes_out.append(ep_new)
df = pd.read_parquet(parquet_files[i])
df["episode_index"] = ep_out
if "frame_index" in df.columns:
df["frame_index"] = range(len(df))
if "task_index" in df.columns:
task_id = ep_new["tasks"][0] if ep_new["tasks"] else 0
df["task_index"] = task_id
if "index" in df.columns:
df["index"] = range(global_frame_index, global_frame_index + len(df))
global_frame_index += len(df)
df.to_parquet(out / "data" / "chunk-000" / f"episode_{ep_out:06d}.parquet", index=False)
if i < len(video_files):
target_dir = out / "videos" / "chunk-000" / video_files[i].parent.name
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(video_files[i], target_dir / f"episode_{ep_out:06d}.mp4")
ep_out += 1
shutil.copy2(datasets[0] / "meta" / "modality.json", out / "meta" / "modality.json")
info = json.loads((datasets[0] / "meta" / "info.json").read_text())
info["total_episodes"] = len(episodes_out)
if all("length" in ep for ep in episodes_out):
info["total_frames"] = sum(ep["length"] for ep in episodes_out)
info["total_tasks"] = len(tasks_out)
(out / "meta" / "info.json").write_text(json.dumps(info, indent=2), encoding="utf-8")
write_jsonl(out / "meta" / "episodes.jsonl", episodes_out)
write_jsonl(out / "meta" / "tasks.jsonl", tasks_out)
print(f"merged {len(datasets)} datasets -> {out}")
print(f"episodes={len(episodes_out)} tasks={len(tasks_out)}")
if __name__ == "__main__":
main()
PY
Chạy:
uv run python tools/merge_groot_lerobot_datasets.py \
--dataset-list datasets_mix.txt \
--output-dir datasets/g1_mix_sim_public
uv run python tools/verify_groot_lerobot_dataset.py datasets/g1_mix_sim_public
Script này cố tình fail nếu modality.json khác nhau. Đó là đúng: không nên trộn raw G1 action với SONIC latent action vào một dataset nếu config không thống nhất.
Điểm cần kiểm tra sau merge:
uv pip install pandas pyarrow
uv run python -c "import pandas as pd; df=pd.read_parquet('datasets/g1_mix_sim_public/data/chunk-000/episode_000000.parquet'); print(df[['episode_index','frame_index','task_index']].head()); print(df.shape)"
uv run python tools/verify_groot_lerobot_dataset.py datasets/g1_mix_sim_public
Tỉ lệ mix đề xuất
Không có tỉ lệ universal. Bắt đầu bằng:
| Mục tiêu | Tỉ lệ đề xuất |
|---|---|
| Task sim mới, public chỉ để regularize | 80% sim / 20% public |
| Muốn generalize lighting/camera/object tốt hơn | 60% sim / 40% public |
| Sim rất synthetic, real/public chất lượng cao | 30-50% sim / 50-70% public |
Nếu merge vật lý không hỗ trợ sampling weight, cách đơn giản là duplicate episode:
# Ví dụ tăng weight sim bằng cách đưa sim path lặp lại trong manifest.
cat > datasets_mix_weighted.txt <<'TXT'
/abs/path/to/sim_dataset
/abs/path/to/sim_dataset
/abs/path/to/public_dataset
TXT
Cách này thô nhưng dễ kiểm soát. Với training lớn, nên thêm sampler weight trong dataloader nếu repo hỗ trợ.
Lỗi thường gặp và cách fix
| Lỗi | Nguyên nhân | Fix |
|---|---|---|
| IsaacLab-Arena không render video headless | Quên enable camera | Thêm --env.enable_cameras=true --env.headless=true. |
EULA not accepted |
Chưa set env | export ACCEPT_EULA=Y PRIVACY_CONSENT=Y. |
| CUDA OOM khi sim | Quá nhiều env/camera/video | Giảm --env.num_envs, camera resolution, tắt video interval dày. |
| Dataset convert xong nhưng loader lỗi | Column name không đúng LeRobot/GR00T convention | So sánh với public lerobot dataset; sửa observation.state, action, annotation.... |
| Video length khác parquet length | Export frame drop hoặc fps mismatch | Trong converter, assert length; nếu drop frame, interpolate hoặc bỏ episode. |
| Merge fail modality mismatch | Hai dataset action/state schema khác nhau | Không merge trực tiếp; convert về cùng schema hoặc train riêng. |
| Sim-only policy không chạy real | Sim-to-real gap | Thêm domain randomization, mix public/real, kiểm tra camera calibration và action scaling. |
Tiêu chí "đã làm đúng"
Bạn đã làm đúng Phần 2 nếu:
- Sim env load được và render camera.
- Có ít nhất 50 episode task-specific nếu định fine-tune nghiêm túc.
- Dataset sau convert có
meta/info.json,episodes.jsonl,tasks.jsonl,modality.json. verify_groot_lerobot_dataset.pypass.- Fine-tune smoke test 100-500 step không NaN/OOM.
- Open-loop eval hoặc PolicyServer load checkpoint được.
- Nếu mix dataset,
modality.jsongiống nhau hoặc bạn đã có mapper schema rõ ràng.
Bài viết liên quan
- GR00T whole-body VLA data: dùng open dataset
- GR00T whole-body VLA data: có cần data real?
- GR00T whole-body VLA: train SONIC controller
- WholeBodyVLA open-source guide