Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Are frames compressed or changed when creating gifs? #3660

Closed
dhsdshdhk opened this issue Feb 16, 2019 · 16 comments
Closed

Are frames compressed or changed when creating gifs? #3660

dhsdshdhk opened this issue Feb 16, 2019 · 16 comments
Labels

Comments

@dhsdshdhk
Copy link

dhsdshdhk commented Feb 16, 2019

What did you do?

Edited frames of a gif and saved them.

What did you expect to happen?

Expected them to be exactly as I saved.

What actually happened?

When I reopened, the frames were not the same.

What are your OS, Python and Pillow versions?

Linux, Python3.5, Pillow 5.4.1

# opening a gif
image = Image.open(host_path)
        if host_path.lower().endswith('gif'):
            frames = []
            try:
                while True:
                    frames.append(numpy.array(image.convert('RGB')))
                    image.seek(image.tell()+1)
            except EOFError:
                pass
            return numpy.asarray(frames)

[operations with the array...]

# saving a gif
    elif host_path.lower().endswith('gif'):
        frames = []
        for f in array:
            frames.append(Image.fromarray(f))
        frames[0].save(host_path, save_all=True, append_images=frames[1:], loop=0)
@radarhere radarhere added the GIF label Feb 17, 2019
@hugovk
Copy link
Member

hugovk commented Feb 17, 2019

I suspect as each frame is re-saved it's going through the GIF encoder afresh.

@dhsdshdhk
Copy link
Author

dhsdshdhk commented Feb 17, 2019

To make it clearer:

#!/usr/bin/env python3

'''
This illustrates the problem I'm having editing gifs.
'''

import numpy
from PIL import Image

# Open the gif.
def gif_open(filename):
    image = Image.open(filename)
    frames = []
    try:
        while True:
            frames.append(numpy.array(image.convert('RGB')))
            image.seek(image.tell()+1)
    except EOFError:
        pass

    return numpy.asarray(frames) # Transform list of frames into numpy array for convenience.

frames = gif_open('gif.gif')

frames[0][0][0][0] = 222
frames[0][0][0][1] = 111
frames[0][0][0][2] = 123

print('{} {} {}'.format(frames[0][0][0][0], frames[0][0][0][1], frames[0][0][0][2])) # Prints 222 111 123 as expected.

# Saving.
gif = []
for f in frames:
    gif.append(Image.fromarray(f))
gif[0].save('_gif.gif', save_all=True, append_images = gif[1:], loop=0)

# Now we open the new gif and check our first pixel of the first frame:
frames_new = gif_open('_gif.gif')
print('{} {} {}'.format(frames_new[0][0][0][0], frames_new[0][0][0][1], frames_new[0][0][0][2])) # Prints different values.

Is this expected behavior? I'm not sure but I think the problem lies in the gif reader being unable to read RGB, reading P or L and having to convert to RGB.

@Artheau
Copy link

Artheau commented Mar 8, 2019

The fix is described in #3665

@radarhere
Copy link
Member

If I run the code from your last comment with hopper.gif from the test images, I get the same pixel value both times.

Do you see a problem running it using https://github.com/python-pillow/Pillow/blob/master/Tests/images/hopper.gif? Are you able to upload an image that causes your script to print different values?

@dhsdshdhk
Copy link
Author

@radarhere the issue does not occur with static gifs.
For example, see the gif below:

gif

@radarhere
Copy link
Member

Here is a simpler, Pillow-only version of the code, using one of the test images -

from PIL import Image

im = Image.open('Tests/images/hopper.gif')
print(im.convert('RGB').getpixel((0, 0)))

im2 = Image.new("RGB", im.size)
im.convert('RGB').save('_gif.gif', save_all=True, append_images=[im2])

im = Image.open('_gif.gif')
print(im.convert('RGB').getpixel((0, 0)))

At a basic level, here is what it is happening - the image starts out in P mode with 256 colours. You convert it to RGB, discarding the palette data. Then the image is saved, which converts it to P mode, creating a new palette of maximum 256 colours. This new palette is not exactly the same as the old one, so the colours are slightly changed.

@dhsdshdhk
Copy link
Author

@radarhere so the solution is not converting to RGB?

@radarhere
Copy link
Member

I'm a little limited in my response because I don't know exactly what you're after. I presume that in between reading and writing the GIF frames, you plan to transform them somehow. A fundamental problem with that vague scenario is the limitation of 256 colours in each GIF frame. If you were to read a frame and paste a new image in the middle, one with many colours in it, then there might be more than 256 colours in that new frame, which can't be perfectly represented in the GIF format.

