1

I created a simple app which uses PyQt5 to display an image inside a QLabel. The image can be static (ex: png, jpeg, bmp) or it can a gif.

The description of the example code below is as follows:

  • Class ImageDisplayer() is responsible for creating a QLabel which contains the desired image to be displayed. The update_image() method allows the shown image inside the QLabel to be updated to the desired new image. The QLabel window is shown on the desired screen (when using multiple monitors).

  • The main() method is an simple demo of a PyQt5 app that uses the ImageDisplayer class to display a desired image on the QLabel. In the real world end use case, this main Qt app would have other complex widgets/logic for interaction with the user (ex: asking user which image to display) and the QLabel displayed from the ImageDisplayer will always show the desired image in full screen on a secondary monitor. However, for simplicity I have not shown this in the example code below.

  • The test_image_sequence() method is a simple function which loops through various test images to debug/troubleshoot the development of the ImageDisplayer() class.

Problem: The ImageDisplayer class works as expected, however, gif images are not animating when I try to call the update_image() method from a separate thread. For example, when I run the test_image_sequence() method in a separate thread using QThreadPool, the static images are displayed as expected, but gif are not animated.

import os, sys, time, pathlib
from PyQt5 import QtWidgets
from PyQt5.QtCore import Qt, QRunnable, QThreadPool
from PyQt5.QtGui import QColor, QPixmap, QMovie
from PyQt5.QtWidgets import QApplication, QLabel, QWidget

CURRENT_PATH = str(pathlib.Path(__file__).parent.absolute())

def main():

    app = QtWidgets.QApplication(sys.argv) 

    my_image_window = ImageDisplayer(monitor_num=0,)

    # Method 1: When not using threads, the gif animates as expected 
    # my_image_window.update_image(CURRENT_PATH + r'\test_images\gif_image_2.gif')

    # Method 2: When using threads, gif does NOT animate 
    thread = QThreadPool.globalInstance()
    worker = Worker(test_image_sequence, my_image_window)
    thread.start(worker)

    app.exec_()


def test_image_sequence(widget):
    print('Will start testing seq. of images')
    time.sleep(1)

    images = []
    images.append(CURRENT_PATH + r'\test_images\static_image_1.png')
    images.append(CURRENT_PATH + r'\test_images\static_image_2.png')
    images.append(CURRENT_PATH + r'\test_images\gif_image_1.gif')
    images.append(CURRENT_PATH + r'\test_images\gif_image_2.gif')

    for i in images:
        print('Updating image to:', i)
        widget.update_image(pattern_file=i)
        time.sleep(3)

class ImageDisplayer():
    
    def __init__(self, monitor_num=0,):

        # Get instance of the current QApplication 
        self.app = QtWidgets.QApplication.instance() #https://stackoverflow.com/a/53387775/4988010 

        # Gather info on avaliable monitor and select the desired one 
        self.screen = self.app.screens()[monitor_num]
        self.screen_width = self.screen.size().width()
        self.screen_height = self.screen.size().height()

        # Init class attributes
        self.pattern_file = None                        # Set a default pattern if given during init 
        self.pixmap = None                              # Static image content
        self.pixmap_mv = None                           # Movie content

        self.scale_window = 2                           # For debugging: If not full screen the window will be scaled by half of screen size 

        # Define a constant color images when no image displayed 
        self.pixmap_blank = QPixmap(self.screen_width, self.screen_height)
        self.pixmap_blank.fill(QColor('green'))
        self.pixmap = self.pixmap_blank                                     # Default during init

        self.app_widget = None          # QLabel widget object
        self.setupGUI()                 # Setup and show the widget 

    def setupGUI(self):

        print('Setting up the QLabel')

        # Create QLabel object
        self.app_widget = QLabel()
        self.app_widget.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.app_widget.setStyleSheet('QLabel { background-color: green;}')
        self.app_widget.setCursor(Qt.BlankCursor)                       # A blank/invisible cursor, typically used when the cursor shape needs to be hidden.

        # Scale the widget to the size of the screen
        self.app_widget.setGeometry(0, 0, self.screen_width/self.scale_window , self.screen_height/self.scale_window)           # Set the size of Qlabel to size of the screen

        # Set a default pattern during init
        self.app_widget.setPixmap(self.pixmap)
        self.app_widget.show()

        # Move window to topleft corner of the selected screen 
        self.app_widget.windowHandle().setScreen(self.screen)
        self.app_widget.move(self.screen.geometry().topLeft())

    def update_image(self, pattern_file):
        self.pattern_file = pattern_file

        print('Pattern file: ', pattern_file)
        filename, file_extension = os.path.splitext(pattern_file)       # Get filename and extension https://stackoverflow.com/a/541394/4988010
        
        self.app_widget.clear()                     # Clear all existing content of the QLabel
        self.pixmap = QPixmap(self.pattern_file)

        if (file_extension == '.png') or (file_extension == '.jpg') or (file_extension == '.jpeg') or (file_extension == '.bmp'):
            # File is a static image
            # https://doc.qt.io/qt-5/qpixmap.html
            print('Image is a static')
            self.app_widget.setPixmap(self.pixmap)
        elif (file_extension == '.gif'):
            # File is a movie
            print('Image is movie')
            self.pixmap_mv = QMovie(self.pattern_file)

            # Connect the "finished() signal to movie_finished() slot"
            self.pixmap_mv.finished.connect(self.movie_finished)

            # Debugging text
            print('Movie is valid: ', self.pixmap_mv.isValid())
            print('loopCount: ', self.pixmap_mv.loopCount())
            print('frameCount: ', self.pixmap_mv.frameCount())
            print('Default speed: ', self.pixmap_mv.speed())

            self.app_widget.setMovie(self.pixmap_mv)
            self.pixmap_mv.start()
            
    def movie_finished(self):

        print('Movie finished')
        # After movie is finished, show blank screen            
        self.app_widget.setPixmap(self.pixmap_blank)

class Worker(QRunnable):

    def __init__(self, fn, *args, **kwargs):
        super(Worker, self).__init__()
        # Store constructor arguments (re-used for processing)
        self.fn = fn
        self.args = args
        self.kwargs = kwargs

    def run(self):
        self.fn(*self.args, **self.kwargs)

if __name__ == "__main__":
    main()
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
Zythyr
  • 1,142
  • 4
  • 20
  • 33

1 Answers1

2

Basic Qt rule: You should not modify the GUI directly from another thread since it is not thread-safe, instead you use the signals. For example in this case you can create a class that is the broker:

# ...
class Signaller(QObject):
    imageChanged = pyqtSignal(str)

    def update_image(self, pattern_file):
        self.imageChanged.emit(pattern_file)


def main():

    app = QtWidgets.QApplication(sys.argv)

    my_image_window = ImageDisplayer(monitor_num=0,)

    signaller = Signaller()
    signaller.imageChanged.connect(my_image_window.update_image)

    thread = QThreadPool.globalInstance()
    worker = Worker(test_image_sequence, signaller)
    thread.start(worker)

    app.exec_()
# ...
eyllanesc
  • 235,170
  • 19
  • 170
  • 241