多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

GPUImageChromaKeyBlendFilter详解:美颜相机绿幕抠像与实时背景替换实战

GPUImageChromaKeyBlendFilter详解:美颜相机绿幕抠像与实时背景替换实战 做美颜相机做到第21天今天这颗滤镜终于不是“磨皮、美白、调色”那三板斧了而是把绿幕换背景、实时抠像这类玩法真正带进了项目。这个模块在项目里登记的名字是“颜色加深混合滤镜”技术上对应的是 GPUImageChromaKeyBlendFilter。先泼一盆冷水它跟PS里的“颜色加深”没有半毛钱关系底层是色度键Chroma Key抠像加双纹理混合。如果你正卡在证件照换底色、直播间背景替换、AR换背景这类需求上这颗滤镜是绕不过去的基础款。这篇笔记会把着色器原理、Android端接入方式、调参顺序、以及我在真机上踩到的几个经典坑一次讲清楚。1. 为什么一个“混合滤镜”能撑起换背景功能1.1 美颜相机的滤镜链从“一个滤镜”到“一个管线”到第21天项目里的美颜相机已经不是一个滤镜从头吃到尾而是典型的滤镜链结构相机采集纹理先进磨皮、美白、瘦脸这类美容节点然后进色彩风格滤镜节点最后交给显示控件。每个节点接收上一节点的纹理处理完再把结果作为下一节点的输入。这个链式结构一旦跑起来后面加什么功能都会顺手很多。GPUImageChromaKeyBlendFilter 在这个链里的位置很特殊它不是普通单输入滤镜而是双输入滤镜。它的第一个输入是前景通常是摄像头实时画面第二个输入是背景图或者背景视频帧。着色器根据前景颜色生成蒙版再根据蒙版把两个输入混合。说白了它是“滤镜链里的一个三通阀”负责把两路信号合成一路。这个设计很适合美颜相机因为换背景不是把背景硬贴上去而是要根据人物的边缘轮廓做半透明过渡。如果用普通的图片叠加或者圆形贴纸思路人像边缘会非常假而ChromaKeyBlend这种“颜色选区分割alpha混合”的方式边缘能做得相对自然。1.2 ChromaKeyBlendFilter 名字背后的真实定位“颜色加深混合滤镜”这个登记名有误导性。Chroma Key 是视频行业的老技术翻译过来是“色度键”电视台天气预报那套绿幕换背景就是它。Blend 则表示最后一步是混合操作不直接丢弃前景像素而是用蒙版在两个纹理之间做插值。所以 GPUImageChromaKeyBlendFilter 做的事情是对前景纹理逐像素计算“看起来像不像指定背景色”得到一个0到1的蒙版值蒙版接近1的像素保留前景接近0的像素换成背景图中间值形成过渡带让边缘不那么生硬。这和PS的“颜色加深/Color Burn”完全是两回事。后者属于图层混合模式改变的是色素叠合效果ChromaKeyBlend 则是“透明度的生成器混合器”。项目里叫它“颜色加深混合滤镜”多半是为了在功能菜单里描述“把背景颜色加深替换掉”的直观感受但写代码的时候心里要清楚这是一个色度键抠像节点。1.3 它到底解决什么问题适合谁参考这颗滤镜解决的核心问题就一个如何高效地从单一相机源中把人像和背景分离并替换成任意新背景。对美颜相机来说最典型的三类场景证件照换底色人站在蓝色或者绿色背景前实时把蓝/绿底换成白底、红底直播/短视频虚拟背景绿幕前开播观众看到的是房间或风景背景板照片合成玩法先用相机拍一段人物视频再离线批处理换背景。适合参考它的开发者是正在搭Android端美颜滤镜管线的初级到中级工程师。如果只是调个色彩饱和度完全用不到它但如果你的需求单上出现“智能换背景”“绿幕抠像”这类字眼这课必须补上。它也是后面接AI分割模型之前性价比最高的传统方案。2. 核心原理YCbCr蒙版和双纹理混合2.1 两个输入纹理的关系要分清楚写任何GLSL滤镜之前先要把输入语义定清楚。GPUImageChromaKeyBlendFilter继承自双输入滤镜基类通常约定inputImageTexture前景纹理也就是摄像头拍到的画面inputImageTexture2背景纹理也就是替换上去的图片或视频帧。两个纹理的采样坐标来源不同。第一个输入使用常规的纹理坐标第二个输入也是同尺寸坐标但渲染目标如果和背景图宽高比不一致就会出现拉伸。这一点后面会单独讲怎么处理。这里有一条重要的GPU使用习惯不要在前景纹理采样之后直接丢到屏幕还要保留一份背景纹理绑定在另一个纹理单元上。两个采样器在片段着色器里用 uniform 传入分别对应 GL_TEXTURE0 和 GL_TEXTURE1。2.2 mask 的三个分量着色器里的 Y/Cr/Cb 到底在算什么直接贴一段核心片段着色器后面所有原理都围着这段代码转precision highp float; varying highp vec2 textureCoordinate; varying highp vec2 textureCoordinate2; uniform sampler2D inputImageTexture; uniform sampler2D inputImageTexture2; uniform mediump float thresholdSensitivity; uniform mediump float smoothing; void main() { highp vec4 currentColor texture2D(inputImageTexture, textureCoordinate); highp vec4 backgroundColor texture2D(inputImageTexture2, textureCoordinate2); // 将 RGB 转成亮度与两个色度分量 highp float maskY 0.2989 * currentColor.r 0.5866 * currentColor.g 0.1145 * currentColor.b; highp float maskCr 1.0 - 0.1687 * currentColor.r - 0.3313 * currentColor.g 0.5 * currentColor.b; highp float maskCb 0.5 * currentColor.r - 0.4187 * currentColor.g - 0.0813 * currentColor.b; // 用平滑阶跃函数把 Y/Cr/Cb 分别映射成 0~1 的权重 highp float mask smoothstep(thresholdSensitivity, thresholdSensitivity smoothing, maskY); mask mask * (1.0 - smoothstep(thresholdSensitivity, thresholdSensitivity smoothing, maskCr)); mask mask * (1.0 - smoothstep(thresholdSensitivity, thresholdSensitivity smoothing, maskCb)); gl_FragColor mix(backgroundColor, currentColor, mask); }琢磨这段代码之前先记住它用的不是 RGB 阈值判断而是 YCrCb 色彩空间。为什么不直接用if (g 0.8 r 0.3)这样判断因为 RGB 的三个通道都携带着亮度信息绿色背景下稍微打个光、出现阴影G 通道变化不大但 R/B 通道也会跟着变RGB 空间里做颜色区间判断非常容易被光照干扰。YCrCb 把亮度和颜色分离Y 是亮度Cr/Cb 是两个色度分量。绿幕场景里人脸的 Y 和背景的 Y 可能差不多但色度分量差异很大用色度判定就稳定很多。着色器里那三行系数其实就是 BT.601 标准里的转换公式只是抽掉了常量的加减偏移。注意一个历史包袱着色器里变量名叫 maskCr 的数学上其实是蓝色色度 Cb 的变形叫 maskCb 的反而是红色色度 Cr 的变形。GPUImage 的老代码里有这么一出错位读的时候别被绕进去知道它们在算色度就够用了。2.3 smoothstep 和两个 uniform 参数的本质thresholdSensitivity和smoothing是这个滤镜最核心的两个可调参数。smoothstep(edge0, edge1, x)是一个平滑阶跃函数x 小于 edge0 返回 0x 大于 edge1 返回 1中间做平滑插值。这里的 edge0 是 thresholdSensitivityedge1 是 thresholdSensitivity smoothing。也就是说thresholdSensitivity 决定“从哪个值开始认为它像背景色”smoothing 决定“从开始认为到完全确定这段过渡区间有多宽”。smoothing 等于 0 时smoothstep 退化成 step 硬阈值边缘会非常硬全是锯齿smoothing 越大过渡带越宽边缘越柔和但调太大就会出现明显的半透明重影。具体调参逻辑可以这样理解绿色背景区域的 Y 值通常不低所以smoothstep(... maskY)会趋向于 1而绿色区域的红蓝色度分量数值偏离标准导致两个色度项的(1 - smoothstep(...))趋向于 0。三个分量乘起来绿色区域的 mask 最终靠近 0于是混合结果偏向背景图。人物主体不满足绿色特征mask 靠近 1混合结果偏向前景。这就是“绿幕消失”的数学由来。默认参数通常给 thresholdSensitivity0.4、smoothing0.1但这只是通用值在暗光环境、阳光直射、绿幕有褶皱的情况下都得重新标定。2.4 最后的 mix 为什么要这样排列mix(backgroundColor, currentColor, mask)等于backgroundColor * (1 - mask) currentColor * mask。mask 在这里当作前景权重前景越“不像绿幕”mask 越接近 1输出越偏向 currentColor前景越“像绿幕”mask 越接近 0输出越偏向 backgroundColor。这个写法非常符合人的直觉蒙版保留前景人物抠掉背景然后让背景图从被抠掉的地方透出来。如果你看到结果是人物和背景完全反了先别怀疑硬件检查一下是不是把两个输入纹理传反了顺序或者把 mix 的两个颜色参数写反了。这个错误的概率特别高因为很多教程里用的是mix(textureColor2, textureColor, mask)这种变量名稍不注意就会看错哪一个是前景、哪一个是背景。3. 实操在Android美颜相机里接入这颗滤镜3.1 方案选型直接套GPUImage还是自写GLES渲染接入之前先想清楚走哪条路。如果项目里已经完整引入了GPUImage框架并且前面的美颜节点都是基于它搭的那直接用现成的 GPUImageChromaKeyBlendFilter 最省事。缺点是框架版本很多API 细节有差异而且双输入滤镜的第二个输入经常要绕一下 GPUImagePicture 的 target 机制。如果项目是自建渲染管线或者你只是想要一个能稳定复现的轮子建议直接抄 GLSL 自写渲染。需要的代码量并不大一颗滤镜总共就几十行着色器再套一个简单的 GLES 绘制流程。自写的好处是纹理单元、Framebuffer、EGL 状态全部可控排查问题的时候不会陷入“框架为什么嘎我一下”的迷局。我在项目里最终选择了“框架封装自写着色器”的混合方案显示和生命周期继续用 GPUImageView 那套但这颗双输入滤镜单独写了一份渲染逻辑避免两个输入源在框架的滤镜组里打架。3.2 GPUImage 快速接入的示例流程如果坚持用框架核心套路如下以我这边主流的GPUImage 1.x分支为例GPUImage mGPUImage new GPUImage(MainActivity.this); GPUImageView mGPUImageView findViewById(R.id.gpu_image_view); mGPUImage.setGLSurfaceView(mGPUImageView); // 摄像头作为前景输入 mGPUImage.setUpCamera(mCamera); // 创建ChromaKeyBlend滤镜 GPUImageChromaKeyBlendFilter blendFilter new GPUImageChromaKeyBlendFilter(); blendFilter.setThresholdSensitivity(0.4f); blendFilter.setSmoothing(0.1f); mGPUImage.setFilter(blendFilter); // 背景图作为第二个输入 Bitmap bgBitmap BitmapFactory.decodeResource(getResources(), R.drawable.bg_room); GPUImagePicture backgroundPicture new GPUImagePicture(this, bgBitmap); // 关键把背景图打到blendFilter的第二个输入链路上 backgroundPicture.addTarget(blendFilter); backgroundPicture.processImage();这里比较值得注意的就是backgroundPicture.addTarget(blendFilter)。GPUImagePicture 的作用是把外部 Bitmap 上传成纹理而双输入滤镜的第二个采样器 uniform 就是靠这条 target 链拿到的。如果发现背景图没有生效优先检查 processImage 有没有被调用、Bitmap 是否已经 recycle、以及 addTarget 是加到了正确的滤镜实例上。不过要提醒一句不同仓库版本里GPUImagePicture 和双输入滤镜的连接方式可能略有不同。如果 addTarget 之后背景仍然没显示直接看框架源码里 ChromaKeyBlendFilter 的secondInputTexture是怎么被赋值的多半能查到原因。3.3 自写GLES渲染的关键代码自写方案更可控也便于理解完整链路。核心分三块编译着色器、设置纹理、绘制。着色器就用上面那段分别定义两个 uniform sampler。下面是绘制侧的关键代码// onSurfaceCreated 阶段 program buildProgram(VERTEX_SHADER, FRAGMENT_SHADER); aPosition glGetAttribLocation(program, position); aTexCoord glGetAttribLocation(program, inputTextureCoordinate); uInputTexture glGetUniformLocation(program, inputImageTexture); uBackgroundTexture glGetUniformLocation(program, inputImageTexture2); uThreshold glGetUniformLocation(program, thresholdSensitivity); uSmoothing glGetUniformLocation(program, smoothing); // 背景纹理上传 if (bgTextureId 0) { bgTextureId loadTexture(bgBitmap); // 使用GL_TEXTURE_2D } // onDrawFrame 阶段 glUseProgram(program); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, foregroundTextureId); glUniform1i(uInputTexture, 0); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, bgTextureId); glUniform1i(uBackgroundTexture, 1); glUniform1f(uThreshold, threshold); glUniform1f(uSmoothing, smoothing); // 绑定全屏四边形顶点 glBindBuffer(GL_ARRAY_BUFFER, vbo); glEnableVertexAttribArray(aPosition); glVertexAttribPointer(aPosition, 2, GL_FLOAT, false, 4 * 4, 0); glEnableVertexAttribArray(aTexCoord); glVertexAttribPointer(aTexCoord, 2, GL_FLOAT, false, 4 * 4, 2 * 4); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glDisableVertexAttribArray(aPosition); glDisableVertexAttribArray(aTexCoord); glBindBuffer(GL_ARRAY_BUFFER, 0);如果foregroundTextureId直接来自摄像头注意大概率是 OES 外部纹理绑定到 GL_TEXTURE_2D 会黑屏。标准做法是先经过一层离屏渲染把 OES 纹理“洗”成 2D 纹理再扔给这颗滤镜。GPUImage 内部就是做了这一层转换所以用框架版没遇到这个问题自写版就绕不开。背景纹理的上传时机也要控制好。不要在 onDrawFrame 里每次都loadTexture否则带宽会爆。我在项目里做了一个setBackgroundBitmap()接口只在外部真正更换背景图时执行一次纹理上传然后缓存在成员变量里。3.4 参数调节与UI联动这个滤镜在真机上要调出能看的边缘顺序很重要。我的建议是先把“换背景”这件事拆成两步第一步只调蒙版第二步再调混合。先临时把 shader 输出改成gl_FragColor vec4(mask, mask, mask, 1.0f);这样屏幕上显示的是纯黑白蒙版图白色是前景保留区黑色是背景替换区。调蒙版完毕之后把 shader 换回正常混合输出再微调边缘过渡。UI 上的两个滑杆可以直接映射两个 uniformthresholdSensitivity 建议范围 0.1 ~ 0.7默认 0.4smoothing 建议范围 0.0 ~ 0.3默认 0.1。滑杆变化时不要直接重绘整个GLSurfaceView只需更新 uniform 就行glUseProgram(program); glUniform1f(uThreshold, thresholdValue); glUniform1f(uSmoothing, smoothingValue);不需要重新上传纹理也不需要重编译程序。这点优化在真机上一旦体验过就会记住我曾经在初版代码里每次滑杆变化都重建着色器程序直接把帧率干到十几帧后来改成只更新 uniform帧率恢复六十。3.5 性能优化实践双输入滤镜相比单输入滤镜多了一次背景纹理的采样开销。如果背景图是一张 1080P 甚至更高分辨率的图GPU 的带宽压力会明显上升。几个实测有效的优化手段背景图统一降采样到屏幕分辨率以内。证件照场景背景是纯色块根本不需要高清纹理直播场景背景图控制在 720P 即可。背景纹理用 RGB565 格式上传色阶损失肉眼基本看不出来带宽却少三分之一。背景图只在切换时上传一次不要在每一帧调用 Bitmap 解码或者纹理上传。如果只是临时玩一下可以在不需要换背景时把滤镜链直接切回单输入滤镜跳过双输入采样连 shader 开销都省掉。另外离屏渲染的 Framebuffer 尽量复用。不要为了一次抠像就创建新的 FBO现代安卓设备的 EGL 资源创建销毁是很有代价的。4. 排查实录边缘发绿、人物残缺、背景变形4.1 边缘发绿/色溢问题真机上最常看到的现象是人物头发、肩膀边缘有一圈绿边。原因是绿幕的绿色灯光打在人物边缘边缘像素其实是“半背景半人物”的混色色度键计算后残留了一部分绿色信息。处理思路有两个层次。简单方案是把 smoothing 加大一点让过渡带覆盖更宽的边缘绿边会被背景色混合冲淡副作用是边缘会变软。如果绿边非常顽固可以在混合之后加一步“边缘去色溢”把边缘附近像素的绿色通道向红蓝方向压一压。这种后处理在自写管线里加非常方便加在 ChromaKeyBlend 后面作为一个单输入滤镜即可。实测中我反而是先检查了灯光。绿幕如果光线不均匀再调 threshold 也救不回来。在室内先保证绿幕被均匀照亮人物离绿幕保持 1 米以上距离能省掉后面一大半调参工作。4.2 人物被“吃掉”或背景残留如果人物身上穿的也是绿色衣服或者人物肤色偏暗偏绿mask 会把人物一部分区域误判为背景表现为人物镂空。反过来如果绿幕有阴影、褶皱、反光mask 可能没把背景彻底压成 0表现是背景替换后还有斑驳的绿色残留。对“人物被吃”的情况优先降低 thresholdSensitivity。降低 threshold 意味着色度判定更严格只有非常接近目标色的像素才被判为背景。对“背景残留”的情况则相反通常要升高 threshold。这就会出现一个扎心的平衡难题threshold 定低了背景残留定高了人物被啃。我的经验是按区域调在 UI 上放两颗滑杆让用户手动微调比算法自动参数更实用。后续如果做自动化可以考虑对蒙版结果做一次形态学腐蚀膨胀去掉孤立噪点再补一个高斯模糊让蒙版过渡更稳。4.3 背景图拉伸变形很多第一次接入的人会直接texture2D(inputImageTexture2, textureCoordinate2)结果背景图被拉伸成屏幕比例。比如 16:9 的屏幕塞了一张 4:3 的背景图人脸全被拉扁。解决思路是给背景图做“cover”适配就像 Android ImageView 的 center-crop 一样计算背景图和渲染目标的长宽比取较大比例作为缩放因子采样时对背景纹理坐标做裁剪。核心逻辑在顶点或者片段着色器里加一组 uniformuniform vec2 bgScale; // 计算好的裁剪缩放 vec2 bgCoord (textureCoordinate2 - 0.5) * bgScale 0.5; // 超出 [0,1] 的坐标会采样到边缘 clamp 值裁剪掉多余部分 vec4 backgroundColor texture2D(inputImageTexture2, bgCoord);Java 侧算 bgScale 也不难float viewRatio viewWidth / (float) viewHeight; float bgRatio bgWidth / (float) bgHeight; float scale Math.max(viewRatio / bgRatio, 1.0f); // 最终 bgScale (scale, scale) 或者根据宽高方向修正做完以后背景图在任何比例下都保持原比例只裁掉多余部分不再变形。4.4 安卓机型差异与EGL上下文恢复这颗滤镜在安卓上的兼容性坑主要不在 GLSL 本身而在 EGL 和纹理生命周期。典型情况是 App 切后台再回来GLSurfaceView 的 EGL Context 被系统销毁所有纹理 ID 全部失效。如果这时候还拿着旧的背景纹理 ID 去绑定很多机型会直接黑屏或者渲染出乱码。处理方式是在onSurfaceCreated回调里做一次“全量恢复”重新编译程序、重新上传背景纹理、重新创建必要的 FBO。不要试图把纹理 ID 缓存到 Activity 成员变量里跨 Context 使用那是无效的。另一个机型差异是 highp 精度声明。某些老 GPU 不支持 fragment shader 里的 highp float编译会报错。稳妥做法是声明成precision mediump float;但色度键计算对精度比较敏感mediump 在一些设备上会出现边缘抖动。我的处理是写一个工具方法运行时判断 GPU 能力动态选择精度字符串。4.5 常见问题速查表现象可能原因处理办法人物边缘一圈绿边绿幕反光污染人物边缘提高smoothing增加去色溢后处理调整绿幕灯光背景残留绿色块/绿斑thresholdSensitivity过低提高threshold给蒙版加形态学去噪人物被镂空/皮肤被替换thresholdSensitivity过高或人物颜色和背景太接近降低threshold检查人物衣物颜色背景图拉伸变形背景纹理坐标直接铺满渲染目标用cover裁剪适配不要直接拉伸切换后台再回来黑屏EGL Context丢失纹理未重建在onSurfaceCreated里重建程序、纹理、FBO背景图不显示GPUImagePicture未processImage或target链路不对检查processImage和addTarget调用顺序帧率骤降每帧都上传Bitmap或编译shader背景纹理缓存只在切换时上传滑杆只更新uniform相机画面全黑前景纹理是OES但按普通2D纹理绑定先做OES到2D的离屏转换或者使用外部纹理采样shader5. 一些可以继续深入的后续方向ChromaKeyBlendFilter 能扛住传统绿幕场景但它毕竟是“基于颜色”的不是“基于语义”的。我在实际项目里做完这一颗滤镜之后很快就意识到如果需求变成“人像在任意杂乱背景下自动抠像”色度键这条路就走不通了。后续比较自然的方向是接一个人像分割模型输出一张软蒙版再复用今天这套双纹理混合管线。混合 shader 的部分不用动只需要把 mask 的来源从“颜色阈值计算”换成“模型输出”。把这一点和这颗滤镜放在一起看会有种很明显的工程递进感传统方案解决“要不要做”AI方案解决“能不能做得更自然”。ChromaKeyBlendFilter 作为铺垫把纹理单元、混合逻辑、边缘过渡这些基础概念都过了一遍后面接分割模型时就是水到渠成的事。最后分享一个影响最深的小技巧调试这种双输入滤镜一定不要对着最终混合效果调参。最终效果里背景、前景、边缘三个变量混在一起很难判断是蒙版错了还是背景纹理错了。先把混合关掉单独看蒙版输出把蒙版调到“人物纯白、背景纯黑”再打开混合。能把这个习惯坚持下来调参时间至少缩短一半。
返回列表