摘要:本系列文章旨在分享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每個算子支持的規格,避免模型轉換不過去又要返工!!