在学习 OpenCV 的过程中,很多人都会接触到 cv::Mat 的逐像素访问,而最常见的三种写法,就是 at<>ptr<> 和直接使用 data 指针。表面上看,这三种方式都能拿到像素并完成修改,但它们在底层访问路径、边界抽象层级以及编译器优化空间上并不完全一样。因此,一个很自然的问题就是:在真实代码中,这三种方式到底谁更快,差距又有多大?

这次我专门写了一个基于 Google Benchmark 的小测试,对 cv::Mat 的三种典型访问方式进行了对比。测试目标非常明确,就是让三种方式做完全相同的工作:遍历整张彩色图像,把每个像素的三个通道都做一次按位异或翻转,然后比较总耗时。最终得到的结果如下:

------------------------------------------------------
Benchmark            Time             CPU   Iterations
------------------------------------------------------
BM_Mat_at       320821 ns       306089 ns         2225
BM_Mat_ptr      200717 ns       191501 ns         3692
BM_Mat_data     209373 ns       199763 ns         3451

从这组结果可以先得到一个非常直观的结论:在这次测试中,ptr 最快,data 紧随其后,而 at 明显最慢。也就是说,如果你在写高频逐像素处理逻辑,那么 at<> 虽然写起来最直观,但性能并不是最优;而 ptr<> 和 data 更接近底层内存访问,效率会更高一些。

一、测试代码长什么样

这次的三组 benchmark 代码如下。

void BM_Mat_at(benchmark::State &state)
{
    Mat img = imread("img.png", IMREAD_COLOR);
    for (auto _ : state)
    {
        for (int i = 0; i < img.rows; i++)
        {
            for (int j = 0; j < img.cols; j++)
            {
                auto &pixel = img.at<Vec3b>(i, j);
                pixel[0] ^= 0xFF;
                pixel[1] ^= 0xFF;
                pixel[2] ^= 0xFF;
            }
        }
        benchmark::DoNotOptimize(img.data);
        benchmark::ClobberMemory();
    }
}

void BM_Mat_ptr(benchmark::State &state)
{
    Mat img = imread("img.png", IMREAD_COLOR);
    for (auto _ : state)
    {
        for (int i = 0; i < img.rows; i++)
        {
            auto *ptr = img.ptr<Vec3b>(i);
            for (int j = 0; j < img.cols; j++)
            {
                auto &pixel = ptr[j];
                pixel[0] ^= 0xFF;
                pixel[1] ^= 0xFF;
                pixel[2] ^= 0xFF;
                benchmark::DoNotOptimize(pixel);
            }
        }
        benchmark::DoNotOptimize(img.data);
    }
}

void BM_Mat_data(benchmark::State &state)
{
    Mat img = imread("img.png", IMREAD_COLOR);
    for (auto _ : state)
    {
        for (int i = 0; i < img.rows; i++)
        {
            uchar *row = img.data + i * img.step;
            for (int j = 0; j < img.cols; j++)
            {
                uchar *pixel = row + j * 3;
                pixel[0] ^= 0xFF;
                pixel[1] ^= 0xFF;
                pixel[2] ^= 0xFF;
            }
        }
        benchmark::DoNotOptimize(img.data);
        benchmark::ClobberMemory();
    }
}

BENCHMARK(BM_Mat_at);
BENCHMARK(BM_Mat_ptr);
BENCHMARK(BM_Mat_data);

虽然代码看起来都差不多,但它们的访问路径其实有明显区别。也正是这种区别,导致了最后的性能差异。

二、BM_Mat_at:写法最直观,但抽象层最高

先看 at<> 版本。

auto &pixel = img.at<Vec3b>(i, j);

这应该是很多人最先学会的一种方式,因为它的语义最清晰:给我第 i 行、第 j 列的像素,然后把它当成 Vec3b 来访问。对于初学者来说,这种写法的可读性非常高,也最不容易写错。你一眼就能看出来,这里访问的是二维图像中的某个彩色像素。

