Enhance image handling and EXIF metadata processing in RecipeRoutes and ExifUtils

- Implemented image optimization in RecipeRoutes, resizing and converting uploaded images to WebP format while preserving metadata.
- Updated ExifUtils to support EXIF data handling for WebP images, ensuring compatibility with various image formats.
- Added a new method for optimizing images, allowing for better performance and quality in image uploads.
This commit is contained in:
Will Miao
2025-03-19 14:17:37 +08:00
parent 03dfe13769
commit c3aaef3916
4 changed files with 158 additions and 21 deletions

View File

@@ -409,12 +409,20 @@ class RecipeRoutes:
import uuid import uuid
recipe_id = str(uuid.uuid4()) recipe_id = str(uuid.uuid4())
# Save the image # Optimize the image (resize and convert to WebP)
image_ext = ".jpg" optimized_image, extension = ExifUtils.optimize_image(
image_filename = f"{recipe_id}{image_ext}" image_data=image,
target_width=250,
format='webp',
quality=85,
preserve_metadata=True
)
# Save the optimized image
image_filename = f"{recipe_id}{extension}"
image_path = os.path.join(recipes_dir, image_filename) image_path = os.path.join(recipes_dir, image_filename)
with open(image_path, 'wb') as f: with open(image_path, 'wb') as f:
f.write(image) f.write(optimized_image)
# Create the recipe JSON # Create the recipe JSON
current_time = time.time() current_time = time.time()
@@ -638,7 +646,6 @@ class RecipeRoutes:
shutil.copy2(image_path, temp_path) shutil.copy2(image_path, temp_path)
# Add recipe metadata to the image # Add recipe metadata to the image
from ..utils.exif_utils import ExifUtils
processed_path = ExifUtils.append_recipe_metadata(temp_path, recipe) processed_path = ExifUtils.append_recipe_metadata(temp_path, recipe)
# Create a URL for the processed image # Create a URL for the processed image

View File

@@ -3,6 +3,7 @@ import json
import logging import logging
from typing import Dict, Optional, Any from typing import Dict, Optional, Any
from io import BytesIO from io import BytesIO
import os
from PIL import Image from PIL import Image
import re import re
@@ -17,8 +18,8 @@ class ExifUtils:
try: try:
# First try to open as image to check format # First try to open as image to check format
with Image.open(image_path) as img: with Image.open(image_path) as img:
if img.format not in ['JPEG', 'TIFF']: if img.format not in ['JPEG', 'TIFF', 'WEBP']:
# For non-JPEG/TIFF images, try to get EXIF through PIL # For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL
exif = img._getexif() exif = img._getexif()
if exif and piexif.ExifIFD.UserComment in exif: if exif and piexif.ExifIFD.UserComment in exif:
user_comment = exif[piexif.ExifIFD.UserComment] user_comment = exif[piexif.ExifIFD.UserComment]
@@ -29,7 +30,7 @@ class ExifUtils:
return user_comment return user_comment
return None return None
# For JPEG/TIFF, use piexif # For JPEG/TIFF/WEBP, use piexif
exif_dict = piexif.load(image_path) exif_dict = piexif.load(image_path)
if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}): if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}):
@@ -52,7 +53,25 @@ class ExifUtils:
try: try:
# Load the image and its EXIF data # Load the image and its EXIF data
with Image.open(image_path) as img: with Image.open(image_path) as img:
exif_dict = piexif.load(img.info.get('exif', b'')) # Get original format
img_format = img.format
# For WebP format, we need a different approach
if img_format == 'WEBP':
# WebP doesn't support standard EXIF through piexif
# We'll use PIL's exif parameter directly
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + user_comment.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
# Save with the exif data
img.save(image_path, format='WEBP', exif=exif_bytes, quality=85)
return image_path
# For other formats, use the standard approach
try:
exif_dict = piexif.load(img.info.get('exif', b''))
except:
exif_dict = {'0th':{}, 'Exif':{}, 'GPS':{}, 'Interop':{}, '1st':{}}
# If no Exif dictionary exists, create one # If no Exif dictionary exists, create one
if 'Exif' not in exif_dict: if 'Exif' not in exif_dict:
@@ -144,3 +163,115 @@ class ExifUtils:
else: else:
# Metadata is in the middle of the string # Metadata is in the middle of the string
return user_comment[:recipe_marker_index] + user_comment[next_line_index:] return user_comment[:recipe_marker_index] + user_comment[next_line_index:]
@staticmethod
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=True):
"""
Optimize an image by resizing and converting to WebP format
Args:
image_data: Binary image data or path to image file
target_width: Width to resize the image to (preserves aspect ratio)
format: Output format (default: webp)
quality: Output quality (0-100)
preserve_metadata: Whether to preserve EXIF metadata
Returns:
Tuple of (optimized_image_data, extension)
"""
try:
# Extract metadata if needed
user_comment = None
if preserve_metadata:
if isinstance(image_data, str) and os.path.exists(image_data):
# It's a file path
user_comment = ExifUtils.extract_user_comment(image_data)
img = Image.open(image_data)
else:
# It's binary data
temp_img = BytesIO(image_data)
img = Image.open(temp_img)
# Save to a temporary file to extract metadata
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(image_data)
user_comment = ExifUtils.extract_user_comment(temp_path)
os.unlink(temp_path)
else:
# Just open the image without extracting metadata
if isinstance(image_data, str) and os.path.exists(image_data):
img = Image.open(image_data)
else:
img = Image.open(BytesIO(image_data))
# Calculate new height to maintain aspect ratio
width, height = img.size
new_height = int(height * (target_width / width))
# Resize the image
resized_img = img.resize((target_width, new_height), Image.LANCZOS)
# Save to BytesIO in the specified format
output = BytesIO()
# WebP format
if format.lower() == 'webp':
resized_img.save(output, format='WEBP', quality=quality)
extension = '.webp'
# JPEG format
elif format.lower() in ('jpg', 'jpeg'):
resized_img.save(output, format='JPEG', quality=quality)
extension = '.jpg'
# PNG format
elif format.lower() == 'png':
resized_img.save(output, format='PNG', optimize=True)
extension = '.png'
else:
# Default to WebP
resized_img.save(output, format='WEBP', quality=quality)
extension = '.webp'
# Get the optimized image data
optimized_data = output.getvalue()
# If we need to preserve metadata, write it to a temporary file
if preserve_metadata and user_comment:
# For WebP format, we'll directly save with metadata
if format.lower() == 'webp':
# Create a new BytesIO with metadata
output_with_metadata = BytesIO()
# Create EXIF data with user comment
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + user_comment.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
# Save with metadata
resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality)
optimized_data = output_with_metadata.getvalue()
else:
# For other formats, use the temporary file approach
import tempfile
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(optimized_data)
# Add the metadata back
ExifUtils.update_user_comment(temp_path, user_comment)
# Read the file with metadata
with open(temp_path, 'rb') as f:
optimized_data = f.read()
# Clean up
os.unlink(temp_path)
return optimized_data, extension
except Exception as e:
logger.error(f"Error optimizing image: {e}", exc_info=True)
# Return original data if optimization fails
if isinstance(image_data, str) and os.path.exists(image_data):
with open(image_data, 'rb') as f:
return f.read(), os.path.splitext(image_data)[1]
return image_data, '.jpg'

