express / backend /services /image_service.py
Raven10492's picture
Upload 7 files
7dd9c94 verified
from PIL import Image, ImageOps
import io
def parse_aspect_ratio(aspect_ratio_str: str) -> float:
"""
Parse aspect ratio string like "16:9" or "9:16" to float.
"""
parts = aspect_ratio_str.split(':')
if len(parts) == 2:
return float(parts[0]) / float(parts[1])
return 16.0 / 9.0 # fallback
def _parse_hex_color(fill_color: str) -> tuple:
"""
Safely parse hex color like "#RRGGBB" to an RGB tuple for PIL.
Falls back to black if parsing fails.
"""
if not fill_color:
return (0, 0, 0)
s = fill_color.strip()
try:
if s.startswith("#"):
s = s[1:]
if len(s) == 6:
r = int(s[0:2], 16)
g = int(s[2:4], 16)
b = int(s[4:6], 16)
return (r, g, b)
except Exception:
pass
return (0, 0, 0)
def process_image(image_bytes: bytes, mode: str, fill_color: str = "#000000", target_aspect_ratio: str = "16:9") -> bytes:
"""
Processes an image to fit the specified aspect ratio.
Args:
image_bytes: The original image in bytes.
mode: The processing mode ('stretch_to_fill', 'compress_to_fit', 'fill').
fill_color: The hex color code for the 'fill' mode background.
target_aspect_ratio: The target aspect ratio string like "16:9" or "9:16".
Returns:
The processed image in bytes (JPEG format).
"""
if not mode or mode == 'none':
return image_bytes
try:
target_ratio = parse_aspect_ratio(target_aspect_ratio)
image = Image.open(io.BytesIO(image_bytes))
# Normalize EXIF orientation to ensure width/height reflect actual display
try:
image = ImageOps.exif_transpose(image)
except Exception:
pass
original_width, original_height = image.size
original_aspect_ratio = original_width / original_height
# If aspect ratio is already correct, no processing needed
if abs(original_aspect_ratio - target_ratio) < 1e-6:
return image_bytes
if mode == 'stretch_to_fill':
if original_aspect_ratio > target_ratio: # Wider than target
# Height is relatively too short, stretch it
new_width = original_width
new_height = int(new_width / target_ratio)
processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
else: # Taller than target
# Width is relatively too short, stretch it
new_height = original_height
new_width = int(new_height * target_ratio)
processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
elif mode == 'compress_to_fit':
if original_aspect_ratio > target_ratio: # Wider than target
# Width is relatively too long, compress it
new_height = original_height
new_width = int(new_height * target_ratio)
processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
else: # Taller than target
# Height is relatively too long, compress it
new_width = original_width
new_height = int(new_width / target_ratio)
processed_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
elif mode == 'fill':
# Pad with color to meet target aspect ratio without scaling the source
if original_aspect_ratio > target_ratio:
# Wider than target -> pad top/bottom
new_width = original_width
new_height = int(round(new_width / target_ratio))
paste_x, paste_y = 0, (new_height - original_height) // 2
else:
# Taller than target -> pad left/right
new_height = original_height
new_width = int(round(new_height * target_ratio))
paste_x, paste_y = (new_width - original_width) // 2, 0
# Use parsed RGB to avoid mode issues and ensure consistent color
fill_rgb = _parse_hex_color(fill_color or "#000000")
background = Image.new('RGB', (new_width, new_height), fill_rgb)
# Preserve alpha if present so the background color shows through transparent regions
if image.mode in ('RGBA', 'LA') or (image.mode == 'P' and 'transparency' in image.info):
if image.mode != 'RGBA':
image = image.convert('RGBA')
background.paste(image, (paste_x, paste_y), image)
else:
background.paste(image, (paste_x, paste_y))
processed_image = background
else:
# If mode is unknown, return original image
return image_bytes
# Save the processed image to a byte buffer
byte_arr = io.BytesIO()
# Ensure image is in RGB mode before saving as JPEG
if processed_image.mode != 'RGB':
processed_image = processed_image.convert('RGB')
processed_image.save(byte_arr, format='JPEG')
return byte_arr.getvalue()
except Exception as e:
print(f"Error processing image: {e}")
# In case of error, return the original image bytes
return image_bytes