2.2 语言绑定和测试层
一个工具库对于应用程序的兼容性,主要体现在对语言绑定的支持。OpenCV广泛应用的原因之一是它对Python、Java等解释性语言的友好。在OpenCV深度学习模块中,整个架构的顶层是语言绑定模块、测试模块及示例程序。这3部分都会调用DNN引擎。
2.2.1 深度学习模块的Python语言绑定
OpenCV的原生语言是C++,与此同时,OpenCV也提供Python语言的绑定。用户可以在Python环境中调用OpenCV的各种算法,这为原型开发带来了极大便利。下面是一个使用OpenCV Python模块显示图片的例子。
import cv2 as cv img=cv.imread('lena.jpg',0) cv.namedWindow('lena',cv.WINDOW_NORMAL) cv.imshow('lena',img) cv.waitKey(0) cv.destroyAllWindows()
作为OpenCV的一个主要模块,深度学习模块通过OpenCV的Python绑定机制为用户提供Python调用接口。1.3.2节给出的就是一个基于Python语言的DNN应用。下面仔细分析一下OpenCV的Python绑定机制。
所谓“OpenCV的Python绑定”,实际上就是为所有需要通过Python访问的C++API实现一个封装器(wrapper)。手动实现这些封装器是一件既麻烦又耗时的事情。为了避免这些工程上的麻烦,OpenCV采用脚本的方式来自动地为每个C++API加上封装器。相关的脚本位于modules/python/src2目录下。下面介绍绑定机制的具体内容。
首先,实现自动化绑定是从modules/python/CMakeLists.txt开始的。这个文件是OpenCV编译Python模块的配置文件,其中指定了需要进行Python绑定的OpenCV模块,与这些模块相对应的C++头文件将被记录下来。
然后,要用到绑定生成器脚本modules/python/src2/gen2.py,以及同目录下的头文件解析脚本hdr_parser.py。gen2.py读取C++头文件,并调用hdr_parser.py来解析这些头文件中的接口元素,包括类定义、函数定义、常量定义等。hdr_parser.py用Python列表来描述这些元素。例如,C++头文件中的函数定义被转换成一个存储有函数名、输入参数类型、返回类型的Python列表数据结构。最终hdr_parser.py脚本将返回一个记录了C++头文件中接口元素(类、函数、结构体、常量等)的巨大列表。gen2.py脚本将为这些列表中的接口元素创建封装器。这些生成的封装器位于build/modules/python/目录下,文件名是pyopencv_generated_*.h。除了自动生成的封装器之外,还需要为OpenCV中的一些基本数据类型(如Mat、Vec4i、Size等)手动生成封装器。例如,Mat类型在OpenCV的Python模块中对应的是Numpy数组类型,Size类型则对应两个元素的元组(tuple)类型。另外,一些复杂的数据结构、类和函数接口定义也需要手动生成封装器。所有这些手写的封装器都位于modules/python/src2/cv2.cpp。接下来就是将这些封装器编译成OpenCV的Python模块,最终生成Python模块文件cv2。Python模块cv2的生成过程如图2-2所示。
图2-2 Python模块cv2的生成过程
通过Python语言调用cv2模块中的某个函数,例如1.3.2节中的下面这行代码调用的是DNN模块下的blobFromImage函数:
# 注意:cv是通过"import cv2 as cv"语句导入的OpenCV Python模块的别名 input=cv.dnn.blobFromImage(image, 1, (224, 224), (104, 117, 123))
输入/输出参数的转换过程如下:输入参数image是Numpy数组对象,运行时会被转换成Mat对象。(224,224)是两个元素的元组对象,被转换成Size对象,(104,117,123)是3个元素的元组对象,被转换成Scalar对象。输出则从Mat对象转换成Numpy数组对象。在函数内部则调用原生C++实现,因此,OpenCV的Python模块在性能上和C++版本的是相当的。
2.2.2 深度学习模块的正确性测试和性能测试
OpenCV的测试基于Google Test框架,包括正确性测试和性能测试两个部分。OpenCV的每个模块会编译出两个可执行文件,即opencv_test_<模块名>和opencv_perf_<模块名>,分别对应两种测试。例如,深度学习模块的两个测试文件是opencv_test_dnn和opencv_perf_dnn。它们位于编译目录的bin文件夹下。运行测试程序之前,需要准备好测试数据。
首先用以下命令来下载测试数据:
$ git clone git://github.com/opencv/opencv_extra.git
然后设置环境变量(以Linux系统为例):
$ export OPENCV_TEST_DATA_PATH=/path_to_opencv_extra/testdata
1.正确性测试
OpenCV是一个非常活跃的开源项目,新的功能在不断地开发出来,老的功能也在持续完善,因此回归测试是非常必要的。另一个需要考虑的问题是,OpenCV在不同软硬件平台上的运行结果必须保持一致,这对算法的可移植性是至关重要的。基于以上两个原因,OpenCV提供了正确性测试框架,以方便OpenCV的开发者开发和维护单元测试用例。这些单元测试以不同的参数组合执行某个OpenCV函数,将结果和预设的正确结果相比较以确定测试是否通过。每个OpenCV模块都有相应的正确性测试用例,代码位于modules/<模块名称>/test目录下。例如,DNN模块的正确性测试用例位于modules/dnn/test目录下。下面以DNN模块中Reshape层的正确性测试为例,讲解如何编写一个测试用例。
DNN模块各个层类型的正确性测试用例在源代码module/dnn/test/test_layers.cpp中。注意,每个源代码文件都需要包含test_precomp.hpp头文件。Reshape层测试用例代码如下:
1 TEST(Layer_Test_Reshape, Accuracy) 2 { 3 { 4 int inp[]={4, 3, 1, 2}; 5 int out[]={4, 3, 2}; 6 testReshape(MatShape(inp, inp + 4), MatShape(out, out + 3), 2, 1); 7 } 8 { 9 int inp[]={1, 128, 4, 4}; 10 int out[]={1, 2048}; 11 int mask[]={-1, 2048}; 12 testReshape(MatShape(inp, inp + 4), MatShape(out, out + 2), 0, -1, 13 MatShape(mask, mask + 2)); 14 } 15 { 16 int inp[]={1, 2, 3}; 17 int out[]={3, 1, 2}; 18 int mask[]={3, 1, 2}; 19 testReshape(MatShape(inp, inp + 3), MatShape(out, out + 3), 0, -1, 20 MatShape(mask, mask + 3)); 21 } 22 }
其中,TEST(Layer_Test_Reshape,Accuracy)的第1个参数Layer_Test_Reshape代表测试用例名称,第2个参数Accuracy代表测试名称。运行DNN模块测试程序opencv_test_dnn时,可以在命令行参数中加入这两个名称来指定运行特定的测试用例。接下来的3个大括号组是3个具体的测试用例,下面以第1个大括号为例:
{ int inp[]={4, 3, 1, 2}; // 定义输入层维度信息 int out[]={4, 3, 2}; // 正确的输出层维度信息 // 运行测试 testReshape(MatShape(inp, inp + 4), MatShape(out, out + 3), 2, 1); }
TestReshape函数是测试主体,它执行层运算并将结果与事先给定的输出层维度信息做比较。具体代码和解释如下:
1 // 参数解释 2 // inputShape:输入层维度信息 3 // targetShape:正确的输出层维度信息 4 // axis:第一个需要调整的维度 5 // num_axes:需要调整的维度数 6 // mask:维度调整方式 7 void testReshape(const MatShape& inputShape, const MatShape& targetShape, 8 int axis=0, int num_axes=-1, 9 MatShape mask=MatShape()) 10 { 11 LayerParams params; // 层参数 12 params.set("axis", axis); // 设置axis参数 13 params.set("num_axes", num_axes); // 设置num_axes参数 14 if (!mask.empty()) 15 { 16 // 设置维度调整参数 17 params.set("dim", DictValue::arrayInt<int*>(&mask[0], mask.size())); 18 } 19 // 准备输入/输出数据 20 Mat inp(inputShape.size(), &inputShape[0], CV_32F); 21 std::vector<Mat> inpVec(1, inp); 22 std::vector<Mat> outVec, intVec; // 注意,这里的intVec是多余代码,没有用到 23 24 // 创建一个Reshape类型的层 25 Ptr<Layer> rl=LayerFactory::createLayerInstance("Reshape", params); 26 runLayer(rl, inpVec, outVec); // 层运算 27 28 Mat& out=outVec[0]; // 获取输出数据 29 MatShape shape(out.size.p, out.size.p + out.dims); // 获取输出数据的维度 //信息 30 EXPECT_EQ(shape, targetShape); // 与正确的维度信息相比较
以上代码定义了一个完整的测试用例,最终会被编译进opencv_test_dnn可执行文件。除了上面这个测试用例之外,opencv_test_dnn还包括modules/dnn/test目录下定义的其他测试用例。如果直接运行opencv_test_dnn,则所有测试用例都会执行一遍。我们可以用下面的命令指定运行Layer_Test_Reshape的Accuracy用例:
$(opencv编译目录)/bin/opencv_test_dnn --gtest_filter=Layer_Test_Reshape.Accuracy
2.性能测试
OpenCV致力于提供高性能的计算机视觉算法,因此性能的测试和评估是功能开发中的重要步骤。本节以DNN模块的性能测试为例,讲解一个典型的性能测试用例的写法和用法。
DNN性能测试源代码在modules/dnn/perf/下,我们以测试卷积计算性能的perf_convolution.cpp为例进行讲解,对一个性能测试用例必备的组成部分进行梳理。完整的源代码见perf_convolution.cpp。
如下面代码所示,首先是必要的头文件perf_precomp.hpp,所有perf测试都必须包含该文件。它的内部是一系列公用的头文件,放在一起方便引用。shape_utils.hpp提供了操作张量形状相关的函数。
#include "perf_precomp.hpp" #include <opencv2/dnn/shape_utils.hpp>
下面代码定义了TestSize_结构,所有需要进行水平尺度和垂直尺度说明的变量都会用到它。
struct TestSize_ { int width, height; operator Size() const { return Size(width, height); } };
以下代码定义了卷积参数结构ConvParam_t。每个成员变量具体含义见代码注释。
struct ConvParam_t { struct TestSize_ kernel; // 卷积核尺寸 struct BlobShape { int dims[4]; } shapeIn; // 输入张量的形状 int outCN; // 输出张量的通道数 int groups; // 输入张量在通道维度的分组组数 struct TestSize_ stride; // 每次运算的滑动距离 struct TestSize_ dilation; // 空洞大小 struct TestSize_ pad; // 补边大小 struct TestSize_ padAdjust; // 补边调整,只在反卷积中使用 const char* padMode; // 补边模式 bool hasBias; // 是否进行偏置运算 double declared_flops; // 该卷积运算的浮点运算次数 };
以下代码定义了一组卷积参数,由于数目巨大,这里不一一列出。测试的时候会对每一个卷积参数定义的卷积运算进行性能测试。
static const ConvParam_t testConvolutionConfigs[]={ /* GFLOPS 10.087 x 1=10.087 */ {{3, 3}, {{1, 576, 38, 50}}, 512, 1, {1, 1}, {1, 1}, {0, 0}, {0, 0}, "SAME", true, 10086963200.}, … };
接下来对上面定义的卷积参数进行封装,让OpenCV的测试框架能够通过模板类调用,具体封装是通过ConvParamID结构体和静态模板函数::testing::internal::ParamGenerator<ConvParamID>all()实现的。代码如下:
struct ConvParamID { enum { CONV_0=0, CONV_100=100, CONV_LAST=sizeof(testConvolutionConfigs) / sizeof(testConvolutionConfigs[0]) }; int val_; ConvParamID(int val=0) : val_(val) {} operator int() const { return val_; } static ::testing::internal::ParamGenerator<ConvParamID> all() { #if 0 enum { NUM=(int)CONV_LAST }; #else enum { NUM=(int)CONV_100 }; #endif ConvParamID v_[NUM]; for (int i=0; i < NUM; ++i) { v_[i]= ConvParamID(i); } // 考虑生成代码的长度,这里只使用了前100组卷积参数 return ::testing::ValuesIn(v_, v_ + NUM); } }; typedef tuple<ConvParamID, tuple<Backend, Target> > ConvTestParam_t; typedef TestBaseWithParam<ConvTestParam_t> Conv;
接下来通过PERF_TEST_P_宏生成测试用例。PERF_TEST_P_是由OpenCV测试框架提供的一个封装测试程序的宏。它的使用方法如下:
/* 第1个参数是测试大类名称,第2个参数是测试名称。一个测试大类可以包含多个测试*/ PERF_TEST_P_(Conv, conv) { // 测试程序代码 }
接下来具体讲解测试程序代码部分。
首先,获取测试参数,包括testConvolutionConfigs、Backend类型和Target类型。代码如下:
int test_id=(int)get<0>(GetParam()); ASSERT_GE(test_id, 0); ASSERT_LT(test_id, ConvParamID::CONV_LAST); const ConvParam_t& params=testConvolutionConfigs[test_id]; double declared_flops=params.declared_flops; Size kernel=params.kernel; MatShape inputShape=MatShape(params.shapeIn.dims, params.shapeIn.dims + 4); int outChannels=params.outCN; int groups=params.groups; Size stride=params.stride; Size dilation=params.dilation; Size pad=params.pad; Size padAdjust=params.padAdjust; std::string padMode(params.padMode); bool hasBias=params.hasBias; Backend backendId=get<0>(get<1>(GetParam())); Target targetId=get<1>(get<1>(GetParam()));
接下来,根据获取的测试参数设置卷积层对象的参数,创建并初始化输入张量,权重张量。具体代码如下:
int inChannels=inputShape[1]; // 输入张量通道数 Size inSize(inputShape[3], inputShape[2]); // 输入张量的宽和高 // 权重维度 int sz[]={outChannels, inChannels / groups, kernel.height, kernel.width}; Mat weights(4, &sz[0], CV_32F); // 创建权重对象 randu(weights, -1.0f, 1.0f); // 随机初始化权重值 int inChannels=inputShape[1]; // 输入张量通道数 /* 以下代码创建层参数对象,并设置每个参数*/ LayerParams lp; lp.set("kernel_w", kernel.width); lp.set("kernel_h", kernel.height); lp.set("pad_w", pad.width); lp.set("pad_h", pad.height); if (padAdjust.width > 0 || padAdjust.height > 0) { lp.set("adj_w", padAdjust.width); lp.set("adj_h", padAdjust.height); } if (!padMode.empty()) lp.set("pad_mode", padMode); lp.set("stride_w", stride.width); lp.set("stride_h", stride.height); lp.set("dilation_w", dilation.width); lp.set("dilation_h", dilation.height); lp.set("num_output", outChannels); lp.set("group", groups); lp.set("bias_term", hasBias); lp.type="Convolution"; lp.name="testLayer"; lp.blobs.push_back(weights); if (hasBias) { Mat bias(1, outChannels, CV_32F); randu(bias, -1.0f, 1.0f); lp.blobs.push_back(bias); } /* 创建并随机初始化输入数据*/ int inpSz[]={1, inChannels, inSize.height, inSize.width}; Mat input(4, &inpSz[0], CV_32F); randu(input, -1.0f, 1.0f);
然后,创建网络对象,进行第一次推理运算,输出网络运算量flops(即浮点运算次数)。第一次推理运算不计入总的运算时间,因为很多具体的优化过程需要在运行期进行,而且只需要一次,导致第一次推理运算耗时较多,影响性能测试数据的准确性。代码如下:
Net net; // 创建网络对象 net.addLayerToPrev(lp.name, lp.type, lp); // 添加层对象 net.setInput(input); // 设置网络输入张量 net.setPreferableBackend(backendId); // 设置后端类型 net.setPreferableTarget(targetId); // 设置运算设备类型 // warmup Mat output=net.forward(); // 第一次网络推理运算 /* 获取网络运算量数据*/ MatShape netInputShape=shape(input); size_t weightsMemory=0, blobsMemory=0; net.getMemoryConsumption(netInputShape, weightsMemory, blobsMemory); int64 flops=net.getFLOPS(netInputShape); CV_Assert(flops > 0); / * 输出测试运算量信息*/ std::cout << "IN=" << divUp(input.total() * input.elemSize(), 1u<<10) << " Kb " << netInputShape << " OUT=" << divUp(output.total() * output.elemSize(), 1u<<10) << " Kb " << shape(output) << " Weights(parameters): " << divUp(weightsMemory, 1u<<10) << " Kb" << " MFLOPS=" << flops * 1e-6 << std::endl; TEST_CYCLE() // 进行多次推理运算 { Mat res=net.forward(); } EXPECT_NEAR(flops, declared_flops, declared_flops * 1e-6); // 运算量检查 SANITY_CHECK_NOTHING(); // 不做结果验证,直接返回true
最后,通过INSTANTIATE_TEST_CASE_P宏注册测试用例。代码如下:
INSTANTIATE_TEST_CASE_P(/**/, Conv, Combine( /* 该测试将运行于所有卷积参数、后端类型、目标设备类型的组合*/ ConvParamID::all(), dnnBackendsAndTargets(false, false) // defined in ../test/test_common.hpp ));
至此,一个完整的性能测试用例就完成了。编译之后可通过以下命令来指定运行该测试:
$(opencv编译目录)/bin/opencv_perf_dnn --gtest_filter=Conv.conv
[1] 参见https://github.com/google/googletest。
[2] 参见https://github.com/opencv/opencv/blob/4.1.0/modules/dnn/perf/perf_convolution.cpp。