如果让我给刚开始学数字图像处理的人推荐一套组合,我会不假思索地说:Jupyter Notebook 加 Python 加 Pillow。Jupyter Notebook 的单元格即时反馈配合 Pillow 这个轻量级图像处理库,基本可以做到“改一行参数、立刻看到结果”,对初学者特别友好。我当初就是靠这套组合入门的,后来带新人做图像相关的小项目,也一直用这套流程跑原型验证,反馈都很不错。
这篇内容适合三类人:一是刚接触 Python 图像处理、想知道从哪下手的初学者;二是已经用过 OpenCV、但感觉太重、想做快速验证的人;三是需要在 Jupyter Notebook 里完成批量图片处理、给图片加水印、生成缩略图这类实际小任务的人。我会把 Pillow 的常用操作拆开讲,每一步都带上可运行代码和易踩的坑,最后给两个可以直接抄作业的实战案例。
1. 环境搭建与工具选型:为什么是 Jupyter Notebook 加上 Pillow
先说结论:不是非用 Jupyter Notebook 不可,但如果你要学图像处理,它确实是目前反馈链路最短的交互环境。
1.1 三步装好运行环境
如果你的电脑上还没装任何 Python 环境,最简单的方式是直接安装 Anaconda 发行版。它自带 Python、Jupyter Notebook、Pillow、NumPy、Matplotlib 等一大堆常用的科学计算库,装完就能用,省去很多折腾。如果你已经装了 Python 或者 Miniconda,可以按下面的步骤手动补齐:
# 创建并进入独立环境,避免污染系统 Python conda create -n image_env python=3.10 conda activate image_env # 安装 Pillow 和 Jupyter,Matplotlib 用来在 Notebook 里显示图片 pip install pillow jupyter matplotlib安装完成后,在终端输入jupyter notebook,浏览器就会自动打开 Notebook 首页。新建一个 Notebook 文件,在单元格里输入下面这段代码,按 Shift + Enter 运行,能输出图片信息就说明环境搭好了:
from PIL import Image img = Image.open("test.jpg") # 换成你本地的图片路径 print(img)提示:Pillow 的版本迭代比较快,建议安装 8.0 以上的版本,功能完整度更高。如果是在老版本代码里见过
Image模块直接 import 就报错,多半是把Pillow和PIL搞混了——Pillow 是继承自老项目 PIL 的升级维护版本,安装包名是pillow,但导入名仍然是from PIL import ...。
1.2 验证环境:跑通第一段图像处理代码
装好之后强烈建议先做一个“最小验证”,确保图片能读、能改、能显示,不然等代码写多了再发现问题,排查范围就会扩大到整个项目。
from PIL import Image import matplotlib.pyplot as plt # 读取图片 img = Image.open("test.jpg") # 放大 2 倍,用高质量的 LANCZOS 重采样 img_big = img.resize((img.width * 2, img.height * 2), Image.LANCZOS) # 在 Notebook 里并排显示原图和放大图 fig, axes = plt.subplots(1, 2, figsize=(10, 5)) axes[0].imshow(img) axes[0].set_title("original") axes[1].imshow(img_big) axes[1].set_title("scaled") plt.show()这步跑通之后,整个学习路径就顺畅了。后面所有的小实验都可以在这个 Notebook 里反复修改、反复验证,每一个单元格的输入和输出都看得见,比写一段脚本然后命令行打印结果要直观得多。
我在实际教学里发现一个共性:初学者最容易卡住的不是代码语法,而是“我改了参数但看不到效果”。Jupyter Notebook 天然解决了这个问题——图像结果直接渲染在单元格下方,改个参数重新运行,前后对比一目了然。这也是我把它作为首选环境的核心原因。
2. 图像读取、属性查看与格式转换:基本功比技巧更重要
很多教程一上来就整滤镜、整人脸识别,看起来很酷,但真正干活的时候,最先碰到的问题往往是“图片怎么读的”“格式怎么转的”“为什么保存出来的颜色不对”。这些基本功决定了你后面所有操作的上限。
2.1 打开图像前必须搞清楚的三个属性
用 Pillow 打开图片只是第一步,真正有用的是读取出图片的三种核心属性:格式、尺寸和模式。
from PIL import Image img = Image.open("photo.jpg") print("格式:" + str(img.format)) print("尺寸:" + str(img.size)) print("模式:" + str(img.mode)) print("通道数:" + str(img.getbands()))这三个属性能解释很多“诡异现象”:
format:比如 JPEG、PNG、GIF。它决定文件底层编码方式,也决定某些操作能不能做。JPEG 不支持半透明通道,PNG 支持。size:返回(宽, 高)元组,注意宽和高是像素个数,不是坐标。后续所有裁切、缩放、拼接都是以这个为基准。mode:这个最容易被忽视。L是 8 位灰度图,只有一个通道,每个像素取值 0 到 255;RGB是 24 位真彩色,每个像素有红、绿、蓝三个分量;RGBA在 RGB 基础上多了透明通道;CMYK多用于印刷,P是调色板模式,用索引映射颜色。
我见过最多的问题:用 PIL 打开一张带透明通道的 PNG,直接保存成 JPEG,然后发现黑色背景或者颜色变了。原因就是 JPEG 不支持透明通道,保存时透明信息被丢弃,具体表现跟底层处理逻辑有关。正确的做法是先把 RGBA 合成到白色背景上,或者做通道丢弃处理。
另外要记住一个关键点:Image.open()是“懒加载”的,它不会立刻把整张图片数据读进内存,而是打开文件头、建立图像对象。真正访问像素数据是在.load()或者.getdata()的时候。如果你open之后把原始文件删了,再调用某些方法就可能报错,这一点在批量处理文件时特别容易踩到。
2.2 格式转换与批量处理的小套路
格式转换最常见的写法是“另存为”。Pillow 会根据你保存的文件扩展名自动决定编码方式:
img = Image.open("photo.png") img.save("photo.jpg", quality=90)这一段代码只要 2 行,但里面藏着几个新手必踩的坑。
第一个坑:PNG 带透明通道转换成 JPEG 会变色,因为 JPEG 不支持透明。解决办法是先把图片合成到背景上:
img = Image.open("photo.png").convert("RGBA") background = Image.new("RGBA", img.size, (255, 255, 255, 255)) result = Image.alpha_composite(background, img) result = result.convert("RGB") result.save("photo.jpg", quality=90)quality参数控制 JPEG 压缩质量,取值范围 1 到 95,数值越高文件越大质量越好。做 Web 图片一般用 80 到 90 比较平衡,做素材存档用 90 以上。
第二个坑:批量转换文件时,可能遇到生成的图片没变化、甚至报错。这类情况大概率是源文件路径拼接或者格式判断出了问题。推荐的写法是用os.scandir或pathlib遍历目录,按扩展名过滤:
import os from PIL import Image from pathlib import Path input_dir = Path("raw_images") output_dir = Path("converted_images") output_dir.mkdir(exist_ok=True) for file_path in input_dir.iterdir(): if file_path.suffix.lower() not in (".png", ".jpg", ".jpeg"): continue img = Image.open(file_path) img.save(output_dir / (file_path.stem + ".png"))这里有个经验:遍历目录时不要用os.listdir然后手动拼字符串,用pathlib的Path对象可以避免操作系统路径分隔符不一致的问题,跨平台兼容性更好。
3. 几何变换与区域操作:裁剪、缩放、旋转的实用细节
几何变换是图像处理里最常用的操作,也是新手最容易产生“参数理解偏差”的地方。这一节我把裁剪、缩放、旋转逐个拆开讲,每个都附上坐标图和注意事项。
3.1 裁剪的坐标陷阱
裁剪用crop()方法,参数是一个四元组(left, top, right, bottom),分别表示裁剪区域的左、上、右、下边界。这里最关键的一点是:右边界和下边界是不包含的,也就是左闭右开区间。
img = Image.open("photo.jpg") # 裁剪从 (100, 50) 到 (300, 250) 的区域 cropped = img.crop((100, 50, 300, 250)) cropped.save("cropped.jpg")如果你想要一个从第 0 行第 0 列开始、宽 100、高 100 的方块,正确写法是crop((0, 0, 100, 100)),它取的是第 0 到 99 行、第 0 到 99 列,并不包含第 100 行第 100 列。如果你写crop((0, 0, 99, 99)),实际得到的是 99 × 99 的图,会比你预期小一圈。
新手很容易把坐标搞反,是因为把图片坐标和数组索引搞混。图片处理里,(x, y)中x表示列方向(水平),y表示行方向(垂直),这在编程中刚好和二维数组的(row, col)顺序相反。写代码时多想想这个对应关系,可以省下很多调试时间。
另外一个坑是裁剪坐标越界。crop()不会报错,但会悄悄把越界部分裁掉,导致输出尺寸和预期不符。比如宽 1000 的图,你写成crop((900, 0, 1100, 100)),实际得到的是 100 宽而不是 200 宽。做批量处理时,建议先判断目标区域是否在原图范围内:
box = (900, 0, 1100, 100) if box[0] >= 0 and box[1] >= 0 and box[2] <= img.width and box[3] <= img.height: cropped = img.crop(box) else: # 做边界裁剪或跳过这一张,避免静默错误 safe_left = max(0, box[0]) safe_right = min(img.width, box[2]) safe_top = max(0, box[1]) safe_bottom = min(img.height, box[3]) cropped = img.crop((safe_left, safe_top, safe_right, safe_bottom))3.2 缩放:用 resize 还是 thumbnail
缩放是图像处理里最常用的操作,但resize()和thumbnail()的选择,直接决定你做事的方式对不对。
resize((new_width, new_height))是按你给的尺寸硬性缩放,不会自动保持宽高比。如果你想将一张 1000 × 600 的图变成 800 × 800,resize会直接拉伸变形。
thumbnail((max_width, max_height))则不同,它会自动计算缩放比例,使结果符合指定的边界框,但保持原始比例,而且只缩不放。它会修改画布,并且只缩小不放大。这在生成缩略图场景下特别实用,不需要手动算比例。
img = Image.open("photo.jpg") # 直接 resize,会拉伸变形 stretched = img.resize((800, 800)) # 用 thumbnail 保持比例,限定在 800*800 范围内 thumb = img.copy() thumb.thumbnail((800, 800)) stretched.save("stretched.jpg") thumb.save("thumb.jpg")如果必须用resize且要保持比例,需要自己算目标尺寸:
target_width = 800 ratio = target_width / img.width target_height = int(img.height * ratio) resized = img.resize((target_width, target_height), Image.LANCZOS)还有一点容易被忽略:resize()返回一个新图像,原图不会变;thumbnail()是原地修改,不返回新对象。所以用thumbnail之前,最好先copy()一份,避免污染原图。
缩放质量方面,resize和thumbnail在省略参数时默认使用BICUBIC重采样。实际经验是:缩小图片用 LANCZOS,放大图片用 BICUBIC 或 LANCZOS 效果比较稳。如果只是快速预览,可以用NEAREST,速度最快但会有明显锯齿。
3.3 旋转时别忘了 expand 参数
旋转用rotate(angle),默认逆时针旋转,角度为正时逆时针。这个方向约定要记住,在处理需要精确角度的场景时很有用。
新手常犯的错误是旋转后图片被裁掉。看这个例子:
img = Image.open("photo.jpg") rotated = img.rotate(45)旋转 45 度后,原图的四个角会转到画布外,但 Pillow 为了保持原有画布尺寸,会把超出范围的部分裁掉,最后得到一张四个角缺失的菱形图。如果你希望画布跟着旋转后的内容扩展,完整显示整张图,需要设置expand=True:
rotated_full = img.rotate(45, expand=True)expand参数会重新计算画布尺寸,让旋转后的图完整显示。注意,旋转后图片尺寸会变大,背景默认是黑色。如果需要指定背景色,还需额外操作填充画布后合并,这在下文合成部分会提到。
如果你要旋转 90 度、180 度或 270 度,用transpose()更高效,它能直接变换像素排列:
rot90 = img.transpose(Image.ROTATE_90) rot180 = img.transpose(Image.ROTATE_180)4. 像素级操作与色彩调整:直接操作像素做图像增强
到了这一节,才是真正触及“数字图像处理”的本质。前面的操作都是对图像整体做变换,而这里我们要进入像素层面,亲手修改每个像素的颜色值。
4.1 用 getpixel 和 putpixel 读写像素
Pillow 提供了最低级的像素读写接口:getpixel((x, y))获取坐标(x, y)处的像素值,putpixel((x, y), value)设置该位置的像素值。对 RGB 图像来说,像素值是(r, g, b)三元组;对灰度图来说,就是一个 0 到 255 的整数。
img = Image.open("photo.jpg") # 获取坐标 (10, 20) 处的像素 pixel = img.getpixel((10, 20)) print("像素值:" + str(pixel)) # 将 (10, 20) 处的像素设为红色 img.putpixel((10, 20), (255, 0, 0))这个接口理解起来很直观,但性能很差。如果你用一个双重循环遍历 1000 × 1000 的图,一共要执行一百万个getpixel/putpixel调用,速度会非常慢。
更高效的做法是用load()拿到一个像素访问对象,在循环里直接操作:
pixels = img.load() width, height = img.size # 把图像反相 for y in range(height): for x in range(width): r, g, b = pixels[x, y] pixels[x, y] = (255 - r, 255 - g, 255 - b)load()返回的对象支持直接索引pixels[x, y],底层效率比getpixel高得多。大量像素操作时强烈推荐这种方式。
不过在 Jupyter Notebook 里做像素循环,还有一个更大的性能瓶颈:Python 的循环本身很慢。如果要对整张图做数学运算,比如加亮、二值化、色彩变化,建议用point()或转成 NumPy 数组批量计算,那才是真正的高效方案。
4.2 点运算调整亮度与对比度
如果你需要对整张图的每个像素应用同一个数学变换,用point()方法。它接收一个函数,作用在每一个像素值上,底层是经过优化处理的,速度远超手动循环。
img = Image.open("photo.jpg") # 亮度增强:每个像素值加 30,超过 255 的截断到 255 brightened = img.point(lambda value: min(255, value + 30))对 RGB 图像使用point()时,传入的函数会作用于每个通道的每个像素值。这里有个需要注意的地方:RGB 模式的point()函数会分别应用到 R、G、B 三个通道上,如果你只想调整其中一个通道,得先把图片拆成单通道处理。
对比度调整的原理稍微绕一点。对比度本质是像素值围绕中间值(128)的拉伸或压缩程度,公式如下:
# 对比度增强:新像素 = (原像素 - 128) * factor + 128 factor = 1.5 # 小于1为降低对比度,大于1为增强 contrast = img.point(lambda value: max(0, min(255, (value - 128) * factor + 128)))point()的运算逻辑和map()类似,就是一个纯函数应用到每个元素上。如果你的运算逻辑比较复杂,用 NumPy 数组操作会更灵活:
import numpy as np from PIL import Image arr = np.array(img).astype(np.float32) arr_gray = np.array(img.convert("L")).astype(np.float32)转成 NumPy 数组之后,你可以用向量化运算一次性完成所有像素的数学操作,比循环快几个数量级。这也是为什么在实际的计算机视觉项目里,Pillow 经常和 NumPy 搭配使用——Pillow 负责读写和处理格式,NumPy 负责高强度数学计算。
4.3 直方图均衡化:灰度图增强的简单实现
直方图均衡化是图像增强的经典操作,它能自动拉伸像素值的分布范围,让过暗或过亮的图对比更清晰。原理很简单:统计每个灰度值的出现次数,计算累计分布函数,再把每个像素值映射到均衡后的灰度值。
用 NumPy 实现直方图均衡化,只需要十几行代码:
import numpy as np from PIL import Image img = Image.open("gray_photo.jpg").convert("L") arr = np.array(img) # 统计灰度直方图 hist = np.bincount(arr.flatten(), minlength=256) # 计算累计分布函数 cdf = np.cumsum(hist) # 归一化到 0-255 并映射 cdf_min = cdf[cdf > 0].min() cdf_normalized = (cdf - cdf_min) / (cdf.max() - cdf_min) * 255 cdf_normalized = cdf_normalized.astype(np.uint8) # 映射原图像素 equalized = cdf_normalized[arr] result = Image.fromarray(equalized) result.save("equalized.jpg")这段代码理解起来不难:np.bincount统计每个灰度值出现的次数,np.cumsum计算累计频率,然后将累计频率归一化到 0 到 255 之间。这样暗部像素会被拉开,亮部像素也会被压缩到合适范围,图像看起来更通透。
Pillow 其实也内置了直方图均衡化接口:ImageOps.equalize(),一行代码搞定。为什么我还要展示原理实现?因为实际项目中经常会遇到“我不知道为什么图变好看/变坏了”的情况。理解了像素分布、累计分布、映射关系这三层逻辑之后,遇到异常结果就能快速定位是直方图统计问题还是映射问题,而不是对着黑盒瞎试。
经验分享:直方图均衡化对灰度图效果最直观,对彩色图直接套用会让饱和度变得奇怪。如果一定要做彩色图增强,先把 RGB 转成灰度图或者做亮度分量均衡化,再合成回去。
5. 滤镜效果与图像合成:从单张图片走向多图协作
滤镜和图像合算是 Pilliow 进阶玩法里最直观有趣的部分,它能把多张图合成一张,也能给图做各种风格化处理。这一节我会把滤镜参数和合成原理讲清楚,方便你按需组合。
5.1 Pillow 内置滤镜的选用与效果对比
Pillow 通过ImageFilter模块提供了很多内置滤镜,用法统一,效果也稳定:
from PIL import ImageFilter img = Image.open("photo.jpg") # 高斯模糊,radius 越大越模糊 blurred = img.filter(ImageFilter.GaussianBlur(radius=5)) # 边缘检测 edges = img.filter(ImageFilter.FIND_EDGES) # 轮廓提取 contour = img.filter(ImageFilter.CONTOUR) # 锐化 sharpened = img.filter(ImageFilter.SHARPEN) blurred.save("blurred.jpg") edges.save("edges.jpg") sharpened.save("sharpened.jpg")常用滤镜的选用逻辑很简单:
| 滤镜 | 作用 | 典型场景 |
|---|---|---|
GaussianBlur(radius) | 高斯模糊,radius 控制模糊程度 | 背景虚化、人像磨皮 |
BLUR | 普通均值模糊,比高斯模糊快但效果粗糙 | 快速降噪 |
BOXBLUR(radius) | 盒式模糊,速度快,效果接近均值模糊 | 大图预览 |
FIND_EDGES | 边缘检测,白色线条显示边缘 | 图像分割预处理 |
CONTOUR | 轮廓提取,输出类似描边效果 | 简化图像 |
DETAIL | 细节增强,适合微小纹理 | 文物、织物纹理 |
EMBOSS和EDGE_ENHANCE | 浮雕和边缘增强 | 图像风格化 |
UNSHARP_MASK | 反锐化掩模,比 SHARPEN 更精细 | 高精度锐化 |
这里有一个容易忽略的问题:BLUR和GaussianBlur虽然都叫模糊,实现机制完全不同。BLUR是对每个像素周围的邻居做简单平均,GaussianBlur是根据高斯分布给邻域像素加权平均,后者在处理边缘时过度更平滑,不会有明显台阶感。
滤镜的本质是卷积运算,即用一个固定尺寸的卷积核(比如 3×3、5×5)在图像上滑动,计算每个位置的加权和。理解这个原理后,你就能明白为什么模糊会让细节丢失、为什么边缘检测能提取轮廓——因为这些操作本质上都是通过对邻域像素的组合运算来实现的。
5.2 图像合成前必须处理的透明通道
图像合成有两种常用方式:blend(按权重混合)和composite(按蒙版混合)。
Image.blend(image1, image2, alpha)对两张图按alpha权重做线性混合:alpha=0时完全显示第一张图,alpha=1时完全显示第二张图,中间值产生淡入淡出效果。使用时有几个硬性条件:两张图的尺寸必须一致,模式最好相同,否则会报错或者产生预期外的颜色。
from PIL import Image img1 = Image.open("sunset.jpg").convert("RGB") img2 = Image.open("city.jpg").convert("RGB") img2 = img2.resize(img1.size) # 尺寸一致是前提 blended = Image.blend(img1, img2, alpha=0.5) blended.save("blended.jpg")Image.composite(image1, image2, mask)更灵活一些,它使用蒙版图像来决定哪些区域显示图像1、哪些区域显示图像2。蒙版通常是灰度图,白色部分显示图像1,黑色部分显示图像2,中间灰度产生半透明融合效果。
mask = Image.new("L", img1.size, 128) # 全灰蒙版,各显示50% composited = Image.composite(img1, img2, mask)如果想做区域合成,先生成一个黑白蒙版即可。比如图片上半部分用图1、下半部分用图2:
mask = Image.new("L", img1.size, 255) # 默认全白 for y in range(img1.height // 2, img1.height): for x in range(img1.width): mask.putpixel((x, y), 0) composited = Image.composite(img1, img2, mask)这里要注意mask必须和两张图大小一致,且为单通道灰度模式。如果蒙版是RGB模式,合成会报错或者结果奇怪。遇到合成结果与预期不符时,优先检查蒙版的模式。
透明通道合成还有一个高频场景:给 PNG 带透明区域的图片叠加背景。上面我提过alpha_composite,这是处理带透明层图片最稳的方法:
foreground = Image.open("logo.png").convert("RGBA") background = Image.open("background.jpg").convert("RGBA") # 把 logo 放在背景左上角 (100, 100) background.paste(foreground, (100, 100), foreground) background.save("combined.png")paste方法的第三个参数是蒙版。当粘贴的图像带透明通道时,把自身作为蒙版传进去,就可以让透明区域不盖住背景,只贴不透明区域。很多人会漏掉这个参数,结果贴出来的 logo 带一个黑色方框——那就是透明通道没有被正确使用导致的。
6. 实战案例:批量生成缩略图与添加水印
理论铺垫得差不多,该落到实际任务了。这一节给两个最常见的真实场景:批量缩略图和批量加水印。每个任务我都会给出完整代码和参数选择思路,你可以直接照搬修改。
6.1 一招搞定:给一堆图片批量生成缩略图
电商上传图片、个人相册整理、深度学习数据集预处理,都会遇到“把一堆大图统一缩成小图”的需求。用thumbnail()加循环就可以解决。
from PIL import Image from pathlib import Path input_dir = Path("originals") output_dir = Path("thumbs") output_dir.mkdir(exist_ok=True) max_width, max_height = 300, 300 for file_path in input_dir.iterdir(): if file_path.suffix.lower() not in (".jpg", ".jpeg", ".png"): continue img = Image.open(file_path) # 复制并生成缩略图,保持宽高比,只缩小不放大 img.thumbnail((max_width, max_height), Image.LANCZOS) save_path = output_dir / file_path.name img.save(save_path, optimize=True) print(f"{file_path.name} -> {img.size}")这段代码有三处细节值得注意:
第一处,用了img.copy()的必要性。thumbnail()会原地修改图像,而img是从原文件加载的。如果不复制就直接缩略,那么后面把这个img保存到本地时,本地保存的就是缩略图本身,原图变量就被覆盖了。虽然这次循环没用到原图,但养成“缩略前先复制”的习惯,可以在更复杂的场景里避免意外污染。
第二处,optimize=True参数。保存 JPEG 时,Pillow 会尽量优化编码,在相同视觉质量下把文件体积压得更小。对缩略图这种本来就要控制体积的场景来说,这个参数值得开。
第三处,thumbnail只缩不放。如果输入图片本身小于 300×300,它不会被放大,这避免了小图被强行拉伸变模糊。如果你希望小图也放大到统一尺寸,就得用resize,但通常缩略图场景下“只缩不放”才是正确的。
6.2 给图片批量添加半透明文字水印
给图片加水印有两大诉求:一是不破坏原图观赏体验(所以常用半透明),二是在保护版权的同时不影响阅读。Pillow 加半透明文字水印需要用到ImageDraw、ImageFont和 RGBA 透明通道。
from PIL import Image, ImageDraw, ImageFont from pathlib import Path def add_watermark( input_path, output_path, text, opacity=100, font_size=36, position=("right_bottom", 20, 20) ): img = Image.open(input_path).convert("RGBA") # 创建一个透明覆盖层,用于绘制水印 overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) # 加载字体。Windows 和类 Unix 系统字体路径不同 try: font = ImageFont.truetype("msyh.ttc", font_size) # 微软雅黑 except OSError: font = ImageFont.truetype("DejaVuSans.ttf", font_size) # 计算文字占据的尺寸,用于定位 bbox = draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] # 定位逻辑 if position[0] == "right_bottom": x = img.width - text_width - position[1] y = img.height - text_height - position[2] elif position[0] == "center": x = (img.width - text_width) // 2 y = (img.height - text_height) // 2 elif position[0] == "left_top": x, y = position[1], position[2] else: x, y = position[1], position[2] # 绘制文字,颜色的 alpha 值控制透明度 draw.text((x, y), text, font=font, fill=(255, 255, 255, opacity)) # 合并原图与水印层 merged = Image.alpha_composite(img, overlay) merged = merged.convert("RGB") merged.save(output_path, quality=95) input_dir = Path("photos") output_dir = Path("watermarked") output_dir.mkdir(exist_ok=True) for file_path in input_dir.iterdir(): if file_path.suffix.lower() not in (".jpg", ".jpeg", ".png"): continue out_path = output_dir / file_path.name add_watermark( file_path, out_path, text="示例水印", opacity=100, font_size=48, position=("right_bottom", 30, 30) )这段代码有几个关键点。
水印的透明效果来自fill元组的最后一个分量 alpha。opacity=255是完全不透明,opacity=100是半透明。如果你直接把水印画在原图上,而不是画在覆盖层上,就无法实现这种局部透明效果。所以这里先画到独立的 RGBA 透明覆盖层,再用alpha_composite合并,是保留半透明效果的关键路径。
中文字体路径在不同系统上差异很大。Windows 上常见的是msyh.ttc(微软雅黑)和simhei.ttf(黑体);Linux 上常见的是DejaVuSans.ttf或NotoSansCJK.ttc。代码里用try ... except OSError做了字体回退,这样在换机器运行时不会因为缺字体直接崩溃。如果你装了自定义字体,也可以直接传字体文件路径。
文字定位时,我用了draw.textbbox()来计算文字实际占用的尺寸,这比用textsize()更准确,因为不同字体的字符宽高差异很大,而且textsize在新版 Pillow 里已经被标记废弃。计算好宽高之后,再根据定位模式算出文字左上角的坐标,这样水印就能落在你预期的位置上。
7. 常见问题与排查技巧实录
最后这部分,我把这些年实际处理图片时遇到的问题整理成一个速查列表,每个都是血泪经验。
7.1 照片突然变色?多半是模式问题
这是最常见的现象:打开一张 PNG 带透明背景的图标,转成 JPEG 后背景变成黑色;或者用convert("L")转灰度后保存再看,颜色和预期完全不同。
核心原因是各种图像模式存储的数据结构不同。L模式只存亮度,RGB 存三个通道的颜色,RGBA 额外存透明信息。你从 RGBA 转换到 RGB 时会丢失透明通道,从 RGB 转换到L时会丢失全部色彩信息。
排查口诀:报错优先看
mode,变色优先看mode,合成结果怪也先看mode。Pillow 里九成以上的“莫名其妙”问题都跟模式有关。
7.2 处理速度太慢?两层原因分开排查
第一层:Python 循环本身就慢。getpixel加putpixel处理一百万像素的图要几秒钟,而用point()、load()或者 NumPy 数组批量运算,同样的操作可能只需要几十毫秒。能用内置方法就不要自己写循环。
第二层:图片太大导致内存消耗高。比如处理一张 8000×6000 的相机原图,仅像素数据就是几千万字节级别,再叠加多步操作时内存很容易翻倍。这种情况下,能用thumbnail就先缩小,能分批处理就不要一次性加载所有图片。
有个技巧叫“中途加载”,非常实用:
# 读取 JPEG 大图时先设定预览尺寸,减少解码负担 img = Image.open("very_large.jpg") img.draft("RGB", (800, 600)) data = img.load()draft()是在不完整解码的情况下按目标尺寸读取 JPEG 数据,适合做快速预览或批量处理前的一次性探测。注意它只对部分格式有效,且必须在load()之前调用。
7.3 路径报错:文件明明存在,却打不开或保存不了
这类问题主要集中在两方面。
第一种,工作目录不对。Jupyter Notebook 里,当前工作目录通常是 Notebook 文件所在的目录,但如果你在脚本里用了相对路径且脚本是从别的目录启动的,路径就不对了。解决方法是打印os.getcwd()看看当前目录,或者把路径转换成绝对路径再操作。推荐用Path对象拼接路径,它天然支持相对路径和绝对路径转换,也避免手动拼接字符串带来的跨平台问题。
第二种,文件被占用或权限不足。在 Windows 上,如果图片已经被某个程序打开,Pillow 去写同一个文件可能会报权限错误。保存到系统目录(比如 C 盘根目录)通常也会被拒绝。遇到这种情况,第一反应是确认文件是否被别的程序占用,第二是查看目标目录是否有写权限,最后再考虑文件名是否包含特殊字符。
7.4 “图片文件已截断”报错:懒加载的代价
Image.open()是懒加载模式,在.load()真正读取像素数据之前,它只会读取文件头信息。一旦原始文件被移动、删除或者被其他操作修改,等到真正需要数据时就会报错,错误信息通常是OSError: image file is truncated。
这在批量处理时特别坑:你循环打开一批图,然后还没处理完,文件就被上一个步骤删掉了,结果加载时报错。解决思路是,一旦确认某张图接下来要用,立刻load()把数据加载到内存,或者直接复制一份对象。另一种情况是原文件本身损坏或下载不完整,可以用 Pillow 的容错参数:
img = Image.open("truncated.jpg") img.load() # 会尝试解码并抛出截断错误 # 容忍截断,部分解码也继续 from PIL import ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = TrueLOAD_TRUNCATED_IMAGES = True会让 Pillow 在遇到不完整图片数据时继续尝试解码,而不是直接抛错。这在爬虫下载图片、网络传输不稳定的场景里非常有用,但要注意它只适用于 JPEG 这类有容错机制的格式,PNG 截断还是照样报错。
7.5 透明通道“黑边”问题:alpha 合成细节
给透明 PNG 贴到背景后,如果发现边缘出现黑色或灰色的“脏边”,一般是两种原因:一是原图的透明区域颜色值不是纯透明,而是带有暗色半透明边缘;二是合成时没有正确使用蒙版,直接把整张图(包括透明区域)贴了上去。
正确的做法是始终用mask参数控制粘贴区域:
background.paste(foreground, (x, y), foreground)第三个参数传前景图本身作为蒙版,这样只有不透明部分会被贴上去。如果边缘还有轻微脏色,可以先将前景图做一次RGB通道清理,或者在load()之后微调 alpha 值。
我这里再分享一个实战小技巧:批量加水印或者合成图片时,如果出现边缘色差,可以先转成RGBA再转回RGB,有时候能有效消除合成时产生的像素偏移。原理是 RGBA 模式下所有颜色通道都带完整的颜色信息,而 JPEG 压缩本身有信息损失,二次转换时通过 RGBA 中间态可以保留更多细节。
做图像处理做久了你会发现,Pillow 本身并不复杂,复杂的是图像背后那套“像素如何组织、颜色如何表达、文件如何编码”的逻辑。把这些基础概念吃透之后,再去看 OpenCV 或者深度学习图像预处理,很多东西都是相通的。我个人的体会是:能让你在图像处理路上少走弯路的,不是你记住多少 API,而是你理解每一个操作背后到底在改什么。多拿自己的照片在 Jupyter Notebook 里玩一玩、改一改、错一错,比看十篇教程都管用。