1. 为什么是Pillow:图像处理入门的瑞士军刀
1.1 Pillow到底解决了什么问题
做Python开发的人,只要跟图片打过交道,几乎都用过Pillow。这个库在Python生态里的地位,相当于美工手里的Photoshop、厨师手里的菜刀——不是它功能最强大,而是它开箱即用、覆盖面广,日常能碰到的图像处理需求,它基本都能接住。
Pillow的前身是PIL(Python Imaging Library),这个库早在上世纪90年代就存在了,但后来年久失修,对Python 3的支持一直跟不上。Pillow是PIL的友好分支,API基本保持一致,但持续维护、支持新版本Python,还加入了大量新特性。所以现在大家说“用PIL”,实际上装的都是Pillow,导入语句仍然写作from PIL import Image。
它到底能解决什么问题?简单说,就是图像的读取、保存、格式转换、缩放裁剪、颜色调整、滤镜特效、绘制文字图形、批量处理,以及GIF动图、WebP、EXIF信息读取这些进阶操作。比如你想把一个目录下几百张图片全部压缩成WebP格式,或者给图片批量加水印,再或者从一张证件照里提取人脸区域裁出来,Pillow都可以几行代码搞定。
有人可能会问,OpenCV不是也能做这些吗?确实能,但OpenCV的重点在计算机视觉算法,比如人脸检测、目标跟踪、图像配准,它的图像读写反而不是强项,而且安装体积大、依赖多。Pillow的场景定位更像是“图像文件处理工具”,比OpenCV轻量得多,语法也友好得多。适合把Pillow当成第一梯队的工具来学。
1.2 Pillow与OpenCV、scikit-image的定位差异
我经常看到初学者纠结该学Pillow还是OpenCV,甚至有人在论坛上问“我用Pillow做图像处理是不是不够专业”。这种焦虑完全没必要。三个库解决的是不同层面的问题,我用一张表把它们的差异说清楚:
| 对比维度 | Pillow | OpenCV | scikit-image |
|---|---|---|---|
| 核心强项 | 图像IO、格式转换、绘制、批量处理 | 计算机视觉算法、视频流处理 | 科学计算图像算法 |
| 典型场景 | 压缩图片、加水印、缩略图、格式转换 | 人脸识别、目标检测、摄像头实时处理 | 图像分割、特征提取、滤波研究 |
| 依赖复杂度 | 低,pip直接装 | 较高,部分功能需要额外库 | 依赖NumPy、SciPy |
| 学习曲线 | 平缓 | 较陡 | 中等 |
| 像素级操作 | 弱,需转NumPy | 强,原生支持 | 强,原生支持 |
从这张表能看出来,Pillow更像一个“图片文件处理工具”,OpenCV则是“视觉算法引擎”。日常自动化办公、爬虫数据清洗、Web后端处理用户上传图片,Pillow是最合适的。你如果要做人脸识别、车牌识别、视频分析,那再去学OpenCV不迟。两个不是替代关系,是互补关系。
另外还有一个库叫scikit-image,它跟SciPy是一套生态的,函数风格跟OpenCV有些类似,更偏科研和算法复现。我的建议是:普通开发者把Pillow吃透,用到的时候再按需学OpenCV,科研人员再加一个scikit-image。不用一上来就全铺开。
1.3 Pillow的高频应用场景
结合我这几年实际用下来的经验,Pillow最常见的应用场景大概是这些:
- 图片格式转换与压缩:把PNG转JPG、转WebP,给图片降体积,批量处理尤其实用。
- 批量添加水印:给摄影师作品、电商图、公众号头图批量打上版权信息。
- 生成缩略图:网站上传图片后自动生成不同尺寸的预览图。
- 图片裁剪与几何变换:按指定区域裁图、旋转、镜像。
- 颜色调整:增强对比度、饱和度、亮度,做老照片修复前的预处理。
- 验证码生成:爬虫模拟登录时虽然现在用得少了,但老一代爬虫脚本里Pillow是标配。
- 图像数据集预处理:做深度学习前,用Pillow把图片统一尺寸、去EXIF信息。
- GIF动图拆帧与合成:把一个动图拆解成逐帧图片,或者反过来把多张图合成动图。
这些场景每一个都不复杂,但组合起来非常能打。接下来我就从环境搭建开始,把Pillow的核心功能一次讲透。
2. 环境准备与核心概念
2.1 安装与版本确认
安装Pillow很简单,一行命令的事:
pip install pillow如果你的环境里有多个Python版本,或者用了虚拟环境,注意先激活对应环境再安装。国内网络环境下,如果pip下载慢,可以加国内镜像源:
pip install pillow -i https://pypi.tuna.tsinghua.edu.cn/simple装完之后,在Python中确认一下版本号,避免跟老项目里残留的PIL产生混淆:
import PIL print(PIL.__version__) from PIL import Image print(Image.__name__)正常情况下会输出类似10.4.0这样的版本号。这里有一个我自己踩过的坑:如果你以前装过PIL,再装Pillow的时候,Python会把两个包都识别出来,导入时优先找的是PIL目录,但API基本一致,一般问题不大。不过为了干净,建议在新虚拟环境里装,不要跟旧的环境混着用。
Pillow的依赖很少,主要依赖就是Python本身。这在部署到服务器时非常友好,不像OpenCV那样动不动就报缺库、缺DLL。如果安装中途报错,绝大多数情况是pip版本太低,先执行pip install --upgrade pip再装一遍。
2.2 图像模式与像素访问
Pillow里最常用的一个类是Image,打开一张图片就是Image.open()。但在深入之前,先把几个核心概念说清楚。
第一个概念是“图像模式”。Pillow支持多种颜色模式,最常用的是这么几种:
| 模式 | 说明 | 通道数 |
|---|---|---|
| L | 灰度图,每个像素一个0-255的亮度值 | 1 |
| RGB | 真彩色,红绿蓝三通道 | 3 |
| RGBA | RGB加Alpha透明通道 | 4 |
| CMYK | 印刷四色模式 | 4 |
| P | 调色板模式,像素值映射到颜色表 | 1 |
| YCbCr | 视频、JPEG常用的颜色空间 | 3 |
不同模式之间可以直接转换,比如img.convert('RGB')可以把RGBA图转成RGB,把P模式转成RGB。后续做JPEG保存时,RGBA必须转RGB才能保存。
第二个概念是“坐标系”。Pillow的坐标系原点在图片左上角,x轴向右递增,y轴向下递增,单位是像素。比如一张宽800、高600的图,左上角是(0, 0),右下角是(799, 599)。所有裁剪、绘制、像素访问都基于这个坐标系,这个概念不搞清楚,裁剪出来的图永远歪。
第三个概念是“像素访问”。最直接的方式是用getpixel和putpixel:
img = Image.open('photo.jpg') point = img.getpixel((100, 100)) print(point) # 输出(红, 绿, 蓝)或灰度值但这两个方法性能非常差,逐像素循环处理一张几百万像素的图,能把CPU跑冒烟。如果要做像素级操作,正确的做法是转成NumPy数组:
import numpy as np arr = np.array(img) # 转成numpy数组 print(arr.shape) # (高度, 宽度, 通道数) arr = arr[:, :, ::-1] # 通道反转,RGB变BGR img2 = Image.fromarray(arr) # 再转回Image对象这条转NumPy的思路在实际工程里用得非常多,几乎是我写Pillow代码的基础操作。原因很简单:NumPy的向量化运算比Python循环快几个数量级,配合Pillow的Image.fromarray,既能享受NumPy的计算能力,又能借助Pillow的IO和保存能力。
2.3 读取、保存与格式转换
先说读取。Image.open()是惰性加载的,也就是说打开文件时不会立刻把全部像素读入内存,而是先读取文件头信息,真正需要像素时才加载。这个设计对处理大图非常友好,你可以先用它快速获取宽、高、模式、格式等元数据,再决定要不要加载。
from PIL import Image img = Image.open('sample.png') print(img.size) # (1920, 1080) print(img.mode) # RGB 或 RGBA print(img.format) # PNG # 真正加载像素后,后续操作才会触发解码 img.load()再说保存。img.save()支持格式由文件扩展名决定,但也可以通过参数显式指定:
img.save('output.jpg', 'JPEG', quality=85, optimize=True)JPEG有损压缩的quality参数控制质量,范围1-95,默认75。日常压缩图片时我一般用85,既保留了不错的视觉质量,体积也能压缩到比较理想的程度。optimize=True会额外做一次优化,让文件更小一点,但保存时间会加长,适合离线批量处理。
格式转换是Pillow最常见也最简单的操作。比如把PNG转成JPEG:
png_img = Image.open('logo.png') rgb_img = png_img.convert('RGB') rgb_img.save('logo.jpg', quality=90)这里有一个经典坑:PNG可能带透明通道,直接save成JPEG会报错,必须先用convert('RGB')把透明通道去掉,否则保存时会出现类似于cannot write mode RGBA as JPEG的报错。
保存WebP格式也很常用,WebP比JPEG体积更小,适合网页使用:
img.save('output.webp', 'WEBP', quality=80)注意WebP支持透明通道。如果你的Pillow版本比较旧,保存WebP可能会提示缺少WebP支持,升级Pillow到最新版基本就解决了。
3. 动手实战:Pillow核心功能逐个击破
3.1 缩放、裁剪、旋转与镜像
图片缩放有两个方法:resize()和thumbnail(),很多人容易搞混。
resize()是直接指定目标宽高,不管原始比例,所以图片可能被拉伸变形:
img = Image.open('photo.jpg') resized = img.resize((200, 100)) # 强制改成200x100,可能变形thumbnail()则保持图片宽高比,自动缩放到指定尺寸范围内,适合生成缩略图:
img.thumbnail((200, 200)) # 在200x200范围内等比缩小 print(img.size) # 实际结果可能是 (200, 150) 或 (150, 200)注意thumbnail()是原地修改,不会返回新图;resize()则返回新图。这个区别刚学Pillow时很容易犯迷糊。
裁剪用crop(),参数是左上角和右下角的坐标元组:
box = (50, 50, 400, 300) # (left, upper, right, lower) cropped = img.crop(box)这里有个小技巧:right和lower坐标是不包含的。比如(0, 0, 100, 100)实际裁剪了100x100的矩形区域,等价于取0到99的像素。边界算错一格通常肉眼看不出来,但在像素级处理时会出问题。
旋转用rotate(),最容易被忽略的是expand参数:
rotated = img.rotate(45, expand=True, fillcolor=(255, 255, 255))不加expand=True时,旋转后的图片尺寸不变,内容会被裁切掉;加了之后,画布会自动扩展,完整保留旋转后的内容,露出的背景可以用fillcolor指定颜色。
镜像翻转用transpose(),这个方法比rotate(90)更精确,因为它是像素级别的映射,不做插值,不会有质量损失:
flip_left_right = img.transpose(Image.FLIP_LEFT_RIGHT) # 左右翻转 flip_top_bottom = img.transpose(Image.FLIP_TOP_BOTTOM) # 上下翻转 rot90 = img.transpose(Image.ROTATE_90) # 逆时针旋转90度实际做数据增强、图片预处理时,这些几何操作会组合使用。比如我写过一个数据增强脚本,对每张图片随机做0到3次90度旋转加一次水平翻转,几行代码就把训练集扩充了好几个批次。
3.2 颜色处理:通道分离、点运算与增强
颜色处理是Pillow比较有意思的部分,它的API设计得很直观,像一个个小积木可以搭出很多效果。
分离和合并通道用split()和merge():
r, g, b = img.split() merged = Image.merge('RGB', (r, g, b))通道分离有个很好玩的用途:交换通道产生伪彩色效果。比如把红蓝通道对调,原本蓝色的天空会变成橙红色,这在艺术创意处理中经常能玩出花。红外摄影、遥感伪彩色合成,原理上也都是通道的重新组合。
point()方法可以对每个像素做映射变换,类似于给每个像素值套一个函数:
# 把图片亮度取反,相当于反色 inverted = img.point(lambda x: 255 - x) # 增加亮度 brighter = img.point(lambda x: min(x + 30, 255))point()内部用查表实现,速度很快,适合做全局像素变换。很多网上的“Pillow亮度调整代码”其实就是在point()里加一个偏移量。
如果想调整的东西更多、更精细,直接用ImageEnhance模块,它能调整亮度、对比度、锐度、色彩饱和度四大类,参考下面的写法:
from PIL import ImageEnhance enhancer = ImageEnhance.Contrast(img) contrast_img = enhancer.enhance(1.5) # 对比度提升50% enhancer = ImageEnhance.Brightness(img) bright_img = enhancer.enhance(1.2) # 亮度提升20% enhancer = ImageEnhance.Sharpness(img) sharp_img = enhancer.enhance(2.0) # 锐度增强 enhancer = ImageEnhance.Color(img) color_img = enhancer.enhance(1.3) # 色彩饱和度提升30%enhance参数小于1时减弱属性,大于1时增强。这套API内部其实是通过Image.blend来实现的,底层逻辑是原图和一个特殊版本图像的混合,所以性能也不错。
老照片修复、图像增强等场景里,我通常先ImageEnhance.Color把饱和度提一点,再用Contrast增加层次感,最后用Sharpness做锐化,三步操作配合使用,效果比单纯做一个滤镜好很多。
3.3 滤镜与图像特效
Pillow自带一个ImageFilter模块,里面有十几种内置滤镜,从基础的模糊、锐化,到轮廓提取、边缘增强都有。
from PIL import ImageFilter blur = img.filter(ImageFilter.BLUR) # 普通模糊 gaussian = img.filter(ImageFilter.GaussianBlur(2)) # 高斯模糊,半径2 edges = img.filter(ImageFilter.FIND_EDGES) # 边缘提取 contour = img.filter(ImageFilter.CONTOUR) # 轮廓提取 sharpen = img.filter(ImageFilter.SHARPEN) # 锐化这里面GaussianBlur最常用,比如做背景虚化、给图片做毛玻璃效果。FIND_EDGES则能提取图片的轮廓信息,配合反色可以做出类似素描线稿的效果。
举个例子,把边缘提取结果反色之后叠加到原图上,可以做出简单的“手绘风”:
from PIL import Image, ImageOps, ImageFilter img = Image.open('photo.jpg').convert('L') # 转灰度 edges = img.filter(ImageFilter.FIND_EDGES) edges = ImageOps.invert(edges) # 反色,白色背景黑色线条 # 也可以把边缘图柔化后再和原图混合 blurred = img.filter(ImageFilter.GaussianBlur(1)) result = Image.blend(blurred, edges, 0.5)这种玩法在创意视觉、海报设计里很实用。做完这些滤镜操作,输出结果时建议用PNG保存,避免JPEG压缩二次损失细节。
有一点值得注意:Pillow的滤镜都是预置好的,无法像OpenCV那样自定义卷积核。如果要做任意卷积滤波,还是得转成NumPy自己实现,或者交给OpenCV。Pillow适合快速出效果,不太适合做算法研究。
3.4 合成与透明:抠图拼图实战
图像合成包括blend、composite、paste三个方法,它们的区别值得好好说说。
blend要求两张图尺寸完全一致,直接按权重混合:
img1 = Image.open('bg.jpg') img2 = Image.open('fg.jpg') blended = Image.blend(img1, img2, alpha=0.3)alpha取0到1之间,0表示完全显示img1,1表示完全显示img2。这个功能像“多张照片淡入淡出”的过渡效果,做幻灯片切换动画时很常用。
composite可以根据mask掩码决定每个像素取哪张图的颜色:
composited = Image.composite(img1, img2, mask)日常用得最多的是paste,把一张图贴到另一张图上。最经典的应用就是给图片加Logo水印或者贴图:
background = Image.open('bg.jpg').convert('RGBA') logo = Image.open('logo.png').convert('RGBA') # logo带透明通道 background.paste(logo, (100, 100), logo) # 第三个参数是mask这里第三个参数传logo,表示用logo的Alpha通道作为透明掩码。有些新手直接把透明PNG贴上去,结果透明区域变成了黑色色块,原因就是没传mask参数。
把背景和贴图都转成RGBA模式是个好习惯,否则在合成时容易出现颜色异常。处理完成后如果想要JPEG输出,记得再转回RGB。
3.5 批量处理与自动化
Pillow的批量处理能力是我工作中用到最多的功能之一。最典型的场景:一个文件夹里几百张照片,要全部压缩、重命名、加水印。
from pathlib import Path from PIL import Image, ImageDraw, ImageFont input_dir = Path('photos') output_dir = Path('compressed') output_dir.mkdir(exist_ok=True) for path in input_dir.glob('*.jpg'): img = Image.open(path) img.thumbnail((1280, 1280)) # 最长边不超过1280 # 加版权水印 draw = ImageDraw.Draw(img) font = ImageFont.truetype('Arial.ttf', 24) draw.text((20, img.height - 50), 'Copyright 2024', fill=(255, 255, 255), font=font) img.convert('RGB').save(output_dir / path.name, quality=85)这段代码里有一个细节值得注意:Image.open打开的是同一个Path对象,处理完成后文件可能还被Pillow持有句柄,如果后续要删除原文件,需要先img.close()或者让img对象离开作用域,否则在Windows上会报文件被占用。
批量处理时还容易遇到一个跟手机有关的坑:手机拍摄的JPEG图片通常带有EXIF方向信息。在电脑上打开是正的,但在某些不读取EXIF的软件里就是横着的。解决办法是使用ImageOps.exif_transpose:
from PIL import ImageOps img = Image.open('phone_photo.jpg') img = ImageOps.exif_transpose(img) # 自动按照EXIF方向修正这个函数会读取EXIF中的方向标记并实际旋转像素,处理完后保存的图片在任何软件里都是正的。我做批量图片压缩时,每次都会先执行这一步,算是经验之谈。
4. 进阶主题:内存操作与实用技巧
4.1 在内存中处理:BytesIO实战
很多场景下,图片不是存在硬盘上的,而是从网络抓下来,或者从数据库读取出来的二进制数据。这时候不用先存成临时文件,直接用BytesIO在内存中处理。
import requests from io import BytesIO from PIL import Image resp = requests.get('https://example.com/image.jpg') img = Image.open(BytesIO(resp.content)) # 处理完后直接保存成新文件 img.convert('RGB').save('downloaded.jpg', quality=90)反过来,也可以把处理好的图片保存到BytesIO,在上传云存储或传给第三方接口时非常方便:
from io import BytesIO buffer = BytesIO() img.save(buffer, format='JPEG', quality=85) data = buffer.getvalue() # 二进制的JPEG数据 # 可以直接作为 requests.post 的 files 参数 files = {'file': ('photo.jpg', data, 'image/jpeg')}这种方式比先落盘再读取少了一次磁盘IO,在高并发场景下能节省大量时间。我在爬虫项目里抓图、做图片压缩接口时都用的这套逻辑。
有一点需要注意:BytesIO用完最好调用buffer.close(),或者用with语句管理,否则会一直占用内存。另外在从BytesIO打开Image后,如果原始BytesIO对象被垃圾回收,Image对象仍然能正常操作,不用担心。
4.2 图片加文字与中文乱码
用Pillow在图片上写字是高频需求,比如加水印、评分卡、海报文案。核心API是ImageDraw.text,但坑很多,最大的坑就是中文字体。
from PIL import Image, ImageDraw, ImageFont img = Image.open('bg.jpg') draw = ImageDraw.Draw(img) # 指定字体文件路径,否则默认字体不支持中文 font = ImageFont.truetype('PingFang.ttc', 36) draw.text((50, 50), '你好,世界', fill=(255, 255, 255), font=font)Pillow自带一个默认位图字体,不支持中文,直接写中文会变成一堆方框。解决方法是ImageFont.truetype()指定一个支持中文的字体文件。不同系统的字体路径不同:
- Windows:
C:\Windows\Fonts\msyh.ttc(微软雅黑)或simhei.ttf(黑体) - macOS:
/System/Library/Fonts/PingFang.ttc或/System/Library/Fonts/STHeiti Light.ttc - Linux:
/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc(文泉驿正黑)
我建议在代码里做一次字体路径探测,或者干脆把字体文件复制到项目目录里统一引用,这样部署到服务器之后不会因为字体缺失而出问题。
设置文字位置时,如果想让文字居中,需要先获取文字宽度和高度:
bbox = draw.textbbox((0, 0), '沙雕水印', font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] # 把文字放在图片中间 x = (img.width - text_w) // 2 y = (img.height - text_h) // 2textbbox返回的是文字实际占据的边界框,比用textlength只算宽度更准确。文字垂直居中时特别注意中文的ascent和descent差异,直接把字体高度除以2往往偏上。
4.3 绘制图形与制作圆角图片
ImageDraw不仅能写字,还能画矩形、线条、椭圆、多边形,做简单的图像标注工具或者给图片加边框都靠它。
from PIL import Image, ImageDraw draw = ImageDraw.Draw(img) draw.rectangle([50, 50, 200, 150], outline=(255, 0, 0), width=3) draw.line([(0, 0), (img.width, img.height)], fill=(0, 255, 0), width=2) draw.ellipse([100, 100, 300, 300], fill=(0, 0, 255), outline=(255, 255, 255))图形绘制有个小技巧:先画在一个透明图层上,再叠加到原图,这样方便统一调整透明度或撤销操作。
圆角图片是很多UI场景的需求。Pillow没有直接提供圆角函数,但可以用mask加paste实现:
from PIL import Image, ImageDraw def rounded_corner_img(img, radius=30): mask = Image.new('L', img.size, 0) draw = ImageDraw.Draw(mask) draw.rounded_rectangle([0, 0, img.width, img.height], radius=radius, fill=255) output = Image.new('RGBA', img.size, (0, 0, 0, 0)) output.paste(img, (0, 0), mask) return output这段代码先创建一个灰度mask,圆角内填充白色,圆角外是黑色,然后用mask作为透明通道把原图贴到透明画布上,四个角就自然变成透明了。用来生成头像、卡片、弹窗背景都很好用。
4.4 GIF动图处理与图像序列
GIF是Pillow进阶功能里比较有意思的一块。它可以读取GIF的每一帧,逐帧处理,再保存成一个新动图。比如给GIF加水印、降低帧率、抽帧。
from PIL import Image, ImageSequence img = Image.open('animation.gif') frames = [] for frame in ImageSequence.Iterator(img): # 把每一帧都转成RGBA,方便处理 frame_rgba = frame.convert('RGBA') frames.append(frame_rgba) # 保存成新的GIF frames[0].save( 'output.gif', save_all=True, append_images=frames[1:], duration=100, # 每帧显示间隔,单位毫秒 loop=0 # 0表示无限循环 )保存GIF时有两个坑。第一个是颜色数量控制,GIF最多256色,如果原图颜色太丰富,保存时会自动抖动处理,图片质量会下降。第二个是duration参数,如果设置太小,动画会飞速播放;设置太大,又变得卡顿,一般100到200毫秒比较自然。
另外GIF的透明处理比较麻烦,某些帧可能不带Alpha通道,转成RGBA时要注意。处理完再转回P模式保存时,透明区域可能变成黑色,这时候需要手动指定透明颜色:
frames[0].save('output.gif', save_all=True, append_images=frames[1:], transparency=0)看GIF帧数可以用img.n_frames,在拆分GIF做逐帧分析时能省不少事。
4.5 处理超大图片的内存控制
Pillow在读取超大图时内存占用会很高。比如一张6000x4000的RGB照片,解码后大约需要72MB内存,这还没算后续处理过程中的临时拷贝。如果图片更大,或者运行环境内存本来就紧张,就需要做一些控制。
好在Image.open是惰性的,不会立刻加载全部像素。但一旦调用resize、filter等操作,Pillow就必须把像素解码到内存中。一个实用的优化思路是draft()方法:
img = Image.open('huge.jpg') img.draft('RGB', (1024, 1024)) # 告诉Pillow只需要解码到大约1024x1024尺寸draft()会尽量只解码需要的数据,对JPEG这种有渐进解码支持的格式效果比较明显。但要注意,draft()不一定生效,需要看具体格式,而且必须在加载像素之前调用。
另一个更普遍的做法是先看宽高再决定处理策略:如果图片太大,先降采样再处理。比如先按比例缩到宽2000以内再做滤镜操作,处理速度和内存占用都会好很多。Pillow的resize在缩小图片时默认使用LANCZOS重采样,质量比较高,但速度稍慢;简单缩略图用NEAREST或BILINEAR更快。
如果遇到比内存还大的极端场景,比如几亿像素的遥感影像,Pillow就不太适合了,那需要用imageio、tifffile、或者像GDAL这样的专业库。Pillow适合处理“日常大图”,不适合处理“超大规模遥感数据”。
5. 常见问题与排查技巧
5.1 高频报错与解决方案
Pillow用多了之后,会发现报错基本就围绕那么几个原因。我把这几年最常见的问题整理成了表格:
| 报错信息 | 出现原因 | 解决办法 |
|---|---|---|
OSError: cannot identify image file | 文件不是有效图片,或者格式扩展名与实际内容不符 | 用十六进制查看文件头,确认真实格式 |
Cannot write mode RGBA as JPEG | RGBA图直接保存为JPEG | 先convert('RGB')再保存 |
OSError: image file is truncated | 图片文件不完整或被截断 | ImageFile.LOAD_TRUNCATED_IMAGES = True,或重新下载文件 |
DecompressionBombError | 图片尺寸超过Pillow默认安全阈值 | 调大Image.MAX_IMAGE_PIXELS,或分批处理 |
UnicodeDecodeError | 中文文件名或路径编码问题 | 用pathlib.Path处理路径,不做字符串拼接 |
AttributeError: module 'PIL' has no attribute 'Image' | 导入方式错误,或安装包名冲突 | 用from PIL import Image,不要用import PIL |
其中image file is truncated在爬虫场景最常见。从网上抓下来的图片,经常因为网络原因只下载了一半,Pillow打开就会报这个错。最简单的处理方式是在打开前检查文件大小,只有一个很小的值(比如几百字节)就直接判为无效文件,不进入后续流程。
如果偏要处理不完整的JPEG,可以加这句:
from PIL import ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True但这样只能修复部分文件,还可能出现像素残缺。做正规项目时我一般不建议这么干,宁可把坏图丢掉。
5.2 性能优化与批量加速
Pillow处理速度的问题,归根结底是两个:像素级操作太慢、大量小图操作太慢。
像素级操作慢,解决方案是转NumPy。前面提到过np.array(img)和Image.fromarray(arr)这对组合。举例来说,你要给所有RGB像素做一次阈值分割,用getpixel循环可能要几秒,用NumPy直接向量化运算连一毫秒都用不到。
大量小图操作慢,要考虑并发。Pillow在处理图片时,某些操作会释放GIL(比如编解码部分),但纯Python层面的处理还是受GIL限制。如果面对几百张甚至上千张图片,最简单的方式是用multiprocessing或者concurrent.futures.ProcessPoolExecutor,把不同图片分给不同进程处理。
from concurrent.futures import ProcessPoolExecutor from pathlib import Path from PIL import Image def compress_one(path: Path): img = Image.open(path) img.convert('RGB').save(path.with_suffix('.compressed.jpg'), quality=80) return path.name if __name__ == '__main__': paths = list(Path('photos').glob('*.jpg')) with ProcessPoolExecutor(max_workers=4) as pool: result = list(pool.map(compress_one, paths))我这里用ProcessPoolExecutor而不是ThreadPoolExecutor,是因为在Windows上Pillow的多线程提升有限,而多进程可以完美利用多核CPU。注意进程池代码要放在if __name__ == '__main__'里面,否则会报递归错误。
还有一个隐蔽的性能问题:Image.open时不加载像素,但如果调用img.size、img.mode这些属性,也会触发文件头的读取。如果同一个文件被反复open,可以考虑把img.copy()缓存起来用。不过大多数场景下这不是瓶颈,真正耗时的大头是编解码和滤镜操作。
5.3 实战中容易被忽略的几个细节
最后分享几个我每次写Pillow代码都会注意的细节,都是血泪教训换来的。
第一个细节:处理完图片要关文件。Image.open返回的对象持有文件句柄,在Windows上如果不显式关闭,后面删除、重命名文件都会报错。最好的做法是用with语句:
with Image.open('photo.jpg') as img: img.thumbnail((800, 800)) img.save('thumb.jpg')Image对象支持上下文管理器,退出时自动关闭。
第二个细节:在Web后端保存用户上传的图片时,不要直接信任文件扩展名。攻击者可以上传一个恶意文件伪装成jpg,如果服务器直接处理,轻则保存失败,重则可能造成安全问题。安全做法是用Pillow重新打开一次再保存,让Pillow重新编码图片,顺带去掉原图里可能隐藏的恶意代码。
from PIL import Image img = Image.open(upload_file) # 验证能否被Pillow识别 img.load() img.thumbnail((2000, 2000)) # 顺便限制尺寸 # 然后保存成自己指定的文件名和格式这样处理后,输出的一定是标准的JPEG或PNG文件。
第三个细节:给图片做压缩或尺寸调整时,最好先做一次色彩模式标准化。很多手机拍的图片是RGB,但某些截图工具生成的是P模式或CMYK,统一转成RGB再处理,可以避免很多隐性问题。
第四个细节:保存文件名不要用中文,或者至少要处理好编码。在Linux服务器上中文文件名问题不大,但在Windows上和某些云存储SDK里容易翻车。我习惯用时间戳加UUID的方式生成文件名,安全又省事。
写在最后
说回Pillow这个库,它在我做过的项目里出现的频率,比OpenCV高得多。爬虫抓图要归它管,后端接口处理上传图要归它管,制作数据集要归它管,连给博客图片压缩体积这种事我也习惯写个几行的脚本交给它。它的API简单到不需要翻文档,但真要抠起来,又能在细节里挖出不少门道。
我个人在实际使用中的体会是:Pillow不是一门“需要专门学习”的技术,而是那种“用到的时候自然就学会了”的工具。如果非要给一个学习路径的建议,我建议你把本文第二、第三部分的示例代码全部手敲一遍,然后找一个真实的批量处理场景练手——比如把自己手机里的两千张照片批量压缩一遍,再给它们加上日期水印。这一趟下来,Pillow的八成功能你都实操过了。
最后再分享一个小技巧:遇到Pillow不知道怎么写的功能,不急着查官方文档,先在交互式环境里用help(Image)看一遍所有属性和方法,很多灵感和解决方案就是这么冒出来的。工具的价值从来不在于功能列表有多长,而在于你真正需要的时候,正好知道用它的哪一招。Pillow就是这样一把趁手的瑞士军刀,希望你也能用好它。