カテゴリー

Pythonの発展系

【python&アニメーション】plotlyで3次元ピラミッドを回転させる

2021年11月20日

こんな人にオススメ

某有名カードゲームアニメのCM入り・終わりのような、逆さのピラミッドが回転するようなアニメーションってplotlyで作成できる?

ということで、今回は以下の記事で作成したplotlyの3Dピラミッドをアニイメーションを使って回転、某有名カードゲームアニメのCM入り・終わりのようなグラフを作成する。自己満。

【python&3Dピラミッド】plotlyで3次元ピラミッドを作成

続きを見る

今回のこのピラミッドシリーズは完全に思いつきで、お手洗いで用を足している最中にそういえばと思っただけ。まあ3Dグラフの練習にはなるとポジティブに捉える。

python環境は以下。

  • Python 3.9.7
  • numpy 1.21.3
  • plotly 5.3.1
  • plotly-orca 3.4.2

運営者のメガネです。YouTubeTwitterInstagramも運営中。自己紹介お問い合わせページあります。

運営者メガネ

作成したコード全文

下準備

import numpy as np
import plotly.graph_objects as go
import plotly.io as pio

まずは下準備としてのimport関連。plotlyにはpxというライブラリもあるんだけど、こちらだとSurfaceプロットがサポートされていないのでgoを使用。

【plotly&3D】px.scatter_3dとpx.line_3Dで3Dグラフを作成

続きを見る

【plotly&3D】goで3Dグラフを作成

続きを見る

pxの方がサクッとグラフができるけど、goの方がカスタムに富んでいる。

グラフ保存用の関数

# グラフ保存用の関数
def save(fig, config, save_name):
    pio.orca.config.executable = '/Applications/orca.app/Contents/MacOS/orca'
    pio.write_html(fig, f"{save_name}.html", config=config,)
    pio.write_image(fig, f"{save_name}.png")

まずはグラフ保存用の関数。保存形式はhtmlとpng。configはグラフの右上に存在するツールバーのようなもの。

configを設定しておくとこのツールバーの内容を追加したり削除したりできる。htmlで使用することで、保存後のhtmlファイルでもツールバーの設定が引き継がれる。

【plotly&config】グラフのツールバーを編集する

続きを見る

今回は3Dグラフでconfigの追加が反映されないのでconfigNoneでしのぐ。このsave関数を使用してグラフの表示と保存を一括で行う関数も定義しておく。

予め定義しておくことで全グラフを保存するときや保存しない時など一括管理が楽になる。個別管理の時はグラフの関数呼び出しの時にその行だけコメントアウトしたらいい。

【plotly&工夫】楽にグラフを描くためのplotlyの関数化

続きを見る

# グラフ保存用の関数
def show_save(fig, config, save_name):
    fig.show()
    save(fig=fig, config=config, save_name=save_name)

ピラミッド用の配列作成用の関数

def make_arr(length):
    # ピラミッドの値の種類(長さが偶数の時は辺+1したピラミッドを作成)
    nums = length // 2 + 1

    rows = []  # ベースとなる配列の各行の値を入れる2次元配列
    for row in range(nums):  # 1行ごとに見る
        n = 0  # 各行の各列の値
        cols = []  # row行目の値を入れる
        for col in range(nums):  # row行目の各列
            cols.append(n)  # 一番左は0
            if row:  # 0行目の時はここはFalse、それ以外の行目ならTrue
                n += 1  # 各列の値は1ずつ増えていく
                row -= 1  # あと何回、列の値が変わるかに相当
        rows.append(cols)  # row行目の配列を格納

    arr = np.array(rows)  # 2次元配列の変換
    bottom = arr[::-1][1:]  # ベース下用の配列
    arr2 = np.vstack([arr, bottom])  # ベース下に配列を結合
    left = arr2[:, ::-1][:, 1:]  # ベース+下の左用の配列
    arr3 = np.hstack([arr2, left])  # ベース+下の左に配列を結合

    return arr3

3次元のグラフを作成するわけだが、plotlyの場合はピラミッドの高さ方向に値を振り分けるだけでいいので、2次元配列を作成するだけ。詳しい作成の内容は以下の記事参照。

【python&3Dピラミッド】plotlyで3次元ピラミッドを作成

続きを見る

例えば1辺の長さ5のピラミッド(length=5)の場合は以下の配列となる。もちろん高さは好みで好きにいじってもいい。

print(make_arr(length=5))
# [[0 0 0 0 0]
#  [0 1 1 1 0]
#  [0 1 2 1 0]
#  [0 1 1 1 0]
#  [0 0 0 0 0]]

グラフ作成用の関数たち

ここではグラフを作成するための各構成要素を関数化している。関数化することでメインの処理がスッキリするし、何か問題があったときに処理の切り分けがしやすい。

surfaceプロット用の関数

