⟳ Automation

Batch resize and convert images with Python

How to resize, compress and convert hundreds of images with Python and Pillow: aspect ratio, orientation, JPEG quality, WebP and GPS metadata.

Photos taken with a recent phone weigh several megabytes each and have resolutions designed for printing, not for an online catalogue, a technical report or an email attachment. Shrinking them one by one in a graphics program is work best avoided. With Python and the Pillow library you can process an entire folder in a few seconds, producing light, consistent files with no unwanted information.

Installation and script structure

pip install Pillow

The pattern is always the same: list the images in a source folder, process them one at a time and save them to a different destination folder. Originals must never be overwritten: if the chosen quality turns out to be too low, you must be able to start again.

from pathlib import Path
from PIL import Image, ImageOps

source = Path("original_photos")
destination = Path("web_photos")
destination.mkdir(exist_ok=True)

extensions = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
for file in sorted(source.iterdir()):
    if file.suffix.lower() not in extensions:
        continue
    with Image.open(file) as img:
        img = ImageOps.exif_transpose(img)
        img.thumbnail((1600, 1600))
        if img.mode in ("RGBA", "P"):
            img = img.convert("RGB")
        img.save(destination / f"{file.stem}.jpg", quality=82, optimize=True)

Each line solves a concrete problem, and it is worth looking at them closely.

Keeping the proportions

thumbnail((1600, 1600)) shrinks the image so that neither width nor height exceeds 1600 pixels, keeping the aspect ratio. A 4000×3000 landscape photo becomes 1600×1200; a 3000×4000 portrait becomes 1200×1600. If the image is already smaller, it is not enlarged. That is almost always the behaviour you want.

resize, by contrast, brings the image to exactly the given dimensions and distorts it if the proportions don't match. It is only needed when a precise size is required, and in that case it is better to crop first. ImageOps.fit does both: it crops from the centre and resizes, which is useful for identical square thumbnails.

Photo orientation

Many cameras don't actually rotate the pixels when you shoot in portrait: they save the image in landscape and write a rotation hint in the EXIF metadata. Viewers respect it, but naive processing does not, and the photos come out sideways. ImageOps.exif_transpose applies the indicated rotation to the pixels, so the result is correct everywhere. Forgetting it is the most common mistake in scripts like these.

Quality and format

In practice the JPEG quality parameter ranges from 1 to about 95. Values between 75 and 85 usually offer a good balance between file size and visual quality for photos meant for screens. It is worth testing a few representative images and viewing them at actual size before processing everything.

Format Suited to Notes
JPEG Photographs No transparency, adjustable compression
PNG Graphics, screenshots, logos Lossless, heavier files for photos
WebP Web images Supports transparency, files often lighter than JPEG

JPEG doesn't support transparency: a PNG with an alpha channel must be converted to RGB before saving, otherwise Pillow raises an error. Transparent areas turn black; if you need a white background, paste the image onto a white rectangle before saving. For WebP just change the destination file extension to .webp: Pillow picks the format from the extension.

HEIC photos, common on iPhones, cannot be read by Pillow without an extension: the pillow-heif package adds support.

Metadata: watch out for location

EXIF data can contain much more than orientation: the phone model, the date and time of the shot and, if location was enabled, the GPS coordinates of the place. Publishing a photo of your warehouse or home office online with embedded coordinates is not a good idea. In the JPEG save shown above, Pillow does not write EXIF data unless it is passed explicitly with the exif parameter, so the output images contain none. In my work I still check the result on a sample with a metadata viewer, because it is a check that takes a few seconds.

Names and subfolders

If the images are organised in subfolders, for example one per product, use source.rglob("*") instead of iterdir() and recreate the same structure in the destination:

relative = file.relative_to(source)
output = (destination / relative).with_suffix(".jpg")
output.parent.mkdir(parents=True, exist_ok=True)

Watch out for duplicate names: photo.png and photo.jpg in the same folder would both produce photo.jpg, and the second would overwrite the first. Checking output.exists() before saving prevents the loss.

Speed

For a few hundred images the script takes seconds or minutes, depending on file size. For thousands of photos you can use more than one processor with the concurrent.futures module and a ProcessPoolExecutor, which processes several images in parallel without changing the function's logic.

In short

  • Pillow processes whole folders of images without graphics software.
  • Use thumbnail to shrink while keeping proportions and exif_transpose for orientation.
  • A JPEG quality between 75 and 85 is a good starting point to check by eye.
  • Convert to RGB before saving as JPEG and consider WebP for the web.
  • Check there is no GPS metadata and never overwrite the originals.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also