Adding image_display widget

This commit is contained in:
2025-12-08 17:33:32 +02:00
parent 4b5d2a7c45
commit bb26d43dd7
4 changed files with 366 additions and 79 deletions

View File

@@ -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,11 +90,24 @@ 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
# 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())
@@ -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()
# 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,
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 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."""

View File

@@ -0,0 +1,5 @@
"""GUI widgets for the microscopy object detection application."""
from src.gui.widgets.image_display_widget import ImageDisplayWidget
__all__ = ["ImageDisplayWidget"]

View File

@@ -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)

View File

@@ -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.