12行代码完成AI物体识别 - ncnn前向计算流程浅析

1 引子:12行

源于 人工智能 的时代潮流,不少同学都在不同平台使用过一些深度学习的前向计算框架(比如tensorflow,caffe,ncnn,pytorch等)。用归用,但框架的内部究竟是如何设计和运作的。

使用ncnn进行前向计算的步骤很简单,就如下十几行代码即可完成。

    /* Step1.1 : 加载.parma 文件 */
    NSString *paramPath = [[NSBundle mainBundle] pathForResource:@"squeezenet_v1.1" ofType:@"param"];
    ncnn_net.load_param(paramPath.UTF8String);
    
    /* Step1.2 : 加载.bin 文件 */
    NSString *binPath = [[NSBundle mainBundle] pathForResource:@"squeezenet_v1.1" ofType:@"bin"];
    ncnn_net.load_model(binPath.UTF8String);

    /* Step2.1 : 构建并配置 提取器 */
    ncnn::Extractor extractor = ncnn_net.create_extractor();
    extractor.set_light_mode(true);

    /* Step2.2 : 设置输入(将图片转换成ncnn::Mat结构作为输入) */
    UIImage *srcImage = [UIImage imageNamed:@"mouth"];
    ncnn::Mat mat_src;
    ts_image2mat(mat_src, srcImage);
    extractor.input("data", mat_src);
    
    /* Step2.3 : 提取输出 */
    ncnn::Mat mat_dst;
    extractor.extract("prob", mat_dst);

如果你仅仅想使用ncnn,上面的参考足够了;但若你想要了解,甚至去更改一些其中的源代码,可以跟我一起看看上面这十多行代码的底层运作原理。

2 代码分析

我姑且将其分为:加载模型、前向检测、输出处理(半划水)、模型封装(全划水) 四个部分来加以分析。

2.1 加载模型

ncnniOS 端使用 .param.bin 两个文件来描述一个神经网络模型,
其中:
.param:描述神经网络的结构,包括层名称,层输入输出信息,层参数信息(如卷积层的kernal大小等)等。
.bin 文件则记录神经网络运算所需要的数据信息(比如卷积层的权重、偏置信息等)

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 ncnn官方的Demo中的模型文件.png

2.1.1 load_param 加载神经网络配置信息

/* Step1.1 : 加载.parma 文件 */
NSString *paramPath = [[NSBundle mainBundle] pathForResource:@"squeezenet_v1.1" ofType:@"param"];
ncnn_net.load_param(paramPath.UTF8String);

load_param的根本目的是将.param文件的信息加载到目标神经网络(一个ncnn::Net结构)中

2.1.1.1 .param文件的结构

首先我们看一下 .param 文件的内容格式

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 squeezenet_v1.1.param 部分信息

一个.param文件由以下几部分组成:

1)MagicNum

固定位7767517,为什么这个数字,不知道
问倪神去吧

2)layer、blob个数

上图示例的文件两个数字分别为:75、83

layer:我们知道神经网络是一层一层向前推进计算的,每一层我们用一个layer表示;

blob:每一个layer都可能会有输入、输出,在ncnn中,它们统一用一个多维(3维)向量表示,我们称每一个输入、输出的原子为一个blob,并为它起名。

2.1.1.2 layer的描述

layer.param 中是一个相对复杂的元素(从第3行起的每一行描述一个layer),所以我们把它单独抽出来一小节进行说明。

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 层描述.png

如图,每一行层描述的内容包括以下几部分:
1)层类型
比如Input、Convolution、ReLU
2)层名
模型训练者为该层起得名字(毕竟相同类型的层可能多次使用,我们要区分它们)
3)层输入输出
包含:层输入blob数量,层输出blob数量,层输入、输出blob的名称
4)层配置参数
比如 卷积层(Convolution Layer)的 卷积核大小、步长信息

2.1.1.3 ncnn的加载的效果

其实了解了param文件的数据结构后,我们就大致知道ncnn做了哪些事情了。无非是读取文件–>解析神经网络信息–>缓存神经网络信息,那么,信息缓存在哪里呢?

/* in net.h */
class Net
{
...
protected:
    std::vector<Blob> blobs;
    std::vector<Layer*> layers;
...
};

原来,ncnn::Net 结构中有 blobslayers 两个 vector,它们保存了 .param文件 中加载的信息。关于 Blob、Layer 的数据结构,在此暂不赘述。(自己看代码呗!)

