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