View File

@@ -74,8 +74,6 @@ class RecipeFormatParser(RecipeMetadataParser):
if not recipe_metadata: if not recipe_metadata:
return {"error": "No recipe metadata found", "loras": []} return {"error": "No recipe metadata found", "loras": []}
logger.info("Found existing recipe metadata in image")
# Process the recipe metadata # Process the recipe metadata
loras = [] loras = []
for lora in recipe_metadata.get('loras', []): for lora in recipe_metadata.get('loras', []):
@@ -96,7 +94,7 @@ class RecipeFormatParser(RecipeMetadataParser):
exists_locally = lora_scanner.has_lora_hash(lora['hash']) exists_locally = lora_scanner.has_lora_hash(lora['hash'])
if exists_locally: if exists_locally:
lora_cache = await lora_scanner.get_cached_data() lora_cache = await lora_scanner.get_cached_data()
lora_item = next((item for item in lora_cache.raw_data if item['sha256'] == lora['hash']), None) lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
if lora_item: if lora_item:
lora_entry['existsLocally'] = True lora_entry['existsLocally'] = True
lora_entry['localPath'] = lora_item['file_path'] lora_entry['localPath'] = lora_item['file_path']

View File

@@ -4,14 +4,6 @@
transition: none !important; /* Disable any transitions that might affect display */ transition: none !important; /* Disable any transitions that might affect display */
} }
/* Ensure error messages maintain proper height and visibility */
.import-section .error-message {
min-height: 1.2em;
visibility: visible !important;
opacity: 1 !important;
display: block !important;
}
/* Import Mode Toggle */ /* Import Mode Toggle */
.import-mode-toggle { .import-mode-toggle {
display: flex; display: flex;
@@ -681,3 +673,12 @@
margin-right: 4px; margin-right: 4px;
font-size: 0.9em; font-size: 0.9em;
} }
/* Error message styling */
.error-message {
color: var(--lora-error);
font-size: 0.9em;
margin-top: 8px;
min-height: 20px; /* Ensure there's always space for the error message */
font-weight: 500;
}