C语言卷积神经网络(无训练,仅用于嵌入式部署)

写在前面

最近正好在做一个基于ZYNQ的人脸识别项目,所以就打算先用PS端做一个C语言的卷积神经网络,结果去网上搜,C语言神经网络的资料有很多,但是好多都不是很好部署,讲解的也不太详细。最后实在没办法,想趁着这个假期没事干,重复造一遍轮子,自己手搓一遍C语言卷积神经网络,也算是加深理解,打发假期时光了。

由于本人是微电子专业,所以代码规范性,代码风格可能比计算机科班出身的同学差很多,也请大家见谅。大家作为参考就好,也非常欢迎计算机专业的同学在我的这个基础上做改进。

我总共编写了3个工程,分别是1)PC端使用malloc动态数组的手写数字识别,2)用于嵌入式设备部署的使用静态数组的手写数字识别,3)以及用于嵌入式设备部署的使用静态数组的人脸识别。这三个工程我都把全部工程上传到gitcode上开源了,供大家参考~

代码开源地址

我把3个工程代码开源在GitCode了,链接在下面,大家自取

GitCode - 全球开发者的开源社区,开源代码托管平台GitCode是面向全球开发者的开源社区,包括原创博客,开源代码托管,代码协作,项目管理等。与开发者社区互动,提升您的研发效率和质量。icon-default.png?t=N7T8https://gitcode.com/Naruto123456__/C_CNN.git

还有Gitee代码链接:

C语言卷积神经网络_无训练仅用于嵌入式部署: 我自己使用C语言卷积神经网络,注意:无训练仅用于嵌入式部署icon-default.png?t=N7T8https://gitee.com/zhao15947470421/C_CNN.git

上面这个链接我同学测试了一下,可能打不开,我还上传了百度网盘,地址在下面

链接:https://pan.baidu.com/s/1Nb2OU4yLefUBnJ9IZx-G9A?pwd=zh66 
提取码:zh66

代码简介

首先,我先说几点这个代码的注意点:

  1. 本程序的各个神经网络函数以及参数是以pytorch框架作为参考的,所以例如卷积层中的参数in_channels表示输入的数据通道数,参考pytorch函数即可。
  2. 卷积函数Conv和池化函数MaxPool没有padding功能,padding直接设置为0即可
  3. 本程序只做部署推理,没有训练
  4. 所有的权重和偏置以及输入数据均在头文件中
  5. 本程序是在VS2022软件下编写,其他的环境可能各个文件的引用会有问题
  6. 本程序现有历程是一个LeNet-5网络做手写数字识别以及一个aceNet人脸识别
  7. 本程序在VS2022中直接点击调试按钮或者按F5即可运行

压缩包里面只包括了代码,需要自己新建一个VS工程,然后添加这些代码,下面说一下这些文件的作用:

  1. data_define.h是输入的数据以及每一层神经网络大小的定义头文件,
  2. weignt_and_bias_layerx.h就是第x层网络的权重和参数数据头文件,
  3. Conv.c, MaxPool.c这些就是卷积,池化层的c文件,每一个函数都有一个对应的.h头文件,
  4. 主函数就在main.c中,
  5. LeNet.ipynb就是这个网络训练的pytorch代码,
  6. 最后还有4个txt文件就是原始的输入数据。

3b92b9ce7f8a443da91314aad2195c71.png

实例讲解具体使用方法

我感觉还是通过一个具体的实例,一步一步教大家怎么使用这个代码比较好。

步骤1 首先通过pytorch等框架训练好一个神经网络

第一步就是通过pytorch或者tensorflow等框架训练好一个神经网络,然后将神经网络的结构和参数打印出来,就像下面这样:

这里我推荐使用pytorch的一个torchsummary库,可以打印出每一层的输出数组尺寸,方便我们后面直接写入尺寸。使用下面的代码查看尺寸。

# 查看神经网络模型大小
summary(model, input_size = [[1, 28, 28]])

4314261fa0a2498abd1adbba5dfcdd89.png

下面是每一层的参数:

这里需要添加一行代码,这样就防止输出的内容含有省略号了。

torch.set_printoptions(threshold=np.inf) # 输出的参数数量不限制

406bc27528644d6b8ad259db2991d05b.png

步骤2 将pytorch中的神经网络参数导出

全选python输出的每一层的权重和偏置参数,然后复制在txt文档中,最后在txt文档中通过替换,把所有的[]替换为{},因为C语言的数组是通过{}定义的。

步骤3 将神经网络的结构和参数导入C语言的头文件中

之后新建或者打开C语言的某一层的权重参数头文件,这里就以第0层卷积层Layer0的参数为例。

