Skip to content

Instantly share code, notes, and snippets.

@morovinger
Last active December 23, 2025 16:51
Show Gist options
  • Select an option

  • Save morovinger/657550074e64e4987b4ca1aeaebc6e01 to your computer and use it in GitHub Desktop.

Select an option

Save morovinger/657550074e64e4987b4ca1aeaebc6e01 to your computer and use it in GitHub Desktop.
webp image converter
# Requirements:
# pip install piexif
# pip install pillow
import os
import piexif
import piexif.helper
from PIL import Image, ImageOps # Import ImageOps
from datetime import datetime
# --- Configuration ---
# Set the desired scale percentage.
# E.g., 50 for 50% of original dimensions (half size).
# Set to 100 or None to disable scaling.
SCALE_PERCENTAGE = 70 # Example: 50 to scale down by 50%
# Set the WebP quality (0-100 for lossy, or affects effort for lossless)
WEBP_QUALITY = 80
# Set to True for lossless WebP, False for lossy.
# If True, WEBP_QUALITY influences compression effort rather than visual quality.
# Lossless typically results in larger files than lossy but preserves all image data.
USE_LOSSLESS_WEBP = False
# Minimum file size in KB to process. Only images larger than this will be converted.
# Set to None or 0 to disable (process all images regardless of size).
IMAGE_SIZE_TARGET_KB = 500 # Example: 500 = only convert images > 500 KB
# --- End Configuration ---
def convert_image_to_webp(path, scale_percentage=None, quality=90, lossless=False):
try:
image = Image.open(path)
original_format = image.format
original_mode = image.mode
# --- Apply EXIF Orientation ---
# This rotates the image pixels according to its EXIF orientation tag
# and removes the orientation tag from the image's internal EXIF data
# as the transform is now applied to the pixels.
try:
image = ImageOps.exif_transpose(image)
except Exception as e_orient:
print(f"Warning: Could not apply EXIF orientation for {path}: {str(e_orient)}")
items = image.info or {} # image.info might be updated by exif_transpose
geninfo = items.pop('parameters', None)
original_exif_data = None
if "exif" in items: # This 'exif' is the raw EXIF blob from original file
try:
original_exif_data = piexif.load(items["exif"])
except Exception: # Catch piexif load errors
# print(f"Warning: Could not parse original EXIF data for {path}")
original_exif_data = {} # Ensure it's a dict
change_datetime = None
if original_exif_data and "Exif" in original_exif_data:
dt_original_bytes = original_exif_data["Exif"].get(piexif.ExifIFD.DateTimeOriginal)
if dt_original_bytes:
original_datetime_str = dt_original_bytes.decode('utf-8', errors="ignore").strip()
if original_datetime_str and len(original_datetime_str) == 19:
try:
change_datetime = datetime.strptime(original_datetime_str, "%Y:%m:%d %H:%M:%S")
except ValueError:
pass
exif_comment_bytes = original_exif_data["Exif"].get(piexif.ExifIFD.UserComment, b'')
if exif_comment_bytes:
try:
exif_comment = piexif.helper.UserComment.load(exif_comment_bytes)
except ValueError:
exif_comment = exif_comment_bytes.decode('utf-8', errors="ignore")
if exif_comment and not geninfo:
geninfo = exif_comment
if not change_datetime:
change_time = os.path.getmtime(path)
change_datetime = datetime.fromtimestamp(change_time)
fields_to_pop = [
'jfif', 'jfif_version', 'jfif_unit', 'jfif_density',
'dpi', 'exif', 'loop', 'background', 'timestamp',
'duration', 'photoshop', 'icc_profile',
'progressive', 'progression', 'adobe', 'adobe_transform',
]
# Pop from a copy of items.keys() if iterating and modifying `items`
# However, these are popped from the `items` dict, not directly related to piexif fields later
for field in fields_to_pop:
items.pop(field, None)
# --- Image Scaling ---
if scale_percentage and 0 < scale_percentage < 100:
original_width, original_height = image.size
new_width = int(original_width * (scale_percentage / 100.0))
new_height = int(original_height * (scale_percentage / 100.0))
if new_width > 0 and new_height > 0:
print(f"Scaling {path} from {original_width}x{original_height} to {new_width}x{new_height}")
try:
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
except AttributeError:
image = image.resize((new_width, new_height), Image.LANCZOS) # type: ignore
else:
print(f"Warning: Calculated new dimensions ({new_width}x{new_height}) are too small. Skipping scaling for {path}.")
# --- Image Mode Conversion for WebP ---
image_to_save = image
if lossless:
if image.mode not in ['RGB', 'RGBA', 'L', 'LA']:
image_to_save = image.convert('RGBA')
elif image.mode == 'P':
image_to_save = image.convert('RGBA')
else: # Lossy
if image.mode == 'P' and 'transparency' in image.info: # image.info here, not items
image_to_save = image.convert('RGBA')
elif image.mode == 'LA':
image_to_save = image.convert('RGBA')
if image_to_save.mode not in ['RGB', 'RGBA']:
image_to_save = image_to_save.convert('RGB')
# If you want to force RGB and discard alpha for lossy (even if RGBA) for some reason:
# if image_to_save.mode == 'RGBA' and not lossless: # (and you don't want alpha in lossy)
# image_to_save = image_to_save.convert('RGB')
# --- Prepare EXIF data for WebP ---
# Since ImageOps.exif_transpose has been applied, the image pixels are correctly oriented.
# So, the EXIF Orientation tag should be set to 1 (Normal).
# Start with all original EXIF data to preserve everything
if original_exif_data and isinstance(original_exif_data, dict):
output_exif_dict = {
"0th": dict(original_exif_data.get("0th", {})),
"Exif": dict(original_exif_data.get("Exif", {})),
"GPS": dict(original_exif_data.get("GPS", {})),
"1st": dict(original_exif_data.get("1st", {})),
"Interop": dict(original_exif_data.get("Interop", {})),
}
else:
output_exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "Interop": {}}
# Sanitize EXIF tags that piexif expects as bytes but might be stored as int/tuple
# Tag 41729 = SceneType (UNDEFINED type, should be bytes)
# Tag 41730 = CFAPattern (UNDEFINED type, should be bytes)
# Tag 37121 = ComponentsConfiguration (UNDEFINED type, should be bytes)
# Tag 37500 = MakerNote (UNDEFINED type, should be bytes)
# Tag 37510 = UserComment handled separately
undefined_tags_exif = [41729, 41730, 37121, 37500, 36864, 40960, 40961, 42016]
for tag in undefined_tags_exif:
if tag in output_exif_dict["Exif"]:
val = output_exif_dict["Exif"][tag]
if isinstance(val, int):
output_exif_dict["Exif"][tag] = bytes([val])
elif isinstance(val, tuple):
# Convert tuple of ints to bytes (common for ComponentsConfiguration)
output_exif_dict["Exif"][tag] = bytes(val)
elif isinstance(val, list):
output_exif_dict["Exif"][tag] = bytes(val)
# Override Orientation to 1 (Normal) since we applied exif_transpose
output_exif_dict["0th"][piexif.ImageIFD.Orientation] = 1
# Ensure DateTimeOriginal is set
output_exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = change_datetime.strftime("%Y:%m:%d %H:%M:%S").encode('utf-8')
# Add UserComment if geninfo exists
if geninfo:
user_comment_bytes = piexif.helper.UserComment.dump(str(geninfo), encoding="unicode")
output_exif_dict["Exif"][piexif.ExifIFD.UserComment] = user_comment_bytes
# Remove thumbnail data (1st IFD) as it's not useful after conversion/scaling
output_exif_dict["1st"] = {}
if "thumbnail" in output_exif_dict:
del output_exif_dict["thumbnail"]
exif_bytes_for_webp = piexif.dump(output_exif_dict)
pre, _ = os.path.splitext(path)
dstpath = pre + '.webp'
save_params = {
"exif": exif_bytes_for_webp,
"method": 6
}
icc_profile = image.info.get('icc_profile') # Get from current image object
if icc_profile:
save_params["icc_profile"] = icc_profile
if lossless:
save_params["lossless"] = True
save_params["quality"] = quality
else:
save_params["quality"] = quality
image_to_save.save(dstpath, **save_params)
print(f"Converted: {dstpath} (Original: {original_format}, Mode: {original_mode} -> WebP Mode: {image_to_save.mode})")
os.remove(path)
print(f"Deleted: {path}")
except FileNotFoundError:
print(f"Error: File not found {path}")
except Exception as e:
print(f"Error processing {path}: {type(e).__name__} - {str(e)}")
def search_and_convert(directory, scale_percentage, webp_quality, use_lossless, min_size_kb=None):
for root, _, files in os.walk(directory):
for file in files:
file_lower = file.lower()
if file_lower.endswith(('.png', '.jpg', '.jpeg')):
src_path = os.path.join(root, file)
# Check file size if min_size_kb is set
if min_size_kb and min_size_kb > 0:
file_size_kb = os.path.getsize(src_path) / 1024
if file_size_kb < min_size_kb:
print(f"Skipped (size {file_size_kb:.1f} KB < {min_size_kb} KB): {src_path}")
continue
try:
convert_image_to_webp(src_path, scale_percentage, webp_quality, use_lossless)
except Exception as e:
print(f"Critical error during conversion call for {src_path}: {str(e)}")
continue
# Run the script
if __name__ == "__main__":
target_directory = os.getcwd()
# import sys
# if len(sys.argv) > 1:
# target_directory = sys.argv[1]
# else:
# print(f"No directory specified, using current: {target_directory}")
print(f"Starting conversion in directory: {target_directory}")
print(f"Scaling: {SCALE_PERCENTAGE if SCALE_PERCENTAGE and 0 < SCALE_PERCENTAGE < 100 else 'Disabled'}")
print(f"WebP Quality: {WEBP_QUALITY}, Lossless: {USE_LOSSLESS_WEBP}")
print(f"Min size filter: {f'{IMAGE_SIZE_TARGET_KB} KB' if IMAGE_SIZE_TARGET_KB and IMAGE_SIZE_TARGET_KB > 0 else 'Disabled (all images)'}")
search_and_convert(target_directory, SCALE_PERCENTAGE, WEBP_QUALITY, USE_LOSSLESS_WEBP, IMAGE_SIZE_TARGET_KB)
print("Conversion process finished.")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment