0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyBullet 入門:stepSimulation を呼ばないと時間は進まない ── 箱を落として理論値と比べ、画面なしで gif に残す

0
Posted at

PyBullet(Python から使える物理シミュレータ)を初めて触ると、多くの人が同じところでつまずきます。箱を空中に置いたのに、落ちない。 原因はたいてい次の3つのどれかです。

落ちない原因 何が起きているか
stepSimulation() を呼んでいない PyBullet の時間は自動では流れない。呼んだぶんだけ進む
setGravity() を呼んでいない 初期状態は無重力
床を置いていない こちらは逆に、どこまでも落ちていく

この記事では、高さ 1 m から小箱を1つ落とすだけのプログラムで、この3点を確かめます。あわせて、

  • 1秒後の高さが 0 ではなく 0.0250 m になる理由
  • 0.3秒後の高さが自由落下の理論値より 1.8 mm 低い理由(実は2つの原因の足し算)
  • 画面を出さずに、落ちる様子を gif に残す方法

まで扱います。必要なのは pip install pybullet numpy matplotlib だけで、この記事のコードだけで動きます。

動作環境

Python 3.10.9
numpy      1.26.4
pybullet   3.2.7
matplotlib 3.7.0

1. PyBullet の時間は、自分で進める

物理エンジン(physics engine:物体にかかる力から、ほんの少し先の位置と速度を計算するプログラム)がやっていることは、パラパラ漫画を1枚ずつ描く作業に近いものです。いまの状態から力を求め、$a = F/m$ で加速度を出し、ほんの少しの時間ぶんだけ速度と位置を更新します。この1枚を「1ステップ」と呼びます。

PyBullet では、この1枚を描く作業は p.stepSimulation() を呼んだときにだけ起きます。

stepSimulation を呼ぶたびに時刻・高さ・速度が1ステップずつ進む様子。時刻0.000秒・高さ1.000m・速度0.00m/s から、1回呼ぶごとに1/240秒ぶん進む

既定では 1ステップ = 1/240 秒(約 0.00417 秒) です。1秒ぶん進めたければ240回呼びます。

for _ in range(240):
    p.stepSimulation()      # 合計で1秒ぶん進む

時間を自分で回せるので、シミュレーションは止めることも、1ステップごとに位置を覗いて記録することもできます。実機にはできないことです。

2. 世界を作る4つの手順

シミュレーション世界の作り方は、ほぼ定型です。

手順 コード 忘れると
① 接続 p.connect(p.DIRECT) 何もできない
② 探索パス p.setAdditionalSearchPath(pybullet_data.getDataPath()) plane.urdf が見つからない
③ 重力 p.setGravity(0, 0, -9.81) 無重力のまま、箱は浮く
④ 床 p.loadURDF("plane.urdf") 箱がどこまでも落ちる

補足が2つあります。

  • 接続のモードは2つあります。p.GUI は画面つき、p.DIRECT は画面なしです。違いは見えるかどうかだけで、計算される物理は同じです。この記事では、サーバでも動き、描画のぶん遅くならない DIRECT を使います。
  • PyBullet は z軸が上です。だから重力は z に -9.81 を入れます。

loadURDF() は、URDF(Unified Robot Description Format:ロボットや物体の形を書く XML ファイル)を読み込む関数です。床の plane.urdf も、あとで落とす箱の cube_small.urdf も PyBullet に同梱されています。

3. 全コード:箱を落として記録し、gif に残す

1ファイルにまとめました。drop_box.py として保存し、python drop_box.py で動きます。

drop_box.py
# PyBullet で小箱を落とし,高さを記録して理論値と比べ,画面なしで gif に残す

# ライブラリの読み込み
import numpy as np                              # 数値計算
import pybullet as p                            # 物理シミュレータ本体
import pybullet_data                            # PyBullet に同梱されたURDF(床・箱)の置き場
import matplotlib.pyplot as plt                 # 描画
from matplotlib.animation import FuncAnimation  # アニメーション


# 重力加速度(m/s^2)。PyBullet は z軸が上向きなので,下向き=マイナス
GRAVITY_Z = -9.81

# シミュレーションの時間の刻み幅(秒)。PyBullet の既定値
TIME_STEP = 1.0 / 240.0     # 1ステップで進む時間(約0.00417秒)
STEPS_PER_SECOND = 240      # 1秒ぶん進めるのに必要なステップ数

# 実験の条件
DROP_HEIGHT   = 1.0     # 箱を落とす高さ(m)
FALL_SECONDS  = 1.0     # 落下を観察する時間(秒)
CAPTURE_EVERY = 6       # 何ステップごとに1コマ撮るか

# カメラ(画面を出さずに画像だけを作る)の設定
CAMERA_WIDTH  = 480     # 画像の幅(ピクセル)
CAMERA_HEIGHT = 360     # 画像の高さ(ピクセル)
GIF_FPS       = 20      # gif の1秒あたりのコマ数


def create_world() -> int:
    """
    画面なし(DIRECT)でシミュレーション世界を作り,床を置く

    戻り値
        シミュレーション世界の識別番号
    """
    # ① 接続する。p.GUI なら画面つき,p.DIRECT なら画面なし(計算は同じ)
    client_id = p.connect(p.DIRECT)

    # ② 同梱URDF(plane.urdf など)を名前だけで読み込めるように探索パスを通す
    p.setAdditionalSearchPath(pybullet_data.getDataPath(), physicsClientId=client_id)

    # ③ 重力を設定する。これを呼ばないと物は落ちない(初期状態は無重力)
    p.setGravity(0.0, 0.0, GRAVITY_Z, physicsClientId=client_id)
    p.setTimeStep(TIME_STEP, physicsClientId=client_id)

    # ④ 床を置く。床が無いと箱はどこまでも落ちていく
    p.loadURDF("plane.urdf", physicsClientId=client_id)
    return client_id


def capture_image(client_id: int) -> np.ndarray:
    """
    いまのシミュレーションの様子を1枚の画像として取り出す(画面なしで描画)

    パラメータ
        client_id: シミュレーション世界の識別番号

    戻り値
        RGB画像(高さ × 幅 × 3 の配列)
    """
    # 視点行列:どこから,どこを見るか(注視点・距離・角度で指定)
    view_matrix = p.computeViewMatrixFromYawPitchRoll(
        cameraTargetPosition=(0.0, 0.0, 0.5), distance=2.0,
        yaw=45.0, pitch=-25.0, roll=0.0,
        upAxisIndex=2,          # z軸を上とみなす
        physicsClientId=client_id)

    # 投影行列:視野角・縦横比・写る奥行きの範囲(どのレンズを使うか)
    projection_matrix = p.computeProjectionMatrixFOV(
        fov=60.0, aspect=CAMERA_WIDTH / CAMERA_HEIGHT,
        nearVal=0.1, farVal=10.0,
        physicsClientId=client_id)

    # 画像を取り出す。CPU だけで動く描画方式を明示する
    _, _, rgb, _, _ = p.getCameraImage(
        width=CAMERA_WIDTH, height=CAMERA_HEIGHT,
        viewMatrix=view_matrix, projectionMatrix=projection_matrix,
        renderer=p.ER_TINY_RENDERER,
        physicsClientId=client_id)

    # RGBA(4チャンネル)で返るので,RGB の3チャンネルだけを取り出す
    rgb = np.reshape(np.array(rgb, dtype=np.uint8), (CAMERA_HEIGHT, CAMERA_WIDTH, 4))
    return rgb[:, :, :3]


def drop_box(client_id: int) -> tuple:
    """
    小箱を高さ DROP_HEIGHT から落とし,1ステップごとの高さと途中の画像を記録する

    パラメータ
        client_id: シミュレーション世界の識別番号

    戻り値
        (時刻の配列, 高さの配列, 画像のリスト)
    """
    box_id = p.loadURDF("cube_small.urdf", [0.0, 0.0, DROP_HEIGHT],
                        physicsClientId=client_id)

    times, heights, frames = [], [], []
    total_steps = int(FALL_SECONDS * STEPS_PER_SECOND)
    for step in range(total_steps + 1):
        # 記録してから進める(逆にすると最初の高さ 1.0 m を取り逃がす)
        position, _ = p.getBasePositionAndOrientation(box_id, physicsClientId=client_id)
        times.append(step * TIME_STEP)
        heights.append(position[2])     # z が高さ

        if step % CAPTURE_EVERY == 0:
            frames.append(capture_image(client_id))

        # 時間を1ステップ(1/240秒)進める。呼ばなければ箱は落ちない
        p.stepSimulation(physicsClientId=client_id)

    return np.array(times), np.array(heights), frames


def save_gif(frames: list, file_name: str) -> None:
    """
    取り出した画像の列を gif アニメーションとして保存する

    パラメータ
        frames: 画像(RGB配列)のリスト
        file_name: 保存先のファイル名
    """
    fig, ax = plt.subplots(figsize=(CAMERA_WIDTH / 100, CAMERA_HEIGHT / 100), dpi=100)
    ax.axis("off")
    ax.set_title("falling box (DIRECT mode)")
    image = ax.imshow(frames[0])

    def update(index: int) -> tuple:
        image.set_data(frames[index])
        return (image,)

    anim = FuncAnimation(fig, update, frames=len(frames), interval=1000 / GIF_FPS, blit=True)
    anim.save(file_name, writer="pillow", fps=GIF_FPS)
    plt.close(fig)
    print(f"gifを保存しました: {file_name}({len(frames)}コマ)")


def main() -> None:
    """
    メイン処理
    """
    client_id = create_world()
    times, heights, frames = drop_box(client_id)
    print(f"落とす前の高さ = {heights[0]:.4f} m")
    print(f"{FALL_SECONDS}秒後の高さ = {heights[-1]:.4f} m")

    # 0.3秒後(まだ空中にいる)を自由落下の理論値 z = z0 - (1/2) g t^2 と比べる
    index = int(0.3 * STEPS_PER_SECOND)
    theory = DROP_HEIGHT - 0.5 * abs(GRAVITY_Z) * times[index] ** 2
    print(f"0.3秒後の高さ: シミュレーション = {heights[index]:.4f} m, "
          f"理論値 = {theory:.4f} m, 差 = {heights[index] - theory:+.4f} m")

    save_gif(frames, "falling_box_anime.gif")
    p.disconnect(physicsClientId=client_id)


if __name__ == "__main__":
    main()

ポイントは drop_box() のループの順番です。

  1. いまの位置を記録する
  2. 6ステップに1回、写真を撮る
  3. stepSimulation() で1/240 秒進める

「記録してから進める」順にしているので、最初の高さ 1.0 m も記録に残ります。逆にすると、最初の状態を取り逃がします。

画面なしで画像を取り出す仕組み

capture_image() は、2つの行列を作って getCameraImage() に渡しています。

行列 決めること 写真でたとえると
視点行列(view matrix) どこから、どこを見るか 三脚をどこに立て、どちらを向くか
投影行列(projection matrix) 視野角・縦横比・写る奥行き どのレンズを使うか

renderer=p.ER_TINY_RENDERER は、GPU(画像を描く計算が得意な装置)を使わず CPU だけで描く方式です。DIRECT では指定しなくてもこちらが使われますが、GUI で接続すると既定が GPU 描画に変わるので、どちらでも同じ絵が出るように明示しています。

4. 実行結果

落とす前の高さ = 1.0000 m
1.0秒後の高さ = 0.0250 m
0.3秒後の高さ: シミュレーション = 0.5567 m, 理論値 = 0.5585 m, 差 = -0.0018 m
gifを保存しました: falling_box_anime.gif(41コマ)

1行目の前に pybullet build time: … という表示が出ますが、PyBullet を読み込んだときのもので、日付は環境によって変わります。

箱が落下する様子のアニメーション。市松模様の床の上で、空中の小さな箱が加速しながら落ち、床に着いて止まる

画面を一度も出さずに、動く絵が手に入りました。41コマを 20 fps で保存しているので、実際の時間の約2倍の長さでゆっくり再生されます。

5. 1秒後の高さが 0 ではなく 0.0250 m になる理由

床に着いたのに高さが 0 にならないのは、getBasePositionAndOrientation() が返すのが箱の中心の位置だからです。cube_small.urdf は一辺 0.05 m の立方体なので、床に着いたとき中心は床から半分の 0.025 m にあります。

床に置いた箱の高さ。上面 z = 0.050、中心 z = 0.025、下面 z = 0.000。getBasePositionAndOrientation() が返すのは中心の値

返ってきた数字が物体のどこを指しているのかは、シミュレータを使うたびに確かめる必要があります。

6. 理論値より 1.8 mm 低い理由は2つある

