在实时相机中实现美颜,真正困难的地方通常不是"把皮肤模糊一点"或者"在脸颊上叠一层粉色"。困难在于,画面中的脸会移动、旋转、侧转,摄像头还可能发生镜像、裁剪和方向变换;与此同时,算法必须在每秒几十帧的预算内完成,不能阻塞相机预览。
一套完整的实时美颜链路,至少需要解决下面几个问题:
- 如何从相机画面中检测出稳定的人脸关键点;
- 如何把检测坐标准确映射到最终显示画面;
- 如何生成只覆盖皮肤、避开眼睛和嘴唇的人脸 Mask;
- 如何在保留五官边缘的同时平滑皮肤;
- 如何让腮红跟随脸部旋转、缩放和侧脸透视;
- 如何让 CPU 检测与 GPU 渲染并行工作,而不是互相等待;
- 如何控制纹理采样数量、显存占用和每帧延迟。
本文会从一帧相机图像出发,逐步推导 Face Landmark、坐标变换、时序平滑、Face Mask、双边滤波、腮红几何、Soft Light 混合以及 CPU/GPU 协同。
1. 先看全局:一帧图像经历了什么
实时美颜可以拆成两条并行流水线。
css
┌─────────────────────────────┐
│ CPU / Vision │
相机 PixelBuffer ────────┤ 人脸检测 → Landmark │
│ │ → 坐标映射 → 时序平滑 │
│ │ → 最新人脸几何快照 │
│ └──────────────┬──────────────┘
│ │
│ │ 中心、轮廓、半径、方向
▼ ▼
┌────────────────────────────────────────────────────────┐
│ GPU / Render │
│ Camera Texture → Face Mask → 双边磨皮 → 腮红 → 输出 │
└────────────────────────────────────────────────────────┘
CPU 更适合完成离散、分支较多、数据量较小的工作,例如:
- 调用人脸检测模型;
- 从数百个 landmark 中抽取需要的轮廓点;
- 计算脸部方向、双眼距离、腮红中心和半径;
- 维护跨帧状态;
- 把计算结果写入 shader uniform。
GPU 更适合完成"对大量像素执行相同计算"的工作,例如:
- 为输出图像中的每个像素计算 Face Mask;
- 为人脸区域内的每个像素执行邻域滤波;
- 计算高斯衰减;
- 完成颜色混合并写入输出纹理。
这里最重要的工程原则是:检测结果可以晚一点到,但相机渲染不能停下来等它。 GPU 在每个渲染帧中使用当前最新的人脸数据;如果新的检测结果还没回来,就暂时沿用上一份平滑后的结果。
2. 开始之前:OpenGL ES 与 GLSL 的必要知识
2.1 GPU 为什么适合做图像处理
假设输出图像大小为 1080 × 1920,一帧约有 207 万个像素。磨皮、Mask 和腮红的很多计算彼此独立:计算像素 A 时,通常不需要等待像素 B。
GPU 有大量并行执行单元,可以同时处理许多像素。开发只需要描述"一个像素应该怎样计算",GPU 会把这段程序并行应用到整张图像。这段运行在 GPU 上的小程序就是 Shader。
2.2 顶点 Shader 与片元 Shader
实时滤镜常用一个覆盖整个屏幕的矩形,由两个三角形组成。
- 顶点 Shader(Vertex Shader) 处理矩形顶点,决定顶点在屏幕上的位置,并把纹理坐标传给下一阶段;
- 片元 Shader(Fragment Shader) 处理光栅化后产生的每个片元,可以把片元近似理解为一个待输出像素。
一个简化的顶点 Shader 如下:
glsl
#version 300 es
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec2 aTexCoord;
uniform mat4 uTextureTransform;
out vec2 vTexCoord;
out vec2 vOutputTexCoord;
void main() {
gl_Position = vec4(aPosition, 1.0);
vTexCoord = (uTextureTransform * vec4(aTexCoord, 0.0, 1.0)).xy;
vOutputTexCoord = aTexCoord;
}
这里有几类常见 GLSL 变量:
in:当前 Shader 阶段的输入;out:传递给后续阶段的数据;uniform:一次 draw call 内保持不变、由 CPU 设置的数据;vec2、vec3、vec4:包含 2、3、4 个浮点分量的向量;mat4:4×4 矩阵。
uTextureTransform 负责处理相机纹理的旋转和镜像。后面会看到,保留一份未经变换的 vOutputTexCoord 同样重要。
2.3 Texture、Sampler 与纹理采样
GPU 中的图像通常存储为 Texture。片元 Shader 通过 sampler 读取它:
glsl
uniform sampler2D uTexture;
vec4 color = texture(uTexture, vTexCoord);
纹理坐标通常使用归一化范围 [0,1] × [0,1]。无论真实纹理是 720p、1080p 还是 4K,都可以使用同一套坐标访问。
Texel 指纹理中的一个实际像素。如果纹理宽高为 (W,H),单个 texel 在 UV 空间中的尺寸为:
Δu=W1,Δv=H1
因此,向右移动 3 个真实像素的采样坐标为:
(u′,v′)=(u,v)+(3W1,0)
2.4 FBO:把渲染结果继续当作纹理使用
默认情况下,GPU 把结果画到屏幕。但多级滤镜需要把中间结果保存下来,例如:
- 第一次 draw 生成 Face Mask;
- 第二次 draw 同时读取 Camera Texture 和 Mask Texture;
- 第二次 draw 的结果再交给水印或预览节点。
Framebuffer Object,简称 FBO,可以把一张 Texture 挂载为渲染目标。GPU 画完后,这张 Texture 就能作为下一次 draw 的输入。
txt
Landmark Uniform
↓
Mask Shader → Mask FBO / Mask Texture
↓
Camera Texture ───────→ Beauty Shader → Output FBO
这种"渲染到纹理,再读取纹理"的模式叫作 Render to Texture,是多级图像处理的基础。
2.5 为什么会有两个 UV 坐标
在相机滤镜中,经常需要同时保留:
vTexCoord:经过相机旋转、镜像矩阵变换,用于读取真实 Camera Texture;vOutputTexCoord:表示当前像素在最终输出画面中的位置,用于读取 Face Mask 和计算腮红。
可以写成:
uvcamera=Tuvoutput
其中 (T) 是纹理变换矩阵。如果用 vTexCoord 去采样 Mask,方向变化时 Mask 可能再次被镜像或旋转;如果用 vOutputTexCoord 读取 Camera Texture,画面方向又会错误。二者必须严格区分。
3. Face Landmark:从图像到人脸几何
3.1 Landmark 是什么
Face Landmark 是分布在人脸结构上的一组语义点。不同索引通常对应固定语义,例如脸部外轮廓、上下眼睑、嘴唇轮廓、鼻尖与脸颊。
检测模型输出的通常不是屏幕像素,而是归一化坐标:
Li=(xi,yi,zi)
其中 xi,yi 一般位于 [0,1] 附近, zi 描述相对深度。当前磨皮和腮红主要使用二维投影 xi,yi。归一化坐标与输入分辨率无关,例如 0.5,0.5始终表示图像中心。
3.2 检测、跟踪与 Landmark 回归
从应用层看,人脸关键点接口通常完成三类工作:
- Face Detection:在整张图像中寻找人脸候选区域;
- Face Tracking:连续视频中利用上一帧位置减少重复搜索;
- Landmark Regression:在人脸区域中预测每个语义点的位置。
模型内部如何实现由具体模型决定。美颜模块不应依赖内部网络结构,而应只依赖稳定的输出协议:landmark 数组、时间戳和检测是否有效。
只处理一张脸可以显著降低后续 Mask 和美颜成本。如果支持多人,需要为每张脸生成独立几何,并解决 Mask 合并、遮挡和总采样成本问题。
3.3 为什么只抽取部分点
检测器可能输出数百个点,但磨皮和腮红不需要全部使用。当前流程抽取 36 个脸部外轮廓点、左右眼各 16 点、外唇 20 点,以及鼻尖和左右脸颊参考点。
脸部外轮廓用于限定皮肤范围;眼睛和嘴唇轮廓用于从皮肤 Mask 中挖孔;鼻尖、脸颊和眼睛中心用于构造腮红。只保留必要点还能减少跨线程传输、CPU 平滑和 GPU uniform 数量。
4. 坐标变换:为什么 Landmark 正确却贴不上脸
人脸效果错位最常见的原因,不是检测错误,而是 landmark 和最终画面没有经历相同的旋转、镜像与裁剪。
4.1 旋转映射
设模型输出点为 Pm=(xm,ym)。当前方向变换使用:
xr=1−ym,yr=1−xm
90° 旋转会交换 x、y;为了让结果继续落在 [0,1] 内并匹配纹理方向,还要对相应轴执行 t→1−t。最终使用哪套顺逆时针公式,取决于 PixelBuffer 方向、模型输入 orientation 和顶点纹理变换,必须以实际渲染链为准。
4.2 镜像与 Y 轴翻转
水平镜像为:
x′=1−x,y′=y
例如 (x=0.42) 镜像后变为 (0.58)。需要区分相机数据是否已经镜像、预览是否翻转、模型看到的方向,避免重复镜像。
当两个坐标系的 Y 方向相反时:
y′=1−y
是否翻转不是由 UIKit 或 OpenGL 的名字决定,而是由 landmark 最终需要对齐的纹理空间决定。
4.3 Aspect Fill 裁剪
设旋转后源画面与目标预览的宽高比分别为:
As=HsWs,At=HtWt
Aspect Fill 会等比例放大源图直到完全覆盖目标,多余部分被裁掉。
当 As>At 时,源画面更宽,左右裁剪。可见宽度比例:
wc=AsAt
左边界:
cx=21−wc
裁剪矩形为 C=(cx,0,wc,1)。
当 As≤At 时,源画面更高,上下裁剪:
hc=AtAs,cy=21−hc
裁剪矩形为 C=(0,cy,1,hc)。
设方向变换后的点为 xr,yr,裁剪矩形为 cx,cy,cw,ch,最终输出 UV 为:
u=cwxr−cx,v=chyr−cy
含义是先减去可见区域起点,再除以可见区域尺寸,把裁剪区域重新映射到 [0,1]。
数值例子
源比例为 16:9,目标为 4:3:
As≈1.778,At≈1.333
wc=1.7781.333≈0.75,cx=21−0.75=0.125
源画面左右各裁掉 12.5%。原坐标 xr=0.5 仍映射到输出中心:
u=0.750.5−0.125=0.5
而 xr=0.125 正好映射到输出左边缘 (u=0)。
5. 时序平滑:让 Landmark 不抖,也不拖尾
5.1 指数平滑公式
即使人脸静止,传感器噪声、曝光变化与模型误差也会让 landmark 轻微抖动。对当前检测点 Ptdetect 和上一帧平滑结果 Pt−1smooth,使用:
Ptsmooth=(1−α)Pt−1smooth+αPtdetect
等价写法为:
Ptsmooth=Pt−1smooth+α(Ptdetect−Pt−1smooth)
第二种写法表示从旧位置向新检测位置移动 α 比例。基础响应系数为 α=0.62,即新结果占 62%,历史占 38%。 α 越小越稳定但拖尾越明显,越大越跟手但更容易抖。
5.2 根据运动速度自适应
固定系数不能同时满足静止和快速转头。先计算人脸框中心:
Ct=(xt+2wt,yt+2ht)
连续结果的归一化位移:
d=(Ct,x−Ct−1,x)2+(Ct,y−Ct−1,y)2
当 d>0.035 时,把响应提高到至少 α=0.82。明显移动时,新检测结果权重从 62% 提升到 82%,可以减少腮红追着脸移动的感觉。
5.3 为什么所有几何都要一起平滑
脸框、脸部轮廓、眼睛、嘴巴、鼻尖和脸颊点必须使用同一响应系数。如果只平滑其中一部分,不同几何会处在不同时间状态,出现 Mask 已移动而腮红仍停留在旧位置的现象。
第一次检测到脸、时间戳乱序、结果间隔超过 250 ms、人脸消失后重现、摄像头或方向切换时,应直接接受新结果并重置历史,避免新脸从旧位置缓慢滑入。
6. Face Mask:从离散轮廓到连续皮肤权重
6.1 Mask 的目标
理想 Mask 应让脸外、眼睛和嘴唇内部为 0,皮肤内部接近 1,所有边界平滑过渡:
M(p)∈0,1
最终效果通过:
O(p)=(1−M(p))I(p)+M(p)E(p)
混合原图 (I) 与效果图 (E)。如果只有硬边 0/1,磨皮会像剪纸一样突然终止。
6.2 射线奇偶规则
脸、眼睛和嘴部都表示成闭合多边形。对像素 p=(px,py) 向水平方向发射射线:相交奇数次表示内部,偶数次表示外部。
边端点为 a=(x0,y0)、 b=(x1,y1)。先判断边是否跨过像素水平线:
(y0>py)=(y1>py)
若成立,交点横坐标为:
xe=x0+(x1−x0)y1−y0py−y0
当 px<xe 时翻转 inside 状态。GLSL 实现需要用很小的 ε 防止水平边产生除零与 NaN。
6.3 点到线段的最短距离
羽化需要知道像素距边界多远。线段方向 v=b−a,点相对起点向量 w=p−a。投影参数:
t0=v⋅vw⋅v
将其截断到线段范围:
t=clamp(t0,0,1)
线段最近点:
q=a+tv
距离:
d(p,ab)=∥p−q∥
循环中可以先比较距离平方 (p−q)⋅(p−q),遍历所有边取最小值后只执行一次 sqrt。
6.4 为什么要在像素空间算距离
在 1080 × 1920 图像中:
1 px horizontal=10801,1 px vertical=19201
同一个 UV 距离在两个方向并不代表相同像素长度。因此先转换:
ppx=puv⊙(W,H)
轮廓点也做相同转换。这样 8 px 羽化才不会随分辨率或方向失真。
6.5 smoothstep 羽化
smoothstep(e_0,e_1,x) 先计算:
t=clamp(e1−e0x−e0,0,1)
再使用三次 Hermite 曲线:
S(t)=t2(3−2t)
曲线在两端一阶导数为 0,比线性插值平滑。
保留脸部内部:
Minside=Iinside⋅S(Fdmin)
保留眼睛和嘴部外部:
Moutside=(1−Iinside)⋅S(Fdmin)
其中 F 是羽化宽度。边界距离为 0,权重为 0;向保留区域深入超过 F 后权重达到 1。
6.6 最终皮肤 Mask
Mskin=Mface⋅MleftEyeOut⋅MrightEyeOut⋅MmouthOut
使用乘法意味着任一项为 0,结果都为 0,且羽化小数可以自然组合。例如脸内权重为 1,左眼外权重为 0.4,其他为 1,则最终 Mask 为 0.4,效果在眼边只应用 40%。
7. 双边滤波磨皮:平滑皮肤但保留边缘
7.1 为什么不用普通高斯模糊
均值和高斯模糊只关心空间距离,会跨过鼻翼、眼睛、嘴唇等颜色边缘混合,导致五官变糊。双边滤波同时考虑像素距离和颜色差异:空间近且颜色相似的像素权重大,颜色差异明显的像素即使很近也会被抑制。
7.2 空间权重
中心像素为 p,邻域像素为 q,偏移 Δx, Δy。空间距离平方:
ds2=Δx2+Δy2
空间权重:
ws(p,q)=exp(−2σs2ds2)
当前 sigmas=5.5。增大它会让远处像素拥有更高权重、平滑范围更大;过大则使局部结构变平。
7.3 颜色权重
Ip=(Rp,Gp,Bp),Iq=(Rq,Gq,Bq)
颜色距离平方:
dr2=(Rq−Rp)2+(Gq−Gp)2+(Bq−Bp)2
颜色权重:
wr(p,q)=exp(−2σr2dr2)
当前 sigmar=0.12,RGB 范围为 [0,1]。较小值保边更强,但可能把色斑也当作边缘;较大值平滑更强,却容易模糊五官。
7.4 联合权重与归一化
w(p,q)=ws(p,q)wr(p,q)
Bp=∑q∈Ωw(p,q)∑q∈Ωw(p,q)Iq
分母用于归一化,否则不同位置的权重和不同,会改变画面亮度。实现中使用 max(weightSum, 0.0001) 防止除零。
7.5 11×11 采样窗口
半径为 5,横纵偏移均为 [-5,5]:
N=(2r+1)2=(2×5+1)2=121
实际 UV 偏移:
Δuv=(i,j)⊙(W1,H1)k
采样间距 k=2.8,所以最远采样点距离中心约 5×2.8=14 texels。增大间距可在不增加采样数的情况下扩大范围,但过大可能因采样稀疏产生颗粒和不连续。
7.6 GLSL 实现
glsl
vec3 weightedColor = vec3(0.0);
float weightSum = 0.0;
for (int y = -5; y <= 5; ++y) {
for (int x = -5; x <= 5; ++x) {
vec2 offset = vec2(float(x), float(y));
vec2 sampleUV = vTexCoord
+ offset * uTexelSize * 2.8;
vec3 sampleColor = texture(uTexture, sampleUV).rgb;
float spatialDistance = dot(offset, offset);
vec3 colorDelta = sampleColor - sourceColor.rgb;
float colorDistance = dot(colorDelta, colorDelta);
float spatialWeight = exp(
-spatialDistance / (2.0 * 5.5 * 5.5)
);
float rangeWeight = exp(
-colorDistance / (2.0 * 0.12 * 0.12)
);
float weight = spatialWeight * rangeWeight;
weightedColor += sampleColor * weight;
weightSum += weight;
}
}
vec3 bilateralColor = weightedColor / max(weightSum, 0.0001);
dot(offset, offset) 等价于 x2+y2,dot(colorDelta,colorDelta) 等价于 RGB 三通道差值平方和。
7.7 非线性强度
用户强度 (s) 先放大:
s′=clamp(1.25s,0,1)
再应用:
g(s)=1−(1−s′)1.6
这条曲线增强中低档响应。例如 s=0.4,则 s′=0.5:
g(0.4)=1−0.51.6≈0.670
最终局部权重:
Wp=clamp(Mskin(p)g(s),0,1)
若该像素 Mask 为 0.5,则 Wp≈0.335。
7.8 细节回填
双边结果 Bp 是低频颜色,原图细节:
Dp=Ip−Bp
先混合:
Rp′=(1−Wp)Ip+WpBp
再回填 22% 的细节:
Rp=Rp′+0.22WpDp
展开可得:
Rp=(1−0.78Wp)Ip+0.78WpBp
因此细节回填等价于把有效模糊混合量从 Wp 降为 0.78Wp,减轻塑料感。
7.9 Dither 与提前退出
平滑区域在 8-bit 输出中容易出现色带。可生成约一个色阶以内的屏幕空间伪随机噪声:
glsl
float noise = fract(
sin(dot(coordinate, vec2(12.9898, 78.233)))
* 43758.5453
) * (1.0 / 255.0);
以 0 为中心并只加到磨皮区域:
Rp←Rp+(noise−2550.5)Wp
Dither 不增加真实精度,而是把规则量化误差打散为不易察觉的微弱噪声。
若强度或 Mask 小于 0.0001,应立即返回原色,避免脸外像素执行 121 次采样。移动 GPU 上动态分支的收益仍应通过真机 GPU 工具验证。
8. 腮红几何:CPU 上建立脸部局部坐标系
8.1 等比例空间
UV 是单位正方形,但输出往往不是正方形。定义宽高比:
A=HW
将点映射为:
E(x,y)=(Ax,y)
这里以输出高度为统一尺度。横向 UV 差乘 W/H 后,才与纵向 UV 差表示相同的物理比例,避免圆形在宽屏中被拉伸。
8.2 眼睛、嘴部中心与脸部横轴
左眼轮廓中心:
Le=nL1i=1∑nLLi
右眼和嘴部中心同理得到 Re,Cm,双眼中点:
Ce=2Le+Re
整个轮廓的平均值比单个角点稳定,因为单点误差会被部分抵消。
在等比例空间中:
xraw=E(Re)−E(Le)
双眼距离与单位横轴:
de=∥xraw∥,qquadx^=∥xraw∥xraw
当 de 过小时数据退化,应停止后续计算,避免归一化放大误差。
8.3 Gram-Schmidt 构造纵轴
从双眼中点指向嘴部中心:
yraw=E(Cm)−E(Ce)
它不一定和横轴垂直。先求横向投影:
p=yraw⋅x^
移除投影:
yorth=yraw−px^
归一化:
y^=∥yorth∥yorth
这就是二维 Gram-Schmidt 正交化。若 haty⋅yraw<0,则翻转 haty,确保纵轴朝向嘴部。若向量退化,则回退到 (−x^y,x^x)。相比简单旋转横轴 90°,该方法更能跟随轻微透视、抬头和低头。
8.4 苹果肌中心
设左右脸颊外侧点为 CL,CR,鼻尖为 N。线性插值:
lerp(a,b,t)=(1−t)a+tb
先向鼻尖收拢 28%:
CL′=lerp(CL,N,0.28),CR′=lerp(CR,N,0.28)
再向对应眼睛抬高 30%:
BL=lerp(CL′,Le,0.30),BR=lerp(CR′,Re,0.30)
这两个比例是审美参数:增大 0.28 会更靠近面中,增大 0.30 会更靠近眼下。
8.5 椭圆半径
脸颊到鼻尖跨度:
sL=∥E(N)−E(CL)∥,qquadsR=∥E(N)−E(CR)∥
横向半径:
rx,L=max(0.52sL,0.18de)
rx,R=max(0.52sR,0.18de)
第一项随半张脸可见跨度变化,第二项提供基于双眼距离的最小宽度,防止侧脸远侧被压得过窄。
纵向跨度:
sv=∣(E(Cm)−E(Ce))⋅y^∣
纵向半径:
ry=max(0.15de,0.22sv)
8.6 侧脸可见度
正脸时 sL,sR 接近;侧脸时远侧跨度缩短。令:
smax=max(sL,sR,ε)
任意一侧的归一化比例:
qi=clamp(smaxsi,0,1)
可见度:
oi=max(qi,0.35)0.75
最低值约为 0.350.75≈0.455。指数小于 1 会抬高中间值,让远侧减弱但不突然消失。
9. 腮红 Mask:GPU 上的旋转椭圆高斯
9.1 局部坐标与椭圆距离
当前输出 UV 为 u,腮红中心为 c。先进入等比例空间:
Δ=(u−c)⊙(A,1)
投影到脸部局部轴:
xl=Δ⋅x^,yl=Δ⋅y^
除以半径:
xn=rxxl,yn=ryyl
椭圆标准化距离平方:
d2=xn2+yn2
当 d=1 时位于标准椭圆边界,小于 1 在内部,大于 1 在外部。GLSL 中应使用 max(radius,0.0001) 防止无效半径除零。
9.2 高斯衰减
G(d)=exp(−kd2),k=2.5
| 标准化距离 (d) | 权重 |
|---|---|
| 0 | 1.000 |
| 0.5 | 0.535 |
| 1.0 | 0.082 |
| 1.5 | 0.004 |
数学椭圆边界并非硬边,在 d=1 时仍有约 8.2% 权重。增大 k 会让颜色更集中,减小则扩散更广。
左右分别乘可见度后取最大值:
Grouge=max(GLoL,GRoR)
取 max 而非相加,可避免两侧高斯尾部在鼻部重叠增亮。最后乘皮肤 Mask:
Gskin=GrougeMskin
腮红因此自动避开脸外、眼睛和嘴唇。
10. 腮红颜色:亮度保护与 Soft Light
10.1 亮度保护
普通 Alpha Blend 会把皮肤直接拉向固定粉色,容易遮盖明暗和纹理。先计算工程近似亮度:
Y=0.299R+0.587G+0.114B
阴影保护:
Ps=smoothstep(0.10,0.30,Y)
当 Y≤0.10 时不上腮红; Y≥0.30 时完整保留;中间平滑过渡。
高光保护:
Ph=1−smoothstep(0.85,0.98,Y)
极亮区域权重逐渐降到 0,保留苹果肌高光与立体感。
设用户强度为 sr,最终腮红权重:
Mr=GskinPsPhclamp(sr,0,1)
四个因子分别控制位置、阴影、高光和用户强度,任意一项接近 0 都会抑制最终效果。
10.2 Soft Light
设单通道底色为 b,混合色为 c。当 c<0.5:
SL(b,c)=2bc+b2(1−2c)
当 c≥0.5:
SL(b,c)=max(b,0) (2c−1)+2b(1−c)
逐通道应用到 RGB。腮红颜色为:
C=(0.95,0.43,0.50)
红色通道倾向提亮和增强红色,绿色略被压低,蓝色接近中性,组合形成暖珊瑚粉,同时保留底色结构。
最终输出:
O=mix(B,SL(B,C),Mr)
即:
O=(1−Mr)B+MrSL(B,C)
10.3 GLSL 重载
glsl
float blendSoftLight(float base, float blend) {
if (blend < 0.5) {
return 2.0 * base * blend
+ base * base * (1.0 - 2.0 * blend);
}
return sqrt(max(base, 0.0)) * (2.0 * blend - 1.0)
+ 2.0 * base * (1.0 - blend);
}
vec3 blendSoftLight(vec3 base, vec3 blend) {
return vec3(
blendSoftLight(base.r, blend.r),
blendSoftLight(base.g, blend.g),
blendSoftLight(base.b, blend.b)
);
}
float 与 vec3 两个重载版本让三个颜色通道明确执行同一公式。
11. 为什么先磨皮、后腮红
glsl
vec3 finalColor = applySmooth(sourceColor, faceMask);
finalColor = applyRouge(finalColor, faceMask);
如果先画腮红,后续双边滤波会把腮红颜色向周围扩散、削弱强度,并使磨皮滑块间接改变腮红表现。先磨皮再上妆,可以让两个功能相对解耦,腮红亮度保护判断的也是最终皮肤底色。
完整流程:
glsl
void main() {
vec4 source = texture(uTexture, vTexCoord);
if (uHasFace < 0.5) {
fragColor = source;
return;
}
float faceMask = texture(
uMaskTexture,
vOutputTexCoord
).r;
vec3 color = applySmooth(source, faceMask);
color = applyRouge(color, faceMask);
fragColor = vec4(clamp(color, 0.0, 1.0), source.a);
}
最后 clamp 防止 Soft Light、细节回填和 Dither 让颜色超出 [0,1]。
12. CPU/GPU 协同:实时性与线程安全
12.1 检测不能阻塞渲染
串行执行"等待检测 → 渲染"会把模型耗时直接变成预览延迟。更合理的结构是异步检测,渲染每帧读取最新结果:
text
Frame N ──→ 异步检测 ─────────→ Result N
└──→ 使用 Latest Result 渲染
Frame N+1 → 异步检测 ─────────→ Result N+1
└──→ 使用 Latest Result 渲染
Landmark 可能比相机帧旧一小段时间,但经过平滑后通常比停下等待检测更可接受。
12.2 不可变快照与锁
每次检测完成后创建完整人脸快照,包含映射后的轮廓、bounds、鼻尖、脸颊和时间戳,创建后不再修改。渲染线程因此不会读到只更新一半的数据。
锁只保护最新快照、磨皮强度、腮红强度和羽化宽度。锁内复制引用和值,随后立刻解锁;腮红几何和 GPU draw 必须在锁外完成,避免长时间阻塞检测与 UI 线程。
12.3 GL 串行队列与 FBO 生命周期
OpenGL program、Texture、FBO 的创建、绑定、绘制和释放应在持有正确 Context 的串行队列执行,避免两帧同时修改 program、resize 与 draw 冲突,或 FBO 被提前回收。
一帧 Mask 的典型生命周期:
- 从 framebuffer cache 获取 FBO;
- 绑定并清屏为 0;
- 上传轮廓、尺寸和 feather uniform;
- 绘制生成 Mask Texture;
- 把它绑定到效果 Shader;
- 绘制磨皮和腮红;
- 当前 draw 消费完成后解锁 FBO;
- FBO 回到缓存供后续帧复用。
缓存避免每帧重复创建纹理,减少分配开销和显存碎片。
12.4 人脸消失
检测失败或人脸离开时,应清空快照、重置平滑历史,并将 uHasFace 置 0。Shader 立即返回原图,避免旧效果残留,也跳过后续计算。
13. 性能分析与优化方向
13.1 Mask Pass
Mask 对每个像素遍历:
36+16+16+20=88
条边。1080 × 1920 约有 207 万像素,理论边处理次数:
2,073,600×88≈182,476,800
这不是精确 GPU 指令数,但能说明全分辨率 Mask 的成本。可优化为:只绘制人脸 bounds、使用 1/2 或 1/4 Mask、三角网格光栅化、Stencil,或在几何变化很小时复用 Mask。低分辨率 Mask 通常性价比较高,因为 Mask 已经羽化,不需要与原图一样清晰。
13.2 双边滤波 Pass
每个参与磨皮的像素有 121 次采样。若人脸覆盖画面 30%,1080p 纵向帧约有:
2,073,600×0.30×121≈75,263,000
次纹理读取。可考虑降采样滤波、十字或泊松采样核、设备分级半径,或者迁移到 Metal Compute,使用 threadgroup memory 复用邻域数据。
双边滤波不能像普通高斯一样严格分离,因为颜色权重依赖中心与邻域颜色;横纵两次滤波只能是质量换性能的近似。
13.3 Uniform 与多人脸
轮廓通过 uniform 数组上传简单直观,但 uniform 数量有限。多人脸时可使用 UBO、landmark 数据纹理、CPU 顶点网格,或者每张脸单独 draw 并在 Mask FBO 合并。
性能判断应结合 Xcode GPU Frame Capture、真机帧时间和 GPU 工具,区分算术受限、纹理带宽受限与 CPU/GPU 同步等待,而不是只凭循环数量猜测。
14. 两个容易忽略的时序问题
14.1 异步结果与提交帧关联
Result N 返回时,相机可能已到 Frame N+2。如果回调读取"当前最新帧"的尺寸、镜像和方向,就可能用 N+2 的元数据映射 N 的 landmark。在摄像头切换、方向变化、分辨率变化或预览 resize 时会瞬间错位。
可维护小型环形缓冲区:
text
timestamp → sourceSize, mirrored, orientation, targetSize
结果返回时按 timestamp 找回提交时的元数据。
14.2 CMTime.value 不等于毫秒
CMTime 的真实秒数为:
t=timescalevalue
例如 value=3000, timescale=600,真实时间是 5 秒,而不是 3000 毫秒。毫秒应显式转换:
tms=timescalevalue×1000
Swift:
swift
let milliseconds = Int(
CMTimeGetSeconds(timestamp) * 1000.0
)
let monotonicTimestamp = max(
milliseconds,
lastTimestamp + 1
)
单调保护只能处理重复或倒退,不能修正错误的时间单位。
15. 参数调节指南
| 参数 | 当前值 | 增大后的效果 | 过大风险 |
|---|---|---|---|
| Landmark 平滑响应 | 0.62 | 更跟手 | 抖动增加 |
| 快速运动响应 | 0.82 | 转头延迟更低 | 瞬时噪声明显 |
| Mask 羽化 | 8 px | 边缘更柔和 | 效果范围收缩 |
| 空间 sigma | 5.5 | 平滑范围更大 | 局部结构变平 |
| 颜色 sigma | 0.12 | 更易跨颜色平滑 | 五官边缘模糊 |
| 采样间距 | 2.8 | 覆盖范围更大 | 采样稀疏 |
| 细节回填 | 0.22 | 保留更多纹理 | 磨皮减弱 |
| 腮红高斯系数 | 2.5 | 腮红更集中 | 中心像色块 |
| 向鼻尖收拢 | 0.28 | 更靠近面中 | 靠鼻翼过近 |
| 向眼睛抬高 | 0.30 | 更靠近眼下 | 侵入下眼睑 |
调参应一次只改一类变量:先固定颜色,调位置与半径;几何稳定后,再调高斯与混合强度。否则很难判断变化来自位置、范围还是颜色。
16. 效果
使用前:

使用后:

使用前:

使用后:

17. 总结
实时人脸美颜不是单个 Shader,而是一套从视觉检测到像素合成的系统:
- Face Landmark 把图像转换为可计算的人脸几何;
- 坐标变换让 landmark 与旋转、镜像、裁剪后的输出严格对齐;
- 时序平滑降低检测抖动,并在快速运动时提高响应;
- 多边形 Mask 把离散轮廓转换成连续皮肤权重,同时避开五官;
- 双边滤波使用空间与颜色权重平滑皮肤并保留边缘;
- 非线性强度、细节回填和 Dither 改善磨皮质感;
- 面部局部坐标系让腮红随脸旋转、缩放和侧转;
- 高斯衰减、亮度保护和 Soft Light 保留皮肤纹理与立体感;
- 异步检测、不可变快照、GL 串行队列和 FBO 缓存保证实时性。
真正决定效果稳定性的,往往不是某一个更复杂的滤镜公式,而是所有环节是否使用同一套空间和时间语义。Landmark、Mask、Camera Texture 与最终输出只要有一个坐标系不一致,后续算法再高级也无法正确贴合;检测、渲染和纹理生命周期只要有一环阻塞,视觉质量再高也无法成为可用的实时功能。