How to gradient fill drawn shapes?

I researched quite a bit already, and all that I found was how to apply gradients to text generated with Pillow. However, I wanted to know how can I apply a gradient instead of a regular single color fill to a drawn shape (specifically a polygon).

image = Image.new('RGBA', (50, 50))
draw = ImageDraw.Draw(image)
draw.polygon([10, 10, 20, 40, 40, 20], fill=(255, 50, 210), outline=None)

Answer

Here’s my attempt to create something, which might fit your use case. It has limited functionality – specifically, only supports linear and radial gradients – and the linear gradient itself is also kind of limited. But, first of all, let’s see an exemplary output:

Exemplary output

Basically, there are two methods

linear_gradient(i, poly, p1, p2, c1, c2)

and

radial_gradient(i, poly, p, c1, c2)

which both get an Pillow Image object i, a list of vertices describing a polygon poly, start and stop colors for the gradient c1 and c2, and either two points p1 and p2 describing the direction of the linear gradient (from one vertex to a second one) or a single point p describing the center of the radial gradient.

In both methods, the initial polygon is drawn on an empty canvas of the final image’s size, solely using the alpha channel.

For the linear gradient, the angle between p1 and p2, is calculated. The drawn polygon is rotated by that angle, and cropped to get the needed dimensions for a proper linear gradient. That one is simply created by np.linspace. The gradient is rotated by the known angle, but in the opposite direction, and finally translated to fit the actual polygon. The gradient image is pasted on the intermediate polygon image to get the polygon with the linear gradient, and the result is pasted onto the actual image.

Limitation for the linear gradient: You better pick two vertices of the polygon “on opposite sides”, or better: Such that all points inside the polygon are within the virtual space spanned by that two points. Otherwise, the current implementation might fail, e.g. when choosing two neighbouring vertices.

The radial gradient method works slightly different. The maximum distance from p to all polygon vertices is determined. Then, for all points in an intermediate image of the actual image size, the distance to p is calculated and normalized by the calculated maximum distance. We get values in the range [0.0 ... 1.0] for all points inside the polygon. The values are used to calculate appropriate colors ranging from c1 to c2. As for the linear gradient, that gradient image is pasted on the intermediate polygon image, and the result is pasted onto the actual image.

Hopefully, the code is self-explanatory using the comments. But if there are questions, please don’t hesitate to ask!

Here’s the full code:

import matplotlib.pyplot as plt
import numpy as np
from PIL import Image, ImageDraw


# Draw polygon with linear gradient from point 1 to point 2 and ranging
# from color 1 to color 2 on given image
def linear_gradient(i, poly, p1, p2, c1, c2):

    # Draw initial polygon, alpha channel only, on an empty canvas of image size
    ii = Image.new('RGBA', i.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(ii)
    draw.polygon(poly, fill=(0, 0, 0, 255), outline=None)

    # Calculate angle between point 1 and 2
    p1 = np.array(p1)
    p2 = np.array(p2)
    angle = np.arctan2(p2[1] - p1[1], p2[0] - p1[0]) / np.pi * 180

    # Rotate and crop shape
    temp = ii.rotate(angle, expand=True)
    temp = temp.crop(temp.getbbox())
    wt, ht = temp.size

    # Create gradient from color 1 to 2 of appropriate size
    gradient = np.linspace(c1, c2, wt, True).astype(np.uint8)
    gradient = np.tile(gradient, [2 * h, 1, 1])
    gradient = Image.fromarray(gradient)

    # Paste gradient on blank canvas of sufficient size
    temp = Image.new('RGBA', (max(i.size[0], gradient.size[0]),
                              max(i.size[1], gradient.size[1])), (0, 0, 0, 0))
    temp.paste(gradient)
    gradient = temp

    # Rotate and translate gradient appropriately
    x = np.sin(angle * np.pi / 180) * ht
    y = np.cos(angle * np.pi / 180) * ht
    gradient = gradient.rotate(-angle, center=(0, 0),
                               translate=(p1[0] + x, p1[1] - y))

    # Paste gradient on temporary image
    ii.paste(gradient.crop((0, 0, ii.size[0], ii.size[1])), mask=ii)

    # Paste temporary image on actual image
    i.paste(ii, mask=ii)

    return i


# Draw polygon with radial gradient from point to the polygon border
# ranging from color 1 to color 2 on given image
def radial_gradient(i, poly, p, c1, c2):

    # Draw initial polygon, alpha channel only, on an empty canvas of image size
    ii = Image.new('RGBA', i.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(ii)
    draw.polygon(poly, fill=(0, 0, 0, 255), outline=None)

    # Use polygon vertex with highest distance to given point as end of gradient
    p = np.array(p)
    max_dist = max([np.linalg.norm(np.array(v) - p) for v in poly])

    # Calculate color values (gradient) for the whole canvas
    x, y = np.meshgrid(np.arange(i.size[0]), np.arange(i.size[1]))
    c = np.linalg.norm(np.stack((x, y), axis=2) - p, axis=2) / max_dist
    c = np.tile(np.expand_dims(c, axis=2), [1, 1, 3])
    c = (c1 * (1 - c) + c2 * c).astype(np.uint8)
    c = Image.fromarray(c)

    # Paste gradient on temporary image
    ii.paste(c, mask=ii)

    # Paste temporary image on actual image
    i.paste(ii, mask=ii)

    return i


# Create blank canvas with zero alpha channel
w, h = (800, 600)
image = Image.new('RGBA', (w, h), (0, 0, 0, 0))

# Draw first polygon with radial gradient
polygon = [(100, 200), (320, 130), (460, 300), (700, 500), (350, 550), (200, 400)]
point = (350, 350)
color1 = (255, 0, 0)
color2 = (0, 255, 0)
image = radial_gradient(image, polygon, point, color1, color2)

# Draw second polygon with linear gradient
polygon = [(500, 50), (650, 250), (775, 150), (700, 25)]
point1 = (700, 25)
point2 = (650, 250)
color1 = (255, 255, 0)
color2 = (0, 0, 255)
image = linear_gradient(image, polygon, point1, point2, color1, color2)

# Draw third polygon with linear gradient
polygon = [(50, 550), (200, 575), (200, 500), (100, 300), (25, 450)]
point1 = (100, 300)
point2 = (200, 575)
color1 = (255, 255, 255)
color2 = (255, 128, 0)
image = linear_gradient(image, polygon, point1, point2, color1, color2)

# Save image
image.save('image.png')
----------------------------------------
System information
----------------------------------------
Platform:      Windows-10-10.0.16299-SP0
Python:        3.9.1
Matplotlib:    3.4.0
NumPy:         1.20.2
Pillow:        8.1.2
----------------------------------------