摘要:本系列文章旨在分享tensorflow->onnx->Caffe->wk模型转换流程,次要针对的是HI3516CV500, Hi3519AV100 反对NNIE推理框架的海思芯片的算法工程落地。
本文分享自华为云社区《将模型转为NNIE框架反对的wk模型——以tensorflow框架为例(一)》,原文作者:wwwyx_^▽^ 。
应用过NNIE框架的同学都晓得,NNIE框架只反对wk模型的推理。
理论应用过程中用海思提供的转换软件工具 RuyiStudio 将 caffe 1.0 模型转为wk。个别状况下如果购买了芯片,海思将会间接将相干的SDK包发送给客户,如果没有的话,大家能够从这个链接获取:RuyiStudio
从下面可知,最终须要将别的框架模型转为caffe才能够应用RuyiStudio,目前支流框架蕴含pytorch、tensorflow、mxnet等,对于pytorch转caffe之前曾经有大佬写过了pytorch->caffe,有须要的同学能够去看下。本文次要讲述tensorflow框架转为caffe可能会遇到的问题以及解决办法。mxnet有间接的接口能够转onnx,也能够参考这篇文章做caffe转换。
上面进入正题。
tensorflow->caffe
这个真的是个大坑(哭泣),这里我应用了两头模型onnx,即最终胜利转换的门路是 pb->onnx->caffe->wk,上面就说一下具体的操作吧~
第一步:tensorflow->onnx
这一步是最简略的一步= =,目前转了一些模型还没有在这里遇到坑。
应用github上的开源我的项目:tensorflow->onnx,间接应用pip install 装置后应用。
关注一下都有哪些参数,每个参数的作用,次要是输出、输入、推理应用nchw还是nhwc(caffe框架为nchw,所以这里都应用nchw)、opset(默认应用 9 ),很多的参数我没有应用到,大家有疑难能够间接去issues下面看下哈。
上面给出一个转换命令供大家参考下:
python -m tf2onnx.convert --input ./model.pb --inputs input_image:0[1,112,112,3] --inputs-as-nchw input_image:0 --outputs output_0:0,output_1:0,output_2:0,output_3:0,output_4:0 --output ./convert.onnx
失去onnx模型之后,能够应用onnx simplifer将一些零散算子合并,或者将一些冗余算子去除,这个工具视状况应用。
python -m onnxsim input_onnx_model output_onnx_model
转换为onnx之后,须要验证输入的后果是否与pb统一,统一后再走前面的流程!!
第二步:onnx->caffe
这里曾经失去了onnx模型,然而间隔胜利还有99%的路要走!!
这一大节Baseline:onnx2caffe
环境: caffe 1.0 + onnx 1.8.0
次要性能代码:
onnx2caffe+-- onnx2caffe| +-- _operators.py| +-- _weightloader.py+-- convertCaffe.py+-- MyCaffe.py
运行命令:
python convertCaffe.py ./model/MobileNetV2.onnx ./model/MobileNetV2.prototxt ./model/MobileNetV2.caffemodel
在转换过程中如果遇到了问题,能够从上面几个方面来适配,
(1)遇到caffe与NNIE不反对的算子,能够批改onnx模型中的node以适配caffe(这里要动员本人的小脑筋,一些算子替换能够参考一下pytorch->caffe这篇博客)。
(2)如果遇到了NNIE与onnx反对的算子,然而caffe 1.0 官网不反对的话,能够在caffe中增加新的层,从新编译之后,再做转换。caffe中增加新的层能够参考:caffe 增加新node
(3)caffe与NNIE都反对的算子,然而转换工具没有反对该算子的转换,在转换代码中增加相应的算子实现。
(4)转换过程中算子转换胜利,然而呈现了shape问题,手动增加一些不须要参数的操作在曾经生成的prototxt中。
针对下面的每个办法给出对应的解决形式。
批改onnx模型中的node以适配caffe
改写onnx模型,首先须要理解一下onnx都反对哪些算子。
onnx反对的op:onnx op
更换模型中的操作时,查看该node的输入输出模式,依照格局对模型进行改写。onnx模型改写波及多种状况,上面介绍几种罕用的办法。
1.对于node的改写有时须要已知其输入输出size,故一开始先筹备一个蕴含每个node输入输出的onnx模型。
import onnx.helper as helperfrom onnx import shape_inference, TensorProtoimport onnxruntimeimport onnxdef add_input_output_from_onnx(onnx_path, save_path): ONNX_DTYPE = { 0: TensorProto.FLOAT, 1: TensorProto.FLOAT, 2: TensorProto.UINT8, 3: TensorProto.INT8, 4: TensorProto.UINT16, 5: TensorProto.INT16, 6: TensorProto.INT32, 7: TensorProto.INT64, 8: TensorProto.STRING, 9: TensorProto.BOOL } # load model onnx_model = onnx.load(onnx_path) graph = onnx_model.graph # rewrite the input tensor of graph input_tensor = graph.input[0] input_shape = input_tensor.type.tensor_type.shape.dim input_tensor_new = onnx.helper.make_tensor_value_info(name = input_tensor.name, elem_type = 1, shape = [1, input_shape[1].dim_value, input_shape[2].dim_value, input_shape[3].dim_value]) graph.input.remove(input_tensor) graph.input.insert(0, input_tensor_new) # append all tensor infos to graph input weight_infos = [] tensors = graph.initializer for i, tensor in enumerate(tensors): value_info = helper.make_tensor_value_info(tensor.name, ONNX_DTYPE[tensor.data_type], tensor.dims) weight_infos.append(value_info) graph.input.insert(i+1, value_info) # because 0 is for placeholder, so start index is 1 # run node shape inference node = graph.node value_info = graph.value_info inferred_onnx_model = shape_inference.infer_shapes(onnx_model) onnx.checker.check_model(onnx_model) inferred_graph = inferred_onnx_model.graph inferred_value_info = inferred_graph.value_info onnx.save(inferred_onnx_model,save_path) return
应用netron关上onnx模型,查看增加size之后的变动:
2.遇到caffe与NNIE不反对的算子,删除onnx模型中的node,将相干操作在内部的预处理阶段进行。这种状况只波及onnx模型中曾经存在的节点删除与扭转已有边连贯的关系,不波及新的边关系的建设。
` 这里应用graph中node的index来拜访node 该代码删除graph node 0,1,2 并且批改node 3的input边 即 input_image --> mul_1 --> sub --> mul --> conv1 变为 input_image --> conv1`def delete_node(onnx_path, save_path): onnx_model = onnx.load(onnx_path) graph = onnx_model.graph Mul_1 = graph.node[0] sub = graph.node[1] mul = graph.node[2] conv1 = graph.node[3] conv1.input[0] = Mul_1.input[0] graph.node.remove(Mul_1) graph.node.remove(sub) graph.node.remove(mul) onnx.checker.check_model(onnx_model) onnx.save(onnx_model, save_path)
3.更改caffe与NNIE不反对的算子,批改onnx模型中的node去适配。如 squeeze 算子,squeeze算子在onnx->caffe的时候会报错,这时能够将onnx模型中的squeeze替换为reshape算子。reshape须要两个输出,而squeeze只对应一个输出,这时须要在graph中创立一个新的常数tensor input。这种状况波及更换曾经存在的node,新的常数tensor的退出,但并不波及新的边关系的建设。
`查看onnx op的操作,reshape须要两个输出对于reshape须要将一个shape tensor退出到onnx graph中,tensor size能够查看第一步生成的onnx model中该squeeze node对应的output size即 input --> squeeze --> output变为 input --> reshape(shape) --> output`def remove_headpose_squeeze_node(onnx_path, save_path): onnx_model = onnx.load(onnx_path) graph = onnx_model.graph ## 增加常数 input shape = onnx.helper.make_tensor('shape', onnx.TensorProto.INT64, [2], [1,3]) graph.initializer.append(shape) for i in range(len(graph.node)): if graph.node[i].op_type == "Squeeze": reshape_node_def = helper.make_node( 'Reshape', # node name inputs=[graph.node[i].input[0], 'shape'], # inputs outputs=[graph.node[i].output[0]], # outputs name = graph.node[i].name ) graph.node.remove(graph.node[i]) graph.node.insert(i, reshape_node_def) onnx.checker.check_model(onnx_model) onnx.save(onnx_model, save_path)
4.caffe不反对div算子,能够将div算子转为pow+mul。这种状况波及将一个node更换为两个,新的常数tensor的退出,以及新的边连贯关系。
div 操作: z = x / y
更换为 pow + mul, pow为幂操作,mul为乘法操作:
temp = pow(y, -1)
z = temp * x
`即:input_x input_y \\ // \\ // div更改为:input_x input_y \\ // \\ // \\ pow(常数tensor作为指数输出) \\ // \\ // --> (新的边) mul`def change_headpose_div_node(onnx_path, save_path): onnx_model = onnx.load(onnx_path) graph = onnx_model.graph pow_scale = onnx.helper.make_tensor('pow_scale', onnx.TensorProto.FLOAT, [3], [-1.0, -1.0, -1.0]) mul12_output = helper.make_tensor_value_info('pred_pose/mul_12_pow_output:0', onnx.TensorProto.FLOAT, [1, 3]) graph.initializer.append(pow_scale) # 'pred_pose/mul_12:0' 相似于上图中的input_y # pow_scale 为下面创立的相应的指数tensor # 'pred_pose/mul_12_pow_output:0' 为新建的output tensor # pow name 给一个不与图中node反复的name mul12_pow_node_def = helper.make_node( 'Pow', # node name inputs=['pred_pose/mul_12:0', 'pow_scale'], # inputs outputs=['pred_pose/mul_12_pow_output:0'], # outputs name = 'pred_pose/mul_12_pow' ) graph.node.insert(len(graph.node), mul12_pow_node_def) for i in range(len(graph.node)): if graph.node[i].name == "pred_pose/truediv_3": input1 = graph.node[i].input[0] input2 = graph.node[i].input[1] output = graph.node[i].output[0] name = graph.node[i].name pow_node_def = helper.make_node( 'Mul', # node name inputs=[input1, mul12_pow_node_def.output[0]], # inputs outputs=[output], # outputs name = name ) print(graph.node[i].name, i) graph.node.remove(graph.node[i]) graph.node.insert(i, pow_node_def) break graph = helper.make_graph(graph.node, graph.name, graph.input, graph.output, graph.initializer) info_model = helper.make_model(graph) model = onnx.shape_inference.infer_shapes(info_model) onnx.save(model, save_path)
通过这个批改之后,应用netron查看node边关系,看是否正确。
5.打印onnx两头某个节点的输入,须要在graph加一个output tensor。
def add_outputNode_info(onnx_path, add_name, output_size, save_path): onnx_model = onnx.load(onnx_path) graph = onnx_model.graph prob_info = helper.make_tensor_value_info(add_name,onnx.TensorProto.FLOAT, output_size) graph.output.insert(0, prob_info) onnx.save(onnx_model, save_path) returnif __name__ == '__main__': onnx_model = './model.onnx' add_node_path = "./addPreprocessOutput.onnx" # "mul:0": 想要输入node的output name # [1,24,14,14]: 想要输入node的output size add_outputNode_info(onnx_model, "mul:0", [1,24,14,14], add_node_path)
下面的例子曾经将大部分node批改的状况涵盖了,批改onnx模型能够参考上述代码。
小tips:Reshape大法好,各种跟维度有关系的都能够用reshape来代替,除此之外,transpose也是网红node,具体问题具体分析~
在转换代码中增加相应的算子实现
在caffe中增加新的层没什么好说的,依照下面给的链接来就能够,这里次要介绍下如何批改转换代码去适配某个模型转换。通过下面批改onnx模型这一步,咱们曾经将onnx模型中的node全副换为caffe与NNIE反对的算子了,但这时onnx2caffe可能还会呈现问题,上面会从不同的状况做onnx2caffe代码适配来逐渐实现模型转换。
1.caffe和NNIE都反对某个操作,然而onnx2caffe模型转换时报错。
如:TanH操作,从源码/caffe/src/caffe/layers/中看到有tanh层的实现,NNIE也反对该操作,然而转换报错。查看onnx2caffe源码发现没有TanH的转换实现,这时须要咱们增加相应的转换代码,次要批改_operators.py、_weightloader.py两个文件,上面以TanH为例解说一下怎么减少转换node。
_operators.py 文件用来实现onnx操作到Caffe操作的变换。对于TanH的适配,首先须要在文件的最初注册算子模块增加TanH,而后减少转换代码。
`转换代码:`def _convert_tanH(node,graph,err): input_name = str(node.inputs[0]) output_name = str(node.outputs[0]) name = str(node.name) layer = myf("TanH",name,[input_name],[output_name]) graph.channel_dims[output_name] = graph.channel_dims[input_name] return layer `增加注册算子:`_ONNX_NODE_REGISTRY = { …… "Tanh": _convert_tanH,}
_weightloader.py 文件用来实现node参数从onnx到Caffe的传递。第一步也是在文件开端增加注册算子,增加同_operators.py。第二步,从 caffe.proto 中查看tanh操作是否存在weight:
message TanHParameter { enum Engine { DEFAULT = 0; CAFFE = 1; CUDNN = 2; } optional Engine engine = 1 [default = DEFAULT];}
因为tanh操作不存在weight,所以onnx到caffe的参数传递为空:
def _convert_tanH(net, node, graph, err): pass
至此,在onnx2caffe中增加tanh操作就实现了,具体工程就蕴含批改下面两个文件夹,次要是注册算子、操作转换的实现、weight值传递。
2.caffe和NNIE都反对某个操作,onnx2caffe也反对该操作,然而操作中有一个输出在模型中被写为weight,与原来的实现不统一。
如: mul算子,一般的mul算子个别都蕴含两个输出,模型中可能会存在mul算子只有一个输出,另一个输出作为weight参数,如下所示:
这种状况下,因为曾经存在了mul的注册算子,咱们只须要在mul算子转换的时候新加一个分支来实现就能够了,还是只波及两个文件的改写。
_operators.py 增加分支代码
def _convert_input1_is_weight_mul(node,graph,max_dim, err): node_name = node.name `这里的input_name须要在netron视图中察看一下是哪一个input作为内部输出,这里不能写 weight 的输出名称!` input_name = str(node.inputs[0]) output_name = str(node.outputs[0]) scale_layer = myf("Scale", node_name, [input_name],[output_name],in_place=False,bias_term=False) graph.channel_dims[output_name] = max_dim return scale_layerdef _convert_Mul(node,graph,err): input_name_list = [str(i) for i in node.inputs] output_name = str(node.outputs[0]) node_name = node.name `这里应用node_name 判断mul算子是否是一个input,新增只有一个input的分支` if node_name == "mul_1": max_dim = 16 return _convert_input1_is_weight_mul(node,graph,max_dim, err) ··· ···
_weightloader.py 也不须要从新注册,间接增加分支代码
def _convert_input1_is_weight_mul(net, node, graph, err): node_name = node.name ` 留神!! scale = np.ones(3) * 3.0 对应的是 内部输出size =(1,3), weight size = (1), 这种状况能够借助 numpy 实现weight与内部输出的channel对齐 这里还有另外一种状况,例如 内部输出 size = (1,128,8,8), weight = (1,128,1,1) 能够这样操作:scale = node.input_tensors[node.inputs[1]] scale = np.reshape(scale, scale.shape[1]) ` scale = np.ones(3) * 3.0 np.copyto(net.params[node_name][0].data, scale, casting='same_kind')`mul自身是没有weight的,所以之前就是间接pass`def _convert_Mul(net, node, graph, err): node_name = node.name if node_name == "mul_1": _convert_input1_is_weight_mul(net, node, graph, err) else: pass
理论转换过程中,add算子也会呈现下面的状况,其中有一个输出作为算子参数,这时能够把其类比到 _convert_BatchNorm 中的scale操作,将scale的weight视为1,bias为add算子的外部输出参数,能够参照BatchNorm批改代码,这里就不具体写了。
转换过程中算子转换胜利,然而呈现了shape问题,手动批改prototxt
下面介绍的是算子的适配,但有时通过onnx2caffe转换代码之后,曾经生成了prototxt文件,最终报错 feature map 的 shape 不匹配,因为onnx2caffe工具在转换的时候就打印出了每一层的output,通过与netron视图比照,定位第一个呈现问题的node。
知己知彼方能屡战屡败,为了定位shape为什么不统一,咱们先要理解一下不同框架的padding策略以及相应的output size的计算方法。
- 查看caffe的output size计算形式,依据代码可得:
output_size=floor((w+2*pad-(d(k-1)+1))/s)+1
template <typename Dtype>void ConvolutionLayer<Dtype>::compute_output_shape() { const int* kernel_shape_data = this->kernel_shape_.cpu_data(); const int* stride_data = this->stride_.cpu_data(); const int* pad_data = this->pad_.cpu_data(); const int* dilation_data = this->dilation_.cpu_data(); this->output_shape_.clear(); for (int i = 0; i < this->num_spatial_axes_; ++i) { // i + 1 to skip channel axis const int input_dim = this->input_shape(i + 1); const int kernel_extent = dilation_data[i] * (kernel_shape_data[i] - 1) + 1; const int output_dim = (input_dim + 2 * pad_data[i] - kernel_extent)/ stride_data[i] + 1; this->output_shape_.push_back(output_dim); }}
- tensorflow的padding策略可依据这篇博客,联合下面caffe的output size计算,感觉caffe的 conv padding 策略与tensorflow pad=VALID统一,会把不能参加的pixel主动去除不进行计算。
好了,理解了不同框架的padding策略以及output size的计算形式之后,咱们来剖析咱们的模型,模型转换是这样的:
剖析下面模型转换的表格参数:
- tensorflow pad=SAME,为了使所有的input pixel都参加计算,tensorflow在推理时偷偷在input的右下补了一行0,这样最初的输入:
output size = (112 - (1 * (3 - 1) + 1) + 1) / 2 + 1 = 56
其中 (112 - (1 * (3 - 1) + 1) + 1) 斜体1示意偷偷补的0。 - 对于onnx, 通过查问与试验,发现 pads 参数[0,0,1,1]示意 feature map 下面不补,右边不补,上面补一行 0,左边补一列,与tf统一,输入没有什么问题。
- 转为caffe之后,caffe模型 conv pad 参数都为0,上下左右都不补,这时依据caffe的outputshape公式,最终计算结果为(1,3,55,55),间接去除input的最初一行和最初一列不参加计算。
为了使输入shape统一,并且计算结果雷同,我采纳了上面的解决办法。
caffe中设置 pad_h:2, pad_w:2。 因为caffe是设置pad参数之后是对称补0的,即input的上下左右都补了两行或者两列0,这时联合output_shape公式,最终输入的shape为:
output_shape = floor((112 + 2 2 - (1 (3 - 1) + 1) + 1) / 2) + 1 = 57
思考一下conv原理,就晓得此时caffe失去的feature map 只是比tf的多了最下面一行和最右边一列。略微解释一下,尽管caffe设置pad=2,然而依据caffe的conv实现,会将右下比tf多补的那一行和那一列主动去除,不参加运算。这时feature map输入为(1,3,57,57), 为了失去正确后果,在prototxt文件的conv算子之后增加两个slice操作,去除最下面一行与最右边一列。
layer { name: "add_slice1" type: "Slice" bottom: "depthwise:0" top: "add_slice1/split:0" top: "add_slice1/split:1" slice_param { axis: 2 slice_point: 1 }}layer { name: "add_slice2" type: "Slice" bottom: "add_slice1/split:1" top: "add_slice2/split:0" top: "add_slice2/split:1" slice_param { axis: 3 slice_point: 1 }}
下面就是针对caffe模型的适配,货色很多很杂,有时候须要一些离奇的思路能力解决问题,当然还波及一些prototxt文件中算子param的批改,具体问题具体分析,这里就不开展讲了。
第三步:验证
将失去的caffe模型的输入后果与pb的输入后果进行比照,个别状况下应该是截然不同的,如果不一样次要关注一下 输出预处理,输入预处理,被批改的node之前的那个node的输入是不是OK(次要是定位是不是本人改的node的问题),切忌心浮气躁,把握办法。每进行一次魔改都做一次推理,这样比拟好定位。
总结
对于tf转caffe的确有一些麻烦,下面可能也只是列了万分之一的问题吧,不过心愿能够帮忙到大家。大家针对这方面什么好的想法心愿能够多交换奥~
针对onnx模型的魔改可能是多余的,应该将相干的转换形式间接写进onnx2caffe的转换工具中会更加好,然而之前想着批改onnx会更简略些,之后心愿能够有工夫把转换工具批改的更通用一些
强烈要求算法同学训练模型之前先看下NNIE框架反对的算子类型!!具体参考《HiSVP 开发指南》5.3.2节反对的算子类型以及3.1.6.2每个算子反对的规格,防止模型转换不过来又要返工!!
点击关注,第一工夫理解华为云陈腐技术~