Last active
May 19, 2025 20:14
-
-
Save benlacey57/816f562d3693358808627c1be9254116 to your computer and use it in GitHub Desktop.
Python script to resize and compress images
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
| # Image Compression and Conversion Script | |
| # Author: Ben Lacey | |
| # Version: 1.1 | |
| # Website: benlacey.co.uk | |
| # Changes made: | |
| # 1. Added a new `--rename` argument to accept a template string | |
| # 2. Created a `generate_filename_from_template` function to handle template substitutions | |
| # 3. Updated the `process_image` function to include counter and use the template | |
| # 4. Added logging to show renamed filenames | |
| import os | |
| import sys | |
| from datetime import datetime | |
| from PIL import Image | |
| import zipfile | |
| import shutil | |
| def process_images(output_dir, max_width, max_height, quality=70): | |
| """ | |
| Process images from the current directory by resizing and compressing them. | |
| Args: | |
| output_dir (str): Directory to save processed images | |
| max_width (int): Maximum width of output images | |
| max_height (int): Maximum height of output images | |
| quality (int): JPEG compression quality (1-100) | |
| """ | |
| # Get current directory | |
| current_dir = os.getcwd() | |
| # Create output directory if it doesn't exist | |
| if not os.path.exists(output_dir): | |
| os.makedirs(output_dir) | |
| # Get list of image files in current directory | |
| valid_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'] | |
| image_files = [] | |
| for filename in os.listdir(current_dir): | |
| if os.path.isfile(filename): # Make sure it's a file, not a directory | |
| ext = os.path.splitext(filename)[1].lower() | |
| if ext in valid_extensions: | |
| image_files.append(filename) | |
| if not image_files: | |
| print("No image files found in the current directory.") | |
| return [] | |
| results = [] | |
| # Process each image | |
| for filename in image_files: | |
| input_path = os.path.join(current_dir, filename) | |
| # Skip the output directory itself if it's in the current directory | |
| if os.path.abspath(input_path) == os.path.abspath(output_dir): | |
| continue | |
| # Get original file size | |
| original_size = os.path.getsize(input_path) | |
| # Open and process the image | |
| try: | |
| with Image.open(input_path) as img: | |
| # Convert to RGB if image has an alpha channel (for saving as JPEG) | |
| if img.mode == 'RGBA': | |
| img = img.convert('RGB') | |
| # Calculate new dimensions while maintaining aspect ratio | |
| width, height = img.size | |
| if width > max_width or height > max_height: | |
| # Calculate aspect ratio | |
| aspect_ratio = width / height | |
| if width > height: | |
| new_width = min(width, max_width) | |
| new_height = int(new_width / aspect_ratio) | |
| # Check if height still exceeds max_height | |
| if new_height > max_height: | |
| new_height = max_height | |
| new_width = int(new_height * aspect_ratio) | |
| else: | |
| new_height = min(height, max_height) | |
| new_width = int(new_height * aspect_ratio) | |
| # Check if width still exceeds max_width | |
| if new_width > max_width: | |
| new_width = max_width | |
| new_height = int(new_width / aspect_ratio) | |
| # Resize image | |
| img = img.resize((new_width, new_height), Image.LANCZOS) | |
| # Save with new name and reduced quality | |
| name, ext = os.path.splitext(filename) | |
| output_filename = f"{name}{ext.lower()}" | |
| output_path = os.path.join(output_dir, output_filename) | |
| # Save as JPEG with quality setting | |
| img.save(output_path, 'JPEG', quality=quality, optimize=True) | |
| # Get new file size | |
| new_size = os.path.getsize(output_path) | |
| # Calculate size reduction percentage | |
| size_reduction = (1 - (new_size / original_size)) * 100 | |
| result = { | |
| 'filename': filename, | |
| 'original_size': original_size, | |
| 'new_size': new_size, | |
| 'size_reduction_percent': size_reduction, | |
| 'output_path': output_path | |
| } | |
| results.append(result) | |
| print(f"Processed: {filename}") | |
| print(f" Original size: {original_size / 1024:.2f} KB") | |
| print(f" New size: {new_size / 1024:.2f} KB") | |
| print(f" Reduction: {size_reduction:.2f}%") | |
| except Exception as e: | |
| print(f"Error processing {filename}: {e}") | |
| return results | |
| def create_zip(file_paths, zip_name): | |
| """Create a zip file containing the processed images""" | |
| with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf: | |
| for file_path in file_paths: | |
| zipf.write(file_path, os.path.basename(file_path)) | |
| print(f"Created zip archive: {zip_name}") | |
| print(f"Zip size: {os.path.getsize(zip_name) / 1024:.2f} KB") | |
| def main(): | |
| # Default parameters | |
| output_dir = "output" | |
| max_width = 1200 | |
| max_height = 1200 | |
| quality = 70 | |
| # Check if we're in a Jupyter notebook by looking at sys.argv[0] | |
| in_jupyter = False | |
| if len(sys.argv) > 0: | |
| if 'jupyter' in sys.argv[0].lower() or '.json' in sys.argv[0].lower(): | |
| in_jupyter = True | |
| # Only use command line args if we're not in Jupyter | |
| if not in_jupyter and len(sys.argv) > 1: | |
| try: | |
| output_dir = sys.argv[1] | |
| if len(sys.argv) > 2: | |
| max_width = int(sys.argv[2]) | |
| if len(sys.argv) > 3: | |
| max_height = int(sys.argv[3]) | |
| if len(sys.argv) > 4: | |
| quality = int(sys.argv[4]) | |
| except ValueError as e: | |
| print(f"Error parsing command line arguments: {e}") | |
| print("Using default values instead.") | |
| # Process images | |
| print(f"Processing images from current directory to '{output_dir}'") | |
| print(f"Max dimensions: {max_width}x{max_height}, Quality: {quality}%") | |
| results = process_images(output_dir, max_width, max_height, quality) | |
| if results: | |
| # Create zip file with timestamp | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| zip_name = f"compressed-images-{timestamp}.zip" | |
| output_files = [result['output_path'] for result in results] | |
| create_zip(output_files, zip_name) | |
| # Print summary | |
| total_original = sum(result['original_size'] for result in results) | |
| total_new = sum(result['new_size'] for result in results) | |
| total_reduction = (1 - (total_new / total_original)) * 100 if total_original > 0 else 0 | |
| print("\nSummary:") | |
| print(f"Processed {len(results)} images") | |
| print(f"Total original size: {total_original / 1024:.2f} KB") | |
| print(f"Total new size: {total_new / 1024:.2f} KB") | |
| print(f"Total reduction: {total_reduction:.2f}%") | |
| if __name__ == "__main__": | |
| main()import os | |
| import sys | |
| import argparse | |
| import re | |
| from datetime import datetime | |
| from PIL import Image, ImageFilter, ImageEnhance | |
| import zipfile | |
| import shutil | |
| import logging | |
| from concurrent.futures import ThreadPoolExecutor | |
| from tqdm import tqdm | |
| # Set up logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='%(asctime)s - %(levelname)s - %(message)s', | |
| handlers=[ | |
| logging.FileHandler("image_processor.log"), | |
| logging.StreamHandler() | |
| ] | |
| ) | |
| logger = logging.getLogger(__name__) | |
| def parse_arguments(): | |
| """Parse command line arguments""" | |
| parser = argparse.ArgumentParser(description='Process and compress images.') | |
| parser.add_argument('--output', '-o', type=str, default="output", | |
| help='Output directory for processed images') | |
| parser.add_argument('--width', '-w', type=int, default=1200, | |
| help='Maximum width of output images') | |
| parser.add_argument('--height', '-ht', type=int, default=1200, | |
| help='Maximum height of output images') | |
| parser.add_argument('--quality', '-q', type=int, default=70, | |
| help='JPEG quality (1-100)') | |
| parser.add_argument('--format', '-f', type=str, default="JPEG", | |
| choices=['JPEG', 'PNG', 'WEBP'], | |
| help='Output format') | |
| parser.add_argument('--sharpness', '-s', type=float, default=1.0, | |
| help='Sharpness enhancement factor (0.0-2.0)') | |
| parser.add_argument('--contrast', '-c', type=float, default=1.0, | |
| help='Contrast enhancement factor (0.0-2.0)') | |
| parser.add_argument('--brightness', '-b', type=float, default=1.0, | |
| help='Brightness enhancement factor (0.0-2.0)') | |
| parser.add_argument('--filter', type=str, default=None, | |
| choices=['BLUR', 'SHARPEN', 'SMOOTH', 'DETAIL', 'EDGE_ENHANCE'], | |
| help='Apply filter to images') | |
| parser.add_argument('--recursive', '-r', action='store_true', | |
| help='Process images in subdirectories') | |
| parser.add_argument('--workers', type=int, default=4, | |
| help='Number of worker threads') | |
| parser.add_argument('--skip-zip', action='store_true', | |
| help='Skip creating ZIP archive') | |
| # New argument for rename template | |
| parser.add_argument('--rename', type=str, default=None, | |
| help='Rename template (use {original}, {width}, {height}, {date}, {counter}, {extension})') | |
| return parser.parse_args() | |
| def get_image_files(directory, recursive=False): | |
| """Get list of image files in directory""" | |
| valid_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp', '.tiff', '.tif'] | |
| image_files = [] | |
| if recursive: | |
| for root, _, files in os.walk(directory): | |
| for filename in files: | |
| if os.path.splitext(filename)[1].lower() in valid_extensions: | |
| image_files.append(os.path.join(root, filename)) | |
| else: | |
| for filename in os.listdir(directory): | |
| if os.path.isfile(os.path.join(directory, filename)): | |
| if os.path.splitext(filename)[1].lower() in valid_extensions: | |
| image_files.append(os.path.join(directory, filename)) | |
| return image_files | |
| def generate_filename_from_template(template, original_filename, img_width, img_height, counter, format_type): | |
| """Generate a filename based on the provided template""" | |
| if not template: | |
| name, _ = os.path.splitext(original_filename) | |
| return f"{name}" | |
| # Get extension based on format | |
| if format_type == 'JPEG': | |
| extension = 'jpg' | |
| elif format_type == 'PNG': | |
| extension = 'png' | |
| elif format_type == 'WEBP': | |
| extension = 'webp' | |
| else: | |
| extension = os.path.splitext(original_filename)[1].lstrip('.').lower() | |
| # Get original name without extension | |
| original_name = os.path.splitext(os.path.basename(original_filename))[0] | |
| # Replace placeholders | |
| result = template | |
| result = result.replace('{original}', original_name) | |
| result = result.replace('{width}', str(img_width)) | |
| result = result.replace('{height}', str(img_height)) | |
| result = result.replace('{date}', datetime.now().strftime('%Y%m%d')) | |
| result = result.replace('{counter}', f"{counter:04d}") | |
| result = result.replace('{extension}', extension) | |
| # Replace any invalid characters for filenames | |
| result = re.sub(r'[\\/*?:"<>|]', '_', result) | |
| return result | |
| def process_image(args): | |
| """Process a single image""" | |
| filepath, output_dir, max_width, max_height, quality, format_type, \ | |
| sharpness, contrast, brightness, filter_type, rename_template, counter = args | |
| try: | |
| # Get original file size | |
| original_size = os.path.getsize(filepath) | |
| filename = os.path.basename(filepath) | |
| # Determine output path, keeping directory structure for recursive mode | |
| rel_path = os.path.dirname(filepath).replace(os.getcwd(), '').lstrip(os.sep) | |
| if rel_path: | |
| output_subdir = os.path.join(output_dir, rel_path) | |
| if not os.path.exists(output_subdir): | |
| os.makedirs(output_subdir) | |
| else: | |
| output_subdir = output_dir | |
| # Open and process the image | |
| with Image.open(filepath) as img: | |
| # Convert image mode if needed | |
| if img.mode not in ('RGB', 'RGBA'): | |
| img = img.convert('RGB') | |
| # Handle EXIF orientation | |
| try: | |
| exif = img._getexif() | |
| if exif: | |
| orientation_key = 274 # EXIF orientation tag | |
| if orientation_key in exif: | |
| orientation = exif[orientation_key] | |
| if orientation == 2: | |
| img = img.transpose(Image.FLIP_LEFT_RIGHT) | |
| elif orientation == 3: | |
| img = img.transpose(Image.ROTATE_180) | |
| elif orientation == 4: | |
| img = img.transpose(Image.FLIP_TOP_BOTTOM) | |
| elif orientation == 5: | |
| img = img.transpose(Image.FLIP_LEFT_RIGHT).transpose(Image.ROTATE_90) | |
| elif orientation == 6: | |
| img = img.transpose(Image.ROTATE_270) | |
| elif orientation == 7: | |
| img = img.transpose(Image.FLIP_LEFT_RIGHT).transpose(Image.ROTATE_270) | |
| elif orientation == 8: | |
| img = img.transpose(Image.ROTATE_90) | |
| except (AttributeError, KeyError, IndexError): | |
| # No EXIF or orientation data | |
| pass | |
| # Calculate new dimensions while maintaining aspect ratio | |
| width, height = img.size | |
| if width > max_width or height > max_height: | |
| # Calculate aspect ratio | |
| aspect_ratio = width / height | |
| if width > height: | |
| new_width = min(width, max_width) | |
| new_height = int(new_width / aspect_ratio) | |
| # Check if height still exceeds max_height | |
| if new_height > max_height: | |
| new_height = max_height | |
| new_width = int(new_height * aspect_ratio) | |
| else: | |
| new_height = min(height, max_height) | |
| new_width = int(new_height * aspect_ratio) | |
| # Check if width still exceeds max_width | |
| if new_width > max_width: | |
| new_width = max_width | |
| new_height = int(new_width / aspect_ratio) | |
| # Resize image with high-quality resampling | |
| img = img.resize((new_width, new_height), Image.LANCZOS) | |
| # Apply filters if specified | |
| if filter_type: | |
| if filter_type == 'BLUR': | |
| img = img.filter(ImageFilter.BLUR) | |
| elif filter_type == 'SHARPEN': | |
| img = img.filter(ImageFilter.SHARPEN) | |
| elif filter_type == 'SMOOTH': | |
| img = img.filter(ImageFilter.SMOOTH) | |
| elif filter_type == 'DETAIL': | |
| img = img.filter(ImageFilter.DETAIL) | |
| elif filter_type == 'EDGE_ENHANCE': | |
| img = img.filter(ImageFilter.EDGE_ENHANCE) | |
| # Apply enhancements | |
| if sharpness != 1.0: | |
| enhancer = ImageEnhance.Sharpness(img) | |
| img = enhancer.enhance(sharpness) | |
| if contrast != 1.0: | |
| enhancer = ImageEnhance.Contrast(img) | |
| img = enhancer.enhance(contrast) | |
| if brightness != 1.0: | |
| enhancer = ImageEnhance.Brightness(img) | |
| img = enhancer.enhance(brightness) | |
| # Generate new filename from template | |
| base_name = generate_filename_from_template( | |
| rename_template, filename, width, height, counter, format_type | |
| ) | |
| # Determine file extension based on format | |
| if format_type == 'JPEG': | |
| output_ext = '.jpg' | |
| elif format_type == 'PNG': | |
| output_ext = '.png' | |
| elif format_type == 'WEBP': | |
| output_ext = '.webp' | |
| else: | |
| output_ext = os.path.splitext(filename)[1].lower() | |
| output_filename = f"{base_name}{output_ext}" | |
| output_path = os.path.join(output_subdir, output_filename) | |
| # Save with appropriate settings | |
| if format_type == 'JPEG': | |
| img.save(output_path, format_type, quality=quality, optimize=True, progressive=True) | |
| elif format_type == 'PNG': | |
| img.save(output_path, format_type, optimize=True) | |
| elif format_type == 'WEBP': | |
| img.save(output_path, format_type, quality=quality, method=6) | |
| else: | |
| img.save(output_path, quality=quality, optimize=True) | |
| # Get new file size | |
| new_size = os.path.getsize(output_path) | |
| # Calculate size reduction percentage | |
| size_reduction = (1 - (new_size / original_size)) * 100 if original_size > 0 else 0 | |
| return { | |
| 'filename': filename, | |
| 'new_filename': output_filename, | |
| 'original_size': original_size, | |
| 'new_size': new_size, | |
| 'size_reduction_percent': size_reduction, | |
| 'output_path': output_path, | |
| 'width': width, | |
| 'height': height, | |
| 'new_width': img.width, | |
| 'new_height': img.height | |
| } | |
| except Exception as e: | |
| logger.error(f"Error processing {filepath}: {e}") | |
| return None | |
| def create_zip(file_paths, zip_name): | |
| """Create a zip file containing the processed images""" | |
| try: | |
| with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf: | |
| for file_path in tqdm(file_paths, desc="Creating ZIP archive"): | |
| arcname = os.path.relpath(file_path, os.path.dirname(zip_name)) | |
| zipf.write(file_path, arcname) | |
| logger.info(f"Created zip archive: {zip_name}") | |
| logger.info(f"Zip size: {os.path.getsize(zip_name) / 1024:.2f} KB") | |
| except Exception as e: | |
| logger.error(f"Error creating ZIP file: {e}") | |
| def main(): | |
| # Get arguments | |
| args = parse_arguments() | |
| # Current directory | |
| current_dir = os.getcwd() | |
| # Create output directory if it doesn't exist | |
| if not os.path.exists(args.output): | |
| os.makedirs(args.output) | |
| # Get list of image files | |
| image_files = get_image_files(current_dir, args.recursive) | |
| if not image_files: | |
| logger.warning("No image files found in the current directory.") | |
| return | |
| logger.info(f"Found {len(image_files)} image files to process") | |
| logger.info(f"Processing images to '{args.output}'") | |
| logger.info(f"Max dimensions: {args.width}x{args.height}, Quality: {args.quality}%") | |
| if args.rename: | |
| logger.info(f"Using rename template: {args.rename}") | |
| # Prepare arguments for each image | |
| process_args = [ | |
| ( | |
| file, args.output, args.width, args.height, args.quality, | |
| args.format, args.sharpness, args.contrast, args.brightness, | |
| args.filter, args.rename, i+1 | |
| ) | |
| for i, file in enumerate(image_files) | |
| ] | |
| # Process images in parallel | |
| results = [] | |
| with ThreadPoolExecutor(max_workers=args.workers) as executor: | |
| for result in tqdm( | |
| executor.map(process_image, process_args), | |
| total=len(image_files), | |
| desc="Processing images" | |
| ): | |
| if result: | |
| results.append(result) | |
| logger.info(f"Processed: {result['filename']}") | |
| if args.rename: | |
| logger.info(f" Renamed to: {result['new_filename']}") | |
| logger.info(f" Original size: {result['original_size'] / 1024:.2f} KB") | |
| logger.info(f" New size: {result['new_size'] / 1024:.2f} KB") | |
| logger.info(f" Reduction: {result['size_reduction_percent']:.2f}%") | |
| logger.info(f" Dimensions: {result['width']}x{result['height']} → {result['new_width']}x{result['new_height']}") | |
| if results: | |
| # Create zip file if not skipped | |
| if not args.skip_zip: | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| zip_name = f"compressed-images-{timestamp}.zip" | |
| output_files = [result['output_path'] for result in results] | |
| create_zip(output_files, zip_name) | |
| # Print summary | |
| total_original = sum(result['original_size'] for result in results) | |
| total_new = sum(result['new_size'] for result in results) | |
| total_reduction = (1 - (total_new / total_original)) * 100 if total_original > 0 else 0 | |
| logger.info("\nSummary:") | |
| logger.info(f"Processed {len(results)} images") | |
| logger.info(f"Total original size: {total_original / (1024 * 1024):.2f} MB") | |
| logger.info(f"Total new size: {total_new / (1024 * 1024):.2f} MB") | |
| logger.info(f"Total reduction: {total_reduction:.2f}%") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment