Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AVIF support #115

Merged
merged 3 commits into from
Jul 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
.vscode
.ruff_cache
.coverage*
.DS_Store
14 changes: 12 additions & 2 deletions docs/guide/save.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file added tests/images/tree.avif
Binary file not shown.
21 changes: 21 additions & 0 deletions tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import filetype

from willow.image import (
AvifImageFile,
BMPImageFile,
GIFImageFile,
HeicImageFile,
Expand Down Expand Up @@ -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):
zerolab marked this conversation as resolved.
Show resolved Hide resolved
"""
Expand All @@ -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()
Expand Down
56 changes: 43 additions & 13 deletions tests/test_pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

import filetype
from PIL import Image as PILImage
from PIL import ImageChops

from willow.image import (
AvifImageFile,
BadImageOperationError,
GIFImageFile,
JPEGImageFile,
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
101 changes: 82 additions & 19 deletions tests/test_wand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -252,44 +306,53 @@ 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))

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

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):
Expand Down
2 changes: 2 additions & 0 deletions willow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ def setup():
from xml.etree import ElementTree

from willow.image import (
AvifImageFile,
BMPImageFile,
GIFImageFile,
HeicImageFile,
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions willow/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def save(self, image_format, output):
"webp",
"svg",
"heic",
"avif",
]:
raise ValueError("Unknown image format: %s" % image_format)

Expand Down Expand Up @@ -264,6 +265,16 @@ def mime_type(self):
return "image/heiс"


class AvifImageFile(ImageFile):
zerolab marked this conversation as resolved.
Show resolved Hide resolved
@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,
Expand All @@ -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,
zerolab marked this conversation as resolved.
Show resolved Hide resolved
}
Loading