PIL Image模块图像处理入门:从打开到批量实战

发布时间:2026/11/3 23:15:12
PIL Image模块图像处理入门:从打开到批量实战

图像处理这件事,很多人第一次接触都是从 PIL 开始的。我印象特别深,早年接手一个批量处理商品图的活儿,几百张图片要统一裁切、调色、加水印,当时第一反应是找现成工具,结果要么功能对不上,要么批量处理卡得不行。后来一位老同事甩给我一段from PIL import Image开头的脚本,几十行代码跑完,我才意识到这个库有多实在。PIL 的全称是 Python Imaging Library,后来社区维护的版本叫 Pillow,它是 Python 生态里做图像处理最基础也最常用的库之一。这篇文章我想聊的就是用 PIL 的 Image 模块做图像处理的基本操作——打开、查看、裁剪、缩放、旋转、调色、格式转换、批量处理这些日常高频动作。不管你是刚学 Python 想找个练手项目,还是工作中需要处理图片但不想装一堆重型软件,又或者想给自动化流程加个图片处理环节,这些内容都能直接拿去用。我会把每个操作的原理、参数怎么选、踩过的坑都摊开讲,尽量让你看完就能上手写脚本。

1. 先搞清楚 PIL 和 Pillow 的关系,别在安装这一步就卡住

1.1 为什么你 pip install PIL 会失败

这是新手最容易遇到的第一个坎。PIL 这个项目本身早就停止维护了,最后一个版本停留在很多年前,而且不支持 Python 3。现在你在 Python 3 环境里执行pip install PIL,大概率直接报错找不到匹配版本。真正在维护的是 Pillow,它是 PIL 的一个分支,接口基本兼容,但适配了现代 Python 版本,修了大量 bug,还加了不少新功能。

所以正确的安装命令是:

pip install Pillow

装完之后导入的时候,用的还是 PIL 这个名字:

from PIL import Image

这个命名上的错位经常让人困惑——装的是 Pillow,导入的是 PIL。你可以这么理解:Pillow 是"新瓶",PIL 是"旧标",瓶换了但标签没换,为的是让老代码不用改导入语句就能跑。我见过不少人装完 Pillow 发现import PIL能用但import Pillow报错,然后怀疑自己装错了,其实完全正常。

提示:如果你在虚拟环境里操作,记得先激活环境再装。另外某些精简版系统镜像可能缺少图像编解码依赖,装完 Pillow 后打开 JPEG 报错的话,补装系统级的图像库依赖即可,具体包名跟操作系统有关。

1.2 Image 模块在整个库里的定位

Pillow 是个大库,Image 只是其中一个模块,但它是最核心的那个。你可以把 Image 理解成"图片对象的总管家"——打开图片得到的是一个 Image 对象,后续所有裁剪、缩放、旋转、调色操作,本质上都是在这个对象上做变换,或者生成新的 Image 对象。

除了 Image,Pillow 还有几个常用模块值得知道:ImageDraw用来画线画矩形写文字,ImageFont配合它设置字体,ImageFilter提供模糊锐化等滤镜,ImageEnhance做亮度对比度增强,ImageOps有一些便捷的批量操作比如自动对比度、边框。但所有这些的前提,都是先用 Image 把图片读进来。所以把 Image 的基本操作吃透,是后面一切的地基。

我个人的习惯是,写任何图片处理脚本,开头永远是那三行:

from PIL import Image import os # 后面才是具体逻辑

先把地基打牢,再往上盖。

2. 打开、查看、保存:图像处理的三步基本功

2.1 打开图片时到底发生了什么

Image.open()这个函数看起来简单,但它的行为有个关键细节很多人不知道:它并不是立刻把整张图片的像素数据读进内存,而是先读取文件头信息,建立一个"懒加载"的对象。也就是说,你调用 open 之后,图片的尺寸、格式、模式这些元信息已经能拿到了,但真正的像素数据要等到你第一次访问它(比如显示、裁剪、保存)时才加载。

这个设计的好处是省内存,尤其是批量处理大量图片时,你可以先快速遍历所有图片的尺寸信息做筛选,不用把每张图都完整读进来。但坏处是,如果你 open 之后文件被删了或者移动了,等到真正访问像素时才会报错,排查起来容易懵。

from PIL import Image img = Image.open("example.jpg") print(img.size) # 宽高,比如 (1920, 1080) print(img.format) # 格式,比如 JPEG print(img.mode) # 色彩模式,比如 RGB

