python-pillow / Pillow

Python Imaging Library (Fork)
https://python-pillow.org
Other
12.17k stars 2.22k forks source link

Why does PIL mask make gif look like there's a filter? #7073

Closed linustws closed 1 year ago

linustws commented 1 year ago

Reduced the original problem with this spike test.

from PIL import Image, ImageDraw

DIMENSIONS = (500, 500)
CENTER = (250, 250)
RADIUS = 225
CENTER_CIRCLE_RADIUS = 100

# mask
mask_img = Image.new('L', DIMENSIONS, color=0)
mask_draw = ImageDraw.Draw(mask_img)
mask_draw.ellipse((CENTER[0] - RADIUS, CENTER[1] - RADIUS, CENTER[0]
                   + RADIUS, CENTER[1] + RADIUS), fill=255)

# center circle mask
center_circle_mask_size = (CENTER_CIRCLE_RADIUS * 2, CENTER_CIRCLE_RADIUS * 2)
center_circle_mask_img = Image.new('L', center_circle_mask_size, color=0)
center_circle_mask_draw = ImageDraw.Draw(center_circle_mask_img)
center_circle_mask_draw.ellipse((0, 0) + center_circle_mask_size, fill=255)

center_circle_img = Image.open("images/joy.png")
spinner_img = Image.new('RGB', DIMENSIONS, color=(0, 0, 0))
spinner_img.putalpha(mask_img)
center_circle_img.putalpha(center_circle_mask_img)
spinner_img.paste(center_circle_img, (
    int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) / 2)), center_circle_img)
spinner_img.save("spike_output.gif")

i realised that this line of code spinner_img.putalpha(mask_img) causes the gif to have a filter effect.

Original picture:

original picture

With spinner_img.putalpha(mask_img):

with mask

Without spinner_img.putalpha(mask_img):

without mask

I also realised this issue only happens if I try to save it as a gif. If i save it as a png file it displays the image as the original, regardless of whether that line of code is present.

My intention is to use this image as the center of a spinwheel gif. Is there any way to retain the image quality even as a gif and yet use the mask to crop the background? Right now even if it's saved as a gif it can still show the original image quality but only without the line of code spinner_img.putalpha(mask_img).

wiredfool commented 1 year ago

GIFs are palettized images with 256 colors per palette -- PNGs have the capability to have arbitrary RGB triples. What you're seeing is that the image has been quantized and similar colors have been flattened to one color.

There are a few approaches to getting better quality out of GIFs, either by dithering, optimized palettes, or using disposal options to allow multiple palettes to show at the same time.

linustws commented 1 year ago

@wiredfool I've played with the disposal arg and it doesn't make a difference whether 0,1,2 or 3. What do the other options mean? and can it display the image as the original image?

wiredfool commented 1 year ago

There are options for dithering and quantization methods when you do the conversion directly, rather than implicitly when converting to a gif.

img = img.quantize() -- https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.quantize

You might get better results from libimagequant as a method, and it looks like FloydSteinberg is the default dither. I don't see it in the posted file, so that might be an option to try.

The disposal options are something that is basically advanced gif hacking -- this is one example of it, but I've definitely seen other pages where it's more spelled out in terms of how to do it with arbitrary gifs.

This is also covered in the wiki: https://en.wikipedia.org/wiki/GIF#Palettes

radarhere commented 1 year ago

To simplify matters a bit, if you remove the mask code, I think you will agree that

from PIL import Image

Image.open("images/joy.png").save("spike_output.gif")

still produces the change in colors.

spike_output

Fundamentally, your original image has 19272 different colors. As has been said, Pillow has to try and change that into just 256 colors when saving as a GIF.

See what you think of this code, using quantize() as suggested in the last comment.

from PIL import Image, ImageDraw

DIMENSIONS = (500, 500)
CENTER = (250, 250)
RADIUS = 225
CENTER_CIRCLE_RADIUS = 100

# Limit the colors in the image to 254, so that there are 2 colors left, 1 for transparency, and 1 for the black circle
center_circle_img = Image.open("images/joy.png").convert("RGB").quantize(254)

# Find a color that isn't used yet to be the transparent background. That is the first color
for i in range(255):
    color = (i, 255, 255)
    if color not in center_circle_img.palette.colors:
        transparent_background = center_circle_img.palette.getcolor(color)
        break

spinner_img = Image.new("P", DIMENSIONS, transparent_background)
spinner_img.info["transparency"] = transparent_background
spinner_img.paste(center_circle_img, (int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) / 2)))
spinner_img.putpalette(center_circle_img.palette)

# mask
mask_img = Image.new('L', DIMENSIONS)
mask_draw = ImageDraw.Draw(mask_img)
# Calling spinner_img.palette.getcolor((0, 0, 0)) allocates the second color to be black
mask_draw.ellipse((CENTER[0] - RADIUS, CENTER[1] - RADIUS, CENTER[0] + RADIUS, CENTER[1] + RADIUS), fill=spinner_img.palette.getcolor((0, 0, 0)))
mask_draw.ellipse((CENTER[0] - CENTER_CIRCLE_RADIUS, CENTER[1] - CENTER_CIRCLE_RADIUS, CENTER[0] + CENTER_CIRCLE_RADIUS, CENTER[1] + CENTER_CIRCLE_RADIUS), fill=0)

spinner_img.paste(mask_img, mask=mask_img)
spinner_img.save("spike_output.gif")

spike_output

linustws commented 1 year ago

@wiredfool i tried the quantize with libimagequant as the method but there wasn't much difference. I then tried pngquant which did help reduce some posterization but it's still not the same image quality as the original. I then tried gifski which does output a gif that looks like the original image (desired image quality) but had to save all frames as png files first and had trouble calling all files using the wildcard using subprocess (entering the gifski command in terminal works though), but i also could not adjust the duration for each frame unlike pil save.

linustws commented 1 year ago

@radarhere thanks!! that image quality of the solution u posted is what i was trying to acheive, although im not really sure what it is doing. Im also not sure whether i can extend this to my original project where the black ring part is actually a spinwheel with colors and it has a background. So im not sure how it would work if i make the mode of the spinner_img to be 'P' instead of 'RGB', which is what it currently is. Like do i need to reserve more colors instead of just 2? Does it depend on the number of colors pie slices i.e. if theres 8 pie slices, must i reserve 9 colors? (1 for transparency and 8 for pie slices) And does it depend on the colors of the background too?

Current gif:

spinner

radarhere commented 1 year ago

Your code was trying to save an RGBA image as a GIF. GIF images are limited to 256 colors because they use a palette, which we call P mode. Rather than composing an RGBA image and then handing it to GifImagePlugin to convert it to P mode, my code constructed the image in P mode from the beginning. quantize(254) converted the image into P mode with 2 colors left, and then I used those two colors, one for the black circle and one for the transparency.

This was a solution to the specific problem you originally posted. The fact that you now don't need transparency in the end might actually make things easier. If you would like help with this new specific situation, consider posting the code and input images.

I don't know why you're trying to save the image as a GIF, but if it is just so you can display an animated image on a website, know that PNGs can also be animated. The browser support for them is good, and they don't have the 256 color restriction. So you might find it easier to use spinner_img.save("spike_output.png", save_all=True)

linustws commented 1 year ago

@radarhere im trying to save it as a gif so that i can send it as an animation through a telegram bot. i think telegram doesnt support apng as of now. my code takes in the user input as options, 1. a randomly picked img from a folder for the center of the spinwheel, 2. a cat cover image for the center to cover 1., imports components like the mask images, outline images and triangle pointer image (i thought importing them would be faster than always having to create them from scratch) and creates the gif frame by frame. i've set the angles and durations too. I think right now my spinner img is rgb cos it has colored pie slices. im not quite sure how to incorporate the 'P' mode for it. i think my biggest issue regarding transparency is the masking of images to crop circular images (for the spinner and the center images). here is the code for the spinner/spinwheel:

import math
import os
import random

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont

from components_creator import create_images

DIMENSIONS = (500, 500)
CENTER = (250, 250)
RADIUS = 225
DIAMETER = RADIUS * 2
CENTER_CIRCLE_RADIUS = 100
NUM_SPIN_FRAMES = 100
NUM_BLINK_FRAMES = 50
NUM_TOTAL_FRAMES = NUM_SPIN_FRAMES + NUM_BLINK_FRAMES
# frame durations
DURATIONS = [1000, 300, 200, 130, 80, 60, 40, 30, 25, 20] \
            + [20 for _ in range(NUM_SPIN_FRAMES - 20)] + [20, 25, 30, 40, 60, 80, 130, 200, 300, 1000] \
            + [100 for _ in range(NUM_BLINK_FRAMES)]  # Fastest 20

# import components
try:
    MASK_IMG = Image.open('mask.png')
    CENTER_CIRCLE_MASK_IMG = Image.open('center_circle_mask.png')
    CIRCLE_OUTLINE_IMG = Image.open('circle_outline.png')
    CENTER_CIRCLE_OUTLINE_IMG = Image.open('center_circle_outline.png')
    TRIANGLE_IMG = Image.open('triangle.png')
except FileNotFoundError as e:
    create_images(DIMENSIONS, CENTER, RADIUS, CENTER_CIRCLE_RADIUS)
    MASK_IMG = Image.open('mask.png')
    CENTER_CIRCLE_MASK_IMG = Image.open('center_circle_mask.png')
    CIRCLE_OUTLINE_IMG = Image.open('circle_outline.png')
    CENTER_CIRCLE_OUTLINE_IMG = Image.open('center_circle_outline.png')
    TRIANGLE_IMG = Image.open('triangle.png')
PASTEL_COLORS = [(220, 214, 255), (214, 240, 255), (222, 255, 239), (255, 250, 240), (255, 237, 237),
                 (255, 222, 222), (247, 246, 207), (182, 216, 242), (244, 207, 223), (87, 132, 186),
                 (154, 200, 235), (204, 212, 191), (231, 203, 169), (238, 186, 178), (245, 243, 231),
                 (245, 226, 228), (245, 191, 210), (229, 219, 156), (208, 188, 172), (190, 180, 197),
                 (230, 165, 126), (33, 139, 130), (154, 217, 219), (229, 219, 217), (152, 212, 187),
                 (235, 150, 170), (198, 201, 208), (229, 179, 187), (196, 116, 130), (249, 150, 139),
                 (118, 205, 205), (123, 146, 170), (228, 206, 224), (161, 93, 152), (220, 130, 143),
                 (247, 206, 118), (140, 115, 134), (156, 147, 89), (165, 114, 131), (232, 213, 149)]

class SpinnerGifMaker:

    def __init__(self, options):
        random.shuffle(options)
        self.options = options
        # 200 x 200 pic
        self.center_circle_cover_img = Image.open("images/cover/cat.png")
        folder_path = "images/joy"
        file_list = os.listdir(folder_path)
        image_list = [filename for filename in file_list if filename.endswith(('.png', '.jpg', '.jpeg'))]
        random_image = random.choice(image_list)
        image_path = os.path.join(folder_path, random_image)
        self.center_circle_img = Image.open(image_path).resize((200, 200))
        # self.center_circle_img = Image.open("images/joy/joy_jc.png")
        self.colors = random.sample(PASTEL_COLORS, len(options))
        first_half = [0, -2, -5, -10, -15, -20, -30, -50, -70, -100] + [i * -150 - 150 for i in
                                                                        range(int((NUM_SPIN_FRAMES - 20) / 2))]
        second_half = [i * -150 + 6000 for i in range(int((NUM_SPIN_FRAMES - 20) / 2))] + [100, 70, 50, 30, 20, 15,
                                                                                           10, 5, 2, 0]
        angles = first_half + second_half
        # start and end at unpredictable positions
        start_offset = random.randint(0, 359)
        end_offset = random.randint(0, 359)
        sector_first_half = [angle - start_offset for angle in angles[:50]]
        sector_second_half = [angle - end_offset for angle in angles[50:]]
        self.sector_angles = sector_first_half + sector_second_half
        self.image_angles = angles

        frame_list = []
        for i in range(NUM_TOTAL_FRAMES):
            frame = self.getSpinnerFrame(i)
            frame_list.append(frame)
        frame_list[0].save('spinner.gif', format='GIF', append_images=frame_list[1:], save_all=True,
                           duration=DURATIONS, disposal=2, loop=0)

    def getSpinnerFrame(self, frame_number):
        bg_img = Image.open("images/bg/strawberry.png")
        spinner_img = Image.new('RGB', DIMENSIONS, color=(0, 0, 0))
        # Add color pie slices
        spinner_draw = ImageDraw.Draw(spinner_img, 'RGBA')
        num_sectors = len(self.options)
        for i, option in enumerate(self.options):
            start_angle = i * (360 / num_sectors)
            end_angle = (i + 1) * (360 / num_sectors)
            color = self.colors[i]
            fill = (255,)
            #draw pie slices
            spinner_draw.pieslice(xy=((CENTER[0] - RADIUS, CENTER[1] - RADIUS), (CENTER[0] + RADIUS, CENTER[1] +
                                                                                 RADIUS)),
                                  start=start_angle,
                                  end=end_angle, fill=color + fill, outline='black')

            # Add text options
            font = ImageFont.truetype("arial.ttf", 30)
            _, _, text_width, text_height = spinner_draw.textbbox((0, 0), option, font=font, anchor="lt")
            sector_center_angle = (start_angle + end_angle) / 2
            sector_center_x = CENTER[0] + (RADIUS + CENTER_CIRCLE_RADIUS) * 0.5 * math.cos(sector_center_angle *
                                                                                           math.pi / 180)
            sector_center_y = CENTER[1] + (RADIUS + CENTER_CIRCLE_RADIUS) * 0.5 * math.sin(sector_center_angle *
                                                                                           math.pi / 180)
            text_angle = 180 - sector_center_angle
            text_img = Image.new('RGBA', (text_width, text_height), color=(0, 0, 0, 0))
            text_draw = ImageDraw.Draw(text_img)
            text_draw.text((0, 0), option, fill=(0, 0, 0), font=font, anchor="lt")
            text_img = text_img.rotate(text_angle, expand=True)
            text_width, text_height = text_img.size
            text_center_x = sector_center_x - text_width / 2
            text_center_y = sector_center_y - text_height / 2
            spinner_img.paste(text_img, (int(text_center_x), int(text_center_y)), text_img)

        center_circle_cover_img = self.center_circle_cover_img.copy()
        center_circle_img = self.center_circle_img.copy()
        # Rotate
        if frame_number < NUM_SPIN_FRAMES:
            spinner_img = spinner_img.rotate(self.sector_angles[frame_number], center=CENTER)
            center_circle_cover_img = center_circle_cover_img.rotate(self.image_angles[frame_number], center=(100, 100))
            center_circle_img = center_circle_img.rotate(self.image_angles[frame_number], center=(100, 100))
        # Stop rotation
        else:
            spinner_img = spinner_img.rotate(self.sector_angles[-1], center=CENTER)
            center_circle_cover_img = center_circle_cover_img.rotate(self.image_angles[-1], center=(100, 100))
            center_circle_img = center_circle_img.rotate(self.image_angles[-1], center=(100, 100))

        bg_img.paste(spinner_img, (0, 0), MASK_IMG)

        # created outline image cos the spinner outline is quite wonky
        bg_img.paste(CIRCLE_OUTLINE_IMG, (int((DIMENSIONS[0] - RADIUS * 2) / 2),
                     int((DIMENSIONS[1] - RADIUS * 2) /
                         2)), CIRCLE_OUTLINE_IMG)

        bg_img.paste(center_circle_img, (
            int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) /
                                                                     2)), CENTER_CIRCLE_MASK_IMG)

        # center circle cover mask that decreases in opacity
        center_circle_cover_mask_size = (CENTER_CIRCLE_RADIUS * 2, CENTER_CIRCLE_RADIUS * 2)
        center_circle_cover_mask_img = Image.new('L', center_circle_cover_mask_size, color=0)
        center_circle_cover_mask_draw = ImageDraw.Draw(center_circle_cover_mask_img)
        if frame_number < 40:
            fill = 255
        elif frame_number >= 60:
            fill = 0
        else:
            fill = int((NUM_TOTAL_FRAMES - frame_number) / NUM_TOTAL_FRAMES * 255)
        center_circle_cover_mask_draw.ellipse((0, 0) + center_circle_cover_mask_size, fill=fill)

        # comment out to see without the cover image
        bg_img.paste(center_circle_cover_img, (
            int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) /
                                                                     2)), center_circle_cover_mask_img)

        # created outline image cos no center circle outline
        bg_img.paste(CENTER_CIRCLE_OUTLINE_IMG, (
            int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) /
                                                                     2)), CENTER_CIRCLE_OUTLINE_IMG)

        # Add blink effect to triangle image on last frame
        if frame_number < NUM_SPIN_FRAMES or frame_number % 2 == 1:
            bg_img.paste(TRIANGLE_IMG, mask=TRIANGLE_IMG)

        return bg_img

# for testing
SpinnerGifMaker(["hi", "play", "sleep", "run", "dance", "eat", "fly", "study"])

and this is the code for creating the component images:

from PIL import Image
from PIL import ImageDraw

def create_images(spinner_dimensions, spinner_center, spinner_radius, center_circle_radius):
    # mask
    mask_img = Image.new('L', spinner_dimensions, color=0)
    mask_draw = ImageDraw.Draw(mask_img)
    mask_draw.ellipse((spinner_center[0] - spinner_radius, spinner_center[1] - spinner_radius, spinner_center[0]
                       + spinner_radius, spinner_center[1] + spinner_radius), fill=255)
    mask_img.save('mask.png')

    # center circle mask
    center_circle_mask_size = (center_circle_radius * 2, center_circle_radius * 2)
    center_circle_mask_img = Image.new('L', center_circle_mask_size, color=0)
    center_circle_mask_draw = ImageDraw.Draw(center_circle_mask_img)
    center_circle_mask_draw.ellipse((0, 0) + center_circle_mask_size, fill=255)
    center_circle_mask_img.save('center_circle_mask.png')

    # circle outline
    circle_outline_img = Image.new('RGBA', spinner_dimensions, (0, 0, 0, 0))
    circle_outline_draw = ImageDraw.Draw(circle_outline_img)
    circle_outline_draw.ellipse((0, 0) + (spinner_radius * 2, spinner_radius * 2),
                                fill=None, outline='black')
    circle_outline_img.save('circle_outline.png')

    # center circle outline
    center_circle_outline_img = Image.new('RGBA', spinner_dimensions, (0, 0, 0, 0))
    center_circle_outline_draw = ImageDraw.Draw(center_circle_outline_img)
    center_circle_outline_draw.ellipse((0, 0) + center_circle_mask_size,
                                fill=None, outline=(0, 0, 0, 255))
    center_circle_outline_img.save('center_circle_outline.png')

    # Triangle pointer
    triangle_img = Image.new('RGBA', spinner_dimensions, (0, 0, 0, 0))
    draw = ImageDraw.Draw(triangle_img)
    triangle_height = 30
    triangle_base = 20
    triangle_top = (spinner_center[0], spinner_center[1] - spinner_radius + triangle_height)
    triangle_left = (spinner_center[0] - triangle_base / 2, spinner_center[1] - spinner_radius - 10)
    triangle_right = (
        spinner_center[0] + triangle_base / 2, spinner_center[1] - spinner_radius - 10)
    draw.polygon([triangle_top, triangle_left, triangle_right], fill='red', outline='black')
    triangle_img.save('triangle.png')

by right it chooses a random image to 'reveal' everytime but to check the image quality i just use this:

joy_jc

this are the center circle cover img and the background img:

cat

strawberry

radarhere commented 1 year ago

Ok, see what you think of this.

16 colors seemed to be enough for the strawberry background. Then 10 colors seemed to be enough for the spinner. The circle outline only has 2 colors. Sometimes the triangle is used, so that needs another color. The rest of the colors can be used for the image in the center. Rather than trying to split colors between both center images, I combined the two center images first, and then quantized the result.

import math
import os
import random

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont

from components_creator import create_images

DIMENSIONS = (500, 500)
CENTER = (250, 250)
RADIUS = 225
DIAMETER = RADIUS * 2
CENTER_CIRCLE_RADIUS = 100
NUM_SPIN_FRAMES = 100
NUM_BLINK_FRAMES = 50
NUM_TOTAL_FRAMES = NUM_SPIN_FRAMES + NUM_BLINK_FRAMES
# frame durations
DURATIONS = [1000, 300, 200, 130, 80, 60, 40, 30, 25, 20] \
            + [20 for _ in range(NUM_SPIN_FRAMES - 20)] + [20, 25, 30, 40, 60, 80, 130, 200, 300, 1000] \
            + [100 for _ in range(NUM_BLINK_FRAMES)]  # Fastest 20

# import components
try:
    MASK_IMG = Image.open('mask.png')
    CENTER_CIRCLE_MASK_IMG = Image.open('center_circle_mask.png')
    CIRCLE_OUTLINE_IMG = Image.open('circle_outline.png')
    CENTER_CIRCLE_OUTLINE_IMG = Image.open('center_circle_outline.png')
    TRIANGLE_IMG = Image.open('triangle.png')
except FileNotFoundError as e:
    create_images(DIMENSIONS, CENTER, RADIUS, CENTER_CIRCLE_RADIUS)
    MASK_IMG = Image.open('mask.png')
    CENTER_CIRCLE_MASK_IMG = Image.open('center_circle_mask.png')
    CIRCLE_OUTLINE_IMG = Image.open('circle_outline.png')
    CENTER_CIRCLE_OUTLINE_IMG = Image.open('center_circle_outline.png')
    TRIANGLE_IMG = Image.open('triangle.png')
PASTEL_COLORS = [(220, 214, 255), (214, 240, 255), (222, 255, 239), (255, 250, 240), (255, 237, 237),
                 (255, 222, 222), (247, 246, 207), (182, 216, 242), (244, 207, 223), (87, 132, 186),
                 (154, 200, 235), (204, 212, 191), (231, 203, 169), (238, 186, 178), (245, 243, 231),
                 (245, 226, 228), (245, 191, 210), (229, 219, 156), (208, 188, 172), (190, 180, 197),
                 (230, 165, 126), (33, 139, 130), (154, 217, 219), (229, 219, 217), (152, 212, 187),
                 (235, 150, 170), (198, 201, 208), (229, 179, 187), (196, 116, 130), (249, 150, 139),
                 (118, 205, 205), (123, 146, 170), (228, 206, 224), (161, 93, 152), (220, 130, 143),
                 (247, 206, 118), (140, 115, 134), (156, 147, 89), (165, 114, 131), (232, 213, 149)]

class SpinnerGifMaker:

    def __init__(self, options):
        random.shuffle(options)
        self.options = options
        # 200 x 200 pic
        self.center_circle_cover_img = Image.open("images/cover/cat.png")
        folder_path = "images/joy"
        file_list = os.listdir(folder_path)
        image_list = [filename for filename in file_list if filename.endswith(('.png', '.jpg', '.jpeg'))]
        random_image = random.choice(image_list)
        image_path = os.path.join(folder_path, random_image)
        self.center_circle_img = Image.open(image_path).resize((200, 200))
        # self.center_circle_img = Image.open("images/joy/joy_jc.png")
        self.colors = random.sample(PASTEL_COLORS, len(options))
        first_half = [0, -2, -5, -10, -15, -20, -30, -50, -70, -100] + [i * -150 - 150 for i in
                                                                        range(int((NUM_SPIN_FRAMES - 20) / 2))]
        second_half = [i * -150 + 6000 for i in range(int((NUM_SPIN_FRAMES - 20) / 2))] + [100, 70, 50, 30, 20, 15,
                                                                                           10, 5, 2, 0]
        angles = first_half + second_half
        # start and end at unpredictable positions
        start_offset = random.randint(0, 359)
        end_offset = random.randint(0, 359)
        sector_first_half = [angle - start_offset for angle in angles[:50]]
        sector_second_half = [angle - end_offset for angle in angles[50:]]
        self.sector_angles = sector_first_half + sector_second_half
        self.image_angles = angles

        frame_list = []
        for i in range(NUM_TOTAL_FRAMES):
            frame = self.getSpinnerFrame(i)
            frame_list.append(frame)
        frame_list[0].save('spinner.gif', format='GIF', append_images=frame_list[1:], save_all=True,
                           duration=DURATIONS, disposal=2, loop=0)

    def paste(self, bg_img, im, box=None, mask=None):
        # To combine one P image with another
        # add all of the new colors to the palette of the first image
        remap = {}
        for color, i in im.palette.colors.items():
            remap[i] = bg_img.palette.getcolor(color)
        # then update the palette indexes in the new image
        for x in range(im.width):
            for y in range(im.height):
                im.putpixel((x, y), remap[im.getpixel((x, y))])
        # and paste
        bg_img.paste(im, box, mask)

        # Return the number of free colors left
        return 256 - len(bg_img.palette.colors)

    def getSpinnerFrame(self, frame_number):
        # 16 colors
        bg_img = Image.open("images/bg/strawberry.png").convert("RGB").quantize(16)
        spinner_img = Image.new('RGB', DIMENSIONS, color=(0, 0, 0))
        # Add color pie slices
        spinner_draw = ImageDraw.Draw(spinner_img, 'RGBA')
        num_sectors = len(self.options)
        for i, option in enumerate(self.options):
            start_angle = i * (360 / num_sectors)
            end_angle = (i + 1) * (360 / num_sectors)
            color = self.colors[i]
            fill = (255,)
            #draw pie slices
            spinner_draw.pieslice(xy=((CENTER[0] - RADIUS, CENTER[1] - RADIUS), (CENTER[0] + RADIUS, CENTER[1] +
                                                                                 RADIUS)),
                                  start=start_angle,
                                  end=end_angle, fill=color + fill, outline='black')

            # Add text options
            font = ImageFont.truetype("arial.ttf", 30)
            _, _, text_width, text_height = spinner_draw.textbbox((0, 0), option, font=font, anchor="lt")
            sector_center_angle = (start_angle + end_angle) / 2
            sector_center_x = CENTER[0] + (RADIUS + CENTER_CIRCLE_RADIUS) * 0.5 * math.cos(sector_center_angle *
                                                                                           math.pi / 180)
            sector_center_y = CENTER[1] + (RADIUS + CENTER_CIRCLE_RADIUS) * 0.5 * math.sin(sector_center_angle *
                                                                                           math.pi / 180)
            text_angle = 180 - sector_center_angle
            text_img = Image.new('RGBA', (text_width, text_height), color=(0, 0, 0, 0))
            text_draw = ImageDraw.Draw(text_img)
            text_draw.text((0, 0), option, fill=(0, 0, 0), font=font, anchor="lt")
            text_img = text_img.rotate(text_angle, expand=True)
            text_width, text_height = text_img.size
            text_center_x = sector_center_x - text_width / 2
            text_center_y = sector_center_y - text_height / 2
            spinner_img.paste(text_img, (int(text_center_x), int(text_center_y)), text_img)

        center_circle_cover_img = self.center_circle_cover_img.copy()
        center_circle_img = self.center_circle_img.copy()
        # Rotate
        if frame_number < NUM_SPIN_FRAMES:
            spinner_img = spinner_img.rotate(self.sector_angles[frame_number], center=CENTER)
            center_circle_cover_img = center_circle_cover_img.rotate(self.image_angles[frame_number], center=(100, 100))
            center_circle_img = center_circle_img.rotate(self.image_angles[frame_number], center=(100, 100))
        # Stop rotation
        else:
            spinner_img = spinner_img.rotate(self.sector_angles[-1], center=CENTER)
            center_circle_cover_img = center_circle_cover_img.rotate(self.image_angles[-1], center=(100, 100))
            center_circle_img = center_circle_img.rotate(self.image_angles[-1], center=(100, 100))

        # 10 colors
        self.paste(bg_img, spinner_img.quantize(10), (0, 0), MASK_IMG)

        # created outline image cos the spinner outline is quite wonky
        colors_left = self.paste(bg_img, CIRCLE_OUTLINE_IMG.convert("RGB").quantize(2), (int((DIMENSIONS[0] - RADIUS * 2) / 2),
                     int((DIMENSIONS[1] - RADIUS * 2) /
                         2)), CIRCLE_OUTLINE_IMG)

        # center circle cover mask that decreases in opacity
        center_circle_cover_mask_size = (CENTER_CIRCLE_RADIUS * 2, CENTER_CIRCLE_RADIUS * 2)
        center_circle_cover_mask_img = Image.new('L', center_circle_cover_mask_size, color=0)
        center_circle_cover_mask_draw = ImageDraw.Draw(center_circle_cover_mask_img)
        if frame_number < 40:
            fill = 255
        elif frame_number >= 60:
            fill = 0
        else:
            fill = int((NUM_TOTAL_FRAMES - frame_number) / NUM_TOTAL_FRAMES * 255)
        center_circle_cover_mask_draw.ellipse((0, 0) + center_circle_cover_mask_size, fill=fill)

        # comment out to see without the cover image
        center_circle_img.paste(center_circle_cover_img, mask=center_circle_cover_mask_img)

        if frame_number < NUM_SPIN_FRAMES or frame_number % 2 == 1:
            colors_left -= 1
        self.paste(bg_img, center_circle_img.convert("RGB").quantize(colors_left), (
            int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) /
                                                                     2)), CENTER_CIRCLE_MASK_IMG)

        # created outline image cos no center circle outline
        self.paste(bg_img, CENTER_CIRCLE_OUTLINE_IMG.convert("RGB").quantize(2), (
            int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) /
                                                                     2)), CENTER_CIRCLE_OUTLINE_IMG)

        # Add blink effect to triangle image on last frame
        if frame_number < NUM_SPIN_FRAMES or frame_number % 2 == 1:
            self.paste(bg_img, TRIANGLE_IMG.convert("RGB").quantize(2), mask=TRIANGLE_IMG)

        return bg_img

# for testing
SpinnerGifMaker(["hi", "play", "sleep", "run", "dance", "eat", "fly", "study"])

spinner

linustws commented 1 year ago

@radarhere thank you for coming up with this solution! it currently works and outputs the desired image quality. However, right now it's taking way too long (309 seconds compared to the original which is 5 seconds). Is there any way to speed up this process? Also, correct me if i'm wrong but if going by your method, the more options the user enters -> the more color pie slices -> the greater the loss of center images quality? Also, how did you determine how many colors are needed for the background?

radarhere commented 1 year ago

Also, correct me if i'm wrong but if going by your method, the more options the user enters -> the more color pie slices -> the greater the loss of center images quality?

If you also increase the number of colors that I've used to quantize spinner_img, and none of those colors happen to be used in the center image, yes. But that seems like it is just part of the fact that you are limited to 256 colors, and nothing specific to my approach.

Also, how did you determine how many colors are needed for the background?

I just manually adjusted the number until the result looked correct. If you would like something more automatic, you could investigate im.getcolors().

radarhere commented 1 year ago

If you update the paste method to use point() instead of putpixel(), it should be much faster.

    def paste(self, bg_img, im, box=None, mask=None):
        # To combine one P image with another
        # add all of the new colors to the palette of the first image
        remap = [0]*256
        for color, i in im.palette.colors.items():
            remap[i] = bg_img.palette.getcolor(color)
        # then update the palette indexes in the new image
        im = im.point(remap)
        # and paste
        bg_img.paste(im, box, mask)

        # Return the number of free colors left
        return 256 - len(bg_img.palette.colors)
linustws commented 1 year ago

If you also increase the number of colors that I've used to quantize spinner_img, and none of those colors happen to be used in the center image, yes. But that seems like it is just part of the fact that you are limited to 256 colors, and nothing specific to my approach.

i see, i guess it's not really a big deal as the loss of image quality is not noticeable.

If you update the paste method to use point() instead of putpixel(), it should be much faster.

yes it's definitely way faster (~10 seconds)! i would prefer it to be even faster but i guess this is good enough. thank you for this solution, definitely an improvement from my original implementation 🙏

linustws commented 1 year ago

@radarhere any suggestions to speed it up even more? i've tried multiprocessing but that does not work for my aws instance that im using to host the bot (cos it only has 1 core). Any other pillow optimization methods I can use? I read that pillow-simd is faster than pillow but i'm not sure whether it is applicable here.

radarhere commented 1 year ago

I don't have much knowledge of pillow-simd.

https://github.com/uploadcare/pillow-simd#why-do-not-contribute-simd-to-the-original-pillow

First of all, the original Pillow supports a large number of architectures, not just x86.

I don't think any of the optimized functions are used here.

There's a chunk of code at the beginning of getSpinnerFrame() that doesn't depend on frame_number. If I extract that out so that it is only run once, rather than 150 times, the code gets about a third faster.

import math
import os
import random

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont

from components_creator import create_images

DIMENSIONS = (500, 500)
CENTER = (250, 250)
RADIUS = 225
DIAMETER = RADIUS * 2
CENTER_CIRCLE_RADIUS = 100
NUM_SPIN_FRAMES = 100
NUM_BLINK_FRAMES = 50
NUM_TOTAL_FRAMES = NUM_SPIN_FRAMES + NUM_BLINK_FRAMES
# frame durations
DURATIONS = [1000, 300, 200, 130, 80, 60, 40, 30, 25, 20] \
            + [20 for _ in range(NUM_SPIN_FRAMES - 20)] + [20, 25, 30, 40, 60, 80, 130, 200, 300, 1000] \
            + [100 for _ in range(NUM_BLINK_FRAMES)]  # Fastest 20

# import components
try:
    MASK_IMG = Image.open('mask.png')
    CENTER_CIRCLE_MASK_IMG = Image.open('center_circle_mask.png')
    CIRCLE_OUTLINE_IMG = Image.open('circle_outline.png')
    CENTER_CIRCLE_OUTLINE_IMG = Image.open('center_circle_outline.png')
    TRIANGLE_IMG = Image.open('triangle.png')
except FileNotFoundError as e:
    create_images(DIMENSIONS, CENTER, RADIUS, CENTER_CIRCLE_RADIUS)
    MASK_IMG = Image.open('mask.png')
    CENTER_CIRCLE_MASK_IMG = Image.open('center_circle_mask.png')
    CIRCLE_OUTLINE_IMG = Image.open('circle_outline.png')
    CENTER_CIRCLE_OUTLINE_IMG = Image.open('center_circle_outline.png')
    TRIANGLE_IMG = Image.open('triangle.png')
PASTEL_COLORS = [(220, 214, 255), (214, 240, 255), (222, 255, 239), (255, 250, 240), (255, 237, 237),
                 (255, 222, 222), (247, 246, 207), (182, 216, 242), (244, 207, 223), (87, 132, 186),
                 (154, 200, 235), (204, 212, 191), (231, 203, 169), (238, 186, 178), (245, 243, 231),
                 (245, 226, 228), (245, 191, 210), (229, 219, 156), (208, 188, 172), (190, 180, 197),
                 (230, 165, 126), (33, 139, 130), (154, 217, 219), (229, 219, 217), (152, 212, 187),
                 (235, 150, 170), (198, 201, 208), (229, 179, 187), (196, 116, 130), (249, 150, 139),
                 (118, 205, 205), (123, 146, 170), (228, 206, 224), (161, 93, 152), (220, 130, 143),
                 (247, 206, 118), (140, 115, 134), (156, 147, 89), (165, 114, 131), (232, 213, 149)]

