Batch Image Resizing and Optimization Pipeline with Python
Processing hundreds or thousands of images manually is impractical. Python lets you build automated pipelines that resize, compress, convert to modern formats (WebP, AVIF), and generate responsive variants at scale. This guide covers Pillow, wand (ImageMagick binding), and parallel processing for maximum throughput.
Installation
pip install Pillow pillow-avif-plugin wand
# For SIMD acceleration:
pip uninstall Pillow
pip install Pillow-SIMD
On Linux, install ImageMagick if using wand:
apt-get install libmagickwand-dev
Basic Resizing with Pillow
from PIL import Image
from pathlib import Path
def resize_image(input_path, output_path, width=None, height=None, max_side=None):
"""
Resize maintaining aspect ratio.
- width/height: fix one dimension, calculate the other
- max_side: limit the longest side to N pixels
"""
img = Image.open(input_path)
w, h = img.size
if max_side:
ratio = max_side / max(w, h)
new_w = int(w * ratio)
new_h = int(h * ratio)
elif width and not height:
ratio = width / w
new_w, new_h = width, int(h * ratio)
elif height and not width:
ratio = height / h
new_w, new_h = int(w * ratio), height
else:
new_w, new_h = width, height
# LANCZOS = best quality for downscaling
resized = img.resize((new_w, new_h), Image.LANCZOS)
resized.save(output_path)
print(f"{Path(input_path).name}: {w}×{h} → {new_w}×{new_h}")
return resized
# Usage
resize_image("photo.jpg", "photo_800.jpg", width=800)
resize_image("image.png", "thumbnail.png", max_side=256)
Smart Cropping
from PIL import Image, ImageFilter
import numpy as np
def center_crop(img, target_w, target_h):
"""Simple centered crop."""
w, h = img.size
left = (w - target_w) // 2
top = (h - target_h) // 2
return img.crop((left, top, left + target_w, top + target_h))
def attention_crop(img, target_w, target_h):
"""
Saliency-based crop using gradient magnitude.
Finds the most visually interesting region.
"""
gray = img.convert('L')
edges = np.array(gray.filter(ImageFilter.FIND_EDGES))
total = edges.sum()
if total == 0:
return center_crop(img, target_w, target_h)
y_idx, x_idx = np.indices(edges.shape)
cx = int((edges * x_idx).sum() / total)
cy = int((edges * y_idx).sum() / total)
w, h = img.size
left = max(0, min(cx - target_w // 2, w - target_w))
top = max(0, min(cy - target_h // 2, h - target_h))
return img.crop((left, top, left + target_w, top + target_h))
# Generate 1:1 thumbnail for social media
img = Image.open("portrait.jpg")
thumb = attention_crop(img, 1080, 1080)
thumb.save("instagram_square.jpg", quality=85)
Converting to WebP and AVIF
from PIL import Image
import pillow_avif # registers AVIF codec with Pillow
def to_webp(input_path, output_path, quality=85, lossless=False):
img = Image.open(input_path).convert('RGB')
img.save(
output_path,
format='WEBP',
quality=quality,
lossless=lossless,
method=6 # 0=fastest, 6=best compression
)
orig = Path(input_path).stat().st_size
new = Path(output_path).stat().st_size
print(f"WebP: {orig/1024:.0f}KB → {new/1024:.0f}KB (saved {(1-new/orig)*100:.1f}%)")
def to_avif(input_path, output_path, quality=60):
img = Image.open(input_path).convert('RGB')
img.save(output_path, format='AVIF', quality=quality)
orig = Path(input_path).stat().st_size
new = Path(output_path).stat().st_size
print(f"AVIF: {orig/1024:.0f}KB → {new/1024:.0f}KB (saved {(1-new/orig)*100:.1f}%)")
to_webp("photo.jpg", "photo.webp")
to_avif("photo.jpg", "photo.avif")
Full Responsive Web Pipeline
Generate all variants a modern <picture> element needs:
from PIL import Image
import pillow_avif
from pathlib import Path
import json
VARIANTS = [
{'suffix': 'sm', 'max_w': 480, 'q_webp': 80, 'q_avif': 55},
{'suffix': 'md', 'max_w': 768, 'q_webp': 82, 'q_avif': 58},
{'suffix': 'lg', 'max_w': 1200, 'q_webp': 85, 'q_avif': 60},
{'suffix': 'xl', 'max_w': 1920, 'q_webp': 87, 'q_avif': 62},
{'suffix': '2x', 'max_w': 2560, 'q_webp': 85, 'q_avif': 60},
]
def image_web_pipeline(source_path, output_dir):
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
orig = Image.open(source_path)
ow, oh = orig.size
stem = Path(source_path).stem
metadata = {'original': {'width': ow, 'height': oh}, 'variants': []}
for v in VARIANTS:
if ow <= v['max_w']:
img, nw, nh = orig.copy(), ow, oh
else:
ratio = v['max_w'] / ow
nw = v['max_w']
nh = int(oh * ratio)
img = orig.resize((nw, nh), Image.LANCZOS)
rgb = img.convert('RGB')
p_webp = output_dir / f"{stem}-{v['suffix']}.webp"
p_avif = output_dir / f"{stem}-{v['suffix']}.avif"
p_jpg = output_dir / f"{stem}-{v['suffix']}.jpg"
rgb.save(str(p_webp), format='WEBP', quality=v['q_webp'], method=6)
rgb.save(str(p_avif), format='AVIF', quality=v['q_avif'])
rgb.save(str(p_jpg), format='JPEG', quality=85, optimize=True, progressive=True)
metadata['variants'].append({
'suffix': v['suffix'],
'width': nw, 'height': nh,
'webp': p_webp.name,
'avif': p_avif.name,
'jpg': p_jpg.name,
})
print(f" {v['suffix']:4} {nw}×{nh}: "
f"WebP={p_webp.stat().st_size//1024}KB "
f"AVIF={p_avif.stat().st_size//1024}KB "
f"JPG={p_jpg.stat().st_size//1024}KB")
json_path = output_dir / f"{stem}-meta.json"
with open(json_path, 'w') as f:
json.dump(metadata, f, indent=2)
return metadata
meta = image_web_pipeline("hero_banner.jpg", "public/images/hero/")
Auto-generate <picture> HTML
def generate_picture_html(metadata, alt="", loading="lazy"):
sources = []
for fmt in ['avif', 'webp']:
srcset = ", ".join(
f"{v[fmt]} {v['width']}w"
for v in metadata['variants']
)
mime = 'image/avif' if fmt == 'avif' else 'image/webp'
sources.append(f' <source type="{mime}" srcset="{srcset}">')
fallback = metadata['variants'][-1]['jpg']
lines = ['<picture>'] + sources
lines.append(f' <img src="{fallback}" alt="{alt}" loading="{loading}">')
lines.append('</picture>')
return '\n'.join(lines)
print(generate_picture_html(meta, alt="Hero Banner", loading="eager"))
Parallel Processing with ProcessPoolExecutor
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import os
def pipeline_worker(args):
source, output_dir = args
try:
image_web_pipeline(str(source), str(output_dir))
return (True, str(source))
except Exception as e:
return (False, f"{source}: {e}")
def batch_pipeline(source_dir, output_dir,
extensions=('jpg', 'jpeg', 'png', 'tiff'),
workers=None):
source_dir = Path(source_dir)
output_dir = Path(output_dir)
workers = workers or os.cpu_count()
images = [f for ext in extensions for f in source_dir.rglob(f'*.{ext}')]
print(f"Processing {len(images)} images with {workers} workers...")
tasks = [(img, output_dir / img.stem) for img in images]
done = 0
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(pipeline_worker, t): t for t in tasks}
for future in as_completed(futures):
ok, msg = future.result()
if ok:
done += 1
print(f" ✓ {Path(msg).name}")
else:
print(f" ✗ {msg}")
print(f"\nDone: {done}/{len(images)} images processed")
batch_pipeline("originals/", "optimized/", workers=4)
Stripping EXIF Metadata (Privacy)
from PIL import Image
def strip_exif(input_path, output_path):
img = Image.open(input_path)
# Rebuild image data without EXIF
clean = Image.new(img.mode, img.size)
clean.putdata(list(img.getdata()))
clean.save(output_path)
print(f"EXIF stripped: {output_path}")
def read_exif(path):
img = Image.open(path)
exif = img._getexif()
if exif:
from PIL.ExifTags import TAGS
for tag_id, value in exif.items():
print(f" {TAGS.get(tag_id, tag_id)}: {value}")
else:
print(" No EXIF data")
print("Before:")
read_exif("photo_original.jpg")
strip_exif("photo_original.jpg", "photo_clean.jpg")
print("\nAfter:")
read_exif("photo_clean.jpg")
Format Comparison: Size vs Quality
| Format | Typical Size | Quality | Browser Support | Recommendation |
|---|---|---|---|---|
| JPEG (85%) | 100 KB | Good | Universal | Required fallback |
| WebP (85%) | 65 KB (-35%) | Very good | 95%+ | Primary for web |
| AVIF (60%) | 45 KB (-55%) | Excellent | 85%+ | First choice in <picture> |
| PNG | 250 KB | Lossless | Universal | Only for alpha transparency |
Additional Resource
For converting images between JPG, WebP, AVIF, PNG, TIFF and more formats without any coding, use KaijuConverter — free, fast, and no registration required.
Related conversions
Most teams that read this guide convert images in one of these directions: