2

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_())
MBV
  • 591
  • 3
  • 17
  • 1
    I'm not really clear on how vispy actually works, but from your code it seems that you're actually **not** using the Qt graphics view framework at all, and there are clear indicators about that: 1. there is absolutely no QGraphicsScene in your code (nor in vispy); 2. there's no related `setScene()`; 3. you *have* to force painting on the vispy widget. This means only one thing: using QGraphicsView is pointless, as it is calling `setViewport()`. Just use a QScrollArea (ensuring you call `setWidgetResizable(True)`) and set the vispy canvas for it. – musicamante Feb 28 '23 at 04:27
  • Thanks, @musicamante, you have been of great help. I need to use the QGrapichsView because I do have some code to write items in the scene. Altough your idea did work if you see my post about the fast zoom and pan pseudo solution, it is just a simple widget, i could do a qgv on top of the background layer that would be this. – MBV Feb 28 '23 at 04:56
  • Then, setting the vispy as viewport seems quite useless. Instead, make it a *child* of the graphics view, *lower* it and make the view transparent. What you need to do is to properly update the vispy widget geometry in the `resizeEvent()` (but ensure you properly call the default `super().resizeEvent()` too!). Note that QGraphicsView has default frame margins, since it inherits from QFrame: use `self.frameWidth()` to fix the geometry accordingly. – musicamante Feb 28 '23 at 05:15
  • If I dont set it as a viewport, then the pan and zoom wont work as I want. What I am trying now is to do the normal widget but change instead the signal for the vispy events for the pan and zoom, docs seen pretty good. – MBV Feb 28 '23 at 05:21
  • What do you mean with "won't work as I want"? The graphics view gets all its user events from its viewport, if you don't get them it's probably because you didn't properly *lower* the vispy widget: it's shown *above* the viewport, so the view is not able to get those events. Ensure that the stacking order is properly set; also, since it (should be) a QWidget, you can also set its `WA_TransparentForMouseEvents` attribute. – musicamante Feb 28 '23 at 05:37
  • I meant that the mouse events I want is to only pan with he middle button not the left, which is the default. The vispy widget is working allrigth, but I just can get it to change the mouse event button. – MBV Feb 28 '23 at 05:59
  • I commented on your other question, but I'll add some info here too. I have no experience with QGraphicsView, but what was said is correct. VisPy is talking to your OpenGL (your GPU) through Qt. You have access to a `.native` property which is a `QOpenGLWidget` subclass. Theoretically you can set/change any Qt properties on that widget object, but this is not something we test or deal with on a regular basis so I can't guarantee that all properties can be changed. My first guess is that some resize event is not getting sent to VisPy properly. – djhoese Feb 28 '23 at 19:30
  • Hi @djhoese, I drop the idea of using the vispy as a viewport. I use vispy as a background layer to display heavy objects using the .native property as a regular widget, also I did sub class the PanZoom to change the pan button. Than you for taking the time to respond. – MBV Mar 01 '23 at 00:46

1 Answers1

1

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)
MBV
  • 591
  • 3
  • 17