Last active
December 7, 2025 10:50
-
-
Save benlacey57/39a99cd4746dd3aa55f4de965bcef2fd to your computer and use it in GitHub Desktop.
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
| import os | |
| import shutil | |
| from pathlib import Path | |
| from zipfile import ZipFile | |
| from datetime import datetime | |
| from PIL import Image | |
| import pillow_heif | |
| from rich.console import Console | |
| from rich.prompt import Confirm, Prompt | |
| from rich.panel import Panel | |
| from rich.progress import Progress, SpinnerColumn, TextColumn | |
| pillow_heif.register_heif_opener() | |
| MAX_WIDTH = 1920 | |
| MAX_FILE_SIZE = "500KB" | |
| OUTPUT_PREFIX = "resized_" | |
| USE_LOSSLESS = False | |
| OUTPUT_FORMAT = ".jpg" | |
| CREATE_ZIP_ARCHIVE = True | |
| DELETE_SOURCE_IMAGES = True | |
| INPUT_FORMATS = { | |
| '.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', | |
| '.bmp', '.tiff', '.tif', '.gif' | |
| } | |
| LOSSLESS_CAPABLE_FORMATS = {'.png', '.webp'} | |
| console = Console() | |
| def parse_file_size(size_string): | |
| """Convert file size string to bytes.""" | |
| size_string = size_string.strip().upper() | |
| if size_string.endswith('MB'): | |
| return float(size_string[:-2]) * 1024 * 1024 | |
| elif size_string.endswith('KB'): | |
| return float(size_string[:-2]) * 1024 | |
| elif size_string.endswith('B'): | |
| return float(size_string[:-1]) | |
| else: | |
| return float(size_string) | |
| def get_file_size_bytes(file_path): | |
| """Get file size in bytes.""" | |
| return os.path.getsize(file_path) | |
| def format_file_size(size_bytes): | |
| """Format bytes into human-readable string.""" | |
| if size_bytes >= 1024 * 1024: | |
| return f"{size_bytes / (1024 * 1024):.2f}MB" | |
| elif size_bytes >= 1024: | |
| return f"{size_bytes / 1024:.2f}KB" | |
| else: | |
| return f"{size_bytes}B" | |
| def get_image_files(directory): | |
| """Get all image files from the specified directory.""" | |
| image_files = [] | |
| for file_path in Path(directory).iterdir(): | |
| if file_path.is_file() and file_path.suffix.lower() in INPUT_FORMATS: | |
| if not file_path.stem.startswith(OUTPUT_PREFIX): | |
| image_files.append(file_path) | |
| return image_files | |
| def get_valid_directory(): | |
| """Prompt user for directory until valid path with images is provided.""" | |
| current_dir = Path.cwd() | |
| image_files = get_image_files(current_dir) | |
| if image_files: | |
| return current_dir, image_files | |
| console.print("[red]✗[/red] No image files found in current directory.", style="bold") | |
| console.print() | |
| while True: | |
| dir_path = Prompt.ask("Enter directory path containing images") | |
| directory = Path(dir_path).expanduser().resolve() | |
| if not directory.exists(): | |
| console.print(f"[red]✗[/red] Directory does not exist: {directory}") | |
| continue | |
| if not directory.is_dir(): | |
| console.print(f"[red]✗[/red] Path is not a directory: {directory}") | |
| continue | |
| image_files = get_image_files(directory) | |
| if not image_files: | |
| console.print(f"[red]✗[/red] No image files found in: {directory}") | |
| continue | |
| return directory, image_files | |
| def resize_image(image, target_width): | |
| """Resize image to target width, maintaining aspect ratio.""" | |
| if image.width <= target_width: | |
| return image | |
| aspect_ratio = image.height / image.width | |
| new_width = target_width | |
| new_height = int(new_width * aspect_ratio) | |
| return image.resize((new_width, new_height), Image.Resampling.LANCZOS) | |
| def save_image_lossless(image, output_path, output_format): | |
| """Save image with lossless compression.""" | |
| if output_format == '.png': | |
| image.save(output_path, format='PNG', optimize=True) | |
| elif output_format == '.webp': | |
| image.save(output_path, format='WEBP', lossless=True, quality=100) | |
| else: | |
| return False | |
| return True | |
| def save_image_with_quality(image, output_path, output_format, quality): | |
| """Save image with specified quality.""" | |
| if output_format in {'.jpg', '.jpeg'}: | |
| image.save(output_path, format='JPEG', quality=quality, optimize=True) | |
| elif output_format == '.png': | |
| compression_level = int((100 - quality) / 10) | |
| compression_level = max(0, min(9, compression_level)) | |
| image.save(output_path, format='PNG', compress_level=compression_level, optimize=True) | |
| elif output_format == '.webp': | |
| image.save(output_path, format='WEBP', quality=quality) | |
| else: | |
| image.save(output_path) | |
| def compress_image_to_size(image, output_path, output_format, max_size_bytes, use_lossless): | |
| """Compress image to meet file size requirement.""" | |
| current_image = image.copy() | |
| if use_lossless and output_format in LOSSLESS_CAPABLE_FORMATS: | |
| save_image_lossless(current_image, output_path, output_format) | |
| file_size = get_file_size_bytes(output_path) | |
| if file_size <= max_size_bytes: | |
| return True, current_image.width, file_size | |
| quality = 90 | |
| while quality >= 10: | |
| save_image_with_quality(current_image, output_path, output_format, quality) | |
| file_size = get_file_size_bytes(output_path) | |
| if file_size <= max_size_bytes: | |
| return True, current_image.width, file_size | |
| quality -= 5 | |
| reduction_step = 0.9 | |
| while current_image.width > 100: | |
| new_width = int(current_image.width * reduction_step) | |
| current_image = resize_image(image, new_width) | |
| quality = 80 | |
| while quality >= 10: | |
| save_image_with_quality(current_image, output_path, output_format, quality) | |
| file_size = get_file_size_bytes(output_path) | |
| if file_size <= max_size_bytes: | |
| return True, current_image.width, file_size | |
| quality -= 10 | |
| save_image_with_quality(current_image, output_path, output_format, 10) | |
| file_size = get_file_size_bytes(output_path) | |
| return False, current_image.width, file_size | |
| def process_image(file_path, max_width, max_size_bytes, output_prefix, output_format, use_lossless): | |
| """Process a single image file.""" | |
| try: | |
| with Image.open(file_path) as img: | |
| original_width = img.width | |
| original_height = img.height | |
| original_size = get_file_size_bytes(file_path) | |
| if output_format in {'.jpg', '.jpeg'} and img.mode in ('RGBA', 'LA', 'P'): | |
| rgb_img = Image.new('RGB', img.size, (255, 255, 255)) | |
| if img.mode == 'P': | |
| img = img.convert('RGBA') | |
| if img.mode in ('RGBA', 'LA'): | |
| rgb_img.paste(img, mask=img.split()[-1]) | |
| else: | |
| rgb_img.paste(img) | |
| img = rgb_img | |
| elif img.mode not in ('RGB', 'L'): | |
| img = img.convert('RGB') | |
| working_img = resize_image(img, max_width) | |
| output_path = file_path.parent / f"{output_prefix}{file_path.stem}{output_format}" | |
| success, final_width, final_size = compress_image_to_size( | |
| working_img, | |
| output_path, | |
| output_format, | |
| max_size_bytes, | |
| use_lossless | |
| ) | |
| final_height = int(final_width * (original_height / original_width)) | |
| return { | |
| 'success': True, | |
| 'filename': file_path.name, | |
| 'output_filename': output_path.name, | |
| 'output_path': output_path, | |
| 'original_dimensions': f"{original_width}x{original_height}", | |
| 'final_dimensions': f"{final_width}x{final_height}", | |
| 'original_size': original_size, | |
| 'final_size': final_size, | |
| 'reduction': ((original_size - final_size) / original_size) * 100, | |
| 'met_target': success | |
| } | |
| except Exception as e: | |
| return { | |
| 'success': False, | |
| 'filename': file_path.name, | |
| 'error': str(e) | |
| } | |
| def display_results(results, page_size=20): | |
| """Display conversion results in paginated format.""" | |
| total_results = len(results) | |
| total_pages = (total_results + page_size - 1) // page_size | |
| for page in range(total_pages): | |
| start_idx = page * page_size | |
| end_idx = min(start_idx + page_size, total_results) | |
| page_results = results[start_idx:end_idx] | |
| # console.print(f"\n[bold cyan]Conversion Results (Page {page + 1}/{total_pages})[/bold cyan]") | |
| # console.print("=" * 80) | |
| # console.print() | |
| for result in page_results: | |
| if result['success']: | |
| status_icon = "✓" if result['met_target'] else "⚠" | |
| status_colour_reduction = "green" if result['met_target'] else "yellow" | |
| console.print(f"[green]{status_icon} {result['filename']}[/green]") | |
| console.print(f" [green]Before: [magenta]{format_file_size(result['original_size'])}[/magenta] ({result['original_dimensions']})[/green]") | |
| console.print(f" [green]After: {format_file_size(result['final_size'])} ({result['final_dimensions']})[/green]") | |
| console.print(f" [green]Change: [yellow]{result['reduction']:.1f}% reduction[/yellow] [{status_colour_reduction}]{status_icon}[/{status_colour_reduction}][/green]") | |
| console.print() | |
| else: | |
| # Changed filename color to red for errors | |
| console.print(f"[red]✗ {result['filename']}[/red]") | |
| console.print(f" [red]Error: {result['error']}[/red]") | |
| console.print() | |
| if page < total_pages - 1: | |
| if not Confirm.ask("Continue to next page?", default=True): | |
| break | |
| def display_summary(results): | |
| """Display overall summary statistics.""" | |
| successful = [r for r in results if r['success']] | |
| failed = [r for r in results if not r['success']] | |
| total_original_size = sum(r['original_size'] for r in successful) | |
| total_final_size = sum(r['final_size'] for r in successful) | |
| total_saved = total_original_size - total_final_size | |
| console.print("[bold cyan]Summary Statistics[/bold cyan]") | |
| console.print("=" * 80) | |
| console.print(f"Total Images Processed: {len(results)}") | |
| console.print(f"Successful Conversions: {len(successful)}") | |
| console.print(f"Failed Conversions: {len(failed)}") | |
| console.print(f"Original Total Size: {format_file_size(total_original_size)}") | |
| console.print(f"Final Total Size: {format_file_size(total_final_size)}") | |
| console.print(f"Space Saved: {format_file_size(total_saved)}") | |
| if total_original_size > 0: | |
| console.print(f"Overall Reduction: {(total_saved / total_original_size) * 100:.1f}%") | |
| console.print() | |
| def create_zip_archive(results, directory): | |
| """Create zip archive of all converted images.""" | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| zip_filename = directory / f"zip_resized_{timestamp}.zip" | |
| successful_results = [r for r in results if r['success']] | |
| with Progress( | |
| SpinnerColumn(), | |
| TextColumn("[progress.description]{task.description}"), | |
| console=console | |
| ) as progress: | |
| task = progress.add_task("Creating zip archive...", total=len(successful_results)) | |
| with ZipFile(zip_filename, 'w') as zipf: | |
| for result in successful_results: | |
| zipf.write(result['output_path'], result['output_filename']) | |
| progress.update(task, advance=1) | |
| console.print(f"[green]✓[/green] Created archive: {zip_filename.name}") | |
| console.print(f" Size: {format_file_size(get_file_size_bytes(zip_filename))}") | |
| console.print() | |
| return zip_filename | |
| def delete_source_images(results): | |
| """Delete original source images.""" | |
| successful_results = [r for r in results if r['success']] | |
| with Progress( | |
| SpinnerColumn(), | |
| TextColumn("[progress.description]{task.description}"), | |
| console=console | |
| ) as progress: | |
| task = progress.add_task("Deleting source images...", total=len(successful_results)) | |
| for result in successful_results: | |
| source_path = result['output_path'].parent / result['filename'] | |
| if source_path.exists(): | |
| source_path.unlink() | |
| progress.update(task, advance=1) | |
| console.print(f"[green]✓[/green] Deleted {len(successful_results)} source image(s)") | |
| console.print() | |
| def main(): | |
| """Main function to process all images.""" | |
| console.print(Panel.fit( | |
| "[bold cyan]Image Resizer[/bold cyan]\n" | |
| f"Max Width: {MAX_WIDTH}px | Max Size: {MAX_FILE_SIZE} (PRIORITY)\n" | |
| f"Output Format: {OUTPUT_FORMAT} | Lossless: {USE_LOSSLESS}", | |
| border_style="cyan" | |
| )) | |
| console.print() | |
| directory, image_files = get_valid_directory() | |
| console.print(f"[cyan]Found {len(image_files)} image(s) to process[/cyan]") | |
| console.print() | |
| results = [] | |
| max_size_bytes = parse_file_size(MAX_FILE_SIZE) | |
| with Progress( | |
| SpinnerColumn(), | |
| TextColumn("[progress.description]{task.description}"), | |
| console=console | |
| ) as progress: | |
| task = progress.add_task("Processing images...", total=len(image_files)) | |
| for file_path in image_files: | |
| result = process_image( | |
| file_path, | |
| MAX_WIDTH, | |
| max_size_bytes, | |
| OUTPUT_PREFIX, | |
| OUTPUT_FORMAT, | |
| USE_LOSSLESS | |
| ) | |
| results.append(result) | |
| progress.update(task, advance=1) | |
| display_results(results) | |
| display_summary(results) | |
| successful_results = [r for r in results if r['success']] | |
| failed_results = [r for r in results if not r['success']] | |
| if successful_results: | |
| if CREATE_ZIP_ARCHIVE: | |
| create_zip_archive(results, directory) | |
| if DELETE_SOURCE_IMAGES: | |
| delete_source_images(results) | |
| console.print() | |
| if not failed_results: | |
| console.print("[green bold]✓ Processing complete![/green bold]") | |
| else: | |
| console.print("[red bold]✗ Processing complete with errors![/red bold]") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment