Last active
December 23, 2025 16:51
-
-
Save morovinger/657550074e64e4987b4ca1aeaebc6e01 to your computer and use it in GitHub Desktop.
webp image converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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