Skip to content

GIF frames: background, transparency, combining #4640

@Dragorn421

Description

@Dragorn421
  • 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
PepePls

PeepoCreepo.gif
PeepoCreepo

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')

With PepePls.gif:
image

With PeepoCreepo.gif:
peepocreepo_frames

(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')

image

peepocreepo_frames_dispose2

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:
image
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:
image
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')

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions