Skip to content

GPU 上的相似性检查(PSNR 与 SSIM)

在 tutorial_video_input_psnr_ssim 教程中,我已经介绍了用于检查两幅图像之间相似性的 PSNR 和 SSIM 方法。正如你所见,执行过程相当耗时,尤其是在 SSIM 的情况下。然而,如果 OpenCV 的 CPU 实现性能不能满足你,而你的系统中恰好有一块 NVIDIA CUDA GPU 设备,那一切还不算晚。你可以尝试为显卡移植或编写你自己的算法。

本教程将让你很好地了解如何使用 OpenCV 的 GPU 模块来进行编程。作为前置条件,你应当已经知道如何使用 core、highgui 和 imgproc 模块。因此,我们的主要目标是:

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

你也可以在 OpenCV 源码库的 samples/cpp/tutorial_code/gpu/gpu-basics-similarity/gpu-basics-similarity 目录中找到源代码和视频文件,或从此处下载。完整的源代码相当长(因为要通过命令行参数来控制应用程序并进行性能测量)。因此,为了避免这些内容使本节显得杂乱,这里只给出函数本身。

PSNR 返回一个浮点数,如果两个输入相似,则该值在 30 到 50 之间(越高越好)。

SSIM 返回图像的 MSSIM。它同样是一个介于 0 到 1 之间的浮点数(越高越好),不过每个通道都有一个。因此,我们返回一个 Scalar 类型的 OpenCV 数据结构:

如上所示,每种操作我们有三类函数。一个用于 CPU,两个用于 GPU。我为 GPU 制作两个函数的原因是要说明:把 CPU 代码简单地移植到 GPU 往往实际上会使其变慢。如果你想获得一些性能提升,就需要记住几条规则,我稍后会详细讲解。

GPU 模块的开发使其尽可能地与对应的 CPU 部分相似。这让移植过程更加容易。在编写任何代码之前,你需要做的第一件事是将 GPU 模块链接到你的项目中,并包含该模块的头文件。GPU 的所有函数和数据结构都位于 cv 命名空间下的 gpu 子命名空间中。你可以通过 use namespace 关键字将其加入默认命名空间,也可以在各处显式地用 cv:: 来标记以避免混淆。我选择后者。

# include <opencv2/gpu.hpp> // GPU structures and methods

GPU 是”图形处理器”(graphics processing unit)的缩写。它最初是为渲染图形场景而构建的。这些场景在某种程度上建立在大量数据之上。然而,这些数据并非都以串行方式相互依赖,因此可以对它们进行并行处理。正因如此,GPU 包含多个更小的处理单元。这些并不是最先进的处理器,在与 CPU 的一对一测试中它会落后。然而,它的优势在于数量。近年来,把 GPU 这种大规模并行能力用于非图形场景(乃至渲染本身)的趋势日益增长。这催生了图形处理器上的通用计算(GPGPU)。

GPU 有自己的内存。当你用 OpenCV 从硬盘读取数据到一个 Mat 对象时,这发生在你的系统内存中。CPU 以某种方式直接在其上工作(通过它的缓存),但 GPU 不能。它必须把计算所需的信息从系统内存传输到自己的内存中。这是通过上传(upload)过程完成的,非常耗时。最后,结果必须下载回你的系统内存,CPU 才能看到并使用它。不建议将小函数移植到 GPU,因为上传/下载的时间将超过并行执行所带来的收益。

Mat 对象只存储在系统内存(或 CPU 缓存)中。要将一个 OpenCV 矩阵放到 GPU 上,你需要使用其 GPU 对应物 cv::cuda::GpuMat。它的工作方式与 Mat 类似,但仅限于二维,且其函数不返回引用(不能将 GPU 引用与 CPU 引用混用)。要将一个 Mat 对象上传到 GPU,你需要在创建该类的实例后调用 upload 函数。要下载,你可以简单地赋值给一个 Mat 对象,或使用 download 函数。

Mat I1; // Main memory item - read image into with imread for example
gpu::GpuMat gI; // GPU matrix - for now empty
gI1.upload(I1); // Upload a data from the system memory to the GPU memory
I1 = gI1; // Download, gI1.download(I1) will work too

