首页
/ OpenCV CUDA 模块实战:在 GPU 上实现 PSNR / SSIM 图像相似度检测与性能优化

OpenCV CUDA 模块实战:在 GPU 上实现 PSNR / SSIM 图像相似度检测与性能优化

2026-09-06 18:37:25作者:薛曦旖Francesca

本教程源自 OpenCV 官方 GPU 入门系列教程(gpu_basics_similarity.markdown),以"图像相似度检测(PSNR 与 MSSIM)"为用例,系统讲解如何把一段纯 CPU 的 OpenCV 算法移植到 CUDA GPU 上运行,并总结出"预分配显存、避免隐式数据传输、引入异步流(Stream)"三条性能优化铁律。读完本文,你将掌握 cv::cuda::GpuMat 的上传/下载模型、面向 GPU 的通道与数据类型约束,以及一个可以直接复制运行的 CPU / GPU / GPU 优化三版对比基准程序(完整源码见 gpu-basics-similarity.cpp)。

1. 背景与目标:为什么要把 PSNR / SSIM 搬到 GPU

在 OpenCV 的另一篇配套教程 Video Input with OpenCV and similarity measurement(video-input-psnr-ssim) 中,作者给出了用纯 CPU 实现的两类图像相似度指标:

  • PSNR(峰值信噪比):返回一个 float,两张输入越相似,数值通常落在 30~50 之间(越高越好)。
  • SSIM / MSSIM(结构相似性):返回每个通道一个的 0~1 之间的浮点数(越高越好),因此 CUDA 版本返回值需要用一个 Scalar 结构装载(B、G、R 各通道一个值)。

实际测量会发现,纯 CPU 的 SSIM 计算相当耗时——它内部要执行多次高斯模糊、平方、乘加与均值归约。如果 CPU 版 OpenCV 的性能不能满足需求,而机器上恰好有一块 NVIDIA CUDA GPU,就可以把这些算法移植到显卡上执行。

本文对应 GPU 教程的总目标是三件事:

  1. 理解 GPU 与 CPU 编程的差异(内存模型、数据类型限制);
  2. 为 PSNR 和 MSSIM 编写 CUDA 版本代码
  3. 按工程方法优化代码,追求最大性能(对应文档的 "Optimization" 章节)。

说明:该文档整体被 @cond CUDA_MODULES ... @endcond 包裹(见 table_of_content_gpu.markdown),意味着它只在启用 CUDA 相关模块构建的 OpenCV 文档中呈现;文档头部还带有 @todo update this tutorial 的待更新标记——正文部分措辞仍保留早期 gpu 模块(cv::gpuopencv2/gpu.hpp)的旧风格,而仓库中随附的 示例源码 已全部采用现代 CUDA 模块接口(cv::cuda 命名空间、opencv2/cudaarithm.hppopencv2/cudafilters.hpp)。本文以仓库示例源码为准进行讲解。

2. 前置知识:GPU 内存模型与 "移植不一定更快"

2.1 为什么 GPU 值得用:GPGPU 的并行本质

GPU 最初为渲染图形而生,图形场景天然包含海量互不依赖的数据,非常适合并行处理。因此 GPU 内部集成了大量相对精简的处理单元——单核一对一比较会输给 CPU,但其强项在于"数量"。近年来把 GPU 的巨量并行能力用于非图形计算(通用计算 GPGPU)已成为主流趋势。

2.2 关键差异:显存与系统内存是两套空间

GPU 拥有独立显存。当用 imread 把图像读进 Mat 时,数据位于系统内存;CPU 通过缓存可以直接操作它,但 GPU 不能——它必须先把数据从系统内存 upload(上传) 到显存,算完后还要把结果 download(下载) 回系统内存供 CPU 使用。上传/下载本身是耗时操作,因此:

把很小的函数移植到 GPU 通常得不偿失——上传/下载的开销会大于并行执行省下的时间。

2.3 GpuMat:GPU 侧的矩阵容器

Mat 只存在于系统内存(或 CPU 缓存)。要得到一张 GPU 上的 OpenCV 矩阵,需要使用其 GPU 对应物 cv::cuda::GpuMat。它用法与 Mat 相似,但有两点限制:

  • 只支持 2D 图像;
  • 其函数不支持返回引用(不能把 GPU 引用与 CPU 引用混用)。

MatGpuMat 之间的搬运接口(文档原文片段):

Mat I1;         // Main memory item - read image into with imread for example
cuda::GpuMat gI1; // 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

数据进入显存后,就可以调用 OpenCV 的 CUDA 函数。绝大多数 CUDA 函数与 CPU 版同名,区别在于它们只接受 GpuMat 输入。注意这里 upload / download / convertTo 等操作在源码中都有实际调用,例如 gpu-basics-similarity.cpp 的 getPSNR_CUDA 实现 第一件事就是 gI1.upload(I1)

2.4 通道数与数据类型的硬性约束

并非所有通道组合都能在 GPU 上高效实现。教程作者基于实践给出的经验准则是:

  • 输入图像通道数最好为 1 或 4
  • 元素类型最好为 uchar(8 位)或 float(32 位)
  • GPU 不支持 double
  • 向某些函数传入不支持的类型会抛出异常并在错误输出中给出信息(OpenCV 文档通常会在各函数处注明可接受的输入类型)。

当输入是 3 通道图像时有两个选择:

  1. 额外补一个通道(仍用 uchar 元素)——教程作者明确不推荐,因为浪费显存;
  2. 拆分通道,把每个单通道图分别送入函数。

另外还有一个技巧:对于不依赖邻域像素位置的函数(如 PSNR 用到的 absdiff),可以把它 reshape 成单通道再计算;而像 GaussianBlur 这类依赖邻域像素的算子则不能 reshape,必须走 split 分通道路线(这正是下面 SSIM 移植的策略)。示例源码正是这样做的——PSNR 版用 t1.reshape(1) 把多通道压成单通道后执行 cuda::absdiff,见 getPSNR_CUDA

3. 三套实现:CPU 基线版、GPU 直译版、GPU 优化版

文档把每个指标都写成三类函数:一个 CPU 版、两个 GPU 版。之所以准备两个 GPU 版本,是为了演示一个重要事实:把 CPU 代码简单"直译"到 GPU,常常反而更慢;想拿到真正的性能收益,必须遵守下文第 4 节的三条规则。

示例程序 gpu-basics-similarity.cpp 对应的六个函数分别为:getPSNRgetPSNR_CUDAgetPSNR_CUDA_optimized(三个 PSNR)与 getMSSIMgetMSSIM_CUDAgetMSSIM_CUDA_optimized(三个 MSSIM)。程序头部的帮助信息给出完整用法(见 help() 函数):

This program shows how to port your CPU code to CUDA or write that from scratch.
You can see the performance improvement for the similarity check methods (PSNR and SSIM).
Usage:
./gpu-basics-similarity referenceImage comparedImage numberOfTimesToRunTest(like 10).

即:./gpu-basics-similarity 参考图 对比图 测试次数(如 10)。主函数会用 getTickCount() 计时、对 CPU / CUDA / CUDA-optimized 三种实现各跑 TIMES(默认 10,可用第三参数覆盖)次并输出平均毫秒数与结果,见 main 的基准测试流程

3.1 PSNR 的 CPU 基线实现

CPU 版 PSNR 公式为:把两图逐像素差平方后求和得到 SSE,再换算 MSE 与 PSNR。完整实现见 getPSNR(CPU)

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

    double  mse = sse /(double)(I1.channels() * I1.total());

    // For very small SSE, add epsilon to approximate infinite PSNR (~361 dB)
    if( sse <= 1e-10) mse+= DBL_EPSILON;

    double psnr = 10.0*log10((255*255)/mse);
    return psnr;
}

几个实现细节值得注意:8 位图像不能直接平方,需先 convertTo(CV_32F);若两图完全相同,SSE 趋近 0,此时加 DBL_EPSILON 以近似"无限大 PSNR(约 361 dB)",避免除零。

3.2 PSNR 的"直译"GPU 版

getPSNR_CUDA 逐行对应 CPU 逻辑:upload → 转 CV_32Fcuda::absdiffcuda::multiply 求平方 → cuda::sum 归约:

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

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

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

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

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

    double  mse = sse /(double)(gI1.channels() * I1.total());

    // For very small SSE, add epsilon to approximate infinite PSNR (~361 dB)
    if( sse <= 1e-10) mse+= DBL_EPSILON;

    double psnr = 10.0*log10((255*255)/mse);
    return psnr;
}

对比可见:GPU 版刻意用 .reshape(1) 压成单通道,因为 absdiff 逐元素运算不关心邻域。但注意每次调用它都在函数体内新建 GpuMat,这埋下了"慢"的伏笔。

3.3 MSSIM 的 CPU 基线实现

SSIM 指标按公式逐像素计算结构相似度图(ssim_map)再取均值。CPU 版用 11×11、σ=1.5 的高斯模糊实现局部均值/方差估计,完整实现见 getMSSIM(CPU)

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;
}

其中常数 C1 = 6.5025 = (K1·L)²(K1=0.01,L=255)、C2 = 58.5225 = (K2·L)²(K2=0.03)是 SSIM 公式的标准稳定项;整张 ssim_map 的逐通道均值即为最终的 MSSIM Scalar

3.4 MSSIM 的 GPU 直译版与优化版

GPU 版 MSSIM 因高斯模糊依赖邻域、不能 reshape,只能分通道处理:先用 cuda::split 把图像拆成单通道 vector<cuda::GpuMat>,再用 cuda::createGaussianFilter(vI2[0].type(), -1, Size(11, 11), 1.5) 一次性创建高斯滤波器(每个通道复用同一个 Ptr<cuda::Filter>)。直译版完整代码见 getMSSIM_CUDA,每个通道内部依次执行 cuda::multiplygauss->applycuda::subtract 及乘加/除法,最后对 ssim_mapcuda::sum 并除以像素总数得到该通道 MSSIM。

需要强调,该直译版在循环体内每通道都 new 出一批 cuda::GpuMatI2_2, I1_2, I1_I2, mu1, mu2, ...),每次迭代都伴随昂贵的显存分配,这正是它慢的根本原因。优化版则在"缓冲区结构体"中一次性声明全部临时变量(见 BufferMSSIM 结构体):

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

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

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

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

    cuda::GpuMat ssim_map;

    cuda::GpuMat buf;
};

优化版实现 getMSSIM_CUDA_optimized 中,每个运算都以 b.xxx 复用缓冲区,并把 cuda::Stream 作为末位参数贯穿所有调用,代码注释还逐条标出了"如果写成 b.sigma1_2 -= b.mu1_22 * b.mu1_mu2 + C1 会引入额外数据传输"的陷阱。

4. 三条 GPU 优化铁律(文档核心方法论)

文档 "Optimization" 一节把性能提升归纳为三条,仓库示例源码一一对应落实:

规则 1:尽量少分配显存——预分配、反复复用

GPU 上的内存分配成本极高。如果写一个会被多次调用的函数,最好只在第一次调用时分配一次局部参数,之后复用。做法是定义一个包含全部局部变量的数据结构(缓冲区),例如 PSNR 的 BufferPSNR

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

    cuda::GpuMat buf;
};

在主程序中只创建一个实例(见 main 中的 bufferPSNR),每次调用都把引用传入函数:double getPSNR_CUDA_optimized(const Mat& I1, const Mat& I2, BufferPSNR& b),函数内以 b.gI1b.buf 方式访问。GpuMat 只会在新矩阵尺寸与旧矩阵不同时才重新分配内存,因此同尺寸重复调用几乎零分配开销。

规则 2:避免不必要的函数间数据传输——多用 CUDA 函数做原位运算

任何一次微小的数据传输放到 GPU 上都会被放大。因此尽可能**in-place(原位)**计算,不要为中间结果创建新对象。例如 SSIM 里有一处需要计算 t1 = 2 * mu1_mu2 + C1,若照抄算术运算符写法:

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

表面上看能通过编译,但背后藏着隐式传输:做加法前系统必须先找一个临时矩阵存放乘法的结果。正确做法是拆成两个显式的 CUDA 函数调用(对应 getMSSIM_CUDA_optimized 中的写法):

cuda::multiply(b.mu1_mu2, 2, b.t1, 1, -1, stream); //b.t1 = 2 * b.mu1_mu2 + C1;
cuda::add(b.t1, C1, b.t1, cuda::GpuMat(), -1, stream);

同理,b.sigma1_2 -= b.mu1_2; 这种重载运算符也应替换为 cuda::subtract(b.sigma1_2, b.mu1_2, b.sigma1_2, ...)(源码注释里明确写着"-= 写法会多一次数据传输",见 第 404-414 行注释)。

规则 3:用 cv::cuda::Stream 引入异步调用

默认情况下,调用 GPU 函数会同步等待该调用结束并返回结果。而 Stream 可以把执行变成异步:发起运算后函数立刻返回,算法可以在后台继续分配数据,主线程可继续排队后续工作。

在 MSSIM 场景中收益点是:程序把图像 split 成多通道、逐通道调用 GPU 函数,存在天然的流水并行机会。通过流,可以把"上传两幅图、执行函数"排队串联——后一个函数等待上传完成后,GPU 空闲时已在为下一个函数做输出缓冲分配。典型用法(文档原代码片段,对应优化版中的实际调用,见 getMSSIM_CUDA_optimized):

cuda::Stream stream;

stream.enqueueConvert(b.gI1, b.t1, CV_32F);    // Upload

cuda::split(b.t1, b.vI1, stream);              // Methods (pass the stream as final parameter).
cuda::multiply(b.vI1[i], b.vI1[i], b.I1_2, 1, -1, stream);        // I1^2

Stream 类型的对象由示例在函数内局部创建(cuda::Stream stream;),并在每个需要时机的环节(如 gauss->apply(..., stream)cuda::divide(..., stream))作为末位参数传入;对最终归约前需要同步结果的点调用 stream.waitForCompletion()(见 getMSSIM_CUDA_optimized 第 434 行)。

5. 编译与运行前提

  • 需要一个 已启用 CUDA 支持 的 OpenCV 构建,且包含提供 cv::cuda 算子的模块(示例依赖 opencv2/cudaarithm.hppopencv2/cudafilters.hpp 头文件对应的模块);本文对应的文档内容本身也被 CUDA 模块开关(@cond CUDA_MODULES)控制展示。没有 NVIDIA CUDA GPU 或未启用 CUDA 的 OpenCV 无法运行本示例。
  • 程序是命令行控制台程序,编译后在终端执行:
./gpu-basics-similarity referenceImage comparedImage numberOfTimesToRunTest

例如 ./gpu-basics-similarity Megamind.avi_frame1.png Megamind_bugy.avi_frame1.png 10。它会分别输出 PSNR CPU / PSNR CUDA / PSNR CUDA Optimized(含首次调用)与 MSSIM CPU / MSSIM CUDA / MSSIM CUDA Optimized(含首次调用)六组计时与结果。

6. 实测结果与结论

文档作者在 Intel P8700 笔记本 CPU + 入门级 NVIDIA GT220M(一套较早期硬件)上测得如下数据(10 次平均):

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

从中可以得出几个对该教程而言至关重要的结论:

  1. "直译"版比 CPU 更慢:PSNR 从 CPU 的 41.4ms 涨到 GPU 直译的 159.0ms,MSSIM 从 484.3ms 涨到 745.1ms。原因正是反复显存分配与每次调用的上传/下载开销,印证了第 2.2 节的警告。
  2. 首次调用额外昂贵:优化版首次调用(含分配与上传)PSNR 约 31.3ms、MSSIM 约 357.7ms,明显高于之后的稳态耗时——这验证了"预分配一次、此后复用"的价值。
  3. 优化后显著提速:稳定阶段 PSNR 优化版降至 24.8ms(对比 CPU 41.4ms),MSSIM 优化版降至 203.1ms(对比 CPU 484.3ms)。教程作者据此给出结论:两种指标都相对 CPU 实现获得了接近翻倍的性能提升——对于受相似度计算拖慢的应用,这可能正是它能否达到实时要求的关键。

需要说明的是:上述数字是原教程在文档所述硬件年代实测并记录的原始参考值,其结果同时也验证了 CPU 版与 GPU 优化版计算出的 PSNR/MSSIM 数值一致(如 MSSIM 稳态均为 B0.890964 G0.903845 R0.936934),可作为正确性对照。不同 GPU 与驱动、不同图像尺寸下的绝对耗时会有差异,建议以示例程序在本机自测为准。

7. 继续深入

总结:把一个算法搬到 GPU 上,"会写 CUDA 代码"只是第一步。真正决定收益的是遵循本文的三条工程铁律——预分配并复用显存缓冲区、用显式 CUDA 函数避免隐式中间传输、以 Stream 让分配与执行异步化。结合文档提供的 CPU / GPU / GPU 优化三版同源对照与基准计时流程,任何人都能像教程一样,把自己的逐帧图像处理环节从"移植后更慢"一步步优化到"接近翻倍提速"。

登录后查看全文
热门项目推荐
相关项目推荐