340 lines
11 KiB
Python
Executable File
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
|