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