class SpinnerGifMaker:

    def __init__(self, options):
        random.shuffle(options)
        self.options = options
        # 200 x 200 pic
        self.center_circle_cover_img = Image.open("images/cover/cat.png")
        folder_path = "images/joy"
        file_list = os.listdir(folder_path)
        image_list = [filename for filename in file_list if filename.endswith(('.png', '.jpg', '.jpeg'))]
        random_image = random.choice(image_list)
        image_path = os.path.join(folder_path, random_image)
        self.center_circle_img = Image.open(image_path).resize((200, 200))
        # self.center_circle_img = Image.open("images/joy/joy_jc.png")
        self.colors = random.sample(PASTEL_COLORS, len(options))
        first_half = [0, -2, -5, -10, -15, -20, -30, -50, -70, -100] + [i * -150 - 150 for i in
                                                                        range(int((NUM_SPIN_FRAMES - 20) / 2))]
        second_half = [i * -150 + 6000 for i in range(int((NUM_SPIN_FRAMES - 20) / 2))] + [100, 70, 50, 30, 20, 15,
                                                                                           10, 5, 2, 0]
        angles = first_half + second_half
        # start and end at unpredictable positions
        start_offset = random.randint(0, 359)
        end_offset = random.randint(0, 359)
        sector_first_half = [angle - start_offset for angle in angles[:50]]
        sector_second_half = [angle - end_offset for angle in angles[50:]]
        self.sector_angles = sector_first_half + sector_second_half
        self.image_angles = angles

        bg_img, spinner_img = self.prepare()

        frame_list = []
        for i in range(NUM_TOTAL_FRAMES):
            frame = self.getSpinnerFrame(bg_img.copy(), spinner_img, i)
            frame_list.append(frame)
        frame_list[0].save('spinner.gif', format='GIF', append_images=frame_list[1:], save_all=True,
                           duration=DURATIONS, disposal=2, loop=0)

    def paste(self, bg_img, im, box=None, mask=None):
        # To combine one P image with another
        # add all of the new colors to the palette of the first image

        remap = [0]*256
        for color, i in im.palette.colors.items():
            remap[i] = bg_img.palette.getcolor(color)
        # then update the palette indexes in the new image
        im = im.point(remap)
        # and paste
        bg_img.paste(im, box, mask)

        # Return the number of free colors left
        return 256 - len(bg_img.palette.colors)

    def prepare(self):
        # 16 colors
        bg_img = Image.open("images/bg/strawberry.png").convert("RGB").quantize(16)
        spinner_img = Image.new('RGB', DIMENSIONS, color=(0, 0, 0))
        # Add color pie slices
        spinner_draw = ImageDraw.Draw(spinner_img, 'RGBA')
        num_sectors = len(self.options)
        for i, option in enumerate(self.options):
            start_angle = i * (360 / num_sectors)
            end_angle = (i + 1) * (360 / num_sectors)
            color = self.colors[i]
            fill = (255,)
            #draw pie slices
            spinner_draw.pieslice(xy=((CENTER[0] - RADIUS, CENTER[1] - RADIUS), (CENTER[0] + RADIUS, CENTER[1] +
                                                                                 RADIUS)),
                                  start=start_angle,
                                  end=end_angle, fill=color + fill, outline='black')

            # Add text options
            font = ImageFont.truetype("arial.ttf", 30)
            _, _, text_width, text_height = spinner_draw.textbbox((0, 0), option, font=font, anchor="lt")
            sector_center_angle = (start_angle + end_angle) / 2
            sector_center_x = CENTER[0] + (RADIUS + CENTER_CIRCLE_RADIUS) * 0.5 * math.cos(sector_center_angle *
                                                                                           math.pi / 180)
            sector_center_y = CENTER[1] + (RADIUS + CENTER_CIRCLE_RADIUS) * 0.5 * math.sin(sector_center_angle *
                                                                                           math.pi / 180)
            text_angle = 180 - sector_center_angle
            text_img = Image.new('RGBA', (text_width, text_height), color=(0, 0, 0, 0))
            text_draw = ImageDraw.Draw(text_img)
            text_draw.text((0, 0), option, fill=(0, 0, 0), font=font, anchor="lt")
            text_img = text_img.rotate(text_angle, expand=True)
            text_width, text_height = text_img.size
            text_center_x = sector_center_x - text_width / 2
            text_center_y = sector_center_y - text_height / 2
            spinner_img.paste(text_img, (int(text_center_x), int(text_center_y)), text_img)
        return bg_img, spinner_img

    def getSpinnerFrame(self, bg_img, spinner_img, frame_number):
        # Rotate
        if frame_number < NUM_SPIN_FRAMES:
            spinner_img = spinner_img.rotate(self.sector_angles[frame_number], center=CENTER)
            center_circle_cover_img = self.center_circle_cover_img.rotate(self.image_angles[frame_number], center=(100, 100))
            center_circle_img = self.center_circle_img.rotate(self.image_angles[frame_number], center=(100, 100))
        # Stop rotation
        else:
            spinner_img = spinner_img.rotate(self.sector_angles[-1], center=CENTER)
            center_circle_cover_img = self.center_circle_cover_img.rotate(self.image_angles[-1], center=(100, 100))
            center_circle_img = self.center_circle_img.rotate(self.image_angles[-1], center=(100, 100))

        # 10 colors
        self.paste(bg_img, spinner_img.quantize(10), (0, 0), MASK_IMG)

        # created outline image cos the spinner outline is quite wonky
        colors_left = self.paste(bg_img, CIRCLE_OUTLINE_IMG.convert("RGB").quantize(2), (int((DIMENSIONS[0] - RADIUS * 2) / 2),
                     int((DIMENSIONS[1] - RADIUS * 2) /
                         2)), CIRCLE_OUTLINE_IMG)

        # center circle cover mask that decreases in opacity
        center_circle_cover_mask_size = (CENTER_CIRCLE_RADIUS * 2, CENTER_CIRCLE_RADIUS * 2)
        center_circle_cover_mask_img = Image.new('L', center_circle_cover_mask_size, color=0)
        center_circle_cover_mask_draw = ImageDraw.Draw(center_circle_cover_mask_img)
        if frame_number < 40:
            fill = 255
        elif frame_number >= 60:
            fill = 0
        else:
            fill = int((NUM_TOTAL_FRAMES - frame_number) / NUM_TOTAL_FRAMES * 255)
        center_circle_cover_mask_draw.ellipse((0, 0) + center_circle_cover_mask_size, fill=fill)

        # comment out to see without the cover image
        center_circle_img.paste(center_circle_cover_img, mask=center_circle_cover_mask_img)

        if frame_number < NUM_SPIN_FRAMES or frame_number % 2 == 1:
            colors_left -= 1
        self.paste(bg_img, center_circle_img.convert("RGB").quantize(colors_left), (
            int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) /
                                                                     2)), CENTER_CIRCLE_MASK_IMG)

        # created outline image cos no center circle outline
        self.paste(bg_img, CENTER_CIRCLE_OUTLINE_IMG.convert("RGB").quantize(2), (
            int((DIMENSIONS[0] - CENTER_CIRCLE_RADIUS * 2) / 2), int((DIMENSIONS[1] - CENTER_CIRCLE_RADIUS * 2) /
                                                                     2)), CENTER_CIRCLE_OUTLINE_IMG)

        # Add blink effect to triangle image on last frame
        if frame_number < NUM_SPIN_FRAMES or frame_number % 2 == 1:
            self.paste(bg_img, TRIANGLE_IMG.convert("RGB").quantize(2), mask=TRIANGLE_IMG)

        return bg_img

# for testing
SpinnerGifMaker(["hi", "play", "sleep", "run", "dance", "eat", "fly", "study"])
linustws commented 1 year ago

@radarhere on top of your solution I found some other things that could be extracted as well. Also i just realised that the number of options do not affect the quality of the center images as the spinner image colors are capped to 10 due to quantized(10), so the number of colors left for the center images is always 229/230. Will try to find other ways to optimise as the aws instance really slow things down, but thanks for helping me out!!