PythonのMatplotlibで3Dアニメーション(3次元GIF)を作成する方法

PythonのMatplotlibとNumpyで3次元(3D)データをアニメーション化し、GIF画像として保存する方法を解説。FuncAnimationの仕組み・ブリッティングの実態・PillowWriter/FFMpegWriterの実測比較・長時間アニメーションのメモリ最適化まで踏み込みます。

MatplotlibとNumpyを使って、3次元データを可視化し、それをアニメーション(GIF画像)として保存する方法を紹介します。時系列で変化する3Dデータを表現する際に非常に有効です。

作成されるGIF画像

Matplotlibで作成した3Dアニメーション(sin波とcos波の軌跡)

コード

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from mpl_toolkits.mplot3d import Axes3D # 3Dプロットのために必要

def make_animation(data_frames, filename="animation.gif"):
    """
    3Dデータをアニメーション(GIF)として作成し保存する関数。

    :param data_frames: 各フレームの3D座標 (X, Y, Z) のリスト。
                        例: [[X_frame1, Y_frame1, Z_frame1], [X_frame2, Y_frame2, Z_frame2], ...]
    :param filename: 保存するGIFファイルのパス。
    """
    print("フレームを生成中...")

    fig = plt.figure(figsize=(8, 6)) # 図のサイズを指定
    ax = fig.add_subplot(111, projection='3d')

    # 軸ラベルの設定
    ax.set_xlabel('X-axis')
    ax.set_ylabel('Y-axis')
    ax.set_zlabel('Z-axis')

    # 軸の範囲をデータに合わせて自動調整、または手動で設定
    # ax.set_xlim(-500, 500) # 例
    # ax.set_ylim(-3, 3)
    # ax.set_zlim(-3, 3)

    ims = [] # 各フレームのArtistオブジェクトを格納するリスト

    for i, (X, Y, Z) in enumerate(data_frames):
        # プログレスバー表示
        progress = (i + 1) * 100 / len(data_frames)
        print(f"\r進捗: {progress:.1f}%", end="")

        # 3Dプロット。marker='o' で点を表示、linestyle='None' で線を表示しない
        # im = ax.plot(X, Y, Z, marker="o", color="red", linestyle='None')
        # plot()はリストを返すので、ims.append(im[0])のように要素を取り出す
        im, = ax.plot(X, Y, Z, marker="o", color="red", linestyle='None') # カンマでアンパック

        ims.append([im]) # ArtistAnimationはリストのリストを期待する

    print("\nアニメーションを生成中...")

    # アニメーションの作成
    # fig: アニメーションの対象となるFigureオブジェクト
    # ims: 各フレームのArtistオブジェクトのリスト
    # interval: フレーム間の遅延時間(ミリ秒)
    # blit: Trueにすると、変更された部分のみを再描画し高速化(ただし、複雑な3Dプロットでは問題を起こす可能性あり)
    ani = animation.ArtistAnimation(fig, ims, interval=50, blit=False)

    # アニメーションの保存
    # writer: アニメーションを保存するためのライター。'ffmpeg' や 'imagemagick' などが必要になる場合がある。
    # fps: フレーム/秒
    ani.save(filename, writer='pillow', fps=20) # 'pillow' は追加のライブラリ不要でGIF保存可能

    print(f"アニメーションを {filename} に保存しました。")
    plt.show() # アニメーションを表示

def main():
    # サンプルデータの生成
    # 時刻tに応じてsin波とcos波が変化する3D軌跡
    t = np.linspace(0, 20 * np.pi, 500) # 0から20πまで500点

    data_frames = []
    for i in range(len(t)):
        # 各フレームで1点だけプロットする例
        X_val = t[i] / (20 * np.pi) * 5 # X軸は時間経過で変化
        Y_val = np.sin(t[i])
        Z_val = np.cos(t[i])
        data_frames.append([[X_val], [Y_val], [Z_val]]) # 各要素をリストにする

    make_animation(data_frames, filename="sin_cos_3d_animation.gif")

if __name__ == '__main__':
  main()

コードの解説

  • matplotlib.animation.ArtistAnimation: plot 関数が返すArtistオブジェクトのリストを渡すことで、それらのオブジェクトが各フレームで描画されるアニメーションを作成します。
  • fig.add_subplot(111, projection='3d'): 3Dプロットを作成するために必須です。
  • ax.plot(X, Y, Z, ...): 3D空間にデータをプロットします。markercolor, linestyle などで表示をカスタマイズできます。
  • ani.save(filename, writer='pillow', fps=20): アニメーションをGIFファイルとして保存します。
    • writer='pillow': Pillowライブラリを使用してGIFを生成します。別途 pip install pillow が必要ですが、ffmpeg などの外部ツールをインストールする必要がないため手軽です。
    • fps: Frames Per Second(1秒あたりのフレーム数)で、アニメーションの速度を調整します。
  • プログレスバー: \r を使って同じ行を上書きすることで、コンソールにプログレスバーを表示しています。

このコードを参考に、様々な3Dデータをアニメーション化して可視化してみてください。

なお上のコードは ArtistAnimation(各フレームのArtistを事前に全部作ってリストで渡す方式)を使っていますが、以降ではより柔軟な FuncAnimation(毎フレーム呼ばれる関数の中でArtistのプロパティだけを更新する方式)を使って、アニメーションの仕組みと注意点を詳しく見ていきます。

FuncAnimationの仕組み:Artist更新モデルとブリッティング

Artistを「作り直す」のではなく「更新する」

matplotlib.animation.FuncAnimation は、フレームごとに図全体を作り直すのではなく、既存のArtistオブジェクト(Line2D/Line3Dなど)のデータだけを書き換えることで動作します。典型的な実装は次のようになります。

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation

t = np.linspace(0, 8 * np.pi, 150)
x = t / (8 * np.pi) * 5
y = np.sin(t)
z = np.cos(t)

fig = plt.figure(figsize=(6, 4.5))
ax = fig.add_subplot(111, projection="3d")
ax.set_xlim(0, 5)
ax.set_ylim(-1.2, 1.2)
ax.set_zlim(-1.2, 1.2)

(trail,) = ax.plot([], [], [], color="tab:red", lw=1.5)
(head,) = ax.plot([], [], [], color="tab:red", marker="o", linestyle="None")


def update(i):
    # 変更が必要な Artist のプロパティだけを更新する
    trail.set_data(x[: i + 1], y[: i + 1])
    trail.set_3d_properties(z[: i + 1])
    head.set_data([x[i]], [y[i]])
    head.set_3d_properties([z[i]])
    # blit=True のときはここで返した Artist だけが再描画対象になる
    return trail, head


ani = animation.FuncAnimation(fig, update, frames=len(t), interval=40, blit=False)
ani.save("funcanimation_demo.gif", writer="pillow", fps=20)

update(i) の中で line.set_data() / set_3d_properties() のように既存Artistの座標だけを差し替え、更新されたArtistのタプルを返すのがポイントです。ArtistAnimation のように全フレーム分のArtistを事前に生成してメモリに保持する必要がないため、フレーム数が多い場合でもメモリ効率がよく、より一般的に使われます。

ブリッティング(blit)が最適化しているもの

blit=True を指定すると、Matplotlibは「変更があったArtistのbounding box領域だけ」を再描画し、それ以外のキャンバス(軸ラベル、グリッド、背景など)は直前にcanvas.copy_from_bbox()でキャッシュしたピクセルをそのまま再利用します。つまりブリッティングが最適化しているのは「キャンバス全体の再描画(フル再ラスタライズ)」であり、変更されていない部分の再描画をスキップすることでインタラクティブな表示を高速化する仕組みです。

ただし、ここに見落とされがちな重要な事実があります。ani.save() でファイルに書き出す場合、blit パラメータの値に関係なく、Matplotlib内部では常に blit=False としてフレームが描画されます。 これはMatplotlib 3.10系のソースコード(matplotlib/animation.pyAnimation.save())で実際に確認できます。

# matplotlib/animation.py (Animation.save 内、抜粋)
for data in zip(*[a.new_saved_frame_seq() for a in all_anim]):
    for anim, d in zip(all_anim, data):
        # TODO: See if turning off blit is really necessary
        anim._draw_next_frame(d, blit=False)
    writer.grab_frame(**savefig_kwargs)

