# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2020-2023 Colin B. Macdonald
import logging
from pathlib import Path
import exif
from PIL import Image
log = logging.getLogger("scan")
[docs]def rotate_bitmap(fname, angle, *, clockwise=False):
"""Rotate bitmap counterclockwise, possibly in metadata.
args:
filename (pathlib.Path/str): name of a file
angle (int): CCW angle of rotation: 0, 90, 180, 270, or -90.
keyword args:
clockwise (bool): By default this is False and we do anti-clockwise
("counter-clockwise") rotations. Pass True if you want `+90`
to be a clockwise rotation instead.
If its a jpeg, we have special handling, otherwise, we use the Python
library ``PIL`` to open, rotate and then resave the image, replacing
the original.
"""
assert angle in (0, 90, 180, 270, -90), f"Invalid rotation angle {angle}"
fname = Path(fname)
if clockwise:
if angle == 90:
angle = -90
elif angle == -90 or angle == 270:
angle = 90
if fname.suffix.lower() in (".jpg", ".jpeg"):
return rotate_bitmap_jpeg_exif(fname, angle)
if angle == 0:
return
# Note PIL does CCW (Issue #2585)
img = Image.open(fname)
new_img = img.rotate(angle, expand=True)
new_img.save(fname)
def rotate_bitmap_jpeg_exif(fname, angle):
"""Rotate jpeg using exif metadata rotations.
args:
filename (pathlib.Path): name of a file
angle (int): CCW angle of rotation 0, 90, 180, 270, or -90.
If the image already had a exif rotation tag it is ignored: the
rotation is absolute, NOT relative to that existing transform.
This is b/c the QR code reading bits earlier in the pipeline do not
support exif tags: perhaps they should and we revisit this decision.
"""
assert angle in (0, 90, 180, 270, -90), f"Invalid rotation angle {angle}"
log.info(f"Rotation of {angle:3} on JPEG {fname}: doing metadata EXIF rotations")
with open(fname, "rb") as f:
im = exif.Image(f)
if im.has_exif:
log.info(f'{fname} has exif already, orientation: {im.get("orientation")}')
# Notation is OrigTop_OrigLeft -> RIGHT_TOP (-90 degree rot CCW)
table = {
0: exif.Orientation.TOP_LEFT,
90: exif.Orientation.LEFT_BOTTOM,
180: exif.Orientation.BOTTOM_RIGHT,
-90: exif.Orientation.RIGHT_TOP,
270: exif.Orientation.RIGHT_TOP,
}
im.set("orientation", table[angle])
with open(fname, "wb") as f:
f.write(im.get_file())
def pil_load_with_jpeg_exif_rot_applied(f):
"""PIL's Image load does not apply exif orientation, so provide a helper that does.
Args:
f (str/pathlib.Path): a path to a file.
If the input is not a jpeg, we simplify open it with ``PIL`` and
return with no special processing. If its a jpeg, we apply the exif
rotations, then return.
Returns:
PIL.Image: with exif orientation applied.
"""
f = Path(f)
im = Image.open(f)
im.load()
if f.suffix.casefold() in (".jpg", ".jpeg"):
r = rot_angle_from_jpeg_exif_tag(f)
im = im.rotate(r, expand=True)
return im
def rot_angle_from_jpeg_exif_tag(img_name):
"""If we have a jpeg and it has exif orientation data, return the angle of that rotation.
That is, if you apply a rotation of this angle, the image will appear the same as
the original would in an exif-aware viewer. The angle is CCW.
If not a jpeg, then return 0.
"""
img_name = Path(img_name)
if img_name.suffix not in (".jpg", ".jpeg"):
return 0
with open(img_name, "rb") as f:
im = exif.Image(f)
if not im.has_exif:
return 0
o = im.get("orientation")
if o is None:
return 0
# print(f"{img_name} has exif orientation: {o}")
if o == exif.Orientation.TOP_LEFT:
return 0
elif o == exif.Orientation.RIGHT_TOP:
return -90
elif o == exif.Orientation.BOTTOM_RIGHT:
return 180
elif o == exif.Orientation.LEFT_BOTTOM:
return 90
else:
raise NotImplementedError(f"Unexpected exif orientation: {o}")