Skip to content

Commit

Permalink
Passing image_orientation value to libheif
Browse files Browse the repository at this point in the history
Signed-off-by: Alexander Piskun <[email protected]>
  • Loading branch information
bigcat88 committed Nov 11, 2023
1 parent 4614b8e commit 9b7ae85
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 46 deletions.
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
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

0 comments on commit 9b7ae85

Please sign in to comment.