很多开发者第一次用脚本处理图片时,卡住的往往不是算法,而是“读进来的到底是什么”。同样是 .jpg,有的带 EXIF 方向标签,手机竖拍存成横的、显示时才旋转;有的带 ICC 色彩配置;有的画质低到放大全是马赛克。.png 又分 8 位/16 位、调色板模式和真彩色模式,.gif 可能是动画,.webp 还分有损无损。只靠文件后缀去猜格式,脚本迟早出错。
Pillow 解决的正是这一层问题:它不负责目标检测、人脸识别这类计算机视觉算法,而是负责图片怎么读、怎么存、怎么缩放裁剪、怎么加水印、怎么清理元数据。批量转格式、生成缩略图、打水印、剥离 GPS 信息这类管线型需求,用 Pillow 最省力;需要做边缘、轮廓、模型推理时,再交给 OpenCV 或 PyTorch,两者是上下游关系而不是替代关系。
一、先建立四个核心模型
1)图像模式 mode 决定每个像素是什么。L 是 8 位灰度,getpixel 返回单个整数;RGB 是三通道 24 位,返回三元组;RGBA 多一个透明通道,返回四元组;P 是调色板索引,常见于 GIF 和小体积 PNG;CMYK 用于印刷。最容易踩的坑是把 RGBA 直接存成 JPEG,结果不是报错就是透明区域变黑,必须先 convert('RGB')。
2)坐标系原点在左上角,x 向右、y 向下,和屏幕坐标一致,和数学坐标相反。getpixel((10, 20)) 里的 10 是列、20 是行,别把顺序写反。
3)Image 对象有四件套:format 是文件格式名,由文件头 magic bytes 判定而不是扩展名;mode 是通道模式;size 是 (宽, 高),与坐标 (x, y) 顺序一致;info 是随文件读入的元信息字典,包含 dpi、icc_profile 等,Pillow 10+ 中 EXIF 建议用 getexif() 单独取。
4)懒加载是性能关键。Image.open() 只读文件头和少量元数据,此时 size、mode、format 已经可用,像素数据还没解码;直到调用 load() 或第一次访问像素时才真正解码进内存。批量扫描目录时,可以先 open 一遍拿到全部尺寸、格式、EXIF,再决定哪些需要解码,避免把上万张图一次性读进内存。
二、常用 API 与参数含义
文件 I/O:Image.open(fp) 打开图片;Image.new(mode, size, color) 新建画布;img.save(fp, format=None, **params) 负责编码,常用参数有 quality、optimize、progressive、dpi、subsampling;img.convert(mode) 做模式转换。
几何变换:crop(box) 裁剪,box 为 (left, top, right, bottom),右下是开区间;resize(size, resample) 会按指定尺寸硬缩放,可能变形;thumbnail(size) 原地等比缩小且绝不放大,是生成缩略图的正确接口;rotate(angle, expand=False) 旋转,expand=True 才会扩大画布防止裁边;transpose 用于镜像翻转;paste(im, box, mask) 做粘贴合成,mask 决定透明度。
像素操作:getpixel/putpixel 适合调试,批量处理不要用;getdata/putdata 整幅读写;split 分离通道,Image.merge 合并通道;getbbox、getextrema、histogram 用于内容范围、最值和直方图分析。
绘制与文字:ImageDraw.Draw(img) 直接在图上绘制;rectangle、ellipse、line、text 是常用方法;字体用 ImageFont.truetype 加载。注意 Pillow 10.1+ 中 draw.text 的 font 参数已成为必填项,老代码不传 font 在新版本会直接报错,这是跨版本兼容的高频坑。
滤镜增强:ImageFilter 提供 GaussianBlur、UnsharpMask、FIND_EDGES、CONTOUR、MedianFilter 等;ImageEnhance 的 Brightness、Contrast、Color、Sharpness 四件套按倍率调整;ImageOps 提供 autocontrast、equalize、flip、mirror、fit 和 exif_transpose。
元数据:img.getexif() 返回类字典对象,274 是方向标签,271、272 是厂商和机型,306 是拍摄时间,34853 是 GPSInfo。手机照片必须先做 ImageOps.exif_transpose 方向转正,再做裁剪缩放,否则后续几何操作全建立在错误方向上。
三、八组可直接运行的示例
示例 1:读取、查信息、转格式、保存。- from PIL import Image
- img = Image.open('photo.jpg')
- print(img.format, img.mode, img.size)
- # RGBA 直接存 JPEG 会报错或黑底,先转 RGB
- img.convert('RGB').save('photo.webp', quality=85, method=6)
- img.convert('RGB').save('photo_opt.jpg', quality=85, optimize=True, progressive=True)
复制代码 quality 对 JPEG 和 WebP 都是 0 到 100,80 到 90 之间通常能兼顾体积与画质;optimize=True 会做二次编码压缩但更慢;progressive=True 输出渐进式 JPEG,适合网络加载。
示例 2:批量缩略图流水线。- from pathlib import Path
- from PIL import Image, ImageOps
- def make_thumb(src: Path, dst: Path, size=(800, 800)):
- with Image.open(src) as img:
- img = ImageOps.exif_transpose(img) # 先按 EXIF 方向转正
- img.thumbnail(size) # 原地等比缩小,不放大
- 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 和 resize 的区别要记牢:thumbnail 保持宽高比且绝不放大,resize 会把图硬拉到指定尺寸导致变形。生成缩略图应始终用前者。
示例 3:EXIF 隐私清理与方向修正。- from PIL import Image, ImageOps
- def sanitize(src, dst):
- with Image.open(src) as img:
- img = ImageOps.exif_transpose(img) # 先把方向转正
- img = img.convert('RGB')
- img.save(dst, quality=88, optimize=True) # 不传 exif 即剥离
- sanitize('origin.jpg', 'clean.jpg')
- print(Image.open('clean.jpg').getexif()) # 空 Exif 对象
复制代码 保存时不传 exif 参数就能实现隐私清理,GPS 坐标在 GPSInfo 字段中,剥离 EXIF 时会一并清除。顺序上必须先是 exif_transpose,再是 crop、resize,否则方向信息丢失后无法补救。
示例 4:文字水印与透明角标。- from PIL import Image, ImageDraw, ImageFont
- def add_watermark(img, text='© 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)
- 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
- draw.text((x, y), text, fill=(255, 255, 255, 180), font=font)
- return Image.alpha_composite(img, overlay)
- photo = Image.open('vacation.jpg')
- add_watermark(photo).convert('RGB').save('wm.jpg', quality=90)
复制代码 水印的正确姿势是先画到透明 overlay 上再 alpha_composite 合成,而不是直接在原图上画,这样不破坏原图、可反复叠加,fill 的 alpha 值还能实现半透明。draw.text 的坐标是文字左上角,所以先用 textbbox 量出宽高才能定位到右下角。字体路径按系统替换即可。
示例 5:圆角头像与遮罩合成。- from PIL import Image, ImageDraw, ImageOps
- 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) # 白色不透明,黑色透明
- result.convert('RGB').save(dst)
- rounded_avatar('avatar.jpg', 'avatar_round.png', size=256, radius=48)
复制代码 paste 的 mask 必须是 L 或 1 模式的单通道灰度图,白色不透明、黑色透明。圆角的本质就是拿一张圆角灰度图当透明度模板。
示例 6:九宫格拼图。- from PIL import Image, ImageOps
- def make_grid(tiles, cols=3, cell=300, gap=10, bg=(255, 255, 255)):
- rows = (len(tiles) + cols - 1) // cols
- W = cols * cell + (cols + 1) * gap
- H = rows * cell + (rows + 1) * gap
- canvas = Image.new('RGB', (W, H), 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) 一次拿到行号和列号;粘贴坐标等于间隙加上序号乘以“格子加间隙”,公式写清楚能省大量调试时间。ImageOps.fit 负责等比裁剪到目标尺寸,不会留白。
示例 7:图像增强与风格滤镜。- from PIL import Image, ImageEnhance, ImageFilter, ImageOps
- def vintage_filter(img, 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, dst_dir):
- img = Image.open(src)
- for i, frame in enumerate(ImageSequence.Iterator(img)):
- if i % 3 == 0: # 每 3 帧抽 1 帧
- frame.convert('RGB').save(f'{dst_dir}/frame_{i:03d}.webp', quality=80)
- gif_to_webp_frames('anim.gif', 'frames')
复制代码 ImageSequence.Iterator 返回的帧对象共享底层数据,处理完就失效,不要长期持有。逐帧另存会丢失 duration、loop 等控制信息,这些参数在 img.info['duration'] 里;要保留动画需要手动回填 save_all=True 和 append_images 参数,否则输出只是一张静态图。
四、常见错误与排查思路
RGBA 存 JPEG 报错或黑底:先 convert('RGB') 再保存。
缩略图变形:用了 resize 而不是 thumbnail,后者才保持比例且不放大。
方向不对:exif_transpose 调用太晚,必须在所有几何操作之前。
EXIF 没清掉:保存时仍传了 exif 参数,Pillow 10+ 下只要不传就默认不保留。
draw.text 报参数错误:Pillow 10.1+ 中 font 已是必填。
blend 报错:两张图 mode 或 size 不一致,先统一再混合。
paste 全透明或全黑:mask 不是 L 或 1 模式。
批量处理极慢:在循环里用 getpixel/putpixel,应改用 getdata 或与 numpy 互操作。
目录扫描占满内存:open 是懒加载,扫描阶段不要 load。
GIF 转出来不动:逐帧保存没带 save_all 和 append_images。
五、性能与工程落地建议
批量场景先做轻量索引:只用 open 读取 size、format 和 EXIF,不触发像素解码,再决定哪些图需要真正处理,这是“先索引后处理”架构的基础。
避免逐像素 Python 循环,优先使用 Pillow 内置的 resize、filter、enhance 等 C 层实现;需要与 numpy 交互时走 tobytes 与 Image.frombytes,注意字节序和步长必须严格匹配。
编码参数按场景取舍:optimize=True 体积更小但更慢,progressive=True 利于网络加载,WebP 的 method=6 压缩更充分但耗时更长,quality 建议落在 80 到 90 之间。
以一个“家庭相册整理与隐私清理工具”为例,可以拆成三层:扫描层用懒加载遍历目录,收集尺寸、格式、方向和 GPS 字段;处理层统一做 exif_transpose 和 convert('RGB'),再按需生成缩略图或水印;输出层保存时不带 exif,顺便统一成目标格式。处理链中始终用 img.copy() 保护原图,几何操作前先转正,透明图存 JPEG 前先转 RGB,这三条能挡掉大部分线上事故。
六、总结
Pillow 的价值在于让你对一张图的每个像素、每段元数据、每种格式拥有完整控制权。记住四个点:mode 决定像素结构,thumbnail 才是缩略图正道,exif_transpose 要放在最前面,save 不传 exif 就是最干脆的隐私清理。把这几条和批量管线结合起来,上千张手机照片的转格式、加水印、去 GPS,几十行脚本就能稳稳跑完。 |