Title: Make a RadialMultiGradientBrush class for use with PIL in Python
The post Make a RadialGradientBrush class for use with PIL in Python explains how you can build a radial gradient brush and the post Fill a polygon with a RadialGradientBrush with PIL in Python shows how you can use it to fill polygons. This post expands the RadialGradientBrush class so it can shade between more than just two colors.
Colors and Offsets
The previous brush's constructor took as parameters start and end colors. The new brush's constructor takes colors and offsets parameters instead. The colors list holds the colors. The offsets list indicates on a 0 to 1 scale the fraction of the distance through the brush corresponding to each of the colors.
For example, consider the following code snippet.
colors = [
(255, 0, 0, 255), # Red
(255, 128, 0, 255), # Orange
(128, 255, 0, 255), # Yellow
(0, 255, 0, 255)] # Green
offsets = [0, 0.4, 0.6, 1]
The brush will start out red (at offset 0), shade to orange at 40% through the brush, shade to yellow at 80%, and finish at green.
Note that offsets usually start at 0 and end at 1, but that's not required. Each offset value should be at least as large as the value before it, however.
radial_multi_gradient_rectangle
You may recall that the pil_fill_polygon method first uses the brush to draw a shaded rectangle. It then uses the polygon to create a mask and uses the mask to paste the re tangle onto the destination image. The key to making new brushes is to modify the way the brush fills its rectangle.
To make this easier for the multi-gradient brush, I've moved the code that fills the rectangle into the following radial_multi_gradient_rectangle function. Some of it should look familiar from the earlier brush, but some deals with the multiple colors that this brush can use.
def radial_multi_gradient_rectangle(bounds, center, colors,
offsets=None, radius=None):
'''Make an image filled with a radial multi-gradient brush.'''
# Unpack parameters.
xmin, ymin, xmax, ymax = bounds
# Make an image to draw on.
wid = xmax - xmin
hgt = ymax - ymin
image = Image.new('RGBA', (wid, hgt), 'white')
pixels = image.load()
# If offsets was omitted, space colors equally.
if offsets is None:
offsets = [i / (len(colors) - 1) for i in range(len(colors))]
# If the radius was omitted, use the distance to the farthest corner.
if radius is None:
dists = [
math.dist(center, (xmin, ymin)),
math.dist(center, (xmax, ymin)),
math.dist(center, (xmin, ymax)),
math.dist(center, (xmax, ymax)),
]
radius = max(dists)
# Translate the center point.
center = (center[0] - xmin, center[1] - ymin)
# Convert offsets from values between 0 and 1 to distances.
offsets = [offset * radius for offset in offsets]
# Color the rectangle.
for x in range(wid):
for y in range(hgt):
# Get the distance from the center to the point.
point_dist = math.dist(center, (x, y))
# See which offset interval this is.
interval = len(offsets) - 1
for i in range(1, len(offsets)):
if offsets[i] >= point_dist:
interval = i
break
# Calculate the color.
fraction = \
(point_dist - offsets[interval - 1]) / \
(offsets[interval] - offsets[interval - 1])
dr = (colors[interval][0] - colors[interval-1][0]) * fraction
dg = (colors[interval][1] - colors[interval-1][1]) * fraction
db = (colors[interval][2] - colors[interval-1][2]) * fraction
da = (colors[interval][3] - colors[interval-1][3]) * fraction
# Set the color.
pixels[x, y] = (
round(colors[interval-1][0] + dr),
round(colors[interval-1][1] + dg),
round(colors[interval-1][2] + db),
round(colors[interval-1][3] + da))
# Return the result.
return image
The function first unpacks some parameters to make them easier to work with. It then creates an image to hold the rectangle much as before.
Next, if the offsets list was not specified, the code creates one with its values spaced evenly between 0 and 1.
If the brush's radius was not specified, the code sets it equal to the distance to the rectangle's farthest corner so the gradient shade across the full rectangle if the brush's center is at the center of the rectangle. If the center is somewhere else or if the shape you're filling doesn't fill the rectangle very well, you may want to set the radius explicitly.
So far the code has been working with drawing coordinates. For example, the rectangle has bounds xmin, ymin, xmax, and ymax. However, it now needs to draw on the new image, which has upper left coordinate (0, 0). TO correctly calculate distances from the pixels on the image to the center, the code translates the center point to its location on the new image.
The code then converts the offsets values from fractions between 0 and 1 to distances in pixels.
Next, the code loops through the image's pixels to set their colors much as before. This time, however, it calculates the colors a bit differently. It calculates the distance from a pixel to the brush's center as before. It then loops through the offsets list to see which interval contains the point. It then calculates the pixel's color by interpolating between the colors entry that corresponds to the offsets interval.
For example, if the pixel's distance is halfway between offsets[2] and offsets[3], then its color is halfway between colors[2] and colors[3].
After it has filled the rectangle, the function returns the resulting image.
RadialMultiGradientBrush
The following code shows the new RadialMultiGradientBrush class.
class RadialMultiGradientBrush(Brush):
'''Represents a radial gradient brush.'''
def __init__(self, center, colors, offsets=None, radius=None):
self.center = center
self.colors = colors
self.offsets = offsets
self.radius = radius
def make_image(self, bounds):
'''Make an image filled with the brush.'''
return radial_multi_gradient_rectangle(bounds, self.center,
self.colors, self.offsets, self.radius)
This brush is very simple. Its constructor simply saves its center, colors, offsets, and radius properties. The make_image function, which returns a filled rectangle, calls the radial_multi_gradient_rectangle function to do the heavy lifting.
That's all there is to it!
RadialGradientBrush
The new radial_multi_gradient_rectangle function also lets us simplify the previous 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 a colors list holding the center and outside colors.
# Set offsets = None so the colors are distributed evenly.
colors = [self.center_color, self.outer_color]
return radial_multi_gradient_rectangle(bounds, self.center,
colors, None, self.radius)
This class's make_image function creates colors and offsets lists for the brush's two colors and then calls radial_multi_gradient_rectangle.
Main Program
Here's how the program draws the star in the upper right of the picture at the top of this post.
bbox[0] += rect_wid + margin
bbox[2] += rect_wid + margin
points = get_star_points(-math.pi / 2, 7, 3, bbox)
center = ((bbox[0] + bbox[2]) / 2, bbox[1])
colors = [
(255, 0, 0, 255),
(255, 255, 255, 255),
(0, 0, 255, 255),
]
brush = RadialMultiGradientBrush(center, colors)
pil_fill_polygon(image, brush, points)
dr.polygon(points, fill=None, outline='black')
if show_centers: pil_draw_circle(dr, center, 3, None, 'black')
The code first adjusts the bounding box coordinates stored in bbox. It calls the get_star_points function (described in the post Draw stars in Python) to get the star's defining points.
Next, the code defines some colors and offsets, and uses them to create a RadialMultiGradientBrush. It calls the pil_fill_polygon method to draw the polygon filled with the brush. The code then draws the polygon's outline separately. Finally, if the show_centers variable defined earlier is True, the program draws a circle at the brush's center. (That variable was False when I took the program's screenshot, so the circles are not shown in the picture at the top of this post.)
Conclusion
The only thing changed in the call to the pil_fill_polygon method is the brush the program passes to it. The previous example used a RadialGradientBrush and this example uses a RadialMultiGradientBrush. As you'll see in my next post, you can define other brushes to fill polygons with other patterns without changing the drawing code.
Meanwhile, download the example to experiment with it and to see additional details.
|