Advanced Image Processing with Python Pillow
Pillow (PIL fork) is the most-used Python image processing library. This tutorial covers advanced techniques: smart resizing, filters, color spaces, layer compositing and batch processing.
1. Open, inspect and save images
from PIL import Image
from pathlib import Path
img = Image.open("photo.jpg")
print(f"Format : {img.format}") # JPEG
print(f"Mode : {img.mode}") # RGB, RGBA, L, CMYK...
print(f"Size : {img.size}") # (width, height)
print(f"Width : {img.width}")
print(f"Height : {img.height}")
img.save("photo.png")
img.save("photo.webp", quality=85)
img.save("photo.jpg", quality=90, optimize=True, progressive=True)
2. Resizing: resize, thumbnail and fit
from PIL import Image, ImageOps
img = Image.open("photo.jpg")
# Exact size (may distort)
resized = img.resize((800, 600), Image.LANCZOS)
# thumbnail: maintains aspect ratio, won't exceed max size
thumb = img.copy()
thumb.thumbnail((400, 400), Image.LANCZOS)
print(thumb.size) # e.g. (400, 267)
# Proportional resize helper
def resize_proportional(img, max_width=None, max_height=None):
w, h = img.size
if max_width and max_height:
ratio = min(max_width/w, max_height/h)
elif max_width:
ratio = max_width / w
elif max_height:
ratio = max_height / h
else:
return img
return img.resize((int(w*ratio), int(h*ratio)), Image.LANCZOS)
small = resize_proportional(img, max_width=800)
# ImageOps.fit: crop + resize to exact size without distortion
square = ImageOps.fit(img, (400, 400), Image.LANCZOS, centering=(0.5, 0.5))
3. Crop and rotate
from PIL import Image, ImageOps
img = Image.open("photo.jpg")
# Crop (left, upper, right, lower)
crop = img.crop((100, 50, 500, 400))
print(crop.size) # (400, 350)
# Rotate (degrees, counter-clockwise)
rot_90 = img.rotate(90, expand=True)
rot_45 = img.rotate(45, expand=True, fillcolor=(255,255,255))
flipped_h = img.transpose(Image.FLIP_LEFT_RIGHT)
flipped_v = img.transpose(Image.FLIP_TOP_BOTTOM)
# Auto-rotate using EXIF orientation
auto_rotated = ImageOps.exif_transpose(img)
4. Filters and enhancements
from PIL import Image, ImageFilter, ImageEnhance
img = Image.open("photo.jpg")
# Standard filters
blur = img.filter(ImageFilter.BLUR)
sharpen = img.filter(ImageFilter.SHARPEN)
edges = img.filter(ImageFilter.FIND_EDGES)
emboss = img.filter(ImageFilter.EMBOSS)
contour = img.filter(ImageFilter.CONTOUR)
# Gaussian blur with controlled radius
gauss = img.filter(ImageFilter.GaussianBlur(radius=5))
# Enhancements (1.0 = original)
bright = ImageEnhance.Brightness(img).enhance(1.3)
contrast = ImageEnhance.Contrast(img).enhance(1.5)
color = ImageEnhance.Color(img).enhance(1.4) # 0 = grayscale
sharpness = ImageEnhance.Sharpness(img).enhance(2.0)
5. Color spaces and conversions
from PIL import Image
import numpy as np
img = Image.open("photo.jpg")
# Convert between modes
gray = img.convert("L") # Grayscale
rgba = img.convert("RGBA") # Add alpha channel
cmyk = img.convert("CMYK") # Print-ready
# Split channels
r, g, b = img.split()
# Sepia effect with numpy (fast)
def sepia(img):
arr = np.array(img.convert("RGB")).astype(float)
r = np.clip(arr[:,:,0]*0.393 + arr[:,:,1]*0.769 + arr[:,:,2]*0.189, 0, 255)
g = np.clip(arr[:,:,0]*0.349 + arr[:,:,1]*0.686 + arr[:,:,2]*0.168, 0, 255)
b = np.clip(arr[:,:,0]*0.272 + arr[:,:,1]*0.534 + arr[:,:,2]*0.131, 0, 255)
return Image.fromarray(np.stack([r, g, b], axis=2).astype(np.uint8))
sepia_img = sepia(img)
sepia_img.save("sepia.jpg")
# Boost blue channel
arr = np.array(img).astype(float)
arr[:,:,2] = np.clip(arr[:,:,2] * 1.3, 0, 255)
result = Image.fromarray(arr.astype(np.uint8))
6. Layer compositing
from PIL import Image
# Paste PNG with transparency onto background
def paste_png(background_path, png_path, position=(0, 0)):
bg = Image.open(background_path).convert("RGBA")
fg = Image.open(png_path).convert("RGBA")
bg.paste(fg, position, mask=fg) # mask uses alpha channel
return bg.convert("RGB")
# Blend two images
img1 = Image.open("photo1.jpg")
img2 = Image.open("photo2.jpg").resize(img1.size)
blended = Image.blend(img1, img2, alpha=0.5)
# Composite with mask
from PIL import ImageDraw
mask = Image.new("L", img1.size, 0)
draw = ImageDraw.Draw(mask)
draw.ellipse([100, 100, 400, 400], fill=255)
composite = Image.composite(img2, img1, mask)
7. Add text to images
from PIL import Image, ImageDraw, ImageFont
def add_watermark(input_path, output_path, text="© 2025"):
img = Image.open(input_path).convert("RGBA")
overlay = Image.new("RGBA", img.size, (255,255,255,0))
draw = ImageDraw.Draw(overlay)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
except OSError:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
x = (img.width - text_w) // 2
y = img.height - text_h - 20
draw.text((x+2, y+2), text, font=font, fill=(0,0,0,128)) # Shadow
draw.text((x, y), text, font=font, fill=(255,255,255,192)) # White text
result = Image.alpha_composite(img, overlay)
result.convert("RGB").save(output_path)
8. Batch processing
from PIL import Image, ImageOps
from pathlib import Path
import concurrent.futures
def process_image(input_path, output_folder, max_width=1920):
try:
img = Image.open(input_path)
img = ImageOps.exif_transpose(img) # Fix EXIF orientation
if img.width > max_width:
ratio = max_width / img.width
new_h = int(img.height * ratio)
img = img.resize((max_width, new_h), Image.LANCZOS)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
out = output_folder / (input_path.stem + ".webp")
img.save(out, "webp", quality=85, optimize=True)
return f"OK: {input_path.name}"
except Exception as e:
return f"ERROR: {input_path.name}: {e}"
def batch_convert(input_folder, output_folder, workers=4):
out = Path(output_folder)
out.mkdir(exist_ok=True)
images = [p for p in Path(input_folder).iterdir()
if p.suffix.lower() in {".jpg",".jpeg",".png",".bmp",".tiff"}]
print(f"Processing {len(images)} images...")
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(process_image, img, out) for img in images]
for f in concurrent.futures.as_completed(futures):
print(f.result())
batch_convert("photos/", "photos_webp/")
9. Format comparison
from PIL import Image
from pathlib import Path
def compare_formats(original_path):
img = Image.open(original_path)
orig_sz = Path(original_path).stat().st_size
formats = [
("JPEG 90%", "jpg", {"quality": 90}),
("JPEG 75%", "jpg", {"quality": 75}),
("PNG", "png", {}),
("WebP 90%", "webp", {"quality": 90}),
("WebP 75%", "webp", {"quality": 75}),
("WebP lossless", "webp", {"lossless": True}),
]
print(f"Original ({img.size}): {orig_sz:,} bytes\n")
for name, ext, opts in formats:
out = Path(f"test.{ext}")
canvas = img.convert("RGB") if ext != "png" else img
canvas.save(out, **opts)
sz = out.stat().st_size
print(f"{name:20s}: {sz:8,} bytes ({sz/orig_sz*100:.1f}%)")
out.unlink()
compare_formats("photo.jpg")
Filter summary
| Filter | Class | Effect |
|---|---|---|
BLUR |
ImageFilter | Soft blur |
GaussianBlur(r) |
ImageFilter | Gaussian blur |
SHARPEN |
ImageFilter | Sharpening |
FIND_EDGES |
ImageFilter | Edge detection |
EMBOSS |
ImageFilter | Emboss effect |
Brightness(f) |
ImageEnhance | Brightness |
Contrast(f) |
ImageEnhance | Contrast |
Color(f) |
ImageEnhance | Saturation |
Sharpness(f) |
ImageEnhance | Detail sharpness |
Related conversions
Most teams that read this guide convert images in one of these directions: