人脸识别这四个字写进项目标题时通常只有一行,真正把它跑到设备上,麻烦是从联调那周开始冒出来的。我前后做过几轮类似的东西,最早的一版是拿 Python 写的原型,笔记本上跑得挺好,精度看着也漂亮,结果往边缘设备上一放,帧率掉到个位数,内存还在缓慢上涨,跑一晚上就自己退了。后来下决心用 C++ 重写了一版,从零搭检测、对齐、特征、比对这四段链路,才算把延迟和内存压到能接受的范围。这篇就聊这件事:用 C++ 从头实现一个人脸识别系统,包含技术路线怎么选、每个环节的参数怎么定、代码怎么落地、上线后会踩哪些坑。适合已经会一点 C++、想把人脸识别真正做成一个能跑的程序而不是跑个 demo 的人看;也适合手上有个 Python 原型、正准备往工程上迁的同行。全文不堆概念,重点放在“为什么这么选”和“实测下来是什么表现”。
1. 项目整体设计与技术路线取舍
动手之前最该想清楚的其实不是代码,而是这条路走不走得通。人脸识别在学术上是“检测 + 对齐 + 表征 + 度量”四件事,在工程上还得加上“采集、质量筛选、特征库、阈值策略、性能预算”这五件。很多项目之所以半路崩掉,不是模型不行,是一开始就把预算算错了——比如默认觉得“反正模型都是现成的,调个库就行”,真到设备上才发现一次推理要八十毫秒,摄像头三十帧,根本来不及。
1.1 为什么最终选了 C++ 而不是继续用脚本语言
最开始那版原型是脚本语言写的,好处是试错快,换个模型、调个阈值、画个可视化,几下就改完。问题出在三个地方,而且这三个问题在脚本语言里基本无解,只能换语言。
第一是启动开销和常驻内存。脚本解释器本身就要吃掉几十兆,加上图像处理库、推理后端、各种动态链接,一个空进程还没开始干活就占了两三百兆。这类程序往往是常驻的,设备内存本来就紧张,剩下的余量一压缩,推理时申请大块内存就容易失败。
第二是延迟的确定性。脚本语言的垃圾回收不是你能控制的,它在什么时候触发、停多久,你说了不算。人脸识别很多时候是跟着视频流走的,一帧一帧处理,最怕的不是平均慢,是偶尔卡一下。我实测过同一段视频,脚本版本平均每帧三十五毫秒,但会周期性地冒出两百多毫秒的尖峰,画面就一卡一卡的。C++ 版本平均二十八毫秒,波动基本在正负三毫秒以内,这个差别在实时预览上是肉眼可见的。
第三是和底层设备的对接成本。摄像头取流、硬件编码、某些专用加速接口,往往只提供 C 接口。脚本语言要靠一层绑定去调,中间的拷贝和类型转换很烦,出问题还很难定位。C++ 直接对着头文件写,虽然啰嗦,但每一步发生了什么你心里是有数的。
所以结论不是“C++ 更高级”,而是这个场景对延迟确定性和内存可控性有硬要求,脚本语言在这两点上天然吃亏。如果只是做个离线批量跑图的工具,脚本语言依然是更划算的选择,没必要为了情怀上 C++。
1.2 三条主流实现路线的横向对比
真到写代码这一步,第一道选择题是“模型和推理引擎怎么来”。市面上大致三条路,各有各的脾气,我把当时对比的情况整理成表,你可以对着自己的约束条件看。
| 路线 | 典型组成 | 优点 | 代价 | 适合场景 |
|---|---|---|---|---|
| 传统特征 + 分类器 | 检测用级联分类器,特征是人工设计的纹理描述子 | 依赖极轻,几百 KB 就能跑,CPU 单核可实时 | 精度上限低,侧脸、遮挡、光照变化基本失效 | 教学演示、资源极端受限的嵌入式 |
| 深度学习全家桶 | 轻量检测网络 + 特征提取网络,用通用推理后端 | 精度和鲁棒性明显好,模型可替换 | 需要额外模型文件,推理有固定开销 | 绝大多数实际项目 |
| 自研训练 | 自己标数据、自己训 | 可针对特定人群和场景优化 | 周期长、门槛高,数据量不够反而更差 | 有明确垂直场景且有数据积累 |
第一条路我踩过。当年用级联分类器做人脸检测,正面、光线均匀、不戴眼镜的情况下召回还不错,一旦人稍微侧头或者逆光,框就飘了或者直接漏检。它的问题不在于算法本身老,而在于它本质上是在匹配一组固定的明暗模式,对姿态和光照没有建模能力。用它做人脸检测还行,用来做识别基本没戏。
第二条路是绝大多数项目的实际选择。检测和特征提取各用一个预训练模型,中间用 C++ 把它们串起来,自己做对齐、做特征库、做阈值策略。这条路的好处是把最难的模型训练部分绕开了,你只需要把工程做好。缺点是模型是黑盒,某些特定场景(比如戴口罩比例很高的人群)效果会打折,得靠后处理去补。
第三条路只在一种情况下值得走:你有一个非常明确的垂直场景,公开模型在这个场景上系统性失效,而你手上又有几千张以上带标注的图。否则自研训出来的模型大概率还不如公开的预训练模型,白花几个月。
我当时选的是第二条,理由很朴素:项目周期三个月,团队两个人,没有标注资源,公开模型在测试集上的表现已经够用,剩下的精力全部投在工程优化上。
1.3 分层架构与数据流
确定路线之后,我把整个程序拆成了五层,这个拆法后来被证明是省时间的,因为每一层都能单独测。
最底下是采集层,负责从摄像头或者视频文件拿帧,做基本的解码和缩放。这一层要注意的是别在采集线程里做任何耗时的事,拿到帧就丢进队列,越快越好。
往上是检测层,输入一张图,输出若干个矩形框加关键点。这一层的输出是后面所有环节的入口,框错了后面全错,所以它值得你花最多的时间去调。
再往上是对齐层,根据关键点把脸“摆正”,裁成固定尺寸。这一步很多人会跳过,觉得检测框直接裁下来送去提特征就行,实测精度差得挺明显,后面会细说。
然后是特征层,把对齐后的人脸图喂给特征模型,得到一个定长的浮点向量。这一步是纯计算,没有状态,可以放心并行。
最上面是比对与业务层,拿着向量去特征库里查,算相似度,跟阈值比,输出“是谁”或者“不认识”。
数据流是这样的:采集线程持续产帧,检测线程从队列取帧做推理,拿到人脸后交给工作线程池做对齐和提特征,最后统一送到比对模块。这个结构的关键在于每一层之间用队列解耦,某一层偶尔慢了不会把整条链路堵死,最多是队列变长然后丢帧。丢帧在人脸识别里是可以接受的,因为人不会在一帧里出现又消失。
2. 一条完整链路的四个核心环节
架构是骨架,真正决定效果的是四个环节的具体做法。这一章我按数据流动的顺序讲,每一段都会说清楚“为什么这么做”和“不这么做会怎样”,参数部分给出我实际用的值,但你要理解这些值的来由,直接抄不一定适配你的场景。
2.1 人脸检测:别一上来就堆重模型
检测环节的目标很明确:在一张任意大小的图里找出所有人脸的位置。听起来简单,实际选型的时候有两个误区。
第一个误区是觉得模型越大越准。大模型确实在困难样本上更强,但它的推理时间是线性增长的,而检测是每帧都要跑的。摄像头三十帧,你给检测留的预算最多十五毫秒,超了这个数整条链路就跟不上。我见过有人拿一个比较大的检测网络做实时,单帧检测就要一百多毫秒,最后只能靠降分辨率硬扛,结果小脸全丢了,得不偿失。
第二个误区是忽略输入分辨率。检测模型通常有个固定的输入尺寸,比如 320×320 或者 640×640。你把一张 1920×1080 的图缩到 320×320,人脸在缩放后可能只有二十几个像素,特征已经糊掉了,检测自然就漏。正确做法是先按比例把图缩小到一个合理的范围,让画面里的人脸保持在 40 到 200 像素之间,再送进检测器。这个范围是根据模型训练时的尺度分布来的,太小了大感受野抓不住,太大了浪费算力。
我实际用的配置是这样:检测输入固定 320×320,置信度阈值 0.9,非极大值抑制阈值 0.3,最多保留 5000 个候选。这三个数里,置信度阈值调到 0.9 是因为业务上宁可漏检也不要误检——误检一个框,后面会白白算一次特征,还可能污染特征库;漏检只是少一帧,下一帧还能补上。NMS 阈值 0.3 是常规值,太大会把相邻的两张脸合并成一个框,太小会把同一张脸留下好几个重叠框。
还有一个容易被忽略的点:检测器实例的输入尺寸是可以动态设置的。很多实现里创建检测器时就固定了尺寸,之后每帧都要缩放到这个尺寸。如果你的视频分辨率固定,直接按视频尺寸设置;如果是变分辨率的流,每帧设置一次也无妨,开销可以忽略。但如果同一帧要检测多次,记得只在尺寸变化时才重新设置,避免重复的内部缓冲分配。
2.2 对齐与预处理:被低估的精度开关
这一步是整条链路里最容易被省掉的,也是我实测收益最大的。
人脸检测给出的只是一个轴对齐的矩形框,但人的头是会歪的。假设同一个人,一张照片头正着,另一张歪了十五度,两张图裁出来的区域内容其实是错位的,眼睛、鼻子、嘴巴在像素上的相对位置对不上。特征提取模型是在大量对齐过的人脸上训练的,你给它一张歪的,它也不是完全认不出,但特征质量会明显下降,表现为同一个人的两张图相似度从 0.7 掉到 0.45 这种。
对齐的做法是:检测器除了给框,还会给五个关键点(左右眼、鼻尖、左右嘴角),用这五个点算一个仿射变换或者相似变换,把脸“转正”并缩放到固定尺寸。五点的选择是有讲究的——两只眼睛能定旋转和尺度,鼻尖和嘴角能定俯仰和偏航的粗略关系。点数越多越准,但五点已经能覆盖大部分场景,而且检测器本来就会输出,成本为零。
固定尺寸一般取 112×112,这是主流特征模型最常用的输入尺寸。为什么要固定?因为特征模型的输入维度是写死的,你不固定尺寸就得在模型内部做自适应池化,那会损失信息。直接用 112×112,最省事。
注意:对齐后别急着提特征,先做一次质量筛选。人脸区域的清晰度(用拉普拉斯方差衡量)、亮度均值、姿态角,都能在几毫秒内算出来。清晰度太低、亮度太暗、偏航角超过三十度的图,直接丢掉不进特征库。这一条能把特征库的脏数据比例从百分之十几降到百分之二以内,后面比对时的误识率会明显下降。
预处理里还有个细节是颜色空间。检测和特征模型都是按某种顺序训练的,多数是 BGR 顺序(OpenCV 读图的默认顺序),如果你从别的采集库拿到的是 RGB,记得转一下。这个错误很隐蔽,模型不会报错,只是精度悄悄掉几个点,非常难查。
2.3 特征提取与嵌入向量
特征提取的输出是一个浮点向量,业内叫嵌入向量或者特征向量。它的性质是:同一个人的不同照片,向量之间的距离近;不同人的向量,距离远。这个性质不是天然就有的,是模型在训练时用度量学习的方式逼出来的。
向量的维度常见的有 128 维和 512 维。128 维的模型小、算得快,512 维的精度通常更好一些,但存储和比对成本翻四倍。选哪个取决于你的库有多大:一万人以下,512 维完全没压力;一百万人以上,光照和角度变化又多,那 512 维的精度优势值得这个成本。我那次是一万人以内的场景,用了 128 维,够用。
提特征的时候有几个操作顺序不能错。先是归一化,把向量缩放到单位长度,这一步做完之后,余弦相似度就等于向量的点积,计算上省一次除法。归一化要放在比对之前做,或者存库的时候就存归一化后的向量,比对时直接点积,避免每次比对都重算。
然后是精度。有人为了省内存把特征存成半精度浮点甚至量化成整数,这个要慎重。128 维的向量用半精度存,能省一半空间,但相似度会掉千分之几,在阈值附近会造成少量误判。如果你的库很大、内存吃紧,可以考虑,但要重新标定阈值。我的建议是:一万以下的库老老实实存单精度,为了那点内存冒精度风险不值。
还有一个坑是特征提取的线程安全。有些推理后端的会话对象不是线程安全的,多线程同时调同一个实例会出诡异的结果——不崩溃,但输出的向量是乱的。稳妥做法是每个工作线程各持有一份实例,或者加锁串行调用。前者内存占用高一些,但吞吐好;后者省内存,但并发度上不去。我一般选前者,因为特征模型的实例本身不大。
2.4 相似度度量与阈值标定
有了向量,接下来就是“像不像”的判断。度量方式主要两种:余弦相似度和欧氏距离。
余弦相似度看的是方向,取值范围负一到一,越接近一越像。欧氏距离看的是绝对距离,越小越像。对归一化后的向量,这两种度量是等价的,单调对应,所以选哪个不影响排序,只影响阈值的数值。
真正麻烦的是阈值怎么定。这个数不能拍脑袋,也不能抄别人的。因为不同模型、不同人群、不同采集条件,同一个数值对应的误识率和拒识率完全不一样。我见过有人直接抄了个 0.5 上去,结果现场一堆人认不出来,因为他的模型输出的相似度分布整体偏低。
正确的做法是自己标定。步骤是这样:采集一批已知身份的人脸,每人至少两张不同场景的图。构造正样本对(同一个人不同图)和负样本对(不同人)。对每一对算相似度,你会得到两个分布:正样本对的相似度分布,集中在高位;负样本对的相似度分布,集中在低位。两条曲线的交叠区域就是模糊地带。把阈值设在这个交叠区的中间偏一侧——偏哪一侧取决于你的业务是更怕误识还是更怕拒识。门禁场景更怕误识(陌生人被放进去),阈值就得往高里调;考勤场景更怕拒识(员工打不上卡),阈值就往低里调。
具体到数值,我那次标定下来,余弦相似度阈值定在 0.42。正样本对的相似度中位数 0.71,百分之九十分位在 0.55 以上;负样本对的中位数 0.12,百分之九十九分位在 0.35 以下。0.42 落在两条分布之间,实测误识率大概万分之三,拒识率百分之四左右,业务能接受。
| 度量方式 | 取值范围 | 越大越像 | 常用阈值区间 | 备注 |
|---|---|---|---|---|
| 余弦相似度 | -1 到 1 | 是 | 0.35 到 0.55 | 需先归一化,等价于点积 |
| 欧氏距离 | 0 到 2(归一化后) | 否 | 0.9 到 1.3 | 与余弦阈值可换算 |
| 内积 | 负无穷到正无穷 | 是 | 视归一化而定 | 未归一化时不可直接比较 |
还有一点要说清楚:阈值不是一劳永逸的。换了摄像头、换了安装位置、季节变了光照变了,分布都会漂。我一般会留一个后台任务,定期抽样统计线上比对分数的分布,一旦中位数偏移超过 0.05,就提示重新标定。
3. 从零跑通的原型代码与参数实录
前面讲的是思路,这一章直接上代码。我用的组合是视觉库自带的检测和识别模块,好处是接口统一、依赖少、不用自己搭推理后端,编译完就能跑。如果你要换成别的模型,无非是替换这两处调用,整体结构不变。
3.1 环境与依赖准备
依赖就三样:一个 C++ 编译器(支持 C++17 就行)、一个视觉库的完整版(要包含人脸模块,版本不能太低)、两个模型文件。模型文件一个是检测网络,一个是特征网络,都是公开可下载的,加起来不到十兆。
环境这块我建议直接用包管理器装,别自己源码编译,除非你要改库的内部行为。自己编一次要小半个小时,还容易因为缺依赖失败。装完之后用一个小程序验证一下,能读到版本号、能创建检测器对象,就说明环境没问题。
# 确认视觉库版本,人脸模块需要 4.5.4 以上 pkg-config --modversion opencv4 # 编译时链接人脸模块所在的库 g++ -std=c++17 face_demo.cpp -o face_demo \ $(pkg-config --cflags --libs opencv4) -pthread编译参数里-pthread别漏,后面要多线程处理。如果链接时报找不到符号,八成是装的版本太老,人脸模块是从某个版本之后才加进去的,回去确认版本。
3.2 检测、对齐、提特征的完整代码
这段代码是一个最小可运行版本,把一张图里的人脸全部找出来,逐个对齐、提特征,然后两两比对。实际项目里会拆成多个类、跑在线程池里,但核心就这几行。
#include <opencv2/opencv.hpp> #include <opencv2/objdetect/face.hpp> #include <iostream> #include <vector> int main(int argc, char** argv) { const std::string detModel = "face_detection_yunet.onnx"; const std::string recModel = "face_recognition_sface.onnx"; cv::Mat img = cv::imread(argv[1]); if (img.empty()) return -1; // 检测器:输入尺寸 320x320,置信度 0.9,NMS 0.3,最多 5000 个候选 cv::Ptr<cv::FaceDetectorYN> detector = cv::FaceDetectorYN::create( detModel, "", cv::Size(320, 320), 0.9f, 0.3f, 5000); detector->setInputSize(img.size()); cv::Mat faces; // 每行 15 列:框 4 + 关键点 10 + 分数 1 detector->detect(img, faces); std::cout << "检测到人脸: " << faces.rows << std::endl; // 识别器:只做对齐和提特征,不做比对 cv::Ptr<cv::FaceRecognizerSF> recognizer = cv::FaceRecognizerSF::create(recModel, ""); std::vector<cv::Mat> feats; for (int i = 0; i < faces.rows; ++i) { cv::Mat aligned; recognizer->alignCrop(img, faces.row(i), aligned); // 输出 112x112 cv::Mat feat; recognizer->feature(aligned, feat); // 输出 1x128 feats.push_back(feat.clone()); // 必须 clone } // 两两比对,余弦相似度 for (size_t i = 0; i < feats.size(); ++i) { for (size_t j = i + 1; j < feats.size(); ++j) { double score = recognizer->match( feats[i], feats[j], cv::FaceRecognizerSF::FR_COSINE); std::cout << i << " vs " << j << " 相似度: " << score << std::endl; } } return 0; }代码里有几个地方我看着像是小事,实际上是坑。
feats.push_back(feat.clone())里的clone()不能省。feature()写出的矩阵在某些实现里复用了内部缓冲,你不拷贝,下一轮调用会把上一轮的结果覆盖掉,最后所有特征长得一模一样,比对全是满分。我第一次遇到这个问题的时候,排查了快两个小时,还以为是模型加载错了。判断方法很简单:打印两两相似度,如果全都是 1.0 或者非常接近,就是这个原因。
faces.row(i)在alignCrop里作为输入,这个调用需要的是单行的矩阵,直接从原矩阵切行传进去是可以的,但要注意这个行视图依赖于原矩阵的生命周期,别在循环中途把faces释放或者重新赋值。
setInputSize要跟实际图像尺寸一致,否则内部会做一次缩放,关键点的坐标是按缩放后的图算的,你拿它去原图上裁就会偏。这个错误特别隐蔽,因为程序不报错,只是框偏一点点,人脸还能勉强认出来,但精度会掉。
3.3 特征库的设计与持久化
特征库本质上就是一张表:一个身份标识,对应一个或者多个特征向量。一个人存多个向量,是为了覆盖不同角度和光照,比对时取最高的那个分数。存几个合适?我的经验是每人三到五个就够了,再多边际收益很低,反而增加比对成本,也更容易引入脏数据。
内存结构我一般这样设计:一个大矩阵,每一行是一个特征,行号到身份标识用一张索引表映射。这样做的好处是比对时可以整块做矩阵运算,比自己写循环快好几倍。
持久化用二进制文件,格式自己定,简单点就行:文件头写版本号和记录数,后面按固定长度依次写特征和标识。不建议用 CSV 或者 JSON,一是体积大,二是读写慢,三是一百万行的时候文本解析能耗掉好几秒。
// 特征库的二进制序列化,格式简单但够用 struct FeatureDB { int version = 1; std::vector<int> ids; // 每行特征对应的身份标识 cv::Mat mat; // N x 128, CV_32F void save(const std::string& path) { std::ofstream ofs(path, std::ios::binary); ofs.write(reinterpret_cast<const char*>(&version), sizeof(int)); int n = mat.rows, dim = mat.cols; ofs.write(reinterpret_cast<const char*>(&n), sizeof(int)); ofs.write(reinterpret_cast<const char*>(&dim), sizeof(int)); ofs.write(reinterpret_cast<const char*>(ids.data()), n * sizeof(int)); ofs.write(reinterpret_cast<const char*>(mat.data), n * dim * sizeof(float)); } };这里有个顺序问题要注意:写入特征矩阵时,mat必须是连续的。如果不是连续内存(比如它是某个大矩阵切出来的子矩阵),直接写mat.data会读到错误的字节。保险做法是写入前先mat = mat.clone(),或者在结构设计上就保证每次都是新建的连续矩阵。
库的规模也决定了检索策略。一万人以内,128 维,暴力检索根本不用想优化,一次全量比对大概一两个毫秒。一百万人,512 维,暴力检索就要几百毫秒,得考虑加索引或者降维。但降维会损失精度,索引结构会引入维护成本,别在没有实际压力的时候提前优化。
3.4 比对逻辑:从暴力检索到分层过滤
比对这块我踩过一个典型的坑:一开始只存了一个向量,比对时取最高分。结果发现有个人怎么都认不出来,查了半天发现他是戴着口罩录入的,而现场比对时不戴口罩,两个向量差得远。后来改成每人存多个向量,问题解决了。
比对流程我做了三层过滤,实际跑下来比单层快不少,也不会漏。
第一层是阈值快速筛。拿查询向量和库里所有向量算一遍相似度,这一步是纯矩阵运算,一万人大概一两毫秒。算完之后取最高分,如果最高分低于一个很低的“完全不可能”阈值(比如 0.2),直接判定为陌生人,后面的都不做了。这一层能挡掉绝大部分陌生人查询。
第二层是身份聚合。如果最高分过了低阈值,就把库里同属一个身份的所有向量分数取最大值,作为这个身份的得分。而不是简单地取全局最高分的那个向量对应的身份。这个区别很关键:一个人存了五个向量,散落在库的不同位置,全局最高分可能是他某个不典型的向量,而他的身份得分应该是五个里的最高分。
第三层是业务阈值判定。拿聚合后的身份得分和标定好的阈值比,过了就是这个人,不过就是陌生人。如果同时有多个身份过阈值,取分数最高的那个,同时记录一下第二名的分数,两者差距太小(比如差 0.03 以内)就标记为“低置信”,这种人脸往往有遮挡或者光照很差,实际应用里我会让用户再试一次。
| 过滤层 | 作用 | 典型阈值 | 耗时占比 |
|---|---|---|---|
| 快速筛 | 挡掉明显不匹配的陌生人 | 相似度 < 0.2 直接拒 | 约 70% |
| 身份聚合 | 按身份取最高分,避免单向量误导 | 无阈值 | 约 25% |
| 业务判定 | 决定最终结果 | 0.42(本次标定值) | 约 5% |
3.5 性能压榨:多线程、批处理与内存复用
原型跑通之后,下一步一定是压性能。我按收益从大到小排了个序,你可以照着试。
第一是减少重复的分配。每帧都要创建cv::Mat存对齐结果和特征,虽然分配本身不慢,但频率高了会产生内存碎片。做法是在线程里用固定大小的缓冲,每次复用。这一项改动在我这里省了大约百分之十五的时间。
第二是让检测和特征处理并行。检测是瓶颈,特征提取相对轻。如果串行做,一帧要二十八毫秒;把特征提取丢到线程池,检测线程拿到框就往下传,不等结果,实际吞吐能到四十帧以上。这里要注意队列长度要有限制,不然内存会被撑爆。
第三是批处理。如果一帧里有好几张脸(比如门禁抓拍常常一次进来好几个人),把它们的对齐图拼成一个批次送进特征模型,比逐个送的吞吐高不少,因为模型内部的并行度上去了。批大小设四到八比较合适,再大收益递减,延迟反而上升。
第四才是考虑硬件加速。有些人一上来就想上加速卡,其实在把前三项做完之前,加速卡带来的收益会被其他环节的瓶颈吃掉。我那次做完前三项,单核已经能跑到三十五帧,完全没必要再加卡。
内存占用也要算一笔账。一万人、128 维、单精度,特征矩阵占 10000 × 128 × 4 字节,大约 5.1 MB。加上索引和身份信息,不到 10 MB。这个量级在设备上完全不是问题。真正占内存的是图像缓冲和模型本身,前者取决于你缓多少帧,后者取决于模型大小,这两个才是要盯的。
4. 落地过程中的踩坑与排查实录
代码能跑只是开始,真正花时间的是上线之后的各种“不对”。这一章把我遇到过的、印象比较深的几类问题整理出来,按现象分类,方便你对照排查。
4.1 识别率上不去的排查顺序
“识别率不行”是最高频的反馈,但这个说法太笼统,得先拆。是认不出熟人(拒识率高),还是把陌生人认成熟人(误识率高),还是同一个人经常被识别成另一个特定的人(混淆)。三种情况的排查方向完全不同。
如果是拒识率高,按这个顺序查:先看对齐有没有做,很多实现直接拿检测框裁图,这一步就能差十个点;再看质量筛选是不是太狠,把正常的人脸也过滤掉了;然后看特征库里每人的样本数够不够,只存一个向量的情况最容易出问题;最后才怀疑模型。前三个都是工程问题,改起来快,收益也大。
如果是误识率高,优先查阈值。阈值偏低是最常见的原因,特别是从别人的项目里抄来的阈值。其次查特征库有没有脏数据,比如某个人录入时角度特别大,导致他的特征向量落在了一堆人的中间,谁查都跟他有点像。
如果是特定两个人混淆,这种人脸本身可能确实相似(亲属、同卵双胞胎),单靠特征模型很难分开。处理方式是提高阈值,接受这两人中的一部分会被拒识,或者引入第二个模态(比如加个密码),别指望模型能解决所有问题。
有个工具我建议一开始就加上:把每次比对的最高分和第二高分都记进日志。出了问题,看日志就知道是分数整体偏低(阈值问题),还是第二高分异常接近(库里有相似样本),定位效率高很多。
4.2 稳定性与内存问题的排查手法
长时间跑之后出问题,是最难查的一类。我遇到过三种。
第一种是内存缓慢上涨。跑一小时涨几十兆,不崩,但最终会被系统杀掉。用内存检测工具跑一遍,定位到是每帧创建的某个矩阵没有被释放——原因是它被存进了一个容器,而那个容器在某个分支里没有被清空。这类问题的通用解法是:所有缓存容器都设上限,超了就淘汰最旧的,别让它无限增长。
第二种是偶发的输出异常。同一个人,绝大多数时候能认出来,偶尔分数特别低。查下来是多线程共享了推理实例,偶发的内部状态冲突。改成每线程一份实例后消失。这个问题的迷惑性在于它不是必现的,跑一万次可能才出一次,靠复现很难,只能靠代码审查发现共享点。
第三种是启动时崩。多半是模型文件路径不对或者文件损坏,也可能是模型和库的版本不匹配。加个加载检查,加载失败打印明确信息,比让程序在第一次推理时崩掉好定位得多。
提示:所有外部资源(模型、特征库、配置)加载完都要做一次健康检查,检查不通过就直接退出并打印原因。宁可启动失败,也别让程序带着残缺状态跑起来,那种问题排查起来成本高十倍。
4.3 常见问题速查表
| 现象 | 最可能的原因 | 快速验证方法 | 处理方式 |
|---|---|---|---|
| 所有比对分数都是 1.0 | 特征矩阵被复用,没有深拷贝 | 打印前两个特征的原始数值 | 提特征后立即 clone |
| 框的位置整体偏移 | 检测时设置的输入尺寸和实际图不符 | 打印输入尺寸和图像尺寸 | 每帧同步 setInputSize |
| 侧脸完全认不出 | 未做对齐,或对齐用的关键点不准 | 保存对齐结果图目视检查 | 补充对齐,检查关键点 |
| 运行几小时后变慢 | 容器无限增长,或内存碎片 | 监控 RSS 曲线 | 缓存设上限,复用缓冲 |
| 换了摄像头精度骤降 | 光照和角度分布变化,阈值失配 | 统计线上分数分布 | 重新标定阈值 |
| 编译链接失败 | 库版本过老,缺人脸模块 | 查版本号 | 升级到含人脸模块的版本 |
| 多人同框只识别出一个 | NMS 阈值过大,把脸合并了 | 打印检测框数量 | 调小 NMS 阈值 |
这张表里的每一条,我基本都真真切切遇到过。最想强调的还是第一条和第二条,因为它们不报错、不崩溃,只是结果悄悄变差,靠肉眼观察根本发现不了,只能靠主动打印中间结果去验证。养成一个习惯:任何新写的链路,第一步都是把中间结果落盘看一眼,对齐后的脸长什么样、特征向量的前几个数是多少、相似度的分布落在哪个区间,这些东西看一眼,能省掉后面几个小时的瞎猜。
最后分享一个我后来固定下来的做法:给整个程序加一个“离线回放”模式,输入一段录好的视频,输出每一帧的检测框、对齐图、比对分数,全部落盘。线上出了问题,把那段视频拿回来跑一遍回放,问题基本都能复现,比在现场猜快得多。这个模式写起来不到一百行,但在我手上救过好几次急。人脸识别这种链路长、环节多的系统,可观测性比算法本身更值得先投入。