Skip to content

Commit

Permalink
[FIX] tools, base: fix orientation of image with EXIF orientation tag
Browse files Browse the repository at this point in the history
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 #37448

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
  • Loading branch information
seb-odoo committed Oct 11, 2019
1 parent 341b568 commit 932a149
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 8 deletions.
54 changes: 54 additions & 0 deletions odoo/addons/base/tests/test_image.py
Expand Up @@ -3,6 +3,7 @@

import base64
import binascii

from PIL import Image, ImageDraw, PngImagePlugin

from odoo import tools
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion odoo/addons/base/wizard/base_document_layout.py
Expand Up @@ -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

Expand Down
73 changes: 66 additions & 7 deletions odoo/tools/image.py
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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


Expand Down

0 comments on commit 932a149

Please sign in to comment.