やってみた 2026年10月24日

実機なしで2足歩行ロボットを立たせて歩かせた。microduckのシミュレーターで「0.15m/sでは歩かない」に当たった話

XECIN RustロボットMuJoCoシミュレーション

GitHubのトレンドに pollen-robotics/microduck が載っていました。スターは10,631、その日だけで421増えています。READMEの書き出しは「This repo is the duck’s brain.」。高さ約25cm・重さ800gの2足歩行ロボット(アヒルの形をしています)を動かす、Rust製のデーモン群のリポジトリです。

ロボットは持っていません。それでも試す気になったのは、READMEの表にあった「No robot on the desk?」という一行でした。scripts/duck-sim を使うと、ロボットで動いているのと同じデーモンを、MuJoCo(物理シミュレーター)の中の体につないで動かせる、とあります。

運用を考えると、実機が手元に無い人や、CIのように実機を置けない場所でも本物のコードを回せる仕組みは、とても大事だと考えています。どこまで本物なのか、どこで差が出るのかを、使い捨てのコンテナで確かめてみました。

試した環境

  • 使い捨てのDockerコンテナ(rust:1-bookworm、Debian 12.15)。メモリ4GB・CPU2つに制限
  • rustc 1.97.1 / Python 3.11.2
  • microduck: 942e58e(2026-10-19、release 0.17.8 の取り込み。起動ログの版表記も 0.17.8)
  • 体の側の microduck_rl: develop ブランチの b02fdea(2026-10-12)
  • mujoco 3.17.0 / numpy 2.4.6 / onnxruntime 1.31.0
  • 方策(学習済みの ONNX): Hugging Face の pollen-robotics/microduck-policies の v8(Apache-2.0。スクリプトが起動時に取得)

まず設計の話:「体」を用意するのに何が要るか

シミュレーターは2つのリポジトリにまたがっています。デーモン(robotd など)は microduck に、MuJoCo の体(duck-body)は学習用の microduck_rl にあります。ドキュメントの準備は「microduck_rl の venv(uv sync)」です。

ここで手が止まりました。microduck_rl は方策を学習するためのリポジトリなので、依存に torch 2.9.1 などが並んでいます。uv.lock に書かれた wheel の大きさを、今回のコンテナに合う形(Linux x86_64・Python 3.12)で足し合わせてみると、次のようになりました。

用意のしかた中身大きさ
ドキュメントどおり uv sync(uv.lock から計算・実際には入れていない)154パッケージ。うち torch 2.9.1 が899.7MB、nvidia-* の15個で2,987.6MBwheel の合計で約4,477MB(圧縮された状態)
今回の最小構成(pip で3つ)mujoco・numpy・onnxruntime(と、それらの依存)展開後の venv 全体で283MB、インストール9.4秒

最小構成で足りると判断したのは、体の本体である body_server.py を読んだからです。import しているのは mujoco と numpy、それに同じパッケージ内のカメラと距離センサーの部品だけで、その2つも mujoco と numpy しか使っていません。onnxruntime は体ではなく、デーモンが方策を動かすための libonnxruntime.so を借りるために入れます。スクリプトは venv の中からこのファイルを探す作りでした。

比べる単位がそろっていない(片方は圧縮された wheel、片方は展開後)ので、何倍とは言いません。ただ、GPU を使わない検証のために CUDA 向けの部品を3GB近く落とす必要はなさそうだ、というのが私の判断です。

ビルドして、立たせる

デーモンのビルドは、scripts/duck-sim が中で呼ぶのと同じ6つのパッケージに絞りました。そのうえでビューア(画面)なしで起動します。

# 体の側:uv sync はせず、body_server.py が使う分だけ
$ python3 -m venv /work/microduck_rl/.venv
$ /work/microduck_rl/.venv/bin/pip install mujoco numpy onnxruntime     # real=9.352
mujoco 3.17.0 / numpy 2.4.6 / onnxruntime 1.31.0(libonnxruntime.so.1.31.0 は 29,352,200 バイト)

# デーモンの側:duck-sim が使う6つだけ
$ cargo build -p robotd -p robotctl -p tof -p sounds -p configd -p updater
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 05s
# target/ は 3.1G、robotd 単体(debug)で 162,768,720 バイト

# 起動(ビューアなし)。PYTHONPATH で microduck_rl の src を通している
$ DUCK_SIM_RL=/work/microduck_rl DUCK_SIM_VIEWER=0 PYTHONPATH=/work/microduck_rl/src sh scripts/duck-sim
== building the daemons from main
== no policy set here — fetching the official one
== rendering duck-a a voice
== starting the body from /work/microduck_rl — a MuJoCo window should open
== starting tofd
== starting configd
== starting updaterd
== starting robotd
== standing up
robot     healthy
  loop      50.0 of 50.0 Hz · 311 ticks · 0 missed · last 2 ms ago
  bus       ok
== duck-a: standing (trunk 0.116 m, gravity z -1.000)
real=28.361

PYTHONPATH で src を通しているのは私の近道で、公式の手順ではありません。microduck_rl をパッケージとして入れずに済ませた分、duck-body のコマンドも作られていません。スクリプトは python -m で体を起動するので今回は困りませんでしたが、ここは改善の余地ありそう、というか自分の使い方次第のところです。

起動は28.4秒でした。方策をHugging Faceから取ってくる初回の時間込みで、2回目の起動は10.2秒です。座った姿勢から始まり、ログには「seated — settling into the seat, then rising via the sitstand policy」と出て、胴の高さ0.116mで立ちました。スクリプトは胴の高さ0.095mを「立った」の線にしていて、その根拠(座ると0.07、立つと0.12)がコメントに書いてあります。

scripts/duck-sim realtime は「1.00x real time」。50Hzの制御ループが取りこぼし0で回っています。

MuJoCo のビューアに映った microduck。青い市松模様の床の中央に、白い頭とオレンジの足を持つ小さな2足ロボットが横向きで立っている

上の画面は、あとでビューアありで起動し直して撮ったものです。コンテナの中に仮想ディスプレイ(Xvfb)を立て、MuJoCo の窓を1280x720に広げて画面を写しました。白い頭がくちばし側で、立った直後の姿勢です。

既定の「歩け」で、歩かなかった

立ったので歩かせます。スクリプトには scripts/duck-sim drive があり、説明は「walk forward for 8 s (args: vx vyaw, default 0.15 0)」。前進0.15m/sを8秒送り続けるコマンドです。

8秒後、胴の位置はほとんど変わっていませんでした。MuJoCo 側の胴の位置(真値)を直接読むと、動いたのは1cmにも届きません。

正直なところ、最初は命令が届いていないのだと思いました。そこで robotctl monitor を開きます。ヘルプに「the stick is forward and the robot is still is unreadable without the reason next to it」とあるとおり、要求と実際に適用した値を並べて見せてくれる道具です。

$ scripts/duck-sim drive &              # 既定の vx 0.15 で 8 秒
$ scripts/duck-sim ctl monitor --hz 1
   76.79   walk   50.0Hz miss=0    ok      g[+0.02 +0.01 -1.00] kp=200  req[+0.15 +0.00 +0.00] app[+0.15 +0.00 +0.00]
   77.79   walk   50.0Hz miss=0    ok      g[+0.01 +0.01 -1.00] kp=200  req[+0.15 +0.00 +0.00] app[+0.15 +0.00 +0.00]
   78.79   walk   50.0Hz miss=0    ok      g[+0.01 +0.01 -1.00] kp=200  req[+0.15 +0.00 +0.00] app[+0.15 +0.00 +0.00]

# monitor --json を10Hzで読み、1秒ごとに「脚の関節の振れ幅(最大−最小)」をまとめた
# あわせて体の TCP(port 7801)に read を送り、MuJoCo 側の胴の位置を前後で読んだ
## vx=0.15 truth(before)
wall=  0.00 sim=  154.30 rt=0.00 x=+0.917 y=-0.405 z=0.116 gz=-1.000
n= 10 policy=walk req=[0.15, 0.0, 0.0] app=[0.15, 0.0, 0.0] odom_x=+0.951 odom_y=-0.409 yaw= -35.5deg leg_span_max=0.087rad fallen=False
n= 20 policy=walk req=[0.15, 0.0, 0.0] app=[0.15, 0.0, 0.0] odom_x=+0.952 odom_y=-0.409 yaw= -35.7deg leg_span_max=0.007rad fallen=False
n= 30 policy=walk req=[0.15, 0.0, 0.0] app=[0.15, 0.0, 0.0] odom_x=+0.952 odom_y=-0.409 yaw= -35.6deg leg_span_max=0.001rad fallen=False
(中略:n=70 まで 0.001rad 前後のまま)
## vx=0.15 truth(after)
wall=  0.00 sim=  163.32 rt=0.00 x=+0.918 y=-0.405 z=0.116 gz=-1.000

命令は届いていました。方策は walk、要求(req)も適用(app)も0.15で、安全装置に削られてもいません。それなのに、脚が動くのは最初の1秒だけ(振れ幅0.087rad)で、そのあとは0.001rad、ほぼ静止です。

速さを変えて、同じ測り方をしたのが次の表です。途中で方策も差し替えたので(理由は次の節)、どの方策で測ったかも書いています。

walk の方策前進の速さ脚の振れ幅(1秒ごとの最大)胴の移動(真値)ロボット自身の推定(odom)
alpha_walking(duck-sim の既定)0.15(2回)最初の1秒 0.087 / 0.074rad、以降 0.001rad前後0.001m / 0.000mほぼ0
alpha_walking0.20最初の1秒 0.108rad、以降 0.001rad前後0.001mほぼ0
alpha_walking0.300.448〜0.584rad(ずっと動く)0.990m(前へ0.902m、右へ0.409m)1.006m(前へ0.912m、右へ0.424m)
velstand(manifest の既定)0.15最初の1秒 0.088rad、以降 0.001〜0.011rad0.001mほぼ0
velstand0.250.521〜0.586rad(ずっと動く)0.683m(前へ0.616m、右へ0.294m)0.698m(前へ0.632m、右へ0.296m)

どれも8秒送った結果で、真値は MuJoCo の胴の位置、odom は robotctl monitor --json の odom.position の差です。この環境では、0.20以下だと「歩く方策に切り替わったまま、その場で止まる」、0.25〜0.30だと「ちゃんと歩く」に分かれました。なぜ0.20以下で止まるのかは、学習側の設定まで追い切れておらず、原因は確かめられていません。

同じビューアで、前進0.30m/sを送り始めて2秒後の microduck。片脚を持ち上げた歩行中の姿勢で、最初の画面より右へ進んでいる

こちらは0.30で歩かせ始めて2秒後の画面です。胴の位置は x=0.019m から 0.240m へ進んでいて、画面でも右へ移動し、脚を上げているのが分かります。歩いた3回とも、真っすぐの命令なのに右へ流れていたのも面白いところでした。0.30の回は、進んだ向きが真っすぐから約24度ずれています。

「既定の方策」はどれなのか

表の左端の列に方策名を書いたのには理由があります。取ってきた方策セット v8 の manifest.json を読むと、velstand.onnx に「Walking gait that also stands still at zero command; the default walk policy since v5, with no separate standing network.」と書いてありました。v5 以降、歩くのも止まるのもこの1本が既定、という意味です。

一方で scripts/duck-sim が書き出す設定ファイルは、walk = alpha_walking.onnx、stand = alpha_stand.onnx と、別々の2本を名指ししていました。シミュレーターは「same policies」とうたっていますが、少なくとも既定の組み合わせは manifest のそれと違います。

だったら velstand にすれば0.15でも歩くのでは、と思って差し替えたのが表の4行目です。結果は同じく止まりました。仮説は外れです。ついでに元に戻そうとしたら、こうなりました。

$ scripts/duck-sim ctl policy load walk /root/.cache/duck-sim/policies/current/velstand.onnx
walk is now running /root/.cache/duck-sim/policies/releases/seed-v8/velstand.onnx
(kept in /etc/robot/robotd.toml; `robotctl policy reset walk` undoes it)

$ scripts/duck-sim ctl policy reset walk
error: the robot kept the policy it was running: reading /opt/robot/policies/current/velstand.onnx: No such file or directory (os error 2)
config still names the file — `robotctl policy reset` to undo

$ scripts/duck-sim ctl health
robot     degraded: using the default policy where an override would not load — walk: reading /opt/robot/policies/current/velstand.onnx: No such file or directory (os error 2)
  loop      50.0 of 50.0 Hz · 14766 ticks · 0 missed · last 13 ms ago

