[ui] create thumbnails asynchronously

This commit is contained in:
mugulmd 2023-01-09 05:52:16 -08:00
parent 220bcfb9e2
commit 60c8e779d5
3 changed files with 98 additions and 47 deletions

View file

@ -116,23 +116,10 @@ class MeshroomApp(QApplication):
pwd = os.path.dirname(__file__)
self.setWindowIcon(QIcon(os.path.join(pwd, "img/meshroom.svg")))
# User specified thumbnail directory
thumbnailDir = os.getenv('MESHROOM_THUMBNAIL_DIR')
if thumbnailDir is not None:
ThumbnailCache.thumbnailDir = thumbnailDir
# User specifed time limit for thumbnails on disk (expressed in days)
thumbnailTimeLimit = os.getenv('MESHROOM_THUMBNAIL_TIME_LIMIT')
if thumbnailTimeLimit is not None:
ThumbnailCache.storageTimeLimit = float(thumbnailTimeLimit)
# User specifed maximum number of thumbnails on disk
thumbnailMaxNumberOnDisk = os.getenv('MESHROOM_MAX_THUMBNAILS_ON_DISK')
if thumbnailMaxNumberOnDisk is not None:
ThumbnailCache.maxThumbnailsOnDisk = int(thumbnailMaxNumberOnDisk)
# Clean thumbnail directory
ThumbnailCache.clean()
# Initialize thumbnail cache:
# - read related environment variables
# - clean cache directory and make sure it exists on disk
ThumbnailCache.initialize()
# QML engine setup
qmlDir = os.path.join(pwd, "qml")

View file

