-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
GIF frames: background, transparency, combining #4640
Description
- OS: had the issue on Debian, moved to Windows for easier testing (I would say OS doesn't matter here)
- Python: 3.7.3 on Debian, 3.6.9 on Windows
- Pillow: 7.1.2
This was originally a question on StackOverflow, but I since think it's more of a Pillow bug/limitation, I will put all relevant information here, no need to read the SO post.
Resources:
PepePls.gif

from PIL import Image
image = Image.open('PepePls.gif')
frames = []
try:
while True:
frames.append(image.copy())
image.seek(image.tell() + 1)
except EOFError:
pass
for i in range(len(frames)):
frames[i].save(f'gif_f{i}.gif')
frames[i].save(f'png_f{i}.png')
frames[i].convert(mode='RGBA').save(f'rgba_gif_f{i}.gif')
frames[i].convert(mode='RGBA').save(f'rgba_png_f{i}.png')(I would shorten the gifs if I knew how to recreate their features, but I don't)
Black borders
The first thing you notice is that there are black borders everywhere. After researching and reading through GifImagePlugin.py, I think this is because in disposal_method==2 (which I think is "replace" whole frame) the load-image code (which I didn't locate precisely, I guess it's C somewhere?) pastes to a new image only the dispose_extent part of a frame which is the part read from the file (aka the part in the middle of the black borders). So that's fine but I guess the "new image" which is being pasted on is likely initialized wrongly, since black seems to be the default color in Pillow it may not be initialized at all.
I fixed this by copying the dispose_extent part onto a fully transparent image, but I'm not sure how to detected if the gif should be read that way (how to tell if it wants a transparent background?), code:
from PIL import Image
image = Image.open('PeepoCreepo.gif')
frames = []
try:
while True:
if image.__dict__.get('disposal_method', 0) == 2:
frame = Image.new('RGBA', image.size, color=(0,0,0,0))
frame.paste(image.crop(image.dispose_extent), box=(image.dispose_extent[0],image.dispose_extent[1]))
frames.append(frame)
else:
frames.append(image.copy())
image.seek(image.tell() + 1)
except EOFError:
pass
for i in range(len(frames)):
frames[i].save(f'gif_f{i}.gif')
frames[i].save(f'png_f{i}.png')
frames[i].convert(mode='RGBA').save(f'rgba_gif_f{i}.gif')
frames[i].convert(mode='RGBA').save(f'rgba_png_f{i}.png')Apart from png_f2.png from PeepoCreepo.gif (no idea what is happening there either), all the black borders have disappeared when saving to png.
gif and rgba
I'm not sure why .convert(rgba).save(gif) makes the saved image file lose transparency. Didn't research that issue at all.
frame combine
Let's zoom in on the rgba_png_fX.png files, because I'm sure they went unnoticed among the hundreds of frames I dumped:

Notice rgba_png_f2.png has missing pixels
Here's a screenshot of GIMP (image manipulation program) with those frames which may help understand the issue:

Notice how in GIMP frame "Image vidéo 3" (which corresponds to our rgba_png_f2.png file) is "combine"
However if you add print(image.__dict__.get('disposal_method', None)) at the beginning of the while True: loop you'll get
1
1
2
[... just 2]
2
1
I assume 1 is "combine" (at time of writing) and 2 is "replace" (at time of writing)
So, Pillow is using the disposal_method value early? If it was delayed by a frame, rgba_png_f2.png would be correctly overlayed on rgba_png_f1.png
my full "debugging" script (can be ignored)
For reference, here's a script with a lot of useless stuff which I used for debugging, it does highlight what undocumented GifImageFile members seem to be relevant here though. It also fixes the "delay combine" issue with a local variable disposal_method_last
from PIL import Image
gif = 'PeepoCreepo.gif'
#gif = 'PepePls.gif'
image = Image.open(gif)
red = Image.new('RGBA', (85, 112), color='red')
redP = red.convert(mode='P')
imTest = Image.new('P', (10, 10), color='red')
imTest.im = Image.core.fill('P', imTest.size, 1)
#imTest.save('imtest.png')
# is this correct?
def getPaletteColor(im, n):
# https://github.com/python-pillow/Pillow/blob/master/src/PIL/ImagePalette.py#L108
try:
return (im.palette.palette[n], im.palette.palette[n+256], im.palette.palette[n+512])
except IndexError:
return f'IndexError palette {n}'
loop = []
frames = []
durations = []
disposal_method_last = 0
try:
while True:
"""
print('im=' + str(image.__dict__.get('im', None)))
tile = image.__dict__.get('tile', None)
print('tile=' + str(tile))
print('tile[0]=' + str(tile[0]))
# probably better than image.dispose_extent but idk
print('tile[0][1]=' + str(tile[0][1]))
"""
print('dispose=' + str(image.__dict__.get('dispose', None)))
print('dispose_extent=' + str(image.__dict__.get('dispose_extent', None)))
print('disposal_method=' + str(image.__dict__.get('disposal_method', None)))
"""
print('background=' + str(image.info.get('background', None)))
print('background color=' + str(getPaletteColor(image, image.info.get('background', 0))))
print('transparency=' + str(image.info.get('transparency', None)))
print('transparency color=' + str(getPaletteColor(image, image.info.get('transparency', 0))))
# indeed the same as transparency
print('upleft pixel dispose=' + str(image.getpixel((image.dispose_extent[0],image.dispose_extent[1]))))
print('upleft_xy+1 pixel dispose=' + str(image.getpixel((image.dispose_extent[0]+1,image.dispose_extent[1]+1))))
# indeed not transparency
print('upleft pixel=' + str(image.getpixel((0,0))))
print('upleft_xy+1 pixel=' + str(image.getpixel((1,1))))
"""
loop.append(image.info.get('loop', None))
disposal_method = disposal_method_last
disposal_method_last = image.__dict__.get('disposal_method', 0)
if disposal_method == 2 or (disposal_method == 1 and frames == []):
#"""
frame = Image.new('RGBA', image.size, color=(255,0,0,0))
"""
frame = Image.new('P', image.size, color=image.info['background'])
frame.palette = image.palette
#"""
frame.paste(image.crop(image.dispose_extent), box=(image.dispose_extent[0],image.dispose_extent[1]))
elif disposal_method == 1:
"""
newStuff = Image.new('RGBA', image.size, color=(0,0,255,0))
newStuff.paste(image.crop(image.dispose_extent), box=(image.dispose_extent[0],image.dispose_extent[1]))
frame = Image.alpha_composite(frames[-1].convert(mode='RGBA'), newStuff.convert(mode='RGBA'))
"""
newStuff = image.crop(image.dispose_extent)
frame = frames[-1].copy()
"""
background = Image.new('P', newStuff.size, color=image.info['background'])
background.palette = image.palette
frame.paste(background, image.dispose_extent)
#"""
frame.paste(newStuff, image.dispose_extent, newStuff.convert("RGBA"))
#"""
else:
frame = image.copy()
transparency = image.info.get('transparency', None)
# "delta frames" not for PepePls apparently, or not how they work
"""
if len(frames) == 0:
frame = image.convert(mode='RGBA')
else:
frame = Image.alpha_composite(frames[-1], image.convert(mode='RGBA'))
"""
# https://stackoverflow.com/questions/4904940/python-converting-gif-frames-to-png
# didn't help
"""
if palette is None:
palette = frame.getpalette()
else:
frame.putpalette(palette)
"""
frames.append(frame)
durations.append(image.info.get('duration', None))
"""
"background" -> no difference
"transparency" -> most borders changed color
image.im = Image.core.fill("P", image.size, image.info["transparency"])
image._prev_im = Image.core.fill("P", image.size, image.info["transparency"])
"""
"""
same as above
image.im = Image.new('P', image.size, color=200).im
image._prev_im = Image.new('P', image.size, color=200).im
"""
image.seek(image.tell() + 1)
except EOFError:
pass
for i in range(len(frames)):
print(f'#{i} loop = {loop[i]} duration = {durations[i]} mode = {frames[i].mode} size = {frames[i].size}')
frames[i].save(f'gif_f{i}.gif')
frames[i].save(f'png_f{i}.png')
frames[i].convert(mode='RGBA').save(f'rgba_gif_f{i}.gif')
frames[i].convert(mode='RGBA').save(f'rgba_png_f{i}.png')



