乐趣区

关于框架:将模型转为NNIE框架支持的wk模型第一步tensorflowcaffe

摘要:本系列文章旨在分享 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 helper
from onnx import shape_inference, TensorProto
import onnxruntime
import onnx

def 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)
    return

if __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_layer

def _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 每个算子反对的规格,防止模型转换不过来又要返工!!

点击关注,第一工夫理解华为云陈腐技术~

退出移动版