Search code examples
python3ddata-visualizationsubplotpyqtgraph

Link cameras' positions of two 3D subplots in pyqtgraph


I need to draw two 3D surface plots in the same window and I would like to "link" the cameras' positions of them, so that if a user moves the view of the first subplot, the second one updates accordingly, in order to keep the same view orientation.
I can successfully achieve this result in matplotlib, as explained in this answer:

import matplotlib.pyplot as plt
import numpy as np
from matplotlib import cm


N = 101
x = np.linspace(-10, 10, N)
y = np.linspace(-10, 10, N)

X, Y = np.meshgrid(x, y)

Z1 = np.sin(X) + np.cos(Y)
Z2 = np.cos(np.sqrt(X**2 + Y**2))


fig = plt.figure(figsize = plt.figaspect(0.5))

ax1 = fig.add_subplot(1, 2, 1, projection = '3d')
surf1 = ax1.plot_surface(X, Y, Z1, rstride = 1, cstride = 1, cmap = cm.jet, linewidth = 0, antialiased = False)

ax2 = fig.add_subplot(1, 2, 2, projection = '3d')
surf2 = ax2.plot_surface(X, Y, Z2, rstride = 1, cstride = 1, cmap = cm.jet, linewidth = 0, antialiased = False)


def on_move(event):
    if event.inaxes == ax1:
        if ax1.button_pressed in ax1._rotate_btn:
            ax2.view_init(elev = ax1.elev, azim = ax1.azim)
        elif ax1.button_pressed in ax1._zoom_btn:
            ax2.set_xlim3d(ax1.get_xlim3d())
            ax2.set_ylim3d(ax1.get_ylim3d())
            ax2.set_zlim3d(ax1.get_zlim3d())
    elif event.inaxes == ax2:
        if ax2.button_pressed in ax2._rotate_btn:
            ax1.view_init(elev = ax2.elev, azim = ax2.azim)
        elif ax2.button_pressed in ax2._zoom_btn:
            ax1.set_xlim3d(ax2.get_xlim3d())
            ax1.set_ylim3d(ax2.get_ylim3d())
            ax1.set_zlim3d(ax2.get_zlim3d())
    else:
        return
    fig.canvas.draw_idle()


c1 = fig.canvas.mpl_connect('motion_notify_event', on_move)

plt.show()

enter image description here

However, I must use pyqtgraph to perform this task. I can draw the two surfaces, but I don't know how to "link" the cameras' positions of the two subplots.

import numpy as np
import pyqtgraph as pg
import pyqtgraph.opengl as gl
from matplotlib.cm import get_cmap
from pyqtgraph.Qt import QtGui


N = 101
x = np.linspace(-10, 10, N)
y = np.linspace(-10, 10, N)
cmap = get_cmap('jet')


X, Y = np.meshgrid(x, y)


Z1 = np.sin(X) + np.cos(Y)

Z1_min = np.min(Z1)
Z1_max = np.max(Z1)
colors1 = cmap((Z1 - Z1_min)/(Z1_max - Z1_min))


Z2 = np.cos(np.sqrt(X**2 + Y**2))

Z2_min = np.min(Z2)
Z2_max = np.max(Z2)
colors2 = cmap((Z2 - Z2_min)/(Z2_max - Z2_min))


win = pg.GraphicsLayoutWidget(show = True, size = (1000, 500))
layoutgb = QtGui.QGridLayout()
win.setLayout(layoutgb)

glvw1 = gl.GLViewWidget()
surf1 = gl.GLSurfacePlotItem(x = x, y = y, z = Z1, colors = colors1)
glvw1.addItem(surf1)
glvw1.sizeHint = lambda: pg.QtCore.QSize(100, 500)
layoutgb.addWidget(glvw1, 0, 0)

glvw2 = gl.GLViewWidget()
surf2 = gl.GLSurfacePlotItem(x = x, y = y, z = Z2, colors = colors2)
glvw2.addItem(surf2)
glvw2.sizeHint = lambda: pg.QtCore.QSize(100, 500)
layoutgb.addWidget(glvw2, 0, 2)