打开weight_and_bias_Layer0.h文件后,如下图所示,首先先更改宏定义,这4个宏定义分别表示该层网络的输出通道数,输入通道数,卷积核宽和高。

之后把txt文档中对应的第0层卷积核的权重和偏置复制到这里替代原始的数据即可。

1873becde5ec4b719e2d05394d85f997.png

复制完每一层的参数后,这一步就完成了。

步骤4 调用头文件

然后打开main.c文件,在最开始的地方添加头文件,由3部分组成:

1. 首先是一些基本的库函数,这里包括:

#include<stdio.h>使用printf等基本函数

#include<math.h>使用指数pow和开根号sqrt函数

#include<malloc.h>使用calloc进行动态内存分配,如果是嵌入式版本其实可以不引用,因为嵌入式设备开发使用堆中的静态数组,不使用malloc

#include<stdlib.h>使用memset()函数来快速清零数组

2. 数据头文件,这里包括:

输入的数据和每一层的权重头文件,只有卷积层和全连接层才有参数,其他层无参数

3. 引入conv,maxpool等函数的头文件

这里注意,一定在data_define.h后面引入,因为IO_size结构体在data_define.h中定义的,否则会找不到结构体类型

最后的结果如下图所示:

步骤5 初始化定义变量

根据自己的需要,在主函数外部初始化定义变量,我这里定义的是一些基本的变量,基本可以满足大部分卷积神经网络的需求。

这里说明一下,对于嵌入式设备来说,尽可能的在函数外定义变量,这种的变量就会分配到堆中,堆的空间很大,如果在程序内部定义,则经常会发生内存空间不够而导致死机的情况。

 /*******************初始化生成配置参数变量*************************/

// 每一层的输入输出尺寸结构体
IO_size size = { 0 };

// in_channels:输入通道数 out_channels:输出通道数 kernel_size:卷积,池化核尺寸 stride:卷积步长 padding:卷积填充尺寸
int in_channels = 0, out_channels = 0, kernel_size = 0, stride = 0, padding = 0;

// p_input:输入数据指针 p_output:输出数据指针 p_kernel:卷积核权重数组指针 p_bias:卷积偏置数组指针 p_weight:全连接层权重数组指针 p_conv_temp:卷积计算的临时数组指针
float* p_input = NULL; float* p_output = NULL; float* p_kernel = NULL; float* p_bias = NULL; float* p_weight = NULL; float* p_conv_temp = NULL;

// 使用静态数组来存储输入和输出,两个数组的大小需要保证大于等于每一层的输出大小,2个数组轮流使用
// 例如第0层输出存储到buffer0,那么第1层输入就是buffer0,输出就是buffer1,第2层输入buffer1,输出buffer0,依次轮流
// 嵌入式部署尽量不要使用malloc等动态数组,因为无MMU,即内存管理单元,无法实现对内存进行动态映射,会发生很多奇奇怪怪的BUG
float mem_buffer_0[6144] = { 0.0f };
float mem_buffer_1[6144] = { 0.0f };
float conv_temp[6144] = { 0.0f }; // 生成一个临时数组,保存卷积的临时结果,使用数组不使用malloc的动态数组,动态数组在嵌入式设备由于没有中会发生各种BUG

/*******************初始化生成配置参数变量*************************/

(重点)步骤6 根据自己的神经网络编辑sequential函数里面的神经网络结构

这一步是重点,我们需要根据自己的神经网络在sequential函数中依次组织我们的神经网络结构。sequential函数的输入参量是待处理原始数据的地址指针,比如一个图像数组的数组名(数组名就是指针),输出返回的参数就是经过神经网络计算之后的结果的地址的指针。

1. 首先,将现有函数中的某一个卷积或者池化层一整段直接复制到对应的层数中:

比如说新的神经网络结构需要添加一个卷积层,那么就随便找一段卷积层的代码,比如第0层卷积:

/*******************第0层卷积*************************/
    // 更改输入输出尺寸
    size = size_0;

    // 指针指向对应的数组
    p_input = input_data;
    p_output = mem_buffer_0;
    memset(p_output, 0, sizeof(conv_temp)); // 清零数组
    p_conv_temp = conv_temp;
    memset(p_conv_temp, 0, sizeof(mem_buffer_0)); // 清零数组
    p_kernel = &kernel_0[0][0][0][0];
    p_bias = bias_0;

    // 第0层参数编辑以及运算
    in_channels = 1, out_channels = 6, kernel_size = 5, stride = 1, padding = 0;
    Conv(size, p_input, p_output, p_conv_temp, p_kernel, p_bias, in_channels, out_channels, kernel_size, stride, padding);
    //Conv_print(p_output, size); // 输出第一层卷积输出
    /*******************第0层卷积*************************/

复制上面的代码,然后粘贴到你需要的层数位置。

2. 然后更改输入输出尺寸,更改本层的参数(stride,padding等)

然后根据具体的神经网络,去更改这一层的参数,以及指向的指针。

这里强调一下,如果使用的是嵌入式设备版本的代码,p_output的数组就是mem_buffer_0和mem_buffer_1轮流交替,例如:第0层的输出的结果通过指针存储在mem_buffer_0,那么第1层就是存储在mem_buffer_1

而且一定要在使用数组之前用memset函数去清零数组。

最终的结果如下,我这里每一层结束还增加了一个运行状态提示的输出:

// 利用sequential组织神经网络的结构,输入为数据的地址,返回一个输出的指针
float* sequential(float* input_data) {
    /*******************第0层卷积*************************/
    // 更改输入输出尺寸
    size = size_0;

    // 指针指向对应的数组
    p_input = input_data;
    p_output = mem_buffer_0;
    memset(p_output, 0, sizeof(conv_temp)); // 清零数组
    p_conv_temp = conv_temp;
    memset(p_conv_temp, 0, sizeof(mem_buffer_0)); // 清零数组
    p_kernel = &kernel_0[0][0][0][0];
    p_bias = bias_0;

    // 第0层参数编辑以及运算
    in_channels = 1, out_channels = 6, kernel_size = 5, stride = 1, padding = 0;
    Conv(size, p_input, p_output, p_conv_temp, p_kernel, p_bias, in_channels, out_channels, kernel_size, stride, padding);
    //Conv_print(p_output, size); // 输出第一层卷积输出
    /*******************第0层卷积*************************/
    // 运行状态提示
    printf("Layer0 Finished!!!\n\r");

    /*******************第1层池化*************************/
    // 更改输入输出尺寸
    size = size_1;

    // 指针指向对应的数组
    p_input = p_output;
    p_output = mem_buffer_1;
    memset(p_output, 0, sizeof(mem_buffer_0)); // 清零数组

    // 第1层参数编辑
    kernel_size = 2, stride = 2, padding = 0;
    MaxPool(size, p_input, p_output, kernel_size, stride, padding);
    //MaxPool_print(p_output_1, size);

    /*******************第1层池化*************************/
    // 运行状态提示
    printf("Layer1 Finished!!!\n\r");


    /*******************第2层卷积*************************/
    ...与之前类似,省略...
    /*******************第2层卷积*************************/
    // 运行状态提示
    printf("Layer2 Finished!!!\n\r");

    /*******************第3层池化*************************/
    ...与之前类似,省略...
    /*******************第3层池化*************************/
    // 运行状态提示
    printf("Layer3 Finished!!!\n\r");

。。。。。。。

    // 运行状态提示
    printf("Layer5 Finished!!!\n\r");

    /*******************第6层激活层*************************/
    ...与之前类似,省略...
    /*******************第6层激活层*************************/
    // 运行状态提示
    printf("Layer6 Finished!!!\n\r");

    return p_output;
};

步骤7 编辑main函数里的执行代码

最后,我们只需要在main函数中调用sequential函数,然后将输入和输出数据的指针分配好,就可以实现卷积神经网络了。

int main() {
    p_input = &input_data[0][0][0];
    p_output = sequential(p_input);
    
    /*******************输出最终结果*************************/
    // max(int input_size, float* p_input)
    int output = max_arr(10, p_output); // 将可能性最大的输出为类型序号

    printf("Input number is %d", output);
    /*******************输出最终结果*************************/

    return 0;
}

未来优化方向

1 因为我不会多维数组指针,所以只能使用一维指针,然后手动计算数组的寻址,后面应该用多维数组指针


2 我不会用头文件定义函数,因为有个结构体,导致会出现各种结构体被重新定义的报错


3 卷积和池化都没有padding功能,后面应该增加

踩坑注意事项

1 对于嵌入式设备开发来说,一般不要使用动态数组,因为嵌入式设备没有内存管理单元,所以使用动态数组进行内存分配的话,就会出现各种各样奇怪的bug,所以我在嵌入式部署的版本中,把动态内存分配全部换成了使用一个静态数组

2 同样,对于嵌入式设备开发来说,不要在一个程序中定义大小非常大的数组,对于数据量非常大的数组,直接放在程序之前的堆中,就是说在主程序之外定义。

3 vitis引用math.h库,需要在编译器中添加一个m,具体去搜索vitis添加math.h的文档

4 添加一个新的神经网络的时候,最好一层一层的去检查,输出结果是否正确,输出一层结果检查一层。

参考文献

[1] http://t.csdnimg.cn/0F0g3

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

为人民服务的FinFET

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值