mirror of
https://github.com/Azure/co-op-translator
synced 2026-08-09 12:00:08 +00:00
Core: Convert translated images to WebP format for storage optimization (#341)
This commit is contained in:
@@ -3,6 +3,11 @@ RGBA_IMAGE_EXTENSIONS = {".png"}
|
||||
RGB_IMAGE_EXTENSIONS = {".jpg", ".jpeg"}
|
||||
SUPPORTED_IMAGE_EXTENSIONS = RGBA_IMAGE_EXTENSIONS.union(RGB_IMAGE_EXTENSIONS)
|
||||
|
||||
# WebP is used for translated images (supports both lossy and lossless compression)
|
||||
WEBP_EXTENSION = ".webp"
|
||||
# All input image formats that can be translated (includes WebP for re-processing)
|
||||
TRANSLATABLE_IMAGE_EXTENSIONS = SUPPORTED_IMAGE_EXTENSIONS.union({WEBP_EXTENSION})
|
||||
|
||||
# Supported notebook file extensions
|
||||
SUPPORTED_NOTEBOOK_EXTENSIONS = {".ipynb"}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from co_op_translator.utils.common.file_utils import (
|
||||
generate_translated_filename,
|
||||
handle_empty_document,
|
||||
migrate_translated_image_filenames,
|
||||
migrate_images_to_webp,
|
||||
)
|
||||
from co_op_translator.utils.common.metadata_utils import (
|
||||
calculate_file_hash,
|
||||
@@ -617,6 +618,12 @@ class TranslationManager:
|
||||
rename_map = migrate_translated_image_filenames(
|
||||
self.image_dir, self.language_codes
|
||||
)
|
||||
|
||||
# Convert existing PNG/JPG images to WebP format for optimal compression
|
||||
# This reduces storage requirements by 25-35% compared to PNG
|
||||
webp_rename_map = migrate_images_to_webp(self.image_dir)
|
||||
rename_map.update(webp_rename_map)
|
||||
|
||||
# Always run link migration to rewrite legacy flattened links in content,
|
||||
# even when no files were moved (empty rename_map)
|
||||
migrated_md = self.directory_manager.migrate_markdown_image_links(
|
||||
|
||||
@@ -31,6 +31,7 @@ from co_op_translator.utils.vision.image_utils import (
|
||||
group_bounding_boxes,
|
||||
pad_text_image_to_target_aspect,
|
||||
adjust_bg_color,
|
||||
save_optimized_image,
|
||||
)
|
||||
from azure.ai.vision.imageanalysis.models import VisualFeatures
|
||||
from co_op_translator.core.llm.text_translator import TextTranslator
|
||||
@@ -460,8 +461,8 @@ class ImageTranslator(ABC):
|
||||
if output_path.suffix.lower() in RGB_IMAGE_EXTENSIONS:
|
||||
image = image.convert("RGB")
|
||||
|
||||
# Save the image and update central metadata file
|
||||
image.save(output_path)
|
||||
# Save the image with optimization and update central metadata file
|
||||
save_optimized_image(image, output_path)
|
||||
save_image_metadata(
|
||||
output_path,
|
||||
Path(original_image_path),
|
||||
@@ -527,7 +528,7 @@ class ImageTranslator(ABC):
|
||||
# Load the original image and save it with the new name and metadata
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_image = Image.open(image_path)
|
||||
original_image.save(output_path)
|
||||
save_optimized_image(original_image, output_path)
|
||||
save_image_metadata(
|
||||
output_path,
|
||||
image_path,
|
||||
@@ -576,7 +577,7 @@ class ImageTranslator(ABC):
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_image = Image.open(image_path)
|
||||
original_image.save(output_path)
|
||||
save_optimized_image(original_image, output_path)
|
||||
save_image_metadata(
|
||||
output_path,
|
||||
image_path,
|
||||
|
||||
@@ -352,6 +352,9 @@ def generate_translated_filename(
|
||||
"""
|
||||
Generate a filename for a translated file, including a unique hash and language code.
|
||||
|
||||
All translated images are saved as WebP format for optimal compression.
|
||||
The original extension is preserved in the base filename for traceability.
|
||||
|
||||
Note:
|
||||
If the file path and the file name are identical, the same hash will be generated.
|
||||
This is because the hash is based on the entire file path.
|
||||
@@ -361,8 +364,9 @@ def generate_translated_filename(
|
||||
language_code (str): The language code for the translation (e.g., 'en', 'fr').
|
||||
|
||||
Returns:
|
||||
str: The translated file's new filename.
|
||||
str: The translated file's new filename (always .webp extension).
|
||||
"""
|
||||
from co_op_translator.config.constants import WEBP_EXTENSION
|
||||
|
||||
original_filepath = Path(original_filepath)
|
||||
|
||||
@@ -375,8 +379,9 @@ def generate_translated_filename(
|
||||
# Use a fixed-size prefix for deterministic filenames across runs/OS
|
||||
hash_prefix = full_hash[:HASH_PREFIX_LENGTH]
|
||||
|
||||
# Generate the new filename with the selected hash prefix (language is now expressed via directory)
|
||||
new_filename = f"{original_filename}.{hash_prefix}{file_ext}"
|
||||
# Generate the new filename with WebP extension for optimal compression
|
||||
# All translated images are saved as WebP regardless of original format
|
||||
new_filename = f"{original_filename}.{hash_prefix}{WEBP_EXTENSION}"
|
||||
|
||||
return new_filename
|
||||
|
||||
@@ -582,6 +587,133 @@ def migrate_translated_image_filenames(
|
||||
return rename_map
|
||||
|
||||
|
||||
def migrate_images_to_webp(image_dir: Path) -> dict[str, str]:
|
||||
"""Migrate existing translated images (PNG, JPG, JPEG) to WebP format.
|
||||
|
||||
This function:
|
||||
1. Finds all non-WebP images in the translated_images directory
|
||||
2. Converts them to WebP format with optimal compression (quality=90)
|
||||
3. Updates the metadata file to reflect the new filenames
|
||||
4. Deletes the original PNG/JPG files after successful conversion
|
||||
|
||||
Returns a mapping from old filenames to new WebP filenames for updating
|
||||
markdown/notebook links (e.g., {"image.abc123.png": "image.abc123.webp"}).
|
||||
|
||||
Args:
|
||||
image_dir: Path to the translated_images directory
|
||||
|
||||
Returns:
|
||||
dict mapping old relative paths to new WebP paths
|
||||
"""
|
||||
from PIL import Image
|
||||
import json
|
||||
|
||||
image_dir = Path(image_dir)
|
||||
if not image_dir.exists():
|
||||
logger.info(f"Image directory does not exist: {image_dir}")
|
||||
return {}
|
||||
|
||||
rename_map: dict[str, str] = {}
|
||||
converted_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
# Process each language subdirectory
|
||||
for lang_dir in image_dir.iterdir():
|
||||
if not lang_dir.is_dir():
|
||||
continue
|
||||
|
||||
metadata_file = lang_dir / ".co-op-translator.json"
|
||||
metadata = {}
|
||||
if metadata_file.exists():
|
||||
try:
|
||||
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load metadata from {metadata_file}: {e}")
|
||||
|
||||
updated_metadata = {}
|
||||
|
||||
for image_file in sorted(lang_dir.iterdir()):
|
||||
if not image_file.is_file():
|
||||
continue
|
||||
|
||||
suffix = image_file.suffix.lower()
|
||||
|
||||
# Skip non-image files and already converted WebP files
|
||||
if suffix == ".webp":
|
||||
# Keep existing WebP metadata
|
||||
key = image_file.name
|
||||
if key in metadata:
|
||||
updated_metadata[key] = metadata[key]
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if suffix not in {".png", ".jpg", ".jpeg"}:
|
||||
continue
|
||||
|
||||
# Generate new WebP filename
|
||||
new_name = image_file.stem + ".webp"
|
||||
new_path = lang_dir / new_name
|
||||
|
||||
try:
|
||||
# Open and convert to WebP
|
||||
with Image.open(image_file) as img:
|
||||
# Preserve RGBA mode for transparency
|
||||
if img.mode in ("RGBA", "LA") or (
|
||||
img.mode == "P" and "transparency" in img.info
|
||||
):
|
||||
img = img.convert("RGBA")
|
||||
else:
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Save as WebP with high quality
|
||||
img.save(new_path, format="WEBP", quality=90, method=6)
|
||||
|
||||
# Update metadata: copy old entry to new key
|
||||
old_key = image_file.name
|
||||
if old_key in metadata:
|
||||
updated_metadata[new_name] = metadata[old_key]
|
||||
else:
|
||||
# If no metadata exists, we can't preserve it
|
||||
logger.debug(f"No metadata found for {old_key}")
|
||||
|
||||
# Add to rename map for link migration
|
||||
old_rel = f"{lang_dir.name}/{image_file.name}"
|
||||
new_rel = f"{lang_dir.name}/{new_name}"
|
||||
rename_map[old_rel] = new_rel
|
||||
rename_map[image_file.name] = new_name
|
||||
|
||||
# Delete the original file after successful conversion
|
||||
image_file.unlink()
|
||||
converted_count += 1
|
||||
logger.debug(f"Converted {image_file.name} -> {new_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert {image_file}: {e}")
|
||||
failed_count += 1
|
||||
# Keep original metadata if conversion failed
|
||||
old_key = image_file.name
|
||||
if old_key in metadata:
|
||||
updated_metadata[old_key] = metadata[old_key]
|
||||
|
||||
# Write updated metadata
|
||||
if updated_metadata:
|
||||
try:
|
||||
metadata_file.write_text(
|
||||
json.dumps(updated_metadata, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata file {metadata_file}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"WebP migration complete: {converted_count} converted, "
|
||||
f"{skipped_count} already WebP, {failed_count} failed"
|
||||
)
|
||||
|
||||
return rename_map
|
||||
|
||||
|
||||
def reset_translation_directories(
|
||||
translations_dir: Path, image_dir: Path, language_codes: list
|
||||
):
|
||||
|
||||
@@ -21,6 +21,53 @@ from co_op_translator.config.constants import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def save_optimized_image(image, output_path):
|
||||
"""
|
||||
Save an image with optimized compression settings based on file format.
|
||||
|
||||
For WebP files: Uses quality=90 for excellent compression with minimal quality loss
|
||||
(25-35% smaller than PNG, visually near-lossless)
|
||||
For PNG files: Uses maximum lossless compression (optimize=True, compress_level=9)
|
||||
For JPEG files: Uses quality=85 with optimization for good balance of size/quality
|
||||
|
||||
This can reduce file sizes significantly, which is crucial for large-scale
|
||||
translation projects with thousands of images.
|
||||
|
||||
Args:
|
||||
image (PIL.Image.Image): The PIL Image object to save.
|
||||
output_path (str or Path): The destination path for the saved image.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
output_path = Path(output_path)
|
||||
suffix = output_path.suffix.lower()
|
||||
|
||||
# Ensure image is in a compatible mode for the output format
|
||||
if suffix == ".webp":
|
||||
# WebP: Excellent compression with near-lossless quality
|
||||
# Quality 90 provides great balance between size and visual quality
|
||||
# method=6 uses slowest but best compression
|
||||
if image.mode == "RGBA":
|
||||
image.save(output_path, format="WEBP", quality=90, method=6)
|
||||
else:
|
||||
# Convert to RGB if not RGBA (WebP supports both)
|
||||
image.save(output_path, format="WEBP", quality=90, method=6)
|
||||
elif suffix == ".png":
|
||||
# PNG: Lossless compression - optimize encoder and use max compression level
|
||||
image.save(output_path, optimize=True, compress_level=9)
|
||||
elif suffix in {".jpg", ".jpeg"}:
|
||||
# JPEG: Lossy compression - quality 85 is visually near-lossless
|
||||
# Convert RGBA to RGB for JPEG (doesn't support transparency)
|
||||
if image.mode == "RGBA":
|
||||
image = image.convert("RGB")
|
||||
image.save(output_path, quality=85, optimize=True)
|
||||
else:
|
||||
# Fallback for other formats
|
||||
image.save(output_path)
|
||||
|
||||
logger.debug(f"Saved optimized image to {output_path}")
|
||||
|
||||
|
||||
def save_bounding_boxes(image_path, bounding_boxes):
|
||||
"""
|
||||
Save bounding boxes and confidence scores to a JSON file.
|
||||
@@ -388,9 +435,9 @@ def plot_bounding_boxes(
|
||||
(x, y), f"{line_info['text']} ({confidence:.2f})", font=font, fill="black"
|
||||
)
|
||||
|
||||
# Save the annotated image
|
||||
# Save the annotated image with optimization
|
||||
output_path = os.path.join("./analyzed_images", os.path.basename(image_path))
|
||||
image.save(output_path)
|
||||
save_optimized_image(image, output_path)
|
||||
|
||||
if display:
|
||||
# Display the image
|
||||
|
||||
@@ -151,8 +151,8 @@ def test_generate_translated_filename(temp_dir):
|
||||
full_hash = get_unique_id(file_path, temp_dir)
|
||||
filename = generate_translated_filename(file_path, language_code, temp_dir)
|
||||
|
||||
# Basic structure checks (language is no longer part of filename)
|
||||
assert filename.endswith(".txt")
|
||||
# All translated images are now saved as WebP for optimal compression
|
||||
assert filename.endswith(".webp")
|
||||
|
||||
parts = filename.split(".")
|
||||
# Expect: basename, hash_prefix, ext
|
||||
@@ -162,7 +162,7 @@ def test_generate_translated_filename(temp_dir):
|
||||
ext = "." + parts[-1]
|
||||
|
||||
assert basename == "file"
|
||||
assert ext == ".txt"
|
||||
assert ext == ".webp" # Always WebP for translated images
|
||||
|
||||
# Hash prefix should be exactly 16 hex (truncated from the full 64-hex hash)
|
||||
assert len(hash_prefix) == 16
|
||||
|
||||
@@ -74,7 +74,7 @@ def test_update_image_links(temp_dir, sample_markdown):
|
||||
)
|
||||
|
||||
assert "ko" in result
|
||||
assert ".png" in result
|
||||
assert ".webp" in result # All translated images are now WebP format
|
||||
assert "test.png" not in result # Original image links should be updated
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user