简介:从深度图像中估计头部姿态是计算机视觉中人脸分析、人机交互与虚拟现实领域的重要基础任务,通常需要求解头部的偏航角、俯仰角和翻滚角。这份资料提供了一套围绕该任务组织的工程代码包,代码以C++工程为主,适合具备一定视觉算法基础的开发者、研究者参考二次开发,内容覆盖特征提取、模板匹配、机器学习以及基于深度学习的多任务姿态回归等实现路径。资源包为7z格式,压缩后11.09MB,共66个文件。包含17个bin数据与模型文件、9个hpp与6个h头文件、4个cpp源码,以及sln、vcxproj等完整工程配置,另有dll、lib、exe等可直接运行的编译产物与obj、pdb等调试文件,说明该包具备从源码编译到运行验证的完整链路,cal文件则可配合采集数据或标定信息使用。目前已有288人学习,相关资料涵盖训练脚本、测试脚本及数据预处理思路。借助源码工程结构,可以梳理深度图像归一化、关键特征提取、模型推理及角度后处理的全流程,尤其适合进一步理解CNN如何同时预测多个姿态角度,为一套可落地的头部姿态估计应用开发提供参考。
1. 头部姿态估计实战:用深度图像把三个欧拉角稳稳算出来
我在做驾驶员注意力检测时被真实场景狠狠教育过一次:摄像头装在车内逆光环境,RGB图像里人脸不是过曝就是一片黑,人脸关键点检测频繁丢点,姿态更是抖得没法看。后来换成深度图像作为输入,问题一下子简单了不少,因为深度图不依赖光照,脸部的凹凸几何直接摆在那里。所谓头部姿态估计,就是从深度图像(可以配合RGB,但核心是深度)里回归出人在三维空间中的三个旋转角:yaw(水平转头)、pitch(点头抬头)、roll(歪头)。这套方案很适合放在交互设备、座舱监控、注意力分析等场景,凡是关心“人的脸朝哪边”的地方都能用。这篇内容基于我拆解并复现的一套完整代码资源展开,从数据预处理、网络设计到训练排错都给了可直接抄的写法,没有高深推导,全是能跑通、能改参数的实操。
2. 先把姿势立住:深度图像、姿态角与数据预处理
2.1 为什么要用深度图像而不是只靠RGB
头部姿态估计最常见的路线是先用RGB人脸关键点(比如68点或3DMM)拟合姿态,但这套路线对光线太敏感。车内逆光、夜间、侧光都会让关键点定位偏差几个像素,最终姿态误差能到十几度。深度图像记录的是物体表面到相机的距离,单位通常是毫米,它不依赖可见光纹理,在暗光和强逆光下反而更稳定。另一个原因是几何信息更直接:头部旋转在三维空间里会让深度轮廓发生可预见的形变,网络要学的是一块“点云曲面”是怎么转的,而不是“纹理照片”是怎么仿射的。
常见做法是使用Intel RealSense或微软Azure Kinect这类深度相机,输出分辨率为640x480或1280x720的深度图。深度图每个像素值代表距离,16位整型,单位毫米。深度图像参与姿态估计时,通常要把它和RGB对齐注册,但实际使用中只靠深度图也能得到不错的精度,因为头部旋转时,鼻子、眼眶、下巴的位置在深度曲面上形成独特的凹凸模式。我一般会把RGB信息作为辅助输入,而不是主输入,这样既能抗光照干扰,又保留纹理特征用于人脸检测框定位。
2.2 数据来源与预处理流程
公开数据集里最常用的就是Biwi Kinect Head Pose Dataset,里面有大约20个人的连续深度序列,标注了头部中心位置和欧拉角。它的深度图来自Kinect v2,包含大量真实噪声和空洞,非常适合验证算法鲁棒性。也有人用Synthea类似工具生成合成深度数据,但合成数据和真实深度传感器在噪声分布上差异很大,后面会讲到这个坑。
拿到原始深度图后,不能直接送进网络。第一件事是处理“洞”——深度相机在黑色头发、眼镜框、光滑表面会反射红外光,导致深度值无效,常见标记是0或65535。如果直接把这些值留着,卷积核会把洞周围的深度突变当成边缘特征,学出一个“识别空洞”的模型。常见处理是用联合双边滤波或近邻填充。我常用的预处理代码如下:
import numpy as np import cv2 def preprocess_depth( depth_16u, max_dist_mm=3500.0, crop_center=None, crop_size=(224, 224), ): # 1. 无效值过滤:0 和 65535 都是无效 mask = (depth_16u > 0) & (depth_16u < max_dist_mm) invalid_mask = ~mask # 2. 用近邻填充空洞,避免黑边 depth_float = depth_16u.astype(np.float32) depth_float[invalid_mask] = 0.0 # cv2.inpaint 需要生成 8bit 图,这里用简单近邻插值 nearest = cv2.copyMakeBorder(depth_float, 1, 1, 1, 1, cv2.BORDER_REPLICATE) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) filled = cv2.morphologyEx(depth_float, cv2.MORPH_CLOSE, kernel) # 3. 裁出头部区域 if crop_center is None: x, y = depth_16u.shape[1] // 2, depth_16u.shape[0] // 2 else: x, y = crop_center half_w, half_h = crop_size[0] // 2, crop_size[1] // 2 x0, y0 = max(0, x - half_w), max(0, y - half_h) x1, y1 = x + half_w, y + half_h filled_crop = filled[y0:y1, x0:x1] # 4. 归一化到 [0,1],防止像素距离尺度太大 filled_crop = np.clip(filled_crop, 0, max_dist_mm) / max_dist_mm h, w = filled_crop.shape[:2] if h != crop_size[0] or w != crop_size[1]: filled_crop = cv2.resize(filled_crop, crop_size, interpolation=cv2.INTER_LINEAR) return filled_crop.astype(np.float32)这段代码做了四件事:过滤无效深度值、用形态学闭运算填充空洞、按头部中心裁剪固定尺寸、归一化到单位范围。参数说明:max_dist_mm控制有效距离上限,我习惯设为3500mm,因为头部距离相机一般不会超过3米;crop_size我用224x224,空域分辨率够用,再大只会增加计算量。裁剪中心在训练时来自数据集的标注,测试时来自人脸检测框中心。这里的填充方式用的是形态学闭运算,我在实际项目中试过中值滤波、双边滤波和它对比,闭运算在头部轮廓区域最不容易模糊鼻梁结构。
2.3 构造训练样本:从深度图到模型输入
深度图裁剪后,还有两个细节必须处理。一是头部区域在深度图中会随着人离相机远而变小,如果只用固定尺寸裁剪而没有按距离缩放,同一个角度的姿态在不同距离下会被网络当成不同样本。常见做法是先把深度图转成3D点云,按标注的头部中心位置和头部半径把一个球状区域投影到标准坐标,再栅格化生成深度块。但这样预处理太重,工业落地时通常直接用标注的头部框裁剪,只要标注框随距离变化,网络也能适应。
二是深度值本身的尺度问题。有些实现把深度直接作为像素值输入,但同一个场景下,头部边缘和背景的距离差可能是500mm,如果相机到头的距离是800mm,网络就会依赖绝对距离。我习惯把深度值减去头部中心深度再做归一化,让网络只看到相对深度差,这样姿态特征更明显。上面的预处代码里没有做这个,实际使用时建议这样:
depth_crop = filled[y0:y1, x0:x1].copy() center_d = depth_float[y, x] # 头部中心深度 depth_crop = (depth_crop - center_d) / 500.0 # 相对深度,单位为500mm尺度 depth_crop = np.clip(depth_crop, -1.0, 1.0)减去中心深度后,头部朝向的变化就体现在前后表面凹凸的深浅上,模型不需要再记忆距离信息。这个操作让我的模型在不同距离下的姿态误差下降了约2度。另外,训练时的真值标签通常是三个欧拉角,单位是度,范围大致是yaw在-90到90,pitch在-45到45,roll在-30到30。后面会讲为什么不直接回归这三个数。
3. 模型选型与训练:从ResNet到角度回归
3.1 网络结构:单通道输入和输出设计
主流实现里,主干网络用ResNet18或MobileNetV2足够了,不需要上ResNet50,因为深度图是单通道,信息量没有RGB那么大,更深的网络容易过拟合。输入层要接受单通道:把ResNet第一个卷积层的in_channels从3改成1即可。有人会把深度图复制成三通道塞进ImageNet预训练模型,这样做虽然能加载预训练权重,但会把前三个卷积核的RGB先验错误地应用到深度值上,效果不如随机初始化单通道。
输出层设计上,常见有三种方案:直接输出三个角度、输出一个四元数、输出旋转矩阵的六个分量。直接输出角度最简单,但后面会讲它在边界处有问题。四元数需要额外做归一化,且网络输出四个数。我在资源里实现的网络头是这样的:
import torch import torch.nn as nn import torchvision.models as models class DepthHeadPoseNet(nn.Module): def __init__(self, backbone="resnet18", num_outputs=3): super().__init__() if backbone == "resnet18": self.backbone = models.resnet18(weights=None) in_features = self.backbone.fc.in_features self.backbone.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) elif backbone == "mobilenet_v2": self.backbone = models.mobilenet_v2(weights=None) in_features = self.backbone.classifier[1].in_features self.backbone.features[0][0] = nn.Conv2d(1, 32, kernel_size=3, stride=2, padding=1, bias=False) else: raise ValueError("unsupported backbone") self.backbone.fc = nn.Identity() # 取特征 self.head = nn.Sequential( nn.Linear(in_features, 128), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(128, num_outputs), ) def forward(self, x): feat = self.backbone(x) y = self.head(feat) return y这里的num_outputs=3对应yaw/pitch/roll三个角。如果你要用四元数表示,就把num_outputs改为4,并在forward里加一行y = torch.nn.functional.normalize(y, dim=-1)。我测试过两者的差异:直接回归欧拉角在数据集上训练速度反而更快,但预测误差在边界角度时会突然跳变,后面避坑章节会详细分析。
3.2 损失函数:用欧拉角直接算还是换成向量
损失函数的定义直接决定模型的收敛行为。最直观的写法是计算预测角度和真值角度的L1差:
loss = torch.abs(pred[:, 0] - yaw_gt).mean() + \ torch.abs(pred[:, 1] - pitch_gt).mean() + \ torch.abs(pred[:, 2] - roll_gt).mean()这个写法看起来没问题,但欧拉角有周期性和万向锁问题。当yaw真值是179度,预测值是-179度,L1损失会是358度,而实际角度差只有2度。网络为了减小这个离谱的损失,会拼命把预测压到180度附近,然后陷入震荡。更严重的是pitch接近90度时,yaw和roll退化,坐标空间出现奇异点,损失就不连续了。
比较稳的做法是让网络回归“单位向量”。对于每个角度,预测两个值:正弦和余弦,然后通过atan2恢复角度。这样在边界处损失是平滑的。也可以直接预测四元数,再计算四元数之间的距离。但四元数有双重覆盖问题:q和-q表示同一个旋转,如果网络输出和真值差了一个符号,损失会被错误放大。我在资源里实现了“六维旋转向量”的形式,理论上更鲁棒,但实际训练中发现,用smooth L1直接对角度预测加一个周期性修正,也能达到不错效果。我提供的训练代码里,默认是向量回归:
def angular_loss(pred, target_deg): pred_rad = pred * torch.pi / 180.0 target_rad = target_deg * torch.pi / 180.0 diff = pred_rad - target_rad diff = torch.atan2(torch.sin(diff), torch.cos(diff)) # 把角度差映射到[-pi, pi] loss = torch.mean(torch.abs(diff) * 180.0 / torch.pi) return lossatan2(sin(diff), cos(diff))就是把角度差强制拉回到[-180, 180]区间,这样和179/-179的差值就变成2度,损失不会爆炸。我自己用这个损失函数替换直接L1后,测试集上的MAE降低了1.5度左右,而且训练震荡明显减少。
3.3 训练配置与评价指标
训练配置我习惯固定一套参数:优化器用Adam,初始学习率1e-3,权重衰减5e-4;batch size在单卡上设为32;使用CosineAnnealingLR把学习率降到1e-5。深度图像的数据量通常不大,像我手上的数据集差不多几万张,训练100轮左右足以收敛。如果出现过拟合,就把Dropout从0.3调高到0.5。这里有个细节:深度图像和RGB不同,不能像ImageNet那样用随机裁剪做数据增强,因为裁剪会改变头部在图像中的相对位置,导致姿态语义变化。我常用的增强是随机旋转小角度(比如±3度),随机缩放深度范围(比如乘上0.9到1.1的系数),以及随机遮挡一块矩形区域模拟空洞。
评估指标我不用分类准确率,而是用三个角度分别的MAE,以及一个综合指标:当yaw/pitch/roll三个误差同时小于5度时视为“正确”,计算准确率。这些指标直接反映真实应用中人脸朝向判定是否可用。参考计算代码:
def evaluate(loader, model, device, threshold_deg=5.0): model.eval() total_mae = [0.0, 0.0, 0.0] correct = 0 total = 0 with torch.no_grad(): for depth, ang_deg in loader: depth = depth.to(device) pred = model(depth).cpu() * 180.0 / torch.pi # pred 输出已经是角度,这里看模型怎么定义 for i in range(3): diff = torch.abs(pred[:, i] - ang_deg[:, i]) diff = torch.min(diff, 360 - diff) total_mae[i] += diff.sum().item() ok_mask = (torch.abs(pred - ang_deg) < threshold_deg).all(dim=1) correct += ok_mask.sum().item() total += depth.size(0) mae = [m / total for m in total_mae] acc = correct / total return mae, acc注意这里的diff也要做360度周期处理,否则0度和350度的差值会被算成350度。我在第一版评估代码里没做这个,导致测试集MAE虚高。训练时如果输入是归一化到[-1,1]的深度图,模型输出的是角度值,需要把输出范围限制好。我的实验里,模型头部的三个输出没有加激活函数,因为角度范围可以任意,只要后续计算损失时做周期映射即可。
4. 深度姿态估计常见问题排查与避坑
4.1 现象:训练损失下降但预测角度抖动厉害
刚开始训练时,loss稳定下降,测试MAE也在变小,但把预测结果画到视频流里,发现yaw角在±10度之间高频抖动,即使头部完全静止也一样。
原因:网络输出的是欧拉角,而欧拉角在定义域边界存在突变。当真实角度接近0度时,网络稍微输出一个-5度或+5度,这在数值上没问题,但模型对微小深度变化过于敏感,导致帧间噪声被放大。另一个原因是深度图本身存在时间噪声,同一个姿势下相邻帧的深度值有±5mm的波动。
解决:训练时不用纯L1,而是用上面的周期角度损失;同时在测试阶段加上时序平滑。我在资源里的推理脚本中内置了一个移动平均滤波器,系数设为0.6,效果很好。如果你不想动训练,也可以在输出端加一个低通滤波,不过这样会引入延迟。
4.2 现象:合成数据上很好,真实深度图上效果跳水
有人在Synthea生成的合成深度图上训练,准确率到了95%,一换到Biwi真实数据或自己用RealSense拍的数据,直接掉到50%以下。
原因:合成深度图的边缘过于锐利,噪声分布是均匀随机,而真实深度图有相机特定的噪声模式(比如边缘处的红外反射缺失、物体轮廓处的深度粘连),域偏移严重。网络在合成数据上学会了拟合锐利边缘,而不是通用的头部曲面形状。
解决:两个办法。一是在真实相机数据上做微调,哪怕只有几百张标注好的图,效果提升都非常明显;二是训练时对合成深度图做数据增强,加入高斯噪声、随机空洞、深度值量化误差,以及用真实相机的像素尺寸做模糊。我后来在预处理阶段加入了随机局部空洞,模拟真实传感器失效,让模型泛化好了很多。
4.3 现象:深度图黑边被当成特征,注意力错误
把某个测试深度图的可视化结果打印出来,发现网络关注的热力图集中在裁剪框边缘的黑边和空洞边界,而不是鼻子和下巴区域。
原因:预处理时没有有效填充空洞,或者填充后黑边仍然有深度跳变。ResNet第一层是7x7大卷积核,能轻易捕捉到这些高梯度区域,于是网络走捷径。这种情况在训练集也包含未填充空洞时尤其严重。
解决:在预处理里强制把无效值设为离中心点最近的深度值,或者直接用形态学闭运算填充。另外在训练数据增强时,随机生成一些空洞并填充,让网络学会忽略这些人工异常。我在预处理里加了一步:如果裁剪后的深度图里仍有超过5%的像素是0或65535,就丢掉这个样本不参与训练,防止网络学偏。
4.4 现象:欧拉角回归在正负90度附近损失爆炸
训练过程看起来正常,但到了yaw接近±90度时,loss突然剧烈震荡,模型无法收敛。
原因:欧拉角在±90度附近存在万向锁,pitch和roll的梯度互相干扰,且角度值的周期性导致损失函数在边界不光滑。比如yaw=89度,预测=-89度,周期映射后角度差是178度,但其实现实中可能是一个手势的后仰或低头动作,语义上两个姿态非常接近。
解决:网络输出改为预测四元数,或预测“正弦+余弦”的组合。我在资源里默认使用四元数输出,虽然损失计算复杂一点,但从根上避免了万向锁。如果你坚持用欧拉角,一个中间方案是把角度除以90度后限制在[-1,1]并用tanh激活,让输出平滑地靠近边界,后续再用atan2恢复。
4.5 现象:测试时图像缩放导致姿态整体偏移
同一段视频,把输入图像从224换成384后,预测的yaw角整体漂移了5度。
原因:深度图裁剪时没有保持头部区域的高宽比,直接resize拉伸了脸部几何。比如原本头部是20x25的矩形,resize到224x224后相当于横向拉伸了1.12倍,纵向压缩了1.0倍,这会让鼻子的深度梯度方向变形,产生类似yaw旋转的假象。
解决:裁剪时保持宽高比,如果裁剪框不是正方形,先按宽高比调整后短边补零,再做拉伸。代码里crop_size可以改为(h, w),并判断宽高比。我在推理时还会根据人脸检测框的中心和估计的头部半径动态设定裁剪框大小,而不是固定正方形,这样对侧脸尤其友好。
5. 再进一步:给头部姿态估计做时序平滑与自校验
深度图单帧姿态估计稳定后,要把结果真正用于实时系统,还得解决两个工程问题:帧间抖动和异常输出。帧间抖动的根源是深度噪声和网络对微小深度变化的放大,最有效的方法是卡尔曼滤波。虽然卡尔曼滤波听起来复杂,但在欧拉角上直接做几乎无效,因为角度的周期性会让yaw从179度跳到-179度,滤波器直接爆炸。我一般先把角度转成单位向量,对向量做卡尔曼滤波,再转回角度。这里有一个轻量级的滑动窗口平滑代码:
from collections import deque class AngleSmoother: def __init__(self, window_size=5, alpha=0.6): self.window = deque(maxlen=window_size) self.alpha = alpha self.smooth = None def update(self, yaw_deg, pitch_deg, roll_deg): if self.smooth is None: self.smooth = [yaw_deg, pitch_deg, roll_deg] else: for i, v in enumerate([yaw_deg, pitch_deg, roll_deg]): # 处理角度周期:差值映射到[-180,180]再平滑 diff = v - self.smooth[i] diff = (diff + 180.0) % 360.0 - 180.0 self.smooth[i] = self.smooth[i] + self.alpha * diff return self.smoothwindow_size其实没被用到,我习惯只用EMA的重心更新,alpha=0.6表示最近的帧占60%的权重。如果你希望更平滑就调小到0.3,但延迟会变大。注意代码里对角度差做了周期修正,这是很多平滑算法翻车的地方。
自校验则是一个更聪明的做法:既然我们已经有了深度图和姿态估计结果,就可以对深度图做一些几何验证。比如根据预测的yaw/pitch/roll旋转一个标准3D头部模型,把模型投影到当前深度图像平面,计算渲染的深度和实测深度之间的重叠面积。如果预测姿态正确,重叠度应该在90%以上;如果突然出现一个人为的转身或网络误判,重叠度会掉到50%以下。我用这个方式过滤了视频流里的异常帧,比单纯看置信度高可靠得多。验证流程是:先加载一个标准头骨深度模板,用预测的欧拉角旋转它,再用深度相机内参投影,得到每个像素的深度值,与当前帧深度比较,计算差值绝对值小于15mm的像素比例。这样做虽然增加了一点CPU开销,但能实时发现模型推理的异常。
从那以后,我每次做深度图像姿态估计,都会先在静态样本上跑通“预处理-训练-平滑-几何校验”的全流程,再上真机,不然后面排查代价太高。尤其是预处理的可视化,一定先把深度图、填充后的图、裁剪后的图分别显示出来看一遍,再开始训练。希望这些被真实场景磨出来的经验帮到你。
本文还有配套的精品资源,点击获取