Compress a Folder of Images with Python

Compress a Whole Folder of Images with Python (Resize + Subfolders + Smart PNG→JPEG)

If you keep images for a blog, documentation, or a project folder, file size becomes a problem fast. Big images slow down pages, take space, and make sharing annoying. This script automates the cleanup: it walks through a folder (and subfolders), optionally resizes images to a max width, compresses JPEGs, compresses PNGs, and can convert PNG to JPEG when it actually makes sense.

When this is useful

  • Before publishing: shrink images for faster blog load times.
  • Docs + screenshots: reduce size without manually exporting every file.
  • Photo folders: compress camera images and keep a clean “compressed” copy.
  • Automation: run it after exporting images from tools like Snipping Tool, Photoshop, or PowerPoint.

What it does

  • Walk subfolders automatically and keeps the same folder structure in the output.
  • Resize to a max width (keeps aspect ratio, never upscales).
  • Compress JPEG using a quality setting (smaller file, some quality loss).
  • Compress PNG (lossless optimization).
  • Convert PNG→JPEG when it makes sense (photo-like PNGs without transparency).

Install

pip install pillow

Script

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional, Tuple

from PIL import Image, ImageOps


@dataclass(frozen=True)
class CompressOptions:
    """
    1) Convert PNG -> JPEG when it makes sense (usually photos without transparency)
    2) Resize images to a max width (preserves aspect ratio)
    3) Walk subfolders automatically (recursive)
    """
    max_width: int = 1600
    jpeg_quality: int = 75
    png_compress_level: int = 9
    recurse: bool = True

    convert_png_to_jpeg: bool = True
    min_savings_to_convert: float = 0.25

    extensions: Tuple[str, ...] = (".jpg", ".jpeg", ".png")


def iter_images(root: Path, recurse: bool, extensions: Tuple[str, ...]) -> Iterable[Path]:
    """Yield image files under root, optionally recursively."""
    exts = {e.lower() for e in extensions}
    pattern = "**/*" if recurse else "*"
    for p in root.glob(pattern):
        if p.is_file() and p.suffix.lower() in exts:
            yield p


def has_alpha(img: Image.Image) -> bool:
    """True if image has transparency (alpha channel)."""
    if img.mode in ("RGBA", "LA"):
        return True
    if img.mode == "P":
        return "transparency" in img.info
    return False


def is_probably_photo(img: Image.Image, sample_step: int = 8, unique_threshold: int = 64) -> bool:
    """
    Quick heuristic:
    photos usually have more color variation than screenshots/logos.
    """
    rgb = img.convert("RGB")
    w, h = rgb.size
    pixels = rgb.load()
    unique = set()

    for y in range(0, h, sample_step):
        for x in range(0, w, sample_step):
            unique.add(pixels[x, y])
            if len(unique) >= unique_threshold:
                return True
    return False


def resize_if_needed(img: Image.Image, max_width: int) -> Image.Image:
    """Resize down to max_width if needed (keeps aspect ratio)."""
    w, h = img.size
    if max_width and w > max_width:
        new_h = int(h * (max_width / w))
        return img.resize((max_width, new_h), Image.Resampling.LANCZOS)
    return img


def ensure_rgb_for_jpeg(img: Image.Image) -> Image.Image:
    """JPEG doesn't support alpha; if transparent, composite onto white."""
    if has_alpha(img):
        bg = Image.new("RGB", img.size, (255, 255, 255))
        rgba = img.convert("RGBA")
        bg.paste(rgba, mask=rgba.split()[-1])
        return bg
    return img.convert("RGB")


def estimate_png_to_jpeg_savings(img: Image.Image, opts: CompressOptions) -> float:
    """Heuristic estimate of savings from PNG->JPEG (no transparency + photo-like)."""
    if not opts.convert_png_to_jpeg:
        return 0.0
    if has_alpha(img):
        return 0.0
    return 0.40 if is_probably_photo(img) else 0.0


def relative_output_path(in_file: Path, in_root: Path, out_root: Path, new_suffix: Optional[str] = None) -> Path:
    """Keep folder structure under output root, optionally changing suffix."""
    rel = in_file.relative_to(in_root)
    out = out_root / rel
    return out.with_suffix(new_suffix or out.suffix)


def compress_folder(input_folder: str, output_folder: str = "compressed", opts: CompressOptions = CompressOptions()) -> None:
    """
    Compress images under input_folder and write to output_folder.
    Keeps originals untouched and preserves folder structure.
    """
    in_root = Path(input_folder)
    out_root = Path(output_folder)

    if not in_root.exists() or not in_root.is_dir():
        print(f"Folder not found: {in_root.resolve()}")
        return

    out_root.mkdir(parents=True, exist_ok=True)

    for in_file in iter_images(in_root, opts.recurse, opts.extensions):
        try:
            with Image.open(in_file) as img:
                # Fix rotation from phone EXIF data when present
                img = ImageOps.exif_transpose(img)

                original_bytes = in_file.stat().st_size

                # Resize if needed
                img = resize_if_needed(img, opts.max_width)

                suffix = in_file.suffix.lower()
                out_suffix = suffix
                do_png_to_jpeg = False

                # Decide PNG->JPEG conversion
                if suffix == ".png" and opts.convert_png_to_jpeg:
                    savings_est = estimate_png_to_jpeg_savings(img, opts)
                    if savings_est >= opts.min_savings_to_convert:
                        do_png_to_jpeg = True
                        out_suffix = ".jpg"

                out_path = relative_output_path(in_file, in_root, out_root, new_suffix=out_suffix)
                out_path.parent.mkdir(parents=True, exist_ok=True)

                exif_data = img.info.get("exif")

                if out_suffix in (".jpg", ".jpeg"):
                    rgb = ensure_rgb_for_jpeg(img)
                    rgb.save(
                        out_path,
                        format="JPEG",
                        optimize=True,
                        quality=opts.jpeg_quality,
                        progressive=True,
                        exif=exif_data,
                    )
                else:
                    img.save(
                        out_path,
                        format="PNG",
                        optimize=True,
                        compress_level=opts.png_compress_level,
                    )

                new_bytes = out_path.stat().st_size
                saved_pct = (1 - (new_bytes / original_bytes)) * 100 if original_bytes else 0

                conv_note = " (PNG->JPEG)" if do_png_to_jpeg else ""
                print(
                    f"{in_file.relative_to(in_root)} -> {out_path.relative_to(out_root)}"
                    f"{conv_note}: {original_bytes/1024:.1f} KB -> {new_bytes/1024:.1f} KB "
                    f"({saved_pct:.1f}% smaller)"
                )

        except Exception as e:
            print(f"Skipped {in_file} (error: {e})")


if __name__ == "__main__":
    compress_folder(
        input_folder="imgs/",
        output_folder="compressed/",
        opts=CompressOptions(
            max_width=1600,
            jpeg_quality=75,
            png_compress_level=9,
            recurse=True,
            convert_png_to_jpeg=True,
            min_savings_to_convert=0.25,
        ),
    )

How to run it

  1. Create a folder named imgs and put your images inside (it can have subfolders too).
  2. Run the script.
  3. Get the output in compressed/ with the same folder structure.

Quick settings that matter

  • Change max size: set max_width (example: 1200 for web posts).
  • More JPEG compression: set jpeg_quality lower (example: 65).
  • Keep originals untouched: this script already writes to compressed/ instead of overwriting.
  • Stop PNG→JPEG conversion: set convert_png_to_jpeg=False.

Comments