Я создаю приложение, которое должно загружать 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