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 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):