OpenCV 4.12.0
开源计算机视觉
加载中...
搜索中...
无匹配项
如何使用OpenCV扫描图像、查找表和进行时间测量

上一个教程: Mat - 基本图像容器
下一个教程: 矩阵上的掩膜操作

原始作者Bernát Gábor
兼容性OpenCV >= 3.0

目标

我们将寻找以下问题的答案

  • 如何遍历图像的每个像素?
  • OpenCV矩阵值是如何存储的?
  • 如何衡量算法的性能?
  • 什么是查找表,为什么要使用它们?

我们的测试用例

让我们考虑一种简单的颜色缩减方法。通过使用 C 和 C++ 中的无符号字符(unsigned char)类型来存储矩阵项,一个像素通道可以有多达 256 个不同的值。对于一个三通道图像来说,这可以形成过多的颜色(精确地说,是 1600 万种)。处理如此多的颜色色调可能会严重影响算法性能。然而,有时只需处理少得多的颜色就能获得相同的最终结果。

在这种情况下,通常我们会进行颜色空间缩减。这意味着我们将颜色空间的当前值除以一个新的输入值,以得到更少的颜色。例如,所有介于零到九之间的值都取新值零,所有介于十到十九之间的值都取新值十,依此类推。

当你将一个 uchar(无符号字符 - 即介于零到 255 之间的值)值除以一个 int 值时,结果也将是 char。这些值只能是字符值。因此,任何小数部分都将被向下取整。利用这一事实,在 uchar 域中的上述操作可以表示为

\[I_{new} = (\frac{I_{old}}{10}) * 10\]

一个简单的颜色空间缩减算法将只包括遍历图像矩阵的每个像素并应用此公式。值得注意的是,我们执行了除法和乘法运算。这些操作对系统来说代价非常高。如果可能,最好通过使用更便宜的操作来避免它们,例如几次减法、加法,或者在最好的情况下,一个简单的赋值。此外,请注意,上述操作的输入值数量有限。对于 uchar 系统来说,精确地说是 256 个。

因此,对于更大的图像,明智的做法是预先计算所有可能的值,并在赋值时直接使用查找表进行赋值。查找表是简单的数组(具有一个或多个维度),对于给定的输入值变化,它存储最终的输出值。它的优点是我们不需要进行计算,只需读取结果即可。

我们的测试用例程序(以及下面的代码示例)将执行以下操作:读取作为命令行参数传递的图像(可以是彩色或灰度图像),并使用给定的命令行参数整数值应用缩减。在 OpenCV 中,目前有三种主要的方式逐像素遍历图像。为了使事情更有趣,我们将使用这些方法中的每一种来扫描图像,并打印出所花费的时间。

你可以在此处下载完整的源代码,或在 OpenCV 的 samples 目录中核心部分的 cpp 教程代码中查找。其基本用法是

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 类将第三个命令行参数从文本转换为整数格式。然后我们使用一个简单的查找和上述公式来计算查找表。这里没有 OpenCV 特定的内容。

另一个问题是如何测量时间?OpenCV 提供了两个简单的函数来实现这一点:cv::getTickCount()cv::getTickFrequency()。前者返回自某个事件(例如系统启动)以来系统 CPU 的时钟周期数。后者返回你的 CPU 每秒发出多少个时钟周期。因此,测量两个操作之间经过的时间就像这样简单:

double t = (double)getTickCount();
// 执行某些操作 ...
t = ((double)getTickCount() - t)/getTickFrequency();
cout << "Times passed in seconds: " << t << endl;

图像矩阵是如何存储在内存中的?

正如你可以在我的Mat - 基本图像容器教程中读到,矩阵的大小取决于所使用的颜色系统。更准确地说,它取决于使用的通道数量。在灰度图像的情况下,我们有类似如下结构:

对于多通道图像,列中包含的子列数量与通道数量相同。例如,在 BGR 颜色系统中

请注意,通道的顺序是反向的:BGR 而不是 RGB。由于在许多情况下内存足够大,可以连续存储行,所以行可以一个接一个地排列,形成一个单一的长行。因为所有内容都连续地存储在一个地方,这有助于加速扫描过程。我们可以使用 cv::Mat::isContinuous() 函数来查询矩阵是否是这种情况。继续阅读下一节以找到示例。

高效方式

谈到性能,你无法超越经典的 C 风格 `operator[]`(指针)访问。因此,我们推荐的最有效的赋值方法是

Mat& ScanImageAndReduceC(Mat& I, const uchar* const table)
{
// 只接受 char 类型矩阵
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;
}

这里我们基本上只是获取指向每行开头的指针,然后遍历直到行结束。在矩阵以连续方式存储的特殊情况下,我们只需请求一次指针并一直遍历到末尾。我们需要注意彩色图像:我们有三个通道,因此每行需要遍历三倍的项目。

还有另一种方法。Mat 对象的 data 数据成员返回指向第一行、第一列的指针。如果此指针为空,则该对象中没有有效输入。检查此项是检查图像加载是否成功的简单方法。如果存储是连续的,我们可以使用它来遍历整个数据指针。在灰度图像的情况下,它看起来像这样:

uchar* p = I.data;
for( unsigned int i = 0; i < ncol*nrows; ++i)
*p++ = table[*p];
I.at<uchar>(y, x) = saturate_cast<uchar>(r);
uchar
unsigned char uchar

你会得到相同的结果。然而,这段代码在后续阅读时会困难得多。如果你在那里有一些更高级的技术,它会变得更难。此外,在实践中我观察到你会获得相同的性能结果(因为大多数现代编译器可能会自动为你进行这种小的优化技巧)。

迭代器(安全)方法

在高效方法中,确保你遍历了正确数量的 uchar 字段并跳过行之间可能出现的间隙是你的责任。迭代器方法被认为是一种更安全的方式,因为它从用户那里接管了这些任务。你所需要做的就是询问图像矩阵的开始和结束位置,然后只需增加开始迭代器直到达到结束。要获取迭代器指向的值,请使用 `*` 运算符(将其加在迭代器前面)。

Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table)
{
// 只接受 char 类型矩阵
const int channels = I.channels();
switch(channels)
{
case 1:
{
for( it = I.begin<uchar>(), end = I.end<uchar>(); it != end; ++it)
*it = table[*it];
break;
}
case 3:
{
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 迭代器,你将只能访问蓝色通道的值。

即时地址计算与引用返回

最后一种方法不建议用于扫描。它旨在获取或修改图像中的随机元素。它的基本用法是指定要访问项的行号和列号。在我们之前的扫描方法中,你可能已经注意到通过什么类型查看图像很重要。在这里也没有什么不同,因为你需要手动指定在自动查找时使用什么类型。你可以在灰度图像的以下源代码中观察到这一点(使用 + cv::Mat::at() 函数的用法)

Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table)
{
// 只接受 char 类型矩阵
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;
}
MatIterator_< _Tp > end()
返回矩阵迭代器并将其设置为矩阵的最后一个元素之后的位置。
MatIterator_< _Tp > begin()
返回矩阵迭代器并将其设置为矩阵的第一个元素。

该函数接收你的输入类型和坐标,然后计算查询项的地址。接着返回对其的引用。当你获取值时,这可能是一个常量引用;当你设置值时,它可能是一个非常量引用。作为安全措施,仅在调试模式下*会进行检查,以确保你的输入坐标有效且存在。如果不是这种情况,你会在标准错误输出流上收到友好的提示消息。与发布模式下的高效方法相比,使用此方法的唯一区别在于,对于图像的每个元素,你都会获得一个新的行指针,我们用它来通过 C 语言的 `operator[]` 获取列元素。

如果你需要对图像使用此方法进行多次查找,那么每次访问都输入类型和 `at` 关键字可能会很麻烦且耗时。为了解决这个问题,OpenCV 提供了一种 cv::Mat_ 数据类型。它与 Mat 相同,但定义时需要额外指定查看数据矩阵的数据类型,作为回报,你可以使用 `operator()` 快速访问元素。更棒的是,它可以轻松地与常规的 cv::Mat 数据类型互相转换。你可以在上述函数的彩色图像示例中看到它的用法。然而,重要的是要注意,相同的操作(具有相同的运行时速度)也可以通过 cv::Mat::at 函数完成。这只是一个为懒惰程序员准备的减少编写代码的小技巧。

核心函数

这是在图像中实现查找表修改的一种额外方法。在图像处理中,你经常需要将给定图像的所有值修改为其他值。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);

性能差异

为了获得最佳结果,请自行编译并运行该程序。为了使差异更明显,我使用了一张相当大(2560 X 1600)的图像。这里显示的性能是针对彩色图像的。为了获得更准确的值,我对函数调用一百次得到的值进行了平均。

方法时间
高效方式79.4717 毫秒
迭代器83.7201 毫秒
即时随机访问93.7878 毫秒
LUT 函数32.5759 毫秒

我们可以得出几点结论。如果可能,请使用 OpenCV 已有的函数(而不是重新发明轮子)。最快的方法是 LUT 函数。这是因为 OpenCV 库通过 Intel Threaded Building Blocks 启用了多线程。然而,如果你需要编写简单的图像扫描,请优先选择指针方法。迭代器是一种更安全的选择,但速度相当慢。在调试模式下,使用即时引用访问方法进行完整图像扫描的成本最高。在发布模式下,它可能击败或未能击败迭代器方法,但它肯定为此牺牲了迭代器的安全性特性。

最后,你可以在我们 YouTube 频道上发布的视频中观看该程序的示例运行。