Files
serv_benchmark/backend/app/utils/image_processor.py
Gilles Soulier c67befc549 addon
2026-01-05 16:08:01 +01:00

340 lines
11 KiB
Python
Executable File

"""
Linux BenchTools - Image Processor
Handles image compression, resizing and thumbnail generation
"""
import os
from pathlib import Path
from typing import Tuple, Optional
from PIL import Image
import hashlib
from datetime import datetime
from app.core.config import settings
from app.utils.image_config_loader import image_compression_config
class ImageProcessor:
"""Image processing utilities"""
@staticmethod
def process_image_with_level(
image_path: str,
output_dir: str,
compression_level: Optional[str] = None,
output_format: Optional[str] = None,
save_original: bool = True
) -> Tuple[str, int, Optional[str]]:
"""
Process an image using configured compression level
Saves original in original/ subdirectory and resized in main directory
Args:
image_path: Path to source image
output_dir: Directory for output
compression_level: Compression level (high, medium, low, minimal)
If None, uses default from config
output_format: Output format (None = PNG from config)
save_original: Save original file in original/ subdirectory
Returns:
Tuple of (output_path, file_size_bytes, original_path)
"""
# Get compression settings and folders config
level_config = image_compression_config.get_level(compression_level)
folders = image_compression_config.get_folders()
if output_format is None:
output_format = image_compression_config.get_output_format()
# Create subdirectories
original_dir = os.path.join(output_dir, folders.get("original", "original"))
os.makedirs(original_dir, exist_ok=True)
os.makedirs(output_dir, exist_ok=True)
# Save original if requested
original_path = None
if save_original and image_compression_config.should_keep_original():
import shutil
original_filename = os.path.basename(image_path)
original_path = os.path.join(original_dir, original_filename)
shutil.copy2(image_path, original_path)
# Process and resize image
resized_path, file_size = ImageProcessor.process_image(
image_path=image_path,
output_dir=output_dir,
max_width=level_config.get("max_width"),
max_height=level_config.get("max_height"),
quality=level_config.get("quality"),
output_format=output_format
)
return resized_path, file_size, original_path
@staticmethod
def create_thumbnail_with_level(
image_path: str,
output_dir: str,
compression_level: Optional[str] = None,
output_format: Optional[str] = None
) -> Tuple[str, int]:
"""
Create thumbnail using configured compression level
Saves in thumbnail/ subdirectory
Args:
image_path: Path to source image
output_dir: Directory for output
compression_level: Compression level (high, medium, low, minimal)
output_format: Output format (None = PNG from config)
Returns:
Tuple of (output_path, file_size_bytes)
"""
# Get compression settings and folders config
level_config = image_compression_config.get_level(compression_level)
folders = image_compression_config.get_folders()
if output_format is None:
output_format = image_compression_config.get_output_format()
# Create thumbnail subdirectory
thumbnail_dir = os.path.join(output_dir, folders.get("thumbnail", "thumbnail"))
os.makedirs(thumbnail_dir, exist_ok=True)
return ImageProcessor.create_thumbnail(
image_path=image_path,
output_dir=thumbnail_dir,
size=level_config.get("thumbnail_size"),
quality=level_config.get("thumbnail_quality"),
output_format=output_format
)
@staticmethod
def process_image(
image_path: str,
output_dir: str,
max_width: Optional[int] = None,
max_height: Optional[int] = None,
quality: Optional[int] = None,
output_format: str = "webp"
) -> Tuple[str, int]:
"""
Process an image: resize and compress
Args:
image_path: Path to source image
output_dir: Directory for output
max_width: Maximum width (None = no limit)
max_height: Maximum height (None = no limit)
quality: Compression quality 1-100 (None = use settings)
output_format: Output format (webp, jpeg, png)
Returns:
Tuple of (output_path, file_size_bytes)
"""
# Use settings if not provided
if max_width is None:
max_width = settings.IMAGE_MAX_WIDTH
if max_height is None:
max_height = settings.IMAGE_MAX_HEIGHT
if quality is None:
quality = settings.IMAGE_COMPRESSION_QUALITY
# Open image
img = Image.open(image_path)
# Convert RGBA to RGB for JPEG/WebP
if img.mode == 'RGBA' and output_format.lower() in ['jpeg', 'jpg', 'webp']:
# Create white background
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3]) # Use alpha channel as mask
img = background
# Resize if needed
original_width, original_height = img.size
if max_width and original_width > max_width or max_height and original_height > max_height:
img.thumbnail((max_width or original_width, max_height or original_height), Image.Resampling.LANCZOS)
# Generate unique filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
original_name = Path(image_path).stem
output_filename = f"{original_name}_{timestamp}.{output_format}"
output_path = os.path.join(output_dir, output_filename)
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Save with compression
save_kwargs = {'quality': quality, 'optimize': True}
if output_format.lower() == 'webp':
save_kwargs['method'] = 6 # Better compression
elif output_format.lower() in ['jpeg', 'jpg']:
save_kwargs['progressive'] = True
img.save(output_path, format=output_format.upper(), **save_kwargs)
# Get file size
file_size = os.path.getsize(output_path)
return output_path, file_size
@staticmethod
def create_thumbnail(
image_path: str,
output_dir: str,
size: Optional[int] = None,
quality: Optional[int] = None,
output_format: Optional[str] = None
) -> Tuple[str, int]:
"""
Create a thumbnail
Args:
image_path: Path to source image
output_dir: Directory for output
size: Thumbnail size (square, None = use settings)
quality: Compression quality (None = use settings)
output_format: Output format (None = use settings)
Returns:
Tuple of (output_path, file_size_bytes)
"""
# Use settings if not provided
if size is None:
size = settings.THUMBNAIL_SIZE
if quality is None:
quality = settings.THUMBNAIL_QUALITY
if output_format is None:
output_format = settings.THUMBNAIL_FORMAT
# Open image
img = Image.open(image_path)
# Convert RGBA to RGB for JPEG/WebP
if img.mode == 'RGBA' and output_format.lower() in ['jpeg', 'jpg', 'webp']:
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
# Resize keeping aspect ratio (width-based)
# size parameter represents the target width
width, height = img.size
aspect_ratio = height / width
new_width = size
new_height = int(size * aspect_ratio)
# Use thumbnail method to preserve aspect ratio
img.thumbnail((new_width, new_height), Image.Resampling.LANCZOS)
# Generate filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
original_name = Path(image_path).stem
output_filename = f"{original_name}_thumb_{timestamp}.{output_format}"
output_path = os.path.join(output_dir, output_filename)
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Save
save_kwargs = {'quality': quality, 'optimize': True}
if output_format.lower() == 'webp':
save_kwargs['method'] = 6
elif output_format.lower() in ['jpeg', 'jpg']:
save_kwargs['progressive'] = True
img.save(output_path, format=output_format.upper(), **save_kwargs)
# Get file size
file_size = os.path.getsize(output_path)
return output_path, file_size
@staticmethod
def get_image_hash(image_path: str) -> str:
"""
Calculate SHA256 hash of image file
Args:
image_path: Path to image
Returns:
SHA256 hash as hex string
"""
sha256_hash = hashlib.sha256()
with open(image_path, "rb") as f:
# Read in chunks for large files
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
@staticmethod
def get_image_info(image_path: str) -> dict:
"""
Get image information
Args:
image_path: Path to image
Returns:
Dictionary with image info
"""
img = Image.open(image_path)
return {
"width": img.width,
"height": img.height,
"format": img.format,
"mode": img.mode,
"size_bytes": os.path.getsize(image_path),
"hash": ImageProcessor.get_image_hash(image_path)
}
@staticmethod
def is_valid_image(file_path: str) -> bool:
"""
Check if file is a valid image
Args:
file_path: Path to file
Returns:
True if valid image, False otherwise
"""
try:
img = Image.open(file_path)
img.verify()
return True
except Exception:
return False
@staticmethod
def get_mime_type(file_path: str) -> Optional[str]:
"""
Get MIME type from image file
Args:
file_path: Path to image
Returns:
MIME type string or None
"""
try:
img = Image.open(file_path)
format_to_mime = {
'JPEG': 'image/jpeg',
'PNG': 'image/png',
'GIF': 'image/gif',
'BMP': 'image/bmp',
'WEBP': 'image/webp',
'TIFF': 'image/tiff'
}
return format_to_mime.get(img.format, f'image/{img.format.lower()}')
except Exception:
return None