# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 import binascii import io from PIL import Image, ImageOps # We can preload Ico too because it is considered safe from PIL import IcoImagePlugin try: from PIL.Image import Transpose, Palette, Resampling except ImportError: Transpose = Palette = Resampling = Image from random import randrange from odoo.exceptions import UserError from odoo.tools.misc import DotDict from odoo.tools.translate import _ # Preload PIL with the minimal subset of image formats we need Image.preinit() Image._initialized = 2 # Maps only the 6 first bits of the base64 data, accurate enough # for our purpose and faster than decoding the full blob first FILETYPE_BASE64_MAGICWORD = { b'/': 'jpg', b'R': 'gif', b'i': 'png', b'P': 'svg+xml', b'U': 'webp', } 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: [Transpose.FLIP_LEFT_RIGHT], # top/right 3: [Transpose.ROTATE_180], # bottom/right 4: [Transpose.FLIP_TOP_BOTTOM], # bottom/left 5: [Transpose.FLIP_LEFT_RIGHT, Transpose.ROTATE_90],# left/top 6: [Transpose.ROTATE_270], # right/top 7: [Transpose.FLIP_TOP_BOTTOM, Transpose.ROTATE_90],# right/bottom 8: [Transpose.ROTATE_90], # left/bottom } # Arbitrary limit to fit most resolutions, including Samsung Galaxy A22 photo, # 8K with a ratio up to 16:10, and almost all variants of 4320p IMAGE_MAX_RESOLUTION = 50e6 class ImageProcess(): def __init__(self, source, verify_resolution=True): """Initialize the `source` image for processing. :param source: the original image binary No processing will be done if the `source` is falsy or if the image is SVG. :param verify_resolution: if True, make sure the original image size is not excessive before starting to process it. The max allowed resolution is defined by `IMAGE_MAX_RESOLUTION`. :type verify_resolution: bool :rtype: ImageProcess :raise: ValueError if `verify_resolution` is True and the image is too large :raise: UserError if the image can't be identified by PIL """ self.source = source or False self.operationsCount = 0 if not source or source[:1] == b'<' or (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'): # don't process empty source or SVG or WEBP self.image = False else: try: self.image = Image.open(io.BytesIO(source)) except (OSError, binascii.Error): raise UserError(_("This file could not be decoded as an image file.")) # 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 UserError(_("Image size excessive, uploaded images must be smaller than %s million pixels.", str(IMAGE_MAX_RESOLUTION / 1e6))) def image_quality(self, quality=0, output_format=''): """Return the image resulting of all the image processing operations that have been applied previously. Return False if the initialized `image` was falsy, and return the initialized `image` without change if it was SVG. Also return the initialized `image` if no operations have been applied and the `output_format` is the same as the original format and the quality is not specified. :param int quality: quality setting to apply. Default to 0. - for JPEG: 1 is worse, 95 is best. Values above 95 should be avoided. Falsy values will fallback to 95, but only if the image was changed, otherwise the original image is returned. - for PNG: set falsy to prevent conversion to a WEB palette. - for other formats: no effect. :param str output_format: the output format. Can be PNG, JPEG, GIF, or ICO. Default to the format of the original image. BMP is converted to PNG, other formats than those mentioned above are converted to JPEG. :return: image :rtype: bytes or False """ if not self.image: return self.source output_image = self.image output_format = output_format.upper() or self.original_format if output_format == 'BMP': output_format = 'PNG' elif output_format not in ['PNG', 'JPEG', 'GIF', 'ICO']: output_format = 'JPEG' if not self.operationsCount and output_format == self.original_format and not quality: return self.source opt = {'output_format': output_format} if output_format == 'PNG': opt['optimize'] = True if quality: if output_image.mode != 'P': # Floyd Steinberg dithering by default output_image = output_image.convert('RGBA').convert('P', palette=Palette.WEB, colors=256) if output_format == 'JPEG': opt['optimize'] = True opt['quality'] = quality or 95 if output_format == 'GIF': opt['optimize'] = True opt['save_all'] = True if output_image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (output_format == 'JPEG' and output_image.mode == 'RGBA'): output_image = output_image.convert("RGB") output_bytes = image_apply_opt(output_image, **opt) if len(output_bytes) >= len(self.source) and self.original_format == output_format and not self.operationsCount: # Format has not changed and image content is unchanged but the # reached binary is bigger: rather use the original. return self.source return output_bytes def resize(self, max_width=0, max_height=0): """Resize the image. The image is never resized above the current image size. This method is only to create a smaller version of the image. The current ratio is preserved. To change the ratio, see `crop_resize`. If `max_width` or `max_height` is falsy, it will be computed from the other to keep the current ratio. If both are falsy, no resize is done. It is currently not supported for GIF because we do not handle all the frames properly. :param int max_width: max width :param int max_height: max height :return: self to allow chaining :rtype: ImageProcess """ 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 if asked_width != w or asked_height != h: self.image.thumbnail((asked_width, asked_height), Resampling.LANCZOS) if self.image.width != w or self.image.height != h: self.operationsCount += 1 return self def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5): """Crop and resize the image. The image is never resized above the current image size. This method is only to create smaller versions of the image. Instead of preserving the ratio of the original image like `resize`, this method will force the output to take the ratio of the given `max_width` and `max_height`, so both have to be defined. The crop is done before the resize in order to preserve as much of the original image as possible. The goal of this method is primarily to resize to a given ratio, and it is not to crop unwanted parts of the original image. If the latter is what you want to do, you should create another method, or directly use the `crop` method from PIL. It is currently not supported for GIF because we do not handle all the frames properly. :param int max_width: max width :param int max_height: max height :param float center_x: the center of the crop between 0 (left) and 1 (right). Defaults to 0.5 (center). :param float center_y: the center of the crop between 0 (top) and 1 (bottom). Defaults to 0.5 (center). :return: self to allow chaining :rtype: ImageProcess """ 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 # original image. # The target size will be reached with the final resize. if w / max_width > h / max_height: new_w, new_h = w, (max_height * w) // max_width else: new_w, new_h = (max_width * h) // max_height, h # No cropping above image size. if new_w > w: new_w, new_h = w, (new_h * w) // new_w if new_h > h: new_w, new_h = (new_w * h) // new_h, h # Correctly place the center of the crop. x_offset = int((w - new_w) * center_x) h_offset = int((h - new_h) * center_y) if new_w != w or new_h != h: self.image = self.image.crop((x_offset, h_offset, x_offset + new_w, h_offset + new_h)) if self.image.width != w or self.image.height != h: self.operationsCount += 1 return self.resize(max_width, max_height) def colorize(self): """Replace the transparent background by a random color. :return: self to allow chaining :rtype: ImageProcess """ if self.image: original = self.image color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24)) self.image = Image.new('RGB', original.size) self.image.paste(color, box=(0, 0) + original.size) self.image.paste(original, mask=original) self.operationsCount += 1 return self def image_process(source, size=(0, 0), verify_resolution=False, quality=0, crop=None, colorize=False, output_format=''): """Process the `source` image by executing the given operations and return the result image. """ if not source or ((not size or (not size[0] and not size[1])) and not verify_resolution and not quality and not crop and not colorize and not output_format): # for performance: don't do anything if the image is falsy or if # no operations have been requested return source image = ImageProcess(source, verify_resolution) if size: if crop: center_x = 0.5 center_y = 0.5 if crop == 'top': center_y = 0 elif crop == 'bottom': center_y = 1 image.crop_resize(max_width=size[0], max_height=size[1], center_x=center_x, center_y=center_y) else: image.resize(max_width=size[0], max_height=size[1]) if colorize: image.colorize() return image.image_quality(quality=quality, output_format=output_format) # ---------------------------------------- # Misc image tools # --------------------------------------- def average_dominant_color(colors, mitigate=175, max_margin=140): """This function is used to calculate the dominant colors when given a list of colors There are 5 steps: 1) Select dominant colors (highest count), isolate its values and remove it from the current color set. 2) Set margins according to the prevalence of the dominant color. 3) Evaluate the colors. Similar colors are grouped in the dominant set while others are put in the "remaining" list. 4) Calculate the average color for the dominant set. This is done by averaging each band and joining them into a tuple. 5) Mitigate final average and convert it to hex :param colors: list of tuples having: 0. color count in the image 1. actual color: tuple(R, G, B, A) -> these can be extracted from a PIL image using :meth:`~PIL.Image.Image.getcolors` :param mitigate: maximum value a band can reach :param max_margin: maximum difference from one of the dominant values :returns: a tuple with two items: 0. the average color of the dominant set as: tuple(R, G, B) 1. list of remaining colors, used to evaluate subsequent dominant colors """ dominant_color = max(colors) dominant_rgb = dominant_color[1][:3] dominant_set = [dominant_color] remaining = [] margins = [max_margin * (1 - dominant_color[0] / sum([col[0] for col in colors]))] * 3 colors.remove(dominant_color) for color in colors: rgb = color[1] if (rgb[0] < dominant_rgb[0] + margins[0] and rgb[0] > dominant_rgb[0] - margins[0] and rgb[1] < dominant_rgb[1] + margins[1] and rgb[1] > dominant_rgb[1] - margins[1] and rgb[2] < dominant_rgb[2] + margins[2] and rgb[2] > dominant_rgb[2] - margins[2]): dominant_set.append(color) else: remaining.append(color) dominant_avg = [] for band in range(3): avg = total = 0 for color in dominant_set: avg += color[0] * color[1][band] total += color[0] dominant_avg.append(int(avg / total)) final_dominant = [] brightest = max(dominant_avg) for color in range(3): value = dominant_avg[color] / (brightest / mitigate) if brightest > mitigate else dominant_avg[color] final_dominant.append(int(value)) 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.Image :return: the resulting image, copy of the source, with orientation fixed or the source image if no operation was applied :rtype: ~PIL.Image.Image """ getexif = getattr(image, 'getexif', None) or getattr(image, '_getexif', None) # support PIL < 6.0 if getexif: exif = 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 return image def binary_to_image(source): try: return Image.open(io.BytesIO(source)) except (OSError, binascii.Error): raise UserError(_("This file could not be decoded as an image file.")) def base64_to_image(base64_source): """Return a PIL image from the given `base64_source`. :param base64_source: the image base64 encoded :type base64_source: string or bytes :rtype: ~PIL.Image.Image :raise: UserError if the base64 is incorrect or the image can't be identified by PIL """ try: return Image.open(io.BytesIO(base64.b64decode(base64_source))) except (OSError, binascii.Error): raise UserError(_("This file could not be decoded as an image file.")) def image_apply_opt(image, output_format, **params): """Return the given PIL `image` using `params`. :type image: ~PIL.Image.Image :param str output_format: :meth:`~PIL.Image.Image.save`'s ``format`` parameter :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save` :return: the image formatted :rtype: bytes """ if output_format == 'JPEG' and image.mode not in ['1', 'L', 'RGB']: image = image.convert("RGB") stream = io.BytesIO() image.save(stream, format=output_format, **params) return stream.getvalue() def image_to_base64(image, output_format, **params): """Return a base64_image from the given PIL `image` using `params`. :type image: ~PIL.Image.Image :param str output_format: :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save` :return: the image base64 encoded :rtype: bytes """ stream = image_apply_opt(image, output_format, **params) return base64.b64encode(stream) def get_webp_size(source): """ Returns the size of the provided webp binary source for VP8, VP8X and VP8L, otherwise returns None. See https://developers.google.com/speed/webp/docs/riff_container. :param source: binary source :return: (width, height) tuple, or None if not supported """ if not (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'): raise UserError(_("This file is not a webp file.")) vp8_type = source[15] if vp8_type == 0x20: # 0x20 = ' ' # Sizes on big-endian 16 bits at offset 26. width_low, width_high, height_low, height_high = source[26:30] width = (width_high << 8) + width_low height = (height_high << 8) + height_low return (width, height) elif vp8_type == 0x58: # 0x48 = 'X' # Sizes (minus one) on big-endian 24 bits at offset 24. width_low, width_medium, width_high, height_low, height_medium, height_high = source[24:30] width = 1 + (width_high << 16) + (width_medium << 8) + width_low height = 1 + (height_high << 16) + (height_medium << 8) + height_low return (width, height) elif vp8_type == 0x4C and source[20] == 0x2F: # 0x4C = 'L' # Sizes (minus one) on big-endian-ish 14 bits at offset 21. # E.g. [@20] 2F ab cd ef gh # - width = 1 + (c&0x3)d ab: ignore the two high bits of the second byte # - height= 1 + hef(c&0xC>>2): used them as the first two bits of the height ab, cd, ef, gh = source[21:25] width = 1 + ((cd & 0x3F) << 8) + ab height = 1 + ((gh & 0xF) << 10) + (ef << 2) + (cd >> 6) return (width, height) return None def is_image_size_above(base64_source_1, base64_source_2): """Return whether or not the size of the given image `base64_source_1` is above the size of the given image `base64_source_2`. """ if not base64_source_1 or not base64_source_2: return False if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'): # False for SVG return False def get_image_size(base64_source): source = base64.b64decode(base64_source) if (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'): size = get_webp_size(source) if size: return DotDict({'width': size[0], 'height': size[0]}) else: # False for unknown WEBP format return False else: return image_fix_orientation(binary_to_image(source)) image_source = get_image_size(base64_source_1) image_target = get_image_size(base64_source_2) return image_source.width > image_target.width or image_source.height > image_target.height def image_guess_size_from_field_name(field_name): """Attempt to guess the image size based on `field_name`. If it can't be guessed or if it is a custom field: return (0, 0) instead. :param str field_name: the name of a field :return: the guessed size :rtype: tuple (width, height) """ if field_name == 'image': return (1024, 1024) if field_name.startswith('x_'): return (0, 0) try: suffix = int(field_name.split('_')[-1]) except ValueError: return (0, 0) if suffix < 16: # If the suffix is less than 16, it's probably not the size return (0, 0) return (suffix, suffix) def image_data_uri(base64_source): """This returns data URL scheme according RFC 2397 (https://tools.ietf.org/html/rfc2397) for all kind of supported images (PNG, GIF, JPG and SVG), defaulting on PNG type if not mimetype detected. """ return 'data:image/%s;base64,%s' % ( FILETYPE_BASE64_MAGICWORD.get(base64_source[:1], 'png'), base64_source.decode(), ) def get_saturation(rgb): """Returns the saturation (hsl format) of a given rgb color :param rgb: rgb tuple or list :return: saturation """ c_max = max(rgb) / 255 c_min = min(rgb) / 255 d = c_max - c_min return 0 if d == 0 else d / (1 - abs(c_max + c_min - 1)) def get_lightness(rgb): """Returns the lightness (hsl format) of a given rgb color :param rgb: rgb tuple or list :return: lightness """ return (max(rgb) + min(rgb)) / 2 / 255 def hex_to_rgb(hx): """Converts an hexadecimal string (starting with '#') to a RGB tuple""" return tuple([int(hx[i:i+2], 16) for i in range(1, 6, 2)]) def rgb_to_hex(rgb): """Converts a RGB tuple or list to an hexadecimal string""" return '#' + ''.join([(hex(c).split('x')[-1].zfill(2)) for c in rgb])