# surfaceプロット
def surface(x, y, z, opacity=0.9):
    contours = dict(
        show=True, usecolormap=True,
        highlightcolor='lime',  # ピラミッドの中のホバーの線色
    )
    d = go.Surface(
        x=x, y=y, z=z,
        opacity=opacity,  # 透明度
        showscale=False,  # スケールはオフ
        colorscale='YlOrBR',  # っぽい色
        contours=dict(x=contours, y=contours, z=contours),
    )

    return d

今回はsurfaceプロットなのでそれ用の関数を作成。contoursでピラミッド本体とxy面などの平面にピラミッドの投影を行う。

といっても今回は背景色を無くしたり軸表示を消したりするので平面へのプロットは適用されない。ただ、ピラミッド本体への投影はされるので、某パズルのような風合いは出せる。

また、透明度を引数で設定できるようにしたりカラーバーはいらないのでオフにしたりしている。

回転角度設定

# 回転角度設定
def set_thetas(deg=0.2):
    thetas = np.arange(0, 2 * np.pi + deg, deg * np.pi)
    print(thetas)  # deg=0.2の時
    # [0.         0.62831853 1.25663706 1.88495559 2.51327412 3.14159265
    #  3.76991118 4.39822972 5.02654825 5.65486678 6.28318531]
    return thetas

ピラミッドを回転させるので何度回転させるのかを指定しないといけない。ということで回転角度用の関数を定義。

本当は細かく回転させたかったけど、記事に載せる際に激重になってしまったので、本記事のグリグリグラフはざっくりした回転角度にしている。

グラフ内テキスト用関数

# グラフ内テキスト
def set_annotation():
    annotations = [dict(
        showarrow=False, x=-0, y=-0, z=5,
        text='<b>遊☆戯☆O<br>デュエルOンスターズ</b>',
        font=dict(color='#571475', size=15, family='Times New Roman')
    )]

    return annotations

グラフに加えて、タイトルロゴっぽいのもつけたかったのでそのテキスト内容を関数化。一応、タイトルとかは伏せ字にしている。これでなんのアニメかはわからないだろう。

<b>タグで太文字にしたり、カラーはいい感じのものにして、フォントサイズもそれなりに、フォントもかっこいいのにしておいた。

レイアウト設定用の関数

# レイアウトの雛形
def set_scene(xval, yval, zval, annotations, **kwargs):
    ans = dict(
        xaxis=dict(range=(-xval, xval)),
        yaxis=dict(range=(-yval, yval)),
        zaxis=dict(range=(-zval, 5)),
        # アスペクト比は手設定
        aspectmode='manual', aspectratio=dict(x=2, y=2, z=1),
        annotations=annotations,  # グラフ内にテキストを追加
        xaxis_visible=False, yaxis_visible=False, zaxis_visible=False,
        **kwargs,
    )
    return ans

レイアウト設定用の関数を定義。アニメーションの場合は各軸の表示範囲を設定してあげないと自動で表示範囲が変更されてフレームごとにグラフが揺れることになる。

また、アスペクト比を自動にしているとグラフの比率がいい感じにならなかったので手動で設定している。また、ここで先程のタイトルロゴを入れている。

あとはピラミッドだけ表示したいので各軸の表示は非表示にして見えなくしている。これでピラミッドが際立つ。

初期プロット用の関数

# 初期表示用のプロットとレイアウト
def initial_pyramid(x, y, z, xval, yval, zval, annotations):
    z0 = np.zeros((len(z), len(z)))
    plot = [
        surface(x=x, y=y, z=z0, opacity=1),  # 底面用
        surface(x=x, y=y, z=z),  # ピラミッド本体
    ]
    # Playボタン
    args = dict(
        frame=dict(duration=200, ),  # フレーム間の動きを早める
        fromcurrent=True,
        transition=dict(duration=10, easing='linear'),
    )
    buttons = dict(
        label='Play', method='animate',
        args=[None, args],
    )

    # レイアウトの設定
    layout = go.Layout(
        scene=set_scene(
            xval=xval, yval=yval, zval=zval,
            annotations=annotations,
            camera=dict(eye=dict(x=2, y=2, z=0.6)),  # カメラ視点指定
        ),
        updatemenus=[dict(
            type='buttons', x=0.5, y=0.8,
            xanchor='center', yanchor='bottom', buttons=[buttons],
        )],
        paper_bgcolor='rgba(0,0,0,0)',  # 背景は透明
        margin=dict(t=0, b=0, r=0, l=0),  # グラフの余白はなし
        height=1000,  # グラフを高く
    )

    return plot, layout

アニメーションの場合、初期プロットとアニメーションの各フレームでそれぞれプロットしないといけない。ということで、ここは切り分けて初期表示専用の関数を定義。

プロット自体は以下の作業が必要。

  • ピラミッドの底面(ひっくり返すから実質天井)のプロット
  • ピラミッド自体のプロット
  • レイアウトの調節

初期表示ではこれらに加えて以下の情報を追加した。

  • アニメーション再生ボタンを追加
  • 背景色を無色透明に
  • 余白は上下左右0
  • グラフの高さを1000に

