4.3 编写C++代码
(1)推理引擎结构体
Core是OpenVINOTM 工具套件里的推理核心类,该类下包含多个方法,可用于创建推理中所使用的其他类。在此处,需要在各个方法中传递的仅仅是所使用的几个变量,因此选择构建一个推理引擎结构体,用于存放各个变量。
// @brief 推理核心结构体
typedef struct openvino_core {
ov::Core core; // core对象
std::shared_ptr<ov::Model> model_ptr; // 读取模型指针
ov::CompiledModel compiled_model; // 模型加载到设备对象
ov::InferRequest infer_request; // 推理请求对象
} CoreStruct;
其中Core是OpenVINOTM 工具套件里的推理机核心,该模块只需要初始化; shared_ptr<ov::Model>
是读取本地模型的方法,新版更新后,该方法发生了较大改动,可支持读取Paddlepaddle飞桨模型、onnx模型以及IR模型;CompiledModel
指的是一个已编译的模型类,其主要是将读取的本地模型应用多个优化转换,然后映射到计算内核,由所指定的设备编译模型;nferRequest
是一个推理请求类,在推理中主要用于对推理过程的操作。
(2)接口方法规划
经典的OpenVINOTM 进行模型推理,一般需要八个步骤,主要是:初始化Core对象、读取本地推理模型、配置模型输入&输出、载入模型到执行硬件、创建推理请求、准备输入数据、执行推理计算以及处理推理计算结果。我们根据原有的八个步骤,对步骤进行重新整合,并根据推理步骤,调整方法接口。
对于方法接口,主要设置为:推理初始化、配置输入数据形状、配置输入数据、模型推理、读取推理结果数据以及删除内存地址
六个大类,其中配置输入数据形状要细分为配置图片数据形状以及普通数据形状,配置输入数据要细分为配置图片输入数据与配置普通数据输入,读取推理结果数据细分为读取float数据和int数据,因此,总共有6类方法接口,9个方法接口。
(3)初始化推理模型
OpenVINOTM推理引擎结构体是联系各个方法的桥梁,后续所有操作都是在推理引擎结构体中的变量上操作的,为了实现数据在各个方法之间的传输,因此在创建推理引擎结构体时,采用的是创建结构体指针,并将创建的结构体地址作为函数返回值返回。推理初始化接口主要整合了原有推理的初始化Core对象、读取本地推理模型、载入模型到执行硬件和创建推理请求步骤,并将这些步骤所创建的变量放在推理引擎结构体中。
初始化推理模型接口方法为:
extern "C" __declspec(dllexport) void* __stdcall core_init(const wchar_t* model_file_wchar, const wchar_t* device_name_wchar);
该方法返回值为CoreStruct
结构体指针,其中model_file_wchar
为推理模型本地地址字符串指针,device_name_wchar
为模型运行设备名指针,在后面使用上述变量时,需要将其转换为string字符串,利用wchar_to_string()
方法可以实现将其转换为字符串格式:
std::string model_file_path = wchar_to_string(model_file_wchar);
std::string device_name = wchar_to_string(device_name_wchar);
模型初始化功能主要包括:初始化推理引擎结构体和对结构体里面定义的其他变量进行赋值操作,其主要是利用InferEngineStruct中创建的Core类中的方法,对各个变量进行初始化操作:
CoreStruct* p = new CoreStruct(); // 创建推理引擎指针
p->model_ptr = p->core.read_model(model_file_path); // 读取推理模型
p->compiled_model = p->core.compile_model(p->model_ptr, "CPU"); // 将模型加载到设备
p->infer_request = p->compiled_model.create_infer_request(); // 创建推理请求
(4)配置输入数据形状
在新版OpenVINOTM 2022.1 中,新增加了对Paddlepaddle模型以及onnx模型的支持,Paddlepaddle模型不支持指定指定默认bath通道数量,因此需要在模型使用时指定其输入;其次,对于onnx模型,也可以在转化时不指定固定形状,因此在配置输入数据前,需要配置输入节点数据形状。其方法接口为:
extern "C" __declspec(dllexport) void* __stdcall set_input_image_sharp(void* core_ptr, const wchar_t* input_node_name_wchar, size_t * input_size);
extern "C" __declspec(dllexport) void* __stdcall set_input_data_sharp(void* core_ptr, const wchar_t* input_node_name_wchar, size_t * input_size);
由于需要配置图片数据输入形状与普通数据的输入形状,在此处设置了两个接口,分别设置两种不同输入的形状。该方法返回值是CoreStruct结构体指针,但该指针所对应的数据中已经包含了对输入形状的设置。第一个输入参数core_ptr是CoreStruct指针,在当前方法中,我们要读取该指针,并将其转换为CoreStruct类型:
CoreStruct* p = (CoreStruct*)core_ptr;
input_node_name_wchar
为待设置网络节点名,input_size 为形状数据数组,对图片数据,需要设置 [batch, dim, height, width] 四个维度大小,所以input_size数组传入4个数据,其设置在形状主要使用Tensor类下的set_shape()方法:
std::string input_node_name = wchar_to_string(input_node_name_wchar); // 将节点名转为string类型
ov::Tensor input_image_tensor = p->infer_request.get_tensor(input_node_name); // 读取指定节点Tensor
input_image_tensor.set_shape({ input_size[0],input_size[1],input_size[2],input_size[3] }); // 设置节点数据形状
(5)配置输入数据
在新版OpenVINOTM 中,Tensor类的T* data()
方法,其返回值为当前节点Tensor的数据内存地址,通过填充Tensor的数据内存,实现推理数据的输入。对于图片数据,其最终也是将其转为一维数据进行输入,不过为方便使用,此处提供了配置图片数据和普通数据的接口,对于输入为图片的方法接口:
extern "C" __declspec(dllexport) void* __stdcall load_image_input_data(void* core_ptr, const wchar_t* input_node_name_wchar, uchar * image_data, size_t image_size);
该方法返回值是CoreStruct结构体指针,但该指针所对应的数据中已经包含了加载的图片数据。第一个输入参数core_ptr
是CoreStruct指针,在当前方法中,我们要读取该指针,并将其转换为CoreStruct类型;第二个输入参数input_node_name_wchar
为待填充节点名,先将其转为string字符串:
std::string input_node_name = wchar_to_string(input_node_name_wchar);
在该项目中,我们主要使用的是以图片作为模型输入的推理网络,模型主要的输入为图片的输入。其图片数据主要存储在矩阵image_data
和矩阵长度image_size
两个变量中。需要对图片数据进行整合处理,利用创建的data_to_mat ()
方法,将图片数据读取到OpenCV中:
cv::Mat input_image = data_to_mat(image_data, image_size);
接下来就是配置网络图片数据输入,对于节点输入是图片数据的网络节点,其配置网络输入主要分为以下几步:
首先,获取网络输入图片大小。
使用InferRequest
类中的get_tensor ()
方法,获取指定网络节点的Tensor,其节点要求输入大小在Shape容器中,通过获取该容器,得到图片的长宽信息:
ov::Tensor input_image_tensor = p->infer_request.get_tensor(input_node_name);
int input_H = input_image_tensor.get_shape()[2]; //获得"image"节点的Height
int input_W = input_image_tensor.get_shape()[3]; //获得"image"节点的Width
其次,按照输入要求,处理输入图片。
在这一步,我们除了要按照输入大小对图片进行放缩之外,还要根据PaddlePaddle对模型输入的要求进行处理。因此处理图片其主要分为交换RGB通道、放缩图片以及对图片进行归一化处理。在此处我们借助OpenCV来实现。
OpenCV读取图片数据并将其放在Mat类中,其读取的图片数据是BGR通道格式,PaddlePaddle要求输入格式为RGB通道格式,其通道转换主要靠一下方式实现:
cv::cvtColor(input_image, blob_image, cv::COLOR_BGR2RGB);
接下来就是根据网络输入要求,对图片进行压缩处理:
cv::resize(blob_image, blob_image, cv::Size(input_H, input_W), 0, 0, cv::INTER_LINEAR);
最后就是对图片进行归一化处理,其主要处理步骤就是减去图像数值均值,并除以方差。查询PaddlePaddle模型对图片的处理,其均值mean = [0.485, 0.456, 0.406]
,方差std = [0.229, 0.224, 0.225]
,利用OpenCV中现有函数,对数据进行归一化处理:
std::vector<float> mean_values{ 0.485 * 255, 0.456 * 255, 0.406 * 255 };
std::vector<float> std_values{ 0.229 * 255, 0.224 * 255, 0.225 * 255 };
std::vector<cv::Mat> rgb_channels(3);
cv::split(blob_image, rgb_channels); // 分离图片数据通道
for (auto i = 0; i < rgb_channels.size(); i++){
//分通道依此对每一个通道数据进行归一化处理
rgb_channels[i].convertTo(rgb_channels[i], CV_32FC1, 1.0 / std_values[i], (0.0 - mean_values[i]) / std_values[i]);
}
cv::merge(rgb_channels, blob_image); // 合并图片数据通道
最后,将图片数据输入到模型中。
在此处,我们重写了网络赋值方法,并将其封装到 fill_tensor_data_image(ov::Tensor& input_tensor, const cv::Mat& input_image)
方法中,input_tensor
为模型输入节点Tensor类,input_image
为处理过的图片Mat数据。因此节点赋值只需要调用该方法即可:
fill_tensor_data_image(input_image_tensor, blob_image);
对于普通数据的输入,其方法接口如下:
extern "C" __declspec(dllexport) void* __stdcall load_input_data(void* core_ptr, const wchar_t* input_node_name_wchar, float* input_data);
与配置图片数据不同点,在于输入数据只需要输入input_data
数组即可。其数据处理哦在外部实现,只需要将处理后的数据填充到输入节点的数据内存中即可,通过调用自定义的fill_tensor_data_float(ov::Tensor& input_tensor, float* input_data, int data_size)
方法即可实现:
std::string input_node_name = wchar_to_string(input_node_name_wchar);
ov::Tensor input_image_tensor = p->infer_request.get_tensor(input_node_name); // 读取指定节点tensor
int input_size = input_image_tensor.get_shape()[1]; //获得输入节点的长度
fill_tensor_data_float(input_image_tensor,input_data, input_size); // 将数据填充到tensor数据内存上
(6)模型推理
上一步中我们将推理内容的数据输入到了网络中,在这一步中,我们需要进行数据推理,这一步中我们留有一个推理接口:
extern "C" __declspec(dllexport) void* __stdcall core_infer(void* core_ptr)
进行模型推理,只需要调用CoreStruct结构体中的infer_request
对象中的infer()
方法即可:
CoreStruct* p = (CoreStruct*)core_ptr;
p->infer_request.infer();
(7)读取推理数据
上一步我们对数据进行了推理,这一步就需要查询上一步推理的结果。对于我们所使用的模型输出,主要有float数据和int数据,对此,留有了两种数据的查询接口,其方法为:
extern "C" __declspec(dllexport) void __stdcall read_infer_result_F32(void* core_ptr, const wchar_t* output_node_name_wchar, int data_size, float* infer_result);
extern "C" __declspec(dllexport) void __stdcall read_infer_result_I32(void* core_ptr, const wchar_t* output_node_name_wchar, int data_size, int* infer_result);
其中data_size为读取数据长度,infer_result 为输出数组指针。读取推理结果数据与加载推理数据方式相似,依旧是读取输出节点处数据内存的地址:
const ov::Tensor& output_tensor = p->infer_request.get_tensor(output_node_name);
const float* results = output_tensor.data<const float>();
针对读取整形数据,其方法一样,只是在转换类型时,需要将其转换为整形数据即可。我们读取的初始数据为二进制数据,因此要根据指定类型转换,否则数据会出现错误。将数据读取出来后,将其放在数据结果指针中,并将所有结果赋值到输出数组中:
for (int i = 0; i < data_size; i++) {
*inference_result = results[i];
inference_result++;
}
(8)删除推理核心结构体指针
推理完成后,我们需要将在内存中创建的推理核心结构地址删除,防止造成内存泄露,影响电脑性能,其接口该方法为:
extern "C" __declspec(dllexport) void __stdcall core_delet(void* core_ptr);
在该方法中,我们只需要调用delete命令,将结构体指针删除即可。
4.4 编写模块定义文件
我们在定义接口方法时,在原有方法的基础上,增加了extern "C" 、 __declspec(dllexport)
以及__stdcall
三个标识,其主要原因是为了让编译器识别我们的输出方法。其中,extern "C"
是指示编译器这部分代码按C语言(而不是C++)的方式进行编译;__declspec(dllexport)
用于声明导出函数、类、对象等供外面调用;__stdcall
是一种函数调用约定。通过上面三个标识,我们在C++种所写的接口方法,会在dll文件中暴露出来,并且可以实现在C#中的调用。
不过上面所说内容,我们在编辑器中可以通过模块定义文件(.def)所实现,在模块定义文件中,添加以下代码:
LIBRARY
"OpenVinoSharp"
EXPORTS
core_init
set_input_image_sharp
set_input_data_sharp
load_image_input_data
load_input_data
core_infer
read_infer_result_F32
read_infer_result_I32
core_delet
LIBRARY后所跟的为输出文件名,EXPORTS后所跟的为输出方法名。仅需要以上语句便可以替代extern "C" 、 __declspec(dllexport)
以及__stdcall
的使用。
4.5 生成dll文件
前面我们将项目配置输出设置为了生成dll文件,因此该项目不是可以执行的exe文件,只能生成不能运行。右键项目,选择重新生成/生成。在没有错误的情况下,会看到项目成功的提示。可以看到dll文件在解决方案同级目录下\x64\Release\文件夹下。
使用dll文件查看器打开dll文件,如图1- 11所示;可以看到,我们创建的四个方法接口已经暴露在dll文件中。
图1- 11 dll文件方法输出目录