Core: Convert translated images to WebP format for storage optimization (#341)

This commit is contained in:
Minseok Song
2026-01-15 17:08:14 +09:00
committed by GitHub
parent b3e139d2fd
commit 24662164f2
7 changed files with 205 additions and 13 deletions
+5
View File
@@ -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,
+135 -3
View File
@@ -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