Python Pillow库实现图像处理的完整指南
目录
第 1 章 背景:为什么处理一张图片会让人崩溃1.1 图像文件的三重暴击1.2 Pillow 的身份:它是谁,不是谁第 2 章 核心概念速览2.1 图像模式(Mode):一张图到底是什么样的矩阵2.2 坐标系:原点在左上角2.3 Image 对象模型:四件套2.4 懒加载:open 不等于读进内存第 3 章 API 说明:核心成员速查3.1 顶层入口与文件 I/O3.2 几何变换3.3 像素与通道操作3.4 绘制与文字3.5 滤镜与增强3.6 EXIF 与元数据3.7 动图与序列第 4 章 详细使用:8 组可运行示例示例 1:最小闭环——读取、查信息、转格式、保存示例 2:批量缩略图流水线(thumbnail 是正道)示例 3:EXIF 隐私清理与方向修正示例 4:文字水印与透明角标示例 5:圆角头像与遮罩合成示例 6:九宫格拼图示例 7:图像增强与风格滤镜示例 8:GIF 动图逐帧处理与统一转 WebP第 5 章 底层实现剖析5.1 懒加载机制:为什么 open 那么快5.2 解码器插件体系:一张图对应一个解码器5.3 线程安全与 GIL5.4 内存模型:mode 决定每一像素占多少字节5.5 save 编码参数:quality 到底是什么第 6 章 常错点 25 坑格式与模式类(6 条)几何操作类(5 条)EXIF 与隐私类(5 条)绘制与合成类(5 条)性能与工程类(4 条)第 7 章 性能优化清单 10 条第 8 章 工程落地建议:家庭相册整理与隐私清理工具8.1 需求画像8.2 分层架构8.3 核心代码骨架目录
- 第 1 章 背景:为什么处理一张图片会让人崩溃
- 1.1 图像文件的三重暴击
- 1.2 Pillow 的身份:它是谁,不是谁
- 第 2 章 核心概念速览
- 2.1 图像模式(Mode):一张图到底是什么样的矩阵
- 2.2 坐标系:原点在左上角
- 2.3 Image 对象模型:四件套
- 2.4 懒加载:open 不等于读进内存
- 第 3 章 API 说明:核心成员速查
- 3.1 顶层入口与文件 I/O
- 3.2 几何变换
- 3.3 像素与通道操作
- 3.4 绘制与文字
- 3.5 滤镜与增强
- 3.6 EXIF 与元数据
- 3.7 动图与序列
- 第 4 章 详细使用:8 组可运行示例
- 示例 1:最小闭环——读取、查信息、转格式、保存
- 示例 2:批量缩略图流水线(thumbnail 是正道)
- 示例 3:EXIF 隐私清理与方向修正
- 示例 4:文字水印与透明角标
- 示例 5:圆角头像与遮罩合成
- 示例 6:九宫格拼图
- 示例 7:图像增强与风格滤镜
- 示例 8:GIF 动图逐帧处理与统一转 WebP
- 第 5 章 底层实现剖析
- 5.1 懒加载机制:为什么 open 那么快
- 5.2 解码器插件体系:一张图对应一个解码器
- 5.3 线程安全与 GIL
- 5.4 内存模型:mode 决定每一像素占多少字节
- 5.5 save 编码参数:quality 到底是什么
- 第 6 章 常错点 25 坑
- 格式与模式类(6 条)
- 几何操作类(5 条)
- EXIF 与隐私类(5 条)
- 绘制与合成类(5 条)
- 性能与工程类(4 条)
- 第 7 章 性能优化清单 10 条
- 第 8 章 工程落地建议:家庭相册整理与隐私清理工具
- 8.1 需求画像
- 8.2 分层架构
- 8.3 核心代码骨架
- 8.4 五条工程红线
- 第 9 章 总结五要点
- 第 10 章 FAQ 10 条速查
Pillow 是 Python 生态里绕不开的图像"地基库"——它不是计算机视觉算法库,而是让你对一张图片的每一个像素、每一段元数据、每一种格式拥有完全掌控的瑞士 军刀。
第 1 章 背景:为什么处理一张图片会让人崩溃
1.1 图像文件的三重暴击
几乎每个开发者都经历过这样的时刻:客户发来 1000 张手机拍的商品图,要求"缩成 800px、加个水印、转成 WebP、把拍摄地点去掉"。你打开 PS 手工做了半小时,然后下定决心用 Python 写脚本——却发现第一行就卡住了。
痛点一:格式碎片化。 一张图片的"格式"远不止文件名后缀那么简单。同样是 .jpg,有的带 EXIF 方向标签(手机竖拍存成横的,显示时靠标签旋转)、有的带 ICC 色彩配置文件、有的质量参数低到放大全是马赛克;.png 有 8 位和 16 位深度、有调色板模式和真彩色模式;.gif 可能是动画;.webp 还有无损/有损之分。要正确处理它们,不能只靠文件后缀猜。
痛点二:批量处理没有银弹。 缩略图、水印、裁剪、格式转换、EXIF 清理……这些需求在每一个项目里都会以略微不同的形态重新出现。如果你每次都用 OpenCV 或者 numpy 手搓像素循环,你会发现 90% 的时间花在了"读取-解码-编码-保存"这个与业务无关的管线上,而不是真正的图像处理逻辑上。
痛点三:隐私泄漏是隐形炸弹。 手机和数码相机拍摄的照片会在 EXIF 元数据里写入 GPS 坐标、拍摄时间、相机型号甚至镜头信息。把这样的原图直接上传到公开平台或发给客户,等于把家庭住址和作息时间拱手送人。2020 年就有知名社交平台因未剥离 EXIF 被用户集体投诉的先例。这不是"元数据洁癖",而是安全底线。
1.2 Pillow 的身份:它是谁,不是谁
Pillow 的前身是 1995 年诞生的 PIL(Python Imaging Library)。PIL 在 2009 年停更,Alex Clark 团队把它 Fork 出来继续维护并更名为 Pillow,2020 年发布 3.0 版本后重新活跃,至今是 Python 图像处理的事实标准——pip install pillow 几乎是每个 Python 环境的默认依赖之一。
要理解 Pillow,先要理解它在图像处理金字塔里的位置:
层级代表工具回答的问题侧重点 像素 I/O 与基础编辑Pillow图片怎么读?怎么存?怎么缩放/裁剪/加水印?文件格式、像素操作、批量管线 计算机视觉算法OpenCV图片里有什么结构?边缘、角点、轮廓在哪?算法矩阵、实时处理 深度学习推理YOLO / PyTorch图片里有什么物体?猫还是狗?框在哪?模型推理、张量运算 数据可视化出图Matplotlib数据怎么变成一张好看的统计图?绘图渲染、出版级输出
判断标准:如果你要做"目标检测/人脸识别/边缘提取",那是 OpenCV 或 YOLO 的地盘;如果你要做"把 1000 张照片统一转格式、生成缩略图、打水印、清掉 GPS 信息",Pillow 是唯一正确且最省力的选择。两者不是竞争关系,而是流水线上下游——Pillow 经常为 OpenCV/YOLO 做预处理,也经常消费它们的输出。
第 2 章 核心概念速览
2.1 图像模式(Mode):一张图到底是什么样的矩阵
Pillow 用 mode 描述图像的通道构成与位深。这是新手踩坑率最高的概念,先记牢这张表:
模式含义通道数位深/像素典型用途 1二值图(黑白)11 bit掩码、打印 L8 位灰度18 bit黑白照片、亮度图 P调色板模式18 bit(索引)GIF、小体积 PNG RGB真彩色324 bit最常见的照片格式 RGBA真彩色+透明432 bitPNG、水印合成 LA灰度+透明216 bit灰度透明图 CMYK印刷四色432 bit印刷出版 YCbCr视频色彩324 bitJPEG 内部编码 HSV色相/饱和度/明度324 bit颜色调整
核心心智:mode 决定 getpixel((x,y)) 返回什么——L 返回单个整数、RGB 返回三元组、RGBA 返回四元组。你以为在处理"三通道彩色 图",却忘了把 RGBA 转成 RGB 就保存成 JPEG,得到的不是报错就是黑色背景(详见常错点)。
2.2 坐标系:原点在左上角
Pillow 的坐标系与屏幕一致:原点 (0,0) 在图片左上角,x 轴向右,y 轴向下。这与数学坐标系(y 向上)相反,也与部分图像库(如一些 3D 库的右下原点)不同。
# 读取 (col=10, row=20) 处的像素
pixel = img.getpixel((10, 20)) # 注意:先 x 后 y
2.3 Image 对象模型:四件套
from PIL import Image
img = Image.open("photo.jpg")
print(img.format) # 'JPEG' 文件格式
print(img.mode) # 'RGB' 通道模式
print(img.size) # (4000, 3000) (宽, 高)
print(img.info) # 头部元数据字典(dpi、icc_profile 等)
- format:文件格式名(不是扩展名),由文件内容 magic bytes 判定;
- mode:见 2.1;
- size:元组顺序是 (width, height),即 (列数, 行数)——与坐标 (x, y) 顺序一致;
- info:随文件读取的元信息,包括 dpi、icc_profile、exif(Pillow 10+ 中 exif 单独用 img.getexif() 获取)。
2.4 懒加载:open 不等于读进内存
这是 Pillow 性能设计里最容易被忽视的一点:
img = Image.open("big.jpg") # 此刻只读了文件头 + 少量元数据
# img.size / img.mode / img.format 已经可用
data = img.load() # 到这一步才真正解码像素数据到内存
对批量处理来说,这意味着你可以在不解码像素的情况下先扫描整个目录的尺寸、格式、EXIF,做"轻量索引";只有真正需要操作像素时才触发解码。这是 2.8 节工程落地里"先索引后处理"架构的基石。
第 3 章 API 说明:核心成员速查
3.1 顶层入口与文件 I/O
API说明关键参数 Image.open(fp)打开图片(懒加载)mode 强制转模式、formats 限制格式 Image.new(mode, size, color=0)新建空白图color 可为元组或整数 img.save(fp, format=None, params)保存/编码format、quality、optimize、progressive、dpi、subsampling img.convert(mode, kwargs)模式转换RGB/L/RGBA/P、dither Image.frombytes(mode, size, data)从原始字节构造字节序与步长需严格匹配 img.tobytes()导出原始像素字节可用于与 numpy 互操作 img.copy()深拷贝处理链中保护原图
3.2 几何变换
API说明关键参数 img.crop(box)裁剪box=(left, top, right, bottom),右/下为开区间 img.resize(size, resample=...)缩放(不保比例)Image.Resampling.LANCZOS 最高质量 img.thumbnail(size)原地等比缩略修改原对象,不返回新图 img.rotate(angle, expand=False)旋转expand=True 才扩大画布防裁边 img.transpose(method)镜像/翻转FLIP_LEFT_RIGHT / FLIP_TOP_BOTTOM / ROTATE_90 等 img.paste(im, box, mask=None)粘贴合成box 左上角;mask 决定透明度
3.3 像素与通道操作
API说明 img.getpixel((x, y)) / img.putpixel((x, y), value)单像素读写(调试用,批量勿用) img.getdata() / img.putdata(data)整幅像素序列读写 img.split()通道分离,返回各单通道图 Image.merge(mode, bands)通道合并 img.getchannel('R')取单通道 img.getbbox() / img.getextrema() / img.histogram()分析:内容范围 / 最值 / 直方图 img.getcolors(maxcolors)颜色统计(量化场景)
3.4 绘制与文字
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(img) # 在 img 上绘制(直接修改)
draw.rectangle((10, 10, 100, 100), fill="red", outline="blue", width=2)
draw.ellipse((200, 200, 300, 300), fill=(255, 0, 0, 128))
draw.line((0, 0, 100, 100), fill="black", width=3)
draw.text((20, 20), "Hello", fill="white", font=font) # 需要字体对象
font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", size=48) # Windows 微软雅黑
注意 Pillow 10.1+ 中 draw.text 的 font 参数已成为必填项(load_default() 的默认行为也已调整),老代码直接 draw.text(...) 不传 font 在新版本会报错——这是跨版本兼容的高频坑。
3.5 滤镜与增强
from PIL import ImageFilter, ImageEnhance, ImageOps
# 滤镜(ImageFilter)
img.filter(ImageFilter.GAUSSIAN_BLUR(radius=2)) # 高斯模糊
img.filter(ImageFilter.UnsharpMask(radius=2, percent=150)) # 锐化
img.filter(ImageFilter.FIND_EDGES) # 边缘
img.filter(ImageFilter.CONTOUR) # 轮廓
img.filter(ImageFilter.MedianFilter(size=3)) # 中值去噪
# 增强(ImageEnhance)四件套
ImageEnhance.Brightness(img).enhance(1.2) # 亮度
ImageEnhance.Contrast(img).enhance(1.1) # 对比度
ImageEnhance.Color(img).enhance(1.0) # 色彩饱和度
ImageEnhance.Sharpness(img).enhance(2.0) # 锐度
# 自动操作(ImageOps)
ImageOps.autocontrast(img) # 自动对比度
ImageOps.equalize(img) # 直方图均衡
ImageOps.flip(img) # 上下翻转
ImageOps.mirror(img) # 左右镜像
ImageOps.exif_transpose(img) # 按 EXIF 方向自动旋转(手机照片必用)
ImageOps.fit(img, (800, 800)) # 裁剪+缩放至目标尺寸
3.6 EXIF 与元数据
exif = img.getexif() # 返回 Exif 对象(类 dict)
orientation = exif.get(274) # 274 = Orientation 方向标签
# 常见标签:271 Make(厂商) / 272 Model(机型) / 306 DateTime / 34853 GPSInfo
# 手机照片方向修正
from PIL import ImageOps
img = ImageOps.exif_transpose(img)
# 保存时剥离 EXIF:save 时不传 exif 参数即可(或传清理后的)
img.save("clean.jpg", quality=88)
3.7 动图与序列
from PIL import ImageSequence
img = Image.open("anim.gif")
for frame in ImageSequence.Iterator(img):
frame = frame.convert("RGB") # 逐帧处理
# ...
第 4 章 详细使用:8 组可运行示例
以下示例以 Python 3.10+、Pillow 10.x 为准,均可在本地直接运行。
示例 1:最小闭环——读取、查信息、转格式、保存
from PIL import Image
img = Image.open("photo.jpg")
print(f"格式={img.format} 模式={img.mode} 尺寸={img.size}")
# 转 RGB(RGBA 直接存 JPEG 会报错/变黑)
img_rgb = img.convert("RGB")
img_rgb.save("photo.webp", quality=85, method=6) # WebP 更小
img_rgb.save("photo_opt.jpg",
quality=85, optimize=True, progressive=True) # JPEG 优化+渐进式
print("done")
要点:quality 对 JPEG 是 0100(建议 8090 平衡体积与质量),对 WebP 同样是 0~100;optimize=True 会做二次编码压缩但更慢;progressive=True 输出渐进式 JPEG(网络加载友好)。
示例 2:批量缩略图流水线(thumbnail 是正道)
import os
from pathlib import Path
from PIL import Image
def make_thumb(src: Path, dst: Path, size=(800, 800)):
with Image.open(src) as img:
img = ImageOps.exif_transpose(img) # 先按方向转正
img.thumbnail(size) # 原地等比缩小,不放大,不破坏比例
# 透明图转 RGB 再存 JPEG,避免黑底
img.convert("RGB").save(dst, quality=82, optimize=True)
src_dir = Path("raw_photos")
dst_dir = Path("thumbs")
dst_dir.mkdir(exist_ok=True)
for f in src_dir.glob("*.jpg"):
make_thumb(f, dst_dir / f.name)
要点:thumbnail(size) 与 resize(size) 的区别是 thumbnail 保持宽高比且绝不放大,是生成缩略图唯一正确的 API;resize 会把图硬拉到指定尺寸导致变形。
示例 3:EXIF 隐私清理与方向修正
from PIL import Image, ImageOps
def sanitize(src: Path, dst: Path):
with Image.open(src) as img:
img = ImageOps.exif_transpose(img) # ① 方向转正(保留像素正确)
img = img.convert("RGB")
# ② 保存时不写 exif —— 一句话实现隐私清理
img.save(dst, quality=88, optimize=True)
# 也可以只保留需要的字段:
# img.save(dst, exif=img.getexif().get_ifd(0x8769) 里挑选后的子集)
# 验证清理结果
check = Image.open("clean.jpg")
print("cleaned exif:", check.getexif()) # 空 Exif 对象
要点:ImageOps.exif_transpose 必须在任何几何操作之前调用,否则后续 crop/resize 基于错误的方向;清理 EXIF 只需保存时不传 exif 参数(Pillow 10+ 默认不保留)。GPS 信息在 GPSInfo 字段(标签 34853),剥离 EXIF 即一并清除。
示例 4:文字水印与透明角标
from PIL import Image, ImageDraw, ImageFont
def add_watermark(img: Image.Image, text: str = "© 2026 Album"):
img = img.convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", size=36)
# 文字宽度测量(新版 API 必须传 font)
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x, y = img.width - tw - 30, img.height - th - 30
# 半透明文字:RGBA fill 的 alpha 生效
draw.text((x, y), text, fill=(255, 255, 255, 180), font=font)
return Image.alpha_composite(img, overlay) # 合成
photo = Image.open("vacation.jpg")
watermarked = add_watermark(photo)
watermarked.convert("RGB").save("wm.jpg", quality=90)
要点:水印正确姿势是先画到透明 overlay 再 alpha_composite,而不是直接在原图上画——这样不会破坏原图、可反复叠加,且 alpha 通道支持半透明。draw.text 的坐标是文字左上角,所以需要先用 textbbox 量宽高来定位右下角。
示例 5:圆角头像与遮罩合成
from PIL import Image, ImageDraw
def rounded_avatar(src, dst, size=256, radius=48):
img = Image.open(src).convert("RGBA")
img = ImageOps.fit(img, (size, size)) # 居中裁剪为正方形
mask = Image.new("L", (size, size), 0) # 灰度掩码
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255)
result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
result.paste(img, (0, 0), mask=mask) # mask 控制透明度
result.convert("RGB").save(dst)
要点:paste(im, box, mask) 中 mask 必须是 L 或 1 模式的单通道灰度图,白色=不透明、黑色=透明;圆角本质是"用一张圆形/圆角灰度图当透明度模板"。
示例 6:九宫格拼图
from PIL import Image
def make_grid(tiles: list[str], cols=3, cell=300, gap=10, bg=(255, 255, 255)):
rows = (len(tiles) + cols - 1) // cols
canvas = Image.new("RGB", (cols * cell + (cols + 1) * gap,
rows * cell + (rows + 1) * gap), bg)
for i, path in enumerate(tiles):
with Image.open(path) as im:
im = ImageOps.exif_transpose(im)
im = ImageOps.fit(im, (cell, cell)) # 裁剪缩放不留白
r, c = divmod(i, cols)
x = gap + c * (cell + gap)
y = gap + r * (cell + gap)
canvas.paste(im, (x, y))
return canvas
make_grid(["a.jpg", "b.jpg", "c.jpg", "d.jpg"]).save("grid.jpg", quality=90)
要点:divmod(i, cols) 同时得到行号与列号;粘贴坐标 = 间隙 + 序号 * (格子 + 间隙),公式写清楚能省一半调试时间。
示例 7:图像增强与风格滤镜
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
def vintage_filter(img: Image.Image, strength=1.0):
# ① 降饱和度 → ② 提亮度 → ③ 提对比度 → ④ 加暖色(黄/红叠加)→ ⑤ 微柔焦
img = ImageEnhance.Color(img).enhance(0.65 * strength)
img = ImageEnhance.Brightness(img).enhance(1.05 * strength)
img = ImageEnhance.Contrast(img).enhance(1.15 * strength)
overlay = Image.new("RGB", img.size, (255, 214, 170)) # 暖色
img = Image.blend(img, overlay, 0.15 * strength) # 按权重混合
return img.filter(ImageFilter.GaussianBlur(radius=0.5)) # 柔焦
photo = Image.open("street.jpg")
vintage_filter(photo).save("vintage.jpg", quality=90)
# 黑白艺术照:直方图均衡 + 轻微锐化
bw = ImageOps.equalize(photo.convert("L"))
bw.filter(ImageFilter.UnsharpMask(radius=2, percent=120)).save("bw.jpg")
要点:Image.blend(im1, im2, alpha) 要求两图同 mode 同 size,alpha=0.15 表示 15% 叠加暖色;滤镜链本质是"一堆像素级操作串行组合",性能上与手写循环不可同日而语。
示例 8:GIF 动图逐帧处理与统一转 WebP
from PIL import Image, ImageSequence
def gif_to_webp_frames(src: str, dst_dir: str):
img = Image.open(src)
for i, frame in enumerate(ImageSequence.Iterator(img)):
frame = frame.convert("RGB") # 逐帧转 RGB
# 只保留关键帧间隔抽帧:i % 3 == 0
if i % 3 == 0:
frame.save(f"{dst_dir}/frame_{i:03d}.webp", quality=80)
gif_to_webp_frames("anim.gif", "frames")
要点:ImageSequence.Iterator 返回的是共享底层数据的帧对象,不要保存迭代器对象本身(处理完会失效);动图的 duration、loop 等控制信息在 img.info['duration'],逐帧另存时默认丢失,需要手动回填(frame.save(..., save_all=True, append_images=...) 才能保动画)。
第 5 章 底层实现剖析
5.1 懒加载机制:为什么 open 那么快
Image.open() 只完成三件事:读取文件头、探测 magic bytes 定位格式、填充 size/mode/format/info。真正的像素解码发生在 load() 或第一次像素访问时。
Image.open()
└─ 头部解析:format 探测(JPEG FFD8 / PNG 8950 4E47 / GIF 47494638 / WebP 52494646...)
└─ info 填充:dpi、icc_profile、duration 等(轻量)
└─ 返回 Image 对象(pixel data = None)
│
└─ img.load() ← 此刻才调用对应解码器的 load() 把像素读入 self.im
这对批量扫描场景是数量级的性能差异:10000 张照片的目录,先 open 一遍拿全尺寸/EXIF/格式(毫秒级每张),再决定哪些需要解码,而不是全部先读进内存。
5.2 解码器插件体系:一张图对应一个解码器
Pillow 内部维护一个格式注册表(Image.init() 时注册所有内置插件),每个格式一个类(如 JpegImagePlugin、PngImagePlugin)。open() 按 magic bytes 找到插件后调用其 _open() 填头部信息,load() 时调用对应解码逻辑。
open("photo.jpg") → 读前 2 字节 0xFF 0xD8 → 命中 JpegImagePlugin._open() → 解析 SOF/DHT/DQT 段 → 填 size/mode → photo.load() → JPEG 解码器(libjpeg)输出 RGB 像素行
这也是为什么"扩展名是 .jpg 但内容是 PNG"这类文件能正常打开——判定格式的是内容 magic bytes,不是扩展名。
5.3 线程安全与 GIL
Pillow 的 Image 对象不是线程安全的:同一个 Image 不能被两个线程同时读写。但批量处理可以放心用多线程——因为每个线程各自 open 自己的文件对象,Pillow 解码时通过 GIL 与 C 扩展(libjpeg/libpng/zlib)交互,save() 编码阶段会释放 GIL 让出 CPU。所以"多线程批量处理"的收益主要来自 I/O 重叠(磁盘读取/编码等待),而不是像素计算并行。
5.4 内存模型:mode 决定每一像素占多少字节
mode每像素字节1000×1000 图内存约 10.125 B(打包位)0.12 MB L1 B1 MB RGB3 B3 MB RGBA4 B4 MB CMYK4 B4 MB
convert() 的代价在于逐像素运算 + 必要时分配新缓冲。处理链上尽量一次转到位:RGBA→RGB 后再做滤镜,比每步都在 RGBA 上操作内存更省。tobytes() 返回的正是按行、每像素连续排列的原始字节(大端逐字节),与 numpy 互操作时 np.frombuffer(img.tobytes(), dtype=np.uint8).reshape(h, w, c) 是标准姿势。
5.5 save 编码参数:quality 到底是什么
- JPEG:quality 0~100,默认 75。它映射到 libjpeg 的量化表缩放,不是线性关系——85→90 的体积增幅远大于 80→85。生产建议:照片 85、截图/图表 90、缩略图 80。
- WebP:quality 同样 0100,配合 method(06,越大压缩越慢但体积越小);lossless=True 时 quality 含义变为压缩级别。
- PNG:无损格式,quality 无意义;体积靠 optimize=True(调优 zlib 压缩)+ compress_level(0~9)。
- 渐进式 JPEG:progressive=True 让图片边下载边显示,但体积略增、且部分旧软件不支持。
第 6 章 常错点 25 坑
格式与模式类(6 条)
- RGBA 直接存 JPEG:JPEG 没有 alpha 通道,直接 img.save("x.jpg") 要么报错要么(部分版本)写出黑底。必须先 convert("RGB")。
- RGB 与 RGBA 混淆取像素:getpixel 返回的元组长度不一样(3 vs 4),写死下标 p[3] 会 IndexError。
- 把 L(灰度)当 1(二值):convert("L") 是 0~255 灰度,convert("1") 是 0/1 二值(带抖动)。二值化用错模式导致掩码全黑/全白。
- CMYK 直接当 RGB 用:印刷图转出颜色明显偏色,应先 convert("RGB")。Pillow 的 CMYK 与 RGB 转换不做 ICC 色彩管理,高保真场景需额外处理 icc_profile。
- 调色板 P 模式像素下标化:getpixel 返回的是调色板索引而非真实颜色;要取颜色先 convert("RGB")。
- 模式与文件格式对不上:.png 可以是 P/RGB/RGBA,.jpg 只有 L/RGB/CMYK——不要假设后缀决定 mode。
几何操作类(5 条)
- resize 破坏宽高比:直接 resize((800,800)) 会把方形图拉扁。除非明确要填满,否则用 thumbnail 或 ImageOps.fit。
- rotate 默认裁边:rotate(90) 不传 expand=True 时画布不变、内容被裁。旋转后尺寸变化要 expand=True。
- 坐标/尺寸顺序搞反:size=(w, h)、box=(left, top, right, bottom)、paste((x, y))——把 (h, w) 传进 crop 轻则错位重则越界报错。
- crop 的 right/bottom 是开区间:crop((0, 0, w, h)) 取到的是第 w-1、h-1 列/行,不含 right/bottom。
- thumbnail 原地修改丢原图:img.thumbnail() 不返回新图而是修改 img 自身;要保留原图先 copy()。
EXIF 与隐私类(5 条)
- 手机照片忘了 exif_transpose:iPhone/多数安卓竖拍照片 Orientation 标签非 1,直接处理会得到横躺图;必须在任何几何操作前转正。
- EXIF 隐私泄漏:img.save() 时 Pillow 10+ 默认不保留 EXIF,但老版本或显式传了 exif 参数时会写回;上传/分发前务必确认 getexif() 为空。
- exif_transpose 后没保存:转正操作只是返回新图,不 save 就等于白做。
- 把 getexif() 当普通 dict:getexif() 返回 Exif 对象,某些字段(如 IFD 子字典)需要 get_ifd() 获取,直接 exif[tag] 可能得到的是 None 或嵌套对象。
- 剥离 EXIF 后 DPI 也丢了:部分场景(打印)需要保留 dpi,保存时用 dpi=img.info.get('dpi') 回填。
绘制与合成类(5 条)
- draw.text 忘传 font:Pillow 10.1+ 中 font 为必填参数,老代码升级后直接 TypeError。
- 中文字体乱码/方块:load_default() 内置字体不支持中文;必须 truetype() 指定中文字体(Windows 用 msyh.ttc,Linux 用 NotoSansCJK)。
- 字体路径不存在:truetype 找不到路径抛 OSError;写跨平台代码用 fontconfig/环境变量定位,别硬编码。
- paste 的 mask 模式错误:mask 必须 L 或 1 模式;拿 RGB 图当 mask 会得到荒诞的透明效果或直接报错。
- alpha 合成顺序反了:Image.alpha_composite(back, front) 是 front 叠在 back 上;Image.blend(im1, im2, alpha) 的 alpha 是 im2 的权重——两个函数参数语义容易记反。
性能与工程类(4 条)
- 批量 getpixel/putpixel 循环:单像素 API 有 Python 层开销,十万级像素循环慢到怀疑人生;批量操作用 point()、ImageOps、滤镜或 putdata()。
- 大图一次性整读 OOM:5000×4000 的 RGBA 图约 80MB,几百张同时 open().load() 会爆内存;用 with Image.open() 作用域 + 处理完即释放。
- 并发写同一文件:多线程 save 同一路径会产生半写文件;输出路径按源文件唯一命名,或用线程池返回值在主线程统一写。
- 保存格式与扩展名不匹配:img.save("x.png") 但内容是 JPEG 数据(或反之)通常按扩展名编码没错,但显式 format= 能避免"内存 buffer 保存"时的隐式猜测错误。
第 7 章 性能优化清单 10 条
优化项说明与建议阈值 1缩略图用 thumbnail 而非 resize等比、不放大、内部用高质量重采样;省掉"先放大再缩小"的浪费 2重采样选择缩小用 LANCZOS(质量优先);放大用 BICUBIC;性能敏感用 BILINEAR 3批量处理用线程池ThreadPoolExecutor(max_workers=os.cpu_count()) 做 I/O 重叠;每个 worker 独立 open 4处理链只 load 一次open 后先 exif_transpose→convert→滤镜→save 一次性完成,避免反复 reload 5mode 一次转到位先 convert("RGB") 再做所有操作;RGBA 上做滤镜内存多 33% 6JPEG quality 分级照片 85 / 截图 90 / 缩略图 80;optimize=True 仅对"要存库"的图开 7归档格式选 WebP同质量下 WebP 比 JPEG 小 20~40%;method=6 更小但编码慢 8及时关闭与释放with Image.open() 或 img.close();批量循环里不 close 会累积句柄 9先索引后处理批量场景先 open 扫元数据(不解码),按条件筛选再 load 处理 10测量驱动用 time.perf_counter 分段统计解码/处理/编码耗时,瓶颈通常是编码而非像素操作
第 8 章 工程落地建议:家庭相册整理与隐私清理工具
8.1 需求画像
输入:一个混杂了 .jpg/.png/.heic/.webp/.gif 的相册目录(可能上万张,含手机照片、网图、截图)。输出:
- 按拍摄日期归档到 年/月 子目录;
- 竖拍照片自动转正;
- 生成统一 800px 缩略图目录(供相册 App/网页使用);
- 剥离全部 EXIF(GPS/设备/时间隐私);
- 可选:统一转 WebP 归档,体积缩减 30%+;
- 输出处理报告(成功/失败/跳过清单)。
8.2 分层架构
raw_photos/ ├─ scan.py # ① 轻量索引:open 不 load,收集 (路径, size, mode, 日期, EXIF方向) ├─ organize.py # ② 归档决策:按日期建目录、处理冲突重名 ├─ process.py # ③ 批量处理:exif_transpose → convert → thumbnail → 去EXIF → save └─ report.py # ④ 结果汇总:成功表 / 失败表 / 跳过表 / 体积对比
关键设计决策:
- 索引与处理分离:scan 阶段不解码像素,毫秒级扫完上万张;process 阶段用线程池并行,坏文件(如 HEIC 无解码器)在 scan 期就标出跳过,不拖累主流程。
- 输出路径幂等:输出文件名 = 日期 + 源文件名,重名时追加序号,绝不覆盖源图;源目录只读。
- HEIC 处理:Pillow 内置不支持 HEIC,需要 pillow-heif 插件;不支持时显式跳过并在报告中标注,而不是静默失败。
8.3 核心代码骨架
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageOps
def process_one(src: Path, out_root: Path, thumb_root: Path) -> dict:
try:
with Image.open(src) as img:
# 日期归档
dt = datetime.fromtimestamp(src.stat().st_mtime)
month_dir = out_root / f"{dt.year:04d}" / f"{dt.month:02d}"
month_dir.mkdir(parents=True, exist_ok=True)
# 方向修正 + 格式统一
img = ImageOps.exif_transpose(img).convert("RGB")
# 归档(去 EXIF:save 不传 exif)
dst = month_dir / f"{src.stem}.webp"
img.save(dst, format="WEBP", quality=85, method=6)
# 缩略图
thumb = img.copy()
thumb.thumbnail((800, 800))
thumb_dir = thumb_root / f"{dt.year:04d}" / f"{dt.month:02d}"
thumb_dir.mkdir(parents=True, exist_ok=True)
thumb.save(thumb_dir / f"{src.stem}.jpg", quality=82)
return {"path": str(src), "status": "ok"}
except Exception as e:
return {"path": str(src), "status": "fail", "error": str(e)}
def run(src_dir: str, out_root: str, thumb_root: str, workers=8):
src_dir = Path(src_dir)
files = [f for f in src_dir.rglob("*")
if f.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".gif"}]
results = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(process_one, f, Path(out_root), Path(thumb_root)): f
for f in files}
for fut in as_completed(futures):
results.append(fut.result())
ok = sum(1 for r in results if r["status"] == "ok")
fail =