Adding image loading
This commit is contained in:
220
docs/IMAGE_CLASS_USAGE.md
Normal file
220
docs/IMAGE_CLASS_USAGE.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Image Class Usage Guide
|
||||||
|
|
||||||
|
The `Image` class provides a convenient way to load and work with images in the microscopy object detection application.
|
||||||
|
|
||||||
|
## Supported Formats
|
||||||
|
|
||||||
|
The Image class supports the following image formats:
|
||||||
|
- `.jpg`, `.jpeg` - JPEG images
|
||||||
|
- `.png` - PNG images
|
||||||
|
- `.tif`, `.tiff` - TIFF images (commonly used in microscopy)
|
||||||
|
- `.bmp` - Bitmap images
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Loading an Image
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.utils import Image, ImageLoadError
|
||||||
|
|
||||||
|
# Load an image from a file path
|
||||||
|
try:
|
||||||
|
img = Image("path/to/image.jpg")
|
||||||
|
print(f"Loaded image: {img.width}x{img.height} pixels")
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f"Failed to load image: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Image Properties
|
||||||
|
|
||||||
|
```python
|
||||||
|
img = Image("microscopy_image.tif")
|
||||||
|
|
||||||
|
# Basic properties
|
||||||
|
print(f"Width: {img.width} pixels")
|
||||||
|
print(f"Height: {img.height} pixels")
|
||||||
|
print(f"Channels: {img.channels}")
|
||||||
|
print(f"Format: {img.format}")
|
||||||
|
print(f"Shape: {img.shape}") # (height, width, channels)
|
||||||
|
|
||||||
|
# File information
|
||||||
|
print(f"File size: {img.size_mb:.2f} MB")
|
||||||
|
print(f"File size: {img.size_bytes} bytes")
|
||||||
|
|
||||||
|
# Image type checks
|
||||||
|
print(f"Is color: {img.is_color()}")
|
||||||
|
print(f"Is grayscale: {img.is_grayscale()}")
|
||||||
|
|
||||||
|
# String representation
|
||||||
|
print(img) # Shows summary of image properties
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with Image Data
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
img = Image("sample.png")
|
||||||
|
|
||||||
|
# Get image data as numpy array (OpenCV format, BGR)
|
||||||
|
bgr_data = img.data
|
||||||
|
print(f"Data shape: {bgr_data.shape}")
|
||||||
|
print(f"Data type: {bgr_data.dtype}")
|
||||||
|
|
||||||
|
# Get image as RGB (for display or processing)
|
||||||
|
rgb_data = img.get_rgb()
|
||||||
|
|
||||||
|
# Get grayscale version
|
||||||
|
gray_data = img.get_grayscale()
|
||||||
|
|
||||||
|
# Create a copy (for modifications)
|
||||||
|
img_copy = img.copy()
|
||||||
|
img_copy[0, 0] = [255, 255, 255] # Modify copy, original unchanged
|
||||||
|
|
||||||
|
# Resize image (returns new array, doesn't modify original)
|
||||||
|
resized = img.resize(640, 640)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using PIL Image
|
||||||
|
|
||||||
|
```python
|
||||||
|
img = Image("photo.jpg")
|
||||||
|
|
||||||
|
# Access as PIL Image (RGB format)
|
||||||
|
pil_img = img.pil_image
|
||||||
|
|
||||||
|
# Use PIL methods
|
||||||
|
pil_img.show() # Display image
|
||||||
|
pil_img.save("output.png") # Save with PIL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with YOLO
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.utils import Image
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
# Load model and image
|
||||||
|
model = YOLO("yolov8n.pt")
|
||||||
|
img = Image("microscopy/cell_01.tif")
|
||||||
|
|
||||||
|
# Run inference (YOLO accepts file paths or numpy arrays)
|
||||||
|
results = model(img.data)
|
||||||
|
|
||||||
|
# Or use the file path directly
|
||||||
|
results = model(str(img.path))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.utils import Image, ImageLoadError
|
||||||
|
|
||||||
|
def process_image(image_path):
|
||||||
|
try:
|
||||||
|
img = Image(image_path)
|
||||||
|
# Process the image...
|
||||||
|
return img
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f"Cannot load image: {e}")
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Batch Processing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from src.utils import Image, ImageLoadError
|
||||||
|
|
||||||
|
def process_image_directory(directory):
|
||||||
|
"""Process all images in a directory."""
|
||||||
|
image_paths = Path(directory).glob("*.tif")
|
||||||
|
|
||||||
|
for path in image_paths:
|
||||||
|
try:
|
||||||
|
img = Image(path)
|
||||||
|
print(f"Processing {img.path.name}: {img.width}x{img.height}")
|
||||||
|
# Process the image...
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f"Skipping {path}: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using with OpenCV Operations
|
||||||
|
|
||||||
|
```python
|
||||||
|
import cv2
|
||||||
|
from src.utils import Image
|
||||||
|
|
||||||
|
img = Image("input.jpg")
|
||||||
|
|
||||||
|
# Apply OpenCV operations on the data
|
||||||
|
blurred = cv2.GaussianBlur(img.data, (5, 5), 0)
|
||||||
|
edges = cv2.Canny(img.data, 100, 200)
|
||||||
|
|
||||||
|
# Note: These operations don't modify the original img.data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Efficient Processing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.utils import Image
|
||||||
|
|
||||||
|
# The Image class loads data into memory
|
||||||
|
img = Image("large_image.tif")
|
||||||
|
print(f"Image size in memory: {img.data.nbytes / (1024**2):.2f} MB")
|
||||||
|
|
||||||
|
# When processing many images, consider loading one at a time
|
||||||
|
# and releasing memory by deleting the object
|
||||||
|
del img
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always use try-except** when loading images to handle errors gracefully
|
||||||
|
2. **Check image properties** before processing to ensure compatibility
|
||||||
|
3. **Use copy()** when you need to modify image data without affecting the original
|
||||||
|
4. **Path objects work too** - The class accepts both strings and Path objects
|
||||||
|
5. **Consider memory usage** when working with large images or batches
|
||||||
|
|
||||||
|
## Example: Complete Workflow
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.utils import Image, ImageLoadError
|
||||||
|
from src.utils.file_utils import get_image_files
|
||||||
|
|
||||||
|
def analyze_microscopy_images(directory):
|
||||||
|
"""Analyze all microscopy images in a directory."""
|
||||||
|
|
||||||
|
# Get all image files
|
||||||
|
image_files = get_image_files(directory, recursive=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for image_path in image_files:
|
||||||
|
try:
|
||||||
|
# Load image
|
||||||
|
img = Image(image_path)
|
||||||
|
|
||||||
|
# Analyze
|
||||||
|
result = {
|
||||||
|
'filename': img.path.name,
|
||||||
|
'width': img.width,
|
||||||
|
'height': img.height,
|
||||||
|
'channels': img.channels,
|
||||||
|
'format': img.format,
|
||||||
|
'size_mb': img.size_mb,
|
||||||
|
'is_color': img.is_color()
|
||||||
|
}
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
print(f"✓ Analyzed {img.path.name}")
|
||||||
|
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f"✗ Failed to load {image_path}: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Run analysis
|
||||||
|
results = analyze_microscopy_images("data/datasets/cells")
|
||||||
|
print(f"\nProcessed {len(results)} images")
|
||||||
151
examples/image_demo.py
Normal file
151
examples/image_demo.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
Example script demonstrating the Image class functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.utils import Image, ImageLoadError
|
||||||
|
|
||||||
|
|
||||||
|
def demonstrate_image_loading():
|
||||||
|
"""Demonstrate basic image loading functionality."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Image Class Demonstration")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Example 1: Try to load an image (replace with your own path)
|
||||||
|
example_paths = [
|
||||||
|
"data/datasets/example.jpg",
|
||||||
|
"data/datasets/sample.png",
|
||||||
|
"tests/test_image.jpg",
|
||||||
|
]
|
||||||
|
|
||||||
|
loaded_img = None
|
||||||
|
for image_path in example_paths:
|
||||||
|
if Path(image_path).exists():
|
||||||
|
try:
|
||||||
|
print(f"\n1. Loading image: {image_path}")
|
||||||
|
img = Image(image_path)
|
||||||
|
loaded_img = img
|
||||||
|
print(f" ✓ Successfully loaded!")
|
||||||
|
print(f" {img}")
|
||||||
|
break
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f" ✗ Failed: {e}")
|
||||||
|
else:
|
||||||
|
print(f"\n1. Image not found: {image_path}")
|
||||||
|
|
||||||
|
if loaded_img is None:
|
||||||
|
print("\nNo example images found. Creating a test image...")
|
||||||
|
create_test_image()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Example 2: Access image properties
|
||||||
|
print(f"\n2. Image Properties:")
|
||||||
|
print(f" Width: {loaded_img.width} pixels")
|
||||||
|
print(f" Height: {loaded_img.height} pixels")
|
||||||
|
print(f" Channels: {loaded_img.channels}")
|
||||||
|
print(f" Format: {loaded_img.format.upper()}")
|
||||||
|
print(f" Shape: {loaded_img.shape}")
|
||||||
|
print(f" File size: {loaded_img.size_mb:.2f} MB")
|
||||||
|
print(f" Is color: {loaded_img.is_color()}")
|
||||||
|
print(f" Is grayscale: {loaded_img.is_grayscale()}")
|
||||||
|
|
||||||
|
# Example 3: Get different formats
|
||||||
|
print(f"\n3. Accessing Image Data:")
|
||||||
|
print(f" BGR data shape: {loaded_img.data.shape}")
|
||||||
|
print(f" RGB data shape: {loaded_img.get_rgb().shape}")
|
||||||
|
print(f" Grayscale shape: {loaded_img.get_grayscale().shape}")
|
||||||
|
print(f" PIL image mode: {loaded_img.pil_image.mode}")
|
||||||
|
|
||||||
|
# Example 4: Resizing
|
||||||
|
print(f"\n4. Resizing Image:")
|
||||||
|
resized = loaded_img.resize(320, 320)
|
||||||
|
print(f" Original size: {loaded_img.width}x{loaded_img.height}")
|
||||||
|
print(f" Resized to: {resized.shape[1]}x{resized.shape[0]}")
|
||||||
|
|
||||||
|
# Example 5: Working with copies
|
||||||
|
print(f"\n5. Creating Copies:")
|
||||||
|
copy = loaded_img.copy()
|
||||||
|
print(f" Created copy with shape: {copy.shape}")
|
||||||
|
print(f" Original data unchanged: {(loaded_img.data == copy).all()}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Demonstration Complete!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_image():
|
||||||
|
"""Create a test image for demonstration purposes."""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
print("\nCreating a test image...")
|
||||||
|
|
||||||
|
# Create a colorful test image
|
||||||
|
width, height = 400, 300
|
||||||
|
test_img = np.zeros((height, width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
# Add some colors
|
||||||
|
test_img[:100, :] = [255, 0, 0] # Blue section
|
||||||
|
test_img[100:200, :] = [0, 255, 0] # Green section
|
||||||
|
test_img[200:, :] = [0, 0, 255] # Red section
|
||||||
|
|
||||||
|
# Save the test image
|
||||||
|
test_path = Path("test_demo_image.png")
|
||||||
|
cv2.imwrite(str(test_path), test_img)
|
||||||
|
print(f"Test image created: {test_path}")
|
||||||
|
|
||||||
|
# Now load and demonstrate with it
|
||||||
|
try:
|
||||||
|
img = Image(test_path)
|
||||||
|
print(f"\nLoaded test image: {img}")
|
||||||
|
print(f"Dimensions: {img.width}x{img.height}")
|
||||||
|
print(f"Channels: {img.channels}")
|
||||||
|
print(f"Format: {img.format}")
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
test_path.unlink()
|
||||||
|
print(f"\nTest image cleaned up.")
|
||||||
|
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f"Error loading test image: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def demonstrate_error_handling():
|
||||||
|
"""Demonstrate error handling."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Error Handling Demonstration")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Try to load non-existent file
|
||||||
|
print("\n1. Loading non-existent file:")
|
||||||
|
try:
|
||||||
|
img = Image("nonexistent.jpg")
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f" ✓ Caught error: {e}")
|
||||||
|
|
||||||
|
# Try unsupported format
|
||||||
|
print("\n2. Loading unsupported format:")
|
||||||
|
try:
|
||||||
|
# Create a text file
|
||||||
|
test_file = Path("test.txt")
|
||||||
|
test_file.write_text("not an image")
|
||||||
|
img = Image(test_file)
|
||||||
|
except ImageLoadError as e:
|
||||||
|
print(f" ✓ Caught error: {e}")
|
||||||
|
test_file.unlink() # Clean up
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("\n")
|
||||||
|
demonstrate_image_loading()
|
||||||
|
print("\n")
|
||||||
|
demonstrate_error_handling()
|
||||||
|
print("\n")
|
||||||
@@ -3,10 +3,27 @@ Annotation tab for the microscopy object detection application.
|
|||||||
Future feature for manual annotation.
|
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.database.db_manager import DatabaseManager
|
||||||
from src.utils.config_manager import ConfigManager
|
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):
|
class AnnotationTab(QWidget):
|
||||||
@@ -18,6 +35,8 @@ class AnnotationTab(QWidget):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db_manager = db_manager
|
self.db_manager = db_manager
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
|
self.current_image = None
|
||||||
|
self.current_image_path = None
|
||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
@@ -25,24 +44,165 @@ class AnnotationTab(QWidget):
|
|||||||
"""Setup user interface."""
|
"""Setup user interface."""
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
group = QGroupBox("Annotation Tool (Future Feature)")
|
# Image loading section
|
||||||
group_layout = QVBoxLayout()
|
load_group = QGroupBox("Image Loading")
|
||||||
label = QLabel(
|
load_layout = QVBoxLayout()
|
||||||
"Annotation functionality will be implemented in future version.\n\n"
|
|
||||||
|
# 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"
|
"Planned Features:\n"
|
||||||
"- Image browser\n"
|
|
||||||
"- Drawing tools for bounding boxes\n"
|
"- Drawing tools for bounding boxes\n"
|
||||||
"- Class label assignment\n"
|
"- Class label assignment\n"
|
||||||
"- Export annotations to YOLO format\n"
|
"- Export annotations to YOLO format\n"
|
||||||
"- Annotation verification"
|
"- Annotation verification"
|
||||||
)
|
)
|
||||||
group_layout.addWidget(label)
|
info_layout.addWidget(info_label)
|
||||||
group.setLayout(group_layout)
|
info_group.setLayout(info_layout)
|
||||||
|
|
||||||
layout.addWidget(group)
|
layout.addWidget(info_group)
|
||||||
layout.addStretch()
|
|
||||||
self.setLayout(layout)
|
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):
|
def refresh(self):
|
||||||
"""Refresh the tab."""
|
"""Refresh the tab."""
|
||||||
pass
|
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__()
|
||||||
145
tests/test_image.py
Normal file
145
tests/test_image.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Image class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import numpy as np
|
||||||
|
from pathlib import Path
|
||||||
|
from src.utils.image import Image, ImageLoadError
|
||||||
|
|
||||||
|
|
||||||
|
class TestImage:
|
||||||
|
"""Test cases for the Image class."""
|
||||||
|
|
||||||
|
def test_load_nonexistent_file(self):
|
||||||
|
"""Test loading a non-existent file raises ImageLoadError."""
|
||||||
|
with pytest.raises(ImageLoadError):
|
||||||
|
Image("nonexistent_file.jpg")
|
||||||
|
|
||||||
|
def test_load_unsupported_format(self, tmp_path):
|
||||||
|
"""Test loading an unsupported format raises ImageLoadError."""
|
||||||
|
# Create a dummy file with unsupported extension
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("not an image")
|
||||||
|
|
||||||
|
with pytest.raises(ImageLoadError):
|
||||||
|
Image(test_file)
|
||||||
|
|
||||||
|
def test_supported_extensions(self):
|
||||||
|
"""Test that supported extensions are correctly defined."""
|
||||||
|
expected_extensions = [".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"]
|
||||||
|
assert Image.SUPPORTED_EXTENSIONS == expected_extensions
|
||||||
|
|
||||||
|
def test_image_properties(self, tmp_path):
|
||||||
|
"""Test image properties after loading."""
|
||||||
|
# Create a simple test image using numpy and cv2
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
test_img = np.zeros((100, 200, 3), dtype=np.uint8)
|
||||||
|
test_img[:, :] = [255, 0, 0] # Blue in BGR
|
||||||
|
|
||||||
|
test_file = tmp_path / "test.jpg"
|
||||||
|
cv2.imwrite(str(test_file), test_img)
|
||||||
|
|
||||||
|
# Load the image
|
||||||
|
img = Image(test_file)
|
||||||
|
|
||||||
|
# Check properties
|
||||||
|
assert img.width == 200
|
||||||
|
assert img.height == 100
|
||||||
|
assert img.channels == 3
|
||||||
|
assert img.format == "jpg"
|
||||||
|
assert img.shape == (100, 200, 3)
|
||||||
|
assert img.size_bytes > 0
|
||||||
|
assert img.is_color()
|
||||||
|
assert not img.is_grayscale()
|
||||||
|
|
||||||
|
def test_get_rgb(self, tmp_path):
|
||||||
|
"""Test RGB conversion."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# Create BGR image
|
||||||
|
test_img = np.zeros((50, 50, 3), dtype=np.uint8)
|
||||||
|
test_img[:, :] = [255, 0, 0] # Blue in BGR
|
||||||
|
|
||||||
|
test_file = tmp_path / "test_rgb.png"
|
||||||
|
cv2.imwrite(str(test_file), test_img)
|
||||||
|
|
||||||
|
img = Image(test_file)
|
||||||
|
rgb_data = img.get_rgb()
|
||||||
|
|
||||||
|
# RGB should have red channel at 255
|
||||||
|
assert rgb_data[0, 0, 0] == 0 # R
|
||||||
|
assert rgb_data[0, 0, 1] == 0 # G
|
||||||
|
assert rgb_data[0, 0, 2] == 255 # B (was BGR blue)
|
||||||
|
|
||||||
|
def test_get_grayscale(self, tmp_path):
|
||||||
|
"""Test grayscale conversion."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
test_img = np.zeros((50, 50, 3), dtype=np.uint8)
|
||||||
|
test_img[:, :] = [128, 128, 128]
|
||||||
|
|
||||||
|
test_file = tmp_path / "test_gray.png"
|
||||||
|
cv2.imwrite(str(test_file), test_img)
|
||||||
|
|
||||||
|
img = Image(test_file)
|
||||||
|
gray_data = img.get_grayscale()
|
||||||
|
|
||||||
|
assert len(gray_data.shape) == 2 # Should be 2D
|
||||||
|
assert gray_data.shape == (50, 50)
|
||||||
|
|
||||||
|
def test_copy(self, tmp_path):
|
||||||
|
"""Test copying image data."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
test_img = np.zeros((50, 50, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
test_file = tmp_path / "test_copy.png"
|
||||||
|
cv2.imwrite(str(test_file), test_img)
|
||||||
|
|
||||||
|
img = Image(test_file)
|
||||||
|
copied = img.copy()
|
||||||
|
|
||||||
|
# Modify copy
|
||||||
|
copied[0, 0] = [255, 255, 255]
|
||||||
|
|
||||||
|
# Original should be unchanged
|
||||||
|
assert not np.array_equal(img.data[0, 0], copied[0, 0])
|
||||||
|
|
||||||
|
def test_resize(self, tmp_path):
|
||||||
|
"""Test image resizing."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
test_img = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
test_file = tmp_path / "test_resize.png"
|
||||||
|
cv2.imwrite(str(test_file), test_img)
|
||||||
|
|
||||||
|
img = Image(test_file)
|
||||||
|
resized = img.resize(50, 50)
|
||||||
|
|
||||||
|
assert resized.shape == (50, 50, 3)
|
||||||
|
# Original should be unchanged
|
||||||
|
assert img.width == 100
|
||||||
|
assert img.height == 100
|
||||||
|
|
||||||
|
def test_str_repr(self, tmp_path):
|
||||||
|
"""Test string representation."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
test_img = np.zeros((100, 200, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
test_file = tmp_path / "test_str.jpg"
|
||||||
|
cv2.imwrite(str(test_file), test_img)
|
||||||
|
|
||||||
|
img = Image(test_file)
|
||||||
|
|
||||||
|
str_repr = str(img)
|
||||||
|
assert "test_str.jpg" in str_repr
|
||||||
|
assert "100x200x3" in str_repr
|
||||||
|
assert "jpg" in str_repr
|
||||||
|
|
||||||
|
repr_str = repr(img)
|
||||||
|
assert "Image" in repr_str
|
||||||
|
assert "test_str.jpg" in repr_str
|
||||||
Reference in New Issue
Block a user