【OpenCV C++20 学习笔记】扫描图片数据

应用情景

图像数据扫描的难点

在上一篇文章《基本图像容器——Mat》中,已经详细描述了OpenCV储存图像数据的形式(图像的每个像素储存为一个矩阵中的数据项,矩阵的每个数据项包括各个颜色通道的值,如RGB3通道包含红、绿、蓝共3个通道的颜色值)。所以,矩阵的值的列数为矩阵的列数乘以颜色通道数,如下图所示,OpenCV默认的BGR格式的数据有3个颜色通道,所以实际有m*3列数值;行数则不变:
矩阵数据示意图

如果我们使用uchar(8bits)类型去储存每个像素的值,那么像素的每个颜色通道可以有256个可能的值( 2 8 = 256 2^8=256 28=256),这样的话如果是3通道的数据,那每个数据项就有( 25 6 3 = 16 , 777 , 216 256^3=16,777,216 2563=16,777,216)种可能的颜色值了。如果矩阵很大,那就会给算法的执行带来很大压力。

颜色空间缩减(color space reduction)

为了减轻扫描图像数据的算法压力,可以将现有的颜色值除以某个值,从而缩小颜色值的值域。例如,将所有0-9的颜色值都用0替代,将所有10-19的颜色值都用10替代,依此类推。用数学公式来表示:
I n e w = ( I o l d 10 ) ∗ 10 I_{new} = ( \frac{I_{old}} {10})*10 Inew=(10Iold)10

  • I n e w I_{new} Inew:缩减之后的颜色值,以下简称缩减值
  • I o l d I_{old} Iold:缩减之前的原始颜色值,以下简称原始值
    推而广之,如果想要应用其他的缩减率,比如2,也就是说,0-1都用0来代替,2-3都用1来代替,那除数就会变成2;将这个除数用 d d d来表示,并称之为缩减因子,则公式会变成:
    I n e w = ( I o l d d ) ∗ d I_{new} = ( \frac{I_{old}} {d})*d Inew=(dIold)d
  • I n e w I_{new} Inew:缩减之后的颜色值,以下简称缩减值
  • I o l d I_{old} Iold:缩减之前的原始颜色值,以下简称原始值
  • d d d缩减因子

注意:uchar类型被整数除了之后,得出的结果依然是uchar类型
但是对每个颜色值都执行上述的除法和乘法运算,仍然会消耗很多算力。然而,由于每个颜色值的值域是有限的,比如uchar类型是[0, 256],所以如果能直接计算出所有可能的缩减结果,并进行赋值运算,会节省很多算力。所以,就产生了“查询表”

查询表

查询表就是储存与原始值一一对应的缩减值的数组(一维或多维)。一旦数据类型确定,查询表的大小就确定不变了。比如,uchar类型的查询表就只有256个缩减值,因为原始值总共就只有256种可能。然后运用这个查询表将每个原始值都替换成查询表中对应的值,就不必对每一个原始值都进行缩减计算了,而只是简单的查询和赋值,这样就能节省算力。而且,原始值越多,节省效果越好。这就是查询表的优势。

扫描算法

该实例将3种算法放在一个main函数中,main函数接收一个参数数组argv[],其中有可以有3个或4个参数:

  • 默认参数:程序名(调试时不需要用户指定)
  • 图片文件路径
  • 缩减因子:即上述公式中的 d d d
  • 图片读取格式:以灰度格式读取,则传递“G”;如不指定该参数,则默认采用BGR格式读取
    静态函数help()详细描述了该方法的使用:
static void help()
{
    std::cout
        << "\n--------------------------------------------------------------------------" << endl
        << "这个程序展示如何在OpenCV中扫描图片(cv::Mat)"
        << "根据输入图片路径以及缩减因子(大于0的整数)" << endl
        << "这个程序分别使用了C风格方法、迭代器方法、坐标方法和LUT方法进行扫描" << endl
        << "参数输入:" << endl
        << "程序名 <图片路径> <缩减因子> [G]" << endl
        << "如果加了参数G,则使用灰度格式读入图片" << endl
        << "--------------------------------------------------------------------------" << endl
        << endl;
}

在main函数中,首先对输入的参数进行分析和处理:

if (argc < 3)
{//参数数量小于3个,则退出函数并输出提示
    cout << "Not enough parameters" << endl;
    return -1;
}

Mat I, J;
if (argc == 4 && !strcmp(argv[3], "G"))	//当参数数量为4且,最后一个参数是"G"时
    I = imread(argv[1], IMREAD_GRAYSCALE);	//以灰度格式读取图片,并储存在Mat对象I中
else
    I = imread(argv[1], IMREAD_COLOR);	//否则以BGR格式读取图片,并储存在Mat对象I中

if (I.empty())
{//如果读取的数据为空,则退出函数并输出提示
    cout << "The image" << argv[1] << " could not be loaded." << endl;
    return -1;
}

计算查询表

接下来,根据传入的参数数组中的第3个参数,即argv[2],计算查询表:

int divideWith { 0 }; //1-4行:将字符串转换成数字,并储存在变量divdeWith中作为缩减因子
stringstream s;
s << argv[2];
s >> divideWith;
if (!s || !divideWith)
{//如果无法接收第3个参数,或者参数为0,则退出函数,并输出提示
    cout << "Invalid number entered for dividing. " << endl;
    return -1;
}

uchar table[256];	//用一个长度为256的一维数组来储存查询表
for (int i { 0 }; i < 256; ++i)	//计算查询表,计算结果为uchar类型
    table[i] = static_cast<uchar>(divideWith * (i / divideWith));

在将字符串转换成数字的过程中使用了C++中的字符串流stringstream,字符串流对象s,接收参数字符串argv[2],然后将其传给整数变量divideWith,作为后面进行缩减运算的缩减因子。
计算查询表的for循环块,进行了256次循环,将[0, 255]中的所有整数依次进行了缩减运算,得出256个uchar类型的缩减值,并依次储存在一个uchar类型的数组中。

统计运算时长

这个程序为了比较不同扫描方法的速度,使用了使用了OpenCV中的cv::getTickCount()cv::getTickFrequency()函数进行计时。前者返回某个运行节点的CPU的tick数(一个tick为CPU频率的倒数);后者返回CPU每秒的tick数。如果获取事件起始点和结束点的CPU的tick数,然后用它们的差除以CPU每秒的tick数就能得到事件起始点和结束点之间的时间差,单位为秒。具体代码如下:

double t { static_cast<double>getTickCount() };	//将返回值类型从原本的int型转换为double型
//扫描函数
t = (static_cast<double>getTickCount() - t)/getTickFrequency();	//转换为double型后,除法运算结果中的小数就会被保留
cout << "用时 " << t << " 秒" << endl;

这几行代码放在每个扫描方法的前后,从而能为每个扫描方法计算运行时间,便可比较它们的运行速度

连续内存

虽然储存图像数据的Mat对象可能是个二维甚至多维矩阵,但是在内存中矩阵是被按行分成若干个一维数组储存的。这些一维数组可能被放在一起,形成一个连续的内存空间,也可能被分开储存。OpenCV中的cv::Mat::isContinuous()可以判断矩阵在内存中是否是连续的。被储存在一个连续内存中的矩阵扫描起来会更快。

3种扫描方法

如果没耐心对比每种方法的思路,可以直接结论部分看每种方法的优缺点,然后找到相应方法的章节进行进一步阅读。
但是,除了LUT方法,前三种方法都需要自己编写将替换原始值的语句,该语句中对查询表的索引,思路比较绕,为了避免重复,本文只在第一种方法,即C风格的扫描方法中对此进行了详细解释,如果看不懂可以到该章节进行参考。

C风格的扫描方法

C风格的扫描方法少不了要进行C风格的二维数组的遍历操作,包括行指针、列指针等。

