OpenCV 图像逐像素遍历实战:指针、迭代器、at() 与 LUT 四种扫描方式解析
【免费下载链接】opencvOpen Source Computer Vision Library项目地址: https://gitcode.com/GitHub_Trending/opencv31/opencv
本文围绕 OpenCV 的图像逐像素扫描技术展开,以仓库中doc/tutorials/core/how_to_scan_images/教程为骨架,讲清楚四个核心问题:如何遍历图像中的每一个像素、cv::Mat矩阵在内存中如何存储、如何用cv::getTickCount()/cv::getTickFrequency()精确测量算法耗时,以及为什么用查找表(LUT)替代逐像素计算能显著提升性能。读完后,你将能熟练使用 C 风格指针访问、迭代器访问、at()随机访问和cv::LUT()四种扫描方式,并能结合仓库示例 how_to_scan_images.cpp 自行编译运行、复现性能对比。
灰度图像在内存中的单通道存储布局(源自 how_to_scan_images.markdown)
问题背景:色彩空间压缩测试用例
教程选择了一个典型应用场景:色彩空间压缩(color space reduction)。使用unsigned char存储的图像每个通道最多有 256 个取值,三通道图像理论上可组合出 1677 万种颜色,过多的色阶会对算法性能造成沉重打击。但很多情况下,用更少的颜色就能得到几乎相同的最终效果。
所谓色彩空间压缩,就是把颜色空间的当前值除以一个新的输入值,从而减少颜色数量:例如 0~9 的所有值取新值 0,10~19 取新值 10,依此类推。
当uchar(取值 0~255 的无符号字符)值除以int值时,结果仍是字符类型,小数部分会被向下取整。利用这一点,上述压缩运算在uchar域内可表达为:
I_new = (I_old / 10) * 10一个朴素的算法只需遍历图像的每个像素并套用该公式。但注意这里包含一次除法和一次乘法——这两类运算对系统而言非常昂贵,应尽量用减法、加法甚至简单赋值等更廉价的运算替代。更重要的是,参与运算的输入值种类有限:对uchar而言正好只有 256 种。
因此,对于大图更明智的做法是:预先计算所有可能的取值,遍历时只做赋值——这正是查找表(lookup table)的价值所在。查找表是一个(或可多维的)简单数组,对给定的输入取值保存最终输出值。它的优势在于不需要现场计算,只需读取结果。
测试程序与命令行用法
仓库中的完整示例代码位于 samples/cpp/tutorial_code/core/how_to_scan_images/how_to_scan_images.cpp。它的功能是:
- 读取命令行传入的图像(彩色或灰度均可);
- 按命令行传入的整数执行色彩压缩;
- 分别用 OpenCV 中三种主要的逐像素遍历方式扫描图像,并打印各自的耗时。
基本用法:
how_to_scan_images imageName.jpg intValueToReduce [G]最后一个参数可选:传入G时以灰度格式加载图像,否则使用 BGR 彩色空间(对应源码中imread(argv[1], IMREAD_GRAYSCALE)与imread(argv[1], IMREAD_COLOR)两个分支)。
第一步是构建查找表。源码(main 函数中的 dividewith 段)先用 C++ 的stringstream类把第三个命令行参数从文本转成整数,再用简单的循环套用上式计算查找表——这一段与 OpenCV 本身无关,是纯 C++ 代码:
//! [dividewith] int divideWith = 0; // convert our input string to number - C++ style stringstream s; s << argv[2]; s >> divideWith; if (!s || !divideWith) { cout << "Invalid number entered for dividing. " << endl; return -1; } uchar table[256]; for (int i = 0; i < 256; ++i) table[i] = (uchar)(divideWith * (i/divideWith)); //! [dividewith]以divideWith = 10为例,table[0..9] = 0、table[10..19] = 10……table[250..255] = 250,恰好实现了向下取整到 10 的倍数的压缩语义。
时间测量:getTickCount 与 getTickFrequency
如何测量算法耗时?OpenCV 提供了两个简单函数(声明见 utility.hpp):
cv::getTickCount():返回自某个事件(如系统开机)以来 CPU 经历的 tick 数;cv::getTickFrequency():返回 CPU 每秒钟发出多少次 tick。
因此,测量两次操作之间的耗时非常简单:
double t = (double)getTickCount(); // do something ... t = ((double)getTickCount() - t)/getTickFrequency(); cout << "Times passed in seconds: " << t << endl;示例程序进一步把这一模式封装为“循环 100 次再取平均”的做法(main 函数),以 2560×1600 的大图反复运行、摊平波动,保证性能数据可比:
const int times = 100; t = (double)getTickCount(); for (int i = 0; i < times; ++i) { cv::Mat clone_i = I.clone(); J = ScanImageAndReduceC(clone_i, table); } t = 1000*((double)getTickCount() - t)/getTickFrequency(); t /= times;图像矩阵在内存中如何存储
如 mat_the_basic_image_container 教程所述,矩阵的存储尺寸取决于使用的颜色系统,更准确地说,取决于通道数。
灰度图像类似下图——每个像素就是一个uchar:
多通道图像中,每列包含与通道数相同的子列。以 BGR 彩色系统为例,每个像素占据连续的 3 个字节(B、G、R)。注意通道顺序与 RGB 相反——OpenCV 使用 BGR 而非 RGB。
由于在许多情况下内存足够大,各行可以依次连续存放,拼成一条“长行”。因为所有数据集中在一个地方、前后相连,这有助于加速扫描过程。可以用cv::Mat::isContinuous()函数来询问矩阵是否满足该条件(声明见 mat.hpp),下一节将给出实际用法。
高效方式:C 风格指针访问
从性能角度看,无人能击败经典的 C 风格operator[](指针)访问。因此,推荐的最有效赋值方式是逐行取指针:
//! [scan-c] Mat& ScanImageAndReduceC(Mat& I, const uchar* const table) { // accept only char type matrices CV_Assert(I.depth() == CV_8U); int channels = I.channels(); int nRows = I.rows; int nCols = I.cols * channels; if (I.isContinuous()) { nCols *= nRows; nRows = 1; } int i,j; uchar* p; for( i = 0; i < nRows; ++i) { p = I.ptr<uchar>(i); for ( j = 0; j < nCols; ++j) { p[j] = table[p[j]]; } } return I; } //! [scan-c](上述代码取自 how_to_scan_images.cpp 的scan-c段。)
这段代码的逻辑要点:
nCols = I.cols * channels:彩色图像每行要遍历的元素是灰度的 3 倍,必须把通道数乘进去;I.isContinuous()为真时,整个矩阵其实是一条连续内存,于是把nCols乘上nRows、令nRows = 1,只需取一次指针就能走到底;I.ptr<uchar>(i)获取第i行起始指针,内层循环用operator[]依次访问。
另外还有一种等价写法:Mat对象的data数据成员返回第一行第一列的指针。如果该指针为 null,说明对象中没有有效输入——检查这一点是判断图像加载是否成功的最简单方法。若存储是连续的,可以直接遍历整个data指针。灰度图像下可写成:
uchar* p = I.data; for( unsigned int i = 0; i < ncol*nrows; ++i) *p++ = table[*p];效果相同,但这段代码日后更难读,若有更复杂的技术在里面就更是如此。而且实际观察中两者的性能结果一致——现代编译器大概率会自动完成这一小优化。
迭代器(安全)方式
高效方式中,“遍历正确数量的uchar字段”以及“跳过行之间可能出现的间隙”是你自己的责任。迭代器方式被认为更安全,因为它把这两件事从用户手中接管过去。你只需请求图像矩阵的 begin 和 end,然后把 begin 迭代器递增到 end 为止。要获取迭代器指向的值,用\*运算符(加在它前面):
//! [scan-iterator] Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table) { // accept only char type matrices CV_Assert(I.depth() == CV_8U); const int channels = I.channels(); switch(channels) { case 1: { MatIterator_<uchar> it, end; for( it = I.begin<uchar>(), end = I.end<uchar>(); it != end; ++it) *it = table[*it]; break; } case 3: { MatIterator_<Vec3b> it, end; for( it = I.begin<Vec3b>(), end = I.end<Vec3b>(); it != end; ++it) { (*it)[0] = table[(*it)[0]]; (*it)[1] = table[(*it)[1]]; (*it)[2] = table[(*it)[2]]; } } } return I; } //! [scan-iterator](取自 how_to_scan_images.cpp 的scan-iterator段。)
两个关键细节:
- 彩色图像每个“列”含 3 个
uchar,可看作一个短的uchar向量,OpenCV 将其命名为Vec3b;访问第 n 个子通道用简单的operator[]。 - OpenCV 迭代器按列推进并自动跳到下一行。因此对彩色图像,如果误用简单的
uchar迭代器,你实际只能访问到蓝色通道的值——这正是示例中对 1 通道和 3 通道分别switch处理的原因。
即时地址计算 + 引用返回:at() 随机访问
最后一种方式不推荐用于整图扫描,它的本意是获取或修改图像中某个随机位置的元素。基本用法是给出要访问元素的行号和列号。与前面的扫描方式一样,这里也取决于你“通过什么类型”去看这张图——需要手动指定at<>模板参数(scan-random段,见 how_to_scan_images.cpp):
//! [scan-random] Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table) { // accept only char type matrices CV_Assert(I.depth() == CV_8U); const int channels = I.channels(); switch(channels) { case 1: { for( int i = 0; i < I.rows; ++i) for( int j = 0; j < I.cols; ++j ) I.at<uchar>(i,j) = table[I.at<uchar>(i,j)]; break; } case 3: { Mat_<Vec3b> _I = I; for( int i = 0; i < I.rows; ++i) for( int j = 0; j < I.cols; ++j ) { _I(i,j)[0] = table[_I(i,j)[0]]; _I(i,j)[1] = table[_I(i,j)[1]]; _I(i,j)[2] = table[_I(i,j)[2]]; } I = _I; break; } } return I; } //! [scan-random]at()接收你的输入类型和坐标,现场计算出目标元素的地址,然后返回一个引用:读取时为const引用,写入时为非const引用。作为安全措施,仅在 debug 模式下会对输入坐标做有效性校验,越界时会在标准错误输出流打印友好的报错信息。与高效方式相比,release 模式下它的差异在于:对图像每个元素都要重新计算一行行指针,再用 C 风格operator[]取列元素。
如果需要对同一张图像做多次at()查找,每次都手写类型和at关键字会很繁琐耗时。为此 OpenCV 提供了cv::Mat_数据类型:它要求在定义时指定通过什么类型查看数据矩阵,作为回报你可以用operator()快速访问元素,且与普通的cv::Mat之间可轻松互相转换——上面彩色分支中的Mat_<Vec3b> _I = I;就是一个实例。需要强调的是:同样的操作(以同样的运行时速度)用cv::Mat::at也能完成,Mat_只是给懒于重复书写的程序员省事的技巧,二者并无本质性能差异。
核心函数:cv::LUT
这是一个“附加”方法——用查找表修改图像值,而无需自己编写任何扫描逻辑。图像处理中“把一张图的所有值改写成别的值”非常常见,OpenCV 为此提供了cv::LUT()(声明及文档见 core.hpp)。其语义是:
dst(I) <- lut(src(I) + d)其中d的取值取决于src的深度:CV_8U/CV_16U为 0,CV_8S为 128,CV_16S为 32768。输入数组须为 8 位或 16 位整数;lut对 8 位输入须有 256 个元素、对 16 位输入须有 65536 个元素;多通道输入时查找表可以是单通道(各通道共用同一张表)或与输入相同通道数。仓库中另有 OpenCL 加速实现 lut.cl,说明该函数在多后端环境下均有覆盖。
用法分两步。先把查找表构建为Mat(table-init段):
//! [table-init] Mat lookUpTable(1, 256, CV_8U); uchar* p = lookUpTable.ptr(); for( int i = 0; i < 256; ++i) p[i] = table[i]; //! [table-init]然后调用函数(I为输入图像,J为输出图像;table-use段):
//! [table-use] LUT(I, lookUpTable, J); //! [table-use]一行代码替代了整个双层循环——这就是“能用现成函数就别重复造轮子”的直观体现。
性能对比
想要最准确的结果,应自行编译并运行程序。教程作者为了使差异更明显,使用了一张相当大(2560×1600)的图像,且以下性能数据针对彩色图像,为每个函数调用 100 次的平均值:
| 方法 | 耗时 |
|---|---|
| 高效方式(C 指针) | 79.4717 毫秒 |
| 迭代器 | 83.7201 毫秒 |
| 即时地址计算(at) | 93.7878 毫秒 |
| LUT 函数 | 32.5759 毫秒 |
可以得出几点结论:
- 优先使用 OpenCV 已有的函数,不要重新发明轮子。本例中最快的恰恰是
cv::LUT()——这是因为 OpenCV 库通过并行后端(如 TBB)开启了多线程执行(从源码结构看,cv::parallel_for_等并行设施是这类向量化操作获得加速的基础),而手写三重循环只能跑满单核。 - 如果必须手写简单的图像扫描,优先选指针方式:它是三种手写方案中最快的。
- 迭代器是更安全的选择(自动处理行间隙与通道推进),但速度稍慢。
at()即时引用访问用于整图扫描在 debug 模式下代价最高(每像素都有一次坐标校验);release 模式下它可能略胜迭代器也可能不,但无论如何它牺牲了迭代器的安全性。
小结
- 逐像素访问
cv::Mat有四种途径:C 指针(最快、需自己处理行间隙与通道数)、迭代器(安全、自动跨行)、at()/Mat_(随机访问友好,不推荐整图扫描)、cv::LUT()等现成函数(向量化 + 并行,通常最快)。 - 查找表把“每个像素一次除加运算”降为“每个像素一次查表赋值”,是处理 8 位图像点运算的经典技巧。
- 计时用
getTickCount()与getTickFrequency()相除,多次运行取平均,才能得到可比的性能数据。 - 想深入复现,直接参考 how_to_scan_images.cpp:编译后执行
how_to_scan_images imageName.jpg 10(彩色)或追加G(灰度),即可在本地得到属于你的性能对比数据。
【免费下载链接】opencvOpen Source Computer Vision Library项目地址: https://gitcode.com/GitHub_Trending/opencv31/opencv
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考