"""
QtImageViewer.py: PyQt image viewer widget for a QPixmap in a QGraphicsView scene with mouse zooming and panning.
"""
import os.path
try:
from PyQt5.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR
from PyQt5.QtGui import QImage, QPixmap, QPainterPath
from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QFileDialog
except ImportError:
try:
from PyQt4.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR
from PyQt4.QtGui import QGraphicsView, QGraphicsScene, QImage, QPixmap, QPainterPath, QFileDialog
except ImportError:
raise ImportError("QtImageViewer: Requires PyQt5 or PyQt4.")
__author__ = "Marcel Goldschen-Ohm <marcel.goldschen@gmail.com>"
__version__ = '0.9.0'
[docs]
class QtImageViewer(QGraphicsView):
"""
PyQt image viewer widget for a QPixmap in a QGraphicsView scene with mouse zooming and panning.
Displays a QImage or QPixmap (QImage is internally converted to a QPixmap).
To display any other image format, you must first convert it to a QImage or QPixmap.
Some useful image format conversion utilities:
qimage2ndarray: NumPy ndarray <==> QImage (https://github.com/hmeine/qimage2ndarray)
ImageQt: PIL Image <==> QImage (https://github.com/python-pillow/Pillow/blob/master/PIL/ImageQt.py)
Mouse interaction:
Left mouse button drag: Pan image.
Right mouse button drag: Zoom box.
Right mouse button doubleclick: Zoom to show entire image.
"""
# Mouse button signals emit image scene (x, y) coordinates.
# !!! For image (row, column) matrix indexing, row = y and column = x.
leftMouseButtonPressed = pyqtSignal(float, float)
rightMouseButtonPressed = pyqtSignal(float, float)
leftMouseButtonReleased = pyqtSignal(float, float)
rightMouseButtonReleased = pyqtSignal(float, float)
leftMouseButtonDoubleClicked = pyqtSignal(float, float)
rightMouseButtonDoubleClicked = pyqtSignal(float, float)
def __init__(self):
QGraphicsView.__init__(self)
# Image is displayed as a QPixmap in a QGraphicsScene attached to this QGraphicsView.
self.scene = QGraphicsScene()
self.setScene(self.scene)
# Store a local handle to the scene's current image pixmap.
self._pixmapHandle = None
# Image aspect ratio mode.
# !!! ONLY applies to full image. Aspect ratio is always ignored when zooming.
# Qt.IgnoreAspectRatio: Scale image to fit viewport.
# Qt.KeepAspectRatio: Scale image to fit inside viewport, preserving aspect ratio.
# Qt.KeepAspectRatioByExpanding: Scale image to fill the viewport, preserving aspect ratio.
self.aspectRatioMode = Qt.KeepAspectRatio
# Scroll bar behaviour.
# Qt.ScrollBarAlwaysOff: Never shows a scroll bar.
# Qt.ScrollBarAlwaysOn: Always shows a scroll bar.
# Qt.ScrollBarAsNeeded: Shows a scroll bar only when zoomed.
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Stack of QRectF zoom boxes in scene coordinates.
self.zoomStack = []
# Flags for enabling/disabling mouse interaction.
self.canZoom = True
self.canPan = True
[docs]
def hasImage(self):
""" Returns whether or not the scene contains an image pixmap.
"""
return self._pixmapHandle is not None
[docs]
def clearImage(self):
""" Removes the current image pixmap from the scene if it exists.
"""
if self.hasImage():
self.scene.removeItem(self._pixmapHandle)
self._pixmapHandle = None
[docs]
def pixmap(self):
""" Returns the scene's current image pixmap as a QPixmap, or else None if no image exists.
:rtype: QPixmap | None
"""
if self.hasImage():
return self._pixmapHandle.pixmap()
return None
[docs]
def image(self):
""" Returns the scene's current image pixmap as a QImage, or else None if no image exists.
:rtype: QImage | None
"""
if self.hasImage():
return self._pixmapHandle.pixmap().toImage()
return None
[docs]
def setImage(self, image):
""" Set the scene's current image pixmap to the input QImage or QPixmap.
Raises a RuntimeError if the input image has type other than QImage or QPixmap.
:type image: QImage | QPixmap
"""
if type(image) is QPixmap:
pixmap = image
elif type(image) is QImage:
pixmap = QPixmap.fromImage(image)
else:
raise RuntimeError("ImageViewer.setImage: Argument must be a QImage or QPixmap.")
if self.hasImage():
self._pixmapHandle.setPixmap(pixmap)
else:
self._pixmapHandle = self.scene.addPixmap(pixmap)
self.setSceneRect(QRectF(pixmap.rect())) # Set scene size to image size.
self.updateViewer()
[docs]
def loadImageFromFile(self, fileName=""):
""" Load an image from file.
Without any arguments, loadImageFromFile() will popup a file dialog to choose the image file.
With a fileName argument, loadImageFromFile(fileName) will attempt to load the specified image file directly.
"""
if len(fileName) == 0:
if QT_VERSION_STR[0] == '4':
fileName = QFileDialog.getOpenFileName(self, "Open image file.")
elif QT_VERSION_STR[0] == '5':
fileName, dummy = QFileDialog.getOpenFileName(self, "Open image file.")
if len(fileName) and os.path.isfile(fileName):
image = QImage(fileName)
self.setImage(image)
[docs]
def updateViewer(self):
""" Show current zoom (if showing entire image, apply current aspect ratio mode).
"""
if not self.hasImage():
return
if len(self.zoomStack) and self.sceneRect().contains(self.zoomStack[-1]):
self.fitInView(self.zoomStack[-1], Qt.IgnoreAspectRatio) # Show zoomed rect (ignore aspect ratio).
else:
self.zoomStack = [] # Clear the zoom stack (in case we got here because of an invalid zoom).
self.fitInView(self.sceneRect(), self.aspectRatioMode) # Show entire image (use current aspect ratio mode).
[docs]
def resizeEvent(self, event):
""" Maintain current zoom on resize.
"""
self.updateViewer()
[docs]
def mousePressEvent(self, event):
""" Start mouse pan or zoom mode.
"""
scenePos = self.mapToScene(event.pos())
if event.button() == Qt.LeftButton:
if self.canPan:
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.leftMouseButtonPressed.emit(scenePos.x(), scenePos.y())
elif event.button() == Qt.RightButton:
if self.canZoom:
self.setDragMode(QGraphicsView.RubberBandDrag)
self.rightMouseButtonPressed.emit(scenePos.x(), scenePos.y())
QGraphicsView.mousePressEvent(self, event)
[docs]
def mouseReleaseEvent(self, event):
""" Stop mouse pan or zoom mode (apply zoom if valid).
"""
QGraphicsView.mouseReleaseEvent(self, event)
scenePos = self.mapToScene(event.pos())
if event.button() == Qt.LeftButton:
self.setDragMode(QGraphicsView.NoDrag)
self.leftMouseButtonReleased.emit(scenePos.x(), scenePos.y())
elif event.button() == Qt.RightButton:
if self.canZoom:
viewBBox = self.zoomStack[-1] if len(self.zoomStack) else self.sceneRect()
selectionBBox = self.scene.selectionArea().boundingRect().intersected(viewBBox)
self.scene.setSelectionArea(QPainterPath()) # Clear current selection area.
if selectionBBox.isValid() and (selectionBBox != viewBBox):
self.zoomStack.append(selectionBBox)
self.updateViewer()
self.setDragMode(QGraphicsView.NoDrag)
self.rightMouseButtonReleased.emit(scenePos.x(), scenePos.y())
[docs]
def mouseDoubleClickEvent(self, event):
""" Show entire image.
"""
scenePos = self.mapToScene(event.pos())
if event.button() == Qt.LeftButton:
self.leftMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y())
elif event.button() == Qt.RightButton:
if self.canZoom:
self.zoomStack = [] # Clear zoom stack.
self.updateViewer()
self.rightMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y())
QGraphicsView.mouseDoubleClickEvent(self, event)
if __name__ == '__main__':
import sys
try:
from PyQt5.QtWidgets import QApplication
except ImportError:
try:
from PyQt4.QtGui import QApplication
except ImportError:
raise ImportError("QtImageViewer: Requires PyQt5 or PyQt4.")
print('Using Qt ' + QT_VERSION_STR)
def handleLeftClick(x, y):
row = int(y)
column = int(x)
print("Clicked on image pixel (row=" + str(row) + ", column=" + str(column) + ")")
# Create the application.
app = QApplication(sys.argv)
# Create image viewer and load an image file to display.
viewer = QtImageViewer()
viewer.loadImageFromFile() # Pops up file dialog.
# Handle left mouse clicks with custom slot.
viewer.leftMouseButtonPressed.connect(handleLeftClick)
# Show viewer and run application.
viewer.show()
sys.exit(app.exec_())