简介:面向计算机视觉与深度学习方向的研究者和开发者,这份头部姿态估计专题文献包聚焦从图像或视频推断头部三维角度与位置的核心问题,覆盖虚拟现实、自动驾驶、智能监控与人机交互等应用背景。包内共5个文件,以4篇PDF论文和1份XML文档为主,压缩包约13.11MB,PDF便于系统阅读方法细节,XML文档则补充结构化信息,方便归档检索。已有352人学习下载,具备一定领域关注度。内容涉及卷积网络与迁移学习、传统特征与深度特征、3D几何模型、开源代码、MSE与角度误差等评估标准,也讨论光照、遮挡、表情变化带来的挑战及未来趋势;其中无关键点细粒度估计、四元数姿态回归、野外场景姿态估计等代表性工作,可帮助读者快速梳理2015—2018年前沿进展,为算法选型、实验设计和项目落地提供参考。
1. 头部姿态估计:为什么“三个角度”比一整张人脸更难估
头部姿态估计(head pose estimation)常被当作人脸检测的附赠功能:人脸框已经出来了,顺便回归三个角度就行。实际顺序恰好相反:检测框偏几个像素没人在意,姿态偏 10° 在注意力判断里就是完全相反的结论。更麻烦的是输出空间——yaw 从 +179° 到 −179° 只差 2°,数值上却是 358 的跳变;roll 接近 ±90° 时另外两个角开始互相纠缠。这类“标签本身带拓扑结构”的回归任务,损失函数、数据分布、后处理三个位置都容易出错,而且错误很隐蔽:loss 曲线看起来很漂亮,指标就是上不去。下面按“角度怎么表示 → 两条路线怎么算 → 标签怎么造 → 哪里会出错”的顺序拆开。
2. 旋转的三种写法:欧拉角为什么会在 ±180° 处翻车
网络吐出三个浮点数,标签也是三个浮点数,看起来就是最普通的回归问题,搭个 L2 损失就能训。前几个 epoch 的 loss 确实降得漂亮,然后验证集 MAE 卡在 9° 附近不动,某几个样本的预测值还会在 +170° 和 −170° 之间来回跳。这不是模型容量不够,是欧拉角这种表示方式本身的问题——它把一个环形的连续空间强行拉直成一条线段,头尾接不上。
头部姿态的欧拉角约定通常是 yaw(绕垂直轴左右转头)、pitch(绕横轴点头)、roll(绕前后轴歪头)。三个角的组合顺序必须显式固定,是 XYZ 还是 ZYX,是内旋(绕自身轴)还是外旋(绕世界轴),结果完全不同。同一组角度值,换一种顺序解释,画出来的姿态能差出几十度。很多“训练正常但精度就是不行”的现场,最后都挖出标签生成端和训练端的顺序不一致。这件事没有中间态,要么对齐,要么全线崩。
2.1 万向节锁与角度回绕:L2 损失在边界处发生了什么
先说回绕。标签是 +179°,预测是 −179°,肉眼一看只差 2°,但 L2 给出的惩罚是 (358)²,比预测成 0° 的惩罚大三个数量级。网络为了降低平均损失,会做一件很“聪明”的事:把所有接近边界样本的预测往 0 的方向拉。结果就是边界附近的样本被系统性地压扁,模型在大角度上永远欠拟合。这个问题在训练集里大角度样本少的时候格外明显,因为梯度被大量正面样本稀释了,少数边界样本的异常梯度只表现为“训练有点抖”。
再说万向节锁。当 pitch 接近 ±90° 时,yaw 轴和 roll 轴在空间上重合,三个自由度退化成两个,同一个姿态对应无穷多组 (yaw, roll) 组合。此时标签在数值上是不唯一的,网络面对完全相同的输入却被要求输出不同的值,只能学出一个“平均值”,误差天然存在。真实场景里人很少把头顶到 90°,但合成数据会——随机采样姿态时如果不做限制,一定会造出贴着奇异点的样本,然后这些样本就成为训练集里的噪声源。
还有个更不易察觉的点:同样的角度误差,在不同姿态下代表的物理偏差不一样。yaw 差 5° 在正面时只是轻微偏头,在侧脸 80° 时可能意味着关键点已经完全跑到轮廓另一侧。用统一的 L2 损失,等于假设整个姿态空间的误差是等价的,这个假设本身就不成立。
2.2 SO(3) 上的测地线距离:把损失定义在旋转矩阵上
绕开这个问题的办法是换表示。旋转矩阵 R 是 3×3 正交阵、行列式为 1,它和真实旋转一一对应,没有奇异点,也没有回绕。两个旋转之间的“角度差”可以直接算:R_pred 转置乘 R_gt 得到一个相对旋转,它的迹和旋转角 θ 满足 tr = 1 + 2cos θ。这个 θ 就是测地线距离,也是真正该被优化的量。
import numpy as np def geodesic_loss(R_pred, R_gt, eps=1e-7): # R_pred / R_gt: (N, 3, 3),都是合法的旋转矩阵 # tr(R_pred^T @ R_gt) = 1 + 2 * cos(theta) Rt = np.transpose(R_pred, (0, 2, 1)) trace = np.trace(np.matmul(Rt, R_gt), axis1=1, axis2=2) cos_theta = np.clip((trace - 1.0) / 2.0, -1.0 + eps, 1.0 - eps) return np.mean(np.arccos(cos_theta)) # 返回弧度 def chordal_loss(R_pred, R_gt): # Frobenius 弦距离,与测地线单调等价,但没有 arccos 的梯度奇点 return np.mean(np.linalg.norm(R_pred - R_gt, axis=(1, 2)))geodesic_loss里的eps截断不能省。arccos 的导数是 −1/√(1−x²),当两个旋转几乎相同时 x→1,梯度直接冲向无穷大。训练初期预测值还很随机,问题不大;到了收敛阶段,大量样本的 θ 接近 0,这个梯度尖峰会让优化器反复越过最优点,表现为 loss 在最后一位小数上抖个不停。chordal_loss是工程上更常用的替代:弦距离是测地线的单调函数,最小值位置一样,但它在 θ=0 和 θ=π 两端都平滑,梯度有界。
用旋转矩阵当回归目标还有个隐性成本:网络要输出 9 个数,而这 9 个数必须满足正交约束。直接回归再做事后正交化(SVD 取最近的正交阵)是常见做法,但正交化本身不可导,训练和推理之间会有一道缝。比较讲究的实现会在损失里加一项正交性惩罚,或者干脆用四元数。四元数是 4 维单位向量,天然满足约束,代价是双覆盖——q 和 −q 表示同一个旋转,直接对四元数做 L2,网络会在符号翻转的位置收到巨大惩罚。处理办法是取内积的绝对值,或者训练前按符号对齐。
提示:如果沿用弦距离训练,评估时仍然要用 arccos 算出真实角度误差再报指标。弦距离的数值没有物理单位,直接当 MAE 报出去会得到一个看起来很小但毫无意义的数字。
2.3 分 bin 分类加期望回归:工程上最稳的折中
还有一条路,把连续回归改造成分类。把 360° 切成若干个 bin,网络输出每个 bin 的概率,推理时取 softmax 期望当作角度值。这个思路朴素但极其有效,因为它从根上消灭了回绕:+179° 和 −179° 落在相邻的两个 bin 里,分类损失完全不知道它们数值上差了 358,只当是两个不同的类别。
import numpy as np BIN_WIDTH = 3.0 # 每格 3 度 NUM_BINS = int(360 / BIN_WIDTH) # 120 个 bin,覆盖整个圆周 def bin_centers(): # bin i 的中心角度,映射到 [-180, 180) return (np.arange(NUM_BINS) + 0.5) * BIN_WIDTH - 180.0 def to_bin_index(angle): # 连续角度 -> bin 索引,取模保证 +179.9 和 -179.9 落在相邻 bin idx = np.floor((angle + 180.0) / BIN_WIDTH).astype(np.int64) % NUM_BINS return idx def soft_argmax_angle(logits): # logits: (N, NUM_BINS) logits = logits - logits.max(axis=1, keepdims=True) p = np.exp(logits) p = p / p.sum(axis=1, keepdims=True) # 每个样本一个 120 维分布 centers = bin_centers()[None, :] return (p * centers).sum(axis=1), p # 期望角 + 分布本身期望回归的精度能超出 bin 宽度,前提是概率分布跨两个相邻 bin 呈钟形——此时加权平均会落在两个 bin 中心之间。但如果网络训得太“自信”,分布尖锐地集中在一个 bin 里,输出就只能等于该 bin 的中心值,精度退化到 BIN_WIDTH 的一半。所以实践里会在期望值后面再接一个残差回归头,最终角度 = 期望角 + 残差,残差只需要在 ±1.5° 的小范围内学习,难度低得多。
代价是这套方案会多出两件事:bin 数量和 bin 宽度的权衡(bin 太细,分类样本不均衡且边界样本多;bin 太粗,残差头负担重),以及相邻 bin 之间的标签平滑——因为角度是连续的,把 89.9° 硬打成第 90 个 bin 的 one-hot,会让网络在边界处学到不必要的锐利边界。常见的缓解是对相邻两个 bin 做软标签。
3. 两条解法:关键点加 PnP 与端到端回归各自在算什么
损失函数解决了“怎么比”,还没解决“从什么算”。目前主流就两条路线:一条是先用关键点检测拿到人脸上一组 2D 点,再和 3D 模板做配准反解姿态;另一条是让网络直接从图像回归角度。这两条路线的误差结构完全不同,选型时最该看的不是论文里的平均精度,而是误差在什么条件下会失控。
3.1 六点 PnP:从 2D 关键点反解刚体变换
PnP(Perspective-n-Point)解决的是一个几何问题:已知 n 个 3D 点和它们在图像上的 2D 投影,求相机的位姿,也就是旋转加平移。放到头部姿态上,3D 点是人脸模板(鼻尖、双眼外角、两嘴角、下巴这些位置固定的参考点),2D 点是检测器给出的对应像素坐标。
import numpy as np # 3D 人脸模板参考点,单位毫米,头部坐标系,原点取鼻尖 MODEL_POINTS = np.array([ [-32.0, 0.0, 0.0], # 左眼外角 [ 32.0, 0.0, 0.0], # 右眼外角 [ 0.0, 0.0, 0.0], # 鼻尖 [-28.0, -35.0, 6.0], # 左嘴角 [ 28.0, -35.0, 6.0], # 右嘴角 [ 0.0, -60.0, 12.0], # 下巴 ], dtype=np.float64) def estimate_head_pose(landmarks_2d, camera_matrix, solver): # landmarks_2d: (6, 2),顺序必须与 MODEL_POINTS 严格一一对应 ok, rvec, tvec = solver.solve( object_points=MODEL_POINTS.reshape(-1, 1, 3), image_points=landmarks_2d.reshape(-1, 1, 2), camera_matrix=camera_matrix, flags=solver.SOLVEPNP_EPNP, # 闭式解,不需要初值,不卡局部极小 ) if not ok: return None R, _ = solver.rodrigues(rvec) # 旋转向量 so(3) -> 旋转矩阵 SO(3) return R, tvec几个参数值得掰开看。MODEL_POINTS的尺度其实不影响旋转结果——把整个模板等比放大,等价于把平移量同比缩放,旋转的解不变。所以模板用毫米还是用归一化坐标都行,但平移向量的物理含义会跟着变,做距离判断时不能混用。真正影响旋转精度的是模板里点的相对位置是否贴合真实人脸:拿一个成年人的模板去解算儿童的脸,或者用中性表情的模板套在夸张表情上,会引入系统性偏差。
SOLVEPNP_EPNP只需要 4 个以上点就能给出闭式解,适合点少、怕初值不好的场景。缺点是闭式解对噪声的抑制能力弱,实际部署里常见做法是先用它拿初值,再切到迭代法做几轮 LM 精化。迭代法需要至少 6 个点,这也是“六点法”这个名字的由来——够用、稳定、代价可控。
最容易出错的地方在最后一行。PnP 解出的 R 是“模板坐标系到相机坐标系”的变换,而欧拉角该从哪个方向取、图像坐标系 y 轴朝下导致 pitch 符号要不要翻,这两件事一旦搞反,训练出来的关键点检测器和姿态解算之间就永远差一个转置。验证方法很简单:拿一张正面正对相机的图,正确解算出来的三个角都应该接近 0;如果 pitch 是 180°,那就是方向反了,取 R 的转置即可。
3.2 端到端回归:输出维度怎么和坐标系绑定
端到端这条路线省掉了模板和内参这两个依赖。骨干网络抽完特征,接一个回归头直接出 3 个数,或者出 9 个数(旋转矩阵)再正交化,或者出 120×3 个 bin 的 logits 走上一章的期望回归。结构上没有秘密,难点全在“网络看到的东西”和“标签定义的东西”之间的一致性上。
一个反直觉的点:裁剪(crop)本身不改变头部旋转。把头从整图里裁出来送进网络,旋转还是那个旋转,所以理论上训练和推理用不同的裁剪策略不会导致标签错误。但实际会——裁剪比例、是否保持长宽比、padding 填什么颜色,都会改变网络看到的上下文范围,进而改变特征分布。训练时用 1.5 倍人脸框、推理时用 1.2 倍,同一个头姿在两种视野下的表观大小和边缘截断情况完全不同,模型的输出会系统性偏移几度。这类误差不会在训练日志里暴露,只会在换预处理的那次发版后突然出现。
回退到前面说的两种表示:如果输出欧拉角,网络必须在输出层隐式学会“这个角度是环形的”这件事,而全连接层做这件事非常吃力,所以才会需要 bin 分类那种显式结构。如果输出旋转矩阵,回归头要拟合的是 9 个强相关的数值,多余的三个自由度会让训练变慢,但收敛后的精度往往更稳。
另一个容易被忽略的细节是辅助监督。单纯回归角度,梯度信号非常稀疏——整张图只有一个监督值。常见做法是加一个关键点热图的辅助分支,让骨干网络在学姿态的同时被迫关注五官位置,这种多任务结构在小数据集上提升明显。代价是标注成本翻倍,而且关键点标注的噪声会同时污染两个任务。
3.3 误差来源拆解:两条路线分别在什么条件下失控
把两种方案的误差拆开看,会发现它们应对的是完全不同的场景。
| 维度 | 关键点 + PnP | 端到端回归 |
|---|---|---|
| 额外依赖 | 3D 模板、相机内参 | 无 |
| 大角度表现 | 一侧关键点被遮挡后急剧退化 | 取决于训练集是否有大角度样本 |
| 精度上限 | 受关键点定位精度限制 | 受标签噪声和分布覆盖限制 |
| 误差可解释性 | 高,能逐点回看 | 低,只能看最终角度 |
| 单帧耗时 | 两段式,多一次解算 | 一次前向 |
PnP 的失效模式很集中:关键点飘了,姿态就飘,而且姿态误差和关键点误差之间是放大关系。在 yaw 超过 60° 之后,一侧眼睛和嘴角基本不可见,检测器只能靠先验“猜”,猜出来的点位置偏差可能到 10 个像素以上,反解出来的角度直接崩掉。这也是为什么做 PnP 方案时,遮挡处理比精度优化更值得投入。
端到端回归的失效模式更分散:一个方向是长尾分布压不住,头部从没偏向某一侧,模型就永远学不会那一侧;另一个方向是过拟合到背景和光照,换一个场景就退化。它没有显式的几何约束兜底,所有合理性都来自数据本身。好处是对遮挡更宽容——只要训练集里有足够多的侧脸样本,网络能从剩余可见区域推断姿态,这一点比 PnP 强不少。
4. 标签从哪来:合成数据如何把大角度样本造出来
头部姿态的标注成本比想象中高。让人工去标一张侧脸图的三维角度,误差通常在 5° 到 10°,比模型本身的误差还大。所以这个领域长期依赖合成数据,做法是把一张正面人脸拟合成可驱动的三维模型,然后按已知角度渲染出各个姿态的图像。标签是渲染参数直接给的,精确到小数点后好几位。
4.1 三维拟合加随机渲染:把角度当成生成参数
流程大致分三步。先用一个三维形变模型(把形状和表情拆成若干基向量,通过拟合系数还原具体人脸)对输入的正脸做拟合,得到带纹理和几何细节的网格。再从一个预设的角度分布里采样一组 (yaw, pitch, roll),对这个网格做刚体旋转。最后用弱透视或者完整透视投影把网格投到图像平面,加背景、加光照,输出图像和对应的旋转矩阵。
采样分布的设计直接决定模型的能力边界。均匀采样看起来公平,但会让接近万向节锁的样本占比过高,前面说过那批样本的标签本身不稳定。更常见的做法是按区间加权,大角度侧脸多给一些样本——因为真实场景里正面样本满地都是,侧脸样本稀缺。yaw 覆盖到 ±90° 是基本要求,pitch 到 ±45° 就够用了,再往上人体的生理结构本身就不自然。
这里真正的风险是域间隙。合成图像的纹理来自单张正面照,光照模型很简化,背景通常是纯色或简单噪声。网络在合成数据上训到 3° 的误差,换到真实监控画面里可能直接 15°。业界通行的做法是混合训练:合成数据占大头负责覆盖角度空间,真实标注数据虽然量少,但负责纠正表观分布。再加一层风格增强——随机改光照方向、加运动模糊、贴真实背景——缩小两边的统计差异。
还有个不容易发现的问题:合成图像里人脸总是居中、尺度统一、几乎不旋转出画面。网络很容易学到“脸在哪”这个捷径,而不是真的在看五官形变。一旦推理时人脸偏出画面中心,精度就掉。缓解办法是渲染时随机平移和缩放,把位置信息打散。
4.2 标签对齐:一个转置能毁掉整轮训练
合成数据的标签是旋转矩阵,训练目标可能是欧拉角、四元数或者分 bin 的索引。每一次转换都是一次出错机会,而且错了之后模型照样能训,只是精度上不去,非常难查。
需要逐项确认的至少有这几条:旋转顺序是 XYZ 还是 ZYX;是内旋还是外旋;给出的是“世界到相机”还是“相机到世界”(两者互为转置);欧拉角单位是度还是弧度;图像坐标系 y 轴朝下,pitch 的正负方向要不要翻转;roll 的取值范围是 [−180, 180] 还是 [0, 360]。
一个很实用的自检办法:构造几个已知答案的极端样本——正脸朝前、纯 yaw 90°、纯 roll 45°——把整条标签流水线跑一遍,看输出和预期是否一致。这件事花十分钟,能省掉后面两天的排查。我见过最典型的翻车是标签生成端用了“相机到世界”,训练端按“世界到相机”解析,模型训出来的东西在验证集上看着还行(因为验证集走了同一条错误链路),一上真实数据整体偏转,像是头一直歪着。
4.3 指标怎么读:MAE 会骗人,尤其是在长尾分布上
| 指标 | 衡量什么 | 什么情况下会骗人 |
|---|---|---|
| 三角度平均 MAE | 整体偏移水平 | 正面样本占绝大多数时,尾部误差被稀释掉 |
| 阈值准确率(误差 < 15°) | 工程可用率 | 阈值宽松,10° 和 14.9° 都算对,掩盖分布形状 |
| 按 yaw 分桶的 MAE | 长尾表现 | 桶内样本太少时方差极大,单次结果不可信 |
| 跨数据集 MAE | 泛化能力 | 两边标签约定不一致时,这个数完全没有意义 |
最该警惕的是只报一个平均 MAE。头部姿态的误差分布是典型的长尾,正面样本误差 3°,侧脸样本误差 20°,平均下来 6°,看起来很不错,但真实业务里出问题的全是那批侧脸。分桶统计加阈值准确率一起看,才能判断模型到底能不能用。
跨数据集对比还有个前提:两个数据集的真值定义得一致。不同来源的标注在坐标系约定、角度范围、甚至“什么算 0°”上都有差异,直接相减得到的数字可能大部分来自约定差异而不是模型能力。做跨集评估前,先用一批重叠样本对齐一下偏移量,或者干脆只用相对排名做比较。
5. 排查记录:训练不收敛与角度跳变的五个现场
下面这些是从实际调试里攒下来的,按发生阶段分。每一条都遵循同一个模式:先看到什么现象,再往下挖到什么原因,最后怎么解决的。
5.1 训练阶段:loss 在降,指标不动
现象一:训练 loss 平稳下降,验证集 MAE 卡在 9° 左右,怎么调学习率都不动。
原因基本可以锁定在标签坐标系上。训练集和验证集的标签来自同一条流水线,所以两边一致,loss 该降还是降;但这条流水线本身可能和图像内容差了一个转置或者一个符号。模型学到的是一个自洽但错误的映射,在自家验证集上表现正常,换一批数据立刻露馅。
解决方式:拿十个样本,人工确认姿态方向(比如“这张是向左转头 30°”),然后把预测值打印出来对比。如果发现 yaw 符号全反,就是转置问题;如果 pitch 恒定偏 180°,是旋转方向搞反。确认之后统一在标签生成端修正,不要在网络输出端打补丁,否则后面每换一个模型都要重新打一次。
现象二:某些 batch 的 loss 突然飙到正常值的几十倍,然后又回落。
这是典型的角度回绕。那批样本的标签刚好在 ±180° 附近,预测落在另一侧,L2 给出了一个天文数字。梯度回传之后把这一批的权重带偏,下一个 batch 又拉回来,表现为周期性震荡。
解决方式:把损失换成测地线或者弦距离,或者在算 L2 之前先做角度归一化(把差值映射到 [−180, 180])。前者更彻底,后者改动量小但只对欧拉角有效,且归一化本身在边界处不可导,还是会留下一个小尖峰。
现象三:模型在正面样本上误差 3°,在侧脸样本上误差 25°,且侧脸样本的 loss 几乎不下降。
两个可能叠在一起:训练集里侧脸样本太少,以及侧脸时关键点被遮挡导致特征质量差。前者是分布问题,后者是信息问题。
解决方式:先按 yaw 分桶统计样本量和 MAE,确认是不是长尾。如果是,加合成数据补足角度分布,同时用加权采样让侧脸样本在 batch 里的占比提上来。如果样本量已经够了但误差还是大,检查一下裁剪策略——侧脸时人脸框会变得很窄,固定比例的 padding 可能把半个头切掉,网络看到的信息本来就不完整。
5.2 推理与部署:换台机器精度就变
现象四:同一段视频,在不同分辨率的摄像头上跑,姿态角整体偏移 5° 到 8°。
如果走的是 PnP 路线,八成是内参没跟着换。相机内参和分辨率强相关,换分辨率不重新标定,焦距和主点还是旧的,解出来的旋转就有系统偏差。如果走的是端到端路线,问题出在预处理:人脸框的绝对像素尺寸变了,缩放之后的表观清晰度和纹理细节跟着变,模型没见过这个尺度。
解决方式:PnP 路线强制要求内参随分辨率一起更新,或者干脆用弱透视近似,把焦距设成图像宽度的常数倍,牺牲一点精度换鲁棒性。端到端路线把预处理固定死——不管输入多大,统一 resize 到固定尺寸再裁剪,让网络看到的永远是同一种尺度。
现象五:离线评估 4° 误差,上线之后角度高频抖动,帧间跳变能到 10°。
这是单帧独立预测的必然结果。模型在相邻两帧上的输入差异极小,但输出的角度会因为特征里的微小扰动而跳动,肉眼看就是“抖”。这不是模型精度问题,是缺少时序约束。
解决方式:加滤波,但要注意滤波的对象。直接对欧拉角做低通,在 ±180° 附近会炸;正确做法是对旋转做平滑——四元数用 slerp 插值,旋转向量做加权平均,或者干脆对旋转矩阵做正交化后的插值。滤波强度是个取舍,下一章细说。
6. 平滑的代价:实时系统里单帧结果该信几分
滤波这件事,做与不做带来的效果差得很大,但做得过头会把系统变得不可用。我一般用 One Euro 滤波器作为默认起点,它的思路是让截止频率随信号速度自适应:头不动的时候用很低的截止频率把抖动压死,快速转头的时候把截止频率提上来,减小相位滞后。
import math class OneEuroFilter: def __init__(self, freq=30.0, min_cutoff=1.0, beta=0.02, d_cutoff=1.0): self.freq = freq # 采样率,必须和实际帧率一致 self.min_cutoff = min_cutoff # 静止截止频率:越小越稳,越大越跟手 self.beta = beta # 速度系数:越大,快速运动时滞后越小 self.d_cutoff = d_cutoff self.x_prev = None self.dx_prev = 0.0 @staticmethod def alpha(cutoff, freq): tau = 1.0 / (2 * math.pi * cutoff) te = 1.0 / freq return 1.0 / (1.0 + tau / te) def __call__(self, x): if self.x_prev is None: self.x_prev = x return x dx = (x - self.x_prev) * self.freq a_d = self.alpha(self.d_cutoff, self.freq) dx_hat = a_d * dx + (1 - a_d) * self.dx_prev cutoff = self.min_cutoff + self.beta * abs(dx_hat) # 速度越大,截止频率越高 a = self.alpha(cutoff, self.freq) x_hat = a * x + (1 - a) * self.x_prev self.x_prev, self.dx_prev = x_hat, dx_hat return x_hat三个参数各有自己的作用范围。min_cutoff决定静止时的抖动幅度,调到 0.5 以下会明显变“肉”,转头时像在水里;beta决定跟随性,调到 0.1 以上基本等于没滤波;freq必须和真实帧率对上,填错了滤波器的时间常数会整体偏移,表现为延迟忽大忽小。这三个数没有普适值,得按业务对延迟的容忍度来定——注意力监测能忍 100ms 的滞后,交互类场景超过 50ms 用户就能感觉到。
真正的取舍在平滑对象上。对欧拉角逐个滤波实现最简单,但只要有一个角跨过 ±180°,滤波器会把这次跳变当成巨大的速度,beta项瞬间把截止频率拉满,等于这一帧完全不滤波,抖动原样透传。更稳的做法是把旋转转成四元数,用 slerp 做插值平滑,或者对旋转向量滤波后再转回矩阵——后者在角度接近 180° 时也需要小心,因为旋转向量在那附近不是连续的。
还有一个不那么技术但更重要的判断:什么时候该不平滑。如果系统要判断的是“有没有转头”这种瞬态事件,滤波会把事件的上升沿抹平,等到输出超过阈值时,动作已经发生了。这种场景下我一般会保留一路未滤波的原始值,专门喂给事件检测,滤波值只用于显示和连续量的统计。两路并行,成本很低,但能避免那种“看着一直在看路,其实已经走神两秒了”的问题。
从那以后,我每次接头部姿态相关的活,都强制先用一批已知姿态的样本把标签流水线走一遍——正脸、纯 yaw 90°、纯 roll 45°,三个样本跑通,再谈模型。这一步花的时间,比后面调参省下来的多得多。希望帮到你。
本文还有配套的精品资源,点击获取