將模型轉為NNIE框架支持的wk模型第一步:tensorflow->caffe


摘要:本系列文章旨在分享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之后的變化:

添加node的輸入輸出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的注冊算子,我們只需要在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。

onnx2caffe轉換輸出log
知己知彼方能百戰百勝,為了定位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每個算子支持的規格,避免模型轉換不過去又要返工!!

 

點擊關注,第一時間了解華為雲新鮮技術~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM