""" Image display widget with zoom functionality for the microscopy object detection application. Reusable widget for displaying images with zoom controls. """ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QScrollArea from PySide6.QtGui import QPixmap, QImage, QKeyEvent from PySide6.QtCore import Qt, QEvent, Signal from pathlib import Path import numpy as np from src.utils.image import Image, ImageLoadError from src.utils.logger import get_logger logger = get_logger(__name__) class ImageDisplayWidget(QWidget): """ Reusable widget for displaying images with zoom functionality. Features: - Display images from Image objects - Zoom in/out with mouse wheel - Zoom in/out with +/- keyboard keys - Reset zoom with Ctrl+0 - Scroll area for large images Signals: zoom_changed: Emitted when zoom level changes (float zoom_scale) """ zoom_changed = Signal(float) # Emitted when zoom level changes def __init__(self, parent=None): """ Initialize the image display widget. Args: parent: Parent widget """ super().__init__(parent) self.current_image = None self.original_pixmap = None # Store original pixmap for zoom self.zoom_scale = 1.0 # Current zoom scale self.zoom_min = 0.1 # Minimum zoom (10%) self.zoom_max = 10.0 # Maximum zoom (1000%) self.zoom_step = 0.1 # Zoom step for +/- keys self.zoom_wheel_step = 0.15 # Zoom step for mouse wheel self._setup_ui() def _setup_ui(self): """Setup user interface.""" layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) # Scroll area for image self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_area.setMinimumHeight(400) self.image_label = QLabel("No image loaded") self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setStyleSheet( "QLabel { background-color: #2b2b2b; color: #888; }" ) self.image_label.setScaledContents(False) # Enable mouse tracking for wheel events self.image_label.setMouseTracking(True) self.scroll_area.setWidget(self.image_label) # Install event filter to capture wheel events on scroll area self.scroll_area.viewport().installEventFilter(self) layout.addWidget(self.scroll_area) self.setLayout(layout) # Set focus policy to receive keyboard events self.setFocusPolicy(Qt.StrongFocus) def load_image(self, image: Image): """ Load and display an image. Args: image: Image object to display Raises: ImageLoadError: If image cannot be displayed """ self.current_image = image # Reset zoom when loading new image self.zoom_scale = 1.0 # Convert to QPixmap and display self._display_image() logger.debug(f"Loaded image into display widget: {image.width}x{image.height}") def clear(self): """Clear the displayed image.""" self.current_image = None self.original_pixmap = None self.zoom_scale = 1.0 self.image_label.setText("No image loaded") self.image_label.setPixmap(QPixmap()) logger.debug("Cleared image display") def _display_image(self): """Display the current image in the image label.""" if self.current_image is None: return try: # Get RGB image data if self.current_image.channels == 3: image_data = self.current_image.get_rgb() height, width, channels = image_data.shape else: image_data = self.current_image.get_grayscale() height, width = image_data.shape channels = 1 # Ensure data is contiguous for proper QImage display image_data = np.ascontiguousarray(image_data) # Use actual stride from numpy array for correct display bytes_per_line = image_data.strides[0] qimage = QImage( image_data.data, width, height, bytes_per_line, self.current_image.qtimage_format, ).copy() # Copy to ensure Qt owns its memory after this scope # Convert to pixmap pixmap = QPixmap.fromImage(qimage) # Store original pixmap for zooming self.original_pixmap = pixmap # Apply zoom and display self._apply_zoom() except Exception as e: logger.error(f"Error displaying image: {e}") raise ImageLoadError(f"Failed to display image: {str(e)}") def _apply_zoom(self): """Apply current zoom level to the displayed image.""" if self.original_pixmap is None: return # Calculate scaled size scaled_width = int(self.original_pixmap.width() * self.zoom_scale) scaled_height = int(self.original_pixmap.height() * self.zoom_scale) # Scale pixmap scaled_pixmap = self.original_pixmap.scaled( scaled_width, scaled_height, Qt.KeepAspectRatio, ( Qt.SmoothTransformation if self.zoom_scale >= 1.0 else Qt.FastTransformation ), ) # Display in label self.image_label.setPixmap(scaled_pixmap) self.image_label.setScaledContents(False) self.image_label.adjustSize() # Emit zoom changed signal self.zoom_changed.emit(self.zoom_scale) def zoom_in(self): """Zoom in on the image.""" if self.original_pixmap is None: return new_scale = self.zoom_scale + self.zoom_step if new_scale <= self.zoom_max: self.zoom_scale = new_scale self._apply_zoom() logger.debug(f"Zoomed in to {int(self.zoom_scale * 100)}%") def zoom_out(self): """Zoom out from the image.""" if self.original_pixmap is None: return new_scale = self.zoom_scale - self.zoom_step if new_scale >= self.zoom_min: self.zoom_scale = new_scale self._apply_zoom() logger.debug(f"Zoomed out to {int(self.zoom_scale * 100)}%") def reset_zoom(self): """Reset zoom to 100%.""" if self.original_pixmap is None: return self.zoom_scale = 1.0 self._apply_zoom() logger.debug("Reset zoom to 100%") def set_zoom(self, scale: float): """ Set zoom to a specific scale. Args: scale: Zoom scale (1.0 = 100%) """ if self.original_pixmap is None: return # Clamp to min/max scale = max(self.zoom_min, min(self.zoom_max, scale)) self.zoom_scale = scale self._apply_zoom() logger.debug(f"Set zoom to {int(self.zoom_scale * 100)}%") def get_zoom_percentage(self) -> int: """ Get current zoom level as percentage. Returns: Zoom level as integer percentage (e.g., 100 for 100%) """ return int(self.zoom_scale * 100) def keyPressEvent(self, event: QKeyEvent): """Handle keyboard events for zooming.""" if event.key() in (Qt.Key_Plus, Qt.Key_Equal): # + or = key (= is the unshifted + on many keyboards) self.zoom_in() event.accept() elif event.key() == Qt.Key_Minus: # - key self.zoom_out() event.accept() elif event.key() == Qt.Key_0 and event.modifiers() == Qt.ControlModifier: # Ctrl+0 to reset zoom self.reset_zoom() event.accept() else: super().keyPressEvent(event) def eventFilter(self, obj, event: QEvent) -> bool: """Event filter to capture wheel events for zooming.""" if event.type() == QEvent.Wheel: wheel_event = event if self.original_pixmap is not None: # Get wheel angle delta delta = wheel_event.angleDelta().y() # Zoom in/out based on wheel direction if delta > 0: # Scroll up = zoom in new_scale = self.zoom_scale + self.zoom_wheel_step if new_scale <= self.zoom_max: self.zoom_scale = new_scale self._apply_zoom() else: # Scroll down = zoom out new_scale = self.zoom_scale - self.zoom_wheel_step if new_scale >= self.zoom_min: self.zoom_scale = new_scale self._apply_zoom() return True # Event handled return super().eventFilter(obj, event)