@ -1,19 +1,23 @@
from meshroom.common import Signal
from PySide2.QtCore import QObject, Slot, QSize, QUrl, Qt
from PySide2.QtGui import QImageReader, QImageWriter
import os
import pathlib
from pathlib import Path
import hashlib
import time
import logging
from multiprocessing.pool import ThreadPool
class ThumbnailCache(QObject):
"""
ThumbnailCache provides an abstraction for the thumbnail cache on disk, available in QML.
"""ThumbnailCache provides an abstraction for the thumbnail cache on disk, available in QML.
For a given image file, it ensures the corresponding thumbnail exists (by creating it if necessary)
and gives access to it.
Since creating thumbnails can be long (as it requires to read the full image from disk)
it is performed asynchronously to avoid blocking the main thread.
The default cache location is a subdirectory of the user's home directory:
~/Meshroom/thumbnails.
@ -34,7 +38,7 @@ class ThumbnailCache(QObject):
"""
# Thumbnail cache directory
thumbnailDir = os.path.join(pathlib.Path.home(), 'Meshroom', 'thumbnails')
thumbnailDir = os.path.join(Path.home(), 'Meshroom', 'thumbnails')
# Thumbnail dimensions limit (the actual dimensions of a thumbnail will depend on the aspect ratio)
thumbnailSize = QSize(100, 100)
@ -45,39 +49,90 @@ class ThumbnailCache(QObject):
# Maximum number of thumbnails in the cache directory
maxThumbnailsOnDisk = 100000
@Slot(QUrl, result=QUrl)
def thumbnail(self, imgSource):
"""
Retrieve the filepath of the thumbnail corresponding to a given image.
If the thumbnail does not exist on disk, it is created.
# Signal to notify listeners that a thumbnail was created and written on disk
# This signal has one argument: the url of the image that the thumbnail is associated to
thumbnailCreated = Signal(QUrl)
# Thread pool for running createThumbnail asynchronously on a fixed number of worker threads
pool = ThreadPool()
@staticmethod
def initialize():
"""Initialize static fields in cache class and cache directory on disk."""
# User specified thumbnail directory
dir = os.getenv('MESHROOM_THUMBNAIL_DIR')
if dir is not None:
ThumbnailCache.thumbnailDir = dir
# User specifed time limit for thumbnails on disk (expressed in days)
timeLimit = os.getenv('MESHROOM_THUMBNAIL_TIME_LIMIT')
if timeLimit is not None:
ThumbnailCache.storageTimeLimit = float(timeLimit)
# User specifed maximum number of thumbnails on disk
maxOnDisk = os.getenv('MESHROOM_MAX_THUMBNAILS_ON_DISK')
if maxOnDisk is not None:
ThumbnailCache.maxThumbnailsOnDisk = int(maxOnDisk)
# Clean thumbnail directory
ThumbnailCache.clean()
# Make sure the thumbnail directory exists before writing into it
os.makedirs(ThumbnailCache.thumbnailDir, exist_ok=True)
@staticmethod
def thumbnailPath(imgPath):
"""Use SHA1 hashing to associate a unique thumbnail to an image.
Args:
imgSource (QUrl): the filepath to the input image
imgPath (str): filepath to the input image
Returns:
QUrl: the filepath to the corresponding thumbnail
str: filepath to the corresponding thumbnail
"""
digest = hashlib.sha1(imgPath.encode('utf-8')).hexdigest()
path = os.path.join(ThumbnailCache.thumbnailDir, f'{digest}.jpg')
return path
@Slot(QUrl, result=QUrl)
def thumbnail(self, imgSource):
"""Retrieve the filepath of the thumbnail corresponding to a given image.
If the thumbnail does not exist on disk, it will be created asynchronously.
When this is done, the createdThumbnail signal is emitted.
Args:
imgSource (QUrl): location of the input image
Returns:
QUrl: location of the corresponding thumbnail if it exists, otherwise None
"""
# Safety check
if not imgSource.isValid():
return None
imgPath = imgSource.toLocalFile()
# Use SHA1 hashing to associate a unique thumbnail to the image
digest = hashlib.sha1(imgPath.encode('utf-8')).hexdigest()
path = os.path.join(ThumbnailCache.thumbnailDir, f'{digest}.jpg')
path = ThumbnailCache.thumbnailPath(imgPath)
source = QUrl.fromLocalFile(path)
# Check if thumbnail already exists
if os.path.exists(path):
# Update last modification time
pathlib.Path(path).touch(exist_ok=True)
Path(path).touch(exist_ok=True)
return source
# Thumbnail does not exist, therefore we create it:
# 1. read the image
# 2. scale it to thumbnail dimensions
# 3. write it in the cache
# Thumbnail does not exist
# create it in a worker thread to avoid UI freeze
ThumbnailCache.pool.apply_async(self.createThumbnail, args=(imgSource,))
return None
def createThumbnail(self, imgSource):
"""Load an image, resize it to thumbnail dimensions and save the result in the cache directory.
Args:
imgSource (QUrl): location of the input image
"""
imgPath = imgSource.toLocalFile()
path = ThumbnailCache.thumbnailPath(imgPath)
logging.debug(f'[ThumbnailCache] Creating thumbnail {path} for image {imgPath}')
# Initialize image reader object
@ -89,10 +144,7 @@ class ThumbnailCache(QObject):
img = reader.read()
if img.isNull():
logging.error(f'[ThumbnailCache] Error when reading image: {reader.errorString()}')
return None
# Make sure the thumbnail directory exists before writing into it
os.makedirs(ThumbnailCache.thumbnailDir, exist_ok=True)
return
# Scale image while preserving aspect ratio
thumbnail = img.scaled(ThumbnailCache.thumbnailSize, aspectMode=Qt.KeepAspectRatio)
@ -102,14 +154,13 @@ class ThumbnailCache(QObject):
success = writer.write(thumbnail)
if not success:
logging.error(f'[ThumbnailCache] Error when writing thumbnail: {writer.errorString()}')
return None
return source
# Notify listeners
self.thumbnailCreated.emit(imgSource)
@staticmethod
def clean():
"""
Scan the thumbnail directory and:
"""Scan the thumbnail directory and:
1. remove all thumbnails that have not been used for more than storageTimeLimit days
2. ensure that the number of thumbnails on disk does not exceed maxThumbnailsOnDisk.
"""

View file

@ -31,6 +31,19 @@ Item {
property var metadata: metadataStr ? JSON.parse(viewpoint.get("metadata").value) : {}
}
onSourceChanged: {
thumbnail.source = ThumbnailCache.thumbnail(root.source)
}
Connections {
target: ThumbnailCache
function onThumbnailCreated(imgSource) {
if (imgSource == root.source) {
thumbnail.source = ThumbnailCache.thumbnail(root.source)
}
}
}
MouseArea {
id: imageMA
anchors.fill: parent
@ -77,9 +90,9 @@ Item {
border.color: isCurrentItem ? imageLabel.palette.highlight : Qt.darker(imageLabel.palette.highlight)
border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0
Image {
id: thumbnail
anchors.fill: parent
anchors.margins: 4
source: ThumbnailCache.thumbnail(root.source)
asynchronous: true
autoTransform: true
fillMode: Image.PreserveAspectFit