refactor image thumbnail generation code

This commit is contained in:
dave 2021-08-19 15:11:23 -07:00
parent 636a386ec4
commit 44e7bc81d6
3 changed files with 109 additions and 99 deletions

View File

@ -6,7 +6,7 @@ import cherrypy
from collections import defaultdict
from urllib.parse import urlparse
from datetime import datetime, timedelta
from photoapp.thumb import ThumbGenerator
from photoapp.thumbtool import ThumbGenerator
from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, mime2ext, regular_mimes, video_mimes
from photoapp.dbsession import DatabaseSession
from photoapp.common import pwhash
@ -258,33 +258,32 @@ class ThumbnailView(object):
formats[photo.format].append(photo)
formats = dict(formats)
thumb_from = None
# prefer image files. If an image is available assume it is the target or intended to be the thumbnail.
imtypes = regular_mimes.intersection(set(formats.keys()))
if imtypes:
# prefer making thumbs from jpeg to avoid loading large raws
# jk we can't load raws anyway
thumb_from = formats.get("image/jpeg")
if thumb_from:
thumb_from = thumb_from[0] # TODO if we're thumbing a set this is an arbitrary image picked
thumb_jpegs = formats.get("image/jpeg")
if thumb_jpegs:
thumb_from = thumb_jpegs[0] # TODO if we're thumbing a set this is an arbitrary image picked
else:
t = imtypes.pop() # TODO arbitrary
thumb_from = formats[t][0] # TODO arbitrary
thumb_fobj = self.master.thumbtool.make_photo_thumb(thumb_from, thumb_size)
vtypes = video_mimes.intersection(set(formats.keys()))
if vtypes and not thumb_from:
t = vtypes.pop() # TODO arbitrary
thumb_from = formats[t][0] # TODO arbitrary
thumb_fobj = self.master.thumbtool.make_thumb(thumb_from, thumb_size)
if thumb_fobj:
return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg")
else:
return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml")
elif video_mimes.intersection(set(formats.keys())):
# call out to the video thumb service
#TODO call out - placeholder for now
#TODO thumb service configuration option or show the ? (or a thumb that suggests its a video?)
#TODO make this set no cache headers
return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml")
else: # No convertable file types
cherrypy.response.headers["Cache-Control"] = "no-store, must-revalidate, max-age=0"
cherrypy.response.headers["Age"] = "0"
return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml")

View File

