PythonMastery
intermediate 30 min read · lesson 14 of 15 in Projects

Project: Image Batch Resizer

1 · The lesson

read

You'll build a CLI tool that takes a folder full of photos and creates smaller copies for the web. It keeps the aspect ratio, skips damaged files and works on several images in parallel. A tool like this saves time whenever you need to upload a large set of photos.

What you'll practise: third-party packages (Pillow), pathlib filesystem iteration, per-file error isolation, argparse, ThreadPoolExecutor, idempotent jobs.


Step 1: The Simplest Version

First install Pillow. It is the most widely used Python imaging library, and a maintained fork of the original PIL.

python
pip install Pillow

Then the simplest possible resize:

python
from PIL import Image

img = Image.open("photo.jpg")
img = img.resize((800, 600))
img.save("photo_small.jpg")
print("done")

Only three lines do the work. Run it on any photo.jpg, and you'll get photo_small.jpg in the same folder. Pillow decodes the JPEG, resamples it and encodes it again. You only gave it the new dimensions.


Step 2: Keep the Aspect Ratio

resize((800, 600)) can distort your images. If your source is 4000x3000, you get 800x600, and it looks right because the ratio is the same. But with a 4000x2250 source, resize((800, 600)) stretches the image vertically, and faces look stretched.

Use thumbnail() instead:

python
from PIL import Image

img = Image.open("photo.jpg")
img.thumbnail((800, 600))         # changes img itself and keeps the aspect ratio
img.save("photo_small.jpg")

print(img.size)                   # for example (800, 450), never bigger than the box

The difference:

MethodBehaviour
resize((w, h))Forces exact w x h. Can stretch.
thumbnail((w, h))In-place. Scales DOWN to fit inside (w, h), keeping ratio. Never enlarges.

Use thumbnail() when you want "make this fit in a 1200px box". Use resize() when you really need exact pixel sizes (thumbnails in a grid, inputs for an ML model).


Step 3: Process a Whole Folder

Resizing one photo is useful, but the real value is resizing a whole folder at once. Use pathlib to loop over the folder, and a context manager so that each file is closed correctly.

python
from pathlib import Path
from PIL import Image

source = Path("photos")
destination = Path("photos_resized")
destination.mkdir(exist_ok=True)        # safe to repeat: no error if it already exists

for path in source.glob("*.jpg"):
    with Image.open(path) as img:
        img.thumbnail((1200, 1200))
        out = destination / path.name
        img.save(out)
        print(f"  {path.name} → {out}")

A few notes:

  • glob("*.jpg") returns only .jpg files. For JPEG, PNG and WebP, loop over all files and filter on .suffix.lower(). (A pattern like glob("*.[jJpPwW]*") also matches other files, such as .pdf and .py.)
  • with Image.open(path) as img: makes sure the file is closed, even if save() raises an error. Pillow's open() is lazy: it doesn't read the pixel data until you need it, so the file stays open. The context manager closes it, which matters especially on Windows.
  • mkdir(exist_ok=True) makes the script safe to run again. You don't get a FileExistsError on the second run.

Step 4: Handle Errors Per File

One damaged JPEG in a folder of 500 should NOT stop the whole batch. Put the code for each file in try/except (see exceptions), and count the successes and failures.

python
from pathlib import Path
from PIL import Image, UnidentifiedImageError

source = Path("photos")
destination = Path("photos_resized")
destination.mkdir(exist_ok=True)

ok, failed = 0, []

for path in source.glob("*.jpg"):
    try:
        with Image.open(path) as img:
            img.thumbnail((1200, 1200))
            img.save(destination / path.name)
        ok += 1
    except (UnidentifiedImageError, OSError) as e:
        # UnidentifiedImageError: not actually an image
        # OSError: truncated file, permission denied, disk full
        failed.append((path.name, str(e)))

print(f"{ok} resized, {len(failed)} failed")
for name, err in failed:
    print(f"  ⚠️  {name}: {err}")

Catch only the specific exceptions you can handle: UnidentifiedImageError for "not an image", and OSError for I/O problems. A bare except: would also catch KeyboardInterrupt, so Ctrl-C could not stop the script.

For a batch tool, it is better to collect the failures and report them at the end than to stop at the first error.


Step 5: Real CLI Arguments

Paths written in the code are fine while you're learning. But the tool is much more useful when you can run it on any folder. Use argparse.

python
import argparse
from pathlib import Path
from PIL import Image, UnidentifiedImageError

