Skip to content

Instantly share code, notes, and snippets.

@benlacey57
Last active December 7, 2025 10:50
Show Gist options
  • Select an option

  • Save benlacey57/39a99cd4746dd3aa55f4de965bcef2fd to your computer and use it in GitHub Desktop.

Select an option

Save benlacey57/39a99cd4746dd3aa55f4de965bcef2fd to your computer and use it in GitHub Desktop.
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