From 66270ff7b8ab40abcb2cf0331d9d3cba7fb438f5 Mon Sep 17 00:00:00 2001 From: Frankie Dintino Date: Tue, 13 Aug 2024 22:02:32 -0400 Subject: [PATCH] fixup --- Tests/images/avif/chimera-missing-pixi.avif | Bin 0 -> 9717 bytes Tests/test_file_avif.py | 70 +++++--- src/PIL/AvifImagePlugin.py | 52 +++--- src/_avif.c | 171 ++++++++++++++++++-- 4 files changed, 238 insertions(+), 55 deletions(-) create mode 100644 Tests/images/avif/chimera-missing-pixi.avif diff --git a/Tests/images/avif/chimera-missing-pixi.avif b/Tests/images/avif/chimera-missing-pixi.avif new file mode 100644 index 0000000000000000000000000000000000000000..4039547d57413457c9b3c6573e9a42b378e00a41 GIT binary patch literal 9717 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

Sh@MyxRQd~_ba6|+ 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 YcWHekFfHtD48Qn4e7zDRI7HC@03D=sOaK4? literal 0 HcmV?d00001 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;