Also, be aware that as discussed in #3735, Pillow has a problem reading colours from the frames after the first frame at the moment.

However, regarding the first frame, yes, not converting the image to RGB would work. This would mean that the original palette is kept, and could be saved back.

Another option would be the quantize method. When writing multiframe GIFs at the moment, Pillow uses the web palette to convert images. Using quantize to convert the image back to P first instead seems like it would give you a better representation of the exact colours used. For example, the following code shows the same value at the start and the end.

from PIL import Image

im = Image.open('Tests/images/hopper.gif')
print(im.convert('RGB').getpixel((0, 0)))

im2 = Image.new("RGB", im.size)
im = im.convert('RGB')
im = im.quantize()
im.save('_gif.gif', save_all=True, append_images=[im2])

im = Image.open('_gif.gif')
print(im.convert('RGB').getpixel((0, 0)))

@dhsdshdhk
Copy link
Author

dhsdshdhk commented Mar 26, 2019

@radarhere

I'm a little limited in my response because I don't know exactly what you're after

I want to encode information in gifs by slightly changing the colors of pixels in each frame, and for that I need the values to remain all exactly the same after being edited and saved.

As you suggested, the quantize method does indeed seem to work, but I'm not sure if it will work in practice where thousands of pixels are changed. I also tried not converting to RGB, it works as well, but produces a black and white gif instead of a colored gif like the original, so I can't really use that.

@radarhere
Copy link
Member

Okay. I don't know why the GIF produced by not converting should be black and white, unless you're somehow losing the palette. For the record, the following code works fine for me.

from PIL import Image

im = Image.open('gif.gif')
print(im.convert('RGB').getpixel((0, 0)))

im.save('_gif.gif', save_all=True)

im = Image.open('_gif.gif')
print(im.convert('RGB').getpixel((0, 0)))

As an alternate, far simpler suggestion - if your goal is not actually to encode information in pixels, but just to add information to a GIF file, then you could use comments -

from PIL import Image

im = Image.open('gif.gif')

im.save('_gif.gif', save_all=True, comment=b"I am a comment")

im = Image.open('_gif.gif')
print(im.info["comment"])

@radarhere
Copy link
Member

Talking about changing thousands of pixels sounds like you are trying to encode a lot of information. The limitation of 256 colours might be a problem for you, but it would naturally depend on how much information you are trying to encode, how you are doing it, and how visually similar you require the resulting image to be. I imagine you already have an algorithm in mind, but here are my thoughts on a way to do it, while keeping the GIF the same. It's not perfect, but see what you think -

import math
from PIL import Image, ImageSequence, GifImagePlugin

def readImage(filename):
	im = Image.open(filename)
	frames = []
	
	def appendToFrames(im):
		im_copy = im.copy()
		im_copy.dispose_extent = im.dispose_extent
		im_copy.disposal_method = im.disposal_method
		im_copy.global_palette = im.global_palette
		frames.append(im_copy)

	appendToFrames(im)
	for i in range(1, im.n_frames):
		# Instead of combining the deltas into a single image as Pillow normally would
		# This clears the image data already loaded, giving us just the data from the new frame
		im.im = None
		im.dispose = None
	
		im.seek(i)
	
		appendToFrames(im)
	return frames

def writeImage(filename, frames):
	# Instead of saving a series of complete images, this saves the deltas
	with open('_gif.gif', 'wb') as fp:
		for s in GifImagePlugin._get_global_header(frames[0], frames[0].info):
			fp.write(s)
		for frame in frames:
			dispose_extent = frame.dispose_extent
			disposal_method = frame.disposal_method
			include_color_table = frame.palette.palette != frame.global_palette.palette
			frame = frame.crop(dispose_extent)
			GifImagePlugin._write_frame_data(fp, frame, dispose_extent[:2], {
				'disposal': disposal_method,
				'duration': frame.info["duration"],
				'include_color_table': include_color_table
			})

def embedMessage(frames, message):
	# Embed the message by changing pixels
	# 1 changed pixel indicates the start of a letter
	# Then there are 3 more pixels
	# Each of the 3 is either the next most similar pixel above it in the palette,
	# the next most similar below it in the palette, or unchanged
	# That gives 3 possible states, stored over 3 pixels gives 3*3*3 = 27 characters, the alphabet plus a space character
	def putSimilarPixel(frame, coord, state):
		if state == 0:
			return
		pixel = frame.getpixel(coord)
		
		palette = frame.im.getpalette("RGB")[:768]
		baseColor = palette[pixel*3:pixel*3+3]
		
		colors = []
		for i in range(int(len(palette) / 3)):
			colors.append(palette[i*3:i*3+3])
		minDistance = None
		paletteRange = range(pixel+1, int(len(palette) / 3)) if state == 1 else range(pixel)
		for i in paletteRange:
			color = colors[i]
			distance = abs(baseColor[0] - color[0]) + \
					   abs(baseColor[1] - color[1]) + \
					   abs(baseColor[2] - color[2])
			if minDistance is None or distance < minDistance:
				newPixel = i
				minDistance = distance
		
		frame.putpixel(coord, newPixel)
	
	charactersPerFrame = math.ceil(len(message) / len(frames))
	for frame in frames:
		states = []
		for char in message[:charactersPerFrame]:
			charPosition = characterDictionary.index(char)
			states += [
				1,
				[1,0,-1][math.floor(charPosition / 9)],
				[1,0,-1][math.floor(charPosition / 3 % 3)],
				[1,0,-1][math.floor(charPosition % 3)]
			]
		message = message[charactersPerFrame:]
		for y in range(frame.height):
			for x in range(frame.width):
				if (x+3 > frame.width or
				   frame.getpixel((x, y) )in [0, 255] or
				   frame.getpixel((x+1, y)) in [0, 255] or
				   frame.getpixel((x+2, y)) in [0, 255] or
				   frame.getpixel((x+3, y)) in [0, 255]):
					continue
				putSimilarPixel(frame, (x, y), states.pop(0))
				if not states:
					break
			if not states:
				break

def decodeMessage(frames, newFrames):
	# Compare the changed frames with the original frames, to decode the message
	message = ''
	charFound = False
	states = []
	for i, frame in enumerate(frames):
		for y in range(frame.height):
			for x in range(frame.width):
				oldPixel = frame.getpixel((x, y))
				newPixel = newFrames[i].getpixel((x, y))
				if charFound:
					if newPixel == oldPixel:
						state = 0
					elif newPixel < oldPixel:
						state = -1
					else:
						state = 1
					states.append(state)
					if len(states) == 3:
						message += characterDictionary[[1,0,-1].index(states[0])*9 + [1,0,-1].index(states[1])*3 + [1,0,-1].index(states[2])]
						states = []
						charFound = False
				elif oldPixel != newPixel:
					charFound = True
	return message

characterDictionary = 'abcdefghijklmnopqrstuvwxyz '

frames = readImage('gif.gif')
embedMessage(frames, 'i am encoded within the image')
writeImage('_gif.gif', frames)

frames = readImage('gif.gif')
newFrames = readImage('_gif.gif')
print(decodeMessage(frames, newFrames))

@dhsdshdhk
Copy link
Author

@radarhere

I don't know why the GIF produced by not converting should be black and white, unless you're somehow losing the palette.

I open and save gifs appending frame by frame, as shown in my second post here, unlike you did. I do that because it makes for easier manipulation. Maybe that's the reason I'm losing the palette? And if so, can I save the palette somehow?
As for the encoding, I already have an algorithm prepared, which I've used for other file formats, but thanks for your example, I can learn more with that.

@radarhere
Copy link
Member

Can you include here the exact code that's giving you the black and white images?

@dhsdshdhk
Copy link
Author

dhsdshdhk commented Mar 28, 2019

Yes, this is it:

#!/usr/bin/env python3

import numpy
from PIL import Image

def gif_open(filename):
    image = Image.open(filename)
    frames = []
    try:
        while True:
            frames.append(numpy.array(image))
            image.seek(image.tell()+1)
    except EOFError:
        pass

    return numpy.asarray(frames)

frames = gif_open('gif.gif')

gif = []
for f in frames:
    gif.append(Image.fromarray(f))
gif[0].save('_gif.gif', save_all=True, append_images = gif[1:], loop=0)

@radarhere
Copy link
Member

Yes, the conversion to a numpy array does not include the palette. Storing and restoring the palette should work for you -

#!/usr/bin/env python3

import numpy
from PIL import Image

def gif_open(filename):
    image = Image.open(filename)
    frames = []
    try:
        while True:
            frames.append([numpy.array(image), image.getpalette()])
            image.seek(image.tell()+1)
    except EOFError:
        pass

    return numpy.asarray(frames)

frames = gif_open('gif.gif')

gif = []
for f, palette in frames:
	image = Image.fromarray(f)
	image.putpalette(palette)
	gif.append(image)
gif[0].save('_gif.gif', save_all=True, append_images = gif[1:], loop=0)

@dhsdshdhk
Copy link
Author

Works! So the only problem left is:

Pillow has a problem reading colours from the frames after the first frame at the moment.

Hopefully a fix will be out soon. Anyway, you may close this issue now. Thanks for helping :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants