PyQt загружает изображения в фоновом режиме

Я создаю приложение, которое должно загружать 100-200 изображений (на самом деле ограничено только производительностью) и отображать их пользователю в виде галереи. Хорошей аналогией были бы просто изображения Google. В линейной реализации загрузка 100 изображений занимает примерно 3-6 секунд, в течение которых графический интерфейс «заморожен». Я попытался выгрузить это в фоновый поток, но быстро понял, что QPixmap не является потокобезопасным и может быть создан только в потоке графического интерфейса. Поэтому, естественно, я изучил многопроцессорность и обнаружил, что это гораздо более сложная проблема, чем я думал, из-за GIL, когда дело доходит до потоковой передачи, и отсутствия общего состояния, когда дело доходит до многопроцессорности …

Таким образом, я смог написать образец приложения, которое может загружать кучу QLabels и заполнять их в фоновом режиме, используя серию потоков, блокировок, очередей и процессов, которые я сколотил в единое целое. ImageManger класс. Это включает в себя создание виджетов в основном потоке графического интерфейса, загрузку QImage's в фоновом процессе, травление их, воссоздание QImage в фоновом потоке, а затем, наконец, используя сигнал, чтобы испустить этот новый QImage из фонового потока в поток графического интерфейса, где он преобразуется в QPixmap.

В целом я действительно очень удивлен, насколько хорошо это работает! Однако я публикуюсь в Code Review, потому что хочу проверить свои знания в области многопроцессорности, многопроцессорности и параллелизма в PyQt, что для меня все еще является трудной почвой. Мои вопросы: сделал ли я это наилучшим образом? Это слишком сложно и есть гораздо лучшее решение? Можно ли еще оптимизировать это?

Мои требования заключаются в том, чтобы поток графического интерфейса пользователя блокировался как можно меньше, и были заполнители для изображений, которые заполняются по мере их буферизации. Скорость — это проблема, но меня больше беспокоит сохранение гибкости графического интерфейса, и если это добавит немного времени к общему процессу, я смогу с этим жить (как и пользователи!).

Вот мой рабочий код. Извините за большое количество строк, я попытался максимально сократить его и разбил вещи на классы / методы, чтобы они (надеюсь) были наглядными:

import sys, os
from PyQt5 import QtGui, QtCore, QtWidgets
from multiprocessing import Process, Manager, Queue

class App(QtWidgets.QDialog):
    def __init__(self):
        super().__init__()
        self.img_loader = ImageManager(self)
        self.img_loader.image_loaded.connect(self.on_image_loaded)
        self.img_loader.start()
        
        self.img_widgets = {}
        
        # gui
        
        self.layout = QtWidgets.QVBoxLayout()
        self.setLayout(self.layout)
        
        self.scroll_area = QtWidgets.QScrollArea()
        self.scroll_area.setWidgetResizable(True)
        self.layout.addWidget(self.scroll_area)
        
        img_scroll_parent = QtWidgets.QWidget()
        self.scroll_area.setWidget(img_scroll_parent)
        self.img_layout = QtWidgets.QVBoxLayout()
        img_scroll_parent.setLayout(self.img_layout)
        
        go_btn = QtWidgets.QPushButton('go')
        go_btn.clicked.connect(self.start_loading_images)
        self.layout.addWidget(go_btn)
        
    def start_loading_images(self):
        load_dir = r'' # <---- path to directory with TONS of images!
        for fn in os.listdir(load_dir):
            path = os.path.join(load_dir, fn)
            if not os.path.isdir(path):
                if os.path.splitext(fn)[1] in ['.png', '.jpg']:
                    widget = QtWidgets.QLabel('...loading...')
                    widget.setScaledContents(True)
                    widget.setFixedHeight(100)
                    self.img_layout.addWidget(widget)
                    
                    self.img_widgets[fn] = widget
                    self.img_loader.load_image(path)
    
    def on_image_loaded(self, path, qimage):
        fn = os.path.split(path)[1]
        if fn in self.img_widgets:
            widget = self.img_widgets[fn]
            pixmap = QtGui.QPixmap(qimage)
            if pixmap.isNull():
                print(f'Error loading {fn}')
                return
            
            h = pixmap.height()
            w = pixmap.width()
            widget.setText('')
            widget.setFixedWidth(int((widget.height() * w) / h)) # this was the fastest way I could find to set an image on a label and maintain aspect ratio.
            widget.setPixmap(pixmap)
            
    def closeEvent(self, event):
        self.img_loader.shutdown()
        super().closeEvent(event)
    
class ImageManager(QtCore.QObject):
    image_loaded = QtCore.pyqtSignal(str, QtGui.QImage)
    
    def __init__(self, parent):
        super().__init__(parent)
        self.work_queue = Queue()
        self.done_queue = Queue()
        self.manager = Manager()
        self.img_list = self.manager.list()
        
        self.signal_thread = self.SignalEmitter(self, self.done_queue, self.img_list)
        self.signal_thread.imgLoaded.connect(self._emit_image)
        
        self.proc_count = 4
        self.bg_procs = []
        for _ in range(self.proc_count):
            bg_proc = Process(target=self._worker, args=(self.work_queue, self.done_queue, self.img_list,))
            self.bg_procs.append(bg_proc)
            
    def start(self):
        self.signal_thread.start()
        for p in self.bg_procs:
            p.start()
        
    def shutdown(self):
        # empty queues and insert poison pills
        while not self.work_queue.empty():
            self.work_queue.get()
        for _ in range(self.proc_count):
            self.work_queue.put(None)
        
        while not self.done_queue.empty():
            self.done_queue.get()
        self.done_queue.put(None)
        
        # ensure everything shuts down
        self.signal_thread.wait()
        for p in self.bg_procs:
            p.join()
        print('Image manager shutting down')
        
    def _emit_image(self, path, qimage):
        self.image_loaded.emit(path, qimage)
    
    def load_image(self, path):
        self.work_queue.put(path)
        print(f'Added {os.path.split(path)[1]} to queue.')
        
    class SignalEmitter(QtCore.QThread):
        imgLoaded = QtCore.pyqtSignal(str, QtGui.QImage)
        
        def __init__(self, parent, done_queue, img_list):
            super().__init__(parent)
            self.done_queue = done_queue
            self.img_list = img_list
            
        def run(self):
            while True:
                img_path = self.done_queue.get()
                if img_path == None:
                    break
                    
                while len(self.img_list) > 0:
                    img_data = self.img_list[0]
                    image = bytearray_to_qimage(img_data['bytes'])
                    self.imgLoaded.emit(img_data['path'], image)
                    self.img_list.pop(0)
                    
            print('Signal emitter shutting down.')
            
    @staticmethod
    def _worker(work_queue, done_queue, list):
        while True:
            path = work_queue.get()
            if path == None:
                break
            
            qimg = QtGui.QImage(path)
            img_dict = {
                'bytes': qimage_to_bytearray(qimg),
                'path': path
            }
            
            list.append(img_dict)
            done_queue.put(path)
            
        print("BG Proc shutting down.")
        return
        
def qimage_to_bytearray(qimage):
    byte_array = QtCore.QByteArray()
    stream = QtCore.QDataStream(byte_array, QtCore.QIODevice.WriteOnly)
    stream << qimage
    return byte_array

def bytearray_to_qimage(byte_array):
    img = QtGui.QImage()
    stream = QtCore.QDataStream(byte_array, QtCore.QIODevice.ReadOnly)
    stream >> img
    return img

0

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *