如果你手上有一个训练好的Insightface模型,而且是用MxNet训练出来的,某天要把它搬到安卓端跑,大概率会被“模型转换”这四个字折腾到怀疑人生。这篇文章就是我完整跑通一次Insightface MxNet转成TNN模型全过程的记录,包含了从环境搭建、格式导出、算子适配到端侧精度对齐的每一步,以及我踩过的那些坑。做移动端人脸识别、需要把服务端训练模型塞进手机里的朋友,可以照着这份流程走,能省下不少排查时间。
1. Insightface MxNet模型转到TNN,核心难点到底在哪
1.1 先搞清楚Insightface的模型到底是什么
Insightface是一套开源的人脸识别方案,里面包含检测、对齐、识别等多个模块。我们要转的通常是识别模型,也就是那个把一张对齐好的人脸图变成特征向量的网络。这类模型在MxNet时代的标准产物是两个文件:一个*-symbol.json,记录网络结构;一个*-0000.params,保存训练好的权重。
识别模型的主干网络常见有两种:轻量的MobileFaceNet和精度更高的ResNet50系列。不管哪种,输入都是一张112x112的RGB人脸图,输出是512维(也可能是256维)的embedding特征。模型内部由卷积、BatchNorm、PRelu、全连接这些基础算子堆叠而成,听起来不复杂,但真要跨框架转换,问题就出在这些基础算子的表示差异上。
以MobileFaceNet为例,它大量使用depthwise卷积和pointwise卷积的组合,也就是常说的深度可分离卷积。MxNet的symbol里这种结构是一组Convolution节点,导出成ONNX后变成Conv + Conv的组合,再到TNN里要映射为对应的移动端算子。哪一步的映射出问题,最后跑出来的结果就对不上。
1.2 MxNet模型为什么不能直接在移动端用
很多人第一次接触模型部署时会想:MxNet本身不是也有推理接口吗?直接把模型和推理库一起塞进App不就行了?理论上可以,实际上太折腾。
移动端跑深度学习推理,约束条件比服务端苛刻得多。首先是包体积,MxNet完整运行库动辄几十甚至上百MB,为了跑一个人脸模型把整个框架带进App,用户下载安装包的成本一下就上去了。其次是算子优化,MxNet对ARM CPU、移动GPU的算子优化深度不足,同样一个卷积,跑出来的速度可能比其他专门为移动端设计的推理引擎慢一倍以上。再就是生态,移动端要做模型量化、内存复用、多线程调度,MxNet的支持都比较弱。
TNN这类移动端推理引擎就是为解决这些问题设计的。它支持ARM CPU、GPU等异构设备,算子针对移动端做了大量优化,还提供模型量化工具和内存优化方案。转换后的模型文件就是纯粹的权重和结构描述,运行时只需要链接TNN的动态库,而不是把整个训练框架搬进去。这是业界标准做法,也是把Insightface这类MxNet模型落地到端上的必经之路。
1.3 选ONNX当中间格式,是效率最高的路
模型转换的路线有好几种,我一开始也在纠结:TNN官方看起来提供MxNet转换能力,是不是可以直接一步到位?实际尝试下来发现,TNN对MxNet的直接支持在算子覆盖面上不如它对ONNX的覆盖广,而且Insightface的MxNet图里有些节点比较特殊,直接转很容易撞到不支持的算子上。
后来走了业界最成熟的路:MxNet导出ONNX,再用ONNX转TNN。为什么绕一圈反而更稳?
ONNX是一个开放的中间表示格式,它像是一门“通用语言”,把不同训练框架的模型统一成一套图描述。MxNet转ONNX有官方工具支持,TNN官方又有完善的ONNX转换器,两边用ONNX作为衔接,兼容性风险被大大分散。而且ONNX生态里有很多辅助工具,比如Netron可视化、onnxruntime推理验证,调试起来非常方便。
我后来也试过直接调用TNN的MxNet转换脚本,小模型能转,但遇到PRelu带per-channel参数或者BN层细节不匹配时,报错信息比较隐晦,整体排查成本很高。所以最后我的建议就是:老老实实走ONNX中转,这是踩过坑之后的经验总结。
2. 动手前的准备工作:环境、文件、输入规格
2.1 环境搭建与版本选择
转换这件事,环境版本卡得比写训练代码还严。我先列一个我自己验证过的组合,然后说明为什么这么配。
| 组件 | 版本 | 说明 |
|---|---|---|
| 操作系统 | Linux x86_64 | 建议在Linux上操作,Windows也能凑合但坑多 |
| Python | 3.7 | 和MxNet的兼容性最稳 |
| mxnet | 1.6.0 | 1.5和1.7各有各的问题,1.6最稳 |
| numpy | 1.17.x | 新版本numpy会触发MxNet导出模块的兼容性问题 |
| onnx | 1.7.0 | 不要用太新的,TNN转换器对opset有上限 |
| onnxruntime | 1.4.0 | 用来验证导出后的ONNX模型输出 |
这里重点提醒一个坑:如果numpy版本装得太新,执行mxnet.onnx.export_model时大概率会报错,提示module 'numpy' has no attribute 'bool'。MxNet 1.6内部还在用np.bool这个老接口,而numpy 1.20之后已经把它删掉了。我当时没注意,反复排查了很久,最后把numpy降到1.17才顺利导出。
2.2 认识模型文件:symbol、params、config
Insightface的MxNet模型文件一般长这样:
model-symbol.json model-0000.params config.py 或 config.jsonsymbol文件是模型结构图,里面描述每个算子叫什么名字、上一层是谁、卷积核尺寸是多少、stride怎样。params文件是二进制权重,包含所有参数值。这两个文件必须放在同一个目录下,命名前缀保持一致,mx.model.load_checkpoint才能正确加载。
另一个容易被忽视的文件是config。Insightface训练时会记录模型的输入分辨率、网络名称、是否带PRelu等信息。有的模型config里还会写embedding维度是512还是128,这对我们后面确认输出节点有用。
拿到模型后,我建议先用Netron打开symbol文件,直接看图。Netron能展示每个算子的属性和数据流向,方便确认输入层名字、输出层名字、中间有没有奇怪的算子。这一步不要省,因为后续导出ONNX时需要手动指定输入输出节点,光靠猜会浪费大量时间。
2.3 确认输入输出节点
Insightface模型的输入节点通常是data,shape是(1, 3, 112, 112),对应NCHW格式的4维张量。输出节点一般是fc1,这一层输出的512维特征就是最终要用的embedding。
但有个问题经常出现:训练时保存的完整symbol里不仅有fc1,还可能包含后面的分类层、损失层。我遇到过导出时把带损失层的整个图都导进ONNX的情况,白白增加模型体积,转换时还容易报错。正确的做法是只截取到embedding输出为止。
在MxNet里可以用get_internals()拿到所有中间层的符号,然后挑出需要的那个节点重新构造图。举个例子,如果完整graph里最后一层是softmax,而fc1在它的上游,就手动截取:
import mxnet as mx sym, arg_params, aux_params = mx.model.load_checkpoint("model", 0) internals = sym.get_internals() # 先打印所有节点名称,找到embedding对应的节点 # print(internals.list_outputs()) fc1_sym = internals["fc1_output"]fc1_output是MxNet内部对fc1层输出的命名规则,具体名字还要以Netron里看到的实际节点名为准。这个操作其实相当于把模型“剪”了一刀,只保留到embedding这一层的图结构,后续导出时就不会带上没用的训练分支了。
3. MxNet导ONNX:最想吐槽但又跑不掉的一步
3.1 加载模型与处理参数
MxNet导出ONNX的核心API是mxnet.onnx.export_model,但在调用它之前,要把模型的symbol和参数组合成它认识的格式。load_checkpoint返回的arg_params和aux_params要合并到一起,因为ONNX导出器需要完整的参数字典。
一个容易踩的细节是:有些模型的data输入是在arg_params里的,有些则不在,如果不在,手动补一个空参数也行。我正常加载后直接合并,没有遇到需要特殊处理的情况。
import mxnet as mx import mxnet.onnx as mx_onnx sym, arg_params, aux_params = mx.model.load_checkpoint("model", 0) all_params = {} all_params.update(arg_params) all_params.update(aux_params) input_shape = [(1, 3, 112, 112)] onnx_filename = "model.onnx" mx_onnx.export_model( sym, all_params, input_shape, onnx_filename=onnx_filename, verbose=True )这里还要留意input_shape的写法,必须是列表的列表,因为导出器支持多输入模型,每个输入对应一个shape。单个输入也写成[(1, 3, 112, 112)],不要直接写(1, 3, 112, 112)。
3.2 export_model导出与常见报错
导出过程不会太慢,几秒钟就完成了。但这一步是报错重灾区,我把常见的几个报错类型和原因列一下。
第一个是开头就报找不到输入节点。原因是模型图的第一个节点名字可能不叫data,有的是data_input,有的是自定义名称。解决办法是用sym.get_internals().list_outputs()打印所有输出节点名,或者在Netron里看第一层卷积的输入名字,然后在导出时指定:
mx_onnx.export_model( sym, all_params, input_shape, input_names=["data"], # 按实际情况修改 output_names=["fc1"], # 按实际节点名修改 onnx_filename=onnx_filename )第二个是导出时遇到不支持的算子直接抛异常。MxNet的onnx导出模块对某些算子支持不完整,尤其是一些组合结构。Insightface里相对容易出问题的是L2Normalization,这个算子在MxNet中有,但早期版本的onnx导出器可能映射不到ONNX对应节点。我的处理办法是导出后再用脚本在ONNX图里补节点,或者直接改MxNet符号图,把L2归一化逻辑拆成可以被ONNX接受的基础算子组合。大多数情况下导出器可以自动处理,遇到确实处理不了的,再针对性修图。
还有一类报错是参数shape不匹配,比如模型保存时有动态维度,导出时指定了固定shape导致reshape节点冲突。Insightface的识别网络一般是固定输入,这种情况不多,但如果遇到,建议回到config里确认训练时的输入尺寸,然后保持导出shape和训练shape一致。
3.3 ONNX导出的体检步骤
导出成功后先别急着转TNN,花几分钟检查一下ONNX模型有没有问题,这一步能省掉后面大量排查时间。
先用Netron打开model.onnx,重点看三件事:输入节点的shape是不是(1, 3, 112, 112),输出节点名字和维度是不是预期的512,图里有没有明显的训练专用节点残留。如果输出的最后一层不是我们要的embedding,说明截取的节点选错了。
然后用onnxruntime跑一遍,验证ONNX模型本身能正常前向。这个过程很快,但能提前发现导出遗漏、算子不兼容等问题。
import onnxruntime as ort import numpy as np session = ort.InferenceSession("model.onnx") input_name = session.get_inputs()[0].name output_name = session.get_outputs()[0].name random_input = np.random.randn(1, 3, 112, 112).astype(np.float32) outputs = session.run([output_name], {input_name: random_input}) print(outputs[0].shape)如果这一步能正常输出shaped像是(1, 512)的结果,说明ONNX模型结构基本没问题,可以放心进入下一步。
4. ONNX转TNN:工具调用与算子问题处理
4.1 TNN转换工具的准备
TNN的源码仓库里自带ONNX转TNN的转换脚本,位置在tools/onnx2tnn目录下。我们需要先把TNN源码clone到本地,然后按照仓库README的说明安装依赖。核心依赖是protobuf和相关python库,装好之后转换脚本就能跑。
有一点需要注意,不同版本的TNN转换脚本参数不完全一样。我用的版本支持通过命令行指定ONNX路径、输出目录和优化选项。先运行一次帮助命令,确认当前版本的参数形式:
python3 onnx2tnn.py --help这一步别跳,因为网上教程写的老参数格式可能和你拉到的最新代码对不上,直接看帮助最可靠。
4.2 执行转换并拿到tnnproto/tnnmodel
确认参数后再执行转换。我当时的命令大致是这样:
python3 onnx2tnn.py -p model.onnx -o ./tnn_output -v v2.0 -opt 1-p指定ONNX模型路径,-o指定输出目录,-opt 1表示开启优化。开启优化后转换工具会尝试做算子融合,比如把BatchNorm和卷积合并成一个节点,这对端侧推理性能有明显帮助。
转换完成后会生成两个文件:一个.tnnproto,描述模型结构和算子参数;一个.tnnmodel,保存权重数据。这两个文件就是最终要在移动端加载的模型产物。
这里我要强调-opt参数的重要性。同样一个MobileFaceNet模型,不开优化时TNN跑起来可能会有多余的中间数据搬运,耗时偏长;开了优化后,BN层被融合进卷积,省掉一整层的内存读写。人脸识别模型对帧率有要求,这一步能优化尽量优化。
4.3 算子不支持的现场处理
TNN的算子覆盖虽然广,但Insightface导出后的ONNX图里偶尔还是会有它不认识的算子。我遇到过一个典型场景:ONNX里的Resize节点用了linear采样方式,TNN转换器在这个版本里对某种参数组合不支持,直接报错。
遇到这种情况,我的处理流程是这样的:先在ONNX图里定位报错节点,看它到底属于什么语义,再去TNN支持的算子列表里找替代方案。如果TNN有等价算子,回ONNX侧修改节点类型和参数,重新导出;如果没有等价算子,就用几个基础算子的组合代替。比如双线性插值如果没被支持,可以先转成nearest采样验证流程,精度需求明确后再补正确方案。
还有一类算子问题是权重初值异常。Insightface的PRelu算子带per-channel权重,有的转换器版本对带斜率的PRelu支持不完整,导致转换后模型跑出来的所有特征都一样。处理方式是检查转换日志,如果看到PRelu相关的warning,就去确认TNN版本是否支持带参数的PRelu,不支持的话升级TNN版本,或者替换网络结构。
5. 精度验证和移动端实测:转换到底靠不靠谱
5.1 预处理一致性是精度的生命线
模型转换之后特征对不上,十有八九不是模型转换本身的问题,而是输入预处理不一致。Insightface这套模型对预处理比较敏感,训练和测试时用的是一套固定的流程,换到端上必须保持完全一致。
Insightface最常用的预处理是这样做的:
import cv2 import numpy as np img = cv2.imread("face.jpg") # BGR img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 转RGB img = img.astype(np.float32) # 转float img = (img - 127.5) / 128.0 # 归一化到[-1, 1] img = np.transpose(img, (2, 0, 1)) # HWC -> CHW img = np.expand_dims(img, 0) # 加batch维度三个关键点一个都不能错。第一是通道顺序,模型训练时用的是RGB,如果端上直接用摄像头输出的BGR数据,特征会完全错乱。第二是归一化范围,(x - 127.5) / 128和x / 255.0虽然都是常见做法,但提前量完全不同,人脸识别模型这种精细化任务对这种差异非常敏感。第三是数据类型,必须转成float32再推理,用uint8直接喂给模型,输出会偏离到无法使用。
如果你手上模型的训练脚本和上述不一样,务必找到原始的config文件确认真实预处理参数,一切以训练时为准。
5.2 同一份输入、两种框架的数值对比
模型转换完成后,最简单的验证办法是取一张人脸图,用同一份输入分别跑MxNet原模型和TNN转换后的模型,对比输出的512维特征向量。
我习惯把MxNet的输出dump成一个npy文件,然后把同一份输入喂给TNN侧,把输出也保存下来,用cosine similarity判断两个向量的相似度。
import numpy as np # 假设从两个框架分别得到特征向量 feature_mxnet = np.load("mxnet_feature.npy").flatten() feature_tnn = np.load("tnn_feature.npy").flatten() cos_sim = np.dot(feature_mxnet, feature_tnn) / ( np.linalg.norm(feature_mxnet) * np.linalg.norm(feature_tnn) ) print(cos_sim)如果cosine similarity在0.999以上,说明转换精度损失在可接受范围内。如果只有0.98甚至更低,不能直接上生产环境。此时先回查预处理是否一致,再看转换日志里有没有未对齐的算子。我见过一种情况是ONNX导出时BN的参数平移量丢了,导致所有特征整体偏移,cosine虽然接近但阈值判定会出问题。
5.3 端侧实测数据
数值对齐做完之后,还需要在实际手机设备上跑一遍。我测试用的模型是MobileFaceNet,MxNet原始params文件大约5MB,转成TNN后体积基本持平,因为权重数据本身是fp32存储,格式转换不会带来明显压缩。
推理耗时方面,主流安卓中高端手机的CPU上,单帧112x112输入大约在15到25毫秒之间,GPU上能跑到5到10毫秒。这个量级对于刷脸解锁、人脸比对这类场景已经够用。如果实测耗时比这个高出一大截,优先检查转换时是否开了优化选项,其次看TNN运行时是否启用了多线程和GPU加速。
内存占用也要心里有数。TNN跑模型时除了模型本身占用的内存,还会为中间feature map分配空间。MobileFaceNet在端上跑一次全流程,内存占用大约在几十MB这个级别,比带MxNet运行库小得多。
6. 常见问题与排查技巧实录
6.1 高频问题一览
转换和部署过程中遇到的问题,我整理成一张速查表,方便直接对照。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 导出ONNX时报numpy.bool错误 | numpy版本过高 | 降级到numpy 1.17.x |
| 导出时找不到输入节点 | 输入名字不是data或symbol有改动 | 用Netron确认实际输入名 |
| 导出的ONNX带loss层 | 截取节点选错 | 用get_internals截取到fc1为止 |
| onnx2tnn转换报算子不支持 | ONNX图中有TNN不支持算子 | 修改节点类型或拆分算子 |
| 转换后特征值全一样 | PRelu参数丢失或算子支持不全 | 升级TNN版本检查PRelu支持 |
| TNN和MxNet特征对不上 | 预处理不一致 | 严格对齐RGB、归一化范围、数据类型 |
| 端侧推理耗时过高 | 没有开启优化或GPU加速 | 用-opt参数重新转换并开启GPU |
| 模型加载失败 | tnnproto和tnnmodel版本不匹配 | 确保两个文件由同一次转换生成 |
6.2 独家避坑心得
整个流程跑下来,我有几个值得分享的经验。
第一个是先用小模型打通链路。我第一次转换时直接用ResNet50,导出ONNX慢、TNN转换慢、调试更慢,一个问题要等半天才能复现。后来换用MobileFaceNet做实验,整个链路跑顺畅之后再换成大模型,效率高很多。
第二个是固定batch为1。人脸识别部署场景基本都是单张图逐帧推理,没必要保留动态batch。转换时固定成(1, 3, 112, 112),能避免很多动态维度带来的算子映射问题。TNN对固定shape的优化也会更彻底。
第三个是要学会保存和利用中间结果。调试精度问题时,我在MxNet侧把输入tensor和输出特征都存下来,在TNN侧也分别保存,哪一层开始出现偏差一目了然。如果只对比最终特征,偏差来源很难定位。
第四个是不建议在ONNX里保留太多个性化算子。Insightface模型本身算子比较基础,但如果你在导出前对网络做过自定义改动,比如加了特殊attention结构,尽量把自定义部分用基础算子重写一遍,再走转换流程。个性化算子越多,转换的不可控因素越大。
还有一个个人心得:遇到转换报错,优先看转换日志里定位到的节点名称,再去onnx图里对照查看,比盲目改参数有效得多。很多转换问题本质上是某个算子参数不被识别,找到节点后基本一眼就能看出问题所在。
最后说一个很多人会忽略的点:模型转换完之后,最好用一批真实人脸测试集做端到端验证,而不是只比较一个输入向量。我用了几十张不同光照、姿态下的人脸图做对比,确认识别精度和MxNet原模型保持一致后才真正放心。单张图对齐只能说明转换没大问题,多张图验证才能暴露边界情况下的隐患。