//! [scan-c]
Mat& ScanImageAndReduceC(Mat& I, const uchar* const table)
{
    //只接收uchar类型的矩阵
    CV_Assert(I.depth() == CV_8U);	//depth函数返回每个通道的数值的类型,如CV_8U为8比特无符号字符串类型

    int channels { I.channels() };	//channels函数返回矩阵中的颜色通道数

    int nRows { I.rows };
    int nCols { I.cols * channels };	//实际列数为矩阵列数*颜色通道数

    if (I.isContinuous())
    {//如果是储存在连续空间,则按一维数组来处理
        nCols *= nRows;	//一维数组的实际列数为原列数*行数
        nRows = 1;	//一维数组行数为1
    }

    int i, j;
    uchar* p;	//uchar类型的p指针用来储存扫描结果
    for (i = 0; i < nRows; ++i)
    {
        p = I.ptr<uchar>(i);	//ptr模板函数根据行数i返回矩阵第i行的行指针,并在尖括号中指定返回的指针类型
        for (j = 0; j < nCols; ++j)
        {//原来的p[j]是个列指针,实际为矩阵i行j列的值,即颜色的原始值
        //因为查询表示按0-255的顺序排列的,
        //所以,以p[j]为下标访问查询表,正好能访问到原始值对应的缩减值
            p[j] = table[p[j]];	//将查询表中的缩减值赋值给p指针中对应的位置
            //由于p是指向Mat对象I中的矩阵的,所以I实际上也被更改了
        }
    }
    return I;	//返回被更改的I
}
//! [scan-c]

注意,该函数传入的第2个参数为uchar类型的常量指针常量,即这个指针指向的对象不能被修改,指针的地址也不能被修改,这样才能保证查询表在函数运行结束之后仍然没变,可以被再次利用。
该函数最核心的部分就是对数组进行遍历操作的for循环语句。这里巧妙地使用列指针,即原始值,作为访问查询表数组的下标,从而找到对应的缩减值。具体思路写在注释里了,读者可以参阅。
当矩阵是被储存在连续的内存空间中的时候,实际上是对一个一维数组进行遍历,i为1,只循环1次。
如果确定矩阵是储存在连续内存空间中的,那么还有另外一种方法可以完成对它的遍历:

uchar* p { I.data };	//data是Mat类的public数据成员,它储存了Mat对象首行的行指针

for(unsinged int i { 0 }; i < ncol*nrows; ++i)
	//行指针自增之后就变成了列指针,即一维数组第i列的地址
	//再进行解引用操作,就得到了第i列的值
	*p++ = table[*p];

迭代器方法

使用迭代器比用数组指针更加方便,因为不用考虑行指针、列指针的问题,没有嵌套的for循环,只需要一层for循环。
但是,迭代器只能代替矩阵中的数据项。也就是说,如果是单通道的灰度格式的图片数据,迭代器正好代替矩阵中的每个颜色值;但是,如果是3通道的BGR格式的图片数据,迭代器实际上代替的是一个长度为3的数组,其中包含了3个颜色值(分别为蓝、绿、红值),在这种情况下还必须对迭代器进行进一步的操作。

//! [scan-iterator]
Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table)
{
    CV_Assert(I.depth() == CV_8U);

    const int channels{ I.channels() };
    switch (channels)
    {//switch语句实现对两种情况的分别处理
    case 1:	//单通道的灰度图片数据
    {
        MatIterator_<uchar> it, end;	//声明起始和终止迭代器
        for (it = I.begin<uchar>(), end = I.end<uchar>(); it != end; ++it)
        //begin和end函数分别返回指向第一个和最后一个数据项的迭代器
            *it = table[*it];	//对迭代器进行解引用操作可获得对应的数据项
        break;
    }
    case 3:	//3通道的BGR图片数据
    {
        MatIterator_<Vec3b> it, end;
        for (it = I.begin<Vec3b>(), end = I.end<Vec3b>(); it != end; ++it)
        {//还需要对迭代器中的3个值分别进行赋值操作
        	//*it[0]为数据项在第一个颜色通道的值
            (*it)[0] = table[(*it)[0]];
            (*it)[1] = table[(*it)[1]];
            (*it)[2] = table[(*it)[2]];
        }
    }
    }

    return I;
}
//! [scan-iterator]

如果在3通道的情况,没有对迭代器中的数组进行进一步的操作的话,那改变的只是每个像素的蓝色值。因为OpenCV将RGB转换成了BGR,第一个值是蓝色值。

坐标方法

这种方法其实一般是用来确定需要某个数据项的行数和列数的,也称为随机获取,所以并不建议用来扫描图像数据。
这种方法也需要对单通道和3通道数据进行分情况的处理。
在单通道数据中,该方法运用了数据项的动态地址,并返回数据项的引用,这由函数cv::Mat::at()来实现;
在3通道数据中,该方法运用了Mat_类型达到了同样的目的。/Mat_类型为储存了每个数据项的类型信息的Mat类型;Mat_类型可以用(row, col)方式来访问;这种访问等同于在Mat类型中用at(row, col)来进行访问
具体解释见代码注释:

//! [scan-random]
Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table)
{
    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)
            	//at函数获取矩阵在i行j列的数据项的地址,并返回它的引用
                I.at<uchar>(i, j) = table[I.at<uchar>(i, j)];
        break;
    }
    case 3:
    {
    	//Vec3b类型为OpenCV中定义的3字节数据类型,用来储存长度为3的uchar类型数组正好
        Mat_<Vec3b> _I = I;	//这里不能用C++20的初始化语法,会报错:无法转化参数类型

        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;
}
//! [scan-random]

LUT方法

在OpenCV的core模块,有一个专门用来修改图片数据矩阵中的值的方法,cv::LUT()。其具体用法如下:

//! [table-init]
Mat lookUpTable(1, 256, CV_8U); //用Mat构造函数创建uchar类型的1维矩阵
uchar* p = lookUpTable.ptr();	//获取矩阵的首地址
for (int i = 0; i < 256; ++i)
    p[i] = table[i];	//将之前计算的查询表中的数值复制到矩阵中
//! [table-init]

//! [table-use]
LUT(I, lookUpTable, J);
//! [table-use]

cv::LUT()函数使用了3个参数

  • 需要进行修改的原始矩阵,输入矩阵
  • 查询表矩阵
  • 接收修改结果的矩阵,输出矩阵
    该函数没有返回值。只用一条语句,就实现了用查询表矩阵中的缩减值替换输入矩阵中的原始值,然后输出替换结果。

算法效率对比

将3种方法整合在一个cpp文件中,并统计每个方法的运算时长。整合后的代码如下:

#include <opencv2/core.hpp>
#include <opencv2/core/utility.hpp>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/highgui.hpp>

import <iostream>;
import <sstream>;

using namespace cv;
using namespace std;

static void help()
{
    std::cout
        << "\n--------------------------------------------------------------------------" << endl
        << "这个程序展示如何在OpenCV中扫描图片(cv::Mat)"
        << "根据输入图片路径以及缩减因子(大于0的整数)" << endl
        << "这个程序分别使用了C风格方法、迭代器方法、坐标方法和LUT方法进行扫描" << endl
        << "参数输入:" << endl
        << "程序名 <图片路径> <缩减因子> [G]" << endl
        << "如果加了参数G,则使用灰度格式读入图片" << endl
        << "--------------------------------------------------------------------------" << endl
        << endl;
}

Mat& ScanImageAndReduceC(Mat& I, const uchar* table);
Mat& ScanImageAndReduceIterator(Mat& I, const uchar* table);
Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* table);

