Repository navigation
Expand file tree
/
Copy pathimage.py
More file actions
595 lines (482 loc) · 23.3 KB
/
Copy pathimage.py
File metadata and controls
595 lines (482 loc) · 23.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import binascii
import io
from typing import Tuple, Union
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 LazyTranslate
__all__ = ["image_process"]
_lt = LazyTranslate('base')
# 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, Transpo
8000
se.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 bytes 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(_lt("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(_lt("Too large image (above %sMpx), reduce the image size.", 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.
The source is returned as-is if it's an SVG, or if no operations have
been applied, 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: Can be PNG, JPEG, GIF, or ICO.
Default to the format of the original image if a valid output format,
otherwise BMP is converted to PNG and the rest are converted to JPEG.
:return: the final image, or ``False`` if the original ``source`` was falsy.
:rtype: bytes | 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, expand=False):
"""Resize the image.
The image is not resized above the current image size, unless the expand
parameter is True. This method is used by default to create smaller versions
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
:param bool expand: whether or not the image size can be increased
: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 expand and (asked_width > w or asked_height > h):
self.image = self.image.resize((asked_width, asked_height))
self.operationsCount += 1
return self
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
# Dimensions should be at least 1.
new_w, new_h = max(new_w, 1), max(new_h, 1)
# 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, color=None):
"""Replace the transparent background by a given color, or by a random one.
:param tuple color: RGB values for the color to use
:return: self to allow chaining
:rtype: ImageProcess
"""
if color is None:
color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
if self.image:
original = self.image
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 add_padding(self, padding):
"""Expand the image size by adding padding around the image
:param int padding: thickness of the padding
:return: self to allow chaining
:rtype: ImageProcess
"""
if self.image:
img_width, img_height = self.image.size
self.image = self.image.resize((img_width - 2 * padding, img_height - 2 * padding))
self.image = ImageOps.expand(self.image, border=padding)
self.operationsCount += 1
return self
def image_process(source, size=(0, 0), verify_resolution=False, quality=0, expand=False, crop=None, colorize=False, output_format='', padding=False):
"""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 and not padding):
# 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], expand=expand)
if padding:
image.add_padding(padding)
if colorize:
image.colorize(colorize if isinstance(colorize, tuple) else None)
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(_lt("This file could not be decoded as an image file."))
def base64_to_image(base64_source: Union[str, bytes]) -> Image:
"""Return a PIL image from the given `base64_source`.
:param base64_source: the image base64 encoded
: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(_lt("This file could not be decoded as an image file."))
def image_apply_opt(image: Image, output_format: str, **params) -> bytes:
"""Return the serialization of the provided `image` to `output_format`
using `params`.
:param image: the image to encode
:param 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
"""
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(_lt("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: str) -> Tuple[int, int]:
"""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 field_name: the name of a field
:return: the guessed size
"""
if field_name == 'image':
return (1024, 1024)
if field_name.startswith('x_'):
return (0, 0)
try:
suffix = int(field_name.rsplit('_', 1)[-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: bytes) -> str:
"""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])