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.6MB | wheel の合計で約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で回っています。

上の画面は、あとでビューアありで起動し直して撮ったものです。コンテナの中に仮想ディスプレイ(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_walking | 0.20 | 最初の1秒 0.108rad、以降 0.001rad前後 | 0.001m | ほぼ0 |
| alpha_walking | 0.30 | 0.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.011rad | 0.001m | ほぼ0 |
| velstand | 0.25 | 0.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.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 のアカウントにつないで外から操作する機能です。どれもロボットを持つ人には要の部分だったりします。次に試すなら、コンテナの中のコンテナを使わずにどこまで行けるか、から見てみるつもりです。