123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- """ 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 <alex.sidorof@ya.ru>"
- __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)
|