From bb26d43dd79b6e6ae2d237e2ae1b579c5879ec22 Mon Sep 17 00:00:00 2001 From: Martin Laasmaa Date: Mon, 8 Dec 2025 17:33:32 +0200 Subject: [PATCH] Adding image_display widget --- src/gui/tabs/annotation_tab.py | 126 ++++------- src/gui/widgets/__init__.py | 5 + src/gui/widgets/image_display_widget.py | 282 ++++++++++++++++++++++++ src/utils/image.py | 32 +++ 4 files changed, 366 insertions(+), 79 deletions(-) create mode 100644 src/gui/widgets/image_display_widget.py diff --git a/src/gui/tabs/annotation_tab.py b/src/gui/tabs/annotation_tab.py index 065b4e1..6aa2462 100644 --- a/src/gui/tabs/annotation_tab.py +++ b/src/gui/tabs/annotation_tab.py @@ -12,16 +12,15 @@ from PySide6.QtWidgets import ( QPushButton, QFileDialog, QMessageBox, - QScrollArea, ) -from PySide6.QtGui import QPixmap, QImage -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QSettings from pathlib import Path from src.database.db_manager import DatabaseManager from src.utils.config_manager import ConfigManager from src.utils.image import Image, ImageLoadError from src.utils.logger import get_logger +from src.gui.widgets import ImageDisplayWidget logger = get_logger(__name__) @@ -68,20 +67,10 @@ class AnnotationTab(QWidget): display_group = QGroupBox("Image Display") display_layout = QVBoxLayout() - # Scroll area for image - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - 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) - - scroll_area.setWidget(self.image_label) - display_layout.addWidget(scroll_area) + # Use the reusable ImageDisplayWidget + self.image_display_widget = ImageDisplayWidget() + self.image_display_widget.zoom_changed.connect(self._on_zoom_changed) + display_layout.addWidget(self.image_display_widget) display_group.setLayout(display_layout) layout.addWidget(display_group) @@ -101,13 +90,26 @@ class AnnotationTab(QWidget): info_group.setLayout(info_layout) layout.addWidget(info_group) + + # Zoom controls info + zoom_info = QLabel("Zoom: Mouse wheel or +/- keys to zoom in/out") + zoom_info.setStyleSheet("QLabel { color: #888; font-style: italic; }") + layout.addWidget(zoom_info) + self.setLayout(layout) def _load_image(self): """Load and display an image file.""" - # Get image repository path or use home directory - repo_path = self.config_manager.get_image_repository_path() - start_dir = repo_path if repo_path else str(Path.home()) + # Get last opened directory from QSettings + settings = QSettings("microscopy_app", "object_detection") + last_dir = settings.value("annotation_tab/last_directory", None) + + # Fallback to image repository path or home directory + if last_dir and Path(last_dir).exists(): + start_dir = last_dir + else: + repo_path = self.config_manager.get_image_repository_path() + start_dir = repo_path if repo_path else str(Path.home()) # Open file dialog file_path, _ = QFileDialog.getOpenFileName( @@ -125,18 +127,16 @@ class AnnotationTab(QWidget): self.current_image = Image(file_path) self.current_image_path = file_path - # Update info label - info_text = ( - f"File: {Path(file_path).name}\n" - f"Size: {self.current_image.width}x{self.current_image.height} pixels\n" - f"Channels: {self.current_image.channels}\n" - f"Format: {self.current_image.format.upper()}\n" - f"File size: {self.current_image.size_mb:.2f} MB" + # Store the directory for next time + settings.setValue( + "annotation_tab/last_directory", str(Path(file_path).parent) ) - self.image_info_label.setText(info_text) - # Convert to QPixmap and display - self._display_image() + # Display image using the ImageDisplayWidget + self.image_display_widget.load_image(self.current_image) + + # Update info label + self._update_image_info() logger.info(f"Loaded image: {file_path}") @@ -149,59 +149,27 @@ class AnnotationTab(QWidget): logger.error(f"Unexpected error loading image: {e}") QMessageBox.critical(self, "Error", f"Unexpected error:\n{str(e)}") - def _display_image(self): - """Display the current image in the image label.""" + def _update_image_info(self): + """Update the image info label with current image details.""" if self.current_image is None: + self.image_info_label.setText("No image loaded") return - try: - # Get RGB image data - rgb_data = self.current_image.get_rgb() + zoom_percentage = self.image_display_widget.get_zoom_percentage() + info_text = ( + f"File: {Path(self.current_image_path).name}\n" + f"Size: {self.current_image.width}x{self.current_image.height} pixels\n" + f"Channels: {self.current_image.channels}\n" + f"Data type: {self.current_image.dtype}\n" + f"Format: {self.current_image.format.upper()}\n" + f"File size: {self.current_image.size_mb:.2f} MB\n" + f"Zoom: {zoom_percentage}%" + ) + self.image_info_label.setText(info_text) - # Convert numpy array to QImage - height, width, channels = rgb_data.shape - bytes_per_line = channels * width - - if channels == 3: - qimage = QImage( - rgb_data.data, - width, - height, - bytes_per_line, - QImage.Format_RGB888, - ) - else: - # Grayscale - qimage = QImage( - rgb_data.data, - width, - height, - bytes_per_line, - QImage.Format_Grayscale8, - ) - - # Convert to pixmap - pixmap = QPixmap.fromImage(qimage) - - # Scale to fit display (max 800px width or height) - max_size = 800 - if pixmap.width() > max_size or pixmap.height() > max_size: - pixmap = pixmap.scaled( - max_size, - max_size, - Qt.KeepAspectRatio, - Qt.SmoothTransformation, - ) - - # Display in label - self.image_label.setPixmap(pixmap) - self.image_label.setScaledContents(False) - - except Exception as e: - logger.error(f"Error displaying image: {e}") - QMessageBox.warning( - self, "Display Error", f"Failed to display image:\n{str(e)}" - ) + def _on_zoom_changed(self, zoom_scale: float): + """Handle zoom level changes from the image display widget.""" + self._update_image_info() def refresh(self): """Refresh the tab.""" diff --git a/src/gui/widgets/__init__.py b/src/gui/widgets/__init__.py index e69de29..2946406 100644 --- a/src/gui/widgets/__init__.py +++ b/src/gui/widgets/__init__.py @@ -0,0 +1,5 @@ +"""GUI widgets for the microscopy object detection application.""" + +from src.gui.widgets.image_display_widget import ImageDisplayWidget + +__all__ = ["ImageDisplayWidget"] diff --git a/src/gui/widgets/image_display_widget.py b/src/gui/widgets/image_display_widget.py new file mode 100644 index 0000000..52d2ce2 --- /dev/null +++ b/src/gui/widgets/image_display_widget.py @@ -0,0 +1,282 @@ +""" +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, + ) + + # 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) diff --git a/src/utils/image.py b/src/utils/image.py index 75f40e4..9dc867d 100644 --- a/src/utils/image.py +++ b/src/utils/image.py @@ -11,6 +11,8 @@ from PIL import Image as PILImage from src.utils.logger import get_logger from src.utils.file_utils import validate_file_path, is_image_file +from PySide6.QtGui import QImage + logger = get_logger(__name__) @@ -58,6 +60,7 @@ class Image: self._channels: int = 0 self._format: str = "" self._size_bytes: int = 0 + self._dtype: Optional[np.dtype] = None # Load the image self._load() @@ -93,6 +96,7 @@ class Image: self._channels = self._data.shape[2] if len(self._data.shape) == 3 else 1 self._format = self.path.suffix.lower().lstrip(".") self._size_bytes = self.path.stat().st_size + self._dtype = self._data.dtype # Load PIL version for compatibility (convert BGR to RGB) if self._channels == 3: @@ -157,6 +161,7 @@ class Image: Returns: Tuple of (height, width, channels) """ + print("shape", self._height, self._width, self._channels) return (self._height, self._width, self._channels) @property @@ -179,6 +184,33 @@ class Image: """Get file size in megabytes.""" return self._size_bytes / (1024 * 1024) + @property + def dtype(self) -> np.dtype: + """Get the data type of the image array.""" + if self._dtype is None: + raise ImageLoadError("Image dtype not available") + return self._dtype + + @property + def qtimage_format(self) -> QImage.Format: + """ + Get the appropriate QImage format for the image. + + Returns: + QImage.Format enum value + """ + if self._channels == 3: + return QImage.Format_RGB888 + elif self._channels == 4: + return QImage.Format_RGBA8888 + elif self._channels == 1: + if self._dtype == np.uint16: + return QImage.Format_Grayscale16 + else: + return QImage.Format_Grayscale8 + else: + raise ImageLoadError(f"Unsupported number of channels: {self._channels}") + def get_rgb(self) -> np.ndarray: """ Get image data as RGB numpy array.