扫描图片数据
- 应用情景
- 图像数据扫描的难点
- 颜色空间缩减(color space reduction)
- 查询表
- 扫描算法
- 计算查询表
- 统计运算时长
- 连续内存
- 3种扫描方法
- C风格的扫描方法
- 迭代器方法
- 坐标方法
- LUT方法
- 算法效率对比
- 结论
应用情景
图像数据扫描的难点
在上一篇文章《基本图像容器——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,就是用空格隔开的两个参数。注意第一个参数可以不用输入,默认为项目程序名。
我这里使用的th.jpg,是一个1920*1200的图片。这是为了测试处理比较大的图片的时候各种方法的性能。
调试运行结果为:
结论
从运行结果可以看到用时最短的是LUT方法,这是因为OpenCV库使用了多线程方法加快了运行速度。但这并不代表LUT方法永远是最好的,其他方法的优缺点如下:
- 给简单的小图片写扫描程序的时候,用C风格的数组方法更好,因为没必要动用多线程;
- 迭代器方法更安全,但是相对较慢
- 坐标的方法需要获取动态引用,是在调试模式中耗时最多的方法,但是在发行模式中可能会比迭代器方法更快;不过肯定没有迭代器方法安全
** 文章较长,不免有遗漏或笔误,欢迎大家指正!**