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