87.9 Pillow: Image Processing
Right, so you need to manipulate images. You could fire up GIMP or Photoshop every time, but you’re a programmer. Your superpower is automation. Enter Pillow, the friendly fork of the venerable (and now defunct) PIL. It’s the Swiss Army knife for your image data, and we’re going to learn how to use it without accidentally slicing off a thumb.
First, the cardinal rule: Pillow is not your display server. It’s a library for processing images. Don’t expect it to open a window and show you a picture with a single command; that’s not its job. Its job is to open an image file, hand you a bunch of pixels to play with, and then save the result after you’ve done your worst (or best).
The Absolute Basics: Open, Manipulate, Save
Let’s start with the holy trinity. You’ll use these three operations more than anything else.
from PIL import Image
# Open an image. It's this easy.
img = Image.open('your_cat.jpg')
# Let's see what we're dealing with.
print(f"Format: {img.format}, Size: {img.size}, Mode: {img.mode}")
# Probably: Format: JPEG, Size: (1200, 800), Mode: RGB
# Now, let's do something trivial but useful: convert it to grayscale.
grayscale_cat = img.convert('L') # 'L' for Luminance
# And save it. Pillow is smart enough to infer the format from the extension.
grayscale_cat.save('your_very_sophisticated_black_and_white_cat.png')
Why convert('L') and not some more obvious name? Because Pillow inherits PIL’s legacy, and sometimes that legacy is a bit… cryptic. The mode tells Pillow how to interpret the data in the file. 'RGB' is three 8-bit channels, 'L' is one 8-bit channel (grayscale), 'RGBA' is RGB with alpha (transparency), and 'CMYK' is for print, which is a whole other world of pain we’ll avoid for now.
Cropping and Resizing: Not the Same Thing
This is where everyone gets tripped up. Cropping is about selecting a sub-section of an image. Resizing is about scaling the entire image to new dimensions.
# Let's assume our cat image is 1200x800
box = (400, 200, 800, 600) # Define a box: (left, upper, right, lower)
cropped_cat = img.crop(box)
cropped_cat.save('cat_close_up.jpg') # Now it's a 400x400 image of the cat's face
# Now for resizing. The quality argument is VITAL. Don't just use the default.
thumbnail_cat = img.resize((300, 200), Image.Resampling.LANCZOS)
thumbnail_cat.save('cat_small.jpg')
Notice I used LANCZOS? That’s a resampling algorithm. It’s slow but high-quality for downscaling. If you’re making a thumbnail, it’s the right choice. If you’re upscaling a tiny icon, you might use NEAREST to avoid making it blurry. The default, if you don’t specify, is NEAREST, which is almost always the wrong choice for photos. It’s a bizarre default that I can only assume was chosen for performance reasons in the Paleolithic era of computing. Always, always specify the resampling filter.
The Almighty Thumbnail Method
Speaking of thumbnails, Pillow has a dedicated method for this, and you should use it. The key difference between .thumbnail() and .resize() is that .thumbnail() preserves the aspect ratio. You give it a maximum size, and it makes sure the image fits within that box.
# This modifies the image in-place and doesn't return a new object!
img.thumbnail((400, 400), Image.Resampling.LANCZOS) # The cat will now fit inside a 400x400 box.
img.save('cat_thumbnail.jpg')
print(img.size) # Could be (400, 266) or (300, 400), depending on the original aspect ratio.
The fact that .thumbnail() operates in-place is a classic Pillow quirk. It’s caught me more than once. It feels like the method should return a new object, but it doesn’t. It just mutates the one you called it on. Consider yourself warned.
Where the Real Magic Happens: The ImageDraw Module
Opening and resizing is cool, but what if you want to draw on your image? Say, to add a timestamp, a watermark, or a beautifully rendered complaint about the image quality? Meet ImageDraw.
from PIL import Image, ImageDraw, ImageFont
img = Image.open('cat.jpg')
draw = ImageDraw.Draw(img) # We're drawing directly onto the original image
# Draw a big red box around your cat's beautiful face.
draw.rectangle([(400, 200), (800, 600)], outline="red", width=5)
# Let's add some text. Font choice is critical.
try:
font = ImageFont.truetype("arial.ttf", 36)
except IOError:
font = ImageFont.load_default() # The dreaded fallback default font
draw.text((450, 620), "A FINE CAT", fill="yellow", font=font)
img.save('cat_annotated.jpg')
The coordinate system starts from (0, 0) at the top-left. This is computer graphics 101, but it still feels wrong every time. The text positioning is also from the top-left of the text bounding box, which is why I placed it at (450, 620)—below the box I just drew. Choosing a font is a whole adventure; you’re at the mercy of what’s installed on your system, so always have a fallback plan.
Common Pitfalls and the GIF of It All
You will run into two problems constantly. First, forgetting that images are manipulated in-place. Many methods, like thumbnail() and any operation with ImageDraw, change the original Image object. If you need the original, you must .copy() it first.
Second, working with RGBA images (transparency). When you paste one image onto another, you must provide a mask if you want the transparency to work.
cat = Image.open('cat.png').convert('RGBA')
hat = Image.open('tiny_sombrero.png').convert('RGBA')
# Paste the hat onto the cat, using the hat's alpha channel as the mask.
cat.paste(hat, (500, 100), mask=hat)
cat.save('cat_in_a_sombrero.png')
And a final word on GIFs: Pillow can read and write them, but it treats animated GIFs as a sequence of frames. You have to use seek() and tell() to navigate between them. It’s a bit clunky, a testament to the format’s age, but it works. Just don’t expect a simple .resize() to automatically resize every frame in the animation; you’ll have to loop through them yourself. Because of course you will. Nothing is ever easy, is it? But that’s why we have code. And Pillow.