GPU上的相似度检测(PNSR 和 SSIM)

学习目标

OpenCV的视频输入和相似度测量 教程中我们已经学习了检测两幅图像相似度的两种方法:PSNR和SSIM。正如我们所看到的,执行这些算法需要相当长的计算时间,其中SSIM(结构相似度)的算法代价相当高昂。然而,OpenCV现在支持Nvidia的CUDA加速,如果你有一块支持CUDA的的Nvidia显卡。您可以将算法改为使用GPU计算从而大幅提高效率。

本教程将提供一个很好的示例来演示如何让OpenCV来使用GPU这些操作。当然在这之前,你应该了解一下如何操作core,highgui 和imgproc 模块。因此我们的目标是:

  • GPU与CPU有什么不同?
  • 为PSNR何SSIM编写GPU 代码
  • 优化代码以获得最佳性能

源代码

你也可以在OPENCV源库文件夹中 samples/cpp/tutorial_code/gpu/gpu-basics-similarity/gpu-basics-similarity 找到源代码以及一些视频文件或者 从这里下载 。 完整的源码很长(因为其中包含了通过命令行参数进行应用程序和性能测量等无关代码)。因此在这里只出现一部分关键代码。

如果两个输入图像的相似,PSNR 返回一个浮点数在30和50之间,(数值越高,相符程度越高).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
double getPSNR(const Mat& I1, const Mat& I2)
{
    Mat s1; 
    absdiff(I1, I2, s1);       // |I1 - I2|
    s1.convertTo(s1, CV_32F);  // cannot make a square on 8 bits
    s1 = s1.mul(s1);           // |I1 - I2|^2

    Scalar s = sum(s1);         // sum elements per channel

    double sse = s.val[0] + s.val[1] + s.val[2]; // sum channels

    if( sse <= 1e-10) // for small values return zero
        return 0;
    else
    {
        double  mse =sse /(double)(I1.channels() * I1.total());
        double psnr = 10.0*log10((255*255)/mse);
        return psnr;
    }
}



double getPSNR_GPU_optimized(const Mat& I1, const Mat& I2, BufferPSNR& b)
{    
    b.gI1.upload(I1);
    b.gI2.upload(I2);

    b.gI1.convertTo(b.t1, CV_32F);
    b.gI2.convertTo(b.t2, CV_32F);

    gpu::absdiff(b.t1.reshape(1), b.t2.reshape(1), b.gs);
    gpu::multiply(b.gs, b.gs, b.gs);

    double sse = gpu::sum(b.gs, b.buf)[0];

    if( sse <= 1e-10) // for small values return zero
        return 0;
    else
    {
        double mse = sse /(double)(I1.channels() * I1.total());
        double psnr = 10.0*log10((255*255)/mse);
        return psnr;
    }
}

struct BufferPSNR                                     // Optimized GPU versions
{   // Data allocations are very expensive on GPU. Use a buffer to solve: allocate once reuse later.
    gpu::GpuMat gI1, gI2, gs, t1,t2;

    gpu::GpuMat buf;
};

double getPSNR_GPU(const Mat& I1, const Mat& I2)
{
    gpu::GpuMat gI1, gI2, gs, t1,t2; 

    gI1.upload(I1);
    gI2.upload(I2);

    gI1.convertTo(t1, CV_32F);
    gI2.convertTo(t2, CV_32F);

    gpu::absdiff(t1.reshape(1), t2.reshape(1), gs); 
    gpu::multiply(gs, gs, gs);

    Scalar s = gpu::sum(gs);
    double sse = s.val[0] + s.val[1] + s.val[2];

    if( sse <= 1e-10) // for small values return zero
        return 0;
    else
    {
        double  mse =sse /(double)(gI1.channels() * I1.total());
        double psnr = 10.0*log10((255*255)/mse);
        return psnr;
    }
}

SSIM 返回图像的结构相似度指标。这是一个在0-1之间的浮点数(越接近1,相符程度越高), 这个算法对每个通道有一个值,所以最终会产生一个OpenCV的 Scalar 数据结构:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
}

Scalar getMSSIM( const Mat& i1, const Mat& i2)
{ 
    const double C1 = 6.5025, C2 = 58.5225;
    /***************************** INITS **********************************/
    int d     = CV_32F;

    Mat I1, I2; 
    i1.convertTo(I1, d);           // cannot calculate on one byte large values
    i2.convertTo(I2, d); 

    Mat I2_2   = I2.mul(I2);        // I2^2
    Mat I1_2   = I1.mul(I1);        // I1^2
    Mat I1_I2  = I1.mul(I2);        // I1 * I2

    /*************************** END INITS **********************************/

    Mat mu1, mu2;   // PRELIMINARY COMPUTING
    GaussianBlur(I1, mu1, Size(11, 11), 1.5);
    GaussianBlur(I2, mu2, Size(11, 11), 1.5);

    Mat mu1_2   =   mu1.mul(mu1);    
    Mat mu2_2   =   mu2.mul(mu2); 
    Mat mu1_mu2 =   mu1.mul(mu2);

    Mat sigma1_2, sigma2_2, sigma12; 

    GaussianBlur(I1_2, sigma1_2, Size(11, 11), 1.5);
    sigma1_2 -= mu1_2;

    GaussianBlur(I2_2, sigma2_2, Size(11, 11), 1.5);
    sigma2_2 -= mu2_2;

    GaussianBlur(I1_I2, sigma12, Size(11, 11), 1.5);
    sigma12 -= mu1_mu2;

    ///////////////////////////////// FORMULA ////////////////////////////////
    Mat t1, t2, t3; 

    t1 = 2 * mu1_mu2 + C1; 
    t2 = 2 * sigma12 + C2; 
    t3 = t1.mul(t2);              // t3 = ((2*mu1_mu2 + C1).*(2*sigma12 + C2))

    t1 = mu1_2 + mu2_2 + C1; 
    t2 = sigma1_2 + sigma2_2 + C2;     
    t1 = t1.mul(t2);               // t1 =((mu1_2 + mu2_2 + C1).*(sigma1_2 + sigma2_2 + C2))

    Mat ssim_map;
    divide(t3, t1, ssim_map);      // ssim_map =  t3./t1;

    Scalar mssim = mean( ssim_map ); // mssim = average of ssim map
    return mssim; 
}

