From 0fc5897742944db4711ee7c700ab9db742b46c1f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Mar 2026 20:54:53 +1100 Subject: [PATCH 1/5] Simplified code --- src/PIL/AvifImagePlugin.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 43c39a9fbe7..60dcca23d69 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -4,7 +4,7 @@ from io import BytesIO from typing import IO -from . import ExifTags, Image, ImageFile +from . import ExifTags, Image, ImageFile, ImageSequence try: from . import _avif @@ -239,18 +239,12 @@ def _save( is_single_frame = total == 1 try: for ims in [im] + append_images: - # Get number of frames in this image - nfr = getattr(ims, "n_frames", 1) - - for idx in range(nfr): - ims.seek(idx) - + for frame in ImageSequence.Iterator(ims): # Make sure image mode is supported - frame = ims - rawmode = ims.mode - if ims.mode not in {"RGB", "RGBA"}: - rawmode = "RGBA" if ims.has_transparency_data else "RGB" - frame = ims.convert(rawmode) + rawmode = frame.mode + if frame.mode not in {"RGB", "RGBA"}: + rawmode = "RGBA" if frame.has_transparency_data else "RGB" + frame = frame.convert(rawmode) # Update frame duration if isinstance(duration, (list, tuple)): From 0d12ee9257bc23bc8c07a0cedd892e52728ed9d8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Mar 2026 23:42:53 +1100 Subject: [PATCH 2/5] Do not iterate through all images to check if there is only one frame --- src/PIL/AvifImagePlugin.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 60dcca23d69..1d84afeef9f 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -153,10 +153,6 @@ def _save( else: append_images = [] - total = 0 - for ims in [im] + append_images: - total += getattr(ims, "n_frames", 1) - quality = info.get("quality", 75) if not isinstance(quality, int) or quality < 0 or quality > 100: msg = "Invalid quality setting" @@ -236,7 +232,7 @@ def _save( frame_idx = 0 frame_duration = 0 cur_idx = im.tell() - is_single_frame = total == 1 + is_single_frame = not append_images and not getattr(im, "is_animated", False) try: for ims in [im] + append_images: for frame in ImageSequence.Iterator(ims): From 049a6ae63126990c592413f76c78e45c5e932fcb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Mar 2026 23:38:32 +1100 Subject: [PATCH 3/5] Load frames with 4:0:0 subsampling in L mode with AVIF_RGB_FORMAT_GRAY --- Tests/test_file_avif.py | 11 ++++++++++- src/_avif.c | 31 +++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index a25f7717797..300bdb7d0bc 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -29,6 +29,7 @@ assert_image_similar_tofile, hopper, skip_unless_feature, + skip_unless_feature_version, ) try: @@ -46,7 +47,7 @@ def assert_xmp_orientation(xmp: bytes, expected: int) -> None: assert int(xmp.split(b'tiff:Orientation="')[1].split(b'"')[0]) == expected -def roundtrip(im: ImageFile.ImageFile, **options: Any) -> ImageFile.ImageFile: +def roundtrip(im: Image.Image, **options: Any) -> ImageFile.ImageFile: out = BytesIO() im.save(out, "AVIF", **options) return Image.open(out) @@ -420,6 +421,14 @@ def test_encoder_subsampling(self, tmp_path: Path, subsampling: str) -> None: test_file = tmp_path / "temp.avif" im.save(test_file, subsampling=subsampling) + @skip_unless_feature_version("avif", "1.3.0") + def test_encoding_subsampling_400(self) -> None: + im = hopper() + reloaded = roundtrip(im, subsampling="4:0:0") + + assert reloaded.mode == "L" + assert_image_similar(reloaded, im.convert("L"), 1.67) + def test_encoder_subsampling_invalid(self, tmp_path: Path) -> None: with Image.open(TEST_AVIF_FILE) as im: test_file = tmp_path / "temp.avif" diff --git a/src/_avif.c b/src/_avif.c index 5e8b9fe8e93..d62ccb7d25f 100644 --- a/src/_avif.c +++ b/src/_avif.c @@ -706,8 +706,19 @@ _decoder_get_info(AvifDecoderObject *self) { PyObject *xmp = NULL; PyObject *ret = NULL; - if (image->xmp.size) { - xmp = PyBytes_FromStringAndSize((const char *)image->xmp.data, image->xmp.size); + char *mode; + if (decoder->alphaPresent) { + mode = "RGBA"; +#if AVIF_VERSION >= 1030000 // 1.3.0 + } else if (image->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) { + mode = "L"; +#endif + } else { + mode = "RGB"; + } + + if (image->icc.size) { + icc = PyBytes_FromStringAndSize((const char *)image->icc.data, image->icc.size); } if (image->exif.size) { @@ -715,8 +726,8 @@ _decoder_get_info(AvifDecoderObject *self) { PyBytes_FromStringAndSize((const char *)image->exif.data, image->exif.size); } - if (image->icc.size) { - icc = PyBytes_FromStringAndSize((const char *)image->icc.data, image->icc.size); + if (image->xmp.size) { + xmp = PyBytes_FromStringAndSize((const char *)image->xmp.data, image->xmp.size); } ret = Py_BuildValue( @@ -724,7 +735,7 @@ _decoder_get_info(AvifDecoderObject *self) { image->width, image->height, decoder->imageCount, - decoder->alphaPresent ? "RGBA" : "RGB", + mode, NULL == icc ? Py_None : icc, NULL == exif ? Py_None : exif, irot_imir_to_exif_orientation(image), @@ -771,7 +782,15 @@ _decoder_get_frame(AvifDecoderObject *self, PyObject *args) { avifRGBImageSetDefaults(&rgb, image); rgb.depth = 8; - rgb.format = decoder->alphaPresent ? AVIF_RGB_FORMAT_RGBA : AVIF_RGB_FORMAT_RGB; + if (decoder->alphaPresent) { + rgb.format = AVIF_RGB_FORMAT_RGBA; +#if AVIF_VERSION >= 1030000 // 1.3.0 + } else if (image->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) { + rgb.format = AVIF_RGB_FORMAT_GRAY; +#endif + } else { + rgb.format = AVIF_RGB_FORMAT_RGB; + } result = avifRGBImageAllocatePixels(&rgb); if (result != AVIF_RESULT_OK) { From 87c43057ed420262a6ed068a69896323a918abd8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Mar 2026 23:40:01 +1100 Subject: [PATCH 4/5] If all frames are grayscale, default to 4:0:0 subsampling when saving --- Tests/test_file_avif.py | 8 ++++++++ docs/handbook/image-file-formats.rst | 3 ++- src/PIL/AvifImagePlugin.py | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index 300bdb7d0bc..9878ad3c12f 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -129,6 +129,14 @@ def test_read(self) -> None: image, "Tests/images/avif/hopper_avif_write.png", 11.5 ) + @skip_unless_feature_version("avif", "1.3.0") + def test_write_l(self) -> None: + im = hopper("L") + reloaded = roundtrip(im) + + assert reloaded.mode == "L" + assert_image_similar(reloaded, im, 1.67) + def test_write_rgb(self, tmp_path: Path) -> None: """ Can we write a RGB mode file to avif without error? diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index a9fd764e613..ec2e4575ac8 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -38,7 +38,8 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: quality, 100 the largest size and best quality. **subsampling** - If present, sets the subsampling for the encoder. Defaults to ``4:2:0``. + If present, sets the subsampling for the encoder. If absent, and all frames are in + grayscale mode without alpha, ``4:0:0`` is used. Otherwise defaults to ``4:2:0``. Options include: * ``4:0:0`` diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 1d84afeef9f..7f4eaef5867 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -153,13 +153,31 @@ def _save( else: append_images = [] + grayscale = True + for ims in [im] + append_images: + for frame in ImageSequence.Iterator(ims): + if frame.mode not in { + "1", + "L", + "I", + "I;16", + "I;16L", + "I;16B", + "I;16N", + "F", + }: + grayscale = False + break + if not grayscale: + break + quality = info.get("quality", 75) if not isinstance(quality, int) or quality < 0 or quality > 100: msg = "Invalid quality setting" raise ValueError(msg) duration = info.get("duration", 0) - subsampling = info.get("subsampling", "4:2:0") + subsampling = info.get("subsampling", "4:0:0" if grayscale else "4:2:0") speed = info.get("speed", 6) max_threads = info.get("max_threads", _get_default_max_threads()) codec = info.get("codec", "auto") From fbcc7af1719efdc5c7925260934373cf726b9827 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Mar 2026 23:40:38 +1100 Subject: [PATCH 5/5] If frame is grayscale, encode with AVIF_RGB_FORMAT_GRAY --- src/PIL/AvifImagePlugin.py | 21 +++++++++------------ src/_avif.c | 4 ++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 7f4eaef5867..c0e973d7d25 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -154,18 +154,10 @@ def _save( append_images = [] grayscale = True + grayscale_modes = {"1", "L", "I", "I;16", "I;16L", "I;16B", "I;16N", "F"} for ims in [im] + append_images: for frame in ImageSequence.Iterator(ims): - if frame.mode not in { - "1", - "L", - "I", - "I;16", - "I;16L", - "I;16B", - "I;16N", - "F", - }: + if frame.mode not in grayscale_modes: grayscale = False break if not grayscale: @@ -256,8 +248,13 @@ def _save( for frame in ImageSequence.Iterator(ims): # Make sure image mode is supported rawmode = frame.mode - if frame.mode not in {"RGB", "RGBA"}: - rawmode = "RGBA" if frame.has_transparency_data else "RGB" + if ims.mode not in {"L", "RGB", "RGBA"}: + if ims.has_transparency_data: + rawmode = "RGBA" + elif ims.mode in grayscale_modes: + rawmode = "L" + else: + rawmode = "RGB" frame = frame.convert(rawmode) # Update frame duration diff --git a/src/_avif.c b/src/_avif.c index d62ccb7d25f..c2c80ea0772 100644 --- a/src/_avif.c +++ b/src/_avif.c @@ -505,6 +505,10 @@ _encoder_add(AvifEncoderObject *self, PyObject *args) { if (strcmp(mode, "RGBA") == 0) { rgb.format = AVIF_RGB_FORMAT_RGBA; +#if AVIF_VERSION >= 1030000 // 1.3.0 + } else if (strcmp(mode, "L") == 0) { + rgb.format = AVIF_RGB_FORMAT_GRAY; +#endif } else { rgb.format = AVIF_RGB_FORMAT_RGB; }