人脸三维重建这个方向,很长一段时间里大家的默认路径都是先套 3DMM 这类参数化模型。基向量组合的思路当然清晰:人脸形状可以被看作一个线性子空间里的点,拟合参数就是在这个子空间里找一个最接近输入图像的点。但真到工程上就会撞上那堵墙——子空间外的细节表达不了,大表情和侧脸很容易崩,稀疏关键点还总得依赖一个额外的检测器去支撑初始化。所以我第一次看到 Position Map Regression Network 这个思路的时候,第一反应是:终于有人不再纠结于“参数”了。
这篇工作做了一件很纯粹的事:输入一张人脸图,直接回归一张 UV 空间里的位置图,位置图上的每个像素存的是三维坐标。拿到这张图,三维网格能重建出来,稠密对齐关系也顺带解决了。不需要迭代优化,不需要 3DMM 系数,不需要单独的关键点对齐。今天这篇博文,我会把它的原理、网络设计、损失函数、训练细节和落地时容易踩的坑一次讲透。不管你刚接触三维重建,还是已经在做数字人和人脸特效的工程开发,应该都能从这里拿到一些可以直接用的东西。
1. 项目概述与整体思路拆解
1.1 三维人脸重建为什么一直卡在“参数”上
先复盘一下传统方案死在哪里。以 3DMM 为例,它的核心假设是人脸形状可以用一组基向量的线性组合来近似。拟合的时候,你要么用稀疏特征点去约束参数,要么直接用像素误差做光度优化。主力优化算法还都绕不开迭代求解:构建残差、计算雅可比、更新参数,然后在重建和匹配之间反复切。
这个框架的问题是全局参数耦合。形状基向量之间是相关的,一个表情参数动一点,整个脸型都会跟着变化;而线性子空间的表达能力有限,典型人脸上没问题,换到数据分布覆盖少的长相,出来的脸就“太平均”。更麻烦的是,整个过程高度依赖初始姿态和关键点检测。关键点偏一点,优化大概率落到局部极小。你去看一些早期方法生成的侧脸重建结果,会发现脸型被硬掰回正面,像贴了一张面具。
后来有人直接用 CNN 回归 3DMM 参数,把迭代优化换成了单次前向推理。速度确实上来了,但你回归的还是同一组全局参数。网络的表达天花板被参数空间锁死,等于换了一辆更快的车,路还是原来的路。这就是为什么“位置图回归”这个方向会让人眼前一亮——它把问题从连续空间里的参数优化,直接改造成了二维图像上的逐像素回归。
1.2 位置图:把三维坐标“画”成一张图
核心洞察其实不复杂。人脸的表面可以展开到一个二维的 UV 平面上,就像把地球仪的表面展开成一幅世界地图。你给每个三维顶点分配一对固定的 UV 坐标,并按分辨率在 UV 图里铺像素,那么“人脸三维形状”就变成了一张图。更进一步,你可以把这个像素的值设成它对应顶点的三维空间坐标,也就是用 RGB 三个通道分别存 x、y、z。这张图就是位置图(Position Map)。
为什么这个看似简单的表示能解决参数化模型的痛点?因为网络不再通过一组全局参数去影响所有顶点,而是直接在输出端逐像素地决定每个顶点的三维位置。这意味着它有能力表达线性空间之外的几何细节——酒窝、法令纹、夸张表情的局部隆起,只要训练数据里出现过,网络理论上都能逼近。更重要的是,位置图是稠密的,每个有效像素都是人脸三维表面上的一个点,它天然内建了顶点索引关系,可以同时处理自遮挡区域。
深度图在姿态估计里也是常见的输出表示,但深度图本质是 2.5D,它保存的是从相机视角看到的深度值,背后的曲面信息是缺失的,被遮挡的区域没有值。位置图不一样,它存的是绝对三维坐标,不依赖观察视角,人脸的侧面和背面只要拓扑展开时有定义,网络就可以去预测。这也为后面做稠密对齐留好了伏笔。
1.3 重建与稠密对齐为什么能合成一件事
传统流程里,重建和对齐是两件需要交替迭代的事。你先要重建出三维模型,再把这个模型投影到图像上跟输入做对齐,然后根据对齐误差再反过来修正三维模型。每一步都有误差传播,每一步都需要设计目标函数。
PRN 把这两件事合并了。训练阶段,网络学习的是一个从二维图像到固定拓扑三维表面的映射。因为位置图的 UV 坐标是预先定义好的,网络在预测三维坐标的同时,其实就已经在推理“这个顶点对应图像的哪个位置”。到了推理阶段,你只需要从位置图里读出三维点和它们在 UV 拓扑中的索引,再用 PnP 求解相机位姿,稠密对应关系就全部确定了。整个过程没有迭代、没有交替优化。所以它叫“联合重建与稠密对齐”,而不是两阶段拼接。
2. 核心原理与网络结构细读
2.1 UV 展开与位置图的数学定义
想真正看懂 PRN,先得搞明白 UV 展开是怎么做的。这里我以通用的人脸模型为例说明。假设你有一个标准三维人脸网格,每个顶点坐标是 v = (x, y, z)。把它按圆柱坐标展开到二维平面上,公式一般长这样:
u = 0.5 + 0.5 * atan2(z, x) / π v = 0.5 - (y - y_min) / (y_max - y_min)做完这个映射,再按分辨率量化到像素坐标,三维顶点就落到了 UV 图里固定的位置上。生成位置图的时候,把顶点的三维坐标归一化到 0~1 或者 0~255 范围,分别写入对应 UV 像素的 RGB 通道。一张位置图,本质上就是你预先定义好的拓扑网格在三维空间里的坐标快照。
这个展开过程有两点要特别留意。第一,UV 边界处的映射会有拉伸畸变,边缘像素对应的三维表面面积往往很小,有些区域甚至可能是无效像素,处理这些位置需要专门的掩码。第二,归一化方式决定了网络学习的目标范围。位置图坐标是绝对空间坐标,不能用图像分类任务里常用的全局均值方差归一化,而是要根据人脸包围盒做线性缩放,保证输出的坐标回到真实度量比例。
2.2 编码器-解码器网络长什么样
网络本身不复杂,大体上是标准的编码器-解码器结构。输入是一张裁剪好的人脸图,经过编码器逐步下采样提取高层语义特征,再经过解码器逐步上采样恢复到 UV 位置图分辨率,最后一层卷积输出 3 个通道,对应 x、y、z 坐标。
我用过的大致结构可以描述成这样:
def forward(self, img): feat = self.encoder(img) # 4~5 个下采样 stage,ResBlock 堆叠 feat = self.decoder(feat) # 对称上采样,双线性插值 + 卷积 position_map = self.head(feat) # 1x1 卷积,输出 3 通道 return position_map编码器的下采样次数和解码器的上采样次数要匹配,否则最后输出的分辨率对不上位置图。原思路里一般用类似 ResNet 的 stem 加 4 个 stage,解码器用双线性上采样加卷积,而不是单纯的反卷积。反卷积容易产生棋盘格伪影,尤其在 UV 图边缘这种像素本来就稀疏的区域,棋盘格会让重建网格出现不自然的条带。
激活函数上,中间层用 ReLU 或 LeakyReLU 都可以,最后一层因为要输出连续坐标值,不需要加 Sigmoid,直接用线性输出。很多人在这里会犯迷糊,以为位置图是图像,输出层要加归一化。实际上坐标值可能是负的,加了 Sigmoid 反而会限制三维坐标的表达范围。
2.3 损失函数与权重图:最终效果的一半秘密
位置图回归的损失函数形式上很简单,就是带权重的逐像素 L2 损失:
loss = (weight * (pred - gt).pow(2)).sum() / weight.sum()简单公式里藏着的门道全在 weight 这个权重图上。如果不加权重,网络通常会在这些地方翻车:
第一个是边缘和 UV 边界。由于圆柱展开的畸变,人脸边缘的 UV 像素对应着非常少的表面面积,但它们的回归误差在数值上跟中心区域是同等对待的,这等于让网络把大量精力花在几乎不可信的边缘像素上。解决方法是给中心区域更高的权重,边缘递减,这样可以减少畸变带来的不均衡。
第二个是被遮挡区域。训练数据里如果有人脸侧转、低头、被手遮挡,某些顶点实际上是不可见的。如果位置图的 GT 还是填了值,网络在可见性不确定的区域就会学到矛盾信息。更合理的是用遮挡掩码把这些区域的权重设成 0,只让可见区域提供监督。
第三个是眼睛、嘴巴这些高形变区域。这些地方近邻帧之间的拓扑位置变化很剧烈,如果权重跟额头一样大,损失会被这些高变区域主导,最后输出的脸容易变成“五官模糊”的平均脸。实践中可以适当降低高变区域的权重,让网络先保证整体脸型的稳定,再去抠局部细节。
权重图不需要网络自己学习,它完全由网格拓扑和训练数据的可见性预先计算出来,训练前放到 GPU 显存里当常量用就行。
3. 从位置图到三维网格与稠密对齐
3.1 后处理管线:位置图到 Mesh
网络输出的位置图不是可以直接渲染的网格,它只是一堆有组织的坐标点。要把它转成可用的人脸三维模型,还需要几步后处理:
- 遍历位置图所有像素,读取归一化坐标并反变换到真实三维坐标。
- 根据 UV 模板的有效掩码,把无效像素剔除。
- 利用预设的拓扑索引表,把三维点连接成三角网格。
- 保存为 OBJ 或 glTF 等标准网格格式,或直接送进渲染管线。
这个过程看起来简单,但有一个最容易被搞错的细节:位置图的分辨率和模板网格的拓扑索引必须严格一致。UV 分辨率是 256 的话,网格顶点数差不多就是 65536 这个量级。你如果训练时的 UV 分辨率是 256,推理时换了 128 的位置图,顶点索引就对不上了。所以后处理里那个索引表,最好在预处理阶段就固化好,跟渲染尺寸一起作为常量保存。
另外一个实操点,是读坐标时要注意存储顺序。常见实现里会把顶点坐标按 xyz 放进 RGB 通道,但因为归一化范围不同,颜色差异会很大,直接用图像查看器打开位置图,看到的往往是一堆奇怪的色块。别用肉眼看颜色去判断模型对不对,直接用三维查看器看 mesh。
3.2 稠密对齐与相机位姿求解
有了三维网格,稠密对齐怎么解?这里用到的是 2D-3D 对应关系。因为位置图是在固定 UV 拓扑上定义的,每个有效像素都知道自己在三维网格里的顶点索引;又因为输入图像经过一个人脸检测器裁剪后,位置图的 UV 坐标和图像像素坐标之间存在一个固定的对应关系,所以你手上其实已经有了成对的“三维点+图像二维点”对应集合。
把这些对应点喂给 PnP 求解器,就能得到相机旋转和平移。以透视相机为例,投影关系可以写成:
s * [u, v, 1]^T = K * [R | t] * [x, y, z, 1]^T其中 K 是相机内参,R 是旋转矩阵,t 是平移向量。求解 R 和 t 之后,每个三维顶点都能投影回图像平面,稠密对齐关系就齐了。这里再强调一次,之所以能做到“稠密”,是因为拓扑固定,顶点和 UV 像素一一对应,而不是传统的稀疏关键点对齐。稀疏对齐只能得到几十个点的对应,PRN 得到的是上万甚至更多的对应关系。
实际工程中,如果只是做人脸特效贴图,使用正交相机模型近似透视投影通常也够了,而且 PnP 求解更稳定。但如果你做的是需要精确叠加虚拟眼镜的增强现实应用,还是按透视模型来,减少边缘区域的投影偏差。
3.3 为什么这个设计在工程上很省事
很多人会忽略一个隐性优势:固定拓扑意味着不需要每次推理都重新生成网格连接关系。三角面索引表只需要生成一次,之后所有帧的 mesh 都复用同一张面表。3DMM 方法里,拟合出的网格拓扑也是固定的,但为了匹配图像往往要做额外的变形优化。PRN 没有这个过程,网络输出的顶点坐标直接就是最终形状,后处理几乎是一个纯查表的操作。
这种设计还天然支持 GPU 加速。读取位置图坐标、按索引连接顶点、计算法线,这些操作都可以写成 GPU kernel,而不是在 CPU 上用嵌套循环遍历每个面。我之前把一个简单的参考实现重写成 GPU 版本,单帧后处理时间从十几毫秒降到了两三毫秒,对一个追求实时性的应用来说,这个差距很关键。
4. 训练一个可用模型:数据、参数与实操经验
4.1 训练数据从哪来,监督怎么生成
PRN 这种监督学习方法最大的训练难点不在网络,而在监督数据。每张人脸图都要有一张对应的三维位置图作为 GT。手工标注三维网格是不可能的,所以常规做法是先用一个已有的高精度三维人脸重建方法去拟合出每张图的形状、表情、姿态,再把拟合出的三维模型按同一个 UV 展开渲染成位置图,作为训练用的监督信号。
这里有个“自举”的味道:用别的方法生成标签来训练新的网络。所以监督质量直接决定了 PRN 的天花板。拟合得不准的数据,会让网络学会输出一个“平均脸版本”的扭曲错误。训练前最好抽一批数据做可视化检查,看位置图渲染出的 mesh 是不是跟原图姿态一致。
还有一个细节是数据源的人脸框。训练数据里的图像如果是用不同的人脸检测器裁剪的,裁剪框中心和尺度会不一致,直接训练会让网络以为输入分布是乱的。统一的人脸框处理,包括统一裁剪到固定尺寸、用同一套关键点做相似变换,会影响最终的姿态估计稳定性。
4.2 训练配置:我用过的参数组合
我在复现时用过一组比较稳定的配置,给你参考:
| 配置项 | 参数值 |
|---|---|
| 输入图像尺寸 | 256x256 |
| 位置图分辨率 | 256x256 |
| 编码器主干 | 类似 ResNet 的前 4 个 stage |
| 优化器 | Adam |
| 初始学习率 | 1e-4 |
| 学习率策略 | 每 10 个 epoch 降为原来的 0.5 |
| Batch Size | 16 |
| 总训练轮数 | 30~50 |
| 数据增强 | 随机旋转 30 度、亮度抖动、随机遮挡 |
这个配置不是唯一答案,但比较稳。如果显存有限,可以先把输入降到 224,输出位置图降到 192,效果损失不会太大。学习率这里要留意,位置图回归的 L2 损失通常在前几个 epoch 下降很快,后面会进入一个平台期。如果你发现 loss 一直在 1e-3 量级下不去,多半不是学习率问题,而是权重图里无效像素处理得不对。
4.3 复现时最容易被忽略的四个细节
第一个是坐标归一化。位置图里存储的三维坐标,如果训练时用的归一化范围和推理时不一样,mesh 会整体变形。我建议把坐标系固定在一个以人脸中心为原点的包围盒内,训练和推理用同一套缩放因子。
第二个是权重图的预计算。别在训练循环里每次现算权重,把权重图和遮挡掩码提前在数据阶段算好、存成常量,训练时直接从显存读。否则这会变成一个不明显但很拖速度的瓶颈。
第三个是解码器的上采样方式。强烈建议用双线性插值加卷积,而不是直接反卷积。反卷积在 UV 图这种稀疏目标上容易产生棋盘伪影,特别是眼睛周围,网格会出现规则的条纹噪声,排查起来很费时间。
第四个是有效掩码的传播。位置图上有些像素永远没有对应的三维顶点,这些像素在损失计算里必须被排除。但注意不要只排除一次,后处理阶段如果丢失了掩码,会导致边缘顶点坐标读出无意义的噪声。把这个掩码同时用于训练和后处理,是保证稳定性的基础。
5. 常见问题与排查技巧实录
5.1 重建结果出现飞点和空洞怎么办
飞点是位置图后处理里最常见的现象。某个本应是无效像素的位置,网络给出了一个巨大的坐标值,网格渲染出来就会在五官周围长出刺状的孤点。排查思路分三步:先看是不是损失函数没加权重,导致网络在边缘无效像素上乱猜;再看后处理有没有用掩码剔除无效像素;最后对输出的顶点坐标做一个统计离群点剔除,把超过正常人脸包围盒范围的顶点直接丢弃。
空洞通常出现在 UV 展开的边界或拓扑覆盖盲区,比如耳朵和脖子连接处。如果你不需要耳朵,建模时就把这些区域直接从模板网格里去掉,不要硬让网络去预测一个它没见过的表面。
5.2 视频里姿态和形状抖得厉害
单帧独立预测带来的时序抖动,是这类方法最常见的落地痛点。网络每一帧都看到一个新的输入,输出的形状和姿态会存在帧间噪声。解决方案分两层。
第一层是在推理后做时间平滑,比如对输出的旋转矩阵做滑动平均,对顶点坐标做指数移动平均。注意对旋转矩阵做平均时要用矩阵形式,不能直接平均欧拉角,否则会出现奇异性问题。
第二层是在训练阶段做视频增强。把同一人的相邻多帧一起喂给网络,在损失函数里加一个时序一致性正则项,让网络对相邻帧输出尽量接近的形状。这个改动比单纯的后处理平滑更治本,但需要视频数据,对数据准备的要求更高。
5.3 遮挡和大表情导致结果退化
口罩、墨镜、夸张表情会造成明显的退化。口罩会把下半脸整个遮挡,网络在遮挡区域缺少有效监督,往往倾向于把嘴部回归成一个平滑的凸面。墨镜会让眼睛区域出现奇怪的凹陷。
这里最管用的办法是训练数据里加随机遮挡增强。用随机形状的 mask 覆盖输入图像,然后在损失计算时把这些 mask 区域的权重设成 0。这样一来,网络学会了在不完整的输入上利用对称性和上下文先验补齐被遮挡的区域,而不是死记硬背输入的完整像素。
5.4 实时推理的性能优化思路
想要达到实时,需要从三个方向同时优化。网络层面,把主干换轻量级网络,比如用 MobileNet 的卷积结构替换 ResNet stage,输入尺寸降到 128 或 192,位置图分辨率降到 128。模型精度会有一点损失,但视频应用里通常可以接受。
后处理层面,把顶点索引和三角化表从 CPU 搬到 GPU,坐标读取、离群点剔除、法线计算全部并行化。这一步通常能省掉大多数耗时。
渲染层面,如果目标是贴特效,可以不用显式导出 mesh,直接在片段着色器里采样位置图做深度测试和法线计算。位置图在网络输出后的显示流程里本来就是一个 GPU 纹理,不需要转成 CPU 数据,来回拷贝的延迟完全可以省掉。
最后再分享一个我自己的体会。PRN 这个方案真正的价值可能不在网络结构本身,而在“位置图”这个中间表示的设计。它把三维重建问题转成了一个跟卷积网络天然契合的二维映射问题。比起调参调结构,我后来花在调整 UV 展开、权重图和掩码上的时间反而更多,而且每次改动都直接反映在最终 mesh 质量上。如果你正准备做轻量级的人脸重建和稠密对齐,不妨沿着这个框架自己搭一版,改起来会比想象中顺手得多。