Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ui] Thumbnail cache #1861

Merged
merged 29 commits into from
Feb 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ff6358e
[ui] new Thumbnail disk cache
mugulmd Jan 5, 2023
1819eac
[ui] override default thumbnail directory with environment variable
mugulmd Jan 5, 2023
7566544
[ui] remove old thumbnails from disk cache when launching app
mugulmd Jan 6, 2023
bfdcfb5
[ui] ThumbnailCache: use logging instead of print
mugulmd Jan 6, 2023
b372946
[ui] ThumbnailCache: check cache dir existence before cleaning
mugulmd Jan 6, 2023
a96d886
[ui] override default time limit for thumbnails on disk with env var
mugulmd Jan 6, 2023
220bcfb
[ui] maximum number of thumbnails on disk
mugulmd Jan 6, 2023
60c8e77
[ui] create thumbnails asynchronously
mugulmd Jan 9, 2023
af2c9c0
[ui] dispatch thumbnail creation signal from grid
mugulmd Jan 9, 2023
88c85e9
[ui] thumbnail busy indicator when loading
mugulmd Jan 10, 2023
cd95589
[ui] ImageGallery: default value for grid view cache buffer
mugulmd Jan 10, 2023
268f608
[ui] ThumbnailCache: use QStandardPath for default cache dir
mugulmd Jan 16, 2023
dfb120c
[ui] ThumbnailCache: limit number of worker threads to 3
mugulmd Jan 16, 2023
a2e6a68
[ui] ImageDelegate: check URL is not empty instead of OS call
mugulmd Jan 16, 2023
7abbf1b
[ui] ThumbnailCache: reduce number of system calls
mugulmd Jan 16, 2023
8551135
[ui] ThumbnailCache: catch FileNotFoundError when removing thumbnails
mugulmd Jan 16, 2023
80174e2
[ui] ImageGallery: faster thumbnail dispatch using caller ID
mugulmd Jan 16, 2023
7e371ed
[ui] ThumbnailCache: resolve default cache dir in initialize method
mugulmd Jan 16, 2023
0926b20
[ui] fallback for dispatching thumbnails in case cellID changed
mugulmd Jan 17, 2023
374b9ab
[ui] ThumbnailCache: replace thread pool with custom thread spawning
mugulmd Jan 17, 2023
3f96778
[ui] ThumbnailCache: check if thumbnail has been created by another t…
mugulmd Jan 17, 2023
ae3184d
[ui] send thumbnail request with timer to avoid deadlocks
mugulmd Jan 17, 2023
dd235dc
[ui] clear thumbnail requests when changing viewpoints
mugulmd Jan 17, 2023
2f72f9e
[ui] ImageDelegate: increase thumbnail timer to 5s
mugulmd Jan 23, 2023
c482331
[ui] ThumbnailCache: asynchronous cache cleaning
mugulmd Jan 23, 2023
9b0be36
[ui] improve thumbnails quality
mugulmd Jan 26, 2023
db8b387
[ui] ImageGallery: do not reduce thumbnail resolution
fabiencastan Feb 8, 2023
db8d00e
[ui] ImageGallery thumbnails: no need for caching and expensive smoot…
fabiencastan Feb 8, 2023
c758bf0
[ui] thumbnail cache: use thread pool to manage thread lifetime autom…
mugulmd Feb 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions meshroom/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from meshroom.ui.components.clipboard import ClipboardHelper
from meshroom.ui.components.filepath import FilepathHelper
from meshroom.ui.components.scene3D import Scene3DHelper, Transformations3DHelper
from meshroom.ui.components.thumbnail import ThumbnailCache
from meshroom.ui.palette import PaletteManager
from meshroom.ui.reconstruction import Reconstruction
from meshroom.ui.utils import QmlInstantEngine
Expand Down Expand Up @@ -115,6 +116,11 @@ def __init__(self, args):
pwd = os.path.dirname(__file__)
self.setWindowIcon(QIcon(os.path.join(pwd, "img/meshroom.svg")))

# 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")
url = os.path.join(qmlDir, "main.qml")
Expand Down Expand Up @@ -146,6 +152,7 @@ def __init__(self, args):
self.engine.rootContext().setContextProperty("Scene3DHelper", Scene3DHelper(parent=self))
self.engine.rootContext().setContextProperty("Transformations3DHelper", Transformations3DHelper(parent=self))
self.engine.rootContext().setContextProperty("Clipboard", ClipboardHelper(parent=self))
self.engine.rootContext().setContextProperty("ThumbnailCache", ThumbnailCache(parent=self))

# additional context properties
self.engine.rootContext().setContextProperty("_PaletteManager", PaletteManager(self.engine, parent=self))
Expand Down
287 changes: 287 additions & 0 deletions meshroom/ui/components/thumbnail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
from meshroom.common import Signal

from PySide2.QtCore import QObject, Slot, QSize, QUrl, Qt, QStandardPaths
from PySide2.QtGui import QImageReader, QImageWriter

