0

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)
Okym
  • 239
  • 2
  • 5
  • 24
  • 2
    Does this answer your question? [Python Imaging Library (PIL) Drawing--Rounded rectangle with gradient](https://stackoverflow.com/questions/7787375/python-imaging-library-pil-drawing-rounded-rectangle-with-gradient) – HansHirse Mar 29 '21 at 08:50
  • Well for my project I'm generating multiple shapes and want each one of them to have a gradient fill, on the example you gave it seems to me as though it's generating the gradient and putting it into the entire image or is that incorrect? – Okym Apr 01 '21 at 18:15

2 Answers2

2

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
----------------------------------------
HansHirse
  • 18,010
  • 10
  • 38
  • 67
  • why did you have to write you own gradient fill function – Golden Lion Apr 12 '21 at 20:23
  • 1
    @GoldenLion What do you mean by "why"? I couldn't find a way to use Pillow inbuilt functions to get gradient filled shapes. So, I tried to find a relatively short solution to get the desired functionality. If you know any other, maybe even Pillow inbuilt methods, please share! – HansHirse Apr 12 '21 at 20:44
  • It seems like there should be a library to do a gradient polyfill. Are you still looking for a solution? – Golden Lion Apr 12 '21 at 21:18
  • This is exactly what I was looking for on how to do but couldn't find anywhere, really appreciate this! – Okym Apr 15 '21 at 13:49
1

I'm still not sure I understand your question fully. But it sounds like you want to have a bunch of shapes with their own gradients? One approach would be to generate the gradients of each shape separately then combine the shapes after the fact.

Piggy-backing off the answer already referred to by @HansHirse, you can do something like:

from PIL import Image, ImageDraw


def channel(i, c, size, startFill, stopFill):
    """calculate the value of a single color channel for a single pixel"""
    return startFill[c] + int((i * 1.0 / size) * (stopFill[c] - startFill[c]))


def color(i, size, startFill, stopFill):
    """calculate the RGB value of a single pixel"""
    return tuple([channel(i, c, size, startFill, stopFill) for c in range(3)])


def round_corner(radius):
    """Draw a round corner"""
    corner = Image.new("RGBA", (radius, radius), (0, 0, 0, 0))
    draw = ImageDraw.Draw(corner)
    draw.pieslice((0, 0, radius * 2, radius * 2), 180, 270, fill="blue")
    return corner


def apply_grad_to_corner(corner, gradient, backwards=False, topBottom=False):
    width, height = corner.size
    widthIter = range(width)

    if backwards:
        widthIter = reversed(widthIter)

    for i in range(height):
        gradPos = 0
        for j in widthIter:
            if topBottom:
                pos = (i, j)
            else:
                pos = (j, i)
            pix = corner.getpixel(pos)
            gradPos += 1
            if pix[3] != 0:
                corner.putpixel(pos, gradient[gradPos])

    return corner


def round_rectangle(size, radius, startFill, stopFill, runTopBottom=False):
    """Draw a rounded rectangle"""
    width, height = size
    rectangle = Image.new("RGBA", size)

    if runTopBottom:
        si = height
    else:
        si = width

    gradient = [color(i, width, startFill, stopFill) for i in range(si)]

    if runTopBottom:
        modGrad = []
        for i in range(height):
            modGrad += [gradient[i]] * width
        rectangle.putdata(modGrad)
    else:
        rectangle.putdata(gradient * height)

    origCorner = round_corner(radius)

    # upper left
    corner = origCorner
    apply_grad_to_corner(corner, gradient, False, runTopBottom)
    rectangle.paste(corner, (0, 0))

    # lower left
    if runTopBottom:
        gradient.reverse()
        backwards = True
    else:
        backwards = False

    corner = origCorner.rotate(90)
    apply_grad_to_corner(corner, gradient, backwards, runTopBottom)
    rectangle.paste(corner, (0, height - radius))

    # lower right
    if not runTopBottom:
        gradient.reverse()

    corner = origCorner.rotate(180)
    apply_grad_to_corner(corner, gradient, True, runTopBottom)
    rectangle.paste(corner, (width - radius, height - radius))

    # upper right
    if runTopBottom:
        gradient.reverse()
        backwards = False
    else:
        backwards = True

    corner = origCorner.rotate(270)
    apply_grad_to_corner(corner, gradient, backwards, runTopBottom)
    rectangle.paste(corner, (width - radius, 0))

    return rectangle


def get_concat_h(im1, im2):
    dst = Image.new("RGB", (im1.width + im2.width, im1.height))
    dst.paste(im1, (0, 0))
    dst.paste(im2, (im1.width, 0))
    return dst


def get_concat_v(im1, im2):
    dst = Image.new("RGB", (im1.width, im1.height + im2.height))
    dst.paste(im1, (0, 0))
    dst.paste(im2, (0, im1.height))
    return dst


img1 = round_rectangle((200, 200), 70, (255, 0, 0), (0, 255, 0), True)
img2 = round_rectangle((200, 200), 70, (0, 255, 0), (0, 0, 255), True)


get_concat_h(img1, img2).save("testcombo.png")

The result looks something like this:

combined image

The only "new" stuff comes in at the end: the images are just combined. If you want to get wild you can rotate the individual shapes or allow them to overlap (by tweaking the position of the images in get_concat_h + playing around with the final image size.)

webelo
  • 1,646
  • 1
  • 14
  • 32