QtGui.QApplication.instance().exec_()

enter image description here


Solution

  • To achieve what You need, You have to sync "Camera" with all linked plots.
    I choose to override gl.GLViewWidget (SyncedCameraViewWidget) and re-implement three events wheelEvent, mouseMoveEvent and mouseReleaseEvent.

    To make things work You have to sync_camera_with with another SyncedCameraViewWidget.
    Technically You can use pure gl.GLViewWidget there.
    I like flexibility, so I'm not enforcing passing another SyncedCameraViewWidget there.
    That's why You have to link PlotA -> PlotB and PlotB -> PlotA.
    Otherwise, when You change Camera of PlotB it won't change PlotA Camera.

    Here is Your code with SyncedCameraViewWidget:

    from typing import List
    
    import numpy as np
    import pyqtgraph as pg
    import pyqtgraph.opengl as gl
    from matplotlib.cm import get_cmap
    from pyqtgraph.Qt import QtGui
    
    
    class SyncedCameraViewWidget(gl.GLViewWidget):
    
        def __init__(self, parent=None, devicePixelRatio=None, rotationMethod='euler'):
            self.linked_views: List[gl.GLViewWidget] = []
            super().__init__(parent, devicePixelRatio, rotationMethod)
    
        def wheelEvent(self, ev):
            """Update view on zoom event"""
            super().wheelEvent(ev)
            self._update_views()
    
        def mouseMoveEvent(self, ev):
            """Update view on move event"""
            super().mouseMoveEvent(ev)
            self._update_views()
    
        def mouseReleaseEvent(self, ev):
            """Update view on move event"""
            super().mouseReleaseEvent(ev)
            self._update_views()
    
        def _update_views(self):
            """Take camera parameters and sync with all views"""
            camera_params = self.cameraParams()
            # Remove rotation, we can't update all params at once (Azimuth and Elevation)
            camera_params["rotation"] = None
            for view in self.linked_views:
                view.setCameraParams(**camera_params)
    
        def sync_camera_with(self, view: gl.GLViewWidget):
            """Add view to sync camera with"""
            self.linked_views.append(view)
    
    
    N = 101
    x = np.linspace(-10, 10, N)
    y = np.linspace(-10, 10, N)
    cmap = get_cmap('jet')
    
    X, Y = np.meshgrid(x, y)
    
    Z1 = np.sin(X) + np.cos(Y)
    
    Z1_min = np.min(Z1)
    Z1_max = np.max(Z1)
    colors1 = cmap((Z1 - Z1_min) / (Z1_max - Z1_min))
    
    Z2 = np.cos(np.sqrt(X ** 2 + Y ** 2))
    
    Z2_min = np.min(Z2)
    Z2_max = np.max(Z2)
    colors2 = cmap((Z2 - Z2_min) / (Z2_max - Z2_min))
    
    win = pg.GraphicsLayoutWidget(show=True, size=(1000, 500))
    layoutgb = QtGui.QGridLayout()
    win.setLayout(layoutgb)
    
    glvw1 = SyncedCameraViewWidget()
    surf1 = gl.GLSurfacePlotItem(x=x, y=y, z=Z1, colors=colors1)
    glvw1.addItem(surf1)
    layoutgb.addWidget(glvw1, 0, 0)
    
    glvw2 = SyncedCameraViewWidget()
    surf2 = gl.GLSurfacePlotItem(x=x, y=y, z=Z2, colors=colors2)
    glvw2.addItem(surf2)
    layoutgb.addWidget(glvw2, 0, 2)
    
    glvw1.sync_camera_with(glvw2)
    glvw2.sync_camera_with(glvw1)
    
    QtGui.QApplication.instance().exec_()
    

    Notice that in the Event method, You must first execute Event in gl.GLViewWidget (change camera in main plot) and only after that _update_views() (change camera for all linked plots).
    Otherwise plot camera won't be in sync.