ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python Pillow图像处理实战:从基础操作到批量脚本

Python Pillow图像处理实战:从基础操作到批量脚本

想处理图片但不想每次打开 Photoshop 来回点鼠标?用 Python 的 Pillow 写几行脚本,就能完成批量改尺寸、加水印、转格式、打滤镜这些活儿。Pillow 是 Python 生态里最常用的图像处理库,也是对新手最友好、文档最齐全的一个。你不需要懂图像编码原理,也不用管 JPEG、PNG 背后的压缩算法细节,装好库、打开图片、调用方法,结果就出来了。这篇文章我从零到一过一遍 Pillow 的核心用法,穿插一些我实际踩过的坑,适合刚接触图像处理的 Python 开发者,也适合想用脚本解放双手的运营、设计和自媒体朋友参考。

我最早用 Pillow 是在某次给一批商品图统一加水印,当时赶时间,把网络上一堆片段代码拼起来跑,结果不是文字糊了,就是图片被拉伸变形。后来花了半天把官方文档的 Image、ImageDraw、ImageFilter 几个模块系统性过了一遍,才算真正摸清它的脾气。Pillow 能做的事比大部分人想象的多:格式转换、缩略图、旋转裁剪、颜色增强、滤镜特效、文字绘制、像素级操作,甚至做简单的 GIF 动图。它最大的优势是开箱即用,依赖少,安装后就能跑,适合写自动化脚本和 Web 应用的后端图片处理。

1. 为什么学 Pillow:它到底解决什么问题

1.1 图像处理需求的底色

刚需场景大概有三个。第一是格式转换,比如把一堆 PNG 转成 JPEG,把 WebP 转成 PNG,或者把 RGBA 带透明通道的图片转成 RGB 以便在某些老旧系统里显示;第二是尺寸调整,电商平台的商品图主图要求 800x800,社交平台的头像要求 200x200,批量改尺寸是高频操作;第三是内容叠加,给图片加文字、加 logo、加日期标记,属于最常见的轻量级需求。

这些需求用专业图像软件做当然没问题,但一旦数量上来了,比如几百张、几千张,逐一打开再导出就很痛苦。Pillow 的本质是让你能通过代码批量、可重复地处理图片,结果稳定且不会手抖出错。它不追求替代专业软件,而是解决"脚本化、自动化"这一层的问题。

1.2 Pillow 与 OpenCV、matplotlib 的定位差异

很多初学者会困惑:已经有了 Pillow,为什么还要学 OpenCV?这两个不是一回事。Pillow 是纯粹的图像处理库,偏应用层,读图片、改图片、存图片,操作直观;OpenCV 是计算机视觉库,偏算法层,做特征检测、人脸识别、目标跟踪这类事情更擅长。如果你要做的只是裁剪、加字、转换格式,用 Pillow 就够了,杀鸡不用牛刀。

matplotlib 里的 imshow 也能显示图片,但它的定位是数据可视化,不是图像处理。Pillow 和 matplotlib 配合使用倒是常见:先用 Pillow 处理图片,再用 matplotlib 做对比展示,或者把处理结果放到数据分析流程里。

简单说:Pillow 是"动手改图"的工具,OpenCV 是"让代码看懂图"的工具,matplotlib 是"把图给人看"的工具。选择之前先问自己到底要做什么。

2. 环境准备与初始上手

2.1 安装与验证

安装没有什么特别的门槛,直接用 pip 装就好:

pip install pillow

如果是国内网络环境,可以临时换用镜像源让下载更快:

pip install pillow -i https://pypi.tuna.tsinghua.edu.cn/simple

装完之后验证一下是否正常。我建议在交互式环境里跑这两行:

from PIL import Image print(Image.__version__)

能输出版本号就说明安装成功。这里有个细节要注意:导入语句写的是from PIL import Image,不是from Pillow import Image。因为 Pillow 是 PIL(Python Imaging Library)的延续分支,包名保留了 PIL。早期有人以为装的是 Pillow,导入时写 PIL 会心里别扭,其实这是完全正常的,官方就是这么设计的。

我用过的版本里,从 8.x 到 10.x 核心用法基本一致,不用太纠结版本问题。当然,如果用到比较新的特性,比如 AVIF 支持,需要保证版本别太老。

2.2 核心概念:Image 对象、坐标系、颜色模式

Pillow 的一切操作几乎都围绕 Image 对象展开。打开一张图片,得到的是一个 Image 实例,这个对象里保存了尺寸、颜色模式、像素数据等信息。后续的裁剪、缩放、旋转、保存,都是对这个对象“做处理”并得到新的对象或直接改内部数据。

坐标系是初学者最容易绕晕的地方。Pillow 的坐标系原点在左上角,x 轴向右,y 轴向下。也就是说,(0, 0) 是第一行第一列那个像素,(width, height) 则是右下角最后一个像素。裁剪时传的元组是(left, upper, right, lower),表示左上角和右下角的坐标。注意right和lower是开区间,也就是不包含这两个坐标本身的像素。这个细节很关键,我见过不少人因为多裁剪了一行像素或者少算了一列,导致结果偏差。

颜色模式是另一个核心概念。最常见的模式有:

  • RGB:红绿蓝三通道,每通道 0~255,不透明
  • RGBA:在 RGB 基础上加了 Alpha 透明度通道
  • L:灰度图,单通道
  • P:调色板模式,保存 GIF 这类索引色图片时会遇到
  • CMYK:印刷用的四色模式

不同模式之间可以用convert()方法转换。比如把 RGBA 转成 RGB,只需img.convert("RGB")。如果图片带透明背景,转成 RGB 后透明部分会变成黑色,这是非常常见的坑。解决思路是先把透明区域换成白色或指定颜色,再转 RGB。我后面在常见问题章节会详细说处理方法。

3. 日常用得最多的基础操作

3.1 打开、读取与保存

打开一张图非常简单:

from PIL import Image img = Image.open("input.jpg") print(img.size) # (宽, 高) print(img.mode) # 'RGB' print(img.format) # 'JPEG'

Image.open()不会立即把所有像素数据加载进内存,它只是读取了文件头信息,建立了一个图像对象。真正访问像素数据要等到调用load()或convert()等方法时才会触发。这个惰性加载机制在大量图片处理时能节约内存,但也带来一个隐患:文件对象被关闭后,Image 对象可能无法再访问像素数据。

举个例子,如果你这样写:

with open("input.jpg", "rb") as f: img = Image.open(f)

然后离开了 with 代码块再访问img,在某些 Pillow 版本里就会报错。所以我在处理图片时,如果后续还要用到,会先调用img.load()把数据强制加载进来,或者干脆直接传入文件路径而不是文件对象。

保存图片用save()方法:

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

对 JPEG 来说,quality参数控制压缩质量,范围 1~95,默认是 75。数值越大画质越好,文件也越大。对 PNG 来说,compress_level控制压缩级别。保存时 Pillow 会根据文件扩展名自动推断格式,也可以显式指定format参数,比如:

img.save("output.data", format="PNG")

需要注意,如果图片原本是 RGBA 模式,直接保存为 JPEG 会报错,因为 JPEG 不支持透明通道。解决办法是先convert("RGB")。

3.2 裁剪、缩放、旋转和翻转

裁剪用crop(),传入一个四元组:

cropped = img.crop((left, upper, right, lower))

缩放用resize(),传入目标尺寸的元组:

resized = img.resize((200, 200))

但如果原图是 1000x600,你想缩到 200x200,直接 resize 会把图片压变形。更专业的做法是先调用thumbnail(),这个方法会自动保持宽高比,并且在缩放过程中会做高质量的图像重采样。它的用法稍微特殊一点,不是返回新的图片,而是原地修改调用它的对象:

img.thumbnail((200, 200))

这样img会变成原来的等比缩略图。如果原图是 800x600,执行thumbnail((200, 200))后,尺寸会变成 200x150。这个 API 设计跟resize()很不一样,我第一次用的时候还愣了一下。

旋转用rotate():

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

expand=True表示旋转后自动扩展画布,避免图片角落被裁剪掉;fillcolor用来填充旋转后露出的背景区域。注意rotate()是逆时针旋转,方向不对就调成负角度。

翻转用transpose():

flipped = img.transpose(Image.FLIP_LEFT_RIGHT) # 左右翻转

Pillow 的transpose()还支持FLIP_TOP_BOTTOM、ROTATE_90、ROTATE_180等。这里有个性能小技巧,需要旋转 90 度、180 度时,优先用transpose()而不是rotate(),因为前者不会重新采样插值,速度快且不会引入画质损失。

3.3 颜色模式转换与像素操作

模式转换用convert():

gray = img.convert("L") rgba_img = img.convert("RGBA")

像素级操作可以逐点读取,也可以做区域统计。getpixel()和putpixel()是简单接口:

pixel = img.getpixel((100, 50)) # 返回 (r, g, b) img.putpixel((100, 50), (255, 0, 0))

但逐像素putpixel()非常慢,处理一张大图要等很久。如果要做像素级的批量处理,更高效的方式是配合point()或ImageEnhance模块,或者直接操作numpy数组。Pillow 跟 numpy 结合很方便:

import numpy as np arr = np.array(img) # 把 Image 转成 numpy 数组 arr = arr[:, :, ::-1] # 举个例子:RGB 转 BGR new_img = Image.fromarray(arr)

很多复杂的颜色变换,走 numpy 比 Pillow 原生的逐像素操作快很多倍。

4. 进阶能力:滤镜、绘图与文字

4.1 ImageFilter 滤镜

Pillow 内置了 ImageFilter 模块,提供模糊、锐化、轮廓查找、浮雕等常见滤镜。用法很简单:

from PIL import Image, ImageFilter blurred = img.filter(ImageFilter.GaussianBlur(radius=3)) sharpened = img.filter(ImageFilter.SHARPEN) contour = img.filter(ImageFilter.CONTOUR)

GaussianBlur的radius参数控制模糊程度,数值越大越模糊。如果要做图片背景虚化效果,可以先放大模糊再叠加文字,效果比直接用默认模糊好很多。

ImageFilter.BLUR是简单的均值模糊,速度很快,但效果不如高斯模糊平滑。ImageFilter.EDGE_ENHANCE可以提升边缘对比度,对于文字截图类图片比较有用。ImageFilter.FIND_EDGES可以用来提取轮廓,在简单的图像识别流程中经常用来做预处理步骤。

4.2 用 ImageDraw 画图

ImageDraw 模块可以用来在图片上画线、画矩形、画椭圆、画多边形,也可以写字。它最常见的用途就是给图片加标注框或者水印。

基本用法是:

from PIL import Image, ImageDraw draw = ImageDraw.Draw(img) draw.rectangle([(50, 50), (200, 200)], fill=(255, 0, 0), outline=(0, 255, 0), width=3) draw.line([(0, 0), (100, 100)], fill=(0, 0, 255), width=2) draw.ellipse([(50, 50), (150, 150)], fill=(255, 255, 0))

这里的坐标传参方式有两种风格:一种是[(x1, y1), (x2, y2)],一种是(x1, y1, x2, y2),看文档的时候不要被两种写法绕晕,它们在效果上是等价的。

画椭圆时,如果传入的矩形框是正方形,画出来的就是圆。这是一个简单易用的技巧。

如果要画半透明形状,需要先创建一个带透明通道的图层,然后在这个图层上用 ImageDraw 绘制,最后用Image.alpha_composite()合成到底图上。

4.3 文字与水印

给图片加文字是 Pillow 使用频率很高的功能。需要用到 ImageDraw 和 ImageFont 两个模块。Pillow 自带的默认字体是位图字体,不支持中文,写中文会直接报错或显示为方块。这是一定要记住的坑。

正确做法是加载系统中的 TTF 字体文件。比如在常见系统上,可以这样指定一个中文字体路径:

from PIL import ImageFont, ImageDraw font = ImageFont.truetype("simhei.ttf", size=36) draw = ImageDraw.Draw(img) draw.text((10, 10), "Hello 你好", font=font, fill=(255, 255, 255))

如果没有现成字体,可以找系统自带的字体文件,在多数 Linux 发行版里类似路径为/usr/share/fonts/...,在 Windows 系统上为C:/Windows/Fonts/...,只要路径下的字体文件支持中文即可。某些服务器环境没有安装中文字体,这时候需要手动上传一个或多个字体文件到项目中,再通过相对路径加载。

size参数是文字尺寸,fill是文字颜色。text方法的anchor参数可以控制锚点对齐方式,比如你希望文字中心点对齐某个坐标,可以用anchor="mm"。这个参数在很多版本里才得到更好的支持,老项目里看不到这个参数,如果遇到,敲一下help(draw.text)确认当前版本支持的参数。

半透明水印的做法:

from PIL import Image, ImageDraw, ImageFont watermark = Image.new("RGBA", img.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(watermark) font = ImageFont.truetype("simhei.ttf", 40) draw.text((img.width - 300, img.height - 100), "示例水印", font=font, fill=(255, 255, 255, 100)) result = Image.alpha_composite(img.convert("RGBA"), watermark)

核心思路是先建一个透明图层,在上面画带有透明度值的文字,然后和原图做 alpha 合成。

5. 综合实战:批量图片处理脚本

5.1 批量缩略图与压缩

拿电商场景举例,经常需要把上传的原始图片批量生成多尺寸缩略图。我先写一个最基础的文件遍历函数,处理一个目录下所有 JPG:

import os from PIL import Image input_dir = "./images" output_dir = "./thumbs" target_size = (300, 300) quality = 80 os.makedirs(output_dir, exist_ok=True) for filename in os.listdir(input_dir): if not filename.lower().endswith((".jpg", ".jpeg")): continue filepath = os.path.join(input_dir, filename) img = Image.open(filepath) img.thumbnail(target_size) out_path = os.path.join(output_dir, filename) img.save(out_path, "JPEG", quality=quality) print("处理完成")

thumbnail()会等比缩放,所以最终尺寸是 300x225 或 225x300 这样的组合,不会变形。但如果平台要求严格的正方形图,可以做"居中裁剪 + 缩放"的组合操作:

def make_square(img, size=300): w, h = img.size side = min(w, h) left = (w - side) // 2 top = (h - side) // 2 img = img.crop((left, top, left + side, top + side)) img = img.resize((size, size), Image.LANCZOS) return img

这段代码先把图片从中间裁成正方形,再缩放到目标尺寸。Image.LANCZOS是高质量的重采样滤波器,适合缩小操作;如果是放大图片,效果不一定理想。

5.2 批量加文字水印

给一组图片加统一水印,用脚本处理比人工操作省太多时间。水印位置我建议不要写死在角落里,而是根据图片尺寸动态计算,放在右下角并留出边距:

from PIL import Image, ImageDraw, ImageFont def add_watermark(img_path, out_path, text="示例水印"): img = Image.open(img_path).convert("RGBA") txt_layer = Image.new("RGBA", img.size, (0, 0, 0, 0)) font = ImageFont.truetype("simhei.ttf", max(20, img.width // 25)) draw = ImageDraw.Draw(txt_layer) # 先测量文字尺寸 bbox = draw.textbbox((0, 0), text, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] margin = 30 x = img.width - text_w - margin y = img.height - text_h - margin draw.text((x, y), text, font=font, fill=(255, 255, 255, 120)) result = Image.alpha_composite(img, txt_layer) result.convert("RGB").save(out_path, "JPEG", quality=88) add_watermark("a.jpg", "a_watermarked.jpg")

textbbox()是用来测量文字实际大小的关键方法,不同字体渲染出来的尺寸不一样,如果不测量直接硬编码坐标,很容易出现文字超出图片边界的问题。透明度选 120 左右比较适合白色水印,既不影响观看,又能起到版权标识作用。

5.3 图片拼接演示

Pillow 也可以做简单的图片拼接,比如把两张图并排放到一起:

from PIL import Image img1 = Image.open("left.jpg") img2 = Image.open("right.jpg") # 统一高度 height = min(img1.height, img2.height) img1 = img1.resize((int(img1.width * height / img1.height), height)) img2 = img2.resize((int(img2.width * height / img2.height), height)) new_img = Image.new("RGB", (img1.width + img2.width, height), (255, 255, 255)) new_img.paste(img1, (0, 0)) new_img.paste(img2, (img1.width, 0)) new_img.save("combined.jpg")

paste()方法的第二个参数是放置位置。如果两张图尺寸不一致,直接拼接会比较难看,所以先统一高度。要注意的是,粘贴带有透明通道的图片时,可以通过第三参数传入 mask 避免透明区域出现黑底,这个细节在 RGBA 图片拼接时很重要。

6. 常见问题与排查避坑记录

6.1 疑难问题速查表

我把实际使用中遇到过的、以及身边朋友问过最多的问题汇总成一个速查表,方便直接查阅:

问题症状根本原因解决方案
保存 JPEG 时报错 "cannot write mode RGBA"JPEG 不支持透明通道先调用convert("RGB"),或先填充透明区域为白色
图片旋转后内容被裁剪rotate()默认不扩展画布设置expand=True
缩放后图片变形resize()等比性不保证改用thumbnail(),或手动按宽高比算目标尺寸
中文文字显示为方块/乱码使用默认位图字体不支持中文加载系统或项目内的中文字体文件
打开图片后报错 "image file is truncated"图片文件不完整或显卡端下载损坏手动补齐文件,或使用ImageFile.LOAD_TRUNCATED_IMAGES = True尝试加载可恢复部分
处理几千张图片时内存占用过大open()未及时释放不用的图用完显式img.close(),或用with Image.open(...) as img:管理上下文
大图putpixel()处理极其慢逐像素 Python 调用开销大改用 numpy 数组操作,或使用Image.point()批量映射
批量处理时遇到格式不支持旧版本 Pillow 对少数格式(如 WebP 动图)支持不完整升级 Pillow 到最新版,或先统一转换到中间格式
图片在网络上传输后颜色变暗淡某些国产浏览器/App 压缩导致色彩配置解析问题转换为 sRGB 模式并重新保存,必要时用 ImageEnhance.Color 增强饱和度

6.2 我的几点实操心得

第一,模式和格式不要混为一谈。RGB、RGBA 是像素存储模式,JPEG、PNG 是文件封装格式。模式决定图片能不能透明,能不能无损保存;格式决定文件大小和兼容范围。写代码之前先想清楚目标格式需要什么模式,可以少踩很多坑。

第二,批量处理脚本一定要先处理单张测试。我以前写过一个批量处理 2000 张图片的脚本,本以为万无一失,跑完后发现其中一批图片是 CMYK 模式的,输出结果颜色严重偏色。后来我在每张图片的处理开头加了一个convert("RGB"),CS 类的问题彻底解决了。

第三,涉及中文路径时要格外小心。Windows 下某些旧版本 Pillow 对中文字符路径的文件名打开会报编码错误,或者保存时乱码。我在项目里统一改为先使用os.chdir()进入目录,再用相对路径处理。或者遇到编码相关问题时,用Path对象而不是直接字符串拼路径。

第四,性能瓶颈不在 Pillow 本身,而在循环体设计。如果你要循环处理一个列表里的图片,尽量不要在循环内部重复加载字体文件、反复创建相同尺寸的图层,这些初始化动作提到循环外面,能明显提速。比如给 50 张图加水印,字体加载只需一次:

font = ImageFont.truetype("simhei.ttf", 40) for path in image_paths: # 循环里只做打开、绘制、保存

第五,Image.open()之后,如果想要保留完整操作能力,最好在同一上下文里完成所有处理再保存,避免后来再访问时数据已经失效。特别是在写后端服务接口时,处理好文件流生命周期非常重要,否则会有莫名的空指针或 SystemError 报错。

7. 一些更偏门但实用的扩展

7.1 图片信息读取与 EXIF 处理

除了像素数据,图片文件里还包含元数据。用 Pillow 可以读取一些基本信息:

from PIL import Image from PIL.ExifTags import TAGS img = Image.open("photo.jpg") exif_data = img.getexif() for tag_id, value in exif_data.items(): tag = TAGS.get(tag_id, tag_id) print(f"{tag}: {value}")

这在处理从相机或手机导出的照片时非常有用。但要注意的是,Pillow 在复制和重存图片时,默认不会完整保留所有 EXIF 信息。如果你希望保留版权信息或拍摄参数,需要在保存前手动把原图的 exif 传进去:

img.save("output.jpg", exif=img.info["exif"])

具体字段兼容性要看源文件和目标格式的匹配情况,保存后最好拿工具打开确认一次。

7.2 制作简单的 GIF 动图

利用 Pillow 的save()支持多帧格式的特性,可以把多张静态图合成 GIF:

from PIL import Image frames = [] for filename in ["frame1.png", "frame2.png", "frame3.png"]: frame = Image.open(filename).convert("P", palette=Image.ADAPTIVE) frames.append(frame) frames[0].save( "output.gif", save_all=True, append_images=frames[1:], duration=200, loop=0, )

duration是每帧间隔的毫秒数,loop=0表示无限循环。这个方法做表情包或简单产品展示动图很方便。不过要提醒,Pillow 对 GIF 编码的压缩率不高,帧数多时文件会偏大,尽量避免做大尺寸长动画。

7.3 减少图片文件大小的几个组合方案

如果目标是减小图片体积而不是追求最高画质,可以组合使用几个手段:

  • 将图片转换为 JPEG 并降低quality到 60~75,这是最直接的压缩方式
  • 将尺寸缩小到实际展示需要的大小,这是最有效的瘦身手段
  • 如果要求透明背景,可以改用 PNG-8 模式,而不是默认的 PNG-32,能显著减小文件
  • 适当降低颜色数,使用quantize()方法进行调色板量化,能大幅压缩 GIF 和 PNG 体积

实际项目中存储空间不够时,我一般优先做尺寸缩小,然后降 JPEG 质量,能减少 80% 的体积,视觉损失非常小。官网要求高清大图的场景,则不建议压到质量低于 80,否则放大后细节会出现明显劣化。

8. 内容还可以怎么扩展

如果你已经把前面这些功能都玩明白了,下一步可以考虑三个方向。

方向一是配合 Web 框架做在线图片处理接口。在 Flask 或者 FastAPI 里写一个接收 Multipart 图片、调用 Pillow 处理、返回新图片的接口,就是一个小型图床服务的基础。需要注意并发和内存控制,避免一次接收超大图片。

方向二是结合 numpy 和 scipy 做自定义的图像算法。Pillow 提供了图像 IO 和基本变换能力,但特定的滤波、频域处理、颜色空间变换等不是它的强项。把图片转成 numpy 数组后用算法处理,再转回 Pillow 显示和保存,是图像处理脚本的常用套路。

方向三是尝试 Pillow 的批量处理插件生态。Pillow 本身支持多类型的插件,比如针对 WebP、PSD、MPO 等格式的扩展。如果你工作的业务场景里经常出现某些特殊格式,查一下对应的插件说明,能省去先转换到中间格式再处理的麻烦。

这个库在大多数图像处理需求里都够用了,关键是搞清楚自己的使用场景,然后把它嵌到正确的流程里去。我经常在脚本里把 Pillow 和文件监听、定时任务结合,让图片处理完全自动跑起来,省下的时间可以做更多有价值的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进