OpenCV 遍历图像、查找表和性能度量

422 阅读7分钟

1. 目标

本文要解答以下的问题:

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

2. 测试用例

考虑一个简单的缩减颜色数量的方法。通过使用 unsigned char C 和 C++ 类型来存储矩阵项,单通道中的像素可以有 256 个不同的值。对于三通道图像,这可能会形成太多颜色(准确地说是 1600万, 256256256=16,777,216256*256*256=16,777,216)。使用如此多的颜色可能会对算法性能造成沉重打击。其实,有时只需使用更少的颜色来获得相同的最终结果就足够了。

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

当您将uchar(无符号字符 - 即 0 到 255 之间的值)值除以int值时,结果也将是char。这些值只能是 char 值。因此,任何分数都将向下舍入。利用这一事实,uchar域中的上运算可以表示为:

Inew=(Iold10)10 I_{new}=\left ( \frac{I_{old}}{10}\right )*10

一个简单的颜色空间缩减算法仅是对图像中的每个像素应用此公式。值得注意的是,公式中进行了除法和乘法运算。这些操作对于一个系统来说是非常昂贵的。如果可能的话,通过使用更简单的操作来避免复杂计算,例如一些减法、加法或最好的情况是一个简单的赋值。此外,值得注意的是,对于上边的操作,只有有限数量的输入值。对于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;     // 从string对象读取字符或字符串
 s << argv[2];       // 把第3个参数读入s
 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 的tick数(例如自您启动系统以来)。第二个返回你的 CPU 在一秒钟内发出多少次tick。因此,测量两个操作之间经过的时间很简单:

double t = (double)getTickCount();
// do something ...
t = ((double)getTickCount() - t)/getTickFrequency();
cout << "Times passed in seconds: " << t << endl;

3. 图像是如何存储在内存中的?

Mat - The Basic Image Container一文中,矩阵的大小取决于所使用的颜色系统。更准确地说,它取决于使用的通道数。在灰度图像的情况下,如下所示:

tutorial_how_matrix_stored_1.png

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

tutorial_how_matrix_stored_2.png

请注意,通道的顺序是相反的:BGR 而不是 RGB。因为在许多情况下,内存足够大,可以以连续的方式存储行,因此行可能会一行接一行的挨着,从而创建一个长行。因为所有数据都是一个接一个,这可能有助于加快扫描过程。可以使用cv::Mat::isContinuous()函数来询问矩阵是否是连续。下一节是示例。

4. Efficient Way 高效的方法

在性能方面,经典的 C 风格 operator[](指针)访问是最快的。因此,推荐的最快的访问方法是:

Mat& ScanImageAndReduceC(Mat& I, const uchar* const table)
{
    // accept only char type matrices
    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);
        for ( j = 0; j < nCols; ++j)  //遍历1行中的像素
        {
            p[j] = table[p[j]];
        }
    }
    return I;
}

上边代码,基本上只是获取指向每行开头的指针并遍历它直到结束。在矩阵以连续方式存储的特殊情况下,只需请求一次指针并一直访问到最后。需要注意彩色图像:有三个通道,所以每行的列索引需要乘以3 (3是channel数量)。

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

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

这样会得到同样的结果。但是,此代码比较难阅读。此外,在实践中,这将将获得相同的性能结果(因为大多数现代编译器可能会自动优化这个小的技巧)。

5. Iterator 迭代器(安全)方法

采用Efficient Way时你需要自己负责确保传递正确数量的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: //彩色图像
        {
            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 项,这已在 OpenCV 中被重新定义为 Vec3bVec3b就是一个uchar类型的数组,长度为 3】。要访问第 n 个子列,使用简单的 operator[] 访问。重要的是要记住 OpenCV 迭代器会遍历列并自动跳到下一行。因此,对于彩色图像,如果使用简单的uchar迭代器,将只能访问蓝色通道值。

6. On-The-Fly 返回引用的即时地址计算

最后一种方法不推荐用于遍历。它被用来获取或修改图像中的随机元素。它的基本用法是指定要访问的项目的行号和列号。在上边遍历的方法中,可能已经注意到图像的类型很重要。这里没有什么不同,因为需要手动指定在自动查找时使用的类型。对于以下源代码的灰度图像,可以观察到这一点(使用 + 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;
}

该函数采用输入类型和坐标查询项目的地址。然后返回对它的引用。当获取值时,这可能是一个常数,而当您设置值时,这可能是一个非常数。仅作为调试模式下的安全步骤* 会检查您的输入坐标是否有效且确实存在。如果不是这种情况,您将在标准错误输出流上得到一个很好的输出消息。与发布模式中的有效方式相比,使用它的唯一区别是,对于图像的每个元素,将获得一个新的行指针,用于我们使用 C 运算符 [] 获取列元素。

如果您需要使用此方法对图像进行多次查找,则为每个访问输入类型和 at 关键字可能既麻烦又耗时。为了解决这个问题,OpenCV 有一个cv::Mat_数据类型。它与 Mat 相同,另外需要在定义时通过查看数据矩阵的内容来指定数据类型,但作为回报,您可以使用 operator() 快速访问项目。为了使事情变得更好,这很容易与通常的cv::Mat数据类型相互转换。在上述函数的彩色图像的情况下,您可以看到一个示例用法。尽管如此,重要的是要注意相同的操作(具有相同的运行时速度)可以用cv::Mat::at完成功能。对于懒惰的程序员技巧来说,这只是一个少写的东西。

7. 内置LUT 函数

这是在图像中实现查找表修改的一种语法糖。在图像处理中,希望将所有给定图像值修改为其他值是很常见的。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);

8. 性能差异

为获得最佳结果,请编译程序并自行运行。为了使差异更加明显,这里使用了一个相当大的 (2560 X 1600) 图像。此处介绍的性能适用于彩色图像。为了获得更准确的值,已经平均调用了函数一百次。

方法时间
Efficient Way79.4717 毫秒
Iterator83.7201 毫秒
On-The-Fly RA93.7878 毫秒
LUT function32.5759 毫秒

可以总结几件事。如果可能,请使用 OpenCV 内部函数(而不是重写这些函数)。最快的方法是 LUT 函数。这是因为 OpenCV 库通过 Intel Threaded Building Blocks 启用了多线程。但是,如果需要编写简单的图像扫描,则首选指针方法(Efficient Way)。迭代器(Iterator)是一个更安全的选择,但速度很慢。在调试模式下,使用on-the-fly reference access 进行全图像扫描是最昂贵的。在发布模式下,它可能会击败迭代器方法,但它不如迭代器安全。

原文地址