├── LICENSE ├── README.rst └── xkcdplot.py /LICENSE: -------------------------------------------------------------------------------- 1 | TBD 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | XKCDify 2 | ============== 3 | 4 | Sketchy, imprecise plotting theme for matplotlib. 5 | 6 | Sometimes you want to illustrate a point and do not want to emphasize on accuracy but on overall relations. This code originated at `jakevdp's blog 7 | `_. 8 | 9 | Semi-log and log-log plots 10 | --------------------------- 11 | 12 | Just take the logarithm before calling the plotting functions, i.e. give matplotlib the base-10 logarithms of the values. Then set the tick labels with the true values:: 13 | 14 | ax.set_xticks([-3, -2, -1], ['1e-3', '1e-2', '1e-1']) 15 | 16 | Sometimes XKCDify also does not appreciate large/negative values. You can solve this by transformations into a space it likes. 17 | 18 | References: 19 | ------------- 20 | 21 | * http://jakevdp.github.com/blog/2012/10/07/xkcd-style-plots-in-matplotlib/ 22 | * https://github.com/matplotlib/matplotlib/pull/1329 23 | 24 | 25 | -------------------------------------------------------------------------------- /xkcdplot.py: -------------------------------------------------------------------------------- 1 | """ 2 | XKCD plot generator 3 | ------------------- 4 | Author: Jake Vanderplas 5 | 6 | This is a script that will take any matplotlib line diagram, and convert it 7 | to an XKCD-style plot. It will work for plots with line & text elements, 8 | including axes labels and titles (but not axes tick labels). 9 | 10 | The idea for this comes from work by Damon McDougall 11 | http://www.mail-archive.com/matplotlib-users@lists.sourceforge.net/msg25499.html 12 | """ 13 | import numpy as np 14 | import pylab as pl 15 | from scipy import interpolate, signal 16 | import matplotlib.font_manager as fm 17 | 18 | 19 | # We need a special font for the code below. It can be downloaded this way: 20 | import os 21 | import urllib2 22 | if not os.path.exists('Humor-Sans.ttf'): 23 | fhandle = urllib2.urlopen('http://antiyawn.com/uploads/Humor-Sans.ttf') 24 | open('Humor-Sans.ttf', 'w').write(fhandle.read()) 25 | 26 | 27 | def xkcd_line(x, y, xlim=None, ylim=None, 28 | mag=1.0, f1=30, f2=0.05, f3=15): 29 | """ 30 | Mimic a hand-drawn line from (x, y) data 31 | 32 | Parameters 33 | ---------- 34 | x, y : array_like 35 | arrays to be modified 36 | xlim, ylim : data range 37 | the assumed plot range for the modification. If not specified, 38 | they will be guessed from the data 39 | mag : float 40 | magnitude of distortions 41 | f1, f2, f3 : int, float, int 42 | filtering parameters. f1 gives the size of the window, f2 gives 43 | the high-frequency cutoff, f3 gives the size of the filter 44 | 45 | Returns 46 | ------- 47 | x, y : ndarrays 48 | The modified lines 49 | """ 50 | x = np.asarray(x) 51 | y = np.asarray(y) 52 | 53 | # get limits for rescaling 54 | if xlim is None: 55 | xlim = (x.min(), x.max()) 56 | if ylim is None: 57 | ylim = (y.min(), y.max()) 58 | 59 | if xlim[1] == xlim[0]: 60 | xlim = ylim 61 | 62 | if ylim[1] == ylim[0]: 63 | ylim = xlim 64 | 65 | # scale the data 66 | x_scaled = (x - xlim[0]) * 1. / (xlim[1] - xlim[0]) 67 | y_scaled = (y - ylim[0]) * 1. / (ylim[1] - ylim[0]) 68 | 69 | # compute the total distance along the path 70 | dx = x_scaled[1:] - x_scaled[:-1] 71 | dy = y_scaled[1:] - y_scaled[:-1] 72 | dist_tot = np.sum(np.sqrt(dx * dx + dy * dy)) 73 | 74 | # number of interpolated points is proportional to the distance 75 | Nu = int(200 * dist_tot) 76 | u = np.arange(-1, Nu + 1) * 1. / (Nu - 1) 77 | 78 | # interpolate curve at sampled points 79 | k = min(3, len(x) - 1) 80 | res = interpolate.splprep([x_scaled, y_scaled], s=0, k=k) 81 | x_int, y_int = interpolate.splev(u, res[0]) 82 | 83 | # we'll perturb perpendicular to the drawn line 84 | dx = x_int[2:] - x_int[:-2] 85 | dy = y_int[2:] - y_int[:-2] 86 | dist = np.sqrt(dx * dx + dy * dy) 87 | 88 | # create a filtered perturbation 89 | coeffs = mag * np.random.normal(0, 0.01, len(x_int) - 2) 90 | b = signal.firwin(f1, f2 * dist_tot, window=('kaiser', f3)) 91 | response = signal.lfilter(b, 1, coeffs) 92 | 93 | x_int[1:-1] += response * dy / dist 94 | y_int[1:-1] += response * dx / dist 95 | 96 | # un-scale data 97 | x_int = x_int[1:-1] * (xlim[1] - xlim[0]) + xlim[0] 98 | y_int = y_int[1:-1] * (ylim[1] - ylim[0]) + ylim[0] 99 | 100 | return x_int, y_int 101 | 102 | 103 | def XKCDify(ax, mag=1.0, 104 | f1=50, f2=0.01, f3=15, 105 | bgcolor='w', 106 | xaxis_loc=None, 107 | yaxis_loc=None, 108 | xaxis_arrow='+', 109 | yaxis_arrow='+', 110 | ax_extend=0.1, 111 | expand_axes=False, 112 | ticks=False, 113 | xticks_inside=False, 114 | yticks_inside=False, 115 | xlabel_inside=False, 116 | ylabel_inside=False, 117 | ): 118 | """Make axis look hand-drawn 119 | 120 | This adjusts all lines, text, legends, and axes in the figure to look 121 | like xkcd plots. Other plot elements are not modified. 122 | 123 | Parameters 124 | ---------- 125 | ax : Axes instance 126 | the axes to be modified. 127 | mag : float 128 | the magnitude of the distortion 129 | f1, f2, f3 : int, float, int 130 | filtering parameters. f1 gives the size of the window, f2 gives 131 | the high-frequency cutoff, f3 gives the size of the filter 132 | xaxis_loc, yaxis_log : float 133 | The locations to draw the x and y axes. If not specified, they 134 | will be drawn from the bottom left of the plot 135 | xaxis_arrow, yaxis_arrow : str 136 | where to draw arrows on the x/y axes. Options are '+', '-', '+-', or '' 137 | ax_extend : float 138 | How far (fractionally) to extend the drawn axes beyond the original 139 | axes limits 140 | expand_axes : bool 141 | if True, then expand axes to fill the figure (useful if there is only 142 | a single axes in the figure) 143 | """ 144 | # Get axes aspect 145 | ext = ax.get_window_extent().extents 146 | aspect = (ext[3] - ext[1]) / (ext[2] - ext[0]) 147 | 148 | xlim = ax.get_xlim() 149 | ylim = ax.get_ylim() 150 | 151 | xspan = xlim[1] - xlim[0] 152 | yspan = ylim[1] - xlim[0] 153 | 154 | xax_lim = (xlim[0] - ax_extend * xspan, 155 | xlim[1] + ax_extend * xspan) 156 | yax_lim = (ylim[0] - ax_extend * yspan, 157 | ylim[1] + ax_extend * yspan) 158 | 159 | if xaxis_loc is None: 160 | xaxis_loc = ylim[0] 161 | 162 | if yaxis_loc is None: 163 | yaxis_loc = xlim[0] 164 | 165 | # Draw axes 166 | xaxis = pl.Line2D([xax_lim[0], xax_lim[1]], [xaxis_loc, xaxis_loc], 167 | linestyle='-', color='k') 168 | yaxis = pl.Line2D([yaxis_loc, yaxis_loc], [yax_lim[0], yax_lim[1]], 169 | linestyle='-', color='k') 170 | 171 | # Label axes3, 0.5, 'hello', fontsize=14) 172 | ax.text(xax_lim[1], xaxis_loc - 0.08 * yspan * (2 * xlabel_inside - 1), ax.get_xlabel(), 173 | fontsize=14, ha='right', va='bottom' if xlabel_inside else 'top', rotation=0) 174 | ax.text(yaxis_loc + 0.04 * xspan * (2 * ylabel_inside - 1), yax_lim[1], ax.get_ylabel(), 175 | fontsize=14, ha='right', va='bottom' if ylabel_inside else 'top', rotation=84) 176 | ax.set_xlabel('') 177 | ax.set_ylabel('') 178 | 179 | # Add title 180 | ax.text(0.5 * (xax_lim[1] + xax_lim[0]), yax_lim[1], 181 | ax.get_title(), 182 | ha='center', va='bottom', fontsize=16) 183 | ax.set_title('') 184 | 185 | Nlines = len(ax.lines) 186 | lines = [xaxis, yaxis] + [ax.lines.pop(0) for i in range(Nlines)] 187 | 188 | for line in lines: 189 | x, y = line.get_data() 190 | ls = line.get_linestyle() 191 | if ls != 'None': 192 | x_int, y_int = xkcd_line(x, y, xlim, ylim, 193 | mag, f1, f2, f3) 194 | else: 195 | x_int, y_int = x, y 196 | # create foreground and background line 197 | lw = line.get_linewidth() 198 | line.set_linewidth(2 * lw) 199 | line.set_data(x_int, y_int) 200 | 201 | # don't add background line for axes 202 | if (line is not xaxis) and (line is not yaxis) and ls != 'None': 203 | line_bg = pl.Line2D(x_int, y_int, color=bgcolor, 204 | linewidth=2 * lw + 4) 205 | ax.add_line(line_bg) 206 | ax.add_line(line) 207 | 208 | # Draw arrow-heads at the end of axes lines 209 | arr1 = 0.04 * np.array([-1, 0, -1]) 210 | arr2 = 0.03 * np.array([-1, 0, 1]) 211 | 212 | arr1[::2] += np.random.normal(0, 0.005 / 2, 2) 213 | arr2[::2] += np.random.normal(0, 0.005 / 2, 2) 214 | 215 | x, y = xaxis.get_data() 216 | if '+' in str(xaxis_arrow): 217 | ax.plot(x[-1] + arr1 * xspan * aspect, 218 | y[-1] + arr2 * yspan, 219 | color='k', lw=2) 220 | if '-' in str(xaxis_arrow): 221 | ax.plot(x[0] - arr1 * xspan * aspect, 222 | y[0] - arr2 * yspan, 223 | color='k', lw=2) 224 | 225 | x, y = yaxis.get_data() 226 | if '+' in str(yaxis_arrow): 227 | ax.plot(x[-1] + arr2 * xspan * aspect**2, 228 | y[-1] + arr1 * yspan / aspect, 229 | color='k', lw=2) 230 | if '-' in str(yaxis_arrow): 231 | ax.plot(x[0] - arr2 * xspan * aspect**2, 232 | y[0] - arr1 * yspan / aspect, 233 | color='k', lw=2) 234 | 235 | # Set the axis limits 236 | ax.set_xlim(xax_lim[0] - 0.1 * xspan, 237 | xax_lim[1] + 0.1 * xspan) 238 | ax.set_ylim(yax_lim[0] - 0.1 * yspan, 239 | yax_lim[1] + 0.1 * yspan) 240 | 241 | # adjust the axes 242 | if ticks: 243 | for x,xtext in zip(ax.get_xticks(), ax.get_xticklabels()): 244 | ax.text(x, xaxis_loc - 0.08 * yspan * (2 * xticks_inside - 1), xtext.get_text(), 245 | fontsize=10, ha='center', va='bottom' if xticks_inside else 'top', rotation=0) 246 | for y,ytext in zip(ax.get_yticks(), ax.get_yticklabels()): 247 | ax.text(yaxis_loc + 0.02 * xspan * (2 * yticks_inside - 1), y, ytext.get_text(), 248 | fontsize=10, ha='left' if yticks_inside else 'right', va='center', rotation=0) 249 | 250 | ax.set_xticks([]) 251 | ax.set_yticks([]) 252 | 253 | # Change all the fonts to humor-sans. 254 | prop = fm.FontProperties(fname='Humor-Sans.ttf', size=16) 255 | for text in ax.texts: 256 | text.set_fontproperties(prop) 257 | 258 | # modify legend 259 | leg = ax.get_legend() 260 | if leg is not None: 261 | leg.set_frame_on(False) 262 | 263 | for child in leg.get_children(): 264 | if isinstance(child, pl.Line2D): 265 | x, y = child.get_data() 266 | child.set_data(xkcd_line(x, y, mag=10, f1=100, f2=0.001)) 267 | child.set_linewidth(2 * child.get_linewidth()) 268 | if isinstance(child, pl.Text): 269 | child.set_fontproperties(prop) 270 | 271 | 272 | if expand_axes: 273 | ax.figure.set_facecolor(bgcolor) 274 | ax.set_axis_off() 275 | ax.set_position([0, 0, 1, 1]) 276 | 277 | return ax 278 | 279 | if __name__ == '__main__': 280 | #np.random.seed(0) 281 | import pylab 282 | import scipy.stats 283 | ax = pylab.axes() 284 | 285 | x = np.linspace(0, 10, 100) 286 | ax.plot(x, np.sin(x) * np.exp(-0.1 * (x - 5) ** 2), 'b', lw=1, label='damped sine') 287 | ax.plot(x, -np.cos(x) * np.exp(-0.1 * (x - 5) ** 2), 'r', lw=1, label='damped cosine') 288 | ax.plot(x, scipy.stats.norm.pdf(x, 5, 1), 'g', lw=1, label='gaussian') 289 | 290 | ax.set_title('Noice plot yo!') 291 | ax.set_xlabel('invariant') 292 | ax.set_ylabel('variable') 293 | 294 | ax.legend(bbox_to_anchor=(1.15,0.4), ncol=1, handlelength=0) 295 | #ax.legend(loc='lower right', handlelength=0) 296 | 297 | #ax.set_xlim(0, 10) 298 | #ax.set_ylim(-1.0, 1.0) 299 | 300 | #XKCDify the axes -- this operates in-place 301 | XKCDify(ax, xaxis_loc=0.0, yaxis_loc=0.0, 302 | xaxis_arrow='+-', yaxis_arrow='+-', 303 | expand_axes=True) 304 | ax.set_position([0, 0, 0.92, 1]) 305 | pylab.savefig("xkcdplot.pdf") #, bbox_inches='tight') 306 | 307 | --------------------------------------------------------------------------------