diff --git a/README.rst b/README.rst index 3b55e29..fb61852 100644 --- a/README.rst +++ b/README.rst @@ -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)`` ✓ ✓ @@ -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()`` ✓ diff --git a/docs/installation.rst b/docs/installation.rst index f89b6bd..013f797 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -19,6 +19,3 @@ or Wand. - `Pillow installation `_ - `Wand installation `_ - -Note that Pillow doesn't support animated GIFs and Wand isn't as fast. -Installing both will give best results. diff --git a/tests/test_pillow.py b/tests/test_pillow.py index ae30881..8ddf81b 100644 --- a/tests/test_pillow.py +++ b/tests/test_pillow.py @@ -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") @@ -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) @@ -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): diff --git a/tests/test_wand.py b/tests/test_wand.py index 79632f7..a1803cc 100644 --- a/tests/test_wand.py +++ b/tests/test_wand.py @@ -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) diff --git a/willow/plugins/pillow.py b/willow/plugins/pillow.py index a1b1cf4..dae1881 100644 --- a/willow/plugins/pillow.py +++ b/willow/plugins/pillow.py @@ -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 @@ -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): @@ -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) @@ -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] diff --git a/willow/registry.py b/willow/registry.py index 7890852..123d80c 100644 --- a/willow/registry.py +++ b/willow/registry.py @@ -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