OpenCV深度学习应用与性能优化实践
上QQ阅读APP看书,第一时间看更新

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 参见https://github.com/google/googletest。框架,包括正确性测试和性能测试两个部分。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 参见https://github.com/opencv/opencv/blob/4.1.0/modules/dnn/perf/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。