粗大メモ置き場

個人用,たまーに来訪者を意識する雑記メモ

matplotlibのインタラクティブなプロットを作る覚書(スクロールでズーム、ドラッグで移動)

背景

オレオレGUIを作る際にインタラクティブなPlotがしたいという動機です。

インタラクティブなPlotについては下記が結構ボリュームがあって良いと思います。

qiita.com

サンプルコード①:スクロールで拡大縮小、ドラッグで移動

下記StackOverflowの議論からコードをもらってPython3用に改変しました。

stackoverflow.com

動作は下記の2つで、それぞれ関数が割り当てられています。

  • スクロールで拡大縮小
  • クリック&ドラッグで移動
from matplotlib.pyplot import figure, show
import numpy

class ZoomPan:
    def __init__(self):
        self.press = None
        self.cur_xlim = None
        self.cur_ylim = None
        self.x0 = None
        self.y0 = None
        self.x1 = None
        self.y1 = None
        self.xpress = None
        self.ypress = None


    def zoom_factory(self, ax, base_scale = 2.):
        def zoom(event):
            cur_xlim = ax.get_xlim()
            cur_ylim = ax.get_ylim()

            xdata = event.xdata # get event x location
            ydata = event.ydata # get event y location

            if event.button == 'down':
                # deal with zoom in
                scale_factor = 1 / base_scale
            elif event.button == 'up':
                # deal with zoom out
                scale_factor = base_scale
            else:
                # deal with something that should never happen
                scale_factor = 1
                print(event.button)

            new_width = (cur_xlim[1] - cur_xlim[0]) * scale_factor
            new_height = (cur_ylim[1] - cur_ylim[0]) * scale_factor

            relx = (cur_xlim[1] - xdata)/(cur_xlim[1] - cur_xlim[0])
            rely = (cur_ylim[1] - ydata)/(cur_ylim[1] - cur_ylim[0])

            ax.set_xlim([xdata - new_width * (1-relx), xdata + new_width * (relx)])
            ax.set_ylim([ydata - new_height * (1-rely), ydata + new_height * (rely)])
            ax.figure.canvas.draw()

        fig = ax.get_figure() # get the figure of interest
        fig.canvas.mpl_connect('scroll_event', zoom)

        return zoom

    def pan_factory(self, ax):
        def onPress(event):
            if event.inaxes != ax: return
            self.cur_xlim = ax.get_xlim()
            self.cur_ylim = ax.get_ylim()
            self.press = self.x0, self.y0, event.xdata, event.ydata
            self.x0, self.y0, self.xpress, self.ypress = self.press

        def onRelease(event):
            self.press = None
            ax.figure.canvas.draw()

        def onMotion(event):
            if self.press is None: return
            if event.inaxes != ax: return
            dx = event.xdata - self.xpress
            dy = event.ydata - self.ypress
            self.cur_xlim -= dx
            self.cur_ylim -= dy
            ax.set_xlim(self.cur_xlim)
            ax.set_ylim(self.cur_ylim)

            ax.figure.canvas.draw()

        fig = ax.get_figure() # get the figure of interest

        # attach the call back
        fig.canvas.mpl_connect('button_press_event',onPress)
        fig.canvas.mpl_connect('button_release_event',onRelease)
        fig.canvas.mpl_connect('motion_notify_event',onMotion)

        #return the function
        return onMotion


fig = figure()

ax = fig.add_subplot(111, xlim=(0,1), ylim=(0,1), autoscale_on=False)

ax.set_title('Click to zoom')
x,y,s,c = numpy.random.rand(4,200)
s *= 200

ax.scatter(x,y,s,c)
scale = 1.1
zp = ZoomPan()
figZoom = zp.zoom_factory(ax, base_scale = scale)
figPan = zp.pan_factory(ax)
show()

出力は下記のようになります。

f:id:ossyaritoori:20210726210806p:plain
サンプルとして用いる散布図グラフ。ホイールで拡大縮小、ドラッグで移動ができます。

ズーム動作

関数の最初にzoom用の関数を定義して、下記のコードでコールバック関数として渡しているのがわかります。

fig.canvas.mpl_connect('scroll_event', zoom)

そして、受け手の関数では変数eventから得られるスクロールの上下に関する情報をもとに図の拡大縮小を行っています。

ドラッグ動作

一方ドラッグ動作では、クリックされたときと離されたとき、マウスをドラッグしたときの動作にそれぞれ関数を割り当てて、 コールバックを呼んでいます。

# attach the call back
        fig.canvas.mpl_connect('button_press_event',onPress)
        fig.canvas.mpl_connect('button_release_event',onRelease)
        fig.canvas.mpl_connect('motion_notify_event',onMotion)

その他の動作

つまり、どんなイベントがあるかだけ把握すれば適切なコールバック関数を渡してあげることでいろいろな動作ができるということです。

では、実際にどんなイベントがあるかについては下記公式ページを参照してください。

matplotlib.org

次のサンプルではkey_press_eventを使います。

GUI動作は組み合わせで行うことが多いのでサンプルコードのようにクラスを定義してその中でどのボタンがホールドされているかなどの変数を保持しておくと捗ると思います。

サンプルコード②:スクロールで左右ズーム、Ctrl押しながらのスクロールで上下ズーム

先程のコードを時系列Plot用に改良しました。

主な動作としては下記の通りになります。

  • スクロール:X軸のみズーム
  • Ctrl+スクロール:Y軸のみズーム
  • ドラッグ:並行移動
  • 「r」キー:描画範囲リセット
from matplotlib.pyplot import figure, show
import numpy
import matplotlib
matplotlib.use('TKAgg')

class ZoomPan:
    def __init__(self,ax):
        self.press = None
        self.cur_xlim = None
        self.cur_ylim = None
        self.x0 = None
        self.y0 = None
        self.x1 = None
        self.y1 = None
        self.xpress = None
        self.ypress = None

        self.ctrl_press = False

        self.ax = ax
        self.orig_xlim = ax.get_xlim()
        self.orig_ylim = ax.get_ylim()


        self.zoom_factory(ax,base_scale=1.1)
        self.ctrl_key(ax)
        self.pan_factory(ax)

    def zoom_factory(self, ax, base_scale = 2.):

        def zoomX(event,scale_factor):
            cur_xlim = ax.get_xlim()
            xdata = event.xdata # get event x location
            new_width = (cur_xlim[1] - cur_xlim[0]) * scale_factor
            relx = (cur_xlim[1] - xdata)/(cur_xlim[1] - cur_xlim[0])

            ax.set_xlim([xdata - new_width * (1-relx), xdata + new_width * (relx)])
            ax.figure.canvas.draw()

        def zoomY(event,scale_factor):
            cur_ylim = ax.get_ylim()
            ydata = event.ydata # get event y location
            new_height = (cur_ylim[1] - cur_ylim[0]) * scale_factor
            rely = (cur_ylim[1] - ydata)/(cur_ylim[1] - cur_ylim[0])

            ax.set_ylim([ydata - new_height * (1-rely), ydata + new_height * (rely)])
            ax.figure.canvas.draw()

        def zoom(event):
            if event.button == 'down':
                # deal with zoom in
                scale_factor = 1 / base_scale
            elif event.button == 'up':
                # deal with zoom out
                scale_factor = base_scale
            else:
                # deal with something that should never happen
                scale_factor = 1
                print(event.button)

            ####### Switch zoom X or Y #########
            if self.ctrl_press:
                zoomY(event,scale_factor)
            else:
                zoomX(event,scale_factor)

        fig = ax.get_figure() # get the figure of interest
        fig.canvas.mpl_connect('scroll_event', zoom)

        return zoom

    def ctrl_key(self,ax):
        def onPress(event):
            #print(event.key)
            if event.inaxes != ax: return
            if event.key == "control":
                self.ctrl_press = True
            elif event.key == "r": # reset zoom
                ax.set_xlim(self.orig_xlim)
                ax.set_ylim(self.orig_ylim)
                ax.figure.canvas.draw()

        def onRelease(event):
            #print(event.key)
            if event.inaxes != ax: return
            if event.key == "control":
                self.ctrl_press = False
        
        fig = ax.get_figure() # get the figure of interest

        # attach the call back
        fig.canvas.mpl_connect('key_press_event',onPress)
        fig.canvas.mpl_connect('key_release_event',onRelease)

    def pan_factory(self, ax):
        def onPress(event):
            if event.inaxes != ax: return
            self.cur_xlim = ax.get_xlim()
            self.cur_ylim = ax.get_ylim()
            self.press = self.x0, self.y0, event.xdata, event.ydata
            self.x0, self.y0, self.xpress, self.ypress = self.press

        def onRelease(event):
            self.press = None
            ax.figure.canvas.draw()

        def onMotion(event):
            if self.press is None: return
            if event.inaxes != ax: return
            dx = event.xdata - self.xpress
            dy = event.ydata - self.ypress
            self.cur_xlim -= dx
            self.cur_ylim -= dy
            ax.set_xlim(self.cur_xlim)
            ax.set_ylim(self.cur_ylim)

            ax.figure.canvas.draw()

        fig = ax.get_figure() # get the figure of interest

        # attach the call back
        fig.canvas.mpl_connect('button_press_event',onPress)
        fig.canvas.mpl_connect('button_release_event',onRelease)
        fig.canvas.mpl_connect('motion_notify_event',onMotion)

        #return the function
        return onMotion


fig = figure()

ax = fig.add_subplot(111, xlim=(0,1), ylim=(0,1), autoscale_on=False)

ax.set_title('Click to zoom')
x,y,s,c = numpy.random.rand(4,200)
s *= 200

ax.scatter(x,y,s,c)
scale = 1.1
zp = ZoomPan(ax)
show()

key_press_event を使ったフラグ管理と注意点

コントロールキーを押しているか管理するためにkey_press_eventを使っています。

キーが押されたらフラグを立てて、キーを話したらフラグを下ろすという2つのコールバックを定義しています。 その他に「r」を押した際に描画範囲をリセットする機能も同時に書いています。

ハマったバグ
Macで開発しているときにハマったのがOS Xのバックエンドだとcontrolキーが押されたかチェックできない問題です。

python - Close pyplot figure using the keyboard on Mac OS X - Stack Overflow

上記の質疑のようにバックエンドをTKAggに変えてことなきを得ました。ちょっと画質が荒くなる感じがしてあまり好きではありませんが。。。

import matplotlib
matplotlib.use('TKAgg')

これ以外にもキーの同時押しのときはバックエンドによって出てくる値が変わるなどこのあたりは結構気をつけることがありそうです。