def parse_args():
    p = argparse.ArgumentParser(description="Batch-resize images.")
    p.add_argument("input_dir", type=Path, help="folder containing source images")
    p.add_argument("output_dir", type=Path, help="folder to write resized images")
    p.add_argument("--max-width", type=int, default=1200, help="longest side in pixels (default 1200)")
    p.add_argument("--format", choices=["jpeg", "png", "webp"], default=None,
                   help="convert all outputs to this format (default: keep original)")
    p.add_argument("--quality", type=int, default=85, help="JPEG/WebP quality 1-95 (default 85)")
    p.add_argument("--recursive", action="store_true", help="include sub-directories")
    return p.parse_args()

def find_images(root, recursive):
    pattern = "**/*" if recursive else "*"
    exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}
    for p in root.glob(pattern):
        if p.is_file() and p.suffix.lower() in exts:
            yield p

def main():
    args = parse_args()
    args.output_dir.mkdir(parents=True, exist_ok=True)

    for src in find_images(args.input_dir, args.recursive):
        try:
            with Image.open(src) as img:
                img.thumbnail((args.max_width, args.max_width))
                ext = f".{args.format}" if args.format else src.suffix
                dst = args.output_dir / (src.stem + ext)
                img.save(dst, quality=args.quality)
                print(f"  {src.name} → {dst.name}")
        except (UnidentifiedImageError, OSError) as e:
            print(f"  ⚠️  {src.name}: {e}")

# In a real script:
# if __name__ == "__main__":
#     main()

Usage:

python
python resize.py ./photos ./web --max-width 1200 --format webp --quality 80 --recursive

argparse creates --help for you, checks that the int arguments are numbers and rejects unknown flags. choices= allows only the listed formats: if you type --format webm by mistake, argparse rejects it before your code runs.


Step 6: Polished Final Version (Parallel, Idempotent, Progress)

A batch of 500 photos needs a lot of I/O and a lot of CPU time. Pillow releases the GIL during the heavy resampling work, so threads give a real speedup with very little extra code. (Processes are another option. See multiprocessing for when to use them.) Add a progress indicator and skip files that already exist, and you have a tool you will want to keep using.

python
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from PIL import Image, UnidentifiedImageError

IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}

def find_images(root, recursive):
    pattern = "**/*" if recursive else "*"
    for p in root.glob(pattern):
        if p.is_file() and p.suffix.lower() in IMAGE_EXTS:
            yield p

def resize_one(src, output_dir, max_dim, fmt, quality, skip_existing):
    ext = f".{fmt}" if fmt else src.suffix
    dst = output_dir / (src.stem + ext)

    if skip_existing and dst.exists():
        return ("skipped", src.name)

    try:
        with Image.open(src) as img:
            img.thumbnail((max_dim, max_dim))
            save_kwargs = {}
            if (fmt or src.suffix.lower()) in (".jpg", ".jpeg", "jpeg", "webp", ".webp"):
                save_kwargs["quality"] = quality
            img.save(dst, **save_kwargs)
        return ("ok", src.name)
    except (UnidentifiedImageError, OSError) as e:
        return ("failed", f"{src.name}: {e}")

def main():
    p = argparse.ArgumentParser(description="Batch-resize images in parallel.")
    p.add_argument("input_dir", type=Path)
    p.add_argument("output_dir", type=Path)
    p.add_argument("--max-width", type=int, default=1200)
    p.add_argument("--format", choices=["jpeg", "png", "webp"], default=None)
    p.add_argument("--quality", type=int, default=85)
    p.add_argument("--recursive", action="store_true")
    p.add_argument("--workers", type=int, default=8)
    p.add_argument("--force", action="store_true", help="re-resize even if output exists")
    args = p.parse_args(["photos", "photos_resized"])    # demo values; in real use: p.parse_args()

    args.input_dir = Path("photos")
    args.output_dir = Path("photos_resized")
    args.output_dir.mkdir(parents=True, exist_ok=True)

    images = list(find_images(args.input_dir, args.recursive))
    total = len(images)
    print(f"Found {total} images. Resizing with {args.workers} workers...")

    counts = {"ok": 0, "skipped": 0, "failed": 0}

    with ThreadPoolExecutor(max_workers=args.workers) as pool:
        futures = [
            pool.submit(resize_one, src, args.output_dir,
                        args.max_width, args.format, args.quality,
                        not args.force)
            for src in images
        ]
        for i, fut in enumerate(as_completed(futures), 1):
            status, msg = fut.result()
            counts[status] += 1
            print(f"\r  {i}/{total}  ok={counts['ok']} skipped={counts['skipped']} failed={counts['failed']}", end="")

    print()    # newline after the progress line
    if counts["failed"]:
        print(f"⚠️  {counts['failed']} failures. Check the log.")

