In the following sections of this IPython Notebook we be looking at the following:
Warm-up proceedures:
In [1]:
import matplotlib
matplotlib.use('nbagg')
Notice that we've left out the following line from our usual notebook prelude:
%matplotlib inline
We've disabled inline so that we get access to the interactive mode. More on that later :-)
Let's continue with the necessary imports:
In [2]:
import random
import sys
import time
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import Image
from typecheck import typecheck
sys.path.append("../lib")
import topo
Let's set up our colors for this notebook:
In [3]:
pallete_name = "husl"
#colors = sns.color_palette(pallete_name, 8)
#colors.reverse()
#cmap = mpl.colors.LinearSegmentedColormap.from_list(pallete_name, colors)
cmap = mpl.colors.Colormap('Sequential')
Before we look at matplotlib's event loop support, let's do a quick survey of event loops and get a refresher on how they work. Here's a pretty simple "event" loop:
while True:
pass
That loop is not going be worth our while to execute in this notebook :-) So let's do another one, almost as simple, that has a good chance of exiting in under a minute:
In [4]:
x = True
while x:
time.sleep(1)
if random.random() < 0.15:
x = False
This loop only handles one "event": the change of a value from True
to False
. That loop will continue to run until the condition for a false value of x
is met (a random float under a particular threshold).
So what relation do these simple loops have with the loops that power toolkits like GTK and Qt or frameworks like Twisted and Tornado? Usually event systems have something like the following:
During each run, a loop will usually check a data structure to see if there are any new events that have occurred since the last time it looped. In a network event system, each loop might check to see if any file descriptors are ready for reading or writing. In a GUI toolkit, each look might check to see if any clicks or button presses had occurred.
Given the simple criteria above, let's try building a minimally demonstrative, if not useful, event loop. To keep this small, we're not going to integrate with socket or GUI events. The event that our loop will respond to will be quite minimal indeed.
In [5]:
class EventLoop:
def __init__(self):
self.command = None
self.status = None
self.handlers = {"interrupt": self.handle_interrupt}
self.resolution = 0.1
def loop(self):
self.command = "loop"
while self.command != "stop":
self.status = "running"
time.sleep(self.resolution)
def start(self):
self.command = "run"
try:
self.loop()
except KeyboardInterrupt:
self.handle_event("interrupt")
def stop(self):
self.command = "stop"
@typecheck
def add_handler(self, fn: callable, event: str):
self.handlers[event] = fn
@typecheck
def handle_event(self, event: str):
self.handlers[event]()
def handle_interrupt(self):
print("Stopping event loop ...")
self.stop()
Here's what we did:
start
method, we check for an interrupt signal, and fire off an interrupt handler for said signalLet's creat an instance and start it up:
In [ ]:
el = EventLoop()
el.start()
When you evaluate that cell, IPython will display the usual indicator that a cell is continuing to run:
In [*]:
As soon as you're satisfied that the loop is merrily looping, go up to the IPython Notebook menu and select "Kernel" -> "Interrupt". The cell with the loop in it should finish, with not only an In
number instead of an asterisk, but our interrupt handler should have printed out a status message as well.
Though this event loop is fairly different from those that power networking libraries or GUI toolkits, it's very close (both in nature and code) to the default event loops matplotlib provides for its canvas objects. As such, this is a perfect starting place for your deeper understanding of matplotlib. To continue in this vein, reading the matplotlib backend source code would serve you well.
With some event loop knowledge under our belts, we're ready to start working with matplotlib events.
Below is the list of supported events in matplotlib as of version 1.4:
Event name | Class and description |
---|---|
button_press_event |
MouseEvent - mouse button is pressed |
button_release_event |
MouseEvent - mouse button is released |
draw_event |
DrawEvent - canvas draw |
key_press_event |
KeyEvent - key is pressed |
key_release_event |
KeyEvent - key is released |
motion_notify_event |
MouseEvent - mouse motion |
pick_event |
PickEvent - an object in the canvas is selected |
resize_event |
ResizeEvent - figure canvas is resized |
scroll_event |
MouseEvent - mouse scroll wheel is rolled |
figure_enter_event |
LocationEvent - mouse enters a new figure |
figure_leave_event |
LocationEvent - mouse leaves a figure |
axes_enter_event |
LocationEvent - mouse enters a new axes |
axes_leave_event |
LocationEvent - mouse leaves an axes |
We'll discuss some of these below in more detail. With that information in hand, you should be able to tackle problems with any of the supported events in matplotlib.
In the next cell, we will define a couple of callback functions, and then connet these to specific canvas events.
Go ahead and render the cell then click on the display plot a couple of times:
In [ ]:
def press_callback(event):
event.canvas.figure.text(event.xdata, event.ydata, '<- clicked here')
def release_callback(event):
event.canvas.figure.show()
(figure, axes) = plt.subplots()
press_conn_id = figure.canvas.mpl_connect('button_press_event', press_callback)
release_conn_id = figure.canvas.mpl_connect('button_release_event', release_callback)
plt.show()
Our callbacks display a little note close to each $(x, y)$ coordinate where we clicked (the location is not exact due to font-sizing, etc.) If we use a graphical indication as opposed to a textual one, we can get much better precision:
In [ ]:
class Callbacks:
def __init__(self):
(figure, axes) = plt.subplots()
axes.set_aspect(1)
figure.canvas.mpl_connect('button_press_event', self.press)
figure.canvas.mpl_connect('button_release_event', self.release)
def start(self):
plt.show()
def press(self, event):
self.start_time = time.time()
def release(self, event):
self.end_time = time.time()
self.draw_click(event)
def draw_click(self, event):
size = 4 * (self.end_time - self.start_time) ** 2
c1 = plt.Circle([event.xdata, event.ydata], 0.002,)
c2 = plt.Circle([event.xdata, event.ydata], 0.02 * size, alpha=0.2)
event.canvas.figure.gca().add_artist(c1)
event.canvas.figure.gca().add_artist(c2)
event.canvas.figure.show()
cbs = Callbacks()
cbs.start()
As you can see, we changed the callback to display a cicle instead of text. If you choose to press and hold, and then release a bit later, you will see that a second, transparent circle is displayed. The longer you hold, the larger the second transpent circle will be.
Let's try something a little more involved, adapted from the line-drawing example in the "Event handling and picking" chapter of the matplotlib Advanced Guide:
In [ ]:
class LineBuilder:
def __init__(self, event_name='button_press_event'):
(self.figure, self.axes) = plt.subplots()
plt.xlim([0, 10])
plt.ylim([0, 10])
(self.xs, self.ys) = ([5], [5])
(self.line,) = self.axes.plot(self.xs, self.ys)
self.axes.set_title('Click the canvas to build line segments...')
self.canvas = self.figure.canvas
self.conn_id = self.canvas.mpl_connect(event_name, self.callback)
def start(self):
plt.show()
def update_line(self, event):
self.xs.append(event.xdata)
self.ys.append(event.ydata)
self.line.set_data(self.xs, self.ys)
def callback(self, event):
if event.inaxes != self.line.axes:
return
self.update_line(event)
self.canvas.draw()
lb = LineBuilder()
lb.start()
For dessert, here's the slider demo from matplotlib:
In [ ]:
from matplotlib import widgets
from matplotlib.backend_bases import MouseEvent
def get_sine_data(amplitude=5, frequency=3, time=None):
return amplitude * np.sin(2 * np.pi * frequency * time)
class SineSliders:
def __init__(self, amplitude=5, frequency=3):
(self.figure, _) = plt.subplots()
self.configure()
self.a0 = amplitude
self.f0 = frequency
self.time = np.arange(0.0, 1.0, 0.001)
self.data = get_sine_data(
amplitude=self.a0, frequency=self.f0, time=self.time)
(self.line,) = plt.plot(self.time, self.data, lw=2, color='red')
self.axes_amp = plt.axes([0.25, 0.15, 0.65, 0.03])
self.axes_freq = plt.axes([0.25, 0.1, 0.65, 0.03])
self.setup_sliders()
self.setup_reset_button()
self.setup_color_selector()
def start(self):
plt.show()
def configure(self):
plt.subplots_adjust(left=0.25, bottom=0.25)
plt.axis([0, 1, -10, 10])
def setup_sliders(self):
self.slider_amp = widgets.Slider(
self.axes_amp, 'Amp', 0.1, 10.0, valinit=self.a0)
self.slider_freq = widgets.Slider(
self.axes_freq, 'Freq', 0.1, 30.0, valinit=self.f0)
self.slider_freq.on_changed(self.update)
self.slider_amp.on_changed(self.update)
def setup_reset_button(self):
reset_axes = plt.axes([0.8, 0.025, 0.1, 0.04])
reset_button = widgets.Button(reset_axes, 'Reset', hovercolor='0.975')
reset_button.on_clicked(self.reset)
def setup_color_selector(self):
radio_axes = plt.axes([0.025, 0.5, 0.15, 0.15], aspect=1)
radio_select = widgets.RadioButtons(
radio_axes, ('red', 'blue', 'green',), active=0)
radio_select.on_clicked(self.switchcolor)
def update(self, val):
self.data = get_sine_data(self.slider_amp.val,
self.slider_freq.val,
self.time)
self.line.set_ydata(self.data)
self.figure.canvas.draw()
def reset(self, event):
self.slider_freq.reset()
self.slider_amp.reset()
def switchcolor(self, label):
self.line.set_color(label)
self.figure.canvas.draw()
sldrs = SineSliders(amplitude=0.5, frequency=20)
sldrs.start()
The IPython Notebook AGG backend currently doesn't provide support for the following matplotlib events:
key_press
scroll_event
(mouse scrolling)Also, mouse movement events can be a little inconsistent (this can be especially true if your browser or other application is running at a significant CPU%, causing events to be missed in matplotlib running in an IPython notebook).
However, we can still use IPython while switching to a new backend for matplotlib. To see which backends are available to you:
In [ ]:
sorted(set(mpl.rcsetup.interactive_bk + mpl.rcsetup.non_interactive_bk + mpl.rcsetup.all_backends))
Currently keyboard events aren't supported by IPython and the matplotlib nbagg backend. So, for this section, we'll switch over to your default platform's GUI toolkit in matplotlib.
You have two options for the remainder of this notebook:
For terminal use, change directory to where you cloned this notebook's git repo and then fire up IPython:
$ cd interaction
$ make repl
The repl
target is a convenience that uses a Python virtual environment and the downloaded dependencies for this notebook. Once you're at the IPython prompt, you may start entering code with automatically-configured access to the libraries needed by this notebook.
If you would like to continue using this notebook instead of switching to the terminal, you'll need to change your backend for the remaining examples. For instance:
In [ ]:
plt.switch_backend('MacOSX')
Let's prepare for our key event explorations by defining some support functions ahead of time:
In [ ]:
def make_data(n, c):
r = 4 * c * np.random.rand(n) ** 2
theta = 2 * np.pi * np.random.rand(n)
area = 200 * r**2 * np.random.rand(n)
return (r, area, theta)
def generate_data(n, c):
while True:
yield make_data(n, c)
def make_plot(radius, area, theta, axes=None):
scatter = axes.scatter(
theta, radius, c=theta, s=area, cmap=cmap)
scatter.set_alpha(0.75)
def update_plot(radius, area, theta, event):
figure = event.canvas.figure
axes = figure.gca()
make_plot(radius, area, theta, axes)
event.canvas.draw()
Now let's make a class which will:
In [ ]:
class Carousel:
def __init__(self, data):
(self.left, self.right) = ([], [])
self.gen = data
self.last_key = None
def start(self, axes):
make_plot(*self.next(), axes=axes)
def prev(self):
if not self.left:
return []
data = self.left.pop()
self.right.insert(0, data)
return data
def next(self):
if self.right:
data = self.right.pop(0)
else:
data = next(self.gen)
self.left.append(data)
return data
def reset(self):
self.right = self.left + self.right
self.left = []
def dispatch(self, event):
if event.key == "right":
self.handle_right(event)
elif event.key == "left":
self.handle_left(event)
elif event.key == "r":
self.handle_reset(event)
def handle_right(self, event):
print("Got right key ...")
if self.last_key == "left":
self.next()
update_plot(*self.next(), event=event)
self.last_key = event.key
def handle_left(self, event):
print("Got left key ...")
if self.last_key == "right":
self.prev()
data = self.prev()
if data:
update_plot(*data, event=event)
self.last_key = event.key
def handle_reset(self, event):
print("Got reset key ...")
self.reset()
update_plot(*self.next(), event=event)
self.last_key = event.key
One more class, to help keep things clean:
In [ ]:
class CarouselManager:
def __init__(self, density=300, multiplier=1):
(figure, self.axes) = plt.subplots(
figsize=(12,12), subplot_kw={"polar": "True"})
self.axes.hold(False)
data = generate_data(density, multiplier)
self.carousel = Carousel(data)
_ = figure.canvas.mpl_connect(
'key_press_event', self.carousel.dispatch)
def start(self):
self.carousel.start(self.axes)
plt.show()
Now we can take it for a spin:
In [ ]:
cm = CarouselManager(multiplier=2)
cm.start()
In the GUI canvas, you should see something that looks a bit like this:
The plot shoudl have the focus automatically. Press the right and left arrow keys to navigate through your data sets. You can return to the beginning of the data set by typing "r", the "reset" key. Play with it a bit, to convince yourself that it's really doing what we intended :-)
In [ ]:
def enter_axes(event):
print('enter_axes', event.inaxes)
event.inaxes.patch.set_facecolor('yellow')
event.canvas.draw()
def leave_axes(event):
print('leave_axes', event.inaxes)
event.inaxes.patch.set_facecolor('white')
event.canvas.draw()
def enter_figure(event):
print('enter_figure', event.canvas.figure)
event.canvas.figure.patch.set_facecolor('red')
event.canvas.draw()
def leave_figure(event):
print('leave_figure', event.canvas.figure)
event.canvas.figure.patch.set_facecolor('grey')
event.canvas.draw()
class FigureAndAxesFocus:
def __init__(self):
(self.figure, (self.axes1, self.axes2)) = plt.subplots(2, 1)
title = "Hover mouse over figure or its axes to trigger events"
self.figure.suptitle(title)
self.setup_figure_events()
self.setup_axes_events()
def start(self):
plt.show()
def setup_figure_events(self):
self.figure.canvas.mpl_connect(
"figure_enter_event", enter_figure)
self.figure.canvas.mpl_connect(
"figure_leave_event", leave_figure)
def setup_axes_events(self):
self.figure.canvas.mpl_connect(
"axes_enter_event", enter_axes)
self.figure.canvas.mpl_connect(
"axes_leave_event", leave_axes)
Let's try it out:
In [ ]:
faaf = FigureAndAxesFocus()
faaf.start()
The next event we will mention is a special one: the event of an object being "picked". Every Artist
instance (naturally including any subclassess of Artist
) has an attribute picker
. Setting this attribute is what enables object picking in matplotlib.
The definition of picked can vary, depending upon context. For instance, setting Artist.picked
has the following results:
True
, picking is enabled for the artist object and a pick_event
will fire any time a mouse event occurs over the artist object in the figure.float
or int
), the value is interpreted as a "tolerance"; if the event's data (such as $x$ and $y$ values) is within the value of that tolerance, the pick_event
will fire.pick_event
is fired.None
, picking is disabled.The example below is adapted from the matplotlib project's picking exercise in the Advanced User's Guide. In it, we create a data set of 100 arrays, each containing 1000 random numbers. The sample mean and standard deviation of each is determined, and a plot is made of the 100 means vs the 100 standard deviations. We then connect the line created by the plot command to the pick event, and plot the original (randomly generated) time series data corresponding to the "picked" points. If more than one point is within the tolerance of the clicked on point, we display multiple subplots for the time series which fall into our tolerance (in this case, 10 pixels).
In [ ]:
class DataPicker:
def __init__(self, range):
self.range = range
self.figure = self.axes = self.line = None
self.xs = np.random.rand(*self.range)
self.means = np.mean(self.xs, axis=1)
self.stddev = np.std(self.xs, axis=1)
def start(self):
self.create_main_plot()
self.figure.canvas.mpl_connect('pick_event', self.handle_pick)
plt.show()
def create_main_plot(self):
(self.figure, self.axes) = plt.subplots()
self.axes.set_title('click on point to plot time series')
(self.line,) = self.axes.plot(self.means, self.stddev, 'o', picker=10)
def create_popup_plot(self, n, event):
popup_figure = plt.figure()
for subplotnum, i in enumerate(event.ind):
popup_axes = popup_figure.add_subplot(n, 1, subplotnum + 1)
popup_axes.plot(self.xs[i])
text_data = (self.means[i], self.stddev[i])
popup_axes.text(
0.05, 0.9,
'$\mu$=%1.3f\n$\sigma$=%1.3f' % text_data,
transform=popup_axes.transAxes, va='top')
popup_axes.set_ylim(-0.5, 1.5)
popup_figure.show()
def handle_pick(self, event):
if event.artist != self.line:
return
n = len(event.ind)
if not n:
return
self.create_popup_plot(n, event)
In [ ]:
dp = DataPicker(range=(100,1000))
dp.start()
This section discusses the combination of multiple events or other sources of data in order to provide a more highly customized user experience, whether that be for visual plot updates, preparation of data, setting object properties, or updating widgets. This is what we will refer to as "compound events".
matplotlib backends come with a feature we haven't discussed yet: a widget for interactive navigation. This widget is available for all the backends (including the nbagg
backend for IPython, when not in "inline" mode). In brief, the functionality associated with the buttons in the widget is as follows:
When a toolbar action is engaged, the NavigationToolbar
instance sets the current mode. For instance, when the Zoom-to-Rectangle button is clicked, the mode will be set to zoom rect
. When in Pan/Zoom, the mode will be set to pan/zoom
. These can be used in conjunction with the supported events to fire callbacks in response to toolbar activity.
In point of fact, the toolbar class, matplotlib.backend_bases.NavigationToolbar2
is an excellent place to look for examples of "compound events". Let's examine the Pan/Zoom button. The class tracks the following via attributes that get set:
During toolbar setup, toolbar button events are connected to callbacks. When these buttons are pressed, and the callbacks are fired, old events are disconnected and new ones connected. In this way, chains of events may be set up with a particular sequence of events firing only a particular set of callbacks and in a particular order.
The code in matplotlib.backend_bases.NavigationToolbar2
is a great place to go to get some ideas about how you might combine events in your own projects. You might have a workflow that requires responses to plot updates, but only if a series of other events has taken place first. You can accomplish these by connecting events to and disconnecting them from various callbacks.
Let's go back to the toolbar for a practical example of creating a compound event.
The problem we want to address is this: when a user pans or zooms out of the range of previously computed data in a plotted area, they are presented with parts of an empty grid with no visualization. It would be nice if we could put our new-found event callback skills to use in order to solve this issue.
Let's look at an example where it would be useful to have the plot figure refreshed when it is moved: a topographic map. Geophysicsist Joe Kington has provided some nice answers on Stackoverflow regarding matplotlib in the context of terrain gradients. In one particular example, he showed how to view the flow of water from random wells on a topographic map. We're going to do a couple of things with this example:
Our custom color map and Joe's equations for generating a topographical map have been saved to ./lib/topo.py
. We'll need to import those. Then we can define TopoFlowMap
, our wrapper class that will be used to update the plot when we pan:
In [ ]:
class TopoFlowMap:
def __init__(self, xrange=None, yrange=None, seed=1):
self.xrange = xrange or (0,1)
self.yrange = yrange or (0,1)
self.seed = seed
(self.figure, self.axes) = plt.subplots(figsize=(12,8))
self.axes.set_aspect(1)
self.colorbar = None
self.update()
def get_ranges(self, xrange, yrange):
if xrange:
self.xrange = xrange
if yrange:
self.yrange = yrange
return (xrange, yrange)
def get_colorbar_axes(self):
colorbar_axes = None
if self.colorbar:
colorbar_axes = self.colorbar.ax
colorbar_axes.clear()
return colorbar_axes
def get_filled_contours(self, coords):
return self.axes.contourf(cmap=topo.land_cmap, *coords.values())
def update_contour_lines(self, filled_contours):
contours = self.axes.contour(filled_contours, colors="black", linewidths=2)
self.axes.clabel(contours, fmt="%d", colors="#330000")
def update_water_flow(self, coords, gradient):
self.axes.streamplot(
coords.get("x")[:,0],
coords.get("y")[0,:],
gradient.get("dx"),
gradient.get("dy"),
color="0.6",
density=1,
arrowsize=2)
def update_labels(self):
self.colorbar.set_label("Altitude (m)")
self.axes.set_title("Water Flow across Land Gradients", fontsize=20)
self.axes.set_xlabel("$x$ (km)")
self.axes.set_ylabel("$y$ (km)")
def update(self, xrange=None, yrange=None):
(xrange, yrange) = self.get_ranges(xrange, yrange)
(coords, grad) = topo.make_land_map(self.xrange, self.yrange, self.seed)
self.axes.clear()
colorbar_axes = self.get_colorbar_axes()
filled_contours = self.get_filled_contours(coords)
self.update_contour_lines(filled_contours)
self.update_water_flow(coords, grad)
self.colorbar = self.figure.colorbar(filled_contours, cax=colorbar_axes)
self.update_labels()
Let's switch back to the IPython Notebook backend, so we have a reference image saved in the notebook:
In [ ]:
plt.switch_backend('nbAgg')
Let's draw the topographical map next, without any ability to update when panning:
In [ ]:
tfm = TopoFlowMap(xrange=(0,1.5), yrange=(0,1.5), seed=1732)
plt.show()
If you click the "pan/zoom" button on the navigation toolbar, and then click+hold on the figure, you can move it about. Note that, when you do so, nothing gets redrawn.
Since we do want to redraw, and there is no "pan event" to connect to, what are our options? Well, two come to mind:
draw_event
, which fires each time the canvas is moved, orbutton_release_event
which will fire when the panning is completeIf our figure was easy to draw with simple equations, the first option would probably be fine. However, we're doing some multivariate calculus on our simulated topography; as you might have noticed, our plot does not render immediately. So let's go with the second option.
There's an added bonus, though, that will make our lives easier: the NavigationTool2
keeps track of the mode it is in on it's mode
attribute. Let's use that to save some coding!
In [ ]:
class TopoFlowMapManager:
def __init__(self, xrange=None, yrange=None, seed=1):
self.map = TopoFlowMap(xrange, yrange, seed)
_ = self.map.figure.canvas.mpl_connect(
'button_release_event', self.handle_pan_zoom_release)
def start(self):
plt.show()
def handle_pan_zoom_release(self, event):
if event.canvas.toolbar.mode != "pan/zoom":
return
self.map.update(event.inaxes.get_xlim(),
event.inaxes.get_ylim())
event.canvas.draw()
Let's switch back to the native backend (in my case, that's MacOSX
; you may need Qt5Agg
, WXAgg
, or GTK3Agg
):
In [ ]:
plt.switch_backend('MacOSX')
Run the next bit of code, and then start panning around and releasing; you should see the new data displayed after the callbacks fires off the recalculation.
In [ ]:
tfmm = TopoFlowMapManager(xrange=(0,1.5), yrange=(0,1.5), seed=1732)
tfmm.start()
This is not a perfect topographical model, so sometimes you will see colors get shifted as the range of altitudes decreases or increases in a given view. Certainly close enough to demonstrate this use case, though :-)
Another thing you could do is add support for zoom rect
such that the contour lines don't get angled (due to sparse data sample spacing), but stay smoothly curved no matter how far you zoom in. Given the example above, that should be fairly easy to implement, and we leave it as a fun exercise for the motivated reader :-)