19、实时美颜与滤镜:GPUShader皮肤平滑、人脸关键点瘦脸/大眼、LUT滤镜、性能与画质平衡
实时美颜,说白了就是让摄像头里的你「看起来更好看」。这可不是简单的加个滤镜就完事。我做了这么多年音视频,见过太多产品——美颜一开,脸是白了,但背景糊成一片,或者人脸变形得像外星人。
这一章,咱们就聊聊WebRTC里怎么实现真正的实时美颜。核心就四个字:GPU加速。CPU做美颜?别想了,实时视频流每秒30帧,CPU根本扛不住。
19.1 皮肤平滑:高斯模糊与双边滤波
皮肤平滑,也叫「磨皮」。原理很简单——把皮肤上的小瑕疵模糊掉。但问题来了:怎么只模糊皮肤,不模糊眼睛、眉毛这些细节?
最简单的做法是高斯模糊。它把每个像素和周围像素做加权平均,权重由高斯分布决定。代码长这样:
// 片元着色器 - 高斯模糊
uniform sampler2D uTexture;
uniform vec2 uTexelSize;
uniform float uBlurRadius;
void main() {
vec2 uv = gl_FragCoord.xy * uTexelSize;
vec4 color = vec4(0.0);
float totalWeight = 0.0;
for (int x = -4; x <= 4; x++) {
for (int y = -4; y <= 4; y++) {
vec2 offset = vec2(float(x), float(y)) * uBlurRadius * uTexelSize;
float weight = exp(-float(x*x + y*y) / (2.0 * uBlurRadius * uBlurRadius));
color += texture2D(uTexture, uv + offset) * weight;
totalWeight += weight;
}
}
gl_FragColor = color / totalWeight;
}
但高斯模糊有个致命问题——它会把整张图都糊掉。你想想看,眼睛、眉毛这些需要保留细节的地方也被模糊了,效果就很假。
所以我更推荐双边滤波。它比高斯模糊多了一个「像素值差异」的判断。如果两个像素颜色差异太大,就不参与模糊。这样皮肤区域被平滑,边缘细节却保留了下来。
核心区别:
- 高斯模糊:只看距离,不看颜色差异
- 双边滤波:距离 + 颜色差异,双重判断
我在项目中遇到过一个问题:双边滤波在GPU上实现时,循环次数太多,性能直接崩了。后来怎么解决的?我把模糊半径缩小到3像素以内,然后用两次pass(先水平再垂直)来优化。嗯,性能一下就上来了。
19.2 人脸关键点:瘦脸与大眼
瘦脸和大眼,靠的是人脸关键点。WebRTC里可以用TensorFlow.js或者MediaPipe的人脸检测模型,输出68个或106个关键点。
有了关键点,剩下的就是「图像变形」。说白了,就是把某些区域的像素「推」到新的位置。
瘦脸原理
瘦脸的核心是局部缩放变形。以脸颊两侧的关键点为圆心,把周围的像素向圆心方向压缩。这样脸就「瘦」了。
伪代码大概是这样:
// 瘦脸 - 片元着色器
uniform vec2 uLeftCheek; // 左脸颊关键点
uniform vec2 uRightCheek; // 右脸颊关键点
uniform float uStrength; // 瘦脸强度
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
vec2 delta = uv - uLeftCheek;
float dist = length(delta);
float radius = 0.15; // 影响半径
if (dist < radius) {
float scale = 1.0 - uStrength * (1.0 - dist / radius);
vec2 newUV = uLeftCheek + delta * scale;
gl_FragColor = texture2D(uTexture, newUV);
} else {
gl_FragColor = texture2D(uTexture, uv);
}
}
大眼的原理类似,只不过是把眼睛区域的像素向外拉伸。注意,拉伸的时候要保证眼睛形状自然,别变成「外星人眼」。
我的经验:瘦脸和大眼的强度不要超过0.3。超过这个值,人脸比例会失调,看起来很不自然。我曾经见过一个产品,瘦脸开到0.8,结果人脸变成了「蛇精脸」……用户反馈很糟糕。
19.3 LUT滤镜:一秒切换风格
LUT(Look-Up Table)滤镜,是业界最常用的调色方案。它本质上是一个颜色映射表——输入一个颜色值,输出另一个颜色值。
为什么用LUT?因为快。你想想看,如果每帧都做复杂的颜色计算,GPU扛不住。但LUT只需要一次纹理采样,性能开销几乎为零。
LUT文件通常是一个512x512的图片,里面分成8x8的格子,每个格子代表不同的亮度层级。GPU着色器里这样用:
// LUT滤镜 - 片元着色器
uniform sampler2D uTexture;
uniform sampler2D uLUT;
void main() {
vec4 color = texture2D(uTexture, gl_FragCoord.xy / uResolution);
// 将RGB映射到LUT坐标
float blue = color.b * 63.0;
vec2 quad1 = vec2(floor(blue) / 8.0, 0.0);
vec2 quad2 = vec2(ceil(blue) / 8.0, 0.0);
float offset = fract(blue);
vec2 lutPos1 = (color.rg * 63.0 / 512.0) + quad1;
vec2 lutPos2 = (color.rg * 63.0 / 512.0) + quad2;
vec4 lutColor = mix(texture2D(uLUT, lutPos1),
texture2D(uLUT, lutPos2), offset);
gl_FragColor = mix(color, lutColor, uLUTStrength);
}
LUT的好处是:你可以提前在Photoshop里调好颜色,导出LUT文件。运行时直接加载,一秒切换风格。暖色调、冷色调、复古风、日系风……随便换。
注意:LUT文件不要太大。512x512的LUT已经够用,再大就是浪费显存。另外,LUT的精度是8位,如果你需要更高的色彩精度,可以考虑10位LUT,但移动端支持有限。
19.4 性能与画质的平衡
实时美颜最头疼的问题是什么?性能和画质的平衡。你美颜效果再好,如果掉帧到15fps,用户一样骂娘。
我总结了几条经验:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 降低分辨率 | 美颜在低分辨率上做,最后再上采样 | 移动端、低端设备 |
| 减少pass次数 | 能一个pass搞定就别用两个 | 所有场景 |
| 纹理复用 | 不要每帧创建新纹理,用FBO复用 | 所有场景 |
| 精度降级 | 从float16降到float8 | 移动端GPU |
| 关键点缓存 | 人脸关键点不用每帧都检测,可以隔帧检测 | 低端设备 |
我个人习惯的做法是:先检测设备性能。如果是高端手机,直接上全精度、多pass。如果是低端设备,把分辨率降到720p,美颜强度也调低一些。
还有一个坑:纹理上传。CPU到GPU的纹理上传是很慢的。我曾经在项目里每帧都上传LUT纹理,结果性能直接腰斩。后来改成初始化时一次性上传,后面只传uniform参数,问题就解决了。
19.5 整体架构图
下面这张图展示了实时美颜的完整流程。从摄像头采集到最终渲染,每一步都在GPU上完成。
从图上你能看到,整个管线是串行的。摄像头采集后,先做人脸检测,再根据关键点做美颜,最后套LUT滤镜。性能优化策略贯穿始终,每个环节都可以做降级处理。
19.6 避坑指南
最后,分享几个我踩过的坑:
- 纹理坐标搞反:OpenGL ES的纹理坐标原点在左下角,而摄像头数据通常是从左上角开始的。不处理好这个映射,美颜效果会歪掉。
- 多线程同步:人脸检测在CPU上做,美颜在GPU上做。两者之间的数据同步要用信号量或者Fence,别用简单的sleep等待。
- 内存泄漏:每帧都创建新的纹理对象,但不释放。跑几分钟后内存就爆了。记得用FBO(帧缓冲对象)复用纹理。
- LUT精度:有些LUT文件是16位的,但WebGL只支持8位纹理。加载前要做精度转换,否则颜色会偏。
我曾经在一个项目里,因为纹理坐标搞反,美颜效果变成了「镜像脸」——左脸瘦了,右脸胖了。调试了整整一天才发现是坐标映射的问题。嗯,从那以后我每次写纹理采样都会先检查坐标方向。
好了,这一章的内容就到这里。实时美颜的核心就是GPU加速 + 人脸关键点 + LUT滤镜。性能优化是永远的主题,没有银弹,只有根据设备做权衡。