Brush Rasterization
\(\newcommand{\bm} [1]{ \mathbf{#1}}\) \(\newcommand{\reals}{ {\rm I\!R}}\) \(\newcommand{\trsp}{ {{\scriptscriptstyle\top}}}\)
One of the advantages of generating curves through the simulation of a movement, is that the smooth kinematics can be exploited to implement more expressive rendering methods. Here we demonstrate a simple brush model, that assumes that the amount of paint deposited is inversely proportional to the speed of the pen. While this is obviously not an accurate model of a brush or pen, it results in renderings that do accentuate the perceived dynamism of the trace.
To render a brush we want a variably smooth texture, one possible way of doing
so is to create a hat function. This can be done with an "S" shaped function,
such as the s-curve
\[
f(t) = -1 + \frac{2}{(1 + e^t)},
\]
the error function (\(\mathrm{erf}\)) or the hyperbolic
tangent (\(\mathrm{tanh}\)).
import cv2 import matplotlib.pyplot as plt import matplotlib.image as mpimg from scipy.special import erf def scurve(t): return 1. / (1. + np.exp(-t))*2. - 1. def hat(x, sharpness, func=erf): # Can use tanh instead... return (func((1.-np.abs(x*2.5))*sharpness)*0.5+0.5) x = np.linspace(-1,1,200) plt.figure(figsize=(10,7)) for s in np.linspace(2., 5, 5): plt.plot(x, hat(x, s), label='$s='+str(s)+'$') plt.legend() plt.show()
To generate an image compactly we can take advantage of a nice NumPy trick, where adding a row and a column vector produces a matrix:
x = np.linspace(1, 5, 5) # row y = np.linspace(1, 5, 5).reshape(-1, 1) m = x + y # SimPy (used for latex rendering) considers numpy arrays with one dimension as column vectors, # so we explicitly set it as a row here latex(x.reshape(1,-1),'x'), latex(y,'y'), latex(m,'x+y')
| \[x=\left[\begin{matrix}1.0 & 2.0 & 3.0 & 4.0 & 5.0\end{matrix}\right]\] | \[y=\left[\begin{matrix}1.0\\2.0\\3.0\\4.0\\5.0\end{matrix}\right]\] | \[x+y=\left[\begin{matrix}2.0 & 3.0 & 4.0 & 5.0 & 6.0\\3.0 & 4.0 & 5.0 & 6.0 & 7.0\\4.0 & 5.0 & 6.0 & 7.0 & 8.0\\5.0 & 6.0 & 7.0 & 8.0 & 9.0\\6.0 & 7.0 & 8.0 & 9.0 & 10.0\end{matrix}\right]\] |
We can then apply then use the "hat" function to generate a variably smooth brush texture.
import scipy.ndimage as ndimage def hat_img(radius, smoothness, center=np.array([0,0]), func=erf): size = int(radius)*4 # image size center = np.array(center)/radius # center (for subpixel rendering) x = np.linspace((-size/2)/radius - center[0], (size/2)/radius - center[0], size+1) y = np.linspace((-size/2)/radius - center[1] , (size/2)/radius - center[1], size+1).reshape(-1,1) img = (x**2 + y**2) img = hat(img, smoothness, func) #scurve) return img img = hat_img(64, 10, center=[0.0,0.]) plt.figure(figsize=(8,4)) plt.subplot(1,2,1) img = hat_img(64, 10, center=[0.0,0.]) plt.imshow(img*255, interpolation='bilinear') plt.subplot(1,2,2) img = hat_img(64, 1, center=[0.0,0.]) plt.imshow(img*255, interpolation='bilinear') plt.show()
Note that it is important to use bilinear intepolation in imshow, othewise Matplotlib will
create artifacts when rendering (which made me loose tons of time!).
The center parameter in the hat_img function will allow us to render the brush at
sub-pixel positions, which would be trivial in OpenGL but needs to be taken care
of here to avoid rendering artefacts.
A more interesting and flexible brush texture can be generated with a super-ellipse
#length(max(abs(p)-b,0.0))-r; def hat_img(w, h, theta, power, smoothness, center=np.array([0,0]), func=erf): radius = max(w, h) size = int(radius)*4 # image size print w/radius, h/radius center = np.array(center)/radius # center (for subpixel rendering) x = np.linspace((-size/2)/radius, (size/2)/radius, size+1) y = np.linspace((-size/2)/radius, (size/2)/radius, size+1).reshape(-1,1) # rotation ct, st = np.cos(theta), np.sin(theta) xr = x * ct - y * st - center[0] yr = x * st + y * ct - center[1] ratio = 0.5 # scale x = xr / (float(w)/radius) y = yr / ((float(h)/radius)) # superellipse distance img = np.sqrt(abs(x**power) + abs(y**power)) img = hat(img, smoothness, func) return img plt.figure(figsize=(8,4)) plt.subplot(1,2,1) img = hat_img(64, 24, 0.6, 4.0, 10, center=[0.0,0.0]) plt.imshow(img*255, interpolation='bilinear') plt.subplot(1,2,2) img = hat_img(64, 24, -0.3, 8.0, 2, center=[0.0,0.0]) plt.imshow(img*255, interpolation='bilinear') plt.show()
which still allows us to generate a round brush (with a power of \(2\))
img = hat_img(64, 64, 0.0, 2.0, 10, center=[0.0,0.0]) plt.imshow(img*255, interpolation='bilinear')
We can now apply the texture to a second image with alpha blending in order to generate patterns
img = np.zeros((256,512)) alpha_blend = lambda a, b, alpha: a*(1-b*alpha) + b*alpha def draw_brush(img, pos, w, h, theta, power, smoothness, func=erf, alpha=1.): px, py = np.array(pos) im_h, im_w = img.shape round = lambda v : int(np.floor(v)) # subpixel position center = np.array([px-round(px), py-round(py)]) pimg = hat_img(w, h, theta, power, smoothness, center=center, func=func) ph, pw = pimg.shape # texture position x1 = round(px) - pw/2 y1 = round(py) - ph/2 x2 = x1 + pw y2 = y1 + ph # culling if x1 < -pw or y1 < -ph: return if x1 >= im_w or y1 >= im_h: return # clip ox1 = abs(min(x1, 0)) oy1 = abs(min(y1, 0)) ox2 = max(x2 - im_w, 0) oy2 = max(y2 - im_h, 0) # blit img[y1+oy1:y2-oy2, x1+ox1:x2-ox2] = \ alpha_blend( img[y1+oy1:y2-oy2, x1+ox1:x2-ox2], pimg[oy1:ph-oy2, ox1:pw-ox2], alpha) def draw_radial_brush(img, pos, radius, smoothness, func=erf, alpha=1.): draw_brush(img, pos, radius, radius, 0., 2., smoothness, func, alpha) # Draw some random particles np.random.seed(23) # random point on matrix rand_2d = lambda shape: [np.random.uniform(0, shape[1]), np.random.uniform(0, shape[0])] for i in range(50): draw_radial_brush(img, rand_2d(img.shape), np.random.uniform(5,100), np.random.uniform(1.0, 4.), func=scurve, alpha=np.random.uniform(0.3,1.)) img = 1. - img plt.figure(figsize=(10,5)) plt.imshow(img, interpolation='bilinear') plt.show()
or to render a variable width line, in a process commonly known as "dabbing" or "stamping"
img = np.zeros((256,512)) n = 50 pts = np.vstack([np.linspace(100, 500,n), np.linspace(120,200,n)]) R = np.linspace(2, 16, n) for p, r in zip(pts.T, R): draw_radial_brush(img, p, r, 1.1, func=np.tanh) img = 1. - img plt.imshow(img, interpolation='bilinear')
The same method can be used to generate "brushy" renderings of a movement path. Here we test it with a spring-damper system that follows a target along a linear path.
from scipy.interpolate import interp1d def spring_path( P, duration, kp, damp_ratio, dt ): kv = damp_ratio * 2. * np.sqrt(kp) m = P.shape[1] n = int(duration / dt) # equilibrium point ft = interp1d( np.linspace(1, m, m), P, 'linear' ) x_hat = ft(np.linspace(1, m, n)) # output y = np.zeros((2, n)) # current pos and velocity x = x_hat[:,0] v = np.zeros(2) # Euler integration for i in range(n): y[:,i] = x f = - v*kv + (x_hat[:,i] - x)*kp v += f*dt x += v*dt return y np.random.seed(10) P = np.random.uniform(10, 500, size=(2,5)) + np.array([60,0]).reshape(-1,1) X = spring_path(P, 2., 100., 0.1, 0.001) plt.figure(figsize=(7,5)) plt.plot(P[0,:], P[1,:], 'r:') plt.plot(X[0,:], X[1,:], 'k') plt.axis('equal') plt.gca().invert_yaxis() plt.show()
To add a sense of dynamism to the image, we vary the size of the brush with an inverse function of the istantaneous speed \[ w_t = w_{min} + (w_{max} - w_{min}) \mathrm{exp} \left( { -\frac{ \left| \dot{x}_t \right| }{\mu_x} } \right) \]
def brush_w(X, minw, maxw): # compute speed, no need for dt since we normalize dX = np.gradient(X, axis=1) s = np.sqrt(np.sum(dX**2, axis=0)) w = np.exp(-s / (np.mean(s)+1e-100)) return minw + (maxw-minw)*w img = np.zeros((512,512)) def brush_path(img, X, minw, maxw, ratio, theta, power, smooth=2.3): W = brush_w(X, minw, maxw) for p, w in zip(X.T, W): draw_brush(img, p, w, w*ratio, theta, power, smooth, alpha=0.2, func=np.tanh) plt.figure(figsize=(13,13)) plt.subplot(2,2,1) img = np.zeros((512,512)) brush_path(img, X, 5, 15, 0.5, -0.5, 5.) img = 1. - img plt.imshow(img, interpolation='bilinear') plt.subplot(2,2,2) img = np.zeros((512,512)) brush_path(img, X, 3, 20, 1., 0.0, 2.) img = 1. - img plt.imshow(img, interpolation='bilinear') plt.subplot(2,2,3) img = np.zeros((512,512)) brush_path(img, X, 10, 25, 1.0, 0.0, 2., 1.) img = 1. - img plt.imshow(img, interpolation='bilinear') plt.subplot(2,2,4) img = np.zeros((512,512)) brush_path(img, X, 8, 20, 0.8, 0.3, 5.) img = 1. - img plt.imshow(img, interpolation='bilinear') plt.show()
Created: 2017-04-17 Mon 14:56

