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
- Create a folder named
imgsand put your images inside (it can have subfolders too). - Run the script.
- 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_qualitylower (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
Post a Comment