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

上一教程: Mat - 基本图像容器
下一教程: 针对矩阵的蒙版操作

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

目标

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

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

我们的测试用例

我们考虑一种简单的颜色减少方法。通过使用 unsigned char C 和 C++ 类型存储矩阵项目,像素通道最多可具有 256 个不同的值。对于三通道图像,这可以允许形成过多的颜色(确切地说是有 1600 万种)。处理如此多的颜色色调可能会严重损害我们的算法性能。但是,有时候处理较少颜色就足以获得相同的结果。

在这种情况下,我们通常会执行颜色空间减少。这意味着我们将当前的颜色空间值除以一个新输入值,以最终获得较少的颜色。例如,介于零和九之间的每个值都取新值零,介于十和十九之间的每个值都取值十,依此类推。

当您将微字符(unsigned char - 亦称介于零和 255 之间的值)值除以整型值时,结果也将是字符。这些值可能仅为字符值。因此,任何小数都将取整。利用此事实,微字符域中的上述运算可以表示为

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

简单的颜色空间减少算法仅包括遍历图像矩阵的每一个像素并应用此公式。值得注意的是,我们执行了除法运算和乘法运算。对于系统而言,这些运算非常昂贵。如果可能,最好避免使用它们,而使用更便宜的运算,如几次减法、加法或在最佳情况下是一个简单的赋值。此外,请注意,针对上述运算,我们仅有有限数量的输入值。对于微字符系统,这确切地是 256 个。

因此,对于较大的图像而言,明智的做法是预先计算所有可能的值并在分配期间仅根据查找表进行分配。查找表是简单的数组(具有一个或多个维度),它针对给定的输入值变化保存最终输出值。它的优势在于我们不需要进行计算,而只需要读取结果。

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

你可以此处下载完整的源代码,或者在 OpenCV 的样本目录中查看核心的 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 << "Invalid number entered for dividing. " << endl;
return -1;
}
uchar table[256];
for (int i = 0; i < 256; ++i)
table[i] = (uchar)(divideWith * (i/divideWith));
unsigned char uchar
定义 interface.h:51

这里,我们首先使用 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)
{
// 只接受 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;
对于( i = 0; i < nRows; ++i)
{
p = I.ptr<uchar>(i);
对于 ( j = 0; j < nCols; ++j)
{
p[j] = table[p[j]];
}
}
返回 I;
}
#define CV_8U
定义 interface.h:73
#define CV_Assert(expr)
检查运行时的条件,如果失败则抛出异常。
定义 base.hpp:342

我们在这里基本获取一个指针指向每行的开始,在到达结束前遍历它。在矩阵以连续方式存储的特殊情况下,我们仅需要请求该指针一次,然后遍历到结束。我们需要观察彩色图像:我们有三个通道,因此需要在每一行中传递多出三倍的项目。

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

uchar* p = I.data;
对于( unsigned int i = 0; i < ncol*nrows; ++i)
*p++ = table[*p];

你会得到相同的结果。但是,以后再读取此代码就难得多。如果你有一些更先进的技术,那么它会变得更难。此外,在实际操作中,我观察到你会获得相同性能结果(因为大多数现代编译器可能会自动为你执行此小的优化技巧)。

迭代器(安全)方法

如果是有效的方法,通过确保你遍历了正确的uchar 字段数量并跳过行之间可能出现的差距,则是你的职责。迭代器方法被认为是一种更安全的方法,因为它从用户那里接管了这些任务。你所需要做的就是询问 image 矩阵的开始和结束,然后仅仅增加开始迭代器,直到到达结束。要获取由迭代器指向的值,请使用 * 运算符(在它之前添加该运算符)。

Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table)
{
// 只接受 char 类型矩阵
CV_Assert(I.depth() == CV_8U);
const int channels = I.channels();
switch(channels)
{
case 1:
{
MatIterator_<uchar> it, end;
对于( it = I.begin<uchar>(), end = I.end<uchar>(); it != end; ++it)
*it = table[*it];
中断;
}
case 3:
{
MatIterator_<Vec3b> it, end;
对于( 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]];
}
}
}
返回 I;
}

在彩色图像的情况下,我们每列有三个 uchar 项。这可以看作是 uchar 项的一个短向量,在 OpenCV 中已使用 Vec3b 名称将其命名。要访问第 n 个子列,我们使用简单的运算符 [] 访问。务必记得,OpenCV 迭代器会遍历各列并自动跳到下一行。因此,针对彩色图像,如果您使用简单的 uchar 迭代器,您将只能访问蓝色通道的值。

使用返回引用的即时地址计算

不建议将最后的方法用于扫描。该方法的制定是为了以某种方式获取或修改图像中的随机元素。其基本用法是指定您想要访问的项的行号和列号。在我们之前使用的扫描方法中,您可能已经注意到,使用哪种类型透视图像非常重要。这里也不例外,因为您需要手动指定在自动查找时要使用的类型。对于以下源代码(使用 + cv::Mat::at() 函数),您可以在灰度图像的情况下发现这一点

Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table)
{
// 只接受 char 类型矩阵
CV_Assert(I.depth() == CV_8U);
const int channels = I.channels();
switch(channels)
{
case 1:
{
对于( int i = 0; i < I.rows; ++i)
对于( int j = 0; j < I.cols; ++j )
I.at<uchar>(i,j) = table[I.at<uchar>(i,j)];
中断;
}
case 3:
{
Mat_<Vec3b> _I = I;
对于( int i = 0; i < I.rows; ++i)
对于( 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;
中断;
}
}
返回 I;
}

该函数获取输入类型和坐标,并计算查询项的地址。然后返回对此项的引用。获取值时,它可能是一个常量,在设置值时,它可能是一个非常量。作为仅在调试模式中执行的安全步骤*,需要执行一个检查,以确保输入坐标有效且存在。如果不是这种情况,将在标准错误输出流中获取一条相关输出消息。与发布模式中的高效方式相比,使用此方式的唯一区别在于,对于图像的每个元素,将获取一个新行指针,我们用它来使用 C 运算符 [] 来获取列元素。

如果您需要使用此方法对图像执行多次查找,则为每次访问输入类型和 at 关键字可能会很麻烦且很耗时。为解决此问题,OpenCV 具有 cv::Mat_ 数据类型。它与 Mat 相同,但额外需要在定义时指定数据类型以根据其查看数据矩阵,但作为回报,您可以使用运算符 () 快速访问项。为了使所有事情变得更好,它可以从通常的 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 频道上程序的示例运行情况。