ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Pillow图像算术运算与通道计算:掌握像素级图像处理核心

Pillow图像算术运算与通道计算:掌握像素级图像处理核心 做图像处理这些年我有一个很深的体会Pillow 的价值并不全在 resize、crop 这些基础操作上真正让一张图片“活”起来的往往是像素级的算术运算与通道计算。所谓 Pillow 图像算术运算简单说就是把两张图、或者一张图的不同通道像普通数字一样做加、减、乘、除、比较和混合通道计算则是按 R、G、B、A 这些分量去拆分、替换、重组颜色信息。多帧平均降噪、通道错位滤镜、视频帧差检测、绿幕抠图里最核心的几行代码归根到底都是这一套东西在起作用。这篇内容我会从最基础的概念开始把 ImageChops 的常用运算、通道拆分重组、以及我踩过的溢出和性能坑一次讲清楚。刚接触图像的初学者可以把它当入门指引已经会用 Pillow 的进阶用户可以重点看后面几节里的实测数据和避坑经验。大部分代码可以直接复制后改改路径就用不需要额外安装第三方库。1. 图像算术运算到底在算什么三句话讲清本质1.1 像素与通道的三层结构把图片想象成一张带格子的填色纸。灰度图是每个格子里只有一个 0 到 255 的数值数值越大越亮越小越暗RGB 图就是每个格子里同时放了三个数值分别代表红、绿、蓝三个通道。所谓图像算术运算本质上就是把这些格子里的数值拿出来做数学运算再把结果写回对应的格子里。这里有个很容易被忽略的细节Pillow 的绝大多数运算比如 ImageChops.add、ImageChops.multiply都是按通道独立计算的。也就是说RGB 图实际上会被拆成 R、G、B 三张互不影响的灰度图先分别算完再合并回去。alpha 透明通道也一样除非你专门操作它否则它不会参与颜色运算。明白了这一点再看那些封装好的 API 就不会觉得抽象了。ImageChops.difference 不是把两张图“相减”而是把 R 通道的差、G 通道的差、B 通道的差分别算出来。理解了“通道独立”这四个字后面所有代码都会顺手很多。1.2 别急着写 for 循环先认识这三层工具大多数人在刚接触 Pillow 时第一反应是写双重 for 循环用 getpixel 取出像素算完再 putpixel 写回去。这个思路没有错但它既慢又容易被边界条件坑。正确做法是优先使用 Pillow 已经编译好的 C 级函数我把它们分成三层。第一层是 ImageChops。这层处理的是两个图像之间的复合运算比如加、减、乘、滤色、差异、取模。它的输入输出都是 PIL Image 对象天然支持 RGB 多通道不需要手动拆通道。第二层是 Image.point 和 Image.eval它们负责单张图的逐点映射通常配合 lambda 函数做阈值化、亮度拉伸、反色这类操作。如果只是把每个像素值做一个固定映射point 会更快因为 Pillow 内部能构建查找表eval 则适合更复杂的表达式。第三层是 Image.blend 和带 mask 的 paste前者做两图按权重混合后者在指定区域粘贴时按 mask 的灰度做局部透明过渡这两者本质上也属于算术运算只是语义更偏“合成”。把它们放在一起看大概可以得出一个原则凡是能用一个函数表达的数学关系就不要自己写循环。我把 ImageChops 最常用的函数整理在下面公式按 8 位灰度图的取值范围写彩色图就是每个通道分别执行。Pillow 函数数学公式逐通道视觉效果典型场景ImageChops.add(a,b)(ab) 饱和裁剪整体提亮、高光易溢出曝光合成、图案叠加ImageChops.add_modulo(a,b)(ab) mod 256溢出部分循环回绕抽象噪点、故障风ImageChops.subtract(a,b)(a-b) 下边界归 0去除共性内容背景减除ImageChops.subtract_modulo(a,b)(a-b) mod 256负值回绕成正数反向渐变效果ImageChops.multiply(a,b)a*b/255整体变暗、对比增强阴影与纹理叠加ImageChops.screen(a,b)255-(255-a)*(255-b)/255整体变亮光晕、耀斑叠加ImageChops.difference(a,b)abs(a-b)只保留差异差异检测、帧差ImageChops.invert(a)255-a反色负片效果、mask 翻转ImageChops.lighter/darker(a,b)max/min(a,b)保留亮部/暗部图层混合这张表里的公式看起来是灰度公式但放到 RGB 上就是每个通道各算各的。选对工具比写一万行循环都值后面节里很多效果都是这张表里几个函数组合出来的。2. 通道计算的地基mode 与 split/merge2.1 为什么每个图像处理项目都要先确认 mode通道计算的起点不是代码而是 mode。Pillow 里最常见的模式有四种L 是单通道灰度RGB 是三通道真彩色RGBA 比 RGB 多一个透明通道CMYK 则是印刷用的四色模式。一张图到底有几个通道直接决定你后面的算术运算能不能对上接口。比如用 ImageChops.add 去加一张 RGB 图和一张 RGBA 图代码会直接报错因为通道数量不相同。做算术运算前把两张图都 convert 到同一模式是个好习惯。很多人只知道 convert(RGB)但忽略了一个细节如果要算灰度图convert(L) 不是简单取三个通道的平均值而是按亮度权重做加权标准公式近似为 L 0.299R 0.587G 0.114B。这也是为什么一张红光偏强的彩色照片转灰度后会显得偏亮因为红色通道在高光区域贡献了不少亮度。还有一个容易踩的坑convert 并不能把一张完全没有 alpha 的 RGB 图凭空变成真正的透明图只会把 alpha 全设成 255。所以如果你后续要做 alpha 通道相关的计算要么在图源阶段保留 RGBA要么在 merge 时手动构造 alpha。我见过不少同事在处理透明素材时先 convert(RGB) 再 convert(RGBA)结果发现透明区域全变成了白色问题就出在这个细节上。2.2 split、getchannel 和 merge三种通道取用方式最常见的三个通道 API 是 split、getchannel 和 merge。split 把一张多通道图拆成多个单通道灰度 Image顺序固定RGB 拆成 R、G、BRGBA 拆成 R、G、B、A。merge 则反过来把多个单通道图组合成一张多通道图。getchannel 是后来版本提供的快捷方式比如 img.getchannel(R) 可以直接拿到红色通道等价于 split 后拿第一个元素。这里有个容易被忽略的点split 之后拿到的都是 L 模式灰度图而不是保留了原来的颜色。也就是说 r 通道本质是一张只显示红色亮度信息的黑白图红色越亮的区域像素值越大。很多人第一次拆通道后以为会得到三张带颜色的图其实不是。想要看到单通道的红色效果正确做法是把 G、B 通道都置为 0再 merge 回 RGB。代码示例比解释更直观from PIL import Image img Image.open(sample.jpg).convert(RGB) r, g, b img.split() # 只保留红色通道其余通道全部置 0 zero Image.new(L, img.size, 0) red_only Image.merge(RGB, (r, zero, zero)) red_only.show()这段代码把 G、B 通道全部清零只保留 R 通道视觉上就是一张红色基调的图。原理其实就是通道计算里最基础的一招替换某个通道的值再重新合并。如果你想把绿色通道保留并增强同理把其他通道清零或用 point 乘系数就行。2.3 通道交换实战一个操作生出三种配色通道交换是我认为最有意思的入门玩法因为它不需要任何复杂数学就能得到非常外显的视觉变化。比如把 R 和 B 通道互换相当于把蓝天变成橙黄色把肤色变成青色调。代码量只有一行r, g, b img.split() swapped Image.merge(RGB, (b, g, r))这个方法在摄影后期里经常被用来做风格化底噪试验。比较常见的做法是先把图像转为灰度再取红通道作为主通道和一部分绿色通道混合最后 merge 回去会得到一种植物从绿色变成淡紫色的奇特色泽。严格说它的色彩逻辑来自不同通道对物体反射亮度的差异但在普通 RGB 图里靠通道加权模拟也能出来七八分效果。给一个通道加权重是更可控的方式。比如你想让画面更冷淡就把蓝色通道整体抬高一点方法是对 b 通道做 point 映射把每个像素值乘上 1.2再和其他通道合并。这个操作本质上是把 b_new min(255, b * 1.2) 应用到每一个像素上。注意这里一定要手动处理溢出否则 b 超出 255 的部分会变成错误值后面第 3 节我会专门讲这个问题。3. 六个常用算术运算拆开看公式、效果、溢出边界3.1 add 与 add_modulo亮部和暗部谁先崩add 和 add_modulo 是最容易翻车的一对因为它们处理溢出的方式完全不同。ImageChops.add 默认采用饱和裁剪也就是结果超过 255 就停在 255低于 0 就停在 0。这个行为符合大多数摄影合成的预期两张高光区域叠在一起最多也就是纯白不会出现颜色环回。但 add_modulo 不是这样它会把超过 255 的部分扣掉 256 再继续也就是取模运算。比如 200 加 100普通 add 得到 255add_modulo 得到 300 - 256 44画面里会出现大量突然变暗的彩色噪点。我第一次做多帧叠加去噪的时候图省事直接用 add 把几十帧累加在一起结果白色区域一片死白暗部细节却因为被裁剪而大量丢失。后来改成滚动平均问题才消失。这是很多新手都会遇到的情况不是 API 不会用而是没想清楚你要的是“加完裁剪”还是“加完全均值”。顺便说一下 add 的两个参数 scale 和 offset。add(a, b, scale2, offset0) 等价于 (ab)/2这正是单次平均想要的效果。但它只适合一次合成就出结果不适合滚动累加原因上面已经说了累加过程中一旦超过 255 就会被裁剪信息已经丢了后面再除以多少都补不回来。注意日常做图像加法时先问自己“结果超过 255 应该怎么办”。是裁掉、取模、归一化到 0-1 的浮点还是保持超过 255 以后再处理选错一步整条链路结果就全错了。3.2 subtract 与 difference两个减法接口业务语义完全不同subtract 和 difference 经常被搞混。subtract 是普通减法结果小于 0 会被截断成 0difference 是取绝对值无论 a 大还是 b 大都会保留差异信息。如果你要做背景去除subtract 能把你想要的主体从静态背景里刨出来但因为背景不会完美一致实际使用中 difference 更常见因为负值不会直接变成纯黑差异区域会更明显。difference 最经典的应用是帧差法。视频里连续两帧如果场景基本没动difference 得到的是接近纯黑的图一旦有物体移动、光线变化对应区域的像素值就会明显抬升。配合 point 做一次阈值二值化就能得到一张标记变化区域的掩膜图。这个技术看起来不算高级但许多后台监控类的“画面是否有变化”判断就是用这个思路做的性价比很高。subtract 还有一个容易被忽视的用法计算两张图片的亮度增量。比如你先拍摄一张基础曝光图再拍摄一张高光补偿图两图相减就能近似得到高光部分的分布。虽然严格的光学补偿需要考虑线性响应但作为快速原型两三行代码就能验证想法。实际用起来把两张图统一转成灰度再相减效果会比彩色相减更容易观察。3.3 multiply、screen 和 invert明暗变换的底层逻辑multiply 和 screen 是明暗调节的两面。multiply(a, b) 的分母 255 是为了保证结果还在 0 到 255 区间。因为任意两个 [0,255] 的数值相乘结果可能到 65025不除以 255 就会整体溢出。它的视觉特点是“越乘越暗”两张图做乘法后暗部会更暗白色区域保持不变这是因为任何数和 255 相乘再除以 255 还是它自己。用这个特性可以把一张带纹理的灰色图层乘到底图上形成布纹、纸纹这类质感。screen 是 multiply 的补运算公式是 255 - ((255-a)*(255-b)/255)。它“越加越亮”且不会无限叠加到纯黑适合做光晕、镜头耀斑这类效果。如果做类比multiply 像是两张幻灯片叠在一起看screen 像是把两束光投射在同一块屏幕上。两者的差序很直观一组图做下来你很快就能分清什么时候该用哪个。invert 就更好理解了输出是 255 - 当前值相当于把图像掰成“补色”。invert 在通道计算里很有用处比如做 mask 的时候把一个白色前景 mask 反色成黑色alpha 逻辑就反过来了。ImageChops.invert 可以直接用也可以对某个通道单独做。比如只对红色通道 invert 后再 merge 回去画面会偏青这种偏色特效就是单通道反色的典型效果。4. 三个可以抄作业的案例降噪、通道错位、帧差检测4.1 多帧平均降噪用 Image.blend 做滚动平均第一个可以直接抄的案例是多帧平均降噪我在笔记本上跑过很多次效果很直观。相机在暗光环境下拍出来的照片感光度拉高之后会出现明显的随机噪点。因为噪点是随机出现的同一场景拍 N 张后把对应像素做平均随机噪点就会互相抵消一部分画面明显干净。数学上如果噪声的标准差是 σN 帧平均后标准差大约会降到 σ/√N。这也是星空摄影里“堆栈降噪”的原理。用 Pillow 实现最稳的方案是滚动平均而不是把几十帧直接 add。直接 add 的溢出问题前面已经说过了滚动平均则每次只做一次加权混合数值永远不会超范围from PIL import Image, ImageChops paths [frame_01.png, frame_02.png, frame_03.png, frame_04.png] acc Image.open(paths[0]).convert(RGB) for i, p in enumerate(paths[1:], start2): frame Image.open(p).convert(RGB) acc Image.blend(acc, frame, 1.0 / i) acc.show()注意一点帧与帧之间不能有大幅位移否则平均出来的不是降噪而是重影。拍摄时尽量固定机位或者先用特征点对齐再来做帧平均。我个人判断是 3 到 8 帧在这个方案里视觉提升最明显十几帧之后边际收益就很低了但耗时线性增长。你可以在循环里打印一下每帧的耗时会发现瓶颈基本在文件读取而不是算术本身。4.2 通道错位风格化几行代码做出偏色胶片感第二个案例是通道错位做风格化滤镜适合不想引入任何外部依赖的快速原型。核心思路是把拆分得到的三个通道分别做不同的位移或增强再重新合并。红色通道向左移几个像素、蓝色通道向右移几个像素那么画面里的彩色边缘就会出现红蓝分离直接得到一种看似失误但很有“胶片感”的效果。from PIL import Image, ImageChops img Image.open(city.jpg).convert(RGB) r, g, b img.split() # 红色通道向左平移 4 像素蓝色通道向右平移 4 像素 r_shift ImageChops.offset(r, xoffset-4, yoffset0) b_shift ImageChops.offset(b, xoffset4, yoffset0) glitch Image.merge(RGB, (r_shift, g, b_shift)) glitch.show()这里用到的 ImageChops.offset 本质上也是对通道做坐标偏移。如果你不想产生纯黑边缘可以先扩大画布再提取通道或者用 Image.paste 时设定 mask。另一个常见套路是在通道合并前给每个通道乘上不同系数比如把红色通道加亮、蓝色通道压暗得到一种更像色彩分级的画面。记住一个原则通道拆分只是手段合并前的系数才是风格本身。4.3 视频帧差与差异检测difference 加 point 二值化第三个案例是视频帧差检测我最开始在监控类项目里用过类似逻辑后来发现做成一个独立函数反而更通用。核心就两步先 difference 求出两帧差异再 point 做阈值二值化。from PIL import Image, ImageChops def frame_diff(f1, f2, threshold30): diff ImageChops.difference(f1.convert(RGB), f2.convert(RGB)) gray diff.convert(L) return gray.point(lambda v: 255 if v threshold else 0) mask frame_diff(frame_1, frame_2) mask.save(motion_mask.png)point 里的 lambda 就是对每个像素做一次比较运算大于 30 置 255否则置 0。threshold 是经验值太小人影晃动的背景都会被标出来太大则漏报。我通常先把 threshold 设为 25-40 这个区间再根据现场画面微调。注意 f1 和 f2 的 mode 必须一致否则 difference 会直接抛异常。为了避免文件读取格式差异统一在函数入口 convert(RGB) 是最省心的做法。拿到 mask 之后可以把它当作布尔索引统计白色像素数量进而判断画面变化程度也可以把 mask 当作透明掩膜只对变化区域做后续处理。这样一次差异检测不仅是一个滤镜而且能成为整个自动化流程的前置条件。5. 性能与精度别让 Python 循环毁掉你的批处理5.1 getpixel 双重循环、point 查找表和 numpy 向量化的速度差距讲完了功能必须讲性能。很多人用 Pillow 上手第一件事就是 getpixel putpixel 双重循环。一张 1920x1080 的图有约 207 万个像素每个像素都要经过一次 Python 解释器调用 getpixel 和 putpixel再算一次简单运算。我做过一个粗略测试单次读取写入循环大概需要几十秒到几分钟取决于机器性能。作为对比ImageChops.difference 处理同样大小只需要几十毫秒。这个差距不是 Python 本身慢而是你让 Python 去做了 C 语言早做好的事。所以我的建议是先看 ImageChops 有没有现成函数没有再看 point/eval 能不能表达最后才考虑手写循环。如果这三层都不能满足需求那就引入 numpy把 PIL Image 转成 numpy 数组在数组上做向量化运算处理 1920x1080 的图通常也就是几十毫秒级别。有一个小技巧值得记下在做全局阈值映射时优先用 point 而不是 eval。point 在内部会尝试生成查找表对 0-255 范围内的每个输入只算一次函数之后就是查表eval 则会对每个像素都真实调用一次 Python 函数。虽然对于单次任务差距未必明显但在批处理几百张图时这个差别会被放大很多。5.2 numpy 方案的 uint8 溢出是个隐形坑如果你决定用 numpy马上会碰到一个和 Pillow 行为完全不同的坑uint8 算术溢出是回绕的。numpy 读入 PIL Image 后默认 dtype 是 uint8取值范围 0-255。200 100 在 numpy 里不是 255而是 300 % 256 44。这和 ImageChops.add 的饱和裁剪完全不同也和你可能预期的普通整数加法完全不一样。正确的做法是先在 numpy 里算的时候注意溢出要么用 int16 转一下要么在每步运算后手动 np.clip。比如要实现 ImageChops.add(a, b) 的饱和加法import numpy as np a np.array(img1, dtypenp.int16) b np.array(img2, dtypenp.int16) c np.clip(a b, 0, 255).astype(np.uint8) result Image.fromarray(c)把 dtype 设为 int16 后中间计算结果有足够余量最后再裁剪回 0-255就不会出现回绕问题。如果还想做多帧累加平均numpy 方案可以先把所有帧读进一个 uint8 数组再转成 float 后求和平均。这比 Pillow 的滚动平均更快也更适合样本量大的场景。另外注意Image.fromarray 对数组的 shape 有严格要求RGB 图必须是 height x width x 3灰度图是 height x width。如果你的算法把通道顺序改了记得也要同步调整否则出来的图会有肉眼可见的颜色错乱。我踩过这个坑当时把 B、G、R 的顺序传给了 fromarray结果整张图变成了蓝橙互换的诡异配色。6. 把算术运算组合成自定义通道算法的几个方向6.1 用 difference 生成 alpha用 paste 完成定向合成最后说一下怎么把这些东西串起来。很多时候一个功能不是单一函数能完成的而是由好几层算术运算组合出来的。比如我要把一张有纹理的图案叠加到天空区域可以先通过 difference 找出两张图里天空位置的不同再用阈值生成一张 mask最后用 mask 控制 paste 的透明度。这一段流程里difference 是算术point 是阈值映射paste with mask 本质也是按 mask 灰度做加权混合。把它们拼在一起就是一个看起来不像是简单滤镜能实现的合成工具。还有一个方向是把通道计算和模式转换结合在一起。比如把一张 RGB 图先转成 HSV 色彩空间再去调整某个分量这类操作在 Pillow 里可以这样实现用 convert(HSV) 转过去然后 split 拆分通道对 H 或 V 通道做算术再 merge 合并。因为 Pillow 的 HSV 模式也是三通道格式所以通道计算的思路完全适用。我没有在这里展开具体代码因为它已经超出这篇内容的标题范围但你可以按这个思路自己试试通了会非常有成就感。6.2 一条备忘这些年我最常用的五个避坑经验写到这里我把这些年用 Pillow 做算术运算时经常被问到、也经常再踩的坑列一下作为最后一个备忘两个图相加前先统一 mode。RGB 和 RGBA 相加会直接报错灰度图转 RGB 也不会提示。任何会产生超过 255 或低于 0 的运算先想清楚是要 clip、wrap、除以系数还是换浮点。split 出来的通道是 L 模式灰度图不是带颜色的图要用 merge 回去才能看到颜色。能用一个 ImageChops 函数解决的就别写 getpixel 循环。性能差距往往有几十倍。大批量处理时尽量复用 Image 对象频繁 convert 和 split 会带来额外的内存拷贝。这些经验里最让我印象深刻的是第一次做多帧叠加降噪。当时我用 add 把八张照片直接累加得到了一张全是纯白死区的图还以为是亮度偏高查了很久才发现是裁剪导致的信息丢失。换成滚动平均之后效果一下子干净了很多。像这类问题只要你在动手前多问一句“结果应该怎么处理边界”基本都能避开。最后再分享一个我一直在用的实验习惯做通道玩法时别直接改原图先复制一份再用 split 和 merge 组合出不同版本对比。你会发现 Pillow 图像算术运算与通道计算能玩的色彩实验远比想象中要多。
返回列表