Modern web applications, e-commerce platforms, and content management systems routinely deal with thousands of images that need to be resized, recompressed
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.
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.
#!/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())
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.
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.
NotADirectoryError caught in main(), logged, and the process exits with code 2.UnidentifiedImageError; the file is logged as a warning and skipped, and processing continues.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.choices= rejects it before any work begins.ImageOps.exif_transpose(), so portrait photos don't come out sideways.RGB before saving, avoiding Pillow's "cannot write mode RGBA as JPEG" error.concurrent.futures.ProcessPoolExecutor — image resizing is CPU-bound and scales nearly linearly with cores.--width values (e.g. 400,800,1600) and emit image-400.webp, image-800.webp, etc., ready for <picture> / srcset.dst write with a boto3 upload or fsspec filesystem abstraction, keeping the same walk-and-transform logic.