Scalar getMSSIM_GPU( const Mat& i1, const Mat& i2)
{ 
    const float C1 = 6.5025f, C2 = 58.5225f;
    /***************************** INITS **********************************/
    gpu::GpuMat gI1, gI2, gs1, t1,t2; 

    gI1.upload(i1);
    gI2.upload(i2);

    gI1.convertTo(t1, CV_MAKE_TYPE(CV_32F, gI1.channels()));
    gI2.convertTo(t2, CV_MAKE_TYPE(CV_32F, gI2.channels()));

    vector<gpu::GpuMat> vI1, vI2; 
    gpu::split(t1, vI1);
    gpu::split(t2, vI2);
    Scalar mssim;

    for( int i = 0; i < gI1.channels(); ++i )
    {
        gpu::GpuMat I2_2, I1_2, I1_I2; 

        gpu::multiply(vI2[i], vI2[i], I2_2);        // I2^2
        gpu::multiply(vI1[i], vI1[i], I1_2);        // I1^2
        gpu::multiply(vI1[i], vI2[i], I1_I2);       // I1 * I2

        /*************************** END INITS **********************************/
        gpu::GpuMat mu1, mu2;   // PRELIMINARY COMPUTING
        gpu::GaussianBlur(vI1[i], mu1, Size(11, 11), 1.5);
        gpu::GaussianBlur(vI2[i], mu2, Size(11, 11), 1.5);

        gpu::GpuMat mu1_2, mu2_2, mu1_mu2; 
        gpu::multiply(mu1, mu1, mu1_2);   
        gpu::multiply(mu2, mu2, mu2_2);   
        gpu::multiply(mu1, mu2, mu1_mu2);   

        gpu::GpuMat sigma1_2, sigma2_2, sigma12; 

        gpu::GaussianBlur(I1_2, sigma1_2, Size(11, 11), 1.5);
        sigma1_2 -= mu1_2;

        gpu::GaussianBlur(I2_2, sigma2_2, Size(11, 11), 1.5);
        sigma2_2 -= mu2_2;

        gpu::GaussianBlur(I1_I2, sigma12, Size(11, 11), 1.5);
        sigma12 -= mu1_mu2;

        ///////////////////////////////// FORMULA ////////////////////////////////
        gpu::GpuMat t1, t2, t3; 

        t1 = 2 * mu1_mu2 + C1; 
        t2 = 2 * sigma12 + C2; 
        gpu::multiply(t1, t2, t3);     // t3 = ((2*mu1_mu2 + C1).*(2*sigma12 + C2))

        t1 = mu1_2 + mu2_2 + C1; 
        t2 = sigma1_2 + sigma2_2 + C2;     
        gpu::multiply(t1, t2, t1);     // t1 =((mu1_2 + mu2_2 + C1).*(sigma1_2 + sigma2_2 + C2))

        gpu::GpuMat ssim_map;
        gpu::divide(t3, t1, ssim_map);      // ssim_map =  t3./t1;

        Scalar s = gpu::sum(ssim_map);    
        mssim.val[i] = s.val[0] / (ssim_map.rows * ssim_map.cols);

    }
    return mssim; 
}
struct BufferMSSIM                                     // Optimized GPU versions
{   // Data allocations are very expensive on GPU. Use a buffer to solve: allocate once reuse later.
    gpu::GpuMat gI1, gI2, gs, t1,t2;

    gpu::GpuMat I1_2, I2_2, I1_I2;
    vector<gpu::GpuMat> vI1, vI2;

    gpu::GpuMat mu1, mu2; 
    gpu::GpuMat mu1_2, mu2_2, mu1_mu2; 

    gpu::GpuMat sigma1_2, sigma2_2, sigma12; 
    gpu::GpuMat t3; 

    gpu::GpuMat ssim_map;

    gpu::GpuMat buf;
};
Scalar getMSSIM_GPU_optimized( const Mat& i1, const Mat& i2, BufferMSSIM& b)
{ 
    int cn = i1.channels();

    const float C1 = 6.5025f, C2 = 58.5225f;
    /***************************** INITS **********************************/

    b.gI1.upload(i1);
    b.gI2.upload(i2);

    gpu::Stream stream;

    stream.enqueueConvert(b.gI1, b.t1, CV_32F);
    stream.enqueueConvert(b.gI2, b.t2, CV_32F);      

    gpu::split(b.t1, b.vI1, stream);
    gpu::split(b.t2, b.vI2, stream);
    Scalar mssim;

    for( int i = 0; i < b.gI1.channels(); ++i )
    {        
        gpu::multiply(b.vI2[i], b.vI2[i], b.I2_2, stream);        // I2^2
        gpu::multiply(b.vI1[i], b.vI1[i], b.I1_2, stream);        // I1^2
        gpu::multiply(b.vI1[i], b.vI2[i], b.I1_I2, stream);       // I1 * I2

        gpu::GaussianBlur(b.vI1[i], b.mu1, Size(11, 11), 1.5, 0, BORDER_DEFAULT, -1, stream);
        gpu::GaussianBlur(b.vI2[i], b.mu2, Size(11, 11), 1.5, 0, BORDER_DEFAULT, -1, stream);

        gpu::multiply(b.mu1, b.mu1, b.mu1_2, stream);   
        gpu::multiply(b.mu2, b.mu2, b.mu2_2, stream);   
        gpu::multiply(b.mu1, b.mu2, b.mu1_mu2, stream);   

        gpu::GaussianBlur(b.I1_2, b.sigma1_2, Size(11, 11), 1.5, 0, BORDER_DEFAULT, -1, stream);
        gpu::subtract(b.sigma1_2, b.mu1_2, b.sigma1_2, stream);
        //b.sigma1_2 -= b.mu1_2;  - This would result in an extra data transfer operation

        gpu::GaussianBlur(b.I2_2, b.sigma2_2, Size(11, 11), 1.5, 0, BORDER_DEFAULT, -1, stream);
        gpu::subtract(b.sigma2_2, b.mu2_2, b.sigma2_2, stream);
        //b.sigma2_2 -= b.mu2_2;

        gpu::GaussianBlur(b.I1_I2, b.sigma12, Size(11, 11), 1.5, 0, BORDER_DEFAULT, -1, stream);
        gpu::subtract(b.sigma12, b.mu1_mu2, b.sigma12, stream);
        //b.sigma12 -= b.mu1_mu2;

        //here too it would be an extra data transfer due to call of operator*(Scalar, Mat)
        gpu::multiply(b.mu1_mu2, 2, b.t1, stream); //b.t1 = 2 * b.mu1_mu2 + C1; 
        gpu::add(b.t1, C1, b.t1, stream);
        gpu::multiply(b.sigma12, 2, b.t2, stream); //b.t2 = 2 * b.sigma12 + C2; 
        gpu::add(b.t2, C2, b.t2, stream);     

        gpu::multiply(b.t1, b.t2, b.t3, stream);     // t3 = ((2*mu1_mu2 + C1).*(2*sigma12 + C2))

        gpu::add(b.mu1_2, b.mu2_2, b.t1, stream);
        gpu::add(b.t1, C1, b.t1, stream);

        gpu::add(b.sigma1_2, b.sigma2_2, b.t2, stream);
        gpu::add(b.t2, C2, b.t2, stream);


        gpu::multiply(b.t1, b.t2, b.t1, stream);     // t1 =((mu1_2 + mu2_2 + C1).*(sigma1_2 + sigma2_2 + C2))        
        gpu::divide(b.t3, b.t1, b.ssim_map, stream);      // ssim_map =  t3./t1;

        stream.waitForCompletion();

        Scalar s = gpu::sum(b.ssim_map, b.buf);    
        mssim.val[i] = s.val[0] / (b.ssim_map.rows * b.ssim_map.cols);

    }
    return mssim; 
}

如何在GPU上实现相同算法?

你可以总共有三种类型的函数来针对每个操作。其中包括一个CPU和2个GPU函数。之所以做两个GPU函数是为了说明一个常识:简单的移植你的CPU程序到GPU反而会让速度变慢。如果你想要一些性能上的改善,你将需牢记住一些规则,我在后面详细说明。

开发的GPU模块尽可能多的与CPU对应,这样才能方便移植。在你编写任何代码之间要做的第一件事是连GPU模块到你的项目中,包括模块的头文件。所有GPU的函数和数据结构都在以 cv 命名空间的子空间 gpu 内。你可以将其添加为默认 use namespace 关键字, 也可以显示的通过cv::来避免混乱。我在后面会做。

#include <opencv2/gpu/gpu.hpp>        // GPU结构和方法

GPU代表**图形处理单元**。最开始是为渲染各种图形场景而建立,这些场景是基于大量的矢量数据建立的。由于矢量图形的特殊性,数据不需要以串行的方式一步一步执行的,而是并行的方式一次性渲染大量的数据。从GPU的结构上来说,不像CPU基于数个寄存器和高速指令集,GPU一般有数百个较小的处理单元。这些处理单元每一个都比CPU的核心慢很多很多。然而,它的力量在于它的数目众多,能够同时进行大量运算。在过去的几年中已经有越来越多的人常识用GPU来进行图形渲染以外的工作。这就产生了GPGPU(general-purpose computation on graphics processing units)的概念。

