简介:面向具备 C++ 基础、希望进入计算机视觉实战的开发者,这份代码工程围绕 OpenCV 实现两张人脸照片的相似度比对,串联人脸检测、特征提取、距离度量与识别模型调用等环节,可作为课程实验或小型项目的起步脚手架。压缩包共 323 个文件、约 20.1MB,以 126 个 hpp 与 75 个 h 头文件为主体,配合 19 个 so 动态库、12 个 bin 数据文件及 8 个 param 参数文件,另有少量 cpp 源码、cmake 构建脚本与 jpg 测试图,构成可直接编译运行的 OpenCV 2.4 工程结构。目前已有 1626 人学习下载。读者可从中获得 Haar 级联检测与 LBPH、EigenFace、FisherFace 等识别接口的实际调用写法,理解如何用 cv::norm 计算特征向量欧氏距离并给出相似度判定,同时参考其中的组织方式处理库依赖与资源路径,为扩展到视频流、多线程或光照、遮挡等复杂场景打下基础。
1. 从两张照片到一句「像不像」:这个 C++ 人脸比对工程解决什么问题
门禁补录照片和现场抓拍对不上、客服收到的证件照和自拍差距太大——这类「是不是同一个人」的判断,用 C++ 加 OpenCV 写一个本地比对工具就能兜住。这个工程做的事很具体:输入两张人脸照片,输出一个 0 到 1 的相似度分数和判定结果,不依赖云服务,单机可跑。它适合两类人:一是在离线环境做身份核验的 C++ 开发者,二是想搞懂人脸比对链路(检测、对齐、特征、相似度)的学生和刚转视觉方向的工程师。下面从工程骨架讲到核心代码,再到怎么改阈值、换模型,按能跑起来的顺序拆。
2. 工程骨架与依赖:CMake 里怎么把 OpenCV 的 face 模块拉进来
人脸比对不是装个 OpenCV 就能直接compare(),它依赖 OpenCV 4.5.4 之后objdetect模块里的FaceDetectorYN和FaceRecognizerSF两个类。很多人第一次编译就卡在「找不到 FaceDetectorYN」,根因通常不是代码写错,而是 OpenCV 版本太老或者装的是精简包。这一章先把目录、CMake 和模型加载理顺,后面写业务代码才不会边写边补环境。
2.1 目录结构:数据、模型、源码怎么分
我习惯把模型文件和样本图从源码里拆出来,避免 Git 仓库被几十兆的 onnx 撑爆,也方便部署时单独替换模型。一个能直接跑的最小工程长这样:
| 路径 | 作用 | 是否进版本库 |
|---|---|---|
CMakeLists.txt | 构建入口,声明 OpenCV 依赖 | 是 |
include/face_engine.h | 比对引擎的对外接口 | 是 |
src/face_engine.cpp | 检测、对齐、特征、相似度实现 | 是 |
src/main.cpp | 命令行入口,参数解析与结果输出 | 是 |
models/ | 检测模型和识别模型 onnx | 建议单独管理 |
samples/ | 两张测试人脸图 | 可选 |
build/ | CMake 生成目录 | 否 |
models/里通常放两个文件:一个做人脸检测,一个做人脸特征提取。OpenCV 官方模型目录给的命名一般带日期后缀,比如检测模型face_detection_yunet_*.onnx、识别模型face_recognition_sface_*.onnx,实际以你下载到的文件名为准。别把模型硬编码进代码,用命令行参数或配置文件传路径,换模型时不用重新编译。
样本图也有讲究。刚跑通时最好准备三组:同一人不同角度、同一人不同光照、不同人。前两组用来看分数是否稳定,第三组用来看误判。只拿两张几乎一样的图测出 0.99 就以为大功告成,是后面翻车的常见起点。
2.2 CMakeLists 与 OpenCV 模块选择
find_package(OpenCV REQUIRED)一把梭会把所有模块拉进来,编译慢、部署包也大。人脸比对真正用到的只有core、imgproc、imgcodecs、objdetect四个,其中objdetect是 OpenCV 4.5.4 之后才包含FaceDetectorYN的模块,必须显式写进COMPONENTS,否则链接阶段才会报未定义符号,排查起来很绕。
cmake_minimum_required(VERSION 3.16) project(face_compare LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 只挑真正用到的模块,objdetect 是人脸检测/识别的关键 find_package(OpenCV 4.5.4 REQUIRED COMPONENTS core imgproc imgcodecs objdetect) if(NOT OpenCV_FOUND) message(FATAL_ERROR "需要 OpenCV 4.5.4 及以上,且包含 objdetect 模块") endif() add_executable(face_compare src/main.cpp src/face_engine.cpp ) target_include_directories(face_compare PRIVATE include) target_link_libraries(face_compare PRIVATE ${OpenCV_LIBS})版本号写4.5.4不是拍脑袋。FaceDetectorYN和FaceRecognizerSF在这之前没有稳定接口,你用 4.2 或 4.4 编译,头文件里根本找不到类声明。CMake 配置阶段就卡住,比链接阶段报一堆模板错误好排查得多。
如果机器上装了多个 OpenCV,比如系统包管理器一个、自己源码编译一个,find_package可能找到旧的那个。可以在配置时显式指定:
cmake -S . -B build \ -DOpenCV_DIR=/path/to/opencv/lib/cmake/opencv4 \ -DCMAKE_BUILD_TYPE=ReleaseOpenCV_DIR指向的是opencv4的 CMake 配置目录,不是安装根目录。指错一层,CMake 会告诉你找不到OpenCVConfig.cmake,顺着报错往上找一层就行。
2.3 模型加载与校验:别让 create 在空路径上崩
模型加载放在引擎构造函数里,只做一次。FaceDetectorYN::create的第三个参数是初始输入尺寸,不能传(0,0),否则部分版本会直接抛异常。我一般先给(320,320)占位,真正检测前再用setInputSize覆盖成实际图像尺寸。
// include/face_engine.h #pragma once #include <opencv2/opencv.hpp> #include <string> struct FaceCompareResult { bool ok = false; // 是否完成比对 float score = 0.0f; // 余弦相似度,越大越像 cv::Rect box; // 第一张图的人脸框,方便调试 std::string message; // 失败原因 }; class FaceEngine { public: FaceEngine(const std::string& det_model, const std::string& rec_model); FaceCompareResult compare(const cv::Mat& img1, const cv::Mat& img2); private: cv::Ptr<cv::FaceDetectorYN> detector_; cv::Ptr<cv::FaceRecognizerSF> recognizer_; // 返回检测到的单张人脸行向量,失败返回空 Mat cv::Mat detectOne(const cv::Mat& img, cv::Rect& box); // 对齐并提取 128 维特征 cv::Mat extractFeature(const cv::Mat& img, const cv::Mat& face_row); };对应的构造函数实现:
// src/face_engine.cpp #include "face_engine.h" FaceEngine::FaceEngine(const std::string& det_model, const std::string& rec_model) { // 空路径直接抛,别等到 detect 时才崩在更深的地方 if (det_model.empty() || rec_model.empty()) { throw std::invalid_argument("模型路径不能为空"); } // config 传空字符串即可;input_size 先给占位值,检测前会覆盖 detector_ = cv::FaceDetectorYN::create( det_model, "", cv::Size(320, 320), 0.9f, // score_threshold:低于此值的人脸直接丢弃 0.3f, // nms_threshold:重叠框合并阈值 5000 // top_k:最多保留多少个候选框 ); recognizer_ = cv::FaceRecognizerSF::create(rec_model, ""); if (detector_.empty() || recognizer_.empty()) { throw std::runtime_error("模型加载失败,检查 onnx 文件是否完整"); } }score_threshold设 0.9 是偏保守的值,适合证件照、门禁抓拍这类质量还行的图。如果业务图里人脸小、侧脸多,可以降到 0.7 到 0.8,但后面一定要加质量过滤,否则低置信度框会把错误关键点带进对齐环节,分数反而更玄学。
nms_threshold控制重叠框合并,0.3 是 OpenCV 示例里的常用值。调高会保留更多重叠框,调低会合并得更狠,单脸场景影响不大,合影场景才需要细调。
top_k是候选框上限,一般不用动。真正需要注意的是模型文件完整性:onnx 下载中断、被 Git LFS 替换成指针文件,create都可能返回空指针而不抛异常,所以构造完必须判empty()。
3. 核心链路拆解:检测、对齐、特征、相似度四步走
人脸比对看起来是「两张图比一下」,实际拆开是四步:先找到脸,再把脸摆正,然后提特征,最后算距离。少任何一步,分数都会变得不可解释。这一章按调用顺序把每步代码写出来,重点讲参数为什么这么给、失败时看哪里。
3.1 人脸检测:FaceDetectorYN 的输入尺寸与置信度阈值
检测器的输入尺寸必须和当前图像一致,这是最容易忽略的一行。setInputSize不调用,检测器会按构造时的(320,320)去缩放,返回的坐标是缩放后的坐标,你拿它去原图裁剪,框会整体偏移。这个 bug 的表现是「框画出来偏左上」或者「对齐后的人脸是半张」,很容易误判成模型问题。
cv::Mat FaceEngine::detectOne(const cv::Mat& img, cv::Rect& box) { if (img.empty()) { return {}; } // 关键:每次检测前同步输入尺寸,否则坐标会错位 detector_->setInputSize(img.size()); cv::Mat faces; detector_->detect(img, faces); if (faces.empty() || faces.rows == 0) { return {}; } // faces 每行 15 列:x, y, w, h, 5 个关键点(10 列), score int best_idx = 0; float best_score = faces.at<float>(0, 14); for (int i = 1; i < faces.rows; ++i) { float s = faces.at<float>(i, 14); if (s > best_score) { best_score = s; best_idx = i; } } cv::Mat face_row = faces.row(best_idx).clone(); box = cv::Rect( cv::Point2f(face_row.at<float>(0, 0), face_row.at<float>(0, 1)), cv::Size2f(face_row.at<float>(0, 2), face_row.at<float>(0, 3)) ); return face_row; }faces是一个N x 15的浮点矩阵。前四列是检测框左上角坐标和宽高,中间十列是五个关键点(左右眼、鼻尖、左右嘴角)的x,y,最后一列是置信度。取row(best_idx).clone()而不是直接引用,是因为faces是局部变量,函数返回后内存会释放,后面alignCrop再用就会读到野数据。
单脸场景直接取第 0 行也能跑,但合影或者背景有人脸时,结果会随检测顺序变化,同一组图跑两次分数不一样。按置信度取最高的一张是最低成本的稳定策略。如果业务上明确要「画面里最大的人脸」,就把比较依据换成w * h,不要混用。
3.2 五点对齐:把歪脸掰正的相似变换
对齐这一步决定了相似度分数的上限。FaceRecognizerSF::alignCrop内部用五个关键点做相似变换,把脸旋转、缩放到固定尺寸(通常是 112x112)。不调用它,直接把原图裁剪送进特征网络,侧脸和正脸的特征距离会被姿态放大,同一个人也能算出很低的分数。
cv::Mat FaceEngine::extractFeature(const cv::Mat& img, const cv::Mat& face_row) { if (face_row.empty()) { return {}; } cv::Mat aligned; // 用 5 个关键点做相似变换,输出对齐后的人脸 recognizer_->alignCrop(img, face_row, aligned); if (aligned.empty()) { return {}; } cv::Mat feature; // 输出 1x128 的 CV_32F 特征向量 recognizer_->feature(aligned, feature); return feature; }alignCrop的第二个参数必须是1x15的检测行,不能只传Rect。这也是为什么detectOne要返回整行而不是只返回框。很多人图省事自己img(box)裁剪再resize,结果分数忽高忽低,根因就是没有做关键点对齐。
对齐后的人脸图可以落盘看一眼。调试阶段我一般会在extractFeature里加一个开关,把aligned存到debug/目录。如果对齐图里人脸是歪的、裁掉半张、或者眼睛位置明显偏,后面调阈值都是白费力气。这一步的可视化比看分数有用得多。
3.3 特征提取与余弦相似度计算
feature输出的是 128 维浮点向量,match支持两种度量:FR_COSINE返回余弦相似度,越大越像;FR_NORM_L2返回 L2 距离,越小越像。业务里用余弦更直观,分数落在 -1 到 1 之间,阈值也好解释。
FaceCompareResult FaceEngine::compare(const cv::Mat& img1, const cv::Mat& img2) { FaceCompareResult result; if (img1.empty() || img2.empty()) { result.message = "输入图像为空"; return result; } cv::Rect box1, box2; cv::Mat row1 = detectOne(img1, box1); cv::Mat row2 = detectOne(img2, box2); if (row1.empty() || row2.empty()) { result.message = "至少一张图未检测到人脸"; return result; } cv::Mat feat1 = extractFeature(img1, row1); cv::Mat feat2 = extractFeature(img2, row2); if (feat1.empty() || feat2.empty()) { result.message = "特征提取失败"; return result; } // 余弦相似度,越大越相似;官方示例参考阈值约 0.363 double cosine = recognizer_->match( feat1, feat2, cv::FaceRecognizerSF::DisType::FR_COSINE); result.ok = true; result.score = static_cast<float>(cosine); result.box = box1; return result; }match的第三个参数是枚举,写FR_COSINE或FR_NORM_L2都行,但阈值完全不同。余弦 0.363 左右是 OpenCV 示例里给的参考值,L2 对应 1.128 左右。这两个数不能混用,更不能直接抄进业务代码当最终阈值。不同人种、不同年龄段、不同摄像头,分数分布差别很大,后面第 6 章会讲怎么用业务样本重新标定。
还有一个细节:feat1和feat2必须是CV_32F,且形状一致。feature输出就是1x128的浮点矩阵,正常不会出问题,但如果你在中途做了类型转换或者reshape,match会直接抛异常。调试时打印一下feat1.type()和feat1.size(),比猜快。
3.4 把四步封装成一个 compare() 接口
对外只暴露compare(img1, img2),内部把检测、对齐、特征、相似度串起来,调用方不需要知道模型细节。这个封装看起来简单,但决定了后面能不能平滑替换模型、加质量过滤、做批量比对。
接口设计上我踩过一个坑:早期版本把threshold写死在compare里,返回布尔值。后来业务方要调阈值,只能改代码重编译。现在改成返回分数,阈值判断放在调用层,同一套引擎可以服务不同严格度的场景。
// 调用侧:阈值不写死在引擎里 FaceEngine engine("models/det.onnx", "models/rec.onnx"); cv::Mat a = cv::imread("samples/a.jpg"); cv::Mat b = cv::imread("samples/b.jpg"); FaceCompareResult r = engine.compare(a, b); if (r.ok) { const float threshold = 0.363f; // 先按参考值,后续用业务样本替换 std::cout << "score=" << r.score << " same=" << (r.score >= threshold) << std::endl; } else { std::cout << "failed: " << r.message << std::endl; }FaceCompareResult里保留box是为了排查。分数异常时,把框画到原图上,能快速判断是检测偏了还是人脸本身质量差。生产环境可以把box去掉,但调试阶段留着省很多事。
4. 命令行工具落地:从两张图到 JSON 结果
引擎写完只是库,真正要用起来得有一个能敲命令的入口。这一章用 OpenCV 自带的CommandLineParser做参数解析,不引第三方库,输出支持人读和 JSON 两种格式,方便脚本调用。
4.1 参数解析与批量模式
CommandLineParser的 key 定义支持默认值和说明,--help自动生成用法。比手写argv循环省事,也不容易漏参数。
// src/main.cpp #include "face_engine.h" #include <iostream> int main(int argc, char** argv) { const char* keys = "{help h usage ? | | 打印用法}" "{det |models/face_detection_yunet.onnx| 检测模型路径}" "{rec |models/face_recognition_sface.onnx| 识别模型路径}" "{a | | 第一张人脸图}" "{b | | 第二张人脸图}" "{json j |false| 以 JSON 输出结果}"; cv::CommandLineParser parser(argc, argv, keys); parser.about("face_compare: 比较两张人脸照片的相似度"); if (parser.has("help") || !parser.has("a") || !parser.has("b")) { parser.printMessage(); return 1; } std::string det = parser.get<std::string>("det"); std::string rec = parser.get<std::string>("rec"); std::string path_a = parser.get<std::string>("a"); std::string path_b = parser.get<std::string>("b"); bool as_json = parser.get<bool>("json"); if (!parser.check()) { parser.printErrors(); return 1; } cv::Mat img_a = cv::imread(path_a, cv::IMREAD_COLOR); cv::Mat img_b = cv::imread(path_b, cv::IMREAD_COLOR); if (img_a.empty() || img_b.empty()) { std::cerr << "图片读取失败,检查路径: " << path_a << " / " << path_b << std::endl; return 2; } try { FaceEngine engine(det, rec); FaceCompareResult r = engine.compare(img_a, img_b); // 输出部分见下一节 } catch (const std::exception& e) { std::cerr << "engine error: " << e.what() << std::endl; return 3; } return 0; }parser.check()放在取值之后、读图之前,参数类型不对能尽早报出来。imread用IMREAD_COLOR保证三通道,模型接受 BGR,不需要额外转 RGB。如果上游传进来灰度图,记得cvtColor转 BGR,否则检测阶段直接失败或者返回空。
det和rec给了默认相对路径,意味着你可以在工程根目录直接./build/face_compare --a ... --b ...跑,不用每次带一长串模型路径。部署时把models/一起拷过去就行。
4.2 输出结构与阈值判定
人读格式适合手动调试,JSON 适合被脚本或服务调用。两种格式共用同一个FaceCompareResult,不重复计算。
const float kThreshold = 0.363f; // 参考阈值,业务上线前必须重新标定 if (as_json) { std::cout << "{" << "\"ok\":" << (r.ok ? "true" : "false") << "," << "\"score\":" << cv::format("%.4f", r.score) << "," << "\"threshold\":" << kThreshold << "," << "\"same\":" << (r.ok && r.score >= kThreshold ? "true" : "false") << "," << "\"message\":\"" << r.message << "\"" << "}" << std::endl; } else { if (!r.ok) { std::cout << "比对失败: " << r.message << std::endl; } else { std::cout << "相似度: " << r.score << " 判定: " << (r.score >= kThreshold ? "同一人" : "不同人") << std::endl; } }cv::format("%.4f", r.score)控制小数位,避免 JSON 里出现一长串浮点尾数。message里不要带引号或换行,否则 JSON 会非法;如果确实需要,做一个简单的转义函数,把"替换成\"。
阈值放在main里而不是引擎里,是为了让同一个可执行文件通过命令行覆盖。加一个--threshold参数即可:
"{threshold t |0.363| 判定阈值}"这样同一套代码,门禁场景用 0.45,相册聚类用 0.30,不用改代码重编译。
4.3 编译与运行实操
在工程根目录执行三条命令,Release 模式一定要开,Debug 下 OpenCV 的推理会慢好几倍。
# 配置,指定 Release cmake -S . -B build -DCMAKE_BUILD_TYPE=Release # 编译,-j 用满 CPU 核心 cmake --build build -j # 运行,输出 JSON ./build/face_compare \ --a samples/a.jpg \ --b samples/b.jpg \ --json正常输出类似:
{"ok":true,"score":0.6123,"threshold":0.363,"same":true,"message":""}如果输出ok:false,先看message:输入图像为空是路径问题,至少一张图未检测到人脸是检测问题,特征提取失败是对齐或模型问题。按这个顺序排查,比直接调阈值有效。
4.4 批量比对:遍历目录输出 CSV
实际业务很少只比一对。加一个--dir模式,遍历目录里所有图片,两两比对输出 CSV,方便后续做阈值分析。
#include <filesystem> #include <vector> namespace fs = std::filesystem; std::vector<cv::Mat> loadAll(const std::string& dir) { std::vector<cv::Mat> imgs; for (const auto& entry : fs::directory_iterator(dir)) { if (!entry.is_regular_file()) continue; cv::Mat img = cv::imread(entry.path().string(), cv::IMREAD_COLOR); if (!img.empty()) imgs.push_back(img); } return imgs; }拿到imgs后两层循环调engine.compare,把i,j,score写到 CSV。同一批图重复跑,分数应该完全一致;如果两次结果不同,检查是不是每次重新创建了FaceEngine,或者检测阶段没有固定setInputSize。批量模式最大的价值不是产出结果,而是让你看到同一人在不同图上的分数分布,阈值该定多少一目了然。
5. 避坑与排查:人脸比对翻车的五个现场
这一章记的是我在真实调试里踩过的坑,每条按「现象 → 原因 → 解决」写。人脸比对的上限由数据和模型决定,但大部分「分数不对」其实是工程问题,不是模型不行。
5.1 现象:明明是同一个人,分数却只有 0.1
现象很直观:两张同一人的清晰正脸,肉眼几乎一样,score却低得离谱,换一张不同人反而更高。
原因通常出在对齐环节。detectOne返回的 15 列里,关键点如果偏移,alignCrop会把脸旋转到错误角度,特征自然对不上。常见触发条件是人脸太小(小于 40x40)、侧脸超过 45 度、戴口罩或刘海遮住眼睛。检测器仍然能给出框,但关键点已经不准,分数就变成玄学。
解决办法分两层。第一层加质量门槛:检测框宽高都小于 60 像素直接拒绝,关键点置信度(如果有)低于阈值拒绝,眼睛和鼻尖的相对位置明显异常拒绝。第二层把aligned图落盘人工看一批,确认对齐没问题再调阈值。不要一上来就改模型或换算法,先确认输入给特征网络的脸是正的。
5.2 现象:一张图里有多张脸,每次跑结果不一样
同一组图跑两次,score在小数点后两位跳动,或者判定结果在「同一人/不同人」之间横跳。
原因是detect返回多行时,代码默认取了第 0 行。检测框的排序并不保证稳定,尤其合影里人脸大小接近、置信度接近时,两次运行的顺序可能不同。你的「第一张脸」这次是目标人物,下次是背景路人,分数当然会变。
解决方式是在detectOne里显式排序。按置信度取最高,或者按面积取最大,业务上选一种并写死。更稳妥的做法是让上游先裁剪出单张人脸再送进来,比对引擎只负责「一张脸对一张脸」,不承担「选哪张脸」的职责。接口职责越单一,后面越好排查。
5.3 现象:编译报 FaceDetectorYN 未定义,或者链接找不到符号
CMake 配置能过,make到链接阶段报一堆undefined reference to cv::FaceDetectorYN::create。
原因有两个。一是 OpenCV 版本低于 4.5.4,头文件里根本没有这个类;二是装的是精简版或某些包管理器拆分的模块,objdetect没装全。pkg-config --modversion opencv4能看版本,但看不出模块是否完整。
解决办法是先确认版本,再确认objdetect存在:
pkg-config --modversion opencv4 pkg-config --cflags --libs opencv4 | tr ' ' '\n' | grep objdetect第二条命令没有输出,说明当前pkg-config指向的 OpenCV 不带objdetect。要么升级系统包,要么自己源码编译一份带objdetect的 OpenCV,然后在 CMake 里用OpenCV_DIR指过去。自己编译时记得打开BUILD_opencv_objdetect=ON,默认是开的,但有人为了瘦身会关掉。
5.4 现象:模型路径明明对,程序启动就崩
FaceDetectorYN::create调用后进程直接退出,没有异常信息,或者报Assertion failed。
常见原因是 onnx 文件不完整。下载中断、被 Git LFS 替换成几十字节的指针文件、或者从网页复制时存成了 HTML,create都可能读到非法内容。另一种情况是input_size传了(0,0),部分 OpenCV 版本会在内部除零。
解决办法是先检查文件大小,检测模型和识别模型通常都是几兆到几十兆,如果只有几百字节,基本可以确定是坏文件。然后在create之后立刻判空:
detector_ = cv::FaceDetectorYN::create(det_model, "", cv::Size(320, 320)); if (detector_.empty()) { throw std::runtime_error("检测模型加载失败: " + det_model); }input_size给一个非零占位值,真正检测前再setInputSize。这两行能挡住大部分启动崩溃,剩下的再去看 onnx 是否完整。
5.5 现象:大图跑得慢、内存一直涨
单张 4000x3000 的图,比对一次要好几秒,批量跑几百张后内存占用持续上升。
慢的原因是把原图直接喂给检测器。FaceDetectorYN的推理时间和输入尺寸强相关,4000 宽的长边会让检测阶段耗时成倍增加。内存涨的原因通常是cv::Mat浅拷贝后原图一直不释放,或者循环里反复创建FaceEngine导致模型重复加载。
解决方式分两步。检测前把长边缩到 640 到 1024,记录缩放比例,检测完把框坐标乘回去,再在原图上做alignCrop。这样检测快,对齐仍然用原图分辨率,精度不受影响。引擎对象在循环外创建一次,循环内只调compare。如果确实要并发,每个线程持有独立的FaceEngine实例,不要共享同一个Ptr,OpenCV 的推理后端不保证线程安全。
6. 二次开发:把比对分数接进业务系统的三个改造点
拿到能跑的 demo 之后,真正花时间的是「分数怎么用」。下面三个改造点是我接过几类需求后总结出来的,按优先级排。
第一个是阈值标定。不要抄 0.363,用业务样本跑一遍分布:同一人 pair 和不同人 pair 各准备几百对,输出分数后看两条分布曲线的重叠区。重叠区越小,阈值越好定;重叠严重,说明模型或数据质量不满足业务,先加质量过滤而不是硬调阈值。一个简单的标定脚本用第 4 章的批量模式就能改出来,把same标签和score写进 CSV,再用任意表格工具看分位数。
| 场景 | 建议阈值区间 | 说明 |
|---|---|---|
| 门禁核验 | 0.42 到 0.50 | 宁可拒绝,不可误放 |
| 相册聚类 | 0.25 到 0.35 | 允许召回高,人工二次确认 |
| 客服身份初筛 | 0.35 到 0.45 | 结合人工复核 |
第二个是质量过滤前置。在detectOne之后、extractFeature之前加三道判断:人脸框短边不小于 60 像素;Laplacian 方差不低于经验值,挡掉模糊图;左右眼关键点连线角度不超过 ±30 度,挡掉大侧脸。这三道过滤会让「可比对」的图变少,但留下来的分数可信度明显提升。血泪经验是:宁可返回「质量不足,请重拍」,也不要返回一个模棱两可的分数让业务方猜。
第三个是换模型与并发。FaceEngine的接口把模型细节挡住了,换检测模型或识别模型只改构造函数参数,compare不用动。并发场景下每个线程一个实例,模型文件只读,可以共享路径。如果 QPS 高,考虑把比对做成常驻服务,模型加载一次,请求进来只做推理,避免每次命令行启动都重新加载几十兆 onnx。
最后说一个我自己的习惯。从那以后我每次接人脸比对的需求,都先把业务样本的分数分布打出来再定阈值,不再拍脑袋写 0.5,也不再拿两张网图测通就交付。分布图里那两条曲线的重叠区,比任何文档都更能告诉你这个场景能不能做。希望帮到你。
本文还有配套的精品资源,点击获取