Search code examples
pythonpyqt5vtk

How to get the coordinates and/or the ID of a point on left mouse click in VTK PolyData in Python


I have a bunch of random points in a VTK renderer using QVTKRenderWindowInteractor. I can update the scene by generating more random points by clicking on the QT push button as seen in the screenshot. Please find the MWE python code at the bottom.

enter image description here

At this point, I want to be able to click on one of these points and get the coordinates and/or the ID of it. I looked at the vtkPointPicker and vtkCellPicker examples. But I wasn't able to figure it out on my own.

I am new to VTK. Here's my code so far. Any pointers will be appreciated.

import sys
import numpy as np

from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QPushButton
)

# noinspection PyUnresolvedReferences
import vtkmodules.vtkInteractionStyle
# noinspection PyUnresolvedReferences
import vtkmodules.vtkRenderingOpenGL2
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
from vtkmodules.vtkCommonCore import vtkPoints
from vtkmodules.vtkCommonColor import vtkNamedColors
from vtkmodules.vtkInteractionWidgets import vtkCameraOrientationWidget
from vtkmodules.vtkCommonDataModel import (
    vtkCellArray,
    vtkPolyData,
)
from vtkmodules.vtkRenderingCore import (
    vtkActor,
    vtkPolyDataMapper,
    vtkRenderer,
)
import vtkmodules.util.numpy_support as vtk_np


class Ui_MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setupUi()

    def setupUi(self):
        self.setWindowTitle('Visualization App')
        self.resize(800, 600)
        self.centralwidget = QWidget(self)
        self.verticalLayout = QVBoxLayout()

        self.colors = vtkNamedColors()

        # Interactor widget
        self.canvas = QVTKRenderWindowInteractor(self.centralwidget)
        self.verticalLayout.addWidget(self.canvas)
        
        # button
        self.pushButton = QPushButton(self.centralwidget)
        self.pushButton.setText('Update')
        self.verticalLayout.addWidget(self.pushButton)
        self.pushButton.clicked.connect(self.update_plot)

        self.centralwidget.setLayout(self.verticalLayout)
        self.setCentralWidget(self.centralwidget)

        self.setup_canvas()

        # enable user interface interactor
        self.show()
        self.ren_win.Render()
        self.interactor.Initialize()
        self.interactor.Start()

    def setup_canvas(self):
        # Renderer, render window and the interactor
        self.ren = vtkRenderer()
        self.ren_win = self.canvas.GetRenderWindow()
        self.interactor = self.ren_win.GetInteractor()

        self.ren.SetBackground(.2, .3, .4)
        self.ren_win.AddRenderer(self.ren)

        # Target
        self.target_actor = self.init_canvas_actor(np.random.normal(size=(np.random.randint(100), 3), scale=0.2))
        self.ren.AddActor(self.target_actor)

        # Camera orientation widget
        # Important: The interactor must be set prior to enabling the widget
        self.interactor.SetRenderWindow(self.ren_win)
        self.cam_orient_manipulator = vtkCameraOrientationWidget()
        self.cam_orient_manipulator.SetParentRenderer(self.ren)
        self.cam_orient_manipulator.On()

        # Camera position
        self.ren.GetActiveCamera().Azimuth(0)
        self.ren.GetActiveCamera().Elevation(-80)
        self.ren.ResetCamera()

    def init_canvas_actor(self, nparray: np.ndarray):
        self.nparray = nparray
        nCoords = nparray.shape[0]

        self.points = vtkPoints()
        self.cells = vtkCellArray()
        self.pd = vtkPolyData()

        self.points.SetData(vtk_np.numpy_to_vtk(nparray))
        cells_npy = np.vstack([np.ones(nCoords, dtype=np.int64), np.arange(nCoords, dtype=np.int64)]).T.flatten()
        self.cells.SetCells(nCoords, vtk_np.numpy_to_vtkIdTypeArray(cells_npy))
        self.pd.SetPoints(self.points)
        self.pd.SetVerts(self.cells)

        mapper = vtkPolyDataMapper()
        mapper.SetInputDataObject(self.pd)

        actor = vtkActor()
        actor.SetMapper(mapper)
        actor.GetProperty().SetRepresentationToPoints()
        actor.GetProperty().SetColor(0.0, 1.0, 0.0)
        actor.GetProperty().SetPointSize(4)
        
        return actor

    def update_plot(self):
        nCoords = np.random.randint(100)
        pc = np.random.normal(size=(nCoords, 3), scale=0.2)
        points: vtkPoints = self.pd.GetPoints()
        points.SetData(vtk_np.numpy_to_vtk(pc))
        cells_npy = np.vstack([np.ones(nCoords, dtype=np.int64), np.arange(nCoords, dtype=np.int64)]).T.flatten()
        self.cells.SetCells(nCoords, vtk_np.numpy_to_vtkIdTypeArray(cells_npy))
        self.pd.SetPoints(points)
        
        points.Modified()
        self.cells.Modified()
        self.pd.Modified()
        self.show()
        self.ren_win.Render()
        

