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_())
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_())