This commit is contained in:
Andrew Murray 2026-05-16 18:28:32 +10:00 committed by GitHub
commit a1237d9fd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 64 additions and 21 deletions

View File

@ -29,6 +29,7 @@ from .helper import (
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)
@ -128,6 +129,14 @@ class TestFileAvif:
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?
@ -420,6 +429,14 @@ class TestFileAvif:
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"

View File

@ -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``

View File

@ -4,7 +4,7 @@ import os
from io import BytesIO
from typing import IO
from . import ExifTags, Image, ImageFile
from . import ExifTags, Image, ImageFile, ImageSequence
try:
from . import _avif
@ -153,9 +153,12 @@ def _save(
else:
append_images = []
total = 0
for ims in [im] + append_images:
total += getattr(ims, "n_frames", 1)
grayscale_modes = {"1", "L", "I", "I;16", "I;16L", "I;16B", "I;16N", "F"}
grayscale = all(
frame.mode in grayscale_modes
for ims in [im] + append_images
for frame in ImageSequence.Iterator(ims)
)
quality = info.get("quality", 75)
if not isinstance(quality, int) or quality < 0 or quality > 100:
@ -163,7 +166,7 @@ def _save(
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")
@ -236,21 +239,20 @@ 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:
# 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 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
if isinstance(duration, (list, tuple)):

View File

@ -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;
}
@ -706,6 +710,17 @@ _decoder_get_info(AvifDecoderObject *self) {
PyObject *xmp = NULL;
PyObject *ret = NULL;
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->xmp.size) {
xmp = PyBytes_FromStringAndSize((const char *)image->xmp.data, image->xmp.size);
if (!xmp) {
@ -736,7 +751,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),
@ -783,7 +798,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) {