ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch人脸识别模型ONNX部署实战:导出、量化与多后端推理

PyTorch人脸识别模型ONNX部署实战:导出、量化与多后端推理 简介这是一套基于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 人脸模型变成可运行的 .onnx2.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_version12, # 12 对大多数后端友好 dynamic_axesNone, # 人脸场景建议固定 batch1 do_constant_foldingTrue, ) print(export done)这段代码的关键点有三个。eval()必须调用否则 BatchNorm 和 Dropout 会带着训练态导出推理结果直接错。opset_version12是我在 onnxruntime、TensorRT、NCNN 之间试出来兼容性最稳的一档低于 11 有些算子不支持高于 13 部分边缘后端还没跟上。dynamic_axesNone意味着固定输入人脸识别单张推理居多固定 shape 能让后端做更激进的图优化延迟通常比动态 shape 低 10% 到 20%。2.2 导出后必须做的三项校验导出成功不等于正确。我见过太多次「导出没报错、推理结果全错」的情况所以每次导出后固定跑三项校验。第一项是结构校验用 onnx 自带的 checkerimport 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_nameinput): 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_inputface_embedding.onnx, model_outputface_embedding_int8.onnx, calibration_data_readerreader, quant_formatQuantFormat.QDQ, # QDQ 格式对多数后端更友好 activation_typeQuantType.QInt8, weight_typeQuantType.QInt8, per_channelTrue, # 权重按通道量化精度更好 )参数说明QuantFormat.QDQ会在图里插入 QuantizeLinear/DequantizeLinear 节点相比 QOperator 格式它更容易被 TensorRT、OpenVINO 这类后端识别和融合。per_channelTrue对卷积权重逐通道算 scale比全局一个 scale 精度高不少代价是模型体积略增。校准数据量不用多200 到 500 张就够但必须覆盖你实际业务里的光照、角度、遮挡分布否则量化后的激活范围估计会偏。3.2 量化后怎么验证人脸比对精度没崩量化完不能只看单张输出的余弦相似度那只能说明数值没炸说明不了业务精度。正确做法是跑一遍人脸验证评测准备一批同人配对和异人配对分别算量化前后的特征画 ROC 曲线比较在相同 FAR 下的 TAR或者直接看最佳阈值处的准确率。def eval_pair_accuracy(sess, pairs, labels, threshold0.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(ffp32: {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 MB8~12 ms3~5 ms 0.3%ResNet50 人脸版约 100 MB约 25 MB60~90 ms20~35 ms 0.5%轻量 ViT 人脸约 25 MB约 7 MB25~40 ms10~18 ms0.5%~1%体积基本是 4 倍压缩延迟提升取决于后端对 int8 算子的支持程度。CPU 上 onnxruntime 的 int8 卷积优化已经比较成熟GPU 上则要看 TensorRT 的融合情况。注意 ViT 类模型量化掉点通常比 CNN 明显因为注意力层的激活分布更分散如果业务对精度要求高ViT 建议只做动态量化或者混合精度。4. 换后端onnxruntime、NCNN、RKNN 的落地差异4.1 onnxruntime 和 onnx 到底什么关系很多人一开始会混淆这两个概念。ONNX 是格式标准定义了一张计算图长什么样onnxruntime 是微软开源的推理引擎负责把这张图跑起来。你可以把 ONNX 理解成 PDFonnxruntime 理解成阅读器。同一份 .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_platformrk3588) rknn.load_onnx(modelface_embedding.onnx) rknn.build(do_quantizationTrue, datasetcalib.txt) rknn.export_rknn(face_embedding.rknn)注意mean_values和std_values如果在这里配了ONNX 图里就不要再做归一化否则会重复处理。calib.txt每行是一张校准图的路径。RKNN 量化后精度如果掉得厉害可以试do_quantizationFalse先跑 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 还在随机丢弃。另一个常见原因是导出时用了trainingtorch.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_axesNone。如果确实需要动态 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 钩子里完全无感但能省掉大量事后排查的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表