「Landmark-Driven Beauty Rendering:实时磨皮与腮红的算法与 GPU 实现」

在实时相机中实现美颜,真正困难的地方通常不是"把皮肤模糊一点"或者"在脸颊上叠一层粉色"。困难在于,画面中的脸会移动、旋转、侧转,摄像头还可能发生镜像、裁剪和方向变换;与此同时,算法必须在每秒几十帧的预算内完成,不能阻塞相机预览。

一套完整的实时美颜链路,至少需要解决下面几个问题:

  1. 如何从相机画面中检测出稳定的人脸关键点;
  2. 如何把检测坐标准确映射到最终显示画面;
  3. 如何生成只覆盖皮肤、避开眼睛和嘴唇的人脸 Mask;
  4. 如何在保留五官边缘的同时平滑皮肤;
  5. 如何让腮红跟随脸部旋转、缩放和侧脸透视;
  6. 如何让 CPU 检测与 GPU 渲染并行工作,而不是互相等待;
  7. 如何控制纹理采样数量、显存占用和每帧延迟。

本文会从一帧相机图像出发,逐步推导 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 设置的数据;
  • vec2vec3vec4:包含 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=1W,Δv=1H \Delta u=\frac{1}{W},\qquad \Delta v=\frac{1}{H} Δu=W1,Δv=H1

因此,向右移动 3 个真实像素的采样坐标为:
(u′,v′)=(u,v)+ (31W,0) (u',v')=(u,v)+\left(3\frac{1}{W},0\right) (u′,v′)=(u,v)+(3W1,0)

2.4 FBO:把渲染结果继续当作纹理使用

默认情况下,GPU 把结果画到屏幕。但多级滤镜需要把中间结果保存下来,例如:

  1. 第一次 draw 生成 Face Mask;
  2. 第二次 draw 同时读取 Camera Texture 和 Mask Texture;
  3. 第二次 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 =T uvoutput \mathbf{uv}{camera}=T\mathbf{uv}{output} uvcamera=Tuvoutput

其中 (T) 是纹理变换矩阵。如果用 vTexCoord 去采样 Mask,方向变化时 Mask 可能再次被镜像或旋转;如果用 vOutputTexCoord 读取 Camera Texture,画面方向又会错误。二者必须严格区分。


3. Face Landmark:从图像到人脸几何

3.1 Landmark 是什么

Face Landmark 是分布在人脸结构上的一组语义点。不同索引通常对应固定语义,例如脸部外轮廓、上下眼睑、嘴唇轮廓、鼻尖与脸颊。

检测模型输出的通常不是屏幕像素,而是归一化坐标:
Li=(xi,yi,zi) L_i=(x_i,y_i,z_i) Li=(xi,yi,zi)

其中 xi,yi x_i,y_i xi,yi 一般位于 [0,1] 附近, zi z_i zi 描述相对深度。当前磨皮和腮红主要使用二维投影 xi,yi x_i,y_i xi,yi。归一化坐标与输入分辨率无关,例如 0.5,0.50.5,0.5 0.5,0.5始终表示图像中心。

3.2 检测、跟踪与 Landmark 回归

从应用层看,人脸关键点接口通常完成三类工作:

  1. Face Detection:在整张图像中寻找人脸候选区域;
  2. Face Tracking:连续视频中利用上一帧位置减少重复搜索;
  3. Landmark Regression:在人脸区域中预测每个语义点的位置。

模型内部如何实现由具体模型决定。美颜模块不应依赖内部网络结构,而应只依赖稳定的输出协议:landmark 数组、时间戳和检测是否有效。

只处理一张脸可以显著降低后续 Mask 和美颜成本。如果支持多人,需要为每张脸生成独立几何,并解决 Mask 合并、遮挡和总采样成本问题。

3.3 为什么只抽取部分点

检测器可能输出数百个点,但磨皮和腮红不需要全部使用。当前流程抽取 36 个脸部外轮廓点、左右眼各 16 点、外唇 20 点,以及鼻尖和左右脸颊参考点。

脸部外轮廓用于限定皮肤范围;眼睛和嘴唇轮廓用于从皮肤 Mask 中挖孔;鼻尖、脸颊和眼睛中心用于构造腮红。只保留必要点还能减少跨线程传输、CPU 平滑和 GPU uniform 数量。


4. 坐标变换:为什么 Landmark 正确却贴不上脸

人脸效果错位最常见的原因,不是检测错误,而是 landmark 和最终画面没有经历相同的旋转、镜像与裁剪。

4.1 旋转映射

设模型输出点为 Pm=(xm,ym) P_m=(x_m,y_m) Pm=(xm,ym)。当前方向变换使用:
xr=1−ym,yr=1−xm x_r=1-y_m,\qquad y_r=1-x_m xr=1−ym,yr=1−xm

90° 旋转会交换 x、y;为了让结果继续落在 [0,1] 内并匹配纹理方向,还要对相应轴执行 t→1−tt\rightarrow1-t t→1−t。最终使用哪套顺逆时针公式,取决于 PixelBuffer 方向、模型输入 orientation 和顶点纹理变换,必须以实际渲染链为准。

4.2 镜像与 Y 轴翻转

水平镜像为:
x′=1−x,y′=yx'=1-x,\qquad y'=y x′=1−x,y′=y

例如 (x=0.42) 镜像后变为 (0.58)。需要区分相机数据是否已经镜像、预览是否翻转、模型看到的方向,避免重复镜像。

当两个坐标系的 Y 方向相反时:
y′=1−yy'=1-y y′=1−y

是否翻转不是由 UIKit 或 OpenGL 的名字决定,而是由 landmark 最终需要对齐的纹理空间决定。

4.3 Aspect Fill 裁剪

设旋转后源画面与目标预览的宽高比分别为:
As= WsHs ,At= WtHt A_s=\frac{W_s}{H_s},\qquad A_t=\frac{W_t}{H_t} As=HsWs,At=HtWt

Aspect Fill 会等比例放大源图直到完全覆盖目标,多余部分被裁掉。

As>At A_s>A_t As>At 时,源画面更宽,左右裁剪。可见宽度比例:
wc= AtAs w_c=\frac{A_t}{A_s} wc=AsAt

左边界:
cx= 1−wc 2 c_x=\frac{1-w_c}{2} cx=21−wc

裁剪矩形为 C=(cx,0,wc,1) C=(c_x,0,w_c,1) C=(cx,0,wc,1)。

As≤At A_s\le A_t As≤At 时,源画面更高,上下裁剪:
hc= AsAt ,cy= 1−hc 2 h_c=\frac{A_s}{A_t},\qquad c_y=\frac{1-h_c}{2} hc=AtAs,cy=21−hc

裁剪矩形为 C=(0,cy,1,hc)C=(0,c_y,1,h_c) C=(0,cy,1,hc)。

设方向变换后的点为 xr,yr x_r,y_r xr,yr,裁剪矩形为 cx,cy,cw,ch c_x,c_y,c_w,c_h cx,cy,cw,ch,最终输出 UV 为:
u= xr−cx cw ,v= yr−cy ch u=\frac{x_r-c_x}{c_w},\qquad v=\frac{y_r-c_y}{c_h} u=cwxr−cx,v=chyr−cy

含义是先减去可见区域起点,再除以可见区域尺寸,把裁剪区域重新映射到 [0,1]

数值例子

源比例为 16:9,目标为 4:3
As≈1.778,At≈1.333 A_s\approx1.778,\qquad A_t\approx1.333 As≈1.778,At≈1.333
wc=1.3331.778≈0.75,cx= 1−0.752 =0.125 w_c=\frac{1.333}{1.778}\approx0.75,\qquad c_x=\frac{1-0.75}{2}=0.125 wc=1.7781.333≈0.75,cx=21−0.75=0.125

源画面左右各裁掉 12.5%。原坐标 xr=0.5 x_r=0.5 xr=0.5 仍映射到输出中心:
u= 0.5−0.1250.75 =0.5 u=\frac{0.5-0.125}{0.75}=0.5 u=0.750.5−0.125=0.5

xr=0.125 x_r=0.125 xr=0.125 正好映射到输出左边缘 (u=0)。


5. 时序平滑:让 Landmark 不抖,也不拖尾

5.1 指数平滑公式

即使人脸静止,传感器噪声、曝光变化与模型误差也会让 landmark 轻微抖动。对当前检测点 Ptdetect P_t{detect} Ptdetect 和上一帧平滑结果 Pt−1 smooth P_{t-1}{smooth} Pt−1smooth,使用:
Ptsmooth =(1−α) Pt−1smooth +α Ptdetect P_t^{smooth}=(1-\alpha)P_{t-1}^{smooth}+\alpha P_t^{detect} Ptsmooth=(1−α)Pt−1smooth+αPtdetect

等价写法为:
Ptsmooth = Pt−1smooth +α( Ptdetect − Pt−1smooth ) P_t^{smooth}=P_{t-1}^{smooth}+ \alpha(P_t^{detect}-P_{t-1}^{smooth}) Ptsmooth=Pt−1smooth+α(Ptdetect−Pt−1smooth)

第二种写法表示从旧位置向新检测位置移动 α\alpha α 比例。基础响应系数为 α=0.62\alpha=0.62 α=0.62,即新结果占 62%,历史占 38%。 α\alpha α 越小越稳定但拖尾越明显,越大越跟手但更容易抖。

5.2 根据运动速度自适应

固定系数不能同时满足静止和快速转头。先计算人脸框中心:
Ct= (xt+ wt2 ,yt+ ht2 ) C_t=\left(x_t+\frac{w_t}{2},y_t+\frac{h_t}{2}\right) Ct=(xt+2wt,yt+2ht)

连续结果的归一化位移:
d= ( Ct,x − Ct−1,x )2+( Ct,y − Ct−1,y )2 d=\sqrt{(C_{t,x}-C_{t-1,x})^2+(C_{t,y}-C_{t-1,y})^2} d=(Ct,x−Ct−1,x)2+(Ct,y−Ct−1,y)2

d>0.035d>0.035 d>0.035 时,把响应提高到至少 α=0.82\alpha=0.82 α=0.82。明显移动时,新检测结果权重从 62% 提升到 82%,可以减少腮红追着脸移动的感觉。

5.3 为什么所有几何都要一起平滑

脸框、脸部轮廓、眼睛、嘴巴、鼻尖和脸颊点必须使用同一响应系数。如果只平滑其中一部分,不同几何会处在不同时间状态,出现 Mask 已移动而腮红仍停留在旧位置的现象。

第一次检测到脸、时间戳乱序、结果间隔超过 250 ms、人脸消失后重现、摄像头或方向切换时,应直接接受新结果并重置历史,避免新脸从旧位置缓慢滑入。


6. Face Mask:从离散轮廓到连续皮肤权重

6.1 Mask 的目标

理想 Mask 应让脸外、眼睛和嘴唇内部为 0,皮肤内部接近 1,所有边界平滑过渡:
M(p)∈0,1M(p)\in0,1 M(p)∈0,1

最终效果通过:
O(p)=(1−M(p))I(p)+M(p)E(p)O(p)=(1-M(p))I(p)+M(p)E(p) O(p)=(1−M(p))I(p)+M(p)E(p)

混合原图 (I) 与效果图 (E)。如果只有硬边 0/1,磨皮会像剪纸一样突然终止。

6.2 射线奇偶规则

脸、眼睛和嘴部都表示成闭合多边形。对像素 p=(px,py) p=(p_x,p_y) p=(px,py) 向水平方向发射射线:相交奇数次表示内部,偶数次表示外部。

边端点为 a=(x0,y0) a=(x_0,y_0) a=(x0,y0)、 b=(x1,y1) b=(x_1,y_1) b=(x1,y1)。先判断边是否跨过像素水平线:
(y0>py)≠(y1>py) (y_0>p_y)\ne(y_1>p_y) (y0>py)=(y1>py)

若成立,交点横坐标为:
xe=x0+(x1−x0) py−y0 y1−y0 x_e=x_0+(x_1-x_0)\frac{p_y-y_0}{y_1-y_0} xe=x0+(x1−x0)y1−y0py−y0

px<xe p_x<x_e px<xe 时翻转 insideinside inside 状态。GLSL 实现需要用很小的 ε\varepsilon ε 防止水平边产生除零与 NaN

6.3 点到线段的最短距离

羽化需要知道像素距边界多远。线段方向 v=b−av=b-a v=b−a,点相对起点向量 w=p−aw=p-a w=p−a。投影参数:
t0= w⋅vv⋅v t_0=\frac{w\cdot v}{v\cdot v} t0=v⋅vw⋅v

将其截断到线段范围:
t=clamp⁡(t0,0,1)t=\operatorname{clamp}(t_0,0,1) t=clamp(t0,0,1)

线段最近点:
q=a+tvq=a+tv q=a+tv

距离:
d(p, ab‾ )=∥p−q∥ d(p,\overline{ab})=\|p-q\| d(p,ab)=∥p−q∥

循环中可以先比较距离平方 (p−q)⋅(p−q)(p-q)\cdot(p-q) (p−q)⋅(p−q),遍历所有边取最小值后只执行一次 sqrt

6.4 为什么要在像素空间算距离

1080 × 1920 图像中:
1 px horizontal=11080,1 px vertical=11920 1\text{ px horizontal}=\frac1{1080},\qquad 1\text{ px vertical}=\frac1{1920} 1 px horizontal=10801,1 px vertical=19201

同一个 UV 距离在两个方向并不代表相同像素长度。因此先转换:
ppx = puv ⊙(W,H) p_{px}=p_{uv}\odot(W,H) ppx=puv⊙(W,H)

轮廓点也做相同转换。这样 8 px 羽化才不会随分辨率或方向失真。

6.5 smoothstep 羽化

smoothstep(e_0,e_1,x) 先计算:
t=clamp⁡ ( x−e0 e1−e0 ,0,1) t=\operatorname{clamp}\left(\frac{x-e_0}{e_1-e_0},0,1\right) t=clamp(e1−e0x−e0,0,1)

再使用三次 Hermite 曲线:
S(t)=t2(3−2t)S(t)=t^2(3-2t) S(t)=t2(3−2t)

曲线在两端一阶导数为 0,比线性插值平滑。

保留脸部内部:
Minside = Iinside ⋅S ( dmin F ) M_{inside}=I_{inside}\cdot S\left(\frac{d_{min}}{F}\right) Minside=Iinside⋅S(Fdmin)

保留眼睛和嘴部外部:
Moutside =(1− Iinside )⋅S ( dmin F ) M_{outside}=(1-I_{inside})\cdot S\left(\frac{d_{min}}{F}\right) Moutside=(1−Iinside)⋅S(Fdmin)

其中 FF F 是羽化宽度。边界距离为 0,权重为 0;向保留区域深入超过 FF F 后权重达到 1。

6.6 最终皮肤 Mask

Mskin = Mface ⋅ MleftEyeOut ⋅ MrightEyeOut ⋅ MmouthOut M_{skin}=M_{face}\cdot M_{leftEyeOut}\cdot M_{rightEyeOut}\cdot M_{mouthOut} Mskin=Mface⋅MleftEyeOut⋅MrightEyeOut⋅MmouthOut

使用乘法意味着任一项为 0,结果都为 0,且羽化小数可以自然组合。例如脸内权重为 1,左眼外权重为 0.4,其他为 1,则最终 Mask 为 0.4,效果在眼边只应用 40%。


7. 双边滤波磨皮:平滑皮肤但保留边缘

7.1 为什么不用普通高斯模糊

均值和高斯模糊只关心空间距离,会跨过鼻翼、眼睛、嘴唇等颜色边缘混合,导致五官变糊。双边滤波同时考虑像素距离和颜色差异:空间近且颜色相似的像素权重大,颜色差异明显的像素即使很近也会被抑制。

7.2 空间权重

中心像素为 pp p,邻域像素为 qq q,偏移 Δx\Delta x Δx, Δy\Delta y Δy。空间距离平方:
ds2=Δx2+Δy2 d_s^2=\Delta x^2+\Delta y^2 ds2=Δx2+Δy2

空间权重:
ws(p,q)=exp⁡ (− ds2 2σs2 ) w_s(p,q)=\exp\left(-\frac{d_s^2}{2\sigma_s^2}\right) ws(p,q)=exp(−2σs2ds2)

当前 sigmas=5.5 sigma_s=5.5 sigmas=5.5。增大它会让远处像素拥有更高权重、平滑范围更大;过大则使局部结构变平。

7.3 颜色权重

Ip=(Rp,Gp,Bp),Iq=(Rq,Gq,Bq) I_p=(R_p,G_p,B_p),\qquad I_q=(R_q,G_q,B_q) Ip=(Rp,Gp,Bp),Iq=(Rq,Gq,Bq)

颜色距离平方:
dr2=(Rq−Rp)2+(Gq−Gp)2+(Bq−Bp)2 d_r^2=(R_q-R_p)^2+(G_q-G_p)^2+(B_q-B_p)^2 dr2=(Rq−Rp)2+(Gq−Gp)2+(Bq−Bp)2

颜色权重:
wr(p,q)=exp⁡ (− dr2 2σr2 ) w_r(p,q)=\exp\left(-\frac{d_r^2}{2\sigma_r^2}\right) wr(p,q)=exp(−2σr2dr2)

当前 sigmar=0.12 sigma_r=0.12 sigmar=0.12,RGB 范围为 [0,1]。较小值保边更强,但可能把色斑也当作边缘;较大值平滑更强,却容易模糊五官。

7.4 联合权重与归一化

w(p,q)=ws(p,q)wr(p,q)w(p,q)=w_s(p,q)w_r(p,q) w(p,q)=ws(p,q)wr(p,q)
Bp= ∑q∈Ω w(p,q)Iq ∑q∈Ω w(p,q) B_p=\frac{\sum_{q\in\Omega}w(p,q)I_q} {\sum_{q\in\Omega}w(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=121N=(2r+1)^2=(2\times5+1)^2=121 N=(2r+1)2=(2×5+1)2=121

实际 UV 偏移:
Δuv=(i,j)⊙ (1W,1H) k\Delta uv=(i,j)\odot\left(\frac1W,\frac1H\right)k Δuv=(i,j)⊙(W1,H1)k

采样间距 k=2.8k=2.8 k=2.8,所以最远采样点距离中心约 5×2.8=145\times2.8=14 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+y2x^2+y^2 x2+y2,dot(colorDelta,colorDelta) 等价于 RGB 三通道差值平方和。

7.7 非线性强度

用户强度 (s) 先放大:
s′=clamp⁡(1.25s,0,1)s'=\operatorname{clamp}(1.25s,0,1) s′=clamp(1.25s,0,1)

再应用:
g(s)=1−(1−s′)1.6g(s)=1-(1-s')^{1.6} g(s)=1−(1−s′)1.6

这条曲线增强中低档响应。例如 s=0.4s=0.4 s=0.4,则 s′=0.5s'=0.5 s′=0.5:
g(0.4)=1−0.51.6≈0.670g(0.4)=1-0.5^{1.6}\approx0.670 g(0.4)=1−0.51.6≈0.670

最终局部权重:
Wp=clamp⁡( Mskin (p)g(s),0,1) W_p=\operatorname{clamp}(M_{skin}(p)g(s),0,1) Wp=clamp(Mskin(p)g(s),0,1)

若该像素 Mask 为 0.5,则 Wp≈0.335 W_p\approx0.335 Wp≈0.335。

7.8 细节回填

双边结果 Bp B_p Bp 是低频颜色,原图细节:
Dp=Ip−Bp D_p=I_p-B_p Dp=Ip−Bp

先混合:
Rp′=(1−Wp)Ip+WpBp R'_p=(1-W_p)I_p+W_pB_p Rp′=(1−Wp)Ip+WpBp

再回填 22% 的细节:
Rp=Rp′+0.22WpDp R_p=R'_p+0.22W_pD_p Rp=Rp′+0.22WpDp

展开可得:
Rp=(1−0.78Wp)Ip+0.78WpBp R_p=(1-0.78W_p)I_p+0.78W_pB_p Rp=(1−0.78Wp)Ip+0.78WpBp

因此细节回填等价于把有效模糊混合量从 Wp W_p Wp 降为 0.78Wp 0.78W_p 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−0.5255)Wp R_p\leftarrow R_p+\left(noise-\frac{0.5}{255}\right)W_p Rp←Rp+(noise−2550.5)Wp

Dither 不增加真实精度,而是把规则量化误差打散为不易察觉的微弱噪声。

若强度或 Mask 小于 0.0001,应立即返回原色,避免脸外像素执行 121 次采样。移动 GPU 上动态分支的收益仍应通过真机 GPU 工具验证。


8. 腮红几何:CPU 上建立脸部局部坐标系

8.1 等比例空间

UV 是单位正方形,但输出往往不是正方形。定义宽高比:
A=WH A=\frac{W}{H} A=HW

将点映射为:
E(x,y)=(Ax,y)E(x,y)=(Ax,y) E(x,y)=(Ax,y)

这里以输出高度为统一尺度。横向 UV 差乘 W/HW/H W/H 后,才与纵向 UV 差表示相同的物理比例,避免圆形在宽屏中被拉伸。

8.2 眼睛、嘴部中心与脸部横轴

左眼轮廓中心:
Le= 1nL ∑i=1nL Li L_e=\frac1{n_L}\sum_{i=1}^{n_L}L_i Le=nL1i=1∑nLLi

右眼和嘴部中心同理得到 Re,Cm R_e,C_m Re,Cm,双眼中点:
Ce= Le+Re 2 C_e=\frac{L_e+R_e}{2} Ce=2Le+Re

整个轮廓的平均值比单个角点稳定,因为单点误差会被部分抵消。

在等比例空间中:
xraw =E(Re)−E(Le) x_{raw}=E(R_e)-E(L_e) xraw=E(Re)−E(Le)

双眼距离与单位横轴:
de=∥ xraw ∥,qquadx^= xraw ∥ xraw ∥ d_e=\|x_{raw}\|,qquad \hat{x}=\frac{x_{raw}}{\|x_{raw}\|} de=∥xraw∥,qquadx^=∥xraw∥xraw

de d_e de 过小时数据退化,应停止后续计算,避免归一化放大误差。

8.3 Gram-Schmidt 构造纵轴

从双眼中点指向嘴部中心:
yraw =E(Cm)−E(Ce) y_{raw}=E(C_m)-E(C_e) yraw=E(Cm)−E(Ce)

它不一定和横轴垂直。先求横向投影:
p= yraw ⋅x^ p=y_{raw}\cdot\hat{x} p=yraw⋅x^

移除投影:
yorth = yraw −px^ y_{orth}=y_{raw}-p\hat{x} yorth=yraw−px^

归一化:
y^= yorth ∥ yorth ∥ \hat{y}=\frac{y_{orth}}{\|y_{orth}\|} y^=∥yorth∥yorth

这就是二维 Gram-Schmidt 正交化。若 haty⋅ yraw <0hat{y}\cdot y_{raw}<0 haty⋅yraw<0,则翻转 hatyhat{y} haty,确保纵轴朝向嘴部。若向量退化,则回退到 (− x^y , x^x ) (-\hat{x}_y,\hat{x}_x) (−x^y,x^x)。相比简单旋转横轴 90°,该方法更能跟随轻微透视、抬头和低头。

8.4 苹果肌中心

设左右脸颊外侧点为 CL,CR C_L,C_R CL,CR,鼻尖为 NN N。线性插值:
lerp⁡(a,b,t)=(1−t)a+tb\operatorname{lerp}(a,b,t)=(1-t)a+tb lerp(a,b,t)=(1−t)a+tb

先向鼻尖收拢 28%:
CL′=lerp⁡(CL,N,0.28),CR′=lerp⁡(CR,N,0.28) C'_L=\operatorname{lerp}(C_L,N,0.28),\qquad C'_R=\operatorname{lerp}(C_R,N,0.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) B_L=\operatorname{lerp}(C'_L,L_e,0.30),\qquad B_R=\operatorname{lerp}(C'_R,R_e,0.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)∥ s_L=\|E(N)-E(C_L)\|,qquad s_R=\|E(N)-E(C_R)\| sL=∥E(N)−E(CL)∥,qquadsR=∥E(N)−E(CR)∥

横向半径:
rx,L =max⁡(0.52sL,0.18de) r_{x,L}=\max(0.52s_L,0.18d_e) rx,L=max(0.52sL,0.18de)
rx,R =max⁡(0.52sR,0.18de) r_{x,R}=\max(0.52s_R,0.18d_e) rx,R=max(0.52sR,0.18de)

第一项随半张脸可见跨度变化,第二项提供基于双眼距离的最小宽度,防止侧脸远侧被压得过窄。

纵向跨度:
sv= ∣ (E(Cm)−E(Ce)) ⋅y^∣ s_v=\left|\left(E(C_m)-E(C_e)\right)\cdot\hat{y}\right| sv=∣(E(Cm)−E(Ce))⋅y^∣

纵向半径:
ry=max⁡(0.15de,0.22sv) r_y=\max(0.15d_e,0.22s_v) ry=max(0.15de,0.22sv)

8.6 侧脸可见度

正脸时 sL,sR s_L,s_R sL,sR 接近;侧脸时远侧跨度缩短。令:
smax =max⁡(sL,sR,ε) s_{max}=\max(s_L,s_R,\varepsilon) smax=max(sL,sR,ε)

任意一侧的归一化比例:
qi=clamp⁡ ( si smax ,0,1) q_i=\operatorname{clamp}\left(\frac{s_i}{s_{max}},0,1\right) qi=clamp(smaxsi,0,1)

可见度:
oi=max⁡(qi,0.35)0.75 o_i=\\max(q_i,0.35)^{0.75} oi=max(qi,0.35)0.75

最低值约为 0.350.75≈0.4550.35^{0.75}\approx0.455 0.350.75≈0.455。指数小于 1 会抬高中间值,让远侧减弱但不突然消失。


9. 腮红 Mask:GPU 上的旋转椭圆高斯

9.1 局部坐标与椭圆距离

当前输出 UV 为 uu u,腮红中心为 cc c。先进入等比例空间:
Δ=(u−c)⊙(A,1)\Delta=(u-c)\odot(A,1) Δ=(u−c)⊙(A,1)

投影到脸部局部轴:
xl=Δ⋅x^,yl=Δ⋅y^ x_l=\Delta\cdot\hat{x},\qquad y_l=\Delta\cdot\hat{y} xl=Δ⋅x^,yl=Δ⋅y^

除以半径:
xn= xlrx ,yn= ylry x_n=\frac{x_l}{r_x},\qquad y_n=\frac{y_l}{r_y} xn=rxxl,yn=ryyl

椭圆标准化距离平方:
d2=xn2+yn2 d^2=x_n^2+y_n^2 d2=xn2+yn2

d=1d=1 d=1 时位于标准椭圆边界,小于 1 在内部,大于 1 在外部。GLSL 中应使用 max(radius,0.0001) 防止无效半径除零。

9.2 高斯衰减

G(d)=exp⁡(−kd2),k=2.5G(d)=\exp(-kd^2),\qquad k=2.5 G(d)=exp(−kd2),k=2.5

标准化距离 (d) 权重
0 1.000
0.5 0.535
1.0 0.082
1.5 0.004

数学椭圆边界并非硬边,在 d=1d=1 d=1 时仍有约 8.2% 权重。增大 kk k 会让颜色更集中,减小则扩散更广。

左右分别乘可见度后取最大值:
Grouge =max⁡(GLoL,GRoR) G_{rouge}=\max(G_Lo_L,G_Ro_R) Grouge=max(GLoL,GRoR)

max 而非相加,可避免两侧高斯尾部在鼻部重叠增亮。最后乘皮肤 Mask:
Gskin = Grouge Mskin G_{skin}=G_{rouge}M_{skin} Gskin=GrougeMskin

腮红因此自动避开脸外、眼睛和嘴唇。


10. 腮红颜色:亮度保护与 Soft Light

10.1 亮度保护

普通 Alpha Blend 会把皮肤直接拉向固定粉色,容易遮盖明暗和纹理。先计算工程近似亮度:
Y=0.299R+0.587G+0.114BY=0.299R+0.587G+0.114B Y=0.299R+0.587G+0.114B

阴影保护:
Ps=smoothstep⁡(0.10,0.30,Y) P_s=\operatorname{smoothstep}(0.10,0.30,Y) Ps=smoothstep(0.10,0.30,Y)

Y≤0.10Y\le0.10 Y≤0.10 时不上腮红; Y≥0.30Y\ge0.30 Y≥0.30 时完整保留;中间平滑过渡。

高光保护:
Ph=1−smoothstep⁡(0.85,0.98,Y) P_h=1-\operatorname{smoothstep}(0.85,0.98,Y) Ph=1−smoothstep(0.85,0.98,Y)

极亮区域权重逐渐降到 0,保留苹果肌高光与立体感。

设用户强度为 sr s_r sr,最终腮红权重:
Mr= Gskin PsPhclamp⁡(sr,0,1) M_r=G_{skin}P_sP_h\operatorname{clamp}(s_r,0,1) Mr=GskinPsPhclamp(sr,0,1)

四个因子分别控制位置、阴影、高光和用户强度,任意一项接近 0 都会抑制最终效果。

10.2 Soft Light

设单通道底色为 bb b,混合色为 cc c。当 c<0.5c<0.5 c<0.5:
SL(b,c)=2bc+b2(1−2c)SL(b,c)=2bc+b^2(1-2c) SL(b,c)=2bc+b2(1−2c)

c≥0.5c\ge0.5 c≥0.5:
SL(b,c)= max⁡(b,0) (2c−1)+2b(1−c)SL(b,c)=\sqrt{\max(b,0)}(2c-1)+2b(1-c) SL(b,c)=max(b,0) (2c−1)+2b(1−c)

逐通道应用到 RGB。腮红颜色为:
C=(0.95,0.43,0.50)\mathbf{C}=(0.95,0.43,0.50) C=(0.95,0.43,0.50)

红色通道倾向提亮和增强红色,绿色略被压低,蓝色接近中性,组合形成暖珊瑚粉,同时保留底色结构。

最终输出:
O=mix⁡(B,SL(B,C),Mr)O=\operatorname{mix}(B,SL(B,C),M_r) O=mix(B,SL(B,C),Mr)

即:
O=(1−Mr)B+MrSL(B,C)O=(1-M_r)B+M_rSL(B,C) 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 的典型生命周期:

  1. 从 framebuffer cache 获取 FBO;
  2. 绑定并清屏为 0;
  3. 上传轮廓、尺寸和 feather uniform;
  4. 绘制生成 Mask Texture;
  5. 把它绑定到效果 Shader;
  6. 绘制磨皮和腮红;
  7. 当前 draw 消费完成后解锁 FBO;
  8. FBO 回到缓存供后续帧复用。

缓存避免每帧重复创建纹理,减少分配开销和显存碎片。

12.4 人脸消失

检测失败或人脸离开时,应清空快照、重置平滑历史,并将 uHasFace 置 0。Shader 立即返回原图,避免旧效果残留,也跳过后续计算。


13. 性能分析与优化方向

13.1 Mask Pass

Mask 对每个像素遍历:
36+16+16+20=8836+16+16+20=88 36+16+16+20=88

条边。1080 × 1920 约有 207 万像素,理论边处理次数:
2,073,600×88≈182,476,8002,073,600\times88\approx182,476,800 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,0002,073,600\times0.30\times121\approx75,263,000 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= valuetimescale t=\frac{value}{timescale} t=timescalevalue

例如 value=3000, timescale=600,真实时间是 5 秒,而不是 3000 毫秒。毫秒应显式转换:
tms = valuetimescale ×1000 t_{ms}=\frac{value}{timescale}\times1000 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 与最终输出只要有一个坐标系不一致,后续算法再高级也无法正确贴合;检测、渲染和纹理生命周期只要有一环阻塞,视觉质量再高也无法成为可用的实时功能。

相关推荐
冯汉栩16 小时前
Swift Control RollingCountdownView(动画倒计时)
开发语言·ios·swift
_瑞1 天前
读懂 iOS 的线程调用栈
ios·编译原理·汇编语言
末代iOS程序员华仔1 天前
iOS 语聊直播/聊天APP4.3条例被拒解决
flutter·ios·swift
2601_965798472 天前
Build a Fast, High-Ranking Restaurant Website with Rolanda Theme
开发语言·ios·swift
极客互动API2 天前
企业微信 iPad 协议消息接口开发:文本 / 图片 / 群发消息的统一封装
人工智能·ios·微信·机器人·企业微信·ipad
我血条子呢2 天前
iOS 软键盘遮挡底部输入框解决报告
ios
思盛iOS签名上架2 天前
为什么 iOS 需要签名?未签名 App 无法安装的底层逻辑
ios
Data_Journal3 天前
Golang 中解析 HTML 的指南
ios·iphone
jike_20263 天前
销售拜访记录APP推荐:客户沟通内容怎么快速整理?
ios·语音识别·iphone