# This code adapted from https://github.com/python-pillow/Pillow/issues/4644 to resolve an issue | |
# described in https://github.com/python-pillow/Pillow/issues/4640 | |
# | |
# There is a known issue with the Pillow library that messes up GIF transparency by replacing the | |
# transparent pixels with black pixels (among other issues) when the GIF is saved using PIL.Image.save(). | |
# This code works around the issue and allows us to properly generate transparent GIFs. | |
from typing import Tuple, List, Union | |
from collections import defaultdict | |
from random import randrange | |
from itertools import chain | |
from PIL.Image import Image | |
class TransparentAnimatedGifConverter(object): | |
_PALETTE_SLOTSET = set(range(256)) | |
def __init__(self, img_rgba: Image, alpha_threshold: int = 0): | |
self._img_rgba = img_rgba | |
self._alpha_threshold = alpha_threshold | |
def _process_pixels(self): | |
"""Set the transparent pixels to the color 0.""" | |
self._transparent_pixels = set( | |
idx for idx, alpha in enumerate( | |
self._img_rgba.getchannel(channel='A').getdata()) | |
if alpha <= self._alpha_threshold) | |
def _set_parsed_palette(self): | |
"""Parse the RGB palette color `tuple`s from the palette.""" | |
palette = self._img_p.getpalette() | |
self._img_p_used_palette_idxs = set( | |
idx for pal_idx, idx in enumerate(self._img_p_data) | |
if pal_idx not in self._transparent_pixels) | |
self._img_p_parsedpalette = dict( | |
(idx, tuple(palette[idx * 3:idx * 3 + 3])) | |
for idx in self._img_p_used_palette_idxs) | |
def _get_similar_color_idx(self): | |
"""Return a palette index with the closest similar color.""" | |
old_color = self._img_p_parsedpalette[0] | |
dict_distance = defaultdict(list) | |
for idx in range(1, 256): | |
color_item = self._img_p_parsedpalette[idx] | |
if color_item == old_color: | |
return idx | |
distance = sum(( | |
abs(old_color[0] - color_item[0]), # Red | |
abs(old_color[1] - color_item[1]), # Green | |
abs(old_color[2] - color_item[2]))) # Blue | |
dict_distance[distance].append(idx) | |
return dict_distance[sorted(dict_distance)[0]][0] | |
def _remap_palette_idx_zero(self): | |
"""Since the first color is used in the palette, remap it.""" | |
free_slots = self._PALETTE_SLOTSET - self._img_p_used_palette_idxs | |
new_idx = free_slots.pop() if free_slots else \ | |
self._get_similar_color_idx() | |
self._img_p_used_palette_idxs.add(new_idx) | |
self._palette_replaces['idx_from'].append(0) | |
self._palette_replaces['idx_to'].append(new_idx) | |
self._img_p_parsedpalette[new_idx] = self._img_p_parsedpalette[0] | |
del(self._img_p_parsedpalette[0]) | |
def _get_unused_color(self) -> tuple: | |
""" Return a color for the palette that does not collide with any other already in the palette.""" | |
used_colors = set(self._img_p_parsedpalette.values()) | |
while True: | |
new_color = (randrange(256), randrange(256), randrange(256)) | |
if new_color not in used_colors: | |
return new_color | |
def _process_palette(self): | |
"""Adjust palette to have the zeroth color set as transparent. Basically, get another palette | |
index for the zeroth color.""" | |
self._set_parsed_palette() | |
if 0 in self._img_p_used_palette_idxs: | |
self._remap_palette_idx_zero() | |
self._img_p_parsedpalette[0] = self._get_unused_color() | |
def _adjust_pixels(self): | |
"""Convert the pixels into their new values.""" | |
if self._palette_replaces['idx_from']: | |
trans_table = bytearray.maketrans( | |
bytes(self._palette_replaces['idx_from']), | |
bytes(self._palette_replaces['idx_to'])) | |
self._img_p_data = self._img_p_data.translate(trans_table) | |
for idx_pixel in self._transparent_pixels: | |
self._img_p_data[idx_pixel] = 0 | |
self._img_p.frombytes(data=bytes(self._img_p_data)) | |
def _adjust_palette(self): | |
"""Modify the palette in the new `Image`.""" | |
unused_color = self._get_unused_color() | |
final_palette = chain.from_iterable( | |
self._img_p_parsedpalette.get(x, unused_color) for x in range(256)) | |
self._img_p.putpalette(data=final_palette) | |
def process(self) -> Image: | |
"""Return the processed mode `P` `Image`.""" | |
self._img_p = self._img_rgba.convert(mode='P') | |
self._img_p_data = bytearray(self._img_p.tobytes()) | |
self._palette_replaces = dict(idx_from=list(), idx_to=list()) | |
self._process_pixels() | |
self._process_palette() | |
self._adjust_pixels() | |
self._adjust_palette() | |
self._img_p.info['transparency'] = 0 | |
self._img_p.info['background'] = 0 | |
return self._img_p | |
def _create_animated_gif(images: List[Image], durations: Union[int, List[int]]) -> Tuple[Image, dict]: | |
"""If the image is a GIF, create an its thumbnail here.""" | |
save_kwargs = dict() | |
new_images: List[Image] = [] | |
for frame in images: | |
thumbnail = frame.copy() # type: Image | |
thumbnail_rgba = thumbnail.convert(mode='RGBA') | |
thumbnail_rgba.thumbnail(size=frame.size, reducing_gap=3.0) | |
converter = TransparentAnimatedGifConverter(img_rgba=thumbnail_rgba) | |
thumbnail_p = converter.process() # type: Image | |
new_images.append(thumbnail_p) | |
output_image = new_images[0] | |
save_kwargs.update( | |
format='GIF', | |
save_all=True, | |
optimize=False, | |
append_images=new_images[1:], | |
duration=durations, | |
disposal=2, # Other disposals don't work | |
loop=0) | |
return output_image, save_kwargs | |
def save_transparent_gif(images: List[Image], durations: Union[int, List[int]], save_file): | |
"""Creates a transparent GIF, adjusting to avoid transparency issues that are present in the PIL library | |
Note that this does NOT work for partial alpha. The partial alpha gets discarded and replaced by solid colors. | |
Parameters: | |
images: a list of PIL Image objects that compose the GIF frames | |
durations: an int or List[int] that describes the animation durations for the frames of this GIF | |
save_file: A filename (string), pathlib.Path object or file object. (This parameter corresponds | |
and is passed to the PIL.Image.save() method.) | |
Returns: | |
Image - The PIL Image object (after first saving the image to the specified target) | |
""" | |
root_frame, save_args = _create_animated_gif(images, durations) | |
root_frame.save(save_file, **save_args) |
Hi, I can't get this to work.
I'm making a discord bot command that generates transparent gif animation by overlaying a hand image over user avatar, making the bot stroke the hair of the user. However, all I get is single-colored background.
I'm creating animation frames with
Image.new("RGBA", (256, 256), (0, 0, 0, 1))
, then I call.paste()
to insert the two transparent parts. Frames themselves are transparent (I checked by exporting them separately), but when I save them as animated gif, I get opaque image with the same background color as I specified in the frame creation.
This may be misleading (if it is just tell me) but I had some of the same problems. The way I do it is that I have a folder full of these pictures then I read this and add it into an array. From there I put it inside the function from this workaround. But I had a problem putting the images into the list and then sending it to "save_transparent_gif". The way I solved this was to import PIL and then open the files by using the basic "open" and then using PIL to open that.
import PIL.Image
imgs = []
for i in range(0, 100):
fp = open('./images/' + str(i) + ".png", "rb");
imgs.append(PIL.Image.open(fp))
save_transparent_gif(images=imgs,save_file=fp_out, durations=5)```
I don't think there is any problem with opening the image, as all the pixels are read correctly and for some avatars the basic frame[0].save(append_images[1:], ...)
works. With that method I'm having problem with the gif frame color palette not having a transparent pixel on index zero on each frame. (Well, that's a complicated sentence)
The script above gets rid of the sometimes-not-transparent-background flickering, but produces opaque color for all frames instead of them being fully transparent. I have a feeling that this could be resolved by tweaking one line of code, but I haven't been able to find it.
Thanks, this resolved my issue with PIL being unable to save transparent GIF animation.
Thanks!!!
Hi, I can't get this to work.
I'm making a discord bot command that generates transparent gif animation by overlaying a hand image over user avatar, making the bot stroke the hair of the user. However, all I get is single-colored background.
I'm creating animation frames with
Image.new("RGBA", (256, 256), (0, 0, 0, 1))
, then I call.paste()
to insert the two transparent parts. Frames themselves are transparent (I checked by exporting them separately), but when I save them as animated gif, I get opaque image with the same background color as I specified in the frame creation.I'm including few of the generated frames, as well as the resulting animation. Do you know what may be wrong?