Python批量压缩图片:支持JPG、PNG、WebP、尺寸限制与CSV报告
网站图片太大、相册上传太慢、项目素材占满磁盘时,逐张打开图片软件修改尺寸和质量非常浪费时间。
本文用 Python 写一个可以直接复用的批量图片压缩工具。给它一个文件夹,它会递归查找 JPG、PNG 和 WebP,限制图片最长边、调整 JPEG/WebP 质量、保留原有目录结构,并生成一份 CSV 报告。完整代码就在正文中,读者不需要访问作者电脑上的任何文件。
它还专门处理了几个常见但容易被忽略的问题:手机照片的 EXIF 方向、透明 PNG 转 JPEG、输出目录被重复扫描、文件名冲突,以及"压缩后反而更大"。
一、先看运行方式
项目只依赖 Pillow。建议使用 Python 3.10 或更高版本,并在虚拟环境中安装依赖:
bash
python -m venv .venv
Windows 激活虚拟环境:
powershell
.venv\Scripts\activate
macOS或Linux:
bash
source .venv/bin/activate
安装 Pillow:
bash
python -m pip install "Pillow>=10,<13"
最简单的使用方式:
bash
python image_compressor.py ./photos
程序默认把结果写入 photos/compressed_output,原图不动。也可以明确指定参数:
bash
python image_compressor.py ./photos \
--output ./output \
--quality 80 \
--max-side 1600
把全部图片转换成 WebP:
bash
python image_compressor.py ./photos --format webp --quality 82
二、一次真实执行会得到什么
我用两张程序生成的测试图片跑了一次完整流程:一张2400×1600的噪声JPEG和一张1200×1600的半透明PNG,最长边限制为1200像素。
text
发现 2 张图片,完成 2,跳过 0,失败 0
合计节省 3655.1 KiB
报告:.../compressed_output/compression_report.csv
测试样本结果如下:
| 文件 | 原尺寸 | 输出尺寸 | 原体积 | 输出体积 | 本次测试节省 |
|---|---|---|---|---|---|
| photo.jpg | 2400×1600 | 1200×800 | 4,147,157 B | 409,647 B | 90.12% |
| poster.png | 1200×1600 | 900×1200 | 11,293 B | 5,931 B | 47.48% |
这些数字只描述这两张合成测试图,不能当作真实照片的固定压缩率。最终结果取决于原图编码、内容复杂度、目标尺寸、输出格式和质量参数。已经高度优化的图片甚至可能无法继续缩小。
三、项目结构
text
032-image-compressor/
├── image_compressor.py
├── requirements.txt
└── test_image_compressor.py
正文先解释关键设计,随后给出完整可运行代码。
四、为什么不能只调用一次save
最短的图片压缩代码可能只有两行:
python
from PIL import Image
Image.open("input.jpg").save("output.jpg", quality=80)
它可以处理一张普通图片,却没有解决批量工具真正需要面对的问题:
- 子目录中的图片如何处理;
- 输出目录位于输入目录中时,如何避免再次扫描;
- 手机照片为什么输出后方向不对;
- 透明图片为什么不能直接保存为 JPEG;
- 压缩失败后是否会留下半个文件;
- 输出已存在时覆盖还是跳过;
- 到底节省了多少空间。
因此,这篇文章的目标不是"调用 Pillow",而是完成一条可重复、可检查的文件处理流程。
五、递归扫描,但排除输出目录
python
SUPPORTED = {".jpg", ".jpeg", ".png", ".webp"}
def iter_images(input_dir: Path, output_dir: Path, recursive: bool):
iterator = input_dir.rglob("*") if recursive else input_dir.glob("*")
output_resolved = output_dir.resolve()
for path in iterator:
if not path.is_file() or path.suffix.lower() not in SUPPORTED:
continue
try:
path.resolve().relative_to(output_resolved)
continue
except ValueError:
yield path
默认输出目录在输入目录内部。如果不排除它,第二次运行时可能把上一次的输出继续当作输入。这里使用 Path.relative_to() 判断候选文件是否位于输出目录中,而不是依赖字符串前缀。
六、限制最长边,同时保持宽高比
python
def resize_image(image: Image.Image, max_side: int) -> Image.Image:
if max_side <= 0 or max(image.size) <= max_side:
return image
resized = image.copy()
resized.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
return resized
thumbnail()会在给定边界内保持宽高比。--max-side 1600表示横图宽度不会超过1600像素,竖图高度不会超过1600像素。参数设为0则不缩放,只重新编码。
需要注意:缩小尺寸通常比单纯降低质量更有效,但会丢失像素。原图是否需要保留,取决于后续是否还要打印、裁切或二次编辑。
七、先处理EXIF方向,再决定是否保留元数据
部分手机照片的像素本身是横着存储的,查看器依赖 EXIF Orientation 自动旋转。如果直接丢弃EXIF,图片可能突然横过来。
python
image = ImageOps.exif_transpose(opened)
source_info = dict(image.info)
ImageOps.exif_transpose()会把方向真正应用到像素,并移除旧方向标记。这里必须在它执行后再读取元数据;如果把旧 Orientation 原样写回,某些软件会再次旋转图片。
工具默认不保留 EXIF,以减少隐私泄露和额外体积。确实需要保留EXIF与ICC色彩配置时,可以使用:
bash
python image_compressor.py ./photos --keep-metadata
"尽量保留"不等于所有格式都能完整保存所有元数据,不同编码器支持范围不同。重要素材应先在副本上测试。
八、透明图片转JPEG时必须处理Alpha通道
JPEG不支持透明通道。把RGBA图片直接保存为JPEG通常会报错,因此转换时需要明确选择背景色:
python
def prepare_mode(image: Image.Image, fmt: str) -> Image.Image:
if fmt == "JPEG" and image.mode not in {"RGB", "L"}:
if "A" in image.getbands():
background = Image.new("RGB", image.size, "white")
background.paste(image, mask=image.getchannel("A"))
return background
return image.convert("RGB")
return image
本文使用白色背景。如果你的网页是深色主题,可以改成黑色或指定品牌背景色。需要保留透明度时,应继续使用PNG或WebP。
九、先写临时文件,再原子替换
如果程序在写文件途中中断,直接写目标路径可能留下损坏文件。更稳妥的方式是先写到同一目录的临时文件,成功后再替换:
python
temp = destination.with_name(destination.name + ".tmp")
try:
image.save(temp, format=fmt, **options)
os.replace(temp, destination)
finally:
temp.unlink(missing_ok=True)
因为临时文件与目标文件位于同一文件系统,os.replace()可以避免目标文件长时间处于半写入状态。
十、压缩后更大怎么办
PNG、WebP和JPEG的原文件可能已经优化过。重新编码不一定变小。因此工具在"保持原格式、尺寸没有变化"的情况下比较前后体积:
python
if target == "keep" and original_size == output_size and output_bytes >= original_bytes:
shutil.copy2(source, destination)
output_bytes = original_bytes
status = "copied_original"
如果新文件没有更小,就把原文件复制到输出目录。这样批量任务仍保留完整目录结构,同时不会因为所谓"压缩"白白增加体积。
主动转换格式或缩放时不采用这条规则,因为用户明确要求了格式或尺寸变化,不能偷偷用原图替代目标结果。
十一、完整可运行代码
将下面代码保存为 image_compressor.py:
python
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import os
import shutil
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
from PIL import Image, ImageOps, UnidentifiedImageError
SUPPORTED = {".jpg", ".jpeg", ".png", ".webp"}
EXTENSIONS = {"keep": None, "jpeg": ".jpg", "png": ".png", "webp": ".webp"}
@dataclass
class Result:
source: str
output: str
status: str
original_bytes: int
output_bytes: int
saved_bytes: int
saved_percent: float
original_size: str
output_size: str
message: str = ""
def iter_images(input_dir: Path, output_dir: Path, recursive: bool) -> Iterable[Path]:
iterator = input_dir.rglob("*") if recursive else input_dir.glob("*")
output_resolved = output_dir.resolve()
for path in iterator:
if not path.is_file() or path.suffix.lower() not in SUPPORTED:
continue
try:
path.resolve().relative_to(output_resolved)
continue
except ValueError:
yield path
def output_path_for(source: Path, input_dir: Path, output_dir: Path, target: str) -> Path:
relative = source.relative_to(input_dir)
suffix = source.suffix.lower() if target == "keep" else EXTENSIONS[target]
return (output_dir / relative).with_suffix(suffix)
def resize_image(image: Image.Image, max_side: int) -> Image.Image:
if max_side <= 0 or max(image.size) <= max_side:
return image
resized = image.copy()
resized.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
return resized
def prepare_mode(image: Image.Image, fmt: str) -> Image.Image:
if fmt == "JPEG" and image.mode not in {"RGB", "L"}:
if "A" in image.getbands():
background = Image.new("RGB", image.size, "white")
background.paste(image, mask=image.getchannel("A"))
return background
return image.convert("RGB")
return image
def save_image(image, destination, fmt, quality, keep_metadata, source_info):
options = {}
if fmt == "JPEG":
options.update(quality=quality, optimize=True, progressive=True)
elif fmt == "WEBP":
options.update(quality=quality, method=6)
elif fmt == "PNG":
options.update(optimize=True, compress_level=9)
if keep_metadata:
for key in ("exif", "icc_profile"):
if source_info.get(key):
options[key] = source_info[key]
destination.parent.mkdir(parents=True, exist_ok=True)
temp = destination.with_name(destination.name + ".tmp")
try:
image.save(temp, format=fmt, **options)
os.replace(temp, destination)
finally:
temp.unlink(missing_ok=True)
def compress_one(source, input_dir, output_dir, target, quality, max_side,
keep_metadata, overwrite):
destination = output_path_for(source, input_dir, output_dir, target)
original_bytes = source.stat().st_size
if destination.exists() and not overwrite:
return Result(str(source), str(destination), "skipped", original_bytes,
destination.stat().st_size, 0, 0.0, "", "", "输出已存在")
try:
with Image.open(source) as opened:
opened.load()
original_size = f"{opened.width}x{opened.height}"
image = ImageOps.exif_transpose(opened)
source_info = dict(image.info)
image = resize_image(image, max_side)
fmt = opened.format if target == "keep" else target.upper()
if fmt == "JPG":
fmt = "JPEG"
image = prepare_mode(image, fmt)
save_image(image, destination, fmt, quality, keep_metadata, source_info)
output_size = f"{image.width}x{image.height}"
except (UnidentifiedImageError, OSError, ValueError) as error:
return Result(str(source), str(destination), "failed", original_bytes,
0, 0, 0.0, "", "", str(error))
output_bytes = destination.stat().st_size
if target == "keep" and original_size == output_size and output_bytes >= original_bytes:
shutil.copy2(source, destination)
output_bytes = original_bytes
status = "copied_original"
else:
status = "compressed"
saved = original_bytes - output_bytes
percent = (saved / original_bytes * 100) if original_bytes else 0.0
return Result(str(source), str(destination), status, original_bytes,
output_bytes, saved, round(percent, 2), original_size, output_size)
def write_report(results: list[Result], report: Path) -> None:
report.parent.mkdir(parents=True, exist_ok=True)
with report.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(file, fieldnames=list(Result.__dataclass_fields__))
writer.writeheader()
writer.writerows(asdict(item) for item in results)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="批量压缩 JPG、PNG 和 WebP 图片")
parser.add_argument("input", type=Path, help="输入目录")
parser.add_argument("-o", "--output", type=Path,
help="输出目录,默认是输入目录下的 compressed_output")
parser.add_argument("--format", choices=EXTENSIONS, default="keep",
help="输出格式,默认保持原格式")
parser.add_argument("--quality", type=int, default=82,
help="JPEG/WebP质量,1-95,默认82")
parser.add_argument("--max-side", type=int, default=1920,
help="最长边像素,0表示不缩放,默认1920")
parser.add_argument("--no-recursive", action="store_true",
help="只处理输入目录第一层")
parser.add_argument("--keep-metadata", action="store_true",
help="尽量保留EXIF和ICC配置")
parser.add_argument("--overwrite", action="store_true",
help="覆盖已存在的输出文件")
return parser
def main() -> int:
args = build_parser().parse_args()
input_dir = args.input.expanduser().resolve()
if not input_dir.is_dir():
raise SystemExit(f"输入目录不存在:{input_dir}")
if not 1 <= args.quality <= 95:
raise SystemExit("--quality 必须在1到95之间")
if args.max_side < 0:
raise SystemExit("--max-side 不能小于0")
output_dir = (args.output or input_dir / "compressed_output").expanduser().resolve()
files = list(iter_images(input_dir, output_dir, not args.no_recursive))
results = [
compress_one(path, input_dir, output_dir, args.format, args.quality,
args.max_side, args.keep_metadata, args.overwrite)
for path in files
]
report = output_dir / "compression_report.csv"
write_report(results, report)
completed = [r for r in results if r.status in {"compressed", "copied_original"}]
failed = [r for r in results if r.status == "failed"]
saved = sum(r.saved_bytes for r in completed)
print(f"发现 {len(files)} 张图片,完成 {len(completed)},"
f"跳过 {len(results)-len(completed)-len(failed)},失败 {len(failed)}")
print(f"合计节省 {saved / 1024:.1f} KiB")
print(f"报告:{report}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
十二、参数怎么选
| 场景 | 建议参数 | 说明 |
|---|---|---|
| 网站普通配图 | --quality 80 --max-side 1600 |
兼顾加载速度和观感 |
| 文章截图 | 保持PNG,限制尺寸 | 截图中文字使用JPEG可能出现噪点 |
| 相册预览图 | --format webp --quality 82 |
浏览器展示场景可尝试WebP |
| 只重新编码 | --max-side 0 |
不改变像素尺寸 |
| 需要打印或归档 | 谨慎压缩 | 建议保留原图并另建输出目录 |
这些只是起始参数,不是统一标准。重要图片应随机抽样查看天空渐变、发丝、文字边缘、透明区域和高对比细节。
十三、自动化测试
配套项目使用 unittest 覆盖了5项行为:
text
EXIF方向被正确应用,旧方向标记不会复用
递归扫描排除输出目录
缩放后保持目录结构并生成WebP
CSV报告可以正常读取
同格式且不缩放时,输出不会比原文件更大
运行:
bash
python -m unittest -v
本次在 Pillow 12.3.0 环境中实际执行,5项测试全部通过。requirements.txt允许Pillow 10至12系列,但"允许安装"不等于每个历史小版本都在本文环境中逐一验证。
十四、工具没有解决什么
这个版本不处理SVG、HEIC、RAW和动画GIF;没有并行压缩,大规模素材库处理速度有限;没有感知质量评价,也不能自动判断一张图片在视觉上是否"足够清晰"。
默认移除元数据有利于隐私和体积,但可能丢失拍摄时间、设备信息和地理位置。真正的照片归档系统不应直接用压缩输出替代原始文件。
另外,PNG是无损格式,quality参数不适用于PNG。代码对PNG使用 optimize=True 和较高压缩级别;JPEG/WebP才使用质量参数。
总结
一个真正能批量使用的图片压缩工具,重点不只是 Image.save(),而是把整个处理链条闭合:
text
扫描输入
→ 排除输出目录
→ 应用EXIF方向
→ 按比例缩放
→ 处理颜色与透明通道
→ 临时文件安全写入
→ 比较前后体积
→ 生成CSV报告
本文代码可以直接用于个人素材整理、博客配图预处理和测试环境。如果要进入团队生产流程,下一步应增加并发限制、任务恢复、图片内容抽检和更严格的元数据策略。
参考资料
- Pillow官方文档,Image模块:https://pillow.readthedocs.io/en/stable/reference/Image.html
- Pillow官方文档,ImageOps.exif_transpose:https://pillow.readthedocs.io/en/stable/reference/ImageOps.html#PIL.ImageOps.exif_transpose
- Pillow官方手册,图像文件格式:https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html
- Python官方文档,argparse:https://docs.python.org/3/library/argparse.html
- Python官方文档,pathlib:https://docs.python.org/3/library/pathlib.html
- Python官方文档,csv:https://docs.python.org/3/library/csv.html