Search code examples
pythonpyqtpyqt5qgraphicsviewqgraphicsscene

How to properly scale GraphicsScene


I've created a small app and I'm trying to make it so that when the main window is resized (and the GraphicsView and scene are too) that the whole scene (pixmap and rectangles) scale vertically to fit completely inside the GraphicsView. I don't want a vertical scrollbar and I don't want it to scale horizontally.

I can't figure out how to scale the scene properly. I use a GraphicsScene to contain a graph and a couple vertical rectangle "markers". When I can scale the graph to fit by redrawing the pixmap and then reattach it, the z-order is wrong AND the rectangle widgets are not scaled with it.

I need to keep track of the rectangle widgets, so I can't just keep deleting and re-adding them as there's meta data along with each one.

I know about fitInView (from here: Issue with fitInView of QGraphicsView when ItemIgnoresTransformations is on) that applies to the containing GraphicsView, but I don't understand why it needs a parameter. I just want the scene to fit in the GraphicsView (vertically but not horizontally) so why doesn't GraphicsView just scale everything in the scene to fit inside it's current size? What should the parameter look like to get the scene to fit vertically?

In the resizeEvent I can redraw the pixmap and re-add, but then it covers the rectangles as the z-order is messed up. Also, it doesn't stay centered vertically in the scene and I would need to copy over the meta data.

import sys
import os
from PyQt5 import QtCore, QtGui, QtWidgets
import PyQt5 as qt
from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt, QPoint
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QHBoxLayout, QGroupBox, QDialog, QVBoxLayout
from PyQt5.QtWidgets import QVBoxLayout, QGridLayout, QStackedWidget, QTabWidget
import numpy as np

class GraphicsScene(QtWidgets.QGraphicsScene):
    def __init__(self, parent=None):
        super(GraphicsScene, self).__init__(parent)

    def minimumSizeHint(self):
        return QtCore.QSize(300, 200)

    def dragMoveEvent(self, event):
        print("dragMoveEvent", event)

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        #super(MainWindow).__init__()

        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)

        max_x, max_y = 2400, 700
        max_x_view = 1200
        self.max_x = max_x
        self.max_y = max_y
        self.first = True
        self.setGeometry(200, 200, max_x_view, self.max_y)

        self.gv = QtWidgets.QGraphicsView(self)
        self.gv.setGeometry(0, 0, max_x_view, self.max_y)
        self.gv2 = QtWidgets.QGraphicsView(self)

        layout.addWidget(self.gv)
        layout.addWidget(self.gv2)

        scene = GraphicsScene()
        self.scene = scene

        self.gv.setScene(scene)
        tab_widget = QTabWidget()
        tab_widget.setTabPosition(QTabWidget.West)
        widget = QWidget()
        widget.setLayout(layout)
        tab_widget.addTab(widget, "main")

        self.setCentralWidget(tab_widget)
        np.random.seed(777)
        self.x_time = np.linspace(0, 12.56, 3000)
        rand_data = np.random.uniform(0.0, 1.0, 3000)
        self.data = .45*(np.sin(2*self.x_time) + rand_data) - .25*(np.sin(3*self.x_time))
        self.first = True

        pixmap_height = max_y//2 - 2*22  # 22 to take care of scrollbar height
        pixmap = self.draw_graph()

        pen = QtGui.QPen()
        pen.setWidth(2)
        pen.setColor(QtGui.QColor("red"))
        self.gv1_pixmap = scene.addPixmap(pixmap)
        rect = scene.sceneRect()
        print("scene rect = {}".format(rect))
        scene.setSceneRect(rect)
        side, offset = 50, 200

        for i in range(2):
            r = QtCore.QRectF(QtCore.QPointF((i + 1)*offset + i * 2 * side, 2), QtCore.QSizeF(side, pixmap_height - 4))
            rect_ref = scene.addRect(r, pen, QColor(255, 0, 0, 127))
            rect_ref.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable)

        all_items = scene.items()
        print(all_items)

    def draw_graph(self):
        print("draw_graph: main Window size {}:".format(self.size()))

        pixmap_height = self.height()//2 - 2*22  # 22 to take care of scrollbar height
        x_final = self.x_time[-1]
        data = self.data / np.max(np.abs(self.data))
        data = [abs(int(k * pixmap_height)) for k in self.data]
        x_pos = [int(self.x_time[i] * self.max_x / x_final) for i in range(len(data))]

        pixmap = QtGui.QPixmap(self.max_x, pixmap_height)
        painter = QtGui.QPainter(pixmap)
        pen = QtGui.QPen()
        pen.setWidth(2)
        rect = pixmap.rect()
        pen.setColor(QtGui.QColor("red"))
        painter.drawRect(rect)
        print("pixmap rect = {}".format(rect))
        painter.fillRect(rect, QtGui.QColor('lightblue'))
        pen.setWidth(2)
        pen.setColor(QtGui.QColor("green"))
        painter.setPen(pen)
        for x, y in zip(x_pos, data):
            painter.drawLine(x, pixmap_height, x, pixmap_height - y)
        painter.end()
        return pixmap

    def resizeEvent(self, a0: QtGui.QResizeEvent):
        #print("main Window resizeEvent")
        print("main Window  size {}:".format(a0.size()))

        redraw = False
        if redraw:
            pixmap = self.draw_graph()
            self.scene.removeItem(self.gv1_pixmap)
            self.gv1_pixmap = self.scene.addPixmap(pixmap)
            self.gv1_pixmap.moveBy(0, 30)
        else:
            #rect = QtCore.QRect(self.gv.startPos, self.gv.endPos)
            #sceneRect = self.gv.mapToScene(rect).boundingRect()
            #print 'Selected area: viewport coordinate:', rect,', scene coordinate:', sceneRect
            #self.gv.fitInView(sceneRect)
            pass

app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

Solution

  • My solution will fit the height of the smallest rectangle that encapsulates all the items (sceneRect) to the viewport of the QGraphicsView. So set the height of the items a value not so small so that the image quality is not lost. I have also scaled the items using the QTransforms. In addition, the QGraphicsView coordinate system was inverted since by default the vertical axis is top-bottom and I have inverted it so that the painting is more consistent with the data.

    I have refactored the OP code to make it more scalable, there is a GraphItem that takes the data (x, y) and the image dimensions.

    Considering the above, the solution is:

    import numpy as np
    from PyQt5 import QtCore, QtGui, QtWidgets
    
    
    class GraphItem(QtWidgets.QGraphicsPixmapItem):
        def __init__(self, xdata, ydata, width, height, parent=None):
            super(GraphItem, self).__init__(parent)
    
            self._xdata = xdata
            self._ydata = ydata
            self._size = QtCore.QSize(width, height)
            self.redraw()
    
        def redraw(self):
            x_final = self._xdata[-1]
            pixmap = QtGui.QPixmap(self._size)
            pixmap_height = pixmap.height()
            pixmap.fill(QtGui.QColor("lightblue"))
            painter = QtGui.QPainter(pixmap)
    
            pen = QtGui.QPen(QtGui.QColor("green"))
            pen.setWidth(2)
            painter.setPen(pen)
            for i, (x, y) in enumerate(
                zip(self._xdata, self._ydata / np.max(np.abs(self._ydata)))
            ):
                x_pos = int(x * self._size.width() / x_final)
                y_pos = abs(int(y * pixmap_height))
                painter.drawLine(x_pos, 0, x_pos, y_pos)
    
            painter.end()
            self.setPixmap(pixmap)
    
    
    class HorizontalRectItem(QtWidgets.QGraphicsRectItem):
        def itemChange(self, change, value):
            if change == QtWidgets.QGraphicsItem.ItemPositionChange and self.scene():
                newPos = self.pos()
                newPos.setX(value.x())
                return newPos
            return super(HorizontalRectItem, self).itemChange(change, value)
    
    
    class GraphicsView(QtWidgets.QGraphicsView):
        def __init__(self, parent=None):
            super(GraphicsView, self).__init__(parent)
            scene = QtWidgets.QGraphicsScene(self)
            self.setScene(scene)
            self.scale(1, -1)
    
        def resizeEvent(self, event):
    
            h = self.mapToScene(self.viewport().rect()).boundingRect().height()
            r = self.sceneRect()
            r.setHeight(h)
            self.setSceneRect(r)
    
            height = self.viewport().height()
            for item in self.items():
                item_height = item.boundingRect().height()
                tr = QtGui.QTransform()
                tr.scale(1, height / item_height)
                item.setTransform(tr)
    
            super(GraphicsView, self).resizeEvent(event)
    
    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self, parent=None):
            super(MainWindow, self).__init__(parent)
    
            tab_widget = QtWidgets.QTabWidget(tabPosition=QtWidgets.QTabWidget.West)
            self.setCentralWidget(tab_widget)
    
            self.graphics_view_top = GraphicsView()
            self.graphics_view_bottom = QtWidgets.QGraphicsView()
    
            container = QtWidgets.QWidget()
            lay = QtWidgets.QVBoxLayout(container)
            lay.addWidget(self.graphics_view_top)
            lay.addWidget(self.graphics_view_bottom)
    
            tab_widget.addTab(container, "main")
    
            self.resize(640, 480)
    
            side, offset, height = 50, 200, 400
    
            np.random.seed(777)
            x_time = np.linspace(0, 12.56, 3000)
            rand_data = np.random.uniform(0.0, 1.0, 3000)
            data = 0.45 * (np.sin(2 * x_time) + rand_data) - 0.25 * (np.sin(3 * x_time))
    
            graph_item = GraphItem(x_time, data, 3000, height)
            self.graphics_view_top.scene().addItem(graph_item)
    
            for i in range(2):
                r = QtCore.QRectF(
                    QtCore.QPointF((i + 1) * offset + i * 2 * side, 2),
                    QtCore.QSizeF(side, height),
                )
                it = HorizontalRectItem(r)
                it.setPen(QtGui.QPen(QtGui.QColor("red"), 2))
                it.setBrush(QtGui.QColor(255, 0, 0, 127))
                self.graphics_view_top.scene().addItem(it)
                it.setFlags(
                    it.flags()
                    | QtWidgets.QGraphicsItem.ItemIsMovable
                    | QtWidgets.QGraphicsItem.ItemSendsGeometryChanges
                )
    
    
    def main():
        import sys
    
        app = QtWidgets.QApplication(sys.argv)
        w = MainWindow()
        w.show()
    
        ret = app.exec_()
    
        sys.exit(ret)
    
    
    if __name__ == "__main__":
        main()