一提到计算机图形学很多人第一反应是OpenGL、着色器、光线追踪觉得离自己很远。我自己的入门路径恰恰相反先用PIL/Pillow把图像当成二维数据玩明白很多抽象概念就突然落地了。Pillow是Python生态里最常用的图像处理库读图、改像素、滤波、几何变换、透明合成都能做配合numpy还能干不少图形学算法验证的活。这篇东西适合刚接触图像处理的人也适合想在正式啃图形学之前先用代码体验“像素操作、滤波、几何变换、混合”这些基础概念的开发者。整篇没有晦涩的数学推导都是能直接写进自己项目里的代码和思路。1. 图像在Pillow眼里不是一张图而是一堆数字1.1 打开图片后先看这四样尺寸、模式、格式和坐标原点第一次用Pillow打开图片时很多人会直接img.show()然后就开始到处找API。我建议你先做一件事把图片的元信息打出来。from PIL import Image img Image.open(demo.jpg) print(img.size) # (宽, 高) print(img.mode) # RGB / RGBA / L / P / CMYK ... print(img.format) # JPEG / PNG / BMP ...这里面最关键的是mode。RGB就是三通道红绿蓝每个通道0到255RGBA多了一个透明通道AL是灰度图只有一个亮度通道P是索引色模式实际显示靠调色板映射。你可以把一张彩色图理解成三个或四个上下叠加的二维矩阵每个矩阵里的数字代表这个通道在这个位置上的强度。图像处理里90%的操作本质就是改这些数字。比如反色就是把每个通道的数值x换成255-x。还有一点容易搞混Pillow的坐标系原点在左上角getpixel((x, y))的第一个参数是列宽度方向第二个参数是行高度方向。这和我们平时写矩阵下标a[i][j]时先写行再写列的习惯正好相反。我见过不少同事在这上面翻车区域裁剪出来歪七扭八最后发现是x和y传反了。不同模式的图像在内存里的组织方式也不一样列个表看得清楚模式通道数典型用途备注RGB3普通彩色照片最常见JPEG就是这个RGBA4带透明背景的图标、贴图A通道决定不透明度L1灰度图、边缘检测输入很多算法先转L再跑P1调色板GIF、索引色PNG处理前一般先convertCMYK4印刷用图Pillow能读通常不直接处理如果从网上下载的图或者某个SDK返回的图像模式五花八门我习惯先做一次img img.convert(RGB)把模式统一。后期处理代码就只需要关心一种数据形态能少踩很多坑。1.2 手动改像素用getpixel/putpixel做一遍“反色”理解像素操作最快的方式就是亲手写一遍反色。逻辑非常简单遍历所有像素把每个通道的值置为255减去原值。def invert_slow(img): img img.convert(RGB) w, h img.size out img.copy() for x in range(w): for y in range(h): r, g, b img.getpixel((x, y)) out.putpixel((x, y), (255 - r, 255 - g, 255 - b)) return out这段代码在逻辑上是完全正确的但先别急着拿它去跑大图。一张1000x1000的图就是100万个像素每个像素要调两次Python函数跑起来会卡到怀疑人生。这个性能问题后面专门用一章来说。即便如此getpixel和putpixel依然是理解“图像像素矩阵”的最好入口。当你手动把一个像素从黑色变成白色你会非常直观地感受到所谓图像处理就是在和二维数组打交道。这个认知建立起来之后再看卷积、滤波、几何变换都只是在这个数组上做不同类型的运算而已。2. 手写光栅化用画笔模式搞懂圆和直线是怎么从公式变成像素的2.1 参数方程画圆步长的选择决定了圆亮不亮光栅化rasterization这个词听起来高级说白了就是把数学描述的几何图形转换成屏幕上像素点阵的过程。你在Pillow里画一个圆ImageDraw.ellipse瞬间搞定但那是C代码帮你做了。要理解光栅化就得自己用putpixel“画”一次。圆的参数方程很简单x cx r * cos(angle) y cy r * sin(angle)下面这段代码会在画布上描出圆的轮廓from PIL import Image import math canvas Image.new(RGB, (600, 400), white) cx, cy, r 300, 200, 150 for angle_deg in range(0, 360): rad math.radians(angle_deg) x int(round(cx r * math.cos(rad))) y int(round(cy r * math.sin(rad))) canvas.putpixel((x, y), black) canvas.show()这里有个非常实际的细节步长到底选多少固定1度对于半径150的圆来说圆周上相邻采样点的弧长约等于2 * pi * 150 / 360 ≈ 2.6像素点之间会有空隙。如果你把半径改成300固定1度采样弧长约5.2像素断点就更明显。更通用的做法是根据半径自适应步长把步长设成1/r弧度这样相邻点之间的弧长大约稳定在1像素左右。如果你画出来的圆是“虚线”的先检查步长而不是怀疑代码写错。填充圆的方法是类似的遍历圆的外接矩形里每个像素判断它到圆心的距离是否小于等于半径满足就涂色。这个“点到点距离判断”的思路在后面做边缘检测、区域填充时会反复用到。2.2 Bresenham直线算法为什么这个算法值得学画直线比画圆更经典因为图形学里真正的主角是直线和多边形。现代GPU里光栅化三角形内部也离不开直线扫描的思路。Bresenham算法的核心就一句话只用整数加减法来判断下一个像素该往哪个方向走。它比直接采样参数方程x x0 t*(x1-x0)快得多因为避免了浮点乘除运算。def bresenham_line(p0, p1): x0, y0 p0 x1, y1 p1 dx abs(x1 - x0) sx 1 if x0 x1 else -1 dy -abs(y1 - y0) sy 1 if y0 y1 else -1 err dx dy points [] while True: points.append((x0, y0)) if x0 x1 and y0 y1: break e2 2 * err if e2 dy: err dy x0 sx if e2 dx: err dx y0 sy return points canvas Image.new(RGB, (600, 400), white) for pt in bresenham_line((50, 200), (550, 350)): canvas.putpixel(pt, black) canvas.show()看着变量err和e2第一次接触会觉得绕。换个角度理解你有一个误差项每走一步都更新它当误差积累到一定程度说明继续沿当前方向走会偏离理想直线太远这时就切换到对角线方向走一步把误差修正回来。整个过程没有浮点数逻辑简单非常适合硬件实现。画完一条直线你其实就掌握了图形学里所有“像素级生成”的基础套路。之后无论做网格、多边形边框、还是简单的三维线框投影显示都是在这些点上做文章。当然真正项目里没人会手写Bresenham去画UIPillow的ImageDraw.line、ImageDraw.ellipse都是C实现画几万条线也没压力。手写一遍的意义在于当你以后看到“GPU光栅化”这个概念时脑子里不再是玄学而是知道GPU只不过是把这条线、这个圆放大了几百万倍用并行单元同时算而已。3. 卷积核模糊、锐化、边缘检测原来用的是同一个操作3.1 卷积怎么算一个小方块滑过整张图如果说光栅化解决的是“几何图形怎么上屏幕”那么卷积解决的是“图像的邻域关系怎么影响中心像素”。你不需要一次理解所有数学先记住操作过程把一个小矩阵比如3x3盖在图像的某个像素上让矩阵里的系数和对应位置的像素值相乘再把9个乘积加起来结果就是输出图像在这个像素位置的新值。然后这个小矩阵从左到右、从上到下扫过整张图。为什么同一个操作既能模糊又能锐化还能检测边缘区别全在系数上卷积核类型3x3系数示例效果均值模糊全部1/9把周围颜色平均掉图像变柔和锐化中心5上下左右-1四角0放大中心与周围的差异Sobel横向边缘-1,0,1 / -2,0,2 / -1,0,1只保留竖直方向边缘模糊核的作用是“平滑”把中心像素变成周围像素的平均值高频细节被抹掉。锐化核则是“反向平滑”中心权重特别大周围是负权重相当于把原图减去一张模糊图边缘反差被拉大。边缘检测核是“差分”中心一侧是正另一侧是负平坦区域求和接近0只有跨越边缘时数值才会变大。3.2 用ImageFilter.Kernel实现Sobel边缘检测的完整过程Pillow自带ImageFilter里面有BLUR、FIND_EDGES这些内置滤镜但想真正理解卷积还是得用ImageFilter.Kernel自定义核。下面是一套完整的Sobel边缘检测流程from PIL import Image, ImageFilter, ImageChops img Image.open(demo.jpg).convert(L) # Sobel X方向核检测竖直边缘 kernel_x [-1, 0, 1, -2, 0, 2, -1, 0, 1] # Sobel Y方向核检测水平边缘 kernel_y [-1, -2, -1, 0, 0, 0, 1, 2, 1] gx img.filter(ImageFilter.Kernel((3, 3), kernel_x, scale1, offset0)) gy img.filter(ImageFilter.Kernel((3, 3), kernel_y, scale1, offset0)) # 近似梯度幅度|gx| |gy| edge ImageChops.add(gx, gy) edge.show()这里有一个非常关键的坑Pillow的Kernel在计算之后会把超出0到255范围的结果截断掉。Sobel核的系数和是0在平坦区域卷积结果应该是0这没问题但在边缘区域计算出来的差值完全可能为负。负数被截断成0就变成黑色了导致你单独看gx会奇怪“为什么只有半个方向的边缘”。所以我上面用了ImageChops.add(gx, gy)把它们加起来。严格来说真正的梯度幅度是sqrt(gx^2 gy^2)这种近似相当于取一阶范数。对于做演示和给图像做快速预处理这个近似已经够用。真要精确计算标准做法是把gx和gy转成numpy数组用向量化运算做开方和归一化第六章会提到。自定义卷积核的意义在于你可以随时造一个自己的核来验证想法。比如做一个纵向条纹增强核、做一个对角线检测核改几个系数就行不需要换库。很多高级滤镜效果追到源头都是不同卷积核的组合。4. 几何变换resize、rotate、透视背后的矩阵视角4.1 resize不是简单拉伸重采样模式怎么选resize大概是Pillow里被用得最多的函数但大多数人只关心尺寸参数很少注意最后一个resample参数。实际上图像缩放不是“把像素点直接搬来搬去”而是牵扯到采样和插值。small img.resize((img.width // 4, img.height // 4), Image.LANCZOS) big small.resize((img.width, img.height), Image.NEAREST)当图片缩小几个像素要合并成一个怎么合并最近邻NEAREST直接丢掉多余像素速度快但容易出锯齿和摩尔纹双线性、双三次会做周围像素加权边缘过渡更平滑LANCZOS则考虑更大范围的邻域缩图质量通常最好代价是更慢。反过来当你把小图放大的时候新产生的像素要用周围已有像素“猜”出来。NEAREST会得到一格格马赛克这在像素风游戏素材放大时反而是优点。放大照片则应该用BICUBIC或LANCZOS。选择建议照片缩略图无脑LANCZOS处理像素画、二维码、UI素材放大用NEAREST中间值BILINEAR适合实时性要求高、对质量没那么敏感的场景。4.2 transform和仿射矩阵让图片“歪”起来如果说resize只是对坐标做简单缩放那transform就是真正意义上的几何坐标变换。Pillow的transform接受一个参数矩阵最常用的是仿射变换。仿射变换用一个2x3矩阵描述效果包括平移、旋转、缩放、斜切保持直线和平行线不变。Pillow的变换映射是输出图像上的点(x, y)通过矩阵计算到输入图像上采样颜色。skewed img.transform( img.size, Image.Transform.AFFINE, (1, 0.2, 0, 0, 1, 0), resampleImage.BICUBIC, )矩阵是6个数字对应关系是x a*x b*y c y d*x e*y f在这个例子里b0.2意味着输出点的x坐标会额外叠加一部分原图的y方向偏移视觉效果就是整个图片被“斜切”了像一摞纸被推歪。你还可以把c和f用来做平移调整图片位置。用rotate时Pillow内部也是构造一个仿射矩阵。rotate(45, expandTrue)会扩大画布避免四角被裁掉fillcolor参数可以控制旋转后露出的背景色。稍微深入一点就会发现这些看似零散的功能其实共用的是同一套数学工具。4.3 透视变换3x3矩阵带来的纵深错觉仿射变换只能做“平行四边形级别”的变换不能模拟近大远小。想要让一张图看起来像贴在墙上、带透视效果需要用到透视变换也就是3x3矩阵。Pillow的PERSPECTIVE模式接收8个参数对应3x3矩阵的前8个元素计算关系比仿射多了一步除以g*x h*y 1u (a*x b*y c) / (g*x h*y 1) v (d*x e*y f) / (g*x h*y 1)这个除法是关键。当g、h不为0时不同位置的像素会被不均匀缩放远处的区域被压缩近处的区域被放大立体感就是这么来的。persp img.transform( img.size, Image.Transform.PERSPECTIVE, (1, 0, 0, 0, 1, 0, 0.0015, 0.0015), resampleImage.BICUBIC, )这里第7、8个参数就是g和h数值越大透视效果越强烈。我用在400x300的测试图上0.0015左右能明显看到图片上方和右侧被压缩像是从斜上方看一块牌子的感觉。注意这个参数和图片尺寸强相关换大图要等比缩小。先拿小参数试不够再加不然图像会扭曲到完全认不出来。山寨一点说二维码扫描里对倾斜二维码做的矫正就是先检测四个角点然后算一个透视矩阵把四边形区域映射回正矩形。这个流程在Pillow里几步就能搭起来理解透视矩阵之后你再看那些“一键矫正文档照片”的工具底层原理立刻透明了。5. alpha合成与蒙版透明度和遮罩的正确玩法5.1 透明度合成的公式以及alpha_composite怎么用做图像拼接、水印、贴图经常遇到半透明图层叠加这里的核心就是alpha合成。先看最基础的公式输出 前景色 * 前景alpha 背景色 * (1 - 前景alpha)Alpha值越高前景越“实”越低背景透得越明显。这也是人类视觉里“隔着玻璃看东西”的抽象模型。Pillow里最省事的RGBA合成函数是Image.alpha_compositebg Image.open(bg.jpg).convert(RGBA) fg Image.open(logo.png).convert(RGBA) # logo本身带透明通道 result Image.alpha_composite(bg, fg) result.convert(RGB).save(merged.jpg)注意两个前提两张图都必须是RGBA模式且尺寸一致。尺寸不一致时先resize或先把目标图resize到合适的画布。alpha_composite是真正的alpha混合它能正确处理前景图自带的半透明边缘不会出现生硬的矩形边界。5.2 paste的第三个参数mask的常见坑Image.paste是老牌API但它的第三个参数mask是个容易出问题的地方。base Image.open(bg.jpg).convert(RGBA) logo Image.open(logo.png).convert(RGBA) x, y 100, 100 base.paste(logo, (x, y)) # 不透明矩形直接盖上去 base.paste(logo, (x, y), masklogo.split()[-1]) # 只贴不透明部分如果不传maskRGBA图的透明区域会被当成“不透明黑色”直接覆盖到底图上结果就是图标周围一块黑底或白底。正确做法是传入alpha通道也就是logo.split()[-1]paste才会只把不透明部分贴上去。另外一个坑paste的目标图如果不是RGBA模式mask可能不生效或者贴上去之后透明信息直接丢失。比如你往一个RGB模式的背景图上贴带透明的PNG贴完转存成JPG那点透明效果根本没机会展示。所以我习惯统一先convert(RGBA)最后保存时再压回RGB转JPG。5.3 用渐变mask做Image.composite图层融合比固定位置贴图更高级一点的玩法是用一张渐变灰度图作为mask控制两张图逐像素过渡。这个在封面图、横幅制作里经常用到比如左边是背景图右边是主视觉图中间一条自然过渡带。先做一个从左到右的线性渐变maskgrad Image.linear_gradient(L).resize(img.size)这里Image.linear_gradient(L)会生成一张256x256的灰度渐变图左边纯黑右边纯白然后resize到目标尺寸。很多人不知道Pillow有这个内置函数其实它就是专门为这类需求设计的。面具就绪之后用Image.composite完成融合left Image.open(bg1.jpg).convert(RGB).resize(img.size) right Image.open(bg2.jpg).convert(RGB).resize(img.size) merged Image.composite(left, right, grad) merged.show()composite的三个参数分别是前景图、背景图和mask。mask中白色区域优先显示前景黑色区域显示背景灰色区域按比例混合。注意mask必须是“L”模式RGB图不能直接拿来当mask用前两张图要同尺寸最好连模式都一致不然结果会很怪。这个技巧特别适合做微信封面图、视频封面、电商详情页的背景过渡。我还用它做过“图片淡入淡出”效果将同一张图在不同缩放级别下的两个版本用渐变动画式mask做切换视觉上非常顺滑。6. 性能优化别再一个个像素getpixel了6.1 逐像素循环慢在哪前面反色那段代码逻辑正确但性能是灾难。2000x2000的图就有400万个像素每个像素两次Python层函数调用再加上tuple的创建和拆包耗时轻松上几十秒。Python循环本身不慢到离谱慢的是“每处理一个元素都要经过完整解释执行流程”这件事。getpixel要查坐标、取通道、构造tupleputpixel要检查模式、校验范围、写内存。这两个函数被调用几百万次光函数调用开销就足以让程序卡死。所以在Pillow里第一原则是能用内置方法解决的绝不手写循环能用C层实现的绝不用Python层实现。6.2 point/LUT一条命令做完全图映射对于反色、亮度调整、阈值化这类“逐像素、逐通道且映射关系完全一致”的操作可以用point它会直接生成一张查找表LUT让底层一次性完成所有像素的映射。img Image.open(demo.jpg) # 反色 inverted img.point(lambda i: 255 - i) # 亮度校正gamma 0.8 lut [int(255 * (i / 255) ** 0.8) for i in range(256)] gamma_img img.point(lut)point只接受0到255的输入适合单通道映射。如果你要精确控制每个灰度值对应关系先构造256长度的LUT再传给point效率极高。这种“查表替代计算”的思路在实时图像处理里特别有用。6.3 numpy向量化把图像当数组算才是最终形态Pillow的局限在于很多运算必须通过构造滤镜或者插件API实现自由度有限。一旦你要做像素级的自定义算法比如自己写的模糊、自己算梯度、自己实现的色彩空间转换最舒服的方式是把图像转成numpy数组用数组运算搞定。import numpy as np from PIL import Image img Image.open(demo.jpg).convert(RGB) arr np.array(img) # 形状 (H, W, 3)dtype uint8 # 反色数组整体操作用的是C底层飞快 arr 255 - arr out Image.fromarray(arr.astype(uint8)) out.show()注意两个细节。第一np.array(img)得到的数组shape是(高, 宽, 通道)这正好和Pillow的坐标习惯反过来用的时候心里要有数。第二dtype必须是uint8。如果你做了浮点运算比如arr * 0.5 20结果可能是float64直接用Image.fromarray会报错或显示乱码先取整、裁剪到0到255、再转回uint8。我把几种反色实现放到一张2000x2000的测试图上做过对比耗时大致是这样的量级实现方式耗时量级备注双层for getpixel/putpixel几十秒只适合小尺寸验证Image.point(lambda)几十毫秒代码最少适合固定映射numpy数组整体运算几十毫秒灵活度最高适合复杂算法耗时数字会随机器浮动但量级差异非常清楚。平时调算法我的习惯是先复制一小块图像区域比如100x100用逐像素循环把逻辑跑通肉眼确认效果然后再把算法用numpy重写一遍应用到全图。这样既保证思路正确又不会等大图处理等到崩溃。Pillow的真正价值在于IO它能打开各种格式、做格式转换、提供基础的几何变换和合成工具。而真正复杂的像素级计算交给numpy会舒服得多。二者配合才是Python做图形学算法验证最顺手的工作流。