Discord

6Computer Vision 6.3Raw Image Processing

6.3.1 Reading and Manipulating Images with Pillow

Open, convert, resize, crop, rotate and save image files with Pillow, and turn a folder of images into one NumPy array.

Why learn Pillow?

In Pooling, we finished the two layers that image networks are built from. Before any network can use them, though, the images have to get from files on disk into arrays of the right shape. In a contest image task, the data typically arrives as a folder of image files, often of different sizes, sometimes grayscale and sometimes with transparency. Your code has to read all of them and make them consistent.

Pillow is Python's standard library for this. It is imported as PIL (the name of the older library it replaced) and it is on the list of libraries for the 2026 contest machines. This page covers the operations you will actually use: opening and inspecting a file, converting between modes, resizing, cropping, rotating, saving, and moving between Pillow images and NumPy arrays. The terms (pixels, channels, modes, width × height) come from Basic Image Terminology.

What you need to know

By the end of this module, you should be comfortable with these ideas:

Idea In one line
Image.open Opens an image file; .size is (width, height) and .mode is, for example, "RGB".
convert Changes the mode: "L" for grayscale, "RGB" for colour. Convert every image on opening.
Transparent images Paste an RGBA image onto a white background instead of just dropping the alpha channel.
resize and thumbnail resize gives exactly the size you ask for; thumbnail shrinks in place and keeps the shape.
crop Cuts out a box given as (left, upper, right, lower).
Rotating and flipping rotate, transpose and the ImageOps helpers.
save Writes a file; the format comes from the file name.
NumPy and back np.asarray(image) gives (H, W, C); Image.fromarray(array) goes back.
A folder of images Open, convert and resize each file the same way, then stack them into one array.

Learn it properly

Pillow is a practical tool, and the best way to learn it is to use it. Read the tutorial below once, then run every cell on this page and try the operations on your own photos.

Pick one main course

Our pick: Pillow's own tutorial

Read the first sections of the official Pillow tutorial: Using the Image class, Reading and writing images, Cutting, pasting, and merging images, Geometrical transforms, and Color transforms. They are short and show each method with a few lines of code. You can skip the sections on image sequences, PostScript printing and controlling the decoder.

If you prefer something else

  • The definitions: Pillow's Concepts page explains modes, bands, size and coordinates precisely. Read it if a mode such as P or I;16 ever surprises you.
  • The helpers: the ImageOps reference lists ready-made operations, including fit, pad and mirror, which save you from working out boxes and sizes by hand.

A quick tour

The rest of this page goes through the operations one at a time on the macaw photo from Basic Image Terminology (download macaw.jpg), and ends by loading a whole folder of images. Go through your chosen course first, then run each cell in a notebook as you read.

Opening an image

Image.open reads a file and returns a Pillow Image. Three attributes tell you what you have:

INPUTPython
from PIL import Image

photo = Image.open("macaw.jpg")
print(photo.format)    # the file format
print(photo.size)      # (width, height)
print(photo.mode)      # what each pixel holds
[OUTPUT]
JPEG
(500, 430)
RGB

Remember the order: size is (width, height), the opposite of a NumPy shape. It is often clearer to unpack it into two names at once:

INPUTPython
width, height = photo.size
print(width, height)
[OUTPUT]
500 430

To look at an image in a notebook, make it the last line of a cell (just photo), or call display(photo) anywhere in a cell; Jupyter draws it below the cell. plt.imshow(photo) from Matplotlib works too and adds axes with pixel coordinates, which helps when you work out a crop.

Converting between modes

The mode says what each pixel holds. The ones you will meet most are "RGB" (three channels), "L" (one channel, grayscale), "RGBA" (RGB plus transparency) and "P" (palette: each pixel is an index into a small table of colours, common in GIF and some PNG files). convert changes the mode:

INPUTPython
import numpy as np

gray = photo.convert("L")
print(gray.mode, gray.size)
print(np.asarray(gray).shape)
back_to_rgb = gray.convert("RGB")       # three equal channels
print(back_to_rgb.mode, np.asarray(back_to_rgb).shape)
[OUTPUT]
L (500, 430)
(430, 500)
RGB (430, 500, 3)

Converting grayscale to RGB copies the one channel three times. The image still looks gray, but now it has the three channels a colour model expects.

In a real dataset, files can come in several modes, and a model needs them all the same. The habit that prevents most problems is to convert every image as you open it, to "RGB" for a colour model or "L" for a grayscale one, even if you believe the files are already in that mode.

Transparent images

An RGBA image needs care. Let us make one: a blue circle on a transparent background, drawn with Pillow's ImageDraw module and saved as a PNG.

INPUTPython
from PIL import ImageDraw

badge = Image.new("RGBA", (60, 60), (0, 0, 0, 0))   # fully transparent
pen = ImageDraw.Draw(badge)
pen.ellipse((10, 10, 50, 50), fill=(31, 72, 199, 255))
badge.save("badge.png")

badge = Image.open("badge.png")
print(badge.mode)
print(badge.getpixel((0, 0)))     # a corner: transparent
print(badge.getpixel((30, 30)))   # the middle: solid blue
[OUTPUT]
RGBA
(0, 0, 0, 0)
(31, 72, 199, 255)

The transparent corner is stored as (0, 0, 0, 0): black, with an alpha of 0. Converting straight to RGB drops the alpha and keeps that hidden black, so the circle ends up on a black square. Usually you want what a viewer would see, which is the circle on a white page. For that, paste the image onto a white background, using its own alpha channel as the mask that says how much of each pixel to copy:

INPUTPython
dropped = badge.convert("RGB")
print(dropped.getpixel((0, 0)))

on_white = Image.new("RGB", badge.size, "white")
on_white.paste(badge, mask=badge)       # the alpha channel is the mask
print(on_white.getpixel((0, 0)), on_white.getpixel((30, 30)))
[OUTPUT]
(0, 0, 0)
(255, 255, 255) (31, 72, 199)

Whether black or white is right depends on the task, but choose on purpose, and treat all images in a dataset the same way.

Resizing

Models need all images at one size, so resizing is the most common operation of all. resize takes the new size as a (width, height) pair and returns a new image:

INPUTPython
half = photo.resize((250, 215))
square = photo.resize((224, 224))
print(half.size, square.size)
[OUTPUT]
(250, 215) (224, 224)

resize gives you exactly what you ask for, even if that changes the shape: the photo is wider than it is tall, so the 224 × 224 version is squashed sideways. thumbnail is the other option. It shrinks the image in place (it changes the image and returns None) so that it fits inside the size you give, keeping its shape, and it never makes an image larger:

INPUTPython
small = photo.copy()           # thumbnail changes the image itself
small.thumbnail((224, 224))
small.size
[OUTPUT]
(224, 193)

The longer side became 224 and the other side shrank in proportion, to 193. To get an exact square without squashing, combine the two ideas: resize so that the shorter side has the size you want, then crop the middle. ImageOps.fit(photo, (224, 224)) does exactly that in one call, and torchvision's Resize and CenterCrop, in Loading Images into Tensors with Torchvision, do the same inside a model's data pipeline.

When it makes an image smaller or larger, resize has to invent new pixel values from the old ones. By default it uses the bicubic method, which blends neighbouring pixels smoothly. That is right for photos and wrong for images whose values are labels, such as a mask that marks an object with 255 and the background with 0: blending would create values in between that are neither. For those, ask for the nearest method, which copies the nearest pixel instead:

INPUTPython
mask = Image.new("L", (8, 8), 0)
ImageDraw.Draw(mask).rectangle((2, 2, 5, 5), fill=255)

smooth = mask.resize((5, 5))
nearest = mask.resize((5, 5), Image.Resampling.NEAREST)
print(np.unique(np.asarray(smooth)))
print(np.unique(np.asarray(nearest)))
[OUTPUT]
[  0 142 190 203 255]
[  0 255]

The default resize invented values between 0 and 255 along the edge of the square; the nearest method kept only the two that mean something.

Cropping

crop cuts out a rectangle, given as a box of four numbers: (left, upper, right, lower). These are pixel coordinates, xx first as always in Pillow, and the right and lower edges are not included, just like the end of a Python slice. Here is a 200 × 200 box around the macaw's head:

INPUTPython
head = photo.crop((250, 60, 450, 260))
head.size
[OUTPUT]
(200, 200)

In NumPy, the same crop is a slice, with rows (the yy range) first:

INPUTPython
pixels = np.asarray(photo)
head_by_slicing = pixels[60:260, 250:450]
np.array_equal(np.asarray(head), head_by_slicing)
[OUTPUT]
True

A centre crop of any size works out the box from the image's size. Writing the arithmetic out once makes the order of the four numbers stick:

INPUTPython
def center_crop(image, crop_width, crop_height):
    width, height = image.size
    left = (width - crop_width) // 2
    upper = (height - crop_height) // 2
    box = (left, upper, left + crop_width, upper + crop_height)
    return image.crop(box)

center_crop(photo, 300, 300).size
[OUTPUT]
(300, 300)

Rotating and flipping

rotate turns an image by an angle in degrees, anticlockwise. By default the result keeps the original size, so the corners are cut off and the gaps filled with black; expand=True makes the canvas big enough for the whole rotated image, and fillcolor chooses the colour of the gaps. For flips and quarter turns, which lose nothing, use transpose:

INPUTPython
tilted = photo.rotate(30, expand=True, fillcolor="white")
quarter_turn = photo.transpose(Image.Transpose.ROTATE_90)
mirrored = photo.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
upside_down = photo.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
print(tilted.size, quarter_turn.size, mirrored.size)
[OUTPUT]
(650, 624) (430, 500) (500, 430)

A quarter turn swaps the width and the height. ImageOps.mirror(photo) and ImageOps.flip(photo) are shorter names for the two flips. Here are several of the operations so far:

Six small panels. The macaw photo, 500 by 430. A 200 by 200 crop around its head. The photo squashed to 224 by 224 with resize. The photo shrunk to 224 by 193 with thumbnail, keeping its shape. The photo rotated 30 degrees anticlockwise with expand, on a white background, 650 by 624. The photo flipped left to right, so the macaw faces left.
The photo and five operations on it, all drawn at the same scale, with the size of each result as width × height.

Flips and small rotations are also the simplest kind of data augmentation: making extra, slightly different training images from the ones you have. Loading Images into Tensors with Torchvision does it randomly during training.

Saving

save writes an image to a file, and picks the format from the file name's extension:

INPUTPython
import os

head.save("head.png")
head.save("head.jpg", quality=90)
for name in ["head.png", "head.jpg"]:
    print(name, Image.open(name).format, os.path.getsize(name))
[OUTPUT]
head.png PNG 74967
head.jpg JPEG 15498

For JPEG, quality goes from 0 (worst) to 95 (best), and the default is 75. JPEG cannot store transparency, so an RGBA image must be converted to RGB before saving it as JPEG; Common mistakes shows the error. As Basic Image Terminology explains, save anything you need back exactly, such as masks, as PNG.

From Pillow to NumPy and back

np.asarray turns an image into an array of shape (height, width, channels) (or (height, width) for mode L), and Image.fromarray turns an array of uint8 values back into an image. In between, you can use any NumPy operation. Here we darken the photo by multiplying every value by 0.6:

INPUTPython
pixels = np.asarray(photo)                       # uint8, (430, 500, 3)
darker = pixels.astype(np.float32) * 0.6         # floats, to be safe
darker = darker.round().astype(np.uint8)         # back to 0..255
darker_photo = Image.fromarray(darker)
print(darker_photo.mode, darker_photo.size)
print(pixels[300, 150], darker[300, 150])
[OUTPUT]
RGB (500, 430)
[245 185   3] [147 111   2]

Two details matter. First, the array from np.asarray(photo) is read-only, because it shares memory with the image; to change pixels in place, make a copy with np.array(photo). Second, Image.fromarray decides the mode from the array's shape and type, and it only understands colour images as uint8. A float array must be scaled to 0–255 and converted with astype(np.uint8) first, as above.

A folder of images

Now everything comes together. An image task typically gives you a folder of files. To show the pattern, we first make a small folder ourselves: six crops of the photo, saved with different sizes and modes, plus a stray text file of the kind that often sits in real data folders:

INPUTPython
os.makedirs("images", exist_ok=True)
boxes = [(0, 0, 200, 200), (250, 60, 450, 260), (100, 200, 400, 430),
         (300, 200, 500, 430), (0, 150, 250, 400), (150, 0, 450, 150)]
for number, box in enumerate(boxes):
    piece = photo.crop(box)
    if number % 3 == 1:
        piece = piece.convert("L")             # some are grayscale
    if number % 3 == 2:
        piece = piece.convert("RGBA")          # some have alpha
    piece.save(f"images/piece_{number}.png")
with open("images/notes.txt", "w") as notes:
    notes.write("not an image")
sorted(os.listdir("images"))
[OUTPUT]
['notes.txt', 'piece_0.png', 'piece_1.png', 'piece_2.png', 'piece_3.png', 'piece_4.png', 'piece_5.png']

Now we load the folder. For each image file: open it, convert it to RGB, resize it to the same size, and turn it into an array. Then we stack the arrays into one:

INPUTPython
from pathlib import Path

def load_image(path, size):
    with Image.open(path) as image:
        image = image.convert("RGB")
        image = image.resize(size)
        return np.asarray(image)

image_paths = sorted(Path("images").glob("*.png"))
arrays = []
for path in image_paths:
    arrays.append(load_image(path, (64, 64)))
all_images = np.stack(arrays)
print(len(image_paths), all_images.shape, all_images.dtype)
[OUTPUT]
6 (6, 64, 64, 3) uint8

Six images of different sizes and modes became one array of shape (6, 64, 64, 3): images, height, width, channels. A few details:

  • glob("*.png") picks only PNG files, so the text file is skipped. With mixed formats, filter on the extension, for example path.suffix.lower() in {".jpg", ".jpeg", ".png"}.
  • sorted gives a fixed order. Without it, the order of files in a folder depends on the system, and your predictions would not line up with the file names.
  • with Image.open(path) as image: closes the file when the block ends. With thousands of files, open files that are never closed can run into the system's limit.
  • np.stack only works if every array has the same shape, which is why every image is converted and resized the same way.

For a few hundred small images this is all you need. For larger datasets, you do the same work one image at a time inside a PyTorch Dataset, so the images are loaded batch by batch during training instead of all at once; Loading Images into Tensors with Torchvision shows how.

Common mistakes

The messages are exactly what Pillow and NumPy print.

Mistake What you see Fix
Giving resize a (height, width) pair No error, but the image comes out with its sides swapped Pillow sizes are always (width, height).
A crop box in the wrong order, e.g. (upper, left, lower, right) Often no error and the wrong region; sometimes ValueError: Coordinate 'right' is less than 'left' The box is (left, upper, right, lower): xx, yy, xx, yy.
small = photo.thumbnail((224, 224)), then small.size AttributeError: 'NoneType' object has no attribute 'size' thumbnail changes the image in place and returns None; copy the image, call thumbnail on the copy, and use the copy.
Saving an RGBA image as JPEG OSError: cannot write mode RGBA as JPEG convert("RGB") first, or save as PNG.
Image.fromarray on a float array TypeError: Cannot handle this data type: (1, 1, 3), <f4 Scale to 0–255 and convert with astype(np.uint8).
Changing the array from np.asarray(image) ValueError: assignment destination is read-only Make a copy with np.array(image).
A non-image file in the folder PIL.UnidentifiedImageError: cannot identify image file 'notes.txt' Filter the file names by extension before opening them.
Mixed modes in one dataset np.stack fails, or a model gets 1 or 4 channels instead of 3 convert("RGB") (or "L") every image on opening.
Resizing masks or label images with the default method No error, but new in-between values appear Use Image.Resampling.NEAREST for them.

How to practise

There is no Pillow section on CodeChef or HackerRank, but CS50's Introduction to Programming with Python has a problem built on it:

  • Solve CS50 P-Shirt from Problem Set 6. It asks you to resize and crop a photo with ImageOps.fit and overlay a shirt with transparency using paste, exactly the operations on this page. The problem is a command-line program, but you can try the image part in a notebook first.
  • Write a function that pads an image to a square with white borders instead of cropping it, then compare your result with ImageOps.pad.
  • Rewrite center_crop with NumPy slicing on the array instead of crop, and check that both give the same pixels.

Check yourself

What comes next

Resources

SourceTitleWhy read it
PillowTutorialOur recommended starting point: Pillow's own tutorial on reading, writing, cropping, resizing, rotating and converting images.
PillowConceptsModes, bands, size and the coordinate system, defined precisely.
PillowImageOps moduleReady-made operations such as fit, pad, mirror and grayscale.
CS50's Introduction to Programming with PythonProblem Set 6: CS50 P-ShirtA practice problem: resize, crop and overlay images with Pillow.
Wikimedia CommonsAra ararauna portrait Rhodes.jpgThe photograph used on this page, by Jebulon, released under CC0 (public domain dedication).

Finished this module? Mark it done.

Comments

Stuck on something, spotted a mistake, or found a resource that helped you? Tell us below.

Loading comments