The same ground the old handbook tutorial covered, written for Pillow and Python 3.
from PIL import Image
im = Image.open("photo.jpg")
print(im.format, im.size, im.mode)
That prints something like JPEG (1600, 1200) RGB. The file has been opened and its header read, but the pixels have not been loaded yet. Pillow waits until something needs them.
im = Image.open("photo.jpg")
im.thumbnail((200, 200))
im.save("thumb.png")
thumbnail keeps the aspect ratio, never enlarges, and changes the image in place rather than returning a new one. If you want an exact size regardless of shape, use resize instead.
box = (100, 100, 400, 400)
piece = im.crop(box)
piece = piece.transpose(Image.Transpose.ROTATE_180)
im.paste(piece, box)
The box is (left, upper, right, lower), with the origin at the top left corner. The right and lower edges are not included, so that box is exactly 300 by 300 pixels.
r, g, b = im.split()
swapped = Image.merge("RGB", (b, g, r))
Each band is an image in its own right, in mode L.
grey = im.convert("L")
Mode is worth understanding early, because most confusing errors come from it: saving RGBA as JPEG, filtering a palette image, or comparing images that do not match. The mode viewer shows what each one does.
small = im.resize((320, 240), Image.Resampling.LANCZOS)
turned = im.rotate(45, expand=True)
Without expand=True the corners are cut off. Which resampling filter you pass matters, and the comparison tool shows why.
from PIL import ImageFilter, ImageEnhance
soft = im.filter(ImageFilter.GaussianBlur(2))
sharp = im.filter(ImageFilter.UnsharpMask(radius=2, percent=120))
brighter = ImageEnhance.Brightness(im).enhance(1.3)
import pathlib
from PIL import Image, ImageOps
for path in pathlib.Path("photos").glob("*.jpg"):
im = Image.open(path)
im = ImageOps.exif_transpose(im)
im.thumbnail((1200, 1200), Image.Resampling.LANCZOS)
im.save(pathlib.Path("web") / path.name, quality=85, optimize=True)
The exif_transpose call is the one people leave out and regret. Phone cameras record orientation as a tag rather than rotating the pixels, so without it a proportion of your output is sideways.
print(im.getpixel((10, 10)))
px = im.load()
px[10, 10] = (255, 0, 0)
getpixel is fine for a handful of values. For anything more, load() is much faster, and point() or numpy faster still.