Search code examples
pythonpython-imaging-librarygif

Issues with saving a transparent GIF using Pillow


I am using Python to create a program that can automatically make Pokemon gifs for me by using Sprite sheets from a Sprite repository. It combines animation, along with its shadow to create a sprite sheet that is then cut up into frames. The frames are assembled into GIFs which are then shown in the program, inside canvasses, so I know what to download. The GIFs look and function perfectly in the cavasses but when I use PIL and try to save the GIF it does this weird thing where it crops the frames so that the edges and the shadow of the frame are sometimes removed like it's unnecessarily clamping around the frame.

As you can see in the code below, I have attempted just about everything I know. I could very well be missing something though.

Please note that each frame is exactly the same size before they are saved and that even by resizing them, the issue remained. When each frame is exported on its own, it retains its original size, it only has an issue when it's trying to put all the frames together into the gif.

    def download_this(button, download_single_buttons):
        export_directory = config.get('Directory', 'export_directory')
        idx = download_single_buttons.index(button)
        starting_frame_index = idx * num_cols
        canvas_frames = frames[starting_frame_index: starting_frame_index + num_cols]

        canvas_frames = [ImageTk.getimage(f) for f in canvas_frames]

        durations = frame_duration_values[:num_cols]
        loop_value = 0 if loop_check_value.get() else 1

        if loop_value == 0:
            filename = f"{export_directory}\{(this_poke.lower())}-{(animations.lower())}({directions[idx]}).gif"
        else:
            filename = f"{export_directory}\{(this_poke.lower())}-{(animations.lower())}_once({directions[idx]}).gif"
        canvas_frames[0].save(filename, format="GIF", append_images=canvas_frames[1:], save_all=True, loop=loop_value,
                              transparency=0, disposal=[2] * len(canvas_frames), duration=durations,
                              compress_level=0, save_local_palette=True, width=frame_width, height=frame_height)

I will also attach the method of creating the frames:

Example values for the arguments:

'anim_selected': .!toplevel.!toplevel

This is the window that is used and thus added buttons/canvasses to.

'animations': 'Hop'

This is the variable that changes based on what animation the user picks.

'search_poke_number': '0194'

This is the numerical value that is used to identify the pokemon.

'this_poke': 'Quagsire'

This is the english name for the pokemon and is used when saving the gif.

