|
|
## XKCD plots in Matplotlib
|
|
|
|
|
|
# This notebook originally appeared as a blog post at [Pythonic Perambulations](http://jakevdp.github.com/blog/2012/10/07/xkcd-style-plots-in-matplotlib/) by Jake Vanderplas.
|
|
|
|
|
|
# One of the problems I've had with typical matplotlib figures is that everything in them is so precise, so perfect. For an example of what I mean, take a look at this figure:
|
|
|
|
|
|
# In[1]:
|
|
|
from IPython.display import Image
|
|
|
Image('http://jakevdp.github.com/figures/xkcd_version.png')
|
|
|
|
|
|
# Out[1]:
|
|
|
# <IPython.core.display.Image at 0x2fef710>
|
|
|
|
|
|
|
|
|
# Sometimes when showing schematic plots, this is the type of figure I want to display. But drawing it by hand is a pain: I'd rather just use matplotlib. The problem is, matplotlib is a bit too precise. Attempting to duplicate this figure in matplotlib leads to something like this:
|
|
|
|
|
|
# In[2]:
|
|
|
Image('http://jakevdp.github.com/figures/mpl_version.png')
|
|
|
|
|
|
# Out[2]:
|
|
|
# <IPython.core.display.Image at 0x2fef0d0>
|
|
|
|
|
|
|
|
|
# It just doesn't have the same effect. Matplotlib is great for scientific plots, but sometimes you don't want to be so precise.
|
|
|
#
|
|
|
# This subject has recently come up on the matplotlib mailing list, and started some interesting discussions.
|
|
|
# As near as I can tell, this started with a thread on a
|
|
|
# [mathematica list](http://mathematica.stackexchange.com/questions/11350/xkcd-style-graphs)
|
|
|
# which prompted a thread on the [matplotlib list](http://matplotlib.1069221.n5.nabble.com/XKCD-style-graphs-td39226.html)
|
|
|
# wondering if the same could be done in matplotlib.
|
|
|
#
|
|
|
# Damon McDougall offered a quick
|
|
|
# [solution](http://www.mail-archive.com/matplotlib-users@lists.sourceforge.net/msg25499.html)
|
|
|
# which was improved by Fernando Perez in [this notebook](http://nbviewer.ipython.org/3835181/), and
|
|
|
# within a few days there was a [matplotlib pull request](https://github.com/matplotlib/matplotlib/pull/1329) offering a very general
|
|
|
# way to create sketch-style plots in matplotlib. Only a few days from a cool idea to a
|
|
|
# working implementation: this is one of the most incredible aspects of package development on github.
|
|
|
#
|
|
|
# The pull request looks really nice, but will likely not be included in a released version of
|
|
|
# matplotlib until at least version 1.3. In the mean-time, I wanted a way to play around with
|
|
|
# these types of plots in a way that is compatible with the current release of matplotlib. To do that,
|
|
|
# I created the following code:
|
|
|
|
|
|
### The Code: XKCDify
|
|
|
|
|
|
# XKCDify will take a matplotlib ``Axes`` instance, and modify the plot elements in-place to make
|
|
|
# them look hand-drawn.
|
|
|
# First off, we'll need to make sure we have the Humor Sans font.
|
|
|
# It can be downloaded using the command below.
|
|
|
#
|
|
|
# Next we'll create a function ``xkcd_line`` to add jitter to lines. We want this to be very general, so
|
|
|
# we'll normalize the size of the lines, and use a low-pass filter to add correlated noise, perpendicular
|
|
|
# to the direction of the line. There are a few parameters for this filter that can be tweaked to
|
|
|
# customize the appearance of the jitter.
|
|
|
#
|
|
|
# Finally, we'll create a function which accepts a matplotlib axis, and calls ``xkcd_line`` on
|
|
|
# all lines in the axis. Additionally, we'll switch the font of all text in the axes, and add
|
|
|
# some background lines for a nice effect where lines cross. We'll also draw axes, and move the
|
|
|
# axes labels and titles to the appropriate location.
|
|
|
|
|
|
# In[3]:
|
|
|
"""
|
|
|
XKCD plot generator
|
|
|
-------------------
|
|
|
Author: Jake Vanderplas
|
|
|
|
|
|
This is a script that will take any matplotlib line diagram, and convert it
|
|
|
to an XKCD-style plot. It will work for plots with line & text elements,
|
|
|
including axes labels and titles (but not axes tick labels).
|
|
|
|
|
|
The idea for this comes from work by Damon McDougall
|
|
|
http://www.mail-archive.com/matplotlib-users@lists.sourceforge.net/msg25499.html
|
|
|
"""
|
|
|
import numpy as np
|
|
|
import pylab as pl
|
|
|
from scipy import interpolate, signal
|
|
|
import matplotlib.font_manager as fm
|
|
|
|
|
|
|
|
|
# We need a special font for the code below. It can be downloaded this way:
|
|
|
import os
|
|
|
import urllib2
|
|
|
if not os.path.exists('Humor-Sans.ttf'):
|
|
|
fhandle = urllib2.urlopen('http://antiyawn.com/uploads/Humor-Sans.ttf')
|
|
|
open('Humor-Sans.ttf', 'wb').write(fhandle.read())
|
|
|
|
|
|
|
|
|
def xkcd_line(x, y, xlim=None, ylim=None,
|
|
|
mag=1.0, f1=30, f2=0.05, f3=15):
|
|
|
"""
|
|
|
Mimic a hand-drawn line from (x, y) data
|
|
|
|
|
|
Parameters
|
|
|
----------
|
|
|
x, y : array_like
|
|
|
arrays to be modified
|
|
|
xlim, ylim : data range
|
|
|
the assumed plot range for the modification. If not specified,
|
|
|
they will be guessed from the data
|
|
|
mag : float
|
|
|
magnitude of distortions
|
|
|
f1, f2, f3 : int, float, int
|
|
|
filtering parameters. f1 gives the size of the window, f2 gives
|
|
|
the high-frequency cutoff, f3 gives the size of the filter
|
|
|
|
|
|
Returns
|
|
|
-------
|
|
|
x, y : ndarrays
|
|
|
The modified lines
|
|
|
"""
|
|
|
x = np.asarray(x)
|
|
|
y = np.asarray(y)
|
|
|
|
|
|
# get limits for rescaling
|
|
|
if xlim is None:
|
|
|
xlim = (x.min(), x.max())
|
|
|
if ylim is None:
|
|
|
ylim = (y.min(), y.max())
|
|
|
|
|
|
if xlim[1] == xlim[0]:
|
|
|
xlim = ylim
|
|
|
|
|
|
if ylim[1] == ylim[0]:
|
|
|
ylim = xlim
|
|
|
|
|
|
# scale the data
|
|
|
x_scaled = (x - xlim[0]) * 1. / (xlim[1] - xlim[0])
|
|
|
y_scaled = (y - ylim[0]) * 1. / (ylim[1] - ylim[0])
|
|
|
|
|
|
# compute the total distance along the path
|
|
|
dx = x_scaled[1:] - x_scaled[:-1]
|
|
|
dy = y_scaled[1:] - y_scaled[:-1]
|
|
|
dist_tot = np.sum(np.sqrt(dx * dx + dy * dy))
|
|
|
|
|
|
# number of interpolated points is proportional to the distance
|
|
|
Nu = int(200 * dist_tot)
|
|
|
u = np.arange(-1, Nu + 1) * 1. / (Nu - 1)
|
|
|
|
|
|
# interpolate curve at sampled points
|
|
|
k = min(3, len(x) - 1)
|
|
|
res = interpolate.splprep([x_scaled, y_scaled], s=0, k=k)
|
|
|
x_int, y_int = interpolate.splev(u, res[0])
|
|
|
|
|
|
# we'll perturb perpendicular to the drawn line
|
|
|
dx = x_int[2:] - x_int[:-2]
|
|
|
dy = y_int[2:] - y_int[:-2]
|
|
|
dist = np.sqrt(dx * dx + dy * dy)
|
|
|
|
|
|
# create a filtered perturbation
|
|
|
coeffs = mag * np.random.normal(0, 0.01, len(x_int) - 2)
|
|
|
b = signal.firwin(f1, f2 * dist_tot, window=('kaiser', f3))
|
|
|
response = signal.lfilter(b, 1, coeffs)
|
|
|
|
|
|
x_int[1:-1] += response * dy / dist
|
|
|
y_int[1:-1] += response * dx / dist
|
|
|
|
|
|
# un-scale data
|
|
|
x_int = x_int[1:-1] * (xlim[1] - xlim[0]) + xlim[0]
|
|
|
y_int = y_int[1:-1] * (ylim[1] - ylim[0]) + ylim[0]
|
|
|
|
|
|
return x_int, y_int
|
|
|
|
|
|
|
|
|
def XKCDify(ax, mag=1.0,
|
|
|
f1=50, f2=0.01, f3=15,
|
|
|
bgcolor='w',
|
|
|
xaxis_loc=None,
|
|
|
yaxis_loc=None,
|
|
|
xaxis_arrow='+',
|
|
|
yaxis_arrow='+',
|
|
|
ax_extend=0.1,
|
|
|
expand_axes=False):
|
|
|
"""Make axis look hand-drawn
|
|
|
|
|
|
This adjusts all lines, text, legends, and axes in the figure to look
|
|
|
like xkcd plots. Other plot elements are not modified.
|
|
|
|
|
|
Parameters
|
|
|
----------
|
|
|
ax : Axes instance
|
|
|
the axes to be modified.
|
|
|
mag : float
|
|
|
the magnitude of the distortion
|
|
|
f1, f2, f3 : int, float, int
|
|
|
filtering parameters. f1 gives the size of the window, f2 gives
|
|
|
the high-frequency cutoff, f3 gives the size of the filter
|
|
|
xaxis_loc, yaxis_log : float
|
|
|
The locations to draw the x and y axes. If not specified, they
|
|
|
will be drawn from the bottom left of the plot
|
|
|
xaxis_arrow, yaxis_arrow : str
|
|
|
where to draw arrows on the x/y axes. Options are '+', '-', '+-', or ''
|
|
|
ax_extend : float
|
|
|
How far (fractionally) to extend the drawn axes beyond the original
|
|
|
axes limits
|
|
|
expand_axes : bool
|
|
|
if True, then expand axes to fill the figure (useful if there is only
|
|
|
a single axes in the figure)
|
|
|
"""
|
|
|
# Get axes aspect
|
|
|
ext = ax.get_window_extent().extents
|
|
|
aspect = (ext[3] - ext[1]) / (ext[2] - ext[0])
|
|
|
|
|
|
xlim = ax.get_xlim()
|
|
|
ylim = ax.get_ylim()
|
|
|
|
|
|
xspan = xlim[1] - xlim[0]
|
|
|
yspan = ylim[1] - xlim[0]
|
|
|
|
|
|
xax_lim = (xlim[0] - ax_extend * xspan,
|
|
|
xlim[1] + ax_extend * xspan)
|
|
|
yax_lim = (ylim[0] - ax_extend * yspan,
|
|
|
ylim[1] + ax_extend * yspan)
|
|
|
|
|
|
if xaxis_loc is None:
|
|
|
xaxis_loc = ylim[0]
|
|
|
|
|
|
if yaxis_loc is None:
|
|
|
yaxis_loc = xlim[0]
|
|
|
|
|
|
# Draw axes
|
|
|
xaxis = pl.Line2D([xax_lim[0], xax_lim[1]], [xaxis_loc, xaxis_loc],
|
|
|
linestyle='-', color='k')
|
|
|
yaxis = pl.Line2D([yaxis_loc, yaxis_loc], [yax_lim[0], yax_lim[1]],
|
|
|
linestyle='-', color='k')
|
|
|
|
|
|
# Label axes3, 0.5, 'hello', fontsize=14)
|
|
|
ax.text(xax_lim[1], xaxis_loc - 0.02 * yspan, ax.get_xlabel(),
|
|
|
fontsize=14, ha='right', va='top', rotation=12)
|
|
|
ax.text(yaxis_loc - 0.02 * xspan, yax_lim[1], ax.get_ylabel(),
|
|
|
fontsize=14, ha='right', va='top', rotation=78)
|
|
|
ax.set_xlabel('')
|
|
|
ax.set_ylabel('')
|
|
|
|
|
|
# Add title
|
|
|
ax.text(0.5 * (xax_lim[1] + xax_lim[0]), yax_lim[1],
|
|
|
ax.get_title(),
|
|
|
ha='center', va='bottom', fontsize=16)
|
|
|
ax.set_title('')
|
|
|
|
|
|
Nlines = len(ax.lines)
|
|
|
lines = [xaxis, yaxis] + [ax.lines.pop(0) for i in range(Nlines)]
|
|
|
|
|
|
for line in lines:
|
|
|
x, y = line.get_data()
|
|
|
|
|
|
x_int, y_int = xkcd_line(x, y, xlim, ylim,
|
|
|
mag, f1, f2, f3)
|
|
|
|
|
|
# create foreground and background line
|
|
|
lw = line.get_linewidth()
|
|
|
line.set_linewidth(2 * lw)
|
|
|
line.set_data(x_int, y_int)
|
|
|
|
|
|
# don't add background line for axes
|
|
|
if (line is not xaxis) and (line is not yaxis):
|
|
|
line_bg = pl.Line2D(x_int, y_int, color=bgcolor,
|
|
|
linewidth=8 * lw)
|
|
|
|
|
|
ax.add_line(line_bg)
|
|
|
ax.add_line(line)
|
|
|
|
|
|
# Draw arrow-heads at the end of axes lines
|
|
|
arr1 = 0.03 * np.array([-1, 0, -1])
|
|
|
arr2 = 0.02 * np.array([-1, 0, 1])
|
|
|
|
|
|
arr1[::2] += np.random.normal(0, 0.005, 2)
|
|
|
arr2[::2] += np.random.normal(0, 0.005, 2)
|
|
|
|
|
|
x, y = xaxis.get_data()
|
|
|
if '+' in str(xaxis_arrow):
|
|
|
ax.plot(x[-1] + arr1 * xspan * aspect,
|
|
|
y[-1] + arr2 * yspan,
|
|
|
color='k', lw=2)
|
|
|
if '-' in str(xaxis_arrow):
|
|
|
ax.plot(x[0] - arr1 * xspan * aspect,
|
|
|
y[0] - arr2 * yspan,
|
|
|
color='k', lw=2)
|
|
|
|
|
|
x, y = yaxis.get_data()
|
|
|
if '+' in str(yaxis_arrow):
|
|
|
ax.plot(x[-1] + arr2 * xspan * aspect,
|
|
|
y[-1] + arr1 * yspan,
|
|
|
color='k', lw=2)
|
|
|
if '-' in str(yaxis_arrow):
|
|
|
ax.plot(x[0] - arr2 * xspan * aspect,
|
|
|
y[0] - arr1 * yspan,
|
|
|
color='k', lw=2)
|
|
|
|
|
|
# Change all the fonts to humor-sans.
|
|
|
prop = fm.FontProperties(fname='Humor-Sans.ttf', size=16)
|
|
|
for text in ax.texts:
|
|
|
text.set_fontproperties(prop)
|
|
|
|
|
|
# modify legend
|
|
|
leg = ax.get_legend()
|
|
|
if leg is not None:
|
|
|
leg.set_frame_on(False)
|
|
|
|
|
|
for child in leg.get_children():
|
|
|
if isinstance(child, pl.Line2D):
|
|
|
x, y = child.get_data()
|
|
|
child.set_data(xkcd_line(x, y, mag=10, f1=100, f2=0.001))
|
|
|
child.set_linewidth(2 * child.get_linewidth())
|
|
|
if isinstance(child, pl.Text):
|
|
|
child.set_fontproperties(prop)
|
|
|
|
|
|
# Set the axis limits
|
|
|
ax.set_xlim(xax_lim[0] - 0.1 * xspan,
|
|
|
xax_lim[1] + 0.1 * xspan)
|
|
|
ax.set_ylim(yax_lim[0] - 0.1 * yspan,
|
|
|
yax_lim[1] + 0.1 * yspan)
|
|
|
|
|
|
# adjust the axes
|
|
|
ax.set_xticks([])
|
|
|
ax.set_yticks([])
|
|
|
|
|
|
if expand_axes:
|
|
|
ax.figure.set_facecolor(bgcolor)
|
|
|
ax.set_axis_off()
|
|
|
ax.set_position([0, 0, 1, 1])
|
|
|
|
|
|
return ax
|
|
|
|
|
|
### Testing it Out
|
|
|
|
|
|
# Let's test this out with a simple plot. We'll plot two curves, add some labels,
|
|
|
# and then call ``XKCDify`` on the axis. I think the results are pretty nice!
|
|
|
|
|
|
# In[4]:
|
|
|
%pylab inline
|
|
|
|
|
|
# Out[4]:
|
|
|
#
|
|
|
# Welcome to pylab, a matplotlib-based Python environment [backend: module://IPython.zmq.pylab.backend_inline].
|
|
|
# For more information, type 'help(pylab)'.
|
|
|
#
|
|
|
# In[5]:
|
|
|
np.random.seed(0)
|
|
|
|
|
|
ax = pylab.axes()
|
|
|
|
|
|
x = np.linspace(0, 10, 100)
|
|
|
ax.plot(x, np.sin(x) * np.exp(-0.1 * (x - 5) ** 2), 'b', lw=1, label='damped sine')
|
|
|
ax.plot(x, -np.cos(x) * np.exp(-0.1 * (x - 5) ** 2), 'r', lw=1, label='damped cosine')
|
|
|
|
|
|
ax.set_title('check it out!')
|
|
|
ax.set_xlabel('x label')
|
|
|
ax.set_ylabel('y label')
|
|
|
|
|
|
ax.legend(loc='lower right')
|
|
|
|
|
|
ax.set_xlim(0, 10)
|
|
|
ax.set_ylim(-1.0, 1.0)
|
|
|
|
|
|
#XKCDify the axes -- this operates in-place
|
|
|
XKCDify(ax, xaxis_loc=0.0, yaxis_loc=1.0,
|
|
|
xaxis_arrow='+-', yaxis_arrow='+-',
|
|
|
expand_axes=True)
|
|
|
|
|
|
|
|
|
# Out[5]:
|
|
|
# <matplotlib.axes.AxesSubplot at 0x2fecbd0>
|
|
|
|
|
|
|
|
|
# image file: tests/ipynbref/XKCD_plots_orig_files/XKCD_plots_orig_fig_00.png
|
|
|
|
|
|
### Duplicating an XKCD Comic
|
|
|
|
|
|
# Now let's see if we can use this to replicated an XKCD comic in matplotlib.
|
|
|
# This is a good one:
|
|
|
|
|
|
# In[6]:
|
|
|
Image('http://imgs.xkcd.com/comics/front_door.png')
|
|
|
|
|
|
# Out[6]:
|
|
|
# <IPython.core.display.Image at 0x2ff4a10>
|
|
|
|
|
|
|
|
|
# With the new ``XKCDify`` function, this is relatively easy to replicate. The results
|
|
|
# are not exactly identical, but I think it definitely gets the point across!
|
|
|
|
|
|
# In[7]:
|
|
|
# Some helper functions
|
|
|
def norm(x, x0, sigma):
|
|
|
return np.exp(-0.5 * (x - x0) ** 2 / sigma ** 2)
|
|
|
|
|
|
def sigmoid(x, x0, alpha):
|
|
|
return 1. / (1. + np.exp(- (x - x0) / alpha))
|
|
|
|
|
|
# define the curves
|
|
|
x = np.linspace(0, 1, 100)
|
|
|
y1 = np.sqrt(norm(x, 0.7, 0.05)) + 0.2 * (1.5 - sigmoid(x, 0.8, 0.05))
|
|
|
|
|
|
y2 = 0.2 * norm(x, 0.5, 0.2) + np.sqrt(norm(x, 0.6, 0.05)) + 0.1 * (1 - sigmoid(x, 0.75, 0.05))
|
|
|
|
|
|
y3 = 0.05 + 1.4 * norm(x, 0.85, 0.08)
|
|
|
y3[x > 0.85] = 0.05 + 1.4 * norm(x[x > 0.85], 0.85, 0.3)
|
|
|
|
|
|
# draw the curves
|
|
|
ax = pl.axes()
|
|
|
ax.plot(x, y1, c='gray')
|
|
|
ax.plot(x, y2, c='blue')
|
|
|
ax.plot(x, y3, c='red')
|
|
|
|
|
|
ax.text(0.3, -0.1, "Yard")
|
|
|
ax.text(0.5, -0.1, "Steps")
|
|
|
ax.text(0.7, -0.1, "Door")
|
|
|
ax.text(0.9, -0.1, "Inside")
|
|
|
|
|
|
ax.text(0.05, 1.1, "fear that\nthere's\nsomething\nbehind me")
|
|
|
ax.plot([0.15, 0.2], [1.0, 0.2], '-k', lw=0.5)
|
|
|
|
|
|
ax.text(0.25, 0.8, "forward\nspeed")
|
|
|
ax.plot([0.32, 0.35], [0.75, 0.35], '-k', lw=0.5)
|
|
|
|
|
|
ax.text(0.9, 0.4, "embarrassment")
|
|
|
ax.plot([1.0, 0.8], [0.55, 1.05], '-k', lw=0.5)
|
|
|
|
|
|
ax.set_title("Walking back to my\nfront door at night:")
|
|
|
|
|
|
ax.set_xlim(0, 1)
|
|
|
ax.set_ylim(0, 1.5)
|
|
|
|
|
|
# modify all the axes elements in-place
|
|
|
XKCDify(ax, expand_axes=True)
|
|
|
|
|
|
|
|
|
# Out[7]:
|
|
|
# <matplotlib.axes.AxesSubplot at 0x2fef210>
|
|
|
|
|
|
|
|
|
# image file: tests/ipynbref/XKCD_plots_orig_files/XKCD_plots_orig_fig_01.png
|
|
|
|
|
|
# Pretty good for a couple hours's work!
|
|
|
#
|
|
|
# I think the possibilities here are pretty limitless: this is going to be a hugely
|
|
|
# useful and popular feature in matplotlib, especially when the sketch artist PR is mature
|
|
|
# and part of the main package. I imagine using this style of plot for schematic figures
|
|
|
# in presentations where the normal crisp matplotlib lines look a bit too "scientific".
|
|
|
# I'm giving a few talks at the end of the month... maybe I'll even use some of
|
|
|
# this code there.
|
|
|
#
|
|
|
# This post was written entirely in an IPython Notebook: the notebook file is available for
|
|
|
# download [here](http://jakevdp.github.com/downloads/notebooks/XKCD_plots.ipynb).
|
|
|
# For more information on blogging with notebooks in octopress, see my
|
|
|
# [previous post](http://jakevdp.github.com/blog/2012/10/04/blogging-with-ipython/)
|
|
|
# on the subject.
|
|
|
|