DevLab / utilities / batch-image-resizer-and-optimizer
UTILITIES PYTHON BEGINNER

Batch Image Resizer and Optimizer

Modern web applications, e-commerce platforms, and content management systems routinely deal with thousands of images that need to be resized, recompressed

Python3.10+
OSLinux / macOS / Windows
Lines~177
Packages1 deps
Install Dependencies
$ pip install Pillow
Usage
$ python3 img_resize.py --input-dir photos/ --width 1920 --quality 85 --format webp --out-dir web/
#utilities#python#beginner#Pillow

Batch Image Resizer and Optimizer

Problem Statement

Modern web applications, e-commerce platforms, and content management systems routinely deal with thousands of images that need to be resized, recompressed, and converted to modern formats before deployment. Uploading a 5 MB camera JPEG to a product page wastes bandwidth, slows page loads, and hurts SEO rankings. Doing this manually in Photoshop or GIMP for a folder of 500 photos is impractical.

This script solves that by providing a single-command batch pipeline: point it at a directory of images, and it walks the tree, resizes each image to a target width (preserving aspect ratio), converts it to a modern format like WebP or AVIF, and writes the optimized output to a mirrored output directory. It's the kind of tool a DevOps engineer drops into a CI/CD pipeline or a sysadmin uses to prepare a static site's assets.

How It Works

The script follows a simple ETL (Extract, Transform, Load) pattern. Extract: it walks the input directory recursively using pathlib.Path.rglob(), collecting every file whose extension matches a known image format. Transform: for each image, it opens it with Pillow, computes the new dimensions using the aspect ratio, applies Image.Resampling.LANCZOS (the highest-quality downsampling filter), and re-encodes it to the target format with the specified quality. Load: it writes the result to a mirrored path under the output directory, creating parent directories on demand.

Key design decisions: (1) output paths mirror input paths, so relative structure is preserved — critical when images are referenced by relative URL; (2) ImageOps.exif_transpose() is applied to correct orientation metadata, preventing sideways photos after resizing; (3) the original is never modified — the script is idempotent and safe to re-run; (4) per-file errors are caught and logged without aborting the whole batch, because in a 10,000-image job, one corrupt file shouldn't kill the run.

Full Script

#!/usr/bin/env python3
"""
Batch Image Resizer and Optimizer

Recursively walks an input directory, resizes every image to a target width
(preserving aspect ratio), converts it to a modern format (webp/jpeg/png/avif),
and writes the result to a mirrored output directory.

Usage:
    python3 img_resize.py --input-dir photos/ --width 1920 --quality 85 \
        --format webp --out-dir web/
"""

from __future__ import annotations

import argparse
import logging
import os
import sys
from pathlib import Path

from PIL import Image, ImageOps, UnidentifiedImageError

# ---------------------------------------------------------------------------
# Configuration (overridable via environment variables)
# ---------------------------------------------------------------------------
DEFAULT_WIDTH = int(os.environ.get("IMG_RESIZE_WIDTH", "1920"))
DEFAULT_QUALITY = int(os.environ.get("IMG_RESIZE_QUALITY", "85"))
DEFAULT_FORMAT = os.environ.get("IMG_RESIZE_FORMAT", "webp").lower()
DEFAULT_OUT_DIR = os.environ.get("IMG_RESIZE_OUT_DIR", "optimized")
DEFAULT_SKIP_LARGER = os.environ.get("IMG_RESIZE_SKIP_LARGER", "true").lower() == "true"

# Extensions we consider "input images". Pillow sniffs actual content anyway.
SUPPORTED_EXTENSIONS = {
    ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".gif",
}

# Pillow format name -> file extension used for output.
FORMAT_EXTENSIONS = {
    "webp": ".webp",
    "jpeg": ".jpg",
    "jpg": ".jpg",
    "png": ".png",
    "avif": ".avif",
}

# Pillow save() kwargs specific to each format.
FORMAT_SAVE_OPTS: dict[str, dict] = {
    "webp": {"quality": None, "method": 6},          # quality injected later
    "jpeg": {"quality": None, "optimize": True, "progressive": True},
    "jpg":  {"quality": None, "optimize": True, "progressive": True},
    "png":  {"optimize": True},                       # PNG ignores quality
    "avif": {"quality": None},
}


def configure_logging(verbose: bool) -> None:
    """Set up root logger with a clean, timestamped format."""
    logging.basicConfig(
        level=logging.DEBUG if verbose else logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%H:%M:%S",
    )


def collect_images(input_dir: Path) -> list[Path]:
    """Return a sorted list of image files found recursively under input_dir."""
    if not input_dir.is_dir():
        raise NotADirectoryError(f"Input directory does not exist: {input_dir}")

    images = [
        p for p in input_dir.rglob("*")
        if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
    ]
    images.sort()
    return images


def compute_target_size(
    original: tuple[int, int], target_width: int, skip_larger: bool
) -> tuple[int, int]:
    """
    Return (width, height) scaled to target_width, preserving aspect ratio.

    If skip_larger is True and the original is already narrower than
    target_width, the original dimensions are returned unchanged.
    """
    orig_w, orig_h = original
    if skip_larger and orig_w <= target_width:
        return orig_w, orig_h

    ratio = target_width / orig_w
    new_h = max(1, round(orig_h * ratio))
    return target_width, new_h


def build_output_path(
    src: Path, input_dir: Path, out_dir: Path, new_ext: str
) -> Path:
    """Mirror the input tree under out_dir, swapping in the new extension."""
    rel = src.relative_to(input_dir)
    return out_dir / rel.with_suffix(new_ext)


def process_image(
    src: Path,
    dst: Path,
    target_width: int,
    quality: int,
    out_format: str,
    skip_larger: bool,
) -> bool:
    """
    Resize + re-encode a single image. Returns True on success, False on skip.

    Raises the underlying Pillow exception on genuine failure so the caller
    can decide whether to continue.
    """
    dst.parent.mkdir(parents=True, exist_ok=True)

    with Image.open(src) as im:
        # Correct orientation from EXIF before any resize math.
        im = ImageOps.exif_transpose(im)

        # Convert to RGB(A) for formats that don't support palette/CMYK.
        if out_format in {"jpeg", "jpg"} and im.mode in {"RGBA", "P", "LA"}:
            im = im.convert("RGB")
        elif out_format == "webp" and im.mode == "P":
            im = im.convert("RGBA")

        new_size = compute_target_size(im.size, target_width, skip_larger)
        if new_size != im.size:
            im = im.resize(new_size, Image.Resampling.LANCZOS)

        save_opts = dict(FORMAT_SAVE_OPTS.get(out_format, {}))
        if save_opts.get("quality") is None:
            save_opts.pop("quality", None)
            if out_format != "png":
                save_opts["quality"] = quality

        # Pillow expects "JPEG" not "JPG".
        pillow_format = "JPEG" if out_format == "jpg" else out_format.upper()
        im.save(dst, format=pillow_format, **save_opts)

    return True


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Batch resize and optimize images.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--input-dir", required=True, type=Path,
                        help="Directory containing source images.")
    parser.add_argument("--out-dir", type=Path, default=Path(DEFAULT_OUT_DIR),
                        help="Directory to write optimized images to.")
    parser.add_argument("--width", type=int, default=DEFAULT_WIDTH,
                        help="Target maximum width in pixels.")
    parser.add_argument("--quality", type=int, default=DEFAULT_QUALITY,
                        help="Encoder quality (1-100). Ignored for PNG.")
    parser.add_argument("--format", default=DEFAULT_FORMAT,
                        choices=sorted(FORMAT_EXTENSIONS.keys()),
                        help="Output image format.")
    parser.add_argument("--skip-larger", action="store_true",
                        default=DEFAULT_SKIP_LARGER,
                        help="Do not upscale images smaller than --width.")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Enable debug logging.")
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    configure_logging(args.verbose)
    log = logging.getLogger("img_resize")

    out_format = args.format.lower()
    new_ext = FORMAT_EXTENSIONS[out_format]

    try:
        images = collect_images(args.input_dir)
    except NotADirectoryError as exc:
        log.error("%s", exc)
        return 2

    if not images:
        log.warning("No supported images found in %s", args.input_dir)
        return 0

    log.info("Found %d image(s). Writing to %s as %s.",
             len(images), args.out_dir, out_format)

    succeeded = failed = 0
    for src in images:
        dst = build_output_path(src, args.input_dir, args.out_dir, new_ext)
        try:
            process_image(
                src, dst,
                target_width=args.width,
                quality=args.quality,
                out_format=out_format,
                skip_larger=args.skip_larger,
            )
            succeeded += 1
            log.debug("OK  %s -> %s", src, dst)
        except UnidentifiedImageError:
            failed += 1
            log.warning("Skipping unreadable image: %s", src)
        except (OSError, ValueError) as exc:
            failed += 1
            log.error("Failed to process %s: %s", src, exc)

    log.info("Done. %d succeeded, %d failed.", succeeded, failed)
    return 0 if failed == 0 else 1


