Issue
I am making an image gallery of sorts where I want to be able to peruse a large sample of images (~1500) at a time. Everything's alright so far but I have run into a bug that happens when I try to close the program.
There are two main heavy processes that occur during loading:
- Put image from filesystem --> memory, perform thumbnailing operation (
spawnThreads
method) - Add widget to layout (
updateThumbs
method)
The first task I use multiple threads to perform and that works fine. The second, I have to perform on the main thread since thats the only place you can do addWidget
. Before, this caused my application to freeze, but now I use QCoreApplication.processEvents()
and the UI is able to continue working fine.
However, if I try to close the program (with the "X" button) while the second task is still working, the UI closes, but Python remains open in the background and the shell remains unresponsive. I have to manually kill Python from Task Manager to regain control of the shell. I have tried to use signals to tell everything to stop working when the main window is trying to close but the effect still remains. To clarify, if I try to close if after the addWidget
operations are done, everything closes fine.
Here is the relevant code:
from PySide6.QtCore import QCoreApplication, QObject, QThread, QSize, Signal, Slot
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QScrollArea, QVBoxLayout, QWidget
from PySide6.QtGui import QImage, QPixmap
from PIL import Image
import sys, os, time
PLACEHOLDER_PATH = "placeholder.png"
IMAGES_PATH = "gallery_images/"
class Gallery(QWidget):
tryingToClose = Signal()
def __init__(self, controller):
self.controller = controller
super().__init__()
self.stopWorking = False
self.minThumbWidth = 128
self.thumbs = []
self.thumbPlaceholderPath = PLACEHOLDER_PATH
self.thumbPlaceholderPixmap = QPixmap(self.thumbPlaceholderPath)
self.setupUI()
def closeEvent(self, event):
event.accept()
self.tryingToClose.emit() # let the window close
self.stopWorking = True
def clearThumbs(self):
self.thumbs = []
while self.thumbsLayout.count():
child = self.layout.takeAt(0)
if child.widget():
child.widget().deleteLater()
def generateThumbs(self, paths):
self.clearThumbs()
self.thumbs = [ImageThumb(path, self.minThumbWidth, self.thumbPlaceholderPixmap) for path in paths]
def updateThumbs(self):
print('Starting op2 (closing shouldn\'t work properly now)')
for i, thumb in enumerate(self.thumbs): #this is heavy operation #2
if self.stopWorking:
print('Aborting thumbs loading (closing will still be broken, but why?)')
return
self.thumbsLayout.addWidget(thumb)
#i add this sleep here to simulate having lots of images to process, in case you dont have a directory with a lot of images
# without this you have to click very fast to close the window to observe the effect
time.sleep(0.1)
QCoreApplication.processEvents()
print('Finished op2 (closing should work fine now)')
def setupUI(self):
self.mainFrame = QVBoxLayout()
self.scrollArea = QScrollArea()
self.scrollFrame = QWidget()
self.thumbsLayout = QHBoxLayout(self.scrollFrame)
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setWidget(self.scrollFrame)
self.scrollFrame.setLayout(self.thumbsLayout)
self.mainFrame.addWidget(self.scrollArea)
self.setLayout(self.mainFrame)
class ImageThumb(QLabel):
def __init__(self, path, size, placeholder):
super().__init__()
self.thumbSize = size
self.placeholder = placeholder.scaled(QSize(size, size))
self.path = path
self.setMinimumSize(1, 1)
self.setPixmap(self.placeholder)
def setup(self):
pImg = Image.open(self.path)
pImg.thumbnail((self.thumbSize, self.thumbSize))
pImg = pImg.convert("RGBA")
data = pImg.tobytes("raw","RGBA")
qImg = QImage(data, pImg.size[0], pImg.size[1], QImage.Format.Format_RGBA8888)
qImg = QPixmap.fromImage(qImg)
self.setPixmap(qImg)
class ImageWorker(QObject):
tookThumb = Signal(int, int)
finishedThumb = Signal(int, int)
finished = Signal(int)
def __init__(self, workerIndex, manager):
super().__init__()
self.index = workerIndex
self.manager = manager
@Slot()
def run(self):
try:
while self.manager.doesWorkExist():
thumbIndex, thumb = self.manager.takeThumb(self.index)
if thumbIndex == -1:
self.finished.emit(self.index)
return
self.tookThumb.emit(self.index, thumbIndex)
thumb.setup()
self.finishedThumb.emit(self.index, thumbIndex)
except Exception as e:
print(f'Worker {self.index} died to {e}')
self.finished.emit(self.index)
class ImageManager(QObject):
def __init__(self, ):
super().__init__()
self.thumbWidgets = []#thumbList
self.maxThreads = 4
self.thumbLocks = {}
self.threads = [QThread() for i in range(self.maxThreads)]
self.workers = [ImageWorker(i, self) for i in range(self.maxThreads)]
self.ignoreWork = False
def setThumbsList(self, newList):
self.thumbWidgets = newList
self.thumbLocks = {i: False for i in range(len(self.thumbWidgets))}
def doesWorkExist(self):
allThumbsLocked = all(self.thumbLocks.values())
return (not allThumbsLocked) and (not self.ignoreWork)
def stopWorking(self):
self.ignoreWork = True
for thread in self.threads:
thread.quit()
def takeThumb(self, workerIndex):
for thumbIndex, isLocked in self.thumbLocks.items():
if isLocked == False:
self.thumbLocks[thumbIndex] = True
return thumbIndex, self.thumbWidgets[thumbIndex]
return -1, None
def spawnThreads(self): #heavy operation #1 but on different threads
for index, thread in enumerate(self.threads):
worker = self.workers[index]
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(thread.quit)
thread.start()
class GalleryController(object):
def __init__(self):
print("Controller initialized")
self.app = QApplication([])
self.window = Gallery(self)
self.thumbsLoader = ImageManager()
self.window.tryingToClose.connect(self.thumbsLoader.stopWorking)
self.paths = []
print("Window initialized")
def loadPaths(self, newPaths):
self.paths = self.filterPaths(newPaths)
self.window.generateThumbs(self.paths)
self.thumbsLoader.setThumbsList(self.window.thumbs)
self.thumbsLoader.spawnThreads()
self.window.updateThumbs()
def isPicture(self, path):
pictureExts = ['png', 'jpg', 'jpeg']
entryName = os.path.basename(path)
ext = ('.' in entryName) and entryName[entryName.index('.') + 1:]
return ext and len(ext) > 0 and (ext in pictureExts)
def filterPaths(self, pathList):
return filter(self.isPicture, pathList)
def start(self):
self.window.show()
mainPath = IMAGES_PATH
paths = os.listdir(mainPath)
paths = [os.path.join(mainPath, path) for path in paths]
self.loadPaths(paths)
sys.exit(self.app.exec())
control = GalleryController()
control.start()
If you want to try to run this, try setting the IMAGES_PATH
to a directory with atleast 10-15 images, or increase the time.sleep
so you can observe the effect. I removed some unnecessary code so the images will clip on each other if you load many but just to clarify that's not the problem.
Edit: Problem solved! It was pointed out by @Demi-Lune. I accidentally was running the blocking code before the main event loop:
self.loadPaths(paths) #<--- long running
sys.exit(self.app.exec()) #<--- start of event loop
Simply changing it so that the long running task starts with a pushbutton fixes the issue.
Solution
The problem was I was executing the long-running code before the start of the main event loop.
self.loadPaths(paths) #<--- long running
sys.exit(self.app.exec()) #<--- start of main event loop
@Demi-Lune points out that one way to solve this is to start a QThread before the event loop that hosts the blocking code, but my use case is solved by connecting the blocking code to the click of a QPushButton.
Answered By - nooky
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.