Skip to content

Commit 1854d62

Browse files
radarherehugovk
andauthored
Support opening and saving L mode AVIF images with libavif >= 1.3.0 (#9471)
Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>
2 parents d4711b7 + 885605c commit 1854d62

4 files changed

Lines changed: 64 additions & 21 deletions

File tree

Tests/test_file_avif.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
assert_image_similar_tofile,
3030
hopper,
3131
skip_unless_feature,
32+
skip_unless_feature_version,
3233
)
3334

3435
try:
@@ -46,7 +47,7 @@ def assert_xmp_orientation(xmp: bytes, expected: int) -> None:
4647
assert int(xmp.split(b'tiff:Orientation="')[1].split(b'"')[0]) == expected
4748

4849

49-
def roundtrip(im: ImageFile.ImageFile, **options: Any) -> ImageFile.ImageFile:
50+
def roundtrip(im: Image.Image, **options: Any) -> ImageFile.ImageFile:
5051
out = BytesIO()
5152
im.save(out, "AVIF", **options)
5253
return Image.open(out)
@@ -128,6 +129,14 @@ def test_read(self) -> None:
128129
image, "Tests/images/avif/hopper_avif_write.png", 11.5
129130
)
130131

132+
@skip_unless_feature_version("avif", "1.3.0")
133+
def test_write_l(self) -> None:
134+
im = hopper("L")
135+
reloaded = roundtrip(im)
136+
137+
assert reloaded.mode == "L"
138+
assert_image_similar(reloaded, im, 1.69)
139+
131140
def test_write_rgb(self, tmp_path: Path) -> None:
132141
"""
133142
Can we write a RGB mode file to avif without error?
@@ -420,6 +429,14 @@ def test_encoder_subsampling(self, tmp_path: Path, subsampling: str) -> None:
420429
test_file = tmp_path / "temp.avif"
421430
im.save(test_file, subsampling=subsampling)
422431

432+
@skip_unless_feature_version("avif", "1.3.0")
433+
def test_encoding_subsampling_400(self) -> None:
434+
im = hopper()
435+
reloaded = roundtrip(im, subsampling="4:0:0")
436+
437+
assert reloaded.mode == "L"
438+
assert_image_similar(reloaded, im.convert("L"), 1.69)
439+
423440
def test_encoder_subsampling_invalid(self, tmp_path: Path) -> None:
424441
with Image.open(TEST_AVIF_FILE) as im:
425442
test_file = tmp_path / "temp.avif"

docs/handbook/image-file-formats.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
3838
quality, 100 the largest size and best quality.
3939

4040
**subsampling**
41-
If present, sets the subsampling for the encoder. Defaults to ``4:2:0``.
41+
If present, sets the subsampling for the encoder. If absent, and all frames are in
42+
grayscale mode without alpha, ``4:0:0`` is used. Otherwise defaults to ``4:2:0``.
4243
Options include:
4344

4445
* ``4:0:0``

src/PIL/AvifImagePlugin.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from io import BytesIO
55
from typing import IO
66

7-
from . import ExifTags, Image, ImageFile
7+
from . import ExifTags, Image, ImageFile, ImageSequence
88

99
try:
1010
from . import _avif
@@ -153,17 +153,20 @@ def _save(
153153
else:
154154
append_images = []
155155

156-
total = 0
157-
for ims in [im] + append_images:
158-
total += getattr(ims, "n_frames", 1)
156+
grayscale_modes = {"1", "L", "I", "I;16", "I;16L", "I;16B", "I;16N", "F"}
157+
grayscale = all(
158+
frame.mode in grayscale_modes
159+
for ims in [im] + append_images
160+
for frame in ImageSequence.Iterator(ims)
161+
)
159162

160163
quality = info.get("quality", 75)
161164
if not isinstance(quality, int) or quality < 0 or quality > 100:
162165
msg = "Invalid quality setting"
163166
raise ValueError(msg)
164167

165168
duration = info.get("duration", 0)
166-
subsampling = info.get("subsampling", "4:2:0")
169+
subsampling = info.get("subsampling", "4:0:0" if grayscale else "4:2:0")
167170
speed = info.get("speed", 6)
168171
max_threads = info.get("max_threads", _get_default_max_threads())
169172
codec = info.get("codec", "auto")
@@ -236,21 +239,20 @@ def _save(
236239
frame_idx = 0
237240
frame_duration = 0
238241
cur_idx = im.tell()
239-
is_single_frame = total == 1
242+
is_single_frame = not append_images and not getattr(im, "is_animated", False)
240243
try:
241244
for ims in [im] + append_images:
242-
# Get number of frames in this image
243-
nfr = getattr(ims, "n_frames", 1)
244-
245-
for idx in range(nfr):
246-
ims.seek(idx)
247-
245+
for frame in ImageSequence.Iterator(ims):
248246
# Make sure image mode is supported
249-
frame = ims
250-
rawmode = ims.mode
251-
if ims.mode not in {"RGB", "RGBA"}:
252-
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
253-
frame = ims.convert(rawmode)
247+
rawmode = frame.mode
248+
if ims.mode not in {"L", "RGB", "RGBA"}:
249+
if ims.has_transparency_data:
250+
rawmode = "RGBA"
251+
elif ims.mode in grayscale_modes:
252+
rawmode = "L"
253+
else:
254+
rawmode = "RGB"
255+
frame = frame.convert(rawmode)
254256

255257
# Update frame duration
256258
if isinstance(duration, (list, tuple)):

src/_avif.c

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,10 @@ _encoder_add(AvifEncoderObject *self, PyObject *args) {
505505

506506
if (strcmp(mode, "RGBA") == 0) {
507507
rgb.format = AVIF_RGB_FORMAT_RGBA;
508+
#if AVIF_VERSION >= 1030000 // 1.3.0
509+
} else if (strcmp(mode, "L") == 0) {
510+
rgb.format = AVIF_RGB_FORMAT_GRAY;
511+
#endif
508512
} else {
509513
rgb.format = AVIF_RGB_FORMAT_RGB;
510514
}
@@ -706,6 +710,17 @@ _decoder_get_info(AvifDecoderObject *self) {
706710
PyObject *xmp = NULL;
707711
PyObject *ret = NULL;
708712

713+
char *mode;
714+
if (decoder->alphaPresent) {
715+
mode = "RGBA";
716+
#if AVIF_VERSION >= 1030000 // 1.3.0
717+
} else if (image->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) {
718+
mode = "L";
719+
#endif
720+
} else {
721+
mode = "RGB";
722+
}
723+
709724
if (image->xmp.size) {
710725
xmp = PyBytes_FromStringAndSize((const char *)image->xmp.data, image->xmp.size);
711726
if (!xmp) {
@@ -736,7 +751,7 @@ _decoder_get_info(AvifDecoderObject *self) {
736751
image->width,
737752
image->height,
738753
decoder->imageCount,
739-
decoder->alphaPresent ? "RGBA" : "RGB",
754+
mode,
740755
NULL == icc ? Py_None : icc,
741756
NULL == exif ? Py_None : exif,
742757
irot_imir_to_exif_orientation(image),
@@ -783,7 +798,15 @@ _decoder_get_frame(AvifDecoderObject *self, PyObject *args) {
783798
avifRGBImageSetDefaults(&rgb, image);
784799

785800
rgb.depth = 8;
786-
rgb.format = decoder->alphaPresent ? AVIF_RGB_FORMAT_RGBA : AVIF_RGB_FORMAT_RGB;
801+
if (decoder->alphaPresent) {
802+
rgb.format = AVIF_RGB_FORMAT_RGBA;
803+
#if AVIF_VERSION >= 1030000 // 1.3.0
804+
} else if (image->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) {
805+
rgb.format = AVIF_RGB_FORMAT_GRAY;
806+
#endif
807+
} else {
808+
rgb.format = AVIF_RGB_FORMAT_RGB;
809+
}
787810

788811
result = avifRGBImageAllocatePixels(&rgb);
789812
if (result != AVIF_RESULT_OK) {

0 commit comments

Comments
 (0)