十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

GPUImage差值混合滤镜实战:着色器原理与美颜相机应用

GPUImage差值混合滤镜实战:着色器原理与美颜相机应用 做美颜相机做到第二十三四天滤镜库基本上已经把所有常见的混合模式都过了一遍唯独有一个东西一直没细抠——GPUImageDifferenceBlendFilter。这个名字翻译过来就是“差值混合滤镜”听起来平平无奇甚至在很多滤镜软件里它都不是默认展示项但只要你愿意深挖它其实是一把非常锋利的手术刀。这两天我基本把它从数学原理到实际接入全捋了一遍顺便在项目里加了一个真正能用的“线稿滤镜”和一个“胶片颗粒滤镜”这篇文章就完整记录一下我在这个过程中的思路、代码、踩过的坑和最终优化方案适合正在做Android美颜相机、或者打算用GPUImage框架折腾自定义滤镜的开发者参考。1. 先搞清楚这个滤镜到底是干什么的1.1 “差值”不是“对比”而是数学上的绝对值减法很多人第一眼看到DifferenceBlendFilter会觉得它是用来对比两张图差异的实际上它做的事情非常纯粹把两张图像对应位置的RGB三个通道分别相减再取绝对值。公式写出来就是这个样子输出颜色 |基础图颜色 - 混合图颜色|关键就在那个绝对值上。由于RGB每个通道的取值范围是[0.0, 1.0]GPUImage里以浮点数纹理输出所以不管基础图颜色是比混合图颜色亮还是暗最终结果一定是正的而且是“偏差越大颜色越显眼”。举一个最极端的例子如果混合图是一张纯白色图也就是RGB为(1.0, 1.0, 1.0)那么输出就是|原色 - 1.0| 1.0 - 原色这正好就是反相效果。如果混合图是纯黑色图也就是(0.0, 0.0, 0.0)输出就是|原色 - 0.0| 原色画面基本没有变化。也就是说第二张输入图是什么内容直接决定了这个滤镜的走向。它不是固定的一种效果而是一个“搬运差异”的工具。1.2 在美颜项目里能玩出什么花样我在动手之前先列了下差值混合在相机类App里常见的用法最终选了三个值得做的应用场景第一个是反相效果。这是最快捷的验证方式拿一张纯白图作为第二输入一秒就能看出滤镜链路通没通。而且反相在创意滤镜里也有它自己的位置很多人拍暗光人像时喜欢来一张负片风格的图。第二个是线稿提取。做法是这样的先把原图转成灰度图再对这个灰度图做一次高斯模糊最后把灰度图和模糊后的灰度图做差值混合。因为模糊后的图已经把边缘细节抹掉了两张图一旦相减剩下的恰好就是边缘和高频信息出来的效果就是一张白底黑线的线稿图。这种风格在文艺类修图App里很常见。第三个是胶片颗粒叠加。把一张带噪点、划痕的胶片纹理图作为第二输入和原图做差值混合出来的颜色会偏得很厉害有一种老式负冲胶片的味道配合适当调节混合强度整个画面的质感一下子就不一样了。1.3 两天的开发目标是怎么拆的第二十三天我主要在做原理验证把GPUImage自带的GPUImageDifferenceBlendFilter在Demo工程里跑通用静态图片验证反相、线稿、纹理叠加三种玩法顺便把源码里两个输入纹理的绑定逻辑看清楚。第二十四天做的事情是把滤镜正式塞进美颜相机的滤镜列表里接入相机预览流程同时把“混合强度调节”“第二输入纹理复用”这两个实际项目里绕不开的问题解决掉。说白了第一天是搞懂它第二天是把它变成能上线的东西。2. 原理拆解2.1 核心着色器代码逐行看GPUImage底层的所有滤镜本质上都是OpenGL ES里的片段着色器程序。GPUImageDifferenceBlendFilter在Android库里继承的是GPUImageTwoInputFilter也就是说它支持两张纹理输入。它的核心片段着色器写出来大致是这样precision mediump float; varying vec2 textureCoordinate; varying vec2 textureCoordinate2; uniform sampler2D inputImageTexture; uniform sampler2D inputImageTexture2; void main() { vec4 baseColor texture2D(inputImageTexture, textureCoordinate); vec4 overlayColor texture2D(inputImageTexture2, textureCoordinate2); gl_FragColor vec4(abs(baseColor.rgb - overlayColor.rgb), 1.0); }textureCoordinate和textureCoordinate2分别是两张输入图对应的纹理坐标inputImageTexture和inputImageTexture2是两个纹理采样器。这里最容易忽略的是末尾的alpha通道我一开始直接用abs(baseColor - overlayColor)把整个vec4都做了差值结果半透明区域的边缘上出现一圈诡异的高亮。原因是alpha通道也被做了绝对值减法比如alpha 0.5和alpha 0.6差出来的0.1会让边缘透明度突然不连续看起来就像描边一样。正确做法是只对rgb做差值alpha通道保持基础图的值或者直接写死为1.0像上面代码里这样。如果想让滤镜可以调节强度也就是在“完全原图”和“完全差值结果”之间插值可以再加一个uniform浮点变量用mix函数做线性混合uniform float mixPercent; void main() { vec4 baseColor texture2D(inputImageTexture, textureCoordinate); vec4 overlayColor texture2D(inputImageTexture2, textureCoordinate2); vec4 diffColor vec4(abs(baseColor.rgb - overlayColor.rgb), baseColor.a); gl_FragColor mix(baseColor, diffColor, mixPercent); }mixPercent为0时输出原图为1时输出完整差值混合结果中间值就是两者按比例融合。这个参数在实际体验里非常关键因为差值混合直接拉满的效果往往太冲给用户一个SeekBar慢慢调反馈会好很多。2.2 和其他混合模式放在一起看做过Photoshop的人对混合模式一定不陌生但到了GPUImage里它们对应的都是一个一个独立的Filter类。我把几个比较常用的并列放在一张表里对比方便理解差值混合的位置混合模式核心公式以RGB为例视觉效果特点Normal普通混合输出 基础图无混合Multiply正片叠底输出 基础图 × 混合图整体变暗亮部损失Screen滤色输出 1 - (1-基础图) × (1-混合图)整体变亮暗部提亮Difference差值输出 |基础图 - 混合图|颜色对撞反转感强Exclusion排除输出 基础图 混合图 - 2×基础图×混合图类似差值但更柔和Difference和Exclusion最大的区别在于“中间调”的处理。差值混合在基础图和混合图完全相等时直接输出黑色0而Exclusion在相等时输出的是50%灰0.5所以Exclusion看起来过渡更平滑不会出现差值那种“啪一下断掉”的突变感。这也是为什么做线稿提取时用差值效果最好因为我们要的就是边缘那种干脆利落的对比而做胶片颗粒时反而适合把强度调低让差值结果的生硬感被原图稀释掉。2.3 GPUImage为什么要把滤镜做成链式GPUImage框架的核心设计是“Source → Filter → Target”的链式模型。一张图片从最上游的相机或者Bitmap进入经过一个个滤镜处理最后输出到GPUImageView或者Bitmap。每个滤镜都是一个独立的OpenGL ES程序处理完的结果作为下一个滤镜的输入纹理。这种设计的好处是解耦每个滤镜只需要关心自己的输入输出组合起来能玩出很多花样。差值混合滤镜作为GPUImageTwoInputFilter的子类多了一个纹理输入端口也就是除了走正常链路进来的那张图还能额外挂一张Bitmap或者纹理。在Android库里这个入口就是setBitmap方法。要理解它可以把GPUImageFilter想象成一个只有单输入的工具箱而GPUImageTwoInputFilter就是一个带两个抽屉的工具箱——多出来的那个抽屉专门用来接“混合素材”。3. Android端接入与代码实现3.1 项目引入GPUImage如果你的项目还没引入GPUImage直接在build.gradle里加依赖就行implementation jp.co.cyberagent.android:gpuimage:2.1.0然后在布局文件里放一个GPUImageViewjp.co.cyberagent.android.gpuimage.GPUImageView android:idid/gpuImageView android:layout_widthmatch_parent android:layout_heightmatch_parent /这一步没什么好说的几乎所有用GPUImage的项目都是这么起步的。有一点需要注意GPUImageView本质上是一个渲染控件底层走的是GLSurfaceView所以它要求你的页面不能放在带硬件加速限制的异常场景下比如某些低端机锁屏恢复后GL上下文会被销毁需要重走初始化流程。3.2 写一个带混合强度参数的自定义Filter工程里我建议不要直接用GPUImage自带的那几个BlendFilter类因为它们都不带强度调节。我自己封装了一个子类继承GPUImageTwoInputFilter核心是上面那段加了mixPercent的着色器。代码不复杂直接贴出来public class GPUImageDifferenceBlendWithPercentFilter extends GPUImageTwoInputFilter { public static final String DIFFERENCE_BLEND_FRAGMENT_SHADER precision mediump float;\n varying vec2 textureCoordinate;\n varying vec2 textureCoordinate2;\n uniform sampler2D inputImageTexture;\n uniform sampler2D inputImageTexture2;\n uniform float mixPercent;\n void main() {\n vec4 baseColor texture2D(inputImageTexture, textureCoordinate);\n vec4 overlayColor texture2D(inputImageTexture2, textureCoordinate2);\n vec4 diffColor vec4(abs(baseColor.rgb - overlayColor.rgb), baseColor.a);\n gl_FragColor mix(baseColor, diffColor, mixPercent);\n }\n; private int mMixPercentLocation; private float mMixPercent 1.0f; public GPUImageDifferenceBlendWithPercentFilter() { super(DIFFERENCE_BLEND_FRAGMENT_SHADER); } Override public void onInit() { super.onInit(); mMixPercentLocation GLES20.glGetUniformLocation(getProgram(), mixPercent); } Override public void onDraw() { super.onDraw(); GLES20.glUniform1f(mMixPercentLocation, mMixPercent); } public void setMixPercent(float percent) { mMixPercent percent; } }注意onInit里通过getProgram()获取shader中uniform变量的位置这个位置在拿到的时候可能因为优化等原因返回-1所以glUniform1f调用前最好做一个非-1的判断不然有些驱动会打警告日志。3.3 离线线稿滤镜的实现流程线稿滤镜我第一版是放在离线处理流程里的也就是用户拍完照片点“线稿”后再计算。这样避免了相机预览帧实时算模糊带来的性能压力。流程是三步第一步把原图转成灰度图。这一步可以直接用GPUImage的GPUImageGrayscaleFilter通过GPUImage.getBitmapWithFilterApplied拿到灰度Bitmap。第二步把灰度图再做高斯模糊得到模糊后的Bitmap。第三步把模糊后的Bitmap设置成DifferenceBlendFilter的第二输入让灰度图作为主输入走一遍差值混合。代码逻辑是这样GPUImage gpuImage new GPUImage(context); // 第一步灰度处理 Bitmap grayBitmap gpuImage.getBitmapWithFilterApplied(originalBitmap, new GPUImageGrayscaleFilter()); // 第二步高斯模糊 GPUImageGaussianBlurFilter blurFilter new GPUImageGaussianBlurFilter(); blurFilter.setBlurSize(2.5f); Bitmap blurredBitmap gpuImage.getBitmapWithFilterApplied(grayBitmap, blurFilter); // 第三步差值混合 GPUImageDifferenceBlendWithPercentFilter diffFilter new GPUImageDifferenceBlendWithPercentFilter(); diffFilter.setBitmap(blurredBitmap); diffFilter.setMixPercent(0.6f); Bitmap result gpuImage.getBitmapWithFilterApplied(grayBitmap, diffFilter);一开始我把原图彩色图直接作为主输入出来的线稿里夹杂着不少彩色噪点观感比较脏。后来把主输入也换成了灰度图整体就干净了很多。混合强度我调到0.6左右因为拉满1.0的时候线条过于生硬有点像描边模板反而没有手绘感。有一点必须提醒setBitmap里面传的Bitmap会通过GLUtils.texImage2D上传到GPU纹理如果这张图是相机刚拍出来的一张1200万像素大图纹理上传开销相当可观。我在离线处理时会把原图先压缩到宽不超过1080的尺寸再做链式操作速度基本能接受。3.4 相机实时预览里的做法接入实时预览时问题比单张图片要麻烦很多。美颜相机的预览链路本来就是Camera → 磨皮 → 美白 → 滤镜 → GPUImageView差值混合滤镜如果走离线那套Bitmap中间转换帧率会掉到惨不忍睹。胶片颗粒这个场景走的是实时预览我的做法是先把颗粒纹理图预先处理好压缩到预览同尺寸然后在进入滤镜环节之前调一次setBitmap把纹理传给滤镜之后每一帧预览只走GPU绘制不再重新上传纹理。代码上只需要在滤镜切换时调用一次GPUImageDifferenceBlendWithPercentFilter diffFilter (GPUImageDifferenceBlendWithPercentFilter) selectedFilter; if (diffFilter.getBitmap() null) { diffFilter.setBitmap(loadedTextureBitmap); }GPUImageTwoInputFilter内部会对setBitmap做一次判断如果mFilterSourceTexture已经创建且没有被reload就不会重复上传。所以只要Bitmap对象不变预览过程中不会每帧都触发纹理上传。3.5 混合强度调节的联动美颜相机里滤镜通常都有一个强度滑杆用户拖到底部就是原图效果往上拉是满强度。我在UI层把SeekBar的进度和setMixPercent做了绑定seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { float percent progress / 100f; diffFilter.setMixPercent(percent); gpuImageView.requestRender(); } });requestRender这一步容易漏。GPUImageView的渲染模式如果是RENDERMODE_WHEN_DIRTY滤镜参数变化后必须手动调用它强制走一帧否则界面上永远停留在上一次绘制的画面。4. 调试踩坑与效果调优4.1 第二输入源在不同机型上表现不一致这个坑是我花时间最多的地方。同样一段代码在A手机上能正常出效果换到B手机上预览画面里完全没有差值混合的效果后来甚至出现了画面闪烁。我一开始以为是滤镜参数没设对排查了很久才发现是纹理单元冲突。GPUImageTwoInputFilter源码里加载第二张纹理时用的是GL_TEXTURE3单元但它的onDraw方法里会默认激活GL_TEXTURE5或者其他单元来处理一些中间纹理。如果你在自己的滤镜里也用了超过纹理单元范围的编号某些驱动的实现就会出问题。最后我查了glGetError日志发现是GL_INVALID_OPERATION定位到纹理绑定阶段后把所有额外纹理统一改到GPUImageTwoInputFilter约定的GL_TEXTURE3单元问题才消失。唯一的解决办法是看库源码不要想当然。Android GPUImage库在不同版本里的纹理单元编号并不是完全一致的升级依赖库版本后要重新验证一遍。4.2 预览颜色偏灰或者偏白差值混合本身不会直接导致颜色偏灰但如果你的相机预览数据源是YUV格式比如常见的NV21再直接转成RGB纹理经常会出现饱和度莫名的下降。特别是在差值混合这种对色彩差异高度敏感的滤镜里颜色一旦偏灰两个输入图之间的差异就变小了效果会显得很平。我最后是把相机的输出格式固定为SurfaceTexture接手配合OpenGL的OES纹理来做预览绕开了YUV到RGB的格式转换损失。如果项目里没法大改渲染链路至少要把颜色矩阵校准一下别让YUV的颜色通道有整体偏移。4.3 性能优化纹理复用和尺寸压缩预览场景下第二输入纹理的尺寸不需要和屏幕完全一致。一张512x512的颗粒纹理贴在任意分辨率的预览上都会被GLSL自动拉伸采样肉眼基本看不出差别但纹理上传和采样的开销能省不少。我做胶片颗粒时用的就是512x512的图实测比1080p纹理的帧率高了接近10%。另外提醒一句粒纹理如果本身就是一张生成出来的Perlin噪声图完全没有必要用PNG格式放到assets里直接在代码里动态生成一个Bitmap会小很多。我第一次图省事塞了一张几MB的噪声图进去App包体直接增加好几兆后来改成动态生成包里只多了几行代码。4.4 滤镜叠加时的顺序问题如果想在磨皮之后再做差值混合要注意滤镜链中的顺序。GPUImage的setFilter方法会直接替换整个滤镜链所以不能简单地在原有美颜滤镜上再set一次diffFilter。正确做法是做一个组合滤镜比如先执行磨皮滤镜再执行差值混合滤镜通过GPUImageFilterGroup把两者串起来GPUImageFilterGroup group new GPUImageFilterGroup(); group.addFilter(beautyFilter); group.addFilter(diffFilter); gpuImageView.setFilter(group);GPUImageFilterGroup内部会把上一个滤镜的输出纹理作为下一个滤镜的输入这样就实现了多段处理。我在接人像美颜流程时把美白和磨皮放在差值混合之前这样涂抹后的平滑皮肤和颗粒纹理之间的差值会更明显胶片感更强。5. 常见问题速查表问题可能原因排查思路滤镜无效果画面还是原图第二输入纹理没有加载混合强度混到了0附近滤镜链被后续操作覆盖检查setBitmap是否调用成功打印mixPercent确认GPUImageView的filter是同一个对象画面全黑着色器编译失败输入纹理尺寸为0Bitmap被回收查看onDrawFrame的glGetError日志确认Bitmap没有recycle预览闪烁纹理单元冲突多线程同时操作GL上下文将纹理加载统一放到GL线程避免在UI线程调用setBitmap后立即requestRender颜色偏灰、饱和度降低相机YUV转RGB精度损失第二输入图本身饱和度低改用OES纹理预览或者调高第二输入图的对比度线稿滤镜出现彩色边主输入用了彩色图做差值先把主输入转为灰度图再进入差值混合混合强度调节不生效忘记requestRenderseekBar回调里主动调用GPUImageView.requestRender大Bitmap上传卡顿纹理过大把第二输入图压缩到512x512左右再setBitmap排查这类GLSL问题我个人的习惯是遇到现象不对就先关掉所有滤镜换回GPUImage自带的GPUImageFilter确认基础链路没问题再一个个把自定义滤镜加上去。这个方法虽然笨但能有效隔离出到底是滤镜本身的问题还是和前后滤镜衔接的问题。6. 一点个人经验做差值混合这两天最大的感受是这个滤镜不像美颜、美白那样“开箱即用”它是需要跟其他操作配合才能出彩的。单独把它挂在滤镜列表里用户大概率觉得只是让画面很难看但把它跟灰度、模糊、纹理叠加放在一起就能做出很多有意思的东西。另一个体会是GPUImage这个框架虽然看起来老但它把OpenGL ES的复杂度封得足够好适合快速验证滤镜算法。缺点也明显它对双输入滤镜的调试支持太少出了问题只能靠日志和耐心。如果你准备长期做美颜相机这类项目光会用GPUImage是不够的还是得把OpenGL ES纹理、着色器、纹理单元这些基础打牢很多坑本质上都是这些底层知识的应用。最后说一个小技巧如果以后你要做像素级编辑工具比如人像局部磨皮或者瑕疵检测不妨试试把“原图”和“理想肤色”做一次差值混合差值图里亮度高的区域就是瑕疵所在的位置。这个思路和线稿提取一模一样都是把“差异”变成“可见信息”这也是我这两天下来最值的一点收获。
返回列表