diff --git a/CHANGELOG.md b/CHANGELOG.md index 8756e068..cfffed88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ### Added - Synonym for `chroma` encoder parameter: `subsampling`(usage is the same as in Pillow JPEG). #161 #165 +- Passing `image_orientation` value to libheif, instead of manually rotating image according to EXIF before encoding. #168 - Pi-Heif: Python3.12 32-bit `armv7` wheels. #160 ### Changed diff --git a/docs/workaround-orientation.rst b/docs/workaround-orientation.rst index 3843e708..495e9f51 100644 --- a/docs/workaround-orientation.rst +++ b/docs/workaround-orientation.rst @@ -34,6 +34,8 @@ but if there are two images and second does not have exif orientation tag, they As we do not have an own soothsayer to say in which image editor will be image opened and will app rotate image or not based on Exif TAG, we comes to next chapter... +*Updated(October 2023): last macOS Sonoma(14.0) changed it's behaviour and do not rotate `HEIF` images based on Exif TAG.* + Q. So is there a decision? """""""""""""""""""""""""" @@ -41,9 +43,12 @@ The best one and simplest solution is to `remove it `_. So we set ``orientation`` to ``1`` in -:py:meth:`~pillow_heif.HeifFile.add_from_pillow` (or during encoding `Pillow.Image`) to remove EXIF/XMP orientation tag +:py:meth:`~pillow_heif.HeifFile.add_from_pillow` to remove EXIF/XMP orientation tag and rotate the image according to the removed tag. +.. note:: *Updated(November 2023, pillow_heif>=1.14.0, PillowPlugin mode): + Image rotation value during encoding will be not removed from EXIF, and in addition will be set in HEIF header.* + That allow us to properly handle situations when JPEG or PNG with orientation get encoded to HEIF. To properly handle HEIF images with rotation tag in Exif/XMP, in Pillow plugin we do the same during image open, diff --git a/pillow_heif/_pillow_heif.c b/pillow_heif/_pillow_heif.c index a146ea17..79354586 100644 --- a/pillow_heif/_pillow_heif.c +++ b/pillow_heif/_pillow_heif.c @@ -44,10 +44,9 @@ int __PyDict_SetItemString(PyObject *p, const char *key, PyObject *val) { return r; } -enum ph_image_type -{ - PhHeifImage = 0, - PhHeifDepthImage = 2, +enum ph_image_type { + PhHeifImage = 0, + PhHeifDepthImage = 2, }; /* =========== Objects ======== */ @@ -516,11 +515,11 @@ static PyObject* _CtxWriteImage_set_nclx_profile(CtxWriteImageObject* self, PyOb static PyObject* _CtxWriteImage_encode(CtxWriteImageObject* self, PyObject* args) { /* ctx: CtxWriteObject, primary: int */ CtxWriteObject* ctx_write; - int primary, save_nclx; + int primary, save_nclx, image_orientation; struct heif_error error; struct heif_encoding_options* options; - if (!PyArg_ParseTuple(args, "Oii", (PyObject*)&ctx_write, &primary, &save_nclx)) + if (!PyArg_ParseTuple(args, "Oiii", (PyObject*)&ctx_write, &primary, &save_nclx, &image_orientation)) return NULL; Py_BEGIN_ALLOW_THREADS @@ -530,6 +529,7 @@ static PyObject* _CtxWriteImage_encode(CtxWriteImageObject* self, PyObject* args self->output_nclx_color_profile = heif_nclx_color_profile_alloc(); if (self->output_nclx_color_profile) options->output_nclx_profile = self->output_nclx_color_profile; + options->image_orientation = image_orientation; error = heif_context_encode_image(ctx_write->ctx, self->image, ctx_write->encoder, options, &self->handle); heif_encoding_options_free(options); Py_END_ALLOW_THREADS @@ -596,13 +596,14 @@ static PyObject* _CtxWriteImage_encode_thumbnail(CtxWriteImageObject* self, PyOb struct heif_image_handle* thumb_handle; struct heif_encoding_options* options; CtxWriteObject* ctx_write; - int thumb_box; + int thumb_box, image_orientation; - if (!PyArg_ParseTuple(args, "Oi", (PyObject*)&ctx_write, &thumb_box)) + if (!PyArg_ParseTuple(args, "Oii", (PyObject*)&ctx_write, &thumb_box, &image_orientation)) return NULL; Py_BEGIN_ALLOW_THREADS options = heif_encoding_options_alloc(); + options->image_orientation = image_orientation; error = heif_context_encode_thumbnail( ctx_write->ctx, self->image, diff --git a/pillow_heif/as_plugin.py b/pillow_heif/as_plugin.py index cbbe4ce2..13ec7c9a 100644 --- a/pillow_heif/as_plugin.py +++ b/pillow_heif/as_plugin.py @@ -14,9 +14,9 @@ CtxEncode, _exif_from_pillow, _get_bytes, + _get_orientation_for_encoder, _get_primary_index, _pil_to_supported_mode, - _rotate_pil, _xmp_from_pillow, set_orientation, ) @@ -279,8 +279,5 @@ def _pil_encode_image(ctx: CtxEncode, img: Image.Image, primary: bool, **kwargs) if primary: _info.update(**kwargs) _info["primary"] = primary - original_orientation = set_orientation(_info) _img = _pil_to_supported_mode(img) - if original_orientation is not None and original_orientation != 1: - _img = _rotate_pil(_img, original_orientation) - ctx.add_image(_img.size, _img.mode, _img.tobytes(), **_info) + ctx.add_image(_img.size, _img.mode, _img.tobytes(), image_orientation=_get_orientation_for_encoder(_info), **_info) diff --git a/pillow_heif/heif.py b/pillow_heif/heif.py index 4594b946..435db107 100644 --- a/pillow_heif/heif.py +++ b/pillow_heif/heif.py @@ -14,6 +14,7 @@ MimCImage, _exif_from_pillow, _get_bytes, + _get_orientation_for_encoder, _get_primary_index, _pil_to_supported_mode, _retrieve_exif, @@ -552,7 +553,14 @@ def _encode_images(images: List[HeifImage], fp, **kwargs) -> None: _info.update(**kwargs) _info["primary"] = True _info.pop("stride", 0) - ctx_write.add_image(img.size, img.mode, img.data, **_info, stride=img.stride) + ctx_write.add_image( + img.size, + img.mode, + img.data, + image_orientation=_get_orientation_for_encoder(_info), + **_info, + stride=img.stride, + ) ctx_write.save(fp) diff --git a/pillow_heif/misc.py b/pillow_heif/misc.py index cac9864f..4ed2345e 100644 --- a/pillow_heif/misc.py +++ b/pillow_heif/misc.py @@ -93,6 +93,40 @@ def set_orientation(info: dict) -> Optional[int]: :param info: `info` dictionary from :external:py:class:`~PIL.Image.Image` or :py:class:`~pillow_heif.HeifImage`. :returns: Original orientation or None if it is absent. """ + return _get_orientation(info, True) + + +def _get_orientation_for_encoder(info: dict) -> int: + image_orientation = _get_orientation(info, False) + return 1 if image_orientation is None else image_orientation + + +def _get_orientation_xmp(info: dict, exif_orientation: Optional[int], reset: bool = False) -> Optional[int]: + xmp_orientation = 1 + if info.get("xmp", None): + xmp_data = info["xmp"].rsplit(b"\x00", 1) + if xmp_data[0]: + decoded_xmp_data = None + for encoding in ("utf-8", "latin1"): + try: + decoded_xmp_data = xmp_data[0].decode(encoding) + break + except Exception: # noqa # pylint: disable=broad-except + pass + if decoded_xmp_data: + match = re.search(r'tiff:Orientation(="|>)([0-9])', decoded_xmp_data) + if match: + xmp_orientation = int(match[2]) + if reset: + decoded_xmp_data = re.sub(r'tiff:Orientation="([0-9])"', "", decoded_xmp_data) + decoded_xmp_data = re.sub(r"([0-9])", "", decoded_xmp_data) + # should encode in "utf-8" anyway, as `defusedxml` do not work with `latin1` encoding. + if encoding != "utf-8" or xmp_orientation != 1: + info["xmp"] = b"".join([decoded_xmp_data.encode("utf-8"), b"\x00" if len(xmp_data) > 1 else b""]) + return xmp_orientation if exif_orientation is None and xmp_orientation != 1 else None + + +def _get_orientation(info: dict, reset: bool = False) -> Optional[int]: original_orientation = None if info.get("exif", None): try: @@ -113,6 +147,8 @@ def set_orientation(info: dict) -> Optional[int]: _original_orientation = unpack(endian_mark + "H", value[0:2])[0] if _original_orientation != 1: original_orientation = _original_orientation + if not reset: + break p_value = pointer + 8 if skipped_exif00: p_value += 6 @@ -121,29 +157,8 @@ def set_orientation(info: dict) -> Optional[int]: break except Exception: # noqa # pylint: disable=broad-except pass - if info.get("xmp", None): - xmp_data = info["xmp"].rsplit(b"\x00", 1) - if xmp_data[0]: - decoded_xmp_data = None - for encoding in ("utf-8", "latin1"): - try: - decoded_xmp_data = xmp_data[0].decode(encoding) - break - except Exception: # noqa # pylint: disable=broad-except - pass - if decoded_xmp_data: - _original_orientation = 1 - match = re.search(r'tiff:Orientation(="|>)([0-9])', decoded_xmp_data) - if match: - _original_orientation = int(match[2]) - if original_orientation is None and _original_orientation != 1: - original_orientation = _original_orientation - decoded_xmp_data = re.sub(r'tiff:Orientation="([0-9])"', "", decoded_xmp_data) - decoded_xmp_data = re.sub(r"([0-9])", "", decoded_xmp_data) - # should encode in "utf-8" anyway, as `defusedxml` do not work with `latin1` encoding. - if encoding != "utf-8" or _original_orientation != 1: - info["xmp"] = b"".join([decoded_xmp_data.encode("utf-8"), b"\x00" if len(xmp_data) > 1 else b""]) - return original_orientation + xmp_orientation = _get_orientation_xmp(info, original_orientation, reset=reset) + return xmp_orientation if xmp_orientation else original_orientation def get_file_mimetype(fp) -> str: @@ -352,8 +367,12 @@ def add_image(self, size: tuple, mode: str, data, **kwargs) -> None: ] ) # encode + image_orientation = kwargs.get("image_orientation", 1) im_out.encode( - self.ctx_write, kwargs.get("primary", False), kwargs.get("save_nclx_profile", options.SAVE_NCLX_PROFILE) + self.ctx_write, + kwargs.get("primary", False), + kwargs.get("save_nclx_profile", options.SAVE_NCLX_PROFILE), + image_orientation, ) # adding metadata exif = kwargs.get("exif", None) @@ -369,7 +388,7 @@ def add_image(self, size: tuple, mode: str, data, **kwargs) -> None: # adding thumbnails for thumb_box in kwargs.get("thumbnails", []): if max(size) > thumb_box > 3: - im_out.encode_thumbnail(self.ctx_write, thumb_box) + im_out.encode_thumbnail(self.ctx_write, thumb_box, image_orientation) def save(self, fp) -> None: """Ask encoder to produce output based on previously added images.""" diff --git a/tests/orientation_test.py b/tests/orientation_test.py index b1c8ab9f..8dfbd1b9 100644 --- a/tests/orientation_test.py +++ b/tests/orientation_test.py @@ -2,6 +2,7 @@ import pytest from helpers import assert_image_similar, hevc_enc +from packaging.version import parse as parse_version from PIL import Image, ImageOps import pillow_heif @@ -9,6 +10,10 @@ pillow_heif.register_heif_opener() +if parse_version(pillow_heif.libheif_version()) < parse_version("1.17.3"): + pytest.skip("Requires libheif version 1.17.3 to pass most orientation tests.", allow_module_level=True) + + def get_xmp_with_orientation(orientation: int, style=1) -> str: xmp_1 = ( '\n' @@ -94,7 +99,10 @@ def test_heif_exif_orientation(orientation): # Image will be automatically rotated by EXIF value before saving. im.save(out_im_heif, format="HEIF", exif=exif_data.tobytes(), quality=-1) im_heif = Image.open(out_im_heif) - assert im_heif.info.get("original_orientation", None) is None + if orientation == 1: + assert im_heif.info["original_orientation"] is None + else: + assert im_heif.info["original_orientation"] == orientation im_heif_exif = im_heif.getexif() assert 0x0112 not in im_heif_exif or im_heif_exif[0x0112] == 1 _im = pillow_heif.misc._rotate_pil(im, orientation) @@ -112,12 +120,15 @@ def test_heif_xmp_orientation(orientation): im.save(out_im_heif, format="HEIF", xmp=xmp.encode("utf-8"), quality=-1) im_heif = Image.open(out_im_heif) _im = pillow_heif.misc._rotate_pil(im, orientation) - assert im_heif.info.get("original_orientation", None) is None + if orientation == 1: + assert im_heif.info["original_orientation"] is None + else: + assert im_heif.info["original_orientation"] == orientation assert_image_similar(_im, im_heif) @pytest.mark.skipif(not hevc_enc(), reason="Requires HEVC encoder.") -@pytest.mark.parametrize("orientation", (1, 2, 8)) +@pytest.mark.parametrize("orientation", (1, 2, 3, 4, 5, 6, 7, 8)) def test_heif_xmp_orientation_exiftool(orientation): im = Image.effect_mandelbrot((256, 128), (-3, -2.5, 2, 2.5), 100).crop((0, 0, 256, 96)) im = im.convert(mode="RGB") @@ -127,12 +138,15 @@ def test_heif_xmp_orientation_exiftool(orientation): im.save(out_im_heif, format="HEIF", xmp=xmp.encode("utf-8"), quality=-1) im_heif = Image.open(out_im_heif) _im = pillow_heif.misc._rotate_pil(im, orientation) - assert im_heif.info.get("original_orientation", None) is None + if orientation != 1: + assert im_heif.info["original_orientation"] == orientation + else: + assert im_heif.info["original_orientation"] is None assert_image_similar(_im, im_heif) @pytest.mark.skipif(not hevc_enc(), reason="Requires HEVC encoder.") -@pytest.mark.parametrize("orientation", (1, 2, 8)) +@pytest.mark.parametrize("orientation", (1, 2, 3, 4, 5, 6, 7, 8)) def test_heif_xmp_orientation_with_exif_eq_1(orientation): im = Image.effect_mandelbrot((256, 128), (-3, -2.5, 2, 2.5), 100).crop((0, 0, 256, 96)) im = im.convert(mode="RGB") @@ -144,12 +158,15 @@ def test_heif_xmp_orientation_with_exif_eq_1(orientation): im.save(out_im_heif, format="HEIF", exif=exif_data.tobytes(), xmp=xmp.encode("utf-8"), quality=-1) im_heif = Image.open(out_im_heif) _im = pillow_heif.misc._rotate_pil(im, orientation) - assert im_heif.info.get("original_orientation", None) is None + if orientation != 1: + assert im_heif.info["original_orientation"] == orientation + else: + assert im_heif.info["original_orientation"] is None assert_image_similar(_im, im_heif) @pytest.mark.skipif(not hevc_enc(), reason="Requires HEVC encoder.") -@pytest.mark.parametrize("orientation", (1, 2, 8)) +@pytest.mark.parametrize("orientation", (1, 2, 3, 4, 5, 6, 7, 8)) @pytest.mark.parametrize("im_format", ("JPEG", "PNG")) def test_exif_heif_exif_orientation(orientation, im_format): out_im = BytesIO()