1
0
mirror of https://github.com/sjlongland/tornado-gallery.git synced 2025-09-14 00:53:19 +10:00
tornado-gallery/tornado_gallery/resizer.py
Stuart Longland 8970021396
resizer: Ensure aspect ratio is maintained.
Implement a function that computes the aspect ratio and scales the width
or height automatically to fit the bounding box given.

Re-implement scaling algorithm using this function.
2018-04-30 21:46:55 +10:00

341 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from tornado.gen import coroutine, Return
from tornado.ioloop import IOLoop
from PIL import Image
from sys import exc_info
from io import BytesIO
from enum import Enum
from tornado.locks import Semaphore
import magic
import logging
try:
import piexif
except ImportError:
pass
from os import makedirs
import multiprocessing
from .pool import WorkerPool
from weakref import WeakValueDictionary
# Filename extension mappings
_FORMAT_EXT = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif'
}
# PIL formats
_FORMAT_PIL = {
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/gif': 'GIF'
}
class ImageFormat(Enum):
JPEG = 'image/jpeg'
PNG = 'image/png'
GIF = 'image/gif'
@property
def ext(self):
return _FORMAT_EXT[self.value]
@property
def pil_fmt(self):
return _FORMAT_PIL[self.value]
def calc_dimensions(raw_width, raw_height, width=None, height=None):
"""
Given the raw dimensions of a photo, and optional target dimensions,
return the dimensions of a photo that will fit in the target dimensions
whilst respecting the aspect ratio of the photo.
"""
# Simple case, neither given, do not scale.
if (width is None) and (height is None):
return (raw_width, raw_height)
# Compute aspect ratio
ratio = float(raw_width) / float(raw_height)
# Simple case: one dimension only given
if width is None:
# Scale by height only.
return (int(height * ratio), height)
elif height is None:
# Scale by width only.
return (width, int(width / ratio))
else:
# Both dimensions, pick the one that fits!
scaled_height = int(width / ratio)
scaled_width = int(height * ratio)
if scaled_width > width:
# Scale-by-height is too wide, scale-by-width.
return (width, scaled_height)
else:
# Scale-by-width is too tall, scale-by-height.
return (scaled_width, height)
class ResizerPool(object):
def __init__(self, root_dir_node, cache_subdir, num_proc=None, log=None):
if log is None:
log = logging.getLogger(self.__class__.__module__)
if num_proc is None:
num_proc = multiprocessing.cpu_count()
self._log = log
self._pool = WorkerPool(num_proc)
self._fs_node = root_dir_node
self._cache_node = self._fs_node[cache_subdir]
self._mutexes = WeakValueDictionary()
@coroutine
def get_resized(self, gallery, photo,
width=None, height=None, quality=60,
rotation=0.0, img_format=None, orientation=0):
"""
Retrieve the given photo in a resized format.
"""
# Determine the path to the original file.
orig_node = self._fs_node.join_node(gallery, photo)
if img_format is None:
# Detect from original file and quality setting.
with magic.Magic(flags=magic.MAGIC_MIME_TYPE) as m:
mime_type = m.id_filename(orig_node.abs_path)
self._log.debug('%s/%s detected format %s',
gallery, photo, mime_type)
if mime_type == 'image/gif':
img_format = ImageFormat.GIF
else:
if quality == 100:
# Assume PNG
img_format = ImageFormat.PNG
else:
# Assume JPEG
img_format = ImageFormat.JPEG
else:
# Use the format given by the user
img_format = ImageFormat(img_format)
self._log.debug('%s/%s using %s format',
gallery, photo, img_format.name)
# Sanitise dimensions given by user.
width, height = self.get_dimensions(gallery, photo, width, height)
self._log.debug('%s/%s target dimensions %d by %d',
gallery, photo, width, height)
# Determine where the file would be cached
(cache_dir, cache_name) = self._get_cache_name(gallery, photo,
width,height, quality, rotation, img_format)
# Do we have this file?
data = self._read_cache(orig_node, cache_dir, cache_name)
if data is not None:
raise Return((img_format, cache_name, data))
# Locate the lock for this photo.
mutex_key = (gallery, photo, width, height, quality, rotation,
img_format)
try:
mutex = self._mutexes[mutex_key]
except KeyError:
mutex = Semaphore(1)
self._mutexes[mutex_key] = mutex
resize_args = (gallery, photo, width, height, quality,
rotation, img_format.value, orientation)
try:
self._log.debug('%s/%s waiting for mutex',
gallery, photo)
yield mutex.acquire()
# We have the semaphore, call our resize routine.
self._log.debug('%s/%s retrieving resized image (args=%s)',
gallery, photo, resize_args)
(img_format, file_name, file_data) = yield self._pool.apply(
func=self._do_resize,
args=resize_args)
raise Return((img_format, file_name, file_data))
except Return:
raise
except:
self._log.exception('Error resizing photo; gallery: %s, photo: %s, '\
'width: %d, height: %d, quality: %f, rotation: %f, format: %s',
gallery, photo, width, height, quality, rotation, img_format)
raise
finally:
mutex.release()
def _get_cache_name(self, gallery, photo, width, height, quality,
rotation, img_format):
"""
Determine what the name of a cached resized image would be.
"""
# Determine the name of the cache file.
photo_noext = '.'.join(photo.split('.')[:-1])
cache_name = ('%(gallery)s-%(photo)s-'\
'%(width)dx%(height)d-'\
'%(quality)d-%(rotation).6f.%(ext)s') % {
'gallery': gallery,
'photo': photo_noext,
'width': width,
'height': height,
'quality': quality,
'rotation': rotation,
'ext': img_format.ext
}
cache_dir = self._cache_node.join(gallery, photo_noext)
return (cache_dir, cache_name)
def _read_cache(self, orig_node, cache_dir, cache_name):
# Do we have this file now?
cache_path = self._cache_node.join(cache_dir, cache_name)
try:
cache_node = self._cache_node[cache_path]
# We do, is it same age/newer and non-zero sized?
if (cache_node.stat.st_size > 0) and \
(cache_node.stat.st_mtime >= orig_node.stat.st_mtime):
# This will do. Re-use the existing file.
return open(cache_node.abs_path, 'rb').read()
except KeyError:
# We do not, press on!
pass
def _do_resize(self, gallery, photo, width, height, quality,
rotation, img_format, orientation):
"""
Perform a resize of the image, and return the result.
"""
img_format = ImageFormat(img_format)
(cache_dir, cache_name) = self._get_cache_name(gallery, photo,
width,height, quality, rotation, img_format)
log = self._log.getChild('%s/%s@%dx%d' \
% (gallery, photo, width, height))
log.debug('Resizing photo; quality %f, '\
'rotation %f, format %s, orientation %s; save as %s in %s',
quality, rotation, img_format.name, orientation,
cache_name, cache_dir)
# Determine the path to the original file.
orig_node = self._fs_node.join_node(gallery, photo)
# Ensure the directory exists
makedirs(cache_dir, exist_ok=True)
# Do we have this file now?
data = self._read_cache(orig_node, cache_dir, cache_name)
if data is not None:
return (img_format, cache_name, data)
# Open the image
img = Image.open(open(orig_node.abs_path,'rb'))
# Credit: http://piexif.readthedocs.io/en/stable/sample.html
if orientation == 2:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
elif orientation == 3:
img = img.transpose(Image.ROTATE_180)
elif orientation == 4:
img = img.transpose(Image.ROTATE_180).transpose(Image.FLIP_LEFT_RIGHT)
elif orientation == 5:
img = img.transpose(Image.ROTATE_270).transpose(Image.FLIP_LEFT_RIGHT)
elif orientation == 6:
img = img.transpose(Image.ROTATE_270)
elif orientation == 7:
img = img.transpose(Image.ROTATE_90).transpose(Image.FLIP_LEFT_RIGHT)
elif orientation == 8:
img = img.transpose(Image.ROTATE_90)
# Rotate if asked:
if rotation != 0:
img = img.rotate(rotation, expand=1)
# Resize!
img = img.resize((width, height), Image.LANCZOS)
# Convert to RGB colourspace if not GIF
if img_format != ImageFormat.GIF:
img = img.convert('RGB')
# Write out the new file.
cache_path = self._cache_node.join(cache_dir, cache_name)
img.save(open(cache_path,'wb'), img_format.pil_fmt)
# Return to caller
log.info('Returning resized result')
return (img_format, cache_name,
open(cache_path, 'rb').read())
def get_dimensions(self, gallery, photo, width=None, height=None):
img_node = self._fs_node.join_node(gallery, photo)
img = Image.open(open(img_node.abs_path,'rb'))
if (width is None) and (height is None):
return img.size
return calc_dimensions(*(img.size + (width, height)))
def get_properties(self, gallery, photo):
"""
Return the raw properties of the photo.
"""
log = self._log.getChild('%s/%s' % (gallery, photo))
img_node = self._fs_node.join_node(gallery, photo)
(width, height) = self.get_dimensions(gallery, photo)
meta = dict(width=width, height=height)
log.debug('Raw dimensions %dx%d', width, height)
try:
exif = piexif.load(img_node.abs_path,
key_is_name=True)
log.debug('Loaded EXIF data')
except:
# Maybe EXIF is not supported? Or maybe piexif isn't loaded.
exif = None
log.debug('No EXIF data available', exc_info=1)
if exif is not None:
# Decode the EXIF data¸ stripping the blobs
# This is an ugly workaround to
# https://github.com/hMatoba/Piexif/issues/58
def _strip_blobs(obj):
if isinstance(obj, bytes):
return obj.decode('UTF-8')
if isinstance(obj, dict):
out = {}
for key, value in obj.items():
try:
out[key] = _strip_blobs(value)
except:
pass
return out
if isinstance(obj, list) or isinstance(obj, tuple):
out = []
for value in obj:
try:
out.append(_strip_blobs(value))
except:
pass
return out
return obj
meta['exif'] = _strip_blobs(exif)
try:
if meta['exif']['0th']['Orientation'] in (5, 6, 7, 8):
log.debug('Swapping width/height due to orientation')
meta['height'] = width
meta['width'] = height
except KeyError:
pass
return meta