size返回的是(宽度, 高度)的元组,注意顺序是宽在前高在后,这点跟很多图形库一致,但跟数学里的坐标习惯(先横后纵)容易混。mode是色彩模式,最常见的三种:RGB是彩色三通道,L是灰度单通道,RGBA是带透明通道的彩色。格式和模式是两回事——格式指文件存储格式(JPEG、PNG),模式指像素的组织方式,一张 PNG 可以是 RGB 也可以是 RGBA。

2.2 显示图片的两种方式及适用场景

调试阶段经常需要看一眼图片长什么样。最直接的是img.show(),它会调用系统默认的图片查看器打开图片。这个方法在本地开发时很方便,但在服务器或者没有图形界面的环境里会失败或者没反应。

另一种方式是在 Jupyter Notebook 这类交互环境里直接输出对象,或者用display(img)。如果你在做数据分析或者教学演示,这种方式更顺手,图片直接嵌在输出里。

我自己的习惯是,写批量脚本时基本不用 show,因为几百张图一张张弹窗会疯掉。取而代之的是先打印尺寸、模式这些元信息做快速检查,实在要看图就抽几张代表性的保存到临时目录再统一看。这个习惯帮我省了大量时间——批量处理最怕的就是中途某张图有问题导致整个流程崩掉,提前抽样检查能规避很多麻烦。

2.3 保存图片时的格式与质量权衡

img.save()是保存,但这里面的门道比想象中多。最基础的用法是给个文件名,Pillow 会根据扩展名自动推断格式:

img.save("output.png") img.save("output.jpg")

但 JPEG 是有损压缩,保存时可以控制质量参数:

img.save("output.jpg", quality=85)

quality范围是 1 到 95(100 也可以但收益递减),默认是 75。这个参数直接影响文件大小和画质。我做过对比测试,同一张 1920x1080 的照片,quality=95 时文件大概 800KB,quality=75 时降到 300KB 左右,quality=50 时只有 150KB 但肉眼已经能看出块状模糊了。日常网页展示用 80 到 85 是比较好的平衡点,需要存档或者印刷才用 95 以上。

PNG 是无损格式,没有 quality 参数,但有个optimize选项可以压缩文件体积:

img.save("output.png", optimize=True)

这个选项会让保存慢一点,但文件通常能小 10% 到 30%,对于要上传到网上的图片很值得开。

注意:保存时的格式和原图格式可以不同,比如打开 JPEG 存成 PNG,Pillow 会自动做转换。但如果你把带透明通道的 RGBA 图存成 JPEG,透明部分会变成黑色或者报错,因为 JPEG 不支持透明。这种情况要么先转成 RGB 再存,要么改存 PNG。

3. 裁剪、缩放、旋转:几何变换的核心操作

3.1 裁剪:理解坐标系的边界

裁剪用crop(),参数是一个四元组(left, top, right, bottom),表示从原图左上角开始,裁出这个矩形区域。这里的关键是坐标系:原点在左上角,x 轴向右,y 轴向下,跟数学坐标系是反的,跟屏幕显示习惯一致。

img = Image.open("example.jpg") # 裁出左上角 400x300 的区域 cropped = img.crop((0, 0, 400, 300))

right和bottom是开区间还是闭区间?实测下来,crop 的区域是左闭右开的,也就是(0, 0, 400, 300)裁出来的图宽度正好是 400,高度正好是 300。这个细节在拼接图片或者做精确对齐时很重要,差一个像素就可能出现缝隙或者重叠。

裁剪最容易踩的坑是坐标越界。如果你给的 right 或 bottom 超过了原图尺寸,Pillow 不会报错,而是用黑色(或者透明)填充超出部分。这个行为有时候是特性有时候是 bug——做图片补边时可以利用它,但如果你本意是裁出有效区域,越界就会得到一张带黑边的图。所以裁剪前最好先确认一下img.size,或者用min()把边界卡住。

我处理过一批用户上传的头像,需求是统一裁成正方形。思路是先取宽高里较小的那个作为边长,然后从中心裁:

w, h = img.size side = min(w, h) left = (w - side) // 2 top = (h - side) // 2 square = img.crop((left, top, left + side, top + side))

这段代码很实用,建议直接抄走。中心裁剪是头像、缩略图场景的标配操作。

3.2 缩放:resize 的插值算法怎么选

缩放用resize(),参数是目标尺寸的元组:

resized = img.resize((800, 600))

但这里有个大坑:如果你只给目标尺寸,图片会被强行拉伸到那个尺寸,宽高比就变了,人脸会变胖或者变瘦。正确做法是先算好等比缩放后的尺寸:

def resize_keep_ratio(img, target_width): w, h = img.size ratio = target_width / w target_height = int(h * ratio) return img.resize((target_width, target_height))

resize()还有个resample参数决定插值算法,这个参数对画质影响很大,很多人不知道就用了默认值。不同算法的对比如下:

算法适用场景特点
Image.NEAREST像素风、需要保留硬边缘最快,但缩放后锯齿明显
Image.BILINEAR一般缩放速度和质量平衡,默认值
Image.BICUBIC缩小图片质量较好,边缘更平滑
Image.LANCZOS高质量缩小质量最好,速度稍慢

实测下来,缩小图片(比如从 4000px 缩到 800px)用LANCZOS效果明显好于默认的BILINEAR,细节保留更多,摩尔纹更少。放大图片则要谨慎,任何算法都救不回原本不存在的细节,BICUBIC相对自然一些。做缩略图批量生成时,我固定用LANCZOS,虽然慢一点但值得。

提示:Pillow 新版本里Image.ANTIALIAS这个老名字已经被移除了,如果你看到老教程里用它会报错,统一换成Image.LANCZOS即可。

3.3 旋转与翻转:角度和方向的细节

旋转用rotate(),参数是角度,逆时针为正:

rotated = img.rotate(90)

默认情况下,旋转后图片尺寸不变,超出原图范围的部分会被裁掉,空出来的角落用黑色填充。如果你希望旋转后完整保留图片内容,加expand=True:

rotated = img.rotate(45, expand=True)

这样输出图片的尺寸会自动扩大以容纳旋转后的完整内容。做证件照旋转、扫描件纠偏时这个参数很关键,不加的话四个角就被切掉了。

翻转用transpose(),配合几个常量:

img.transpose(Image.FLIP_LEFT_RIGHT) # 水平翻转(左右镜像) img.transpose(Image.FLIP_TOP_BOTTOM) # 垂直翻转(上下颠倒) img.transpose(Image.ROTATE_90) # 逆时针旋转 90 度

这里有个容易混淆的点:rotate(90)和transpose(Image.ROTATE_90)结果一样吗?实测下来,rotate(90)是逆时针转 90 度,transpose(Image.ROTATE_90)也是逆时针转 90 度,结果一致。但transpose的旋转是"无损"的,直接重排像素,速度快;rotate走的是通用旋转逻辑,任意角度都支持但 90 度这种特殊角度反而绕了远路。所以做 90 度整数倍旋转时,优先用transpose,效率更高。

4. 色彩模式转换与调色:从 RGB 到灰度再到各种增强

4.1 色彩模式转换的常见需求

convert()是模式转换的核心方法。最常见的场景是把彩色图转灰度:

gray = img.convert("L")

转灰度时 Pillow 用的是加权平均,权重大致是红 0.299、绿 0.587、蓝 0.114,这个权重来自人眼对不同颜色敏感度的差异——人眼对绿色最敏感,对蓝色最不敏感。所以同样亮度的纯绿和纯蓝,转成灰度后绿色会明显更亮。这个细节在做图像分析时要注意,如果你需要的是简单的三通道平均,得自己算。

另一个常见转换是 RGB 转 RGBA,给图片加透明通道:

rgba = img.convert("RGBA")

反过来 RGBA 转 RGB 时,透明部分会被丢弃,通常变成黑色。如果你想让透明部分变成白色背景,得先创建一个白色底图再粘贴上去:

background = Image.new("RGB", img.size, (255, 255, 255)) background.paste(img, mask=img.split()[3]) # 用 alpha 通道做蒙版

这段代码处理带透明 PNG 转白底 JPEG 特别有用,电商场景里商品图去透明底经常用到。

4.2 用 ImageEnhance 做亮度对比度调整

调色这块,ImageEnhance模块提供了几个开箱即用的增强器,比手动算像素方便太多:

from PIL import ImageEnhance enhancer = ImageEnhance.Brightness(img) brighter = enhancer.enhance(1.5) # 亮度提升 50% enhancer = ImageEnhance.Contrast(img) more_contrast = enhancer.enhance(1.2) # 对比度提升 20% enhancer = ImageEnhance.Color(img) more_saturated = enhancer.enhance(1.3) # 饱和度提升 30%

enhance()的参数是个倍数,1.0 表示不变,小于 1 是减弱,大于 1 是增强。这个接口设计很直观,不用记复杂的公式。我做过一个批量"提亮"的脚本,对偏暗的商品图统一enhance(1.3),效果立竿见影,比手动调曲线快多了。

但要注意,增强是叠加的,如果你对同一张图连续调用多次亮度增强,效果会累积。批量处理时最好每次从原图重新开始,避免反复增强导致过曝。

4.3 灰度图与二值化的实际应用

灰度图在 OCR、边缘检测、文档扫描这些场景里是标配预处理步骤。而二值化(把灰度图变成纯黑白)用point()方法配合阈值:

gray = img.convert("L") # 像素值大于 128 变 255(白),否则变 0(黑) binary = gray.point(lambda x: 255 if x > 128 else 0, mode="1")

这里的 128 是阈值,实际用的时候要根据图片亮度分布调整。文档扫描件通常背景偏白文字偏黑,阈值设在 150 到 180 之间效果比较好;如果是照片类的图,二值化往往损失大量信息,要谨慎使用。

我处理过一批扫描的表格图片,原始图偏灰,直接二值化后文字断断续续。后来加了一步先用ImageOps.autocontrast()自动拉伸对比度,再二值化,效果好很多。这个组合拳值得记住:先自动对比度,再二值化,是文档图像预处理的经典套路。

5. 批量处理与实战脚本:把零散操作串成流水线

5.1 批量处理的目录遍历与异常兜底

单个图片操作会了之后,真正的生产力来自批量。核心就是遍历目录、对每张图执行操作、保存到输出目录。但批量脚本最容易死在异常上——某张图损坏、格式不支持、权限不足,整个流程就中断了。

我的标准写法是给每张图的处理套上 try-except,单张失败不影响整体:

import os from PIL import Image input_dir = "input" output_dir = "output" os.makedirs(output_dir, exist_ok=True) failed = [] for filename in os.listdir(input_dir): if not filename.lower().endswith((".jpg", ".jpeg", ".png")): continue src = os.path.join(input_dir, filename) dst = os.path.join(output_dir, filename) try: with Image.open(src) as img: img = img.convert("RGB") img.thumbnail((800, 800)) # 等比缩放到不超过 800x800 img.save(dst, quality=85) except Exception as e: failed.append((filename, str(e))) print(f"处理完成,失败 {len(failed)} 张") for name, err in failed: print(f" {name}: {err}")

这里用了thumbnail()而不是resize(),区别在于thumbnail会原地修改图片并保持宽高比,而且只缩小不放大——如果原图本来就小于目标尺寸,它不会放大。这个行为在生成缩略图时正是我们想要的。另外用了with语句管理文件句柄,确保图片对象用完及时释放,批量处理大量图片时能避免内存泄漏。

5.2 一个完整的实战脚本:统一尺寸加水印

把前面学的串起来,写一个电商场景常见的脚本——把商品图统一成 800x800 白底方图,右下角加文字水印:

from PIL import Image, ImageDraw, ImageFont import os def process_product_image(src, dst, size=800, watermark="SAMPLE"): with Image.open(src) as img: img = img.convert("RGBA") # 等比缩放,长边不超过 size img.thumbnail((size, size), Image.LANCZOS) # 创建白底画布 canvas = Image.new("RGBA", (size, size), (255, 255, 255, 255)) # 居中粘贴 x = (size - img.width) // 2 y = (size - img.height) // 2 canvas.paste(img, (x, y), img) # 加水印 draw = ImageDraw.Draw(canvas) try: font = ImageFont.truetype("arial.ttf", 24) except OSError: font = ImageFont.load_default() bbox = draw.textbbox((0, 0), watermark, font=font) tw = bbox[2] - bbox[0] th = bbox[3] - bbox[1] draw.text((size - tw - 20, size - th - 20), watermark, fill=(150, 150, 150, 180), font=font) canvas.convert("RGB").save(dst, quality=88)

这段脚本里有几个值得说的点。第一,thumbnail之后图片尺寸可能小于画布,所以用居中粘贴而不是直接 resize 拉伸,避免变形。第二,paste的第三个参数传了img作为蒙版,这样带透明通道的图片粘贴时透明部分不会覆盖白底。第三,字体加载用了 try-except,因为不同系统字体路径不一样,找不到就退回默认字体,保证脚本不会因为字体问题崩掉。第四,水印颜色用了带 alpha 的灰色,半透明效果更自然,不会太抢眼。

注意:textbbox是较新版本 Pillow 的方法,老版本用的是textsize,如果你在旧环境里跑报错,换成draw.textsize(watermark, font=font)即可。这类版本差异在跨环境部署时特别容易踩,建议脚本里加个版本判断或者直接用兼容写法。

5.3 内存与性能的实操心得

批量处理几百上千张图时,性能和内存是两个绕不开的话题。我踩过的坑总结几条:

第一,及时释放图片对象。用with语句或者手动img.close(),尤其是在循环里。我早期写脚本没注意这点,处理几千张图后内存飙到几个 G,最后被系统杀掉。

第二,大图先缩后处理。如果最终输出是 800px,没必要在 4000px 的原图上做滤镜和绘制,先thumbnail再处理,速度能快好几倍。像素级操作的开销跟像素数量成正比,这个优化立竿见影。

第三,JPEG 保存质量别盲目拉满。quality=95 和 quality=85 的文件大小可能差一倍,但肉眼几乎看不出区别。批量场景下这个差异累积起来很可观,存储和传输成本都受影响。

第四,多进程能提速但要小心。图片处理是 CPU 密集型任务,用multiprocessing并行确实能提速,但进程间传大图片对象有序列化开销,而且 Pillow 对象不一定能直接跨进程传递。我的经验是,图片数量多、单张处理快的时候并行收益明显;单张处理本身就慢(比如大图复杂滤镜)时,并行的收益会被开销吃掉。可以先小批量测试再决定要不要上并行。

6. 常见报错与排查速查

实际写脚本时,报错信息往往不够直观,这里整理一份我遇到过的高频问题对照表:

报错或现象可能原因解决方向
ModuleNotFoundError: No module named 'PIL'没装 Pillow 或装错环境确认虚拟环境,pip install Pillow
UnidentifiedImageError文件损坏或不是图片检查文件完整性,跳过异常文件
保存 JPEG 报错cannot write mode RGBA as JPEGRGBA 图存 JPEG先convert("RGB")再存
旋转后四角被切没加expand=True旋转时加expand=True
缩放后人脸变形没保持宽高比先算等比尺寸或用thumbnail
中文水印显示成方块字体不支持中文指定支持中文的字体文件
批量处理内存暴涨图片对象未释放用with或手动close()
ANTIALIAS属性不存在新版 Pillow 移除了旧名改用Image.LANCZOS

中文水印那个问题特别值得展开说。ImageFont.load_default()加载的默认字体只支持 ASCII,写中文会变成方块或者问号。解决办法是显式指定一个支持中文的字体文件路径,比如系统里的黑体、宋体之类。但字体路径跨平台差异大,Windows、macOS、Linux 各不相同,所以脚本里最好做成可配置的,或者用 try 多个候选路径。我一般会准备一个字体路径列表,逐个尝试直到加载成功,这样脚本换台机器也能跑。

还有一个隐蔽的坑是图片的 EXIF 方向信息。手机拍的照片经常带一个 EXIF 旋转标记,图片查看器会自动按标记旋转显示,但 Pillow 打开时不会自动应用这个旋转。结果就是你在查看器里看是正的,脚本处理出来却是躺倒的。解决办法是用ImageOps.exif_transpose():

from PIL import ImageOps img = ImageOps.exif_transpose(img)

这一步在处理用户上传的照片时几乎是必加的,不然批量输出的图方向乱七八糟。这个坑我踩过不止一次,后来养成了习惯——凡是处理来源不明的照片,打开后第一件事就是exif_transpose。

7. 我对这套基本操作的理解

用 PIL 的 Image 模块做图像处理,说到底就是把一堆基础动作组合起来解决实际问题。打开、裁剪、缩放、旋转、调色、保存,每个单独看都不复杂,但真正拉开差距的是对细节的把握——坐标系的边界、插值算法的选择、色彩模式的转换、异常的处理、内存的管理。这些东西文档里都有,但只有实际跑过几百张图、被各种报错折磨过之后,才会变成肌肉记忆。

我个人在实际操作中的体会是,写图片处理脚本最忌讳一上来就追求"一步到位"的复杂逻辑。先把单张图的处理流程跑通,确认每一步的输出符合预期,再套上批量循环和异常兜底,最后才考虑性能优化。这个顺序反过来做,出了问题很难定位是哪一环的锅。另外,养成随手保存中间结果的习惯,处理到哪一步就存一张看看,比盯着代码猜哪里错了高效得多。图像处理是所见即所得的事,眼睛比日志更可靠。