0

I have been trying to make a line viewer in PyQt5, the main process is to load a geopackage file using geopandas and adding it to a QGrapihcsView scene. The QGrapichsView class is a zoom and pan capable.

For this example I used a generic line creator to best represent the actual problem. I used a Voronoi generator that add closed polygons to the scene.

Problem

The pan and zoom area extremely slow when I do a close up and later a pan action. Also the count line order of magnitude is similar to actual real life examples.

What I have tried so far

Basically, I have tried to reduce the size of the updated viewport to minimum, also tried rendering using concurrent futures, which turn out to be a very bad idea. Also I united all objects under a QPainterPath but the slow pan and zoom persists.

I would appreciate any help, also would be nice to know if QGrapichsView is just not the tool to do this and what would you recommend to do it with that works with PyQt5

Edit: I have made it a self contained example, although it is very difficult to replicate a contour line from actual topography I do get a similar effect using the voronoi polylines.

Code

import sys
from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsView, QMainWindow
from PyQt5 import QtCore, QtGui, QtWidgets
import struct
import numpy as np

def dummy_generator(num_points, max_points):
    from scipy.spatial import Voronoi
    
    # Define las dimensiones
    city_width = 8000
    city_height = 6000

    points = np.random.rand(num_points, 2) * np.array([city_width , city_height])
    # Calcula el diagrama de Voronoi de los puntos para generar los bordes de los predios
    vor = Voronoi(points)
    # Crea una lista de coordenadas de vértices para cada polilínea que representa los bordes de los predios
    polylines = []
    for ridge in vor.ridge_vertices:
        if ridge[0] >= 0 and ridge[1] >= 0:
            x1, y1 = vor.vertices[ridge[0]]
            x2, y2 = vor.vertices[ridge[1]]
            if x1 < 0 or x1 > city_width  or y1 < 0 or y1 > city_height :
                continue
            if x2 < 0 or x2 > city_width  or y2 < 0 or y2 > city_height :
                continue
            # Genera puntos intermedios en la línea para obtener polilíneas más suaves
            val = np.random.randint(3, max_points)
            xs = np.linspace(x1, x2, num=val)
            ys = np.linspace(y1, y2, num=val)
            polyline = [(xs[i], ys[i]) for i in range(val)]
            points = np.array(polyline).T
            polylines.append(points)

    return polylines

class GraphicsView(QtWidgets.QGraphicsView):
    def __init__(self, scene):
        super(GraphicsView, self).__init__(scene)
        self.pos_init_class = None
        # "VARIABLES INICIALES"
        self.scale_factor = 1.5
        # "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.ViewportAnchor.AnchorViewCenter)
        # Set the item index method to BspTreeIndex
        scene.setItemIndexMethod(QtWidgets.QGraphicsScene.BspTreeIndex)
        # "MEJORAR EL RENDER DE VECTORES"
        self.setRenderHint(QtGui.QPainter.Antialiasing, False)
        self.setOptimizationFlag(QtWidgets.QGraphicsView.DontAdjustForAntialiasing, True)
        self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
        self.setViewportUpdateMode(QtWidgets.QGraphicsView.ViewportUpdateMode.MinimalViewportUpdate)

    def mousePressEvent(self, event):
        pos = self.mapToScene(event.pos())
        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):
        if self.pos_init_class and event.button() == QtCore.Qt.MiddleButton:
            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:
            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):
        self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
        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 vector_to_scene(grapchisView):
    # Get the coordinates of each contour
    #additional scipy package
    list_coords = dummy_generator(10000, 500)


    # Calculate the start and end indices of each contour
    pos_arr = np.array([max(_.shape) for _ in list_coords]) - 1
    fill_arr = np.ones(np.sum(pos_arr)).astype(int)
    zero_arr = np.zeros(len(pos_arr)).astype(int)
    c = np.insert(fill_arr, np.cumsum(pos_arr), zero_arr)
    x, y = np.concatenate(list_coords, axis=1)

    # Create a QPainterPath to store all the lines
    path = QtGui.QPainterPath()
    xy_path = arrayToQPath(x, -y, connect=np.array(c))
    path.addPath(xy_path)

    # Set the pen properties for the lines
    pen = QtGui.QPen()
    pen.setColor(QtCore.Qt.black)
    pen.setWidthF(1)
    pen.setStyle(QtCore.Qt.SolidLine)

    # Add the lines to the graphics view
    grapchisView.scene().addPath(path, pen)

def arrayToQPath( x, y, connect='all'):
    """Convert an array of x,y coordinats to QPainterPath as efficiently as possible.
    The *connect* argument may be 'all', indicating that each point should be
    connected to the next; 'pairs', indicating that each pair of points
    should be connected, or an array of int32 values (0 or 1) indicating
    connections.
    """
    path = QtGui.QPainterPath()
    n = x.shape[0]
    # create empty array, pad with extra space on either end
    arr = np.empty(n + 2, dtype=[('x', '>f8'), ('y', '>f8'), ('c', '>i4')])
    # profiler('allocate empty')
    byteview = arr.view(dtype=np.ubyte)
    byteview[:12] = 0
    byteview.data[12:20] = struct.pack('>ii', n, 0)
    # Fill array with vertex values
    arr[1:-1]['x'] = x
    arr[1:-1]['y'] = y

    # decide which points are connected by lines
    if connect in ['all']:
        arr[1:-1]['c'] = 1
    elif connect in ['pairs']:
        arr[1:-1]['c'][::2] = 1
        arr[1:-1]['c'][1::2] = 0
    elif connect in ['finite']:
        arr[1:-1]['c'] = np.isfinite(x) & np.isfinite(y)
    elif isinstance(connect, np.ndarray):
        arr[1:-1]['c'] = connect
    else:
        raise Exception('connect argument must be "all", "pairs", "finite", or array')

    # write last 0
    lastInd = 20 * (n + 1)
    byteview.data[lastInd:lastInd + 4] = struct.pack('>i', 0)
    # create datastream object and stream into path
    path.strn = byteview.data[12:lastInd + 4]  # make sure data doesn't run away
    try:
        buf = QtCore.QByteArray.fromRawData(path.strn)
    except TypeError:
        buf = QtCore.QByteArray(bytes(path.strn))
    ds = QtCore.QDataStream(buf)

    ds >> path
    return path

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        # Create the scene and view
        self.scene = QGraphicsScene()
        self.view = GraphicsView(self.scene)

        self.view.setScene(self.scene)
        vector_to_scene( self.view)

        # Set the central widget
        self.setCentralWidget(self.view)


if __name__ == '__main__':
    app_ = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app_.exec_())