0.3 秒後の高さを自由落下の式で計算すると、

$$
z = z_0 - \frac{1}{2} g t^2 = 1.0 - \frac{1}{2} \times 9.81 \times 0.3^2 = 0.5585 \ \text{m}
$$

です。シミュレーションは 0.5567 m なので、1.8 mm 低い位置にいます。この差は、向きが逆の2つの原因に分けられます。

原因① 時間を刻みに区切っているから(理論値より下へ)

PyBullet は半陰的オイラー法(semi-implicit Euler:先に速度を更新し、その新しい速度で位置を進める計算のしかた)で時間を進めます。1刻みぶん速くなった速度で位置を進めるので、箱は理論値より余分に落ちます。このずれは刻み幅 $\Delta t$ に比例し、

$$
-\frac{1}{2} g , t , \Delta t = -\frac{1}{2} \times 9.81 \times 0.3 \times \frac{1}{240} = -0.0061 \ \text{m}
$$

と見積もれます。

原因② 既定で減衰が入っているから(理論値より上へ)

PyBullet は、読み込んだ物体に最初から小さな減衰(damping:速度に応じて動きを弱める効果。空気抵抗に近い)を入れています。そのぶん落下が少し遅れます。

減衰を切って(p.changeDynamics(box_id, -1, linearDamping=0.0) を箱の読み込み直後に足す)、刻み幅を変えて測ると次のようになります。差は「シミュレーション − 理論値」です。

刻み幅 $\Delta t$ 減衰あり(既定) 減衰なし 原因①の見積もり
1/240 秒 −0.0018 m −0.0061 m −0.0061 m
1/480 秒 +0.0012 m −0.0031 m −0.0031 m
1/960 秒 +0.0028 m −0.0015 m −0.0015 m

減衰を切ると、実測は見積もりと一致します。減衰ありと減衰なしの差は、どの刻みでも +0.0043 m で一定です。つまり、

$$
-0.0018 = \underbrace{-0.0061}{\text{①刻み(}\Delta t\text{ に比例)}} + \underbrace{0.0043}{\text{②減衰(刻みによらない)}}
$$

という足し算でした。

刻みを細かくすれば正確になる、とは限りません。
表の「減衰あり」の列を見ると、刻みを 1/240 → 1/480 → 1/960 と細かくしたのに、差の大きさは 0.0018 → 0.0012 → 0.0028 と、いったん減ってから増えています。①は縮みますが、②は縮まないからです。合わないときは刻みを細かくする前に、差の中身を分けてみてください。

理論値とシミュレーションを重ねると、ほとんど区別がつきません。約 0.45 秒で床に着き、そこから先は水平になっています。

落下する箱の高さのグラフ。横軸が時間0〜1秒、縦軸が高さ0〜1メートル。破線の理論値と実線のシミュレーションがほぼ重なって下降し、約0.45秒で0.025メートルに達して水平になる

この図は、記事のコードの times と heights を matplotlib で描いたものです(描画コードは省略)。

7. 手元で試すなら

  • p.stepSimulation(...) の行をコメントアウトすると、高さは最後まで 1.0000 m のままです
  • p.setGravity(...) の行をコメントアウトしても、やはり落ちません。「落ちない」原因は2種類あります
  • p.loadURDF("plane.urdf", ...) をコメントアウトすると、1秒後の高さが大きな負の値になります
  • GRAVITY_Z を -1.62(月面)にすると、1秒後にはまだ空中にいます

まとめ

  • PyBullet の時間は stepSimulation() を呼んだぶんだけ進む(1回 = 1/240 秒)
  • 重力は setGravity() で設定しないと無重力、床は自分で置く
  • 返ってくる位置は物体の中心。床の上の小箱なら 0.025 m
  • 理論値との差 −1.8 mm は「刻み −6.1 mm」+「既定の減衰 +4.3 mm」
  • DIRECT + getCameraImage() で、画面を出さずに図と gif が作れる

次回は、床と箱の代わりに自分で書いた2軸アームを置きます。今回 plane.urdf と cube_small.urdf を名前だけで読み込んだ、その URDF ファイルを自分で書きます。

ロボットの自作記事まとめ(2軸・3軸・6軸アームから PyBullet まで):

役に立ったら いいね・ストック で応援いただけると、次回の励みになります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?