def generate_gifs(anim_selected, animations, search_poke_number, this_poke, shadow_check_value):
    global photo_img, frames

    url = f'https://github.com/PMDCollab/SpriteCollab/blob/master/sprite/{int(search_poke_number[0]) + 1:04}/AnimData.xml?raw=true'
    response = requests.get(url, verify=False)
    root = ET.fromstring(response.content)

    for anim in root.findall('./Anims/Anim'):
        if anim.find('Name').text == animations:
            frame_width = int(anim.find('FrameWidth').text)
            frame_height = int(anim.find('FrameHeight').text)
            frame_duration_values = [int(dur.text) * 20 for dur in anim.findall('./Durations/Duration')]

    shadow_size = root.find('ShadowSize').text

    anim_url = f'https://github.com/PMDCollab/SpriteCollab/blob/master/sprite/{int(search_poke_number[0]) + 1:04}/{animations}-Anim.png?raw=true'
    response = requests.get(anim_url, verify=False)
    anim_img = Image.open(io.BytesIO(response.content))

    if shadow_check_value is True:
        shadow_url = f'https://github.com/PMDCollab/SpriteCollab/blob/master/sprite/{int(search_poke_number[0]) + 1:04}/{animations}-Shadow.png?raw=true'
        response = requests.get(shadow_url, verify=False)
        shadow_img = Image.open(io.BytesIO(response.content))

        shadow_arr = np.array(shadow_img)

        white = [255, 255, 255, 255]
        blue = [0, 0, 255, 255]
        green = [0, 255, 0, 255]
        red = [255, 0, 0, 255]
        black = [0, 0, 0, 255]
        transparent = [0, 0, 0, 0]

        colour_list = [white, green, red, blue]

        # Replace the colors with black or transparent based on shadow_size
        i = 0
        while i <= 3:
            if i <= int(shadow_size) + 1:
                shadow_arr[(shadow_arr == colour_list[i]).all(axis=2)] = black
            else:
                shadow_arr[(shadow_arr == colour_list[i]).all(axis=2)] = transparent
            i += 1

        shadow_img = Image.fromarray(shadow_arr)

        # Combine the images and merge them with the shadow image underneath
        merged_img = Image.alpha_composite(shadow_img, anim_img)
    else:
        merged_img = anim_img

    photo_img = ImageTk.PhotoImage(merged_img)

    # Calculate the number of columns and rows in the sprite sheet
    num_cols = photo_img.width() // frame_width
    num_rows = photo_img.height() // frame_height

    # Create a list of PhotoImage objects for each frame in the GIF
    frames = [None] * (num_rows * num_cols)
    frame_count = 0
    for row in range(num_rows):
        for col in range(num_cols):
            x1 = col * frame_width
            y1 = row * frame_height
            x2 = x1 + frame_width
            y2 = y1 + frame_height
            cropped_img = merged_img.crop((x1, y1, x2, y2))
            frame = ImageTk.PhotoImage(cropped_img)
            frames[frame_count] = frame
            frame_count += 1

    # Create a list of directions in anti-clockwise order
    directions = ['S', 'SE', 'E', 'NE', 'N', 'NW', 'W', 'SW']
    gifs = []
    for direction in directions[:num_rows]:
        # Find the starting frame for this direction
        starting_frame_index = directions.index(direction) * num_cols
        # Get the frames for this direction
        gif_frames = frames[starting_frame_index: starting_frame_index + num_cols]
        # Create a gif from the frames
        gif = []
        for frame in gif_frames:
            gif.append(frame)
        gifs.append(gif)

    frame_duration_values = frame_duration_values * num_rows
    canvases = []
    for i, direction in enumerate(directions[:num_rows]):
        # Create a new canvas
        canvas = tk.Canvas(anim_selected, width=70, height=70)
        canvas.config(bg='#78acff')

        # Add the gif to the canvas
        canvas.anim_frames = gifs[i]
        canvas.anim_delays = frame_duration_values[starting_frame_index:starting_frame_index + num_cols]
        canvas.anim_index = 0
        canvas.anim_after_id = None
        canvas.anim_running = True  # Start the animation immediately

        # Create a function to animate the canvas
        def animate(canvas, frame_delays):
            # Get the current frame
            canvas.delete('all')
            frame = canvas.anim_frames[canvas.anim_index]

            # Update the canvas image
            canvas.create_image(70 / 2, 70 / 2, image=frame, anchor=tk.CENTER)

            # Increment the index
            canvas.anim_index += 1
            if canvas.anim_index >= len(canvas.anim_frames):
                canvas.anim_index = 0

            # Get the delay for the next frame
            next_frame_index = canvas.anim_index - 1 if canvas.anim_index - 1 < len(canvas.anim_frames) else 0
            next_frame_delay = frame_delays[next_frame_index]

            # Schedule the next animation with the delay of the next frame
            canvas.anim_after_id = canvas.after(next_frame_delay, animate, canvas, frame_delays)

        # Add the canvas to the list
        canvases.append(canvas)

        # Start the animation
        animate(canvas, canvas.anim_delays)



    def change_directory():

        # Open a folder selector dialog to select the export directory
        selected_directory = filedialog.askdirectory()

        # Update the export directory path if a directory is selected
        if selected_directory:
            export_directory = selected_directory

            # Save the export directory path to the configuration file
            config['Directory'] = {'export_directory': export_directory}
            with open(config_file_path, 'w') as f:
                config.write(f)

    def download_this(button, download_single_buttons):
        export_directory = config.get('Directory', 'export_directory')
        idx = download_single_buttons.index(button)
        starting_frame_index = idx * num_cols
        canvas_frames = frames[starting_frame_index: starting_frame_index + num_cols]

        canvas_frames = [ImageTk.getimage(f) for f in canvas_frames]

        durations = frame_duration_values[:num_cols]

        loop_value = 0 if loop_check_value.get() else 1

        if loop_value == 0:
            filename = f"{export_directory}/{(this_poke.lower())}-{(animations.lower())}({directions[idx]}).gif"
        else:
            filename = f"{export_directory}/{(this_poke.lower())}-{(animations.lower())}({directions[idx]})-once.gif"

        canvas_frames[0].save(filename, append_images=canvas_frames[1:], save_all=True, loop=loop_value,
                              transparency=0, disposal=2, duration=durations,
                              compress_level=0, size=(frame_width, frame_height), background=0)

    def download_all():
        export_directory = config.get('Directory', 'export_directory')
        for i, canvas in enumerate(canvases):
            idx = canvases.index(canvas)
            starting_frame_index = idx * num_cols
            canvas_frames = frames[starting_frame_index: starting_frame_index + num_cols]
            images = [ImageTk.getimage(f) for f in canvas_frames]
            durations = frame_duration_values[:num_cols]
            loop_value = 0 if loop_check_value.get() else 1

            if loop_value == 0:
                filename = f"{export_directory}/{(this_poke.lower())}-{(animations.lower())}({directions[idx]}).gif"
            else:
                filename = f"{export_directory}/{(this_poke.lower())}-{(animations.lower())}_once({directions[idx]}).gif"

            images[0].save(filename, append_images=images[1:], save_all=True, loop=loop_value,
                           transparency=0, disposal=[2] * len(images), duration=durations)

    download_single_buttons = []
    directions_long = ['South', 'South East', 'East', 'North East', 'North', 'North West', 'West', 'South West']
    for i, canvas in enumerate(canvases):
        row = i // 4
        col = i % 4
        canvas.grid(row=(row * 4) + 2, column=col, padx=5, pady=1)
        download_single_button = tk.Button(anim_selected, text='Download')
        download_single_button.config(width=8, height=1, bg='#78acff')
        download_single_button.grid(row=(row * 4) + 3, column=col, padx=5, pady=1)
        download_single_button.config(
            command=lambda button=download_single_button: download_this(button, download_single_buttons))
        download_single_buttons.append(download_single_button)

        direction_label = tk.Label(anim_selected, text=directions_long[i])
        direction_label.config(width=8, height=1, bg='#78acff')
        direction_label.grid(row=(row * 4) + 1, column=col, padx=5, pady=1)

        buffer = tk.Label(anim_selected, text="   ")
        buffer.grid(row=(row + 1) * 4, column=col, padx=5, pady=10)

        def my_upd():
            print('Loop gif? :', loop_check_value.get())

    loop_check_value = tk.BooleanVar()
    loop_check = tk.Checkbutton(anim_selected, text="Loop", variable=loop_check_value, onvalue=True, offvalue=False,
                                command=my_upd)
    loop_check_value.set(True)
    if canvases.index(canvas) <= 0:
        loop_check.grid(row=0, column=0, padx=10, pady=10)
    else:
        download_all_button = tk.Button(anim_selected, text='Get All')
        download_all_button.config(width=8, height=1, bg='#78acff')
        download_all_button.grid(row=0, column=0, padx=10, pady=10)
        download_all_button.config(command=download_all)
        loop_check.grid(row=0, column=1, padx=10, pady=10)

        export_location = tk.Button(anim_selected, text='Export To')
        export_location.config(width=8, height=1, bg='#78acff')
        export_location.grid(row=0, column=3, padx=10, pady=10)
        export_location.config(command=change_directory)


In theory, this should all give me a GIF like this: enter image description here

But instead I get a GIF like this: enter image description here

EDIT: So after trying for a few days it seems like it's something to do with the save function itself. Even if I make sure the files are the correct size they are changed when I try to save them as a gif together. Even saving the individual frames to a folder and assembling the gif from that results in the exact same issue. At this point, I'm just looking for an alternative/workaround.


Solution

  • In summary, the issue was that PIL struggles when creating gifs with frames that have transparency.

    The answer to my issue is the top voted answer here.

    Specifically the code chunk, which I will copy here:

    # 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 long-standing 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)
    

    If, like I was, you're having the issue and wondering what to do with the code, you should simply create a new py file in your project and put the code within it.

    Next, instead of using the save function in your code you are using to save the gif, you will use save_transparent_gif(canvas_frames, durations, filename).

    Be sure to replace the arguments with either raw values or the vars you have already made.