图像处理器有它自己的内存,一般称呼为显存。当你从硬盘驱动器读数据并产生一个 Mat 对象的时候,数据是放在普通内存当中的(由CPU掌管)CPU可以直接操作内存, 然而GPU不能这样,对于电脑来说GPU只是个外设,它只能操作它自己的显存,当计算时,需要先让CPU将用于计算的信息转移到GPU掌管的显存上。 这是通过一个上传过程完成,需要比访问内存多很多的时间。而且最终的计算结果将要回传到你的系统内存处理器才能和其他代码交互,由于传输的代价高昂,所以注定移植太小的函数到GPU上并不会提高效率。

Mat对象仅仅存储在内存或者CPU缓存中。为了得到一个GPU能直接访问的opencv 矩阵你必须使用GPU对象 GpuMat 。它的工作方式类似于2维 Mat,唯一的限制是你不能直接引用GPU函数。(因为它们本质上是完全不同的代码,不能混合引用)。要传输*Mat*对象到*GPU*上并创建GpuMat时需要调用上传函数,在回传时,可以使用简单的重载赋值操作符或者调用下载函数。

Mat I1;         // 内存对象,可以用imread来创建
gpu::GpuMat gI; // GPU 矩阵 - 现在为空
gI1.upload(I1); //将内存数据上传到显存中

I1 = gI1;       //回传, gI1.download(I1) 也可以

记住:一旦你的数据被传到GPU显存中,你就只能调用GPU函数来操作,大部分gpu函数名字与原来CPU名字保持一致,不同的是他们只接收 GpuMat 输入。可以在: 在线文档 中找到一些说明,或者查阅OpenCV参考手册。

另外要记住的是:并非所有的图像类型都可以用于GPU算法中。很多时候,GPU函数只能接收单通道或者4通道的uchar或者float类型(CV_8UC1,CV_8UC4,CV_32FC1,CV_32FC4),GPU函数不支持双精度。如果你试图向这些函数传入非指定类型的数据时,这些函数会抛出异常或者输出错误信息。这些函数的文档里大都说明了接收的数据类型。如果函数只接收4通道或者单通道的图像而你的数据类型刚好是3通道的话,你能做的就是两件事:1.增加一个新的通道(使用char类型)或 2.将图像的三个通道切分为三个独立的图像,为每个图像调用该函数。不推荐使用第一个方法,因为有些浪费内存。

对于不需要在意元素的坐标位置的某些函数,快速解决办法就是将它直接当作一个单通道图像处理。这种方法适用于PSNR中需要调用的absdiff方法。然而,对于GaussianBlur就不能这么用,对于SSIM,就需要使用分离通道的方法。知道这些知识就已经可以让你的GPU开始运行代码。但是你也可能发现GPU“加速”后的代码仍然可能会低于你的CPU执行速度。

优化

因为大量的时间都耗费在传输数据到显存这样的步骤了,大量的计算时间被消耗在内存分配和传输上,GPU的高计算能力根本发挥不出来,我们可以利用 gpu::Stream 来进行异步传输,从而节约一些时间.

#. GPU上的显存分配代价是相当大的。因此如果可能的话,应该尽可能少的分配新的内存。如果你需要创建一个调用多次的函数,就应该在一开始分配全部的内存,而且仅仅分配一次。在第一次调用的时候可以创建一个结构体包含所有将使用局部变量。 对于PSNR例子:

struct BufferPSNR                                     // 优化版本
  {   // 基于GPU的数据分配代价是高昂的。所以使用一个缓冲区来改善这点:分配一次,以后重用。
  gpu::GpuMat gI1, gI2, gs, t1,t2;

  gpu::GpuMat buf;
};

在主程序中创建一个实例:
BufferPSNR bufferPSNR;

最后每次调用函数时都使用这个结构:
double getPSNR_GPU_optimized(const Mat& I1, const Mat& I2, BufferPSNR& b)