2.1.2 load_model 加载模型训练数据

/* Step1.2 : 加载.bin 文件 */
NSString *binPath = [[NSBundle mainBundle] pathForResource:@"squeezenet_v1.1" ofType:@"bin"];
ncnn_net.load_model(binPath.UTF8String);

load_model的根本目的是将 .bin文件 的信息加载到 目标神经网络(一个ncnn::Net结构)中。

2.1.2.1 .bin文件的内容

.bin 文件存储了对应模型中部分层的计算需求参数。
比如2.1.1.1节中的 第四行的Convolution层

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 卷积层举例.png

.bin 文件中就存储了其
1728(3 * 3 * 3 * 64) 个float类型的
权重数据(weight_data) 和 64个float类型的
偏置数据(bias_data)

2.1.2.2 .bin文件的结构

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 用vim打开.bin文件.png

bin = binary,.bin 文件的基本结构就是 [二进制]

但这
并不代表我们失去了 [手动修改它] 的权利!

惊不惊喜,意不意外?下节即揭晓!

2.1.2.3 手撕二进制

1)bin文件信息存储说明
假设 bin 文件存储 0.3342, 0.4853, 0.2843, 0.1231 四个数字,这四个数字使用float32的数据结构来描述,分别为:3eab1c43、3ef8793e、3e918fc5、3dfc1bda,那么bin文件中的内容就是 3eab1c433ef8793e3e918fc53dfc1bda,我们进行读取的时候使用一个float的数组去承载这些二进制数据即可。

2)手撕
你当然也可以自己写一段bin文件数据的读取方法,比如这么一段

const void * __log_binInfo_conv1(const void *dataOffset) {
    printf("\n【conv1】层类型为【Convolution】(卷积层)\n"
          "参数配置 0=64 1=3 2=1 3=2 4=0 5=1 6=1728,即:\n"
          "输出单元 数量: 64\n"
          "核 大小: 3, 3\n"
          "核 膨胀: 2, 2\n"
          "Pad 大小: 0, 0\n"
          "是否有偏置项: 1(是)\n"
          "权重数据 数量: 1728 (= 3(核高) * 3(核宽) * 3(RGB三通道) * 64(输出单元数量)\n");
    
    printf("\n【conv1】Load1_1: 加载weight_data数据类型标志(固定为自动类型)\n");
    unsigned char *p_load1_1 = (unsigned char *)dataOffset;
    for (int i = 0; i < 4; i++) {
        printf("Flag %d : %d\n", i, p_load1_1[i]);
    }
    p_load1_1 += 4;
    
    printf("\n【conv1】Load1_2: 加载weight_data数据(1728项,自动为float32类型)\n");
    float *p_load1_2 = (float *)p_load1_1;
    for (int i = 0; i < 1728; i++) {
        if (i < 10 || i > 1720) {
            printf("Weight %d : %.9f\n", i, p_load1_2[i]);
        }
    }
    p_load1_2 += 1728;
    
    printf("\n【conv1】Load2: 加载bias偏置数据(64项,固定为float32类型)\n");
    float *p_load2 = (float *)p_load1_2;
    for (int i = 0; i < 64; i++) {
        if (i < 5 || i > 60) {
            printf("Bias %d : %.9f\n", i, p_load2[i]);
        }
    }
    p_load2 += 64;
    
    return p_load2;
}

Demo:
https://github.com/chrisYooh/ncnnSrcDemo
1)打开其下的 NcnnSrcDemo 工程
2)进入 ViewController解除 自定义bin文件加载测试的 注释

/* 自定义 bin 文件加载测试 */
[self loadModel_myAnalysis];

3)运行看看结果吧,也可以用 ncnn的loadModel 去跑,然后打断点看看解读的 .bin 文件数据一致不。

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 自定义bin文件信息读取.png

了解了bin文件的信息存储形式,我们当然就可以进行信息修改咯!不同的框架模型进行转化时,就要做这样的事情。
哇,那我们可以 自己写转模型的工具 啦!从技术上说,完全没错!
(当然我们还要补习神经网络中各种层的信息,以及不同框架的数据结构设计)

2.2 Detect 检测

完成了 网络初始化 load_param()、 load_bin()之后,我们可以填写一个输入并使用 网络提取器Extractor 计算输出了。

2.2.1 创建提取器 Extractor

/* Step2.1 : 构建并配置 提取器 */
ncnn::Extractor extractor = ncnn_net.create_extractor();
extractor.set_light_mode(true);

提取器 extractor 使用 目标网络 通过 友元函数 创建实例,因为它需要获取对应神经网络的信息;同时,extractor 还可以自定义部分配置信息。

Extractor含3个关键类变量
1)net: 指向对应网络的指针
2)blob_mats: 计算的过程中存储输入、输出的临时数据
3)opt: 配置参数

2.2.2 extractor.input 配置输入

/* Step2.2 : 设置输入(将图片转换成ncnn::Mat结构作为输入) */
UIImage *srcImage = [UIImage imageNamed:@"mouth"];
ncnn::Mat mat_src;
ts_image2mat(mat_src, srcImage);
extractor.input("data", mat_src);

1)我们要 构造一个ncnn::Mat的结构,将我们的输入填入其中
2)利用 Extractor的input()函数 将输入mat填入对应的位置。
注意input() 函数中的第一个字符串参数输入的是 blob的名称 而不是 layer的名称 哦!(如有有些懵,可以回看一下 [2.1.1.1节].param的文件描述,区分下 layerblob

2.2.3 extractor.extract 提取输出

/* Step2.3 : 提取输出 */
ncnn::Mat mat_dst;
extractor.extract("prob", mat_dst);

1)我们要 构造一个ncnn::Mat的结构,用以承载输出
2)利用 Extractor的extract()函数 将计算结果填写到我们构造的输出mat中。
注意:input()函数中的第一个字符串参数输入的是 blob的名称 而不是 layer的名称 哦!(如有有些懵,可以回看一下 [2.1.1.1节].param的文件描述,区分下 layerblob

2.2.3.1 extract() 的递归流程图

ncnn 在进行 extract() 的时候,使用了递归的方式,这边将其 宏观逻辑进行抽象(描绘所有的代码细节会使图过于复杂,不易阅读)

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 递归实现.png

2.2.3.2 extract() 最简递归展开流程

之所以说最简,因为我们假设:
1 目标网络的每层都只有 一个输入(blob)一个输出(blob)
2 使用的extract是新创建的(即 无缓存数据)

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 extract()递推展开.png

如图:
1)每一层进行forward()的时候,需要一些输入参数,这些输入参数是 由上面的层的forward()运算输出的
2)只有 输入层的输入参数是我们填写的(2.2.2 节),也正是因为它的存在,递归得以有了终结。

2.3 输出处理

    /* Step3.1 : 结果处理(获取检测概率最高的5种物品,认为存在) */
    NSArray *rstArray = ts_mat2array(mat_dst);
    NSArray *top5Array = ts_topN(rstArray, 5);
    
    /* Step3.2 : 打印输出 */
    NSLog(@"%@", top5Array);
    
    /* 说明:该Demo中发现输出的第一项是 index 为 673 的项目,
     * 在result_info.json中查找下 "index" : "673" 发现对应的描述是 鼠标
     * 也可以换其他图片进行检测,但要将图片规格化成 227 * 227 的大小才可以保证结果的准确性
     */

输出处理是根据需求具体模型需求,很灵活的。
比如我给予输出结果的每个数字以 概念(识别到某种物品的概率) ,并对输出结果进行排序后取其 概率最高的 五个值。

2.4 封装

玩一玩的话,12行代码足够了;但若真的要工程化的话,我们还是要将 面向过程 的思路 向 面向对象 靠拢的。
可惜的是,这边只能提点一下 要有封装的意识
因为在公司领导决定开源我们的SDK之前,不太方便透漏我们相关的封装思路咯。

3 文尾福利 Demo

老套路,文尾送福利!

这边提供一个 NcnniOS源码Demo。其中,你可以直接 在ncnn源码中打断点,加日志,通过调试源码的方式对ncnn快速理解。

Github地址:https://github.com/chrisYooh/ncnnSrcDemo
打开其下的 ncnnSrcDemo工程,然后,开始愉快地 Debug 吧!:)

《12行代码完成AI物体识别 - ncnn前向计算流程浅析》 随性的图.png

呃……好像最近蛮流行 文章末尾随便塞张图……
你猜我消消乐玩到第几关了?😈

    原文作者:Chriszzzz
    原文地址: https://www.jianshu.com/p/0cfd6a982a30
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