From 4b5d2a7c45cf0d8e59d89c6b5bcd0df21f42d912 Mon Sep 17 00:00:00 2001 From: Martin Laasmaa Date: Mon, 8 Dec 2025 16:28:58 +0200 Subject: [PATCH] Adding image loading --- docs/IMAGE_CLASS_USAGE.md | 220 ++++++++++++++++++++++++++++ examples/image_demo.py | 151 +++++++++++++++++++ src/gui/tabs/annotation_tab.py | 180 +++++++++++++++++++++-- src/utils/__init__.py | 7 + src/utils/image.py | 259 +++++++++++++++++++++++++++++++++ tests/test_image.py | 145 ++++++++++++++++++ 6 files changed, 952 insertions(+), 10 deletions(-) create mode 100644 docs/IMAGE_CLASS_USAGE.md create mode 100644 examples/image_demo.py create mode 100644 src/utils/image.py create mode 100644 tests/test_image.py diff --git a/docs/IMAGE_CLASS_USAGE.md b/docs/IMAGE_CLASS_USAGE.md new file mode 100644 index 0000000..dd4c2a8 --- /dev/null +++ b/docs/IMAGE_CLASS_USAGE.md @@ -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") \ No newline at end of file diff --git a/examples/image_demo.py b/examples/image_demo.py new file mode 100644 index 0000000..9f5c4eb --- /dev/null +++ b/examples/image_demo.py @@ -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") diff --git a/src/gui/tabs/annotation_tab.py b/src/gui/tabs/annotation_tab.py index 7b2f5fc..065b4e1 100644 --- a/src/gui/tabs/annotation_tab.py +++ b/src/gui/tabs/annotation_tab.py @@ -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 diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e69de29..5f75af4 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -0,0 +1,7 @@ +""" +Utility modules for the microscopy object detection application. +""" + +from src.utils.image import Image, ImageLoadError + +__all__ = ["Image", "ImageLoadError"] diff --git a/src/utils/image.py b/src/utils/image.py new file mode 100644 index 0000000..75f40e4 --- /dev/null +++ b/src/utils/image.py @@ -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__() diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 0000000..88b617f --- /dev/null +++ b/tests/test_image.py @@ -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