addon
This commit is contained in:
339
backend/app/utils/image_processor.py
Executable file
339
backend/app/utils/image_processor.py
Executable file
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user