From 932a14932ef22f43ceb85019f792d14621731bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 11 Oct 2019 16:38:55 +0000 Subject: [PATCH] [FIX] tools, base: fix orientation of image with EXIF orientation tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, some images would display incorrectly orientated. This typically happens for images taken from a non-standard orientation by some phones or other devices that are able to report orientation. The specified transposition is applied to the image before all other operations, because all of them expect the image to be in its final orientation, which is the case only when the first row of pixels is the top of the image and the first column of pixels is the left of the image. Moreover the EXIF tags will not be kept when the image is later saved, so the transposition has to be done to ensure the final image is correctly orientated. closes odoo/odoo#37448 Signed-off-by: Sébastien Theys (seb) --- odoo/addons/base/tests/test_image.py | 54 ++++++++++++++ .../base/wizard/base_document_layout.py | 2 +- odoo/tools/image.py | 73 +++++++++++++++++-- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/odoo/addons/base/tests/test_image.py b/odoo/addons/base/tests/test_image.py index 838c37a84a1f9..2d4c3940b8dc3 100644 --- a/odoo/addons/base/tests/test_image.py +++ b/odoo/addons/base/tests/test_image.py @@ -3,6 +3,7 @@ import base64 import binascii + from PIL import Image, ImageDraw, PngImagePlugin from odoo import tools @@ -67,6 +68,29 @@ def test_01_image_to_base64(self): image_base64 = tools.image_to_base64(image, 'PNG') self.assertEqual(image_base64, self.base64_1x1_png) + def test_02_image_fix_orientation(self): + """Test that the orientation of images is correct.""" + + # Colors that can be distinguished among themselves even with jpeg loss. + blue = (0, 0, 255) + yellow = (255, 255, 0) + green = (0, 255, 0) + pink = (255, 0, 255) + # Image large enough so jpeg loss is not a huge factor in the corners. + size = 50 + expected = (blue, yellow, green, pink) + + # They are all supposed to be same image: (blue, yellow, green, pink) in + # that order, but each encoded with a different orientation. + self._orientation_test(1, (blue, yellow, green, pink), size, expected) # top/left + self._orientation_test(2, (yellow, blue, pink, green), size, expected) # top/right + self._orientation_test(3, (pink, green, yellow, blue), size, expected) # bottom/right + self._orientation_test(4, (green, pink, blue, yellow), size, expected) # bottom/left + self._orientation_test(5, (blue, green, yellow, pink), size, expected) # left/top + self._orientation_test(6, (yellow, pink, blue, green), size, expected) # right/top + self._orientation_test(7, (pink, yellow, green, blue), size, expected) # right/bottom + self._orientation_test(8, (green, blue, pink, yellow), size, expected) # left/bottom + def test_10_image_process_base64_source(self): """Test the base64_source parameter of image_process.""" wrong_base64 = b'oazdazpodazdpok' @@ -236,3 +260,33 @@ def test_16_image_process_format(self): def test_20_image_data_uri(self): """Test that image_data_uri is working as expected.""" self.assertEqual(tools.image_data_uri(self.base64_1x1_png), 'data:image/png;base64,' + self.base64_1x1_png.decode('ascii')) + + def _assertAlmostEqualSequence(self, rgb1, rgb2, delta=10): + self.assertEqual(len(rgb1), len(rgb2)) + for index, t in enumerate(zip(rgb1, rgb2)): + self.assertAlmostEqual(t[0], t[1], delta=delta, msg="%s vs %s at %d" % (rgb1, rgb2, index)) + + def _get_exif_colored_square_b64(self, orientation, colors, size): + image = Image.new('RGB', (size, size), color=self.bg_color) + draw = ImageDraw.Draw(image) + # Paint the colors on the 4 corners, to be able to test which colors + # move on which corners. + draw.rectangle(xy=[(0, 0), (size // 2, size // 2)], fill=colors[0]) # top/left + draw.rectangle(xy=[(size // 2, 0), (size, size // 2)], fill=colors[1]) # top/right + draw.rectangle(xy=[(0, size // 2), (size // 2, size)], fill=colors[2]) # bottom/left + draw.rectangle(xy=[(size // 2, size // 2), (size, size)], fill=colors[3]) # bottom/right + # Set the proper exif tag based on orientation params. + exif = b'Exif\x00\x00II*\x00\x08\x00\x00\x00\x01\x00\x12\x01\x03\x00\x01\x00\x00\x00' + bytes([orientation]) + b'\x00\x00\x00\x00\x00\x00\x00' + # The image image is saved with the exif tag. + return tools.image_to_base64(image, 'JPEG', exif=exif) + + def _orientation_test(self, orientation, colors, size, expected): + # Generate the test image based on orientation and order of colors. + b64_image = self._get_exif_colored_square_b64(orientation, colors, size) + # The image is read again now that it has orientation added. + fixed_image = tools.image_fix_orientation(tools.base64_to_image(b64_image)) + # Ensure colors are in the right order (blue, yellow, green, pink). + self._assertAlmostEqualSequence(fixed_image.getpixel((0, 0)), expected[0]) # top/left + self._assertAlmostEqualSequence(fixed_image.getpixel((size - 1, 0)), expected[1]) # top/right + self._assertAlmostEqualSequence(fixed_image.getpixel((0, size - 1)), expected[2]) # bottom/left + self._assertAlmostEqualSequence(fixed_image.getpixel((size - 1, size - 1)), expected[3]) # bottom/right diff --git a/odoo/addons/base/wizard/base_document_layout.py b/odoo/addons/base/wizard/base_document_layout.py index e41ed375d90db..d380c1c49a6d5 100644 --- a/odoo/addons/base/wizard/base_document_layout.py +++ b/odoo/addons/base/wizard/base_document_layout.py @@ -137,7 +137,7 @@ def _parse_logo_colors(self, logo=None, white_threshold=225): logo += b'===' if type(logo) == bytes else '===' try: # Catches exceptions caused by logo not being an image - image = tools.base64_to_image(logo) + image = tools.image_fix_orientation(tools.base64_to_image(logo)) except Exception: return False, False diff --git a/odoo/tools/image.py b/odoo/tools/image.py index f5b4b953da90b..590bdf1bbfa8f 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -4,7 +4,7 @@ import binascii import io -from PIL import Image +from PIL import Image, ImageOps # We can preload Ico too because it is considered safe from PIL import IcoImagePlugin @@ -27,6 +27,21 @@ b'P': 'svg+xml', } +EXIF_TAG_ORIENTATION = 0x112 +# The target is to have 1st row/col to be top/left +# Note: rotate is counterclockwise +EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS = { # Initial side on 1st row/col: + 0: [], # reserved + 1: [], # top/left + 2: [Image.FLIP_LEFT_RIGHT], # top/right + 3: [Image.ROTATE_180], # bottom/right + 4: [Image.FLIP_TOP_BOTTOM], # bottom/left + 5: [Image.FLIP_LEFT_RIGHT, Image.ROTATE_90], # left/top + 6: [Image.ROTATE_270], # right/top + 7: [Image.FLIP_TOP_BOTTOM, Image.ROTATE_90], # right/bottom + 8: [Image.ROTATE_90], # left/bottom +} + # Arbitraty limit to fit most resolutions, including Nokia Lumia 1020 photo, # 8K with a ratio up to 16:10, and almost all variants of 4320p IMAGE_MAX_RESOLUTION = 45e6 @@ -62,12 +77,17 @@ def __init__(self, base64_source, verify_resolution=True): else: self.image = base64_to_image(self.base64_source) + # Original format has to be saved before fixing the orientation or + # doing any other operations because the information will be lost on + # the resulting image. + self.original_format = (self.image.format or '').upper() + + self.image = image_fix_orientation(self.image) + w, h = self.image.size if verify_resolution and w * h > IMAGE_MAX_RESOLUTION: raise ValueError(_("Image size excessive, uploaded images must be smaller than %s million pixels.") % str(IMAGE_MAX_RESOLUTION / 10e6)) - self.original_format = self.image.format.upper() - def image_base64(self, quality=0, output_format=''): """Return the base64 encoded image resulting of all the image processing operations that have been applied previously. @@ -152,7 +172,7 @@ def resize(self, max_width=0, max_height=0): :return: self to allow chaining :rtype: ImageProcess """ - if self.image and self.image.format != 'GIF' and (max_width or max_height): + if self.image and self.original_format != 'GIF' and (max_width or max_height): w, h = self.image.size asked_width = max_width or (w * max_height) // h asked_height = max_height or (h * max_width) // w @@ -198,7 +218,7 @@ def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5): :return: self to allow chaining :rtype: ImageProcess """ - if self.image and self.image.format != 'GIF' and max_width and max_height: + if self.image and self.original_format != 'GIF' and max_width and max_height: w, h = self.image.size # We want to keep as much of the image as possible -> at least one # of the 2 crop dimensions always has to be the same value as the @@ -331,6 +351,45 @@ def average_dominant_color(colors, mitigate=175, max_margin=140): return tuple(final_dominant), remaining +def image_fix_orientation(image): + """Fix the orientation of the image if it has an EXIF orientation tag. + + This typically happens for images taken from a non-standard orientation + by some phones or other devices that are able to report orientation. + + The specified transposition is applied to the image before all other + operations, because all of them expect the image to be in its final + orientation, which is the case only when the first row of pixels is the top + of the image and the first column of pixels is the left of the image. + + Moreover the EXIF tags will not be kept when the image is later saved, so + the transposition has to be done to ensure the final image is correctly + orientated. + + Note: to be completely correct, the resulting image should have its exif + orientation tag removed, since the transpositions have been applied. + However since this tag is not used in the code, it is acceptable to + save the complexity of removing it. + + :param image: the source image + :type image: PIL.Image + + :return: the resulting image, copy of the source, with orientation fixed + or the source image if no operation was applied + :rtype: PIL.Image + """ + # `exif_transpose` was added in Pillow 6.0 + if hasattr(ImageOps, 'exif_transpose'): + return ImageOps.exif_transpose(image) + if (image.format or '').upper() == 'JPEG' and hasattr(image, '_getexif'): + exif = image._getexif() + if exif: + orientation = exif.get(EXIF_TAG_ORIENTATION, 0) + for method in EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS.get(orientation, []): + image = image.transpose(method) + return image + + def base64_to_image(base64_source): """Return a PIL image from the given `base64_source`. @@ -374,8 +433,8 @@ def is_image_size_above(base64_source_1, base64_source_2): if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'): # False for SVG return False - image_source = base64_to_image(base64_source_1) - image_target = base64_to_image(base64_source_2) + image_source = image_fix_orientation(base64_to_image(base64_source_1)) + image_target = image_fix_orientation(base64_to_image(base64_source_2)) return image_source.width > image_target.width or image_source.height > image_target.height