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

45 阅读10分钟

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

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

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

本文会从一帧相机图像出发,逐步推导 Face Landmark、坐标变换、时序平滑、Face Mask、双边滤波、腮红几何、Soft Light 混合以及 CPU/GPU 协同。


1. 先看全局:一帧图像经历了什么

实时美颜可以拆成两条并行流水线。

                         ┌─────────────────────────────┐
                         │       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 如下:

#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 读取它:

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}

因此,向右移动 3 个真实像素的采样坐标为:

(u,v)=(u,v)+(31W,0)(u',v')=(u,v)+\left(3\frac{1}{W},0\right)

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

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

  1. 第一次 draw 生成 Face Mask;
  2. 第二次 draw 同时读取 Camera Texture 和 Mask Texture;
  3. 第二次 draw 的结果再交给水印或预览节点。

Framebuffer Object,简称 FBO,可以把一张 Texture 挂载为渲染目标。GPU 画完后,这张 Texture 就能作为下一次 draw 的输入。

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\mathbf{uv}_{camera}=T\mathbf{uv}_{output}

其中 (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)

其中 xi,yix_i,y_i 一般位于 [0,1] 附近,ziz_i 描述相对深度。当前磨皮和腮红主要使用二维投影 xi,yix_i,y_i。归一化坐标与输入分辨率无关,例如 0.5,0.50.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)。当前方向变换使用:

xr=1ym,yr=1xmx_r=1-y_m,\qquad y_r=1-x_m

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

4.2 镜像与 Y 轴翻转

水平镜像为:

x=1x,y=yx'=1-x,\qquad y'=y

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

当两个坐标系的 Y 方向相反时:

y=1yy'=1-y

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

4.3 Aspect Fill 裁剪

设旋转后源画面与目标预览的宽高比分别为:

As=WsHs,At=WtHtA_s=\frac{W_s}{H_s},\qquad A_t=\frac{W_t}{H_t}

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

当 As>AtA_s>A_t 时,源画面更宽,左右裁剪。可见宽度比例:

wc=AtAsw_c=\frac{A_t}{A_s}

左边界:

cx=1wc2c_x=\frac{1-w_c}{2}

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

当 AsAtA_s\le A_t 时,源画面更高,上下裁剪:

hc=AsAt,cy=1hc2h_c=\frac{A_s}{A_t},\qquad c_y=\frac{1-h_c}{2}

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

设方向变换后的点为 xr,yrx_r,y_r,裁剪矩形为 cx,cy,cw,chc_x,c_y,c_w,c_h,最终输出 UV 为:

u=xrcxcw,v=yrcychu=\frac{x_r-c_x}{c_w},\qquad v=\frac{y_r-c_y}{c_h}

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

数值例子

源比例为 16:9,目标为 4:3

As1.778,At1.333A_s\approx1.778,\qquad A_t\approx1.333
wc=1.3331.7780.75,cx=10.752=0.125w_c=\frac{1.333}{1.778}\approx0.75,\qquad c_x=\frac{1-0.75}{2}=0.125

源画面左右各裁掉 12.5%。原坐标 xr=0.5x_r=0.5 仍映射到输出中心:

u=0.50.1250.75=0.5u=\frac{0.5-0.125}{0.75}=0.5

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


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

5.1 指数平滑公式

即使人脸静止,传感器噪声、曝光变化与模型误差也会让 landmark 轻微抖动。对当前检测点 PtdetectP_t{detect} 和上一帧平滑结果 Pt1smoothP_{t-1}{smooth},使用:

Ptsmooth=(1α)Pt1smooth+αPtdetectP_t^{smooth}=(1-\alpha)P_{t-1}^{smooth}+\alpha P_t^{detect}

等价写法为:

Ptsmooth=Pt1smooth+α(PtdetectPt1smooth)P_t^{smooth}=P_{t-1}^{smooth}+ \alpha(P_t^{detect}-P_{t-1}^{smooth})

第二种写法表示从旧位置向新检测位置移动 α\alpha 比例。基础响应系数为 α=0.62\alpha=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)

连续结果的归一化位移:

d=(Ct,xCt1,x)2+(Ct,yCt1,y)2d=\sqrt{(C_{t,x}-C_{t-1,x})^2+(C_{t,y}-C_{t-1,y})^2}

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

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

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

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


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

6.1 Mask 的目标

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

M(p)[0,1]M(p)\in[0,1]

最终效果通过:

O(p)=(1M(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) 向水平方向发射射线:相交奇数次表示内部,偶数次表示外部。

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

(y0>py)(y1>py)(y_0>p_y)\ne(y_1>p_y)

若成立,交点横坐标为:

xe=x0+(x1x0)pyy0y1y0x_e=x_0+(x_1-x_0)\frac{p_y-y_0}{y_1-y_0}

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

6.3 点到线段的最短距离

羽化需要知道像素距边界多远。线段方向 v=bav=b-a,点相对起点向量 w=paw=p-a。投影参数:

t0=wvvvt_0=\frac{w\cdot v}{v\cdot v}

将其截断到线段范围:

t=clamp(t0,0,1)t=\operatorname{clamp}(t_0,0,1)

线段最近点:

q=a+tvq=a+tv

距离:

d(p,ab)=pqd(p,\overline{ab})=\|p-q\|

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

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

在 1080 × 1920 图像中:

1 px horizontal=11080,1 px vertical=119201\text{ px horizontal}=\frac1{1080},\qquad 1\text{ px vertical}=\frac1{1920}

同一个 UV 距离在两个方向并不代表相同像素长度。因此先转换:

ppx=puv(W,H)p_{px}=p_{uv}\odot(W,H)

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

6.5 smoothstep 羽化

smoothstep(e0,e1,x)smoothstep(e_0,e_1,x) 先计算:

t=clamp(xe0e1e0,0,1)t=\operatorname{clamp}\left(\frac{x-e_0}{e_1-e_0},0,1\right)

再使用三次 Hermite 曲线:

S(t)=t2(32t)S(t)=t^2(3-2t)

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

保留脸部内部:

Minside=IinsideS(dminF)M_{inside}=I_{inside}\cdot S\left(\frac{d_{min}}{F}\right)

保留眼睛和嘴部外部:

Moutside=(1Iinside)S(dminF)M_{outside}=(1-I_{inside})\cdot S\left(\frac{d_{min}}{F}\right)

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

6.6 最终皮肤 Mask

Mskin=MfaceMleftEyeOutMrightEyeOutMmouthOutM_{skin}=M_{face}\cdot M_{leftEyeOut}\cdot M_{rightEyeOut}\cdot M_{mouthOut}

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


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

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

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

7.2 空间权重

中心像素为 pp,邻域像素为 qq,偏移 Δx\Delta x,Δy\Delta y。空间距离平方:

ds2=Δx2+Δy2d_s^2=\Delta x^2+\Delta y^2

空间权重:

ws(p,q)=exp(ds22σs2)w_s(p,q)=\exp\left(-\frac{d_s^2}{2\sigma_s^2}\right)

当前 sigmas=5.5sigma_s=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)

颜色距离平方:

dr2=(RqRp)2+(GqGp)2+(BqBp)2d_r^2=(R_q-R_p)^2+(G_q-G_p)^2+(B_q-B_p)^2

颜色权重:

wr(p,q)=exp(dr22σr2)w_r(p,q)=\exp\left(-\frac{d_r^2}{2\sigma_r^2}\right)

当前 sigmar=0.12sigma_r=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)
Bp=qΩw(p,q)IqqΩw(p,q)B_p=\frac{\sum_{q\in\Omega}w(p,q)I_q} {\sum_{q\in\Omega}w(p,q)}

分母用于归一化,否则不同位置的权重和不同,会改变画面亮度。实现中使用 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

实际 UV 偏移:

Δuv=(i,j)(1W,1H)k\Delta uv=(i,j)\odot\left(\frac1W,\frac1H\right)k

采样间距 k=2.8k=2.8,所以最远采样点距离中心约 5×2.8=145\times2.8=14 texels。增大间距可在不增加采样数的情况下扩大范围,但过大可能因采样稀疏产生颗粒和不连续。

7.6 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^2dot(colorDelta,colorDelta) 等价于 RGB 三通道差值平方和。

7.7 非线性强度

用户强度 (s) 先放大:

s=clamp(1.25s,0,1)s'=\operatorname{clamp}(1.25s,0,1)

再应用:

g(s)=1(1s)1.6g(s)=1-(1-s')^{1.6}

这条曲线增强中低档响应。例如 s=0.4s=0.4,则 s=0.5s'=0.5

g(0.4)=10.51.60.670g(0.4)=1-0.5^{1.6}\approx0.670

最终局部权重:

Wp=clamp(Mskin(p)g(s),0,1)W_p=\operatorname{clamp}(M_{skin}(p)g(s),0,1)

若该像素 Mask 为 0.5,则 Wp0.335W_p\approx0.335

7.8 细节回填

双边结果 BpB_p 是低频颜色,原图细节:

Dp=IpBpD_p=I_p-B_p

先混合:

Rp=(1Wp)Ip+WpBpR'_p=(1-W_p)I_p+W_pB_p

再回填 22% 的细节:

Rp=Rp+0.22WpDpR_p=R'_p+0.22W_pD_p

展开可得:

Rp=(10.78Wp)Ip+0.78WpBpR_p=(1-0.78W_p)I_p+0.78W_pB_p

因此细节回填等价于把有效模糊混合量从 WpW_p 降为 0.78Wp0.78W_p,减轻塑料感。

7.9 Dither 与提前退出

平滑区域在 8-bit 输出中容易出现色带。可生成约一个色阶以内的屏幕空间伪随机噪声:

float noise = fract(
    sin(dot(coordinate, vec2(12.9898, 78.233)))
    * 43758.5453
) * (1.0 / 255.0);

以 0 为中心并只加到磨皮区域:

RpRp+(noise0.5255)WpR_p\leftarrow R_p+\left(noise-\frac{0.5}{255}\right)W_p

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

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


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

8.1 等比例空间

UV 是单位正方形,但输出往往不是正方形。定义宽高比:

A=WHA=\frac{W}{H}

将点映射为:

E(x,y)=(Ax,y)E(x,y)=(Ax,y)

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

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

左眼轮廓中心:

Le=1nLi=1nLLiL_e=\frac1{n_L}\sum_{i=1}^{n_L}L_i

右眼和嘴部中心同理得到 Re,CmR_e,C_m,双眼中点:

Ce=Le+Re2C_e=\frac{L_e+R_e}{2}

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

在等比例空间中:

xraw=E(Re)E(Le)x_{raw}=E(R_e)-E(L_e)

双眼距离与单位横轴:

de=xraw,qquadx^=xrawxrawd_e=\|x_{raw}\|,qquad \hat{x}=\frac{x_{raw}}{\|x_{raw}\|}

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

8.3 Gram-Schmidt 构造纵轴

从双眼中点指向嘴部中心:

yraw=E(Cm)E(Ce)y_{raw}=E(C_m)-E(C_e)

它不一定和横轴垂直。先求横向投影:

p=yrawx^p=y_{raw}\cdot\hat{x}

移除投影:

yorth=yrawpx^y_{orth}=y_{raw}-p\hat{x}

归一化:

y^=yorthyorth\hat{y}=\frac{y_{orth}}{\|y_{orth}\|}

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

8.4 苹果肌中心

设左右脸颊外侧点为 CL,CRC_L,C_R,鼻尖为 NN。线性插值:

lerp(a,b,t)=(1t)a+tb\operatorname{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)

再向对应眼睛抬高 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)

这两个比例是审美参数:增大 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)\|

横向半径:

rx,L=max(0.52sL,0.18de)r_{x,L}=\max(0.52s_L,0.18d_e)
rx,R=max(0.52sR,0.18de)r_{x,R}=\max(0.52s_R,0.18d_e)

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

纵向跨度:

sv=(E(Cm)E(Ce))y^s_v=\left|\left(E(C_m)-E(C_e)\right)\cdot\hat{y}\right|

纵向半径:

ry=max(0.15de,0.22sv)r_y=\max(0.15d_e,0.22s_v)

8.6 侧脸可见度

正脸时 sL,sRs_L,s_R 接近;侧脸时远侧跨度缩短。令:

smax=max(sL,sR,ε)s_{max}=\max(s_L,s_R,\varepsilon)

任意一侧的归一化比例:

qi=clamp(sismax,0,1)q_i=\operatorname{clamp}\left(\frac{s_i}{s_{max}},0,1\right)

可见度:

oi=[max(qi,0.35)]0.75o_i=[\max(q_i,0.35)]^{0.75}

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


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

9.1 局部坐标与椭圆距离

当前输出 UV 为 uu,腮红中心为 cc。先进入等比例空间:

Δ=(uc)(A,1)\Delta=(u-c)\odot(A,1)

投影到脸部局部轴:

xl=Δx^,yl=Δy^x_l=\Delta\cdot\hat{x},\qquad y_l=\Delta\cdot\hat{y}

除以半径:

xn=xlrx,yn=ylryx_n=\frac{x_l}{r_x},\qquad y_n=\frac{y_l}{r_y}

椭圆标准化距离平方:

d2=xn2+yn2d^2=x_n^2+y_n^2

d=1d=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
标准化距离 (d)权重
01.000
0.50.535
1.00.082
1.50.004

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

左右分别乘可见度后取最大值:

Grouge=max(GLoL,GRoR)G_{rouge}=\max(G_Lo_L,G_Ro_R)

max 而非相加,可避免两侧高斯尾部在鼻部重叠增亮。最后乘皮肤 Mask:

Gskin=GrougeMskinG_{skin}=G_{rouge}M_{skin}

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


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

10.1 亮度保护

普通 Alpha Blend 会把皮肤直接拉向固定粉色,容易遮盖明暗和纹理。先计算工程近似亮度:

Y=0.299R+0.587G+0.114BY=0.299R+0.587G+0.114B

阴影保护:

Ps=smoothstep(0.10,0.30,Y)P_s=\operatorname{smoothstep}(0.10,0.30,Y)

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

高光保护:

Ph=1smoothstep(0.85,0.98,Y)P_h=1-\operatorname{smoothstep}(0.85,0.98,Y)

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

设用户强度为 srs_r,最终腮红权重:

Mr=GskinPsPhclamp(sr,0,1)M_r=G_{skin}P_sP_h\operatorname{clamp}(s_r,0,1)

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

10.2 Soft Light

设单通道底色为 bb,混合色为 cc。当 c<0.5c<0.5

SL(b,c)=2bc+b2(12c)SL(b,c)=2bc+b^2(1-2c)

c0.5c\ge0.5

SL(b,c)=max(b,0)(2c1)+2b(1c)SL(b,c)=\sqrt{\max(b,0)}(2c-1)+2b(1-c)

逐通道应用到 RGB。腮红颜色为:

C=(0.95,0.43,0.50)\mathbf{C}=(0.95,0.43,0.50)

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

最终输出:

O=mix(B,SL(B,C),Mr)O=\operatorname{mix}(B,SL(B,C),M_r)

即:

O=(1Mr)B+MrSL(B,C)O=(1-M_r)B+M_rSL(B,C)

10.3 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. 为什么先磨皮、后腮红

vec3 finalColor = applySmooth(sourceColor, faceMask);
finalColor = applyRouge(finalColor, faceMask);

如果先画腮红,后续双边滤波会把腮红颜色向周围扩散、削弱强度,并使磨皮滑块间接改变腮红表现。先磨皮再上妆,可以让两个功能相对解耦,腮红亮度保护判断的也是最终皮肤底色。

完整流程:

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 检测不能阻塞渲染

串行执行“等待检测 → 渲染”会把模型耗时直接变成预览延迟。更合理的结构是异步检测,渲染每帧读取最新结果:

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

条边。1080 × 1920 约有 207 万像素,理论边处理次数:

2,073,600×88182,476,8002,073,600\times88\approx182,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×12175,263,0002,073,600\times0.30\times121\approx75,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 时会瞬间错位。

可维护小型环形缓冲区:

timestamp → sourceSize, mirrored, orientation, targetSize

结果返回时按 timestamp 找回提交时的元数据。

14.2 CMTime.value 不等于毫秒

CMTime 的真实秒数为:

t=valuetimescalet=\frac{value}{timescale}

例如 value=3000, timescale=600,真实时间是 5 秒,而不是 3000 毫秒。毫秒应显式转换:

tms=valuetimescale×1000t_{ms}=\frac{value}{timescale}\times1000

Swift:

let milliseconds = Int(
    CMTimeGetSeconds(timestamp) * 1000.0
)

let monotonicTimestamp = max(
    milliseconds,
    lastTimestamp + 1
)

单调保护只能处理重复或倒退,不能修正错误的时间单位。


15. 参数调节指南

参数当前值增大后的效果过大风险
Landmark 平滑响应0.62更跟手抖动增加
快速运动响应0.82转头延迟更低瞬时噪声明显
Mask 羽化8 px边缘更柔和效果范围收缩
空间 sigma5.5平滑范围更大局部结构变平
颜色 sigma0.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 与最终输出只要有一个坐标系不一致,后续算法再高级也无法正确贴合;检测、渲染和纹理生命周期只要有一环阻塞,视觉质量再高也无法成为可用的实时功能。