去年我接了一个模拟项目X,要在Windows和Linux两种环境下跑人脸识别,训练那边用的是PyTorch,部署端却是C++为主,后面甚至还要考虑换到嵌入式板卡上。模型格式如果不统一,每次换环境几乎等于把推理代码重写一遍,维护成本极高。最后整套方案都围绕ONNX来搭:模型统一导出成ONNX,推理统一走ONNX Runtime,训练框架和部署框架各管一头,中间用一套标准接口对接。这套方案前后跑了小半年,稳定性和识别效果都调到了能用的状态。这篇文章就把我从模型选型、转换、推理、优化到踩坑的记录完整整理一遍,适合正在做模型部署,或者被跨框架推理问题折磨的人。
1. 为什么选择ONNX做人脸识别中间格式
1.1 跨框架生态的“通用语”
先说一个很现实的场景:人脸识别不是一个单一模型就能搞定的东西,它通常包含人脸检测、人脸关键点、人脸特征提取三个独立环节。检测模型常用训练框架A跑出来的,识别模型可能是训练框架B跑出来的,甚至关键点模型来自C。如果每个模型都用自己原生格式部署,那推理服务里要同时维护几套不同的加载和调用接口,代码会变得非常恶心。
ONNX(Open Neural Network Exchange)解决的就是这个问题。它不是某个私有格式,而是一套开放的模型交换标准。PyTorch可以导出ONNX,TensorFlow可以转成ONNX,训练好之后统一给ONNX Runtime消费。这样不管源头是什么框架,到了部署侧都变成同一个东西,加载方式一致,输入输出约定也相对统一。
相比其他格式,TorchScript绑定PyTorch生态,TensorRT绑定特定GPU硬件,TFLite偏移动端,ONNX的兼容面是最广的。对于需要同时覆盖CPU、GPU,甚至后续可能换嵌入式板卡的场景,ONNX是折中之后最稳的选择。
1.2 推理运行时与部署形态的灵活性
ONNX Runtime是官方主推的推理引擎,它不只是能跑普通PC,还支持移动端、WebAssembly、嵌入式环境,也有完整的C++ API。这一点对人脸识别项目很重要,因为很多实际项目是摄像头盒子、门禁机这类设备,不能指望每台机器都装Python环境。
我当时的部署目标有两个:一个是带独立显卡的PC服务端,另一个是无GPU的Linux盒子。ONNX Runtime在两种环境下都能跑,只需要切换执行提供程序(Execution Provider),代码主体不用动。这比换框架重写推理要省太多事。
还有一个隐藏优势:ONNX模型是静态的计算图描述,可读性比TorchScript的二进制好很多。遇到模型输出维度对不上、算子不兼容之类的问题,能用Netron打开图直接看结构,定位效率高。人脸识别里检测头、解码层、输出reshape这些环节经常出错,可视化的价值非常大。
1.3 从训练到部署的整体转换思路
转换思路其实很简单:模型训练完,导出前把模型切到eval模式,固定一些可变参数,用假输入跑一遍前向,同时把输入输出名字约定好。转换完不要直接丢到项目里,先用几个真实样张做一致性验证,确认两边输出的特征向量误差在可接受范围内,再进行下一步。
如果原始模型是TensorFlow,也有对应的转换工具,本质上就是把计算图映射成ONNX算子。这里有个经验:尽量不要在模型内部塞太多复杂后处理逻辑,尤其是NMS和自定义解码。这些东西在不同推理引擎上的算子兼容性很容易翻车,宁可让模型只输出最原始的卷积结果,解码和NMS放到外部用代码实现,后面部署到其他设备时更省心。
2. 模型选型与转换细节
2.1 人脸检测、对齐、识别模型怎么选
基于ONNX的人脸识别,本质上还是“检测 + 对齐 + 识别”的经典流水线。先说检测模型。轻量级首选UltraFace类模型,输入分辨率通常是320或640,输出是若干候选框和关键点,计算量小,CPU也能跑到实时。如果对精度要求更高,可以用RetinaFace类模型,但尺寸和耗时都会上来。
识别模型这边,常用的是MobileFaceNet这一类轻量化特征提取网络,输出维度一般是128维或512维。128维内存和计算更小,512维表达能力更强。对于几百人的库,128维已经够用;如果做大规模检索,512维配合向量检索工具更好。
对齐这一步容易被忽略,但实际影响很大。很多检测模型会同时输出眼睛、鼻子、嘴角等关键点,利用关键点做仿射变换把脸部区域校正到标准位置,再送入识别模型。没有这一步,侧脸、抬头、低头的识别率会明显下降。
2.2 从PyTorch导出ONNX的关键参数
以PyTorch导出MobileFaceNet为例,核心代码大概是这样:
import torch from mobilefacenet import MobileFaceNet model = MobileFaceNet(num_classes=512) checkpoint = torch.load("checkpoint.pth", map_location="cpu") model.load_state_dict(checkpoint["state_dict"]) model.eval() dummy_input = torch.randn(1, 3, 112, 112) torch.onnx.export( model, dummy_input, "mobilefacenet.onnx", input_names=["input"], output_names=["embedding"], dynamic_axes={"input": {0: "batch"}, "embedding": {0: "batch"}}, opset_version=11, )几个参数要重点说。model.eval()必须放在导出之前,不然BatchNorm和Dropout的状态不对,导出的模型推理结果会和训练时不一致。dummy_input的shape要和真实输入一致,这里识别模型用的是3x112x112。dynamic_axes支持动态batch,这样运行时可以一次传多张人脸图,也可以不设置动态轴,固定1张更简单,但灵活性差很多。
opset_version也就是算子集版本,一般选11到13之间比较稳定。太高可能遇到推理端尚未支持的算子,太低则某些新算子导出不了。如果模型里有用到较新的op,可以先导出看看报错信息再调整。
2.3 输入输出约定:NCHW、归一化与特征向量
这部分是所有人脸识别部署里最容易踩坑的地方。先说输入格式。绝大多数模型训练时用的是RGB图像,张量布局是NCHW。但OpenCV默认读图是BGR,而且高度和宽度顺序是HWC,所以推理前必须做:
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (112, 112)) img = img.astype(np.float32) / 255.0 img = (img - 0.5) / 0.5 img = np.transpose(img, (2, 0, 1)) img = np.expand_dims(img, 0).astype(np.float32)归一化这一步尤其关键。如果训练时用的是ImageNet的mean/std,但推理时只做了/255,或者反过来,最终的特征向量会整体偏移,相似度全部乱套。我习惯在模型训练代码里找到数据预处理那段,原封不动搬到推理端,而不是凭经验猜。
输出这边,特征提取模型最终输出一个形状为(1, 512)或(512,)的向量。训练时用的损失函数通常是基于归一化内积或余弦距离的,所以推理时最好也做一次L2归一化:
embedding = embedding / np.linalg.norm(embedding)归一化之后,比对两个人的特征时直接算内积或者余弦相似度,值域会稳定在一个合理范围内,阈值才有意义。
2.4 转换后的一致性验证
模型导出完不能直接上线,要做一致性验证。方法很简单:取一两张标准人脸图,分别用PyTorch原生推理和ONNX Runtime推理,对比两者输出的特征向量。
import onnxruntime as ort import numpy as np session = ort.InferenceSession("mobilefacenet.onnx") onnx_out = session.run(["embedding"], {"input": img})[0]比对时注意两点:一是opfset版本不同可能带来浮点微小差异,一般余弦相似度在0.999以上就算正常;二是如果发现输出形状不一致,优先检查输入数据是否完全一样,包括图像缩放方式、归一化参数、通道顺序。验证通过后再往项目里集成,能省掉后面大量排查时间。
3. 基于ONNX Runtime的推理实现
3.1 环境准备与依赖安装
推理环境其实很简单,核心就是ONNX Runtime和OpenCV。我通常直接用pip装:
pip install onnx onnxruntime opencv-python numpy如果机器有NVIDIA显卡,还可以安装onnxruntime-gpu,这样能自动用CUDA跑模型。CPU机器则安装CPU版,体积更小。
这里有个经验:不要同时混装onnxruntime和onnxruntime-gpu,两个包的模块名都是onnxruntime,混装会导致二选一,而且经常出现库加载失败的问题。先确定目标环境,再选择对应版本。
3.2 用Python实现检测与特征提取
推理逻辑不复杂,关键是预处理要和模型训练时的约定匹配。下面是一个简化的检测和识别流程:
import cv2 import numpy as np import onnxruntime as ort det_session = ort.InferenceSession("ultraface.onnx", providers=["CPUExecutionProvider"]) rec_session = ort.InferenceSession("mobilefacenet.onnx", providers=["CPUExecutionProvider"]) def preprocess_det(img, input_size=(320, 320)): h, w = img.shape[:2] scale = min(input_size[0] / h, input_size[1] / w) new_w, new_h = int(w * scale), int(h * scale) resized = cv2.resize(img, (new_w, new_h)) canvas = np.zeros((input_size[0], input_size[1], 3), dtype=np.float32) canvas[:new_h, :new_w] = resized blob = canvas[:, :, ::-1].transpose(2, 0, 1)[None, ...].astype(np.float32) return blob, scale, new_w, new_h检测模型的输出需要根据具体网络解码。有的导出模型已经包含了anchor解码,直接输出坐标;有的只输出原始heatmap,需要自己解码。我在项目里默认输出是已经解码的候选框,形如(batch, num_anchors, 4),然后接过置信度过滤和NMS。
得到人脸框之后,从原图裁出人脸区域,用关键点做对齐。简化版可以直接用检测框里的内容resize到112x112,但效果会比较差。完整做法是用关键点计算仿射矩阵,把眼部位置对齐到固定坐标:
def align_face(img, keypoints): left_eye = keypoints[0] right_eye = keypoints[1] dx = right_eye[0] - left_eye[0] dy = right_eye[1] - left_eye[1] angle = np.degrees(np.arctan2(dy, dx)) center = (img.shape[1] // 2, img.shape[0] // 2) matrix = cv2.getRotationMatrix2D(center, angle, 1.0) aligned = cv2.warpAffine(img, matrix, (img.shape[1], img.shape[0])) return aligned对齐后的人脸再做一次跟训练时一致的resize和归一化,送入识别模型得到特征向量。
3.3 人脸比对:余弦相似度与阈值
特征比对有很多种方式,欧式距离、余弦相似度、甚至直接算内积。因为前面做了L2归一化,用余弦相似度和内积结果完全等价。代码非常短:
def cosine_similarity(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))阈值怎么定?不要拍脑袋。最稳妥的做法是拿一批同一人的不同照片和不同人的照片分别计算相似度,画出分布,再取两者之间分离度最大的值。我用的MobileFaceNet模型,同一个人不同角度照片相似度通常在0.55到0.8之间,不同的人通常在0.2以下,阈值取0.45到0.5之间效果比较好。
阈值设太低,陌生人会频繁误识别;设太高,本人稍微换个角度就容易拒识。项目里我把阈值做成配置项,方便现场调整。
4. 性能优化与部署要点
4.1 模型量化与精度验证
人脸识别模型要跑实时,模型体积和计算量都是瓶颈。ONNX Runtime本身支持模型量化,把FP32转成FP16或INT8。FP16在NVIDIA GPU上比较安全,INT8则更多用于CPU或者嵌入式环境。
ONNX Runtime的量化工具可以直接调用:
from onnxruntime.quantization import quantize_static, QuantType from onnxruntime.quantization.preprocess import quant_pre_process quant_pre_process("mobilefacenet.onnx", "mobilefacenet_preprocessed.onnx") quantize_static( "mobilefacenet_preprocessed.onnx", "mobilefacenet_int8.onnx", calibration_data_reader=reader, quant_format=QuantType.QInt8, )量化验证是个大坑。INT8量化后,特征向量的浮点精度会损失,余弦相似度普遍下降。我当时量化一个识别模型,FP32版本人脸比对相似度在0.60左右,INT8版本直接掉到0.45。这不是简单的阈值调低就能补救,因为同人相似度下降的同时,不同人之间的相似度也可能上升,误识率会上升。
解决办法是重新准备校准集。校准集不能随便用几十张图,要覆盖不同光照和角度,否则量化时激活值分布拟合不准。换了一组更接近真实场景的校准图之后,INT8版本的相似度恢复到0.55左右,基本能用了。
4.2 执行提供程序的选择策略
ONNX Runtime支持多种执行提供程序,CPU、CUDA、TensorRT等。在Python里可以这样查看当前环境支持哪些:
import onnxruntime as ort print(ort.get_available_providers())创建Session时可以指定优先顺序:
session = ort.InferenceSession( "mobilefacenet.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"] )这个顺序的意思是:如果CUDA可用就用GPU跑,否则自动回退到CPU。这个策略在部署很实用,同一个程序在不同机器上能自适应。
真正需要注意的是,GPU推理时如果图像预处理还在CPU做,整体耗时未必降低。检测和识别两个模型如果都在GPU上跑,图像在CPU和GPU之间来回拷贝会吃掉很多时间。我建议流程图尽量清晰:摄像头取帧、缩放、颜色转换都在CPU,推理部分全部交给GPU,减少跨设备拷贝。
4.3 输入批处理与线程安全
如果视频流里经常同时出现多张人脸,可以一次batch多张图传入识别模型。ONNX Runtime支持动态batch:
blob_batch = np.concatenate([face_blob1, face_blob2, face_blob3], axis=0) embeddings = rec_session.run(["embedding"], {"input": blob_batch})[0]这样比逐张推理快很多,因为矩阵乘法的并行利用率更高。但要注意,动态batch的输入尺寸在内存里需要连续,代码里一次构建好batch再推理,别在中间插入其他操作。
线程安全也要留意。ONNX Runtime的Session对象在多线程下同时run,官方并不保证完全线程安全。我在项目里对每个线程创建独立Session,或者用锁保护推理调用。前者浪费内存,后者会让多线程退化成单线程。更常见的做法是:一个线程只负责推理,图像预处理和结果后处理放到其他线程,流水线并行。
4.4 C++部署要点
Python适合快速验证,但最终交付到嵌入式或服务端,还是得用C++。ONNX Runtime的C++ API和Python思路一致,只是代码更繁琐一点。初始化Session的核心代码大致是:
#include <onnxruntime/core/session/onnxruntime_cxx_api.h> Ort::SessionOptions session_options; session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, model_path.c_str(), session_options); auto input_name = session.GetInputNameAllocated(0, allocator).get(); auto output_name = session.GetOutputNameAllocated(0, allocator).get(); std::array<int64_t, 4> input_shape{1, 3, 112, 112}; Ort::Value input_tensor = Ort::Value::CreateTensor<float>( allocator_info, data, size, input_shape.data(), input_shape.size()); auto outputs = session.Run(Ort::RunOptions{nullptr}, &input_name, &input_tensor, 1, &output_name, 1);C++部署的坑主要在内存管理上。OpenCV的Mat数据是HWC,而ONNX Runtime需要的是CHW,转换时要提前分配好连续内存,避免拷贝次数过多。封装成C++类时,Session对象不要频繁创建销毁,建议做成单例或者长生命周期对象。
5. 踩坑记录与排查思路
5.1 输出维度对不上的排查
人脸检测模型的输出格式五花八门。有的是(1, N, 4),有的是(1, N, 16),其中前4个是框坐标,后面的是关键点或者置信度。ONNX Runtime里查看输出最直接的办法:
for output in session.get_outputs(): print(output.name, output.shape)拿到输出后,一定要先打印实际shape,再和模型训练时的解码代码对比。我遇到过一次模型输出shape最后两维顺序反了,直接用reshape调整维度后跑通,但如果不检查这一步,后面所有坐标都是错的。
5.2 图像预处理差异导致准确率骤降
这是人脸识别最容易翻车的地方。训练时如果用RGB,推理端用BGR,模型虽然不会报错,但特征提取结果会发生明显偏移。某个周末我调了一晚上准确率,最后发现只是把cv2.imread读进来的图忘了转RGB。
同样的坑还有归一化顺序。有的模型输入期望像素值在[0,1],有的期望在[-1,1],还有的期望直接给0-255。拿到一个模型,第一件事就是从训练代码里确认输入分布,而不是用默认方式处理。我建议把预处理封装成独立函数,跟模型文件放在一起,下次换模型时直接替换,不用再猜。
5.3 ONNX算子兼容性问题
转换模型时最容易报错的就是算子不兼容。常见场景是模型里有自定义算子,或者用了较高版本torch才能导出的op。人脸检测模型里的NMS算子,在ONNX Runtime上有时表现不稳定,导出的模型能跑,但结果不对;在嵌入式端的NPU上则直接不支持。
我的解决方案比较保守:模型导出时把NMS从图里拿掉,只保留最朴素的卷积输出,然后在推理侧用OpenCV或numpy实现候选框解码和NMS。虽然多写点代码,但整个模型更干净,对不同推理引擎的兼容性也更好。
5.4 多人脸场景下的框选与去重
摄像头画面里出现多个人时,检测框会来回跳动,如果直接拿每一帧的结果做识别,很容易出现同一个人被反复匹配,产生重复记录。解决方法是加一个跟踪或者冷却机制:同一张人脸被连续N帧匹配到同一个身份,才输出识别结果;识别成功后设定几秒钟冷却,不再触发。
还有个细节:一张脸同时被两个检测框框住,特征会不稳定。可以在检测后做一下非极大值抑制,或者在比对时对同一人的多个框取相似度最高值。这些逻辑在ONNX模型层面解决不了,必须在应用代码里处理。
6. 完整案例:一个轻量人脸门禁模拟
6.1 需求拆解
项目背景是一个模拟门禁场景:USB摄像头采集画面,识别出已注册人员并在界面显示姓名和相似度。设备是一台普通的Linux小主机,没有独立GPU,所以整个推理必须CPU实时。期望帧率不低于15FPS,注册人数初期控制在50人以内。
选型上,检测用UltraFace,识别用MobileFaceNet,都转成ONNX。PC上先用Python开发,验证完pipeline后再封装成C++服务。整个系统分成三个模块:视频采集、检测跟踪、特征比对。
6.2 注册与识别流程
注册阶段,我实现了一个非常简单的逻辑:对同一个人连续采集10帧,每帧提取一个特征向量,然后取平均并归一化,作为该人员的标准特征。这样比只取一帧稳很多,能缓解表情和光线变化带来的随机波动。特征库用CSV保存,字段是姓名和512维浮点向量。
识别阶段的流程是:
class FaceDatabase: def __init__(self): self.data = {} # name -> embedding def register(self, name, embedding): embedding = embedding / np.linalg.norm(embedding) self.data[name] = embedding def match(self, query, threshold=0.5): query = query / np.linalg.norm(query) best_name = None best_score = -1.0 for name, emb in self.data.items(): score = float(np.dot(query, emb)) if score > best_score: best_score = score best_name = name if best_score >= threshold: return best_name, best_score return None, best_score主循环里每帧做一次检测,对每个检测框做人脸对齐和特征提取,然后去特征库里匹配。如果匹配成功且连续几帧都是同一个身份,就判定为通过。
6.3 实测效果与参数调整
在室内正常光照下,识别很稳定,15FPS能跑满。逆光场景下,人脸区域过曝,检测框偶尔会丢,识别率掉到85%左右。我加了一个简单的gamma校正预处理,把暗部细节提亮,逆光识别率恢复到92%。这个gamma参数不能设太强,否则正常光照下人脸颜色会失真,特征也会飘。
口罩场景是另一个问题。识别模型没见过戴口罩的数据,戴了口罩之后,上半脸特征仍然可用,但整体相似度下降明显。我在现场测试时把阈值从0.5降到0.42,能挽回一部分通过率,但误识率也随之上升。后面加了口罩检测逻辑,如果检测到口罩就只比对上半脸区域的特征,效果更好。这个方案只是应急,真正解决还是需要用带口罩样本微调识别模型。
多线程方面,我把采集线程和推理线程分开,推理线程只处理最新一帧,如果处理不过来就丢帧,保证实时性。这样虽然会偶尔跳过几帧,但整体流畅度比串行处理高很多。
最后分享一点个人经验
整套基于ONNX的人脸识别方案跑下来,我最深刻的体会是:难点从来不在模型导出,而在整个pipeline里每个环节都保持同一套约定。训练时的预处理、归一化、letterbox参数,必须原样搬到推理端;特征比对前一定要做L2归一化;检测模型输出坐标的解码方式要和训练时一致。这些细节看起来小,任何一个对不上,最终准确率都会断崖式下降。
如果你也在做类似的人脸识别落地,建议先把端到端跑通,再考虑优化速度。先用Python写一个最简单但完整可用的版本,选好阈值,录好测试视频,验证准确率没问题了,再去做C++封装和量化。这样定位问题会快很多。另外注册特征时多采集几张不同角度取平均,这个小的改动亲测能明显提升鲁棒性,值得一试。