但问题在于,at<> 属于一种更高层、更封装的接口。虽然在 Release 模式下编译器通常会做很多优化,但从访问路径上说,它毕竟不是最直接的裸指针寻址。它需要根据 (i, j) 去定位元素,语义清晰的同时,也意味着它通常不会像行指针或者裸数据指针那样“贴着内存跑”。

这次 benchmark 的结果也印证了这一点。BM_Mat_at 的 CPU 时间大约是 306089 ns,是三者里最慢的一组。这个结果并不让人意外,因为 at<> 的优势从来都不是极限性能,而是代码直观、类型明确、适合先把逻辑写对。

三、BM_Mat_ptr:逐行取指针,是 OpenCV 中很经典的高效写法

再看 ptr<> 版本。

auto *ptr = img.ptr<Vec3b>(i);
auto &pixel = ptr[j];

这种写法的思路是:先拿到第 i 行的首地址,然后在这一行内部通过数组下标访问第 j 个像素。和 at<> 相比,它少了一层“二维坐标到元素”的接口表达,直接退到“行指针 + 列偏移”的访问模型上,所以更贴近底层内存布局。

这也是为什么很多 OpenCV 性能相关代码里,经常能看到 ptr<> 的身影。它保留了不错的可读性,因为你仍然知道这是第几行、这一行上第几个像素;同时,它又比 at<> 更接近底层访问,因此常常能取得更好的性能。

你的 benchmark 里,BM_Mat_ptr 的 CPU 时间大约是 191501 ns,明显快于 BM_Mat_at。这说明在逐像素遍历这类高频场景下,按行取 ptr 再访问像素,确实是一种非常实用的性能优化方式。它不像裸 data 那样需要你手动处理所有字节偏移,但已经足够接近底层,因此通常是“性能和可读性都比较平衡”的方案。

四、BM_Mat_data:最接近底层内存,但写法也最原始

最后看 data 版本。

uchar *row = img.data + i * img.step;
uchar *pixel = row + j * 3;

这里的写法完全是从图像内存布局出发来处理的。先通过 img.data 拿到底层首地址,再通过 i * img.step 定位到第 i 行,然后在这一行中用 j * 3 找到当前像素的起始位置。因为你的图像是三通道 8 位图,所以一个像素就是 3 个字节,于是 pixel[0]、pixel[1]、pixel[2] 分别对应 B、G、R 三个通道。

这种写法的最大优点,就是非常接近裸内存访问,几乎没有额外抽象层。它非常适合你已经完全清楚图像类型、通道布局和步长关系,并且对极限性能有追求的场景。缺点也同样明显,那就是可读性和安全性都更差一些。你必须自己保证图像类型正确,必须自己知道每个像素占多少字节,还必须自己处理 step 和通道数,否则就很容易写错。

从这次 benchmark 的结果来看,BM_Mat_data 的 CPU 时间大约是 199763 ns,和 BM_Mat_ptr 非常接近,但略慢一点。这其实也很好理解。虽然 data 理论上已经非常底层,但你这里仍然是通过字节指针来定位像素,并且手动做 row + j * 3 的偏移,而 ptr(i) 版本则可以让编译器以 Vec3b 为单位来理解访问模式。从工程实践上看,ptr<> 在很多情况下反而更容易写出既高效又清晰的代码。

五、为什么这次是 ptr 最快,而不是 data 最快

很多人第一次看到结果时,可能会产生一个疑问:不是说裸指针最底层吗,为什么这里反而是 ptr 比 data 还略快?这个问题其实非常典型。

关键点在于,ptr<> 并不是真正“高级很多”的接口。它本质上还是给你一段已经按元素类型解释好的行首指针。你这里的 ptr(i),其实已经把“这一行上每个元素是一个 Vec3b 像素”这个信息告诉了编译器。于是编译器在优化时,往往更容易理解访问模式。而 data 版本虽然也很底层,但它操作的是 uchar*,你自己还要手动做字节偏移,这种写法不一定就天然比 ptr 更占优。

换句话说,ptr<> 其实处在一个很好的位置:它既没有 at<> 那么高的抽象层,又没有 data 那么原始和琐碎,因此在许多实际项目里,它往往会成为逐像素处理的首选方案。

六、这次 benchmark 里还有一个小细节:BM_Mat_ptr 里多了一次 DoNotOptimize(pixel)

如果从严格实验设计角度来看,你这三组代码其实还可以再“公平”一点。因为在 BM_Mat_ptr 里,你额外写了这一句:

benchmark::DoNotOptimize(pixel);

而 BM_Mat_at 和 BM_Mat_data 并没有在像素级别上额外调用这一句。虽然整体上这不会推翻结论,但它确实让 BM_Mat_ptr 的测试条件和另外两者略有不同。按道理说,既然 BM_Mat_ptr 在多做一层 DoNotOptimize(pixel) 的情况下依然更快,那它的优势其实更说明问题;但如果你想要做一份更严谨、更适合写进正式笔记或博客的 benchmark,那么最好让三组测试在“工作内容”和“防优化手段”上保持完全一致。

例如更统一的写法应该是:三者都只在每轮结束时调用一次 benchmark::DoNotOptimize(img.data) 和 benchmark::ClobberMemory(),不要只在某一组里给单个像素再额外加屏障。这样测出来的结果会更干净,也更容易解释。

七、如何理解这组 benchmark 的实际意义

看到这里,你其实已经可以得出一个很有价值的工程结论了。那就是:如果你只是写功能验证、算法原型、学习代码,at<> 很适合,因为它最直观、最不容易犯错;但如果你写的是高频逐像素处理,比如图像增强、阈值操作、颜色变换、统计分析,或者工业视觉里那种每帧都要扫整张图的逻辑,那么 ptr<> 通常是更值得优先考虑的方式。它的性能明显优于 at<>,同时代码又不会像手工操作 data 那样太底层、太脆弱。

至于 data,它并不是不能用,恰恰相反,它在一些非常底层、非常讲究内存布局的场景里依然很有价值。但如果只是普通的 OpenCV 工程开发,ptr<> 往往是更平衡的选择。它既快,又不至于让代码写得太“指针体操”。

八、从这次实验还能学到什么

这次 benchmark 除了说明三种访问方式的性能差异,其实还让你进一步理解了 cv::Mat 的底层结构。因为你会发现,不管是 at<>、ptr<> 还是 data,本质上最终都绕不开 Mat 的底层内存:图像是一块按行组织的数据区,rows 和 cols 定义了二维结构,step 决定了一行跨多少字节,类型如 CV_8UC3 决定了每个像素由几个字节组成。所谓不同访问方式,差别更多是在于你站在哪个抽象层去看这块内存。

at<> 是“把它当二维坐标系中的元素来访问”,ptr<> 是“把它当一行一行的像素数组来访问”,而 data 则是“把它当一整块原始字节内存来访问”。理解了这一点,你对 cv::Mat 的认知就会更完整,后面无论是写优化代码,还是分析 OpenCV 算子的底层效率,都会更有感觉。

九、最后的结论

把这次 benchmark 的结论浓缩成一句话,就是:在 cv::Mat 的逐像素遍历中,ptr<> 通常是性能和可读性最平衡的方案,data 也很快但写法更底层,at<> 最直观却相对最慢。如果你追求的是先把功能写清楚,at<> 完全没问题;如果你追求的是实际工程中的遍历效率,那么优先考虑 ptr<> 会更合适。

从你的测试结果来看,三者大致可以理解为:at 是“好写但慢一些”,ptr 是“够快也够清晰”,data 是“很底层但未必比 ptr 更有优势”。这正是很多 OpenCV 实战经验最后会收敛出的结论。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