import logging import io from typing import Dict, Any, Tuple, Optional from PIL import Image, ImageOps from PIL.ExifTags import TAGS logger = logging.getLogger(__name__) class ImageProcessor: """Service for image processing operations""" def extract_metadata(self, image_data: bytes) -> Dict[str, Any]: """ Extract metadata from an image Args: image_data: Binary image data Returns: Dictionary of metadata """ try: metadata = {} # Open the image with PIL with Image.open(io.BytesIO(image_data)) as img: # Basic image info metadata["width"] = img.width metadata["height"] = img.height metadata["format"] = img.format metadata["mode"] = img.mode # Try to extract EXIF data if available if hasattr(img, '_getexif') and img._getexif(): exif = {} for tag_id, value in img._getexif().items(): tag = TAGS.get(tag_id, tag_id) # Skip binary data that might be in EXIF if isinstance(value, (bytes, bytearray)): value = "binary data" exif[tag] = value metadata["exif"] = exif return metadata except Exception as e: logger.error(f"Error extracting image metadata: {e}") # Return at least an empty dict rather than failing return {} def resize_image(self, image_data: bytes, max_width: int = 1200, max_height: int = 1200) -> Tuple[bytes, Dict[str, Any]]: """ Resize an image while maintaining aspect ratio Args: image_data: Binary image data max_width: Maximum width max_height: Maximum height Returns: Tuple of (resized_image_data, metadata) """ try: # Open the image with PIL img = Image.open(io.BytesIO(image_data)) # Get original size original_width, original_height = img.size # Calculate new size width_ratio = max_width / original_width if original_width > max_width else 1 height_ratio = max_height / original_height if original_height > max_height else 1 # Use the smaller ratio to ensure image fits within max dimensions ratio = min(width_ratio, height_ratio) # Only resize if the image is larger than the max dimensions if ratio < 1: new_width = int(original_width * ratio) new_height = int(original_height * ratio) img = img.resize((new_width, new_height), Image.LANCZOS) logger.info(f"Resized image from {original_width}x{original_height} to {new_width}x{new_height}") else: logger.info(f"No resizing needed, image size {original_width}x{original_height}") # Save to bytes output = io.BytesIO() img.save(output, format=img.format) resized_data = output.getvalue() # Get new metadata metadata = { "width": img.width, "height": img.height, "format": img.format, "mode": img.mode, "resized": ratio < 1 } return resized_data, metadata except Exception as e: logger.error(f"Error resizing image: {e}") # Return original data on error return image_data, {"error": str(e)} def is_image(self, mime_type: str) -> bool: """ Check if a file is an image based on MIME type Args: mime_type: File MIME type Returns: True if the file is an image """ return mime_type.startswith('image/') def validate_image(self, image_data: bytes, mime_type: str) -> Tuple[bool, Optional[str]]: """ Validate an image file Args: image_data: Binary image data mime_type: File MIME type Returns: Tuple of (is_valid, error_message) """ if not self.is_image(mime_type): return False, "File is not an image" try: # Try opening the image with PIL with Image.open(io.BytesIO(image_data)) as img: # Verify image can be read img.verify() return True, None except Exception as e: logger.error(f"Invalid image: {e}") return False, f"Invalid image: {str(e)}" # Create a singleton service image_processor = ImageProcessor()