为什么需要批量压缩图片
当你手头有 1000 张从相机导出的高清照片,每张 8MB,整个目录占用近 8GB 空间。如果只是上传到网站、塞进 PPT 或通过邮件发送,这个体积完全不可接受。逐张用图像编辑软件另存为既低效又容易出错——这正是脚本化批量处理的用武之地。
Python 的 Pillow 库是处理图像的事实标准。它提供了对 JPEG、PNG、WebP、BMP、TIFF 等主流格式的读写支持,以及对质量因子、采样率、色彩模式的细粒度控制。通过几十行 Python 代码,你可以一次性遍历整个目录树,按照统一的压缩策略输出,并保持原有的目录结构不变。
本文将从 Pillow 的基础用法讲起,逐步构建出一个完整、可运行、支持多线程的批量压缩脚本,并覆盖 EXIF 保留、格式转换、智能参数选择等实际工程中会遇到的细节。
Pillow 库安装与基础用法
安装
Pillow 是 PIL(Python Imaging Library)的活跃维护分支,但导入名仍是 PIL。用 pip 安装即可:
pip install Pillow
如果需要处理大量图片或追求更快的解码速度,可以安装带有插件支持的完整版:
pip install "Pillow[all]"
安装完成后,验证版本:
from PIL import Image, __version__
print(__version__) # 例如 10.4.0
基础操作
Pillow 的核心入口是 Image 类。最常见的三个操作是打开、修改、保存:
from PIL import Image
# 打开图像(懒加载,像素数据在首次访问时读取)
img = Image.open("photo.jpg")
print(img.format, img.size, img.mode) # JPEG (4000, 3000) RGB
# 获取像素值
pixel = img.getpixel((100, 100))
# 修改尺寸
resized = img.resize((1920, 1080))
# 保存
resized.save("photo_small.jpg", quality=85)
有几个细节值得注意:Image.open() 返回的对象持有一个文件引用,直到显式关闭或退出 with 块后才释放。在批量处理中,务必用上下文管理器或显式 close(),否则文件句柄会泄漏。
单张图片压缩代码示例
批量压缩的基础是单张图片的处理。我们先实现一个函数,支持 JPEG 质量调整、分辨率缩减和格式转换三种操作。
JPEG 质量调整
JPEG 的 quality 参数取值 1-100,直接控制量化表的缩放比例,是调节体积与画质的核心旋钮:
from PIL import Image
def compress_jpeg(input_path, output_path, quality=75):
"""以指定质量因子压缩 JPEG"""
with Image.open(input_path) as img:
# JPEG 不支持透明通道,RGBA/P 模式需先转换
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
elif img.mode != "RGB":
img = img.convert("RGB")
img.save(
output_path,
format="JPEG",
quality=quality,
optimize=True, # 启用 Huffman 编码优化
progressive=True, # 生成渐进式 JPEG,略增体积但加载体验更好
)
# 对比不同质量因子
compress_jpeg("photo.jpg", "q95.jpg", quality=95)
compress_jpeg("photo.jpg", "q75.jpg", quality=75)
compress_jpeg("photo.jpg", "q50.jpg", quality=50)
optimize=True 会让 Pillow 在保存时额外扫描一遍数据,重新构建最优的 Huffman 编码表,通常能再减小 2-5% 的体积,代价是保存速度稍慢。
分辨率调整
对于网页展示,4000×3000 的原图远超需要。用 thumbnail() 方法按比例缩小是最高效的做法——分辨率减半,体积大约减少 75%:
from PIL import Image
def resize_image(input_path, output_path, max_size=1920, quality=80):
"""将长边缩放到 max_size,保持原始宽高比"""
with Image.open(input_path) as img:
# thumbnail 是原地操作,不会超出指定尺寸
# Image.Resampling.LANCZOS 是降采样的最佳质量算法
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
if img.mode != "RGB":
img = img.convert("RGB")
img.save(output_path, format="JPEG", quality=quality, optimize=True)
resize_image("photo.jpg", "photo_1920.jpg", max_size=1920, quality=80)
thumbnail() 与 resize() 的区别在于:thumbnail() 会保持宽高比且不放大图像,最终尺寸一定小于等于指定值;resize() 则强制拉伸到目标尺寸。批量处理中几乎总是用 thumbnail()。
格式转换
将 PNG 转为 WebP 是常见的优化手段——WebP 在同等画质下比 PNG 小 26%,比 JPEG 小 25-35%,且支持透明通道:
from PIL import Image
def convert_to_webp(input_path, output_path, quality=80, lossless=False):
"""将任意格式转换为 WebP"""
with Image.open(input_path) as img:
img.save(
output_path,
format="WebP",
quality=quality,
lossless=lossless, # True 时使用无损模式
method=6, # 压缩努力程度 0-6,6 最慢但体积最小
)
convert_to_webp("icon.png", "icon.webp", quality=85)
convert_to_webp("logo.png", "logo_lossless.webp", lossless=True)
method 参数控制压缩的努力程度,范围 0-6。值越大压缩率越高但耗时越长。批量处理中如果追求极致体积,可以设为 6;如果追求速度,设为 4 是不错的平衡点。
批量压缩完整脚本
现在把单张处理逻辑组装成完整的批量脚本。这个脚本会遍历输入目录的所有图片,保持原有目录结构输出,并显示处理进度和总体压缩效果。
下面的流程图展示了批量处理的整体工作流:
完整脚本如下:
"""
batch_compress.py - 批量图片压缩脚本
用法: python batch_compress.py <输入目录> <输出目录> [--quality 75] [--max-size 1920]
"""
import os
import sys
import argparse
from pathlib import Path
from PIL import Image
SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif"}
def compress_single(input_path, output_path, quality=75, max_size=1920,
target_format=None, keep_exif=True):
"""压缩单张图片"""
with Image.open(input_path) as img:
# 1. 提取 EXIF 数据(拍摄时间、相机型号、GPS 等)
exif_data = img.info.get("exif", b"") if keep_exif else b""
# 2. 色彩模式转换:JPEG 不支持透明通道
out_format = (target_format or img.format or "JPEG").upper()
if out_format in ("JPEG", "JPG") and img.mode in ("RGBA", "P", "LA"):
# 在透明通道上叠加白色背景,避免转换后出现黑色区域
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
img = background
elif img.mode not in ("RGB", "RGBA", "L"):
img = img.convert("RGB")
# 3. 分辨率调整:仅缩小不放大
if max_size and max(img.size) > max_size:
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
# 4. 保存
save_kwargs = {"optimize": True}
if out_format in ("JPEG", "JPG"):
save_kwargs["quality"] = quality
save_kwargs["progressive"] = True
if exif_data:
save_kwargs["exif"] = exif_data
elif out_format == "WEBP":
save_kwargs["quality"] = quality
save_kwargs["method"] = 6
elif out_format == "PNG":
save_kwargs["optimize"] = True
img.save(output_path, format=out_format, **save_kwargs)
def batch_compress(input_dir, output_dir, quality=75, max_size=1920,
target_format=None, keep_exif=True):
"""批量压缩整个目录"""
input_dir = Path(input_dir)
output_dir = Path(output_dir)
# 收集所有待处理文件
tasks = []
for root, _, files in os.walk(input_dir):
for filename in files:
if Path(filename).suffix.lower() in SUPPORTED_FORMATS:
src = Path(root) / filename
rel = src.relative_to(input_dir)
dst = output_dir / rel
# 根据目标格式调整扩展名
if target_format:
dst = dst.with_suffix(f".{target_format.lower()}")
tasks.append((src, dst))
total = len(tasks)
if total == 0:
print("未找到支持的图片文件")
return
print(f"共发现 {total} 张图片,开始压缩...\n")
processed = 0
total_original = 0
total_compressed = 0
errors = []
for src, dst in tasks:
# 确保输出目录存在
dst.parent.mkdir(parents=True, exist_ok=True)
original_size = src.stat().st_size
try:
compress_single(src, dst, quality, max_size, target_format, keep_exif)
new_size = dst.stat().st_size
total_original += original_size
total_compressed += new_size
except Exception as e:
errors.append((src, str(e)))
new_size = original_size # 失败时按原大小计
processed += 1
pct = processed / total * 100
saved = (1 - new_size / original_size) * 100 if original_size else 0
print(f"[{pct:5.1f}%] ({processed}/{total}) {src.relative_to(input_dir)} "
f"{original_size // 1024}KB -> {new_size // 1024}KB (-{saved:.1f}%)")
# 汇总报告
print(f"\n{'=' * 50}")
print(f"处理完成: {processed} 张成功, {len(errors)} 张失败")
if total_original > 0:
ratio = total_original / total_compressed if total_compressed else 0
saved_mb = (total_original - total_compressed) / 1024 / 1024
print(f"原始体积: {total_original / 1024 / 1024:.2f} MB")
print(f"压缩体积: {total_compressed / 1024 / 1024:.2f} MB")
print(f"节省空间: {saved_mb:.2f} MB (压缩比 {ratio:.2f}x)")
for src, err in errors:
print(f" 失败: {src} - {err}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="批量图片压缩工具")
parser.add_argument("input_dir", help="输入图片目录")
parser.add_argument("output_dir", help="输出目录")
parser.add_argument("--quality", type=int, default=75, help="JPEG/WebP 质量因子 (1-100)")
parser.add_argument("--max-size", type=int, default=1920, help="长边最大像素")
parser.add_argument("--format", default=None, help="目标格式 (JPEG/WebP/PNG)")
parser.add_argument("--no-exif", action="store_true", help="丢弃 EXIF 信息")
args = parser.parse_args()
batch_compress(
args.input_dir,
args.output_dir,
quality=args.quality,
max_size=args.max_size,
target_format=args.format,
keep_exif=not args.no_exif,
)
运行示例:
# 基本用法
python batch_compress.py ./photos ./output
# 转 WebP,质量 80,长边限制 2560
python batch_compress.py ./photos ./output --quality 80 --max-size 2560 --format webp
这个脚本的核心设计有三点:用 os.walk() 递归遍历保持目录结构;用 relative_to() 计算相对路径确保输出布局与输入一致;用 Path.stat() 获取文件大小以计算实际压缩效果。
高级技巧
EXIF 信息保留
EXIF 数据包含拍摄时间、GPS 坐标、相机参数、光圈快门等信息。默认情况下 img.save() 会丢弃这些元数据。保留它们需要手动提取并在保存时传入:
from PIL import Image
def compress_with_exif(input_path, output_path, quality=75):
with Image.open(input_path) as img:
exif = img.info.get("exif", b"") # 提取原始 EXIF 字节
if img.mode != "RGB":
img = img.convert("RGB")
img.save(
output_path,
format="JPEG",
quality=quality,
exif=exif if exif else None, # 没有 EXIF 时传 None
optimize=True,
)
如果只需要保留部分 EXIF 字段(例如仅保留方向信息),可以用 getexif() 方法逐项挑选:
from PIL import ExifTags
def keep_orientation_only(input_path, output_path, quality=75):
with Image.open(input_path) as img:
exif = img.getexif()
orientation = exif.get(0x0112) # Orientation 标签
# 根据方向旋转图像
if orientation:
from PIL import ImageOps
img = ImageOps.exif_transpose(img)
img.save(output_path, format="JPEG", quality=quality, optimize=True)
ImageOps.exif_transpose() 会根据 EXIF 中的方向标签自动旋转图像,这在处理手机拍摄的照片时尤其有用——很多手机传感器是横向安装的,靠 EXIF 方向标签指示正确显示方向。
PNG 转 WebP
PNG 转 WebP 时需要根据图像特征选择有损或无损模式。对于含文字、线条的图标类图像,无损 WebP 保留锐利边缘;对于照片类图像,有损 WebP 体积优势明显:
from PIL import Image
def png_to_webp(input_path, output_path, lossless_threshold=0.3):
"""根据图像特征智能选择有损或无损 WebP"""
with Image.open(input_path) as img:
# 统计颜色数量判断是否为简单图形
colors = img.getcolors(maxcolors=65536)
is_simple = colors is not None and len(colors) < 256
if is_simple:
# 颜色少、边缘锐利:无损模式
img.save(output_path, format="WebP", lossless=True, method=6)
print(f"无损 WebP (颜色数: {len(colors)})")
else:
# 颜色丰富、照片类:有损模式
if img.mode != "RGB":
img = img.convert("RGB")
img.save(output_path, format="WebP", quality=82, method=6)
print("有损 WebP (照片类)")
getcolors() 返回颜色列表或 None(颜色超过 maxcolors 时)。颜色数少通常意味着是图标、截图等简单图形,适合无损压缩。
智能选择压缩参数
不同图像适合不同的压缩策略。下面这个函数根据分辨率、透明通道、颜色数量自动选择格式与参数:
from PIL import Image
def smart_compress(input_path, output_path):
"""根据图像特征自动选择最优压缩策略"""
with Image.open(input_path) as img:
width, height = img.size
pixel_count = width * height
mode = img.mode
has_transparency = mode in ("RGBA", "LA") or (
mode == "P" and "transparency" in img.info
)
# 策略 1:有透明通道 -> WebP(兼顾透明与体积)
if has_transparency:
if pixel_count > 2_000_000:
q = 80
else:
q = 88
img.save(output_path, format="WebP", quality=q, method=6)
return "WebP 有损 (透明)"
# 策略 2:超大尺寸照片 -> 缩小 + JPEG 中等质量
if pixel_count > 8_000_000 or width > 4000:
img = img.convert("RGB")
img.thumbnail((2560, 2560), Image.Resampling.LANCZOS)
img.save(output_path, format="JPEG", quality=75, optimize=True)
return "JPEG (大图缩放)"
# 策略 3:普通照片 -> JPEG 较高质量
if pixel_count > 500_000:
img = img.convert("RGB")
img.save(output_path, format="JPEG", quality=82, optimize=True)
return "JPEG (高质量)"
# 策略 4:小图 -> 保持原格式或 PNG
img.save(output_path, format="PNG", optimize=True)
return "PNG (小图保留)"
这套策略的核心逻辑是:透明图像走 WebP,大图先缩放再压,普通照片用 JPEG,小图用 PNG 保真。实际使用时可以在此基础上调整阈值。
性能优化
多线程处理
图像压缩是 I/O 密集型与 CPU 密集型混合的任务。磁盘读写和编码各占一部分时间。Python 的 GIL 限制了纯 CPU 并行,但 Pillow 在底层调用 C 库执行 JPEG/WebP 编码时会释放 GIL,因此多线程能带来可观的加速:
import os
from pathlib import Path
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}
_print_lock = threading.Lock()
def compress_single(input_path, output_path, quality=75, max_size=1920):
with Image.open(input_path) as img:
exif = img.info.get("exif", b"")
if img.mode != "RGB":
img = img.convert("RGB")
if max_size and max(img.size) > max_size:
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
kwargs = {"quality": quality, "optimize": True, "progressive": True}
if exif:
kwargs["exif"] = exif
img.save(output_path, format="JPEG", **kwargs)
def batch_compress_threaded(input_dir, output_dir, quality=75, max_size=1920,
workers=4):
input_dir = Path(input_dir)
output_dir = Path(output_dir)
# 收集任务
tasks = []
for root, _, files in os.walk(input_dir):
for f in files:
if Path(f).suffix.lower() in SUPPORTED_FORMATS:
src = Path(root) / f
rel = src.relative_to(input_dir)
dst = output_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
tasks.append((src, dst))
total = len(tasks)
print(f"共 {total} 张图片,使用 {workers} 线程并行处理\n")
completed = 0
total_saved = 0
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(compress_single, src, dst, quality, max_size): (src, dst)
for src, dst in tasks
}
for future in as_completed(futures):
src, dst = futures[future]
try:
future.result()
saved = src.stat().st_size - dst.stat().st_size
total_saved += saved
except Exception as e:
with _print_lock:
print(f"失败: {src.name} - {e}")
completed += 1
with _print_lock:
print(f"[{completed / total * 100:5.1f}%] ({completed}/{total}) {src.name}")
print(f"\n完成。节省空间: {total_saved / 1024 / 1024:.2f} MB")
if __name__ == "__main__":
batch_compress_threaded("./photos", "./output", quality=75, max_size=1920, workers=4)
线程数的经验值是 CPU 逻辑核心数。对于 8 核机器,4-6 个线程通常能获得接近最优的加速比。线程数过多反而会因为磁盘 I/O 争用和内存压力导致性能下降。
如果需要更进一步的并行,可以用 ProcessPoolExecutor 绕过 GIL,但代价是进程间无法共享内存,每个进程都要独立加载 Pillow 库,启动开销更大。
内存管理
处理高分辨率图片时,内存占用不容忽视。一张 6000×4000 的 RGB 图片解码后占用约 72MB 内存(6000×4000×3 字节)。如果同时打开 10 张,就是 720MB。
几个关键的内存管理原则:
from PIL import Image
import gc
# 1. 始终用 with 语句,确保文件句柄和像素缓冲及时释放
def safe_compress(input_path, output_path, quality=75):
with Image.open(input_path) as img:
# 2. 复制需要修改的图像,避免操作原图
work = img.copy()
# img 已释放,work 仍在作用域内
if work.mode != "RGB":
work = work.convert("RGB")
work.save(output_path, format="JPEG", quality=quality, optimize=True)
work.close() # 3. 显式关闭
# 4. 批量处理时定期触发垃圾回收
def batch_with_gc(tasks, interval=100):
for i, task in enumerate(tasks):
process(task)
if i % interval == 0:
gc.collect()
此外,Image.MAX_IMAGE_PIXELS 默认限制约 1.78 亿像素(约 8900×8900),超过会抛出 DecompressionBombError。如果确实需要处理超大图片,可以适当调高这个阈值,但要注意内存消耗:
Image.MAX_IMAGE_PIXELS = None # 关闭限制(谨慎使用)
压缩效果对比
下面是在一组 50 张手机照片(原始平均 6.5MB,4000×3000 像素)上的实测数据。所有测试在相同的硬件上运行,分辨率统一缩减到长边 1920:
| 质量因子 | 格式 | 平均体积 | 压缩比 | 主观画质 | 适用场景 |
|---|---|---|---|---|---|
| 原图 | JPEG | 6.5 MB | 1.0x | 基准 | — |
| 95 | JPEG | 1.8 MB | 3.6x | 几乎无差异 | 高质量存档 |
| 85 | JPEG | 0.92 MB | 7.1x | 肉眼难辨 | 网页主图 |
| 75 | JPEG | 0.58 MB | 11.2x | 细看有轻微伪影 | 缩略图、列表页 |
| 65 | JPEG | 0.42 MB | 15.5x | 可见块状瑕疵 | 不推荐 |
| 50 | JPEG | 0.28 MB | 23.2x | 明显失真 | 仅极小预览 |
| 80 | WebP | 0.51 MB | 12.7x | 与 JPEG 85 相当 | 现代网页 |
| 无损 | WebP | 4.2 MB | 1.5x | 完全无损 | 需要透明通道 |
从数据可以看出几个规律:
- 质量 85 是性价比拐点:相比 95 节省近一半体积,画质差异肉眼几乎不可辨。
- 质量低于 70 收益递减:体积继续下降但画质损失明显加速。
- WebP 质量因子与 JPEG 不可直接等同:WebP 80 的画质约等于 JPEG 85,但体积更小。
- 无损 WebP 仅在需要透明通道时才有意义,对照片而言体积优势不大。
常见问题
Q1:压缩后图片出现方向颠倒怎么办?
这是因为手机的 EXIF 方向标签在压缩过程中被丢弃,而原图实际像素是横向存储的。解决方法是在保存前调用 ImageOps.exif_transpose(img),它会根据 EXIF 方向标签旋转像素数据,使图像在方向正确后再保存。这样即使后续软件不读取 EXIF 方向信息,图像也能正确显示。
Q2:处理 1000 张图片时内存占用持续增长怎么解决?
最常见的原因是没有及时关闭图像对象。务必用 with Image.open(...) as img: 上下文管理器,确保每张图片处理完后立即释放。其次,多线程版本中如果同时提交了所有任务,future 对象会持有结果引用直到被消费,导致内存堆积。解决方法是用 chunksize 分批提交,或改用生产者-消费者队列模式控制并发量。最后,定期调用 gc.collect() 可以回收循环引用导致的内存残留。
Q3:PNG 转 JPEG 后透明区域变黑了怎么办?
JPEG 格式不支持透明通道,Pillow 默认将 RGBA 转为 RGB 时会把透明区域的背景填为黑色。解决方法是先创建一个白色背景的 RGB 图像,再用 paste() 方法将原图贴上去,用 alpha 通道作为掩码:
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3]) # 第 4 通道是 alpha
或者直接转 WebP,它原生支持透明通道,无需额外处理。
Q4:多线程为什么没有带来线性的加速比?
图像压缩包含磁盘 I/O 和 CPU 编码两部分。当线程数增加到一定程度后,磁盘带宽成为瓶颈,CPU 再快也要等数据读写。此外,高分辨率图片解码后的像素数据较大,多线程同时处理多张大图会导致内存带宽竞争和缓存命中率下降。实际测试中,4-8 线程通常能获得 2-4 倍加速,继续增加线程收益迅速递减。如果磁盘是机械硬盘,建议线程数不超过 4;如果是 NVMe SSD,可以适当增加。
总结
用 Python Pillow 批量压缩图片,核心可以归结为三个层次:
- 基础层:掌握
Image.open()、thumbnail()、save()三个方法,配合quality和optimize参数,就能完成单张图片的压缩。这是所有批量处理的构建块。 - 工程层:用
os.walk()遍历目录、Path.relative_to()保持结构、concurrent.futures并行处理,把单张逻辑扩展到上千张图片的批量场景。这一层关注的是正确性、健壮性和吞吐量。 - 优化层:根据图像特征智能选择格式与参数——透明图走 WebP、大图先缩放、小图保 PNG。EXIF 保留、方向校正、内存管理这些细节决定了脚本能否在生产环境中稳定运行。
质量因子的选择有一条经验法则:85 是肉眼无损的临界点,75 是体积与画质的平衡点,低于 65 不建议用于展示场景。WebP 在现代浏览器中已经全面普及,对新项目应作为首选格式。
这套脚本的价值不在于替代专业工具,而在于可定制性。你可以根据实际需求调整每个环节——为特定目录配置不同参数、在压缩后自动上传到 CDN、集成到 CI/CD 流水线中。理解了每一行代码背后的原理,就拥有了应对任何图像处理场景的基础能力。
相关阅读: