Skip to content

Commit

Permalink
Add animated GIF support into Pillow
Browse files Browse the repository at this point in the history
  • Loading branch information
kaedroho committed Apr 12, 2019
1 parent ca7b023 commit 7ad41b7
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 16 deletions.
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Available operations
Operation Pillow Wand OpenCV
=================================== ==================== ==================== ====================
``get_size()`` ✓ ✓ ✓
``get_frame_count()`` ✓** ✓ ✓**
``get_frame_count()`` ✓ ✓ ✓**
``get_pixel_count()`` ✓ ✓ ✓
``resize(size)`` ✓ ✓
``crop(rect)`` ✓ ✓
Expand All @@ -84,7 +84,7 @@ Operation Pillow Wand Op
``save_as_png(file)`` ✓ ✓
``save_as_gif(file)`` ✓ ✓
``has_alpha()`` ✓ ✓ ✓*
``has_animation()`` ✓* ✓ ✓*
``has_animation()`` ✓ ✓ ✓*
``get_pillow_image()`` ✓
``get_wand_image()`` ✓
``detect_features()`` ✓
Expand Down
3 changes: 0 additions & 3 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,3 @@ or Wand.

- `Pillow installation <http://pillow.readthedocs.org/en/3.0.x/installation.html#basic-installation>`_
- `Wand installation <http://docs.wand-py.org/en/0.4.2/guide/install.html>`_

Note that Pillow doesn't support animated GIFs and Wand isn't as fast.
Installing both will give best results.
25 changes: 18 additions & 7 deletions tests/test_pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from PIL import Image as PILImage

from willow.image import JPEGImageFile, PNGImageFile, GIFImageFile, WebPImageFile
from willow.plugins.pillow import _PIL_Image, PillowImage, UnsupportedRotation
from willow.plugins.pillow import _PIL_Image, PillowImage, PillowAnimatedImage, UnsupportedRotation


no_webp_support = not PillowImage.is_format_supported("WEBP")
Expand Down Expand Up @@ -136,6 +136,19 @@ def test_save_as_gif_converts_back_to_supported_mode(self):
image = _PIL_Image().open(output)
self.assertEqual(image.mode, 'P')

def test_save_as_gif_animated(self):
with open('tests/images/newtons_cradle.gif', 'rb') as f:
image = PillowAnimatedImage.open(GIFImageFile(f))

output = io.BytesIO()
return_value = image.save_as_gif(output)
output.seek(0)

loaded_image = PillowAnimatedImage.open(GIFImageFile(output))

self.assertTrue(loaded_image.has_animation())
self.assertEqual(loaded_image.get_frame_count(), 34)

def test_has_alpha(self):
has_alpha = self.image.has_alpha()
self.assertTrue(has_alpha)
Expand Down Expand Up @@ -184,22 +197,20 @@ def test_save_transparent_gif(self):
# Check that the alpha of pixel 1,1 is 0
self.assertEqual(image.image.convert('RGBA').getpixel((1, 1))[3], 0)

@unittest.expectedFailure # Pillow doesn't support animation
def test_animated_gif(self):
with open('tests/images/newtons_cradle.gif', 'rb') as f:
image = PillowImage.open(GIFImageFile(f))
image = PillowAnimatedImage.open(GIFImageFile(f))

self.assertFalse(image.has_alpha())
self.assertTrue(image.has_alpha())
self.assertTrue(image.has_animation())

@unittest.expectedFailure # Pillow doesn't support animation
def test_resize_animated_gif(self):
with open('tests/images/newtons_cradle.gif', 'rb') as f:
image = PillowImage.open(GIFImageFile(f))
image = PillowAnimatedImage.open(GIFImageFile(f))

resized_image = image.resize((100, 75))

self.assertFalse(resized_image.has_alpha())
self.assertTrue(resized_image.has_alpha())
self.assertTrue(resized_image.has_animation())

def test_get_pillow_image(self):
Expand Down
13 changes: 13 additions & 0 deletions tests/test_wand.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ def test_save_as_gif(self):
self.assertIsInstance(return_value, GIFImageFile)
self.assertEqual(return_value.f, output)

def test_save_as_gif_animated(self):
with open('tests/images/newtons_cradle.gif', 'rb') as f:
image = WandImage.open(GIFImageFile(f))

output = io.BytesIO()
return_value = image.save_as_gif(output)
output.seek(0)

loaded_image = WandImage.open(GIFImageFile(output))

self.assertTrue(loaded_image.has_animation())
self.assertEqual(loaded_image.get_frame_count(), 34)

def test_has_alpha(self):
has_alpha = self.image.has_alpha()
self.assertTrue(has_alpha)
Expand Down
151 changes: 147 additions & 4 deletions willow/plugins/pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ def _PIL_Image():
return PIL.Image


def is_format_supported(image_format):
formats = _PIL_Image().registered_extensions()
return image_format in formats.values()


class PillowImage(Image):
def __init__(self, image):
self.image = image
Expand All @@ -29,8 +34,7 @@ def check(cls):

@classmethod
def is_format_supported(cls, image_format):
formats = _PIL_Image().registered_extensions()
return image_format in formats.values()
return is_format_supported(image_format)

@Image.operation
def get_size(self):
Expand Down Expand Up @@ -221,7 +225,6 @@ def get_pillow_image(self):
@classmethod
@Image.converter_from(JPEGImageFile)
@Image.converter_from(PNGImageFile)
@Image.converter_from(GIFImageFile, cost=200)
@Image.converter_from(BMPImageFile)
@Image.converter_from(TIFFImageFile)
@Image.converter_from(WebPImageFile)
Expand Down Expand Up @@ -251,4 +254,144 @@ def to_buffer_rgba(self):
return RGBAImageBuffer(image.size, image.tobytes())


willow_image_classes = [PillowImage]
class PillowAnimatedImage(Image):
def __init__(self, frames):
self.frames = frames

@classmethod
def check(cls):
_PIL_Image()

@classmethod
def is_format_supported(cls, image_format):
return is_format_supported(image_format)

@Image.operation
def get_size(self):
return self.frames[0].get_size()

@Image.operation
def get_frame_count(self):
return len(self.frames)

@Image.operation
def has_alpha(self):
return self.frames[0].has_alpha()

@Image.operation
def has_animation(self):
return self.get_frame_count() > 1

@Image.operation
def resize(self, size):
return PillowAnimatedImage([frame.resize(size) for frame in self.frames])

@Image.operation
def crop(self, rect):
return PillowAnimatedImage([frame.crop(rect) for frame in self.frames])

@Image.operation
def rotate(self, angle):
return PillowAnimatedImage([frame.rotate(angle) for frame in self.frames])

@Image.operation
def set_background_color_rgb(self, color):
return PillowAnimatedImage([frame.set_background_color_rgb(color) for frame in self.frames])

@Image.operation
def save_as_jpeg(self, f, quality=85, optimize=False, progressive=False):
if self.has_animation():
pass # TODO: Raise warning

return self.frames[0].save_as_jpeg(f, quality=quality, optimize=optimize, progressive=progressive)

@Image.operation
def save_as_png(self, f, optimize=False):
if self.has_animation():
pass # TODO: Raise warning

return self.frames[0].save_as_png(f, optimize=optimize)

@Image.operation
def save_as_gif(self, f):
image = self.frames[0].image
frames = self.frames

# All gif files use either the L or P mode but we sometimes convert them
# to RGB/RGBA to improve the quality of resizing. We must make sure that
# they are converted back before saving.
if image.mode not in ['L', 'P']:
frames = [
frame.convert('P', palette=_PIL_Image().ADAPTIVE)
for frame in frames
]

params = {
'save_all': True,
'duration': image.info['duration'],
'append_images': [frame.image for frame in frames[1:]]
}

if 'transparency' in image.info:
params['transparency'] = image.info['transparency']

image.save(f, 'GIF', **params)

return GIFImageFile(f)

@Image.operation
def save_as_webp(self, f):
if self.has_animation():
pass # TODO: Raise warning

return self.frames[0].save_as_png(f, optimize=optimize)

@Image.operation
def auto_orient(self):
# Animated GIFs don't have EXIF data
return self

@classmethod
@Image.converter_from(GIFImageFile)
def open(cls, image_file):
image_file.f.seek(0)
image = _PIL_Image().open(image_file.f)

frame = image
frames = []
while frame:
frames.append(frame.copy())

try:
foo = image.seek(image.tell() + 1)
except EOFError:
break

return cls([PillowImage(frame) for frame in frames])

@Image.converter_to(RGBImageBuffer, cost=200)
def to_buffer_rgb(self):
if self.has_animation():
pass # TODO: Raise warning

image = self.image

if image.mode != 'RGB':
image = image.convert('RGB')

return RGBImageBuffer(image.size, image.tobytes())

@Image.converter_to(RGBAImageBuffer, cost=200)
def to_buffer_rgba(self):
if self.has_animation():
pass # TODO: Raise warning

image = self.image

if image.mode != 'RGBA':
image = image.convert('RGBA')

return RGBAImageBuffer(image.size, image.tobytes())


willow_image_classes = [PillowImage, PillowAnimatedImage]
3 changes: 3 additions & 0 deletions willow/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,9 @@ def find_closest_image_class(self, start, image_classes):
for image_class in image_classes:
path, cost = self.find_shortest_path(start, image_class)

if path is None:
continue

if current_cost is None or cost < current_cost:
current_class = image_class
current_cost = cost
Expand Down

0 comments on commit 7ad41b7

Please sign in to comment.