matplotlibのインタラクティブなプロットを作る覚書(スクロールでズーム、ドラッグで移動)
背景
オレオレGUIを作る際にインタラクティブなPlotがしたいという動機です。
インタラクティブなPlotについては下記が結構ボリュームがあって良いと思います。
サンプルコード①:スクロールで拡大縮小、ドラッグで移動
下記StackOverflowの議論からコードをもらってPython3用に改変しました。
動作は下記の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()
出力は下記のようになります。
ズーム動作
関数の最初に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)
その他の動作
つまり、どんなイベントがあるかだけ把握すれば適切なコールバック関数を渡してあげることでいろいろな動作ができるということです。
では、実際にどんなイベントがあるかについては下記公式ページを参照してください。
次のサンプルでは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')
これ以外にもキーの同時押しのときはバックエンドによって出てくる値が変わるなどこのあたりは結構気をつけることがありそうです。