Change to using PIL for image rendering. This lets us do all thumbnail generation on the cover thread.

I couldn't make the PIL ImageQt stuff work. It seems to use a common butter somewhere. Generating and adding a second image to the CoverCache corrupted previously added images. This implementation avoids this problem by passing a byte string of the PIL thumbnail to re_render(), which creates the QPixmap on the GUI thread. As a side effect, all the CoverCache operations are now on the GUI thread.

This implementation is visibly faster than the last one.
This commit is contained in:
Charles Haley 2024-01-26 13:24:50 +00:00
parent 07f988b109
commit f312435b59
2 changed files with 106 additions and 74 deletions

View File

@ -5,12 +5,14 @@ __license__ = 'GPL v3'
__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
import itertools import itertools
from io import BytesIO
import math import math
import operator import operator
import os import os
import weakref import weakref
from collections import namedtuple from collections import namedtuple
from functools import wraps from functools import wraps
from PIL import Image
from qt.core import ( from qt.core import (
QAbstractItemView, QApplication, QBuffer, QByteArray, QColor, QDrag, QEasingCurve, QAbstractItemView, QApplication, QBuffer, QByteArray, QColor, QDrag, QEasingCurve,
QEvent, QFont, QHelpEvent, QIcon, QImage, QIODevice, QItemSelection, QEvent, QFont, QHelpEvent, QIcon, QImage, QIODevice, QItemSelection,
@ -22,12 +24,10 @@ from qt.core import (
from textwrap import wrap from textwrap import wrap
from threading import Event, Thread from threading import Event, Thread
from calibre import fit_image, human_readable, prepare_string_for_xml, prints from calibre import fit_image, human_readable, prepare_string_for_xml
from calibre.constants import DEBUG, config_dir, islinux from calibre.constants import DEBUG, config_dir, islinux
from calibre.ebooks.metadata import fmt_sidx, rating_to_stars from calibre.ebooks.metadata import fmt_sidx, rating_to_stars
from calibre.gui2 import ( from calibre.gui2 import config, empty_index, gprefs, rating_font
FunctionDispatcher, config, empty_index, gprefs, is_gui_thread, rating_font,
)
from calibre.gui2.dnd import path_from_qurl from calibre.gui2.dnd import path_from_qurl
from calibre.gui2.gestures import GestureManager from calibre.gui2.gestures import GestureManager
from calibre.gui2.library.caches import CoverCache, ThumbnailCache from calibre.gui2.library.caches import CoverCache, ThumbnailCache
@ -86,6 +86,8 @@ def handle_enter_press(self, ev, special_action=None, has_edit_cell=True):
def image_to_data(image): # {{{ def image_to_data(image): # {{{
# Although this function is no longer used in this file, it is used in
# other places in calibre. Don't delete it.
ba = QByteArray() ba = QByteArray()
buf = QBuffer(ba) buf = QBuffer(ba)
buf.open(QIODevice.OpenModeFlag.WriteOnly) buf.open(QIODevice.OpenModeFlag.WriteOnly)
@ -718,7 +720,7 @@ CoverTuple = namedtuple('CoverTuple', ['book_id', 'has_cover', 'cache_valid',
@setup_dnd_interface @setup_dnd_interface
class GridView(QListView): class GridView(QListView):
update_item = pyqtSignal(object) update_item = pyqtSignal(object, object)
files_dropped = pyqtSignal(object) files_dropped = pyqtSignal(object)
books_dropped = pyqtSignal(object) books_dropped = pyqtSignal(object)
@ -902,7 +904,6 @@ class GridView(QListView):
def shown(self): def shown(self):
self.update_memory_cover_cache_size() self.update_memory_cover_cache_size()
if self.render_thread is None: if self.render_thread is None:
self.render_cover_dispatcher = FunctionDispatcher(self.render_cover)
self.fetch_thread = Thread(target=self.fetch_covers) self.fetch_thread = Thread(target=self.fetch_covers)
self.fetch_thread.daemon = True self.fetch_thread.daemon = True
self.fetch_thread.start() self.fetch_thread.start()
@ -917,22 +918,17 @@ class GridView(QListView):
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
continue continue
try: try:
# Fetch the cover from the cache or file system on this non- # Fetch the cover from the cache or file system
# GUI thread, putting all file system waits outside the GUI
cover_tuple = self.fetch_cover_from_cache(book_id) cover_tuple = self.fetch_cover_from_cache(book_id)
# Render the cover on the GUI thread. There is no limit on # Render/resize the cover.
# the length of a signal connection queue. Using a thumb = self.make_thumbnail(cover_tuple)
# dispatcher instead of a queued connection prevents
# overloading the GUI with paint requests, which could make
# performance sluggish.
self.render_cover_dispatcher(cover_tuple)
# Tell the GUI to redisplay the cover. These can queue, but # Tell the GUI to redisplay the cover. These can queue, but
# the work is limited to painting the cover if it is visible # the work is limited to painting the cover if it is visible
# so there shouldn't be much performance lag. Using a # so there shouldn't be much performance lag. Using a
# dispatcher to eliminate the queue would probably be worse. # dispatcher to eliminate the queue would probably be worse.
self.update_item.emit(book_id) self.update_item.emit(book_id, thumb)
except: except:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -941,19 +937,22 @@ class GridView(QListView):
def fetch_cover_from_cache(self, book_id): def fetch_cover_from_cache(self, book_id):
''' '''
This method is called on the cover thread, not the GUI thread. It This method fetches the cover from the cache if it exists, otherwise the
returns a namedTuple of cover and cache data: cover.jpg stored in the library.
It is called on the cover thread.
It returns a CoverTuple containing the following cover and cache data:
book_id: The id of the book for which a cover is wanted. book_id: The id of the book for which a cover is wanted.
has_cover: True if the book has an associated cover image file. has_cover: True if the book has an associated cover image file.
cdata: Cover data. Can be None (no cover data), jpg string data, or a cdata: Cover data. Can be None (no cover data), or a rendered cover image.
previously rendered cover.
cache_valid: True if the cache has correct data, False if a cover exists cache_valid: True if the cache has correct data, False if a cover exists
but isn't in the cache, None if the cache has data but the but isn't in the cache, None if the cache has data but the
cover has been deleted. cover has been deleted.
timestamp: the cover file modtime if the cover came from the file system, timestamp: the cover file modtime if the cover came from the file system,
the timestamp in the cache if a valid cover is in the cache, the timestamp in the cache if a valid cover is in the cache,
None otherwise. otherwise None.
''' '''
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
return return
@ -962,9 +961,15 @@ class GridView(QListView):
return return
cdata, timestamp = self.thumbnail_cache[book_id] # None, None if not cached. cdata, timestamp = self.thumbnail_cache[book_id] # None, None if not cached.
if timestamp is None: if timestamp is None:
# Cover not in cache Get the cover from the file system if it exists. # Cover not in cache. Try to read the cover from the library.
has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0) has_cover, cdata, timestamp = db.new_api.cover_or_cache(book_id, 0)
cache_valid = False if has_cover else None if has_cover:
# There is a cover.jpg. Convert the byte string to an image.
cache_valid = False
cdata = Image.open(BytesIO(cdata))
else:
# No cover.jpg
cache_valid = None
else: else:
# A cover is in the cache. Check whether it is up to date. # A cover is in the cache. Check whether it is up to date.
has_cover, tcdata, timestamp = db.new_api.cover_or_cache(book_id, timestamp) has_cover, tcdata, timestamp = db.new_api.cover_or_cache(book_id, timestamp)
@ -972,22 +977,21 @@ class GridView(QListView):
if tcdata is None: if tcdata is None:
# The cached cover is up-to-date. # The cached cover is up-to-date.
cache_valid = True cache_valid = True
cdata = Image.open(BytesIO(cdata))
else: else:
# The cached cover is stale # The cached cover is stale
cache_valid = False cache_valid = False
# Replace the cached cover data with the data from the cover file # Convert the bytes from the cover.jpg. The image will be
cdata = tcdata # resized later.
cdata = Image.open(BytesIO(tcdata))
else: else:
# We found a cached cover for a book without a cover. This can # We found a cached cover for a book without a cover. This can
# happen in older version of calibres that can reuse book_ids # happen in older version of calibre that can reuse book_ids
# between libraries and the books in one library have covers # between libraries and books in one library have covers where
# where they don't in another library. Removing the cover from # they don't in another library. Versions of calibre with this
# the cache isn't strictly necessary, but seems better than # source code don't have this problem because the cache UUID is
# leaving incorrect data in the cache. It will get regenarated # set when the database changes instead of when the cache thread
# if the user switches to the other library. Versions of calibre # is created.
# with this source code don't have this problem because the
# cache UUID is set when the database changes instead of when
# the cache thread is created.
self.thumbnail_cache.invalidate((book_id,)) self.thumbnail_cache.invalidate((book_id,))
cache_valid = None cache_valid = None
cdata = None cdata = None
@ -996,66 +1000,90 @@ class GridView(QListView):
return CoverTuple(book_id=book_id, has_cover=has_cover, cache_valid=cache_valid, return CoverTuple(book_id=book_id, has_cover=has_cover, cache_valid=cache_valid,
cdata=cdata, timestamp=timestamp) cdata=cdata, timestamp=timestamp)
def render_cover(self, cover_tuple): def make_thumbnail(self, cover_tuple):
# Render the cover image data to the current size and correct image format. # Render the cover image data to the thumbnail size and correct format.
# This method must be called on the GUI thread # Rendering isn't needed if the cover came from the cache and the cache
if not is_gui_thread(): # is valid. Put a newly rendered image into the cache. Returns a byte
raise RuntimeError('render_cover not in GUI Thread') # string for the thumbnail image that can be rendered in the GUI. This
dpr = self.device_pixel_ratio # method is called on the cover thread.
page_width = int(dpr * self.delegate.cover_size.width())
page_height = int(dpr * self.delegate.cover_size.height()) def pil_image_to_data():
# Convert the image to a byte string. We don't use the global
# image_to_data() because it uses Qt types that might not be thread
# safe.
bio = BytesIO()
cdata.save(bio, format=CACHE_FORMAT)
return bio.getvalue()
cdata = cover_tuple.cdata cdata = cover_tuple.cdata
book_id = cover_tuple.book_id book_id = cover_tuple.book_id
if cover_tuple.has_cover: if cover_tuple.has_cover:
# The book has a cover. Render the cover data as needed to get the # The book has a cover. Render the cover data as needed to get the
# pixmap that will be cached. # thumbnail that will be cached.
p = QPixmap() if cdata.getbbox() is None and cover_tuple.cache_valid:
p.loadFromData(cdata, CACHE_FORMAT if cover_tuple.cache_valid else 'JPEG') # Something wrong with the cover data in the cache. Remove it
p.setDevicePixelRatio(dpr) # from the cache and render it again.
if p.isNull() and cover_tuple.cache_valid:
# Something wrong with the cover data. Remove it from the cache
# and render it again.
self.thumbnail_cache.invalidate((book_id,)) self.thumbnail_cache.invalidate((book_id,))
self.render_queue.put(book_id) self.render_queue.put(book_id)
return return None
cdata = None if p.isNull() else p
if not cover_tuple.cache_valid: if not cover_tuple.cache_valid:
# cover isn't in the cache, is stale, or isn't a valid image. # The cover isn't in the cache, is stale, or isn't a valid
# image. We might have the image from cover.jpg, in which case
# make it into a thumbnail.
if cdata is not None: if cdata is not None:
# Scale the cover # We have a cover from the file system. Scale it by creating
width, height = p.width(), p.height() # a thumbnail
scaled, nwidth, nheight = fit_image( dpr = self.device_pixel_ratio
width, height, page_width, page_height) page_width = int(dpr * self.delegate.cover_size.width())
page_height = int(dpr * self.delegate.cover_size.height())
scaled, nwidth, nheight = fit_image(cdata.width, cdata.height,
page_width, page_height)
if scaled: if scaled:
# The image is the wrong size. Scale it.
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
return return
p = p.scaled(int(nwidth), int(nheight), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) # The PIL thumbnail operation works in-place, changing
p.setDevicePixelRatio(dpr) # the source image.
cdata = p cdata.thumbnail((int(nwidth), int(nheight)))
if cdata is None: # Put the new thumbnail into the cache.
try:
cdata = pil_image_to_data()
self.thumbnail_cache.insert(book_id, cover_tuple.timestamp, cdata)
except Exception:
self.thumbnail_cache.invalidate((book_id,))
import traceback
traceback.print_exc()
else:
# The cover data isn't valid. Remove it from the cache # The cover data isn't valid. Remove it from the cache
self.thumbnail_cache.invalidate((book_id,)) self.thumbnail_cache.invalidate((book_id,))
else: else:
# Put the newly scaled cover into the cache. # The data from the cover cache is valid. Get its byte string to
try: # pass to the GUI
self.thumbnail_cache.insert(book_id, cover_tuple.timestamp, cdata = pil_image_to_data()
image_to_data(cdata))
except EncodeError as err:
self.thumbnail_cache.invalidate((book_id,))
prints(err)
except Exception:
import traceback
traceback.print_exc()
# else: cached cover image used directly. Nothing to do.
elif cover_tuple.cache_valid is not None: elif cover_tuple.cache_valid is not None:
# Cover was removed, but it exists in cache. Remove it from the cache # Cover was removed, but it exists in cache. Remove it from the cache
self.thumbnail_cache.invalidate((book_id,)) self.thumbnail_cache.invalidate((book_id,))
cdata = None cdata = None
# This can put None into the cache so re-rendering doesn't try again. # Return the thumbnail, which is either None or an image byte string.
self.delegate.cover_cache.set(book_id, cdata) # This can result in putting None into the cache so re-rendering doesn't
# try again.
return cdata
def re_render(self, book_id): def re_render(self, book_id, thumb):
# This is called on the GUI thread when a cover thumbnail is not in the
# CoverCache. The parameter "thumb" is a byte string representation of
# the correctly-sized thumbnail.
self.delegate.cover_cache.clear_staging() self.delegate.cover_cache.clear_staging()
if thumb is None:
self.delegate.cover_cache.set(book_id, None)
else:
# There seems to be deadlock or memory corruption problems when
# using loadFromData in a non-GUI thread. Avoid these by doing the
# conversion here instead of in the cover thread.
p = QPixmap()
p.setDevicePixelRatio(self.device_pixel_ratio)
p.loadFromData(thumb, CACHE_FORMAT)
self.delegate.cover_cache.set(book_id, p)
m = self.model() m = self.model()
try: try:
index = m.db.row(book_id) index = m.db.row(book_id)

View File

@ -25,7 +25,12 @@ class ThumbnailCache(TC):
class CoverCache(dict): class CoverCache(dict):
' This is a RAM cache to speed up rendering of covers by storing them as QPixmaps ' '''
This is a RAM cache to speed up rendering of covers by storing them as
QPixmaps. It is possible that it is called from multiple threads, thus the
locking and staging. For example, it can be called by the db layer when a
book is removed either by the GUI or the content server.
'''
def __init__(self, limit=100): def __init__(self, limit=100):
self.items = OrderedDict() self.items = OrderedDict()
@ -59,7 +64,6 @@ class CoverCache(dict):
# faster # faster
ans = QPixmap.fromImage(ans) ans = QPixmap.fromImage(ans)
self.items[key] = ans self.items[key] = ans
return ans return ans
def set(self, key, val): def set(self, key, val):