摘要:本系列文章旨在分享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 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 modelonnx_model = onnx.load(onnx_path)graph = onnx_model.graph# rewrite the input tensor of graphinput_tensor = graph.input[0]input_shape = input_tensor.type.tensor_type.shape.diminput_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 inputweight_infos = []tensors = graph.initializerfor 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 inferencenode = graph.nodevalue_info = graph.value_infoinferred_onnx_model = shape_inference.infer_shapes(onnx_model)onnx.checker.check_model(onnx_model)inferred_graph = inferred_onnx_model.graphinferred_value_info = inferred_graph.value_infoonnx.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.graphMul_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## 添加常数 inputshape = 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 nameinputs=[graph.node[i].input[0], 'shape'], # inputsoutputs=[graph.node[i].output[0]], # outputsname = 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.graphpow_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重复的namemul12_pow_node_def = helper.make_node('Pow', # node nameinputs=['pred_pose/mul_12:0', 'pow_scale'], # inputsoutputs=['pred_pose/mul_12_pow_output:0'], # outputsname = '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].namepow_node_def = helper.make_node('Mul', # node nameinputs=[input1, mul12_pow_node_def.output[0]], # inputsoutputs=[output], # outputsname = name)print(graph.node[i].name, i)graph.node.remove(graph.node[i])graph.node.insert(i, pow_node_def)breakgraph = 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.graphprob_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 sizeadd_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_dimreturn 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 = 16return _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.0np.copyto(net.params[node_name][0].data, scale, casting='same_kind')`mul本身是没有weight的,所以之前就是直接pass`def _convert_Mul(net, node, graph, err):node_name = node.nameif 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 axisconst 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: 2slice_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: 3slice_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每个算子支持的规格,避免模型转换不过去又要返工!!

点击关注,第一时间了解华为云新鲜技术~

将模型转为NNIE框架支持的wk模型第一步:tensorflow->caffe相关推荐

  1. Pytorch版本YOLOv3模型转Darknet weights模型然后转caffemodel再转wk模型在nnie上面推理

    Pytorch版本YOLOv3模型转darknet weights模型然后转caffemodel再转wk模型在nnie上面推理 文章目录 Pytorch版本YOLOv3模型转darknet weigh ...

  2. 数据分析技术:结构方程模型;想要“追求”,了解是第一步

    基础准备 上篇推送,我们正式开启了AMOS软件应用的介绍.看过上篇文章的朋友知道AMOS软件是用于处理结构方程模型的,文章也简要介绍了结构方程模型可以细分成测量模型和结构模型,以及AMOS软件分析结构 ...

  3. 海思YOLOv3 wk模型在nnie设备上面推理

    海思YOLOv3 wk模型在nnie设备上面推理 文章目录 海思YOLOv3 wk模型在nnie设备上面推理 前言 1. nnie推理代码 2. 修改代码 sample_nnie.c sample_s ...

  4. 量化交易有因子动物园 深度学习里有模型动物园(ModelZoo)又叫模型市场基于深度学习的增量学习,迁移学习等技术发展而来【调研】

    前言 随着迁移模型的概念流行起来,就像快乐会传染样,自然语言处理,计算机视觉,生成模型,强化学习,非监监督学习,语音识别 这几个领域内部产生了大量的可复用可迁移学习的基础模型,领域之间的方法也在互相学 ...

  5. java并发框架支持锁包括,tip/面试题_并发与多线程.md at master · 171437912/tip · GitHub...

    01. java用()机制实现了进程之间的同步执行 A. 监视器 B. 虚拟机 C. 多个CPU D. 异步调用 正解: A 解析: 监视器机制即锁机制 02. 线程安全的map在JDK 1.5及其更 ...

  6. AI 框架部署方案之模型部署概述

    0 概述 模型训练重点关注的是如何通过训练策略来得到一个性能更好的模型,其过程似乎包含着各种"玄学",被戏称为"炼丹".整个流程包含从训练样本的获取(包括数据采 ...

  7. 《强化学习周刊》第37期:视觉深层框架、Transformer World模型、注意力增强强化学习...

    No.37 智源社区 强化学习组 强 化 学  习 研究 观点 资源 活动 关于周刊 强化学习作为人工智能领域研究热点之一,其研究进展与成果也引发了众多关注.为帮助研究与工程人员了解该领域的相关进展和 ...

  8. 11 个 AI 和机器学习模型的开源框架

    译文链接:http://www.codeceo.com/article/11-frameworks-for-ai-and-ml.html 英文原文:11 Open-Source Frameworks ...

  9. Python:pmml格式文件的简介、安装、使用方法(利用python将机器学习模型转为Java常用的pmml格式文件)之详细攻略

    Python:pmml格式文件的简介.安装.使用方法(利用python将机器学习模型转为Java常用的pmml格式文件)之详细攻略 目录 pmml格式文件的简介 1.PMML结构 pmml安装 pmm ...

最新文章

  1. Hadoop mapreduce框架简介
  2. 网站建设套用模板后该如何做到出类拔萃?
  3. Servlet——简单用户登录实例+http协议解析
  4. callable object与新增的function相关 C++11中万能的可调用类型声明std::function...
  5. java学习曲线建议
  6. jsp到java xml配置,JSP中web配置:web.xml
  7. vue项目统一响应_vue中使用$set实现深入响应式原理
  8. 数据结构 之 图的存储和遍历
  9. hdu 3333 Turing Tree 求区间内不同数的和——线段树解法
  10. Zabbix 5.0 监控 SSH 登录
  11. ATL之深入浅出书评(转)
  12. python入门基础语法答案_第一阶段:Python开发基础 Python基础语法入门  day03 课后作业...
  13. 推荐系统实践学习笔记(一)
  14. 宣讲会通知|香港科技大学工学院理学硕士线上直播综合场【MSc】
  15. 实用金属材料手册_各种金属材料单位重量计算公式(汇总版)
  16. unity3d显示c4d材质_纯干货:C4D从初学者到精通,其实很简单
  17. 扇贝单词与百词斩的竞品分析
  18. javascript - 焦点事件(onfocus ,onblur )
  19. 华为 编程语言实验室,薪水_作为实验室科学家学习编程
  20. html怎样设置图片的圆角矩形,css怎么画圆角矩形?

热门文章

  1. Bootstrap3 源码版本的文件结构
  2. Bootstrap 折叠插件
  3. 启航篇——四旋翼飞行器之入坑两年心路历程和毕设总结
  4. 运行Gazebo出现cmd /opt/ros/melodic/lib/gazebo_ros/gzserver类似错误
  5. 理发店收银系统php,【毕业论文】基于php+mysql美发店收银系统设计与实现.doc
  6. oracle获取上年年初,【Oracle】TRUNC:获取去年年初和年末,获取月初和月末;
  7. python开发信息系统权限设置_python Django 用户管理和权限认证
  8. 广东哪所大学计算机专业好,准备考研,广东哪所大学的计算机专业最好?除了985,这所大学性价比很高...
  9. c语言程序设计指针教学,C语言程序设计中指针教学要点分析
  10. python中构造方法的名字,【填空题】Python提供了名称为 的构造方法,实现让类的对象完成初始化。...