if __name__ == '__main__':
    app = QApplication(sys.argv)
    main = Ui_MainWindow()
    # main.show()
    sys.exit(app.exec_())

I am using Python: v3.10.12, Qt: v5.15.2, PyQt: v5.15.10, VTK: v9.3.0.


Solution

  • I found an elegant way to get the coordinates of the clicked point. It uses vtkPointPicker to get the point ID and then from that ID one can get the index and subsequently the coordinates of the said point.

    In the MWE below, I have put together everything that is needed for the said objective. The code is organized in a structured way. It shows the index and the coordinates of the point when left-mouse-button is clicked and clears the text when right-mouse-button is clicked.

    import sys
    import numpy as np
    
    from PyQt5.QtCore import QRect
    from PyQt5.QtWidgets import (
        QApplication, QMainWindow, QWidget, QGridLayout, QPushButton
    )
    
    # noinspection PyUnresolvedReferences
    import vtkmodules.vtkInteractionStyle
    # noinspection PyUnresolvedReferences
    import vtkmodules.vtkRenderingOpenGL2
    from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
    from vtkmodules.vtkCommonCore import vtkPoints, vtkIntArray, vtkFloatArray
    from vtkmodules.vtkInteractionWidgets import vtkCameraOrientationWidget
    from vtkmodules.vtkCommonDataModel import vtkPolyData
    from vtkmodules.vtkFiltersSources import vtkSphereSource
    from vtkmodules.vtkFiltersCore import vtkGlyph3D
    from vtkmodules.vtkRenderingLOD import vtkLODActor
    from vtkmodules.vtkRenderingCore import (
        vtkTextActor,
        vtkCamera,
        vtkPolyDataMapper,
        vtkRenderer,
        vtkAssembly,
        vtkColorTransferFunction,
        vtkPointPicker
    )
    
    class MainWindow(QMainWindow):
        """This class implements the main window of the application
        """
        def __init__(self, parent=None):
            super(MainWindow, self).__init__(parent)
            self.setWindowTitle('Visualization App')
            self.setGeometry(QRect(0, 0, 800, 600))
    
            self.central_widget = VisualizerWidget(self)
    
        def startup(self):
            self.setCentralWidget(self.central_widget)
            self.show()
    
    class VisualizerWidget(QWidget):
        """This class implements the central widget of the application
        """
        def __init__(self, parent=None):
            super(VisualizerWidget, self).__init__(parent)
            self.init_ui()
    
        def init_ui(self):
            self.canvas = CanvasViewer(self)
    
            self.update_button = QPushButton('Update', self)
            self.update_button.clicked.connect(self.canvas.update_canvas)
    
            self.central_layout = QGridLayout(self)
            self.central_layout.setContentsMargins(4, 4, 4, 10)
            self.central_layout.addWidget(self.canvas.iren)
            self.central_layout.addWidget(self.update_button)
    
            self.setLayout(self.central_layout)
            self.canvas.startup()
    
    class CanvasViewer(QWidget):
        """This class implements the canvas elements
        """
        def __init__(self, parent: None):
            super(CanvasViewer, self).__init__(parent)
            self.init_ui()
    
        def init_ui(self):
            self.iren = QVTKRenderWindowInteractor(self)
            self.ren = vtkRenderer()
            self.ren_win = self.iren.GetRenderWindow()
            self.interactor = self.ren_win.GetInteractor()
    
            self.ren.SetBackground(.2, .3, .4)
            self.ren_win.AddRenderer(self.ren)
            self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera()
            self.interactor.Enable()
            self.interactor.Initialize()
    
            # Camera orientation widget
            # Important: The interactor must be set prior to enabling the widget
            self.interactor.SetRenderWindow(self.ren_win)
            self.cam_orient_manipulator = vtkCameraOrientationWidget()
            self.cam_orient_manipulator.SetParentRenderer(self.ren)
            self.cam_orient_manipulator.On()
    
            # Text actor for printing the coordinates of the clicked point
            self.actor_title = vtkTextActor()
            self.actor_title.SetInput('')
            self.actor_title.GetTextProperty().SetFontFamilyToArial()
            self.actor_title.GetTextProperty().BoldOn()
            self.actor_title.GetTextProperty().SetFontSize(12)
            self.actor_title.GetTextProperty().SetColor(1, 0.9, 0.8)
            self.actor_title.SetDisplayPosition(10, 10)
            self.ren.AddActor(self.actor_title)
    
            # Build an LUT for colors
            self.lut_color = vtkColorTransferFunction()
            self.lut_color.AddRGBPoint(0, 1.0, 0.0, 0.0)
            self.lut_color.AddRGBPoint(1, 0.0, 1.0, 0.0)
            self.lut_color.AddRGBPoint(2, 0.0, 0.0, 1.0)
            self.lut_color.AddRGBPoint(3, 0.1, 0.1, 0.1)
    
            self.interactor.AddObserver('LeftButtonPressEvent', self.on_pick_left)
            self.interactor.AddObserver('RightButtonPressEvent', self.on_pick_right)
    
            self.current_points_to_plot = np.empty((0, 3))
    
            self.set_camera_position()
    
        def set_camera_position(self):
            """This function sets up the camera position
            """
            camera = vtkCamera()
            camera.SetPosition((0, 0, 25))
            camera.SetFocalPoint((0, 0, 0))
            camera.SetViewUp((0, 1, 0))
            camera.SetDistance(25)
            camera.SetClippingRange((15, 40))
            self.ren.SetActiveCamera(camera)
            self.ren_win.Render()
    
        def startup(self):
            self.ren.ResetCamera()
            self.ren_win.Render()
            self.interactor.Start()
    
        def update_canvas(self):
            """This function updates the canvas when the push button is clicked
            """
            points = 10 * np.random.normal(size=(np.random.randint(100), 3), scale=0.2)
            self.current_points_to_plot = points
    
            self.clear_point_actors()
    
            # Finally render the canvas with current points
            self.set_points(self.current_points_to_plot)
    
        def set_points(self, coords):
            """This function sets the new set of coordinates on the canvas
            """
    
            n_tgt = len(coords)
            radii, colors, indices = CanvasViewer.sphere_prop_to_vtkarray(n_tgt, 1, 0)
            
            polydata = vtkPolyData()
            polydata.GetPointData().AddArray(radii)
            polydata.GetPointData().SetActiveScalars(radii.GetName())
            polydata.GetPointData().AddArray(colors)
            polydata.GetPointData().AddArray(indices)
    
            points = vtkPoints()
            points.SetNumberOfPoints(n_tgt)
            for i, (x, y, z) in enumerate(coords):
                points.SetPoint(i, x, y, z)
    
            polydata.SetPoints(points)
    
            # Finally update the renderer
            self.current_point_actors = self.build_scene(polydata)
            self.ren.AddActor(self.current_point_actors)
    
            self.iren.Render()
    
        def build_scene(self, polydata):
            """build a vtkPolyData object for a given frame of the trajectory
            """
    
            # The rest is for building the point-spheres
            sphere = vtkSphereSource()
            sphere.SetCenter(0, 0, 0)
            sphere.SetRadius(0.2)
            sphere.SetPhiResolution(100)
            sphere.SetThetaResolution(100)
    
            self.glyph = vtkGlyph3D()
            self.glyph.GeneratePointIdsOn()
            self.glyph.SetInputData(polydata)
            self.glyph.SetScaleModeToScaleByScalar() 
            self.glyph.SetSourceConnection(sphere.GetOutputPort())
            self.glyph.Update()
    
            sphere_mapper = vtkPolyDataMapper()
            sphere_mapper.SetLookupTable(self.lut_color)
            sphere_mapper.SetInputConnection(self.glyph.GetOutputPort())
            sphere_mapper.SetScalarModeToUsePointFieldData()
            sphere_mapper.SelectColorArray('color')
            
            ball_actor = vtkLODActor()
            ball_actor.SetMapper(sphere_mapper)
            ball_actor.GetProperty().SetAmbient(0.2)
            ball_actor.GetProperty().SetDiffuse(0.5)
            ball_actor.GetProperty().SetSpecular(0.3)
    
            self._picking_domain = ball_actor
    
            assembly = vtkAssembly()
            assembly.AddPart(ball_actor)
    
            return assembly
        
        def clear_point_actors(self):
            if not hasattr(self, 'current_point_actors'):
                pass
            else:
                self.current_point_actors.VisibilityOff()
                self.current_point_actors.ReleaseGraphicsResources(self.ren_win)
                self.ren.RemoveActor(self.current_point_actors)
    
        def on_pick_left(self, obj, event=None):
            """Event handler when a point is mouse-picked with the left mouse button
            """
            
            if not hasattr(self, '_picking_domain'):
                return
    
            # Get the picked position and retrieve the index of the target that was picked from it
            pos = obj.GetEventPosition()
    
            picker = vtkPointPicker()
            picker.SetTolerance(0.005)
    
            picker.AddPickList(self._picking_domain)
            picker.PickFromListOn()
            picker.Pick(pos[0], pos[1], 0, self.ren)
            pid = picker.GetPointId()
            if pid > 0:
                idx = int(self.glyph.GetOutput().GetPointData().GetArray('index').GetTuple1(pid))
                text = f'Index: {idx} {self.current_points_to_plot[idx]}'
                print(text)
                self.actor_title.SetInput(text)
        
        def on_pick_right(self, obj, event=None):
            """Clears the the text field when right mouse button is clicked
            """
        
            self.actor_title.SetInput(f'')
    
        @staticmethod
        def sphere_prop_to_vtkarray(n_sphere, radius, color):
            radii = vtkFloatArray()
            radii.SetName('radius')
            for _ in range(n_sphere):
                radii.InsertNextTuple1(radius)
    
            colors = vtkFloatArray()
            colors.SetName('color')
            for _ in range(n_sphere):
                colors.InsertNextTuple1(color)
    
            indices = vtkIntArray()
            indices.SetName('index')
            for idx in range(n_sphere):
                indices.InsertNextTuple1(idx)
    
            return radii, colors, indices
            
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        window = MainWindow()
        window.startup()
        sys.exit(app.exec_())
    

    Here is the screenshot of the result: enter image description here

    However, I am not entirely sure if this is the best way to achieve my goal. Any comments are appreciated.