OpenCV 4.11.0
开源计算机视觉库
加载中…
搜索中…
未找到匹配项
如何扫描图像、查找表和时间测量

上一篇教程: Mat - 基本图像容器
下一篇教程: 矩阵掩码操作

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

目标

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

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

我们的测试案例

让我们考虑一种简单的颜色简化方法。使用无符号字符 `uchar` (C 和 C++ 类型) 来存储矩阵项,像素通道最多可以有 256 个不同的值。对于三通道图像,这可以产生过多的颜色(确切地说,是 1600 万种)。处理如此多的颜色阴影可能会严重影响算法性能。但是,有时使用更少的颜色就能获得相同的最终结果。

在这种情况下,通常我们会进行颜色空间缩减。这意味着我们将颜色空间的当前值除以一个新的输入值,以最终获得更少的颜色。例如,0 到 9 之间的每个值取新值 0,10 到 19 之间的每个值取值 10,依此类推。

当您将一个 `uchar`(无符号字符 - 即 0 到 255 之间的数值)值除以一个 `int` 值时,结果也将是 `uchar`。这些值只能是 `uchar` 值。因此,任何小数部分都将被向下取整。利用这一事实,`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; // 将输入字符串转换为数字 - C++ 风格
stringstream s;
s << argv[2];
s >> divideWith;
if (!s || !divideWith)
{
cout << "输入的除数无效。" << 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 << "经过的时间(秒):" << t << endl;

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

正如您在我的Mat - 基本图像容器教程中已经阅读到的那样,矩阵的大小取决于使用的颜色系统。更准确地说,它取决于使用的通道数。对于灰度图像,我们有:

对于多通道图像,列包含与通道数一样多的子列。例如,对于 BGR 颜色系统:

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

高效的方法

谈到性能,经典的 C 风格运算符 [](指针)访问无可匹敌。因此,我们推荐的最高效的赋值方法是

Mat& ScanImageAndReduceC(Mat& I, const uchar* const table)
{
// 只接受字符类型矩阵
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];
unsigned char uchar
定义 interface.h:51

你会得到相同的结果。但是,这段代码以后阅读起来要困难得多。如果你在那里使用了更高级的技术,它会变得更加困难。此外,在实践中,我已经观察到你会得到相同的性能结果(因为大多数现代编译器可能会自动为你进行这个小的优化技巧)。

迭代器(安全)方法

对于高效的方法,确保你遍历正确的 *uchar* 字段数量并跳过行之间可能出现的间隙是你的责任。迭代器方法被认为是一种更安全的方法,因为它承担了用户这些任务。你只需要获取图像矩阵的开头和结尾,然后增加开头迭代器直到到达结尾即可。要获取迭代器 *指向* 的值,请使用 * 运算符(将其放在前面)。

Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table)
{
// 只接受字符类型矩阵
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 个子列,我们使用简单的运算符 [] 访问。重要的是要记住,OpenCV 迭代器遍历列并自动跳到下一行。因此,对于彩色图像,如果使用简单的 *uchar* 迭代器,你将只能访问蓝色通道的值。

使用引用返回的动态地址计算

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

Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table)
{
// 只接受字符类型矩阵
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 运算符 [] 来获取列元素。

如果您需要对图像使用此方法进行多次查找,则每次访问都需要输入类型和 at 关键字可能会很麻烦且费时。为了解决这个问题,OpenCV 提供了一个 cv::Mat_ 数据类型。它与 Mat 相同,额外需要在 at 定义中通过查看数据矩阵的内容来指定数据类型,但是作为回报,您可以使用运算符 () 快速访问项目。为了使事情变得更好,它可以轻松地从通常的 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 毫秒
即时RA93.7878 毫秒
LUT 函数32.5759 毫秒

我们可以得出一些结论。如果可能,请使用 OpenCV 的现有函数(而不是重新发明这些函数)。事实证明,最快的 方法是 LUT 函数。这是因为 OpenCV 库通过英特尔线程构建块启用了多线程。但是,如果您需要编写简单的图像扫描,则更喜欢指针方法。迭代器是更安全的选择,但速度较慢。对于完整的图像扫描,使用即时引用访问方法在调试模式下成本最高。在发布模式下,它可能会超过或不超过迭代器方法,但是它肯定为此牺牲了迭代器的安全特性。

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