Search code examples
pythonpyqt5rotationpyqtgraph

Rotate curves in place in pyqtgraph


Is it possible to rotate curves independently of their axis' values in a graph? I made this test file:

import pyqtgraph as pg
from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QSlider
from PyQt5.QtCore import Qt, QPointF
from PyQt5.QtGui import QPen, QTransform
import numpy as np


class CurveViewer(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Curve Viewer")

        self.central_widget = QWidget(self)
        self.setCentralWidget(self.central_widget)

        self.layout = QVBoxLayout(self.central_widget)

        # Create a GraphicsLayoutWidget to hold the plot
        self.graph_widget = pg.GraphicsLayoutWidget()
        self.layout.addWidget(self.graph_widget)

        # Create sliders for controlling the selected curve
        self.slider_x = QSlider(Qt.Horizontal)
        self.slider_x.setRange(-100, 100)
        self.layout.addWidget(self.slider_x)

        self.slider_y = QSlider(Qt.Vertical)
        self.slider_y.setRange(-100, 100)
        self.layout.addWidget(self.slider_y)

        self.slider_rotation = QSlider(Qt.Horizontal)
        self.slider_rotation.setRange(-180, 180)  # Angle range in degrees
        self.layout.addWidget(self.slider_rotation)

        # Create a single PlotItem to hold all curves
        self.plot_item = self.graph_widget.addPlot()
        self.curves = []
        self.curve_data = []  # Store initial curve data
        self.offsets = []  # Store offsets for each curve
        self.rotations = []  # Store rotations for each curve

        # Create example plot data with multiple curves
        num_curves = 3
        for i in range(num_curves):
            x_data = np.linspace(-2, 2, 100)
            y_data = np.sin(x_data) + i
            pen = pg.mkPen(color=pg.intColor(i), width=1.0)
            curve_item = self.plot_item.plot(x_data, y_data, pen=pen, clickable=True)
            curve_item.setCurveClickable(True)
            curve_item.sigClicked.connect(self.__handle_curve_click)
            self.curves.append(curve_item)
            self.curve_data.append((x_data, y_data))
            self.offsets.append((0, 0))  # Initialize offsets to zero
            self.rotations.append(0)  # Initialize rotations to zero

        # Store the currently clicked curve
        self.clicked_curve = None

        # Copy the original data for all curves
        self.original_curve_data = self.curve_data.copy()

        # Connect slider valueChanged signals to slot functions
        self.slider_x.valueChanged.connect(self.update_curve_position)
        self.slider_y.valueChanged.connect(self.update_curve_position)
        self.slider_rotation.valueChanged.connect(self.update_curve_rotation)

    def __handle_curve_click(self, curve_item):
        # Reset the pen properties of all curves to the default thickness
        for curve in self.curves:
            pen = curve.opts['pen']
            pen = pg.mkPen(pen)
            default_pen = pg.mkPen(color=pen.color(), width=1.0)
            curve.setPen(default_pen)

        # Modify the pen properties of the selected curve to make it thicker
        pen = curve_item.opts['pen']
        pen = pg.mkPen(pen)
        selected_pen = pg.mkPen(color=pen.color(), width=2.0)
        curve_item.setPen(selected_pen)

        self.clicked_curve = curve_item

        # Set slider values to match the current offset and rotation of the clicked curve
        x_offset, y_offset = self.offsets[self.curves.index(self.clicked_curve)]
        rotation = self.rotations[self.curves.index(self.clicked_curve)]
        self.slider_x.setValue(int(x_offset))
        self.slider_y.setValue(int(y_offset))
        self.slider_rotation.setValue(int(rotation))

    def update_curve_position(self):
        if self.clicked_curve is not None:
            x_offset = self.slider_x.value()
            y_offset = self.slider_y.value()
            index = self.curves.index(self.clicked_curve)
            x_data, y_data = self.original_curve_data[index]
            rotation_degrees = self.rotations[index]

            # Update the offset for the clicked curve
            self.offsets[index] = (x_offset, y_offset)

            # Apply the rotation transformation first
            rotation_matrix = QTransform().rotate(rotation_degrees)
            rotated_x_data, rotated_y_data = [], []
            for x, y in zip(x_data, y_data):
                rotated_point = rotation_matrix.map(QPointF(x, y))
                rotated_x_data.append(rotated_point.x())
                rotated_y_data.append(rotated_point.y())

            # Then apply the translation (offset) transformation
            translated_x_data = [x + x_offset for x in rotated_x_data]
            translated_y_data = [y + y_offset for y in rotated_y_data]

            # Update the data for the clicked curve
            self.clicked_curve.setData(translated_x_data, translated_y_data)

    def update_curve_rotation(self):
        if self.clicked_curve is not None:
            rotation_degrees = self.slider_rotation.value()
            index = self.curves.index(self.clicked_curve)
            x_data, y_data = self.original_curve_data[index]
            x_offset, y_offset = self.offsets[index]

            # Update the rotation for the clicked curve
            self.rotations[index] = rotation_degrees

            # Apply both the translation (offset) and rotation transformations
            rotation_matrix = QTransform().rotate(rotation_degrees)
            rotated_x_data, rotated_y_data = [], []
            for x, y in zip(x_data, y_data):
                rotated_point = rotation_matrix.map(QPointF(x, y))
                rotated_x_data.append(rotated_point.x())
                rotated_y_data.append(rotated_point.y())

            translated_x_data = [x + x_offset for x in rotated_x_data]
            translated_y_data = [y + y_offset for y in rotated_y_data]

            # Update the data for the clicked curve
            self.clicked_curve.setData(translated_x_data, translated_y_data)


if __name__ == "__main__":
    app = QApplication([])
    viewer = CurveViewer()
    viewer.show()
    app.exec_()

In this test file I have 3 lines/curves which I can select. By selecting a line it gets thicker such that the user knows it's selected. Then I can move and rotate the selected curve with sliders. I didn't have an issue with rotating the curves in the test file because it was only integer values, but in my original program I have different values on the axis'.

Let's say I have a graph with y-axis range(1000, 6000) and x-axis range(0, 0.00008)

On x-axis I obviously have tiny values. The movement I fixed with scaling factors but I don't know how to solve the rotation. Also didn't find any proper solution in the web. So is it possible to just 'ignore' the values of the axis and still being able to rotate the curve?


Solution

  • You can scale the points by each axis' visible size before applying the rotation. This applies it in an artificial space where the screen is a square of side 1. We then rescale to your points' actual space before plotting them.

    The effect of these transformations is to make the curves rotate onscreen without being deformed (to check it, you can manually unzoom then zoom, which will disable axis auto-scaling).

    The code is below:

    # Apply both the translation (offset) and rotation transformations
    rotation_matrix = QTransform().rotate(rotation_degrees)
    rotated_x_data, rotated_y_data = [], []
    x_range, y_range = self.plot_item.getViewBox().viewRange()
    x_size = x_range[1] - x_range[0]
    y_size = y_range[1] - y_range[0]
    for x, y in zip(x_data / x_size, y_data / y_size):
        rotated_point = rotation_matrix.map(QPointF(x, y))
        rotated_x_data.append(rotated_point.x() * x_size)
        rotated_y_data.append(rotated_point.y() * y_size)
    

    Note: This should probably be made into a function, as the code is currently duplicated for translation and rotation.