Search code examples
pythonqtpysidepyside2

QThreadPool causing freezes when loading QImage in Qt5, when perfectly smooth in Qt4


I made a widget for loading lots of images in a deferred fashion by using threads. When using Python 2 (with PySide), the scrolling is super smooth with all the threads running. On Python 3 (with PySide2), it freezes every time you attempt to scroll.

I narrowed it down to the QtGui.QImage call within the thread.

class ImageLoader(QtCore.QRunnable):
    def __init__(self, item):
        self.item = item
        super(ImageLoader, self).__init__()

    def run(self):
        QtGui.QImage(self.item.path)  # shortened to the min reproducible example

Does anyone know why it would only be causing issues on newer versions of Qt, and perhaps how I can fix the issue?

Here's the full script, trimmed down as much as I was able to. Resizing the window or scrolling will be smooth in PySide, and in PySide2 it will freeze until all the threads have finished.

PATH_TO_LARGE_FILE = 'C:/large_image.png'  # Pick something >1MB to really show the slowness

import sys
from Qt import QtCore, QtGui, QtWidgets

class GridView(QtWidgets.QListView):
    def __init__(self):
        super(GridView, self).__init__()
        self.setViewMode(QtWidgets.QListView.IconMode)
        self.setModel(GridModel())

class GridModel(QtGui.QStandardItemModel):
    def __init__(self):
        super(GridModel, self).__init__()
        self.threadPool = QtCore.QThreadPool()

    def data(self, index, role=QtCore.Qt.UserRole):
        # Load image
        if role == QtCore.Qt.DecorationRole:
            item = self.itemFromIndex(index)
            worker = ImageLoader(item)
            self.threadPool.start(worker)
            return None

        # Set size of icons
        elif role == QtCore.Qt.SizeHintRole:
            return QtCore.QSize(64, 89)
        return super(GridModel, self).data(index, role)

class ImageLoader(QtCore.QRunnable):
    def __init__(self, item):
        self.item = item
        super(ImageLoader, self).__init__()

    def run(self):
        QtGui.QImage(self.item.path)

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    widget = GridView()
    for i in range(1000):
        item = QtGui.QStandardItem('test {}'.format(i))
        item.path = PATH_TO_LARGE_FILE
        widget.model().appendRow(item)
    widget.show()
    app.setActiveWindow(widget)
    app.exec_()

Edit (requested in comments): Link to full script

Edit (March 2023): I've found the issue appears to be specific to PySide. With the latest releases, PySide6 has the issue whereas PyQt6 does not. I've reported the bug and it's now been fixed in PySide6 6.6.0