初期表示で上の内容を設定しておくことでアニメーション中でもこれらの設定を引き継ぐことができる。

初期表示のプロット作成の流れは以下。

  1. 底面用の配列z0を定義
  2. 初期表示用のプロットplotを作成
  3. 再生ボタンbuttonを作成
  4. レイアウトlayoutの作成
  5. plotlayoutを返り値にする

plotlyのボタンについては以下参照。plotlyでは簡単にボタンを追加して動きをつけたりプロットの切り替えなどが可能。便利。

【plotly&ボタン】plotlyのupdatemenusにbuttonsを追加

続きを見る

【plotly&ボタン】グラフに複数種のボタンを追加

続きを見る

回転中のプロット用関数

# 回転中のピラミッド
def frame_pyramid(thetas, x, y, z, scene):
    frames = []
    z0 = np.zeros((len(z), len(z)))
    for theta in thetas:
        xr = x * np.cos(theta) - y * np.sin(theta)  # 一定角回転後のx
        yr = x * np.sin(theta) + y * np.cos(theta)  # 一定角回転後のy
        # 各フレーム
        frame = go.Frame(
            data=[
                surface(x=xr, y=yr, z=z0, opacity=1),  # 底面用
                surface(x=xr, y=yr, z=z),  # ピラミッド本体
            ],
        )
        frames.append(frame)
    return frames

今度は打って変わって回転中のプロットを関数化。今回の回転はz軸を軸として回転させるので以下の式で対応可能。

$x'\ =\ x\cos(\theta)\ -\ y\sin(\theta)\\y'\ =\ x\sin(\theta)\ +\ y\cos(\theta)$

これを各フレームで作成する。また、回転中は底面も回転するので各フレームで底面のプロットも必要になる。

レイアウトは初期設定で設定したので、フレーム中にタイトルを変更したいとかがなければ設定する必要はない。設定する場合はgo.Framedataの後にlayout=として書けばいい。

グラフ化する


ということで実際にグラフ化する。グラフ化はシンプルで、これまでに定義した内容をツラツラと並べたらいい。流れは以下。

  1. ピラミッドの辺の長さからx, yの1次元配列を計算
  2. x, yを回転させるためにmeshgridで2次元配列に変換
  3. zとしてmake_arrで2次元配列を作成(逆さにするので負符号をつける)
  4. 表示範囲とグラフ内テキストを設定
  5. 初期表示を設定
  6. 回転中のプロットを設定
  7. グラフ作成と表示と保存

引数としてピラミッドの辺の長さと回転角度を設定できるようにした。辺の長さが大きくなるとより滑らかに、回転角度を少なくすると回転が滑らかになる。

それに伴ってデータ数が多くなりファイルが重くなる。今回は記事に載せられたギリギリのサイズlength=29deg=0.2で設定。ちょっとギザギザで回転もガクガクだけど仕方ない。

def make_pyramid3d(length, deg=0.2):
    # 横軸・縦軸の1次元配列をの作成
    base = np.array(range(length))  # ベースとなる配列
    x = base - length // 2  # 配列の真ん中を0にする
    y = base - length // 2  # yも同様

    # x, y, zのデータ
    x, y = np.meshgrid(x, y)  # meshgridで指定してもいい
    pyramid = -make_arr(length=length)  # 負の数にして上下逆転

    # 表示範囲の設定
    xyval = length - 10  # x, yの表示範囲
    zval = length // 2  # zの表示範囲

    # グラフ内テキスト
    annotations = set_annotation()
    # レイアウトの表示範囲やテキストの追加
    scene = set_scene(
        xval=xyval, yval=xyval, zval=zval,
        annotations=annotations,
    )

    # 初期表示
    plot, layout = initial_pyramid(
        x=x, y=y, z=pyramid, xval=xyval, yval=xyval, zval=zval,
        annotations=annotations,
    )
    # 回転中のピラミッド
    thetas = set_thetas(deg=deg)
    frames = frame_pyramid(thetas=thetas, x=x, y=y, z=pyramid, scene=scene)

    # グラフ作成と表示と保存
    fig = go.Figure(data=plot, layout=layout, frames=frames)
    save_name = f"pyramid3d_{length}_{str(deg).replace('.', 'p')}"
    show_save(fig=fig, config=None, save_name=save_name)

make_pyramid3d(length=29)

なお、ファイル保存名はlengthdegで自動で名称変更されるようにしたけど、degの小数点がネックなるので小数点: Decimal Pointの「p」で置き換えている。

データ数を増やしてみる

ということで、最後はデータ数と角度刻みを増やした版を作成。上のピラミッドは初めのピラミッドの45倍くらいの容量になった。記事に載せたら容量オーバー。

# 辺の長さを101、角度刻みを0.05 radに
make_pyramid3d(length=101, deg=0.05)

回転させるだけなら結構簡単

今回は自己満のピラミッドを回転させて某アニメをパロってみた。本当はロゴとか背景とかも作成した方がリアリティはあるだろうけど、シンプルに作成するだけならこれでいい。<