在家用電腦上運行 Microduck RL:在 6GB RTX 2060 上安裝、操控及訓練行走 policy

  • Post author:
  • Post published:
  • Post category:科技
You are currently viewing 在家用電腦上運行 Microduck RL:在 6GB RTX 2060 上安裝、操控及訓練行走 policy

Pollen Robotics(開源人形機械人 Reachy 背後的法國團隊)為其新推出、約 25cm/約 800g 的雙足機械人 Microduck 開源了整套強化學習訓練系統:超過 44 個 RL 任務環境、完整的 sim2real 方案,以及真機上運行的全部 policy。即使沒有實體機械人,亦可使用。本文是我在家中電腦安裝該系統、在模擬器中操作三個環境,並在 6GB 的 RTX 2060 上從零訓練一個行走 policy 的完整紀錄,當中包括真實數據及我遇過的問題。

① Velocity walk:alpha_walking 參考 policy 在平地上衝刺。指令 0.35 m/s,直線行走:11.5 秒內前進 5.9 米。
② VelStand:具備行走及起身能力的 policy 承受 1.15 m/s 推撞(訓練課程中最大的推撞)後倒下,約一秒內重新站起。
③ BallKick:將 70 mm 的球放在踢球腳前方,兩個已訓練的 episodic policy(左/右)完成擺腿、踢球及重新平衡。
④ 我在本地訓練的 Velocity-Flat policy(PPO、2048 個並行環境、RTX 2060、約 2 小時),匯出為 ONNX 後由同一個推理 loop 驅動。

1 · 所需配置

部件 最低要求 備註
GPU(訓練) 任何 CUDA 顯示卡,約 6GB VRAM RTX 2060 6GB 運行 2048 個並行環境只佔用 3.7GB;8GB 或以上可支援 4096 個(上游預設值)。
GPU(只操作、不訓練) 不需要,CPU 亦可 Policy 推理及 3D viewer 毋須 CUDA 亦可正常運行。
RAM/磁碟 16GB/約 10GB 空間 首次執行 uv sync 會下載約 2GB CUDA wheels(torch 2.9.1 cu128、MuJoCo Warp)。
作業系統 Ubuntu(原生或 WSL2) 本文所有步驟均在 Windows 11 + WSL2 + NVIDIA WSL 驅動程式環境下運行。

2 · 安裝(15 分鐘)

只需數行指令即可完成。uv 是本項目使用的 Python 套件管理器;由於首次 sync 會下載約 2GB 指定版本的 CUDA wheels,需要將 HTTP timeout 延長。

curl -LsSf https://astral.sh/uv/install.sh | sh
git clone https://github.com/pollen-robotics/microduck_rl
cd microduck_rl
export UV_HTTP_TIMEOUT=600
uv sync          # 首次約 2GB,光纖網絡下少於 10 分鐘
uv run list-envs # 任務登記表:44 個任務

list-envs 會列出全部任務:行走、起身、坐下與站立、蹲下拾物、踢球、前滾翻(roulade)、滾軸溜冰(六個 roller 任務),以及每個任務均設有的 Backlash 版本(在每個關節齒輪加入真實齒隙)。每個任務都是一個獨立的 PPO gym。

WSL 的 NVIDIA 驅動程式由 Windows 端提供,訓練前請先在 WSL 內執行 nvidia-smi 確認。我的電腦顯示 NVIDIA GeForce RTX 2060, 6144 MiB,import torch 後 torch.cuda.is_available() == True。

3 · 下載真機正在使用的 policy

Pollen 將實體 Microduck 所運行的網絡全部發佈於 Hugging Face,共 11 個 ONNX 檔案,每個約 800KB。換言之,你可以在模擬器中運行與真機相同的神經網絡。

uv run python - <<'EOF'
from huggingface_hub import snapshot_download
snapshot_download(repo_id="pollen-robotics/microduck-policies",
                  local_dir="/home/<你>/microduck_policies")
EOF

4 · 以鍵盤操控 policy

Repo 中的 scripts/infer_policy.py 會載入場景、掛上 ONNX policy,並以 50Hz 在一般桌面 viewer 中運行 MuJoCo,所用的 actuator 模型、控制頻率及觀察協議均與真機相同:

uv run scripts/infer_policy.py 
  --walking ~/microduck_policies/velstand.onnx 
  --new-cmd-obs
現時所有 policy 均使用 61 維觀察,因此必須加上 –new-cmd-obs(舊有的 51 維模式屬統一指令協議推出前的版本)。
按鍵 作用
↑ / ↓ 前進速度 +/−(上限 0.3m/s)
← / → 側移(velstand 支援橫向追蹤)
A / E 轉向 ±1.5rad/s
空白鍵 指令歸零,同一個 policy 會保持站立
P 以 1.0m/s 向隨機方向推撞軀幹(訓練中最大的推撞)
K / L / R 踢球(左/右)/前滾翻(需同時載入對應的 ONNX)
Q 離開

只要加上 –kick-left / –kick-right,官方的踢球場景便會自動載入(同樣需要 –new-cmd-obs),示範③正是以此方式完成。

5 · 三段影片的錄製方法(無畫面 headless 輸出)

上述三段影片全部是同一個 loop 直接以 MuJoCo 渲染而成。我編寫了一個約 180 行的 script:重用 repo 本身的 PolicyInference 類別,運行同一套物理設定(每個控制 tick 4 個子步、BAM 電壓 actuator),以 MuJoCo EGL 後端進行離屏渲染,並將 frame 直接 pipe 至 ffmpeg:

MUJOCO_GL=egl uv run python cloof_demo.py --demo walk    # / recovery / kick
# 1152x864、25fps、每 2 個控制步輸出 1 frame、libx264 CRF21
在沒有顯示器的環境下,關鍵有三點:MUJOCO_GL=egl;將 model.vis.global_.offwidth / offheight 調至大於預設的 640×480;以及加上 scene.flags[mjRND_SHADOW] = 0 以消除條紋陰影瑕疵。完整 script 已附於文末的 code block,直接貼入 scripts/ 即可使用。

6 · 在 6GB 顯示卡上自行訓練行走 policy

一行指令即可在 2048 個並行環境中啟動 PPO:

uv run train Mjlab-Velocity-Flat-MicroDuck 
  --env.scene.num-envs 2048 
  --agent.logger tensorboard 
  --agent.max-iterations 2000 
  --agent.run-name cloof_local_2048

RTX 2060 的實際數據:

設定 數值
1024 個環境(測試) 3.0 秒/iter · 1.25GB VRAM
2048 個環境(正式運行) 3.5–3.7 秒/iter · 3.7/6.1GB VRAM · GPU 使用率約 49%
2000 個 iteration(已完成) 2 小時 01 分 · 98,304,000 步模擬 · 最終 mean reward 108/episode 長度 940/跌倒次數 0.375
Checkpoints logs/rsl_rl/<run>/model_NNNN.pt(訓練期間持續儲存;以 Ctrl-C 中止後,可用 --agent.load-checkpoint 繼續訓練)
必須設定 --agent.logger tensorboard:預設 logger 為 wandb,需要帳戶及 API key。TensorBoard 的所有資料均保留在本機,執行 uv run tensorboard --logdir logs/rsl_rl 即可觀察 reward 上升(即機械人逐步學會行走)。

匯出(exporter 會將觀察 normalizer 寫入 ONNX graph,這是產生可部署檔案的唯一途徑):

uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck 
  --checkpoint-file logs/rsl_rl/<yours>/model_1999.pt
# 匯出後,可使用與官方 policy 相同的推理 loop 運行:
uv run scripts/infer_policy.py 
  --walking my_new_walk.onnx --new-cmd-obs --lin-vel-x 0.22

上方第四段影片便是我在這次訓練中得出的 model,與官方 policy 使用同一個推理 loop。

7 · 數據帶給我的啟示

  • 我兩小時的本地 PPO 訓練:穩定但保守。最終 checkpoint 在所有測試中均未曾跌倒;指令為 0.30 時追蹤至約 0.13m/s,指令為 0.50 時追蹤至約 0.28m/s(約 56%),而且存在死區:指令低於約 0.25 時便原地站立不動。官方 sprint 參考版本行得較快(0.53m/s),但一張家用 6GB 顯示卡已模擬了 9,830 萬步,所謂「通宵訓練」其實只花了一個悠閒的晚上。
  • 官方預設的「行走」policy 刻意設計得較保守。velstand.onnx(真機預設的 policy,以崎嶇地形及齒隙條件訓練)在平坦場景中,指令為 0.22m/s 時只達到約 0.07m/s。我以官方推理 loop 重現了相同比率,證明並非我接駁出錯:它以追蹤表現換取穩健性。不過,它的轉向及起身表現依然出色。
  • 需要速度時應使用 sprint 參考 policy。Pollen 另行發佈參考 policy(例如 microduck-sprint-2m):sprint 參考版本在 0.35m/s 指令下達到 0.53m/s(由於它是為 2 米短跑而訓練,因此會超速),直線行走十分漂亮,但若同時加入急轉彎便會跌倒。
  • 起身才是最大亮點。VelStand v8 將「跌倒後起身」訓練進行走 policy 本身:影片中的機械鴨被 1.15m/s 推撞後側身倒地,約 1 秒後便重新站起。
  • 整個系統以 ONNX 為核心。Policy 在 GPU(MuJoCo Warp)上訓練,再匯出為 61→14 維的 ONNX;同一個檔案可在模擬器、真機及任何支援 ONNX Runtime 的環境中運行,因此「下載官方檔案→本地運行」毋須任何膠水代碼。

8 · 下一步

  • 以 Pollen 的 checkpoint 作 warm-start,再於平地上微調,即「policy remix」;如有真機,可部署回實體機械人。
  • 嘗試崎嶇地形(rough terrain)及斜坡任務;環境代碼在每次 reset 時都會隨機生成地形。
  • 教學角度:真機上的 61→14 協議容許以一個按鈕切換 walk→recover→kick,對學習 AI 的學生而言,是「model switching」一個非常具體的實例。
  • 滾軸溜冰任務系列(共六個)訓練腳下裝有被動輪的機械人,是最具娛樂性的示範素材,已列入我的待辦清單。

來源及授權

附錄 · 完整 headless 渲染 script

cloof_demo.py:按此展開(貼入 repo 的 scripts/ 目錄)
#!/usr/bin/env python3
"""Headless demo-video recorder for microduck_rl (Cloof tooling).

Runs the official ONNX policies through the SAME loop as
scripts/infer_policy.py (BAM actuators, 50 Hz control, decimation 4),
renders offscreen with MuJoCo EGL, and pipes frames into ffmpeg.

Demos:
  walk      velstand protocol: stand -> forward -> strafe -> turn -> forward
  recovery  velstand: get pushed 1 m/s (curriculum cap) and recover
  kick      scene_ball.xml: kick left, then kick right
"""
import argparse
import json
import math
import os
import subprocess
import sys

os.environ.setdefault("MUJOCO_GL", "egl")

import numpy as np  # noqa: E402

REPO = "/home/cloofsiu/microduck_rl"
POLICIES = "/home/cloofsiu/microduck_policies"
sys.path.insert(0, os.path.join(REPO, "scripts"))
import infer_policy as ip  # noqa: E402
import mujoco  # noqa: E402

DEMO_DATA = {
    "walk": {
        "scene": ip.MICRODUCK_XML,
        "sessions": {"walking_onnx_path": f"{POLICIES}/../microduck_sprint/policy.onnx"
                     if os.path.exists(f"{POLICIES}/../microduck_sprint/policy.onnx")
                     else f"{POLICIES}/velstand.onnx"},
        "plan": [
            (0.5, "vel", (0.35, 0.0, 0.0)),
            (9.8, "vel", (0.0, 0.0, 0.0)),
        ],
        "seconds": 11.5,
    },
    "local": {
        "scene": f"{REPO}/src/mjlab_microduck/robot/microduck/scene_walk.xml",
        "sessions": {"walking_onnx_path": f"{REPO}/output.onnx"},
        "plan": [
            (0.5, "vel", (0.30, 0.0, 0.0)),
            (9.8, "vel", (0.0, 0.0, 0.0)),
        ],
        "seconds": 11.5,
    },
    "recovery": {
        "scene": ip.MICRODUCK_XML,
        "sessions": {"walking_onnx_path": f"{POLICIES}/velstand.onnx"},
        "plan": [
            (1.5, "push", (1.15, math.radians(105))),
            (7.5, "vel", (0.35, 0.0, 0.0)),
            (9.8, "vel", (0.0, 0.0, 0.0)),
        ],
        "seconds": 10.5,
        "push": 1.15,
    },
    "kick": {
        "scene": ip.MICRODUCK_BALL_XML,
        "sessions": {
            "walking_onnx_path": f"{POLICIES}/velstand.onnx",
            "kick_left_onnx_path": f"{POLICIES}/ball_kick_left.onnx",
            "kick_right_onnx_path": f"{POLICIES}/ball_kick_right.onnx",
            "kick_duration": 3.0,
        },
        "plan": [
            (1.2, "kick", "kick_left"),
            (4.5, "kick", "kick_right"),
        ],
        "seconds": 7.5,
    },
}


def trunk_addresses(model):
    jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "trunk_base_freejoint")
    return int(model.jnt_qposadr[jid]), int(model.jnt_dofadr[jid])


def build(spec, action_scale=1.0, width=1152, height=864):
    bam_model = ip.load_bam_model(ip.BAM_KP_FW, 7.4, ip.BAM_MAX_CURRENT)
    model, data, bam_ctrl, _names = ip.load_mujoco_with_bam(
        spec["scene"], bam_model, 0.005, None, ip.BAM_VIN_MIN)
    model.vis.global_.offwidth = max(width, 640)
    model.vis.global_.offheight = max(height, 480)
    policy = ip.PolicyInference(
        model, data, bam_ctrl=bam_ctrl, action_scale=action_scale,
        use_projected_gravity=True, new_cmd_obs=True,
        **spec["sessions"])
    qpos_adr, qvel_adr = trunk_addresses(model)
    data.qpos[qpos_adr:qpos_adr + 2] = 0.0
    data.qpos[qpos_adr + 2] = 0.125
    data.qpos[qpos_adr + 3:qpos_adr + 7] = [1, 0, 0, 0]
    for i, idx in enumerate(policy.joint_qpos_indices):
        data.qpos[idx] = policy.default_pose[i]
    if spec["scene"] == ip.MICRODUCK_BALL_XML:
        bj = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "ball_free")
        bqa = int(model.jnt_qposadr[bj])
        data.qpos[bqa:bqa + 3] = [-0.5, 0.35, 0.035]
        data.qpos[bqa + 3:bqa + 7] = [1, 0, 0, 0]
    bam_ctrl.reset(data.qpos)
    policy.set_position_targets(policy.default_pose)
    mujoco.mj_forward(model, data)
    policy.vel_max_x, policy.vel_min_x = 0.3, -0.3
    policy.vel_max_y, policy.vel_min_y = 0.2, -0.2
    policy.vel_max_ang = 1.5
    return model, data, bam_ctrl, policy, qpos_adr, qvel_adr


def plan_op(plan, t):
    for (pt, kind, args) in plan:
        if abs(t - pt) < 0.01:
            return kind, args
    return None, None


def run(demo, out_dir, width, height, fps_div, push_mag=None):
    spec = dict(DEMO_DATA[demo])
    if push_mag is not None:
        spec["plan"] = [(t, k, (push_mag, a) if k == "push" else v)
                        for (t, k, v) in spec["plan"]]
    model, data, bam_ctrl, policy, qpos_adr, qvel_adr = build(spec, width=width, height=height)
    os.makedirs(out_dir, exist_ok=True)
    mp4 = os.path.join(out_dir, f"duck_{demo}.mp4")

    from mujoco import Renderer
    metrics = {"demo": demo, "steps": 0, "control_dt": 0.02,
               "vel_avg": [], "frames": 0}
    renderer = None
    ffmpeg = None
    try:
        renderer = Renderer(model, height=height, width=width)
        cam = mujoco.MjvCamera()
        cam.azimuth = 130
        cam.elevation = -14
        cam.distance = 0.78

        ffmpeg = subprocess.Popen([
            "ffmpeg", "-y", "-loglevel", "error",
            "-f", "rawvideo", "-pix_fmt", "rgb24",
            "-s", f"{width}x{height}", "-r", "25", "-i", "pipe:",
            "-c:v", "libx264", "-preset", "medium", "-crf", "21",
            "-pix_fmt", "yuv420p", "-movflags", "+faststart", mp4,
        ], stdin=subprocess.PIPE)

        decim = 4
        control_dt = decim * model.opt.timestep
        n_steps = int(spec["seconds"] / control_dt)
        metrics["steps"] = n_steps
        metrics["control_dt"] = control_dt
        frame_every = max(1, fps_div)
        vel_hist = []
        start_pos = None
        for step in range(n_steps):
            t = step * control_dt
            kind, args = plan_op(spec["plan"], t)
            if kind == "vel":
                policy.set_vel_cmd(*args)
                print(f"[{t:5.1f}s] cmd -> {args}", flush=True)
            elif kind == "push":
                mag, ang = args
                data.qvel[qvel_adr + 0] = mag * math.cos(ang)
                data.qvel[qvel_adr + 1] = mag * math.sin(ang)
                print(f"[{t:5.1f}s] PUSH {mag:.2f} m/s @ {math.degrees(ang):.0f} deg", flush=True)
            elif kind == "kick":
                policy.trigger_behavior(args)
                print(f"[{t:5.1f}s] trigger {args}", flush=True)

            policy.update_ground_pick_phase(control_dt)
            policy.update_behavior(control_dt)
            action = policy.infer()
            policy.apply_action(action)
            for _ in range(decim):
                bam_ctrl.update()
                mujoco.mj_step(model, data)

            quat = data.qpos[qpos_adr + 3:qpos_adr + 7].astype("float32")
            v_w = data.qvel[qvel_adr:qvel_adr + 3].astype("float32")
            v_b = policy.quat_rotate_inverse(quat, v_w)
            vel_hist.append((float(v_b[0]), float(v_b[1]), float(t)))
            if len(vel_hist) > 50:
                vel_hist.pop(0)
            if step % 50 == 0:
                avg = tuple(float(sum(v[i] for v in vel_hist) / len(vel_hist)) for i in (0, 1))
                metrics["vel_avg"].append({"t": round(float(t), 2), "fwd": round(avg[0], 3),
                                           "lat": round(avg[1], 3),
                                           "cmd": [float(policy.vel_cmd[0]), float(policy.vel_cmd[1])],
                                           "trunk_z": round(float(data.qpos[qpos_adr + 2]), 4),
                                           "policy": policy.current_policy})
            if start_pos is None:
                start_pos = np.array(data.qpos[qpos_adr:qpos_adr + 2])
            if step % frame_every == 0:
                p = data.qpos[qpos_adr:qpos_adr + 3]
                cam.lookat[:] = [p[0], p[1], max(0.02, float(p[2]) * 0.55)]
                renderer.update_scene(data, camera=cam)
                renderer.scene.flags[mujoco.mjtRndFlag.mjRND_SHADOW] = 0
                img = renderer.render()
                ffmpeg.stdin.write(img.tobytes())
                metrics["frames"] += 1
        ffmpeg.stdin.close()
        ffmpeg.wait()
        metrics["travelled_m"] = round(float(np.abs(data.qpos[qpos_adr:qpos_adr + 2] - start_pos).sum()), 3)
        with open(os.path.join(out_dir, f"duck_{demo}_metrics.json"), "w") as f:
            json.dump(metrics, f, indent=2)
    finally:
        if renderer is not None:
            try:
                renderer.close()
            except Exception as exc:
                print(f"renderer close warning: {exc}", flush=True)
        if ffmpeg is not None and ffmpeg.stdin is not None and not ffmpeg.stdin.closed:
            ffmpeg.stdin.close()
            ffmpeg.wait(timeout=60)
    print(f"DONE {mp4}  travelled={metrics.get('travelled_m')}", flush=True)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--demo", required=True, choices=list(DEMO_DATA))
    ap.add_argument("--out", required=True)
    ap.add_argument("--width", type=int, default=1152)
    ap.add_argument("--height", type=int, default=864)
    ap.add_argument("--fps-div", type=int, default=2)
    ap.add_argument("--push", type=float, default=None)
    a = ap.parse_args()
    run(a.demo, a.out, a.width, a.height, a.fps_div, a.push)


if __name__ == "__main__":
    main()