[IMP] Speed Imp: remove unnecessary base64 encode & decode

Avoid to base64 encode, then decode to process assets and images for a ~25% speed improvement.
Change image processing tool to work on images, rather than base64 encoded strings.

Performance is ~25% faster on assets & images:

  /web/assets/...frontend.min.css:    13ms to 7ms,  base64 enc/dec: 2 -> 0
  /web/image/XML_ID:                  10ms to 8ms,  base64 enc/dec: 3 -> 0
  /web/image/res.users/2/avatar_128:  40ms to 20ms, base64 enc/dec: 6 -> 2

closes odoo/odoo#82851

Related: odoo/enterprise#23537
Signed-off-by: Fabien Pinckaers <fp@odoo.com>
This commit is contained in:
Fabien Pinckaers
2022-01-22 11:51:42 +00:00
parent c7a772f628
commit 6b87526048
23 changed files with 205 additions and 246 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from pytz import UTC
from datetime import datetime, time
from random import choice
@@ -153,7 +154,7 @@ class HrEmployeePrivate(models.Model):
if employee.user_id:
avatar = employee.user_id[avatar_field]
else:
avatar = employee._avatar_get_placeholder()
avatar = base64.b64encode(employee._avatar_get_placeholder())
employee[avatar_field] = avatar
def name_get(self):
+3 -4
View File
@@ -24,10 +24,9 @@ class LivechatController(http.Controller):
if isinstance(mock_attachment, list): # suppose that CSS asset will not required to be split in pages
mock_attachment = mock_attachment[0]
# can't use /web/content directly because we don't have attachment ids (attachments must be created)
status, headers, content = request.env['ir.http'].binary_content(id=mock_attachment.id, unique=asset.checksum)
content_base64 = base64.b64decode(content) if content else ''
headers.append(('Content-Length', len(content_base64)))
return request.make_response(content_base64, headers)
_, headers, content = request.env['ir.http'].binary_content(id=mock_attachment.id, unique=asset.checksum)
headers.append(('Content-Length', len(content)))
return request.make_response(content, headers)
@http.route('/im_livechat/load_templates', type='json', auth='none', cors="*")
def load_templates(self, **kwargs):
+6 -4
View File
@@ -88,10 +88,12 @@ class CustomerPortal(portal.CustomerPortal):
def _purchase_order_get_page_view_values(self, order, access_token, **kwargs):
#
def resize_to_48(b64source):
if not b64source:
b64source = base64.b64encode(request.env['ir.http']._placeholder())
return image_process(b64source, size=(48, 48))
def resize_to_48(source):
if not source:
source = request.env['ir.http']._placeholder()
else:
source = base64.b64decode(source)
return base64.b64encode(image_process(source, size=(48, 48)))
values = {
'order': order,
+1 -1
View File
@@ -1436,7 +1436,7 @@ class Binary(http.Controller):
'/web/image/<int:id>-<string:unique>/<string:filename>',
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>',
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], type='http', auth="public")
def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
def content_image(self, xmlid=None, model='ir.attachment', id=None, field='raw',
filename_field='name', unique=None, filename=None, mimetype=None,
download=None, width=0, height=0, crop=False, access_token=None,
**kwargs):
+12 -16
View File
@@ -140,54 +140,50 @@ class Http(models.AbstractModel):
if status != 200:
return self._response_by_status(status, headers, content)
else:
content_base64 = base64.b64decode(content)
headers.append(('Content-Length', len(content_base64)))
response = request.make_response(content_base64, headers)
headers.append(('Content-Length', len(content)))
response = request.make_response(content, headers)
return response
@api.model
def _content_image(self, xmlid=None, model='ir.attachment', res_id=None, field='datas',
def _content_image(self, xmlid=None, model='ir.attachment', res_id=None, field='raw',
filename_field='name', unique=None, filename=None, mimetype=None, download=None,
width=0, height=0, crop=False, quality=0, access_token=None, **kwargs):
status, headers, image_base64 = self.binary_content(
status, headers, image = self.binary_content(
xmlid=xmlid, model=model, id=res_id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype,
default_mimetype='image/png', access_token=access_token
)
return self._content_image_get_response(
status, headers, image_base64, model=model, field=field, download=download,
status, headers, image, model=model, field=field, download=download,
width=width, height=height, crop=crop, quality=quality)
@api.model
def _content_image_get_response(self, status, headers, image_base64, model='ir.attachment',
field='datas', download=None, width=0, height=0, crop=False, quality=0):
def _content_image_get_response(self, status, headers, image, model='ir.attachment',
field='raw', download=None, width=0, height=0, crop=False, quality=0):
if status in [301, 304] or (status != 200 and download):
return self._response_by_status(status, headers, image_base64)
if not image_base64:
return self._response_by_status(status, headers, image)
if not image:
placeholder_filename = False
if model in self.env:
placeholder_filename = self.env[model]._get_placeholder_filename(field)
placeholder_content = self._placeholder(image=placeholder_filename)
image = self._placeholder(image=placeholder_filename)
# Since we set a placeholder for any missing image, the status must be 200. In case one
# wants to configure a specific 404 page (e.g. though nginx), a 404 status will cause
# troubles.
status = 200
image_base64 = base64.b64encode(placeholder_content)
if not (width or height):
width, height = odoo.tools.image_guess_size_from_field_name(field)
try:
image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop, quality=int(quality))
content = image_process(image, size=(int(width), int(height)), crop=crop, quality=int(quality))
except Exception:
return request.not_found()
content = base64.b64decode(image_base64)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
return response
@api.model
def _placeholder_image_get_response(self, placeholder_base64):
content = base64.b64decode(placeholder_base64)
def _placeholder_image_get_response(self, content):
headers = http.set_safe_image_headers([], content)
response = request.make_response(content, headers)
response.status_code = 200
+11 -11
View File
@@ -20,7 +20,7 @@ from odoo.exceptions import UserError
from odoo.modules.module import get_resource_path
from odoo.tools import file_open
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.image import image_data_uri, base64_to_image
from odoo.tools.image import image_data_uri, binary_to_image
from odoo.addons.base.models.assetsbundle import AssetsBundle
from ..models.ir_attachment import SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_IMAGE_MIMETYPES
@@ -193,8 +193,8 @@ class Web_Editor(http.Controller):
if is_image:
format_error_msg = _("Uploaded image's format is not supported. Try with: %s", ', '.join(SUPPORTED_IMAGE_EXTENSIONS))
try:
data = tools.image_process(data, size=(width, height), quality=quality, verify_resolution=True)
mimetype = guess_mimetype(b64decode(data))
data = tools.image_process(b64decode(data), size=(width, height), quality=quality, verify_resolution=True)
mimetype = guess_mimetype(data)
if mimetype not in SUPPORTED_IMAGE_MIMETYPES:
return {'error': format_error_msg}
except UserError:
@@ -299,7 +299,7 @@ class Web_Editor(http.Controller):
}
if data:
attachment_data['datas'] = data
attachment_data['raw'] = data
elif url:
attachment_data.update({
'type': 'url',
@@ -613,7 +613,7 @@ class Web_Editor(http.Controller):
or not attachment.public
or not attachment.url.startswith(request.httprequest.path)):
raise werkzeug.exceptions.NotFound()
svg = b64decode(attachment.datas).decode('utf-8')
svg = attachment.raw.decode('utf-8')
else:
svg = self._get_shape_svg(module, 'shapes', filename)
@@ -634,18 +634,18 @@ class Web_Editor(http.Controller):
@http.route(['/web_editor/image_shape/<string:img_key>/<module>/<path:filename>'], type='http', auth="public", website=True)
def image_shape(self, module, filename, img_key, **kwargs):
svg = self._get_shape_svg(module, 'image_shapes', filename)
_, _, image_base64 = request.env['ir.http'].binary_content(
_, _, image = request.env['ir.http'].binary_content(
xmlid=img_key, model='ir.attachment', field='datas', default_mimetype='image/png')
if not image_base64:
image_base64 = b64encode(request.env['ir.http']._placeholder())
image = base64_to_image(image_base64)
width, height = tuple(str(size) for size in image.size)
if not image:
image = request.env['ir.http']._placeholder()
img = binary_to_image(image)
width, height = tuple(str(size) for size in img.size)
root = etree.fromstring(svg)
root.attrib.update({'width': width, 'height': height})
# Update default color palette on shape SVG.
svg, _ = self._update_svg_colors(kwargs, etree.tostring(root, pretty_print=True).decode('utf-8'))
# Add image in base64 inside the shape.
uri = image_data_uri(image_base64)
uri = image_data_uri(b64encode(image))
svg = svg.replace('<image xlink:href="', '<image xlink:href="%s' % uri)
return request.make_response(svg, [
+1 -1
View File
@@ -144,5 +144,5 @@ def get_video_thumbnail(video_url):
response = requests.get(f'https://www.instagram.com/p/{video_id}/media/?size=t', timeout=10)
if response and response.ok:
return image_process(base64.b64encode(response.content))
return image_process(response.content)
return None
+4 -4
View File
@@ -90,7 +90,7 @@ class Web_Unsplash(http.Controller):
continue
# get mime-type of image url because unsplash url dosn't contains mime-types in url
image_base64 = base64.b64encode(req.content)
image = req.content
except requests.exceptions.ConnectionError as e:
logger.exception("Connection Error: " + str(e))
continue
@@ -98,8 +98,8 @@ class Web_Unsplash(http.Controller):
logger.exception("Timeout: " + str(e))
continue
image_base64 = tools.image_process(image_base64, verify_resolution=True)
mimetype = guess_mimetype(base64.b64decode(image_base64))
image = tools.image_process(image, verify_resolution=True)
mimetype = guess_mimetype(image)
# append image extension in name
query += mimetypes.guess_extension(mimetype) or ''
@@ -110,7 +110,7 @@ class Web_Unsplash(http.Controller):
'name': '_'.join(url_frags),
'url': '/' + '/'.join(url_frags),
'mimetype': mimetype,
'datas': image_base64,
'raw': image,
'public': res_model == 'ir.ui.view',
'res_id': res_id,
'res_model': res_model,
+1 -1
View File
@@ -248,7 +248,7 @@ class Website(models.Model):
@api.model
def _handle_favicon(self, vals):
if 'favicon' in vals:
vals['favicon'] = tools.image_process(vals['favicon'], size=(256, 256), crop='center', output_format='ICO')
vals['favicon'] = base64.b64encode(tools.image_process(base64.b64decode(vals['favicon']), size=(256, 256), crop='center', output_format='ICO'))
@api.model
def _handle_domain(self, vals):
+3 -2
View File
@@ -3,6 +3,7 @@
from PIL import Image
import base64
from odoo import api, fields, models
from odoo.exceptions import ValidationError
@@ -37,10 +38,10 @@ class Website(models.Model):
if not website.favicon:
website.app_icon = False
continue
image = ImageProcess(website.favicon)
image = ImageProcess(base64.b64decode(website.favicon))
w, h = image.image.size
square_size = w if w > h else h
image.crop_resize(square_size, square_size)
image.image = image.image.resize((512, 512))
image.operationsCount += 1
website.app_icon = image.image_quality_base64(output_format='PNG')
website.app_icon = base64.b64encode(image.image_quality(output_format='PNG'))
+8 -11
View File
@@ -36,9 +36,8 @@ class WebsiteProfile(http.Controller):
return False
def _get_default_avatar(self):
img_path = modules.get_module_resource('web', 'static/img', 'placeholder.png')
with open(img_path, 'rb') as f:
return base64.b64encode(f.read())
with tools.file_open("web/static/img/placeholder.png", 'rb') as f:
return f.read()
def _check_user_profile_access(self, user_id):
user_sudo = request.env['res.users'].sudo().browse(user_id)
@@ -82,26 +81,24 @@ class WebsiteProfile(http.Controller):
can_sudo = self._check_avatar_access(user_id, **post)
if can_sudo:
status, headers, image_base64 = request.env['ir.http'].sudo().binary_content(
status, headers, image = request.env['ir.http'].sudo().binary_content(
model='res.users', id=user_id, field=field,
default_mimetype='image/png')
else:
status, headers, image_base64 = request.env['ir.http'].binary_content(
status, headers, image = request.env['ir.http'].binary_content(
model='res.users', id=user_id, field=field,
default_mimetype='image/png')
if status == 301:
return request.env['ir.http']._response_by_status(status, headers, image_base64)
return request.env['ir.http']._response_by_status(status, headers, image)
if status == 304:
return werkzeug.wrappers.Response(status=304)
if not image_base64:
image_base64 = self._get_default_avatar()
if not image:
image = self._get_default_avatar()
if not (width or height):
width, height = tools.image_guess_size_from_field_name(field)
image_base64 = tools.image_process(image_base64, size=(int(width), int(height)), crop=crop)
content = base64.b64decode(image_base64)
content = tools.image_process(image, size=(int(width), int(height)), crop=crop)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
+5 -6
View File
@@ -747,22 +747,21 @@ class WebsiteSlides(WebsiteProfile):
if not slide:
raise werkzeug.exceptions.NotFound()
status, headers, image_base64 = request.env['ir.http'].sudo().binary_content(
status, headers, image = request.env['ir.http'].sudo().binary_content(
model='slide.slide', id=slide.id, field=field,
default_mimetype='image/png')
if status == 301:
return request.env['ir.http']._response_by_status(status, headers, image_base64)
return request.env['ir.http']._response_by_status(status, headers, image)
if status == 304:
return werkzeug.wrappers.Response(status=304)
if not image_base64:
image_base64 = self._get_default_avatar()
if not image:
image = self._get_default_avatar()
if not (width or height):
width, height = tools.image_guess_size_from_field_name(field)
image_base64 = tools.image_process(image_base64, size=(int(width), int(height)), crop=crop)
content = tools.image_process(image, size=(int(width), int(height)), crop=crop)
content = base64.b64decode(image_base64)
headers = http.set_safe_image_headers(headers, content)
response = request.make_response(content, headers)
response.status_code = status
+7 -7
View File
@@ -25,11 +25,11 @@ class AvatarMixin(models.AbstractModel):
_avatar_name_field = "name"
# all image fields are base64 encoded and PIL-supported
avatar_1920 = fields.Image("Avatar", max_width=1920, max_height=1920, compute="_compute_avatar_1920")
avatar_1024 = fields.Image("Avatar 1024", max_width=1024, max_height=1024, compute="_compute_avatar_1024")
avatar_512 = fields.Image("Avatar 512", max_width=512, max_height=512, compute="_compute_avatar_512")
avatar_256 = fields.Image("Avatar 256", max_width=256, max_height=256, compute="_compute_avatar_256")
avatar_128 = fields.Image("Avatar 128", max_width=128, max_height=128, compute="_compute_avatar_128")
avatar_1920 = fields.Image("Avatar", compute="_compute_avatar_1920")
avatar_1024 = fields.Image("Avatar 1024", compute="_compute_avatar_1024")
avatar_512 = fields.Image("Avatar 512", compute="_compute_avatar_512")
avatar_256 = fields.Image("Avatar 256", compute="_compute_avatar_256")
avatar_128 = fields.Image("Avatar 128", compute="_compute_avatar_128")
def _compute_avatar(self, avatar_field, image_field):
for record in self:
@@ -38,7 +38,7 @@ class AvatarMixin(models.AbstractModel):
if record.id and record[record._avatar_name_field]:
avatar = record._avatar_generate_svg()
else:
avatar = record._avatar_get_placeholder()
avatar = b64encode(record._avatar_get_placeholder())
record[avatar_field] = avatar
@api.depends(lambda self: [self._avatar_name_field, 'image_1920'])
@@ -76,4 +76,4 @@ class AvatarMixin(models.AbstractModel):
return "base/static/img/avatar_grey.png"
def _avatar_get_placeholder(self):
return b64encode(file_open(self._avatar_get_placeholder_path(), 'rb').read())
return file_open(self._avatar_get_placeholder_path(), 'rb').read()
+3 -5
View File
@@ -316,13 +316,11 @@ class IrAttachment(models.Model):
try:
img = fn_quality = False
if is_raw:
img = ImageProcess(False, verify_resolution=False)
img.image = Image.open(io.BytesIO(values['raw']))
img.original_format = (img.image.format or '').upper()
img = ImageProcess(values['raw'], verify_resolution=False)
fn_quality = img.image_quality
else: # datas
img = ImageProcess(values['datas'], verify_resolution=False)
fn_quality = img.image_quality_base64
img = ImageProcess(base64.b64decode(values['datas']), verify_resolution=False)
fn_quality = lambda **args: base64.b64encode(img.image_quality(**args))
w, h = img.image.size
nw, nh = map(int, max_resolution.split('x'))
+20 -19
View File
@@ -359,11 +359,11 @@ class IrHttp(models.AbstractModel):
module_resource_path = os.path.normpath(module_resource_path)
if module_resource_path.startswith(module_path):
with open(module_resource_path, 'rb') as f:
content = base64.b64encode(f.read())
content = f.read()
status = 200
filename = os.path.basename(module_resource_path)
mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype)
filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
mimetype = record.mimetype
filehash = record.checksum
if not content:
status = 301
@@ -372,7 +372,7 @@ class IrHttp(models.AbstractModel):
return status, content, filename, mimetype, filehash
def _binary_record_content(
self, record, field='datas', filename=None,
self, record, field='raw', filename=None,
filename_field='name', default_mimetype='application/octet-stream'):
model = record._name
@@ -383,18 +383,23 @@ class IrHttp(models.AbstractModel):
field_def = record._fields[field]
if field_def.type == 'binary' and field_def.attachment and not field_def.related:
if model != 'ir.attachment':
field_attachment = self.env['ir.attachment'].sudo().search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['datas', 'mimetype', 'checksum'], limit=1)
field_attachment = self.env['ir.attachment'].sudo().search_read(domain=[('res_model', '=', model), ('res_id', '=', record.id), ('res_field', '=', field)], fields=['raw', 'mimetype', 'checksum'], limit=1)
if field_attachment:
mimetype = field_attachment[0]['mimetype']
content = field_attachment[0]['datas']
content = field_attachment[0]['raw']
filehash = field_attachment[0]['checksum']
else:
mimetype = record['mimetype']
content = record['datas']
content = record['raw']
filehash = record['checksum']
if not content:
content = record[field] or ''
if model == 'ir.attachment':
content = record.raw
else:
data = record[field] or b''
content = base64.b64decode(data)
filehash = '"%s"' % hashlib.md5(str(content).encode('utf-8')).hexdigest()
# filename
default_filename = False
@@ -406,11 +411,7 @@ class IrHttp(models.AbstractModel):
filename = "%s-%s-%s" % (record._name, record.id, field)
if not mimetype:
try:
decoded_content = base64.b64decode(content)
except base64.binascii.Error: # if we could not decode it, no need to pass it down: it would crash elsewhere...
return (404, [], None)
mimetype = guess_mimetype(decoded_content, default=default_mimetype)
mimetype = guess_mimetype(content, default=default_mimetype)
# extension
_, existing_extension = os.path.splitext(filename)
@@ -420,12 +421,12 @@ class IrHttp(models.AbstractModel):
filename = "%s%s" % (filename, extension)
if not filehash:
filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
filehash = '"%s"' % hashlib.md5(str(base64.b64encode(content)).encode('utf-8')).hexdigest()
status = 200 if content else 404
return status, content, filename, mimetype, filehash
def _binary_set_headers(self, status, content, filename, mimetype, unique, filehash=None, download=False):
def _binary_set_headers(self, status, filename, mimetype, unique, filehash=None, download=False):
headers = [('Content-Type', mimetype), ('X-Content-Type-Options', 'nosniff'), ('Content-Security-Policy', "default-src 'none'")]
# cache
etag = bool(request) and request.httprequest.headers.get('If-None-Match')
@@ -439,9 +440,9 @@ class IrHttp(models.AbstractModel):
if download:
headers.append(('Content-Disposition', content_disposition(filename)))
return (status, headers, content)
return (status, headers)
def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas',
def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='raw',
unique=False, filename=None, filename_field='name', download=False,
mimetype=None, default_mimetype='application/octet-stream',
access_token=None):
@@ -479,8 +480,8 @@ class IrHttp(models.AbstractModel):
record, field=field, filename=filename, filename_field=filename_field,
default_mimetype='application/octet-stream')
status, headers, content = self._binary_set_headers(
status, content, filename, mimetype, unique, filehash=filehash, download=download)
status, headers = self._binary_set_headers(
status, filename, mimetype, unique, filehash=filehash, download=download)
return status, headers, content
+2 -1
View File
@@ -159,7 +159,8 @@ class Company(models.Model):
@api.depends('partner_id.image_1920')
def _compute_logo_web(self):
for company in self:
company.logo_web = tools.image_process(company.partner_id.image_1920, size=(180, 0))
img = company.partner_id.image_1920
company.logo_web = img and base64.b64encode(tools.image_process(base64.b64decode(img), size=(180, 0)))
@api.onchange('state_id')
def _onchange_state(self):
+1 -1
View File
@@ -264,7 +264,7 @@ class Partner(models.Model):
partners_with_internal_user = self.filtered(lambda partner: partner.user_ids - partner.user_ids.filtered('share'))
super(Partner, partners_with_internal_user)._compute_avatar(avatar_field, image_field)
for partner in self - partners_with_internal_user:
partner[avatar_field] = partner[image_field] or partner._avatar_get_placeholder()
partner[avatar_field] = partner[image_field] or base64.b64encode(partner._avatar_get_placeholder())
def _avatar_get_placeholder_path(self):
if self.is_company:
+1 -1
View File
@@ -28,7 +28,7 @@ from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationErro
from odoo.http import request
from odoo.osv import expression
from odoo.service.db import check_super
from odoo.tools import partition, collections, frozendict, lazy_property, image_process
from odoo.tools import partition, collections, frozendict, lazy_property
_logger = logging.getLogger(__name__)
+2 -2
View File
@@ -51,10 +51,10 @@ class TestAvatarMixin(TransactionCase):
self.assertEqual(expectedAvatar, b64decode(self.user_without_image.partner_id.avatar_1920).decode('utf-8'))
def test_partner_without_name_has_default_placeholder_image_as_avatar(self):
self.assertEqual(self.user_without_name.partner_id._avatar_get_placeholder(), self.user_without_name.partner_id.avatar_1920)
self.assertEqual(self.user_without_name.partner_id._avatar_get_placeholder(), b64decode(self.user_without_name.partner_id.avatar_1920))
def test_external_partner_has_default_placeholder_image_as_avatar(self):
self.assertEqual(self.external_partner._avatar_get_placeholder(), self.external_partner.avatar_1920)
self.assertEqual(self.external_partner._avatar_get_placeholder(), b64decode(self.external_partner.avatar_1920))
def test_partner_and_user_have_the_same_avatar(self):
self.assertEqual(self.user_without_image.partner_id.avatar_1920, self.user_without_image.avatar_1920)
+73 -82
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import io
import binascii
from PIL import Image, ImageDraw, PngImagePlugin
@@ -11,6 +12,10 @@ from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
def img_open(data):
return Image.open(io.BytesIO(data))
class TestImage(TransactionCase):
"""Tests for the different image tools helpers."""
def setUp(self):
@@ -18,12 +23,12 @@ class TestImage(TransactionCase):
self.bg_color = (135, 90, 123)
self.fill_color = (0, 160, 157)
self.base64_1x1_png = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC'
self.base64_svg = base64.b64encode(b'<svg></svg>')
self.base64_1920x1080_jpeg = tools.image_to_base64(Image.new('RGB', (1920, 1080)), 'JPEG')
self.img_1x1_png = base64.b64decode(b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC')
self.img_svg = b'<svg></svg>'
self.img_1920x1080_jpeg = tools.image_apply_opt(Image.new('RGB', (1920, 1080)), 'JPEG')
# The following image contains a tag `Lens Info` with a value of `3.99mm f/1.8`
# This particular tag 0xa432 makes the `exif_transpose` method fail in 5.4.1 < Pillow < 7.2.0
self.base64_exif_jpg = b"""/9j/4AAQSkZJRgABAQAAAQABAAD/4QDQRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAYAAAEaAAUA
self.img_exif_jpg = base64.b64decode(b"""/9j/4AAQSkZJRgABAQAAAQABAAD/4QDQRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAYAAAEaAAUA
AAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAEAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAA
AAAAAAABAAAAAQAAAAEAAAABAAWQAAAHAAAABDAyMzGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCg
AQADAAAAAf//AACkMgAFAAAABAAAAKgAAAAAAAABjwAAAGQAAAGPAAAAZAAAAAkAAAAFAAAACQAA
@@ -37,7 +42,7 @@ class TestImage(TransactionCase):
AwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHB
CSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0
dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX
2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q=="""
2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==""")
# Draw a red square in the middle of the image, this will be used to
# verify crop is working. The border is going to be `self.bg_color` and
@@ -51,7 +56,7 @@ class TestImage(TransactionCase):
(offset, 0),
(image.size[0] - offset, image.size[1])
], fill=self.fill_color)
self.base64_1920x1080_png = tools.image_to_base64(image, 'PNG')
self.img_1920x1080_png = tools.image_apply_opt(image, 'PNG')
# vertical image (border is top/bottom)
image = Image.new('RGB', (1080, 1920), color=self.bg_color)
@@ -61,18 +66,14 @@ class TestImage(TransactionCase):
(0, offset),
(image.size[0], image.size[1] - offset)
], fill=self.fill_color)
self.base64_1080x1920_png = tools.image_to_base64(image, 'PNG')
self.img_1080x1920_png = tools.image_apply_opt(image, 'PNG')
def test_00_base64_to_image(self):
"""Test that base64 is correctly opened as a PIL image."""
image = tools.base64_to_image(self.base64_1x1_png)
image = img_open(self.img_1x1_png)
self.assertEqual(type(image), PngImagePlugin.PngImageFile, "base64 as bytes, correct format")
self.assertEqual(image.size, (1, 1), "base64 as bytes, correct size")
image = tools.base64_to_image(self.base64_1x1_png.decode('ASCII'))
self.assertEqual(type(image), PngImagePlugin.PngImageFile, "base64 as string, correct format")
self.assertEqual(image.size, (1, 1), "base64 as string, correct size")
with self.assertRaises(UserError, msg="This file could not be decoded as an image file. Please try with a different file."):
image = tools.base64_to_image(b'oazdazpodazdpok')
@@ -83,7 +84,7 @@ class TestImage(TransactionCase):
"""Test that a PIL image is correctly saved as base64."""
image = Image.new('RGB', (1, 1))
image_base64 = tools.image_to_base64(image, 'PNG')
self.assertEqual(image_base64, self.base64_1x1_png)
self.assertEqual(image_base64, base64.b64encode(self.img_1x1_png))
def test_02_image_fix_orientation(self):
"""Test that the orientation of images is correct."""
@@ -110,84 +111,74 @@ class TestImage(TransactionCase):
def test_03_image_fix_orientation_exif(self):
"""Test that a jpg image with exif orientation tag gets rotated"""
image = tools.base64_to_image(self.base64_exif_jpg)
image = img_open(self.img_exif_jpg)
self.assertEqual(image.size, (6,3))
image = tools.image_fix_orientation(image)
self.assertEqual(image.size, (3,6))
def test_10_image_process_base64_source(self):
"""Test the base64_source parameter of image_process."""
wrong_base64 = b'oazdazpodazdpok'
self.assertFalse(tools.image_process(False), "return False if base64_source is falsy")
self.assertEqual(tools.image_process(self.base64_svg), self.base64_svg, "return base64_source if format is SVG")
def test_10_image_process_source(self):
"""Test the source parameter of image_process."""
self.assertFalse(tools.image_process(False), "return False if source is falsy")
self.assertEqual(tools.image_process(self.img_svg), self.img_svg, "return source if format is SVG")
# in the following tests, pass `quality` to force the processing
with self.assertRaises(UserError, msg="This file could not be decoded as an image file. Please try with a different file."):
tools.image_process(wrong_base64, quality=95)
with self.assertRaises(UserError, msg="This file could not be decoded as an image file. Please try with a different file."):
tools.image_process(b'oazdazpodazdpokd', quality=95)
image = tools.base64_to_image(tools.image_process(self.base64_1920x1080_jpeg, quality=95))
image = img_open(tools.image_process(self.img_1920x1080_jpeg, quality=95))
self.assertEqual(image.size, (1920, 1080), "OK return the image")
# test that nothing happens if no operation has been requested
# (otherwise those would raise because of wrong base64)
self.assertEqual(tools.image_process(wrong_base64), wrong_base64)
self.assertEqual(tools.image_process(wrong_base64, size=False), wrong_base64)
def test_11_image_process_size(self):
"""Test the size parameter of image_process."""
# Format of `tests`: (original base64 image, size parameter, expected result, text)
# Format of `tests`: (original image, size parameter, expected result, text)
tests = [
(self.base64_1920x1080_jpeg, (192, 108), (192, 108), "resize to given size"),
(self.base64_1920x1080_jpeg, (1920, 1080), (1920, 1080), "same size, no change"),
(self.base64_1920x1080_jpeg, (192, None), (192, 108), "set height from ratio"),
(self.base64_1920x1080_jpeg, (0, 108), (192, 108), "set width from ratio"),
(self.base64_1920x1080_jpeg, (192, 200), (192, 108), "adapt to width"),
(self.base64_1920x1080_jpeg, (400, 108), (192, 108), "adapt to height"),
(self.base64_1920x1080_jpeg, (3000, 2000), (1920, 1080), "don't resize above original, both set"),
(self.base64_1920x1080_jpeg, (3000, False), (1920, 1080), "don't resize above original, width set"),
(self.base64_1920x1080_jpeg, (None, 2000), (1920, 1080), "don't resize above original, height set"),
(self.base64_1080x1920_png, (3000, 192), (108, 192), "vertical image, resize if below"),
(self.img_1920x1080_jpeg, (192, 108), (192, 108), "resize to given size"),
(self.img_1920x1080_jpeg, (1920, 1080), (1920, 1080), "same size, no change"),
(self.img_1920x1080_jpeg, (192, None), (192, 108), "set height from ratio"),
(self.img_1920x1080_jpeg, (0, 108), (192, 108), "set width from ratio"),
(self.img_1920x1080_jpeg, (192, 200), (192, 108), "adapt to width"),
(self.img_1920x1080_jpeg, (400, 108), (192, 108), "adapt to height"),
(self.img_1920x1080_jpeg, (3000, 2000), (1920, 1080), "don't resize above original, both set"),
(self.img_1920x1080_jpeg, (3000, False), (1920, 1080), "don't resize above original, width set"),
(self.img_1920x1080_jpeg, (None, 2000), (1920, 1080), "don't resize above original, height set"),
(self.img_1080x1920_png, (3000, 192), (108, 192), "vertical image, resize if below"),
]
count = 0
for test in tests:
image = tools.base64_to_image(tools.image_process(test[0], size=test[1]))
image = img_open(tools.image_process(test[0], size=test[1]))
self.assertEqual(image.size, test[2], test[3])
count = count + 1
self.assertEqual(count, 10, "ensure the loop is ran")
def test_12_image_process_verify_resolution(self):
"""Test the verify_resolution parameter of image_process."""
res = tools.image_process(self.base64_1920x1080_jpeg, verify_resolution=True)
res = tools.image_process(self.img_1920x1080_jpeg, verify_resolution=True)
self.assertNotEqual(res, False, "size ok")
base64_image_excessive = tools.image_to_base64(Image.new('RGB', (45001, 1000)), 'PNG')
image_excessive = tools.image_apply_opt(Image.new('RGB', (45001, 1000)), 'PNG')
with self.assertRaises(ValueError, msg="size excessive"):
tools.image_process(base64_image_excessive, verify_resolution=True)
tools.image_process(image_excessive, verify_resolution=True)
def test_13_image_process_quality(self):
"""Test the quality parameter of image_process."""
# CASE: PNG RGBA doesn't apply quality, just optimize
image = tools.image_to_base64(Image.new('RGBA', (1080, 1920)), 'PNG')
image = tools.image_apply_opt(Image.new('RGBA', (1080, 1920)), 'PNG')
res = tools.image_process(image)
self.assertLessEqual(len(res), len(image))
# CASE: PNG RGB doesn't apply quality, just optimize
image = tools.image_to_base64(Image.new('P', (1080, 1920)), 'PNG')
image = tools.image_apply_opt(Image.new('P', (1080, 1920)), 'PNG')
res = tools.image_process(image)
self.assertLessEqual(len(res), len(image))
# CASE: JPEG optimize + reduced quality
res = tools.image_process(self.base64_1920x1080_jpeg)
self.assertLessEqual(len(res), len(self.base64_1920x1080_jpeg))
res = tools.image_process(self.img_1920x1080_jpeg)
self.assertLessEqual(len(res), len(self.img_1920x1080_jpeg))
# CASE: GIF doesn't apply quality, just optimize
image = tools.image_to_base64(Image.new('RGB', (1080, 1920)), 'GIF')
image = tools.image_apply_opt(Image.new('RGB', (1080, 1920)), 'GIF')
res = tools.image_process(image)
self.assertLessEqual(len(res), len(image))
@@ -200,32 +191,32 @@ class TestImage(TransactionCase):
# Format of `tests`: (original base64 image, size parameter, crop parameter, res size, res color (top, bottom, left, right), text)
tests = [
(self.base64_1920x1080_png, None, None, (1920, 1080), (fill, fill, bg, bg), "horizontal, verify initial"),
(self.base64_1920x1080_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "horizontal, crop biggest possible"),
(self.base64_1920x1080_png, (2000, 4000), 'center', (540, 1080), (fill, fill, fill, fill), "horizontal, size vertical, limit height"),
(self.base64_1920x1080_png, (4000, 2000), 'center', (1920, 960), (fill, fill, bg, bg), "horizontal, size horizontal, limit width"),
(self.base64_1920x1080_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "horizontal, type center"),
(self.base64_1920x1080_png, (512, 512), 'top', (512, 512), (fill, fill, fill, fill), "horizontal, type top"),
(self.base64_1920x1080_png, (512, 512), 'bottom', (512, 512), (fill, fill, fill, fill), "horizontal, type bottom"),
(self.base64_1920x1080_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "horizontal, wrong crop value, use center"),
(self.base64_1920x1080_png, (192, 0), None, (192, 108), (fill, fill, bg, bg), "horizontal, not cropped, just do resize"),
(self.img_1920x1080_png, None, None, (1920, 1080), (fill, fill, bg, bg), "horizontal, verify initial"),
(self.img_1920x1080_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "horizontal, crop biggest possible"),
(self.img_1920x1080_png, (2000, 4000), 'center', (540, 1080), (fill, fill, fill, fill), "horizontal, size vertical, limit height"),
(self.img_1920x1080_png, (4000, 2000), 'center', (1920, 960), (fill, fill, bg, bg), "horizontal, size horizontal, limit width"),
(self.img_1920x1080_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "horizontal, type center"),
(self.img_1920x1080_png, (512, 512), 'top', (512, 512), (fill, fill, fill, fill), "horizontal, type top"),
(self.img_1920x1080_png, (512, 512), 'bottom', (512, 512), (fill, fill, fill, fill), "horizontal, type bottom"),
(self.img_1920x1080_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "horizontal, wrong crop value, use center"),
(self.img_1920x1080_png, (192, 0), None, (192, 108), (fill, fill, bg, bg), "horizontal, not cropped, just do resize"),
(self.base64_1080x1920_png, None, None, (1080, 1920), (bg, bg, fill, fill), "vertical, verify initial"),
(self.base64_1080x1920_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "vertical, crop biggest possible"),
(self.base64_1080x1920_png, (2000, 4000), 'center', (960, 1920), (bg, bg, fill, fill), "vertical, size vertical, limit height"),
(self.base64_1080x1920_png, (4000, 2000), 'center', (1080, 540), (fill, fill, fill, fill), "vertical, size horizontal, limit width"),
(self.base64_1080x1920_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "vertical, type center"),
(self.base64_1080x1920_png, (512, 512), 'top', (512, 512), (bg, fill, fill, fill), "vertical, type top"),
(self.base64_1080x1920_png, (512, 512), 'bottom', (512, 512), (fill, bg, fill, fill), "vertical, type bottom"),
(self.base64_1080x1920_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "vertical, wrong crop value, use center"),
(self.base64_1080x1920_png, (108, 0), None, (108, 192), (bg, bg, fill, fill), "vertical, not cropped, just do resize"),
(self.img_1080x1920_png, None, None, (1080, 1920), (bg, bg, fill, fill), "vertical, verify initial"),
(self.img_1080x1920_png, (2000, 2000), 'center', (1080, 1080), (fill, fill, fill, fill), "vertical, crop biggest possible"),
(self.img_1080x1920_png, (2000, 4000), 'center', (960, 1920), (bg, bg, fill, fill), "vertical, size vertical, limit height"),
(self.img_1080x1920_png, (4000, 2000), 'center', (1080, 540), (fill, fill, fill, fill), "vertical, size horizontal, limit width"),
(self.img_1080x1920_png, (512, 512), 'center', (512, 512), (fill, fill, fill, fill), "vertical, type center"),
(self.img_1080x1920_png, (512, 512), 'top', (512, 512), (bg, fill, fill, fill), "vertical, type top"),
(self.img_1080x1920_png, (512, 512), 'bottom', (512, 512), (fill, bg, fill, fill), "vertical, type bottom"),
(self.img_1080x1920_png, (512, 512), 'wrong', (512, 512), (fill, fill, fill, fill), "vertical, wrong crop value, use center"),
(self.img_1080x1920_png, (108, 0), None, (108, 192), (bg, bg, fill, fill), "vertical, not cropped, just do resize"),
]
count = 0
for test in tests:
count = count + 1
# process the image, pass quality to make sure the result is palette
image = tools.base64_to_image(tools.image_process(test[0], size=test[1], crop=test[2], quality=95))
image = img_open(tools.image_process(test[0], size=test[1], crop=test[2], quality=95))
# verify size
self.assertEqual(image.size, test[3], "%s - correct size" % test[5])
@@ -253,44 +244,44 @@ class TestImage(TransactionCase):
image_rgba = Image.new('RGBA', (1, 1))
self.assertEqual(image_rgba.mode, 'RGBA')
self.assertEqual(image_rgba.getpixel((0, 0)), (0, 0, 0, 0))
base64_rgba = tools.image_to_base64(image_rgba, 'PNG')
rgba = tools.image_apply_opt(image_rgba, 'PNG')
# CASE: color random, color has changed
image = tools.base64_to_image(tools.image_process(base64_rgba, colorize=True))
image = img_open(tools.image_process(rgba, colorize=True))
self.assertEqual(image.mode, 'RGB')
self.assertNotEqual(image.getpixel((0, 0)), (0, 0, 0))
def test_16_image_process_format(self):
"""Test the format parameter of image_process."""
image = tools.base64_to_image(tools.image_process(self.base64_1920x1080_jpeg, output_format='PNG'))
image = img_open(tools.image_process(self.img_1920x1080_jpeg, output_format='PNG'))
self.assertEqual(image.format, 'PNG', "change format to PNG")
image = tools.base64_to_image(tools.image_process(self.base64_1x1_png, output_format='JpEg'))
image = img_open(tools.image_process(self.img_1x1_png, output_format='JpEg'))
self.assertEqual(image.format, 'JPEG', "change format to JPEG (case insensitive)")
image = tools.base64_to_image(tools.image_process(self.base64_1920x1080_jpeg, output_format='BMP'))
image = img_open(tools.image_process(self.img_1920x1080_jpeg, output_format='BMP'))
self.assertEqual(image.format, 'PNG', "change format to BMP converted to PNG")
self.base64_image_1080_1920_rgba = tools.image_to_base64(Image.new('RGBA', (108, 192)), 'PNG')
image = tools.base64_to_image(tools.image_process(self.base64_image_1080_1920_rgba, output_format='jpeg'))
image_1080_1920_rgba = tools.image_apply_opt(Image.new('RGBA', (108, 192)), 'PNG')
image = img_open(tools.image_process(image_1080_1920_rgba, output_format='jpeg'))
self.assertEqual(image.format, 'JPEG', "change format PNG with RGBA to JPEG")
# pass quality to force the image to be processed
self.base64_image_1080_1920_tiff = tools.image_to_base64(Image.new('RGB', (108, 192)), 'TIFF')
image = tools.base64_to_image(tools.image_process(self.base64_image_1080_1920_tiff, quality=95))
image_1080_1920_tiff = tools.image_apply_opt(Image.new('RGB', (108, 192)), 'TIFF')
image = img_open(tools.image_process(image_1080_1920_tiff, quality=95))
self.assertEqual(image.format, 'JPEG', "unsupported format to JPEG")
def test_20_image_data_uri(self):
"""Test that image_data_uri is working as expected."""
self.assertEqual(tools.image_data_uri(self.base64_1x1_png), 'data:image/png;base64,' + self.base64_1x1_png.decode('ascii'))
self.assertEqual(tools.image_data_uri(base64.b64encode(self.img_1x1_png)), 'data:image/png;base64,' + base64.b64encode(self.img_1x1_png).decode('ascii'))
def _assertAlmostEqualSequence(self, rgb1, rgb2, delta=10):
self.assertEqual(len(rgb1), len(rgb2))
for index, t in enumerate(zip(rgb1, rgb2)):
self.assertAlmostEqual(t[0], t[1], delta=delta, msg="%s vs %s at %d" % (rgb1, rgb2, index))
def _get_exif_colored_square_b64(self, orientation, colors, size):
def _get_exif_colored_square(self, orientation, colors, size):
image = Image.new('RGB', (size, size), color=self.bg_color)
draw = ImageDraw.Draw(image)
# Paint the colors on the 4 corners, to be able to test which colors
@@ -302,13 +293,13 @@ class TestImage(TransactionCase):
# Set the proper exif tag based on orientation params.
exif = b'Exif\x00\x00II*\x00\x08\x00\x00\x00\x01\x00\x12\x01\x03\x00\x01\x00\x00\x00' + bytes([orientation]) + b'\x00\x00\x00\x00\x00\x00\x00'
# The image image is saved with the exif tag.
return tools.image_to_base64(image, 'JPEG', exif=exif)
return tools.image_apply_opt(image, 'JPEG', exif=exif)
def _orientation_test(self, orientation, colors, size, expected):
# Generate the test image based on orientation and order of colors.
b64_image = self._get_exif_colored_square_b64(orientation, colors, size)
image = self._get_exif_colored_square(orientation, colors, size)
# The image is read again now that it has orientation added.
fixed_image = tools.image_fix_orientation(tools.base64_to_image(b64_image))
fixed_image = tools.image_fix_orientation(img_open(image))
# Ensure colors are in the right order (blue, yellow, green, pink).
self._assertAlmostEqualSequence(fixed_image.getpixel((0, 0)), expected[0]) # top/left
self._assertAlmostEqualSequence(fixed_image.getpixel((size - 1, 0)), expected[1]) # top/right
+3 -2
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo.tests import common
import base64
import odoo
GIF = b"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
@@ -62,9 +63,9 @@ class test_ir_http_mimetype(common.TransactionCase):
'type': 'binary',
})
resized = odoo.tools.image_process(prop.value_binary, size=(64, 64))
resized = odoo.tools.image_process(base64.b64decode(prop.value_binary), size=(64, 64))
# Simul computed field which resize and that is not attachement=True (E.G. on product)
prop.write({'value_binary': resized})
prop.write({'value_binary': base64.b64encode(resized)})
status, headers, content = self.env['ir.http'].binary_content(
model='ir.property',
id=prop.id,
+9 -2
View File
@@ -2356,10 +2356,17 @@ class Image(Binary):
records.env.cache.update(records, self, [cache_value] * len(records))
def _image_process(self, value):
return image_process(value,
if self.readonly and not self.max_width and not self.max_height:
# no need to process images for computed fields, or related fields
return value
try:
img = base64.b64decode(value or '') or False
except:
raise UserError(_("Image is not encoded in base64."))
return base64.b64encode(image_process(img,
size=(self.max_width, self.max_height),
verify_resolution=self.verify_resolution,
)
) or b'') or False
def _process_related(self, value):
"""Override to resize the related value before saving it on self."""
+27 -62
View File
@@ -49,14 +49,13 @@ IMAGE_MAX_RESOLUTION = 45e6
class ImageProcess():
def __init__(self, base64_source, verify_resolution=True):
"""Initialize the `base64_source` image for processing.
def __init__(self, source, verify_resolution=True):
"""Initialize the `source` image for processing.
:param base64_source: the original image base64 encoded
:param source: the original image binary
No processing will be done if the `base64_source` is falsy or if
No processing will be done if the `source` is falsy or if
the image is SVG.
:type base64_source: string or bytes
:param verify_resolution: if True, make sure the original image size is not
excessive before starting to process it. The max allowed resolution is
@@ -65,16 +64,19 @@ class ImageProcess():
:rtype: ImageProcess
:raise: ValueError if `verify_resolution` is True and the image is too large
:raise: UserError if the base64 is incorrect or the image can't be identified by PIL
:raise: UserError if the image can't be identified by PIL
"""
self.base64_source = base64_source or False
self.source = source or False
self.operationsCount = 0
if not base64_source or base64_source[:1] in (b'P', 'P'):
if not source or source[:1] == b'<':
# don't process empty source or SVG
self.image = False
else:
self.image = base64_to_image(self.base64_source)
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
@@ -112,7 +114,7 @@ class ImageProcess():
:rtype: bytes or False
"""
if not self.image:
return self.image
return self.source
output_image = self.image
@@ -123,7 +125,7 @@ class ImageProcess():
output_format = 'JPEG'
if not self.operationsCount and output_format == self.original_format and not quality:
return self.image
return self.source
opt = {'output_format': output_format}
@@ -145,40 +147,6 @@ class ImageProcess():
return image_apply_opt(output_image, **opt)
def image_quality_base64(self, quality=0, output_format=''):
"""Return the base64 encoded image resulting of all the image processing
operations that have been applied previously.
Return False if the initialized `base64_source` was falsy, and return
the initialized `base64_source` without change if it was SVG.
Also return the initialized `base64_source` 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 base64 encoded or False
:rtype: bytes or False
"""
if not self.image:
return self.base64_source
stream = self.image_quality(quality=quality, output_format=output_format)
if stream != self.image:
return base64.b64encode(stream)
return self.base64_source
def resize(self, max_width=0, max_height=0):
"""Resize the image.
@@ -280,16 +248,16 @@ class ImageProcess():
return self
def image_process(base64_source, size=(0, 0), verify_resolution=False, quality=0, crop=None, colorize=False, output_format=''):
"""Process the `base64_source` image by executing the given operations and
return the result as a base64 encoded image.
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 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):
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 base64_source
return source
image = ImageProcess(base64_source, verify_resolution)
image = ImageProcess(source, verify_resolution)
if size:
if crop:
center_x = 0.5
@@ -303,7 +271,7 @@ def image_process(base64_source, size=(0, 0), verify_resolution=False, quality=0
image.resize(max_width=size[0], max_height=size[1])
if colorize:
image.colorize()
return image.image_quality_base64(quality=quality, output_format=output_format)
return image.image_quality(quality=quality, output_format=output_format)
# ----------------------------------------
@@ -411,6 +379,12 @@ def image_fix_orientation(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`.
@@ -422,7 +396,7 @@ def base64_to_image(base64_source):
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. Please try with a different file."))
raise UserError(_("This file could not be decoded as an image file."))
def image_apply_opt(image, output_format, **params):
@@ -523,12 +497,3 @@ 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])
if __name__=="__main__":
import sys
assert len(sys.argv)==3, 'Usage to Test: image.py SRC.png DEST.png'
img = base64.b64encode(open(sys.argv[1],'rb').read())
new = image_process(img, size=(128, 100))
open(sys.argv[2], 'wb').write(base64.b64decode(new))