1. 项目拆解:人脸识别不是“一个模型”的事
1.1 完整链路里的人脸识别到底包含哪些环节
前阵子接了一个基于onnx的人脸识别项目,刚开始我以为把训练好的模型转成onnx,再用onnxruntime跑一下就能完事。真正动手才发现,人脸识别从来不是一个模型单独能扛下来的东西,完整链路里至少包含四个环节:人脸检测、人脸对齐、特征提取、特征比对。
先说人脸检测,它负责从摄像头画面或图片里把人脸区域框出来。检测会给出几个关键信息:人脸框坐标、置信度,有些模型还会同时输出人脸上的关键点坐标,比如左眼、右眼、鼻尖、左嘴角、右嘴角。有了关键点,才能进入人脸对齐环节。对齐的意思是把歪头的、侧脸角度不一的照片,通过相似变换统一矫正到标准角度,比如112×112的正脸图。这一步非常关键,因为绝大多数人脸识别模型在训练时用的都是对齐后的标准图,你直接拿原始检测框裁剪出来丢给模型,识别率会明显下降。
特征提取是对齐图经过骨干网络变成一个向量,常见的是512维。之后进入特征比对,把新提取的特征和注册库里已经存好的特征做相似度计算,常用的是余弦相似度,也可以算欧氏距离。相似度超过阈值就认定为同一人,低于阈值就拒绝。整个流程串起来才算一个可用的识别服务。
所以我在这篇文章里聊的“基于onnx的人脸识别”,不是单指某一个模型,而是一套以onnx作为中间格式、以onnxruntime作为推理引擎的工程化方案。你把检测模型、特征提取模型各自转成onnx,再写一个调度层把它们串起来,就能得到一个跨平台、可部署的人脸识别Demo。
1.2 为什么最终选择ONNX而不是原生推理框架
选ONNX的原因,可以用一个生活类比来解释:它就像统一充电接口。你训练时可能用的是PyTorch,也可能有些模型来自TensorFlow,不同框架有各自独立的运行环境。但ONNX把这些模型统一成同一种中间文件格式,之后不管在哪个平台运行,只要有一个合适的ONNX推理引擎就能加载。
当时团队里两种框架的模型都有,如果各跑各的,就要维护两套推理服务:一个实例要装PyTorch和CUDA环境,另一个实例要装TensorFlow Runtime。容器体积大不说,依赖版本经常互相打架。全部转成ONNX后,服务端只需要统一封装onnxruntime,运维和上线都轻量很多。
另外还有一个原因,onnxruntime本身就是为推理场景设计的,它不像PyTorch那样要在推理时带上一堆训练相关的逻辑。它会对计算图做算子融合、常量折叠、内存复用,这些都是实打实的性能收益。对中小型项目来说,这种“导出一次,处处可跑”的思路,性价比比我之前试过的直接拿训练框架部署高太多了。
1.3 为什么不直接在PyTorch或TensorFlow里做服务化
之前我也试过直接把PyTorch模型包装成一个HTTP服务。模型加载没问题,但每次请求进来,都要经过完整的Python处理链,依赖的库非常多。更难受的是,模型文件一旦换版本,整个服务镜像就得重新构建,线上更新策略非常僵硬。TensorFlow Serving也能用,但它的配置和版本绑定同样需要专门经验去维护,对团队里的成员要求更高。
换到ONNX之后,这些负担明显变小。首先推理逻辑被收敛到一个专门的文件里,模型就是那一个.onnx文件,镜像里只需要装onnxruntime和OpenCV这些基础库。其次,如果以后要换推理硬件,比如从CPU换到某款加速卡,只要硬件厂商提供对应的ONNX执行提供方,模型的替换成本很低。
并不是说PyTorch或TensorFlow部署不行,它们适合大型项目,有专门的团队去维护。但面对“快速落地、稳定运行、能跨端复用”这类需求,ONNX方案更贴近实际。
2. 环境准备与模型转换:把训练好的模型安全搬进ONNX
2.1 依赖清单和版本搭配建议
我这次项目主要用到的工具链很简单,依赖如下:
- PyTorch:用来加载训练好的模型权重,并导出onnx
- onnx:读取、检查、保存onnx模型
- onnxruntime:CPU/GPU端推理
- onnxsim:简化onnx计算图,去掉冗余算子
- opencv-python:图像读取、颜色转换、尺寸缩放、仿射变换
- numpy:数组操作和特征归一化
这里要提醒一句,版本不要乱上新。我踩过的一个典型的坑是PyTorch版本太新,导出的算子集对应opset版本很高,但生产环境的onnxruntime版本相对旧,加载时直接报不认识某个算子。比较稳妥的做法是:PyTorch用1.10到1.13之间,onnxruntime用1.14以上的稳定版本,导出时opset_version控制在12左右。这样兼容性最好,绝大多数嵌入式设备或厂商SDK都能识别。
可以用一个requirements.txt把版本锁住,这样团队协作时不会出现“我本地能跑,一到服务器就挂了”的问题。请不要把onnxruntime-nightly或PyTorch-nightly装进生产环境,夜间构建版本虽然新,但稳定性没有保障。
2.2 导出前必须做的模型改造
训练好的模型,尤其是人脸识别模型,在训练阶段通常有分类头、dropout层、BatchNorm的training状态,这些在部署阶段要么没用、要么会拉低性能。在导出之前,原则上只保留骨干网络直到特征向量输出的那一段。
我的做法是重新包一个只包含特征提取的Module,把骨干网络拿过来,关掉所有需要梯度或训练状态的层,然后forward直接返回特征向量。比如ArcFace一类的模型,训练时外面还有一个分类头,部署时去掉分类头,只留backbone输出512维向量。
模型输入输出名字也建议提前定好。很多人忽略这一点,导出来之后用接口默认的“input.1”“output.1”这种名字,后面写推理代码时还得去查图结构。更麻烦的是,如果以后要接新的runtime,输入输出名字不一致会很容易出问题。我通常统一命名为“input”和“embedding”。
Batch维度的处理也有讲究。如果你只跑单张图片,可以将batch固定为1,这样图结构更干净,某些runtime会针对静态shape做更激进的内存优化。如果你要并发处理多张图片,建议用动态batch,在dynamic_axes里把第0维标记为动态轴。
2.3 torch.onnx.export的完整导出示例
下面是我在这个项目里使用的导出代码,以特征提取模型为例:
import torch import torch.nn as nn class FeatureExtractor(nn.Module): def __init__(self, backbone): super().__init__() self.backbone = backbone self.backbone.eval() def forward(self, x): # 输入已经是预处理好的 [B, 3, 112, 112] return self.backbone(x) # 假设model是一个训练好的backbone backbone = torch.load("backbone.pth", map_location="cpu") model = FeatureExtractor(backbone) model.eval() fake_input = torch.randn(1, 3, 112, 112) torch.onnx.export( model, fake_input, "face_feature.onnx", input_names=["input"], output_names=["embedding"], dynamic_axes={"input": {0: "batch"}, "embedding": {0: "batch"}}, opset_version=12, do_constant_folding=True, verbose=False )这里的do_constant_folding建议打开,它会把一些能用常量提前算好的节点直接折叠掉,减少推理时的计算量。dynamic_axes让batch维度可以变化,可带来的代价是模型结构比固定batch更复杂一点。如果你的设备严格限制了输入尺寸,比如只能单张识别,把batch固定成1反而更容易压榨性能。
导出成功后,可以用下面的代码快速验证:
import onnxruntime as ort sess = ort.InferenceSession("face_feature.onnx", providers=["CPUExecutionProvider"]) inp = sess.get_inputs()[0] print(inp.name, inp.shape, inp.type) print(sess.get_outputs()[0].name)正常会看到输入名称是“input”,形状是[None, 3, 112, 112]。看到这个结果基本就说明模型结构正确。
2.4 导出后的检查与图简化
导出后不能直接上线,我一般会做三步检查。第一是结构检查,用onnx.checker把模型文件完整读一遍,确认没有非法算子或缺失参数。第二是精简计算图,用onnxsim把冗余节点去掉,很多模型的体积和推理耗时都能下来一点。第三是输出一致性检查,拿同一个输入分别跑PyTorch原模型和onnx模型,对比输出误差。
import onnx from onnxsim import simplify model_onnx = onnx.load("face_feature.onnx") onnx.checker.check_model(model_onnx) sim_model, check = simplify(model_onnx) if check: onnx.save(sim_model, "face_feature_sim.onnx") print("simplified and saved") else: print("simplify failed")为什么建议onnxsim?因为训练框架导出的图经常会带一些冗余transpose、identity节点,还有格式转换节点,这些在图规模大的时候会拖慢推理。onnxsim能把它们清理掉,还会做常量折叠,部分模型体积能减少20%以上。
输出一致性检查是关键,我用一句话概括:一旦两边误差超过1e-3,基本可以断定是预处理方式不一致或者模型结构在导出时被意外篡改。后面我会单独展开说排查方法。
3. 用onnxruntime跑通完整的人脸识别链路
3.1 预处理细节直接决定识别效果
onnxruntime本身只负责算模型,不管图像处理。你给它的输入必须和训练时保持一致,否则再好的模型也会翻车。
人脸识别模型最常见的输入是112×112的RGB图,像素归一化到[-1,1]区间。注意顺序:OpenCV读出来的是BGR,模型训练时用的往往是PIL读取的RGB,如果你忘了转换,识别效果会直接崩掉,特征相似度会变得很奇怪。
我写的预处理函数大致是这样的:
import cv2 import numpy as np def preprocess(image_bgr, target_size=(112, 112)): # 颜色空间转换:BGR -> RGB img = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) # 尺寸缩放 img = cv2.resize(img, target_size) # 转float并归一化到[0,1] img = img.astype(np.float32) / 255.0 # 再归一化到[-1,1] img = (img - 0.5) / 0.5 # HWC -> CHW img = img.transpose(2, 0, 1) # 增加batch维度 img = np.expand_dims(img, axis=0) return img这里有几个很容易忽略的细节。第一,cv2.resize的插值方式,训练时用的可能是双线性,这里也建议保持一致。第二,归一化前一定要先转float32,先归一化再转float和先转float再归一化结果一样,但先转float性能更好。第三,如果训练时还做了减均值、除方差,这里要补上同款参数。训练脚本里自己写死的均值和方差,一定要翻出来对齐。
3.2 封装一个通用的ONNX推理器
我用ONNX跑过很多模型,不仅是人脸识别,踩出来的经验是:别每次调用都新建InferenceSession。Session创建是非常重的操作,它会做图优化、申请内存、加载算子库,如果每次识别都建一次,性能会差一个量级。正确做法是应用启动时创建一次,识别时只调用run。
下面这个类是我项目的核心部分:
import onnxruntime as ort class FaceFeatureExtractor: def __init__(self, onnx_path): opts = ort.SessionOptions() opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL self.sess = ort.InferenceSession(onnx_path, sess_options=opts, providers=["CPUExecutionProvider"]) self.input_name = self.sess.get_inputs()[0].name def __call__(self, aligned_bgr_img): # 输入:已对齐好的112x112 BGR图像 inp = preprocess(aligned_bgr_img) outputs = self.sess.run(None, {self.input_name: inp}) embedding = outputs[0].flatten() # L2归一化,保证特征向量单位长度 norm = np.linalg.norm(embedding) if norm > 0: embedding = embedding / norm return embedding这里我在注释里写的是“已对齐好的图像”,因为检测和对齐发生在调用这个类之前。特征提取前做L2归一化很重要,模型在训练时一般也用归一化后的特征做损失计算,你推理时不归一化,相似度分数会受影响。另外,sess.run的输入可以用None作为输出名称列表,表示返回所有输出,简化代码。
如果你的模型输出有多个,比如同时输出特征向量和辅助分类结果,可以将输出名称列表写出来,避免每次拿全量输出造成不必要的拷贝。
3.3 人脸检测与关键点对齐的串联方式
人脸检测模型我用过一个输出格式为[1, 1, N, 7]的ONNX模型,其中N是检测出的人脸数量,7通常表示[x1, y1, x2, y2, score, landmark_x5, landmark_y5]。这里5个关键点的顺序一般固定为左眼、右眼、鼻尖、左嘴角、右嘴角。
检测部分的调用方式和特征提取类似,只是输入尺寸不同,一般检测模型要求输入640×640或者320×320的图。你需要把整帧画面缩放到模型输入尺寸,并把检测框坐标还原回原始图像坐标。
拿到关键点后,对齐环节需要做一个仿射变换。常见做法是设计一个标准人脸的参考关键点坐标,比如左眼在(38, 34),右眼在(74, 34),鼻尖在(56, 48),然后根据检测到的关键点和标准点计算相似变换矩阵,最后把整张人脸透视校正到112×112。
这一步我踩过的坑是:不同公开数据集训练的关键点顺序可能不一样,有的模型把顺序定义成“右眼、左眼”,如果你按固定顺序处理,对齐结果就会镜像翻转,特征提取完全失效。建议先从不同角度的几张测试图打印关键点坐标,排除顺序问题。
3.4 人脸特征比对与阈值选择
特征比对这一步相对简单,本质是算两个向量的余弦相似度。
def cosine_similarity(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))由于特征向量已经做了L2归一化,这里可以直接点乘:
def cosine_similarity_normalized(a, b): return float(np.dot(a, b))阈值的选择不能拍脑袋。我实际项目里的做法是:选一批同一个人的多张不同照片作为正样本,再选一批不同人的照片作为负样本,把所有相似度分数统计出来,再取一个正负样本分布分界比较合理的值。通常来说,阈值设在0.3到0.5之间比较常见,具体看你是希望更严防误识别,还是更宽容防止漏识别。
此外,注册库不建议简单存原始特征数组。最好用结构化存储,每条记录包含人员ID、姓名、特征向量、注册时间等字段。每次比对时,遍历库里所有特征向量计算相似度,取最大分数和对应人员ID。如果库里人数很多,后续可以考虑建立索引或聚类,但在中小项目里遍历就够用了。
4. 性能调优和工程落地中的细节
4.1 先量清楚时间花在哪,再谈优化
性能优化最忌讳上来就调线程数或量化,因为很多时候瓶颈并不在模型推理本身,而在图像解码、预处理、或者频繁创建对象。
我当时先给全链路加了耗时打点,把视频帧从摄像头读出来后,按“图像解码/缩放”“检测推理”“仿射变换”“特征提取”“特征比对”五个阶段统计。结果发现,检测和特征提取加一起还不到总耗时的一半,反倒是图像缩放在用OpenCV的interpolation时反复申请内存,占了不少时间。这就说明,先优化预处理可能比量化模型收益更大。
建议你也在代码里加几个计时点,用time.perf_counter包住关键函数,跑一遍典型的输入,这时候再去优化才不跑偏。
4.2 线程数与内置优化参数的设置
onnxruntime默认会使用尽量多的CPU资源,这在服务端场景不一定是好事,可能会导致容器CPU被打满,拖垮其他业务。我通过SessionOptions控制它的并行度:
import onnxruntime as ort sess_options = ort.SessionOptions() sess_options.intra_op_num_threads = 2 sess_options.inter_op_num_threads = 1 sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("face_feature.onnx", sess_options=sess_options, providers=["CPUExecutionProvider"])intra_op是对单个算子内部的并行度,inter_op是不同算子之间的流水线并行度。对单张图片识别来说,inter_op设为1反而更稳定,因为算子间并行会引入额外调度开销。intra_op可以设置为物理核数的一半左右,具体需要压测。
如果你部署的是GPU版本,还要指定CUDAProvider。需要留意的是,onnxruntime的GPU执行提供方命名在不同版本里有过调整,新版可能是“CUDAExecutionProvider”,旧的可能是“CUDAExecutionProvider”,写代码时最好先执行available_providers确认一下。
4.3 模型量化与精简的取舍
onnxruntime自带了量化工具,算子级动态量化是最省事的,不需要重新训练:
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic("face_feature_sim.onnx", "face_feature_int8.onnx", weight_type=QuantType.QInt8)动态量化主要把权重转成int8,推理时再反量化为float计算,好处是通用性好,坏处是速度提升不算特别猛,但模型体积可以明显下降。另一种选择是FP16量化,在GPU上经常能拿到更直接的加速,但有些算子不支持,转完需要回头验证。
量化后一定不要只看单张图的结果,要在验证集上比对相似度分布。我之前量化过一个模型,单张特征比对似乎没问题,但有一个人的照片在入库后和新的照片相似度反而更低了,最后发现是量化误差把边缘样本推过了阈值。量化率不是越高越好,工程落地要用数据说话。
4.4 架构层面的小建议:服务端和客户端分离
如果只是做个Demo,单进程串行处理就可以了。但要做真实项目,我建议把人脸检测和特征提取封装成独立服务,或者至少拆成两个模块。原因很简单,检测模型更新频率高,特征提取模型相对稳定,两者因为图片尺寸、预处理逻辑不同,放到一起会互相干扰。
我实际项目里是把检测服务和特征提取服务部署在同一台机器的两个进程里,共享一个消息队列,检测服务输出的人脸框和关键点结构体通过队列传给特征提取服务。这样即使某个模型升级,也不需要整体重启。数据量变大后,还可以把两个服务拆分到不同机器,队列换成消息中间件。
另外一个容易被忽略的点是GPU显存。onnxruntime在GPU上会默认给每个session申请显存缓存,如果你在同一个进程里同时创建检测和特征提取两个session,显存占用可能成倍增加。建议复用session,并监控整个进程的显存曲线,确认没有不断增长的趋势。
5. 常见问题与排查记录
5.1 高频问题速查表
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| 加载ONNX文件报错 | 路径中包含相对路径,或模型文件不完整 | 改用绝对路径,并检查模型文件大小 |
| 输入维度不匹配 | 动态batch轴没有按预期传入 | 打印session.get_inputs()确认shape,把batch维度补上 |
| 算子无法识别 | opset版本过高或runtime版本过旧 | 降低opset_version,或升级onnxruntime |
| 特征输出和PyTorch不一致 | 预处理不一致或未做L2归一化 | 用同一张图对比输出,统一预处理链路 |
| CPU识别速度很慢 | 未配置线程参数或模型未简化 | 设置intra_op_num_threads,并先做onnxsim |
| 内存持续增长 | 每次都在创建session | 把session固定为单例,复用同一个实例 |
| 摄像头画面卡顿 | 检测和特征提取串行在同一线程 | 拆成流水线,用队列缓冲帧 |
5.2 一个典型的“精度对不上”排查过程
我在项目里最常遇到的是:onnx模型跑出来的特征和PyTorch模型跑出来的特征有差异。一开始我也以为是模型转换出了问题,后来排查下来发现,大部分是因为预处理细节不一致。
定位问题的方法很简单。先取一张112×112的RGB图,分别用PyTorch和onnxruntime推理,看输出差多少:
import numpy as np torch_out = model(fake_tensor).detach().cpu().numpy() ort_out = sess.run(None, {"input": fake_input_numpy})[0] np.testing.assert_allclose(torch_out, ort_out, rtol=1e-3, atol=1e-5)如果差异太大,再逐层检查。可以先测试不带预处理的两个框架输出,如果一致,说明问题出在预处理;如果不一致,很可能是导出时模型结构被改变了,需要检查是否有多余的归一化层或dropout层混在导出的图里。
我后来养成一个习惯:把模型转换、预处理、推理的每一步都做成独立的纯函数,方便随时做单元测试。这个习惯在多人协作时尤其有用,你不用每次都被拉去“回忆我当时是怎么写的”。
5.3 跨平台和版本兼容性的经验
onnxruntime在不同操作系统、不同硬件平台上的行为会有细微差别。同一个模型在Windows上跑得好好的,到了Linux服务器上,可能因为OpenMP调度策略不同,性能表现会很不一样。建议生产环境镜像固定onnxruntime版本,不要用latest。
如果模型要部署到终端设备,比如手机或边缘盒子,尽量使用设备厂商提供的runtime。不同runtime对onnx算子支持的范围不一样,导出时选择opset 12这个相对保守的版本,兼容性最好。如果遇到不支持某个算子,一个粗暴的替代方案是在导出前用onnxsim把该算子替换成更基础的操作,但这样做会增加模型复杂度,所以最好还是参照厂商文档选择支持的算子范围。
还有一个经验是:不要同时升级多个依赖。我见过有团队把onnxruntime从1.12升到1.16,顺便升级了PyTorch和OpenCV,结果模型加载、图像处理、输出全部发生变化,排查了整整一天,最后发现是OpenCV的bugfix改变了某个插值算法的默认行为。要升级就一次只动一个组件,升级完立刻跑回归测试。
一点个人的收尾经验
我做完这个基于onnx的人脸识别项目后,最大的感受是:onnx本身并不难,真正的精力要花在保持整条链路的“一致性”上。模型转换、预处理、对齐、特征归一化、阈值选择,任何一环和训练时不一致,最后结果都会很难看。如果让我重新做一次,我会先写一个统一的数据加载和预处理模块,把模型导出、推理、测试全部建立在这套模块上,而不是一边查文档一边拼代码。希望这篇经验分享能让你少踩几个我已经踩过的坑。