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 collections import defaultdict
from urllib.parse import urlparse from urllib.parse import urlparse
from datetime import datetime, timedelta 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.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, mime2ext, regular_mimes, video_mimes
from photoapp.dbsession import DatabaseSession from photoapp.dbsession import DatabaseSession
from photoapp.common import pwhash from photoapp.common import pwhash
@ -258,33 +258,32 @@ class ThumbnailView(object):
formats[photo.format].append(photo) formats[photo.format].append(photo)
formats = dict(formats) 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. # 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())) imtypes = regular_mimes.intersection(set(formats.keys()))
if imtypes: if imtypes:
# prefer making thumbs from jpeg to avoid loading large raws # prefer making thumbs from jpeg to avoid loading large raws
# jk we can't load raws anyway # jk we can't load raws anyway
thumb_from = formats.get("image/jpeg") thumb_jpegs = formats.get("image/jpeg")
if thumb_from: if thumb_jpegs:
thumb_from = thumb_from[0] # TODO if we're thumbing a set this is an arbitrary image picked thumb_from = thumb_jpegs[0] # TODO if we're thumbing a set this is an arbitrary image picked
else: else:
t = imtypes.pop() # TODO arbitrary t = imtypes.pop() # TODO arbitrary
thumb_from = formats[t][0] # 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
if thumb_fobj: thumb_fobj = self.master.thumbtool.make_thumb(thumb_from, thumb_size)
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())): if thumb_fobj:
# call out to the video thumb service return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg")
#TODO call out - placeholder for now else:
#TODO thumb service configuration option or show the ? (or a thumb that suggests its a video?) cherrypy.response.headers["Cache-Control"] = "no-store, must-revalidate, max-age=0"
#TODO make this set no cache headers cherrypy.response.headers["Age"] = "0"
return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml")
else: # No convertable file types
return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml") 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 os
import sys import sys
import traceback import traceback
from time import time
from collections import defaultdict
from multiprocessing import Process
from PIL import Image, ImageOps from PIL import Image, ImageOps
import tempfile from multiprocessing import Process
from shutil import copyfileobj
from contextlib import closing
import logging
# style tuples: max x, max y, rotate ok) # style tuples: max x, max y, rotate ok)
@ -20,91 +14,60 @@ THUMB_STYLES = {"tiny": (80, 80, False),
"big": (2048, 1536, True)} "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): def thumb_path(style_name, uuid):
return os.path.join("thumbs", style_name, uuid[0:2], "{}.jpg".format(uuid)) return os.path.join("thumbs", style_name, uuid[0:2], "{}.jpg".format(uuid))
class ThumbGenerator(object): def image_style(image, style_name, orientation):
def __init__(self, library, storage): """
self.library = library Given an input PIL Image object, return a scaled/cropped image object of style_name
self.storage = storage """
self._failed_thumbs_cache = defaultdict(dict) thumb_width, thumb_height, flip_ok = THUMB_STYLES[style_name]
i_width, i_height = image.size
def make_photo_thumb(self, photo, style_name): im_is_rotated = orientation % 2 != 0 or i_height > i_width
"""
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 im_is_rotated and flip_ok:
thumb_width, thumb_height = thumb_height, thumb_width
# 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)
if self.storage.exists(dest): def image_file_style(src_path, output_path, style_name, orientation):
return self.storage.open(dest, "rb") """
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')
if photo.uuid in self._failed_thumbs_cache[style_name]: def _image_file_style_process_worker(src_path, output_path, style_name, orientation):
return None 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)
# if photo.width is None: # todo better detection of images that PIL can't open def image_file_style_process(src_path, output_path, style_name, orientation):
# return None """
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:
return False
thumb_width, thumb_height, flip_ok = THUMB_STYLES[style_name] return True
i_width = photo.width
i_height = photo.height
im_is_rotated = photo.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)
# 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)
p = Process(target=gen_thumb_process,
args=(fpath, fthumblocal.name, thumb_width, thumb_height, photo.orientation))
p.start()
p.join()
if p.exitcode != 0:
self._failed_thumbs_cache[style_name][photo.uuid] = True # dont retry failed generations
return None
with closing(self.storage.open(dest, 'wb')) as fdest:
copyfileobj(fthumblocal, fdest)
fthumblocal.seek(0)
return fthumblocal

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