Search code examples
pythonpyqt5vispy

Vispy and PyQt5 fullscreen resize event problem


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.

enter image description here

after the fullscreen it goes to the bottom left and does not rezize.

enter image description here

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

Solution

  • 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)