Pillow
The standard image library for Python. Pillow (PIL fork) gives you straightforward image I/O, resizing, cropping, color conversion, and drawing — simpler and more Pythonic than OpenCV for most non-CV tasks. Every ML training pipeline starts with Pillow to load and resize images.
Table of Contents
Installation / Setup
pip install Pillow # or: uv add Pillow
from PIL import Image, ImageFilter, ImageDraw, ImageFont, ImageEnhance
import numpy as np
Pillow images are RGB by default (unlike OpenCV's BGR).
Core Syntax
Image loading and saving
from PIL import Image
img = Image.open("photo.jpg") # RGB (or RGBA for PNG)
img_gray = Image.open("photo.jpg").convert("L")
img.save("output.png")
img.save("output.jpg", quality=95)
| Mode |
Description |
"L" |
Grayscale (8-bit) |
"RGB" |
3-channel color |
"RGBA" |
RGB + alpha transparency |
"CMYK" |
Print color space |
"1" |
Binary (black/white) |
Image info
w, h = img.size # (width, height)
mode = img.mode
fmt = img.format
Resizing
# fixed size
resized = img.resize((640, 480), Image.LANCZOS)
# keep aspect ratio
w, h = img.size
ratio = 512 / max(w, h)
resized = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS)
# thumbnail modifies in place (max dimension)
img_copy = img.copy()
img_copy.thumbnail((224, 224), Image.LANCZOS)
| Resampling filter |
Use |
Image.NEAREST |
Fast, pixelated |
Image.BILINEAR |
General purpose |
Image.BICUBIC |
Better quality for enlarging |
Image.LANCZOS |
Best quality (default for resizing) |
Cropping
cropped = img.crop((left, top, right, bottom)) # tuple: (left, upper, right, lower)
center = img.crop((w//4, h//4, 3*w//4, 3*h//4))
Rotating and flipping
rotated = 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)
Color spaces
gray = img.convert("L")
rgb = img.convert("RGB")
rgba = img.convert("RGBA")
# to NumPy array (RGB)
arr = np.array(img)
# back to PIL from NumPy
img = Image.fromarray(arr)
Common Operations
Filtering
blurred = img.filter(ImageFilter.GaussianBlur(radius=3))
sharpened = img.filter(ImageFilter.SHARPEN)
edges = img.filter(ImageFilter.FIND_EDGES)
median = img.filter(ImageFilter.MedianFilter(size=3))
| Filter |
Effect |
ImageFilter.GaussianBlur(radius) |
Smooth / denoise |
ImageFilter.SHARPEN |
Enhance edges |
ImageFilter.FIND_EDGES |
Edge detection |
ImageFilter.EDGE_ENHANCE |
Subtle edge enhancement |
ImageFilter.SMOOTH |
Gentle smoothing |
ImageFilter.MedianFilter(size) |
Salt-and-pepper noise removal |
Enhancement
from PIL import ImageEnhance
enhancer = ImageEnhance.Brightness(img)
bright = enhancer.enhance(1.3) # 1.0 = original, >1 brighter
enhancer = ImageEnhance.Contrast(img)
contrast = enhancer.enhance(1.5)
enhancer = ImageEnhance.Color(img)
saturated = enhancer.enhance(1.2)
enhancer = ImageEnhance.Sharpness(img)
sharp = enhancer.enhance(2.0)
Drawing and annotations
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(img)
# shapes
draw.rectangle([x1, y1, x2, y2], outline="red", width=2)
draw.ellipse([x1, y1, x2, y2], fill="blue")
draw.line([(x1, y1), (x2, y2)], fill="green", width=3)
# text
try:
font = ImageFont.truetype("arial.ttf", size=20)
except IOError:
font = ImageFont.load_default()
draw.text((50, 50), "Hello World", fill="white", font=font)
Compositing
# paste one image onto another
background.paste(foreground, (x, y), mask=foreground) # mask for transparency
# resize and paste
fg = foreground.resize((100, 100))
background.paste(fg, (10, 10))
exif = img.getexif()
print(dict(exif))
# strip metadata for smaller files
img.save("clean.jpg", exif=b"")
Practical Examples
Example 1 — Data augmentation pipeline for training
from PIL import Image, ImageEnhance, ImageFilter
import random
def augment(img, seed=None):
if seed: random.seed(seed)
img = img.transpose(random.choice([Image.FLIP_LEFT_RIGHT, Image.FLIP_TOP_BOTTOM]))
img = img.rotate(random.uniform(-15, 15), fillcolor=(128, 128, 128))
img = ImageEnhance.Brightness(img).enhance(random.uniform(0.8, 1.2))
img = ImageEnhance.Contrast(img).enhance(random.uniform(0.8, 1.2))
if random.random() > 0.5:
img = img.filter(ImageFilter.GaussianBlur(radius=random.uniform(0, 1)))
return img.resize((224, 224), Image.LANCZOS)
from PIL import Image
import glob, math
def contact_sheet(paths, cols=4, thumb_size=(200, 200)):
imgs = [Image.open(p).resize(thumb_size, Image.LANCZOS) for p in paths[:cols * 6]]
rows = math.ceil(len(imgs) / cols)
sheet = Image.new("RGB", (cols * thumb_size[0], rows * thumb_size[1]), "white")
for i, img in enumerate(imgs):
x = (i % cols) * thumb_size[0]
y = (i // cols) * thumb_size[1]
sheet.paste(img, (x, y))
return sheet
paths = glob.glob("photos/*.jpg")
sheet = contact_sheet(paths)
sheet.save("contact_sheet.jpg")
from PIL import Image
import glob, os
for path in glob.glob("raw_images/*.jpg"):
img = Image.open(path).convert("RGB")
img.thumbnail((800, 800), Image.LANCZOS)
out = os.path.join("processed", os.path.splitext(os.path.basename(path))[0] + ".png")
img.save(out, "PNG")
Common Patterns
Pattern: convert to NumPy, process, convert back
import numpy as np
from PIL import Image
img = Image.open("photo.jpg")
arr = np.array(img) # (H, W, 3) uint8 RGB
# custom processing
arr[arr > 200] = 255 # threshold
result = Image.fromarray(arr)
result.save("result.jpg")
def load_and_resize(path, size=(224, 224)):
return Image.open(path).convert("RGB").resize(size, Image.LANCZOS)
Pattern: tile images into a montage
from PIL import Image
import glob
imgs = [Image.open(p).resize((128, 128)) for p in glob.glob("data/*.png")[:16]]
cols = 4
rows = (len(imgs) + cols - 1) // cols
montage = Image.new("RGB", (cols * 128, rows * 128), "gray")
for i, img in enumerate(imgs):
montage.paste(img, ((i % cols) * 128, (i // cols) * 128))
montage.save("montage.jpg")
Best Practices
- Use
Image.LANCZOS for resizing — best quality; Image.NEAREST only for masks/labels.
- Pillow uses RGB (unlike OpenCV's BGR) — match the convention of your pipeline.
- Always
convert("RGB") before saving as JPEG — RGBA JPEGs fail silently.
Image.open() is lazy — it reads header only; actual pixel data loaded on access.
- For large batch processing, keep images in a list then save one-by-one to manage memory.
- Use
thumbnail() for in-place resizing (modifies the image object), resize() returns a new image.
- Strip EXIF orientation before training —
ImageOps.exif_transpose() fixes rotated phones photos.
Common Mistakes
| Mistake |
Fix |
| RGBA image fails to save as JPEG |
convert("RGB") before saving |
Image.open() is lazy — file handle stays open |
Call .load() or keep a reference until save |
Forgetting expand=True on rotate — corners get cropped |
rotate(angle, expand=True) keeps full image |
| JPEG quality too low — artifacts |
save(path, quality=95) (default is 75) |
| Mixing PIL and OpenCV arrays — RGB/BGR confusion |
PIL is RGB; OpenCV is BGR — convert before passing |
thumbnail modifies in place |
Don't reassign: call img.thumbnail(...) not img = img.thumbnail(...) |
| Memory blow-up with huge images |
Resize or load() before heavy processing |
Troubleshooting
| Symptom |
Cause / Fix |
OSError: cannot identify image file |
File is corrupted, not an image, or wrong extension |
PIL.UnidentifiedImageError |
Try Image.open(path).verify() or install libjpeg/libpng |
| Transposed/oriented JPEG from phone |
Use ImageOps.exif_transpose(img) |
| Memory errors on large images |
img.load() forces loading; process in tiles |
FileNotFoundError for font |
Install the font file or use ImageFont.load_default() |
- opencv.md — advanced CV: contours, video, webcam
- torchvision — ML transforms and pretrained models
- yolo — object detection
- NumPy — array operations
- matplotlib — displaying images in notebooks
- Python — loops, functions, file I/O
Official Documentation
Last Updated
2026-09-06 — Initial version.