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/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) diff --git a/tests/images/tree.avif b/tests/images/tree.avif new file mode 100644 index 00000000..23ef358b Binary files /dev/null and b/tests/images/tree.avif differ 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): 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)