1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3import base64 4import binascii 5import io 6 7from PIL import Image, ImageOps 8# We can preload Ico too because it is considered safe 9from PIL import IcoImagePlugin 10 11from random import randrange 12 13from odoo.exceptions import UserError 14from odoo.tools.translate import _ 15 16 17# Preload PIL with the minimal subset of image formats we need 18Image.preinit() 19Image._initialized = 2 20 21# Maps only the 6 first bits of the base64 data, accurate enough 22# for our purpose and faster than decoding the full blob first 23FILETYPE_BASE64_MAGICWORD = { 24 b'/': 'jpg', 25 b'R': 'gif', 26 b'i': 'png', 27 b'P': 'svg+xml', 28} 29 30EXIF_TAG_ORIENTATION = 0x112 31# The target is to have 1st row/col to be top/left 32# Note: rotate is counterclockwise 33EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS = { # Initial side on 1st row/col: 34 0: [], # reserved 35 1: [], # top/left 36 2: [Image.FLIP_LEFT_RIGHT], # top/right 37 3: [Image.ROTATE_180], # bottom/right 38 4: [Image.FLIP_TOP_BOTTOM], # bottom/left 39 5: [Image.FLIP_LEFT_RIGHT, Image.ROTATE_90], # left/top 40 6: [Image.ROTATE_270], # right/top 41 7: [Image.FLIP_TOP_BOTTOM, Image.ROTATE_90], # right/bottom 42 8: [Image.ROTATE_90], # left/bottom 43} 44 45# Arbitraty limit to fit most resolutions, including Nokia Lumia 1020 photo, 46# 8K with a ratio up to 16:10, and almost all variants of 4320p 47IMAGE_MAX_RESOLUTION = 45e6 48 49 50class ImageProcess(): 51 52 def __init__(self, base64_source, verify_resolution=True): 53 """Initialize the `base64_source` image for processing. 54 55 :param base64_source: the original image base64 encoded 56 No processing will be done if the `base64_source` is falsy or if 57 the image is SVG. 58 :type base64_source: string or bytes 59 60 :param verify_resolution: if True, make sure the original image size is not 61 excessive before starting to process it. The max allowed resolution is 62 defined by `IMAGE_MAX_RESOLUTION`. 63 :type verify_resolution: bool 64 65 :return: self 66 :rtype: ImageProcess 67 68 :raise: ValueError if `verify_resolution` is True and the image is too large 69 :raise: UserError if the base64 is incorrect or the image can't be identified by PIL 70 """ 71 self.base64_source = base64_source or False 72 self.operationsCount = 0 73 74 if not base64_source or base64_source[:1] in (b'P', 'P'): 75 # don't process empty source or SVG 76 self.image = False 77 else: 78 self.image = base64_to_image(self.base64_source) 79 80 # Original format has to be saved before fixing the orientation or 81 # doing any other operations because the information will be lost on 82 # the resulting image. 83 self.original_format = (self.image.format or '').upper() 84 85 self.image = image_fix_orientation(self.image) 86 87 w, h = self.image.size 88 if verify_resolution and w * h > IMAGE_MAX_RESOLUTION: 89 raise ValueError(_("Image size excessive, uploaded images must be smaller than %s million pixels.", str(IMAGE_MAX_RESOLUTION / 10e6))) 90 91 def image_base64(self, quality=0, output_format=''): 92 """Return the base64 encoded image resulting of all the image processing 93 operations that have been applied previously. 94 95 Return False if the initialized `base64_source` was falsy, and return 96 the initialized `base64_source` without change if it was SVG. 97 98 Also return the initialized `base64_source` if no operations have been 99 applied and the `output_format` is the same as the original format and 100 the quality is not specified. 101 102 :param quality: quality setting to apply. Default to 0. 103 - for JPEG: 1 is worse, 95 is best. Values above 95 should be 104 avoided. Falsy values will fallback to 95, but only if the image 105 was changed, otherwise the original image is returned. 106 - for PNG: set falsy to prevent conversion to a WEB palette. 107 - for other formats: no effect. 108 :type quality: int 109 110 :param output_format: the output format. Can be PNG, JPEG, GIF, or ICO. 111 Default to the format of the original image. BMP is converted to 112 PNG, other formats than those mentioned above are converted to JPEG. 113 :type output_format: string 114 115 :return: image base64 encoded or False 116 :rtype: bytes or False 117 """ 118 output_image = self.image 119 120 if not output_image: 121 return self.base64_source 122 123 output_format = output_format.upper() or self.original_format 124 if output_format == 'BMP': 125 output_format = 'PNG' 126 elif output_format not in ['PNG', 'JPEG', 'GIF', 'ICO']: 127 output_format = 'JPEG' 128 129 if not self.operationsCount and output_format == self.original_format and not quality: 130 return self.base64_source 131 132 opt = {'format': output_format} 133 134 if output_format == 'PNG': 135 opt['optimize'] = True 136 if quality: 137 if output_image.mode != 'P': 138 # Floyd Steinberg dithering by default 139 output_image = output_image.convert('RGBA').convert('P', palette=Image.WEB, colors=256) 140 if output_format == 'JPEG': 141 opt['optimize'] = True 142 opt['quality'] = quality or 95 143 if output_format == 'GIF': 144 opt['optimize'] = True 145 opt['save_all'] = True 146 147 if output_image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (output_format == 'JPEG' and output_image.mode == 'RGBA'): 148 output_image = output_image.convert("RGB") 149 150 return image_to_base64(output_image, **opt) 151 152 def resize(self, max_width=0, max_height=0): 153 """Resize the image. 154 155 The image is never resized above the current image size. This method is 156 only to create a smaller version of the image. 157 158 The current ratio is preserved. To change the ratio, see `crop_resize`. 159 160 If `max_width` or `max_height` is falsy, it will be computed from the 161 other to keep the current ratio. If both are falsy, no resize is done. 162 163 It is currently not supported for GIF because we do not handle all the 164 frames properly. 165 166 :param max_width: max width 167 :type max_width: int 168 169 :param max_height: max height 170 :type max_height: int 171 172 :return: self to allow chaining 173 :rtype: ImageProcess 174 """ 175 if self.image and self.original_format != 'GIF' and (max_width or max_height): 176 w, h = self.image.size 177 asked_width = max_width or (w * max_height) // h 178 asked_height = max_height or (h * max_width) // w 179 if asked_width != w or asked_height != h: 180 self.image.thumbnail((asked_width, asked_height), Image.LANCZOS) 181 if self.image.width != w or self.image.height != h: 182 self.operationsCount += 1 183 return self 184 185 def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5): 186 """Crop and resize the image. 187 188 The image is never resized above the current image size. This method is 189 only to create smaller versions of the image. 190 191 Instead of preserving the ratio of the original image like `resize`, 192 this method will force the output to take the ratio of the given 193 `max_width` and `max_height`, so both have to be defined. 194 195 The crop is done before the resize in order to preserve as much of the 196 original image as possible. The goal of this method is primarily to 197 resize to a given ratio, and it is not to crop unwanted parts of the 198 original image. If the latter is what you want to do, you should create 199 another method, or directly use the `crop` method from PIL. 200 201 It is currently not supported for GIF because we do not handle all the 202 frames properly. 203 204 :param max_width: max width 205 :type max_width: int 206 207 :param max_height: max height 208 :type max_height: int 209 210 :param center_x: the center of the crop between 0 (left) and 1 (right) 211 Default to 0.5 (center). 212 :type center_x: float 213 214 :param center_y: the center of the crop between 0 (top) and 1 (bottom) 215 Default to 0.5 (center). 216 :type center_y: float 217 218 :return: self to allow chaining 219 :rtype: ImageProcess 220 """ 221 if self.image and self.original_format != 'GIF' and max_width and max_height: 222 w, h = self.image.size 223 # We want to keep as much of the image as possible -> at least one 224 # of the 2 crop dimensions always has to be the same value as the 225 # original image. 226 # The target size will be reached with the final resize. 227 if w / max_width > h / max_height: 228 new_w, new_h = w, (max_height * w) // max_width 229 else: 230 new_w, new_h = (max_width * h) // max_height, h 231 232 # No cropping above image size. 233 if new_w > w: 234 new_w, new_h = w, (new_h * w) // new_w 235 if new_h > h: 236 new_w, new_h = (new_w * h) // new_h, h 237 238 # Correctly place the center of the crop. 239 x_offset = int((w - new_w) * center_x) 240 h_offset = int((h - new_h) * center_y) 241 242 if new_w != w or new_h != h: 243 self.image = self.image.crop((x_offset, h_offset, x_offset + new_w, h_offset + new_h)) 244 if self.image.width != w or self.image.height != h: 245 self.operationsCount += 1 246 247 return self.resize(max_width, max_height) 248 249 def colorize(self): 250 """Replace the transparent background by a random color. 251 252 :return: self to allow chaining 253 :rtype: ImageProcess 254 """ 255 if self.image: 256 original = self.image 257 color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24)) 258 self.image = Image.new('RGB', original.size) 259 self.image.paste(color, box=(0, 0) + original.size) 260 self.image.paste(original, mask=original) 261 self.operationsCount += 1 262 return self 263 264 265def image_process(base64_source, size=(0, 0), verify_resolution=False, quality=0, crop=None, colorize=False, output_format=''): 266 """Process the `base64_source` image by executing the given operations and 267 return the result as a base64 encoded image. 268 """ 269 if not base64_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): 270 # for performance: don't do anything if the image is falsy or if 271 # no operations have been requested 272 return base64_source 273 274 image = ImageProcess(base64_source, verify_resolution) 275 if size: 276 if crop: 277 center_x = 0.5 278 center_y = 0.5 279 if crop == 'top': 280 center_y = 0 281 elif crop == 'bottom': 282 center_y = 1 283 image.crop_resize(max_width=size[0], max_height=size[1], center_x=center_x, center_y=center_y) 284 else: 285 image.resize(max_width=size[0], max_height=size[1]) 286 if colorize: 287 image.colorize() 288 return image.image_base64(quality=quality, output_format=output_format) 289 290 291# ---------------------------------------- 292# Misc image tools 293# --------------------------------------- 294 295def average_dominant_color(colors, mitigate=175, max_margin=140): 296 """This function is used to calculate the dominant colors when given a list of colors 297 298 There are 5 steps : 299 1) Select dominant colors (highest count), isolate its values and remove 300 it from the current color set. 301 2) Set margins according to the prevalence of the dominant color. 302 3) Evaluate the colors. Similar colors are grouped in the dominant set 303 while others are put in the "remaining" list. 304 4) Calculate the average color for the dominant set. This is done by 305 averaging each band and joining them into a tuple. 306 5) Mitigate final average and convert it to hex 307 308 :param colors: list of tuples having: 309 [0] color count in the image 310 [1] actual color: tuple(R, G, B, A) 311 -> these can be extracted from a PIL image using image.getcolors() 312 :param mitigate: maximum value a band can reach 313 :param max_margin: maximum difference from one of the dominant values 314 :returns: a tuple with two items: 315 [0] the average color of the dominant set as: tuple(R, G, B) 316 [1] list of remaining colors, used to evaluate subsequent dominant colors 317 """ 318 dominant_color = max(colors) 319 dominant_rgb = dominant_color[1][:3] 320 dominant_set = [dominant_color] 321 remaining = [] 322 323 margins = [max_margin * (1 - dominant_color[0] / 324 sum([col[0] for col in colors]))] * 3 325 326 colors.remove(dominant_color) 327 328 for color in colors: 329 rgb = color[1] 330 if (rgb[0] < dominant_rgb[0] + margins[0] and rgb[0] > dominant_rgb[0] - margins[0] and 331 rgb[1] < dominant_rgb[1] + margins[1] and rgb[1] > dominant_rgb[1] - margins[1] and 332 rgb[2] < dominant_rgb[2] + margins[2] and rgb[2] > dominant_rgb[2] - margins[2]): 333 dominant_set.append(color) 334 else: 335 remaining.append(color) 336 337 dominant_avg = [] 338 for band in range(3): 339 avg = total = 0 340 for color in dominant_set: 341 avg += color[0] * color[1][band] 342 total += color[0] 343 dominant_avg.append(int(avg / total)) 344 345 final_dominant = [] 346 brightest = max(dominant_avg) 347 for color in range(3): 348 value = dominant_avg[color] / (brightest / mitigate) if brightest > mitigate else dominant_avg[color] 349 final_dominant.append(int(value)) 350 351 return tuple(final_dominant), remaining 352 353 354def image_fix_orientation(image): 355 """Fix the orientation of the image if it has an EXIF orientation tag. 356 357 This typically happens for images taken from a non-standard orientation 358 by some phones or other devices that are able to report orientation. 359 360 The specified transposition is applied to the image before all other 361 operations, because all of them expect the image to be in its final 362 orientation, which is the case only when the first row of pixels is the top 363 of the image and the first column of pixels is the left of the image. 364 365 Moreover the EXIF tags will not be kept when the image is later saved, so 366 the transposition has to be done to ensure the final image is correctly 367 orientated. 368 369 Note: to be completely correct, the resulting image should have its exif 370 orientation tag removed, since the transpositions have been applied. 371 However since this tag is not used in the code, it is acceptable to 372 save the complexity of removing it. 373 374 :param image: the source image 375 :type image: PIL.Image 376 377 :return: the resulting image, copy of the source, with orientation fixed 378 or the source image if no operation was applied 379 :rtype: PIL.Image 380 """ 381 getexif = getattr(image, 'getexif', None) or getattr(image, '_getexif', None) # support PIL < 6.0 382 if getexif: 383 exif = getexif() 384 if exif: 385 orientation = exif.get(EXIF_TAG_ORIENTATION, 0) 386 for method in EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS.get(orientation, []): 387 image = image.transpose(method) 388 return image 389 return image 390 391 392def base64_to_image(base64_source): 393 """Return a PIL image from the given `base64_source`. 394 395 :param base64_source: the image base64 encoded 396 :type base64_source: string or bytes 397 398 :return: the PIL image 399 :rtype: PIL.Image 400 401 :raise: UserError if the base64 is incorrect or the image can't be identified by PIL 402 """ 403 try: 404 return Image.open(io.BytesIO(base64.b64decode(base64_source))) 405 except (OSError, binascii.Error): 406 raise UserError(_("This file could not be decoded as an image file. Please try with a different file.")) 407 408 409def image_to_base64(image, format, **params): 410 """Return a base64_image from the given PIL `image` using `params`. 411 412 :param image: the PIL image 413 :type image: PIL.Image 414 415 :param params: params to expand when calling PIL.Image.save() 416 :type params: dict 417 418 :return: the image base64 encoded 419 :rtype: bytes 420 """ 421 stream = io.BytesIO() 422 image.save(stream, format=format, **params) 423 return base64.b64encode(stream.getvalue()) 424 425 426def is_image_size_above(base64_source_1, base64_source_2): 427 """Return whether or not the size of the given image `base64_source_1` is 428 above the size of the given image `base64_source_2`. 429 """ 430 if not base64_source_1 or not base64_source_2: 431 return False 432 if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'): 433 # False for SVG 434 return False 435 image_source = image_fix_orientation(base64_to_image(base64_source_1)) 436 image_target = image_fix_orientation(base64_to_image(base64_source_2)) 437 return image_source.width > image_target.width or image_source.height > image_target.height 438 439 440def image_guess_size_from_field_name(field_name): 441 """Attempt to guess the image size based on `field_name`. 442 443 If it can't be guessed, return (0, 0) instead. 444 445 :param field_name: the name of a field 446 :type field_name: string 447 448 :return: the guessed size 449 :rtype: tuple (width, height) 450 """ 451 suffix = '1024' if field_name == 'image' else field_name.split('_')[-1] 452 try: 453 return (int(suffix), int(suffix)) 454 except ValueError: 455 return (0, 0) 456 457 458def image_data_uri(base64_source): 459 """This returns data URL scheme according RFC 2397 460 (https://tools.ietf.org/html/rfc2397) for all kind of supported images 461 (PNG, GIF, JPG and SVG), defaulting on PNG type if not mimetype detected. 462 """ 463 return 'data:image/%s;base64,%s' % ( 464 FILETYPE_BASE64_MAGICWORD.get(base64_source[:1], 'png'), 465 base64_source.decode(), 466 ) 467 468 469def get_saturation(rgb): 470 """Returns the saturation (hsl format) of a given rgb color 471 472 :param rgb: rgb tuple or list 473 :return: saturation 474 """ 475 c_max = max(rgb) / 255 476 c_min = min(rgb) / 255 477 d = c_max - c_min 478 return 0 if d == 0 else d / (1 - abs(c_max + c_min - 1)) 479 480 481def get_lightness(rgb): 482 """Returns the lightness (hsl format) of a given rgb color 483 484 :param rgb: rgb tuple or list 485 :return: lightness 486 """ 487 return (max(rgb) + min(rgb)) / 2 / 255 488 489 490def hex_to_rgb(hx): 491 """Converts an hexadecimal string (starting with '#') to a RGB tuple""" 492 return tuple([int(hx[i:i+2], 16) for i in range(1, 6, 2)]) 493 494 495def rgb_to_hex(rgb): 496 """Converts a RGB tuple or list to an hexadecimal string""" 497 return '#' + ''.join([(hex(c).split('x')[-1].zfill(2)) for c in rgb]) 498 499 500if __name__=="__main__": 501 import sys 502 503 assert len(sys.argv)==3, 'Usage to Test: image.py SRC.png DEST.png' 504 505 img = base64.b64encode(open(sys.argv[1],'rb').read()) 506 new = image_process(img, size=(128, 100)) 507 open(sys.argv[2], 'wb').write(base64.b64decode(new)) 508