sim2realの分野でよく使われるMuJoCoはPythonにpip installして使うことができます。ここでは、当ブログでお馴染みの1自由度ばねマスダンパーをモデリングしてシミュレーションする方法を通してMuJoCoに入門します。さらに、MuJoCoで計算した結果をPythonで動画にする方法を学びます。
こんにちは。wat(@watlablog)です。sim2realの世界に入門したいので、今回はMuJoCoをPythonで触ってみます!
MuJoCoとは?
MuJoCo(Multi-Joint dynamics with Contact)は、関節でつながった剛体(ロボットなど)が環境と接触・摩擦しながら動く状況を、高速かつ高精度に解くことを重視した物理エンジンです。ロボティクス、バイオメカ、強化学習など「大量試行が必要で、接触が絡む」研究開発でよく使われます。
・GitHub:https://github.com/google-deepmind/mujoco
もともとはRoboti LLCにより開発・商用提供されていましたが、2021年10月にDeepMindがMuJoCoを買収し、研究コミュニティを支える目的で無償提供へ舵を切りました。
・DeepMindブログ:https://deepmind.google/blog/opening-up-a-physics-simulator-for-robotics/
その後、2022年5月にコードベース全体のオープンソース化が完了し、GitHub上でApache-2.0ライセンスの下、DeepMind(Google DeepMind)がメンテナンスする形になっています。
・DeepMindブログ:https://deepmind.google/blog/open-sourcing-mujoco/
さらに、MuJuCoは pip installで簡単にインストールでき、Pythonプログラミングで利用することも可能です。ここではMuJuCoをPythonで使う方法をまとめます。
動作環境
この記事のコードは以下の環境で動作を確認しました。
| Mac | OS | macOS Sonoma 14.5 |
|---|---|---|
| チップ | Apple M3 | |
| CPU | 1.4[GHz] | |
| メモリ | 16[GB] |
|
1 2 3 4 5 6 |
Package Version ------------------- ----------- matplotlib 3.10.8 mujoco 3.4.0 numpy 2.4.1 pillow 12.1.0 |
PythonでMuJoCoを使ってシミュレーション結果を得る
MuJoCoをpip installする
MuJoCoはC++で書かれていいますが、pybind11を使ってC++のコードをPythonから簡単に呼び出せる形になっている mujocoというライブラリがPyPIにあります。以下のコマンドで pip installしましょう。
|
1 |
pip install mujoco |
・参考:https://mujoco.readthedocs.io/en/3.1.5/python.html
モデルを.xmlでつくる
いきなりロボットや倒立振子をモデリングする前に、この記事では入門的に下図に示す1自由度ばねマスダンパーモデルを作成して振動のシミュレーションを行います(自分にとってはこれが最も簡単なので)。

MuJoCoで使うモデルは .xmlファイルで作成します。このモデルはMJCFと呼ばれます。まずはリポジトリに空の oscillator.xmlをつくりましょう。ターミナルに次のコマンドを打ってつくるのがはやいと思います(作り方は自由です)。
|
1 |
touch oscillator.xml |
MJCFの他に、ロボティクス分野でよく使われるURDF(Unified Robot Description Format)も使えますが、ここでは説明しません。
oscillator.xmlにモデル情報を記述します。内容は後述します。
|
1 2 3 4 5 6 7 8 9 10 |
<mujoco model="1d_oscillator"> <option gravity="0 0 0" timestep="0.001"/> <worldbody> <body name="mass" pos="0 0 0"> <joint name="x" type="slide" axis="1 0 0" stiffness="20" damping="0.5" springref="0"/> <geom type="sphere" size="0.05" mass="1"/> </body> </worldbody> </mujoco> |
1行目はモデルのタイトルです。ここでは1自由度振動モデルを意味する
1d_oscillatorとしました。
2行目は
optionの設定です。それぞれ次の意味があります。MuJoCoは古くからのシミュレーションソフトにあるように単位系はユーザーが決める仕様です。MuJoCo側はユーザーが入力した数値を単に次元に合わせて計算するだけです。つまり、長さを[m]、時間を[s]で入力した場合速度は[m/s]になります。今回はSI単位系(長さ=[m], 重さ=[kg], 時間=[s])を使いましょう。何も入力しないと設定されるデフォルト値もSI単位系とSI併用単位(°)が使われるので、SI単位系で書いておけば間違いないと思います。
-
gravity:重力
0, 0, 0は3D空間\((x, y, z)\)のどの方向にも重力が働いていないことを表現しています。 -
timestep:シミュレーション時の時間刻み
今回は0.001[s]単位で結果を出力するようになります。
3行目から9行目に worldbodyがありますが、今回は1自由度なので bodyを一つだけ記述します。この bodyに以下の諸元を記述します。
- name: bodyや jointの名前
- pos:初期位置
jointの項目でジョイントの設定を行います。MuJoCoでは諸元が入力されたモデルをジョイント(締結構造)で結合するモデリングを行います。この考え方はMBD(Multi-Body Dynamics)の分野でよく出てくるもので、以下の記事でも扱ったようにこの方法でモデルを記述するとラグランジュ法などを使った運動方程式の導出が機械的になって楽になります。
・参考:Python/SymPy:ラグランジュ法による運動方程式の自動導出
-
type:
jointの種類
ここでは slide(並進1自由度ジョイント)をx方向指定で設定していますが、他にも回転1自由度ジョイント hinge、回転3自由度(球)ジョイント ball、6自由度ジョイント freeがあります。
↓以下のリンク先に各ジョイントの動きがわかりやすい動画もあります。
・参考:https://mujoco.readthedocs.io/en/stable/overview.html - stiffness:ばね定数
- damping:粘性係数
- springref:つり合い位置
ジオメトリの設定を geomに記述します。今回は衝突問題を解きませんが、衝突問題の場合はこの形やサイズによって結果が変わります(球の場合は球の衝突判定になるなど)。
- type:質点の形
- size:サイズ
- mass:質量
Pythonコードで計算する
次のPythonコードを用意(名前は任意)します。まずはシミュレーションを実施して matplotlibによるグラフを表示するまでをコーディングしました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
from pathlib import Path import mujoco import numpy as np import matplotlib.pyplot as plt # 読み込むMJCF(XML)ファイルと、対象にするジョイント名 XML_PATH = Path("oscillator.xml") JOINT_NAME = "x" # 初期条件(初期変位と初期速度) X0 = 1.0 V0 = 0.0 # シミュレーション秒数 T_END = 20.0 # XMLファイルからモデルを読み込み、状態(時間・位置・速度など)を保持するdataを作成 model = mujoco.MjModel.from_xml_path(str(XML_PATH)) data = mujoco.MjData(model) # ジョイント名から、qpos配列・qvel配列のどこに値が入っているかインデックスを探査 jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, JOINT_NAME) qpos_adr = model.jnt_qposadr[jid] qvel_adr = model.jnt_dofadr[jid] # 状態を初期化してから、初期変位と初期速度を代入、内部計算を整合させるためにmj_forwardを実行 mujoco.mj_resetData(model, data) data.qpos[qpos_adr] = X0 data.qvel[qvel_adr] = V0 mujoco.mj_forward(model, data) # シミュレーション刻み幅(timestep)から、必要なステップ数を決めてログ用配列を確保 dt = model.opt.timestep steps = int(T_END / dt) + 1 t_log = np.empty(steps, dtype=float) x_log = np.empty(steps, dtype=float) v_log = np.empty(steps, dtype=float) # t=0 の状態を記録 t_log[0] = data.time x_log[0] = data.qpos[qpos_adr] v_log[0] = data.qvel[qvel_adr] # 1ステップずつmj_stepで時間発展、時刻・変位・速度を記録 for i in range(1, steps): mujoco.mj_step(model, data) t_log[i] = data.time x_log[i] = data.qpos[qpos_adr] v_log[i] = data.qvel[qvel_adr] # グラフのプロット plt.rcParams["font.size"] = 14 plt.rcParams["font.family"] = "Times New Roman" plt.rcParams["xtick.direction"] = "in" plt.rcParams["ytick.direction"] = "in" fig = plt.figure() ax1 = fig.add_subplot(111) ax1.xaxis.set_ticks_position("both") ax1.yaxis.set_ticks_position("both") ax1.plot(t_log, x_log, label="x(t)") ax1.set_xlabel("Time[s]") ax1.set_ylabel("Disp.[m]") ax1.legend() fig.tight_layout() plt.show() |
MuJoCo特有のコードを見てみましょう。
MjModel と MjData
|
1 2 3 |
# XMLファイルからモデルを読み込み、状態(時間・位置・速度など)を保持するdataを作成 model = mujoco.MjModel.from_xml_path(str(XML_PATH)) data = mujoco.MjData(model) |
MuJoCoでは、モデルの構造や各種パラメータ( body・ jointの構成、形状、質量・慣性、描画設定など)を MjModelが保持します。一方、シミュレーション中に変化する状態(一般化位置 qpos,一般化速度 qvel, 時間 timeなど)や計算結果・ワークスペースは MjDataに格納されます。ここではそれらのインスタンス化を行なっています。
jointインデックスの探査
|
1 2 3 4 |
# ジョイント名から、qpos配列・qvel配列のどこに値が入っているかインデックスを探査 jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, JOINT_NAME) qpos_adr = model.jnt_qposadr[jid] qvel_adr = model.jnt_dofadr[jid] |
joint名(今回は x)からID番号を検索する .mj_name2idを使って番号を取得し、 qpos配列・ qvel配列のどこにその jointの値が入っているかインデックスを探査します。 jointの種類(今回は slide)によって要素数が異なり、さらに一般化位置 qposと一般化速度 qvelの中でも要素数が異なるので、シミュレーションをかける前にインデックスを一回だけ把握しておきます。
初期化と.mj_forward
|
1 2 3 4 5 |
# 状態を初期化してから、初期変位と初期速度を代入、内部計算を整合させるためにmj_forwardを実行 mujoco.mj_resetData(model, data) data.qpos[qpos_adr] = X0 data.qvel[qvel_adr] = V0 mujoco.mj_forward(model, data) |
最初の3行は初期化です。 MjDataの中の一般化位置と一般化速度を手動で書き換えています。また、手動で書き換えたのでワールド座標系との整合をとるために一度 .mj_forwardを走らせます。
mujoco.mj_step(model, data)
|
1 |
mujoco.mj_step(model, data) |
シミュレーションを1ステップずつ回す部分です。ただ、Pythonでforループを回すと内部的にはCの高速な物理計算がされているといってもPython→C呼び出しにおけるオーバーヘッド分は出てしまいます。どうやらこのオーバーヘッド分による損失を軽減する対策もいくつかあるようですが、それは後日にします。 mj_stepでステップ毎に計算する目的は、後でモデルベース制御や強化学習に応用したいからです。
結果を理論値と比較する
上記コードを実行するとmatplotlibのグラフが表示されます。下図に上記コードに減衰自由振動の理論式と比較する処理を追加した結果を示します。
MuJoCoによるシミュレーション結果と理論式が一致していることが確かめられました。

