简介:这是一套基于ONNX深度学习框架构建的人脸识别系统源码与模型资源,面向计算机视觉学习者、AI应用开发者及需要快速落地人脸识别功能的工程师。资源覆盖人脸检测、人脸识别、年龄性别识别与人脸关键点识别四大能力,并支持图片路径识别、摄像头实时识别以及Web接口调用三种使用方式,适合作为课程设计、项目原型或二次开发的基础方案。压缩包共39个文件,约667.61MB,其中15个onnx模型文件承载检测与识别推理,6个py脚本负责路径推理、摄像头调用与服务接口,另有png、jpg示例图片、ttc字体、bin索引及md说明文档,结构完整。目前已有3333人学习下载,配套B站教程视频可辅助理解。读者可获得可直接运行的多模型推理代码、预置人脸库与索引文件、Flask服务端示例,以及从检测到识别再到属性分析的完整工程组织方式,便于快速验证与迁移到自有业务场景。
1. 从 PyTorch 到 ONNX:人脸识别系统为什么值得换一条推理链路
训练好一个 ArcFace 或 MobileFaceNet,在 PyTorch 里跑出 99% 的验证准确率,这只是上半场。真正把它塞进业务里,你会发现推理侧完全是另一套逻辑:服务器上要压延迟、边缘盒子上要省内存、安卓端要离线跑,而训练框架那套动态图机制在这些场景里又重又慢。ONNX 就是为解决这个断层而生的中间表示——它把模型的计算图固化成一套与框架无关的格式,让同一份权重可以在 onnxruntime、TensorRT、NCNN、RKNN 等不同后端上执行。人脸识别系统尤其吃这套:检测、对齐、特征提取三段流水线里,特征提取模型往往要跨平台部署,用 ONNX 做一次导出,后面换硬件只改推理引擎,不动模型本身。
这篇笔记面向的是已经能训出人脸模型、但卡在部署环节的工程师。我会按「导出 → 校验 → 量化 → 换后端 → 排错」的顺序,把每一步的命令、参数和翻车点讲清楚。你不需要先读完 ONNX 规范,跟着做就能在本地跑通一条完整链路。适合谁:做安防、门禁、考勤、相册聚类这类需要人脸特征比对的开发者,以及想把 PyTorch 模型搬到 C++ 或移动端的人。
2. 导出与校验:把 PyTorch 人脸模型变成可运行的 .onnx
2.1 为什么人脸模型导出比普通分类模型更容易翻车
普通图像分类模型导出 ONNX 通常很顺,因为它的前向就是一条直线:卷积、池化、全连接。人脸识别模型不一样,它有几个天然容易出问题的结构。第一,很多实现会在推理阶段做 L2 归一化或者余弦相似度计算,这些操作如果写在 forward 里,导出时可能被拆成一组算子,也可能因为动态 shape 直接报错。第二,人脸模型常用到 adaptive pooling、动态尺寸输入,导出时如果不固定输入维度,ONNX 会生成带符号维度的图,某些后端不认。第三,ArcFace 的 margin 分支只在训练时生效,推理时必须切到 eval 模式并确认那部分不参与导出。
我一般的做法是:先把模型包一层纯推理的 wrapper,把归一化、预处理这些后处理逻辑从图里剥出来,放到 ONNX 外面用 numpy 或 C++ 做。这样导出的图干净,后端兼容性最好。下面是一个最小可复现的导出脚本。
import torch import torch.nn as nn class FaceInferWrapper(nn.Module): """只保留 backbone 前向,输出未归一化的 embedding""" def __init__(self, backbone): super().__init__() self.backbone = backbone def forward(self, x): # x: [N, 3, 112, 112],已经是归一化后的 float32 feat = self.backbone(x) return feat # 不做 L2 norm,交给外部处理 # 假设 model 是你训练好的模型,先切 eval model.eval() wrapper = FaceInferWrapper(model.backbone).eval() dummy = torch.randn(1, 3, 112, 112) torch.onnx.export( wrapper, dummy, "face_embedding.onnx", input_names=["input"], output_names=["embedding"], opset_version=12, # 12 对大多数后端友好 dynamic_axes=None, # 人脸场景建议固定 batch=1 do_constant_folding=True, ) print("export done")这段代码的关键点有三个。eval()必须调用,否则 BatchNorm 和 Dropout 会带着训练态导出,推理结果直接错。opset_version=12是我在 onnxruntime、TensorRT、NCNN 之间试出来兼容性最稳的一档,低于 11 有些算子不支持,高于 13 部分边缘后端还没跟上。dynamic_axes=None意味着固定输入,人脸识别单张推理居多,固定 shape 能让后端做更激进的图优化,延迟通常比动态 shape 低 10% 到 20%。
2.2 导出后必须做的三项校验
导出成功不等于正确。我见过太多次「导出没报错、推理结果全错」的情况,所以每次导出后固定跑三项校验。
第一项是结构校验,用 onnx 自带的 checker:
import onnx model = onnx.load("face_embedding.onnx") onnx.checker.check_model(model) print("ir_version:", model.ir_version) print("opset:", model.opset_import[0].version) for inp in model.graph.input: print("input:", inp.name, [d.dim_value for d in inp.type.tensor_type.shape.dim])check_model只保证图结构合法,不保证数值正确,所以第二项是数值对齐。用同一张图分别过 PyTorch 和 onnxruntime,比较输出的余弦相似度:
import numpy as np import onnxruntime as ort img = np.random.randn(1, 3, 112, 112).astype(np.float32) with torch.no_grad(): torch_out = wrapper(torch.from_numpy(img)).numpy() sess = ort.InferenceSession("face_embedding.onnx", providers=["CPUExecutionProvider"]) onnx_out = sess.run(None, {"input": img})[0] # 余弦相似度,越接近 1 越好 cos = np.dot(torch_out.flatten(), onnx_out.flatten()) / ( np.linalg.norm(torch_out) * np.linalg.norm(onnx_out)) print("cosine:", cos)经验阈值:余弦相似度低于 0.999 就要查原因,低于 0.99 基本可以判定导出有问题。常见原因是预处理没对齐(比如 PyTorch 里做了归一化,ONNX 输入却喂了原始像素),或者某个算子被替换成了近似实现。
第三项是算子清单检查,看看有没有后端不支持的算子:
from onnx import shape_inference model = shape_inference.infer_shapes(model) ops = set() for node in model.graph.node: ops.add(node.op_type) print(sorted(ops))如果清单里出现GridSample、NonMaxSuppression这类算子,部署到 NCNN 或 RKNN 时大概率要额外处理,后面第 5 章会讲。
2.3 输入预处理放在图内还是图外
这是人脸识别 ONNX 部署里最容易被忽略的选型问题。把归一化(减均值、除标准差)放进图内,好处是调用方只喂原始像素,接口简单;坏处是图里多了一串算子,量化时这些算子对精度影响不好控制,而且不同后端对Sub、Div的融合策略不一样。
我的习惯是:训练和导出时图内不做归一化,把(x - 127.5) / 128.0这类操作放到调用侧。这样 ONNX 图就是一个纯粹的卷积网络,量化友好,跨后端一致。代价是每个调用方都要自己写预处理,所以我会把它封装成一个函数或一个 C++ 头文件,避免各处实现不一致。这个决定在后期做 int8 量化时会省掉很多麻烦,因为量化校准用的就是原始输入分布,不用再反推图内归一化带来的偏移。
3. int8 量化:让 .onnx 在人脸比对精度和速度之间找平衡
3.1 动态量化与静态量化的选择依据
ONNX 的 int8 量化分两条路。动态量化(dynamic quantization)只量化权重,激活值在运行时动态算 scale,实现简单,不需要校准数据,但加速有限,通常只快 1.2 到 1.5 倍。静态量化(static quantization)权重和激活都量化,需要一批校准数据跑一遍统计激活分布,加速能到 2 到 4 倍,是人脸识别系统上生产环境的主流选择。
人脸模型对精度敏感,因为最终比的是特征向量的余弦距离,量化误差会直接反映成误识率和拒识率的变化。我一般先用静态量化试,如果 FAR(误接受率)在阈值点上恶化超过 0.5 个百分点,就退回动态量化或者只量化部分层。下面是一个静态量化的完整流程。
from onnxruntime.quantization import ( quantize_static, CalibrationDataReader, QuantType, QuantFormat ) import numpy as np class FaceCalibReader(CalibrationDataReader): """校准数据:200~500 张真实人脸对齐后的图,覆盖不同光照和角度""" def __init__(self, img_paths, input_name="input"): self.data = iter([ {input_name: preprocess(p).astype(np.float32)} for p in img_paths ]) def get_next(self): return next(self.data, None) reader = FaceCalibReader(calib_paths) quantize_static( model_input="face_embedding.onnx", model_output="face_embedding_int8.onnx", calibration_data_reader=reader, quant_format=QuantFormat.QDQ, # QDQ 格式对多数后端更友好 activation_type=QuantType.QInt8, weight_type=QuantType.QInt8, per_channel=True, # 权重按通道量化,精度更好 )参数说明:QuantFormat.QDQ会在图里插入 QuantizeLinear/DequantizeLinear 节点,相比 QOperator 格式,它更容易被 TensorRT、OpenVINO 这类后端识别和融合。per_channel=True对卷积权重逐通道算 scale,比全局一个 scale 精度高不少,代价是模型体积略增。校准数据量不用多,200 到 500 张就够,但必须覆盖你实际业务里的光照、角度、遮挡分布,否则量化后的激活范围估计会偏。
3.2 量化后怎么验证人脸比对精度没崩
量化完不能只看单张输出的余弦相似度,那只能说明数值没炸,说明不了业务精度。正确做法是跑一遍人脸验证评测:准备一批同人配对和异人配对,分别算量化前后的特征,画 ROC 曲线,比较在相同 FAR 下的 TAR,或者直接看最佳阈值处的准确率。
def eval_pair_accuracy(sess, pairs, labels, threshold=0.5): """pairs: [(img_a, img_b), ...], labels: 1 同人 0 异人""" correct = 0 for (a, b), label in zip(pairs, labels): fa = sess.run(None, {"input": a[None].astype(np.float32)})[0].flatten() fb = sess.run(None, {"input": b[None].astype(np.float32)})[0].flatten() cos = np.dot(fa, fb) / (np.linalg.norm(fa) * np.linalg.norm(fb)) pred = 1 if cos > threshold else 0 correct += (pred == label) return correct / len(pairs) acc_fp32 = eval_pair_accuracy(sess_fp32, pairs, labels) acc_int8 = eval_pair_accuracy(sess_int8, pairs, labels) print(f"fp32: {acc_fp32:.4f}, int8: {acc_int8:.4f}")如果 int8 掉点超过 1 个百分点,先别急着放弃量化,按这个顺序排查:校准集是不是太小或分布太窄;per_channel有没有开;是不是把第一层和最后一层也量化了(这两层对精度影响大,可以排除);QuantFormat换成 QOperator 试试。我遇到过校准集全是正脸、业务里却有大量侧脸,量化后侧脸特征直接崩掉的情况,补了侧脸样本就恢复了。
3.3 量化模型的体积和延迟实测参考
下面这张表是我在几个常见人脸 backbone 上的实测区间,硬件是 x86 CPU 单线程,输入 112x112,仅供参考,具体数值随实现和后端版本浮动。
| 模型 | fp32 体积 | int8 体积 | fp32 延迟 | int8 延迟 | 精度变化 |
|---|---|---|---|---|---|
| MobileFaceNet | 约 5 MB | 约 1.3 MB | 8~12 ms | 3~5 ms | < 0.3% |
| ResNet50 人脸版 | 约 100 MB | 约 25 MB | 60~90 ms | 20~35 ms | < 0.5% |
| 轻量 ViT 人脸 | 约 25 MB | 约 7 MB | 25~40 ms | 10~18 ms | 0.5%~1% |
体积基本是 4 倍压缩,延迟提升取决于后端对 int8 算子的支持程度。CPU 上 onnxruntime 的 int8 卷积优化已经比较成熟,GPU 上则要看 TensorRT 的融合情况。注意 ViT 类模型量化掉点通常比 CNN 明显,因为注意力层的激活分布更分散,如果业务对精度要求高,ViT 建议只做动态量化或者混合精度。
4. 换后端:onnxruntime、NCNN、RKNN 的落地差异
4.1 onnxruntime 和 onnx 到底什么关系
很多人一开始会混淆这两个概念。ONNX 是格式标准,定义了一张计算图长什么样;onnxruntime 是微软开源的推理引擎,负责把这张图跑起来。你可以把 ONNX 理解成 PDF,onnxruntime 理解成阅读器。同一份 .onnx 文件,onnxruntime 能跑,TensorRT 能跑,NCNN 也能跑,区别在于各家对算子的支持程度和优化策略。
onnxruntime 的优势是跨平台、安装简单、CPU 上性能不错,适合服务器端和桌面端快速落地。它的 Python 包一行 pip 就能装,C++ 也有预编译库。人脸识别系统如果部署在 x86 服务器上做批量比对,onnxruntime 基本是首选。配置上主要调这几个:intra_op_num_threads控制算子内并行线程数,graph_optimization_level设成ORT_ENABLE_ALL开启图优化,execution_mode用ORT_SEQUENTIAL还是ORT_PARALLEL看你的 batch 策略。
so = ort.SessionOptions() so.intra_op_num_threads = 4 so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL sess = ort.InferenceSession("face_embedding_int8.onnx", so, providers=["CPUExecutionProvider"])4.2 转 NCNN 和 RKNN 时要注意什么
移动端和国产 NPU 上,NCNN 和 RKNN 是绕不开的两个后端。NCNN 主打手机 CPU 推理,体积小、无第三方依赖;RKNN 是瑞芯微 NPU 的工具链,跑在 RK3588 这类板子上。两者都需要把 ONNX 转成自己的格式,转换过程是踩坑重灾区。
NCNN 转换用onnx2ncnn工具,转完还要用ncnnoptimize做一次图优化。常见问题是 ONNX 里的某些算子 NCNN 不支持,比如HardSwish、Gelu,需要你在导出前把模型里的这些激活换成ReLU或Sigmoid,或者用 NCNN 的自定义层补。转换后一定要用onnx2ncnn输出的警告信息逐条核对,它会把不支持的算子列出来。
RKNN 转换用rknn-toolkit2,流程是 ONNX → RKNN,中间要做量化。RKNN 对输入 shape 和量化校准集要求更严,输入必须是固定 shape,校准集建议 300 张以上。转换脚本大致长这样:
from rknn.api import RKNN rknn = RKNN() rknn.config(mean_values=[[127.5, 127.5, 127.5]], std_values=[[128.0, 128.0, 128.0]], target_platform="rk3588") rknn.load_onnx(model="face_embedding.onnx") rknn.build(do_quantization=True, dataset="calib.txt") rknn.export_rknn("face_embedding.rknn")注意mean_values和std_values如果在这里配了,ONNX 图里就不要再做归一化,否则会重复处理。calib.txt每行是一张校准图的路径。RKNN 量化后精度如果掉得厉害,可以试do_quantization=False先跑 fp16,确认模型本身没问题再开量化。
4.3 一份模型多后端分发的工程组织方式
实际项目里,同一份人脸模型往往要同时供服务器、安卓、边缘盒子使用。我的组织方式是:训练仓库只负责导出 fp32 ONNX,作为唯一源头;然后每个目标平台一个转换脚本,产物放在各自的deploy/目录下,用 CI 串起来。这样模型更新时只改一处,各端重新转换即可。目录结构大致是:
models/ face_embedding.onnx # 源头,fp32 face_embedding_int8.onnx # onnxruntime 用 deploy/ ncnn/convert.sh rknn/convert.py tensorrt/convert.py每个转换脚本都要带一个校验步骤,转换完自动跑一遍数值对齐,对齐不过就 fail,避免把坏模型发到端上。这个习惯帮我挡过好几次「转换工具静默出错」的问题。
5. 避坑与排查:人脸识别 ONNX 部署最常见的五类翻车
5.1 导出成功但推理结果全错
现象:torch.onnx.export没报错,onnxruntime 也能加载,但输出和 PyTorch 对不上,余弦相似度只有 0.7 甚至更低。原因通常是模型没切eval(),BatchNorm 用了训练态的 running stats,或者 Dropout 还在随机丢弃。另一个常见原因是导出时用了training=torch.onnx.TrainingMode.TRAINING,图里保留了训练分支。解决:导出前强制model.eval(),并用torch.no_grad()包住 dummy 前向;导出后立刻跑 2.2 节的数值对齐,不通过就不往下走。
5.2 动态 shape 导致后端加载失败
现象:ONNX 在 onnxruntime 上跑得好好的,转到 NCNN 或 RKNN 时报 shape 不匹配或者直接拒绝加载。原因是导出时用了dynamic_axes,图里带了符号维度,而这两个后端只接受固定 shape。解决:人脸识别场景输入尺寸本来就是固定的(112x112 或 160x160),导出时直接dynamic_axes=None。如果确实需要动态 batch,只在 onnxruntime 和 TensorRT 上用,边缘端单独导一份固定 shape 的。
5.3 int8 量化后同人比对距离整体偏移
现象:量化后模型没崩,但同一个人的两张图余弦相似度普遍下降,异人之间的相似度也下降,导致固定阈值失效。原因是量化改变了特征空间的尺度,虽然排序关系大体保留,但绝对距离变了。解决:量化后必须重新标定阈值,不能沿用 fp32 的阈值。用一批标注好的同人/异人配对重新画 ROC,选等错误率点作为新阈值。如果业务允许,也可以在量化模型后面接一个轻量的校准层,但多数情况下重标阈值就够了。
5.4 校准集选得不对导致量化精度雪崩
现象:量化后正脸精度还行,侧脸、暗光、戴口罩的场景误识率飙升。原因是校准集只用了正脸清晰图,激活范围估计偏窄,量化 scale 覆盖不到真实分布。解决:校准集必须从业务真实数据里采样,覆盖各种光照、角度、遮挡、年龄分布,数量 200 到 500 张即可,但分布要广。我一般会按场景分层采样,每个场景至少 30 张,避免某一类样本主导校准。
5.5 多线程推理下结果偶发不一致
现象:单线程跑没问题,开多线程后偶尔出现特征向量异常,比对结果抖动。原因是 onnxruntime 的 session 在多线程下共享,如果调用方没有做好输入 buffer 隔离,或者用了ORT_PARALLEL执行模式但模型里有状态算子,就可能出问题。解决:人脸识别这种单张低延迟场景,execution_mode用ORT_SEQUENTIAL,靠intra_op_num_threads提并行度;每个线程用独立的输入 numpy 数组,不要复用同一个 buffer;如果并发量高,起多个 session 实例做池化,比单 session 多线程更稳。
6. 用一张参考图做端到端回归:把 ONNX 人脸链路钉死在 CI 里
前面讲的都是单点,真正让这套链路稳定的是回归测试。我的做法是:在仓库里放一张固定的参考人脸图,以及它对应的 fp32 特征向量(golden embedding),每次模型导出、量化、换后端后,自动跑一遍端到端比对,把新特征和 golden 特征算余弦相似度,低于阈值就报警。这样任何一次改动引入的精度回退都会在合并前暴露,而不是等上线后靠用户投诉发现。
具体实现上,golden 特征在第一次确认模型正确时生成并入库,之后作为基准。回归脚本要覆盖三个层次:fp32 ONNX 对 PyTorch、int8 ONNX 对 fp32 ONNX、目标后端(NCNN/RKNN)对 fp32 ONNX。每层的阈值可以不同,fp32 层要求 0.999 以上,int8 层放宽到 0.995,边缘后端因为量化策略不同可以放到 0.99。下面是一个可以直接抄的回归脚本骨架。
import numpy as np import onnxruntime as ort GOLDEN = np.load("golden_embedding.npy") # 首次确认正确后保存 IMG = preprocess("regression_face.jpg")[None].astype(np.float32) def cosine(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def check(model_path, threshold): sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) out = sess.run(None, {"input": IMG})[0].flatten() sim = cosine(out, GOLDEN) status = "PASS" if sim >= threshold else "FAIL" print(f"{model_path}: cosine={sim:.5f} [{status}]") return sim >= threshold ok = check("face_embedding.onnx", 0.999) ok &= check("face_embedding_int8.onnx", 0.995) assert ok, "regression failed, do not ship"这个脚本的价值在于把「模型对不对」从主观判断变成了一条可执行的断言。我踩过最深的坑就是某次换了个量化工具版本,模型体积和延迟都正常,但特征空间悄悄偏了,靠人工抽检根本发现不了,最后是回归脚本拦下来的。阈值不要设得太松,松了等于没设;也不要太紧,紧到每次微小改动都报警,团队会逐渐忽略它。我的经验是留出刚好能覆盖正常量化误差的余量,然后严格执行。
另外一个小技巧:golden 特征不要只存一张图,存 5 到 10 张覆盖不同场景的图,取平均相似度或者最差相似度作为判据。单张图容易受预处理细节影响,多张图能更稳地反映整体精度。这套回归跑一次不到两秒,放进 CI 的 pre-commit 钩子里完全无感,但能省掉大量事后排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取