|
|
""" |
|
|
Image Processor Module |
|
|
|
|
|
Handles all image processing operations including loading, validation, |
|
|
resizing, normalization, and format conversion. |
|
|
""" |
|
|
|
|
|
import hashlib |
|
|
import magic |
|
|
from pathlib import Path |
|
|
from typing import Tuple, Optional, Union |
|
|
import numpy as np |
|
|
from PIL import Image, ImageOps |
|
|
from loguru import logger |
|
|
|
|
|
from core.config import config |
|
|
from core.exceptions import ( |
|
|
ImageProcessingError, |
|
|
InvalidFileError, |
|
|
FileSizeError, |
|
|
UnsupportedFormatError, |
|
|
) |
|
|
|
|
|
|
|
|
class ImageProcessor: |
|
|
""" |
|
|
Process images for analysis. |
|
|
|
|
|
Handles validation, resizing, normalization, and format conversion |
|
|
for images before they are passed to AI models. |
|
|
""" |
|
|
|
|
|
def __init__(self): |
|
|
"""Initialize ImageProcessor.""" |
|
|
self.max_size = config.MAX_IMAGE_SIZE |
|
|
self.max_dimension = config.IMAGE_MAX_DIMENSION |
|
|
self.allowed_formats = config.ALLOWED_IMAGE_FORMATS |
|
|
logger.info("ImageProcessor initialized") |
|
|
|
|
|
def load_image(self, image_path: Union[str, Path]) -> Image.Image: |
|
|
""" |
|
|
Load image from file path. |
|
|
|
|
|
Args: |
|
|
image_path: Path to image file |
|
|
|
|
|
Returns: |
|
|
PIL Image object |
|
|
|
|
|
Raises: |
|
|
InvalidFileError: If image cannot be loaded |
|
|
""" |
|
|
try: |
|
|
image_path = Path(image_path) |
|
|
if not image_path.exists(): |
|
|
raise InvalidFileError( |
|
|
f"Image file not found: {image_path}", |
|
|
{"path": str(image_path)} |
|
|
) |
|
|
|
|
|
|
|
|
self.validate_image(image_path) |
|
|
|
|
|
|
|
|
image = Image.open(image_path) |
|
|
|
|
|
|
|
|
if image.mode != "RGB": |
|
|
image = image.convert("RGB") |
|
|
|
|
|
logger.info(f"Loaded image: {image_path.name} ({image.size})") |
|
|
return image |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Failed to load image: {e}") |
|
|
raise InvalidFileError( |
|
|
f"Cannot load image: {str(e)}", |
|
|
{"path": str(image_path), "error": str(e)} |
|
|
) |
|
|
|
|
|
def validate_image(self, image_path: Path) -> bool: |
|
|
""" |
|
|
Validate image file. |
|
|
|
|
|
Args: |
|
|
image_path: Path to image file |
|
|
|
|
|
Returns: |
|
|
True if valid |
|
|
|
|
|
Raises: |
|
|
FileSizeError: If file too large |
|
|
UnsupportedFormatError: If format not supported |
|
|
InvalidFileError: If file is corrupted |
|
|
""" |
|
|
|
|
|
file_size = image_path.stat().st_size |
|
|
if file_size > self.max_size: |
|
|
raise FileSizeError( |
|
|
f"Image too large: {file_size / 1024 / 1024:.1f}MB", |
|
|
{"max_size": self.max_size, "actual_size": file_size} |
|
|
) |
|
|
|
|
|
|
|
|
ext = image_path.suffix.lower() |
|
|
if ext not in self.allowed_formats: |
|
|
raise UnsupportedFormatError( |
|
|
f"Unsupported image format: {ext}", |
|
|
{"allowed": self.allowed_formats, "received": ext} |
|
|
) |
|
|
|
|
|
|
|
|
try: |
|
|
mime = magic.from_file(str(image_path), mime=True) |
|
|
if not mime.startswith("image/"): |
|
|
raise InvalidFileError( |
|
|
f"File is not a valid image: {mime}", |
|
|
{"mime_type": mime} |
|
|
) |
|
|
except Exception as e: |
|
|
logger.warning(f"Could not verify MIME type: {e}") |
|
|
|
|
|
return True |
|
|
|
|
|
def resize_image( |
|
|
self, |
|
|
image: Image.Image, |
|
|
max_size: Optional[Tuple[int, int]] = None, |
|
|
maintain_aspect_ratio: bool = True |
|
|
) -> Image.Image: |
|
|
""" |
|
|
Resize image to specified dimensions. |
|
|
|
|
|
Args: |
|
|
image: PIL Image object |
|
|
max_size: Maximum (width, height) tuple |
|
|
maintain_aspect_ratio: Whether to maintain aspect ratio |
|
|
|
|
|
Returns: |
|
|
Resized PIL Image |
|
|
""" |
|
|
if max_size is None: |
|
|
max_size = config.DEFAULT_IMAGE_SIZE |
|
|
|
|
|
original_size = image.size |
|
|
|
|
|
if maintain_aspect_ratio: |
|
|
|
|
|
image.thumbnail(max_size, Image.Resampling.LANCZOS) |
|
|
else: |
|
|
|
|
|
image = image.resize(max_size, Image.Resampling.LANCZOS) |
|
|
|
|
|
logger.debug(f"Resized image: {original_size} -> {image.size}") |
|
|
return image |
|
|
|
|
|
def normalize_image(self, image: Image.Image) -> np.ndarray: |
|
|
""" |
|
|
Normalize image to numpy array with values [0, 1]. |
|
|
|
|
|
Args: |
|
|
image: PIL Image object |
|
|
|
|
|
Returns: |
|
|
Normalized numpy array (H, W, C) |
|
|
""" |
|
|
|
|
|
img_array = np.array(image, dtype=np.float32) |
|
|
|
|
|
|
|
|
img_array = img_array / 255.0 |
|
|
|
|
|
logger.debug(f"Normalized image to shape: {img_array.shape}") |
|
|
return img_array |
|
|
|
|
|
def apply_exif_orientation(self, image: Image.Image) -> Image.Image: |
|
|
""" |
|
|
Apply EXIF orientation to image. |
|
|
|
|
|
Args: |
|
|
image: PIL Image object |
|
|
|
|
|
Returns: |
|
|
Oriented PIL Image |
|
|
""" |
|
|
try: |
|
|
image = ImageOps.exif_transpose(image) |
|
|
logger.debug("Applied EXIF orientation") |
|
|
except Exception as e: |
|
|
logger.warning(f"Could not apply EXIF orientation: {e}") |
|
|
|
|
|
return image |
|
|
|
|
|
def get_image_hash(self, image_path: Path) -> str: |
|
|
""" |
|
|
Generate SHA256 hash of image file. |
|
|
|
|
|
Args: |
|
|
image_path: Path to image file |
|
|
|
|
|
Returns: |
|
|
Hex string of hash |
|
|
""" |
|
|
sha256_hash = hashlib.sha256() |
|
|
|
|
|
with open(image_path, "rb") as f: |
|
|
|
|
|
for chunk in iter(lambda: f.read(8192), b""): |
|
|
sha256_hash.update(chunk) |
|
|
|
|
|
return sha256_hash.hexdigest() |
|
|
|
|
|
def process( |
|
|
self, |
|
|
image_path: Union[str, Path], |
|
|
resize: bool = True, |
|
|
normalize: bool = False, |
|
|
apply_orientation: bool = True |
|
|
) -> Union[Image.Image, np.ndarray]: |
|
|
""" |
|
|
Complete image processing pipeline. |
|
|
|
|
|
Args: |
|
|
image_path: Path to image file |
|
|
resize: Whether to resize image |
|
|
normalize: Whether to normalize to numpy array |
|
|
apply_orientation: Whether to apply EXIF orientation |
|
|
|
|
|
Returns: |
|
|
Processed image (PIL Image or numpy array) |
|
|
""" |
|
|
try: |
|
|
|
|
|
image = self.load_image(image_path) |
|
|
|
|
|
|
|
|
if apply_orientation: |
|
|
image = self.apply_exif_orientation(image) |
|
|
|
|
|
|
|
|
if resize: |
|
|
image = self.resize_image(image) |
|
|
|
|
|
|
|
|
if normalize: |
|
|
return self.normalize_image(image) |
|
|
|
|
|
return image |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Image processing failed: {e}") |
|
|
raise ImageProcessingError( |
|
|
f"Failed to process image: {str(e)}", |
|
|
{"path": str(image_path), "error": str(e)} |
|
|
) |
|
|
|
|
|
def get_image_info(self, image_path: Union[str, Path]) -> dict: |
|
|
""" |
|
|
Get information about an image. |
|
|
|
|
|
Args: |
|
|
image_path: Path to image file |
|
|
|
|
|
Returns: |
|
|
Dictionary with image information |
|
|
""" |
|
|
image_path = Path(image_path) |
|
|
image = self.load_image(image_path) |
|
|
|
|
|
return { |
|
|
"filename": image_path.name, |
|
|
"format": image.format, |
|
|
"mode": image.mode, |
|
|
"size": image.size, |
|
|
"width": image.size[0], |
|
|
"height": image.size[1], |
|
|
"file_size": image_path.stat().st_size, |
|
|
"hash": self.get_image_hash(image_path), |
|
|
} |
|
|
|