当你传入的参数为 b.gI1 , b.buf 等,除非类型发生变化,GpuMat都将直接使用原来的内容而不重新分配。

  1. 在GPU中,任何小的数据传输都是一次大的开销,所以应该尽量避免不必要的数据传输。因此应该尽可能的在现有对象上计算(换句话说,不创造新的显存对象,上面已经解释过)。例如,虽然使用算术表达式会让一个公式更浅显易懂但是在GPU代码里,这个步骤是很缓慢的。例如在SSIM例子中需要计算:

    b.t1 = 2 * b.mu1_mu2 + C1;
    

    在这个表达式的调用过程中必然会有一个隐性内存分配过程,调用乘法的结果必然要用一个临时对象储存,然后才能和*C1*相加。如果直接用表达式写GPU代码,就必然会创建一个临时变量来储存乘积的矩阵,然后加上 C1 值并储存在 t1 。为了避免这种额外的开销,我们应该使用GPU处理函数代替算术表达式,从而避免额外创建不必要的临时对象:

    gpu::multiply(b.mu1_mu2, 2, b.t1); //b.t1 = 2 * b.mu1_mu2 + C1;
    gpu::add(b.t1, C1, b.t1);
    
  2. 使用异步调用 (the gpu::Stream)。默认情况下,无论何时你调用一个GPU函数,系统都将等待调用完成并返回后继结果,这就意味着在GPU计算的时候CPU没干活。如果使异步调用,就会意味着它将调用后立即返回,并在后台异步执行,也就是说虽然CPU需要的数据还没从GPU返回,但是并不妨碍CPU进行其它的计算或者调用另一个函数。这是一个小的MSSIM可优化点。在我们的默认实现中,我们将图像分裂成多个通道,然后为每个通道调用GPU函数。这本来应该是一个平行操作,我们可以建立一个流,通过使用流我们可以异步进行数据分配、传输等各种操作,并且给GPU执行操作方法以便GPU在合适的时候自动调用。例如我们需要传输两幅图像。我们依次调用现成的函数处理它们。该函数还是要等到上传数据完毕才会开始工作,但是在执行第二个函数的时候,原来的输出缓存将直接作为第二个操作的输出参数而不需要传输过程。

    gpu::Stream stream;
    
    stream.enqueueConvert(b.gI1, b.t1, CV_32F);    // 上传
    
    gpu::split(b.t1, b.vI1, stream);              // 分离方法(在最后的参数里传递stream).
    gpu::multiply(b.vI1[i], b.vI1[i], b.I1_2, stream);        // I1^2
    

结果和结论

以下是一个Intel P8700的笔记本CPU,与一个低端的Nvidia GT220M的性能比较,结果如下:

Time of PSNR CPU (averaged for 10 runs): 41.4122 milliseconds. With result of: 19.2506
Time of PSNR GPU (averaged for 10 runs): 158.977 milliseconds. With result of: 19.2506
Initial call GPU optimized:              31.3418 milliseconds. With result of: 19.2506
Time of PSNR GPU OPTIMIZED ( / 10 runs): 24.8171 milliseconds. With result of: 19.2506

Time of MSSIM CPU (averaged for 10 runs): 484.343 milliseconds. With result of B0.890964 G0.903845 R0.936934
Time of MSSIM GPU (averaged for 10 runs): 745.105 milliseconds. With result of B0.89922 G0.909051 R0.968223
Time of MSSIM GPU Initial Call            357.746 milliseconds. With result of B0.890964 G0.903845 R0.936934
Time of MSSIM GPU OPTIMIZED ( / 10 runs): 203.091 milliseconds. With result of B0.890964 G0.903845 R0.936934

在这个案例中,使用GPU加速比单纯使用CPU提高了近100%的性能。这对构建高速实时系统甚有帮助。你可以在并不存在于天朝人民概念中的`YouTube 上看到实例视频 `<https://www.youtube.com/watch?v=3_ESXmFlnvY>`_.

翻译者

azuredsky@ OpenCV中文网站 <chfeilong@126.com>

重新翻译

nahcooo@ OpenCV中文网站 <nahcooo@gmail.com>