Closed linustws closed 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.
@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?
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
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.
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")
@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.
@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:
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)
@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:
this are the center circle cover img and the background img:
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"])
@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?
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()
.
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)
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 usepoint()
instead ofputpixel()
, 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 🙏
@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.
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"])
@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!!
Reduced the original problem with this spike test.
i realised that this line of code
spinner_img.putalpha(mask_img)
causes the gif to have a filter effect.Original picture:
With
spinner_img.putalpha(mask_img)
:Without
spinner_img.putalpha(mask_img)
: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)
.