PyQt5 Plotly viewer creates multiple save file dialogs - python

I've created a lightweight browser to view plotly .html files within a PyQt5 application instead of in the default browser, using QWebEngineView based on other questions such as this: Using a local file in html for a PyQt5 webengine
The viewer works, but when multiple windows are open with several plots, attempting to save one of the plots as .png file causes several save file dialogs to open (one for every window that has been open since the program started running).
I tried debugging this, after the download request it seems to jump to sys.exit(app.exec_()), then back to the download request again. Although several dialogs are open, only one plot is actually saved.
Is there a way to ensure only one dialog is created?
To reproduce, run the following code and click plot the button 2 or more times, creating several windows. Use the plotly "download plot as png" option and after saving the plot, one or more additional save file dialogs are presented.
import os
import sys
from pathlib import Path
import plotly
import plotly.express as px
from PyQt5 import QtCore, QtWidgets
from PyQt5 import QtWebEngineWidgets, QtGui
user_profile = Path(os.environ.get("USERPROFILE"))
APP_DATA_FOLDER = user_profile / "AppData" / "Local" / "program"
APP_DATA_FOLDER.mkdir(parents=True, exist_ok=True)
class PlotlyViewer(QtWebEngineWidgets.QWebEngineView):
"""A lightweight browser used to view Plotly
figures without relying on an external browser.
"""
def __init__(
self, fig, title="Plot Viewer", count=0, download_directory=None
):
super().__init__()
self.windows = []
# Create a temporary html file containing the plot
self.file_path = str(APP_DATA_FOLDER / f"temp{count}.html")
plotly.offline.plot(
fig, filename=self.file_path, auto_open=False,
)
# Open the html file with the PlotlyViewer
self.load(QtCore.QUrl.fromLocalFile(self.file_path))
self.setWindowTitle(title)
self.resize(1000, 600)
# When a downloadRequest is received, run the download_file method
self.page().profile().downloadRequested.connect(self.download_file)
def closeEvent(self, event):
# When the plot is closed, delete the temporary file
os.remove(self.file_path)
#QtCore.pyqtSlot(QtWebEngineWidgets.QWebEngineDownloadItem)
def download_file(self, download):
# Get a save_file_dialog... For some reason this happens twice!
plot_path, _ = QtWidgets.QFileDialog.getSaveFileName(
None, "Save Plot As...", str(user_profile), "Image (*.png)"
)
if plot_path:
download.setPath(plot_path)
download.accept()
#staticmethod
def save_file_dialog(export_dir):
file_path, _ = QtWidgets.QFileDialog.getSaveFileName(
None, "Save Plot As...", export_dir, "Image (*.png)"
)
return file_path
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.setFixedSize(150, 100)
MainWindow.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor))
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.btn_plot = QtWidgets.QPushButton(self.centralwidget)
self.btn_plot.setGeometry(QtCore.QRect(0, 0, 70, 23))
self.btn_plot.setObjectName("btn_plot")
self.btn_plot.setText("plot")
self.connect_slots()
def connect_slots(self):
self.btn_plot.clicked.connect(self.create_plot)
def create_plot(self):
fig = px.scatter(x=[0, 1, 2, 3, 4], y=[0, 1, 4, 9, 16])
browser_title = "a window title"
plot_window = PlotlyViewer(fig, browser_title, download_directory=user_profile)
plot_window.windows.append(plot_window)
plot_window.show()
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Ui_MainWindow()
ui.setupUi(MainWindow)
MainWindow.show()
sys.exit(app.exec_())

By default, all QWebEnginePages share the same QWebEngineProfile, meaning that self.page().profile() will always return the same profile object.
Qt connections are always cumulative, even for the same target slot/function: connecting a signal to the same function twice results in calling that function twice for every time the signal is emitted.
Since you're connecting to the signal of the same profile every time a new PlotlyViewer instance is created, download_file will be called for each instance every time a download is requested.
You have three possibilities:
connect the signal just once using the defaultProfile(), and externally from the PlotlyViewer class;
create a new standalone profile (similar to the "private mode") for each view:
self.profile = QWebEngineProfile()
self.setPage(QWebEnginePage(profile))
self.profile.downloadRequested.connect(self.download_file)
check that the page() of the download request belongs to the page of the view:
def download_file(self, download):
if download.page() != self.page():
return
# ...
Further important notes:
most widgets override event handlers, and it's always good practice to call the base implementation (unless you really know what you're doing); you must call super().closeEvent(event) in the closeEvent override;
since you probably don't intend to reuse closed views, you should always delete them, otherwise their resources will unnecessarily occupy memory (and a lot); add self.setAttribute(Qt.WA_DeleteOnClose) in the __init__, or call self.deleteLater() in closeEvent();
self.windows is an instance attribute, adding the plot_window instance to it is completely useless, since it will always contain one object (the instance itself); if you want to keep track of all existing windows you should create the list as an attribute of the parent object (ie. the main window) or as a class attribute (for PlotlyViewer); also, considering the point above, you should delete the reference whenever the view is closed and destroyed;
you shall not edit pyuic generated files; unless you did this for the sake of the example, be aware that it's considered bad practice, and you should instead follow the official guidelines about using Designer;

Related

How to call other functions while update image(opencv) on a label Pyqt5?

I want to update the label with images but when I start to update the label, nothing works until the updating is done for example I want to pause updating or closing the window. It worked perfect when I used tkinter but it doesn't work same method in Pyqt5. Here is the code I have
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtWidgets import QFileDialog, QDialog, QMainWindow,QMessageBox,QGroupBox,QSlider,QPushButton,QRadioButton,QLabel,QCheckBox,QFrame,QWidget,QTabWidget,QProgressBar,QTextBrowser,QTableWidget
from PyQt5.QtCore import QCoreApplication, Qt, QSize,QThread, QRect,QSize
from PyQt5.QtGui import QIcon, QFont,QPixmap,QIcon
import cv2
import os
import glob
class Ui_Form(object):
def setupUi(self, Form):
Form.setObjectName("Form")
Form.resize(800, 800)
self.label = QtWidgets.QLabel(Form)
self.label.setGeometry(QtCore.QRect(165, 125, 61, 16))
self.label.setObjectName("label")
self.btn1 = QPushButton(Form)
self.image_frame = QtWidgets.QLabel(Form)
self.btn1.setGeometry(QRect(350, 200, 111, 31))
self.btn1.clicked.connect(self.load_images_from_folder)
self.retranslateUi(Form)
QtCore.QMetaObject.connectSlotsByName(Form)
def retranslateUi(self, Form):
_translate = QtCore.QCoreApplication.translate
Form.setWindowTitle(_translate("Form", "Form"))
self.btn1.setText(_translate("Form","Btn"))
def load_images_from_folder(self):
for filename in glob.glob('path/*.jpg'):
img = cv2.imread(filename, cv2.IMREAD_UNCHANGED)
img = cv2.resize(img,(200,200))
print(filename)
self.showimg(img)
def showimg(self,img):
self.image = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
self.image = QtGui.QImage(self.image.data, self.image.shape[1], self.image.shape[0], QtGui.QImage.Format_RGB888).rgbSwapped()
self.image_frame.setPixmap(QtGui.QPixmap.fromImage(self.image))
self.image_frame.setGeometry(QRect(70, 310, 461, 441))
Form.update()
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
Form = QtWidgets.QWidget()
ui = Ui_Form()
ui.setupUi(Form)
Form.show()
sys.exit(app.exec_())
After I clicked Btn label updates but I can't click Btn again until the updating is finished
In UI environments, control has to be returned as quickly as possible to the main thread. If you have lots of image processing, this means that the function that loads the image (which also calls the function that sets the image) will not release control to the main thread until it's finished.
If you don't, the result is that the UI will be completely frozen (no updates, no user input) until that function returns.
The solution is to use threads, but you also need to consider that UI elements are involved, and no access is allowed to UI elements from external threads. So, the actual solution is to use QThreads and custom signals: Qt signals are thread safe, and they are queued whenever two objects belonging to different threads are trying to communicate.
In this specific case, the thread will be a QThread with a custom signal (which has to be emitted whenever a new image is ready):
class ImageLoader(QtCore.QThread):
imageReady = QtCore.pyqtSignal(object)
def __init__(self):
super().__init__()
self.images = []
def run(self):
while True:
while self.images:
img = cv2.imread(self.images.pop(), cv2.IMREAD_UNCHANGED)
img = cv2.resize(img,(200,200))
self.imageReady.emit(img)
def loadImages(self, imageList):
# if you want to *clear* the current queue
self.images[:] = imageList
# otherwise, just append:
# self.images.extend(imageList)
The implementation then requires to create the thread instance and add the images to the queue in load_images_from_folder:
class Ui_Form(object):
def setupUi(self, Form):
# ...
self.imageLoader = ImageLoader()
self.imageLoader.imageReady.connect(self.showimg)
self.imageLoader.start()
def load_images_from_folder(self):
self.imageLoader.loadImages(glob.glob('path/*.jpg'))
Important notes:
editing files generated with pyuic is considered bad practice, as it almost always leads to unexpected behavior and bugs that are difficult to track; the comment in the header of those files is pretty clear: you should not edit them; instead, create those files, leave them as they are, and then import them in your main script, as explained in the official guidelines about using Designer;
accessing Form (Form.update()), which is fundamentally a global variable, is another bad practice; just like editing pyuic files, it's something that should never be done unless you really know what you're doing (and if you do know, you won't probably do it at all); in any case, setting the pixmap on a label automatically schedules an update, so that function call is completely useless since we're properly using threading, which allows the main event loop to request updates on the UI;
setting fixed geometries is yet another practice usually discouraged; learn how to use layout managers and how to use them in Designer instead; if you want the QLabel to have a fixed size, use setFixedSize();

How to reference & change a PyQt5 window object from another window class or from an extra .py file such as init_events_ui.py

Is it possible to add another .py file (via import) and use it as an initialisation and event handler for a PyQt5 Multiwindow project other window & main .py files?
I have a project that consists of the following files:
main.py - the main app which imports the Ui...py files below & the ui_init_events.py file also
main.py - the main program
Ui_main_window_ui.py - a complied Qt UI to display the main window
Ui_frmConsoleLog_ui.py - a complied Qt UI to display a window with a textEdit & comboBox objects
ui_init_events.py - I want this file to have functions for setting each windows Ui object fields as well as contain the Ui windows object events such as btn.clicked.connect(self.SomeotherFunc)
I have found both these posts helpful but am stuck as I do not know how to reference objects other than the self object and can't find where this is explained. See: PyQt: Modifying Widget Object from another Function
Multiple Windows in PyQt4
In the 2nd post they are using multi-windows (via the QDialog) object also, however they are only using a single .py file. I am using Visual Studio Code & have built the Ui files then complied them. They are works in progress so I expect to make more changes meaning they wil be overwritten so I do not want to edit these files.
I cannot work out how to reference and change the properties in another window for initialisation purposes. The thread is:
Here is the main bits. Currently I call a function to return some data using self.combobox.additems (see #1) but I think calling this over and over again from main.py somewhat decreases code readability.
(#2) Therefore I would like advice how to move all the initialisation parts of an existing PyQt window (being an imported .py file used to generate the window + controls) into a separate .py file (eg named ui_init_events.py).
However in trying & researching this I do not know nor can find an example how to reference the objects using their full hierarchical naming convention. I have tried Application. QWindow., etc. I only know how to use self.... and it isn't working (of course as that would be the referring to the function itself I understand that its being called in, not the PyQt window I'm wanting to reference). See (#3) for what is not working.
Any ideas please on resources available to assist with understanding how PyQt labels its objects from the root of the object tree is appreciated as well as an example of how to set the comboBox_Selector in the Ui_frmConsoleLog window from app.py or another .py file other than from the Ui_frmConsoleLog_ui.py or within the class definition under --init--(self, parent=None): ?
The desired file structure is like this:
+-- main.py (or app.py)
|
+--- Ui_main_window_ui.py (has setupui & adds objects for main window)
|
+--- Ui_main_window_init.py (has init code for Ui_main_window_ui.py)
|
+--- Ui_main_window_events.py (has event code for Ui_main_window_ui.py)
|
+--- Ui_frmConsoleLog_ui.py (has setupui & adds objects for 2nd window)
|
+--- Ui_frmConsoleLog_init.py (has init code for Ui_frmConsoleLog_ui.py)
|
+--- Ui_frmConsoleLog_events.py (has event code for Ui_frmConsoleLog_ui.py)
main.py file contents
# Default imports
import sys, os, io, sched, time
import ui_resources_rc
from PyQt5.QtCore import QObject, QThread, pyqtSignal
from configparser import ConfigParser
...a portion of code here uses a ini config file for retrieving/saving some settings of the UI & I want to set the properties for each object accordingly...
from PyQt5.QtWidgets import (QApplication, QDialog, QMainWindow, QMessageBox, QWidget)
from PyQt5.uic import loadUi
from Ui_main_window_ui import Ui_MainWindow
from Ui_frmConsoleLog_ui import Ui_frmConsoleLog
from ui_init_events import (funcUpdateFormCombos)
...todo... open a network connection to the system (its an inverter)
# This window will be used for filtering network poll event types
class ConsoleLogWindow(QWidget, Ui_frmConsoleLog):
def __init__(self):
super().__init__()
self.setWindowTitle('Console Log')
class AppWindow(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.win = None # No Console Log window visible yet.
self.setupUi(self)
#(1) Here is where I will have to call this a lot of times to
# populate the vaious window objects which I dont want to do
self.comboBox_Selector.addItems(funcUpdateFormCombos())
self.action_Console.triggered.connect(self.OpenConsoleLogWindow)
self.action_About.triggered.connect(self.about)
# Populate the controls as per the config file
#initialise() <-- (2) Here is where I want to call a
# function in ui_init_events.py to setup
# the form's objects as created in QtDesigner
# & exported as a .py file. I want to continue
# to edit each PyQt window later so I dont want
# to add to the Ui_... files themselves but in
# main.py call a function that has the code to
# set the object fields like checked, combobox
# list contents, etc.
def OpenConsoleLogWindow(self, checked):
if self.win is None:
self.win = ConsoleLogWindow()
self.win.show()
else:
self.win.close() # Close window.
self.win = None # Discard reference.
if __name__ == "__main__":
app = QApplication(sys.argv)
win = AppWindow()
win.show()
print('HERE')
sys.exit(app.exec())
ui_init_events.py file contents (this is the file I want to house all window object setup without editing the PyQt UI converted .ui to .py files such as Ui_main_window_u.py & Ui_frmConsoleLog_ui.py which I don't want to edit at all.
# Contains the initialisation of widgets fields
def funcUpdateFormCombos():
LIST = ['Amps', 'Volts', 'Watts']
LIST.sort()
return tuple(LIST)
# Would prefer to have a single file per Ui_...py file that houses
# all the events that get triggered for each Ui windows.
# How is this done? eg: <not working> (3)
def initialise()
frmConsoleLog.self.comboBox_Selector.addItems('Watts','Amps','Volts')
Contents of Ui_frmConsoleLog_ui.py (has the combobox I want to populate)
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_frmConsoleLog(object):
def setupUi(self, frmConsoleLog):
frmConsoleLog.setObjectName("frmConsoleLog")
frmConsoleLog.resize(640, 468)
frmConsoleLog.setToolTip("")
frmConsoleLog.setStatusTip("")
frmConsoleLog.setAccessibleName("")
self.horizontalLayout = QtWidgets.QHBoxLayout(frmConsoleLog)
self.horizontalLayout.setContentsMargins(0, 1, 0, 0)
self.horizontalLayout.setObjectName("horizontalLayout")
self.comboBox_Selector = QtWidgets.QComboBox(frmConsoleLog)
self.comboBox_Selector.setObjectName("comboBox_Selector")
self.horizontalLayout.addWidget(self.comboBox_Selector)
self.textEdit_ConsoleLog = QtWidgets.QTextEdit(frmConsoleLog)
self.textEdit_ConsoleLog.setEnabled(True)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.textEdit_ConsoleLog.sizePolicy().hasHeightForWidth())
self.textEdit_ConsoleLog.setSizePolicy(sizePolicy)
self.textEdit_ConsoleLog.setObjectName("textEdit_ConsoleLog")
self.horizontalLayout.addWidget(self.textEdit_ConsoleLog)
self.retranslateUi(frmConsoleLog)
QtCore.QMetaObject.connectSlotsByName(frmConsoleLog)
def retranslateUi(self, frmConsoleLog):
_translate = QtCore.QCoreApplication.translate
frmConsoleLog.setWindowTitle(_translate("frmConsoleLog", "Console Log"))
Worked it out, thanks to an answer in my previous post (that the poster removed?), here's the snippet that works and allows your functions in another .py file to set MainWindow pyQt object control properties in the parent main.py file for a much tidier project. I honestly don't care that professional devs think that is not good practice (if they do they are very closed minded).
In app.py (or main.py - I had two files and was playing with names)
class AppWindow(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.win = None # No Console Log window visible yet.
self.setupUi(self)
from init_Ui_main_window import (initMainWindow)
initMainWindow(self)
...
In init_Ui_main_window.py:
def initMainWindow(window):
funcLoadConfig()
# Populate the controls in the main window
window.line_Settings_Simple_Default_Selection.setText(str(SETTINGS_SIMPLE_DEFAULT_SELECTOR))
Note: SETTINGS_SIMPLE_DEFAULT_SELECTOR is a globally defined static variable that wont ever be changed as its read from a config ini file. The user will be able to change the line value if they want to override the default, but it's nice to have a default value populated.
Was that easy as when initMainWindow(self) is called self from the previous object context is passed across to the function and set in function variable window, so to reference the main windows objects its just a case of referencing window.[object name as one would in main.py]

QMovie does not start if started from a thread

fellow developers! I have a question regarding Qt and multithreading.
=== SHORT VERSION ===========================================
Is it possible to do what I want with Qt? That is (1) show a loader; (2) download a gif in the background; (3) show the downloaded gif in the main window after it has been downloaded?
=== LONG VERSION ============================================
I have this idea that when I push a button, it:
shows a loader;
activates a thread that downloads a gif from the web;
replaces the default gif hidden in the main window with the downloaded one and shows it
hides the loader;
The problem that I'm experiencing is that when the downloaded gif is show, it is "frozen" or just the first frame is shown. Other than that everything is fine. However, it need the gif to be animated after the loader is hidden.
It is mentioned here that
So the Qt event loop is responsible for executing your code in response to various things that happen in your program, but while it is executing your code, it can't do anything else.
I believe this is the heart of the problem. It is also suggested that
It is recommended to use a QThread with Qt rather than a Python thread, but a Python thread will work fine if you don't ever need to communicate back to the main thread from your function.
Since my thread replaced the contents of the default gif, I believe that it does communicates back :(
Please find my code below :)
import sys
from time import sleep
from PyQt5.QtCore import QThread
from PyQt5.QtGui import QMovie
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel
class ChangeGif(QThread):
"""
A worker that:
1 - waits for 1 second;
2 - changes the content of the default gif;
3 - shows the default gif with new contents and hide the loader.
"""
def __init__(self, all_widgets):
QThread.__init__(self)
self.all = all_widgets
def run(self):
sleep(1)
self.new_gif_file = QMovie("files/new.gif") # This is the "right" gif that freezes :(
self.new_gif_file.start()
self.all.gif_label.setMovie(self.new_gif_file)
self.all.gif_label.show()
self.all.loader_label.hide()
class MainWindow(QWidget):
"""
Main window that shows a button. If you push the button, the following happens:
1 - a loader is shown;
2 - a thread in background is started;
3 - the thread changes the contents of the default gif;
4 - when the gif is replaced, the loader disappears and the default gif with the new content is shown
"""
def __init__(self):
super(MainWindow, self).__init__()
# BUTTON
self.button = QPushButton("Push me", self)
self.button.setFixedSize(100, 50)
self.button.clicked.connect(lambda: self.change_gif())
# DEFAULT GIF
self.gif_file = QMovie("files/default.gif") # This is the "wrong" gif
self.gif_file.start()
self.gif_label = QLabel(self)
self.gif_label.setMovie(self.gif_file)
self.gif_label.move(self.button.width(), 0)
self.gif_label.hide()
# LOADER
self.loader_file = QMovie("files/loader.gif")
self.loader_file.start()
self.loader_label = QLabel(self)
self.loader_label.setMovie(self.loader_file)
self.loader_label.move(self.button.width(), 0)
self.loader_label.hide()
# WINDOW SETTINGS
self.setFixedSize(500, 500)
self.show()
def change_gif(self):
self.loader_label.show()
self.worker = ChangeGif(self)
self.worker.start()
app = QApplication(sys.argv)
window = MainWindow()
app.exec_()
Other than the fact that no UI elements should be ever accessed or created outside the main Qt thread, this is valid also for UI elements that use objects created in other threads.
In your specific case, this not only means that you cannot set the movie in the separate thread, but you cannot create the QMovie there either.
In the following example, I'm opening a local file, and use a signal to send the data to the main thread. From there, I create a QBuffer to store the data in a IO device that can be used by QMovie. Note that both the buffer and the movie must have a persistent reference, otherwise they will be garbage collected as soon as the function returns.
from PyQt5.QtCore import QThread, QByteArray, QBuffer
class ChangeGif(QThread):
dataLoaded = pyqtSignal(QByteArray)
def __init__(self, all_widgets):
QThread.__init__(self)
self.all = all_widgets
def run(self):
sleep(1)
f = QFile('new.gif')
f.open(f.ReadOnly)
self.dataLoaded.emit(f.readAll())
f.close()
class MainWindow(QWidget):
# ...
def change_gif(self):
self.loader_label.show()
self.worker = ChangeGif(self)
self.worker.dataLoaded.connect(self.applyNewGif)
self.worker.start()
def applyNewGif(self, data):
# a persistent reference must be kept for both the buffer and the movie,
# otherwise they will be garbage collected, causing the program to
# freeze or crash
self.buffer = QBuffer()
self.buffer.setData(data)
self.newGif = QMovie()
self.newGif.setCacheMode(self.newGif.CacheAll)
self.newGif.setDevice(self.buffer)
self.gif_label.setMovie(self.newGif)
self.newGif.start()
self.gif_label.show()
self.loader_label.hide()
Note that the example above is just for explanation purposes, as the downloading process can be done using QtNetwork modules, which work asynchronously and provide simple signals and slots to download remote data:
from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
class MainWindow(QWidget):
def __init__(self):
# ...
self.downloader = QNetworkAccessManager()
def change_gif(self):
self.loader_label.show()
url = QUrl('https://path.to/animation.gif')
self.device = self.downloader.get(QNetworkRequest(url))
self.device.finished.connect(self.applyNewGif)
def applyNewGif(self):
self.loader_label.hide()
self.newGif = QMovie()
self.newGif.setDevice(self.device)
self.gif_label.setMovie(self.newGif)
self.newGif.start()
self.gif_label.show()
The main rule working with Qt is that only one main thread is responsible for manipulating UI widgets. It is often referred to as GUI thread. You should never try to access widgets from another threads. For example, Qt timers won't start activated from another thread and Qt would print warning in the runtime console. In your case - if you put all the manipulation with the QMovie in the GUI Thread, most probably everything will work as expected.
What to do? Use the signals and slots - they are also designed to work between threads.
What your code should do:
Show a loader form the main thread.
Activate a thread that downloads a gif from the web.
After the download is ready - emit a signal and capture it in main GUI thread'. Remember to use Qt::QueuedConnection` when connecting the signal and the slot, though it will be used automatically in some cases.
In the receiving slot replace the default gif in main window with the downloaded one and show it and hide the loader.
You'll have to use some synchronization mechanism to avoid data-racing. A mutex will be enough. Or you could pass the data as the signal-slot parameter, but in case of a gif it probably would be too big.

QtWidget.grab() consumes gradually memory when called in the loop

I have PyQt5 GUI, where I load some data, which I consequently plot into graphs
to do not upload whole application I created just example where is used what crashes ...
once, I need to save "GUI-visible" graphs as pictures (for later usage), so I call:
grabbed = some_graphically_visible_widget.grab()
and
grabbed.save("My_name.png")
these two methods are called in the loop up to 350 times and during the
loop, python saves the grabbed "object" somewhere because as memory_profiler
showed and I found out, each .grab() cycle memory consumption increases ~ 1.5MB
also, I tried multiple variations of using:
del grabbed
in the end of loop, or playing with
gc.collect()
But nothing helped and calling this cycle always eats "it's part".
Below is full example application, which is fully working once
the PyQt5 and pyqtgraph modules are provided to be "imported":
import sys
import os
from random import randint
from PyQt5 import QtCore, QtWidgets, QtGui
from PyQt5.QtWidgets import QShortcut, QMessageBox
from PyQt5.QtGui import QKeySequence
import pyqtgraph
app = QtWidgets.QApplication(sys.argv)
class Ui_MainWindow(object):
def __init__(self):
self.graph_list = []
def setupUi(self, MainWindow):
MainWindow.setObjectName("Example")
MainWindow.resize(750, 750)
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
MainWindow.setCentralWidget(self.centralwidget)
self.tabWidget = QtWidgets.QTabWidget(self.centralwidget)
self.tabWidget.setGeometry(QtCore.QRect(5, 5, 740, 740))
self.tabWidget.setObjectName("tabWidget")
self.shortcut_CtrlL = QShortcut(QKeySequence('Ctrl+E'),self.centralwidget)
self.shortcut_CtrlL.activated.connect(self.doExport)
progress = QtWidgets.QProgressDialog("Creating enough graphs to simulate my case ... (350) ", None, 0, 350, self.centralwidget)
progress.setWindowTitle("...")
progress.show()
"Typical amount of graphs in application"
for tab_idx in range(350):
font = QtGui.QFont()
font.setPixelSize(15)
tab = QtWidgets.QWidget()
graph = pyqtgraph.PlotWidget(tab)
self.graph_list.append(graph)
graph.setGeometry(QtCore.QRect(5, 5, 740, 740))
graph.addLegend(size=None, offset=(370, 35))
x = []
y = []
min = []
max = []
for num in range(10):
x.append(num)
y.append(randint(0, 10))
min.append(0)
max.append(10)
graph.plot(x, y, symbol='o', symbolPen='b', symbolBrush='b', name = "List of randomized values")
graph.plot(x, min, pen=pyqtgraph.mkPen('r', width=3, style=QtCore.Qt.DashLine))
graph.plot(x, max, pen=pyqtgraph.mkPen('r', width=3, style=QtCore.Qt.DashLine))
graph.showGrid(x=True)
graph.showGrid(y=True)
graph.setTitle(str(graph))
self.tabWidget.addTab(tab, str(tab_idx))
progress.setValue(tab_idx)
app.processEvents()
msgBox = QMessageBox()
msgBox.setIcon(QMessageBox.Information)
str_to_show = "Once you see GUI, press CTRL+E and watch memory consumption in task manager"
msgBox.setText(str_to_show)
msgBox.setWindowTitle("Information")
msgBox.setStandardButtons(QMessageBox.Ok)
msgBox.exec()
progress.close()
def doExport(self):
iterations = 0
progress = QtWidgets.QProgressDialog("Doing .grab() and .save() iterations \nnow you may watch increase RAM consumption - you must open taskmgr", None, 0, 350, self.centralwidget)
progress.setWindowTitle("...")
progress.show()
for graph in self.graph_list:
iterations += 1
grabbed = graph.grab()
grabbed.save("Dont_worry_I_will_be_multiple_times_rewritten.png")
progress.setValue(iterations)
app.processEvents()
progress.close()
msgBox = QMessageBox()
msgBox.setIcon(QMessageBox.Information)
str_to_show = str(iterations) + ' graphs was grabbed and converted into .png and \n python\'s RAM consumption had to increase ...'
msgBox.setText(str_to_show)
msgBox.setWindowTitle("Information")
msgBox.setStandardButtons(QMessageBox.Ok)
msgBox.exec()
if __name__ == "__main__":
MainWindow = QtWidgets.QMainWindow()
ui = Ui_MainWindow()
ui.setupUi(MainWindow)
MainWindow.show()
sys.exit(app.exec_())
This has nothing to do with grab (at least, not directly), but with QGraphicsView caching (pyqtgraph PlotWidgets are actually QGraphicsView subclasses).
In fact, if you comment the whole grabbing and use self.tabWidget.setCurrentIndex(iterations) instead, you'll see the memory spiking anyway, and that's because grab() obviously causes the widget to be painted and, therefore, create the graphics view cache.
The solution to your issue is to disable caching for each graph:
def setupUi(self, MainWindow):
# ...
for tab_idx in range(350):
# ...
graph = pyqtgraph.PlotWidget(tab)
self.graph_list.append(graph)
graph.setCacheMode(graph.CacheNone)
The real question is: do you really need to add so many graphs? If you only need to grab each graph, use a single plot widget, and set each plot/grab in a for cycle. Honestly, I don't understand what is the benefit of showing so many graphs all at once: 350 QGraphicsViews are a lot, and I sincerely doubt you really need the user to access them all at once, especially considering that using QTabWidget would make them hard to access.
Also:
You're creating a tab QWidget for each tab, but you're just adding a single graphics view and without any layout manager; this results in a problem when resizing the main window (the plot widgets don't adjust their size) and is anyway unnecessary: just add the plot widget to the QTabWidget: self.tabWidget.addTab(graph, str(tab_idx))
NEVER modify the files generated by pyuic, nor try to mimic their behavior; if you are building the UI from code, just subclass the widget that will act as a container/window (QMainWindow, in your case), add child widgets to it (using a central widget for main windows) and implement all methods from that class, otherwise follow the official guidelines about using Designer.

Python Script with QGis - Python.exe Stopped Working

I purchased this book called Building Mapping Applications with QGIS and I am trying to work through one of the exercises. There is one script that I try to run that crashes python, generating the error message "python.exe has stopped working".
import sys
import os
from qgis.core import *
from qgis.gui import *
from PyQt4.QtGui import *
from PyQt4.QtCore import Qt
#############################################################################
class MapViewer(QMainWindow):
def __init__(self, shapefile):
QMainWindow.__init__(self)
self.setWindowTitle("Map Viewer")
canvas = QgsMapCanvas()
canvas.useImageToRender(False)
canvas.setCanvasColor(Qt.white)
canvas.show()
layer = QgsVectorLayer(shapefile, "layer1", "ogr")
if not layer.isValid():
raise IOError("Invalid shapefile")
QgsMapLayerRegistry.instance().addMapLayer(layer)
canvas.setExtent(layer.extent())
canvas.setLayerSet([QgsMapCanvasLayer(layer)])
layout = QVBoxLayout()
layout.addWidget(canvas)
contents = QWidget()
contents.setLayout(layout)
self.setCentralWidget(contents)
#############################################################################
def main():
""" Our main program.
"""
QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'], True)
QgsApplication.initQgis()
app = QApplication(sys.argv)
viewer = MapViewer("C:/folder/shapefile.shp")
viewer.show()
app.exec_()
QgsApplication.exitQgis()
#############################################################################
if __name__ == "__main__":
main()
I don't know a whole lot about Python with QGIS so I'm not too sure what is causing python to crash. I am positive that all of the modules are importing correctly because if I define my paths and then import the modules in the script using the OSGeo4W Shell, there are no error messages.
This is how my paths are defined:
SET OSGEO4W_ROOT=C:\OSGeo4W64
SET QGIS_PREFIX=%OSGEO4W_ROOT%\apps\qgis
SET PATH=%PATH%;%QGIS_PREFIX%\bin
SET PYTHONPATH=%QGIS_PREFIX%\python;%PYTHONPATH%
Given all of this, I think there has to be something wrong in the script. However, when I check the script using http://pep8online.com/ there are no errors that I can fix that will result in python not crashing.
Note that I have tried I have tried SET PATH=%QGIS_PREFIX%\bin;%PATH% instead of SET PATH=%PATH%;%QGIS_PREFIX%\bin with no success.
I was fortunate enough to get in touch with the author of the book so I will share his response here:
I suspect I may know what the problem is...after looking at this
reader's problems in more depth, I've discovered that something has
changed in newer versions of QGIS, and the example code no longer
works as it is written. In technical terms, it seems that you now
need to instantiate the QApplication object before making the call to
QgsApplication.initQgis() -- the example program in the book
instantiates the QApplication object after calling
QgsApplication.initQgis(), which causes the program to crash. To fix
this, change the main() function to look like the following:
def main():
""" Our main program.
"""
app = QApplication(sys.argv)
QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'],True)
QgsApplication.initQgis()
viewer = MapViewer("C:/folder/shapefile.shp")
viewer.show()
app.exec_()
QgsApplication.exitQgis()
As you can see, I've moved the "app = QApplication(sys.argv)" line to the top.
Important Note: Make sure that forward slashes are used in viewer = MapViewer("C:/folder/shapefile.shp") - using a backslash will result in an error message stating that the shapefile is invalid.
I also thought it would be worth mentioning that none of the above fixes (comments on the question) were necessary. So, the script will work if the paths are defined as follows:
SET OSGEO4W_ROOT=C:\OSGeo4W64
SET QGIS_PREFIX=%OSGEO4W_ROOT%\apps\qgis
SET PATH=%PATH%;%QGIS_PREFIX%\bin
SET PYTHONPATH=%QGIS_PREFIX%\python;%PYTHONPATH%
Then, the entire script looks like this:
import sys
import os
from qgis.core import *
from qgis.gui import *
from PyQt4.QtGui import *
from PyQt4.QtCore import Qt
#############################################################################
class MapViewer(QMainWindow):
def __init__(self, shapefile):
QMainWindow.__init__(self)
self.setWindowTitle("Map Viewer")
canvas = QgsMapCanvas()
canvas.useImageToRender(False)
canvas.setCanvasColor(Qt.white)
canvas.show()
layer = QgsVectorLayer(shapefile, "layer1", "ogr")
if not layer.isValid():
raise IOError("Invalid shapefile")
QgsMapLayerRegistry.instance().addMapLayer(layer)
canvas.setExtent(layer.extent())
canvas.setLayerSet([QgsMapCanvasLayer(layer)])
layout = QVBoxLayout()
layout.addWidget(canvas)
contents = QWidget()
contents.setLayout(layout)
self.setCentralWidget(contents)
#############################################################################
def main():
""" Our main program.
"""
app = QApplication(sys.argv)
QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'],True)
QgsApplication.initQgis()
viewer = MapViewer("C:/folder/shapefile.shp")
viewer.show()
app.exec_()
QgsApplication.exitQgis()
#############################################################################
if __name__ == "__main__":
main()
Execute it in the OSGEO4W Shell using the following command:
python "C:\script.py"
Lastly, note that at the time of this writing, the script works properly and launches a viewer showing the shapefile referenced, but returns a few errors in the shell that do not seem to be problematic:
ERROR: Opening of authentication db FAILED
ERROR: Unable to establish authentication database connection
ERROR: Auth db could not be created and opened
QSqlDatabasePrivate::database: unable to open database: "unable to open database file Error opening database"
ERROR: Opening of authentication db FAILED
Much thanks to the author Erik Westra for providing me with this solution.
One thing that seems suspect is that you're creating a gui element without giving it a parent - QgsMapCanvas() - and then trying to manually show() it before adding it to a layout. You should never have to call show() on subwidgets, and all subwidgets should be parented to the main widget (or one of its other subwidgets).
Also, you should store persistent references to the python objects; otherwise, it's possible the underlying C++ object with get garbage collected and cause your program to crash. You do this by assigning your widgets and layouts to an attribute on self
Ex.
self.layout = QVBoxLayout(...
self.layer = ...
You should be adding the canvas like this, you should not need to call .show()
self.canvas = QgsMapCanvas(self)
layout.addWidget(self.canvas)

Categories

Resources