|
|
""" |
|
|
High-Quality Image Converter Module |
|
|
|
|
|
This module provides a class-based image converter that preserves original quality |
|
|
and aspect ratio during format conversion. Supports all major image formats. |
|
|
""" |
|
|
|
|
|
from .image_base import ImageBase |
|
|
from PIL import Image |
|
|
import os |
|
|
import base64 |
|
|
import io |
|
|
from typing import Dict, Optional, Tuple |
|
|
from custom_logger import logger_config |
|
|
import pillow_heif |
|
|
pillow_heif.register_heif_opener() |
|
|
|
|
|
|
|
|
class Converter(ImageBase): |
|
|
""" |
|
|
High-quality image format converter that preserves original quality and aspect ratio. |
|
|
|
|
|
This class handles conversion between various image formats while maintaining |
|
|
the highest possible quality and exact dimensional preservation. |
|
|
""" |
|
|
def __init__(self): |
|
|
super().__init__("converter") |
|
|
|
|
|
def _validate_output_format(self, output_format: str) -> str: |
|
|
""" |
|
|
Validate and normalize the output format. |
|
|
|
|
|
Args: |
|
|
output_format: Desired output format |
|
|
|
|
|
Returns: |
|
|
Normalized PIL format name |
|
|
|
|
|
Raises: |
|
|
ValueError: If format is not supported |
|
|
""" |
|
|
if not output_format or not output_format.strip(): |
|
|
raise ValueError("Output format cannot be empty") |
|
|
|
|
|
format_clean = output_format.lower().strip() |
|
|
|
|
|
if format_clean not in self.supported_formats: |
|
|
supported_list = ', '.join(self.supported_formats.keys()) |
|
|
raise ValueError(f"Unsupported format '{output_format}'. Supported: {supported_list}") |
|
|
|
|
|
return self.supported_formats[format_clean] |
|
|
|
|
|
def _get_optimal_save_settings(self, format_name: str) -> Dict: |
|
|
""" |
|
|
Get optimal save settings for maximum quality preservation. |
|
|
|
|
|
Args: |
|
|
format_name: PIL format name (e.g., 'JPEG', 'PNG') |
|
|
|
|
|
Returns: |
|
|
Dictionary of save settings for the format |
|
|
""" |
|
|
settings = {} |
|
|
|
|
|
if format_name == 'JPEG': |
|
|
settings = { |
|
|
'quality': 100, |
|
|
'optimize': False, |
|
|
'progressive': False, |
|
|
'subsampling': 0 |
|
|
} |
|
|
|
|
|
elif format_name == 'PNG': |
|
|
settings = { |
|
|
'optimize': False, |
|
|
'compress_level': 1 |
|
|
} |
|
|
|
|
|
elif format_name == 'WEBP': |
|
|
settings = { |
|
|
'lossless': True, |
|
|
'quality': 100, |
|
|
'method': 6 |
|
|
} |
|
|
|
|
|
elif format_name == 'TIFF': |
|
|
settings = { |
|
|
'compression': None |
|
|
} |
|
|
|
|
|
elif format_name == 'BMP': |
|
|
settings = {} |
|
|
|
|
|
elif format_name == 'GIF': |
|
|
settings = { |
|
|
'optimize': False |
|
|
} |
|
|
|
|
|
elif format_name == 'HEIC': |
|
|
settings = { |
|
|
'quality': 100 |
|
|
} |
|
|
|
|
|
return settings |
|
|
|
|
|
def _preserve_metadata(self, original_image: Image.Image, format_name: str) -> Dict: |
|
|
""" |
|
|
Extract metadata from original image that's compatible with target format. |
|
|
|
|
|
Args: |
|
|
original_image: Original PIL Image object |
|
|
format_name: Target PIL format name |
|
|
|
|
|
Returns: |
|
|
Dictionary of metadata to preserve |
|
|
""" |
|
|
metadata = {} |
|
|
|
|
|
if not hasattr(original_image, 'info') or not original_image.info: |
|
|
return metadata |
|
|
|
|
|
|
|
|
if 'dpi' in original_image.info and format_name in ['JPEG', 'PNG', 'TIFF', 'WEBP']: |
|
|
metadata['dpi'] = original_image.info['dpi'] |
|
|
|
|
|
|
|
|
if 'icc_profile' in original_image.info and format_name in ['JPEG', 'PNG', 'TIFF', 'WEBP']: |
|
|
metadata['icc_profile'] = original_image.info['icc_profile'] |
|
|
|
|
|
|
|
|
if 'exif' in original_image.info and format_name in ['JPEG', 'TIFF', 'WEBP']: |
|
|
metadata['exif'] = original_image.info['exif'] |
|
|
|
|
|
return metadata |
|
|
|
|
|
def _convert_color_mode(self, image: Image.Image, target_format: str) -> Image.Image: |
|
|
""" |
|
|
Convert image color mode if required by target format, preserving quality. |
|
|
|
|
|
Args: |
|
|
image: PIL Image object |
|
|
target_format: Target PIL format name |
|
|
|
|
|
Returns: |
|
|
Image with appropriate color mode |
|
|
""" |
|
|
|
|
|
converted_image = image.copy() |
|
|
|
|
|
|
|
|
if target_format == 'JPEG' and converted_image.mode in ('RGBA', 'LA', 'P'): |
|
|
logger_config.info("Converting to RGB (JPEG doesn't support transparency)") |
|
|
|
|
|
if converted_image.mode == 'P': |
|
|
|
|
|
converted_image = converted_image.convert('RGBA') |
|
|
|
|
|
|
|
|
background = Image.new("RGB", converted_image.size, (255, 255, 255)) |
|
|
if converted_image.mode in ('RGBA', 'LA'): |
|
|
|
|
|
bg_rgba = background.convert('RGBA') |
|
|
composite = Image.alpha_composite(bg_rgba, converted_image.convert('RGBA')) |
|
|
converted_image = composite.convert('RGB') |
|
|
|
|
|
elif target_format == 'BMP' and converted_image.mode in ('RGBA', 'LA'): |
|
|
logger_config.info("Converting to RGB (BMP doesn't support transparency)") |
|
|
background = Image.new("RGB", converted_image.size, (255, 255, 255)) |
|
|
bg_rgba = background.convert('RGBA') |
|
|
composite = Image.alpha_composite(bg_rgba, converted_image.convert('RGBA')) |
|
|
converted_image = composite.convert('RGB') |
|
|
|
|
|
return converted_image |
|
|
|
|
|
def _convert_to_svg(self, image: Image.Image, output_path: str, original_size: Tuple[int, int]) -> None: |
|
|
""" |
|
|
Convert a raster image to SVG by embedding it as a base64-encoded image. |
|
|
|
|
|
Args: |
|
|
image: PIL Image object |
|
|
output_path: Path to save the SVG file |
|
|
original_size: Original image dimensions (width, height) |
|
|
""" |
|
|
|
|
|
buffer = io.BytesIO() |
|
|
|
|
|
|
|
|
if image.mode in ('RGBA', 'LA', 'P'): |
|
|
image.save(buffer, format='PNG', optimize=False) |
|
|
mime_type = 'image/png' |
|
|
else: |
|
|
image.save(buffer, format='PNG', optimize=False) |
|
|
mime_type = 'image/png' |
|
|
|
|
|
buffer.seek(0) |
|
|
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') |
|
|
|
|
|
width, height = original_size |
|
|
|
|
|
|
|
|
svg_content = f'''<?xml version="1.0" encoding="UTF-8"?> |
|
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" |
|
|
width="{width}" height="{height}" viewBox="0 0 {width} {height}"> |
|
|
<image width="{width}" height="{height}" |
|
|
xlink:href="data:{mime_type};base64,{image_base64}"/> |
|
|
</svg>''' |
|
|
|
|
|
with open(output_path, 'w', encoding='utf-8') as f: |
|
|
f.write(svg_content) |
|
|
|
|
|
|
|
|
if not os.path.exists(output_path): |
|
|
raise Exception("SVG file was not created") |
|
|
|
|
|
file_size = os.path.getsize(output_path) |
|
|
logger_config.info(f"β SVG file created: {file_size:,} bytes") |
|
|
|
|
|
def _verify_conversion_quality(self, original_size: Tuple[int, int], output_path: str) -> bool: |
|
|
""" |
|
|
Verify that the converted image maintains original quality and dimensions. |
|
|
|
|
|
Args: |
|
|
original_size: Original image dimensions (width, height) |
|
|
output_path: Path to converted image |
|
|
|
|
|
Returns: |
|
|
True if verification passes |
|
|
|
|
|
Raises: |
|
|
Exception: If verification fails |
|
|
""" |
|
|
if not os.path.exists(output_path): |
|
|
raise Exception("Output file was not created") |
|
|
|
|
|
|
|
|
file_size = os.path.getsize(output_path) |
|
|
logger_config.info(f"β Output file created: {file_size:,} bytes") |
|
|
|
|
|
|
|
|
with Image.open(output_path) as verify_img: |
|
|
if verify_img.size != original_size: |
|
|
raise Exception(f"Dimensions changed! Expected: {original_size}, Got: {verify_img.size}") |
|
|
|
|
|
logger_config.info(f"β Aspect ratio preserved: {verify_img.size}") |
|
|
|
|
|
return True |
|
|
|
|
|
def convert_image(self, input_file_name: str, output_format: str) -> str: |
|
|
""" |
|
|
Convert an image to the specified format with maximum quality and ratio preservation. |
|
|
|
|
|
Args: |
|
|
input_file: Path to the input image file |
|
|
output_format: Target image format |
|
|
|
|
|
Returns: |
|
|
Path to the converted output file |
|
|
|
|
|
Raises: |
|
|
FileNotFoundError: If input file doesn't exist |
|
|
ValueError: If parameters are invalid |
|
|
Exception: If conversion fails |
|
|
""" |
|
|
try: |
|
|
|
|
|
self.input_file_name = input_file_name |
|
|
self.input_file_path = f'{self.input_dir}/{self.input_file_name}' |
|
|
self._validate_input_file() |
|
|
target_format = self._validate_output_format(output_format) |
|
|
|
|
|
logger_config.info(f"Starting conversion: {self.input_file_path}") |
|
|
|
|
|
|
|
|
with Image.open(self.input_file_path) as original_image: |
|
|
original_size = original_image.size |
|
|
original_mode = original_image.mode |
|
|
original_format = original_image.format |
|
|
|
|
|
logger_config.info(f"Original - Format: {original_format}, Mode: {original_mode}, Size: {original_size}") |
|
|
|
|
|
|
|
|
converted_image = self._convert_color_mode(original_image, target_format) |
|
|
|
|
|
|
|
|
if converted_image.size != original_size: |
|
|
raise Exception(f"Size changed during conversion! Original: {original_size}, Current: {converted_image.size}") |
|
|
|
|
|
|
|
|
output_path = self._generate_output_path(output_format) |
|
|
|
|
|
|
|
|
if target_format == 'SVG': |
|
|
logger_config.info(f"Converting to SVG (embedding as base64)") |
|
|
self._convert_to_svg(converted_image, output_path, original_size) |
|
|
else: |
|
|
|
|
|
save_settings = self._get_optimal_save_settings(target_format) |
|
|
|
|
|
|
|
|
metadata = self._preserve_metadata(original_image, target_format) |
|
|
save_settings.update(metadata) |
|
|
|
|
|
logger_config.info(f"Converting to {target_format} with maximum quality") |
|
|
|
|
|
|
|
|
converted_image.save(output_path, format=target_format, **save_settings) |
|
|
|
|
|
|
|
|
self._verify_conversion_quality(original_size, output_path) |
|
|
|
|
|
logger_config.info(f"β Conversion completed successfully: {output_path}") |
|
|
return output_path |
|
|
|
|
|
except Exception as e: |
|
|
logger_config.info(f"Image conversion failed: {str(e)}") |
|
|
return None |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
converter = Converter() |
|
|
converter.convert_image("image/test.HEIC", "png") |