1. 为什么用 Python Pillow 入门计算机图形学Python 能火到今天这个程度生态和上手速度占了九成功劳。而 Pillow前身是 PIL基本上是 Python 处理图像的事实标准库我在实际项目里用它做验证码识别前的图像预处理、爬虫图片清洗、给模型训练做数据增强脚本、批量加文字水印甚至做个简易的九宫格切图工具都行。如果你正在学计算机图形学或者刚开始接触图像处理Pillow 几乎是最好的第一站。很多人在听到“计算机图形学”这个词的时候第一反应是 OpenGL、Shader、游戏引擎或者光线追踪那一套东西。这确实是图形学的核心方向但对初学者特别不友好——光是把一个三角形画到屏幕上就要理解顶点缓冲、渲染管线、坐标系变换劝退率极高。Pillow 绕开了这套底层机制让你直接面对“像素数组”也就是数字图像最原始的样子。你可以在 Python 里打开一张图片用一个双层 for 循环修改每一个像素点的 RGB 值肉眼看到变化规律这种反馈对建立图像直觉非常有帮助。这篇内容适合三种人第一种是完全零基础想用 Python 练手图像处理的第二种是在学《计算机图形学》这门课需要理解图像原理、矩阵变换和滤波算法落地的第三种是已经在写爬虫或者做数据清洗急需一个批量处理图片的实用工具又不想引入 OpenCV 这种重量级依赖的同学。反正一句话Pillow 不是万能的它做不了 3D 渲染也不是给视频做帧动画特效的工具。但作为图像处理入门工具箱它能让你在半天之内完成从“知道有这回事”到“写代码做出实际效果”的跨越。下面我会从环境搭建、图像数据结构、核心实操到批量处理一步步拆解。2. 环境搭建与 Pillow 的图像底层逻辑2.1 安装与版本选择Pillow 的安装流程很简单优先通过 pip 安装pip install Pillow如果你在写代码的时候用的是import PIL那么恭喜你你本来就安装了 Pillow——因为 PIL 过去停止维护之后社区把代码转移到了 Pillow 名下但包名PIL一直延续了下来。所以项目里可以from PIL import Image这其实是在导入 Pillow。Python 3.11 及以上版本使用时建议用最新的 Pillow 版本老版本在某些新 Python 版本上会出现 wheel 包缺失导致编译失败。我遇到过很多次通过pip install PIL直接报错找不到包很多同学就懵了。实际上正确的做法就是先pip install Pillow然后在代码里写from PIL import Image。这个坑网上问的人极多这里先替你踩了。安装完成后可以跑一下这行代码看看基础信息from PIL import Image print(Image.__version__) # 或者这样验证2.2 图像本质像素、坐标与通道在深入代码之前一定要理解一张数字图像在计算机里是什么。你可以把图像想象成一个大表格每个格子是一个像素Pixel像素的位置用 (x, y) 坐标表示。注意这里跟数学坐标系不一样y 轴是向下增长的左上角是原点 (0, 0)。这个反直觉的坐标系统是初学者最容易搞错的地方很多人的图像出现上下反转就是栽在这里。每个像素点的颜色在 Pillow 里通常用一个 RGB 三元组表示比如 (255, 0, 0) 是红色(0, 255, 0) 是绿色(0, 0, 255) 是蓝色。如果是带透明度的 PNG 图片那就是 RGBA 四元组A 是 Alpha 通道表示透明度。Pillow 图像有一个mode属性它决定了一个像素占用多少比特以及如何解释颜色。最常用的是下面几种模式说明像素示例RGB真彩色每个像素 3 个通道(255, 0, 0)RGBA带透明通道的真彩色(255, 0, 0, 128)L灰度图每个像素只有一个亮度值128P调色板模式像素值指向调色板颜色58理解 mode 非常重要。比如你用Image.open()打开一张带透明度的 PNG 图片然后直接调用.convert(RGB)转换透明区域会变成黑色底这在合成图片时会出大问题。如果需要保留透明信息应该先判断或者统一用.convert(RGBA)。2.3 用代码读出像素的感觉先做一个最简单的小实验创建一张 100×100 的纯红色图片然后读取几个特殊位置的像素值from PIL import Image # 创建一张 100x100 的纯红色图片 img Image.new(RGB, (100, 100), (255, 0, 0)) # 查看图片大小和模式 print(img.size) # (100, 100) print(img.mode) # RGB # 读取某个坐标的像素值注意是 (x, y) 不是 (行, 列) pixel img.getpixel((50, 50)) print(pixel) # (255, 0, 0) # 修改一个像素为绿色 img.putpixel((0, 0), (0, 255, 0)) print(img.getpixel((0, 0))) # (0, 255, 0) # 保存为文件 img.save(red.png)getpixel()和putpixel()是 Pillow 里最基础的两个像素操作接口简单直观适合学习和调试。但如果你要遍历整张 1000×1000 的图片一次读取一个像素、处理、再写回去速度会非常慢。等你有了一点基础处理大量像素的时候建议用load()接口或者直接转成 Numpy 数组性能差距可能有几十倍。3. 从零绘制图形Pillow 像是一个基础画笔工具很多做图形学入门的朋友最喜欢问的问题之一就是能不能用 Pillow 画线、画圆、画多边形让它像一个极简版的画图板答案是能而且 Pillow 在这方面的 API 设计得比较直观。3.1 ImageDraw 模块在画布上作画ImageDraw是 Pillow 的绘图模块提供了一个可在任意 Image 对象上绘制的画布。它支持直线、弧线、多边形、矩形、椭圆、文字等基本图元。创建一个画布的方式很简单from PIL import Image, ImageDraw # 创建一个 500x500 白色底图 img Image.new(RGB, (500, 500), (255, 255, 255)) draw ImageDraw.Draw(img) # 画一条红色直线起点 (50, 50)终点 (450, 50) draw.line((50, 50, 450, 50), fill(255, 0, 0), width3) # 画一个蓝色矩形 draw.rectangle((100, 100, 300, 250), outline(0, 0, 255), width2) # 画一个绿色椭圆参数是外接矩形所以能画圆也能画扁圆 draw.ellipse((150, 300, 350, 450), fill(0, 255, 0)) img.save(draw_demo.png)这里最容易搞混的就是椭圆的参数它不是“圆心 半径”或者“x轴半径 y轴半径”而是接收一个四元组代表椭圆外接矩形的左上角和右下角坐标。如果你要给菱形、三角形这样的任意多边形上色用polygon()接口传入顶点坐标列表即可。3.2 写文字给图像加标注的常用操作Pillow 的text()方法不能直接传字体路径参数到默认设置否则会用内置的位图字体在放大时会特别模糊。写文字的正确姿势是先加载 TrueType 字体文件from PIL import Image, ImageDraw, ImageFont img Image.new(RGB, (500, 200), (255, 255, 255)) draw ImageDraw.Draw(img) # 先加载字体你需要准备一个 .ttf 或 .otf 字体文件 font ImageFont.truetype(/System/Library/Fonts/Arial.ttf, size36) # Windows 路径示例C:/Windows/Fonts/msyh.ttc draw.text((20, 50), Hello Pillow, fill(0, 0, 0), fontfont)注意如果路径填错了Pillow 会直接抛异常。所以我一般建议把字体文件放到项目目录下用相对路径读取这样换一台电脑跑的时候不容易出问题。3.3 图形绘制背后的“画布思维”Pillow 的绘制方式和现实生活中画画很相似底层图像数据是一张空白画布ImageDraw 对象是你的画笔每调一次绘制函数就相当于拿笔在画布上留下一笔后画的元素会覆盖先画的元素。这跟 OpenGL 里后画的三角形会覆盖先画的三角形是同一个道理——图形渲染顺序是一个核心概念。这里可以做一个有意思的入门练习用随机参数画 100 个颜色随机的半圆观察叠加顺序。如果在绘制之前把刚才的draw.ellipse()加一个透明度参数需要把模式从 RGB 换成 RGBA还能看到颜色混合效果。颜色混合Alpha Blending是图形学中非常基础的概念Pillow 可以直接做出来感知比纯理论要好很多。4. 图像变换的实操配方几何变换与滤镜如果说画图是“从无到有”那么图像变换就是“给已有的图像做手术”。Pillow 在这方面的接口用起来非常顺滑而且学一次能迁移到 OpenCV、scikit-image 等其他库中去。4.1 缩放、旋转、翻转缩放用resize()注意这里传入的是目标尺寸的元组(宽, 高)不是放大倍数。旋转用rotate()默认逆时针旋转但旋转后画布尺寸是不变的周围会用黑色填充所以非常容易产生黑边。如果旋转角度不是 90 度的整数倍黑边是不可避免的除非我们手动设置expandTrue让画布跟着旋转一起扩大。from PIL import Image img Image.open(demo.png) # 缩放到 300x200 resized img.resize((300, 200)) # 旋转 45 度并且让画布跟随扩大 rotated img.rotate(45, expandTrue, fillcolor(255, 255, 255)) # 左右翻转 flipped_h img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) # 上下翻转 flipped_v img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)还有一个常用操作是裁剪crop()接收一个四元组(left, upper, right, lower)右下角的坐标是开区间也就是不包含在裁剪结果内。裁剪不缩放直接切出一块区域内存占用也不大可以用来做图像局部处理。4.2 滤镜效果图像降噪和高斯模糊Pillow 内置了一个ImageFilter模块里面实现了高斯模糊、轮廓提取、边缘增强、浮雕等常见效果。别看它是“内置封箱滤镜”原理跟图形学课上的卷积核是一回事。from PIL import Image, ImageFilter img Image.open(demo.png) # 高斯模糊半径越大越模糊 blurred img.filter(ImageFilter.GaussianBlur(radius5)) # 轮廓提取可以看到像简笔画一样的效果 contour img.filter(ImageFilter.CONTOUR) # 边缘增强 edge_enhance img.filter(ImageFilter.EDGE_ENHANCE) # 浮雕效果 emboss img.filter(ImageFilter.EMBOSS)4.3 图像合成与像素混合在图像处理任务里经常需要把两张图叠加在一起比如给照片加水印、把两个图层合成一张。Pillow 提供了Image.blend()和Image.composite()两个接口。blend适合两张大小完全相同的图像通过 alpha 参数控制混合比例。composite则需要一个 mask 图像来决定哪些地方显示第一张图哪些地方显示第二张图。from PIL import Image img1 Image.open(photo1.jpg).convert(RGB) img2 Image.open(photo2.jpg).convert(RGB) # 统一尺寸否则会报错 img2 img2.resize(img1.size) # 各取 50% 的混合 mixed Image.blend(img1, img2, alpha0.5) # 更精细的合成用一个 mask 决定区域 from PIL import ImageDraw mask Image.new(L, img1.size, 0) draw ImageDraw.Draw(mask) # 圆形区域 255 表示显示 img1 draw.ellipse((100, 100, 400, 400), fill255) combined Image.composite(img1, img2, mask)这里要注意Image.blend两个图像的模式必须是同一个如果一张是 RGB 一张是 RGBA会直接报错。处理这种问题的方法很粗暴但有效全部.convert(RGB)。4.4 用矩阵变换理解图形学里的仿射变换Pillow 提供了一个叫transform的高级接口可以应用仿射变换矩阵。仿射变换在图形学里包括平移、旋转、缩放、错切Shear等核心是一个 2×3 的矩阵。虽然 Pillow 对仿射变换的实现方式接收的是一个包含 6 个参数的元组跟数学教材上常见的矩阵写法不完全一样但核心思想是共通的。from PIL import Image img Image.open(demo.png) # 仿射变换参数 (a, b, c, d, e, f) # x a*x b*y c # y d*x e*y f # 水平错切把图像往右推出平行四边形效果 coeffs (1, 0.3, 0, 0, 1, 0) # 注意 Pillow 要求先乘以 2x3 矩阵的转置所以行列式艺高的写法可能略有不同 transformed img.transform( img.size, Image.Transform.AFFINE, coeffs, resampleImage.Resampling.BICUBIC )这里有一个细节Pillow 的 AFFINE 模式内部使用的其实是反变换也就是它在计算结果时会用逆矩阵去插值源图像的像素。这导致传给transform的 6 个系数与数学上“从目标坐标映射到源坐标”的矩阵之间有一个转置关系初学者经常被坑。实际上调整好之后你得到的就是一个错切效果。如果你只是想完成旋转或缩放直接用rotate()和resize()就够了没必要用 transform。但当你以后接触 OpenCV 的cv2.warpAffine()或者 OpenGL 的纹理映射这个矩阵思想会一再出现所以建议至少做一次手动仿射变换的实验不要跳过。5. 实战案例批量加水印与图片去重工具学了前面这些基础如果不做一个贴近真实项目的工具就是纸上谈兵。考虑到很多人的需求来自爬虫数据清洗和图片管理这里给出两个最容易直接抄作业的实战案例。5.1 案例一给一批图片批量加文字水印比如你有一批产品图需要在右下角加上公司的名字统一保存到 output 目录。手工用 PS 处理几百张图完全是噩梦用脚本几秒钟搞定。import os from PIL import Image, ImageDraw, ImageFont def add_watermark(input_dir, output_dir, textmy_watermark): os.makedirs(output_dir, exist_okTrue) # 字体文件请按你自己系统路径来 font ImageFont.truetype(/System/Library/Fonts/Arial.ttf, size48) for filename in os.listdir(input_dir): if not filename.lower().endswith((.png, .jpg, .jpeg, .bmp)): continue img_path os.path.join(input_dir, filename) img Image.open(img_path).convert(RGB) # 在图片右下角加文字先要计算文本宽度和高度 draw ImageDraw.Draw(img) text_bbox draw.textbbox((0, 0), text, fontfont) text_w text_bbox[2] - text_bbox[0] text_h text_bbox[3] - text_bbox[1] margin 20 x img.width - text_w - margin y img.height - text_h - margin # 先画一个半透明黑底再写字避免水印看不清 draw.rectangle((x - 10, y - 10, x text_w 10, y text_h 10), fill(0, 0, 0)) draw.text((x, y), text, fill(255, 255, 255), fontfont) out_path os.path.join(output_dir, filename) img.save(out_path) print(fprocessed: {filename}) if __name__ __main__: add_watermark(images, watermarked)这个脚本有两个小细节值得一提。第一textbbox()去计算文本实际占用的像素大小而不是随便写一个差不多的坐标否则文字极其容易超出右边界。第二在写文字之前先画一个黑色矩形在底部作为阴影底衬这样不管图片背景多亮水印都能看清。这是做图文水印非常常见的一个套路。5.2 案例二批量检测图片相似度均值哈希做数据清洗的时候经常要处理重复图片的问题。最简单的方案是用感知哈希或者均值哈希算法perceptual hash / average hash。均值哈希的原理是先把图片缩放到很小的尺寸比如 8×8转换成灰度图然后计算所有像素的平均亮度跟平均亮度比较生成一个 64 位的二进制指纹两张图的指纹海明距离越小说明越相似。Pillow 完全够用来实现这个逻辑from PIL import Image def average_hash(img_path, hash_size8): # 统一缩放到 hash_size x hash_size转换灰度 img Image.open(img_path).convert(L) img img.resize((hash_size, hash_size), Image.Resampling.LANCZOS) # 读取所有像素值 pixels list(img.getdata()) # 计算平均值 avg sum(pixels) / len(pixels) # 生成二进制字符串 hash_str .join(1 if p avg else 0 for p in pixels) return int(hash_str, 2) def hamming_distance(h1, h2): # 两个整数异或然后统计二进制位中 1 的个数 xor_result h1 ^ h2 return bin(xor_result).count(1) # 使用示例 h1 average_hash(photo1.jpg) h2 average_hash(photo2.jpg) dist hamming_distance(h1, h2) if dist 5: print(两张图非常相似) else: print(不太一样)这个脚本的效率很高如果配合 Numpy处理 1 万张图的去重任务也就在几分钟内完成。Pillow 在其中承担的就是图片读取、缩放到统一尺寸、转灰度这些最基础的职责。5.3 常见问题内存占用与性能优化批量处理图片时的最大陷阱不是逻辑写错而是内存爆掉。很多人图省事一次性用Image.open()把成百上千张图全部放进列表里几 GB 内存瞬间就没了。正确的做法是循环处理一次只处理一张用完随即释放。如果你确实需要并行处理建议用concurrent.futures的线程池配合每张图片单独读取、单独处理、单独保存不要共享同一个 Image 对象。另外Pillow 的resize()在不同重采样滤波器下的速度差异也很大Image.Resampling.NEAREST最快但质量差LANCZOS质量最好但最慢日常场景用BILINEAR或BICUBIC就十分够用。如果是对实时性要求高的场景别图省事直接放大图——尽量用目标尺寸附近的缩略图来做。5.4 常见问题图片模式冲突与颜色失真模式冲突是新手最容易遇到的问题。例如把 RGBA 图片和 RGB 图片做Image.blend时直接报错把 CMYK 模式的图片保存成 JPEG 时也可能出现颜色怪怪的情况。我在实际项目里养成了一个习惯只要是从外部拿到图片先统一convert(RGB)再执行后续操作如果后续操作需要透明通道就统一convert(RGBA)。这个习惯帮我避开了大量低级 bug。另外JPEG 压缩是有损的一张图片反复打开、保存多次画质会肉眼可见地下降。如果你做的是中间过程文件或者要保存透明通道建议直接用 PNG 格式等最后输出成品再转 JPEG。5.5 常见问题坐标系与方向搞反开始的时候我已经强调过图片坐标是 (x, y)x 轴水平向右y 轴垂直向下。这在crop()的时候也容易出问题crop((left, upper, right, lower))中left 要比 right 小upper 要比 lower 小否则裁剪区域为空或者越界抛异常。我在做旋转、翻转、拼接时的经验是每处理一步就立刻save()一个中间结果看图别到最后才发现方向反了调试会非常痛苦。6. 用 Pillow 打开图形学后续学习的通道这篇文章介绍的都是 Pillow 自带接口的使用方法没有涉及性能特别硬的角落。但你要清楚Pillow 能做的这些事——图像缩放、旋转、裁剪、滤镜、水印、哈希比对——它们背后的原理非常深也正好对应着图形学图像处理课程里的重采样、插值、卷积、坐标系变换、Alpha 混合这些核心概念。我个人的建议流程是这样的先熟练用 Pillow 手写一些类似“把图片灰度化”“把图片旋转 30 度”“给图片加马赛克”的小项目不用管性能。等到你理解了这些操作“应该长什么样”再去看用 Numpy 和 OpenCV 的实现最后甚至可以尝试用 Numba 或者 CUDA 去加速。你在 Pillow 上积累的坐标直觉、像素直觉、模式直觉会直接迁移到后面的所有工具里基本不会浪费。如果你正在上课期末有一个图像处理的大作业Pillow 也绝对够用。它体积小、文档全、生态成熟零基础起步半天就能写出像样的图像处理工具。遇到不知道怎么实现的功能去官方文档查ImageFilter、ImageEnhance、ImageDraw、ImageChops这几个模块的目录大多数场景都已经有现成方案了。对了最后再分享一个实用的小经验调试图像相关代码时不要总用print输出像素值太反直觉了。直接把中间结果保存成图片文件塞到一个debug_output文件夹里肉眼检查比任何日志都好用。我在刚学图像处理的时候靠这个办法省下了很多不必要的困惑。希望这篇文章能帮你少走几步弯路。