reset は「このロボットが出荷時に持っている方策」に戻す命令で、その置き場所は実機の /opt/robot/policies/current でした。シミュレーターにはそのディレクトリが無いので失敗し、velstand のまま残ります。しかも health は healthy から degraded に変わり、以降ずっとそのままでした。表の velstand 0.25 は、この「戻せなかった」状態で測っています。

今思えば、ここが一番「実機との差」が見えた場所です。デーモンは本物なので、実機の場所を前提にした命令は、シミュレーターでも実機と同じように実機の場所を見に行くんですよね。

真値があるから、オドメトリの答え合わせができる

表の右2列は、私が一番うれしかったところです。体のサーバーは、デーモンが読む値とは別に、胴の位置(trunk)をわざわざ返してくれます。コメントには「No robot knows that either」。実機には自分の本当の位置を知る手段が無いので、シミュレーターならではの値です。

それと比べると、ロボット自身の推定(odom)は、0.30の回で1.006m(真値0.990m)、0.25の回で0.698m(真値0.683m)。どちらも1.5〜2%ほど多めに見積もっていて、右へ流れる向きも含めて、形はよく合っていました。センサーの値から位置を推定する部分を、答えのある状態で確かめられるのは、運用を考えると心強いと考えています。

CPU を絞ったら倒れるか

ドキュメントには、シミュレーターが実時間に追いつけないと方策がバランスを取れなくなる、と何度も書かれています。そこでコンテナの CPU 上限を、立たせたまま途中で絞ってみました。

# ホストから: 立っている最中に CPU 上限を 2 → 0.3 → 2
$ docker update --cpus 0.3 ght-trial-...
$ scripts/duck-sim ctl health
  loop      50.0 of 50.0 Hz · 14766 ticks · 0 missed · last 13 ms ago

# もう一段: 2 → 0.05 → 2
$ docker update --cpus 0.05 ght-trial-...
$ scripts/duck-sim ctl health
  loop      20.9 of 50.0 Hz · 16900 ticks · 97 missed · last 0 ms ago

# 2 に戻した後
  loop      50.0 of 50.0 Hz · 17658 ticks · 120 missed · last 11 ms ago
== the duck: standing (trunk 0.116 m, gravity z -1.000)
== the world is running at 1.00x real time

1体・ビューアなしなら、CPU 0.3 でも取りこぼし0のままでした。0.05まで絞ると、制御ループは50Hzのうち20.9Hzしか回らず、97回取りこぼしましたが、アヒルは立ったまま。体のログに「behind real time」は一度も出ていません。立っているだけなら、ループが半分以下に落ちても倒れなかった、というのが今回の観測です。

ドキュメントにある「45Hzを下回ると unhealthy」の判定は、このとき health の1行目が先ほどの degraded のままだったので、読み取れませんでした。歩かせながら絞る試しもしていません。

ビューアの重さは別の形で見えました。ビューアなしでは立ち上がり時点の取りこぼしが0でしたが、ビューアありでは2〜5回、歩かせた後には19〜40回に増えています。それでも実時間は1.00倍を保っていて、歩くのにも支障はありませんでした。

試してみての所感

全体で見ると、コンテナの作成から破棄まで786秒。そのうちビルドが2分5秒、立ち上がりが28秒で、実機なしで本物の robotd が立ち、歩き、自分の位置を推定するところまで確かめられました。体の側を学習用の一式から切り離せば、ぶっちゃけ CPU だけの小さな環境で十分です。

私はこの仕組みを、IPC やクライアント(robotctl や自作のスクリプト)を書くときの相手として使いたいと考えています。答え合わせ用の真値が取れるので、オドメトリや転倒検知の確認にも向いています。一方で、歩かせる速さは0.25以上を前提にしないと「動かない」を不具合と取り違えそうです。0.20以下で止まる理由と、実機でも同じなのかは分かっていないので、ここは様子見にします。

今回試していないものも書いておきます。systemd-nspawn で1体ずつ別マシンとして起動する boot 形式(sudo が要ります)、カメラと WebRTC でつながる操作画面、Hugging Face のアカウントにつないで外から操作する機能です。どれもロボットを持つ人には要の部分だったりします。次に試すなら、コンテナの中のコンテナを使わずにどこまで行けるか、から見てみるつもりです。