diff --git a/Tests/images/avif/rot0mir0.avif b/Tests/images/avif/rot0mir0.avif new file mode 100644 index 00000000000..f5720309365 Binary files /dev/null and b/Tests/images/avif/rot0mir0.avif differ diff --git a/Tests/images/avif/rot0mir1.avif b/Tests/images/avif/rot0mir1.avif new file mode 100644 index 00000000000..8a9fb5e5431 Binary files /dev/null and b/Tests/images/avif/rot0mir1.avif differ diff --git a/Tests/images/avif/rot1mir0.avif b/Tests/images/avif/rot1mir0.avif new file mode 100644 index 00000000000..0c7c7620fd4 Binary files /dev/null and b/Tests/images/avif/rot1mir0.avif differ diff --git a/Tests/images/avif/rot1mir1.avif b/Tests/images/avif/rot1mir1.avif new file mode 100644 index 00000000000..0181b088cec Binary files /dev/null and b/Tests/images/avif/rot1mir1.avif differ diff --git a/Tests/images/avif/rot2mir0.avif b/Tests/images/avif/rot2mir0.avif new file mode 100644 index 00000000000..ddaa02f3f89 Binary files /dev/null and b/Tests/images/avif/rot2mir0.avif differ diff --git a/Tests/images/avif/rot2mir1.avif b/Tests/images/avif/rot2mir1.avif new file mode 100644 index 00000000000..63326b86caa Binary files /dev/null and b/Tests/images/avif/rot2mir1.avif differ diff --git a/Tests/images/avif/rot3mir0.avif b/Tests/images/avif/rot3mir0.avif new file mode 100644 index 00000000000..ed5dbe3d0bb Binary files /dev/null and b/Tests/images/avif/rot3mir0.avif differ diff --git a/Tests/images/avif/rot3mir1.avif b/Tests/images/avif/rot3mir1.avif new file mode 100644 index 00000000000..ccd8861d5ae Binary files /dev/null and b/Tests/images/avif/rot3mir1.avif differ diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index cc9f0a3a088..1d56b3be0bf 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -10,6 +10,7 @@ from pathlib import Path from struct import unpack from typing import Any +from unittest import mock import pytest @@ -329,17 +330,29 @@ def test_exif(self) -> None: exif = im.getexif() assert exif[274] == 3 - @pytest.mark.parametrize("bytes", [True, False]) - def test_exif_save(self, tmp_path: Path, bytes: bool) -> None: + @pytest.mark.parametrize("bytes,orientation", [(True, 1), (False, 2)]) + def test_exif_save( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + bytes: bool, + orientation: int, + ) -> None: + mock_avif_encoder = mock.Mock(wraps=_avif.AvifEncoder) + monkeypatch.setattr(_avif, "AvifEncoder", mock_avif_encoder) exif = Image.Exif() - exif[274] = 1 + exif[274] = orientation exif_data = exif.tobytes() with Image.open(TEST_AVIF_FILE) as im: test_file = str(tmp_path / "temp.avif") im.save(test_file, exif=exif_data if bytes else exif) with Image.open(test_file) as reloaded: - assert reloaded.info["exif"] == exif_data + if orientation == 1: + assert "exif" not in reloaded.info + else: + assert reloaded.info["exif"] == exif_data + mock_avif_encoder.mock_calls[0].args[16:17] == (b"", orientation) def test_exif_invalid(self, tmp_path: Path) -> None: with Image.open(TEST_AVIF_FILE) as im: @@ -347,6 +360,35 @@ def test_exif_invalid(self, tmp_path: Path) -> None: with pytest.raises(SyntaxError): im.save(test_file, exif=b"invalid") + @pytest.mark.parametrize( + "rot,mir,exif_orientation", + [ + (0, 0, 4), + (0, 1, 2), + (1, 0, 5), + (1, 1, 7), + (2, 0, 2), + (2, 1, 4), + (3, 0, 7), + (3, 1, 5), + ], + ) + def test_rot_mir_exif( + self, rot: int, mir: int, exif_orientation: int, tmp_path: Path + ) -> None: + with Image.open(f"Tests/images/avif/rot{rot}mir{mir}.avif") as im: + exif = im.info["exif"] + test_file = str(tmp_path / "temp.avif") + im.save(test_file, exif=exif) + + exif_data = Image.Exif() + exif_data.load(exif) + assert exif_data[274] == exif_orientation + with Image.open(test_file) as reloaded: + exif_data = Image.Exif() + exif_data.load(reloaded.info["exif"]) + assert exif_data[274] == exif_orientation + def test_xmp(self) -> None: with Image.open("Tests/images/avif/xmp_tags_orientation.avif") as im: xmp = im.info["xmp"] diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 2b205c4a53a..3b34e52e576 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -86,7 +86,9 @@ def _open(self) -> None: ) # Get info from decoder - width, height, n_frames, mode, icc, exif, xmp = self._decoder.get_info() + width, height, n_frames, mode, icc, exif, xmp, exif_orientation = ( + self._decoder.get_info() + ) self._size = width, height self.n_frames = n_frames self.is_animated = self.n_frames > 1 @@ -99,6 +101,16 @@ def _open(self) -> None: if xmp: self.info["xmp"] = xmp + if exif_orientation != 1 or exif is not None: + exif_data = Image.Exif() + orig_orientation = 1 + if exif is not None: + exif_data.load(exif) + orig_orientation = exif_data.get(ExifTags.Base.Orientation, 1) + if exif_orientation != orig_orientation: + exif_data[ExifTags.Base.Orientation] = exif_orientation + self.info["exif"] = exif_data.tobytes() + def seek(self, frame: int) -> None: if not self._seek_check(frame): return @@ -176,9 +188,14 @@ def _save( else: exif_data = Image.Exif() exif_data.load(exif) - exif_orientation = exif_data.pop(ExifTags.Base.Orientation, 1) + exif_orientation = exif_data.pop(ExifTags.Base.Orientation, 0) + if exif_orientation != 0: + if len(exif_data): + exif = exif_data.tobytes() + else: + exif = None else: - exif_orientation = 1 + exif_orientation = 0 xmp = info.get("xmp") diff --git a/src/_avif.c b/src/_avif.c index ac2ec283bc1..f8041b40107 100644 --- a/src/_avif.c +++ b/src/_avif.c @@ -76,6 +76,44 @@ exc_type_for_avif_result(avifResult result) { } } +static uint8_t +irot_imir_to_exif_orientation(const avifImage *image) { +#if AVIF_VERSION_MAJOR >= 1 + uint8_t axis = image->imir.axis; +#else + uint8_t axis = image->imir.mode; +#endif + uint8_t angle = image->irot.angle; + int irot = !!(image->transformFlags & AVIF_TRANSFORM_IROT); + int imir = !!(image->transformFlags & AVIF_TRANSFORM_IMIR); + if (irot && angle == 1) { + if (imir) { + return axis ? 7 // 90 degrees anti-clockwise then swap left and right. + : 5; // 90 degrees anti-clockwise then swap top and bottom. + } + return 6; // 90 degrees anti-clockwise. + } + if (irot && angle == 2) { + if (imir) { + return axis ? 4 // 180 degrees anti-clockwise then swap left and right. + : 2; // 180 degrees anti-clockwise then swap top and bottom. + } + return 3; // 180 degrees anti-clockwise. + } + if (irot && angle == 3) { + if (imir) { + return axis ? 5 // 270 degrees anti-clockwise then swap left and right. + : 7; // 270 degrees anti-clockwise then swap top and bottom. + } + return 8; // 270 degrees anti-clockwise. + } + if (imir) { + return axis ? 2 // Swap left and right. + : 4; // Swap top and bottom. + } + return 1; // Default orientation ("top-left", no-op). +} + static void exif_orientation_to_irot_imir(avifImage *image, int orientation) { const avifTransformFlags otherFlags = @@ -485,7 +523,9 @@ AvifEncoderNew(PyObject *self_, PyObject *args) { return NULL; } } - exif_orientation_to_irot_imir(image, exif_orientation); + if (exif_orientation > 0) { + exif_orientation_to_irot_imir(image, exif_orientation); + } self->image = image; self->frame_index = -1; @@ -806,14 +846,15 @@ _decoder_get_info(AvifDecoderObject *self) { } ret = Py_BuildValue( - "IIIsSSS", + "IIIsSSSI", image->width, image->height, decoder->imageCount, self->mode, NULL == icc ? Py_None : icc, NULL == exif ? Py_None : exif, - NULL == xmp ? Py_None : xmp + NULL == xmp ? Py_None : xmp, + irot_imir_to_exif_orientation(image) ); Py_XDECREF(xmp);