mirror of
https://github.com/sjlongland/tornado-gallery.git
synced 2025-09-13 16:43:16 +10:00
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.
341 lines
12 KiB
Python
341 lines
12 KiB
Python
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
|