コメントに “TODO: See if turning off blit is really necessary”(本当にblitをオフにする必要があるか要検討)とある通り、これは意図的な実装です。理由は単純で、ファイル書き出し時は毎フレームの完全なビットマップをエンコーダに渡す必要があり、「差分だけ再描画してキャッシュを使い回す」というブリッティングの前提(=画面に表示され続けているキャンバスのピクセルバッファ)が保存処理にはそぐわないためです。

この事実を検証するため、視点を毎フレーム回転させる3Dアニメーションを blit=Trueblit=False の両方でGIF保存し、出力フレームをピクセル単位で比較してみました。

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from PIL import Image, ImageSequence


def make_gif(blit, filename):
    fig = plt.figure(figsize=(4, 4))
    ax = fig.add_subplot(111, projection="3d")
    (line,) = ax.plot([0], [0], [0], marker="o", color="red")

    def update(i):
        line.set_data([np.cos(i / 5)], [np.sin(i / 5)])
        line.set_3d_properties([0])
        ax.view_init(elev=20, azim=i * 10)  # フレームごとに視点を回転
        return (line,)

    ani = animation.FuncAnimation(fig, update, frames=6, blit=blit, interval=200)
    ani.save(filename, writer="pillow", fps=2)
    plt.close(fig)


make_gif(True, "blit_true.gif")
make_gif(False, "blit_false.gif")


def frames_of(path):
    im = Image.open(path)
    return [np.array(f.convert("RGB")) for f in ImageSequence.Iterator(im)]


f_true = frames_of("blit_true.gif")
f_false = frames_of("blit_false.gif")
identical = all(np.array_equal(a, b) for a, b in zip(f_true, f_false))
print(f"blit=True と blit=False で保存された {len(f_true)} フレームがすべて一致: {identical}")

実行結果:

blit=True と blit=False で保存された 6 フレームがすべて一致: True

予想通り、保存されたGIFは blit の値に関わらず完全に同一のピクセル列になりました。つまり、GIF/MP4への書き出しにおいてblitパラメータは何の効果も持ちません。ブリッティングが意味を持つのはあくまで plt.show() によるインタラクティブな画面表示(またはJupyterのウィジェット表示)に限られます。

3D Axesでブリッティングが素直に機能しない理由

インタラクティブ表示に限っても、3D Axes(Axes3D)とブリッティングの組み合わせにはよく知られた制限があります。Matplotlibの公式Issueトラッカーには、blit=True で3Dアニメーションを表示するとマウスによる視点の回転・拡大縮小が正しく機能しなくなるという報告が複数存在します( matplotlib/matplotlib#8162 )。

技術的な背景はこうです。ブリッティングは「キャッシュした背景ピクセル + 変更されたArtistの差分」で画面を合成しますが、3D表示ではカメラ視点(elev/azim/roll)が変わると、視点変換によってシーン全体の投影・奥行き(Z-order)が再計算される必要があります。これは2Dの「特定のArtistだけ座標が変わる」ケースとは根本的に異なり、キャッシュされた背景(回転前のパネルやグリッド線)は視点が変わった瞬間に無効になります。そのため公式には、3D Axesを回転させながらのインタラクティブなブリッティング利用は推奨されていません(blit=False で描画するか、後述する新しいMatplotlibバージョンでのパフォーマンス改善を活用するのが安全です)。

とはいえ前述の通り、この記事のようにGIF/MP4へ保存するだけの用途では、blit の値を気にする必要は実質的にありません(保存時は常に blit=False 相当の完全描画になるため)。blit=True を指定する意味があるのは、plt.show() でリアルタイムに表示しながら2D Axesを高速に動かしたいケースに限られます。

書き出し形式のトレードオフ:PillowWriter(GIF)vs FFMpegWriter(MP4)

元記事のコードでは writer='pillow' を使ってGIFとして保存していますが、Matplotlibは FFMpegWriter を使ってMP4(H.264)として保存することもできます。両者は次のようなトレードオフを持ちます。

PillowWriter(GIF)FFMpegWriter(MP4)
追加の外部ツール不要(pip install pillow のみ)必要(ffmpeg バイナリ)
色数パレット256色(劣化しやすい)フルカラー
ブラウザでの自動再生<img> タグでそのまま自動再生・ループ<video> タグが必要
ファイルサイズ・エンコード速度後述の通り不利後述の通り有利

検証環境にはシステム全体にインストールされた ffmpeg が存在しなかったため(which ffmpeg は見つからず)、pip install imageio-ffmpeg が同梱するスタティックビルドの ffmpeg バイナリを matplotlib.rcParams["animation.ffmpeg_path"] に指定して利用しました。以下は実際に実行して計測したコードと結果です。

import os
import time
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.animation as animation

# システムに ffmpeg がないため、imageio-ffmpeg 同梱のバイナリを指定する
import imageio_ffmpeg

matplotlib.rcParams["animation.ffmpeg_path"] = imageio_ffmpeg.get_ffmpeg_exe()


def build_animation(n_frames, dpi):
    t = np.linspace(0, 8 * np.pi, n_frames)
    x, y, z = t / (8 * np.pi) * 5, np.sin(t), np.cos(t)

    fig = plt.figure(figsize=(6, 4.5), dpi=dpi)
    ax = fig.add_subplot(111, projection="3d")
    ax.set_xlim(0, 5)
    ax.set_ylim(-1.2, 1.2)
    ax.set_zlim(-1.2, 1.2)
    (trail,) = ax.plot([], [], [], color="tab:red", lw=1.5)
    (head,) = ax.plot([], [], [], color="tab:red", marker="o", linestyle="None")

    def update(i):
        trail.set_data(x[: i + 1], y[: i + 1])
        trail.set_3d_properties(z[: i + 1])
        head.set_data([x[i]], [y[i]])
        head.set_3d_properties([z[i]])
        return trail, head

    return fig, animation.FuncAnimation(fig, update, frames=n_frames, interval=40)


for n_frames in (60, 150, 300):
    fig, ani = build_animation(n_frames, dpi=100)
    t0 = time.perf_counter()
    ani.save(f"gif_{n_frames}.gif", writer="pillow", fps=20)
    gif_time = time.perf_counter() - t0
    gif_size = os.path.getsize(f"gif_{n_frames}.gif") / 1024
    plt.close(fig)

    fig, ani = build_animation(n_frames, dpi=100)
    t0 = time.perf_counter()
    ani.save(f"mp4_{n_frames}.mp4", writer=animation.FFMpegWriter(fps=20, codec="libx264"))
    mp4_time = time.perf_counter() - t0
    mp4_size = os.path.getsize(f"mp4_{n_frames}.mp4") / 1024
    plt.close(fig)

    print(
        f"frames={n_frames:4d}  GIF: {gif_size:8.1f} KB / {gif_time:5.2f} s   "
        f"MP4: {mp4_size:8.1f} KB / {mp4_time:5.2f} s   "
        f"size ratio(GIF/MP4)={gif_size / mp4_size:5.1f}x"
    )

実行結果(Apple Silicon Mac、Matplotlib 3.11.1、DPI=100固定):

frames=  60  GIF:    358.1 KB /  1.64 s   MP4:     31.5 KB /  0.72 s   size ratio(GIF/MP4)= 11.4x
frames= 150  GIF:    637.5 KB /  4.03 s   MP4:     41.3 KB /  1.77 s   size ratio(GIF/MP4)= 15.4x
frames= 300  GIF:   1088.7 KB /  7.97 s   MP4:     74.4 KB /  3.61 s   size ratio(GIF/MP4)= 14.6x

PillowWriter(GIF)とFFMpegWriter(MP4)のファイルサイズ・エンコード時間比較

同じアニメーション・同じDPIで比較すると、MP4はGIFよりファイルサイズが11〜15倍小さく、エンコード時間も2倍前後速いという結果になりました。これはGIFがパレット256色に量子化されるフォーマットである一方、H.264は動き補償やフレーム間予測を使った本格的な動画圧縮を行うためです。ブログ記事の埋め込みのように「<img> タグで手軽に自動再生したい」場合はGIFの手軽さに軍配が上がりますが、フレーム数が多い・高解像度が必要といったケースでは、ffmpeg を用意してMP4で書き出す方が実用的です。

長時間アニメーションのメモリ使用量:全フレーム蓄積 vs その場生成

元記事のコードは、ArtistAnimation を使うために事前に全フレームのArtistを ims というリストへ蓄積していました。データそのもの(座標配列)も同様に、ループの前で全フレーム分をリストへためてから渡す実装をよく見かけますが、これはフレーム数に比例してメモリ使用量が増える点に注意が必要です。

FuncAnimationupdate() 関数は「今必要なフレームのデータだけをその場で計算し、使い終わったら破棄する」という書き方ができるため、長時間・大量フレームのアニメーションではこちらの方が明らかにメモリ効率がよくなります。実際に tracemalloc でピークメモリを計測して比較しました。

import gc
import tracemalloc
import numpy as np

N_POINTS = 200  # 1フレームあたりの点数


def peak_mb_accumulate_all(n_frames, seed=123):
    """NG例: 全フレーム分のデータを先にリストへためてからアニメーション化"""
    tracemalloc.start()
    rng = np.random.default_rng(seed)
    frames = []
    for _ in range(n_frames):
        X = rng.normal(size=N_POINTS)
        Y = rng.normal(size=N_POINTS)
        Z = rng.normal(size=N_POINTS)
        frames.append((X, Y, Z))  # ここで全フレームを保持し続ける
    _, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    del frames
    gc.collect()
    return peak / 1e6


def peak_mb_on_the_fly(n_frames, seed=123):
    """OK例: update関数の中でその場でフレームを生成し、使い終わったら破棄する"""
    tracemalloc.start()
    rng = np.random.default_rng(seed)
    peak_seen = 0
    for _ in range(n_frames):
        X = rng.normal(size=N_POINTS)
        Y = rng.normal(size=N_POINTS)
        Z = rng.normal(size=N_POINTS)
        _, peak = tracemalloc.get_traced_memory()
        peak_seen = max(peak_seen, peak)
        # X, Y, Z はここでスコープを抜けて破棄される
    tracemalloc.stop()
    return peak_seen / 1e6


for n_frames in (500, 1000, 2000, 4000, 8000, 16000):
    list_mb = peak_mb_accumulate_all(n_frames)
    fly_mb = peak_mb_on_the_fly(n_frames)
    print(
        f"n_frames={n_frames:6d}  "
        f"事前リスト蓄積: {list_mb:8.3f} MB  "
        f"逐次生成: {fly_mb:8.4f} MB  "
        f"比率: {list_mb / fly_mb:8.1f}倍"
    )

実行結果:

n_frames=   500  事前リスト蓄積:    3.476 MB  逐次生成:   0.0088 MB  比率:    395.3倍
n_frames=  1000  事前リスト蓄積:    5.218 MB  逐次生成:   0.0087 MB  比率:    596.7倍
n_frames=  2000  事前リスト蓄積:   10.433 MB  逐次生成:   0.0087 MB  比率:   1193.2倍
n_frames=  4000  事前リスト蓄積:   20.866 MB  逐次生成:   0.0087 MB  比率:   2386.3倍
n_frames=  8000  事前リスト蓄積:   41.732 MB  逐次生成:   0.0087 MB  比率:   4772.6倍
n_frames= 16000  事前リスト蓄積:   83.465 MB  逐次生成:   0.0087 MB  比率:   9545.4倍

事前リスト蓄積とその場生成のピークメモリ使用量比較(対数スケール)

事前にリストへ蓄積する方式は、点群1つあたりのデータ量(200点×3軸×8バイト ≈ 4.7KB)にフレーム数を掛けた分だけメモリが線形に増加し、16,000フレームでは約83MBに達しました。一方、その場生成方式は常に「今のフレーム1つ分」しかメモリに保持しないため、フレーム数を増やしても約9KB弱でほぼ一定のままです。数百フレーム程度の短いアニメーションでは大差ありませんが、数万フレーム・高解像度データを扱う長時間アニメーションでは、この差がプロセスのメモリ不足に直結し得ます。可能な限り update() 関数内でその場計算する設計にしましょう。

よくある落とし穴:FuncAnimationオブジェクトの参照を保持し忘れる

FuncAnimation / ArtistAnimation はPythonの通常のオブジェクトなので、他のオブジェクトと同様にどこからも参照されなくなるとガベージコレクションの対象になります。関数内で ani = animation.FuncAnimation(...) と作ってその関数から ani を返さない(=呼び出し元で変数に代入しない)と、アニメーションがまだ実行中であってもオブジェクトが破棄され、アニメーションが途中で停止してしまうことがあります。特にJupyter Notebookのセル内で関数化して呼び出す場合や、GUIアプリのコールバック内で一時的に生成する場合にハマりやすい落とし穴です。

import gc
import weakref
import matplotlib.pyplot as plt
import matplotlib.animation as animation


def make_animation_without_keeping_reference():
    fig, ax = plt.subplots()
    (line,) = ax.plot([], [])

    def update(i):
        line.set_data([0, 1], [0, i])
        return (line,)

    ani = animation.FuncAnimation(fig, update, frames=20, interval=10)
    # ani をローカル変数として返さず weakref だけ返す
    # -> 関数を抜けた時点で ani への強参照がどこにも残らない
    return weakref.ref(ani), fig


ani_ref, fig = make_animation_without_keeping_reference()
gc.collect()
print("参照を保持しないまま gc.collect() した後も生存しているか:", ani_ref() is not None)

実行結果:

参照を保持しないまま gc.collect() した後も生存しているか: False

実際、このコードを実行するとMatplotlib自身が次の警告を出します。

UserWarning: Animation was deleted without rendering anything. This is most likely
not intended. To prevent deletion, assign the Animation to a variable, e.g. `anim`,
that exists until you output the Animation using `plt.show()` or `anim.save()`.

対策はシンプルで、ani.save(...) を呼ぶまで(あるいは plt.show() でイベントループが回っている間)は ani をどこかの変数に確実に保持しておくことです。モジュールレベルの変数に代入する、クラスの属性として保持する、あるいは呼び出し元関数のローカル変数としてそのまま生存させておくなど、スコープの外までオブジェクトが生き残るようにしましょう。

最新動向:Matplotlibの3D性能改善とPlotlyという選択肢

Matplotlibの3D関連機能は近年も継続的に改善されています。

  • Matplotlib 3.10(2024年12月):マウスによる3D視点回転が直感的になり(Ken ShoemakeのARCBALLベース、rcParams["axes3d.mouserotationstyle"]で切替可)、Axes3D.fill_between による3D面塗りつぶしが追加されました。
  • Matplotlib 3.11(2026年6月):3D Axesでlog/symlog/logitなどの非線形スケールが使えるようになったほか、奥行きシェーディングの不具合修正、Controlキーを押しながらの回転角スナップなどが追加されました。特に注目すべきは3Dの描画パフォーマンス改善で、サーフェスプロット・ワイヤーフレームの描画が最大で10倍高速化されています(マスク配列処理のベクトル化によるもの)。

これらの改善は主に「インタラクティブに3Dを触る」体験の向上が中心で、本記事で扱った GIF/MP4 書き出しのワークフロー自体を大きく変えるものではありませんが、大量の点群や高解像度サーフェスをアニメーション化する際の描画速度には効いてきます。

また、ブラウザでの閲覧を前提にインタラクティブな3Dアニメーションを作りたい場合は、 Plotly も有力な選択肢です。Plotlyは最初からWeb向け・インタラクティブ表示を前提に設計されており、frames を使ったアニメーションやマウスでの自由な回転・ズームを標準でサポートします。一方で、静止画としての印刷品質やMatplotlibエコシステムとの親和性(本記事のようなax.plotベースの科学技術計算パイプラインとの統合しやすさ)はMatplotlibに分があるため、「GIFやMP4として配布したい静的なアニメーション」であれば引き続きMatplotlib + PillowWriter/FFMpegWriterが手軽です。

関連記事


関連ツール