From 5b62f87b4eb7816030127c5a39f3c2f746757d34 Mon Sep 17 00:00:00 2001 From: salty-ivy Date: Sat, 8 Jul 2023 17:03:35 +0100 Subject: [PATCH 1/3] Add support for AVIF --- willow/__init__.py | 2 ++ willow/image.py | 12 ++++++++++++ willow/plugins/pillow.py | 12 +++++++++++- willow/plugins/wand.py | 23 ++++++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/willow/__init__.py b/willow/__init__.py index 69911d39..75b68ea0 100644 --- a/willow/__init__.py +++ b/willow/__init__.py @@ -5,6 +5,7 @@ def setup(): from xml.etree import ElementTree from willow.image import ( + AvifImageFile, BMPImageFile, GIFImageFile, HeicImageFile, @@ -31,6 +32,7 @@ def setup(): registry.register_image_class(RGBAImageBuffer) registry.register_image_class(SvgImageFile) registry.register_image_class(SvgImage) + registry.register_image_class(AvifImageFile) registry.register_plugin(pillow) registry.register_plugin(wand) diff --git a/willow/image.py b/willow/image.py index d069f0df..2262f161 100644 --- a/willow/image.py +++ b/willow/image.py @@ -118,6 +118,7 @@ def save(self, image_format, output): "webp", "svg", "heic", + "avif", ]: raise ValueError("Unknown image format: %s" % image_format) @@ -264,6 +265,16 @@ def mime_type(self): return "image/heiс" +class AvifImageFile(ImageFile): + @property + def format_name(self): + return "avif" + + @property + def mime_type(self): + return "image/avif" + + INITIAL_IMAGE_CLASSES = { # A mapping of image formats to their initial class image_types.Jpeg().extension: JPEGImageFile, @@ -274,4 +285,5 @@ def mime_type(self): image_types.Webp().extension: WebPImageFile, "svg": SvgImageFile, image_types.Heic().extension: HeicImageFile, + image_types.Avif().extension: AvifImageFile, } diff --git a/willow/plugins/pillow.py b/willow/plugins/pillow.py index 98f978b2..73811d69 100644 --- a/willow/plugins/pillow.py +++ b/willow/plugins/pillow.py @@ -1,9 +1,10 @@ try: - from pillow_heif import HeifImagePlugin # noqa: F401 + from pillow_heif import AvifImagePlugin, HeifImagePlugin # noqa: F401 except ImportError: pass from willow.image import ( + AvifImageFile, BadImageOperationError, BMPImageFile, GIFImageFile, @@ -227,6 +228,14 @@ def save_as_heic(self, f, quality=80, lossless=False): self.image.save(f, "HEIF", quality=quality) return HeicImageFile(f) + @Image.operation + def save_as_avif(self, f, quality=80, lossless=False): + if lossless: + self.image.save(f, "AVIF", quality=-1, chroma=444) + else: + self.image.save(f, "AVIF", quality=quality) + return AvifImageFile(f) + @Image.operation def auto_orient(self): # JPEG files can be orientated using an EXIF tag. @@ -279,6 +288,7 @@ def get_pillow_image(self): @Image.converter_from(TIFFImageFile) @Image.converter_from(WebPImageFile) @Image.converter_from(HeicImageFile) + @Image.converter_from(AvifImageFile) def open(cls, image_file): image_file.f.seek(0) image = _PIL_Image().open(image_file.f) diff --git a/willow/plugins/wand.py b/willow/plugins/wand.py index a1abb860..b3506716 100644 --- a/willow/plugins/wand.py +++ b/willow/plugins/wand.py @@ -2,6 +2,7 @@ from ctypes import c_char_p, c_void_p from willow.image import ( + AvifImageFile, BadImageOperationError, BMPImageFile, GIFImageFile, @@ -170,7 +171,6 @@ def save_as_gif(self, f): @Image.operation def save_as_webp(self, f, quality=80, lossless=False): with self.image.convert("webp") as converted: - converted.compression_quality = quality if lossless: library = _wand_api().library library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p] @@ -179,10 +179,30 @@ def save_as_webp(self, f, quality=80, lossless=False): b"webp:lossless", b"true", ) + else: + converted.compression_quality = quality converted.save(file=f) return WebPImageFile(f) + @Image.operation + def save_as_avif(self, f, quality=80, lossless=False): + with self.image.convert("avif") as converted: + if lossless: + converted.compression_quality = 100 + library = _wand_api().library + library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p] + library.MagickSetOption( + converted.wand, + b"heic:lossless", + b"true", + ) + else: + converted.compression_quality = quality + converted.save(file=f) + + return AvifImageFile(f) + @Image.operation def auto_orient(self): image = self.image @@ -230,6 +250,7 @@ def get_wand_image(self): @Image.converter_from(TIFFImageFile, cost=150) @Image.converter_from(WebPImageFile, cost=150) @Image.converter_from(HeicImageFile, cost=150) + @Image.converter_from(AvifImageFile, cost=150) def open(cls, image_file): image_file.f.seek(0) image = _wand_image().Image(file=image_file.f) From 0a8ffe69c6a17bc644f453ccf9a7f3106a0e3c3f Mon Sep 17 00:00:00 2001 From: salty-ivy Date: Sat, 8 Jul 2023 17:04:10 +0100 Subject: [PATCH 2/3] Add tests Co-Authored-By: zerolab --- .gitignore | 1 + tests/images/tree.avif | Bin 0 -> 15049 bytes tests/test_image.py | 21 +++++++++ tests/test_pillow.py | 56 +++++++++++++++++------ tests/test_wand.py | 101 +++++++++++++++++++++++++++++++++-------- 5 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 tests/images/tree.avif diff --git a/.gitignore b/.gitignore index 490fa52e..b121ee09 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .vscode .ruff_cache .coverage* +.DS_Store diff --git a/tests/images/tree.avif b/tests/images/tree.avif new file mode 100644 index 0000000000000000000000000000000000000000..23ef358bd01e49319632f16c62783655057b4959 GIT binary patch literal 15049 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 Oz9|u3;EJdq@c#fuD#hji literal 0 HcmV?d00001 diff --git a/tests/test_image.py b/tests/test_image.py index 0df86846..afbfeb6f 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -6,6 +6,7 @@ import filetype from willow.image import ( + AvifImageFile, BMPImageFile, GIFImageFile, HeicImageFile, @@ -169,6 +170,16 @@ def test_heic(self): self.assertEqual(height, 241) self.assertEqual(image.mime_type, "image/heiс") + def test_avif(self): + with open("tests/images/tree.avif", "rb") as f: + image = Image.open(f) + width, height = image.get_size() + + self.assertIsInstance(image, AvifImageFile) + self.assertEqual(width, 320) + self.assertEqual(height, 241) + self.assertEqual(image.mime_type, "image/avif") + class TestSaveImage(unittest.TestCase): """ @@ -194,6 +205,16 @@ def test_save_as_heic(self): self.assertIsInstance(image, HeicImageFile) self.assertEqual(image.mime_type, "image/heiс") + def test_save_as_avif(self): + with open("tests/images/sails.bmp", "rb") as f: + image = Image.open(f) + buf = io.BytesIO() + image.save("avif", buf) + buf.seek(0) + image = Image.open(buf) + self.assertIsInstance(image, AvifImageFile) + self.assertEqual(image.mime_type, "image/avif") + def test_save_as_foo(self): image = Image() image.save_as_jpeg = mock.MagicMock() diff --git a/tests/test_pillow.py b/tests/test_pillow.py index 0fafe211..abd7df38 100644 --- a/tests/test_pillow.py +++ b/tests/test_pillow.py @@ -3,8 +3,10 @@ import filetype from PIL import Image as PILImage +from PIL import ImageChops from willow.image import ( + AvifImageFile, BadImageOperationError, GIFImageFile, JPEGImageFile, @@ -14,6 +16,7 @@ from willow.plugins.pillow import PillowImage, UnsupportedRotation, _PIL_Image no_webp_support = not PillowImage.is_format_supported("WEBP") +no_avif_support = not PillowImage.is_format_supported("AVIF") class TestPillowOperations(unittest.TestCase): @@ -298,27 +301,54 @@ def test_open_webp_w_alpha(self): self.assertFalse(image.has_animation()) @unittest.skipIf(no_webp_support, "Pillow does not have WebP support") - def test_open_webp_quality(self): + def test_save_webp_quality(self): high_quality = self.image.save_as_webp(io.BytesIO(), quality=90) low_quality = self.image.save_as_webp(io.BytesIO(), quality=30) self.assertTrue(low_quality.f.tell() < high_quality.f.tell()) @unittest.skipIf(no_webp_support, "Pillow does not have WebP support") - def test_open_webp_lossless(self): + def test_save_webp_lossless(self): original_image = self.image.image + lossless_file = self.image.save_as_webp(io.BytesIO(), lossless=True) lossless_image = PillowImage.open(lossless_file).image - identically = True - for x in range(original_image.width): - for y in range(original_image.height): - original_pixel = original_image.getpixel((x, y)) - # don't compare fully transparent pixels - if original_pixel[3] == 0: - continue - if original_pixel != lossless_image.getpixel((x, y)): - identically = False - break - self.assertTrue(identically) + + diff = ImageChops.difference(original_image, lossless_image) + self.assertIsNone(diff.getbbox()) + + @unittest.skipIf(no_avif_support, "Pillow does not have AVIF support") + def test_save_as_avif(self): + output = io.BytesIO() + return_value = self.image.save_as_avif(output) + output.seek(0) + + self.assertEqual(filetype.guess_extension(output), "avif") + self.assertIsInstance(return_value, AvifImageFile) + self.assertEqual(return_value.f, output) + + @unittest.skipIf(no_avif_support, "Pillow does not have AVIF support") + def test_open_avif(self): + with open("tests/images/tree.webp", "rb") as f: + image = PillowImage.open(AvifImageFile(f)) + + self.assertFalse(image.has_alpha()) + self.assertFalse(image.has_animation()) + + @unittest.skipIf(no_avif_support, "Pillow does not have AVIF support") + def test_save_avif_quality(self): + high_quality = self.image.save_as_avif(io.BytesIO(), quality=90) + low_quality = self.image.save_as_avif(io.BytesIO(), quality=30) + self.assertTrue(low_quality.f.tell() < high_quality.f.tell()) + + @unittest.skipIf(no_avif_support, "Pillow does not have AVIF support") + def test_save_avif_lossless(self): + original_image = self.image.image + + lossless_file = self.image.save_as_avif(io.BytesIO(), lossless=True) + lossless_image = PillowImage.open(lossless_file).image + + diff = ImageChops.difference(original_image, lossless_image) + self.assertIsNone(diff.getbbox()) class TestPillowImageOrientation(unittest.TestCase): diff --git a/tests/test_wand.py b/tests/test_wand.py index aa0f027b..9b9c40ba 100644 --- a/tests/test_wand.py +++ b/tests/test_wand.py @@ -3,8 +3,10 @@ import filetype from PIL import Image as PILImage +from wand import version as WAND_VERSION from willow.image import ( + AvifImageFile, BadImageOperationError, GIFImageFile, JPEGImageFile, @@ -14,6 +16,7 @@ from willow.plugins.wand import UnsupportedRotation, WandImage, _wand_image no_webp_support = not WandImage.is_format_supported("WEBP") +no_avif_support = not WandImage.is_format_supported("AVIF") class TestWandOperations(unittest.TestCase): @@ -242,7 +245,58 @@ def test_get_wand_image(self): self.assertIsInstance(wand_image, _wand_image().Image) - @unittest.skipIf(no_webp_support, "imagemagic was not built with WebP support") + @unittest.skipIf(no_avif_support, "ImageMagick was built without AVIF support") + def test_open_avif(self): + with open("tests/images/tree.avif", "rb") as f: + image = WandImage.open(AvifImageFile(f)) + + self.assertFalse(image.has_alpha()) + self.assertFalse(image.has_animation()) + + @unittest.skipIf(no_avif_support, "ImageMagick was built without AVIF support") + def test_save_as_avif(self): + output = io.BytesIO() + return_value = self.image.save_as_avif(output) + output.seek(0) + + self.assertEqual(filetype.guess_extension(output), "avif") + self.assertIsInstance(return_value, AvifImageFile) + self.assertEqual(return_value.f, output) + + @unittest.skipIf(no_avif_support, "ImageMagick was built without AVIF support") + def test_save_avif_quality(self): + high_quality = self.image.save_as_avif(io.BytesIO(), quality=90) + low_quality = self.image.save_as_avif(io.BytesIO(), quality=30) + self.assertTrue(low_quality.f.tell() < high_quality.f.tell()) + + @unittest.skipIf(no_webp_support, "ImageMagick was built without AVIF support") + def test_save_avif_lossless(self): + original_image = self.image.image + + lossless_file = self.image.save_as_avif(io.BytesIO(), lossless=True) + lossless_image = WandImage.open(lossless_file).image + + magick_version = WAND_VERSION.MAGICK_VERSION_INFO + if magick_version >= (7, 1): + # we allow a small margin of error to account for OS/library version differences + # Ref: https://github.com/bigcat88/pillow_heif/blob/3798f0df6b12c19dfa8fd76dd6259b329bf88029/tests/write_test.py#L415-L422 + _, result_metric = original_image.compare( + lossless_image, metric="root_mean_square" + ) + self.assertTrue(result_metric <= 0.02) + else: + identical = True + for x in range(original_image.width): + for y in range(original_image.height): + original_pixel = original_image[x, y] + # don't compare fully transparent pixels + if original_pixel.alpha == 0.0: + continue + if original_pixel != lossless_image[x, y]: + break + self.assertTrue(identical) + + @unittest.skipIf(no_webp_support, "ImageMagick was built without WebP support") def test_save_as_webp(self): output = io.BytesIO() return_value = self.image.save_as_webp(output) @@ -252,7 +306,7 @@ def test_save_as_webp(self): self.assertIsInstance(return_value, WebPImageFile) self.assertEqual(return_value.f, output) - @unittest.skipIf(no_webp_support, "imagemagic was not built with WebP support") + @unittest.skipIf(no_webp_support, "ImageMagick was built without WebP support") def test_open_webp(self): with open("tests/images/tree.webp", "rb") as f: image = WandImage.open(WebPImageFile(f)) @@ -260,7 +314,7 @@ def test_open_webp(self): self.assertFalse(image.has_alpha()) self.assertFalse(image.has_animation()) - @unittest.skipIf(no_webp_support, "imagemagic was not built with WebP support") + @unittest.skipIf(no_webp_support, "ImageMagick was built without WebP support") def test_open_webp_w_alpha(self): with open("tests/images/tux_w_alpha.webp", "rb") as f: image = WandImage.open(WebPImageFile(f)) @@ -268,28 +322,37 @@ def test_open_webp_w_alpha(self): self.assertTrue(image.has_alpha()) self.assertFalse(image.has_animation()) - @unittest.skipIf(no_webp_support, "imagemagic does not have WebP support") - def test_open_webp_quality(self): + @unittest.skipIf(no_webp_support, "ImageMagick was built without WebP support") + def test_save_webp_quality(self): high_quality = self.image.save_as_webp(io.BytesIO(), quality=90) low_quality = self.image.save_as_webp(io.BytesIO(), quality=30) self.assertTrue(low_quality.f.tell() < high_quality.f.tell()) - @unittest.skipIf(no_webp_support, "imagemagic does not have WebP support") - def test_open_webp_lossless(self): + @unittest.skipIf(no_webp_support, "ImageMagick was built without WebP support") + def test_save_webp_lossless(self): original_image = self.image.image - lossless_file = self.image.save_as_webp(io.BytesIO(), lossless=True) + + new_f = io.BytesIO() + lossless_file = self.image.save_as_webp(new_f, lossless=True) lossless_image = WandImage.open(lossless_file).image - identically = True - for x in range(original_image.width): - for y in range(original_image.height): - original_pixel = original_image[x, y] - # don't compare fully transparent pixels - if original_pixel.alpha == 0.0: - continue - if original_pixel != lossless_image[x, y]: - identically = False - break - self.assertTrue(identically) + + magick_version = WAND_VERSION.MAGICK_VERSION_INFO + if magick_version >= (7, 1): + _, result_metric = original_image.compare( + lossless_image, metric="root_mean_square" + ) + self.assertTrue(result_metric <= 0.001) + else: + identical = True + for x in range(original_image.width): + for y in range(original_image.height): + original_pixel = original_image[x, y] + # don't compare fully transparent pixels + if original_pixel.alpha == 0.0: + continue + if original_pixel != lossless_image[x, y]: + break + self.assertTrue(identical) class TestWandImageOrientation(unittest.TestCase): From 9fe9f80823b4fc61cd4bc1932eaefaf505060c0a Mon Sep 17 00:00:00 2001 From: salty-ivy Date: Sat, 8 Jul 2023 17:16:43 +0100 Subject: [PATCH 3/3] Update documentation for AVIF support --- docs/guide/save.rst | 14 ++++++++++++-- docs/reference.rst | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/guide/save.rst b/docs/guide/save.rst index 751732c3..d391972a 100644 --- a/docs/guide/save.rst +++ b/docs/guide/save.rst @@ -7,6 +7,10 @@ In Willow there are separate save operations for each image format: - :meth:`~Image.save_as_png` - :meth:`~Image.save_as_gif` - :meth:`~Image.save_as_webp` + - :meth:`~Image.save_as_svg` + - :meth:`~Image.save_as_heic` + - :meth:`~Image.save_as_avif` + All three take one positional argument, the file-like object to write the image data to. @@ -46,14 +50,20 @@ can force Willow to always save a "progressive" JPEG file by setting the with open('progressive.jpg', 'wb') as f: i.save_as_jpeg(f, progressive=True) -Lossless WebP +Lossless AVIF, HEIC and WebP ----------------- -You can encode the image to WebP without any loss by setting the +You can encode the image to AVIF, HEIC (Pillow-only) and WebP without any loss by setting the ``lossless`` keyword argument to ``True``: .. code-block:: python + with open('lossless.avif', 'wb') as f: + i.save_as_avif(f, lossless=True) + + with open('lossless.heic', 'wb') as f: + i.save_as_heic(f, lossless=True) + with open('lossless.webp', 'wb') as f: i.save_as_webp(f, lossless=True) diff --git a/docs/reference.rst b/docs/reference.rst index 1c8b86ac..23445acd 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -291,6 +291,21 @@ Here's a full list of operations provided by Willow out of the box: image.save_as_heic(f) +.. method:: save_as_avif(file, quality=80, lossless=False) + + (requires the pillow-heif library) + + Saves the image to the specified file-like object in AVIF format. + When saving with `lossless=True`, the `quality` value is set to `-1` and `chroma` to `444`. + + returns a ``AvifImageFile`` wrapping the file. + + .. code-block:: python + + with open('out.avif', 'wb') as f: + image.save_as_avif(f) + + .. method:: save_as_svg(file) (SVG images only)