I have a line viewer made in PyQt5
; I managed to make a vispy
scene as the viewport for the QGrapichsView
. I have made it zoom and pan capable.
Problem
The viewer works very fast but the problem comes when I try to manually (push the fullscreen button in the right upper corner) the QgrapichsView (main widget) resize fine, but the sceneCanvas from vispy does not resize and stay the same.
states
before the manual fullscreen click, anyways this is not well positioned try to pan or zoom and you will se there is a barrier in the rigth side.
after the fullscreen it goes to the bottom left and does not rezize.
This code comes from needing to use a faster approach to show many lines, but now the problem is the correct positioing of the canvas.
What I have tried
Mainly, I have made a signal from the QgrapichsView
resize event to pass to the vispy
canvas, but it just wont work.
Code
import sys
from PyQt5.QtGui import QPainter, QPaintEvent
from PyQt5 import QtCore
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QGraphicsView, QApplication
import vispy.scene
from vispy.scene import visuals
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 = []
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 VispyViewport(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.scale_factor = 1.5
self.setRenderHint(QPainter.Antialiasing)
self.setInteractive(True)
# Create a VisPy canvas and add it to the QGraphicsView
# self.canvas = canvas = vispy.scene.SceneCanvas( app='pyqt5', show=True, size=(2100, 600))
self.canvas = canvas = vispy.scene.SceneCanvas(app='pyqt5', show=True)
vispy_widget = canvas.native
vispy_widget.setParent(self)
# Set the VisPy widget as the viewport for the QGraphicsView
self.setViewport(vispy_widget)
self.setGeometry(QtCore.QRect(0,0, 2100,600))
# Create a grid layout and add it to the canvas
grid = canvas.central_widget.add_grid()
# Create a ViewBox and add it to the grid layout
self.view_vispy = grid.add_view(row=0, col=0, bgcolor='#c0c0c0')
self.grid = self.canvas.central_widget.add_grid()
line_data, connect = dummy_generator(5000, 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 = vispy.scene.PanZoomCamera()
self.view_vispy.camera.set_range()
self.view_vispy.camera.aspect = 1.0
#get m transformer
self.tform = self.view_vispy.scene.transform
def wheelEvent(self, event):
# Get the center of the viewport in scene coordinates
pos = event.pos()
# Determine the zoom factor
if event.angleDelta().y() > 0:
zoom_factor = 1 / self.scale_factor
else:
zoom_factor = self.scale_factor
#map to vispy coordinates
center = self.tform.imap((pos.x(), pos.y(), 0))
#apply zoom factor to a center anchor
self.view_vispy.camera.zoom(zoom_factor, center=center)
def mousePressEvent(self, event):
if event.button() == Qt.MiddleButton:
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.setInteractive(True)
self.mouse_press_pos = event.pos()
self.mouse_press_center = self.view_vispy.camera.center[:2]
else:
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.MiddleButton:
self.setDragMode(QGraphicsView.NoDrag)
self.setInteractive(False)
else:
super().mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
if self.dragMode() == QGraphicsView.ScrollHandDrag:
# Get the difference in mouse position
diff = event.pos() - self.mouse_press_pos
# Get the movement vector in scene coordinates
move_vec = self.tform.imap((diff.x(), diff.y())) - self.tform.imap((0, 0))
# Apply panning and set center
self.view_vispy.camera.center = (self.mouse_press_center[0] - move_vec[0], self.mouse_press_center[1] - move_vec[1])
else:
super().mouseMoveEvent(event)
def paintEvent(self, event: QPaintEvent) -> None:
# force send paintevent
self.canvas.native.paintEvent(event)
return super().paintEvent(event)
if __name__ == '__main__':
app = QApplication(sys.argv)
view = VispyViewport()
view.show()
# Start the Qt event loop
sys.exit(app.exec_())
for anybody trying to use vispy
this class migth help. This is very fast to render scenes with millions of lines and fairly large rasters. I use a 1080 Ti gpu and it felt like nothing. So I hope this helps.
Just create an pyqt5 instance and connect the signal to update the scene extent.
class VispyCanvasWrapper(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._parent = parent
self.canvas = SceneCanvas(app='pyqt5')
self.grid = self.canvas.central_widget.add_grid()
self.view_vispy = self.grid.add_view(0, 0, bgcolor=(86/255, 86/255, 86/255, 1))
self.view_vispy.camera = "panzoom"
self.view_vispy.camera.aspect = 1.0
# Set the parent widget of the canvas.native widget to this widget
self.canvas.native.setParent(self)
# Set the size and position of the canvas.native widget
self.setObjectName('background_layer')
self.line_geometries = {'LineString', 'MultiLineString', 'LineStringZ', 'MultiLineStringZ'}
self.polygon_geometries = {'Polygon', 'MultiPolygon', 'PolygonZ', 'MultiPolygonZ'}
def parallel_raster_read(self, file_path):
#get total memory
mem = psutil.virtual_memory().available
# Get the number of CPU cores
cores = psutil.cpu_count(logical=False)
# Estimate the optimal cache size as a fraction of the available memory
cache_size = int(mem * 0.4)
# Estimate the optimal number of threads as a fraction of the CPU cores
num_threads = int(cores * 0.75)
# Set the cache size and number of threads
gdal.SetConfigOption('GDAL_CACHEMAX', str(cache_size))
gdal.SetConfigOption('GDAL_NUM_THREADS', str(num_threads))
# Open the file using the GA_ReadOnly option
img_data = gdal.Open(file_path, gdal.GA_ReadOnly)
# Read the first band as an array
band = img_data.GetRasterBand(1)
array = band.ReadAsArray()
return array
def add_raster(self, filename, name):
# image
dataset = gdal.Open(filename)
# Get the spatial information of the image
geotransform = dataset.GetGeoTransform()
x_size = dataset.RasterXSize
y_size = dataset.RasterYSize
# Calculate the pixel size and the location of the top-left corner
x_pixel_size = geotransform[1]
y_pixel_size = -geotransform[5] # note the negative sign
x_origin = geotransform[0]
y_origin = geotransform[3] - y_size * y_pixel_size # note the subtraction
# Get the number of bands
num_bands = dataset.RasterCount
if num_bands > 1:
# Create the image
img_data = imread(filename=filename)
img_data = np.flipud(img_data)
# If the array has three dimensions, it's an RGB image
_ = visuals.Image(img_data, interpolation='nearest', parent=self.view_vispy.scene, method='subdivide')
else:
img_data = self.parallel_raster_read(filename)
img_data = np.flipud(img_data)
# If the array has only two dimensions, it's an elevation raster
img_data = img_data.astype(np.float32)
# Filter out None or null values
img_data[img_data < 0] = np.nan
# Compute the minimum and maximum values of your data, ignoring np.nan values
vmin, vmax = np.nanmin(img_data), np.nanmax(img_data)
# Create a transparent colormap
cmap = color.get_colormap('terrain')
cmap_array = cmap.colors.rgba.copy()
cmap_array[:, 3] = np.linspace(0, 1, cmap_array.shape[0])
cmap_array = np.insert(cmap_array, 0, [0, 0, 0, 0], axis=0) # Add a transparent color at the beginning
cmap = color.Colormap(cmap_array)
# Set the colormap and range
clim = (vmin, vmax)
_ = visuals.Image(img_data, interpolation='linear', parent=self.view_vispy.scene, method='subdivide', cmap=cmap, clim=clim)
# Set the projection information
projection = dataset.GetProjection()
if projection is not None:
# Set the transform to match the GeoTIFF file's spatial information
_.transform = transforms.STTransform(scale=(x_pixel_size, y_pixel_size), translate=(x_origin, y_origin))
# Free up the memory and close the dataset
band = None
dataset.FlushCache()
dataset = None
def add_vector(self, filename, name):
vector_file = fiona.open(filename)
types = {feature['geometry']['type'] for feature in vector_file[:100]}
if types.intersection(self.line_geometries):
# load to vispy
line_data, connect = self.line_to_scene(filename)
if isinstance(connect, np.ndarray):
_ = visuals.Line(line_data, parent=self.view_vispy.scene, connect=connect, color=(0.50196, 0.50196, 0.50196, 1))
else:
print("No supported geometry types found in the sample.")
def line_to_scene(self, path_file):
"""Converts a vector file with line features to a list of coordinates and
a boolean array indicating whether to connect each point to the next.
Args:
path_file (str): The path to the vector file.
Returns:
Tuple[np.ndarray, np.ndarray]: The first array contains the x and y
coordinates of each point, and the second array contains a boolean
value for each point indicating whether to connect it to the next.
"""
# Read the vector file and filter to include only lines
try:
try:
gdf = pyogrio.read_dataframe(path_file, use_arrow=True)
except:
gdf = gpd.read_file(path_file, engine='fiona')
except Exception as e:
return None, None
mask = gdf.geom_type.isin(["MultiLineString", "LineString"])
gdf_unprojected = gdf[mask]
actual_crs = gdf_unprojected.crs
if actual_crs:
utm_crs = gdf_unprojected.estimate_utm_crs()
if utm_crs != actual_crs:
gdf = gdf_unprojected.to_crs(epsg=utm_crs.srs.replace('EPSG:', ''))
else:
gdf = gdf_unprojected
else:
gdf = gdf_unprojected
# explode MultiLineString to all LineString
gdf_explode = gdf.explode(index_parts=True)
# Get the coordinates of each contour
coords, arr = shapely.get_coordinates(gdf_explode['geometry'], return_index=True)
# Get the unique values in the array
_, indices = np.unique(arr, return_index=True)
indices = indices[1:] - 1
# Create a boolean array of the same length as the input array
connect = np.full_like(arr, True, dtype=bool)
# Set the borders to False
connect[indices] = False
# Set the borders for the last value
connect[-1] = False
return coords, connect
@QtCore.pyqtSlot(QtCore.QRectF)
def sync_pan_zoom(self, rect):
# Convert the rectangle from Qt coordinate system to VisPy coordinate system.
xmin, ymax = rect.x(), -rect.y()
xmax, ymin = xmin + rect.width(), ymax - rect.height()
# Set the view range of the VisPy canvas camera.
self.view_vispy.camera.set_range(x=(xmin, xmax), y=(ymin, ymax), margin=0)