##// END OF EJS Templates
Transformer refactor
Transformer refactor

File last commit:

r10386:6416b524
r10436:1f517a13
Show More
XKCD_plots.orig.md
460 lines | 62.5 KiB | text/x-minidsrc | MarkdownLexer

XKCD plots in Matplotlib

This notebook originally appeared as a blog post at Pythonic Perambulations 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:

from IPython.display import Image
Image('http://jakevdp.github.com/figures/xkcd_version.png')
    <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:

Image('http://jakevdp.github.com/figures/mpl_version.png')
    <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
which prompted a thread on the matplotlib list
wondering if the same could be done in matplotlib.

Damon McDougall offered a quick
solution
which was improved by Fernando Perez in this notebook, and
within a few days there was a matplotlib pull request 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.

"""
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!

%pylab inline
Welcome to pylab, a matplotlib-based Python environment [backend: module://IPython.zmq.pylab.backend_inline].
For more information, type 'help(pylab)'.
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)  
    <matplotlib.axes.AxesSubplot at 0x2fecbd0>

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:

Image('http://imgs.xkcd.com/comics/front_door.png')
    <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!

# 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)  
    <matplotlib.axes.AxesSubplot at 0x2fef210>

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.
For more information on blogging with notebooks in octopress, see my
previous post
on the subject.