Title: Make a RadialGradientBrush class for use with PIL in Python
This is the first in a series of posts about gradient brushes.
One of the omissions in PIL is the ability to draw with gradient brushes. You could loop through the pixels that you need to color and assign them colors, but I wanted to implement a slightly more general solution. To do that, I've created a Brush parent class and a RadialGradientBrush subclass. Then drawing methods can use the brush to fill various shapes.
RadialGradientBrush
Here's the Brush parent class.
import math
from PIL import Image, ImageTk
class Brush:
def make_image(self, bounds):
'''Make an image filled with the brush.'''
raise NotImplementedError('Subclass must implement make_image')
This isn't a big deal. It just defines the make_image method that subclasses should provide.
The following code shows the RadialGradientBrush class.
class RadialGradientBrush(Brush):
'''Represents a radial gradient brush.'''
def __init__(self, center, center_color, outer_color, radius=None):
self.center = center
self.center_color = center_color
self.outer_color = outer_color
self.radius = radius
def make_image(self, bounds):
'''Make an image filled with the brush.'''
# Make an image to draw on.
wid = bounds[2] - bounds[0]
hgt = bounds[3] - bounds[1]
image = Image.new('RGBA', (wid, hgt), 'white')
pixels = image.load()
# Unpack parameters.
xmin, ymin, xmax, ymax = bounds
center_r, center_g, center_b, center_a = self.center_color
end_r, end_g, end_b, end_a = self.outer_color
# If the radius was omitted, use the distance to the farthest corner.
radius = self.radius
if radius is None:
dists = [
math.dist(self.center, (xmin, ymin)),
math.dist(self.center, (xmax, ymin)),
math.dist(self.center, (xmin, ymax)),
math.dist(self.center, (xmax, ymax)),
]
radius = max(dists)
# Translate the center point.
center = (
self.center[0] - xmin,
self.center[1] - ymin)
# Get unit color component differences across the maximum distance.
dr = (end_r - center_r) / radius
dg = (end_g - center_g) / radius
db = (end_b - center_b) / radius
da = (end_a - center_a) / radius
# Color the rectangle.
for x in range(0, wid):
for y in range(0, hgt):
# Get the distance from the center to the point.
point_dist = math.dist(center, (x, y))
# Set the color.
pixels[x, y] = (
round(center_r + dr * point_dist),
round(center_g + dg * point_dist),
round(center_b + db * point_dist),
round(center_a + da * point_dist))
# Return the image.
return image
The constructor simply saves the brush's parameters.
The make_image function is where the fun begins. This method creates an image filled with the radial gradient brush. One of the more confusing things about this method is that it is filling a rectangle in some drawing coordinate space (say, with 100 <= X <= 200 and 50 <= Y <= 100), but images have upper left coordinates (0, 0). You'll see shortly how the code translates the pixels' coordinates to make that work out.
The function calculates the image's width and height, and creates a new PIL image. It calls the image's load method so we can work efficiently with the image's pixels.
Next, the code unpacks some variables to make them easier to work with. Then, if the brush's radius parameter is undefined, the code sets the local radius value equal to the distance from the center point to the farthest corner of the rectangle. That way the brush will shade smoothly to every point in the rectangle. If radius is smaller than this distance, the shading will stop at points farther away from the brush's center point.
The function then translates the center point so it is positioned correctly for the image located at (0, 0).
The code then calculates the incremental color components: the amounts by which the colors' red, green, blue, and alpha color components should change while moving from the center color to the outside color across the brush's radius.
Note that the calculations will continue for points outside of the brush's radius. For example, suppose the brush shades from yellow (255, 255, 0, 0) to orange (255, 128, 0, 0). Points that are farther away will move past orange and eventually become red (255, 0, 0, 0). Fortunately PIL protects itself against nonsense pixel colors like (300, 0, 0, 0) or (0, 0, -10, 0). For example, colors beyond red stop at red with no harm done.
Now the code loops over the image's pixels. For each pixel, the code calculates the distance from the pixel to the brush's center point. (Note that this is the pixel in the image's coordinate system and the translated center point that works with those coordinates.) The code multiplies the distance by the incremental color components and sets the pixel's color.
After it finishes coloring the image's pixels, the function returns the result.
pil_fill_rectangle
Now that we have a bush class, we need a way to use it to draw. The following pil_fill_rectangle uses the brush to fill a rectangle on a PIL image.
def pil_fill_rectangle(image, brush, bounds):
'''Fill the rectangle with the brush.'''
# Get the filled rectangle.
rect_image = brush.make_image(bounds)
# Draw the filled rectangle on the PIL image.
image.paste(rect_image, (bounds[0], bounds[1]))
This method's bounds parameter indicates where in the image we should draw the rectangle. The code calls the brush's make_image function passing it the rectangle's bounds to get an image filed with the brush's gradient.
Next, the code pastes the image onto the destination rectangle defined by bounds.
That's all there is to it!
Drawing Rectangles
Here's how the program draws the rectangle in the upper left of the picture at the top of this post.
def fill_rectangles(self):
# Get the canvas's dimensions.
self.canvas.update()
wid = self.canvas.winfo_width()
hgt = self.canvas.winfo_height()
# Make an image to draw on.
image = Image.new('RGBA', (wid, hgt), 'wheat')
dr = ImageDraw.Draw(image)
margin = 10
rect_wid = round((wid - 4 * margin) / 2)
rect_hgt = round((hgt - 4 * margin) / 2)
show_centers = False
# Rectangle 1.
xmin = ymin = margin
xmax = xmin + rect_wid
ymax = ymin + rect_hgt
bounds = [xmin, ymin, xmax, ymax]
center = ((xmin + xmax) / 2, (ymin + ymax) / 2)
center_color = (255, 0, 0, 255)
outer_color = (0, 255, 0, 255)
brush = RadialGradientBrush(center, center_color, outer_color)
pil_fill_rectangle(image, brush, bounds)
if show_centers: pil_draw_circle(dr, center, 3, None, 'black') # For debugging.
The program first gets the width and height of its canvas widget and creates an image to fit. It sets a few drawing parameters and then starts drawing the rectangle.
It sets the bounds for the rectangle. It then places the brush's center point at the center of the rectangle. Note that this is in the drawing coordinate system. Some drawing systems use a 0.0 - 1.0 coordinate system to define things like a brush's center point and radius, but I decided things were confusing enough already.
The code defines the center color (red) and outer color (green), and then creates the brush. The program then calls pil_fill_rectangle to fill the rectangle with the brush. This code doesn't specify the brush's radius so the bush shades all the way to the rectangle's corners.
If the show_centers variable is True, the code draws a circle showing where the brush's center is mostly for debugging purposes.
Download the code to see how the program draws its other rectangles. In the upper right rectangle, the brush's center point is at the top of the rectangle. The lower left rectangle uses a relatively small brush radius so the rectangle's outer pixels are green. The lower right rectangle uses a brush with center point in the rectangle's upper left corner.
Conclusion
This may seem like a lot of work just to shade a rectangle, but it'll pay off shortly. In my next post, I'll show how you can use a radial gradient brush to fill polygons instead of just rectangles.
Download the example to experiment with it and to see additional details.
|