理論式は「Pythonで1自由度減衰系の自由振動シミュレーション」という記事で紹介しています。今回のMJCFモデルのパラメータに合わせて書くと次のPythonコードになります。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# mass=1 [kg], stiffness=20 [N/m], damping=0.5 [N·s/m], 変位xは[m], 速度vは[m/s] m = 1.0 k = 20.0 c = 0.5 t = t_log state0 = np.array([X0, V0], dtype=float) cc = 2 * np.sqrt(m * k) zeta = c / cc omega = np.sqrt(k / m) omega_d = omega * np.sqrt(1 - np.power(zeta, 2)) sigma = omega_d * zeta n_order_v = state0[1] / (state0[0] * omega) # 減衰振動の場合 if zeta < 1: X = np.sqrt(np.power(state0[0], 2) + np.power((state0[1] + sigma * state0[0]) / omega_d, 2)) phi = np.arctan((state0[1] + (sigma * state0[0])) / (state0[0] * omega_d)) theory = np.exp(- sigma * t) * X * np.cos(omega_d * t - phi) # 臨界減衰の場合 elif zeta == 1: theory = state0[0] * np.exp(- omega * t) * ((n_order_v + 1) * omega * t + 1) # 過減衰の場合 else: theory = state0[0] * np.exp(- zeta * omega * t) * ( np.cosh(omega * t * np.sqrt(zeta ** 2 - 1)) + (n_order_v + zeta) / (np.sqrt(zeta ** 2 + 1)) * np.sinh(omega * t * np.sqrt(zeta ** 2 - 1)) |
MuJuCoのシミュレーション結果を動画にする
GIF動画を作成する
MuJuCoを pip installすると mujuco.viewerというGUIビューア(レンダリングはOpenGL)もインストールされます。 mujuco.viewerには色々な起動方法がありますが、ここではPythonコード内部でシミュレーション結果の画像を連番で取得し、最後にGIF動画にする方法を紹介します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
from pathlib import Path import os import glob import mujoco from PIL import Image # 読み込むMJCF(XML)ファイルと、対象にするジョイント名 XML_PATH = Path("oscillator.xml").resolve() JOINT_NAME = "x" # 初期条件(初期変位と初期速度) X0 = 1.0 V0 = 0.0 # シミュレーション秒数[s] T_END = 20.0 # 画像出力フォルダ(なければ作成) IMG_DIR = Path("img") IMG_DIR.mkdir(exist_ok=True) # 出力する画像サイズ WIDTH = 640 HEIGHT = 480 # GIF動画の間引き設定[s] FRAME_DT = 0.05 def create_gif(in_dir, out_filename, frame_dt_s, loop=0): """連番画像からGIFを作成""" path_list = sorted(glob.glob(os.path.join(in_dir, "*"))) if len(path_list) == 0: raise FileNotFoundError(f"No images found in: {in_dir}") imgs = [] for p in path_list: imgs.append(Image.open(p)) # PILのdurationは「1フレームあたりの表示時間[ms]」 duration_ms = max(1, int(round(frame_dt_s * 1000))) imgs[0].save( out_filename, save_all=True, append_images=imgs[1:], optimize=False, duration=duration_ms, loop=loop, ) return # 以前の画像が残っている場合、imgフォルダ内のpngを削除 for p in IMG_DIR.glob("*.png"): p.unlink() # XMLファイルからモデルを読み込み、状態(時間・位置・速度など)を保持するdataを作成 model = mujoco.MjModel.from_xml_path(str(XML_PATH)) data = mujoco.MjData(model) # ジョイント名から、qpos配列・qvel配列のどこに値が入っているかインデックスを探査 jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, JOINT_NAME) qpos_adr = model.jnt_qposadr[jid] qvel_adr = model.jnt_dofadr[jid] # 状態を初期化してから、初期変位と初期速度を代入、内部計算を整合させるためにmj_forwardを実行 mujoco.mj_resetData(model, data) data.qpos[qpos_adr] = X0 data.qvel[qvel_adr] = V0 mujoco.mj_forward(model, data) # オフスクリーンレンダラを作成(ビューアは使わず、画像として取り出す) renderer = mujoco.Renderer(model, width=WIDTH, height=HEIGHT) # シミュレーション刻み幅(timestep)から、必要なステップ数と間引き間隔を決める dt = model.opt.timestep steps = int(T_END / dt) + 1 # FRAME_DTごとに保存したいので、何ステップごとに保存するかを計算する save_every = max(1, int(round(FRAME_DT / dt))) # 連番画像を書き出し(t=0も含めて保存) frame_idx = 0 for i in range(steps): if i > 0: mujoco.mj_step(model, data) if i % save_every == 0: renderer.update_scene(data) rgb = renderer.render() Image.fromarray(rgb).save(IMG_DIR / f"{frame_idx:06d}.png") frame_idx += 1 renderer.close() # 保存した連番画像からGIFを作成(FRAME_DTをそのまま渡す) create_gif(in_dir=str(IMG_DIR), out_filename="animation.gif", frame_dt_s=FRAME_DT) |
画像の間引き
GIF動画を作るための画像はすべて imgフォルダの中に蓄積されます。画像が多すぎると容量が大きくなりすぎてしまったり、実時間で再生するのが難しくなったりします。そこで、上記コードには画像を一定の秒数間隔で間引く設定を追加しました。 FRAME_DTの秒数を変更することで、何秒間隔で画像を取得するかを決めます。
|
1 2 |
# GIF動画の間引き設定[s] FRAME_DT = 0.05 |
GIF画像作成関数
GIF画像作成関数は以下の記事のコードをそのまま流用しています。
・Pythonで複数画像からGIFを作る時に便利な処理まとめ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def create_gif(in_dir, out_filename, frame_dt_s, loop=0): """連番画像からGIFを作成""" path_list = sorted(glob.glob(os.path.join(in_dir, "*"))) if len(path_list) == 0: raise FileNotFoundError(f"No images found in: {in_dir}") imgs = [] for p in path_list: imgs.append(Image.open(p)) # PILのdurationは「1フレームあたりの表示時間[ms]」 duration_ms = max(1, int(round(frame_dt_s * 1000))) imgs[0].save( out_filename, save_all=True, append_images=imgs[1:], optimize=False, duration=duration_ms, loop=loop, ) return |
.xlmファイルでカメラの引きを設定
上記コードを実行するとanimation.gifというGIF動画が作成されますが、今回のシミュレーションでは点の大きさが小さいのに対して変位は大きいため、画面に映らないほど大きく動きます。次のコードの2行目に記載の statisticを追加し、 extentの値を大きくするとカメラを引いた位置に設定できます。何も設定しないと、全体のサイズに対してMuJuCoが自動で画面のサイズを決定します。
|
1 2 3 4 5 6 7 8 9 10 11 |
<mujoco model="1d_oscillator"> <statistic center="0 0 0" extent="2"/> <option gravity="0 0 0" timestep="0.001"/> <worldbody> <body name="mass" pos="0 0 0"> <joint name="x" type="slide" axis="1 0 0" stiffness="20" damping="0.5" springref="0"/> <geom type="sphere" size="0.05" mass="1"/> </body> </worldbody> </mujoco> |
GIF動画の例
こちらが上記コードで得られる動画です。

まとめ
この記事ではMuJoCoの入門として、すでに当WATLABブログでよく扱っている1自由度のばねマスダンパーモデルを対象としたシミュレーションを行いました。MJCFとして
.xmlにモデルを記述する方法やPythonを使って計算するコードを紹介しました。
MuJoCoは
pip installで簡単にインストールできるので、簡単にPythonで呼び出すことができます。Pythonを使うことで動画の作成までコードで書けるため非常に使いやすいと思いました。
MuJoCoに入門しました!これはPythonで扱える機構解析ソフトとしても非常に使いやすいと思います!
Xでも関連情報をつぶやいているので、wat(@watlablog)のフォローお待ちしています!

ついにWATLABブログから書籍「いきなりプログラミングPython」が発売しました!