Question: 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:  But instead I get a GIF like this:  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.

Answer: 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.