1. Pillow模块是什么,为什么值得花时间系统学一遍
做Python图像处理,绕不开的就是Pillow这个库。它是早期PIL(Python Imaging Library)的延续分支,PIL当年因为Python 2时代停止维护,社区后来接手做了兼容Python 3的fork,也就是现在的Pillow。很多入门教程里写from PIL import Image,这个PIL包名其实指向的就是Pillow,这个历史遗留的命名习惯坑了不少新手,但也没必要纠结,记住一条规则就行:安装写pip install pillow,import写from PIL import ...。
Pillow能做什么?简单说,凡是你想用代码对图片做的常规操作,它基本都覆盖了:打开图片、读取尺寸和颜色模式、裁剪、缩放、旋转、翻转、调亮度对比度、加滤镜、绘文字画图形、加文字水印、多图拼接、GIF动图逐帧处理、批量格式转换。它不擅长的事情也很明显:复杂的图像识别、深度学习相关的特征提取、高精度图像分割这类任务,要交给OpenCV或者专门的目标检测框架,Pillow定位是轻量级图像处理工具库,不是视觉计算框架。
我见过不少把Pillow和OpenCV混为一谈的提问,实际上两者定位差别很大。OpenCV偏重视频流处理、摄像头采集、特征点匹配、矩阵运算这类底层视觉任务,图像数据在OpenCV里是以numpy数组为核心来操作的,BGR通道顺序、像素矩阵翻转这些概念对新手不太友好。Pillow则更接近“日常修图工具”,API设计直观,读代码像读自然语言,适合快速解决实际业务里常见的图片处理需求,比如网页上传头像裁剪、博客封面图批量压缩、给一批截图加水印、生成缩略图、做简易表情包。
这篇文章适合谁看?刚学完Python基础语法、想做点有视觉效果的小项目的初学者,可以在读完后独立完成一批图片批处理脚本;被领导临时派了“把这几百张图统一改成800x600并加logo”这类任务的打工人,可以照着文里的例子直接抄作业;已经在用Pillow但只停留在Image.open和save两个方法、想系统补全功能的开发者,也能从后面的进阶部分找到新东西。我会把我实际踩过的坑和排查思路都写进去,尽量让你少走弯路。
2. 安装环境与导入机制,先把地基打稳
2.1 安装命令和版本选择
安装Pillow没什么特殊的,稳定的公共源或者国内镜像源都能装:
pip install pillow如果需要指定版本,比如某个老项目锁定版本环境,就写成:
pip install pillow==10.2.0我的建议是优先用较新的稳定版本,因为Pillow对Python 3.12、3.13这类新版本的兼容性问题,在老版本上更容易暴露。你说你还在用Python 2或者Python 3.6以下?那我的建议是先把Python版本升上去,Pillow新版已经基本放弃了对极旧Python版本的支持,硬在老环境里跑老Pillow,遇到的坑往往比升级成本还高。
装完之后验证是否成功,在Python交互环境里执行:
from PIL import Image print(Image.__version__)能打印出版本号就说明安装正常。如果这里报ModuleNotFoundError,优先检查是不是有多个Python环境混用了——很多人电脑里系统自带一个Python,又装了Anaconda,还在项目里建了虚拟环境,三个地方各自管各自的pip,你在A环境装的包,换到B环境跑自然找不到。这个问题的排查思路很直接:which python和which pip(Windows对应where python、where pip),确认当前命令行用的是哪个解释器,然后用同一个解释器的pip去安装。
2.2 包名PIL和Pillow的关系要理清
这是新手最容易懵的地方。Pillow这个发行包,安装之后提供的import名称是PIL,也就是说你代码里写的from PIL import Image,导入的实际上是Pillow这个包的内容。Pillow官方解释过:为了兼容老项目里大量写死的import PIL语句,它们延续了原来的包名。所以你在代码里完全看不到import pillow这种写法,这是历史包袱,不是你的问题。
另一个需要注意的是,一个项目环境里不要同时装PIL和Pillow,两者会冲突。我见过有人为了“兼容”把两个都装上,结果import PIL之后到底用的是哪个全看运气,出了诡异的问题还不好排查。装Pillow之前如果发现有个叫PIL的旧包残留,先卸载干净再说。
2.3 环境隔离是省心必须
做图像处理相关的小项目,我始终建议在虚拟环境里干活。不管你是用venv、virtualenv还是conda,把项目依赖隔离在独立环境里,能避免很多“本地能跑、换台机器就炸”的尴尬。
python -m venv pillow_env source pillow_env/bin/activate # Windows使用 pillow_env\Scripts\activate pip install pillow虚拟环境的好处在于:你写博客或者发项目代码的时候,只需要附一个requirements.txt,别人克隆下来就能复现环境,不用对着系统Python乱炖一锅。这也算是后续写完整项目的一个好习惯,Pillow本身是单一依赖库,环境问题不明显,但等你后面引入numpy、matplotlib、requests这类库的时候,环境管理会直接决定你的幸福指数。
3. 图像读取与基础属性,从打开一张图片开始
3.1 打开图片的三种姿势
Image.open是使用频率最高的入口方法。它最常见的用法是传入文件路径:
from PIL import Image img = Image.open("example.jpg") print(img)输出结果类似<PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=1920x1080 at 0x...>,这一行信息其实很有用,mode和size一眼就看出来了。
Image.open在默认情况下是惰性加载的,也就是说它并不会真的把所有像素数据读进内存,而是先读取文件头信息(尺寸、模式、格式等),真正访问像素的时候才加载数据。这在处理大量图片时能省不少内存,但也带来一个坑:如果你open之后立刻关闭了源文件,或者文件句柄被系统回收,之后再访问图像数据就可能报错。稳妥的做法是确保图片对象在生命周期内源文件始终可访问,或者干脆用img.load()强制加载像素数据到内存,之后再关掉文件句柄就无所谓了。
第二种姿势是从文件对象读取:
with open("example.png", "rb") as f: img = Image.open(f) # 注意,在with块内完成图像操作,退出快后文件对象关闭 img.load()这种方式常用于文件在网络上或者从别处临时拿到的情况,你手里只有文件对象,没有文件路径。第三种是从BytesIO缓冲区读取,这在处理网络请求返回的图片数据时极其常用:
import requests from io import BytesIO from PIL import Image resp = requests.get("https://some-site.com/pic.jpg") img = Image.open(BytesIO(resp.content))脚本程序在爬虫场景或者工单系统里遇到多数是这种:请求返回的内容是二进制,不能直接丢给Image.open(它不会帮你解析),必须包一层BytesIO。这个细节我见过好几个人卡住,报错内容通常是OSError: cannot identify image file,英文提示翻译过来就是“识别不了这个图片文件”,其实并不是文件坏了,而是你给它的入口不对。
3.2 图片属性:size、mode、format
拿到Image对象之后,有三个属性是最高频用到的:
img.size:返回(width, height)的元组。注意这个顺序,先宽后高,跟很多人习惯的“高x宽”正好反着,写代码的时候过一遍脑子。img.mode:返回颜色模式字符串。常见的有RGB(真彩色)、RGBA(带透明通道)、L(8位灰度)、P(调色板模式)、CMYK(印刷色彩)、I(32位整型)等。这个属性是很多坑的根源,后面会详细说。img.format:返回图片原始格式,如JPEG、PNG、GIF。用BytesIO打开的网络图片,这个属性往往是None,因为Pillow还没真正识别文件格式,不过大多数场景下你也不需要用这个属性判断格式。
判断一个目录下图片的基本信息,可以很快写个小脚本:
from PIL import Image from pathlib import Path for path in Path("images").iterdir(): if path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".bmp"}: img = Image.open(path) print(f"{path.name}: {img.size}, {img.mode}, {img.format}")这个简单脚本在实践里能派上很大用场:很多素材管理、图片上传服务,第一步就是做合规性校验——尺寸是否符合要求、模式是否是预期的RGB、格式是否被允许。你可以在这一步就把不合规的图片提前过滤掉,避免后面渲染时出幺蛾子。
3.3 模式转换是基本功
convert方法是Pillow里最常用也最容易被忽视的方法。它的核心作用是在颜色模式之间转换。最常见的转换:
img_rgb = img.convert("RGB") img_gray = img.convert("L") img_rgba = img.convert("RGBA")为什么这个操作如此重要?因为很多图像处理方法对模式有硬性要求。比如你给一个RGBA模式的PNG加一个需要背景填充的滤镜,或者JPEG格式根本不支持透明通道,直接保存时Pillow会报OSError: cannot write mode RGBA as JPEG。解决办法就是在保存前把模式转成RGB,透明部分要么丢弃,要么用纯色背景先合成。还有一种常见场景是把灰度图转RGB送去模型服务,对方要求的输入是三通道图片,直接拿L模式的数据会报通道数不匹配。
调色板模式P对应的坑更多。有些PNG是从网络下载的,模式是P,上面挂着调色板,直接对这类图片做强处理会出现颜色错乱的情况。常规思路是先convert("RGBA")或者convert("RGB")再操作,把调色板彻底展开成真实像素值,后续处理就顺了。
我总结一句话:凡是遇到模式相关的报错,第一反应就是加一次convert,转成你需要的目标模式。这不一定是唯一解法,但常常是最省事的解法。
3.4 保存图片的格式章法
img.save()是出口方法,但它有很多隐藏细节。第一是格式推断,Pillow会根据你提供的文件扩展名自动判断保存格式,所以:
img.save("output.png") # 保存为PNG img.save("output.jpg") # 保存为JPEG img.save("output.webp") # 保存为WebP第二是质量参数,JPEG和WebP这类有损格式支持用quality控制压缩质量,取值范围0到100:
img.save("compressed.jpg", quality=85)实践里quality=85是一个很好的平衡点,肉眼几乎看不出和原图的差别,但文件体积能小一大截。有些场景需要极致压缩,比如上传图片到内容平台,quality可以降到60-70,但降到50以下画质损失会明显可见,我不建议常规场景低于60。
第三是优化标志。optimize=True会让编码器额外花时间寻找更紧凑的压缩方式,对JPEG和PNG有效。实测下来,对某些图片可以再省5%到15%的体积,代价是保存速度变慢。批量处理大量图片时不一定划算,单张和对保存时间不敏感的场景可以开。
第四是色彩空间的坑。Pillow保存PNG时默认保留RGBA信息,但保存JPEG时如果有透明通道会报错。此外,保存CMYK模式的图到JPEG时,如果原图不带ICC色彩配置信息,部分看图软件显示出来的颜色会跟屏幕上看到的有偏差。这种偏差属于色彩管理范畴,普通业务场景不用太深究,但如果你的图片要用于印刷或者电商商品图,就需要引入ICC配置文件的概念,Pillow的icc_profile参数可以处理这个,这是另一个大话题了。
4. 几何变换与像素处理,图片的“整形手术”
4.1 缩放图片,resize和thumbnail有本质区别
resize是最直观的缩放方法,直接传入新尺寸:
img_resized = img.resize((800, 600))注意它不会保持原始宽高比。你让一张1920x1080的图变成800x600,它会硬生生地把横向比例压扁,效果就是人物变“矮胖”、建筑变“瘦高”。要等比缩放,得自己算比例:
w, h = img.size target_w = 800 target_h = int(h * target_w / w) img_resized = img.resize((target_w, target_h))thumbnail则不一样,它是Pillow专门为生成缩略图设计的方法,自动保持比例,并且只会缩小不会放大,原地修改Image对象,不返回新对象:
img.thumbnail((800, 800)) print(img.size) # 输出:等比例缩放后不超过800x800的尺寸实践里这两个方法的选择很简单:如果想要指定尺寸且不在乎比例变形,用resize(比如很多证件照尺寸是固定比例,用户上传的图会被拉伸裁切);如果想要生成一个“最大不超过某尺寸”的等比缩略图,用thumbnail。
resize的重采样参数值得单独说。Pillow的resize支持resample参数,常见取值有:
Image.NEAREST:最近邻,速度快,但放大后马赛克感严重,适合像素艺术风格图。Image.BILINEAR:双线性插值,速度较快,缩放效果一般。Image.BICUBIC:双三次插值,效果较好,速度也慢一些。Image.LANCZOS:兰索斯滤波,效果最好,速度最慢,适合高质量缩小。
我的选择习惯是:缩小常用LANCZOS,因为缩小需要对像素做低通滤波,否则容易出摩尔纹和锯齿;放大常用BICUBIC,放大更需要平滑填充,LANCZOS在某些放大场景会显得过于锐利。如果对速度有硬性要求,比如实时缩略图服务,可以用BILINEAR兜底。
4.2 裁剪和粘贴,图像拼接的基础
裁剪用crop方法,传入一个四元组(left, upper, right, lower),四个值分别是左边界、上边界、右边界、下边界。注意这个坐标体系是笛卡尔坐标系的二维平面,原点在图片左上角,x轴向右,y轴向下:
box = (100, 100, 500, 400) cropped = img.crop(box)裁剪的边界是左闭右开的,也就是说(100, 100, 500, 400)实际截取的是x从100到499、y从100到399的像素区域,宽400、高300。这个细节在精确裁剪时很重要,差一个像素可能就是偏了一格。
paste和crop是天生一对,用来把一块图像贴到另一块上:
img.paste(cropped, (0, 0))这里第二个参数是粘贴的左上角坐标。paste还支持一个mask参数,这玩意儿是处理透明贴图的关键,后面做水印和图像合成时要靠它。
4.3 旋转和翻转,注意expand参数
rotate方法按角度旋转,逆时针为正:
img_rotated = img.rotate(45)默认情况下,Pillow旋转后不会扩展画布尺寸,而是把超出边界的部分裁掉,结果图尺寸和原图一样,但边缘被切了,四个角露出来的区域是黑色填充。要保留完整内容、让画布跟着旋转后的矩形范围扩展,加expand=True:
img_rotated = img.rotate(45, expand=True)旋转默认会用黑色填充空白区域,可以通过fillcolor参数改成其他颜色:
img_rotated = img.rotate(45, expand=True, fillcolor="white")transpose系列则负责镜像翻转:
img_flip_lr = img.transpose(Image.FLIP_LEFT_RIGHT) # 左右镜像 img_flip_tb = img.transpose(Image.FLIP_TOP_BOTTOM) # 上下镜像另外transpose(Image.ROTATE_90)这类旋转90度、180度、270度的操作有一个重要优势:它是非插值的,不会引起像素重采样,旋转后的图像质量无损,而且速度极快。所以如果只是做直角旋转,永远优先用transpose而不是rotate。
4.4 像素级访问:getpixel和putpixel
有些需求是精细到单个像素的,比如把某个颜色范围的像素统一替换成另一种颜色,或者做简单的反色处理。最直接的方式是:
img_rgb = img.convert("RGB") w, h = img_rgb.size pixels = img_rgb.load() # 返回PixelAccess对象,可直接索引 for y in range(h): for x in range(w): r, g, b = pixels[x, y] if r > 200 and g > 200 and b > 200: pixels[x, y] = (255, 0, 0)img.load()返回的PixelAccess对象支持类似于二维数组的索引方式pixels[x, y],读和写都行。但这里有个很实在的性能提醒:双重循环逐像素操作在Python里是出了名的慢,一张1920x1080的图就是两百多万个像素,每个像素做一次Python层面的判断,跑起来能让你等得怀疑人生。如果图片尺寸大而且处理逻辑复杂,建议用numpy数组来承载像素数据,把循环交给C层面的数组运算,速度能快几十倍甚至上百倍。
Pillow可以通过np.array(img)直接转到numpy数组,处理完再用Image.fromarray(array)转回Image对象:
import numpy as np arr = np.array(img) # 对arr做矢量化操作 np_arr = np.where(arr > 200, 255, arr) img_new = Image.fromarray(np_arr)这一步带来的性能提升是质的飞跃,值得专门花半小时了解一下。虽然这篇博文主要讲Pillow本身,但Pillow和numpy的组合拳在实际项目中太常见了,我不能不提。
5. 滤镜、增强、绘制与水印,图片的“化妆术”
5.1 内置滤镜,ImageFilter一行代码搞定
ImageFilter模块内置了一批现成的滤镜效果:
from PIL import Image, ImageFilter img_blur = img.filter(ImageFilter.BLUR) img_gaussian = img.filter(ImageFilter.GaussianBlur(radius=2)) img_contour = img.filter(ImageFilter.CONTOUR) img_emboss = img.filter(ImageFilter.EMBOSS) img_sharpen = img.filter(ImageFilter.SHARPEN) img_edge = img.filter(ImageFilter.FIND_EDGES)这里我需要展开讲讲GaussianBlur和普通BLUR的区别。BLUR是一个固定半径的简单模糊,效果比较生硬;GaussianBlur是基于高斯核的卷积模糊,半径可以用radius参数控制,数值越大模糊越重,效果平滑自然。实际业务中做背景虚化、人脸马赛克后的过渡模糊、生成毛玻璃效果时,高斯模糊是主力。
CONTOUR和FIND_EDGES都是边缘检测类滤镜,前者会输出类似手绘轮廓的效果,后者输出的是黑白底加彩色边缘线的效果。这两类滤镜在做图片风格化、生成预览图占位图的时候比较常用。EMBOSS是浮雕效果,会让图像看起来有立体凹凸感。
ImageFilter还支持自定义卷积核,用ImageFilter.Kernel传入一个矩阵:
# 3x3锐化卷积核 kernel = ImageFilter.Kernel( size=(3, 3), kernel=[0, -1, 0, -1, 5, -1, 0, -1, 0], scale=1, offset=0 ) img_sharpened = img.filter(kernel)卷积核的scale是滤波器权重之和,offset是亮度偏移量。上面这个核的中心值是5、周围是-1、和是1,等价于把中心像素加重、周围像素减淡,视觉上就是锐化。对卷积原理不熟的读者,可以把卷积想象成“一个滑动窗口扫过整张图,每个新像素值是窗口内旧像素的加权和”——这就是图像滤波的本质。
5.2 色彩与光照增强,ImageEnhance四件套
ImageEnhance模块提供四个增强器:亮度(Brightness)、对比度(Contrast)、色彩饱和度(Color)、锐度(Sharpness),每个都接收一个增强系数,1.0表示不变,大于1增强,0到1之间减弱,0表示完全去掉该项属性:
from PIL import ImageEnhance enhancer = ImageEnhance.Brightness(img) img_bright = enhancer.enhance(1.2) # 亮度提升20% enhancer = ImageEnhance.Contrast(img) img_contrast = enhancer.enhance(1.5) # 对比度提升50% enhancer = ImageEnhance.Color(img) img_colorful = enhancer.enhance(1.3) # 色彩更鲜艳 enhancer = ImageEnhance.Sharpness(img) img_sharp = enhancer.enhance(2.0) # 锐化加倍我之前的理解有个偏差:以为Color(0)会把图片变成灰度图。实测发现Color(0)确实会让图片变成近乎灰度的效果,但它跟直接convert("L")有微妙差别,前者保留了原图的色彩通道结构,后者直接丢掉色彩信息只剩单通道亮度。如果要做“低饱和度风格”,用Color调节更灵活,因为它可以控制在0.3、0.5这类中间值,而不是一刀切。
这四个增强器的内部实现基本上是像素数学变换,不会改变图片尺寸和模式。批量处理的时候要注意:每调用一次enhance都会生成一个新的Image对象,如果处理链条很长,中间产物会占内存。比较好的习惯是链式处理时把中间结果赋给同一个变量名,让之前的对象可以被垃圾回收。
5.3 文字绘制与中文字体坑,ImageDraw实操
ImageDraw模块用来在图片上绘制图形和文字。最基础的用法是在内存中创建一张透明图层,画完内容后再粘贴到原图上,这样做的好处是绘图失败或中间调整不影响底图:
from PIL import ImageDraw, ImageFont # 创建透明图层 overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) # 画矩形 draw.rectangle([50, 50, 200, 150], fill=(255, 0, 0, 128), outline=(255, 255, 255, 255)) # 画圆 draw.ellipse([300, 50, 400, 150], fill=(0, 255, 0, 128)) # 画直线 draw.line([(500, 50), (600, 150)], fill=(0, 0, 255, 255), width=3) # 写文字 font = ImageFont.truetype("arial.ttf", 24) draw.text((100, 200), "Hello Pillow", fill=(255, 255, 255, 255), font=font)这里必须展开讲中文字体的坑。ImageDraw.text默认使用的字体是Pillow内置的点阵字体,它不支持中文,直接在图上写中文,出来的是一堆方块或者乱码。解决办法是指定系统现有的中文字体文件路径,Windows和macOS各有自己的字体目录:
# Windows font = ImageFont.truetype("C:/Windows/Fonts/simhei.ttf", 28) # 黑体 # macOS font = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 28)如果字体路径不确定,可以先用ImageFont.load_default()认识一下默认字体长什么样,然后到系统字体目录里找ttf或ttc文件,逐个试。还有一种踩坑情况是:在本地跑得好好的中文字体,部署到Linux服务器上就报OSError: cannot open resource,因为服务器上没有这个字体文件。解决思路很简单——把字体文件作为项目资源一起打包部署,然后用相对路径加载:
font = ImageFont.truetype("assets/fonts/simhei.ttf", 28)这个坑在Docker容器里格外常见,属于部署环境问题,提前把字体文件放进镜像目录就能避免。
另一个值得强调的是textbbox方法(旧版本叫textsize,已废弃),它用来测量文字渲染后的实际尺寸。写水印的时候,不测量文字宽度就直接定位,水印很容易跑到画面外面去。正确的做法是先量后画:
bbox = draw.textbbox((0, 0), "上传于 2024", font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] x = img.width - text_w - 20 y = img.height - text_h - 20 draw.text((x, y), "上传于 2024", fill=(255, 255, 255, 200), font=font)新版Pillow还提供了draw.textlength()可以直接算宽度,但只有宽度没有高度,需要获得完整文本范围时还是textbbox更全。
5.4 水印的透明叠加原理,理解alpha通道的工作机制
给图片加水印是接单时最常见的需求之一,而透明度的正确处理是水印是否自然的关键。
透明叠加的核心是paste方法的mask参数。mask是一张灰度图,它决定粘贴区域的透明度:mask中白色(255)对应的像素完全不透明,黑色(0)对应的像素完全透明,灰色对应的像素按灰度值半透明。这个机制跟PS里的图层蒙版一个道理。
做半透明文字水印的完整示例:
from PIL import Image, ImageDraw, ImageFont base = Image.open("photo.jpg").convert("RGBA") # 创建与底图同尺寸的透明图层 watermark = Image.new("RGBA", base.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(watermark) font = ImageFont.truetype("assets/fonts/simhei.ttf", 60) text = "仅供学习交流" bbox = draw.textbbox((0, 0), text, font=font) tw = bbox[2] - bbox[0] th = bbox[3] - bbox[1] x = base.width - tw - 30 y = base.height - th - 30 # 用半透明的灰色画文字 draw.text((x, y), text, font=font, fill=(128, 128, 128, 100)) # 关键一步:paste过去,mask用watermark的alpha通道 base.paste(watermark, (0, 0), mask=watermark) # 保存前转回RGB result = base.convert("RGB") result.save("watermarked.jpg", quality=90)这里有个新手容易写错的点:base.paste(watermark, (0, 0))如果不传mask参数,Pillow会直接把watermark的像素贴过去,包括它的透明区域,透明区域会被当成黑色填上——这是很多人做出来水印背景一团黑的原因。正确做法是mask参数传watermark自身,让alpha通道作为蒙版来控制透明度。
如果只是想在所有图片的右下角统一贴一个logo,且logo本身是PNG带透明通道的,那么把logo当作mask用两种方式都可以,一种是上面示例中的整图RGBA方式,一种是单独提取logo的alpha通道作为mask。后者更快,也更符合常规做法:
logo = Image.open("logo.png").convert("RGBA") logo_mask = logo.split()[3] # 提取alpha通道 base.paste(logo, (base.width - logo.width - 20, base.height - logo.height - 20), mask=logo_mask)logo.split()会把RGBA四个通道拆成四个独立的单通道图,索引3就是alpha通道。对这种固定logo贴图,整图RGBA方式和alpha方式效果几乎一致,但alpha方式在内存和速度上都有优势。
5.5 多图合成与拼接,贴图拼图的实现思路
把多张图拼成网格、把一张小图贴到大图的指定位置、做九宫格朋友圈拼图,这些都属于图像合成场景。
最简单的贴图就是我们前面paste的用法。但对于“把多张图拼成一张长图”的场景,需要先创建一张空白画布,再把各张图按布局粘贴上去:
from PIL import Image images = [Image.open(f"imgs/img{i}.jpg") for i in range(1, 5)] # 四张图横向拼接 total_w = sum(im.width for im in images) max_h = max(im.height for im in images) canvas = Image.new("RGB", (total_w, max_h), (255, 255, 255)) x_offset = 0 for im in images: canvas.paste(im, (x_offset, 0)) x_offset += im.width canvas.save("panorama.jpg")纵向拼接同理,把宽高方向换一下即可。网格拼图稍微复杂一点,需要提前计算每张缩略图的大小,再在画布上按行列循环粘贴。有一个经验是:拼接前先对每张图做统一的resize和裁剪,保证各格子尺寸一致,否则拼出来的图歪歪扭扭。这一步通常结合thumbnail和crop来实现。
5.6 GIF动图处理,逐帧读取与保存
Pillow对GIF的支持一直被很多人低估,其实用它处理GIF动图的逐帧操作很方便。先解释GIF的基本结构:GIF由多帧画面组成,每帧有自己的延迟时间(duration),还有是否使用局部调色板的信息。Pillow可以通过Image.open打开GIF,然后用seek和tell在两个帧之间跳转:
gif = Image.open("animation.gif") print(gif.n_frames) # 总帧数 frames = [] for i in range(gif.n_frames): gif.seek(i) frame = gif.convert("RGBA") frames.append(frame)这里有个很典型的坑:如果直接gif.seek(i)然后gif.save,不同帧之间的差分压缩信息会导致错位或损坏,因为GIF帧可能只是存了上一帧的差异部分。正确做法是把每一帧都独立提取并转换成一个完整图像,再组合保存。上面代码里每一帧都做了convert("RGBA"),把每帧变成完整的独立图像,就规避了这个问题。
保存GIF时要设置save_all=True并给每帧的duration:
frames[0].save( "output.gif", save_all=True, append_images=frames[1:], duration=100, # 每帧停留100ms loop=0 # 循环次数,0表示无限循环 )duration的单位是毫秒,loop=0表示无限循环播放。批量修改GIF帧间隔或者做帧抽取,都是这个思路。我自己做过一个“把GIF每帧加一个放大效果再重新导出”的小工具,核心代码不超过30行,Pillow处理起来没有任何压力。
6. 实战案例拆解:批量处理脚本从头写到尾
6.1 需求设定与方案设计
我这里用一个具体需求来串联前面所有知识点。
模拟场景:手头有一个文件夹,里面有两百多张从手机和相机导出的照片,格式混杂(jpg、png、heic转出来的),尺寸不一。现在要做三件事:
- 统一把图片最大边缩到1600像素以内,等比缩放;
- 在每张图右下角加上“© 2024”文字水印;
- 全部转换成JPEG格式保存到另一个目录,压缩质量85。
这个需求很典型,我把它拆成三个步骤,每一步对应一个函数,最后用主流程串联起来。这样的设计无论对自己还是对别人都清晰易维护。
这里要插一句HEIC格式的提醒:手机拍摄的很多是HEIC格式,Pillow原生的Image.open打不开这种格式,解包会报OSError: cannot identify image file。遇到HEIC,需要先转成JPEG或PNG,转换可以用系统自带工具、其他库,或者先把它们统一预处理成Pillow能读的格式。我在本文示例里默认输入格式是Pillow能直接打开的,遇到HEIC时建议预处理或换库,不要在Pillow层面硬碰。
6.2 完整脚本,按函数拆解并逐行说明
import os from pathlib import Path from PIL import Image, ImageDraw, ImageFont # 配置 INPUT_DIR = Path("input_photos") OUTPUT_DIR = Path("output_photos") MAX_EDGE = 1600 QUALITY = 85 WATERMARK_TEXT = "© 2024" FONT_PATH = "assets/fonts/simhei.ttf" def resize_to_max_edge(img, max_edge=1600): """等比缩放,让最长边不超过max_edge。""" w, h = img.size longest = max(w, h) if longest <= max_edge: return img scale = max_edge / longest new_size = (int(w * scale), int(h * scale)) return img.resize(new_size, Image.LANCZOS) def add_text_watermark(img, text, font_path=None): """在图片右下角添加半透明文字水印。""" img = img.convert("RGBA") overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) if font_path and os.path.exists(font_path): font = ImageFont.truetype(font_path, 36) else: font = ImageFont.load_default() bbox = draw.textbbox((0, 0), text, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] margin = 20 x = img.width - text_w - margin y = img.height - text_h - margin draw.text((x, y), text, font=font, fill=(255, 255, 255, 160)) img = Image.alpha_composite(img, overlay) return img.convert("RGB") def process_image(src_path, dest_path): """处理单张图片:缩放、加水印、保存为JPEG。""" with Image.open(src_path) as img: img = resize_to_max_edge(img, MAX_EDGE) img = add_text_watermark(img, WATERMARK_TEXT, FONT_PATH) img.save(dest_path, "JPEG", quality=QUALITY, optimize=True) print(f"完成: {src_path.name} -> {dest_path.name}") def main(): OUTPUT_DIR.mkdir(exist_ok=True) # 支持的输入格式 extensions = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} files = [p for p in INPUT_DIR.iterdir() if p.suffix.lower() in extensions] for src in files: dest = OUTPUT_DIR / (src.stem + ".jpg") try: process_image(src, dest) except Exception as e: print(f"处理失败 {src.name}: {e}") if __name__ == "__main__": main()这段脚本里有几个地方值得展开说明。
第一个是with Image.open(src_path) as img的用法。Image对象支持上下文管理器协议,with块结束时自动关闭文件资源,这是个好习惯,批量处理几百张文件时尤其能避免“文件被占用”这类问题。
第二个是Image.alpha_composite,它比我之前介绍的paste更严格也更专业。alpha_composite要求两张图必须是RGBA模式,它按alpha通道做正确的色彩混合,不会出现paste不传mask时的黑色背景问题。两者的区别在于:paste是替换式叠加,alpha_composite是真正的透明合成。在画布透明图层方案里,用alpha_composite更稳妥。
第三个是异常处理。批量处理场景下,某一张图损坏或者格式不兼容导致整批中断,是体验极差的事。我在循环里包了try/except,单张失败只打印提示,不阻断整批处理——这是批处理脚本必须有的健壮性设计。
第四点是文件重名问题。这里直接用了src.stem + ".jpg"作为输出文件名,如果输入目录里同时有a.png和a.jpg,输出会撞名互相覆盖。实际项目中可以根据需要加序号或者哈希后缀,但这不属于本文核心,就不展开了。
6.3 性能优化:并发处理两百张图片
上面脚本是串行处理,两百张图逐张跑,速度取决于单张处理耗时。如果处理链里有多次RGB转换和水印合成,每张可能要半秒到一秒,全部跑完要两三分钟。对批量任务来说可以接受,但还可以更快一点。
Pillow本身是线程安全的吗?这是一个经常被问的问题。严格来说,Pillow不是完全线程安全的,但在常见的“每个线程处理不同图片对象”的模式下,经验上是没有大问题的。我的建议是别在多线程里共享同一个Image对象并做写操作,尽量做到“每线程独立处理自己负责的图片”。用标准库的concurrent.futures.ThreadPoolExecutor即可:
from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=8) as executor: futures = [] for src in files: dest = OUTPUT_DIR / (src.stem + ".jpg") futures.append(executor.submit(process_image, src, dest)) for future in futures: future.result()如果任务量再大一个量级,比如几万张图,建议优先考虑用numpy批量运算预处理大图部分,再用进程池处理IO密集的读写环节。图像IO是磁盘和内存之间的搬运,多进程对CPU密集的像素运算类任务效果更明显。实践中的权衡标准是:单张处理时间受CPU计算主导就用多进程,受磁盘读写主导就用多线程。
还有一个小技巧:批量压缩时,可以先做一张样本图,测试不同quality下文件体积和画质的平衡点,再批量套用,避免全部处理完才发现体积超标。这种“先抽样测试、再批量执行”的思路,在真实项目里能帮你省掉大批返工成本。
7. 常见报错与问题排查,这些坑希望你提前避开
7.1 报错信息速查表
我把我实际遇到的和跟同行交流时收集到的高频报错整理成一张表,方便你随时查阅。表格里的排查思路是我的实战经验,不一定覆盖所有场景,但至少能帮你把问题范围缩小一大半。
| 报错信息 | 常见原因 | 排查思路 |
|---|---|---|
ModuleNotFoundError: No module named 'PIL' | Pillow未安装或安装到了别的Python环境 | 检查pip list,确认执行环境和安装环境一致 |
OSError: cannot identify image file | 文件不是有效图片,或用了错误方式打开 | 确认文件路径;如果是BytesIO必须包一层;确认格式是Pillow支持的 |
OSError: cannot write mode RGBA as JPEG | JPEG不支持alpha透明通道 | 保存前convert("RGB");有透明背景需求就存PNG |
OSError: cannot open resource | 字体文件路径不正确 | 确认字体文件存在;部署环境把字体作为资源一起打包 |
ValueError: image has wrong mode | 操作要求特定模式但当前不匹配 | 先convert()到目标模式再操作 |
MemoryError | 图片过大或同时加载过多图片 | 降低图片分辨率;分批处理;关闭不再用的Image对象 |
DecompressionBombError | 图片尺寸超过Pillow默认安全上限(约1.78亿像素) | 用Image.MAX_IMAGE_PIXELS = None解除限制,但同时要确认图片来源可信 |
AttributeError: 'JpegImageFile' object has no attribute 'textsize' | 新版删除了textsize | 改用textbbox或textlength |
7.2 几个隐蔽问题的深入剖析
第一个隐蔽问题是P模式图片的颜色错乱。前面提过,P模式是调色板模式,像素值是对调色板的索引,不是真实颜色。如果你直接用PixelAccess去读P模式图的像素,拿到的是索引值而不是RGB值。如果再把这种索引值当作RGB做颜色替换,结果就是一团糟。解决办法就是对P模式图先转成RGB或RGBA再访问像素。
第二个隐蔽问题是exif方向信息。手机拍摄的JPEG照片通常带有EXIF元数据,其中Orientation字段记录了拍摄时的设备朝向。看图软件会自动读取这个字段把照片转正,但Pillow打开图片时并不会自动应用旋转。这就是为什么你代码里打开一张手机拍的竖图,img.size可能是横着的宽高,人也是横着的。解决方法是读取EXIF的Orientation并手动转置:
from PIL import ImageOps img = Image.open("phone_photo.jpg") img = ImageOps.exif_transpose(img)ImageOps.exif_transpose会自动读取EXIF里的方向信息并旋转图片。这个函数在真实场景中极其常用——处理用户上传的手机照片时不加这一行,后面全部操作都是在“横着的图”上做的,效果当然不对。
第三个隐蔽问题是逐帧GIF处理时的帧残留。如果处理的GIF帧间有差分压缩,单纯seek和splice再合并可能产生重影或者帧错乱。我的建议是在读取每帧时复制一份独立图像,不要直接引用原gif对象里的帧缓冲。上面示例里的frame = gif.convert("RGBA")其实已经创建了新图像对象,这就是正确的做法。
第四个是大图内存雪崩。一张2万x2万的超大遥感图或工程图纸,打开后会占很大的内存,如果同时处理多张,内存可能直接被压爆。Pillow有一个安全上限MAX_IMAGE_PIXELS,默认约1.78亿像素,超过之后打开会抛DecompressionBombError。这个机制是防炸弹攻击的,正常业务里如果确实需要处理超大图,可以通过设置Image.MAX_IMAGE_PIXELS = None解除,但务必要确认图片来源可信,否则一个超大图片文件就能把你的服务内存拖垮。
第五个是文件占用问题。在Windows上,Image.open之后如果没关闭,后续试图删除或覆盖这个文件会报“文件被另一个进程占用”。养成用with块的习惯能避免大部分这类问题。如果用了img.load()之后还要继续写,也要记得在合适的时机del img或让引用离开作用域。
7.3 调试图片处理脚本的思路
遇到Pillow相关的问题,我有一套自己的排查顺序:先确认图片能正常打开(排除文件源问题),再看mode是否正确(排除模式问题),再看操作步骤是否合法(排除API误用问题),最后再怀疑版本问题。
# 调试三板斧 img = Image.open("problem.jpg") print(img.size, img.mode, img.format) # 基础信息 img.show() # 人工查看,前提是有GUI环境 img.save("debug.png") # 保存中间产物排查在服务器环境下img.show()不可用,就多保存几张中间过程的图片到磁盘,肉眼确认每个阶段的结果是否符合预期。这种方式虽然原始,但在图像处理调试里非常有效,因为它能把“每一步的结果”可视化,快速定位是哪一步出了问题。有时候你写了几行看似合理的代码,运行不报错但结果不对,这时候就得把中间图一张张看,问题往往就在某个细节参数上。
8. 扩展阅读与实践建议,把Pillow放进更大的技术版图
8.1 Pillow和相邻生态的组合用法
Pillow在真实项目里很少单打独斗,它通常和别的库配合。我结合自己的经验举几个典型组合。
第一个组合是Pillow + requests,前面提过,用于下载网络图片然后做处理。这个组合在爬虫采集、头像处理服务里出现的频率非常高。
第二个组合是Pillow + numpy,适合做像素级操作。numpy数组的矢量化运算速度优势明显,配合Pillow的Image.fromarray和np.array互转,可以在不改变Pillow便利性的前提下拿到接近底层的高性能。像批量做颜色映射、阈值分割、直方图均衡化这类任务,numpy版本代码比纯Python逐像素循环简洁得多,也快得多。
第三个组合是Pillow + Flask/FastAPI,用来构建带图片上传处理功能的Web服务。接口收到上传文件后,Pillow负责校验、裁剪、压缩、加水印,再返回处理后的图片或URL。这个场景在用户头像上传、电商商品图处理、博客封面自动生成等项目里特别常见。有个小建议:Web服务里用完Image对象后及时关闭释放资源,高并发下资源泄漏问题会被放大。
第四个组合是Pillow + matplotlib,matplotlib生成的图表可以用Pillow做后续加工,比如给图表加水印、裁剪边缘空白、拼接到报告模板里。matplotlib的savefig可以直接保存到BytesIO,再用Pillow的Image.open(BytesIO(...))读取,两者天然衔接。
8.2 学习路径与练手项目建议
如果你想系统学Pillow,我的建议是别光看文档,文档啃起来枯燥,务必配着练手项目走。
第一步是跟着本文把基础方法过一遍,每个方法都动手跑一遍,尤其是open、save、convert、resize、crop、paste、rotate、filter、enhance、text、paste带mask这些高频方法。练完基础后,我推荐下面几个练手项目,难度递增:
- 批量图片压缩工具:输入一个文件夹,输出压缩后的另一个文件夹,支持自定义质量和尺寸。
- 简易表情包生成器:在图片上添加不同样式的文字,保存为多种格式。
- 图片加水印脚本:支持文字水印和PNG logo水印,可调位置、透明度、间距。
- GIF帧提取与合成工具:把一个GIF拆成帧并导出成PNG序列,或者把一组PNG合成GIF动图。
- 九宫格切图工具:把一张大图切成九张均等的子图,这是很多社交平台分享长图时的常见需求。
- 身份证照片/证件照尺寸调整工具:按指定宽高比例裁剪并等比缩放。
这些项目每个都不复杂,但都覆盖了Pillow核心API,做完一遍基本能独立应付大多数日常图片处理需求。
8.3 官方文档的正确打开方式
Pillow官方文档非常详尽,但也比较庞大,建议按需查阅,不要试图通读。我的经验是先看Handbook部分的Concept页面,理解坐标系、模式、通道的基本概念,然后用到什么查什么。实际操作中遇到不确定的API,直接查官方文档对应页面或者看源码里的docstring,Pillow的源码注释质量很不错,比很多博客教程靠谱。
另外提一个查阅技巧:Pillow的不同版本API有增删变化,查文档时注意版本标注。比如Image.Resampling这个枚举是10.0版本引入的,老项目里写Image.LANCZOS这种旧写法在新版本里也能兼容,但会出现DeprecationWarning。遇到警告信息不要慌,看英文提示,多半是告诉你去哪里换新写法。
8.4 最后再分享几个我常用的工作习惯
我在日常使用Pillow的过程中积累了一套习惯,不一定是最优解,但确实帮我省了很多事。
第一个习惯是统一用路径对象。我很少写裸字符串路径,而是用pathlib.Path构建路径,好处是Windows和Linux两套系统下不用改代码,拼接路径、遍历目录都更简洁。
第二个习惯是输出文件名带处理参数。批量处理时把quality值和最大边写进文件名,比如photo_1600x_q85.jpg,这样目录里看到文件名就知道这批图的处理参数,省得反复打开图看属性。
第三个习惯是异常信息里打印文件路径。批处理脚本出错时,如果异常信息只有一行“cannot identify image file”,你根本不知道是哪张图出的问题。我在日志里把src_path一起打印出来,定位问题效率提高一倍。
第四个习惯是处理前后对比图保留一份。做一个新处理方案时,我会在方案落地前先跑三张样图,把前后对比放在一个文件夹里人工确认效果。这个步骤虽然看起来慢,但能最大程度避免整批处理的返工。
从打开第一张图片到完成批量水印脚本,Pillow的学习曲线并不陡峭,但它的细节坑也不算少。只要你把每个方法的参数含义、模式转换规则、透明度机制这三条主线理清楚,绝大多数问题都能自己排查。希望这篇文章能让你少踩几个我当年踩过的坑。