""" ImageViewer.py: PyQt image viewer widget based on QGraphicsView with mouse croping. """ import os.path from PySide6.QtCore import Qt, QRectF, QRect, QPoint, QPointF, QEvent, QSize, Signal from PySide6.QtGui import QImage, QPixmap, QPainterPath, QMouseEvent, QPainter, QPen from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QFileDialog, QSizePolicy, QMdiArea # numpy is optional: only needed if you want to display numpy 2d arrays as images. try: import numpy as np except ImportError: np = None # qimage2ndarray is optional: useful for displaying numpy 2d arrays as images. # !!! qimage2ndarray requires PyQt5. # Some custom code in the viewer appears to handle the conversion from numpy 2d arrays, # so qimage2ndarray probably is not needed anymore. I've left it here just in case. try: import qimage2ndarray except ImportError: qimage2ndarray = None __author__ = "Alex Sidorov " __version__ = '0.1' class ImageViewer(QGraphicsView): """ PyQt image viewer widget based on QGraphicsView with mouse croping. Image File: ----------- Calling set_file() Image: ------ Use the setImage(im) method to set the image data in the viewer. - im can be a QImage, QPixmap, or NumPy 2D array (the later requires the package qimage2ndarray). For display in the QGraphicsView the image will be converted to a 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: ------ Mouse interactions for cropted and panning is fully customizable by simply setting the desired button interactions: e.g., regionZoomButton = Qt.LeftButton # Drag a zoom box. zoomOutButton = Qt.RightButton # Pop end of zoom stack (double click clears zoom stack). panButton = Qt.MiddleButton # Drag to pan. wheelZoomFactor = 1.25 # Set to None or 1 to disable mouse wheel zoom. To disable any interaction, just disable its button. e.g., to disable panning: panButton = None """ # Mouse button signals emit image scene (x, y) coordinates. # !!! For image (row, column) matrix indexing, row = y and column = x. # !!! These signals will NOT be emitted if the event is handled by an interaction such as zoom or pan. # !!! If aspect ratio prevents image from filling viewport, emitted position may be outside image bounds. leftMouseButtonPressed = Signal(float, float) leftMouseButtonReleased = Signal(float, float) middleMouseButtonPressed = Signal(float, float) middleMouseButtonReleased = Signal(float, float) rightMouseButtonPressed = Signal(float, float) rightMouseButtonReleased = Signal(float, float) leftMouseButtonDoubleClicked = Signal(float, float) rightMouseButtonDoubleClicked = Signal(float, float) # Emitted upon zooming/panning. viewChanged = Signal() # Emitted on mouse motion. # Emits mouse position over image in image pixel coordinates. # !!! setMouseTracking(True) if you want to use this at all times. mousePositionOnImageChanged = Signal(QPoint) def __init__(self, parent=None): super(ImageViewer, self).__init__(parent) # Image is displayed as a QPixmap in a QGraphicsScene attached to this QGraphicsView. self.scene = QGraphicsScene() self.setScene(self.scene) # Better quality pixmap scaling? # self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) # Displayed image pixmap in the QGraphicsScene. self._image: QPixmap = None # Image aspect ratio mode. # 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.AspectRatioMode.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.ScrollBarPolicy.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) # Interactions (set buttons to None to disable interactions) # !!! Events handled by interactions will NOT emit *MouseButton* signals. # Note: regionZoomButton will still emit a *MouseButtonReleased signal on a click (i.e. tiny box). self.regionZoomButton = Qt.MouseButton.LeftButton # Drag a zoom box. # Pop end of zoom stack (double click clears zoom stack). self.zoomOutButton = Qt.MouseButton.RightButton self.panButton = Qt.MouseButton.MiddleButton # Drag to pan. # Set to None or 1 to disable mouse wheel zoom. self.wheelZoomFactor = 1.25 # Stack of QRectF zoom boxes in scene coordinates. # !!! If you update this manually, be sure to call updateViewer() to reflect any changes. self.zoomStack: list[QRectF] = [] #Stack of image self.imageStack: list[QPixmap] = [] # Flags for active zooming/panning. self._isZooming: bool = False self._isPanning: bool = False # Store temporary position in screen pixels or scene units. self._pixelPosition = QPoint() self._scenePosition = QPointF() # Track mouse position. e.g., For displaying coordinates in a UI. # self.setMouseTracking(True) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def sizeHint(self): return QSize(900, 600) def hasImage(self): """ Returns whether the scene contains an image pixmap. """ return self._image is not None def clearImage(self): """ Removes the current image pixmap from the scene if it exists. """ if self.hasImage(): self.scene.removeItem(self._image) self._image = None 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._image.pixmap() return None 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._image.pixmap().toImage() return None 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) elif type(image) is str: pixmap = QPixmap.fromImage(QImage(image)) elif (np is not None) and (type(image) is np.ndarray): if qimage2ndarray is not None: qimage = qimage2ndarray.array2qimage(image, True) pixmap = QPixmap.fromImage(qimage) else: image = image.astype(np.float32) image -= image.min() image /= image.max() image *= 255 image[image > 255] = 255 image[image < 0] = 0 image = image.astype(np.uint8) height, width = image.shape bytes = image.tobytes() qimage = QImage(bytes, width, height, QImage.Format.Format_Grayscale8) pixmap = QPixmap.fromImage(qimage) else: raise RuntimeError( "ImageViewer.setImage: Argument must be a QImage, QPixmap, or numpy.ndarray.") if self.hasImage(): self._image.setPixmap(pixmap) else: self._image = self.scene.addPixmap(pixmap) self.imageStack.append(pixmap) # Better quality pixmap scaling? # !!! This will distort actual pixel data when zoomed way in. # For scientific image analysis, you probably don't want this. self._image.setTransformationMode(Qt.SmoothTransformation) # Set scene size to image size. self.setSceneRect(QRectF(pixmap.rect())) self.updateViewer() def updateViewer(self): """ Show current zoom (if showing entire image, apply current aspect ratio mode). """ if not self.hasImage(): return if len(self.imageStack) > 1: # Show zoomed rect. # self.fitInView(self.zoomStack[-1], self.aspectRatioMode) self._image.setPixmap(self.imageStack[-1]) else: # Show entire image. self._image.setPixmap(self.imageStack[0]) # self.fitInView(self.sceneRect(), self.aspectRatioMode) def clearZoom(self): if len(self.zoomStack) > 0: self.zoomStack = [] self.imageStack = self.imageStack[0] self.updateViewer() self.viewChanged.emit() def resizeEvent(self, event): """ Maintain current zoom on resize. """ self.updateViewer() def mousePressEvent(self, event): """ Start mouse pan or zoom mode. """ # Ignore dummy events. e.g., Faking pan with left button ScrollHandDrag. dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier) if event.modifiers() == dummyModifiers: QGraphicsView.mousePressEvent(self, event) event.accept() return # Start dragging a region zoom box? if (self.regionZoomButton is not None) and (event.button() == self.regionZoomButton): self._pixelPosition = event.pos() # store pixel position self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) QGraphicsView.mousePressEvent(self, event) event.accept() self._isZooming = True return if (self.zoomOutButton is not None) and (event.button() == self.zoomOutButton): if len(self.zoomStack): self.zoomStack.pop() self.imageStack.pop() self.updateViewer() self.viewChanged.emit() event.accept() return # Start dragging to pan? if (self.panButton is not None) and (event.button() == self.panButton): self._pixelPosition = event.pos() # store pixel position self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) if self.panButton == Qt.MouseButton.LeftButton: QGraphicsView.mousePressEvent(self, event) else: # ScrollHandDrag ONLY works with LeftButton, so fake it. # Use a bunch of dummy modifiers to notify that event should NOT be handled as usual. self.viewport().setCursor(Qt.CursorShape.ClosedHandCursor) dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier) dummyEvent = QMouseEvent(QEvent.Type.MouseButtonPress, QPointF(event.pos()), Qt.MouseButton.LeftButton, event.buttons(), dummyModifiers) self.mousePressEvent(dummyEvent) sceneViewport = self.mapToScene( self.viewport().rect()).boundingRect().intersected(self.sceneRect()) self._scenePosition = sceneViewport.topLeft() event.accept() self._isPanning = True return scenePos = self.mapToScene(event.pos()) if event.button() == Qt.MouseButton.LeftButton: self.leftMouseButtonPressed.emit(scenePos.x(), scenePos.y()) elif event.button() == Qt.MouseButton.MiddleButton: self.middleMouseButtonPressed.emit(scenePos.x(), scenePos.y()) elif event.button() == Qt.MouseButton.RightButton: self.rightMouseButtonPressed.emit(scenePos.x(), scenePos.y()) QGraphicsView.mousePressEvent(self, event) def mouseReleaseEvent(self, event): """ Stop mouse pan or zoom mode (apply zoom if valid). """ # Ignore dummy events. e.g., Faking pan with left button ScrollHandDrag. dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier) if event.modifiers() == dummyModifiers: QGraphicsView.mouseReleaseEvent(self, event) event.accept() return # Finish dragging a region zoom box? if (self.regionZoomButton is not None) and (event.button() == self.regionZoomButton): QGraphicsView.mouseReleaseEvent(self, event) zoomRect = self.scene.selectionArea().boundingRect().intersected(self.sceneRect()) # Clear current selection area (i.e. rubberband rect). self.scene.setSelectionArea(QPainterPath()) self.setDragMode(QGraphicsView.DragMode.NoDrag) # If zoom box is 3x3 screen pixels or smaller, do not zoom and proceed to process as a click release. zoomPixelWidth = abs(event.pos().x() - self._pixelPosition.x()) zoomPixelHeight = abs(event.pos().y() - self._pixelPosition.y()) if zoomPixelWidth > 3 and zoomPixelHeight > 3: if zoomRect.isValid() and (zoomRect != self.sceneRect()): self.zoomStack.append(zoomRect) self.crop_image() self.updateViewer() self.viewChanged.emit() event.accept() self._isZooming = False return # Finish panning? if (self.panButton is not None) and (event.button() == self.panButton): if self.panButton == Qt.MouseButton.LeftButton: QGraphicsView.mouseReleaseEvent(self, event) else: # ScrollHandDrag ONLY works with LeftButton, so fake it. # Use a bunch of dummy modifiers to notify that event should NOT be handled as usual. self.viewport().setCursor(Qt.CursorShape.ArrowCursor) dummyModifiers = Qt.KeyboardModifier(Qt.KeyboardModifier.ShiftModifier | Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier | Qt.KeyboardModifier.MetaModifier) dummyEvent = QMouseEvent(QEvent.Type.MouseButtonRelease, QPointF(event.pos()), Qt.MouseButton.LeftButton, event.buttons(), dummyModifiers) self.mouseReleaseEvent(dummyEvent) self.setDragMode(QGraphicsView.DragMode.NoDrag) if len(self.zoomStack) > 0: sceneViewport = self.mapToScene( self.viewport().rect()).boundingRect().intersected(self.sceneRect()) delta = sceneViewport.topLeft() - self._scenePosition self.zoomStack[-1].translate(delta) self.zoomStack[-1] = self.zoomStack[-1].intersected( self.sceneRect()) self.viewChanged.emit() event.accept() self._isPanning = False return scenePos = self.mapToScene(event.pos()) if event.button() == Qt.MouseButton.LeftButton: self.leftMouseButtonReleased.emit(scenePos.x(), scenePos.y()) elif event.button() == Qt.MouseButton.MiddleButton: self.middleMouseButtonReleased.emit(scenePos.x(), scenePos.y()) elif event.button() == Qt.MouseButton.RightButton: self.rightMouseButtonReleased.emit(scenePos.x(), scenePos.y()) QGraphicsView.mouseReleaseEvent(self, event) def mouseDoubleClickEvent(self, event): """ Show entire image. """ # Zoom out on double click? if (self.zoomOutButton is not None) and (event.button() == self.zoomOutButton): self.clearZoom() event.accept() return scenePos = self.mapToScene(event.pos()) if event.button() == Qt.MouseButton.LeftButton: self.leftMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y()) elif event.button() == Qt.MouseButton.RightButton: self.rightMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y()) QGraphicsView.mouseDoubleClickEvent(self, event) def mouseMoveEvent(self, event): # Emit updated view during panning. if self._isPanning: QGraphicsView.mouseMoveEvent(self, event) if len(self.zoomStack) > 0: sceneViewport = self.mapToScene( self.viewport().rect()).boundingRect().intersected(self.sceneRect()) delta = sceneViewport.topLeft() - self._scenePosition self._scenePosition = sceneViewport.topLeft() self.zoomStack[-1].translate(delta) self.zoomStack[-1] = self.zoomStack[-1].intersected( self.sceneRect()) self.updateViewer() self.viewChanged.emit() scenePos = self.mapToScene(event.pos()) if self.sceneRect().contains(scenePos): # Pixel index offset from pixel center. x = int(round(scenePos.x() - 0.5)) y = int(round(scenePos.y() - 0.5)) imagePos = QPoint(x, y) else: # Invalid pixel position. imagePos = QPoint(-1, -1) self.mousePositionOnImageChanged.emit(imagePos) QGraphicsView.mouseMoveEvent(self, event) def enterEvent(self, event): pass # self.setCursor(Qt.CursorShape.CrossCursor) def leaveEvent(self, event): pass # self.setCursor(Qt.CursorShape.ArrowCursor) def crop_image(self) -> QPixmap: if self.hasImage(): rect: QRect = self.zoomStack[-1].toRect() image = self.imageStack[-1].copy(rect) self._image.setPixmap(image) self.imageStack.append(image) def save_image(self, path_file: str) -> None: self._image.save(path_file)