一旦你的数据进入了 GPU 内存,你就可以调用 OpenCV 中启用 GPU 的函数。大多数函数保持与 CPU 上相同的名字,区别在于它们只接受 GpuMat 输入。

另一件需要记住的事情是,并非对所有通道数都能在 GPU 上做出高效的算法。一般而言,我发现 GPU 图像的输入图像需要是单通道或四通道的,并且元素类型为 char 或 float 之一。抱歉,GPU 不支持 double。对某些函数传入其他类型的对象会抛出异常,并在错误输出中给出错误消息。文档在大多数地方详细说明了输入所接受的类型。如果你的输入是三通道图像,你可以做两件事:要么添加一个新通道(并使用 char 元素),要么拆分图像并对每个图像分别调用该函数。前者并不真正推荐,因为这会浪费内存。

对于某些函数,元素的位置(邻接项)无关紧要,快速解决方法是将其重塑为单通道图像。PSNR 实现就是这种情况,对于 absdiff 方法,邻接项的值并不重要。然而,对于 GaussianBlur 这不是一个选项,因此 SSIM 需要使用 split 方法。有了这些知识,你就可以编写可在 GPU 上运行的代码(比如我的 GPU 版本)并运行它。你会惊讶地发现,它可能比你的 CPU 实现还要慢。

原因在于你把内存分配和数据传输的代价都抛到了脑后。而在 GPU 上,这些代价非常高。另一种优化可能性是借助 cv::cuda::Stream 引入异步的 OpenCV GPU 调用。

  1. 在 GPU 上内存分配的开销相当大。因此,如果可能,应尽可能少地分配新内存。如果你创建了一个打算多次调用的函数,最好只在第一次调用时为该函数的所有局部参数分配一次内存。为此,你创建一个数据结构,包含你将使用的所有局部变量。例如,对于 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;
};
然后在主程序中创建它的一个实例:
BufferPSNR bufferPSNR;
最后,每次调用函数时都把它传给函数:
double getPSNR_GPU_optimized(const Mat& I1, const Mat& I2, BufferPSNR& b)
现在你可以这样访问这些局部参数:*b.gI1*、*b.buf* 等等。只有当新矩阵的尺寸与前一个不同时,GpuMat 才会在新的调用中重新分配自身。
  1. 避免不必要的函数内数据传输。一旦你用到 GPU,任何小的数据传输都会很显著。因此,如果可能,应就地(in-place)完成所有计算(换言之,不要创建新的内存对象——原因见上一点)。例如,尽管用单行公式来表达算术运算可能更容易,但会更慢。在 SSIM 中,某处我需要计算:
b.t1 = 2 *b.mu1_mu2 + C1;
尽管上面的调用会成功,但请注意其中存在一个隐藏的数据传输。在执行加法之前,它需要把乘法结果存到某个地方。因此,它会在后台创建一个局部矩阵,将 *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);
  1. 使用异步调用(cv::cuda::Stream)。默认情况下,每当你调用一个 GPU 函数时,它都会等待调用完成并随后返回结果。然而,可以进行异步调用,这意味着它会请求执行操作、为算法进行耗时的数据分配,然后立即返回。现在你可以根据需要调用另一个函数。对于 MSSIM,这是一个小的优化点。在我们的默认实现中,我们将图像拆分为多个通道,并对每个通道调用 GPU 函数。通过 stream 可以实现一定程度的小并行化。使用 stream,我们可以在 GPU 已经在执行某个方法的同时进行数据分配和上传操作。例如,我们需要上传两幅图像。我们将它们依次排队,并调用处理它的函数。这些函数会等待上传完成,但在等待的同时,它会为接下来要执行的函数进行输出缓冲区的分配。
gpu::Stream stream;
stream.enqueueConvert(b.gI1, b.t1, CV_32F); // Upload
gpu::split(b.t1, b.vI1, stream); // Methods (pass the stream as final parameter).
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

在两种情况下,与 CPU 实现相比,我们都实现了接近 100% 的性能提升。这可能正是让你的应用正常运行所需要的改进。你可以在此处的 YouTube上观看一个运行实例。

视频演示