Adding image loading
This commit is contained in:
@@ -3,10 +3,27 @@ Annotation tab for the microscopy object detection application.
|
||||
Future feature for manual annotation.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QGroupBox
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QGroupBox,
|
||||
QPushButton,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
)
|
||||
from PySide6.QtGui import QPixmap, QImage
|
||||
from PySide6.QtCore import Qt
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AnnotationTab(QWidget):
|
||||
@@ -18,6 +35,8 @@ class AnnotationTab(QWidget):
|
||||
super().__init__(parent)
|
||||
self.db_manager = db_manager
|
||||
self.config_manager = config_manager
|
||||
self.current_image = None
|
||||
self.current_image_path = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
@@ -25,24 +44,165 @@ class AnnotationTab(QWidget):
|
||||
"""Setup user interface."""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
group = QGroupBox("Annotation Tool (Future Feature)")
|
||||
group_layout = QVBoxLayout()
|
||||
label = QLabel(
|
||||
"Annotation functionality will be implemented in future version.\n\n"
|
||||
# Image loading section
|
||||
load_group = QGroupBox("Image Loading")
|
||||
load_layout = QVBoxLayout()
|
||||
|
||||
# Load image button
|
||||
button_layout = QHBoxLayout()
|
||||
self.load_image_btn = QPushButton("Load Image")
|
||||
self.load_image_btn.clicked.connect(self._load_image)
|
||||
button_layout.addWidget(self.load_image_btn)
|
||||
button_layout.addStretch()
|
||||
|
||||
load_layout.addLayout(button_layout)
|
||||
|
||||
# Image info label
|
||||
self.image_info_label = QLabel("No image loaded")
|
||||
load_layout.addWidget(self.image_info_label)
|
||||
|
||||
load_group.setLayout(load_layout)
|
||||
layout.addWidget(load_group)
|
||||
|
||||
# Image display section
|
||||
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)
|
||||
|
||||
display_group.setLayout(display_layout)
|
||||
layout.addWidget(display_group)
|
||||
|
||||
# Future features info
|
||||
info_group = QGroupBox("Annotation Tool (Future Feature)")
|
||||
info_layout = QVBoxLayout()
|
||||
info_label = QLabel(
|
||||
"Full annotation functionality will be implemented in future version.\n\n"
|
||||
"Planned Features:\n"
|
||||
"- Image browser\n"
|
||||
"- Drawing tools for bounding boxes\n"
|
||||
"- Class label assignment\n"
|
||||
"- Export annotations to YOLO format\n"
|
||||
"- Annotation verification"
|
||||
)
|
||||
group_layout.addWidget(label)
|
||||
group.setLayout(group_layout)
|
||||
info_layout.addWidget(info_label)
|
||||
info_group.setLayout(info_layout)
|
||||
|
||||
layout.addWidget(group)
|
||||
layout.addStretch()
|
||||
layout.addWidget(info_group)
|
||||
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())
|
||||
|
||||
# Open file dialog
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Select Image",
|
||||
start_dir,
|
||||
"Images (*.jpg *.jpeg *.png *.tif *.tiff *.bmp)",
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
# Load image using Image class
|
||||
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"
|
||||
)
|
||||
self.image_info_label.setText(info_text)
|
||||
|
||||
# Convert to QPixmap and display
|
||||
self._display_image()
|
||||
|
||||
logger.info(f"Loaded image: {file_path}")
|
||||
|
||||
except ImageLoadError as e:
|
||||
logger.error(f"Failed to load image: {e}")
|
||||
QMessageBox.critical(
|
||||
self, "Error Loading Image", f"Failed to load image:\n{str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
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."""
|
||||
if self.current_image is None:
|
||||
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,
|
||||
)
|
||||
|
||||
# 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 refresh(self):
|
||||
"""Refresh the tab."""
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Utility modules for the microscopy object detection application.
|
||||
"""
|
||||
|
||||
from src.utils.image import Image, ImageLoadError
|
||||
|
||||
__all__ = ["Image", "ImageLoadError"]
|
||||
|
||||
259
src/utils/image.py
Normal file
259
src/utils/image.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Image loading and management utilities for the microscopy object detection application.
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ImageLoadError(Exception):
|
||||
"""Exception raised when an image cannot be loaded."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Image:
|
||||
"""
|
||||
A class for loading and managing images from file paths.
|
||||
|
||||
Supports multiple image formats: .jpg, .jpeg, .png, .tif, .tiff, .bmp
|
||||
Provides access to image data in multiple formats (OpenCV/numpy, PIL).
|
||||
|
||||
Attributes:
|
||||
path: Path to the image file
|
||||
data: Image data as numpy array (OpenCV format, BGR)
|
||||
pil_image: Image data as PIL Image (RGB)
|
||||
width: Image width in pixels
|
||||
height: Image height in pixels
|
||||
channels: Number of color channels
|
||||
format: Image file format
|
||||
size_bytes: File size in bytes
|
||||
"""
|
||||
|
||||
SUPPORTED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"]
|
||||
|
||||
def __init__(self, image_path: Union[str, Path]):
|
||||
"""
|
||||
Initialize an Image object by loading from a file path.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file (string or Path object)
|
||||
|
||||
Raises:
|
||||
ImageLoadError: If the image cannot be loaded or is invalid
|
||||
"""
|
||||
self.path = Path(image_path)
|
||||
self._data: Optional[np.ndarray] = None
|
||||
self._pil_image: Optional[PILImage.Image] = None
|
||||
self._width: int = 0
|
||||
self._height: int = 0
|
||||
self._channels: int = 0
|
||||
self._format: str = ""
|
||||
self._size_bytes: int = 0
|
||||
|
||||
# Load the image
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""
|
||||
Load the image from disk.
|
||||
|
||||
Raises:
|
||||
ImageLoadError: If the image cannot be loaded
|
||||
"""
|
||||
# Validate path
|
||||
if not validate_file_path(str(self.path), must_exist=True):
|
||||
raise ImageLoadError(f"Invalid or non-existent file path: {self.path}")
|
||||
|
||||
# Check file extension
|
||||
if not is_image_file(str(self.path), self.SUPPORTED_EXTENSIONS):
|
||||
ext = self.path.suffix.lower()
|
||||
raise ImageLoadError(
|
||||
f"Unsupported image format: {ext}. "
|
||||
f"Supported formats: {', '.join(self.SUPPORTED_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Load with OpenCV (returns BGR format)
|
||||
self._data = cv2.imread(str(self.path), cv2.IMREAD_UNCHANGED)
|
||||
|
||||
if self._data is None:
|
||||
raise ImageLoadError(f"Failed to load image with OpenCV: {self.path}")
|
||||
|
||||
# Extract metadata
|
||||
self._height, self._width = self._data.shape[:2]
|
||||
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
|
||||
|
||||
# Load PIL version for compatibility (convert BGR to RGB)
|
||||
if self._channels == 3:
|
||||
rgb_data = cv2.cvtColor(self._data, cv2.COLOR_BGR2RGB)
|
||||
self._pil_image = PILImage.fromarray(rgb_data)
|
||||
elif self._channels == 4:
|
||||
rgba_data = cv2.cvtColor(self._data, cv2.COLOR_BGRA2RGBA)
|
||||
self._pil_image = PILImage.fromarray(rgba_data)
|
||||
else:
|
||||
# Grayscale
|
||||
self._pil_image = PILImage.fromarray(self._data)
|
||||
|
||||
logger.info(
|
||||
f"Successfully loaded image: {self.path.name} "
|
||||
f"({self._width}x{self._height}, {self._channels} channels, "
|
||||
f"{self._format.upper()})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading image {self.path}: {e}")
|
||||
raise ImageLoadError(f"Failed to load image: {e}") from e
|
||||
|
||||
@property
|
||||
def data(self) -> np.ndarray:
|
||||
"""
|
||||
Get image data as numpy array (OpenCV format, BGR or grayscale).
|
||||
|
||||
Returns:
|
||||
Image data as numpy array
|
||||
"""
|
||||
if self._data is None:
|
||||
raise ImageLoadError("Image data not available")
|
||||
return self._data
|
||||
|
||||
@property
|
||||
def pil_image(self) -> PILImage.Image:
|
||||
"""
|
||||
Get image data as PIL Image (RGB or grayscale).
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
if self._pil_image is None:
|
||||
raise ImageLoadError("PIL image not available")
|
||||
return self._pil_image
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Get image width in pixels."""
|
||||
return self._width
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
"""Get image height in pixels."""
|
||||
return self._height
|
||||
|
||||
@property
|
||||
def shape(self) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Get image shape as (height, width, channels).
|
||||
|
||||
Returns:
|
||||
Tuple of (height, width, channels)
|
||||
"""
|
||||
return (self._height, self._width, self._channels)
|
||||
|
||||
@property
|
||||
def channels(self) -> int:
|
||||
"""Get number of color channels."""
|
||||
return self._channels
|
||||
|
||||
@property
|
||||
def format(self) -> str:
|
||||
"""Get image file format (e.g., 'jpg', 'png')."""
|
||||
return self._format
|
||||
|
||||
@property
|
||||
def size_bytes(self) -> int:
|
||||
"""Get file size in bytes."""
|
||||
return self._size_bytes
|
||||
|
||||
@property
|
||||
def size_mb(self) -> float:
|
||||
"""Get file size in megabytes."""
|
||||
return self._size_bytes / (1024 * 1024)
|
||||
|
||||
def get_rgb(self) -> np.ndarray:
|
||||
"""
|
||||
Get image data as RGB numpy array.
|
||||
|
||||
Returns:
|
||||
Image data in RGB format as numpy array
|
||||
"""
|
||||
if self._channels == 3:
|
||||
return cv2.cvtColor(self._data, cv2.COLOR_BGR2RGB)
|
||||
elif self._channels == 4:
|
||||
return cv2.cvtColor(self._data, cv2.COLOR_BGRA2RGBA)
|
||||
else:
|
||||
return self._data
|
||||
|
||||
def get_grayscale(self) -> np.ndarray:
|
||||
"""
|
||||
Get image as grayscale numpy array.
|
||||
|
||||
Returns:
|
||||
Grayscale image as numpy array
|
||||
"""
|
||||
if self._channels == 1:
|
||||
return self._data
|
||||
else:
|
||||
return cv2.cvtColor(self._data, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
def copy(self) -> np.ndarray:
|
||||
"""
|
||||
Get a copy of the image data.
|
||||
|
||||
Returns:
|
||||
Copy of image data as numpy array
|
||||
"""
|
||||
return self._data.copy()
|
||||
|
||||
def resize(self, width: int, height: int) -> np.ndarray:
|
||||
"""
|
||||
Resize the image to specified dimensions.
|
||||
|
||||
Args:
|
||||
width: Target width in pixels
|
||||
height: Target height in pixels
|
||||
|
||||
Returns:
|
||||
Resized image as numpy array (does not modify original)
|
||||
"""
|
||||
return cv2.resize(self._data, (width, height))
|
||||
|
||||
def is_grayscale(self) -> bool:
|
||||
"""
|
||||
Check if image is grayscale.
|
||||
|
||||
Returns:
|
||||
True if image is grayscale (1 channel)
|
||||
"""
|
||||
return self._channels == 1
|
||||
|
||||
def is_color(self) -> bool:
|
||||
"""
|
||||
Check if image is color.
|
||||
|
||||
Returns:
|
||||
True if image has 3 or more channels
|
||||
"""
|
||||
return self._channels >= 3
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the Image object."""
|
||||
return (
|
||||
f"Image(path='{self.path.name}', "
|
||||
f"shape=({self._width}x{self._height}x{self._channels}), "
|
||||
f"format={self._format}, "
|
||||
f"size={self.size_mb:.2f}MB)"
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the Image object."""
|
||||
return self.__repr__()
|
||||
Reference in New Issue
Block a user