多任务人脸分析:疲劳、表情与年龄性别预测的实战指南

📅 发布时间:2026/10/21 22:41:35
多任务人脸分析:疲劳、表情与年龄性别预测的实战指南
把疲劳检测、表情识别、年龄性别预测塞进同一个摄像头里这句话听起来像是产品经理随口提的愿景但真正落过地的人知道它背后是三个模型、三份推理延迟、三套维护成本以及一个永远对不齐的输出节奏。我前前后后做过几个人脸智能分析系统从司乘疲劳预警到门店客群分析最后得到的结论是这三个任务不该被拆开做但也绝不能简单焊在一起。这篇文章想把我在选型、训练、部署和踩坑过程中积累下来的东西讲清楚希望能帮到正在做人脸视觉应用、或者想从跑通开源Demo迈向能上线系统的开发者。先说清楚这套系统是什么输入一路摄像头视频流输出画面中每个人的疲劳状态、表情分类、年龄段和性别。三个任务共享同一个人脸检测器和特征提取主干各自接一个轻量输出头最终在同等硬件上能跑到实时甚至准实时。适合谁看如果你手里已经有一个能跑通的人脸检测模型想往上叠加更多属性分析或者你正在评估多任务人脸分析方案到底划算不划算这篇文章应该能给你省一周的调研时间。1. 为什么要把三个任务做进同一套推理管线1.1 场景需求反推系统设计很多人一开始会这样想疲劳检测是一个模型表情识别是一个模型年龄性别再是一个模型反正现在框架这么成熟三个模型串起来跑不就行了。表面上确实可以但你看实际业务场景就知道问题在哪。以常见的司机状态预警为例摄像头装在驾驶室A柱附近光线从正午烈日到凌晨隧道全程都能遇到驾驶员还可能戴眼镜、戴口罩、偶尔扭头看后视镜。这时候如果只是把三个模型顺序执行每一帧都要先做人脸检测再分别跑三个模型。哪怕每个模型只花20毫秒算上检测和预处理单帧总耗时很容易超过120毫秒甚至更多。在连续视频流里意味着实际帧率不到10疲劳判定的时序信息已经被拖垮了。1.2 三个模型各自为战的问题拆成三个模型部署问题还远不止速度。首先是工程复杂度。每个模型都有自己的预处理逻辑有的要112x112有的要224x224有的要归一化到[-1,1]有的要保持RGB顺序三套数据流同时存在代码里到处都是分支判断。其次是内存占用。三个模型放在GPU里显存至少多吃几百兆放在边缘设备上更是分分钟爆掉。最后是结果同步问题。三个模型独立跑各自检测到的人脸框位置还不一定完全一致你想把某一帧的疲劳结果和表情结果关联到同一个人脸上先得多写一段人脸对齐的逻辑。1.3 多任务不是银弹要看清收益边界但反过来说这三个任务有一个天然优势它们吃的都是同一张对齐后的人脸图像。检测是共享的特征提取是共享的只是最后输出头不同。这种结构带来的直接好处是一次特征前向就能同时产出三组结果速度变成加法而不是乘法。而且多任务训练本身有正则化效果年龄和性别预测会迫使骨干网络学到更丰富的人脸结构特征表情识别也能从中受益这在样本量不大的时候尤其明显。当然你也要清醒一点多任务共享只在任务输入高度一致的时候划算。如果你硬要把车型识别、车牌识别这种完全不同的输入塞进来共享特征层反而会打架性能两头都掉。所以这个思路适用于人脸这个域内的多个任务不要无脑扩张。2. 检测器、骨干网与推理框架的选型逻辑2.1 人脸检测器速度与小人脸精度的平衡这一层是整个系统的地基。检测器选不好后面的疲劳、表情、年龄全都在摇晃。早期项目里我试过MTCNN部署方便、速度快但它的定位精度在侧脸、低头、逆光场景下明显不够回头你提取眼周关键点时点位飘得很厉害。后来换到RetinaFace精度确实好但对边缘设备来说还是偏重。最近几个项目我比较倾向SCRFD这类轻量级高精度检测器它把重参数化和解耦头玩得比较明白在CPU上的推理时间能做到和MTCNN一个量级精度却高出一截。如果你用的训练框架是PyTorch导出ONNX很顺滑部署也不费劲。我有一个建议送给新人别一上来就追检测器的极致精度。对这套系统来说只要检测框不整体跑偏到背景上后续关键点和分类头的修正空间都很大。真正要命的是检测器在低分辨率下丢人脸一丢就是整帧全丢。2.2 关键点数量与任务匹配做疲劳检测不一定需要很多关键点。很多人看到MediaPipe的468点就会觉得越多越精确但在遮挡场景下点数越多越容易有某个点突然飞掉给你的眼周横纵比计算带来一个莫名其妙的尖峰值。我现在做疲劳检测基本只用68点方案里眼周6个点、嘴周几个点就够了轻量又稳定。表情、年龄、性别任务也一样它们关心的是整张脸的纹理和结构对齐阶段用5点关键点做仿射变换到标准位置即可不需要高密度的点云。这里的取舍逻辑一句话关键点数量应该由任务精度和稳定性共同决定而不是越多越好。你在模型选型时如果看到两个关键点模型精度差不多优先选点数少、速度快、对遮挡鲁棒的。2.3 骨干网络与三个轻量输出头的设计骨干网络我建议从MobileNetV3或GhostNet系列里选。做实时人脸属性分析不需要上ResNet50这种级别的网络。MobileNetV3在ImageNet上的精度可能不算顶尖但提炼人脸特征给下游三个头用完全够。头的设计要留意一个细节三个任务不要共用同一个全连接层输出各用各的独立小网络。我在项目里是这样分的在骨干网络最后一层特征图之后分别接三个并行的分支。疲劳分支只取人脸中心区域眼部和嘴部的特征表情分支取全局特征年龄性别分支也取全局特征。这样既共享了底层计算又保留了各任务关注区域的不同。2.4 推理框架选型参考推理框架决定了同一份模型在不同硬件上跑多快。我做过的几个系统里纯CPU部署且目标是Intel平台首选OpenVINO对自家CPU的图优化做得最狠。公司内已有NVIDIA GPU或者要跑在工控机加显卡的环境TensorRTFP16精度下速度能再翻一翻。要跨Windows、Linux、ARM多平台ONNX Runtime最省心生态全踩坑最少。调试阶段大家通常都用PyTorch跑但真上线我一般导出ONNX再做平台适配。ONNX不仅仅是格式转换你还可以在中间插入一些图优化比如把BatchNorm折叠进卷积、去掉冗余的Transpose。这些优化看着不起眼在边缘设备上能省出20%到30%的时间。3. 疲劳检测的核心算法与报警状态机设计3.1 眼睛纵横比最经典的闭眼信号疲劳检测里最经典、也最稳的一个特征就是眼睛纵横比Eye Aspect Ratio, EAR。它的思路非常直白睁眼时眼睛高度和宽度的比值稳定闭眼时比值急剧下降。用68点关键点的话眼睛区域取6个点。EAR公式是[ EAR \frac{|p_2-p_6| |p_3-p_5|}{2 |p_1-p_4|} ]其中p1和p4是眼角的两个点p2到p6依次是上下眼睑的轮廓点。左右眼各算一份取平均就行。我实际项目里的代码长这样import numpy as np from scipy.spatial import distance def eye_aspect_ratio(eye_landmarks): # eye_landmarks: 6个关键点坐标 vertical_1 distance.euclidean(eye_landmarks[1], eye_landmarks[5]) vertical_2 distance.euclidean(eye_landmarks[2], eye_landmarks[4]) horizontal distance.euclidean(eye_landmarks[0], eye_landmarks[3]) ear (vertical_1 vertical_2) / (2.0 * horizontal) return ear正常情况下EAR大概在0.25到0.3之间晃闭眼时会掉到0.2以下甚至更低。但这里有个新手必踩的坑阈值不是固定的。摄像头安装高度、人脸距离、镜头畸变都会改变EAR的静态分布。我后来采取的做法是初始化阶段取前100帧眼睛睁开时的EAR平均值作为基准再在这个基准上乘一个0.6到0.7的系数作为当前环境的闭眼阈值。这样换摄像头、换安装位置都不用重新调参。3.2 PERCLOU标准别只看单帧闭眼只看单帧EAR有一个问题正常人每分钟眨眼十几次每次眨眼持续100毫秒到200毫秒。如果你把单帧EAR小于阈值当成疲劳信号那每隔几秒就会触发一次假报警整个系统根本没法用。所以要用PERCLOS也就是单位时间内眼睛闭合时间所占的百分比。疲劳检测领域常用的E70或E80标准核心思想是记录眼皮遮住瞳孔超过70%或80%的事件统计它们在单位时间内的累计时间占比再和经验阈值比较。具体实现上我会维护一个固定时间窗口比如60秒窗口内的每帧都算出EAR标记为闭眼或睁眼累积闭眼帧数除以总帧数就是PERCLOS值。疲劳阈值通常定在0.4到0.5意思是60秒里有超过40%的时间处于闭眼或半闭眼状态基本可以判定为疲劳。# 简化的PERCLOS窗口统计 ear_history deque(maxlen150) # 假设30FPS下5秒窗口 ear_threshold adaptive_threshold() # 前面提到的自适应阈值 for frame_ear in video_stream: ear_history.append(frame_ear) close_frames sum(1 for v in ear_history if v ear_threshold) perclos close_frames / len(ear_history) if perclos 0.4: trigger_fatigue_alert(疲劳)3.3 哈欠与点头两个重要的补充特征单纯靠眼睛判断疲劳在强光下司机戴墨镜、或者低头角度大导致眼部特征被遮挡时就会失效所以要加两个补充特征。第一个是嘴部纵横比Mouth Aspect Ratio, MAR。哈欠的典型特征是嘴部高度突然增大且持续至少1到2秒。计算方式和EAR类似取嘴周几个关键点的纵横比。需要注意区分哈欠和说话说话时嘴部纵横比变化频率快、波形密集哈欠则是单一宽脉冲。我在工程上会加一个持续时间约束只有嘴部持续张开超过0.5秒才可能算哈欠。第二个是头部姿态估计。通过人脸关键点配合solvePnP算法可以估算出头部的俯仰角、偏航角和翻滚角。疲劳时驾驶员点头动作会呈现出周期性的低头和恢复表现为俯仰角反复小幅波动。但这里有个坑驾驶员看手机、捡东西时也会低头。所以单纯低头不算要看短时间内的重复频率。我之前定的经验值是连续5分钟内低头次数超过8次且伴随其他疲劳信号才上报。3.4 状态机与防抖把单帧信号变成稳定事件把上面的特征都算出来之后不要直接根据数值触发报警。真实场景里每一帧的特征值都会有抖动单帧误判的比例不低。正确做法是引入状态机让系统从离散指标中提取出稳定的事件。我的状态机定义大致是这样正常状态、预警状态、确认疲劳状态、恢复状态。在正常状态下某一帧出现EAR低于阈值、或者嘴部张开幅度较大只是进入预警状态并不触发报警。只有在预警状态下连续若干帧比如0.5秒以上持续满足疲劳特征才切换到确认疲劳状态并触发报警。报警后需要一个冷却时间比如2分钟内不重复报警避免同一个疲劳事件被反复刷屏。这种状态机设计的好处是疲劳信号天然是持续性的单帧抖动被窗口过滤掉同时报警输出也规范了很多。你接到的是一个一个的离散事件而不是一条连续的触发流对接下游短信、语音提醒、后台看板都方便。4. 表情识别从分类标签到现场置信度的落地思路4.1 数据集选择与类别不均衡表情识别最常见的做法是分成7类生气、厌恶、害怕、开心、伤心、惊讶、中性。这个体系主要继承自FER2013数据集。FER2013最大的问题是标注噪声大图像质量也参差不齐拿它训练出来的模型在实验室数据集上还行一到真实摄像头就原形毕露。我后续项目里更偏好用RAF-DB或者AffectNet。RAF-DB是真实场景的标注带有人脸框和质量标签比较贴近实际部署环境。AffectNet数据量大但要自己处理类别不均衡其中中性和开心占了绝大多数厌恶害怕等类别样本非常少。类别不均衡会直接导致模型在推理时把大多数样本都预测成高频类别。我处理的方案有两个一是在采样时按类别权重过采样让低频类别也能被充分看到二是在损失函数里给每个类别加权重misclassified的惩罚和样本数量成反比。如果你觉得麻烦至少把权重加上效果立竿见影。4.2 训练细节标签平滑与数据增强自然表情在类别边界上是模糊的比如惊讶和害怕在表情肌运动上就有重叠。如果训练时用硬标签强逼模型输出0或1模型的置信度会变得非常极端稍有扰动就大幅反转。我习惯在训练时做标签平滑把真实标签从1改成0.9剩余0.1平均分给其他类别。这个操作代价最小但能让模型输出概率分布更平滑推理时抗干扰能力强不少。数据增强方面也有讲究。常见做法是随机裁剪、水平翻转、颜色抖动、高斯模糊。针对真实场景我会额外加一部分随机遮挡增强模拟口罩、刘海、帽子对脸部的遮挡。还有一个小技巧在训练样本里模拟摄像头常见的偏色和低照度。真实摄像头和开源数据集的色彩分布差异很大提前在训练期把这种差异打进去能显著减少部署时的掉点。4.3 部署形态单帧分类必须要做时序平滑表情识别在部署时的最大陷阱是单帧模型太敏感。人在自然对话过程中表情本来就是连续变化的每一帧的表情类别跳来跳去如果直接把这个结果送给业务方他们会觉得这个系统完全不可用。解决方案就是做时间上的平滑。我在表情分支的输出层之前加了一个简单的时序缓冲维护最近5帧的表情概率向量逐类别取平均再取最大值对应的表情作为当前结果。这一个小小的改动能让表情分类变得稳定非常多。需要注意的代价是响应延迟变大了大约会滞后3到5帧但对于绝大多数业务场景完全可接受。另外还要说清楚一个认知边界表情识别结果不等于情绪状态。模型输出的是这张脸上最接近哪一类表情而不是这个人心里在想什么。做司乘系统或者门店服务评估时这个区分一定要提前跟业务方掰扯清楚否则后面验收时会被反复挑战。4.4 现场表现为例为什么中性和开心占大多数我去过一个做门店客流分析的项目业务方一开始希望模型能识别顾客的满意度。结果现场采集的数据统计出来大部分人脸在绝大多数时间都是中性和开心状态生气、厌恶这些类别的出现频率极低。这其实才是真实分布不是模型坏了。这种情况下光看表情分类对业务没有太大价值。我后来把策略调整成只统计特定表情事件比如微笑持续时间占比皱眉出现频率用这些事件化的指标来评估服务质量。这个思路同样适用于你的项目——表情识别的产出最好是事件和趋势而不是单个标签值。5. 年龄与性别预测序数回归、标签噪声与实测干扰5.1 年龄为什么不能直接当回归任务做年龄预测最直观的建模方式就是回归输入人脸图像输出一个年龄值用L1或者L2损失去拟合。听起来很合理但实际训练出来的模型经常会出现一个现象对于同年龄段的人脸预测值会疯狂波动30岁的人有时候能被预测成20有时候被预测成40。原因在于相邻年龄的人脸特征差异非常小而回归损失把每一个年龄差异都当成同等的误差在惩罚模型根本分不清19岁和20岁的边界在哪里。相比之下把年龄当成分类问题来建模反而更稳输出一个向量每个位点代表一个年龄的概率最后取期望值或者最大概率年龄。工程上更推荐把年龄划分成几个区间比如常见的儿童、少年、青年、中年、老年几档因为业务方关心的一般是群体画像而不是某一个人的精确年龄。5.2 标签平滑怎么做才算对年龄分类的标签平滑要特别注意标签顺序的关系。FaceID相关的论文里常用一个做法把真实年龄附近的年龄概率也设成正数离真实年龄越远概率越低。我用过一个很简单的实现给真实年龄前后各一个标签分配少量概率比如真实年龄25岁时标签设定为24、25、26分别对应0.1、0.8、0.1的概率。这样模型预测25岁和26岁之间的边界不会变得太绝对而且相邻年龄的预测分布既平滑又合理。年龄段分类相对简单直接在区间粒度上做。可以参考公开的年龄段体系把0到100岁粗略分成8到12个区间。隐藏的技巧是训练时的软标签也要按区间跨度的距离来衰减这样模型能学会50岁的人和40岁的人之间出了错相对可以谅解这个逻辑。5.3 性别与年龄联合训练的好处性别和年龄不是完全独立的同一个年龄段的男性和女性衰老特征曲线完全不同。这也是多任务系统的一个隐藏加分项共享特征提取层让年龄分支在推理时可以隐式利用性别信息性别分支也能利用年龄信息。我在训练时会让性别分支和年龄分支共享骨干网络的浅层和中层只在最后两个分支各自用少量卷积层全连接层独立预测。损失函数按比例加权性别损失和年龄损失的权重初始设为1:1训练到中间阶段再适当降低年龄权重避免年龄损失主导整个模型。5.4 实测干扰与掉点应对年龄性别预测在真实场景里最容易被以下几样东西干扰妆感会显著影响年龄判断大概率会把女性年龄往小里判墨镜和口罩会拿掉脸部大量判别性特征性别分支在只剩半张脸时表现退化大幅度侧脸和低头会明显加大误差不同人种和不同光照下的年龄感知偏差也一直存在。项目里如果业务方要求必须在戴口罩场景下做年龄性别预测我一般给出的建议是把预期指标主动下调。遮挡下人脸本身的信息量就是不够的任何模型都无法凭空补出被遮住的信息。你宁可把置信度阈值调高、输出无法判断的比例高一些也不要用一个低置信度结果去污染统计报表。6. 工程整合跳帧检测、异步推理与性能优化6.1 单线程串行处理的死结把检测、对齐、三个分支推理、跟踪、报警全部写在同一个循环里的系统我见过太多。这种写法在演示Demo时没问题一旦进入真实部署任何一步慢下来都会拖垮整条链路。问题出在串行依赖上如果这一帧的检测耗时突然增加那么后续的疲劳判定、表情输出和报警全都会跟着延迟。视频流还讲究时序一致性延迟高到一定程度疲劳时长统计就失真了。我的建议是把整个处理流程拆成独立线程采集线程只管收帧和放时间戳检测线程负责低频率的人脸检测属性分析线程从检测结果里取人脸框做对齐和推理最后状态机线程消费属性结果输出事件。线程之间用带时间戳的队列传递避免互相等待。6.2 跳帧检测与追踪ID绑定人脸检测在整条链路里占用的耗时最大。一个常见优化是跳帧检测不是每一帧都做人脸检测而是每3帧做一次中间的帧用跟踪算法来延续人脸位置。跟踪用简单的IoU关联加Kalman滤波就够如果场景里人特别多、经常交叉可以上ByteTrack这类 MOT 扩展。但跳帧检测有一个副作用新进入画面的人可能要被等一两帧才会被发现。对疲劳报警这种场景问题不大对需要极速响应的场景就要权衡。我一般会根据实际帧率动态调整跳帧频率端到端帧率高于20时跳3帧低于12时跳1帧甚至不跳。多人场景下人脸框必须和跟踪ID绑定。否则一旦检测器漏检了一帧重新检测出来的人脸如果匹配错了ID疲劳状态和表情状态就会张冠李戴报警和报表都可能出错。这个细节在单目标Demo里根本体现不出来但在一路摄像头下坐着四五个人的客车上你会庆幸提前做了ID绑定。6.3 模型导出与量化FP16和INT8怎么选部署阶段我基本不用PyTorch原生推理统一导出ONNX。导出时最需要注意的是动态轴设置否则后面一换输入分辨率或者batch size模型就报错。量化方面我的经验是FP16对精度影响极小能开就开INT8省得更多但它对网络每层的敏感度不一样。在整套系统里我观察到人脸检测和年龄性别分支做INT8量化之后掉点在可接受范围内但疲劳检测涉及的关键点精度对量化非常敏感眼周坐标稍微偏移1到2个像素EAR值就可能产生质变。所以最终方案里检测器和分类头走FP16关键点相关的部分宁可保留FP32或者单独做一个高精度分支。6.4 一组实测性能参考下面是我在一套模拟项目X的参考数据硬件配置分别是一台普通办公笔记本CPU和一块入门级GPU。跑的是640x480输入检测跳3帧属性分析每帧跑部署环境端到端FPS单帧p95延迟CPU/GPU占用备注CPU-only OpenVINO14280msCPU约60%属性分支用FP16CPU-only ONNX Runtime10340msCPU约55%交叉平台测试用入门级GPU TensorRT3855msGPU显存约1.2GB检测与属性均FP16数据只是一份基准参考不同硬件差异很大。但你可以看到趋势同一份模型不同推理后端和硬件组合下能差到3到4倍。上线前一定要在你实际要用的机器上测一遍不要信别人报的任何数字。7. 上线前绕不开的五个实测坑7.1 报警反复横跳第一个上线系统时我们报警逻辑做得太粗暴某一帧EAR低于阈值就拉响警报。结果测试员只是正常眨眼系统就不停报警。后来加了状态机和窗口统计情况好了很多但要提醒你窗口长度也不是越大越好。窗口太长真实的疲劳事件会被淹没在正常状态里报不出来。我之前调过的参数是闭眼持续0.5秒以上算一次预疲劳配合冷却时间2分钟。7.2 帧率不稳对疲劳判定是致命的疲劳检测的所有时序指标都严重依赖稳定帧率。如果系统在CPU负载高的时候掉到8帧PERCLOS的统计窗口就会拉伸闭眼时长也会被压缩原本算好的报警阈值全部失效。解决办法有二一是尽可能稳定帧率宁可统一抽帧到10FPS做疲劳分析也不要让它忽快忽慢二是所有判定都要基于时间戳而不是帧数。例如判定闭眼持续0.4秒应该记录这一帧的时间戳用时间差来判断而不是依赖连续12帧闭眼。时间戳方案天然抵抗帧率波动。7.3 眼镜、墨镜与反光戴眼镜的人镜片反光经常会让眼周关键点在几个像素范围内来回横跳。单纯的EAR值偶尔会出现一个巨大尖峰帧率和状态机都可能被误导。处理手段我排过优先级优先用关键点置信度过滤当眼周点置信度低于阈值时这一帧的EAR不参与统计再不行就上红外摄像头红外下眼镜反光和可见光完全不同也是一个靠谱的后备方案。单纯加大图像平滑不能根治因为反光带来的跳动既随机又剧烈。7.4 多人同框时的ID绑定问题单摄像头多人的场景比想象中常见网约车后排、门店闸机、教学场景全都是多人同框。如果检测和跟踪做得不够稳同一个人脸上的属性标签会频繁串号。我的做法是用一个轻量跟踪器维护每个人脸的最近N帧位置和外观特征每帧检测结果先和现有轨迹做IoU匹配匹配不上的再按外观相似度匹配仍然匹配不上才创建新轨迹。这一套逻辑在普通CPU上额外耗时不超过2毫秒但对整体稳定性的提升是质的。7.5 开源测试集和现场数据的差距最后说一个所有做视觉落地的开发者都躲不开的问题模型在FER2013、Adience这类公开测试集上可能已经很漂亮了但一放到现场摄像头底下准确率能跌十个点以上。差距来源主要就是数据域迁移你的是低照度、广角畸变、运动模糊的摄像头画面数据集里是网络爬来的正脸图。我的体会是项目上线前一定要预留一到两周做现场数据采集哪怕只采几百张用半自动方式做标注然后用这些数据对模型做微调效果都会好非常多。这一步没有捷径越多现场数据模型越不容易翻车。7.6 顺带说一句别忽略摄像头安装角度的细节这个看起来最简单却经常被排在最后才处理。摄像头装在头顶俯拍和装在正前方平视模型看到的完全是两个世界。头部姿态估算、关键点坐标、表情幅度全都会变。每次改动摄像头安装位置都要重新采集一批数据验证不要认为模型会自动适应。你宁可多花半天调整安装角度让正脸占比尽量大后面的算法会省心很多。这套系统做到这个阶段留给我的一个深刻体会是多任务人脸分析真正难的不是某个模型的精度而是从单模型到多任务系统的组织和稳定性。疲劳、表情、年龄性别三者共用一条管线牵一发而动全身。如果你也想做类似项目我的建议是从最小闭环开始先串通检测加一个任务跑通状态机和报警链路再逐步把表情、年龄性别分支叠进去。每一步的小改动都要回到真实场景里验证这个习惯替我省掉了无数次返工。