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_())