如何扫描图像、查找表与 OpenCV 时间测量
我们将寻求以下问题的答案:
- 如何遍历图像中的每一个像素?
- OpenCV 矩阵值是如何存储的?
- 如何衡量我们算法的性能?
- 什么是查找表(lookup table),为什么要使用它?
让我们考虑一个简单的颜色缩减方法。通过使用 C 和 C++ 的 unsigned char 类型来存储矩阵项,一个像素的一个通道最多可以有 256 个不同的值。对于三通道图像,这可能允许形成过多的颜色(准确地说是 1600 万种)。处理如此多的色调会对我们的算法性能造成沉重打击。然而,有时只用少得多的颜色就足以得到相同的最终结果。
在这种情况下,我们通常会进行一次颜色空间缩减(color space reduction)。这意味着我们用一个新的输入值去除当前颜色空间的值,从而得到更少的颜色。例如,0 到 9 之间的每个值都取新值 0,10 到 19 之间的每个值都取 10,依此类推。
当你用一个 int 值去除一个 uchar(unsigned char,即 0 到 255 之间的值)值时,结果也会是 char。这些值只能是 char 值。因此,任何小数部分都会被向下取整。利用这一事实,上述运算在 uchar 域中可以表示为:
一个简单的颜色空间缩减算法只需遍历图像矩阵的每个像素并应用这个公式即可。值得注意的是,我们做了一次除法和一次乘法运算。这些运算对系统来说代价高昂。如果可能的话,值得用更廉价的运算来避免它们,比如几次减法、加法,或者在最佳情况下只做一次简单的赋值。此外,注意上述运算的输入值数量是有限的。在 uchar 系统中,准确地说有 256 个。
因此,对于较大的图像,事先计算出所有可能的值,然后在赋值时只做赋值操作(通过使用查找表)是明智的。查找表是简单的数组(具有一个或多个维度),对于给定的输入值变化,它保存了最终的输出值。它的强大之处在于我们不需要进行计算,只需读取结果即可。
我们的测试用例程序(以及下面的代码示例)将执行以下操作:读入一幅作为命令行参数传入的图像(可以是彩色或灰度),并用给定的命令行参数整数值进行缩减。在 OpenCV 中,目前有三种主要的逐像素遍历图像的方法。为了让事情更有趣一些,我们将使用每种方法来扫描图像,并打印出所花费的时间。
你可以从此处下载完整源代码,或在 OpenCV 的 samples 目录中 core 部分的 cpp 教程代码中查找它。它的基本用法是:
how_to_scan_images imageName.jpg intValueToReduce [G]最后一个参数是可选的。如果给出,图像将以灰度格式加载,否则使用 BGR 颜色空间。第一件事是计算查找表。
(计算查找表的代码实现见源代码。)
这里我们首先使用 C++ 的 stringstream 类将第三个命令行参数从文本转换为整数格式。然后我们使用一个简单的循环和上述公式来计算查找表。这里没有任何 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;

