image_management_api/src/services/image_processor.py
2025-05-23 22:03:39 +02:00

143 lines
5.1 KiB
Python

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()