Search code examples
pythonpyqt5pyopengl

QtWidgets.QGraphicsLineItem issue when setting viewport to QOpenGLWidget()


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

enter image description here

example without issue

enter image description here

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 whit the issue

this is an example without the issue

enter image description here


Solution

  • The issue is caused by two reasons.

    Sampling of the OpenGL surface

    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)
            # ...
    

    The pen width is too small

    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.

    Set a cosmetic pen whenever the scale is too big

    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:

    • I'm assuming a very basic scenario based on your example; for obvious reasons, you may need some further implementation, also considering that you're using different pen widths depending on the objects;
    • I'm using a mix-in class that takes advantage of the cooperative multi-inheritance followed by PyQt;
    • the implementation is quite basic and assumes that you always call setPen() to set a "default pen width"; you can obviously make your own classes using the pen width as argument in the constructor;
    • the "cosmetic pen" choice is based on arbitrary values; the assumption is that a pen becomes "non cosmetic" whenever a pixel ratio of 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
    

    Final notes

    • avoid unnecessary 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;
    • unless you actually need numpy for other reasons, avoid its requirement for mathematical purposes: either use the 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()
    
    • merge/group items; lines shouldn't be separated by their arrows; if they share the same pen, just use a single QPainterPath (and QGraphicsPathItem), otherwise make the arrows as child items of the line;
    • use caching: there's no point in creating new paths for each arrow every time; just create one (using a 0-reference point) and add a translated copy to the new path whenever you need it; the result is conceptually the same, with the benefit of working on the C++ side of things;
    • 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;