Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passing image_orientation value to libheif #168

Merged
merged 2 commits into from
Nov 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/workaround-orientation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,21 @@ 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?
""""""""""""""""""""""""""

The best one and simplest solution is to
`remove it <https://github.com/strukturag/libheif/issues/219#issuecomment-638110043>`_.

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,
Expand Down
17 changes: 9 additions & 8 deletions pillow_heif/_pillow_heif.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 ======== */
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions pillow_heif/as_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
10 changes: 9 additions & 1 deletion pillow_heif/heif.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
MimCImage,
_exif_from_pillow,
_get_bytes,
_get_orientation_for_encoder,
_get_primary_index,
_pil_to_supported_mode,
_retrieve_exif,
Expand Down Expand Up @@ -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)


Expand Down
69 changes: 44 additions & 25 deletions pillow_heif/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<tiff:Orientation>([0-9])</tiff:Orientation>", "", 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:
Expand All @@ -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
Expand All @@ -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"<tiff:Orientation>([0-9])</tiff:Orientation>", "", 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:
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand Down
31 changes: 24 additions & 7 deletions tests/orientation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@

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

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 = (
'<?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?>\n'
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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()
Expand Down