1
0
mirror of https://github.com/sjlongland/tornado-gallery.git synced 2025-09-13 16:43:16 +10:00
tornado-gallery/tornado_gallery/resizer.py

241 lines
8.2 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
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]
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):
"""
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)
# Do we need to compute full dimensions?
if (width is None) or (height is None):
raw_width, raw_height = yield self.get_dimensions(gallery, photo)
ratio = float(raw_width) / float(raw_height)
if (ratio >= 1.0) or (height is None):
# Fit to width
height = int(width / ratio)
else:
# Fit to height
width = int(height * ratio)
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)
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):
"""
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; save as %s in %s',
quality, rotation, img_format.name, 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'))
# 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_properties(self, gallery, photo):
"""
Return the raw properties of the photo.
"""
img_node = self._fs_node.join_node(gallery, photo)
img = Image.open(open(img_node.abs_path,'rb'))
(width, height) = img.size
return dict(width=width, height=height)