一、识别比检测多了什么
检测用的还是那套已经跑通的 SCRFD 2.5g:输入一帧,输出若干个检测框 + 每个框的 5 个关键点(左右眼、鼻尖、左右嘴角)。
识别要的是特征向量:把一张对齐好的 112×112 人脸送进 ArcFace(这里用的是 arcface_mobilefacenet.hef,输出 512 维),得到一个方向向量。同一个人的两张照片,向量夹角小;不同人夹角大。因为向量做了 L2 归一化,余弦相似度就等于点积。
所以"认人"的完整链路是四步:
检测:找到脸,拿到框和 5 个关键点
对齐:按 5 个关键点把人脸摆正,裁成 112×112
提特征:送进 ArcFace,得到 512 维向量
比对:和底库里每个人的模板算余弦,取最大值,超过阈值就判为命中
板子上现成的 TAPPAS 组件看起来正好能把 1→3 串成一条级联流水线:hailonet(SCRFD) → hailocropper(按关键点裁脸) → hailonet(ArcFace) → hailoaggregator(合并结果) → hailoexportfile(导出)。我先试的就是这条。
二、第一版方案
图 1:级联方案的结构。检测、裁剪、识别三个网络串在一条流水线里,最后靠聚合器把两条支路的结果合起来。
跑之前一切都对。480 帧跑完用了 27.0 秒,退出码 0,日志里没有一行报错。我按下面这条命令把结果导出来:
# 在板子上:用 8 张底库照片拼成的视频跑一遍注册流程
RUNSEC=150 bash ~/e-face-recognize.sh --enroll --export-at agg --snap 2
导出的 /tmp/rec.json 有 1.2 MB、480 帧,看起来很正常。但把它解析开,键是这些:
/tmp/rec.json 1205.9 KB 帧数=480
键=HailoBBox×960,HailoDetection×480,HailoLandmarks×480,HailoROI×480
框在(HailoBBox 960 个 = 480 帧 × 2 层)、检测对象在、5 个关键点也在——唯独没有 HailoMatrix。而 512 维特征在 TAPPAS 里正是以 HailoMatrix 的形式挂在对象上的。
换句话说:特征向量在聚合器这一步凭空消失了,而且不报任何错。
于是我把导出位置挪进两条支路,绕开聚合器,让检测和特征各走各的:
RUNSEC=150 bash ~/e-face-recognize.sh --enroll --split --snap 2
图 2:两种导出位置的真实终端输出。上面走聚合器——跑完了但特征没了;下面绕开聚合器——流水线在第 9 帧就再也不动了。
这次连跑都跑不完:150 秒上限被强制杀掉,特征文件只有 2 个字节(就是一对空括号 []),检测支路停在第 9 帧。换 90 秒重跑一次,行为一模一样。
也说明一下,这条 --split 我早先侥幸跑通过一次,那一次它老老实实导出了 480 帧 × 512 维:
/tmp/rec_feat.json 11826.9 KB 帧数=480
键=HailoBBox×480,HailoLandmarks×480,HailoMatrix×480,HailoROI×480
Matrix×480 name=['None'] dim=[512]
同一份脚本、同一段输入,一次能出 480×512、一次卡在第 9 帧。 这种偶发在工程上比稳定失败更麻烦。
三、第二版方案:拆成两段单网络流水线
既然"一条流水线挂两个网络"不稳,那就一条流水线只挂一个网络,中间结果落到本机,本机再把它变成下一段的输入。
图 3:两段式结构。第一段板端只跑检测,第二段板端只跑特征提取,中间的对齐在本机完成。
第 1 段(板端):hailonet(SCRFD) → hailofilter(scrfd_2_5g) → hailoexportfile,导出检测框 + 关键点
中间步(本机):读框和关键点 → 算相似变换 → 裁出 8 张 112×112 对齐脸
第 2 段(板端):hailonet(ArcFace) → hailofilter(arcface_nv12) → hailoexportfile,导出 512 维特征
收尾(本机):取平均、归一化、建底库、算相似度
两段各自的真实耗时:
图 4:两段输出。检测段 480 帧 13.99 秒,提取段 223 帧 2.22 秒。
段落 | 输入规模 | gst 自报耗时 | 折算 | 结果 |
|---|---|---|---|---|
第 1 段 检测 | 480 帧 | 13.99 s | 34.31 FPS(29.1 ms/帧) | 479 帧各 1 个框,置信度 0.757~0.867 |
第 2 段 提特征 | 223 帧 @112×112 | 2.22 s | 约 100 FPS | 223 帧全部拿到 512 维向量 |
两段都只有一个网络,都是"跑到底、出 EOS、零 ERROR"。第 1 段导出的是 479 帧而不是 480,末帧没赶上 EOS 落盘(短输入常见的丢尾帧),底库每张照片有几十帧可用,不影响结果。
对比一下:同一条链路里塞两个网络会卡死,拆成两条单网络链路却是 34 FPS + 100 FPS。 这就是我最后选它的理由。
四、中间步:为什么要在本机做对齐
ArcFace 对"脸摆得正不正"极其敏感——它期望两眼、鼻尖、嘴角落在固定的模板点上。官方给的 112×112 模板点是:
左眼 (38.2946, 51.6963) 右眼 (73.5318, 51.5014) 鼻尖 (56.0252, 71.7366)
左嘴角 (41.5493, 92.3655) 右嘴角 (70.7299, 92.2041)
做法就是用 Umeyama 相似变换(含旋转、等比缩放、平移,不含透视)求一个 2×3 矩阵,把检测出来的 5 个关键点映射到这 5 个模板点,再把整张原图按这个矩阵做仿射重采样。
三个值得说的细节:
不要用框直接裁。直接用 bbox 裁出来的脸,角度、位置、大小都不一致,ArcFace 拿到会"认不准"——这是很多人第一步就埋下的坑。
反向映射要用逆矩阵。PIL 的仿射变换是反向查找,直接传正矩阵会得到一张糊掉的图。
对齐后要做几何自检。我在每张图上算了"5 点投影到模板点后的最大残差"和"出界关键点个数":
01_1 对齐残差 max=4.08px 出界关键点=0
02_2 对齐残差 max=11.95px 出界关键点=0
05_5 对齐残差 max=10.19px 出界关键点=0
08_8 对齐残差 max=10.62px 出界关键点=0
8 张全部出界 0 个。残差 4~12 px 是正常的——5 个点本来就不是严格的相似变换关系(表情、姿态会带来非线性形变),相似变换只是最优近似。
图 5:本机对齐产出的 8 张 112×112 人脸,这就是最终喂给 ArcFace 的输入。
五、关键点坐标系的坑(这一条最花时间)
第一版用的脸是板端 cropper 按框裁出来的(只有框、没按 5 个关键点摆正)。我拿它算了一遍相似度,结果是低得离谱:同一个人 8 张照片,两两余弦最低 0.146、中位 0.345。同一张图内部 30 帧的一致性却有 0.99 —— 说明流水线本身没问题,问题出在对齐上。
排查办法不是去调参数,而是先做一个几何自检:把人脸的基本比例算出来,看合不合理。
SCRFD 给的 5 个关键点坐标是归一化小数。它们到底是"相对整帧归一化"还是"相对检测框归一化"?两种解释都能算出数,但只有一种是物理上成立的:
图 6:同一组关键点,两种坐标解释。左边把坐标当成整帧坐标,嘴角跑到框外面去了;右边当成框内相对坐标,5 个点全在框里。
批量验完 8 张底库照片,结论毫无争议:
解释方式 | 嘴角落在框内 | 眼距 ÷ 框宽 | 是否可能 |
|---|---|---|---|
当成整帧坐标 | 0 / 8 | 0.88 ~ 1.25 | 不可能——眼睛比脸还宽 |
当成框内相对坐标 | 8 / 8 | 0.26 ~ 0.44 | 正常人脸比例 |
也就是说,要把关键点换算到整帧坐标,得这样算:
整帧x = 框xmin + 关键点x × 框宽
整帧y = 框ymin + 关键点y × 框高
改过来之后,同人跨样本的相似度上限从 0.640 抬到 0.789,留一法下限从 0.359 抬到 0.402。
这里我要说得准确一点,避免误导:换对齐之后,相似度中位数反而略降(0.625 → 0.558)。所以判据不是"中位数更高",而是下限抬高、上限打开——而这套结论最硬的支撑是上面那张几何表:0/8 和 8/8 是物理上谁对谁错的问题,不是分数高低的问题。
六、建底库、定阈值
底库的构建方式:每张照片在视频里重复了 30 帧,我取这 30 帧的向量做平均再 L2 归一化,当作这张照片的模板。段内一致性(30 帧与均值的余弦下界)是:
01_1=0.9917 02_2=0.9386 03_3=0.9483 04_4=0.9490
05_5=0.9416 06_6=0.9388 07_7=0.9639 08_8=0.9528
全部在 0.93 以上,说明同一张图的特征非常稳定,取平均是安全的。
然后是阈值。因为手头只有一个人,我只能用留一法来自我标定:把当前这张照片从底库里剔掉,用它去撞剩下的 7 个模板,看最高分是多少。这个分数就是"一个从未见过的同人样本"能达到的下限。
图 7:同一个人 8 张照片两两之间的余弦相似度。对角线是自己在自己身上,恒为 1。
指标 | 值 |
|---|---|
跨样本余弦(56 个格子) | min 0.283 / 中位 0.465 / max 0.789,≥0.5 的占 39% |
留一法最高相似度 | min 0.402 / 中位 0.558 / max 0.789 |
建议阈值(下限 × 0.9) | 0.362 |
8 张照片各自的留一法分数,摊开看是这样:
模板 | 01_1 | 03_3 | 04_4 | 02_2 | 07_7 | 06_6 | 08_8 | 05_5 |
|---|---|---|---|---|---|---|---|---|
留一法最高相似度 | 0.789 | 0.789 | 0.725 | 0.560 | 0.555 | 0.542 | 0.474 | 0.402 |
阈值取 0.362 的理由很直接:它必须低于同人留一法的下限(0.402),否则同一个人的新照片就会被自己拒绝。
然后就是这套方案最该说清楚的一件事:留一法下限 0.402 来自 8 张里的第 5 张——那张大幅仰头、头发还遮了半张脸。它只有 0.402,而其余 7 张都在 0.474 以上。阈值一旦往上抬一点,这张就先掉队。
七、端到端验证
验证脚本把三段串起来:板端检测 → 本机对齐 → 板端提特征 → 与底库比对,最后画一张判定图。
图 8:留一法端到端验证。把待验证照片从底库剔掉,伪装成一张没见过的新照片。
待验证照片 | 留一法最高相似度 | 阈值 | 判定 | 单张耗时(检测 / 特征) |
|---|---|---|---|---|
4.png(正常正面) | 0.729 | 0.362 | 命中 | 约 1.0 s / 1.08 s |
5.png(大幅仰头) | 0.336 | 0.362 | 判为 | 约 0.9 s / 0.64 s |
这里出现了一个值得单独说的落差:同为 5.png,底库自检时留一法能给到 0.402,端到端实测只有 0.336。 差 0.066,刚好跨过阈值。
原因是两者的特征并不是从同一条路径出来的:底库模板取自注册视频里 30 帧的均值,端到端查询是把这张照片重新走一遍"拼视频 → 检测 → 对齐 → 提特征",中间过了一次完整的编码/缩放/裁剪;同一张脸经两条路径进网络,余弦差个 0.05~0.07 是常态。
所以底库自检的分数是偏乐观的。 真实新照片的分数会再低一点——这也是阈值必须留余量(×0.9)的原因。但即使留了余量,5.png 这张仍然掉出去了。
还必须说清楚:这两张其实是同一个人。 所以右边这一次不是"正确拒绝了外人",而是漏识——底库里的角度覆盖不到这个极端姿态。
这就是我在第一条结论里说"底库的姿态覆盖决定漏识率"的原因。正确做法不是把阈值一味往下压(那会放进别人),而是让底库覆盖更多角度:正脸、侧脸、仰头、低头各来一张,光靠一张证件照建库,识别率和没做差不多。
另外还有一个没标定完的点:手头只有一个人,我算不出"误接受率"(把别人认成他)。要定死阈值,至少还需要另一个人的几张照片当"冒充样本"跑一遍。目前 0.362 只能保证"同人不被自己拒"。
汇总
项目 | 数值 |
|---|---|
模型 | SCRFD 2.5g(检测) + ArcFace MobileFaceNet(特征,512 维) |
板端检测吞吐 | 480 帧 / 13.99 s = 34.31 FPS(29.1 ms/帧) |
板端特征吞吐 | 223 帧 / 2.22 s ≈ 100 FPS(112×112 输入) |
单张照片端到端 | 检测约 1 s + 特征 0.6~1.1 s ≈ 2 秒 |
段内一致性(30 帧) | ≥ 0.9386 |
同人跨样本余弦 | 中位 0.465 / 最高 0.789 |
留一法阈值 | 0.362(同人下限 0.402 × 0.9) |
端到端留一法 | 4.png 0.729 命中 / 5.png 0.336 漏识(同人极端姿态) |