import os
from pathlib import Path
import stat
import hashlib
import time
import logging
from threading import Thread
from multiprocessing.pool import ThreadPool


class ThumbnailCache(QObject):
"""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 can be overriden with the MESHROOM_THUMBNAIL_DIR environment variable.

This class also takes care of cleaning the thumbnail directory,
i.e. scanning this directory and removing thumbnails that have not been used for too long.
This operation also ensures that the number of thumbnails on disk does not exceed a certain limit,
by removing thumbnails if necessary (from least recently used to most recently used).
Since this operation is done at application startup, it is also performed asynchronously.

The default time limit is 90 days,
and can be overriden with the MESHROOM_THUMBNAIL_TIME_LIMIT environment variable.

The default maximum number of thumbnails on disk is 100000,
and can be overriden with the MESHROOM_MAX_THUMBNAILS_ON_DISK.

The main use case for thumbnails in Meshroom is in the ImageGallery.
"""

# Thumbnail cache directory
# Cannot be initialized here as it depends on the organization and application names
thumbnailDir = ''

# Thumbnail dimensions limit (the actual dimensions of a thumbnail will depend on the aspect ratio)
thumbnailSize = QSize(256, 256)

# Time limit for thumbnail storage on disk, expressed in days
storageTimeLimit = 90

# Maximum number of thumbnails in the cache directory
maxThumbnailsOnDisk = 100000

# Signal to notify listeners that a thumbnail was created and written on disk
# This signal has two argument:
# - the url of the image that the thumbnail is associated to
# - an identifier for the caller, e.g. the component that sent the request (useful for faster dispatch in QML)
thumbnailCreated = Signal(QUrl, int)

# Threads info and LIFO structure for running clean and createThumbnail asynchronously
requests = []
cleaningThread = None
workerThreads = ThreadPool(processes=3)

@staticmethod
def initialize():
"""Initialize static fields in cache class and cache directory on disk."""
# Thumbnail directory: default or user specified
dir = os.getenv('MESHROOM_THUMBNAIL_DIR')
if dir is not None:
ThumbnailCache.thumbnailDir = dir
else:
ThumbnailCache.thumbnailDir = os.path.join(QStandardPaths.writableLocation(QStandardPaths.CacheLocation),
'thumbnails')

# 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
# This is performed asynchronously to avoid freezing the app at startup
ThumbnailCache.cleaningThread = Thread(target=ThumbnailCache.clean)
ThumbnailCache.cleaningThread.start()

# Make sure the thumbnail directory exists before writing into it
os.makedirs(ThumbnailCache.thumbnailDir, exist_ok=True)

@staticmethod
def clean():
"""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.
"""
# Check if thumbnail directory exists
if not os.path.exists(ThumbnailCache.thumbnailDir):
logging.debug('[ThumbnailCache] Thumbnail directory does not exist yet.')
return

# Get current time
now = time.time()

# Scan thumbnail directory and gather all thumbnails to remove
toRemove = []
remaining = []
for f_name in os.listdir(ThumbnailCache.thumbnailDir):
pathname = os.path.join(ThumbnailCache.thumbnailDir, f_name)

# System call to get current item info
f_stat = os.stat(pathname, follow_symlinks=False)

# Check if this is a regular file
if not stat.S_ISREG(f_stat.st_mode):
continue

# Compute storage duration since last usage of thumbnail
lastUsage = f_stat.st_mtime
storageTime = now - lastUsage
logging.debug(f'[ThumbnailCache] Thumbnail {f_name} has been stored for {storageTime}s')

if storageTime > ThumbnailCache.storageTimeLimit * 3600 * 24:
# Mark as removable if storage time exceeds limit
logging.debug(f'[ThumbnailCache] {f_name} exceeded storage time limit')
toRemove.append(pathname)
else:
# Store path and last usage time for potentially sorting and removing later
remaining.append((pathname, lastUsage))

# Remove all thumbnails marked as removable
for path in toRemove:
logging.debug(f'[ThumbnailCache] Remove {path}')
try:
os.remove(path)
except FileNotFoundError:
logging.error(f'[ThumbnailCache] Tried to remove {path} but this file does not exist')

# Check if number of thumbnails on disk exceeds limit
if len(remaining) > ThumbnailCache.maxThumbnailsOnDisk:
nbToRemove = len(remaining) - ThumbnailCache.maxThumbnailsOnDisk
logging.debug(
f'[ThumbnailCache] Too many thumbnails: {len(remaining)} remaining, {nbToRemove} will be removed')

# Sort by last usage order and remove excess
remaining.sort(key=lambda elt: elt[1])
for i in range(nbToRemove):
path = remaining[i][0]
logging.debug(f'[ThumbnailCache] Remove {path}')
try:
os.remove(path)
except FileNotFoundError:
logging.error(f'[ThumbnailCache] Tried to remove {path} but this file does not exist')

@staticmethod
def thumbnailPath(imgPath):
"""Use SHA1 hashing to associate a unique thumbnail to an image.

Args:
imgPath (str): filepath to the input image

Returns:
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

@staticmethod
def checkThumbnail(path):
"""Check if a thumbnail already exists on disk, and if so update its last modification time.

Args:
path (str): filepath to the thumbnail

Returns:
(bool): whether the thumbnail exists on disk or not
"""
if os.path.exists(path):
# Update last modification time
Path(path).touch(exist_ok=True)
return True
return False

@Slot(QUrl, int, result=QUrl)
def thumbnail(self, imgSource, callerID):
"""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 thumbnailCreated signal is emitted.

Args:
imgSource (QUrl): location of the input image
callerID (int): identifier for the object that requested the thumbnail

Returns:
QUrl: location of the corresponding thumbnail if it exists, otherwise None
"""
if not imgSource.isValid():
return None

imgPath = imgSource.toLocalFile()
path = ThumbnailCache.thumbnailPath(imgPath)
source = QUrl.fromLocalFile(path)

# Check if thumbnail already exists
if ThumbnailCache.checkThumbnail(path):
return source

# Thumbnail does not exist
# Create request and submit to worker threads
ThumbnailCache.requests.append((imgSource, callerID))
ThumbnailCache.workerThreads.apply_async(func=self.handleRequestsAsync)

return None

def createThumbnail(self, imgSource, callerID):
"""Load an image, resize it to thumbnail dimensions and save the result in the cache directory.

Args:
imgSource (QUrl): location of the input image
callerID (int): identifier for the object that requested the thumbnail
"""
imgPath = imgSource.toLocalFile()
path = ThumbnailCache.thumbnailPath(imgPath)

# Check if thumbnail already exists (it may have been created by another thread)
if ThumbnailCache.checkThumbnail(path):
self.thumbnailCreated.emit(imgSource, callerID)
return

logging.debug(f'[ThumbnailCache] Creating thumbnail {path} for image {imgPath}')

# Initialize image reader object
reader = QImageReader()
reader.setFileName(imgPath)
reader.setAutoTransform(True)

# Read image and check for potential errors
img = reader.read()
if img.isNull():
logging.error(f'[ThumbnailCache] Error when reading image: {reader.errorString()}')
return

# Scale image while preserving aspect ratio
thumbnail = img.scaled(ThumbnailCache.thumbnailSize,
aspectMode=Qt.KeepAspectRatio,
mode=Qt.SmoothTransformation)

# Write thumbnail to disk and check for potential errors
writer = QImageWriter(path)
success = writer.write(thumbnail)
if not success:
logging.error(f'[ThumbnailCache] Error when writing thumbnail: {writer.errorString()}')

# Notify listeners
self.thumbnailCreated.emit(imgSource, callerID)

def handleRequestsAsync(self):
"""Process thumbnail creation requests in LIFO order.

Note: this operation waits for the cleaning process to finish before starting,
in order to avoid synchronization issues.
"""
# Wait for cleaning thread to finish
if ThumbnailCache.cleaningThread is not None and ThumbnailCache.cleaningThread.is_alive():
ThumbnailCache.cleaningThread.join()

# Handle requests until the requests stack is empty
try:
while True:
req = ThumbnailCache.requests.pop()
self.createThumbnail(req[0], req[1])
except IndexError:
# No more request to process
return

@Slot()
def clearRequests(self):
"""Clear all pending thumbnail creation requests.

Requests already under treatment by a worker thread will still be completed.
"""
ThumbnailCache.requests.clear()
35 changes: 33 additions & 2 deletions meshroom/ui/qml/ImageGallery/ImageDelegate.qml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Item {
id: root

property variant viewpoint
property int cellID: -1
property bool isCurrentItem: false
property alias source: _viewpoint.source
property alias metadata: _viewpoint.metadata
Expand All @@ -31,6 +32,31 @@ Item {
property var metadata: metadataStr ? JSON.parse(viewpoint.get("metadata").value) : {}
}

// update thumbnail location
// can be called from the GridView when a new thumbnail has been written on disk
function updateThumbnail() {
thumbnail.source = ThumbnailCache.thumbnail(root.source, root.cellID);
}
onSourceChanged: {
updateThumbnail();
}

// Send a new request every 5 seconds until thumbnail is loaded
// This is meant to avoid deadlocks in case there is a synchronization problem
Timer {
interval: 5000
repeat: true
running: true
onTriggered: {
if (thumbnail.status == Image.Null) {
updateThumbnail();
}
else {
running = false;
}
}
}

MouseArea {
id: imageMA
anchors.fill: parent
Expand Down Expand Up @@ -77,13 +103,18 @@ 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: root.source
sourceSize: Qt.size(100, 100)
asynchronous: true
autoTransform: true
fillMode: Image.PreserveAspectFit
smooth: false
cache: false
}
BusyIndicator {
anchors.centerIn: parent
running: thumbnail.status != Image.Ready
}
}
// Image basename
Expand Down
Loading