[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky]
[Build Your Own Ray Tracer With Python]

[Beginning Database Design Solutions, Second Edition]

[Beginning Software Engineering, Second Edition]

[Essential Algorithms, Second Edition]

[The Modern C# Challenge]

[WPF 3d, Three-Dimensional Graphics with WPF and C#]

[The C# Helper Top 100]

[Interview Puzzles Dissected]

Title: Make a LinearGradientBrush class for use with PIL in Python

[Four polygons filled with linear gradient brushes using PIL and Python]

My earlier post Make a RadialMultiGradientBrush class for use with PIL in Python explains how to make a brush that smoothly shades from one color to another as points move away from a center point. A linear gradient brush shades between colors as points move along a line.

If you followed the earlier posts in this thread, then you can probably guess that the LinearGradientBrush class will come with a LinearMultigradientBrush class and that both of those classes will rely heavily on a linear_multi_gradient_rectangle function that fills a rectangle with a color gradient. That function is the key to how points are shaded. Unfortunately the geometry is a little more complicated than it is for radial gradient brushes.

Brush Geometry

The picture below shows a linear gradient brush that shades from a start point to an end point. It's easy to see how points along the arrow should be colored by the brush. It's less obvious how points off to the side should be colored.
[A linear gradient brush shades colors as points move along a line]

Consider the following picture where we're trying to find the color for point p. Let B be the vector from the brush's start point to its end point. Let A be the vector from the start point to point p.

[Finding the color for point p]
The dashed line moves perpendicularly from p to the vector B. The color for point p is given by the blue arrow along vector B. That vector is the projection of vector A onto vector B. Finding that vector isn't too hard, but we don't really need it, we just need its length.

The length of the projected vector is given by the simple formula A · B where · is the dot product of the two vectors. The easiest way to calculate A · B is to multiply the vectors' components and add them up. In two dimensions, that means A · B = A[0] * B[0] + A[1] + B[1].

That gives us point p's distance along the brush's gradient and from that we can calculate its color more or less the way we did with the radial gradient brush.

linear_multi_gradient_rectangle

The linear_multi_gradient_rectangle function shown in the following code fills a rectangle with a linear gradient much as the linear_multi_gradient_rectangle function did for radial gradient brushes.

def linear_multi_gradient_rectangle(bounds, start_point, end_point, colors, offsets=None): '''Fill the rectangle with a linear gradient brush.''' # Translate the start and end points. xmin, ymin, xmax, ymax = bounds start_point = (start_point[0] - xmin, start_point[1] - ymin) end_point = (end_point[0] - xmin, end_point[1] - ymin) # Unpack parameters. start_x, start_y = start_point end_x, end_y = end_point # 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))] # Get vector B, unit vector from start point toward end point. length = math.dist((start_x, start_y), (end_x, end_y)) B = [(end_x - start_x) / length, (end_y - start_y) / length] # Convert offsets from values between 0 and 1 to distances. offsets = [offset * length for offset in offsets] # Color the rectangle. for x in range(wid): for y in range(hgt): # Get vector A, from start point to current point p. A = [x - start_x, y - start_y] # Calculate the projection's distance along B. proj_dist = A[0] * B[0] + A[1] * B[1] # See which offset interval this is. interval = len(offsets) - 1 for i in range(1, len(offsets)): if offsets[i] >= proj_dist: interval = i break # Calculate the color. fraction = \ (proj_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 original brush sits somewhere in drawing space but we will draw on a new image that sits at the origin (0, 0), so the function first translates the brush's start and end points to work with the new image we will draw on. The code then unpacks some parameters and creates the image.

As for radial brushes, if the offsets list was omitted, the code creates a list that spaces the colors evenly.

Next, the code gets the vector B as in the picture and calculates its length. It uses the length to convert the offsets values from fractions between 0 and 1 to distances in pixels.

The code then enters the loop where it colors the image's pixels. For each pixel, it gets vector A in the picture and uses the dot product to calculate the length of the projection vector.

After this point, the calculation is similar to the one used for radial gradient brushes. See the earlier post for details.

Drawing Shapes

Here's how the program draws the star in the lower left of the picture at the top of this post.

# Polygon 3. bbox = [ margin, rect_hgt + 2 * margin, margin + rect_wid, 2 * rect_hgt + 2 * margin] points = get_star_points(-math.pi / 2, 7, 3, bbox) start_point = points[12] end_point = points[4] colors = [ (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255), (0, 255, 128, 255), (0, 255, 255, 255), (0, 128, 255, 255), (0, 0, 255, 255), ] offsets = [0, 0.4, 0.44, 0.48, 0.52, 0.56, 0.6, 1] brush = LinearMultiGradientBrush(start_point, end_point, colors, offsets) pil_fill_polygon(image, brush, points) dr.polygon(points, fill=None, outline='black') if show_points: pil_draw_circle(dr, start_point, 3, None, 'black') pil_draw_circle(dr, end_point, 3, None, 'black')

The code defines a bounding box for the star. It calls the get_star_points function (from the post Draw stars in Python) to get the star's vertices. It sets start_point and end_point to two of the star's points. It defines some colors and offsets, and uses them to create the brush.

After all that setup, the code calls pil_fill_polygon to fill the star. This is the same function used in previous examples and it didn't require any changes. The code simply passes that function a different brush object and the function uses the brush to generate the necessary shaded rectangle.

After filling the star, the code outlines it and, if show_points is True, it draws circles at the brush's end points.

Conclusion

Hopefully you're starting to see the pattern. A brush class provides a make_image function that creates a shaded rectangle. The pil_fill_polygon method uses a mask to fill a polygon with that rectangle.

Download the example to experiment with it and to see additional details.

© 2024 - 2025 Rocky Mountain Computer Consulting, Inc. All rights reserved.