How to scan images, lookup tables and time measurement with OpenCV
如何使用 OpenCV 扫描图像、查找表和时间测量
Goal
我们将寻求以下问题的答案:
如何遍历图像的每个像素?
OpenCV 矩阵值是如何存储的?
如何衡量我们算法的性能?
什么是查找表,为什么要使用它们?
Our test case
让我们考虑一个简单的颜色减少方法。通过使用 unsigned char C 和 C++ 类型来存储矩阵项,一个像素通道可以有多达 256 个不同的值。对于三通道图像,这可能会形成太多颜色(准确地说是 1600 万)。使用如此多的色调可能会对我们的算法性能造成沉重打击。但是,有时只需使用更少的它们来获得相同的最终结果就足够了。
在这种情况下,我们通常会减少色彩空间。这意味着我们将颜色空间当前值除以一个新的输入值,最终得到更少的颜色。例如,0 到 9 之间的每个值都取新值 0,10 到 19 之间的每个值都取值 10,依此类推。
当您将 uchar(无符号字符 - 即 0 到 255 之间的值)值除以 int 值时,结果也将是 char。这些值可能只是 char 值。因此,任何分数都将向下舍入。利用这一事实,uchar 域中的上层运算可以表示为:
一个简单的色彩空间缩减算法将包括仅通过图像矩阵的每个像素并应用此公式。值得注意的是,我们进行了除法和乘法运算。这些操作对于一个系统来说是非常昂贵的。如果可能的话,通过使用更便宜的操作来避免它们是值得的,例如一些减法、加法或最好的情况是一个简单的赋值。此外,请注意,对于上层操作,我们只有有限数量的输入值。对于 uchar 系统,准确地说是 256。
因此,对于较大的图像,明智的做法是事先计算所有可能的值,并在分配期间通过使用查找表进行分配。查找表是简单的数组(具有一维或多维),对于给定的输入值变化保存最终输出值。它的优点是我们不需要进行计算,我们只需要读取结果。
我们的测试用例程序(以及下面的代码示例)将执行以下操作:读入作为命令行参数传递的图像(它可以是彩色或灰度),并使用给定的命令行参数整数值应用缩减。在 OpenCV 中,目前有三种主要的方式来逐个像素地遍历图像。为了让事情变得更有趣,我们将使用这些方法中的每一种来扫描图像,并打印出花费了多长时间。
您可以在此处下载完整的源代码,或者在核心部分的 cpp 教程代码中的 OpenCV 示例目录中查找它。它的基本用法是:
how_to_scan_images imageName.jpg intValueToReduce [G]
最后一个参数是可选的。 如果给定图像将以灰度格式加载,否则使用 BGR 颜色空间。 首先是计算查找表。
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));
这里我们首先使用 C++ stringstream 类将第三个命令行参数从文本转换为整数格式。 然后我们使用简单的look和上式计算查找表。 这里没有 OpenCV 特定的东西。
另一个问题是我们如何计算时间? 那么 OpenCV 提供了两个简单的函数来实现这个 cv::getTickCount() 和 cv::getTickFrequency() 。 第一个返回来自某个事件的系统 CPU 的滴答数(例如自您启动系统以来)。 第二个返回你的 CPU 在一秒钟内发出多少次滴答声。 因此,测量两个操作之间经过的时间很简单:
double t = (double)getTickCount();
// do something ...
t = ((double)getTickCount() - t)/getTickFrequency();
cout << "Times passed in seconds: " << t << endl;
How is the image matrix stored in memory?
正如您已经在我的 Mat - The Basic Image Container 教程中读到的那样,矩阵的大小取决于所使用的颜色系统。 更准确地说,它取决于使用的通道数。 在灰度图像的情况下,我们有类似的东西:
对于多通道图像,列包含与通道数一样多的子列。 例如,在 BGR 颜色系统的情况下:
请注意,通道的顺序是相反的:BGR 而不是 RGB。 因为在许多情况下,内存足够大,可以以连续的方式存储行,因此行可能会一个接一个地跟随,从而创建一个长行。 因为一切都在一个接一个的地方,这可能有助于加快扫描过程。 我们可以使用 cv::Mat::isContinuous() 函数来询问矩阵是否是这种情况。 继续下一节以查找示例。
The efficient way
在性能方面,您无法击败经典的 C 风格 operator[](指针)访问。 因此,我们可以推荐的最有效的分配方法是:
Mat& ScanImageAndReduceC(Mat& I, const uchar* const table)
{
// 只接受 char 类型的矩阵
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);//第i行
for ( j = 0; j < nCols; ++j)
{
p[j] = table[p[j]];
}
}
return I;
}
在这里,我们基本上只是获取指向每行开头的指针并遍历它直到它结束。 在矩阵以连续方式存储的特殊情况下,我们只需请求一次指针并一直到最后。 我们需要注意彩色图像:我们有三个通道,所以我们需要通过每行中三倍以上的项目。
还有另一种方法。 Mat 对象的 data 数据成员返回指向第一行第一列的指针。 如果此指针为空,则您在该对象中没有有效输入。 检查这是检查图像加载是否成功的最简单方法。 如果存储是连续的,我们可以使用它来遍历整个数据指针。 在灰度图像的情况下,这看起来像:
uchar* p = I.data; // 指向第一行第一列的指针
for( unsigned int i = 0; i < ncol*nrows; ++i)
*p++ = table[*p];
你会得到同样的结果。 但是,此代码以后更难阅读this code is a lot harder to read later on。 如果你有一些更先进的技术,那就更难了。 此外,在实践中,我观察到您将获得相同的性能结果(因为大多数现代编译器可能会自动为您制作这个小的优化技巧)。
The iterator (safe) method
如果采用有效方法确保您通过正确数量的 uchar 字段并跳过行之间可能出现的间隙是您的责任。 迭代器方法被认为是一种更安全的方法,因为它从用户那里接管了这些任务。 您需要做的就是询问图像矩阵的开始和结束,然后增加开始迭代器直到到达结束。 要获取迭代器指向的值,请使用 * 运算符(将其添加到它之前)。
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: //BGR
{
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;
}
在彩色图像的情况下,我们每列有三个 uchar 项。 这可能被认为是 uchar 项的短向量,已在 OpenCV 中使用 Vec3b 名称进行了洗礼。 要访问第 n 个子列,我们使用简单的 operator[] 访问。 重要的是要记住 OpenCV 迭代器会遍历列并自动跳到下一行。 因此,对于彩色图像,如果您使用简单的 uchar 迭代器,您将只能访问蓝色通道值。
On-the-fly address calculation with reference returning
带参考返回的即时地址计算
最后一种方法不推荐用于扫描。 它被用来获取或修改图像中的随机元素。 它的基本用法是指定要访问的项的行号和列号。 在我们早期的扫描方法中,您可能已经注意到我们正在查看图像的类型很重要。 这里没有什么不同,因为您需要手动指定在自动查找时使用的类型。 对于以下源代码的灰度图像,您可以观察到这一点(使用 + cv::Mat::at() 函数):
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;
}
该函数采用您的输入类型和坐标并计算查询项 的地址。然后返回对它的引用。当您获取值时,这可能是一个常数,而当您设置值时,这可能是一个非常数。作为仅在调试模式下的安全步骤*,系统会检查您的输入坐标是否有效且确实存在。如果不是这种情况,您将在标准错误输出流上得到一个很好的输出消息。与release模式中的有效方式相比,使用它的唯一区别是,对于图像的每个元素,您将获得一个新的行指针,用于我们使用 C 运算符 [] 获取列元素。
如果您需要使用此方法对图像进行多次查找,则为每个访问输入类型和 at 关键字可能既麻烦又耗时。为了解决这个问题,OpenCV 有一个 cv::Mat_ 数据类型。它与 Mat 相同,另外需要在定义时通过查看数据矩阵的内容来指定数据类型,但作为回报,您可以使用 operator() 快速访问项目。为了使事情变得更好,这很容易与通常的 cv::Mat 数据类型相互转换。在上述函数的彩色图像的情况下,您可以看到一个示例用法。尽管如此,重要的是要注意相同的操作(具有相同的运行时速度)可以使用 cv::Mat::at 函数完成。对于懒惰的程序员技巧来说,这只是一个少写的东西。
The Core Function
这是在图像中实现查找表修改的一种bonus 红利方法。 在图像处理中,您希望将所有给定图像值修改为其他值是很常见的。 OpenCV提供了修改图像值的功能,无需编写图像的扫描逻辑。 我们使用核心模块的 cv::LUT() 函数。 首先我们建立一个 Mat 类型的查找表:
Mat lookUpTable(1, 256, CV_8U);
uchar* p = lookUpTable.ptr();
for( int i = 0; i < 256; ++i)
p[i] = table[i];
最后调用函数(I 是我们的输入图像,J 是输出图像):
LUT(I, lookUpTable, J);
Performance Difference
为获得最佳结果,请编译程序并自行运行。 为了使差异更加清晰,我使用了一个相当大的 (2560 X 1600) 图像。 此处介绍的性能适用于彩色图像。 为了获得更准确的值,我已经平均了从函数调用中获得的值一百次。
Method | Time |
Efficient Way | 79.4717 milliseconds |
Iterator | 83.7201 milliseconds |
On-The-Fly RA | 93.7878 milliseconds |
LUT function | 32.5759 milliseconds |
我们可以总结几件事。 如果可能,请使用 OpenCV 已经制作的功能(而不是重新发明这些功能)。 最快的方法是 LUT 函数。 这是因为 OpenCV 库通过 Intel Threaded Building Blocks 启用了多线程。 但是,如果您需要编写简单的图像扫描,则首选指针方法。 迭代器是一个更安全的选择,但速度很慢。 在调试模式下,使用动态参考访问方法进行全图像扫描是最昂贵的。 在release模式下,它可能会击败迭代器方法,但它肯定会为此牺牲迭代器的安全特性。
最后,您可以在我们的 YouTube 频道上发布的视频中观看该程序的示例运行。