Coverage for adhoc-cicd-odoo-odoo / odoo / tools / image.py: 43%
249 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3import base64
4import binascii
5import io
6from typing import Tuple, Union
8from PIL import Image, ImageOps
9# We can preload Ico too because it is considered safe
10from PIL import IcoImagePlugin
11try:
12 from PIL.Image import Transpose, Palette, Resampling
13except ImportError:
14 Transpose = Palette = Resampling = Image
16from random import randrange
18from odoo.exceptions import UserError
19from odoo.tools.misc import DotDict
20from odoo.tools.translate import LazyTranslate
23__all__ = ["image_process"]
24_lt = LazyTranslate('base')
26# Preload PIL with the minimal subset of image formats we need
27Image.preinit()
28Image._initialized = 2
30# Maps only the 6 first bits of the base64 data, accurate enough
31# for our purpose and faster than decoding the full blob first
32FILETYPE_BASE64_MAGICWORD = {
33 b'/': 'jpg',
34 b'R': 'gif',
35 b'i': 'png',
36 b'P': 'svg+xml',
37 b'U': 'webp',
38}
40EXIF_TAG_ORIENTATION = 0x112
41# The target is to have 1st row/col to be top/left
42# Note: rotate is counterclockwise
43EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS = { # Initial side on 1st row/col:
44 0: [], # reserved
45 1: [], # top/left
46 2: [Transpose.FLIP_LEFT_RIGHT], # top/right
47 3: [Transpose.ROTATE_180], # bottom/right
48 4: [Transpose.FLIP_TOP_BOTTOM], # bottom/left
49 5: [Transpose.FLIP_LEFT_RIGHT, Transpose.ROTATE_90],# left/top
50 6: [Transpose.ROTATE_270], # right/top
51 7: [Transpose.FLIP_TOP_BOTTOM, Transpose.ROTATE_90],# right/bottom
52 8: [Transpose.ROTATE_90], # left/bottom
53}
55# Arbitrary limit to fit most resolutions, including Samsung Galaxy A22 photo,
56# 8K with a ratio up to 16:10, and almost all variants of 4320p
57IMAGE_MAX_RESOLUTION = 50e6
60class ImageProcess:
62 def __init__(self, source, verify_resolution=True):
63 """Initialize the ``source`` image for processing.
65 :param bytes source: the original image binary
67 No processing will be done if the `source` is falsy or if
68 the image is SVG.
69 :param verify_resolution: if True, make sure the original image size is not
70 excessive before starting to process it. The max allowed resolution is
71 defined by `IMAGE_MAX_RESOLUTION`.
72 :type verify_resolution: bool
73 :rtype: ImageProcess
75 :raise: ValueError if `verify_resolution` is True and the image is too large
76 :raise: UserError if the image can't be identified by PIL
77 """
78 self.source = source or False
79 self.operationsCount = 0
81 if not source or source[:1] == b'<' or (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
82 # don't process empty source or SVG or WEBP
83 self.image = False
84 else:
85 try:
86 self.image = Image.open(io.BytesIO(source))
87 except (OSError, binascii.Error):
88 raise UserError(_lt("This file could not be decoded as an image file."))
90 # Original format has to be saved before fixing the orientation or
91 # doing any other operations because the information will be lost on
92 # the resulting image.
93 self.original_format = (self.image.format or '').upper()
95 self.image = image_fix_orientation(self.image)
97 w, h = self.image.size
98 if verify_resolution and w * h > IMAGE_MAX_RESOLUTION: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 raise UserError(_lt("Too large image (above %sMpx), reduce the image size.", str(IMAGE_MAX_RESOLUTION / 1e6)))
101 def image_quality(self, quality=0, output_format=''):
102 """Return the image resulting of all the image processing
103 operations that have been applied previously.
105 The source is returned as-is if it's an SVG, or if no operations have
106 been applied, the `output_format` is the same as the original format,
107 and the quality is not specified.
109 :param int quality: quality setting to apply. Default to 0.
111 - for JPEG: 1 is worse, 95 is best. Values above 95 should be
112 avoided. Falsy values will fallback to 95, but only if the image
113 was changed, otherwise the original image is returned.
114 - for PNG: set falsy to prevent conversion to a WEB palette.
115 - for other formats: no effect.
117 :param str output_format: Can be PNG, JPEG, GIF, or ICO.
118 Default to the format of the original image if a valid output format,
119 otherwise BMP is converted to PNG and the rest are converted to JPEG.
120 :return: the final image, or ``False`` if the original ``source`` was falsy.
121 :rtype: bytes | False
122 """
123 if not self.image:
124 return self.source
126 output_image = self.image
128 output_format = output_format.upper() or self.original_format
129 if output_format == 'BMP': 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 output_format = 'PNG'
131 elif output_format not in ['PNG', 'JPEG', 'GIF', 'ICO']: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 output_format = 'JPEG'
134 if not self.operationsCount and output_format == self.original_format and not quality:
135 return self.source
137 opt = {'output_format': output_format}
139 if output_format == 'PNG':
140 opt['optimize'] = True
141 if quality: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 if output_image.mode != 'P':
143 # Floyd Steinberg dithering by default
144 output_image = output_image.convert('RGBA').convert('P', palette=Palette.WEB, colors=256)
145 if output_format == 'JPEG':
146 opt['optimize'] = True
147 opt['quality'] = quality or 95
148 if output_format == 'GIF': 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 opt['optimize'] = True
150 opt['save_all'] = True
152 if output_image.mode not in ["1", "L", "P", "RGB", "RGBA"] or (output_format == 'JPEG' and output_image.mode == 'RGBA'): 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 output_image = output_image.convert("RGB")
155 output_bytes = image_apply_opt(output_image, **opt)
156 if len(output_bytes) >= len(self.source) and self.original_format == output_format and not self.operationsCount: 156 ↛ 159line 156 didn't jump to line 159 because the condition on line 156 was never true
157 # Format has not changed and image content is unchanged but the
158 # reached binary is bigger: rather use the original.
159 return self.source
160 return output_bytes
162 def resize(self, max_width=0, max_height=0, expand=False):
163 """Resize the image.
165 The image is not resized above the current image size, unless the expand
166 parameter is True. This method is used by default to create smaller versions
167 of the image.
169 The current ratio is preserved. To change the ratio, see `crop_resize`.
171 If `max_width` or `max_height` is falsy, it will be computed from the
172 other to keep the current ratio. If both are falsy, no resize is done.
174 It is currently not supported for GIF because we do not handle all the
175 frames properly.
177 :param int max_width: max width
178 :param int max_height: max height
179 :param bool expand: whether or not the image size can be increased
180 :return: self to allow chaining
181 :rtype: ImageProcess
182 """
183 if self.image and self.original_format != 'GIF' and (max_width or max_height):
184 w, h = self.image.size
185 asked_width = max_width or (w * max_height) // h
186 asked_height = max_height or (h * max_width) // w
187 if expand and (asked_width > w or asked_height > h): 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 self.image = self.image.resize((asked_width, asked_height))
189 self.operationsCount += 1
190 return self
191 if asked_width != w or asked_height != h:
192 self.image.thumbnail((asked_width, asked_height), Resampling.LANCZOS)
193 if self.image.width != w or self.image.height != h:
194 self.operationsCount += 1
195 return self
197 def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5):
198 """Crop and resize the image.
200 The image is never resized above the current image size. This method is
201 only to create smaller versions of the image.
203 Instead of preserving the ratio of the original image like `resize`,
204 this method will force the output to take the ratio of the given
205 `max_width` and `max_height`, so both have to be defined.
207 The crop is done before the resize in order to preserve as much of the
208 original image as possible. The goal of this method is primarily to
209 resize to a given ratio, and it is not to crop unwanted parts of the
210 original image. If the latter is what you want to do, you should create
211 another method, or directly use the `crop` method from PIL.
213 It is currently not supported for GIF because we do not handle all the
214 frames properly.
216 :param int max_width: max width
217 :param int max_height: max height
218 :param float center_x: the center of the crop between 0 (left) and 1
219 (right). Defaults to 0.5 (center).
220 :param float center_y: the center of the crop between 0 (top) and 1
221 (bottom). Defaults to 0.5 (center).
222 :return: self to allow chaining
223 :rtype: ImageProcess
224 """
225 if self.image and self.original_format != 'GIF' and max_width and max_height:
226 w, h = self.image.size
227 # We want to keep as much of the image as possible -> at least one
228 # of the 2 crop dimensions always has to be the same value as the
229 # original image.
230 # The target size will be reached with the final resize.
231 if w / max_width > h / max_height:
232 new_w, new_h = w, (max_height * w) // max_width
233 else:
234 new_w, new_h = (max_width * h) // max_height, h
236 # No cropping above image size.
237 if new_w > w:
238 new_w, new_h = w, (new_h * w) // new_w
239 if new_h > h:
240 new_w, new_h = (new_w * h) // new_h, h
242 # Dimensions should be at least 1.
243 new_w, new_h = max(new_w, 1), max(new_h, 1)
245 # Correctly place the center of the crop.
246 x_offset = int((w - new_w) * center_x)
247 h_offset = int((h - new_h) * center_y)
249 if new_w != w or new_h != h:
250 self.image = self.image.crop((x_offset, h_offset, x_offset + new_w, h_offset + new_h))
251 if self.image.width != w or self.image.height != h:
252 self.operationsCount += 1
254 return self.resize(max_width, max_height)
256 def colorize(self, color=None):
257 """Replace the transparent background by a given color, or by a random one.
259 :param tuple color: RGB values for the color to use
260 :return: self to allow chaining
261 :rtype: ImageProcess
262 """
263 if color is None:
264 color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
265 if self.image:
266 original = self.image
267 self.image = Image.new('RGB', original.size)
268 self.image.paste(color, box=(0, 0) + original.size)
269 self.image.paste(original, mask=original)
270 self.operationsCount += 1
271 return self
273 def add_padding(self, padding):
274 """Expand the image size by adding padding around the image
276 :param int padding: thickness of the padding
277 :return: self to allow chaining
278 :rtype: ImageProcess
279 """
280 if self.image:
281 img_width, img_height = self.image.size
282 self.image = self.image.resize((img_width - 2 * padding, img_height - 2 * padding))
283 self.image = ImageOps.expand(self.image, border=padding)
284 self.operationsCount += 1
285 return self
288def image_process(source, size=(0, 0), verify_resolution=False, quality=0, expand=False, crop=None, colorize=False, output_format='', padding=False):
289 """Process the `source` image by executing the given operations and
290 return the result image.
291 """
292 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 and not padding):
293 # for performance: don't do anything if the image is falsy or if
294 # no operations have been requested
295 return source
297 image = ImageProcess(source, verify_resolution)
298 if size: 298 ↛ 309line 298 didn't jump to line 309 because the condition on line 298 was always true
299 if crop: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true
300 center_x = 0.5
301 center_y = 0.5
302 if crop == 'top':
303 center_y = 0
304 elif crop == 'bottom':
305 center_y = 1
306 image.crop_resize(max_width=size[0], max_height=size[1], center_x=center_x, center_y=center_y)
307 else:
308 image.resize(max_width=size[0], max_height=size[1], expand=expand)
309 if padding: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true
310 image.add_padding(padding)
311 if colorize: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 image.colorize(colorize if isinstance(colorize, tuple) else None)
313 return image.image_quality(quality=quality, output_format=output_format)
316# ----------------------------------------
317# Misc image tools
318# ---------------------------------------
320def average_dominant_color(colors, mitigate=175, max_margin=140):
321 """This function is used to calculate the dominant colors when given a list of colors
323 There are 5 steps:
325 1) Select dominant colors (highest count), isolate its values and remove
326 it from the current color set.
327 2) Set margins according to the prevalence of the dominant color.
328 3) Evaluate the colors. Similar colors are grouped in the dominant set
329 while others are put in the "remaining" list.
330 4) Calculate the average color for the dominant set. This is done by
331 averaging each band and joining them into a tuple.
332 5) Mitigate final average and convert it to hex
334 :param colors: list of tuples having:
336 0. color count in the image
337 1. actual color: tuple(R, G, B, A)
339 -> these can be extracted from a PIL image using
340 :meth:`~PIL.Image.Image.getcolors`
341 :param mitigate: maximum value a band can reach
342 :param max_margin: maximum difference from one of the dominant values
343 :returns: a tuple with two items:
345 0. the average color of the dominant set as: tuple(R, G, B)
346 1. list of remaining colors, used to evaluate subsequent dominant colors
347 """
348 dominant_color = max(colors)
349 dominant_rgb = dominant_color[1][:3]
350 dominant_set = [dominant_color]
351 remaining = []
353 margins = [max_margin * (1 - dominant_color[0] /
354 sum([col[0] for col in colors]))] * 3
356 colors.remove(dominant_color)
358 for color in colors:
359 rgb = color[1]
360 if (rgb[0] < dominant_rgb[0] + margins[0] and rgb[0] > dominant_rgb[0] - margins[0] and
361 rgb[1] < dominant_rgb[1] + margins[1] and rgb[1] > dominant_rgb[1] - margins[1] and
362 rgb[2] < dominant_rgb[2] + margins[2] and rgb[2] > dominant_rgb[2] - margins[2]):
363 dominant_set.append(color)
364 else:
365 remaining.append(color)
367 dominant_avg = []
368 for band in range(3):
369 avg = total = 0
370 for color in dominant_set:
371 avg += color[0] * color[1][band]
372 total += color[0]
373 dominant_avg.append(int(avg / total))
375 final_dominant = []
376 brightest = max(dominant_avg)
377 for color in range(3):
378 value = dominant_avg[color] / (brightest / mitigate) if brightest > mitigate else dominant_avg[color]
379 final_dominant.append(int(value))
381 return tuple(final_dominant), remaining
384def image_fix_orientation(image):
385 """Fix the orientation of the image if it has an EXIF orientation tag.
387 This typically happens for images taken from a non-standard orientation
388 by some phones or other devices that are able to report orientation.
390 The specified transposition is applied to the image before all other
391 operations, because all of them expect the image to be in its final
392 orientation, which is the case only when the first row of pixels is the top
393 of the image and the first column of pixels is the left of the image.
395 Moreover the EXIF tags will not be kept when the image is later saved, so
396 the transposition has to be done to ensure the final image is correctly
397 orientated.
399 Note: to be completely correct, the resulting image should have its exif
400 orientation tag removed, since the transpositions have been applied.
401 However since this tag is not used in the code, it is acceptable to
402 save the complexity of removing it.
404 :param image: the source image
405 :type image: ~PIL.Image.Image
406 :return: the resulting image, copy of the source, with orientation fixed
407 or the source image if no operation was applied
408 :rtype: ~PIL.Image.Image
409 """
410 getexif = getattr(image, 'getexif', None) or getattr(image, '_getexif', None) # support PIL < 6.0
411 if getexif: 411 ↛ 418line 411 didn't jump to line 418 because the condition on line 411 was always true
412 exif = getexif()
413 if exif:
414 orientation = exif.get(EXIF_TAG_ORIENTATION, 0)
415 for method in EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS.get(orientation, []): 415 ↛ 416line 415 didn't jump to line 416 because the loop on line 415 never started
416 image = image.transpose(method)
417 return image
418 return image
421def binary_to_image(source):
422 try:
423 return Image.open(io.BytesIO(source))
424 except (OSError, binascii.Error):
425 raise UserError(_lt("This file could not be decoded as an image file."))
427def base64_to_image(base64_source: Union[str, bytes]) -> Image:
428 """Return a PIL image from the given `base64_source`.
430 :param base64_source: the image base64 encoded
431 :raise: UserError if the base64 is incorrect or the image can't be identified by PIL
432 """
433 try:
434 return Image.open(io.BytesIO(base64.b64decode(base64_source)))
435 except (OSError, binascii.Error):
436 raise UserError(_lt("This file could not be decoded as an image file."))
439def image_apply_opt(image: Image, output_format: str, **params) -> bytes:
440 """Return the serialization of the provided `image` to `output_format`
441 using `params`.
443 :param image: the image to encode
444 :param output_format: :meth:`~PIL.Image.Image.save`'s ``format`` parameter
445 :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save`
446 :return: the image formatted
447 """
448 if output_format == 'JPEG' and image.mode not in ['1', 'L', 'RGB']: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 image = image.convert("RGB")
450 stream = io.BytesIO()
451 image.save(stream, format=output_format, **params)
452 return stream.getvalue()
455def image_to_base64(image, output_format, **params):
456 """Return a base64_image from the given PIL `image` using `params`.
458 :type image: ~PIL.Image.Image
459 :param str output_format:
460 :param dict params: params to expand when calling :meth:`~PIL.Image.Image.save`
461 :return: the image base64 encoded
462 :rtype: bytes
463 """
464 stream = image_apply_opt(image, output_format, **params)
465 return base64.b64encode(stream)
468def get_webp_size(source):
469 """
470 Returns the size of the provided webp binary source for VP8, VP8X and
471 VP8L, otherwise returns None.
472 See https://developers.google.com/speed/webp/docs/riff_container.
474 :param source: binary source
475 :return: (width, height) tuple, or None if not supported
476 """
477 if not (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
478 raise UserError(_lt("This file is not a webp file."))
480 vp8_type = source[15]
481 if vp8_type == 0x20: # 0x20 = ' '
482 # Sizes on big-endian 16 bits at offset 26.
483 width_low, width_high, height_low, height_high = source[26:30]
484 width = (width_high << 8) + width_low
485 height = (height_high << 8) + height_low
486 return (width, height)
487 elif vp8_type == 0x58: # 0x48 = 'X'
488 # Sizes (minus one) on big-endian 24 bits at offset 24.
489 width_low, width_medium, width_high, height_low, height_medium, height_high = source[24:30]
490 width = 1 + (width_high << 16) + (width_medium << 8) + width_low
491 height = 1 + (height_high << 16) + (height_medium << 8) + height_low
492 return (width, height)
493 elif vp8_type == 0x4C and source[20] == 0x2F: # 0x4C = 'L'
494 # Sizes (minus one) on big-endian-ish 14 bits at offset 21.
495 # E.g. [@20] 2F ab cd ef gh
496 # - width = 1 + (c&0x3)d ab: ignore the two high bits of the second byte
497 # - height= 1 + hef(c&0xC>>2): used them as the first two bits of the height
498 ab, cd, ef, gh = source[21:25]
499 width = 1 + ((cd & 0x3F) << 8) + ab
500 height = 1 + ((gh & 0xF) << 10) + (ef << 2) + (cd >> 6)
501 return (width, height)
502 return None
505def is_image_size_above(base64_source_1, base64_source_2):
506 """Return whether or not the size of the given image `base64_source_1` is
507 above the size of the given image `base64_source_2`.
508 """
509 if not base64_source_1 or not base64_source_2: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 return False
511 if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'): 511 ↛ 513line 511 didn't jump to line 513 because the condition on line 511 was never true
512 # False for SVG
513 return False
515 def get_image_size(base64_source):
516 source = base64.b64decode(base64_source)
517 if (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'): 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true
518 size = get_webp_size(source)
519 if size:
520 return DotDict({'width': size[0], 'height': size[0]})
521 else:
522 # False for unknown WEBP format
523 return False
524 else:
525 return image_fix_orientation(binary_to_image(source))
527 image_source = get_image_size(base64_source_1)
528 image_target = get_image_size(base64_source_2)
529 return image_source.width > image_target.width or image_source.height > image_target.height
532def image_guess_size_from_field_name(field_name: str) -> Tuple[int, int]:
533 """Attempt to guess the image size based on `field_name`.
535 If it can't be guessed or if it is a custom field: return (0, 0) instead.
537 :param field_name: the name of a field
538 :return: the guessed size
539 """
540 if field_name == 'image':
541 return (1024, 1024)
542 if field_name.startswith('x_'):
543 return (0, 0)
544 try:
545 suffix = int(field_name.rsplit('_', 1)[-1])
546 except ValueError:
547 return 0, 0
549 if suffix < 16:
550 # If the suffix is less than 16, it's probably not the size
551 return (0, 0)
553 return (suffix, suffix)
556def image_data_uri(base64_source: bytes) -> str:
557 """This returns data URL scheme according RFC 2397
558 (https://tools.ietf.org/html/rfc2397) for all kind of supported images
559 (PNG, GIF, JPG and SVG), defaulting on PNG type if not mimetype detected.
560 """
561 return 'data:image/%s;base64,%s' % (
562 FILETYPE_BASE64_MAGICWORD.get(base64_source[:1], 'png'),
563 base64_source.decode(),
564 )
567def get_saturation(rgb):
568 """Returns the saturation (hsl format) of a given rgb color
570 :param rgb: rgb tuple or list
571 :return: saturation
572 """
573 c_max = max(rgb) / 255
574 c_min = min(rgb) / 255
575 d = c_max - c_min
576 return 0 if d == 0 else d / (1 - abs(c_max + c_min - 1))
579def get_lightness(rgb):
580 """Returns the lightness (hsl format) of a given rgb color
582 :param rgb: rgb tuple or list
583 :return: lightness
584 """
585 return (max(rgb) + min(rgb)) / 2 / 255
588def hex_to_rgb(hx):
589 """Converts an hexadecimal string (starting with '#') to a RGB tuple"""
590 return tuple([int(hx[i:i+2], 16) for i in range(1, 6, 2)])
593def rgb_to_hex(rgb):
594 """Converts a RGB tuple or list to an hexadecimal string"""
595 return '#' + ''.join([(hex(c).split('x')[-1].zfill(2)) for c in rgb])