MBV
  • 591
  • 3
  • 17
  • Why, why... *why* are you trying to manually paint the scene in `render()` (which, by the way, is an *existing* function of QWidget *and* QGraphicsView, and, as such, should *not* be overwritten)?!? You do *not*, *ever* paint on your own the contents of a scene on the view, it does it on its own, and using its own optimizations. Also, painting on a QWidget can **only** happen within a paint event, and a painter must **never** be manually called on a widget outside of that context. – musicamante Feb 24 '23 at 23:06
  • I was just trying to do it using a concurrent approach, but it really did not seem to do anything but I kept it to show that I already tried that, sorry if it’s a very bad idea. – MBV Feb 24 '23 at 23:08
  • Paint events are always out of the control of the program, they can only be triggered by a Qt (or system) event, there is no way to explicitly and directly trigger such events, nor force a QPainter outside of a paint event. If you want to "refresh" (*update*) the view at the given coordinates, just call `update()` on the viewport (in view coordinates) or use [`QGraphicsScene.invalidate()`](https://doc.qt.io/qt-5/qgraphicsscene.html#invalidate). Remove that `render()` function, as it's useless (and at least a good part of your issue). – musicamante Feb 24 '23 at 23:14
  • That said, unfortunately we cannot rely on external resources, since questions should *always* be self-contained, and until your question fits that need, we cannot answer it as that's a basic rule of SO. Please take your time and ensure that you provide a [mre] that can be used without requiring the usage of external data. – musicamante Feb 24 '23 at 23:15
  • thank you for taking the time to read the question, is it alight if I do a random line generator in order to get the same effect? – MBV Feb 24 '23 at 23:41
  • How you do it is not that relevant - random data might be fine, as long as its *reproducible* representation doesn't make it a problem (for instance, the *reproducibility* of the problem becomes more *or* less visible depending on the data); in that case, a *consistent* (deterministic) data might be preferred. Just ensure that you provide us an example that is *both* minimal *and* reproducible: the shortest code that allows us to easily copy, paste and run it (possibly without any substantial modifications) and clearly *reproduce* the problem. – musicamante Feb 24 '23 at 23:48
  • hi @musicamante I made it self contained, I really appreciate the help. – MBV Feb 25 '23 at 02:13
  • Sorry, I appreciate your efforts, but your example is not really minimal, but, most importantly, not very reproducible: it requires lots of uncommon modules and programs that few people would install just for you (and I wouldn't). Your issue clearly is *not* based on the data source, so all those modules are unnecessary for the code. I understand that you need help, but this isn't a help desk (and we're not payed), so we expect that you put us in the condition of helping you *as easily as possible* (and installing geo/map libraries isn't, as they usually bring along lots of requirements). – musicamante Feb 25 '23 at 02:35
  • don't worry, thank you. I will try to reproduce something similar using numpy, so it does not need any geo library, if I succeed I will post it again I really appreciate the time. – MBV Feb 25 '23 at 02:39
  • Thank you, I appreciate it. Please take your time, I realize that putting together a reproducible example with that kind of data isn't easy (as geo/map is quite complex by nature), but we really must be able to reproduce the problem as easily as possible so that we can focus on it, instead of trying to reproduce it in the first place; and that's not only about us: consider somebody else having a similar problem in the future, but they don't really care about geo/mapping; they must be able to realize if they're having a similar problem as yours. Please notify us when you've updated your post. – musicamante Feb 25 '23 at 02:45
  • Hi @musicamante I have tried some different approaches and I found this two that resemble my actual problem, but could also be general enough for a person to get some useful insight when trying to deal with a lot of lines in PyQt5. Again I am very great full for the time and I hope this helps other people as well. – MBV Feb 26 '23 at 22:36
  • Sorry, but I really cannot install unknown/unnecessary modules (specifically, `noise`). – musicamante Feb 27 '23 at 04:33
  • Hi, @musicamante, thanks for checking the reply, I also put another example only using scipy and matplotlib, can you use that? It is another function that I included in the code. I will remove the function with the noise package. Thanks for the time. – MBV Feb 27 '23 at 14:55
  • Ok, thanks for the effort. The problem is caused by the *enormity* of the path, which is composed by *millions* of elements. That is a serious issue for performance, primarily because by default the painter tries to draw the *whole* path, even if only a portion of it is present: in order to ensure that all elements that should be visible are actually shown, it has to iterate the *whole* element list. Solving this is not easy; geographic viewers normally split the "scene" in *tiles* which allows optimization for both speed and memory usage: only the visible tiles are actually processed. – musicamante Feb 27 '23 at 18:24
  • I'm afraid that I cannot really provide you an answer for this, you have to find your way to do so, but one thing is certain: you *must* split the data (the paths) in smaller portions. How to do so, depends on your needs. An idea could be to create a sort of "tree" of items, where you have a hierarchy of "child" items (bigger tiles), each one having its own child items, and so on; that could be done by splitting the areas in tiles, as said above, or by "regions" (similarly to a real geographic representation: countries, districts, cities, etc). – musicamante Feb 27 '23 at 18:38
  • @musicamante thanks for the advice, I did not know how the View renders the content of the scene, I will try to split the path, also I tried the vispy library and it really improves the pan and zoom actions. I will post both for anybody having this same issue. – MBV Feb 27 '23 at 18:58

1 Answers1

0

for anybody having trouble with this, vispy could be a really fast and powerful solution. It had no problem with million of lines for my actual example it works very fast.

some comments on this, vispy is very well documented but nonetheless is sort of tricky to use it in pyqt5 considering that I am not a PyQt5 expert. I am still trying to change the panning button to the middle one and disabling the right pan button using the vispy scene. Also is not very clear if I can use the vispy canvas as a viewport for my original QGrapichsView class that has pan an zoom.

Also, I am building the pyqt5 concept @musicamante gave me to see if I have any favorable results. I will keep updating this post.

Any help is very welcome.

Code

import sys
import numpy as np
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QHBoxLayout
from vispy.scene import SceneCanvas, visuals

def dummy_generator(num_points, max_points):
    from scipy.spatial import Voronoi

    # Define las dimensiones
    city_width = 8000
    city_height = 6000

    points = np.random.rand(num_points, 2) * np.array([city_width, city_height])
    # Calcula el diagrama de Voronoi de los puntos para generar los bordes de los predios
    vor = Voronoi(points)
    # Crea una lista de coordenadas de vértices para cada polilínea que representa los bordes de los predios
    polylines = []
    pos_arr = []
    for ridge in vor.ridge_vertices:
        if ridge[0] >= 0 and ridge[1] >= 0:
            x1, y1 = vor.vertices[ridge[0]]
            x2, y2 = vor.vertices[ridge[1]]
            if x1 < 0 or x1 > city_width or y1 < 0 or y1 > city_height:
                continue
            if x2 < 0 or x2 > city_width or y2 < 0 or y2 > city_height:
                continue
            # Genera puntos intermedios en la línea para obtener polilíneas más suaves
            val = np.random.randint(3, max_points)
            xs = np.linspace(x1, x2, num=val)
            ys = np.linspace(y1, y2, num=val)
            polyline = [(xs[i], ys[i]) for i in range(val)]
            points = np.array(polyline).T
            polylines.append(points)
            pos_arr.append(max(points.shape))

    # Calculate the start and end indices of each contour
    pos_arr = np.array(pos_arr) - 1
    fill_arr = np.ones(np.sum(pos_arr)).astype(int)
    zero_arr = np.zeros(len(pos_arr)).astype(int)
    c = np.insert(fill_arr, np.cumsum(pos_arr), zero_arr)
    connect = np.where(c == 1, True, False)
    coords = np.concatenate(polylines, axis=1)
    return coords.T, connect



class CanvasWrapper(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.canvas = SceneCanvas()
        self.grid = self.canvas.central_widget.add_grid()

        self.view_vispy = self.grid.add_view(0, 0, bgcolor='#c0c0c0')
        line_data, connect = dummy_generator(50000, 50)
        self.line = visuals.Line(line_data, parent=self.view_vispy.scene, connect=connect, color=(0.50196, 0.50196, 0.50196, 1))

        self.view_vispy.camera = "panzoom"
        self.view_vispy.camera.set_range()
        self.view_vispy.camera.aspect = 1.0

        layout = QHBoxLayout(self)
        layout.addWidget(self.canvas.native)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)


class MainWindow(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        central_widget = CanvasWrapper(self)
        self.setCentralWidget(central_widget)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())
MBV
  • 591
  • 3
  • 17
  • "vispy is very well documented" you are definitely the first person to say this ;) "I am still trying to change the panning button to the middle one": If you want to customize the buttons used I think you'd need to subclass the PanZoomCamera and override the mouse-related functions to switch which buttons are used. – djhoese Feb 28 '23 at 19:23
  • Oh and as far as mixing QGraphicsView and vispy: I've never used QGraphicsView, but I'd advise against mixing Qt elements inside a VisPy Canvas. VisPy is vispy, Qt is Qt, vispy just happens to talk to OpenGL (your GPU) through Qt so it can be embedded in a Qt GUI. Beyond the `.native` property being a QWidget subclass, VisPy doesn't really know anything about Qt objects. At least, not in a way that you (the user) are supposed to worry about. – djhoese Feb 28 '23 at 19:26