@ -1,14 +1,8 @@
import os
import sys
import traceback
from time import time
from collections import defaultdict
from multiprocessing import Process
from PIL import Image, ImageOps
import tempfile
from shutil import copyfileobj
from contextlib import closing
import logging
from multiprocessing import Process
# style tuples: max x, max y, rotate ok)
@ -20,91 +14,60 @@ THUMB_STYLES = {"tiny": (80, 80, False),
"big": (2048, 1536, True)}
def gen_thumb(src_img, dest_img, width, height, rotation):
start = time()
# TODO lock around the dir creation
os.makedirs(os.path.split(dest_img)[0], exist_ok=True)
image = Image.open(src_img)
if image.mode != "RGB":
image = image.convert("RGB")
image = image.rotate(90 * rotation, expand=True)
thumb = ImageOps.fit(image, (width, height), Image.ANTIALIAS)
thumb.save(dest_img, 'JPEG')
logging.info("Generated {} in {}s".format(dest_img, round(time() - start, 4)))
return dest_img
def gen_thumb_process(src_img, dest_img, width, height, rotation):
try:
return gen_thumb(src_img, dest_img, width, height, rotation)
except Exception:
traceback.print_exc()
if os.path.exists(dest_img):
os.unlink(dest_img)
sys.exit(1)
def thumb_path(style_name, uuid):
return os.path.join("thumbs", style_name, uuid[0:2], "{}.jpg".format(uuid))
class ThumbGenerator(object):
def __init__(self, library, storage):
self.library = library
self.storage = storage
self._failed_thumbs_cache = defaultdict(dict)
def make_photo_thumb(self, photo, style_name):
def image_style(image, style_name, orientation):
"""
Create a thumbnail of the given photo, scaled/cropped to the given named style
:return: local path to thumbnail file or None if creation failed or was blocked
Given an input PIL Image object, return a scaled/cropped image object of style_name
"""
dest = thumb_path(style_name, photo.uuid)
if self.storage.exists(dest):
return self.storage.open(dest, "rb")
if photo.uuid in self._failed_thumbs_cache[style_name]:
return None
# if photo.width is None: # todo better detection of images that PIL can't open
# return None
thumb_width, thumb_height, flip_ok = THUMB_STYLES[style_name]
i_width = photo.width
i_height = photo.height
im_is_rotated = photo.orientation % 2 != 0 or i_height > i_width
i_width, i_height = image.size
im_is_rotated = orientation % 2 != 0 or i_height > i_width
if im_is_rotated and flip_ok:
thumb_width, thumb_height = thumb_height, thumb_width
thumb_width = min(thumb_width, i_width if i_width > 0 else 999999999)
thumb_height = min(thumb_height, i_height if i_height > 0 else 999999999)
# prevents scale-up (why do i want this?)
thumb_width = min(thumb_width, i_width) if i_width > 0 else thumb_width
thumb_height = min(thumb_height, i_height) if i_height > 0 else thumb_height
image = image.rotate(90 * orientation, expand=True)
return ImageOps.fit(image, (thumb_width, thumb_height), Image.ANTIALIAS)
# TODO have the subprocess download the file
# TODO thundering herd
with tempfile.TemporaryDirectory() as tmpdir:
fthumblocal = tempfile.NamedTemporaryFile(delete=True)
fpath = os.path.join(tmpdir, "image")
with self.library.storage.open(photo.path, 'rb') as fsrc:
with open(fpath, 'wb') as ftmpdest:
copyfileobj(fsrc, ftmpdest)
def image_file_style(src_path, output_path, style_name, orientation):
"""
Given an input and output image paths, create a scaled/cropped image file of style_name
"""
image = Image.open(src_path)
if image.mode != "RGB":
image = image.convert("RGB")
output = image_style(image, style_name, orientation)
output.save(output_path, 'JPEG')
p = Process(target=gen_thumb_process,
args=(fpath, fthumblocal.name, thumb_width, thumb_height, photo.orientation))
def _image_file_style_process_worker(src_path, output_path, style_name, orientation):
try:
image_file_style(src_path, output_path, style_name, orientation)
except Exception:
traceback.print_exc()
if os.path.exists(output_path):
os.unlink(output_path)
sys.exit(1)
def image_file_style_process(src_path, output_path, style_name, orientation):
"""
Same as image_file_style but the image manipulation is done in a background process
"""
p = Process(target=_image_file_style_process_worker,
args=(src_path, output_path, style_name, orientation))
p.start()
p.join()
if p.exitcode != 0:
self._failed_thumbs_cache[style_name][photo.uuid] = True # dont retry failed generations
return None
return False
with closing(self.storage.open(dest, 'wb')) as fdest:
copyfileobj(fthumblocal, fdest)
fthumblocal.seek(0)
return fthumblocal
return True

48
photoapp/thumbtool.py Normal file
View File

@ -0,0 +1,48 @@
import os
from collections import defaultdict
import tempfile
from shutil import copyfileobj
from contextlib import closing
from photoapp.thumb import thumb_path, image_file_style_process
class ThumbGenerator(object):
def __init__(self, library, storage):
self.library = library
self.storage = storage
self._failed_thumbs_cache = defaultdict(dict)
def make_thumb(self, photo, style_name):
"""
Create a thumbnail of the given photo, scaled/cropped to the given named style
:return: local path to thumbnail file or None if creation failed or was blocked
"""
dest = thumb_path(style_name, photo.uuid)
if self.storage.exists(dest):
return self.storage.open(dest, "rb")
if photo.uuid in self._failed_thumbs_cache[style_name]:
return None
# if photo.width is None: # todo better detection of images that PIL can't open
# return None
# TODO have the subprocess download the file
# TODO thundering herd
with tempfile.TemporaryDirectory() as tmpdir:
fthumblocal = tempfile.NamedTemporaryFile(delete=True)
fpath = os.path.join(tmpdir, "image")
with self.library.storage.open(photo.path, 'rb') as fsrc:
with open(fpath, 'wb') as ftmpdest:
copyfileobj(fsrc, ftmpdest)
if not image_file_style_process(fpath, fthumblocal.name, style_name, photo.orientation):
self._failed_thumbs_cache[style_name][photo.uuid] = True # dont retry failed generations
return
with closing(self.storage.open(dest, 'wb')) as fdest:
copyfileobj(fthumblocal, fdest)
fthumblocal.seek(0)
return fthumblocal