int main(int argc, char* argv[])
{
    help();
    if (argc < 3)
    {
        std::cout << "缺少参数" << endl;
        return -1;
    }

    Mat I, J;
    if (argc == 4 && !strcmp(argv[3], "G"))
        I = imread(argv[1], IMREAD_GRAYSCALE);
    else
        I = imread(argv[1], IMREAD_COLOR);

    if (I.empty())
    {
        std::cout << "图片" << argv[1] << "打不开。" << endl;
        return -1;
    }

    //! [dividewith]
    int divideWith{ 0 };
    stringstream s;
    s << argv[2];
    s >> divideWith;
    if (!s || !divideWith)
    {
        std::cout << "无效缩减因子。 " << endl;
        return -1;
    }

    uchar table[256];
    for (int i{ 0 }; i < 256; ++i)
        table[i] = static_cast<uchar>(divideWith * (i / divideWith));
    //! [dividewith]

    const int times{ 100 };
    double t;

    t = static_cast<double>(getTickCount());

    for (int i{ 0 }; i < times; ++i)
    {
        Mat clone_i{ I.clone() };
        J = ScanImageAndReduceC(clone_i, table);
    }

    t = 1000 * (static_cast<double>(getTickCount()) - t) / getTickFrequency();
    t /= times;

    std::cout << "C风格方法每运行"
        << times << "次平均耗时: " << t << "毫秒。" << endl;

    t = static_cast<double>(getTickCount());

    for (int i{ 0 }; i < times; ++i)
    {
        Mat clone_i{ I.clone() };
        J = ScanImageAndReduceIterator(clone_i, table);
    }

    t = 1000 * (static_cast<double>(getTickCount()) - t) / getTickFrequency();
    t /= times;

    std::cout << "迭代器方法每运行"
        << times << "次平均耗时:" << t << "毫秒。" << endl;

    t = static_cast<double>(getTickCount());

    for (int i{ 0 }; i < times; ++i)
    {
        Mat clone_i{ I.clone() };
        ScanImageAndReduceRandomAccess(clone_i, table);
    }

    t = 1000 * (static_cast<double>(getTickCount()) - t) / getTickFrequency();
    t /= times;

    std::cout << "坐标方法每运行"
        << times << "次平均耗时:" << t << "毫秒。" << endl;

    //! [table-init]
    Mat lookUpTable(1, 256, CV_8U);
    uchar* p{ lookUpTable.ptr() };
    for (int i{ 0 }; i < 256; ++i)
        p[i] = table[i];
    //! [table-init]

    t = static_cast<double>(getTickCount());

    for (int i{ 0 }; i < times; ++i)
        //! [table-use]
        LUT(I, lookUpTable, J);
    //! [table-use]

    t = 1000 * (static_cast<double>(getTickCount()) - t) / getTickFrequency();
    t /= times;

    std::cout << "LUT方法每运行 "
        << times << "次平均耗时:" << t << "毫秒。" << endl;
    return 0;
}

//! [scan-c]
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)
        {
            p[j] = table[p[j]];
        }
    }
    return I;
}
//! [scan-c]

//! [scan-iterator]
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;
}
//! [scan-iterator]

//! [scan-random]
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;
}
//! [scan-random]

要调试程序,需要输入参数的main函数。在VS中,可以在项目属性中提前输入参数,如下图中黑体的th.jpg 10,就是用空格隔开的两个参数。注意第一个参数可以不用输入,默认为项目程序名。
输入main函数参数
我这里使用的th.jpg,是一个1920*1200的图片。这是为了测试处理比较大的图片的时候各种方法的性能。
调试运行结果为:
图片扫描结果

结论

从运行结果可以看到用时最短的是LUT方法,这是因为OpenCV库使用了多线程方法加快了运行速度。但这并不代表LUT方法永远是最好的,其他方法的优缺点如下:

  • 给简单的小图片写扫描程序的时候,用C风格的数组方法更好,因为没必要动用多线程;
  • 迭代器方法更安全,但是相对较慢
  • 坐标的方法需要获取动态引用,是在调试模式中耗时最多的方法,但是在发行模式中可能会比迭代器方法更快;不过肯定没有迭代器方法安全

** 文章较长,不满有遗漏或笔误,欢迎大家指正!**

  • 14
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值