Source code for spinningdiskanalyzer.frontend.viewer.widgets

"""Provides an image viewer widget for a Qt application."""

from .images  import DeepZoom
from .tiles   import TileCache
from .errors  import FileFormatError

from PySide6.QtWidgets import QLabel
from PySide6.QtWidgets import QWidget
from PySide6.QtWidgets import QFrame
from PySide6.QtGui     import QImage
from PySide6.QtGui     import QPixmap
from PySide6.QtGui     import QAction
from PySide6.QtGui     import QCursor
from PySide6.QtCore    import QSize
from PySide6.QtCore    import QEvent
from PySide6.QtCore    import Qt

from pathlib import Path
from logging import getLogger


log = getLogger(__name__)


[docs] class ImageViewer(QLabel): """ Qt widget that displays a zoom-level image pyramid. Drag while keeping the left mouse button pressed. Zoom in with the <kbd>+</kbd> key, zoom out with <kbd>-</kbd>. Or use the mouse wheel or pinch gesture on the track pad. """ #################################### # Setup # #################################### def __init__(self, parent: QWidget = None): # Create widget. super().__init__(parent) self.setFocusPolicy(Qt.ClickFocus) self.setMinimumSize(32, 32) self.setFrameStyle(QFrame.NoFrame) # Create the canvas, a second label serving as a pixel cache that # this label here is a viewport into. The canvas must be a # contiguous memory view of the underlying NumPy array. That is, # we cannot just have our viewport be an array slice. This is so # that Qt can quickly update the UI when the image is dragged, # whereas the canvas itself can be updated during the regular # event loop. self.canvas = QLabel(self) self.canvas.setAlignment(Qt.AlignLeft) # Make sure widget can receive focus and thus key strokes. self.setFocusPolicy(Qt.ClickFocus) # Display all actions as the context menu. self.setContextMenuPolicy(Qt.ActionsContextMenu) # Assign short-cut keys to actions. action = QAction('Zoom in', self) action.triggered.connect(self.zoom_in) action.setShortcut('+') self.addAction(action) action = QAction('Zoom out', self) action.triggered.connect(self.zoom_out) action.setShortcut('-') self.addAction(action) # Initialize instance attributes. self.image = None self.tiles = None self.dragging = False
[docs] def sizeHint(self): """Tells Qt the initial size of the widget.""" return QSize(512, 512)
#################################### # Interaction # ####################################
[docs] def load(self, file: Path | None, zoom: int = 0): """ Displays the image loaded from the given `file`. `file` is a `Path` object pointing to an image file. Currently, only DeepZoom images (`.dzi`) are supported. `zoom` specifies the initial zoom level, with 0 being most zoomed out and greater numbers being zoomed in. There is no way to determine the maximum zoom other than querying the image. """ if file is None: self.image = None self.tiles = None self.dragging = False self.canvas.setPixmap(QPixmap()) return if file.suffix != '.dzi': error = 'Only DeepZoom images (.dzi) are supported.' log.error(error) raise NotImplementedError(error) self.image = DeepZoom(file) self.tiles = TileCache(self.image) self.tiles.zoom_to(zoom, 0, 0) self.redraw()
[docs] def zoom_in(self): """Zooms in by one level, towards the position of the mouse.""" level = self.tiles.zoom_level self.zoom_to(level + 1)
[docs] def zoom_out(self): """Zooms out by one level, relative to the mouse position.""" level = self.tiles.zoom_level self.zoom_to(level - 1)
[docs] def zoom_by(self, levels: int): """ Zooms in or out by the given number of `levels`. Zooms in if `levels` is positive, out if negative. """ if levels == 0: return level = self.tiles.zoom_level self.zoom_to(level + levels)
[docs] def zoom_to(self, level: int): """Zooms to the given zoom `level`.""" mouse = self.mapFromGlobal(QCursor.pos()) stale = self.tiles.zoom_to(level, mouse.x(), mouse.y()) if stale: self.redraw()
[docs] def move_by(self, Δx: int, Δy: int): """Moves viewport by `(Δx, Δy)` pixels.""" self.tiles.move_by(Δx, Δy) self.redraw()
#################################### # Event handling # ####################################
[docs] def redraw(self): """Redraws the widget.""" # Skip if no image loaded yet. if not self.tiles: return # Update the canvas. (width, height) = (self.geometry().width(), self.geometry().height()) self.tiles.viewport_size(width, height) self.tiles.update() canvas = self.tiles.canvas # Determine image format based on number of color channels. if canvas.dtype != 'uint8': raise FileFormatError('Only 8-bit color depth is supported.') if len(canvas.shape) == 2: grayscale = True (height, width) = canvas.shape elif len(canvas.shape) == 3: grayscale = False (height, width, channels) = canvas.shape else: error = 'Pixel array has unexpected shape.' log.error(error) raise FileFormatError(error) if grayscale: format = QImage.Format_Indexed8 elif channels == 3: format = QImage.Format_RGB888 elif channels == 4: format = QImage.Format_RGB32 else: error = f'Unexpected number of color channels: {channels}.' log.error(error) raise FileFormatError(error) # Create Qt-compatible memory view into canvas NumPy array. bytes_per_line = canvas.strides[0] image = QImage(canvas.data, width, height, bytes_per_line, format) # Set as new pixel map for this label's canvas. pixmap = QPixmap.fromImageInPlace(image) self.canvas.setPixmap(pixmap) # Adjust this label's viewport into the larger canvas. (x, y, w, h) = self.tiles.viewport_rect() self.canvas.setGeometry(-x, -y, w+x, h+y)
[docs] def mousePressEvent(self, event: QEvent): """Called when the user pressed a mouse button.""" if event.buttons() == Qt.LeftButton: self.dragging = (event.position().x(), event.position().y())
[docs] def mouseReleaseEvent(self, event: QEvent): """Called when the user released a mouse button.""" self.dragging = False
[docs] def mouseDoubleClickEvent(self, event: QEvent): """Called when the user double-clicked with the mouse.""" if event.buttons() == Qt.LeftButton: if event.modifiers() == Qt.KeyboardModifier.ShiftModifier: self.zoom_out() else: self.zoom_in()
[docs] def mouseMoveEvent(self, event: QEvent): """Called when the user moved the mouse.""" if event.buttons() == Qt.LeftButton: if not self.dragging: return (x1, y1) = self.dragging (x2, y2) = (event.position().x(), event.position().y()) (Δx, Δy) = (int(x2-x1), int(y2-y1)) if not (Δx, Δy): return self.dragging = (x2, y2) self.move_by(Δx, Δy)
[docs] def wheelEvent(self, event: QEvent): """Called when the user turned the mouse wheel.""" degrees = event.angleDelta().y() / 8 levels = int(degrees / 15) self.zoom_by(levels)
[docs] def resizeEvent(self, event: QEvent): """Called when the widget's size changed.""" self.redraw()