[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__) pwd = os.path.dirname(__file__)
self.setWindowIcon(QIcon(os.path.join(pwd, "img/meshroom.svg"))) self.setWindowIcon(QIcon(os.path.join(pwd, "img/meshroom.svg")))
# User specified thumbnail directory # Initialize thumbnail cache:
thumbnailDir = os.getenv('MESHROOM_THUMBNAIL_DIR') # - read related environment variables
if thumbnailDir is not None: # - clean cache directory and make sure it exists on disk
ThumbnailCache.thumbnailDir = thumbnailDir ThumbnailCache.initialize()
# 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()
# QML engine setup # QML engine setup
qmlDir = os.path.join(pwd, "qml") 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.QtCore import QObject, Slot, QSize, QUrl, Qt
from PySide2.QtGui import QImageReader, QImageWriter from PySide2.QtGui import QImageReader, QImageWriter
import os import os
import pathlib from pathlib import Path
import hashlib import hashlib
import time import time
import logging import logging
from multiprocessing.pool import ThreadPool
class ThumbnailCache(QObject): 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) For a given image file, it ensures the corresponding thumbnail exists (by creating it if necessary)
and gives access to it. 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: The default cache location is a subdirectory of the user's home directory:
~/Meshroom/thumbnails. ~/Meshroom/thumbnails.
@ -34,7 +38,7 @@ class ThumbnailCache(QObject):
""" """
# Thumbnail cache directory # 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) # Thumbnail dimensions limit (the actual dimensions of a thumbnail will depend on the aspect ratio)
thumbnailSize = QSize(100, 100) thumbnailSize = QSize(100, 100)
@ -45,39 +49,90 @@ class ThumbnailCache(QObject):
# Maximum number of thumbnails in the cache directory # Maximum number of thumbnails in the cache directory
maxThumbnailsOnDisk = 100000 maxThumbnailsOnDisk = 100000
@Slot(QUrl, result=QUrl) # Signal to notify listeners that a thumbnail was created and written on disk
def thumbnail(self, imgSource): # This signal has one argument: the url of the image that the thumbnail is associated to
""" thumbnailCreated = Signal(QUrl)
Retrieve the filepath of the thumbnail corresponding to a given image.
If the thumbnail does not exist on disk, it is created. # 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: Args:
imgSource (QUrl): the filepath to the input image imgPath (str): filepath to the input image
Returns: 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(): if not imgSource.isValid():
return None return None
imgPath = imgSource.toLocalFile() imgPath = imgSource.toLocalFile()
path = ThumbnailCache.thumbnailPath(imgPath)
# 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')
source = QUrl.fromLocalFile(path) source = QUrl.fromLocalFile(path)
# Check if thumbnail already exists # Check if thumbnail already exists
if os.path.exists(path): if os.path.exists(path):
# Update last modification time # Update last modification time
pathlib.Path(path).touch(exist_ok=True) Path(path).touch(exist_ok=True)
return source return source
# Thumbnail does not exist, therefore we create it: # Thumbnail does not exist
# 1. read the image # create it in a worker thread to avoid UI freeze
# 2. scale it to thumbnail dimensions ThumbnailCache.pool.apply_async(self.createThumbnail, args=(imgSource,))
# 3. write it in the cache 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}') logging.debug(f'[ThumbnailCache] Creating thumbnail {path} for image {imgPath}')
# Initialize image reader object # Initialize image reader object
@ -89,10 +144,7 @@ class ThumbnailCache(QObject):
img = reader.read() img = reader.read()
if img.isNull(): if img.isNull():
logging.error(f'[ThumbnailCache] Error when reading image: {reader.errorString()}') logging.error(f'[ThumbnailCache] Error when reading image: {reader.errorString()}')
return None return
# Make sure the thumbnail directory exists before writing into it
os.makedirs(ThumbnailCache.thumbnailDir, exist_ok=True)
# Scale image while preserving aspect ratio # Scale image while preserving aspect ratio
thumbnail = img.scaled(ThumbnailCache.thumbnailSize, aspectMode=Qt.KeepAspectRatio) thumbnail = img.scaled(ThumbnailCache.thumbnailSize, aspectMode=Qt.KeepAspectRatio)
@ -102,14 +154,13 @@ class ThumbnailCache(QObject):
success = writer.write(thumbnail) success = writer.write(thumbnail)
if not success: if not success:
logging.error(f'[ThumbnailCache] Error when writing thumbnail: {writer.errorString()}') logging.error(f'[ThumbnailCache] Error when writing thumbnail: {writer.errorString()}')
return None
return source # Notify listeners
self.thumbnailCreated.emit(imgSource)
@staticmethod @staticmethod
def clean(): 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 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. 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) : {} 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 { MouseArea {
id: imageMA id: imageMA
anchors.fill: parent anchors.fill: parent
@ -77,9 +90,9 @@ Item {
border.color: isCurrentItem ? imageLabel.palette.highlight : Qt.darker(imageLabel.palette.highlight) border.color: isCurrentItem ? imageLabel.palette.highlight : Qt.darker(imageLabel.palette.highlight)
border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0 border.width: imageMA.containsMouse || root.isCurrentItem ? 2 : 0
Image { Image {
id: thumbnail
anchors.fill: parent anchors.fill: parent
anchors.margins: 4 anchors.margins: 4
source: ThumbnailCache.thumbnail(root.source)
asynchronous: true asynchronous: true
autoTransform: true autoTransform: true
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit