Search code examples
pythonpython-3.xpyqt4zoomingqgraphicsview

How to enable Pan and Zoom in a QGraphicsView


I am using python and Qt Designer to implement loading tiff images and to enable Pan and Zoom on some mouse event (wheel - zoom, press wheel - pan).

I was looking into some options and classes that can work with images etc, and so far I have found:

QGraphicsScene, QImage, QGraphicsView

I have three classes (just testing)

  1. ViewerDemo which have QGraphicsView element:

        """description of class"""
        # Form implementation generated from reading ui file 'GraphicsViewdemo.ui'
        try:
        _fromUtf8 = QtCore.QString.fromUtf8
        except AttributeError:
            def _fromUtf8(s):
                return s
            class Ui_Dialog(object):
                def setupUi(self, Dialog):
                    Dialog.setObjectName(("Dialog"))
                    Dialog.resize(500, 500)
                self.graphicsView = QtGui.QGraphicsView(Dialog)
                self.graphicsView.setGeometry(QtCore.QRect(0, 0, 500, 500))
                self.graphicsView.setObjectName(("graphicsView"))
                self.retranslateUi(Dialog)
                QtCore.QMetaObject.connectSlotsByName(Dialog)
            def retranslateUi(self, Dialog):
                Dialog.setWindowTitle(QtGui.QApplication.translate("Dialog", "Dialog", None,
    QtGui.QApplication.UnicodeUTF8))
    
  2. MyForm class, that is QDialog, where I call class ViewerDemo, loading Image, and put image into QGraphicsView

        import sys
        from ViewerDemo import *
        from PyQt4 import QtGui
        class MyForm(QtGui.QDialog):
            def __init__(self, url, parent=None):
                QtGui.QWidget.__init__(self, parent)
    
    
                self.ui = Ui_Dialog()
                self.ui.setupUi(self)
                self.scene = QtGui.QGraphicsScene(self)
               self.image = QtGui.QImage(url)
                pixmap= QtGui.QPixmap.fromImage(self.image)
                item=QtGui.QGraphicsPixmapItem(pixmap)
                self.scene.addItem(item)
                self.ui.graphicsView.setScene(self.scene)
                self.scale = 1
                QtCore.QObject.connect(self.scene, QtCore.SIGNAL('mousePressEvent()'),self.mousePressEvent)
    
        def mousePressEvent(self, event):
            print ('PRESSED : ',event.pos())
    

(3) is just where the application is executing:

    from PyQt4 import QtGui, QtCore
    import sys
    from MyForm import MyForm
    if __name__ == "__main__":
        app = QtGui.QApplication(sys.argv)
        url = "D:/probaTiff"
        myapp = MyForm(url)
        myapp.show()
        sys.exit(app.exec_())

I found how to do something on mouse-click (left and wheel click), to print pixel coordinates (I will need that to get the coordinates in the Coordinate System of the picture WGS84, for example).

What I need more, is how to zoom picture (wheel or double click, whatever) and to pan picture (holding left mouse click or holding wheel).

Or, is there some better Qt classes for doing this, and some better way Can you help me please?

This is what I have so far with this code


Solution

  • This is not too difficult to do using the built in capabilities of QGraphicsView.

    The demo script below has left-button panning and wheel zoom (including anchoring to the current cursor position). It also shows the pixel coordinates under the mouse and allows pinning the current zoom level. The fitInView method has been reimplemented because the built in version adds a weird fixed margin that can't be removed.

    PyQt6 version:

    from PyQt6 import QtCore, QtGui, QtWidgets
    
    SCALE_FACTOR = 1.25
    
    
    class PhotoViewer(QtWidgets.QGraphicsView):
        coordinatesChanged = QtCore.pyqtSignal(QtCore.QPoint)
    
        def __init__(self, parent):
            super().__init__(parent)
            self._zoom = 0
            self._pinned = False
            self._empty = True
            self._scene = QtWidgets.QGraphicsScene(self)
            self._photo = QtWidgets.QGraphicsPixmapItem()
            self._photo.setShapeMode(
                QtWidgets.QGraphicsPixmapItem.ShapeMode.BoundingRectShape)
            self._scene.addItem(self._photo)
            self.setScene(self._scene)
            self.setTransformationAnchor(
                QtWidgets.QGraphicsView.ViewportAnchor.AnchorUnderMouse)
            self.setResizeAnchor(
                QtWidgets.QGraphicsView.ViewportAnchor.AnchorUnderMouse)
            self.setVerticalScrollBarPolicy(
                QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
            self.setHorizontalScrollBarPolicy(
                QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
            self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(30, 30, 30)))
            self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
    
        def hasPhoto(self):
            return not self._empty
    
        def resetView(self, scale=1):
            rect = QtCore.QRectF(self._photo.pixmap().rect())
            if not rect.isNull():
                self.setSceneRect(rect)
                if (scale := max(1, scale)) == 1:
                    self._zoom = 0
                if self.hasPhoto():
                    unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
                    self.scale(1 / unity.width(), 1 / unity.height())
                    viewrect = self.viewport().rect()
                    scenerect = self.transform().mapRect(rect)
                    factor = min(viewrect.width() / scenerect.width(),
                                 viewrect.height() / scenerect.height()) * scale
                    self.scale(factor, factor)
                    if not self.zoomPinned():
                        self.centerOn(self._photo)
                    self.updateCoordinates()
    
        def setPhoto(self, pixmap=None):
            if pixmap and not pixmap.isNull():
                self._empty = False
                self.setDragMode(QtWidgets.QGraphicsView.DragMode.ScrollHandDrag)
                self._photo.setPixmap(pixmap)
            else:
                self._empty = True
                self.setDragMode(QtWidgets.QGraphicsView.DragMode.NoDrag)
                self._photo.setPixmap(QtGui.QPixmap())
            if not (self.zoomPinned() and self.hasPhoto()):
                self._zoom = 0
            self.resetView(SCALE_FACTOR ** self._zoom)
    
        def zoomLevel(self):
            return self._zoom
    
        def zoomPinned(self):
            return self._pinned
    
        def setZoomPinned(self, enable):
            self._pinned = bool(enable)
    
        def zoom(self, step):
            zoom = max(0, self._zoom + (step := int(step)))
            if zoom != self._zoom:
                self._zoom = zoom
                if self._zoom > 0:
                    if step > 0:
                        factor = SCALE_FACTOR ** step
                    else:
                        factor = 1 / SCALE_FACTOR ** abs(step)
                    self.scale(factor, factor)
                else:
                    self.resetView()
    
        def wheelEvent(self, event):
            delta = event.angleDelta().y()
            self.zoom(delta and delta // abs(delta))
    
        def resizeEvent(self, event):
            super().resizeEvent(event)
            self.resetView()
    
        def toggleDragMode(self):
            if self.dragMode() == QtWidgets.QGraphicsView.DragMode.ScrollHandDrag:
                self.setDragMode(QtWidgets.QGraphicsView.DragMode.NoDrag)
            elif not self._photo.pixmap().isNull():
                self.setDragMode(QtWidgets.QGraphicsView.DragMode.ScrollHandDrag)
    
        def updateCoordinates(self, pos=None):
            if self._photo.isUnderMouse():
                if pos is None:
                    pos = self.mapFromGlobal(QtGui.QCursor.pos())
                point = self.mapToScene(pos).toPoint()
            else:
                point = QtCore.QPoint()
            self.coordinatesChanged.emit(point)
    
        def mouseMoveEvent(self, event):
            self.updateCoordinates(event.position().toPoint())
            super().mouseMoveEvent(event)
    
        def leaveEvent(self, event):
            self.coordinatesChanged.emit(QtCore.QPoint())
            super().leaveEvent(event)
    
    
    class Window(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            self.viewer = PhotoViewer(self)
            self.viewer.coordinatesChanged.connect(self.handleCoords)
            self.labelCoords = QtWidgets.QLabel(self)
            self.labelCoords.setAlignment(
                QtCore.Qt.AlignmentFlag.AlignRight |
                QtCore.Qt.AlignmentFlag.AlignCenter)
            self.buttonOpen = QtWidgets.QPushButton(self)
            self.buttonOpen.setText('Open Image')
            self.buttonOpen.clicked.connect(self.handleOpen)
            self.buttonPin = QtWidgets.QPushButton(self)
            self.buttonPin.setText('Pin Zoom')
            self.buttonPin.setCheckable(True)
            self.buttonPin.toggled.connect(self.viewer.setZoomPinned)
            layout = QtWidgets.QGridLayout(self)
            layout.addWidget(self.viewer, 0, 0, 1, 3)
            layout.addWidget(self.buttonOpen, 1, 0, 1, 1)
            layout.addWidget(self.buttonPin, 1, 1, 1, 1)
            layout.addWidget(self.labelCoords, 1, 2, 1, 1)
            layout.setColumnStretch(2, 2)
            self._path = None
    
        def handleCoords(self, point):
            if not point.isNull():
                self.labelCoords.setText(f'{point.x()}, {point.y()}')
            else:
                self.labelCoords.clear()
    
        def handleOpen(self):
            if (start := self._path) is None:
                start = QtCore.QStandardPaths.standardLocations(
                    QtCore.QStandardPaths.StandardLocation.PicturesLocation)[0]
            if path := QtWidgets.QFileDialog.getOpenFileName(
                self, 'Open Image', start)[0]:
                self.labelCoords.clear()
                if not (pixmap := QtGui.QPixmap(path)).isNull():
                    self.viewer.setPhoto(pixmap)
                    self._path = path
                else:
                    QtWidgets.QMessageBox.warning(self, 'Error',
                        f'<br>Could not load image file:<br>'
                        f'<br><b>{path}</b><br>'
                        )
    
    
    if __name__ == '__main__':
        import sys
        app = QtWidgets.QApplication(sys.argv)
        window = Window()
        window.setGeometry(500, 300, 800, 600)
        window.show()
        sys.exit(app.exec())
    

    PyQt5 version:

    from PyQt5 import QtCore, QtGui, QtWidgets
    
    SCALE_FACTOR = 1.25
    
    
    class PhotoViewer(QtWidgets.QGraphicsView):
        coordinatesChanged = QtCore.pyqtSignal(QtCore.QPoint)
    
        def __init__(self, parent):
            super().__init__(parent)
            self._zoom = 0
            self._pinned = False
            self._empty = True
            self._scene = QtWidgets.QGraphicsScene(self)
            self._photo = QtWidgets.QGraphicsPixmapItem()
            self._photo.setShapeMode(
                QtWidgets.QGraphicsPixmapItem.BoundingRectShape)
            self._scene.addItem(self._photo)
            self.setScene(self._scene)
            self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
            self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
            self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(30, 30, 30)))
            self.setFrameShape(QtWidgets.QFrame.NoFrame)
    
        def hasPhoto(self):
            return not self._empty
    
        def resetView(self, scale=1):
            rect = QtCore.QRectF(self._photo.pixmap().rect())
            if not rect.isNull():
                self.setSceneRect(rect)
                if (scale := max(1, scale)) == 1:
                    self._zoom = 0
                if self.hasPhoto():
                    unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
                    self.scale(1 / unity.width(), 1 / unity.height())
                    viewrect = self.viewport().rect()
                    scenerect = self.transform().mapRect(rect)
                    factor = min(viewrect.width() / scenerect.width(),
                                 viewrect.height() / scenerect.height()) * scale
                    self.scale(factor, factor)
                    if not self.zoomPinned():
                        self.centerOn(self._photo)
                    self.updateCoordinates()
    
        def setPhoto(self, pixmap=None):
            if pixmap and not pixmap.isNull():
                self._empty = False
                self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)
                self._photo.setPixmap(pixmap)
            else:
                self._empty = True
                self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
                self._photo.setPixmap(QtGui.QPixmap())
            if not (self.zoomPinned() and self.hasPhoto()):
                self._zoom = 0
            self.resetView(SCALE_FACTOR ** self._zoom)
    
        def zoomLevel(self):
            return self._zoom
    
        def zoomPinned(self):
            return self._pinned
    
        def setZoomPinned(self, enable):
            self._pinned = bool(enable)
    
        def zoom(self, step):
            zoom = max(0, self._zoom + (step := int(step)))
            if zoom != self._zoom:
                self._zoom = zoom
                if self._zoom > 0:
                    if step > 0:
                        factor = SCALE_FACTOR ** step
                    else:
                        factor = 1 / SCALE_FACTOR ** abs(step)
                    self.scale(factor, factor)
                else:
                    self.resetView()
    
        def wheelEvent(self, event):
            delta = event.angleDelta().y()
            self.zoom(delta and delta // abs(delta))
    
        def resizeEvent(self, event):
            super().resizeEvent(event)
            self.resetView()
    
        def toggleDragMode(self):
            if self.dragMode() == QtWidgets.QGraphicsView.ScrollHandDrag:
                self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
            elif not self._photo.pixmap().isNull():
                self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)
    
        def updateCoordinates(self, pos=None):
            if self._photo.isUnderMouse():
                if pos is None:
                    pos = self.mapFromGlobal(QtGui.QCursor.pos())
                point = self.mapToScene(pos).toPoint()
            else:
                point = QtCore.QPoint()
            self.coordinatesChanged.emit(point)
    
        def mouseMoveEvent(self, event):
            self.updateCoordinates(event.pos())
            super().mouseMoveEvent(event)
    
        def leaveEvent(self, event):
            self.coordinatesChanged.emit(QtCore.QPoint())
            super().leaveEvent(event)
    
    
    class Window(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            self.viewer = PhotoViewer(self)
            self.viewer.coordinatesChanged.connect(self.handleCoords)
            self.labelCoords = QtWidgets.QLabel(self)
            self.labelCoords.setAlignment(
                QtCore.Qt.AlignRight | QtCore.Qt.AlignCenter)
            self.buttonOpen = QtWidgets.QPushButton(self)
            self.buttonOpen.setText('Open Image')
            self.buttonOpen.clicked.connect(self.handleOpen)
            self.buttonPin = QtWidgets.QPushButton(self)
            self.buttonPin.setText('Pin Zoom')
            self.buttonPin.setCheckable(True)
            self.buttonPin.toggled.connect(self.viewer.setZoomPinned)
            layout = QtWidgets.QGridLayout(self)
            layout.addWidget(self.viewer, 0, 0, 1, 3)
            layout.addWidget(self.buttonOpen, 1, 0, 1, 1)
            layout.addWidget(self.buttonPin, 1, 1, 1, 1)
            layout.addWidget(self.labelCoords, 1, 2, 1, 1)
            layout.setColumnStretch(2, 2)
            self._path = None
    
        def handleCoords(self, point):
            if not point.isNull():
                self.labelCoords.setText(f'{point.x()}, {point.y()}')
            else:
                self.labelCoords.clear()
    
        def handleOpen(self):
            if (start := self._path) is None:
                start = QtCore.QStandardPaths.standardLocations(
                    QtCore.QStandardPaths.PicturesLocation)[0]
            if path := QtWidgets.QFileDialog.getOpenFileName(
                self, 'Open Image', start)[0]:
                self.labelCoords.clear()
                if not (pixmap := QtGui.QPixmap(path)).isNull():
                    self.viewer.setPhoto(pixmap)
                    self._path = path
                else:
                    QtWidgets.QMessageBox.warning(self, 'Error',
                        f'<br>Could not load image file:<br>'
                        f'<br><b>{path}</b><br>'
                        )
    
    
    if __name__ == '__main__':
    
        import sys
        app = QtWidgets.QApplication(sys.argv)
        window = Window()
        window.setGeometry(500, 300, 800, 600)
        window.show()
        sys.exit(app.exec_())
    

    This is the unrevised, original demo script.

    PyQt4 version:

    from PyQt4 import QtCore, QtGui
    
    class PhotoViewer(QtGui.QGraphicsView):
        def __init__(self, parent):
            super(PhotoViewer, self).__init__(parent)
            self._zoom = 0
            self._scene = QtGui.QGraphicsScene(self)
            self._photo = QtGui.QGraphicsPixmapItem()
            self._scene.addItem(self._photo)
            self.setScene(self._scene)
            self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
            self.setResizeAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
            self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(30, 30, 30)))
            self.setFrameShape(QtGui.QFrame.NoFrame)
    
        def fitInView(self):
            rect = QtCore.QRectF(self._photo.pixmap().rect())
            if not rect.isNull():
                unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
                self.scale(1 / unity.width(), 1 / unity.height())
                viewrect = self.viewport().rect()
                scenerect = self.transform().mapRect(rect)
                factor = min(viewrect.width() / scenerect.width(),
                             viewrect.height() / scenerect.height())
                self.scale(factor, factor)
                self.centerOn(rect.center())
                self._zoom = 0
    
        def setPhoto(self, pixmap=None):
            self._zoom = 0
            if pixmap and not pixmap.isNull():
                self.setDragMode(QtGui.QGraphicsView.ScrollHandDrag)
                self._photo.setPixmap(pixmap)
                self.fitInView()
            else:
                self.setDragMode(QtGui.QGraphicsView.NoDrag)
                self._photo.setPixmap(QtGui.QPixmap())
    
        def zoomFactor(self):
            return self._zoom
    
        def wheelEvent(self, event):
            if not self._photo.pixmap().isNull():
                if event.delta() > 0:
                    factor = 1.25
                    self._zoom += 1
                else:
                    factor = 0.8
                    self._zoom -= 1
                if self._zoom > 0:
                    self.scale(factor, factor)
                elif self._zoom == 0:
                    self.fitInView()
                else:
                    self._zoom = 0
    
    class Window(QtGui.QWidget):
        def __init__(self):
            super(Window, self).__init__()
            self.viewer = PhotoViewer(self)
            self.edit = QtGui.QLineEdit(self)
            self.edit.setReadOnly(True)
            self.button = QtGui.QToolButton(self)
            self.button.setText('...')
            self.button.clicked.connect(self.handleOpen)
            layout = QtGui.QGridLayout(self)
            layout.addWidget(self.viewer, 0, 0, 1, 2)
            layout.addWidget(self.edit, 1, 0, 1, 1)
            layout.addWidget(self.button, 1, 1, 1, 1)
    
        def handleOpen(self):
            path = QtGui.QFileDialog.getOpenFileName(
                self, 'Choose Image', self.edit.text())
            if path:
                self.edit.setText(path)
                self.viewer.setPhoto(QtGui.QPixmap(path))
    
    if __name__ == '__main__':
    
        import sys
        app = QtGui.QApplication(sys.argv)
        window = Window()
        window.setGeometry(500, 300, 800, 600)
        window.show()
        sys.exit(app.exec_())