EurekaMoments

新米エンジニアが一人前を目指す修行の日々を記していくブログです。

Animation Playback GUI Tool with matplotlib of Python

Introduction

実験で取った時系列データを見返したり、プロトタイピングしたロジックをシミュレーションでデバッグしたい時は、それをアニメーション表示させるということをよくやります。しかしながら、普通にアニメーション表示させるだけだとただデータの頭からお尻までを再生するだけで終わってしまうので、一時停止や巻き戻し、時には早送りなどしながらじっくりデータを見たい時は少々不便です。
そこで今回は、Pythonの描画ライブラリmatplotlibのGUI作成機能を利用して、アニメーション再生や一時停止、巻き戻しなどができるGUIツールのサンプルを作成したので紹介したいと思います。

Sample

今回作成したツールは以下のようになりました。
f:id:sy4310:20180909074138p:plain ソースコードGIFアニメーション、お試し用のサンプルデータはGitHubで公開しています。
github.com

Required modules

今回のツールでは以下のモジュールが必要になるのでimportしておきましょう。

from matplotlib.widgets import RadioButtons, Slider
import matplotlib.pyplot as plt
import matplotlib.gridspec as gds
import pandas as pd
import datetime

Read sample data

今回はサンプルデータとして、GPSのNMEAデータをpandasのデータフレームとして保存したCSVファイルを読み込ませます。GPSのNMEAデータに関しては前回の記事を参照ください。

eurekamoments.hatenablog.com

# close all figure window
plt.close('all')

# read data frame csv file
nmea_gpgga_data_frame = pd.read_csv('nmea_gpgga_data_frame.csv', index_col=0)

# date time index
date_time_array = pd.to_datetime(nmea_gpgga_data_frame.index)

# each data array
x             = nmea_gpgga_data_frame['x']
y             = nmea_gpgga_data_frame['y']
altitude      = nmea_gpgga_data_frame['Altitude']
quality       = nmea_gpgga_data_frame['Quality']
hdop          = nmea_gpgga_data_frame['HDOP']
satellite_num = nmea_gpgga_data_frame['Satellites Num']

Animation playback figure window

GPSのX-Y座標プロットグラフ、HDOPの時系列グラフ、測位衛星数の時系列グラフのaxesを作成しておきます。時系列グラフのX軸にはDateTimeIndexを割り当てているのですが、縦方向にかなりのスペースを使ってしまっています。今後はこういうところの省スペース化も考えてのが今後の課題ですね。

fig_anime = plt.figure(figsize=(11, 9))
gs_anime  = gds.GridSpec(2, 2)
plt.subplots_adjust(wspace=0.4, hspace=0.7)
ax_pos  = plt.subplot(gs_anime[:,0]) # axes of X-Y Position
ax_pos.plot(x, y, c='#212121')
ax_pos.set_xlabel('X [m]')
ax_pos.set_ylabel('Y [m]')
ax_pos.grid()
ax_pos.axis('equal')
ax_hdop = plt.subplot(gs_anime[0,1]) # axes of Time-HDOP
ax_hdop.set_xlim([date_time_array[0], date_time_array[-1]])
ax_hdop.set_ylim([hdop.min(), hdop.max()])
ax_hdop.set_xticklabels(date_time_array, rotation=40)
ax_hdop.set_xlabel('DateTime')
ax_hdop.set_ylabel('HDOP')
ax_hdop.grid()
ax_sat  = plt.subplot(gs_anime[1,1]) # axes of Time-Satellite Num
ax_sat.set_xlim([date_time_array[0], date_time_array[-1]])
ax_sat.set_ylim([satellite_num.min(), satellite_num.max()])
ax_sat.set_xticklabels(date_time_array, rotation=40)
ax_sat.set_title('DateTime - Satellite Num')
ax_sat.set_xlabel('DateTime')
ax_sat.set_ylabel('Satellite Num')
ax_sat.grid()
fig_anime.subplots_adjust(left=0.28, bottom=0.2, right=None, top=None)

Radio button GUI

アニメーションの再生速度を設定するものと、再生と一時停止を切り替えるものと、2種類のラジオボタンを作成します。今回のアニメーションはデフォルトで50msサイクルで再生しますが、それを2倍速、1.5倍速、0.75倍速、0.5倍速に設定できるようにしています。一気にざっと見たい場合でもじっくり見たい場合でも対応できるようにしました。

# radio button for setting playback speed
radio_btn_clr  = 'lightgoldenrodyellow'
ax_pb_Spd_btn  = plt.axes([0.05, 0.7, 0.1, 0.1], facecolor=radio_btn_clr)
pb_spd_btn_obj = RadioButtons(ax_pb_Spd_btn, ('Normal', '2X', '1.5X', '0.75X', '0.5X'))
global pb_spd_prm
pb_spd_prm = 1
# function
def select_playback_speed(label):
    global pb_spd_prm
    if label == 'Normal':   # 50ms
        pb_spd_prm = 1
    elif label == '2X':     # 25ms
        pb_spd_prm = 1/2
    elif label == '1.5X':   # 33ms
        pb_spd_prm = 1/1.5
    elif label == '0.75X':  # 67ms
        pb_spd_prm = 1/0.75
    elif label == '0.5X':   # 100ms
        pb_spd_prm = 1/0.5
    else:
        pb_spd_prm = 1
pb_spd_btn_obj.on_clicked(select_playback_speed)

# radio button for switching play/stop animation
ax_srt_stp_btn  = plt.axes([0.05, 0.5, 0.1, 0.1], facecolor=radio_btn_clr)
srt_stp_btn_obj = RadioButtons(ax_srt_stp_btn, ('Stop', 'Start'))
global pushed_start
pushed_start = False
# function
def select_start_stop(label):
    global pushed_start
    if label == 'Stop':    # stop playback
        pushed_start = False
    elif label == 'Start': # start playback
        pushed_start = True
    else:
        pushed_start = False
srt_stp_btn_obj.on_clicked(select_start_stop)

Slider GUI

アニメーションを手動でコマ送りしたり、巻き戻したりするためのGUIをスライダーで実現します。ここでは、スライダーには再生するデータ配列のindexを割り当てておきます。whileループで配列のデータを0から順番に抽出して表示するのと同時に、そのindexの値をスライダーオブジェクトが持つset_valメソッドでセットすることにより、表示データの切り替えとスライダー上の値の変化を連動させています。

# slider for controling animation progress
global start_date_time, end_date_time, roop_count
len_date_time = len(date_time_array)
roop_count = 0
ax_prg_sld  = plt.axes([0.15, 0.01, 0.7, 0.03])
prg_sld_obj = Slider(ax_prg_sld, 'Date Time', 0, len_date_time-1, valinit=0)
# function
def control_animation_progress(slider_value):
    global roop_count
    roop_count = int(slider_value)
prg_sld_obj.on_changed(control_animation_progress)

# animation roop
while roop_count <= len_date_time-1:
    crnt_date_time = date_time_array[roop_count]
    crnt_x       = x[roop_count]
    crnt_y       = y[roop_count]
    crnt_qly    = quality[roop_count]
    total_hdop   = hdop[0:roop_count+1]
    total_sat   = satellite_num[0:roop_count+1]
    update_animation(crnt_date_time, crnt_x, crnt_y, crnt_qly, total_hdop, total_sat)
    if pushed_start == True:
        roop_count += 1
    prg_sld_obj.set_val(roop_count)

またこの時、X-Y座標は各時刻の瞬間的な位置座標のみを表示させますが、HDOPと測位衛星数の時系列グラフは頭から表示時刻までの全てのデータを表示させるので、データ配列に対するindex指定の仕方が異なります。

Update animation

最後にアニメーションの更新処理部分を作成します。通常通りのplt.plotとかだと、ループが回るたびにplotオブジェクトが生成されてしまうのでどんどん処理が重くなってしまいます。そのため、まずはplotオブジェクトをデータが何もセットされていない状態で定義しておきます。

# Update plot objects
quality_text    = ax_pos.text(0.05, 0.8, '', transform=ax_pos.transAxes)
snd_aln_plot,   = ax_pos.plot([], [], '.', c='#2196F3', ms=15)
diff_plot,      = ax_pos.plot([], [], '.', c='#f44336', ms=15)
dt_hdop_plot,   = ax_hdop.plot([], [], c='#2196F3', linewidth=2.0)
dt_sat_plot,    = ax_sat.plot([], [], c='#2196F3', linewidth=2.0)

そして表示するデータを更新する際は、各plotオブジェクトが持つset_dataメソッドで各ループでのデータをセットすることによって実現します。

def update_animation(crnt_date_time, crnt_x, crnt_y, crnt_qly, total_hdop, total_sat):
    global pb_spd_prm

    ax_pos.set_title(crnt_date_time)
    if crnt_qly == 1:
        snd_aln_plot.set_data(crnt_x, crnt_y)
        diff_plot.set_data([], [])
    elif crnt_qly == 2:
        snd_aln_plot.set_data([], [])
        diff_plot.set_data(crnt_x, crnt_y)
    quality_text.set_text('GPS Quality = %d' % (crnt_qly))

    dt_hdop_plot.set_data(pd.to_datetime(total_hdop.index), total_hdop.values)
    ax_hdop.set_title(total_hdop.values[-1])

    dt_sat_plot.set_data(pd.to_datetime(total_sat.index), total_sat.values)
    ax_sat.set_title(total_sat.values[-1])

    plt.pause(0.05 * pb_spd_prm)

Source code

最後に、ここまで記載したソースコードを全て組み合わせると以下のようになります。

# -*- coding: utf-8 -*-
"""
Animation Player sample with matplotlib GUI

You can playback data as animation.

The animation progress can be controled by slider GUI.

Playback speed can be controled by radio button.

Animation start/stop can be controled by radio button.
"""

from matplotlib.widgets import RadioButtons, Slider
import matplotlib.pyplot as plt
import matplotlib.gridspec as gds
import pandas as pd
import datetime

def update_animation(crnt_date_time, crnt_x, crnt_y, crnt_qly, total_hdop, total_sat):
    global pb_spd_prm

    ax_pos.set_title(crnt_date_time)
    if crnt_qly == 1:
        snd_aln_plot.set_data(crnt_x, crnt_y)
        diff_plot.set_data([], [])
    elif crnt_qly == 2:
        snd_aln_plot.set_data([], [])
        diff_plot.set_data(crnt_x, crnt_y)
    quality_text.set_text('GPS Quality = %d' % (crnt_qly))

    dt_hdop_plot.set_data(pd.to_datetime(total_hdop.index), total_hdop.values)
    ax_hdop.set_title(total_hdop.values[-1])

    dt_sat_plot.set_data(pd.to_datetime(total_sat.index), total_sat.values)
    ax_sat.set_title(total_sat.values[-1])

    plt.pause(0.05 * pb_spd_prm)

if __name__ == '__main__':

    # close all figure window
    plt.close('all')

    # read data frame csv file
    nmea_gpgga_data_frame = pd.read_csv('nmea_gpgga_data_frame.csv', index_col=0)

    # date time index
    date_time_array = pd.to_datetime(nmea_gpgga_data_frame.index)

    # each data array
    x             = nmea_gpgga_data_frame['x']
    y             = nmea_gpgga_data_frame['y']
    altitude      = nmea_gpgga_data_frame['Altitude']
    quality       = nmea_gpgga_data_frame['Quality']
    hdop          = nmea_gpgga_data_frame['HDOP']
    satellite_num = nmea_gpgga_data_frame['Satellites Num']

    # Animation Playback figure window
    fig_anime = plt.figure(figsize=(11, 9))
    gs_anime  = gds.GridSpec(2, 2)
    plt.subplots_adjust(wspace=0.4, hspace=0.7)
    ax_pos  = plt.subplot(gs_anime[:,0]) # axes of X-Y Position
    ax_pos.plot(x, y, c='#212121')
    ax_pos.set_xlabel('X [m]')
    ax_pos.set_ylabel('Y [m]')
    ax_pos.grid()
    ax_pos.axis('equal')
    ax_hdop = plt.subplot(gs_anime[0,1]) # axes of Time-HDOP
    ax_hdop.set_xlim([date_time_array[0], date_time_array[-1]])
    ax_hdop.set_ylim([hdop.min(), hdop.max()])
    ax_hdop.set_xticklabels(date_time_array, rotation=40)
    ax_hdop.set_xlabel('DateTime')
    ax_hdop.set_ylabel('HDOP')
    ax_hdop.grid()
    ax_sat  = plt.subplot(gs_anime[1,1]) # axes of Time-Satellite Num
    ax_sat.set_xlim([date_time_array[0], date_time_array[-1]])
    ax_sat.set_ylim([satellite_num.min(), satellite_num.max()])
    ax_sat.set_xticklabels(date_time_array, rotation=40)
    ax_sat.set_title('DateTime - Satellite Num')
    ax_sat.set_xlabel('DateTime')
    ax_sat.set_ylabel('Satellite Num')
    ax_sat.grid()
    fig_anime.subplots_adjust(left=0.28, bottom=0.2, right=None, top=None)

    # Update plot objects
    quality_text    = ax_pos.text(0.05, 0.8, '', transform=ax_pos.transAxes)
    snd_aln_plot,   = ax_pos.plot([], [], '.', c='#2196F3', ms=15)
    diff_plot,      = ax_pos.plot([], [], '.', c='#f44336', ms=15)
    dt_hdop_plot,   = ax_hdop.plot([], [], c='#2196F3', linewidth=2.0)
    dt_sat_plot,    = ax_sat.plot([], [], c='#2196F3', linewidth=2.0)

    # radio button for setting playback speed
    radio_btn_clr  = 'lightgoldenrodyellow'
    ax_pb_Spd_btn  = plt.axes([0.05, 0.7, 0.1, 0.1], facecolor=radio_btn_clr)
    pb_spd_btn_obj = RadioButtons(ax_pb_Spd_btn, ('Normal', '2X', '1.5X', '0.75X', '0.5X'))
    global pb_spd_prm
    pb_spd_prm = 1
    # function
    def select_playback_speed(label):
        global pb_spd_prm
        if label == 'Normal':   # 50ms
            pb_spd_prm = 1
        elif label == '2X':     # 25ms
            pb_spd_prm = 1/2
        elif label == '1.5X':   # 33ms
            pb_spd_prm = 1/1.5
        elif label == '0.75X':  # 67ms
            pb_spd_prm = 1/0.75
        elif label == '0.5X':   # 100ms
            pb_spd_prm = 1/0.5
        else:
            pb_spd_prm = 1
    pb_spd_btn_obj.on_clicked(select_playback_speed)

    # radio button for switching play/stop animation
    ax_srt_stp_btn  = plt.axes([0.05, 0.5, 0.1, 0.1], facecolor=radio_btn_clr)
    srt_stp_btn_obj = RadioButtons(ax_srt_stp_btn, ('Stop', 'Start'))
    global pushed_start
    pushed_start = False
    # function
    def select_start_stop(label):
        global pushed_start
        if label == 'Stop':    # stop playback
            pushed_start = False
        elif label == 'Start': # start playback
            pushed_start = True
        else:
            pushed_start = False
    srt_stp_btn_obj.on_clicked(select_start_stop)

    # slider for controling animation progress
    global start_date_time, end_date_time, roop_count
    len_date_time = len(date_time_array)
    roop_count = 0
    ax_prg_sld  = plt.axes([0.15, 0.01, 0.7, 0.03])
    prg_sld_obj = Slider(ax_prg_sld, 'Date Time', 0, len_date_time-1, valinit=0)
    # function
    def control_animation_progress(slider_value):
        global roop_count
        roop_count = int(slider_value)
    prg_sld_obj.on_changed(control_animation_progress)

    # animation roop
    while roop_count <= len_date_time-1:
        crnt_date_time = date_time_array[roop_count]
        crnt_x   = x[roop_count]
        crnt_y   = y[roop_count]
        crnt_qly = quality[roop_count]
        total_hdop      = hdop[0:roop_count+1]
        total_sat       = satellite_num[0:roop_count+1]
        update_animation(crnt_date_time, crnt_x, crnt_y, crnt_qly, total_hdop, total_sat)
        if pushed_start == True:
            roop_count += 1
        prg_sld_obj.set_val(roop_count)