SameOS ~/tools/pillow-bulk-thumbnails.md

Bulk Thumbnail Generation With Python Pillow

Once an image gallery grows, you cannot list original files directly - you need thumbnails. If thumbnails come as an afterthought with thousands of images already stored, a batch script becomes essential, and in practice it needs more rules than it first appears. These notes come from a script that has processed thousands of images in real operation.

Rules to Decide First

First, store thumbnails in a thumbs/ subfolder rather than next to originals, so the original listing stays clean. Second, skip files whose thumbnail already exists - this makes the script idempotent and safe to re-run anytime. Third, exclude the thumbs/ folder itself from traversal so you never generate thumbnails of thumbnails.

Conversion Pitfalls

PNG alpha channels and palette-mode images (which store colors as a numbered table) must be converted to RGB before saving as JPEG. For animated GIFs, the first frame is enough. LANCZOS resampling (recalculating pixels to fit the smaller size) gives the best downscaling quality, and the thumbnail() method preserves aspect ratio while fitting inside the target box, so there is no manual width/height math.

The script below walks a folder recursively and creates thumbnails capped at 400px. Quality 82 is plenty for list views and typically lands at 3-8% of the original size.

import os
from PIL import Image

ROOT = "/srv/photos"
MAX_SIZE = 400
QUALITY = 82
EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}

def make_thumb(path: str) -> bool:
    parent = os.path.dirname(path)
    if os.path.basename(parent) == "thumbs":
        return False                      # no thumbnails of thumbnails
    thumbs = os.path.join(parent, "thumbs")
    os.makedirs(thumbs, exist_ok=True)
    base = os.path.splitext(os.path.basename(path))[0]
    out = os.path.join(thumbs, base + ".jpg")
    if os.path.exists(out):
        return False                      # already exists - skip, safe to re-run
    with Image.open(path) as img:
        rgb = img.convert("RGB")          # handles PNG alpha/palette modes
        rgb.thumbnail((MAX_SIZE, MAX_SIZE), Image.LANCZOS)
        rgb.save(out, "JPEG", quality=QUALITY, optimize=True)
    return True

count = 0
for root, dirs, files in os.walk(ROOT):
    dirs[:] = [d for d in dirs if d != "thumbs"]   # skip thumbs during traversal
    for name in files:
        if os.path.splitext(name)[1].lower() in EXTS:
            if make_thumb(os.path.join(root, name)):
                count += 1
print(f"created {count} thumbnails")

Operation Tips

Thousands of images take 0.5-1 second each, so run the script in the background with nohup or tmux (tools that keep a job running after you close the terminal). Because it is idempotent, an interrupted run simply continues where it left off when re-executed. For folders that keep growing, register the same script in cron. To estimate the storage cost up front, use the thumbnail cache calculator.

Get the full script (GitHub) Open the thumbnail cache calculator Choosing WebP Conversion Settings Find Duplicate Images With Python