# In a real script: if __name__ == "__main__": main()

This version adds three improvements:

  • Threads: ThreadPoolExecutor runs the I/O and the resampling (which releases the GIL) in parallel, so a large batch finishes much faster. For a nicer progress bar, use tqdm: pip install tqdm and wrap as_completed(futures) in tqdm(...).
  • Idempotency: because of skip_existing, if you add a few photos and run the script again, it processes only the new ones. The output folder works like a cache.
  • Format conversion: --format webp converts every image to WebP. WebP files are usually smaller than JPEG files of the same visual quality, so your pages load faster.

Stretch Goals

1. EXIF preservation: read img.info.get("exif"), then pass it as exif= to save(). Otherwise the resized copy loses the rotation, camera and GPS metadata.
2. Auto-orient before resize: from PIL import ImageOps; img = ImageOps.exif_transpose(img). Many phone photos are stored sideways, with an EXIF tag that says "rotate me 90°". If you resize without applying this rotation, and the EXIF tag is lost, the thumbnail appears sideways.
3. Watermark overlay: use ImageDraw.text() or Image.paste() to put text or a logo in the bottom-right corner before saving.
4. Smart crop for variants: from each source, create a 1200x1200 square version and a 1080x1920 portrait version, centred on the largest face (use opencv-python for face detection).
5. --replace mode: after a successful resize, delete the original. This is dangerous, so require a confirmation prompt and an extra --i-mean-it flag.


🎯 Your Turn: compute_new_size

thumbnail() does this calculation for you, but it is useful to know the maths when you write your own resizing code, plan CSS layouts or set the size of canvas elements. Given an image's original dimensions and a maximum dimension (longest side), return the new (width, height) that fits the constraint while preserving aspect ratio. Never enlarge: if the image already fits within max_dim, return the original size unchanged.

python
def compute_new_size(orig_w, orig_h, max_dim):
    """Scale (orig_w, orig_h) so the longest side equals max_dim,
    preserving aspect ratio. Never upscale. Return ints.
    """
    # TODO 1: if both sides already fit, return (orig_w, orig_h)
    # TODO 2: compute the scale factor: max_dim / longest_side
    # TODO 3: apply scale to both dimensions, round to int
    pass

# Test
assert compute_new_size(4000, 3000, 1200) == (1200, 900)
assert compute_new_size(3000, 4000, 1200) == (900, 1200)
assert compute_new_size(800, 600, 1200) == (800, 600)         # no upscale
assert compute_new_size(1200, 1200, 1200) == (1200, 1200)     # exact fit
print("all passed")
Hint 1: Which side is longest? longest = max(orig_w, orig_h). The scale factor is then max_dim / longest.
Hint 2: Don't upscale If longest <= max_dim, just return the original dimensions. Otherwise compute the scale and apply.
Show full solution
python
def compute_new_size(orig_w, orig_h, max_dim):
    longest = max(orig_w, orig_h)
    if longest <= max_dim:
        return (orig_w, orig_h)
    scale = max_dim / longest
    return (round(orig_w * scale), round(orig_h * scale))

# Verify
for w, h, m, expected in [
    (4000, 3000, 1200, (1200, 900)),
    (3000, 4000, 1200, (900, 1200)),
    (800, 600, 1200, (800, 600)),
    (1200, 1200, 1200, (1200, 1200)),
    (5000, 1000, 1000, (1000, 200)),
]:
    got = compute_new_size(w, h, m)
    assert got == expected, f"({w},{h}) max={m}: got {got}, want {expected}"
    print(f"  ({w}, {h}) max={m} → {got}")

The "never upscale" rule matters: enlarging an image makes it blurry and adds no detail. Pillow's thumbnail() follows the same rule.


What You Learned

  • Pillow basics: Image.open(), resize() vs thumbnail(), and save() with quality and format options.
  • pathlib.Path.glob() for filtering files by extension.
  • Per-file error handling: one bad file doesn't stop the batch.
  • argparse for real CLI tools with --help and type-checked arguments.
  • ThreadPoolExecutor for parallel work. It works well here because Pillow releases the GIL.
  • Idempotent jobs: skipping files that already exist makes a script safe to run again.

You can use this tool in many projects: images for blog posts, preparing ML datasets, photos uploaded by customers. The skip-if-exists pattern is also used in build systems, data pipelines and CDN caches, so you now understand one of the ideas they are built on.

Next: Rule-Based Chatbot, about pattern matching, conversation state, and the limits of a chatbot built from if statements.

Found this useful? Share it with a friend who’s learning Python.