☰
C++人脸识别系统落地:ONNX Runtime+OpenCV工程实践
2026/10/26 14:19:42 网站建设 项目流程

客户要求整套人脸识别跑在一台没有独立显卡的工控机上,识别延迟压到 200 毫秒以内,人脸数据不许出内网。这三个条件一摆出来,Python 生态里那些开箱即用的服务化方案基本就没戏了——模型要自己加载、内存要自己管、线程要自己排、异常要自己兜。最后落地的形态就是一套C++ 实现的人脸识别系统:OpenCV 做图像底座,ONNX Runtime 跑推理,检测、对齐、特征提取、比对检索这几段全部自己串起来。

这套东西写下来,代码量其实不大,五六千行能撑起来,真正耗时间的是那些文档里不会写的东西:检测框该往外扩多少、对齐之后精度为什么反而掉了、同一张图两次推理结果为什么不一样、阈值在一台机器上好好的换台机器就翻车。下面这些内容是我从零搭这套系统、又在现场调了两周参数攒下来的,包含选型逻辑、可抄的参数、以及几个必须提前知道的坑。适合手里有 C++ 基础、想在本地跑通一套完整人脸识别链路的人看;如果你只是想调个接口玩一下,那确实没必要读这么长。

1. 先把"人脸识别"拆成三段流水线,别一上来就调库

很多人第一次做这件事,脑子里是一个函数:输入一张图,输出一个名字。真动手才发现这个黑盒根本没法调。工程上必须把它拆成三段独立的东西,每段有自己的输入输出、自己的指标、自己的优化方向。

1.1 检测、对齐、特征比对,三段的失败模式完全不同

第一段是人脸检测,输入整幅图,输出若干个矩形框加关键点,指标是召回率和定位精度。它失败的表现是"漏检"和"框歪"——侧脸、遮挡、小尺寸人脸最容易出问题。

第二段是对齐与归一化,输入是框加关键点,输出一张固定尺寸、五官位置标准化的脸图。它失败的表现很隐蔽:图还是那张图,人眼看不出来问题,但特征向量的质量已经掉了一截。这一段是最容易被跳过、也最容易背锅的部分。

第三段是特征提取与比对,输入是对齐后的脸图,输出一个定长向量(常见是 128 维或 512 维),然后用距离度量判断"是不是同一个人"。它失败的表现是"双胞胎混淆""同一个人换个角度就认不出"。

把这三段分开的最大好处是:出问题时你能定位。识别错了,先看检测框准不准,再看对齐图正不正,最后才怀疑模型。如果揉成一个函数,你只能靠猜。

提示:从第一天起就把三段的中间结果都存盘。检测框、关键点、对齐后的脸图,各存一份。后面标阈值、查错误样本,全靠这些中间产物救命。

1.2 为什么这个场景仍然值得用 C++

说句实话,做原型阶段用脚本语言快得多。但有三类硬约束会把方案逼到 C++ 上:无法带运行时环境(客户机器上不能装一堆解释器和依赖)、延迟与内存有硬指标(单帧预算明确到毫秒)、要长期常驻运行(跑几个月不重启,内存不能涨)。

我遇到的是三种全占。C++ 在这三件事上的优势很直接:编译出来是一个可执行文件加几个模型文件,扔过去就能跑;内存分配自己控制,能精确知道峰值在哪;线程模型完全自己设计,不会被底层框架的调度策略牵着走。

代价也很明显——所有的边界检查、异常处理、资源释放都得自己写。一个cv::Mat的浅拷贝没注意,就可能出现两个线程同时写同一块内存。这个后面单独讲。

1.3 一个最小骨架长什么样

整个系统的骨架其实就这几行的事,先让链路跑通,再谈优化:

// 骨架示意,省略错误处理 Detector detector("det.onnx"); // 检测器 Recognizer recognizer("rec.onnx"); // 特征提取器 FaceIndex index("gallery.bin"); // 特征库 cv::Mat frame = cv::imread(path); auto faces = detector.detect(frame); // 1. 检测 for (auto& f : faces) { cv::Mat aligned = alignFace(frame, f.landmarks, 112); // 2. 对齐 std::vector<float> feat = recognizer.extract(aligned); // 3. 提特征 normalize(feat); // L2 归一化 auto hits = index.search(feat, 1); // 4. 检索 if (!hits.empty() && hits[0].score > kThreshold) printf("%s %.3f\n", hits[0].name.c_str(), hits[0].score); }

这段跑通之后,你会得到一个能出结果的系统,但离能用还差得远。接下来的每一节,基本都是在这四行里补细节。

2. 选型对比:四条落地路径各自的天花板在哪

选型这件事没什么标准答案,取决于你的底库规模、精度要求和硬件条件。我把实际试过的四条路摊开讲,包括各自的实测体感和劝退点。

2.1 传统特征加浅层分类器:快,但天花板肉眼可见

最早试的是最朴素的做法:检测器给框,把脸缩放到固定尺寸,提 HOG 或者 LBP 直方图特征,再用 LBPH 之类的浅层方法做匹配。这条路的好处是极快且极轻,在低端 ARM 板上单帧几毫秒,模型文件几百 KB。

问题是精度。正面、光照均匀、无遮挡的条件下还凑合,一旦人稍微转个角度,或者侧面来光,相似度就乱跳。更麻烦的是它给的分数没有稳定的物理意义,阈值调起来全靠试。

我的建议是:如果场景是门禁打卡这种配合度极高的场景,底库几十人,光照可控,这条路完全可以考虑,成本低到离谱。但如果要面对"用户随便走过来"的场景,趁早换。

2.2 dlib 加距离阈值:中等规模最省心的一条路

第二条路是用 dlib 的现成能力,配一个基于残差网络的 128 维特征提取器。它的好处是接口干净、依赖少、编译一次就能用,不需要额外的推理运行时。特征维度低,128 维 float 存一个人只要 512 字节,五万人的底库也就 25 MB,全部塞内存里毫无压力。

实际精度方面,在底库几千人、质量尚可的图像上,误识率可以控制得比较低。它的短板是两个:一是对小人脸和侧脸不够鲁棒,二是它的检测器在密集场景下偏慢。

这条路特别适合"底库不大、部署环境简单、不想引入复杂推理框架"的项目。我有个内部工具用的就是它,稳定性几年没出过问题。

2.3 ONNX Runtime 加深度度量模型:精度上限最高

第三条路是把检测和识别都换成带角度间隔损失的深度度量模型,用 ONNX Runtime 做推理后端。512 维特征,配合合适的对齐,在同样底库下的区分度明显好于 128 维方案,尤其是跨年龄、跨姿态的样本。

代价是工程复杂度上来了:要处理模型输入的形状和归一化参数、要管 session 的线程数、要注意不同版本的算子兼容。另外 512 维 float 一个人是 2 KB,十万人底库就是 200 MB 纯特征,检索必须做加速,不能暴力扫。

我在正式项目里最终选了这条。原因很实际:客户现场的光照条件不可控,人员走动姿态随意,128 维方案在这种条件下误识率压不下去。

2.4 四条路径的决策表

路径特征维度底库规模上限单帧延迟(参考)主要劝退点
传统特征 + 浅层匹配数百维稀疏百人级5 ms 以内姿态光照一变就崩
残差网络 + 距离阈值128万人级20–40 ms小人脸、侧脸偏弱
深度度量 + ONNX 运行时512十万人级(需索引)30–60 ms工程复杂度高
自研轻量主干 + 量化128/256千人级10–20 ms需自己训练,周期长

表里的延迟是 CPU 单线程的粗略量级,具体数字跟 CPU 型号、输入分辨率关系很大,别直接拿去当验收标准。

3. 检测与对齐:真正吃掉七成工作量的地方

我自己的体感是,模型选好之后,剩下的精度提升有七成来自检测框的处理和对齐,只有三成来自换更强的特征模型。这一段值得反复打磨。

3.1 检测框外扩的边距,为什么不能省

检测器给出的框通常只覆盖五官区域,额头、下巴、脸颊边缘是切掉的。如果直接拿这个框去裁剪再缩放,等于把模型训练时见过的完整人脸分布给破坏了。

标准做法是把框按比例往外扩。我实测下来,外扩 0.2 到 0.25 的比例在多数模型上表现最好:

cv::Rect expand(const cv::Rect& r, float ratio, const cv::Size& bound) { int dw = static_cast<int>(r.width * ratio); int dh = static_cast<int>(r.height * ratio); int x = std::max(0, r.x - dw); int y = std::max(0, r.y - dh); int w = std::min(bound.width - x, r.width + 2 * dw); int h = std::min(bound.height - y, r.height + 2 * dh); return cv::Rect(x, y, w, h); }

这里有两个细节必须注意。第一,扩边之后要重新做边界裁剪,否则框会越界,后面裁剪直接抛异常或者读到垃圾内存。第二,宽高比要锁死。多数识别模型的输入是正方形,如果直接把长方形框缩成正方形,人脸会被横向或纵向拉伸,特征就歪了。正确做法是先按长边补齐成正方形再缩放,或者用关键点做相似变换(下一小节)。

外扩比例这个参数不是拍脑袋来的。你可以这么验证:准备两百张标注好的脸,把 ratio 从 0 到 0.5 按 0.05 步长扫一遍,每档都跑一遍特征提取,算同一人不同图的类内距离和不同人的类间距离,取"类内距离/类间距离"最小的那个值。这个比值越小说明类内越紧、类间越开,模型判别力越强。我扫出来的最优值在 0.2 附近,和文献里的常见设定基本能对上。

3.2 五点关键点做相似变换,比直接缩放强在哪

直接按框裁剪再 resize,实际做的是"缩放",它没法纠正人脸的旋转和倾斜。人脸稍微歪一点,裁剪出来的图五官位置就偏了,而特征模型对这种位置偏移是敏感的。

相似变换能同时处理平移、旋转、统一缩放,恰好覆盖人脸对齐需要的自由度。做法是先定一个标准模板,比如 112×112 的图,五个关键点的目标坐标固定在:

// 112x112 模板下的五点理想位置(左眼、右眼、鼻尖、左嘴角、右嘴角) static const float kTemplate[5][2] = { {38.2946f, 51.6963f}, {73.5318f, 51.5014f}, {56.0252f, 71.7366f}, {41.5493f, 92.3655f}, {70.7299f, 92.2041f} };

然后用最小二乘估一个 2×3 的仿射矩阵,把检测到的关键点映射到模板位置:

cv::Mat src(5, 2, CV_32F), dst(5, 2, CV_32F); // ... 填充 src 为检测关键点,dst 为模板点 cv::Mat M = cv::estimateAffinePartial2D(src, dst, cv::noArray(), cv::LMEDS); cv::Mat aligned; cv::warpAffine(img, aligned, M, cv::Size(112, 112), cv::INTER_LINEAR, cv::BORDER_REPLICATE);

这里estimateAffinePartial2D只会估出不含错切的相似变换,比完整的仿射更稳。用LMEDS做鲁棒估计,遇到个别关键点检测歪了也不会把整个矩阵带偏——这个细节在现场救过我好几次,侧脸时鼻子关键点经常飞出去,用最小二乘会把整张图拉变形,换成鲁棒估计就没事。

注意:关键点的顺序必须和模板严格一致。左右眼搞反是新手最容易犯的错,症状是对齐后的图整体旋转 180 度,脸是倒的。这种图喂进模型,特征向量照样能算出来,结果是稳定的错——排查时极难发现。

3.3 光照和模糊的预处理,做多了反而有害

一开始我在预处理里加了一堆东西:直方图均衡、伽马校正、锐化、去噪。结果精度不升反降。后来才想明白——特征模型是在特定分布的数据上训练的,你额外做的增强,反而把它推离了训练分布。

正确的思路是:只做模型训练时用过的那几步。多数深度度量模型的标准流程就三步:BGR 转 RGB、像素值归一化到 [0,1] 或 [-1,1](取决于模型)、按通道减均值除标准差。这三步的顺序和参数必须和训练时一致,差一个通道顺序,精度掉几个点很正常。

真正值得加的预处理只有一种情况:输入图明显过暗或过曝。这时可以做一次自适应的对比度拉伸,但要先判断——如果图的平均亮度和方差都在正常范围内,就别动它。我用的判据是平均亮度落在 60 到 190 之间、标准差大于 35,满足就原样送进去。

bool needEnhance(const cv::Mat& gray) { cv::Scalar mean, stddev; cv::meanStdDev(gray, mean, stddev); return mean[0] < 60 || mean[0] > 190 || stddev[0] < 35; }

模糊的判断更简单,用拉普拉斯响应的方差,低于阈值就认为太糊,直接拒识比硬认要安全:

cv::Mat lap; cv::Laplacian(gray, lap, CV_64F); cv::Scalar m, s; cv::meanStdDev(lap, m, s); if (s[0] * s[0] < 60.0) return Quality::TooBlurry;

这个 60 的阈值是拿现场几百张样本试出来的,你们的相机和场景不一样,得自己扫。

4. 特征比对与阈值标定:把"像不像"变成"是不是"

拿到了特征向量,事情才完成一半。真正决定系统好不好用的,是那道阈值的门。

4.1 余弦相似度还是欧氏距离,先归一化再谈

特征向量先做 L2 归一化之后,余弦相似度和欧氏距离是单调等价的:对两个单位向量 u、v,有 ‖u−v‖² = 2 − 2·⟨u,v⟩。所以选哪个只影响阈值的数值写法,不影响排序。

我习惯用余弦相似度,原因很简单——它的取值范围固定在 [-1, 1],阈值 0.4 这个数字在整个系统里含义一致,换模型的时候只需要重标一次,不用重新理解量纲。而欧氏距离的取值范围跟维度有关,512 维和 128 维的合理阈值完全不同,容易搞混。