Solution

  • Turns out it's a bug with PySide where QImage was missing the allow-thread flag. It is now fixed and from what I can see, due to be released in PySide6 6.5.1.

    Since I'm stuck with older versions for the foreseeable future, I found a workaround. By reading the raw bytes and sending the image format, then it's able to display the majority of images just as fast.

    Here's the code I used to make it work:

    class ImageLoader(QtCore.QRunnable):
        ...
        def run(self):
            ...
            supportedImageFormats = [f.data().decode() for f in QtGui.QImageReader.supportedImageFormats()]
    
            imagePath = self.item.path()
            imageExt = os.path.splitext(imagePath)[1][1:].lower()
          
            # Original way
            if PySide2.__version__ > '6.5.0':
                img = QtGui.QImage(imagePath)
                self.loadedImage.emit(img)
    
            # Workaround
            elif imageExt in supportedImageFormats:
                reader = QtGui.QImageReader(imagePath)
                with open(self.item.path(), 'rb') as f:
                    self.loadedBytes.emit(f.read(), reader.format())
        
            else:
                self.loadedImage.emit(None)
    
    class GridModel(QtGui.QStandardItemModel):
        ...
        def setItemPixmapFromBytes(self, item, imgBytes, format):
            """Load an image from bytes."""
            image = QtGui.QImage()
            image.loadFromData(imgBytes, aformat=format)
            self.setItemPixmap(item, image)
       
        def setItemPixmap(self, item, pixmap):
            if isinstance(pixmap, QtGui.QImage):
                pixmap = QtGui.QPixmap.fromImage(pixmap)
            ...
    

    The drawback is certain image formats may not work - in my case I found a bunch of TGA files were not loading. I found a post from 2017 showing an alternative way to load them, then converted to Python with the help of AI. To implement this, under the ImageLoader.run method, you would check the extension, and use self.loadedImage.emit(loadTga(imagePath)) if a TGA, otherwise proceed with sending the self.loadedBytes signal.

    def loadTga(filePath):
        img = QtGui.QImage()
        if not img.load(filePath):
    
            # open the file
            fsPicture = open(filePath, 'rb')
    
            if not fsPicture.is_open():
                img = QtGui.QImage(1, 1, QtGui.QImage.Format_RGB32)
                img.fill(QtCore.Qt.red)
                return img
    
            # some variables
            vui8Pixels = []
            ui32BpP = 0
            ui32Width = 0
            ui32Height = 0
    
            # read in the header
            ui8x18Header = [0]*19
            fsPicture.readinto(ui8x18Header)
    
            #get variables
            ui32IDLength = ui8x18Header[0]
            ui32PicType = ui8x18Header[2]
            ui32PaletteLength = ui8x18Header[6] * 0x100 + ui8x18Header[5]
            ui32Width = ui8x18Header[13] * 0x100 + ui8x18Header[12]
            ui32Height = ui8x18Header[15] * 0x100 + ui8x18Header[14]
            ui32BpP = ui8x18Header[16]
    
            # calculate some more information
            ui32Size = ui32Width * ui32Height * ui32BpP // 8
            bCompressed = ui32PicType == 9 or ui32PicType == 10
            vui8Pixels.resize(ui32Size)
    
            # jump to the data block
            fsPicture.seek(ui32IDLength + ui32PaletteLength, 1)
    
            if ui32PicType == 2 and (ui32BpP == 24 or ui32BpP == 32):
                fsPicture.readinto(vui8Pixels)
    
            # else if compressed 24 or 32 bit
            elif ui32PicType == 10 and (ui32BpP == 24 or ui32BpP == 32): # compressed
                tempChunkHeader = 0
                tempData = [0]*5
                tempByteIndex = 0
    
                while True:
                    fsPicture.readinto(tempChunkHeader)
    
                    if tempChunkHeader >> 7: # repeat count
                        # just use the first 7 bits
                        tempChunkHeader = (tempChunkHeader << 1) >> 1
    
                        fsPicture.readinto(tempData)
    
                        for i in range(0, tempChunkHeader+1):
                            vui8Pixels[tempByteIndex] = tempData[0]
                            vui8Pixels[tempByteIndex+1] = tempData[1]
                            vui8Pixels[tempByteIndex+2] = tempData[2]
                            if ui32BpP == 32:
                                vui8Pixels[tempByteIndex+3] = tempData[3]
                            tempByteIndex += 4
                    else: # data count
                        # just use the first 7 bits
                        tempChunkHeader = (tempChunkHeader << 1) >> 1
    
                        for i in range(0, tempChunkHeader+1):
                            fsPicture.readinto(tempData)
    
                            vui8Pixels[tempByteIndex] = tempData[0]
                            vui8Pixels[tempByteIndex+1] = tempData[1]
                            vui8Pixels[tempByteIndex+2] = tempData[2]
                            if ui32BpP == 32:
                                vui8Pixels[tempByteIndex+3] = tempData[3]
                            tempByteIndex += 4
    
                    if tempByteIndex >= ui32Size:
                        break
    
            # not useable format
            else:
                fsPicture.close()
                img = QtGui.QImage(1, 1, QtGui.QImage.Format_RGB32)
                img.fill(QtCore.Qt.red)
                return img
    
            fsPicture.close()
    
            img = QtGui.QImage(ui32Width, ui32Height, QtGui.QImage.Format_RGB888)
            pixelSize = 4 if ui32BpP == 32 else 3
    
            for x in range(ui32Width):
                for y in range(ui32Height):
                    valr = vui8Pixels[y * ui32Width * pixelSize + x * pixelSize + 2]
                    valg = vui8Pixels[y * ui32Width * pixelSize + x * pixelSize + 1]
                    valb = vui8Pixels[y * ui32Width * pixelSize + x * pixelSize]
    
                    value = QtGui.QColor(valr, valg, valb)
                    img.setPixelColor(x, y, value)
    
            img = img.mirrored()
        return img
    

    With all of this implemented, on the test texture library I'm using it on, both Python 2 and my workaround load in 17 seconds. The bugged way in Python 3 takes 34 seconds.