I have a window with a QGraphicsScene as painter, and i want to render its elements to a pdf file on press of a button.
def generateReport(self):
lineList = {}
for i in self.circleList:
for j,k in i.lineItems:
if j not in lineList:
lineList[j] = [i, k]
printed = QPdfWriter("Output.pdf")
printed.setPageSize(QPagedPaintDevice.A4)
printer = QPainter(printed)
self.painter.render(printer)
for i,j in enumerate(lineList):
# j.scene().render(printer)
# lineList[j][0].scene().render(printer)
# lineList[j][1].scene().render(printer)
printer.drawText(0, self.painter.height() + i*200, f'{j.nameItem.toPlainText()}: {lineList[j][0].m_items[4].toPlainText()}, {lineList[j][1].m_items[4].toPlainText()}')
printer.end()
nameItem on j is the name label for the line, m_items[4] is the name label for each circle.
My issue is that i cant seem to get the exact height of the rendered scene, moreover I have zero clue as to how i could overflow the text to the next page should the contents not fit in one.
it would be lovely if i could somehow render every line and its corresponding circles seperately for each connection, stored in lineList
note: the line is a child of every circle , and the names of every line and circle are children of theirs, implemented much in the same way as the answer to my previous question where in lies my final issue of the grip items also being rendered.
I have discovered that I can create a new scene, move each item one by one and render it out to the pdf but this raises two separate issues
I cant add a line break and avoid overdrawing the new render over the previous one, and
I cant position the text as addText doesnt take positional arguments.
MRE:
import random
from fbs_runtime.application_context.PyQt5 import ApplicationContext
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPainter, QPdfWriter, QBrush, QPagedPaintDevice
from PyQt5.QtWidgets import (QDialog, QGraphicsScene,
QGraphicsView, QGridLayout,
QPushButton, QGraphicsEllipseItem)
class gui(QDialog):
def __init__(self, parent=None):
super(gui, self).__init__(parent)
self.resize(1280, 720)
self.painter = QGraphicsScene(0, 0, self.width() - 50, self.height() - 70)
self.painter.setBackgroundBrush(QBrush(Qt.white))
self.canvas = QGraphicsView(self.painter)
mainLayout = QGridLayout()
mainLayout.addWidget(self.canvas, 0, 0, -1, -1)
self.setLayout(mainLayout)
#property
def circleList(self):
return [item for item in self.painter.items() if isinstance(item, QGraphicsEllipseItem)]
def newCircle(self):
self.painter.addEllipse( random.randint(100, 400), random.randint(100, 400), 50 + random.random() * 200, 50 + random.random() * 200)
def generateReport(self):
printed = QPdfWriter("Output.pdf")
printed.setPageSize(QPagedPaintDevice.A4)
printer = QPainter(printed)
self.painter.render(printer)
for i,j in enumerate(self.circleList):
printer.drawText(0, printer.viewport().height() + i*200, 'test')
printer.end()
if __name__ == "__main__":
app = ApplicationContext()
test = gui()
test.newCircle()
test.newCircle()
test.newCircle()
test.generateReport()
test.show()
exit(app.app.exec_())
if possible , the ability to print, test then circle for all circles would be decent enough for me.
Incorrect output example:
To understand what painting is like, you have to understand how QGraphicsScene::render() method works:
void QGraphicsScene::render(QPainter *painter, const QRectF &target = QRectF(), const QRectF &source = QRectF(), Qt::AspectRatioMode aspectRatioMode = Qt::KeepAspectRatio)
Renders the source rect from scene into target, using painter. This
function is useful for capturing the contents of the scene onto a
paint device, such as a QImage (e.g., to take a screenshot), or for
printing with QPrinter. For example:
QGraphicsScene scene;
scene.addItem(...
...
QPrinter printer(QPrinter::HighResolution);
printer.setPaperSize(QPrinter::A4);
QPainter painter(&printer);
scene.render(&painter);
If source is a null rect, this function will use sceneRect() to
determine what to render. If target is a null rect, the dimensions of
painter's paint device will be used.
The source rect contents will be transformed according to
aspectRatioMode to fit into the target rect. By default, the aspect
ratio is kept, and source is scaled to fit in target.
See also QGraphicsView::render().
In your case, if the source is not passed, the entire sceneRect (0, 0, 1230, 650) will be copied and painted on the pdf page, if the sizes do not match, the sizes will be scaled. So from the above it follows that if you want to print an item then you must pass as source the space it occupies in the scene and hide the other items, and the target is the place where you want to paint, which involves calculating the new position based on where the previous item was printed.
Considering the above, a possible solution is the following:
import random
from PyQt5 import QtCore, QtGui, QtWidgets
class Gui(QtWidgets.QDialog):
def __init__(self, parent=None):
super(Gui, self).__init__(parent)
self.resize(1280, 720)
self.scene = QtWidgets.QGraphicsScene(
0, 0, self.width() - 50, self.height() - 70
)
self.scene.setBackgroundBrush(QtGui.QBrush(QtCore.Qt.white))
self.canvas = QtWidgets.QGraphicsView(self.scene)
mainLayout = QtWidgets.QGridLayout(self)
mainLayout.addWidget(self.canvas)
#property
def circleList(self):
return [
item
for item in self.scene.items()
if isinstance(item, QtWidgets.QGraphicsEllipseItem)
]
def newCircle(self):
self.scene.addEllipse(
random.randint(100, 400),
random.randint(100, 400),
50 + random.random() * 200,
50 + random.random() * 200,
)
def generateReport(self):
printer = QtGui.QPdfWriter("Output.pdf")
printer.setPageSize(QtGui.QPagedPaintDevice.A4)
printer.setResolution(100)
painter = QtGui.QPainter(printer)
delta = 20
f = painter.font()
f.setPixelSize(delta)
painter.setFont(f)
# hide all items
last_states = []
for item in self.scene.items():
last_states.append(item.isVisible())
item.setVisible(False)
target = QtCore.QRectF(0, 0, printer.width(), 0)
for i, item in enumerate(self.circleList):
item.setVisible(True)
source = item.mapToScene(item.boundingRect()).boundingRect()
target.setHeight(source.height())
if target.bottom() > printer.height():
printer.newPage()
target.moveTop(0)
self.scene.render(painter, target, source)
f = painter.font()
f.setPixelSize(delta)
painter.drawText(
QtCore.QRectF(
target.bottomLeft(), QtCore.QSizeF(printer.width(), delta + 5)
),
"test",
)
item.setVisible(False)
target.setTop(target.bottom() + delta + 20)
# restore visibility
for item, state in zip(self.scene.items(), last_states):
item.setVisible(state)
painter.end()
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
w = Gui()
for _ in range(200):
w.newCircle()
w.generateReport()
w.show()
sys.exit(app.exec_())
Related
I have something like this:
I can't show you more, but this is a simple QTreeView with QStandardItems in it. The items in the figure have a parent item which has a parent item as well.
When I activate the breakpoint on a item I have this:
which is ok but I also would like to add a circle next it as the majority of IDEs do (I took as example PyCharm):
The problem is that I have no idea how to do it. Anyone can help?
A possible solution is to override the drawRow method of QTreeView and use the information from the QModelIndex to do the painting:
import sys
from PySide2.QtCore import Qt, QRect
from PySide2.QtGui import QColor, QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QApplication, QTreeView
IS_BREAKPOINT_ROLE = Qt.UserRole + 1
class TreeView(QTreeView):
def drawRow(self, painter, option, index):
super().drawRow(painter, option, index)
if index.column() == 0:
if not index.data(IS_BREAKPOINT_ROLE):
return
rect = self.visualRect(index)
if not rect.isNull():
margin = 4
r = QRect(0, rect.top(), rect.height(), rect.height()).adjusted(
margin, margin, -margin, -margin
)
painter.setBrush(QColor("red"))
painter.drawEllipse(r)
def main(args):
app = QApplication(args)
view = TreeView()
view.setSelectionBehavior(QAbstractItemView.SelectRows)
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["col1", "col2"])
view.setModel(model)
counter = 0
for i in range(10):
item1 = QStandardItem("Child 1-{}".format(i))
item2 = QStandardItem("Child 2-{}".format(i))
for j in range(10):
child1 = QStandardItem("Child {}-1".format(counter))
child2 = QStandardItem("Child {}-2".format(counter))
child1.setData(counter % 2 == 0, IS_BREAKPOINT_ROLE)
item1.appendRow([child1, child2])
counter += 1
model.appendRow([item1, item2])
view.show()
view.resize(320, 240)
view.expandAll()
sys.exit(app.exec_())
if __name__ == "__main__":
main(sys.argv)
I'd like to propose an alternate solution based on the answer by eyllanesc, which adds a left margin to the viewport, avoiding painting over the hierarchy lines (which could hide expanding decoration arrows for parent items that still need to show the circle).
Some important notes:
the left margin is created using setViewportMargins(), but all item views automatically reset those margins when calling updateGeometries() (which happens almost everytime the layout is changed), so that method needs overriding;
painting on the margins means that painting does not happen in the viewport, so we cannot implement paintEvent() (which by default is called for updates on the viewport); this results in implementing the drawing in the event() instead;
updates must be explicitly called when the scroll bar change or items are expanded/collapsed, but Qt only updates the region interested by items that have been actually "changed" (thus possibly excluding other "shifted" items); in order to request the update on the full extent we need to call the base implementation of QWidget (not that of the view, as that method is overridden);
class TreeView(QTreeView):
leftMargin = 14
def __init__(self, *args, **kwargs):
super().__init__()
self.leftMargin = self.fontMetrics().height()
self.verticalScrollBar().valueChanged.connect(self.updateLeftMargin)
self.expanded.connect(self.updateLeftMargin)
self.collapsed.connect(self.updateLeftMargin)
def updateLeftMargin(self):
QWidget.update(self,
QRect(0, 0, self.leftMargin + self.frameWidth(), self.height()))
def setModel(self, model):
if self.model() != model:
if self.model():
self.model().dataChanged.disconnect(self.updateLeftMargin)
super().setModel(model)
model.dataChanged.connect(self.updateLeftMargin)
def updateGeometries(self):
super().updateGeometries()
margins = self.viewportMargins()
if margins.left() < self.leftMargin:
margins.setLeft(margins.left() + self.leftMargin)
self.setViewportMargins(margins)
def event(self, event):
if event.type() == event.Paint:
pos = QPoint()
index = self.indexAt(pos)
qp = QPainter(self)
border = self.frameWidth()
bottom = self.height() - border * 2
qp.setClipRect(QRect(border, border, self.leftMargin, bottom))
top = .5
if self.header().isVisible():
top += self.header().height()
qp.translate(.5, top)
qp.setBrush(Qt.red)
qp.setRenderHints(qp.Antialiasing)
deltaY = self.leftMargin / 2 - border
circle = QRect(
border + 1, 0, self.leftMargin - 2, self.leftMargin - 2)
row = 0
while index.isValid():
rect = self.visualRect(index)
if index.data(IS_BREAKPOINT_ROLE):
circle.moveTop(rect.center().y() - deltaY)
qp.drawEllipse(circle)
row += 1
pos.setY(rect.bottom() + 2)
if pos.y() > bottom:
break
index = self.indexAt(pos)
return super().event(event)
I'm struggling with working out how to make all the stuff in the title work together in a certain situation. I'm using PyQt5 here, but feel free to respond with regular C++ Qt as I can translate pretty easily.
I'm attempting to make a UI with the following:
A main form (inherits from QWidget, could just as well use QMainWindow)
The main form should contain a QSplitter oriented vertically containing a QTextEdit at the top and containing a custom class (inheriting from QLabel) to show an image taking up the rest of the space.
The QTextEdit at the top should default to about 3 lines of text high, but this should be resizable to any reasonable extreme via the QSplitter.
The custom class should resize the image to be as big as possible given the available space while maintaining the aspect ratio.
Of course the tricky part is getting everything to resize correctly depending on how big a monitor the user has and how the move the form around. I need this to run on screens as small as about 1,000 px width and perhaps as big as 3,000+ px width.
Here is what I have so far:
# QSplitter3.py
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication, QWidget, QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QSizePolicy, \
QFrame, QTabWidget, QTextEdit, QSplitter
from PyQt5.QtGui import QImage, QPixmap, QPainter
from PyQt5.Qt import Qt
from PyQt5.Qt import QPoint
def main():
app = QApplication([])
screenSize = app.primaryScreen().size()
print('screenSize = ' + str(screenSize.width()) + ', ' + str(screenSize.height()))
mainForm = MainForm(screenSize)
mainForm.show()
app.exec()
# end function
class MainForm(QWidget):
def __init__(self, screenSize):
super().__init__()
# set the title and size of the Qt QWidget window
self.setWindowTitle('Qt Window')
self.setGeometry(screenSize.width() * 0.2, screenSize.height() * 0.2,
screenSize.width() * 0.5 , screenSize.height() * 0.7)
# declare a QTextEdit to show user messages at the top, set the font size, height, and read only property
self.txtUserMessages = QTextEdit()
self.setFontSize(self.txtUserMessages, 14)
self.txtUserMessages.setReadOnly(True)
# make the min height of the text box about 2 lines of text high
self.txtUserMessages.setMinimumHeight(self.getTextEditHeightForNLines(self.txtUserMessages, 2))
# populate the user messages text box with some example text
self.txtUserMessages.append('message 1')
self.txtUserMessages.append('message 2')
self.txtUserMessages.append('message 3')
self.txtUserMessages.append('stuff here')
self.txtUserMessages.append('bla bla bla')
self.txtUserMessages.append('asdasdsadds')
# instantiate the custom ImageWidget class below to show the image
self.imageWidget = ImageWidget()
self.imageWidget.setMargin(0)
self.imageWidget.setContentsMargins(0, 0, 0, 0)
self.imageWidget.setScaledContents(True)
self.imageWidget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self.imageWidget.setAlignment(Qt.AlignCenter)
# declare the splitter, then add the user message text box and tab widget
self.splitter = QSplitter(Qt.Vertical)
self.splitter.addWidget(self.txtUserMessages)
self.splitter.addWidget(self.imageWidget)
defaultTextEditHeight = self.getTextEditHeightForNLines(self.txtUserMessages, 3)
print('defaultTextEditHeight = ' + str(defaultTextEditHeight))
# How can I use defaultTextEditHeight height here, but still allow resizing ??
# I really don't like this line, the 1000 is a guess and check that may only work with one screen size !!!
self.splitter.setSizes([defaultTextEditHeight, 1000])
# Should setStretchFactor be used here ?? This does not seem to work
# self.splitter.setStretchFactor(0, 0)
# self.splitter.setStretchFactor(1, 1)
# What about sizeHint() ?? Should that be used here, and if so, how ??
# set the main form's layout to the QGridLayout
self.gridLayout = QGridLayout()
self.gridLayout.addWidget(self.splitter)
self.setLayout(self.gridLayout)
# open the two images in OpenCV format
self.openCvImage = cv2.imread('image.jpg')
if self.openCvImage is None:
print('error opening image')
return
# end if
# convert the OpenCV image to QImage
self.qtImage = openCvImageToQImage(self.openCvImage)
# show the QImage on the ImageWidget
self.imageWidget.setPixmap(QPixmap.fromImage(self.qtImage))
# end function
def setFontSize(self, widget, fontSize):
font = widget.font()
font.setPointSize(fontSize)
widget.setFont(font)
# end function
def getTextEditHeightForNLines(self, textEdit, numLines):
fontMetrics = textEdit.fontMetrics()
rowHeight = fontMetrics.lineSpacing()
rowHeight = rowHeight * 1.21
textEditHeight = int(numLines * rowHeight)
return textEditHeight
# end function
# end class
def openCvImageToQImage(openCvImage):
# get the height, width, and num channels of the OpenCV image, then compute the byte value
height, width, numChannels = openCvImage.shape
byteValue = numChannels * width
# make the QImage from the OpenCV image
qtImage = QImage(openCvImage.data, width, height, byteValue, QImage.Format_RGB888).rgbSwapped()
return qtImage
# end function
class ImageWidget(QLabel):
def __init__(self):
super(QLabel, self).__init__()
# end function
def setPixmap(self, pixmap):
self.pixmap = pixmap
# end function
def paintEvent(self, event):
size = self.size()
painter = QPainter(self)
point = QPoint(0, 0)
scaledPixmap = self.pixmap.scaled(size, Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation)
point.setX((size.width() - scaledPixmap.width()) / 2)
point.setY((size.height() - scaledPixmap.height()) / 2)
painter.drawPixmap(point, scaledPixmap)
# end function
# end class
if __name__ == '__main__':
main()
Currently I'm testing on a 2560x1440 screen and with the magic 1000 entered it works on this screen size, but I really don't like the hard-coded 1000. I suspect the area of the code where I'm missing something is this part:
# declare the splitter, then add the user message text box and tab widget
self.splitter = QSplitter(Qt.Vertical)
self.splitter.addWidget(self.txtUserMessages)
self.splitter.addWidget(self.imageWidget)
defaultTextEditHeight = self.getTextEditHeightForNLines(self.txtUserMessages, 3)
print('defaultTextEditHeight = ' + str(defaultTextEditHeight))
# How can I use defaultTextEditHeight height here, but still allow resizing ??
# I really don't like this line, the 1000 is a guess and check that may only work with one screen size !!!
self.splitter.setSizes([defaultTextEditHeight, 1000])
# Should setStretchFactor be used here ?? This does not seem to work
# self.splitter.setStretchFactor(0, 0)
# self.splitter.setStretchFactor(1, 1)
# What about sizeHint() ?? Should that be used here, and if so, how ??
# set the main form's layout to the QGridLayout
self.gridLayout = QGridLayout()
self.gridLayout.addWidget(self.splitter)
With the hard coded 1000 and on this particular screen it works pretty well:
To reiterate (hopefully more clearly) I'm attempting to be able to remove the hard-coded 1000 and command Qt as follows:
Initially make the form take up about 2/3 of the screen
Initially make the text box about 3 lines of text high (min of 2 lines of text high)
Allow the user to use the QSplitter to resize the text box and image at any time and without limit
When the form is resized (or minimized or maximized), resize the text box and image proportionally per how the user had them at the time of the resize
I've tried about every combination of the stuff mentioned in the title and so far in this post but I've not been able to get this functionality, except with the hard-coded 1000 that probably won't work with a different screen size.
How can I remove the hard-coded 1000 and modify the above to achieve the intended functionality?
In my solution I will not take into account the part of opencv since it adds unnecessary complexity.
The solution is to use the setStretchFactor() method, in this case override the sizeHint() method of the QTextEdit to set the initial size and setMinimumHeight() for the minimum height. To show the image I use a QGraphicsView instead of the QLabel since the logic is easier.
from PyQt5 import QtCore, QtGui, QtWidgets
class TextEdit(QtWidgets.QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
font = self.font()
font.setPointSize(14)
self.setFont(font)
self.setMinimumHeight(self.heightForLines(2))
def heightForLines(self, n):
return (
n * self.fontMetrics().lineSpacing() + 2 * self.document().documentMargin()
)
def showEvent(self, event):
self.verticalScrollBar().setValue(self.verticalScrollBar().minimum())
def sizeHint(self):
s = super().sizeHint()
s.setHeight(self.heightForLines(3))
return s
class GraphicsView(QtWidgets.QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.setFrameShape(QtWidgets.QFrame.NoFrame)
self.setBackgroundBrush(self.palette().brush(QtGui.QPalette.Window))
scene = QtWidgets.QGraphicsScene(self)
self.setScene(scene)
self._pixmap_item = QtWidgets.QGraphicsPixmapItem()
scene.addItem(self._pixmap_item)
def setPixmap(self, pixmap):
self._pixmap_item.setPixmap(pixmap)
def resizeEvent(self, event):
self.fitInView(self._pixmap_item, QtCore.Qt.KeepAspectRatio)
self.centerOn(self._pixmap_item)
super().resizeEvent(event)
class Widget(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.textedit = TextEdit()
for i in range(10):
self.textedit.append("Message {}".format(i))
self.graphicsview = GraphicsView()
self.graphicsview.setPixmap(QtGui.QPixmap("image.jpg"))
splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical)
splitter.addWidget(self.textedit)
splitter.addWidget(self.graphicsview)
splitter.setStretchFactor(1, 1)
lay = QtWidgets.QGridLayout(self)
lay.addWidget(splitter)
screenSize = QtWidgets.QApplication.primaryScreen().size()
self.setGeometry(
screenSize.width() * 0.2,
screenSize.height() * 0.2,
screenSize.width() * 0.5,
screenSize.height() * 0.7,
)
def main():
app = QtWidgets.QApplication([])
w = Widget()
w.resize(640, 480)
w.show()
app.exec_()
if __name__ == "__main__":
main()
I am trying to draw rectangles according to a percentage value inside my labels. So it is important for me to know the exact coordinates of my labels. Since I am using multiple windows in cascade, the global positions of the current window are not retrieved accurately. Is there an easy workaround to this problem without changing my entire coding layout? form2 is my current view which is displayed as part of form1.
It could be helpful if I can map the coordinates of my label by using mapTo some widget inside my current window. My code seems to work except for the position of my rectangle. I have tried maptToGlobal() and mapToParent() and
self.label_pos = self.mapTo(self.frame, self.label_1.geometry().bottomLeft()) I cannot seem to access the widgets written inside my parent class using self. Or is there something wrong with my syntax?
import Ui_ImageCrop # design of my window using Qt Designer
import math
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMainWindow, QLabel
from PyQt5.QtGui import QPainter, QPen
class ImageCrop(Ui_ImageCrop.Ui_MainWindow, QMainWindow):
def __init__(self, parent=None):
super(ImageCrop,self ).__init__()
self.setupUi(self)
def paintEvent(self,e):
painter = QPainter(self)
self.square1 = Show_Square()
if self.lineEdit_1.text() == "":
self.square1.percent = 0
else:
self.square1.percent = float(self.lineEdit_1.text())
self.label_pos = self.mapTo(self, self.label_1.geometry().bottomLeft())
self.square1.x = self.label_pos.x()
self.square1.y = self.label_pos.y()
self.square1.w = self.label_1.width()
self.square1.h = self.label_1.height()
self.square1.drawRectangles(painter)
self.update()
class Show_Square(QLabel):
def __init__(self):
super(Show_Square,self).__init__()
self.percent = 50
self.x = 0
self.y = 0
self.w = 10
self.h = 10
def drawRectangles(self, painter):
center_x = float(self.x+self.w/2)
center_y = float(self.y+self.h/2)
rect_crop = float(self.percent*self.w*self.h/100)
k = float(self.h/self.w)
rect_w = int(math.sqrt(rect_crop/k))
rect_h = int(k*rect_w)
rect_x = int(center_x-rect_h/2)
rect_y = int(center_y-rect_w/2)
painter.setPen(QPen(Qt.black, 1, Qt.SolidLine))
painter.drawRect(rect_x,rect_y,rect_w,rect_h)
This is a solution that I could come out with. Total three frames were used in my layout for each label. So each label positions were found after adding the enclosing frame geometries.
frame1 = self.frame_1.geometry().topLeft()
frame2 = self.frame_2.geometry().topLeft()
frame3 = self.frame_3.geometry().topLeft()
label1_topLeft = frame1 + frame2 + frame3 + self.label_1.geometry().topLeft()
label1_bottomRight = frame1 + frame2 + frame3 + self.label_1.geometry().bottomRight()
Also, instead of placing the rectangles by drawing over the pixmap each time in paintEvent, it seems to work faster when the rectangles are drawn inside the labels. One could leave the images partly transparent.
Currently I am working on a program, to display SIP-Trace log files. It is written in Python 3.7 using the PyQt 5(.11.3) module to load and operate a GUI made in QDesigner. As a main feature it parses the SIP-Trace file and displays it as a sequence diagram to a QGraphicsScene with QGraphicsObjects.
My problem lies in the following: For later reference, the content of the QGraphicsScene should be saved as an image file, like .jpg or .png. In the Qt/PyQt documentation I found the useful sounding command QGraphicsScene.render() which renders the content of the GraphicsScene to a saveable file like QImage using QPainter. In the last days, I tried a couple of ways/sample codes found here and elsewhere, but cannot render the GraphicsScene to the QImage much less to an image file. Since I am rather new to Python and Qt, I think I am missing some basic setting somewhere. Following is a minimal version of my code.
# -*- coding: utf8 -*-
"""Class for getting a sequence diagram of a sip traffic"""
from PyQt5.QtWidgets import *
from PyQt5 import uic
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys
class VoipGui(QMainWindow):
""" Class that handles the interaction with the UI """
def __init__(self, parent=None):
super().__init__(parent)
self.ui = uic.loadUi("main_window.ui", self)
self.showMaximized()
self.sequence_scene = QGraphicsScene()
self.ui.graphicsView.setScene(self.sequence_scene)
# self.sequence_scene.setSceneRect(0, 0, 990, 2048)
# sets the spacing between nodes
# For more than three nodes columns should be generated in a more automatic way
self.left_column = 51
self.middle_column = 381
self.right_column = 711
self.flow_height = 60 # Sets the spacing between the arrows in the flowchart
# --------------------------------- /class init and var set -------------------------------------------
self.actionOpenFile.triggered.connect(self.on_open_file)
self.actionCloseFile.triggered.connect(self.on_close_file)
self.actionCloseProgram.triggered.connect(self.close)
self.actionSaveFile.triggered.connect(self.save_seq_image)
# --------------------------------- /connecting slots and signals ----------------------------
def on_open_file(self):
"""Dummy version of the open file dialog"""
self.draw_node(self.left_column, 5, "192.168.2.1", 10)
self.draw_node(self.middle_column, 5, "192.168.2.22", 10)
def on_close_file(self):
self.ui.textBrowser.clear()
self.sequence_scene.clear()
def save_seq_image(self):
""" Here lies the problem: Save the rendered sequence scene to file for later use"""
rect_f = self.sequence_scene.sceneRect()
# rect = self.sequence_scene.sceneRect().toRect()
# img = QPixmap(rect.size())
img = QImage()
p = QPainter()
# p.setPen(QColor(255, 255, 255))
# p.setViewport(rect)
painting = p.begin(img)
self.sequence_scene.render(p, target=QRectF(img.rect()), source=rect_f)
p.end()
if painting:
print("Painter init pass")
elif not painting:
print("Painter init fail")
saving = img.save("save.jpg")
if saving:
print("Saving Pass")
elif not saving:
print("Saving Not Pass")
def draw_node(self, x_pos, y_pos, ip_address, y_stops):
"""Participating devices are displayed as these nodes"""
width = 100.0
height = 40.0
pc_box = QGraphicsRectItem(x_pos - 50, y_pos, width, height)
self.sequence_scene.addItem(pc_box)
pc_ip = QGraphicsTextItem("%s" % ip_address)
pc_ip.setPos(x_pos - 50, y_pos)
self.sequence_scene.addItem(pc_ip)
node_line = QGraphicsLineItem(x_pos, y_pos + 40, x_pos, y_pos + (y_stops * self.flow_height))
self.sequence_scene.addItem(node_line)
def show_window():
app = QApplication(sys.argv)
dialog = VoipGui()
dialog.show()
sys.exit(app.exec_())
if __name__ == "__main__":
show_window()
The problem is simple, in render() you are indicating that the size of the target is equal to that of QImage, and how size is QImage?, how are you using QImage() the size is QSize(0, 0) so it can not be generated the image, the solution is to create a QImage with a size:
def save_seq_image(self):
""" Here lies the problem: Save the rendered sequence scene to file for later use"""
rect_f = self.sequence_scene.sceneRect()
img = QImage(QSize(640, 480), QImage.Format_RGB888)
img.fill(Qt.white)
p = QPainter(img)
self.sequence_scene.render(p, target=QRectF(img.rect()), source=rect_f)
p.end()
saving = img.save("save.jpg")
print("Saving Pass" if saving else "Saving Not Pass")
Output:
Primary issue: the QGraphicsView.mapToScene method returns different answers depending on whether or not the GUI is shown. Why, and can I get around it?
The context is I'm trying to write unit tests but I don't want to actually show the tools for the tests.
The small example below illustrates the behavior. I use a sub-classed view that prints mouse click event positions in scene coordinates with the origin at the lower left (it has a -1 scale vertically) by calling mapToScene. However, mapToScene does not return what I am expecting before the dialog is shown. If I run the main section at the bottom, I get the following output:
Size is (150, 200)
Putting in (50, 125) - This point should return (50.0, 75.0)
Before show(): PyQt5.QtCore.QPointF(84.0, -20.0)
After show() : PyQt5.QtCore.QPointF(50.0, 75.0)
Before show(), there is a consistent offset of 34 pixels in x and 105 in y (and in y the offset moves in reverse as if the scale is not being applied). Those offset seem rather random, I have no idea where they are coming from.
Here is the example code:
import numpy as np
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QPointF, QPoint
from PyQt5.QtWidgets import (QDialog, QGraphicsView, QGraphicsScene,
QVBoxLayout, QPushButton, QApplication,
QSizePolicy)
from PyQt5.QtGui import QPixmap, QImage
class MyView(QGraphicsView):
"""View subclass that emits mouse events in the scene coordinates."""
mousedown = pyqtSignal(QPointF)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setSizePolicy(QSizePolicy.Fixed,
QSizePolicy.Fixed)
# This is the key thing I need
self.scale(1, -1)
def mousePressEvent(self, event):
return self.mousedown.emit(self.mapToScene(event.pos()))
class SimplePicker(QDialog):
def __init__(self, data, parent=None):
super().__init__(parent=parent)
# Get a grayscale image
bdata = ((data - data.min()) / (data.max() - data.min()) * 255).astype(np.uint8)
wid, hgt = bdata.shape
img = QImage(bdata.T.copy(), wid, hgt, wid,
QImage.Format_Indexed8)
# Construct a scene with pixmap
self.scene = QGraphicsScene(0, 0, wid, hgt, self)
self.scene.setSceneRect(0, 0, wid, hgt)
self.px = self.scene.addPixmap(QPixmap.fromImage(img))
# Construct the view and connect mouse clicks
self.view = MyView(self.scene, self)
self.view.mousedown.connect(self.mouse_click)
# End button
self.doneb = QPushButton('Done', self)
self.doneb.clicked.connect(self.accept)
# Layout
layout = QVBoxLayout(self)
layout.addWidget(self.view)
layout.addWidget(self.doneb)
#pyqtSlot(QPointF)
def mouse_click(self, xy):
print((xy.x(), xy.y()))
if __name__ == "__main__":
# Fake data
x, y = np.mgrid[0:4*np.pi:150j, 0:4*np.pi:200j]
z = np.sin(x) * np.sin(y)
qapp = QApplication.instance()
if qapp is None:
qapp = QApplication(['python'])
pick = SimplePicker(z)
print("Size is (150, 200)")
print("Putting in (50, 125) - This point should return (50.0, 75.0)")
p0 = QPoint(50, 125)
print("Before show():", pick.view.mapToScene(p0))
pick.show()
print("After show() :", pick.view.mapToScene(p0))
qapp.exec_()
This example is in PyQt5 on Windows, but PyQt4 on Linux does the same thing.
Upon diving into the C++ Qt source code, this is the Qt definition of mapToScene for a QPoint:
QPointF QGraphicsView::mapToScene(const QPoint &point) const
{
Q_D(const QGraphicsView);
QPointF p = point;
p.rx() += d->horizontalScroll();
p.ry() += d->verticalScroll();
return d->identityMatrix ? p : d->matrix.inverted().map(p);
}
The critical things there are the p.rx() += d->horizontalScroll(); and likewise vertical scroll. A QGraphicsView always contains scroll bars, even if they are always off or not shown. The offsets observed before the widget is shown are from the values of the horizontal and vertical scroll bars upon initialization, which must get modified to match the view/viewport when the widgets are shown and layouts calculated. In order for mapToScene to operate properly, the scroll bars must be set up to match the scene/view.
If I put the following lines put before the call to mapToScene in the example, then I get the appropriate transformation result without the necessity of showing the widget.
pick.view.horizontalScrollBar().setRange(0, 150)
pick.view.verticalScrollBar().setRange(-200, 0)
pick.view.horizontalScrollBar().setValue(0)
pick.view.verticalScrollBar().setValue(-200)
To do this more generally, you can pull some relevant transformations from the view.
# Use the size hint to get shape info
wid, hgt = (pick.view.sizeHint().width()-2,
pick.view.sizeHint().height()-2) # -2 removes padding ... maybe?
# Get the opposing corners through the view transformation
px = pick.view.transform().map(QPoint(wid, 0))
py = pick.view.transform().map(QPoint(0, hgt))
# Set the scroll bars accordingly
pick.view.horizontalScrollBar().setRange(px.y(), px.x())
pick.view.verticalScrollBar().setRange(py.y(), py.x())
pick.view.horizontalScrollBar().setValue(px.y())
pick.view.verticalScrollBar().setValue(py.y())
This is a hack-ish and ugly solution, so while it does work there may be a more elegant way to handle this.
have you tried implementing your own qgraphicsview and overriding your resizeEvent? When you mess around with mapTo"something" you gotta take care of your resizeEvents, have a look in this piece of code I've took from yours and modified a bit ><
from PyQt5.QtCore import QRectF
from PyQt5.QtWidgets import (QGraphicsScene, QGraphicsView, QVBoxLayout,
QApplication, QFrame, QSizePolicy)
from PyQt5.QtCore import QPoint
class GraphicsView(QGraphicsView):
def __init__(self):
super(GraphicsView, self).__init__()
# Scene and view
scene = QGraphicsScene(0, 0, 150, 200,)
scene.setSceneRect(0, 0, 150, 200)
def resizeEvent(self, QResizeEvent):
self.setSceneRect(QRectF(self.viewport().rect()))
qapp = QApplication(['python'])
# Just something to be a parent
view = GraphicsView()
# Short layout
# Make a test point
p0 = QPoint(50, 125)
# Pass in the test point before and after
print("Passing in point: ", p0)
print("Received point before show:", view.mapToScene(p0))
view.show()
print("Received point after show:", view.mapToScene(p0))
qapp.exec_()
Is that the behavior you wanted? ")