1. 三合一识别到底在解决什么问题
第一次接触“人脸、微笑、口罩识别”这个组合需求,是在一个园区门禁改造的讨论会上。当时的需求方提得很直白:员工刷脸进门,同时要判断有没有戴口罩,没戴口罩的要提醒,另外还想统计一下员工进入园区时的情绪状态,微笑的比例有多少。三个识别任务,一个摄像头画面,要在一台边缘设备上跑完。
这个场景其实很有代表性。人脸识别解决的是“你是谁”,微笑识别解决的是“你状态怎么样”,口罩识别解决的是“你合规不合规”。三个任务共享同一张人脸图像,但各自关注的区域和特征完全不同。如果分别跑三个独立模型,推理时间叠加,帧率直接掉到个位数,体验就没法看了。所以核心问题不是“能不能识别”,而是“怎么在有限算力下把三个任务串起来,还能跑得动”。
我后来把这个方案复现了好几遍,从最开始的三个模型硬堆,到后来的共享骨干网络多任务输出,中间踩了不少坑。这篇文章就把整个拆解过程、技术选型逻辑、实操步骤和避坑经验完整分享出来。适合有一定深度学习基础、想做多任务视觉落地的开发者,也适合刚入门想了解人脸相关应用全流程的朋友。读完之后,你至少能搞清楚三件事:三个任务怎么共享计算、每个任务的精度怎么调、实际部署时哪些参数最影响体验。
2. 整体方案设计与技术选型思路
2.1 为什么不做三个独立模型
最直觉的做法是找三个现成的模型:一个人脸检测+识别模型,一个表情分类模型,一个口罩分类模型。每个模型单独推理,结果拼在一起输出。这个方案在开发阶段最省事,但一到部署就出问题。
我实测过一组数据:在同一个输入尺寸下,人脸检测模型单帧推理约18毫秒,表情分类约12毫秒,口罩分类约10毫秒。三个串行跑,单帧40毫秒,理论帧率25帧。但实际因为内存拷贝、预处理重复、后处理叠加,实测只有14帧左右。如果换成视频流,还要加上解码和显示的时间,最终掉到10帧以下,画面明显卡顿。
更关键的是,三个模型都要做人脸检测或者人脸对齐,这部分计算重复了三次。人脸检测本身是计算大头,重复三次等于白白浪费了两倍的算力。所以从工程角度,必须做模型融合或者至少做检测结果共享。
2.2 共享检测+多任务分支的架构
我最终采用的方案是“一次检测,多任务分支”。具体来说,用一个人脸检测网络先把人脸框出来,然后对每个人脸区域做对齐和裁剪,得到标准的人脸图像。这个图像同时送入三个轻量级分支:一个做人脸特征提取和比对,一个做微笑二分类,一个做口罩二分类。
这样做的好处很明显。检测只跑一次,对齐只做一次,三个分支共享同一张对齐后的人脸图。分支网络可以做得非常轻,因为输入已经是标准化的人脸,不需要再处理复杂背景。实测下来,整体单帧推理时间控制在22毫秒左右,帧率能稳定在30帧以上,满足实时性要求。
这里有个关键决策点:人脸识别分支和另外两个分类分支要不要共享骨干网络。我试过两种方案。方案A是三个分支完全独立,各自一个小网络。方案B是共享一个轻量骨干,然后接三个不同的头。方案B的参数更少,推理更快,但训练时三个任务的损失需要平衡,调参麻烦一些。方案A训练简单,但参数量大,推理稍慢。最终我选了方案B,因为部署端的算力实在太紧张了,参数少一点是一点。
2.3 检测网络的选择逻辑
人脸检测网络我对比了三种:基于锚框的、基于关键点的、基于中心点的。基于锚框的方案成熟稳定,但对小脸和遮挡场景需要精细调锚框参数。基于关键点的方案对口罩遮挡更鲁棒,因为关键点定位不依赖嘴部区域。基于中心点的方案后处理简单,但密集场景容易漏检。
考虑到口罩识别场景下,很多人脸被口罩遮住下半部分,基于锚框的方案如果锚框比例设置不当,容易把戴口罩的脸判为非人脸。我最后选了一个轻量级的基于关键点辅助的检测网络,它在训练时同时回归人脸框和五个关键点,推理时只用框,但关键点的监督信号让网络对遮挡更鲁棒。这个选择在实际测试中效果很明显,戴口罩人脸的检出率比纯锚框方案高了将近八个百分点。
2.4 微笑识别的技术路线
微笑识别本质上是一个二分类问题:微笑或者不微笑。但难点在于“微笑”的定义很主观,不同人的微笑幅度差异巨大。有人嘴角微微上扬就算微笑,有人咧嘴大笑才被标注为微笑。如果标注标准不统一,模型学出来的边界就很模糊。
我的做法是先用一个公开的表情数据集做预训练,然后在自己的业务数据上做微调。微调时特别注意标注一致性:我让标注人员统一标准,以“嘴角是否明显上扬且面部肌肉有收缩”为判断依据,避免主观偏差。网络结构上,我用了一个极轻量的卷积网络,输入64x64的灰度图,五层卷积加全局平均池化,最后接一个二分类头。这个网络参数量不到十万,推理时间不到3毫秒,精度在自建测试集上能到93%左右。
这里有个经验:微笑识别不要用太深的网络。深网络容易过拟合到身份特征,而不是表情特征。我试过一个ResNet18级别的骨干,结果模型在训练集上精度很高,但换一批人测试就掉得厉害。后来换成浅网络,加上数据增强里的随机裁剪和亮度扰动,泛化能力反而更好。
2.5 口罩识别的关键考量
口罩识别看起来简单,其实坑不少。第一个坑是口罩类型多样:医用外科口罩、N95、布口罩、甚至有人把口罩拉到下巴上。如果训练数据只包含标准佩戴的医用口罩,模型遇到其他类型就容易误判。第二个坑是遮挡程度:口罩遮住口鼻,但露出鼻子或者露出嘴巴的情况怎么算?业务上通常定义为“未正确佩戴口罩”,但模型需要能区分“完全遮住”“露出鼻子”“拉到下巴”这几种状态。
我的处理方式是把它做成三分类:正确佩戴、错误佩戴、未佩戴。训练数据里刻意包含了各种错误佩戴的样本,让模型学习区分。网络结构上和微笑分支类似,也是轻量卷积网络,但输入用了彩色图,因为口罩的颜色和纹理信息对区分类型有帮助。实测下来,三分类的精度比二分类低了两个点左右,但业务价值高很多,因为可以给出更具体的提醒。
3. 核心细节解析与实操要点
3.1 人脸检测与对齐的细节处理
检测阶段最影响后续精度的其实是对齐。很多人做完检测直接裁剪人脸框送进分类网络,这样做的问题是人脸角度、尺度、位置都不统一,分类网络需要额外学习这些变化,浪费容量。我的做法是用检测网络输出的五个关键点做相似变换,把人脸对齐到标准姿态。
具体来说,五个关键点通常是左右眼中心、鼻尖、左右嘴角。用这五个点计算一个仿射变换矩阵,把人脸旋转到双眼水平,缩放到固定尺寸,裁剪到112x112或者96x96。这个尺寸的选择有讲究:太大浪费计算,太小丢失细节。我实测112x112对于人脸识别足够,对于微笑和口罩识别,64x64其实就够了。所以我在对齐后做了两次裁剪:112x112送人脸识别分支,64x64送微笑和口罩分支。
对齐时有个细节容易被忽略:关键点检测本身有误差,如果直接用原始关键点做变换,遇到侧脸或者遮挡时对齐结果会抖动。我的做法是对关键点做时序平滑,在视频流里用前一帧的关键点做加权平均,权重根据检测置信度动态调整。这样对齐后的人脸序列更稳定,分类结果的抖动也明显减少。
3.2 多任务损失平衡的实操方法
三个分支联合训练时,损失函数是三个任务损失的加权和。权重怎么设?我试过几种方案。等权重最简单,但效果不好,因为三个任务的收敛速度不一样。人脸识别任务的损失通常下降最快,微笑和口罩分类收敛慢一些。如果等权重,训练后期人脸识别任务主导梯度,另外两个任务学不好。
我的做法是动态权重:训练初期三个任务等权重,让每个任务都充分学习。训练到一定轮次后,根据每个任务在验证集上的精度调整权重,精度低的任务权重调高。具体公式是权重正比于该任务当前误差的平方根。这样训练下来,三个任务的最终精度比较均衡,没有哪个任务明显拖后腿。
还有一个技巧是梯度裁剪。多任务训练时,某个任务的梯度突然变大可能会破坏其他任务已经学好的特征。我在共享骨干后面加了梯度裁剪,把每个任务回传的梯度范数限制在一个阈值内。这个操作很便宜,但训练稳定性提升明显。
3.3 数据标注与增强的注意事项
数据标注的质量直接决定模型上限。我踩过的坑是:一开始让标注人员同时标人脸框、关键点、微笑标签、口罩标签,结果标注一致性很差。后来改成流水线作业:第一组人只标人脸框和关键点,第二组人只标微笑,第三组人只标口罩。每组人只关注一个任务,标注质量明显提升。
微笑标注有个特殊处理:我让标注人员对每个样本打两个标签,一个是“是否微笑”,一个是“微笑置信度”。置信度分三档:明显微笑、轻微微笑、不微笑。训练时把置信度作为样本权重,明显微笑的样本权重高,轻微微笑的权重低。这样模型学到的边界更符合人的直觉。
口罩标注我加了“遮挡区域”的标注,就是口罩遮住了哪些部位。这个信息在训练时可以作为辅助监督,让模型关注正确的区域。比如正确佩戴口罩的样本,模型应该关注口鼻区域是否被覆盖;错误佩戴的样本,模型应该关注暴露的部位。加了辅助监督后,口罩分类的精度提升了大约三个百分点。
数据增强方面,我用了随机水平翻转、随机亮度对比度扰动、随机小角度旋转。特别注意不要用随机裁剪,因为裁剪可能把口罩裁掉或者把嘴角裁掉,导致标签错误。旋转角度也控制在正负十五度以内,角度太大对齐会失败。
3.4 推理加速的工程手段
模型训练完只是第一步,部署时的推理加速才是决定体验的关键。我用了几个手段。第一是输入分辨率分级:检测网络用320x320,对齐后人脸用112x112和64x64,都是小图,计算量可控。第二是批处理:视频流里一次取四帧做批推理,GPU利用率更高。第三是半精度推理:在支持半精度的设备上开启,速度提升约百分之四十,精度损失不到半个百分点。
还有一个容易被忽略的点是后处理优化。人脸识别需要比对特征,如果每次都用全量底库比对,底库大了之后耗时线性增长。我的做法是用近似最近邻搜索,先做粗筛再做精排。粗筛用哈希或者量化索引,把候选范围缩小到几十个,再做精确比对。这样即使底库有几十万人,单次比对也能控制在几毫秒内。
内存管理也很重要。视频流场景下,每帧都要分配和释放内存,频繁操作会导致内存碎片和延迟抖动。我的做法是预分配内存池,所有中间结果都从池里取,用完还回去。这个改动让帧率稳定性提升了很多,不再出现偶尔卡顿的情况。
4. 完整实操流程与核心环节实现
4.1 环境搭建与依赖安装
我用的开发环境是Python 3.8,深度学习框架选的是PyTorch,推理部署用的是ONNX Runtime。选ONNX Runtime的原因是跨平台支持好,从服务器到边缘设备都能跑,而且对半精度和批处理支持完善。
安装依赖的命令如下:
pip install torch==1.12.0 torchvision==0.13.0 pip install onnx onnxruntime-gpu pip install opencv-python numpy scikit-learn如果部署在边缘设备上,比如算力有限的嵌入式板子,建议用ONNX Runtime的CPU版本,配合量化后的模型。量化可以把模型大小压缩到原来的四分之一,推理速度提升两到三倍,精度损失通常在一个百分点以内。
注意:安装ONNX Runtime时要注意版本匹配。GPU版本需要和CUDA版本对应,版本不匹配会导致推理时直接报错。建议先确认设备的CUDA版本,再选择对应的ONNX Runtime版本。
4.2 数据准备与预处理脚本
数据目录结构我建议这样组织:
dataset/ train/ images/ labels.csv val/ images/ labels.csvlabels.csv的格式是每行一条记录:图片文件名、人脸框坐标、五个关键点坐标、微笑标签、口罩标签。关键点坐标按左眼、右眼、鼻尖、左嘴角、右嘴角的顺序排列。
预处理脚本的核心逻辑是读取图片和标注,做对齐和裁剪,保存成标准尺寸的人脸图。对齐用OpenCV的仿射变换实现:
import cv2 import numpy as np def align_face(image, landmarks, output_size=112): src_points = np.array(landmarks, dtype=np.float32) dst_points = np.array([ [38.29, 51.69], [73.53, 51.50], [56.02, 71.73], [41.54, 92.36], [70.72, 92.20] ], dtype=np.float32) dst_points = dst_points * (output_size / 112.0) tform = cv2.estimateAffinePartial2D(src_points, dst_points)[0] aligned = cv2.warpAffine(image, tform, (output_size, output_size)) return aligned这个脚本跑完,数据集里就是标准化的人脸图,后续训练直接读取即可。
4.3 模型定义与训练配置
共享骨干我用了MobileNetV2的前几层,输出一个128维的特征向量。然后接三个头:人脸识别头输出512维特征,微笑头输出2类,口罩头输出3类。
训练配置如下表:
| 参数 | 取值 | 说明 |
|---|---|---|
| 输入尺寸 | 112x112 | 人脸识别分支 |
| 输入尺寸 | 64x64 | 微笑和口罩分支 |
| 批大小 | 128 | 根据显存调整 |
| 初始学习率 | 0.001 | 余弦退火 |
| 训练轮次 | 60 | 早停耐心10轮 |
| 优化器 | AdamW | 权重衰减0.0001 |
| 损失权重 | 动态调整 | 初始1:1:1 |
训练时的关键代码片段:
for epoch in range(epochs): for images, labels in dataloader: feat = backbone(images) id_loss = id_criterion(id_head(feat), labels['id']) smile_loss = smile_criterion(smile_head(feat), labels['smile']) mask_loss = mask_criterion(mask_head(feat), labels['mask']) total_loss = w1*id_loss + w2*smile_loss + w3*mask_loss total_loss.backward() torch.nn.utils.clip_grad_norm_(backbone.parameters(), 5.0) optimizer.step() # 根据验证集精度更新w1, w2, w3 update_weights()4.4 模型导出与推理部署
训练完成后,把PyTorch模型导出为ONNX格式:
torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["id_feat", "smile_logit", "mask_logit"], dynamic_axes={"input": {0: "batch"}}, opset_version=11 )推理时的流程是:读取视频帧,检测人脸,对齐裁剪,批处理送模型,解析输出。人脸识别分支的输出和底库特征做比对,用余弦相似度,阈值设0.6左右。微笑和口罩分支取softmax后的最大值作为类别。
提示:导出ONNX时一定要指定dynamic_axes,否则批处理维度固定,部署时只能单帧推理,浪费算力。
4.5 阈值调优与业务适配
模型输出的是概率值,实际业务需要的是明确的判断。阈值怎么设?我的经验是不要用默认的0.5。微笑识别的阈值我设了0.65,因为业务上宁可漏报也不要误报,误报多了用户会觉得系统很烦。口罩识别的阈值设了0.7,因为漏报的代价是防疫风险,误报的代价只是多提醒一次,所以阈值偏高。
人脸识别的相似度阈值需要根据底库大小调整。底库小的时候阈值可以低一些,底库大了要调高,否则误识率会上升。我一般用ROC曲线找等错误率点,然后根据业务对误识和拒识的容忍度做微调。
5. 常见问题与排查技巧实录
5.1 检测不到人脸或频繁漏检
这是最常见的问题。排查思路按顺序来:先看输入图像质量,是不是太暗或者太模糊。如果图像质量没问题,再看检测网络的输入尺寸是不是太小,小脸在低分辨率下特征不明显。如果尺寸也没问题,检查锚框或者关键点回归的参数,是不是和实际人脸尺度分布不匹配。
我遇到过一次特殊情况:摄像头装的位置偏高,拍出来的人脸都是俯视角度,检测网络训练数据里缺少这个角度的样本,导致漏检。解决办法是在训练数据里补充俯视和仰视的样本,或者调整摄像头安装角度。
5.2 微笑识别误判率高
微笑误判通常有两个原因。一是训练数据里微笑和不微笑的样本不平衡,模型倾向于预测多数类。解决办法是做数据重采样或者用类别权重。二是标注标准不一致,模型学到的边界模糊。解决办法是重新审核标注,统一标准。
还有一个隐蔽的原因是身份泄漏:模型可能学到了“某个人经常微笑”这种身份相关的特征,而不是真正的表情特征。解决办法是在训练时做身份解耦,比如用对抗训练让特征对身份不敏感。这个操作复杂一些,但效果明显。
5.3 口罩识别对特殊口罩失效
如果训练数据里只有医用蓝色口罩,模型遇到黑色布口罩或者带图案的口罩就容易出错。解决办法是在数据增强里加入颜色扰动和纹理替换,模拟不同口罩的外观。另外可以在训练数据里主动收集各种类型的口罩样本,哪怕每种只有几十张,也能显著提升泛化能力。
5.4 多任务训练时某个任务精度上不去
这通常是损失权重的问题。某个任务权重太低,梯度被其他任务淹没。解决办法是动态调整权重,或者用梯度归一化,让每个任务的梯度范数在同一量级。还有一个可能是共享骨干容量不够,三个任务互相竞争特征。这时候可以考虑给每个任务单独加几层私有卷积,共享部分只保留底层特征。
5.5 推理速度不达标
速度问题按瓶颈定位。先用性能分析工具看时间花在哪里。如果是预处理慢,优化图像解码和缩放。如果是模型推理慢,考虑量化、剪枝或者换更轻的骨干。如果是后处理慢,优化比对算法或者用更快的索引结构。
我遇到过一次速度问题是因为内存拷贝:数据在CPU和GPU之间来回拷贝,耗时比推理本身还长。解决办法是把预处理也放到GPU上做,用CUDA加速的图像处理库,减少拷贝次数。
5.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决手段 |
|---|---|---|---|
| 漏检严重 | 输入分辨率低 | 可视化输入图 | 提高检测输入尺寸 |
| 误检多 | 阈值过低 | 看ROC曲线 | 调高置信度阈值 |
| 微笑误判 | 标注不一致 | 抽查标注样本 | 重新审核标注 |
| 口罩失效 | 训练数据单一 | 看混淆矩阵 | 补充多样本 |
| 速度慢 | 内存拷贝多 | 性能分析 | 预处理上GPU |
| 精度抖动 | 对齐不稳定 | 看关键点时序 | 加时序平滑 |
5.7 几个独家避坑技巧
第一个技巧:训练时保存每个任务的验证集精度曲线,不要只看总损失。总损失下降不代表每个任务都在进步,有时候一个任务变好另一个变差,总损失看起来在降,但实际业务效果在退化。
第二个技巧:部署前一定要做端到端测试,不要只测模型精度。我遇到过模型精度很高但端到端效果很差的情况,原因是预处理和后处理的实现有bug,比如对齐时坐标算错了,导致输入模型的人脸是歪的。
第三个技巧:底库特征要定期更新。人的外貌会变化,发型、体重、年龄都会影响人脸特征。如果底库长期不更新,识别率会慢慢下降。我的做法是每次识别成功后,把当前特征和底库特征做加权平均,缓慢更新底库,这样底库能跟上人的变化。
第四个技巧:戴口罩场景下,人脸识别的精度会下降,因为口罩遮住了下半张脸。如果业务允许,可以只用人脸的上半部分做识别,或者训练一个专门针对口罩遮挡的人脸识别模型。我实测过,用上半脸特征做识别,戴口罩情况下的精度比用全脸高了将近十个百分点。
6. 实际部署中的性能调优经验
6.1 批处理大小的选择
批处理能提升GPU利用率,但不是越大越好。批太大内存占用高,延迟也增加。我实测下来,批大小为4的时候,吞吐量和延迟的平衡最好。批大小为1时GPU利用率只有百分之四十左右,批大小为4时能到百分之八十,再往上提升不明显,但延迟线性增长。
如果业务对延迟极其敏感,比如要求单帧延迟低于20毫秒,那批大小只能设1或者2。如果对吞吐量要求高,比如要同时处理多路视频流,那批大小可以设8或者16,用延迟换吞吐。
6.2 模型量化的实操细节
量化是部署加速的利器,但操作不当会掉精度。我建议用训练后量化,先在小批量校准数据上统计激活值范围,再生成量化模型。校准数据要覆盖各种场景,不能只用简单样本。
量化后的模型在CPU上速度提升明显,但在某些GPU上可能没有加速效果,因为GPU对低精度计算的支持程度不一样。部署前一定要在目标设备上实测,不要想当然。
6.3 多路视频流的资源分配
如果要同时处理多路摄像头,资源分配很关键。我的做法是给每路视频流分配独立的推理线程,但共享同一个模型实例。模型实例内部做批处理,把多路视频流的帧拼成一个批送进去。这样GPU利用率高,而且各路之间互不阻塞。
内存方面,每路视频流预分配固定大小的缓冲区,避免动态分配导致的内存碎片。缓冲区大小根据分辨率和帧率计算,留百分之二十的余量。
6.4 长时间运行的稳定性保障
系统跑几个小时之后变慢或者崩溃,通常是内存泄漏或者温度降频。内存泄漏用工具排查,看哪些对象没有释放。温度降频在边缘设备上很常见,解决办法是加散热片或者降低推理频率。
我还遇到过一种情况是模型推理结果逐渐漂移,原因是底库特征在持续更新,但更新策略有问题,导致底库特征越来越偏离真实分布。解决办法是给底库更新加约束,比如限制每次更新的幅度,或者定期用全量数据重新计算底库特征。
7. 后续扩展方向与个人体会
这套三合一识别的框架其实可以继续扩展。比如加一个年龄和性别识别分支,或者加一个视线方向估计分支,都是共享同一个骨干,边际成本很低。每加一个分支,参数量增加很少,推理时间增加一两毫秒,但业务价值可能很大。
另一个扩展方向是把识别结果做时序分析。单帧的微笑识别可能抖动,但如果看连续几十帧的微笑比例,就能判断一个人是持续微笑还是偶尔微笑。这个时序信息在业务上更有价值,比如可以统计某个区域的整体情绪氛围。
我在实际部署中最大的体会是:精度和速度的平衡不是一次性能搞定的,需要反复迭代。先跑通流程,再优化速度,最后调精度。不要一开始就追求极致精度,那样可能选了一个很重的模型,后面速度优化非常痛苦。反过来,先选轻量模型跑通,再根据实际效果决定要不要换更重的模型,这样迭代成本最低。
还有一个体会是:业务理解比技术选型更重要。微笑的定义、口罩佩戴的标准、人脸识别的安全等级,这些业务问题想清楚了,技术方案自然就清晰了。如果业务需求模糊,技术做得再好也可能白费功夫。