Batch image compression is essential for web performance optimization. With Python and Pillow you can fully automate the process: reduce file size, resize, convert formats, and generate statistics.
Installation
pip install pillow
Basic JPG and PNG compression
from PIL import Image
import os
def compress_image(input_path, output_path, quality=85, max_width=1920):
"""
Compress an image while maintaining aspect ratio.
quality: 1-95 (JPEG); compress_level 0-9 (PNG)
"""
img = Image.open(input_path)
if img.width > max_width:
ratio = max_width / img.width
new_height = int(img.height * ratio)
img = img.resize((max_width, new_height), Image.LANCZOS)
ext = output_path.rsplit('.', 1)[-1].lower()
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
if ext in ('jpg', 'jpeg'):
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img.save(output_path, format='JPEG', quality=quality,
optimize=True, progressive=True)
elif ext == 'png':
img.save(output_path, format='PNG', optimize=True, compress_level=9)
elif ext == 'webp':
img.save(output_path, format='WEBP', quality=quality, method=6)
orig = os.path.getsize(input_path)
comp = os.path.getsize(output_path)
savings = (1 - comp / orig) * 100
return orig, comp, savings
orig, comp, savings = compress_image('photo.jpg', 'photo_opt.jpg', quality=82)
print(f"{orig//1024}KB → {comp//1024}KB ({savings:.0f}% smaller)")
Batch compression with statistics
from PIL import Image
from pathlib import Path
import os, time
def compress_directory(
input_dir,
output_dir=None,
jpg_quality=82,
webp_quality=80,
max_width=1920,
convert_to_webp=False,
):
source = Path(input_dir)
dest = Path(output_dir) if output_dir else source
dest.mkdir(parents=True, exist_ok=True)
extensions = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff'}
files = [f for f in sorted(source.iterdir())
if f.suffix.lower() in extensions and f.is_file()]
print(f"Found: {len(files)} files in {source}")
stats = {'ok': 0, 'error': 0, 'bytes_orig': 0, 'bytes_comp': 0}
start = time.time()
for file in files:
try:
img = Image.open(file)
orig_size = file.stat().st_size
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
if convert_to_webp:
ext_out = '.webp'
else:
ext_out = file.suffix.lower()
if ext_out in ('.bmp', '.tiff'):
ext_out = '.jpg'
out_path = dest / (file.stem + ext_out)
if ext_out in ('.jpg', '.jpeg'):
if img.mode in ('RGBA', 'P', 'LA'):
img = img.convert('RGB')
img.save(out_path, format='JPEG', quality=jpg_quality,
optimize=True, progressive=True)
elif ext_out == '.png':
img.save(out_path, format='PNG', optimize=True, compress_level=9)
elif ext_out == '.webp':
img.save(out_path, format='WEBP', quality=webp_quality, method=6)
comp_size = out_path.stat().st_size
savings = (1 - comp_size / orig_size) * 100
print(f" OK: {file.name:40s} "
f"{orig_size//1024:5d}KB → {comp_size//1024:5d}KB "
f"({savings:+.0f}%)")
stats['ok'] += 1
stats['bytes_orig'] += orig_size
stats['bytes_comp'] += comp_size
except Exception as e:
print(f" ERROR: {file.name}: {e}")
stats['error'] += 1
duration = time.time() - start
total_savings = (1 - stats['bytes_comp'] / max(stats['bytes_orig'], 1)) * 100
print(f"\n{'='*60}")
print(f"Processed: {stats['ok']} | Errors: {stats['error']}")
print(f"Original: {stats['bytes_orig']//1024//1024:.1f} MB")
print(f"Compressed:{stats['bytes_comp']//1024//1024:.1f} MB")
print(f"Savings: {total_savings:.1f}%")
print(f"Time: {duration:.1f}s")
compress_directory('images/', 'images_opt/', jpg_quality=82)
compress_directory('images/', 'images_webp/', convert_to_webp=True, webp_quality=80)
Progressive compression (find optimal quality)
from PIL import Image
import io
def optimal_jpg_quality(input_path, target_kb=100, min_q=40, max_q=95):
"""Binary search for minimum JPEG quality that keeps file under target size."""
img = Image.open(input_path)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
target_bytes = target_kb * 1024
low, high = min_q, max_q
best_quality = high
while low <= high:
mid = (low + high) // 2
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=mid, optimize=True)
size = buf.tell()
if size <= target_bytes:
best_quality = mid
low = mid + 1
else:
high = mid - 1
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=best_quality, optimize=True)
print(f"Optimal quality: {best_quality} → {buf.tell()//1024} KB (target: {target_kb} KB)")
return best_quality, buf.getvalue()
quality, data = optimal_jpg_quality('large_photo.jpg', target_kb=150)
with open('optimized.jpg', 'wb') as f:
f.write(data)
Generate thumbnails in multiple sizes
from PIL import Image
from pathlib import Path
SIZES = {'sm': (150, 150), 'md': (300, 300), 'lg': (600, 600), 'xl': (1200, 1200)}
def generate_thumbnails(image_path, output_dir='.', square=False):
img = Image.open(image_path)
name = Path(image_path).stem
out = Path(output_dir)
out.mkdir(exist_ok=True)
for suffix, size in SIZES.items():
if square:
img_tmp = img.copy()
img_tmp.thumbnail((max(size), max(size)), Image.LANCZOS)
w, h = img_tmp.size
left = (w - size[0]) // 2
top = (h - size[1]) // 2
thumb = img_tmp.crop((left, top, left + size[0], top + size[1]))
else:
thumb = img.copy()
thumb.thumbnail(size, Image.LANCZOS)
path = out / f'{name}_{suffix}.jpg'
if thumb.mode in ('RGBA', 'P'):
thumb = thumb.convert('RGB')
thumb.save(path, format='JPEG', quality=85, optimize=True)
print(f" {suffix}: {thumb.size} → {path}")
generate_thumbnails('product.jpg', 'thumbnails/', square=True)
Add watermark in batch
from PIL import Image, ImageDraw, ImageFont
from pathlib import Path
def watermark_batch(directory, text='© MyBrand', opacity=0.3):
source = Path(directory)
out = source / 'watermarked'
out.mkdir(exist_ok=True)
try:
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 36)
except (IOError, OSError):
font = ImageFont.load_default()
for path in sorted(source.glob('*.jpg')):
img = Image.open(path).convert('RGBA')
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x = img.width - tw - 20
y = img.height - th - 20
draw.text((x, y), text, fill=(255, 255, 255, int(255 * opacity)), font=font)
result = Image.alpha_composite(img, overlay).convert('RGB')
result.save(out / path.name, format='JPEG', quality=88, optimize=True)
print(f" Watermarked: {path.name}")
watermark_batch('products/', text='© MyStore 2024')
Related conversions
Most teams that read this guide convert images in one of these directions: