diff --git a/Tests/images/avif/chimera-missing-pixi.avif b/Tests/images/avif/chimera-missing-pixi.avif new file mode 100644 index 00000000000..4039547d574 Binary files /dev/null and b/Tests/images/avif/chimera-missing-pixi.avif differ diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index 9ba93310213..d30b2fdf6a8 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -9,7 +9,7 @@ import pytest -from PIL import AvifImagePlugin, Image, UnidentifiedImageError, features +from PIL import AvifImagePlugin, Image, ImageDraw, UnidentifiedImageError, features from .helper import ( PillowLeakTestCase, @@ -18,6 +18,7 @@ assert_image_similar_tofile, hopper, skip_unless_feature, + skip_unless_feature_version, ) try: @@ -72,17 +73,6 @@ def is_docker_qemu(): return "qemu" in init_proc_exe -def skip_unless_avif_version_gte(version): - if not _avif: - reason = "AVIF unavailable" - should_skip = True - else: - version_str = ".".join([str(v) for v in version]) - reason = f"{_avif.libavif_version} < {version_str}" - should_skip = _avif.VERSION < version - return pytest.mark.skipif(should_skip, reason=reason) - - def has_alpha_premultiplied(im_bytes): stream = BytesIO(im_bytes) length = len(im_bytes) @@ -240,7 +230,7 @@ def test_background_from_gif(self, tmp_path): difference = sum( [abs(original_value[i] - reread_value[i]) for i in range(0, 3)] ) - assert difference < 12 + assert difference < 5 def test_save_single_frame(self, tmp_path): temp_file = str(tmp_path / "temp.avif") @@ -449,7 +439,6 @@ def test_encoder_codec_cannot_encode(self, tmp_path): im.save(test_file, codec="dav1d") @skip_unless_avif_encoder("aom") - @skip_unless_avif_version_gte((0, 8, 2)) @skip_unless_feature("avif") def test_encoder_advanced_codec_options(self): with Image.open(TEST_AVIF_FILE) as im: @@ -468,7 +457,6 @@ def test_encoder_advanced_codec_options(self): assert ctrl_buf.getvalue() != test_buf.getvalue() @skip_unless_avif_encoder("aom") - @skip_unless_avif_version_gte((0, 8, 2)) @skip_unless_feature("avif") @pytest.mark.parametrize("val", [{"foo": "bar"}, 1234]) def test_encoder_advanced_codec_options_invalid(self, tmp_path, val): @@ -524,6 +512,7 @@ def test_encoder_codec_available_cannot_decode(self): def test_encoder_codec_available_invalid(self): assert _avif.encoder_codec_available("foo") is False + @skip_unless_feature_version("avif", "1.0.0") @pytest.mark.parametrize( "quality,expected_qminmax", [ @@ -535,15 +524,16 @@ def test_encoder_codec_available_invalid(self): ], ) def test_encoder_quality_qmin_qmax_map(self, tmp_path, quality, expected_qminmax): - MockEncoder = mock.Mock(wraps=_avif.AvifEncoder) - with mock.patch.object(_avif, "AvifEncoder", new=MockEncoder) as mock_encoder: - with Image.open("Tests/images/avif/hopper.avif") as im: - test_file = str(tmp_path / "temp.avif") - if quality is None: - im.save(test_file) - else: - im.save(test_file, quality=quality) - assert mock_encoder.call_args[0][3:5] == expected_qminmax + qmin, qmax = expected_qminmax + with Image.open("Tests/images/avif/hopper.avif") as im: + out_quality = BytesIO() + out_qminmax = BytesIO() + im.save(out_qminmax, "AVIF", qmin=qmin, qmax=qmax) + if quality is None: + im.save(out_quality, "AVIF") + else: + im.save(out_quality, "AVIF", quality=quality) + assert len(out_quality.getvalue()) == len(out_qminmax.getvalue()) def test_encoder_quality_valueerror(self, tmp_path): with Image.open("Tests/images/avif/hopper.avif") as im: @@ -586,6 +576,37 @@ def test_decoder_upsampling_invalid(self): finally: AvifImagePlugin.CHROMA_UPSAMPLING = "auto" + def test_p_mode_transparency(self): + im = Image.new("P", size=(64, 64)) + draw = ImageDraw.Draw(im) + draw.rectangle(xy=[(0, 0), (32, 32)], fill=255) + draw.rectangle(xy=[(32, 32), (64, 64)], fill=255) + + buf_png = BytesIO() + im.save(buf_png, format="PNG", transparency=0) + im_png = Image.open(buf_png) + buf_out = BytesIO() + im_png.save(buf_out, format="AVIF", quality=100) + + assert_image_similar(im_png.convert("RGBA"), Image.open(buf_out), 1) + + def test_decoder_strict_flags(self): + # This would fail if full avif strictFlags were enabled + with Image.open("Tests/images/avif/chimera-missing-pixi.avif") as im: + assert im.size == (480, 270) + + @skip_unless_avif_encoder("aom") + def test_aom_optimizations(self): + im = hopper("RGB") + buf = BytesIO() + im.save(buf, format="AVIF", codec="aom", speed=1) + + @skip_unless_avif_encoder("svt") + def test_svt_optimizations(self): + im = hopper("RGB") + buf = BytesIO() + im.save(buf, format="AVIF", codec="svt", speed=1) + @skip_unless_feature("avif") class TestAvifAnimation: @@ -685,7 +706,6 @@ def test_heif_raises_unidentified_image_error(self): with Image.open("Tests/images/avif/rgba10.heif"): pass - @skip_unless_avif_version_gte((0, 9, 0)) @pytest.mark.parametrize("alpha_premultipled", [False, True]) def test_alpha_premultiplied_true(self, alpha_premultipled): im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 6a110282786..c792f8570c7 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -1,6 +1,6 @@ from io import BytesIO -from . import Image, ImageFile +from . import ExifTags, Image, ImageFile try: from . import _avif @@ -13,6 +13,7 @@ # to Image.open (see https://github.com/python-pillow/Pillow/issues/569) DECODE_CODEC_CHOICE = "auto" CHROMA_UPSAMPLING = "auto" +DEFAULT_MAX_THREADS = 0 _VALID_AVIF_MODES = {"RGB", "RGBA"} @@ -47,9 +48,12 @@ class AvifImageFile(ImageFile.ImageFile): __loaded = -1 __frame = 0 + def load_seek(self, pos: int) -> None: + pass + def _open(self): self._decoder = _avif.AvifDecoder( - self.fp.read(), DECODE_CODEC_CHOICE, CHROMA_UPSAMPLING + self.fp.read(), DECODE_CODEC_CHOICE, CHROMA_UPSAMPLING, DEFAULT_MAX_THREADS ) # Get info from decoder @@ -114,28 +118,16 @@ def _save(im, fp, filename, save_all=False): is_single_frame = total == 1 - qmin = info.get("qmin") - qmax = info.get("qmax") - - if qmin is None and qmax is None: - # The min and max quantizer settings in libavif range from 0 (best quality) - # to 63 (worst quality). If neither are explicitly specified, we use a 0-100 - # quality scale (default 75) and calculate the qmin and qmax from that. - # - # - qmin is 0 for quality >= 64. Below that, qmin has an inverse linear - # relation to quality (i.e., quality 63 = qmin 1, quality 0 => qmin 63) - # - qmax is 0 for quality=100, then qmax increases linearly relative to - # quality decreasing, until it flattens out at quality=37. - quality = info.get("quality", 75) - if not isinstance(quality, int) or quality < 0 or quality > 100: - msg = "Invalid quality setting" - raise ValueError(msg) - qmin = max(0, min(64 - quality, 63)) - qmax = max(0, min(100 - quality, 63)) + qmin = info.get("qmin", -1) + qmax = info.get("qmax", -1) + quality = info.get("quality", 75) + if not isinstance(quality, int) or quality < 0 or quality > 100: + raise ValueError("Invalid quality setting") duration = info.get("duration", 0) subsampling = info.get("subsampling", "4:2:0") speed = info.get("speed", 6) + max_threads = info.get("max_threads", DEFAULT_MAX_THREADS) codec = info.get("codec", "auto") range_ = info.get("range", "full") tile_rows_log2 = info.get("tile_rows", 0) @@ -147,6 +139,20 @@ def _save(im, fp, filename, save_all=False): exif = info.get("exif", im.info.get("exif")) if isinstance(exif, Image.Exif): exif = exif.tobytes() + + exif_orientation = 0 + if exif: + exif_data = Image.Exif() + try: + exif_data.load(exif) + except SyntaxError: + pass + else: + orientation_tag = next( + k for k, v in ExifTags.TAGS.items() if v == "Orientation" + ) + exif_orientation = exif_data.get(orientation_tag) or 0 + xmp = info.get("xmp", im.info.get("xmp") or im.info.get("XML:com.adobe.xmp")) if isinstance(xmp, str): @@ -181,6 +187,7 @@ def _save(im, fp, filename, save_all=False): qmax, quality, speed, + max_threads, codec, range_, tile_rows_log2, @@ -189,6 +196,7 @@ def _save(im, fp, filename, save_all=False): autotiling, icc_profile or b"", exif or b"", + exif_orientation, xmp or b"", advanced, ) @@ -214,6 +222,10 @@ def _save(im, fp, filename, save_all=False): "A" in ims.mode or "a" in ims.mode or (ims.mode == "P" and "A" in ims.im.getpalettemode()) + or ( + ims.mode == "P" + and ims.info.get("transparency", None) is not None + ) ) rawmode = "RGBA" if alpha else "RGB" frame = ims.convert(rawmode) diff --git a/src/_avif.c b/src/_avif.c index 55a592c1a14..54208ed536e 100644 --- a/src/_avif.c +++ b/src/_avif.c @@ -46,7 +46,7 @@ typedef struct { static PyTypeObject AvifDecoder_Type; -static int max_threads = 0; +static int default_max_threads = 0; static void init_max_threads(void) { @@ -77,7 +77,7 @@ init_max_threads(void) { goto error; } - max_threads = (int)num_cpus; + default_max_threads = (int)num_cpus; done: Py_XDECREF(os); @@ -131,6 +131,118 @@ exc_type_for_avif_result(avifResult result) { } } +static void +exif_orientation_to_irot_imir(avifImage *image, int orientation) { + const avifTransformFlags otherFlags = + image->transformFlags & ~(AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR); + + // + // Mapping from Exif orientation as defined in JEITA CP-3451C section 4.6.4.A + // Orientation to irot and imir boxes as defined in HEIF ISO/IEC 28002-12:2021 + // sections 6.5.10 and 6.5.12. + switch (orientation) { + case 1: // The 0th row is at the visual top of the image, and the 0th column is + // the visual left-hand side. + image->transformFlags = otherFlags; + image->irot.angle = 0; // ignored +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; // ignored +#else + image->imir.mode = 0; // ignored +#endif + return; + case 2: // The 0th row is at the visual top of the image, and the 0th column is + // the visual right-hand side. + image->transformFlags = otherFlags | AVIF_TRANSFORM_IMIR; + image->irot.angle = 0; // ignored +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 1; +#else + image->imir.mode = 1; +#endif + return; + case 3: // The 0th row is at the visual bottom of the image, and the 0th column + // is the visual right-hand side. + image->transformFlags = otherFlags | AVIF_TRANSFORM_IROT; + image->irot.angle = 2; +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; // ignored +#else + image->imir.mode = 0; // ignored +#endif + return; + case 4: // The 0th row is at the visual bottom of the image, and the 0th column + // is the visual left-hand side. + image->transformFlags = otherFlags | AVIF_TRANSFORM_IMIR; + image->irot.angle = 0; // ignored +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; +#else + image->imir.mode = 0; +#endif + return; + case 5: // The 0th row is the visual left-hand side of the image, and the 0th + // column is the visual top. + image->transformFlags = + otherFlags | AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR; + image->irot.angle = 1; // applied before imir according to MIAF spec + // ISO/IEC 28002-12:2021 - section 7.3.6.7 +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; +#else + image->imir.mode = 0; +#endif + return; + case 6: // The 0th row is the visual right-hand side of the image, and the 0th + // column is the visual top. + image->transformFlags = otherFlags | AVIF_TRANSFORM_IROT; + image->irot.angle = 3; +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; // ignored +#else + image->imir.mode = 0; // ignored +#endif + return; + case 7: // The 0th row is the visual right-hand side of the image, and the 0th + // column is the visual bottom. + image->transformFlags = + otherFlags | AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR; + image->irot.angle = 3; // applied before imir according to MIAF spec + // ISO/IEC 28002-12:2021 - section 7.3.6.7 +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; +#else + image->imir.mode = 0; +#endif + return; + case 8: // The 0th row is the visual left-hand side of the image, and the 0th + // column is the visual bottom. + image->transformFlags = otherFlags | AVIF_TRANSFORM_IROT; + image->irot.angle = 1; +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; // ignored +#else + image->imir.mode = 0; // ignored +#endif + return; + default: // reserved + break; + } + + // The orientation tag is not mandatory (only recommended) according to JEITA + // CP-3451C section 4.6.8.A. The default value is 1 if the orientation tag is + // missing, meaning: + // The 0th row is at the visual top of the image, and the 0th column is the visual + // left-hand side. + image->transformFlags = otherFlags; + image->irot.angle = 0; // ignored +#if AVIF_VERSION_MAJOR >= 1 + image->imir.axis = 0; // ignored +#else + image->imir.mode = 0; // ignored +#endif +} + static int _codec_available(const char *name, uint32_t flags) { avifCodecChoice codec = avifCodecChoiceFromName(name); @@ -200,6 +312,8 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { int qmax = 10; // "High Quality", but not lossless int quality = 75; int speed = 8; + int exif_orientation = 0; + int max_threads = default_max_threads; PyObject *icc_bytes; PyObject *exif_bytes; PyObject *xmp_bytes; @@ -215,7 +329,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { if (!PyArg_ParseTuple( args, - "IIsiiiissiiOOSSSO", + "IIsiiiiissiiOOSSiSO", &width, &height, &subsampling, @@ -223,6 +337,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { &qmax, &quality, &speed, + &max_threads, &codec, &range, &tile_rows_log2, @@ -231,6 +346,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { &autotiling, &icc_bytes, &exif_bytes, + &exif_orientation, &xmp_bytes, &advanced)) { return NULL; @@ -249,8 +365,18 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { return NULL; } - enc_options.qmin = normalize_quantize_value(qmin); - enc_options.qmax = normalize_quantize_value(qmax); + if (qmin == -1 || qmax == -1) { +#if AVIF_VERSION >= 1000000 + enc_options.qmin = -1; + enc_options.qmax = -1; +#else + enc_options.qmin = normalize_quantize_value(64 - quality); + enc_options.qmax = normalize_quantize_value(100 - quality); +#endif + } else { + enc_options.qmin = normalize_quantize_value(qmin); + enc_options.qmax = normalize_quantize_value(qmax); + } enc_options.quality = quality; if (speed < AVIF_SPEED_SLOWEST) { @@ -313,12 +439,24 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { encoder = avifEncoderCreate(); if (max_threads == 0) { - init_max_threads(); + if (default_max_threads == 0) { + init_max_threads(); + } + max_threads = default_max_threads; } - encoder->maxThreads = max_threads; + int is_aom_encode = strcmp(codec, "aom") == 0 || + (strcmp(codec, "auto") == 0 && + _codec_available("aom", AVIF_CODEC_FLAG_CAN_ENCODE)); + + encoder->maxThreads = is_aom_encode && max_threads > 64 ? 64 : max_threads; #if AVIF_VERSION >= 1000000 - encoder->quality = enc_options.quality; + if (enc_options.qmin != -1 && enc_options.qmax != -1) { + encoder->minQuantizer = enc_options.qmin; + encoder->maxQuantizer = enc_options.qmax; + } else { + encoder->quality = enc_options.quality; + } #else encoder->minQuantizer = enc_options.qmin; encoder->maxQuantizer = enc_options.qmax; @@ -381,6 +519,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { (uint8_t *)PyBytes_AS_STRING(xmp_bytes), PyBytes_GET_SIZE(xmp_bytes)); } + exif_orientation_to_irot_imir(image, exif_orientation); self->image = image; self->frame_index = -1; @@ -584,10 +723,11 @@ AvifDecoderNew(PyObject *self_, PyObject *args) { char *codec_str; avifCodecChoice codec; avifChromaUpsampling upsampling; + int max_threads = 0; avifResult result; - if (!PyArg_ParseTuple(args, "Sss", &avif_bytes, &codec_str, &upsampling_str)) { + if (!PyArg_ParseTuple(args, "Sssi", &avif_bytes, &codec_str, &upsampling_str, &max_threads)) { return NULL; } @@ -636,9 +776,20 @@ AvifDecoderNew(PyObject *self_, PyObject *args) { self->decoder = avifDecoderCreate(); #if AVIF_VERSION >= 80400 if (max_threads == 0) { - init_max_threads(); + if (default_max_threads == 0) { + init_max_threads(); + } + max_threads = default_max_threads; } self->decoder->maxThreads = max_threads; +#endif +#if AVIF_VERSION >= 90200 + // Turn off libavif's 'clap' (clean aperture) property validation. + self->decoder->strictFlags &= ~AVIF_STRICT_CLAP_VALID; + // Allow the PixelInformationProperty ('pixi') to be missing in AV1 image + // items. libheif v1.11.0 and older does not add the 'pixi' item property to + // AV1 image items. + self->decoder->strictFlags &= ~AVIF_STRICT_PIXI_REQUIRED; #endif self->decoder->codecChoice = codec;