图像矩阵在内存中是如何存储的?
Section titled “图像矩阵在内存中是如何存储的?”正如你已经在我的 Mat - 基本图像容器教程中所读到的,矩阵的大小取决于所使用的颜色系统。更准确地说,它取决于所使用的通道数。在灰度图像的情况下,我们有这样的结构:
对于多通道图像,列中包含与通道数相同数量的子列。例如在 BGR 颜色系统中:
请注意通道的顺序是相反的:BGR 而不是 RGB。因为在许多情况下内存足够大,可以连续地存储各行,各行可以一个接一个地排列,形成一条单一的长行。由于所有内容都在一个地方一个接一个地排列,这可能有助于加快扫描过程。我们可以使用 cv::Mat::isContinuous() 函数来询问矩阵是否属于这种情况。继续阅读下一节以查看示例。
在性能方面,没有什么能比得上经典的 C 风格 [] 运算符(指针)访问。因此,我们能推荐的最高效的赋值方法是:
(C 风格指针扫描的代码实现见源代码。)
这里我们基本上只是获取每行起始的指针,并遍历它直到结束。在矩阵以连续方式存储的特殊情况下,我们只需请求一次指针并一直走到末尾。我们需要注意彩色图像:我们有三个通道,因此每行需要遍历三倍数量的项。
还有另一种方式。Mat 对象的 data 数据成员返回指向第一行第一列的指针。如果这个指针为空,说明该对象中没有有效的输入。检查这一点是检查图像加载是否成功的最简单方法。如果存储是连续的,我们可以用它来遍历整个 data 指针。在灰度图像的情况下,它会是这样的:
uchar* p = I.data;
for( unsigned int i = 0; i < ncol*nrows; ++i) *p++ = table[*p];你会得到相同的结果。然而,这段代码日后阅读起来要困难得多。如果你在那里使用了一些更高级的技术,它会变得更加困难。此外,在实践中我观察到你会得到相同的性能结果(因为大多数现代编译器可能会自动为你做这个小优化技巧)。
迭代器(安全)方法
Section titled “迭代器(安全)方法”在高效方式中,确保你遍历了正确数量的 uchar 字段并跳过行与行之间可能出现的间隙是你自己的责任。迭代器方法被认为是一种更安全的方式,因为它从用户那里接管了这些任务。你所需要做的就是获取图像矩阵的 begin 和 end,然后只需递增 begin 迭代器直到到达 end。要获取迭代器指向的值,使用 * 运算符(加在它前面)。
(迭代器扫描的代码实现见源代码。)
在彩色图像的情况下,每列我们有三个 uchar 项。这可以被看作是一个 uchar 项的短向量,OpenCV 中给它命名为 Vec3b。要访问第 n 个子列,我们使用简单的 [] 运算符访问。重要的是要记住,OpenCV 迭代器会遍历各列并自动跳到下一行。因此在彩色图像中,如果你使用一个简单的 uchar 迭代器,你将只能访问到蓝色通道的值。
即时地址计算与引用返回
Section titled “即时地址计算与引用返回”最后一种方法不推荐用于扫描。它是为获取或修改图像中某种程度的随机元素而设计的。它的基本用法是指定你要访问的项的行号和列号。在我们之前的扫描方法中,你已经可以注意到,我们以何种类型来看待图像是很重要的。这里也不例外,因为你需要手动指定在自动查找时使用什么类型。你可以在以下灰度图像的源代码中观察到这一点(使用 cv::Mat::at() 函数):
(即时地址访问的代码实现见源代码。)
该函数接收你的输入类型和坐标,并计算所查询项的地址。然后返回对该项的引用。当你获取值时它可能是一个常量,当你设置值时它是非常量的。作为仅限调试模式下的一个安全步骤,会执行一个检查,验证你的输入坐标是否有效且确实存在。如果不是这种情况,你会在标准错误输出流上得到一条不错的输出消息。与高效方式相比,在发布模式下使用此方法的唯一区别是,对于图像的每个元素,你都会得到一个新的行指针,我们用 C 的 [] 运算符来获取列元素。
如果你需要用这种方法对一幅图像进行多次查找,那么为每次访问都输入类型和 at 关键字可能会很麻烦也很耗时。为了解决这个问题,OpenCV 提供了 cv::Mat_ 数据类型。它与 Mat 相同,但额外要求在定义时需要指定用于查看数据矩阵的数据类型,作为回报,你可以使用 () 运算符快速访问项。更好的是,它可以很容易地与通常的 cv::Mat 数据类型相互转换。在上面函数的彩色图像情况中你可以看到它的一个示例用法。不过,重要的是要注意,相同的操作(具有相同的运行时速度)本可以用 cv::Mat::at 函数完成。这只是为懒惰程序员少写些代码的一个技巧。
这是在图像中实现查找表修改的一种附加方法。在图像处理中,你想要把一幅图像的所有给定值修改为某个其他值是很常见的。OpenCV 提供了一个修改图像值的函数,而无需编写图像的扫描逻辑。我们使用 core 模块的 cv::LUT() 函数。首先我们构建一个 Mat 类型的查找表:
(初始化查找表的代码实现见源代码。)
最后调用该函数(I 是我们的输入图像,J 是输出图像):
(使用查找表的代码实现见源代码。)
为了获得最佳结果,请编译程序并自己运行它。为了使差异更明显,我使用了一幅相当大的(2560 X 1600)图像。这里给出的性能是针对彩色图像的。为了获得更准确的值,我对函数调用所得的值取了 100 次的平均。
| 方法 | 耗时 |
|---|---|
| 高效方式 | 79.4717 毫秒 |
| 迭代器 | 83.7201 毫秒 |
| 即时引用访问 | 93.7878 毫秒 |
| LUT 函数 | 32.5759 毫秒 |
我们可以得出几点结论。如果可能,请使用 OpenCV 已有的函数(而不是重新发明它们)。最快的方法是 LUT 函数。这是因为 OpenCV 库通过 Intel 线程构建模块(Threaded Building Blocks)启用了多线程。然而,如果你需要编写一个简单的图像扫描,请优先使用指针方法。迭代器是一个更安全的选择,但相当慢。使用即时引用访问方法进行全图扫描在调试模式下代价最高。在发布模式下它可能击败也可能不会击败迭代器方法,但它无疑为此牺牲了迭代器的安全性。
最后,你可以在我们 YouTube 频道上发布的视频中观看该程序的运行示例。