inline float cosine(const float* a, const float* b, int dim) { float dot = 0.f; for (int i = 0; i < dim; ++i) dot += a[i] * b[i]; return dot; // 两边都已 L2 归一化 }

一个性能上的小技巧:循环里不要用std::inner_product,也不要用double累加。实测在 512 维上,单精度累加的误差远小于阈值判定的粒度,而速度能快一倍多。用-O3 -march=native编译后编译器会自动向量化,手写循环比库函数还快。

4.2 阈值不能拍脑袋,用你自己的底库跑一遍标定

网上的文章经常直接给一个数,比如 0.5 或者 0.6。这个数字在别人那儿对,在你这儿大概率不对,因为特征分布跟模型版本、对齐方式、数据质量都相关。

我的标定方法是这样的。准备两组样本:正样本对(同一个人的不同图,至少 200 对)、负样本对(不同人的图,至少 2000 对)。全部过一遍完整流水线,算出所有对儿的余弦相似度,得到两个分布。然后从 0 到 1 扫阈值,每档算两个错误率:正样本对被拒的比例(拒识率)、负样本对被接受的比例(误识率)。

相似度阈值拒识率误识率适用场景
0.303.1%1.8%宽松,适合先筛后人工复核
0.388.5%0.4%通用,我现场的取值
0.4517.2%0.06%严格,适合高安全场景
0.5534.8%0.01%极严,只做 1:1 核验

表里是我一套 512 维模型的实际数字,你们只能参考趋势,绝对值不可照搬。要注意的是拒识率和误识率不可能同时低,这是分布重叠决定的,不是调参能解决的。你能做的是选一个业务上可接受的平衡点。

选点的逻辑取决于业务代价。如果误识一次的代价远大于拒识一次(比如支付验证),就往 0.45 以上走;如果拒识的体验代价更大(比如员工考勤),0.35 左右更合适。这里没有技术上的最优解,只有业务上的取舍。

4.3 一万人的底库,暴力检索还够不够用

先算个数。512 维 float32,一个人 2 KB。一万人底库就是 20 MB,全部常驻内存没任何问题。暴力检索一次要做 10000×512 次乘加,约 512 万次浮点运算。现代 CPU 单核大概能做到每秒几十亿次乘加,理论上一毫秒内能扫完,实际因为内存访问模式,两三毫秒。

一万到三万人,暴力扫是可以接受的。超过这个量级才需要考虑索引。往上走的方案有两种:

一个是降维。用主成分分析或者直接取前若干维(前提是维度重要性有序),把 512 维降到 128 维,检索速度提升四倍,精度损失通常在可接受范围。降维矩阵要离线算好存下来,运行时只做一次矩阵乘。

另一个是近似最近邻索引,比如基于聚类或者图结构的索引。它的代价是引入了召回率损失——近似检索可能漏掉真正的最近邻,而这个错误是不可见的,你根本不知道它漏了。

除非底库上到十万级,我的建议是老老实实暴力扫。工程上少一层不确定性,比省那几毫秒值钱。真要加速,先把特征向量用float紧凑排列成连续内存块(AoS 改成 SoA),这样 SIMD 能满速跑,实测能白捡两倍性能:

struct FeatureStore { int dim, n; std::vector<float> data; // n * dim,连续存储 const float* get(int i) const { return data.data() + (size_t)i * dim; } };

用std::vector<std::vector<float>>存就做不到这一点,每个人的向量散落在堆上,缓存命中率极低。

5. C++ 工程化的硬骨头:内存、线程、模型加载

算法跑通了,离能交付还差一整套工程细节。这一段是纯踩坑经验,不涉及算法。

5.1 Mat 的浅拷贝,最容易出事的地方

cv::Mat的赋值是引用计数加浅拷贝,不复制像素数据。这个设计是为了性能,但在多线程下就是个雷。下面这段代码是错的:

// 错误示例:frame 在另一个线程被复用了 cv::Mat face = frame(roi); // 浅拷贝,仍然指向 frame 的像素 queue.push(face); // 另一个线程稍后读这块内存

如果frame是循环里复用的缓冲区,下一帧一读进来,队列里那张脸的数据就被覆盖了。症状是识别结果随机跳动,且越到高帧率越明显。

正确的做法是显式克隆,或者在入队前就把数据落地:

cv::Mat face = frame(roi).clone(); // 深拷贝,独立内存

代价是多一次内存拷贝,对 112×112 的图来说这点开销可以忽略。但如果你传的是整幅 1080p 图,就该考虑换一种设计——比如只传 ROI 坐标加帧序号,让下游自己去取。

提示:为了排查这类问题,我加过一个调试开关,在每张图的角上写一个自增编号。识别结果异常时看编号,如果出现乱序或者重复,基本就能确定是缓冲区复用问题。

5.2 多线程流水线和底层库的线程数打架

为了压低延迟,很自然的想法是流水线化:读图一路线程、检测一路、识别一路,用队列串起来。这个设计本身没问题,问题出在底层库默认会自己开线程。

OpenCV 的很多操作内部用了并行,默认线程数等于核数。如果你又开了四个工作线程,每个线程内部再开八路并行,就是三十二个线程抢八个核,上下文切换的开销比计算还大。实测在四核机器上,这种情况延迟反而比单线程高 30%。

解决方式是在初始化时把底层库的并行关掉,让并发完全由自己的流水线控制:

cv::setNumThreads(0); // 0 表示禁用内部并行 // ONNX Runtime 侧设置 intra_op_num_threads

然后按核数分配工作线程数,通常是核数减一(留一个给主线程和 IO)。这个参数必须实测扫一遍,从 1 到核数逐档跑压测,我这边四核机器最优是 3 个识别线程。

另外队列要有容量上限。无界队列在下游变慢时会无限堆积,内存直接爆掉。我一般设成 2 到 4 帧,满了就丢最旧的——实时场景里丢帧比延迟堆积好得多。

5.3 模型常驻与句柄复用

会话对象的创建开销很大,加载模型加初始化可能几百毫秒。这个东西必须常驻,在进程启动时创建一次,之后全局复用。

需要注意两个坑。一是会话对象是不是线程安全的。多数推理运行时的会话支持多线程并发调用Run,但不保证顺序,你需要每个线程有自己的输入输出张量缓冲区,避免互相覆盖。二是在多进程部署时要小心,每个进程都会加载一份模型到内存,四个进程就是四份,内存涨得很快。

// 每个工作线程独立的输入输出缓冲 struct InferCtx { std::vector<float> input; // 1*3*112*112 std::vector<float> output; // 1*512 }; thread_local InferCtx ctx; // 线程局部,天然隔离

用thread_local是这类场景最省事的写法,不用自己维护线程和缓冲区的映射关系。但要记住线程退出时这些内存才会释放,线程池如果有大量短命线程,反而会造成频繁分配。

6. 压测与指标:别只盯着"识别对了没有"

交付前必须做压测,而且压测的方法本身就是个坑。

6.1 自己攒一个评测集,比公开榜更有意义

公开评测集的数据质量和你的现场数据差得很远——那里的图都是精挑细选的正面高清照,你现场拿到的是逆光、戴口罩、低头刷卡。用公开指标做选型可以,用来定验收标准就是自欺欺人。

我的做法是在现场采集一批真实数据,注意是经过授权的、明确告知用途的。规模不用大,三百到五百人就够,每人两到五张,覆盖不同时段、不同光照、是否戴眼镜等条件。这批数据分成两半,一半进底库,一半做测试,保证测试样本和底库样本不是同一张图——这个细节很多报告里都忽略了,用同一张图去测,指标能好看到不真实。

评测时至少报四个数:1:1 核验的通过率、1:N 检索的首位命中率、误识率、平均延迟(均值加 95 分位)。只看通过率是不够的,因为把阈值调到极低,通过率能到 100%,但那时候谁来都能过。

6.2 延迟要拆开看,别只报一个总数

单帧 180 毫秒这个数字没有信息量。必须拆成:解码多少、检测多少、对齐多少、特征提取多少、比对多少。我的实测构成大致是这样:

环节耗时(四核 CPU)占比
图像解码8 ms5%
人脸检测55 ms32%
关键点与对齐6 ms3%
特征提取95 ms55%
比对检索3 ms2%
其他(拷贝、日志)5 ms3%

拆开之后优化目标立刻明确了:特征提取占一半以上,优化它收益最大。具体手段有输入分辨率下调(112 降到 96,精度略降)、模型量化(半精度或定点,需要验证精度损失)、算子融合。而检测那 32% 也有空间,可以降检测输入尺寸或者改成隔帧检测——视频流里人脸位置变化不大,隔一帧复用上一帧的框完全可行。

注意:报延迟一定要报 95 分位。平均值 60 ms 听起来很好,但 95 分位可能是 300 ms,用户是能明显感觉到卡顿的。我在现场就吃过这个亏,平均值达标,客户体验却说卡。

6.3 误识和拒识的调平,是个持续过程

上线之后会发现,真实的分布和标定时不一样。有人换了发型、有人瘦了十几斤、冬天大家都戴帽子。这些都会让相似度整体下移。

我的处理方式是保留一段时间的置信度分布日志(只记分数和是否通过,不记原始图像),定期看分布的漂移情况。如果正样本对的分数中位数从 0.62 掉到 0.55,说明该更新底库了——让这些人重新录入一次。

另一个很实用的策略是双阈值。高于高阈值的直接通过,低于低阈值的直接拒绝,落在中间的走二次验证(比如要求抬头、眨眼,或者输一个工号)。这样能在不显著增加误识的前提下,把整体通过率拉上来。实际用下来,双阈值能把拒识率从 8% 压到 3% 左右,代价是大约 10% 的请求多花几百毫秒。

7. 五个真实踩过的坑,以及排查链路

下面这几个问题都不是看文档能解决的,每个都花了我至少半天。

7.1 同一张图,两次推理结果不一样

现象很诡异:同一张图连续跑十次,特征向量在小数点后四五位有差异,相似度比对时偶尔会跨过阈值导致结果翻转。

排查过程是这样的:先怀疑模型本身有随机性,检查了一遍,前向计算里没有随机算子;再怀疑输入不一样,把输入张量打印出来逐字节比对,发现完全一致;最后把怀疑缩小到线程上——发现推理运行时会根据负载动态调整内部并行策略,浮点归约顺序不同,结果就有微小的末位差异。

这个差异本身只有 1e-6 量级,正常情况下无害。但如果你把阈值卡得极紧(比如刚好在某对样本的分界上),就会出现结果翻转。解决办法是别把阈值卡在临界值上,标定时如果发现某对样本的分数正好落在候选阈值附近,就换一个阈值。同时可以固定推理的线程数和执行模式,让归约顺序稳定下来。

7.2 加了高精度对齐,精度反而掉了

一开始我用的检测器给的是标准五点,对齐模板用的是一套网上的坐标。加了对齐之后,1:N 首位命中率比"直接按框裁剪缩放"还低了两个点。这个结果非常反直觉。

查了两天才发现问题:我用的对齐模板和特征模型训练时用的不是一套。模型训练时用的是另一套目标坐标,我的对齐把脸摆到了一个模型没见过的位置,等于人为制造了分布偏移。

教训是:对齐模板必须和模型的训练配置对齐,这个信息通常在模型的说明或者配套代码里。如果拿不到,就扫一遍——把模板的缩放和偏移作为两个参数,各取三档做九组测试,选最优的。我用这个方法把命中率找回了那丢失的两个点,还多赚了半个点。

7.3 阈值在 A 机器上好好的,换台机器就翻车

这个坑最折磨人。开发机上标定好的阈值 0.38,部署到现场机器上,误识率明显升高。

根因是浮点运算的指令集差异。开发机支持更宽的向量指令,累加顺序和部署机不同,虽然差异在小数点后六七位,但底库大了之后,总有一些样本对儿的分数正好在阈值边缘,误差被放大了。

处理方式是双管齐下:一是在部署环境上重新标定阈值,别拿开发机的数字直接用;二是把相似度计算统一成明确的累加顺序(比如固定分块累加),减少编译器自动向量化带来的不确定性。第二点听起来有点过度,但在对一致性要求高的场景里是必要的。

7.4 跑了三天,内存缓慢增长

压测跑通,上线三天后进程内存从 200 MB 涨到 1.2 GB。没有崩溃,就是缓慢涨。

排查用了三步。第一步,用系统的内存分析工具看增长曲线,发现是线性增长,说明有东西在累积,不是泄漏点的偶发分配。第二步,在关键路径上加计数打点,看每帧分配次数是否恒定,发现特征库的检索结果容器每帧都在扩容。第三步,定位到代码:检索函数里返回了一个 vector,我为了省事在循环外声明成了静态变量然后push_back,从来没清空。

修复就一行:每帧开始前clear()。但这个坑提醒了一件事——所有跨帧复用的容器都必须显式清理,不能指望作用域。现在我的规范是,跨帧变量一律加注释标记,初始化时统一清零。

7.5 视频流里检测框抖动,识别结果闪烁

单张图测试全对,一上视频流就出现"同一人一会儿认出一会儿认不出"。

原因是检测框每帧有几个像素的抖动,导致对齐后的脸图每帧都有细微偏移,特征向量跟着抖,分数在阈值附近来回跳。

解决办法是做时序平滑。最简单有效的是对检测框做指数滑动平均:新框和上一帧的框按权重融合,权重取 0.6 到 0.7 偏向新框,兼顾响应速度和平滑度。同时给每个人的识别结果加一个短时投票——连续三帧里至少两帧判为同一人,才输出这个结果。这两招下去,闪烁基本消失,代价是响应延迟增加了两三帧。

// 简单的框平滑 cv::Rect smooth(const cv::Rect& prev, const cv::Rect& cur, float a = 0.65f) { return cv::Rect( cvRound(a * cur.x + (1 - a) * prev.x), cvRound(a * cur.y + (1 - a) * prev.y), cvRound(a * cur.width + (1 - a) * prev.width), cvRound(a * cur.height + (1 - a) * prev.height)); }

需要提醒的是,平滑要在跟踪 ID 一致的前提下做,否则人一走一进,把两个人的框平均到一起就出鬼了。所以这套东西必须配一个简单的跟踪器,哪怕只是基于交并比的最近邻匹配。

说个额外的体会:人脸识别这套系统,算法部分网上资料多得是,真正拉开差距的是这些工程细节。我在现场调参数的两周里,改算法核心的次数屈指可数,剩下全在改边界处理、改线程模型、改日志。如果让我重来一遍,我会在第一天就把中间结果全部落盘、把延迟拆开打点、把标定脚本先写好——这三件事能省掉后面大部分的猜测时间。另外提醒一句,做这类系统一定要把数据授权和用途告知做在前面,采集、存储、比对每个环节都要有明确的范围约定,别等到上线了才回头补,那时候改起来成本高得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询