Situation
I have a PyQt5 app that shows lines, text and circles, it shows them correctly but the text rendering is a bit slow. I have a custom class for QGrapichsView
that implement all this.
problem
When I set in the properties of the gv the following I start getting errors such as the example. The text and circles render correctly at a much faster render time(much better) but the lines get the error in rendering.
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
# format.setVersion(3, 0)
format.setProfile(QSurfaceFormat.CoreProfile)
self.gl_widget.setFormat(format)
self.setViewport(self.gl_widget)
the render of text get much much better and it shows them as it should. but a problem comes with the lines that start having strange behavior.
example with issue
example without issue
note how the width of the lines is variable even tough is set to a unique value, also, when I do a zoom out or zoom in, some of this lines appear and disappear randomly.
As soon as I use path item the problems begin, just a line item does not create this problem.
Does anybody have any idea what could this mean?
what to look for?
The issue is that the width of the lines are random, and not the set value I put in the code. Also when you zoom in or out, it disappears.
It seems to have something to do with the set width, as a bigger width helps, but does not remove it.
minimal reproducible example
import sys
from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsTextItem
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtWidgets import QOpenGLWidget
import numpy as np
from PyQt5.QtGui import QPainterPath, QPen
from PyQt5.QtWidgets import QGraphicsPathItem, QGraphicsLineItem, QGraphicsPolygonItem
from PyQt5.QtGui import QPolygonF
from PyQt5.QtCore import QLineF, QPointF
from PyQt5.QtGui import QSurfaceFormat
class GraphicsView(QtWidgets.QGraphicsView):
def __init__(self):
super(GraphicsView, self).__init__()
self.pos_init_class = None
# "VARIABLES INICIALES"
self.scale_factor = 1.5
# "ASIGNAR LINEAS DE MARCO"
self.setFrameShape(QtWidgets.QFrame.VLine)
# "ACTIVAR TRACKING DE POSICION DE MOUSE"
self.setMouseTracking(True)
# "REMOVER BARRAS DE SCROLL"
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
# "ASIGNAR ANCLA PARA HACER ZOOM SOBRE EL MISMO PUNTO"
self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
# "MEJORAR EL RENDER DE VECTORES"
self.setRenderHint(QtGui.QPainter.Antialiasing, False)
self.setRenderHint(QtGui.QPainter.SmoothPixmapTransform, False)
self.setRenderHint(QtGui.QPainter.TextAntialiasing, False)
self.setRenderHint(QtGui.QPainter.HighQualityAntialiasing, False)
self.setRenderHint(QtGui.QPainter.NonCosmeticDefaultPen, True)
self.setOptimizationFlag(QtWidgets.QGraphicsView.DontAdjustForAntialiasing, True)
self.setOptimizationFlag(QtWidgets.QGraphicsView.DontSavePainterState, True)
self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
self.setViewportUpdateMode(QtWidgets.QGraphicsView.BoundingRectViewportUpdate)
#Try OpenGL stuff
# self.gl_widget = QOpenGLWidget()
# self.setViewport(self.gl_widget)
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
format.setVersion(2, 8)
format.setProfile(QSurfaceFormat.CoreProfile)
self.gl_widget.setFormat(format)
self.setViewport(self.gl_widget)
def mousePressEvent(self, event):
pos = self.mapToScene(event.pos())
# "PAN MOUSE"
if event.button() == QtCore.Qt.MiddleButton:
self.pos_init_class = pos
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.ClosedHandCursor)
super(GraphicsView, self).mousePressEvent(event)
def mouseReleaseEvent(self, event):
# PAN Y RENDER TEXT
if self.pos_init_class and event.button() == QtCore.Qt.MiddleButton:
# PAN
self.pos_init_class = None
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.ArrowCursor)
super(GraphicsView, self).mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
if self.pos_init_class:
# "PAN"
delta = self.pos_init_class - self.mapToScene(event.pos())
r = self.mapToScene(self.viewport().rect()).boundingRect()
self.setSceneRect(r.translated(delta))
super(GraphicsView, self).mouseMoveEvent(event)
def wheelEvent(self, event):
old_pos = self.mapToScene(event.pos())
# Determine the zoom factor
if event.angleDelta().y() > 0:
zoom_factor = self.scale_factor
else:
zoom_factor = 1 / self.scale_factor
# Apply the transformation to the view
transform = QtGui.QTransform()
transform.translate(old_pos.x(), old_pos.y())
transform.scale(zoom_factor, zoom_factor)
transform.translate(-old_pos.x(), -old_pos.y())
# Get the current transformation matrix and apply the new transformation to it
current_transform = self.transform()
self.setTransform(transform * current_transform)
def zoom_extent(self):
x_range, y_range, h_range, w_range = self.scene().itemsBoundingRect().getRect()
rect = QtCore.QRectF(x_range, y_range, h_range, w_range)
self.setSceneRect(rect)
unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
self.scale(1 / unity.width(), 1 / unity.height())
viewrect = self.viewport().rect()
scenerect = self.transform().mapRect(rect)
factor = min(viewrect.width() / scenerect.width(), viewrect.height() / scenerect.height())
self.scale(factor, factor)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.view = GraphicsView()
self.scene = QGraphicsScene()
self.view.setScene(self.scene)
self.generate_random_lines()
self.setCentralWidget(self.view)
self.showMaximized()
self.view.zoom_extent()
def rotate_vector(self, origin, point, angle):
"""
ROTATE A POINT COUNTERCLOCKWISE BY A GIVEN ANGLE AROUND A GIVEN ORIGIN. THE ANGLE SHOULD BE GIVEN IN RADIANS.
:param origin: SOURCE POINT ARRAYS, [X_SOURCE, Y_SOURCE], LEN N
:param point: DESTINATION POINT, [X_DEST, Y_DEST], LEN N
:param angle: ARRAY OF ANGLE TO ROTATE VECTOR (ORIGIN --> POINT), [ANG], LEN N
:return:
"""
ox, oy = origin
px, py = point
qx = ox + np.cos(angle) * (px - ox) - np.sin(angle) * (py - oy)
qy = oy + np.sin(angle) * (px - ox) + np.cos(angle) * (py - oy)
return qx, qy
def create_line_with_arrow_path(self, x1, y1, x2, y2, arr_width, arr_len):
"""
This function creates a line with an arrowhead at the end.
The line is created between two points (x1, y1) and (x2, y2).
The arrowhead is defined by its width (arr_width) and length (arr_len).
Returns a QGraphicsPathItem with the line and arrowhead.
"""
# Initialize the path for the line and arrowhead
path = QPainterPath()
path.moveTo(x1, y1)
path.lineTo(x2, y2)
# Calculate the midpoint of the line
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
# Define the points of the arrowhead
arrow_x = np.array([arr_width, -arr_len, -arr_width, -arr_len, arr_width]) * 5
arrow_y = np.array([0, arr_width, 0, -arr_width, 0]) * 5
arrow_x += mid_x
arrow_y += mid_y
# Calculate the angle of the line
angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
# Rotate the arrowhead points to align with the line
origin = (np.array([mid_x, mid_x, mid_x, mid_x, mid_x]), np.array([mid_y, mid_y, mid_y, mid_y, mid_y]))
point = (arrow_x, arrow_y)
self.x_init, self.y_init = self.rotate_vector(origin, point, np.deg2rad(angle))
# Add the arrowhead to the path
arrow_path = QtGui.QPainterPath()
arrow_path.moveTo(self.x_init[0], self.y_init[0])
for i in range(1, len(arrow_x)):
arrow_path.lineTo(self.x_init[i], self.y_init[i])
path.addPath(arrow_path)
# Create a QGraphicsPathItem with the line and arrowhead
item = QGraphicsPathItem(path)
pen = QPen()
pen.setWidthF(0.1)
item.setPen(pen)
return item, angle
def create_line_with_arrow_item(self, x1, y1, x2, y2, arr_width, arr_len):
# Calculate the midpoint of the line
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
# Define the coordinates for the arrow
arrow_x = np.array([arr_width, -arr_len, -arr_width, -arr_len, arr_width]) * 10
arrow_y = np.array([0, arr_width, 0, -arr_width, 0]) * 10
arrow_x += mid_x
arrow_y += mid_y
# Calculate the angle of the line
angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
# Rotate the arrow to align with the line
origin = (np.array([mid_x, mid_x, mid_x, mid_x, mid_x]), np.array([mid_y, mid_y, mid_y, mid_y, mid_y]))
point = (arrow_x, arrow_y)
x_init, y_init = self.rotate_vector(origin, point, np.deg2rad(angle))
# Create the line and arrow
line = QLineF(x1, y1, x2, y2)
arrow = QPolygonF([QPointF(x_init[0], y_init[0]),
QPointF(x_init[1], y_init[1]),
QPointF(x_init[2], y_init[2]),
QPointF(x_init[3], y_init[3]),
QPointF(x_init[4], y_init[4])])
item = QGraphicsLineItem(line)
item_arrow = QGraphicsPolygonItem(arrow)
# Set the pen for both line and arrow
pen = QPen()
pen.setWidthF(1)
item.setPen(pen)
item_arrow.setPen(pen)
# Return the line and arrow items
return item, item_arrow, angle
def generate_random_lines(self):
case = 'issue'
x = np.array([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) * 10
y = np.array([0, 20, 10, 0, 35, 90, 10, 60, 60, 90, 100]) * 10
for pos, i in enumerate(range(len(x) - 1)):
x1 = x[i]
y1 = y[i]
x2 = x[i + 1]
y2 = y[i + 1]
if case in ['issue']:
#add lines
path, angle = self.create_line_with_arrow_path(x1, y1, x2, y2, 0.5, 1.5)
self.scene.addItem(path)
# add text
text1 = QGraphicsTextItem()
text1.setPlainText(str(pos))
text1.setPos(x1, y1)
text1.setRotation(angle)
self.scene.addItem(text1)
else:
#add lines
line, arrow, angle = self.create_line_with_arrow_item(x1, y1, x2, y2, 0.5, 1.5)
self.scene.addItem(line)
self.scene.addItem(arrow)
# add text
text1 = QGraphicsTextItem()
text1.setPlainText(str(pos))
text1.setPos(x1, y1)
text1.setRotation(angle)
self.scene.addItem(text1)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
figures for minimal example
this is an example whit the issue
this is an example without the issue
The issue is caused by two reasons.
When an analog object is shown in a digital context, aliasing always happens. That's the basic difference between real and integer numbers.
Now, the "issue" with OpenGL (and, from my understanding, any 3D basic visualization) is that if an object doesn't fill a whole pixel, by default that pixel won't be shown.
Anti-aliasing allows to tell if a pixel should be shown even if only a part of that pixel may be covered by the object. OpenGL is capable of multisample anti-aliasing, which provides an optimized spacial anti-aliasing: the amount of samples tells the engine if a pixel should be shown (and how) or not.
I'm not completely sure on how Qt decides the default sampling (my assumption is that it's based on the detected screen capabilities), but, in my tests and according to the documentation, the default is -1
, meaning that no multisampling is enabled.
The convention I found normally uses 16 samples as default, so you need to do the following:
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
format.setSamples(16)
# ...
You're using a default pen width of 0.1
, which is extremely small. Even with the standard raster rendering (without QOpenGlWidget as viewport), the pen would be almost invisible.
Imagine having a thread (I mean a physical yarn) that is very small in size: you can see it if you're close to it, but as you get far from it you'll eventually stop seeing it at some point.
The solution is quite simple, conceptually speaking: use the actual size as long as the scaling (the "zoom factor") allows to properly see the object.
Making it in practice requires some ingenuity.
Take these considerations for the following example:
setPen()
to set a "default pen width"; you can obviously make your own classes using the pen width as argument in the constructor;penWidth * 4
would theoretically cover a whole pixel;class VisibleGlShapeItem(object):
'''
A pseudo class that potentially overrides setPen() and provides a
custom method to override the default setPen() implementation.
'''
penWidth = None
def setPen(self, pen):
super().setPen(pen)
if self.penWidth is None:
self.penWidth = pen.widthF()
def setCosmeticPen(self, cosmeticScale):
if self.penWidth is None:
self.penWidth = .1
cosmetic = .5 / cosmeticScale > self.penWidth
pen = self.pen()
if pen.isCosmetic() != cosmetic:
pen.setCosmetic(cosmetic)
if cosmetic:
pen.setWidthF(.5)
else:
pen.setWidthF(self.penWidth)
super().setPen(pen)
# mixin classes creation
class VisibleGlPathItem(VisibleGlShapeItem, QGraphicsPathItem): pass
class VisibleGlLineItem(VisibleGlShapeItem, QGraphicsLineItem): pass
class GraphicsView(QtWidgets.QGraphicsView):
def __init__(self):
# ...
self.setRenderHint(QtGui.QPainter.Antialiasing) # mandatory
# ...
self.gl_widget = QOpenGLWidget()
format.setSamples(16)
# ...
def wheelEvent(self, event):
# ...
self.updatePens()
def showEvent(self, event):
# ensure that updatePens is called at least on first start
super().showEvent(event)
if not event.spontaneous():
self.updatePens()
def updatePens(self):
# get the minimum transformation scale; while you seem to be using
# a fixed ratio, a reference should always be considered
scale = min(self.transform().m11(), self.transform().m22())
for item in self.items():
if isinstance(item, QGraphicsPathItem):
item.setCosmeticPen(scale)
class MainWindow(QtWidgets.QMainWindow):
# ...
def create_line_with_arrow_path(self, x1, y1, x2, y2, arr_width, arr_len):
# ...
item = VisibleGlPathItem(path)
pen = QPen()
pen.setWidthF(0.1)
item.setPen(pen)
return item, angle
renderHint
flags; by default, QGraphicsView only uses TextAntialiasing
, so, setting any other flag to False
is completely pointless; also, both HighQualityAntialiasing
and NonCosmeticDefaultPen
are obsolete;math
module, or Qt capabilities; for instance, if you need to "rotate" a point around another, you can use QLineF, as it's normally quite fast and has the major benefit of better readability: vector = QLineF(p1, p2)
vector.setAngle(angle)
newP2 = vector.p2()
setFrameShape(QtWidgets.QFrame.VLine)
only makes sense for separators; don't use it for the wrong reason or widget;setOverrideCursor()
is intended for the whole application, and you shouldn't use it to temporarily change the cursor of a single widget; use setCursor()
and unsetCursor()
instead;