if __name__ == "__main__":
    sys.exit(main())

Configuration

The script can be configured via CLI flags or environment variables (flags take precedence). Env vars are useful when embedding the script in a Docker image or CI pipeline where the command line is fixed.

Setting Env Var Default Notes
Target width IMG_RESIZE_WIDTH 1920 Pixels. Height scales proportionally.
Quality IMG_RESIZE_QUALITY 85 1–100. Ignored for PNG. 80–90 is the sweet spot for WebP/JPEG.
Output format IMG_RESIZE_FORMAT webp One of webp, jpeg, jpg, png, avif.
Output directory IMG_RESIZE_OUT_DIR optimized Created automatically if missing.
Skip upscaling IMG_RESIZE_SKIP_LARGER true Set to false to upscale small images.

Note: AVIF support requires Pillow ≥ 11.0 with pillow-avif-plugin or a recent libavif-enabled build. If AVIF fails, the error is logged per-file and the batch continues.

Usage Examples

1. Convert a photo library to WebP for a static site:

$ python3 img_resize.py --input-dir photos/ --width 1920 --quality 85 \
    --format webp --out-dir web/
15:42:01 [INFO] Found 247 image(s). Writing to web/ as webp.
15:42:14 [INFO] Done. 247 succeeded, 0 failed.

2. Generate thumbnails, skipping images already smaller than the target:

$ python3 img_resize.py --input-dir uploads/ --width 400 --quality 80 \
    --format jpeg --out-dir thumbs/ --skip-larger
15:43:02 [INFO] Found 312 image(s). Writing to thumbs/ as jpeg.
15:43:09 [INFO] Done. 312 succeeded, 0 failed.

3. Use environment variables in a CI job (no flags):

$ IMG_RESIZE_WIDTH=1280 IMG_RESIZE_FORMAT=webp IMG_RESIZE_QUALITY=75 \
    python3 img_resize.py --input-dir src/assets --out-dir dist/assets
15:44:11 [INFO] Found 89 image(s). Writing to dist/assets as webp.
15:44:15 [INFO] Done. 89 succeeded, 0 failed.

Error Handling

  • Input directory missing → NotADirectoryError caught in main(), logged, and the process exits with code 2.
  • Corrupt or non-image file with an image extension → Pillow raises UnidentifiedImageError; the file is logged as a warning and skipped, and processing continues.
  • Unwritable output path / disk full → OSError is caught per-file, logged, and the loop continues. The final exit code is 1 if any file failed, so CI pipelines can detect partial failures.
  • Unsupported output format → argparse's choices= rejects it before any work begins.
  • EXIF orientation → handled automatically via ImageOps.exif_transpose(), so portrait photos don't come out sideways.
  • Mode mismatches (e.g. saving a PNG with alpha as JPEG) → the image is converted to RGB before saving, avoiding Pillow's "cannot write mode RGBA as JPEG" error.

Extending Ideas

  • Parallelism: wrap the per-file loop in concurrent.futures.ProcessPoolExecutor — image resizing is CPU-bound and scales nearly linearly with cores.
  • Manifest / diff mode: emit a JSON manifest mapping input paths to output paths and byte savings, or skip files whose output is newer than the input for incremental builds.
  • Responsive image sets: accept multiple --width values (e.g. 400,800,1600) and emit image-400.webp, image-800.webp, etc., ready for <picture> / srcset.
  • S3 / cloud upload: replace the local dst write with a boto3 upload or fsspec filesystem abstraction, keeping the same walk-and-transform logic.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com