本章包括以下内容:
- 访问像素值;
- 用指针扫描图像;
- 用迭代器扫描图像;
- 编写高效的图像扫描循环;
- 扫描图像并访问相邻像素;
- 实现简单的图像运算;
- 图像重映射。
2.2 访问像素值
我们将创建一个简单的函数,用它在图像中加入椒盐噪声。这里只是随机选择一些像素,把它们设置为白色。
创建一个接受输入图像的函数,在函数中对图像进行修改。第二个参数是需要改成白色的像素数量。
void salt(cv::Mat image, int n) {
// C++11 的随机数生成器
std::default_random_engine generator;
std::uniform_int_distribution<int> randomRow(0, image.rows - 1);
std::uniform_int_distribution<int> randomCol(0, image.cols - 1);
int i,j;
for (int k=0; k<n; k++) {
// 随机生成图形位置
i= randomCol(generator);
j= randomRow(generator);
if (image.type() == CV_8UC1) { // 灰度图像
// 单通道 8 位图像
image.at<uchar>(j,i)= 255;
} else if (image.type() == CV_8UC3) { // 彩色图像
// 3 通道图像
image.at<cv::Vec3b>(j,i)[0]= 255;
image.at<cv::Vec3b>(j,i)[1]= 255;
image.at<cv::Vec3b>(j,i)[2]= 255;
// or simply:
// image.at<cv::Vec3b>(j, i) = cv::Vec3b(255, 255, 255);
}
}
}
现在你可以调用这个函数,并传入已经打开的图像。
// 打开图像
cv::Mat image= cv::imread("boldt.jpg",1);
// 调用函数以添加噪声
salt(image,3000);
// 显示结果
cv::namedWindow("Image");
cv::imshow("Image",image);
cv::Mat 类包含多种方法,可用来访问图像的各种属性:
- 利用公共成员变量 cols 和 rows 可得到图像的列数和行数;
- 利用cv::Mat 的 at(int y,int x) 方法可以访问元素,其中 x 是列号,y 是行号。
在编译时必须明确方法返回值的类型,因为cv::Mat 可以接受任何类型的元素,所以程序员需要指定返回值的预期类型。正因为如此,at 方法被实现成一个模板方法。在调用at 方法时,你必须指定图像元素的类型。
cv::Mat_ 模板类
如果已经知道矩阵的类型,就可以使用 cv::Mat_类(cv::Mat 类的模板子类)。cv::Mat_类定义了一些新的方法,但没有定义新的数据属性,因此这两个类的指针或引用可以直接互相转换。
新方法中有一个 operator(),可用来直接访问矩阵的元素。因此可以这样写代码(其中image是一个对应uchar 矩阵的cv::Mat 变量):
// 用 Mat 模板操作图像
cv::Mat_<uchar> img(image);
img(50,100)= 0; // 访问第50 行、第100 列处那个值
在创建cv::Mat_变量时,我们就定义了它的元素类型,因此在编译时就已经知道了operator()的返回类型。使用操作符operator()和使用at 方法产生的结果是完全相同的,只是前者的代码更简短。
2.3 指针扫描图像
本节和下一节将展示几种实现高效扫描循环的方法,本节将使用指针运算。
为了说明图像扫描的过程,我们来做一个简单的任务:减少图像中颜色的数量。
基本的减色算法很简单。假设 N 是减色因子,将图像中每个像素的值除以N(这里假定使用整数除法,不保留余数)。然后将结果乘以N,得到 N 的倍数,并且刚好不超过原始像素值。加上N / 2,就得到相邻的 N 倍数之间的中间值。对所有 8 位通道值重复这个过程,就会得到 (256 / N) × (256 / N) × (256 / N) 种可能的颜色值。
处理过程很简单,只要创建一个二重循环遍历所有像素值,代码如下所示:
void colorReduce(cv::Mat image, int div=64) {
int nl= image.rows; // 行数
int nc= image.cols * image.channels(); // 每行的元素数量
for (int j=0; j<nl; j++) {
// 取得行j 的地址
uchar* data= image.ptr<uchar>(j);
for (int i=0; i<nc; i++) {
// 处理每个像素 ---------------------
data[i]= data[i]/div*div + div/2;
// 像素处理结束 ----------------
} // 一行结束
}
}
可以用下面的代码片段测试这个函数:
// 读取图像
image= cv::imread("boldt.jpg");
// 处理图像
colorReduce(image,64);
// 显示图像
cv::namedWindow("Image");
cv::imshow("Image",image);
在彩色图像中,图像数据缓冲区的前3 字节表示左上角像素的三个通道的值,接下来的3字节表示第1 行的第2 个像素,以此类推(注意OpenCV 默认的通道次序为BGR)。
用下面的代码可获得每一行中像素值的个数:
int nc= image.cols * image.channels();
为了简化指针运算的计算过程,cv::Mat 类提供了一个方法,可以直接访问图像中一行的起始地址。这就是 ptr 方法,它是一个模板方法,返回第 j 行的地址:
uchar* data= image.ptr<uchar>(j);
其他减色算法
减色计算也可以使用取模运算符,它可以直接得到div 的倍数,代码如下所示:
data[i]= data[i] – data[i]%div + div/2;
另外还可以使用位运算符。如果把减色因子限定为2 的指数,即div=pow(2,n),那么把像素值的前 n 位掩码后就能得到最接近的 div 的倍数。可以用简单的位移操作获得掩码,代码如下所示:
// 用来截取像素值的掩码
uchar mask= 0xFF<<n; // 如div=16,则mask= 0xF0
可用下面的代码实现减色运算:
*data &= mask; // 掩码
*data++ += div>>1; // 加上div/2
// 这里的+也可以改用“按位或”运算符
一般来说,使用位运算的代码运行效率很高,因此在效率为重时,位运算是不二之选。
使用输入和输出参数
前面的减色函数直接在输入图像中进行了转换,这称为就地转换。这种做法不需要额外的图像来输出结果,可以减少内存的使用。但是有的程序不希望对原始图像进行修改,这时就必须在调用函数前备份图像。请注意,对图像进行深复制最简单的方法是使用clone()方法,如下面的代码所示:
// 读入图像
image= cv::imread("boldt.jpg");
// 复制图像
cv::Mat imageClone= image.clone();
// 处理图像副本
// 原始图像保持不变
colorReduce(imageClone);
// 显示结果图像
cv::namedWindow("Image Result");
cv::imshow("Image Result",imageClone);
如果在定义函数时,能允许用户选择是否要采用就地处理,就可以避免这些额外的过程。方法的签名为:
void colorReduce(const cv::Mat &image, // 输入图像
cv::Mat &result, // 输出图像
int div=64);
注意,输入图像是一个引用的const,表示这幅图像不会在函数中修改。输出图像是一个引用参数,在函数中会被修改,并且返回给调用这个函数的代码。如果需要就地处理,可以在输入和输出参数中用同一个image 变量:
colorReduce(image,image);
否则就可以提供一个cv::Mat 实例:
cv::Mat result;
colorReduce(image,result);
这里的关键是先检查输出图像,验证它是否分配了一定大小的数据缓冲区,以及像素类型与输入图像是否相符——所幸cv::Mat 的create 方法中已经包含了这个检查过程。当你用新的大小和像素类型重新分配矩阵时,就要调用create 方法。如果矩阵已有的大小和类型刚好与指定的大小和类型相同,这个方法就不会执行任何操作,也不会修改实例,而只是直接返回。
因此,函数中首先要调用create 方法,构建一个大小和类型都与输入图像相同的矩阵(如果必要):
result.create(image.rows,image.cols,image.type());
分配的内存块的大小表示为total()*elemSize()。扫描过程中使用两个指针:
for (int j=0; j<nl; j++) {
// 取得行j 的地址
const uchar* data_in= image.ptr<uchar>(j);
uchar* data_out= result.ptr<uchar>(j);
for (int i=0; i<nc*nchannels; i++) {
// 处理每个像素 ---------------------
data_out[i]= data_in[i]/div*div + div/2;
// 像素处理结束 ----------------
} // 一行结束
}
}
对连续图像的高效扫描
为了提高性能,可以在图像的每行末尾用额外的像素进行填充。有趣的是,在去掉填充后,图像仍可被看作一个包含W×H 像素的长一维数组。用cv::Mat 的isContinuous方法可轻松判断图像有没有被填充。如果图像中没有填充像素,它就返回true。我们还能这样测试矩阵的连续性:
// 检查行的长度(字节数)与“列的个数×单个像素”的字节数是否相等
image.step == image.cols*image.elemSize();
为确保完整性,测试时还需要检查矩阵是否只有一行;如果是,这个矩阵就是连续的。但是不管哪种情况,都可以用isContinuous 方法检查矩阵的连续性。在一些特殊的处理算法中,你可以充分利用图像的连续性,在单个(更长)循环中处理图像。处理函数就可以改为:
void colorReduce6(cv::Mat image, int div=64) {
int nl= image.rows; // 行数
int nc= image.cols * image.channels(); // 每行的元素总数
if (image.isContinuous()) {
// 没有填充的像素
nc= nc*nl;
nl= 1; // 它现在成了一个一维数组
}
int n= static_cast<int>(log(static_cast<double>(div))/log(2.0) + 0.5);
// 用来截取像素值的掩码
uchar mask= 0xFF<<n; // 如果div=16, 那么mask= 0xF0
uchar div2 = div >> 1; // div2 = div/2
// 对于连续图像,这个循环只执行一次
for (int j=0; j<nl; j++) {
uchar* data= image.ptr<uchar>(j);
for (int i=0; i<nc; i++) {
*data &= mask;
*data++ += div2;
} // 一行结束
}
}
如果连续性测试结果表明图像中没有填充像素,我们就把宽度设为1,高度设为W×H,从而去除外层的循环。注意,这里还需要用reshape 方法。本例中需要这样写:
if (image.isContinuous()) {
// 没有填充像素
image.reshape(1, // 新的通道数
1) ; // 新的行数
}
int nl= image.rows; // 行数
int nc= image.cols*image.channels() ;
如果是用reshape 方法修改矩阵的维数,就不需要复制内存或重新分配内存了。第一个参数是新的通道数,第二个参数是新的行数。列数会进行相应的修改。
2.4 用迭代器扫描图像
OpenCV 也提供了 cv::Mat 的迭代器类,并且与C++ STL 中的标准迭代器兼容。
要得到 cv::Mat 实例的迭代器,首先要创建一个cv::MatIterator_对象。跟cv::Mat_类似,这个下划线表示它是一个模板子类。
必须在编译时就明确返回值的类型。可以这样定义彩色图像的迭代器:
cv::MatIterator_<cv::Vec3b> it;
也可以使用在Mat_模板类内部定义的iterator 类型:
cv::Mat_<cv::Vec3b>::iterator it;
然后就可以使用常规的迭代器方法 begin 和 end 对像素进行循环遍历了。现在,减色函数可以这样编写:
void colorReduce(cv::Mat image, int div=64) {
// div 必须是2 的幂
int n= static_cast<int>(log(static_cast<double>(div))/log(2.0) + 0.5);
// 用来截取像素值的掩码
uchar mask= 0xFF<<n; // e.g. for div=16, mask= 0xF0
uchar div2 = div >> 1; // div2 = div/2
// 迭代器
cv::Mat_<cv::Vec3b>::iterator it= image.begin<cv::Vec3b>();
cv::Mat_<cv::Vec3b>::iterator itend= image.end<cv::Vec3b>();
// 扫描全部像素
for ( ; it!= itend; ++it) {
(*it)[0]&= mask;
(*it)[0]+= div2;
(*it)[1]&= mask;
(*it)[1]+= div2;
(*it)[2]&= mask;
(*it)[2]+= div2;
}
}
首先你要使用合适的专用类创建迭代器对象,在本例中是cv::Mat_<cv::Vec3b>::iterator (或cv::MatIterator_<cv::Vec3b>)。
然后可以用begin 方法,在开始位置(本例中为图像的左上角)初始化迭代器。对于彩色图像的cv::Mat 实例,可以使用image.begin<cv::Vec3b>()。
还可以在迭代器上使用数学计算,例如若要从图像的第二行开始,可以用image.begin<cv::Vec3b>()+image.cols 初始化cv::Mat 迭代器。
获取集合结束位置的方法也类似,只是改用end 方法。但是,用end 方法得到的迭代器已经超出了集合范围,因此必须在结束位置停止迭代过程。结束的迭代器也能使用数学计算,例如你想在最后一行前就结束迭代,可使用image.end<cv::Vec3b>()-image.cols。
最后,在循环内部使用取值运算符*来访问当前元素,你可以用它来读(例如element=*it;)或写(例如*it= element;)。
你也可以创建常量迭代器,用作对常量cv::Mat 的引用,或者表示当前循环不修改cv::Mat 实例。常量迭代器的定义如下所示:
cv::MatConstIterator_<cv::Vec3b> it;
// or:
cv::Mat_<cv::Vec3b>::const_iterator it;
2.5 编写高效的图像扫描循环
在编写图像处理函数时,你需要充分考虑运行效率。在设计函数时,你要经常检查代码的运行效率,找出处理过程中可能使程序变慢的瓶颈。
但是有一点非常重要,除非确实必要,不要以牺牲代码的清晰度来优化性能。简洁的代码总是更容易调试和维护。只有对程序效率至关重要的代码段,才需要进行重度优化。
OpenCV 有一个非常实用的函数可以用来测算函数或代码段的运行时间,它就是cv::getTickCount(),该函数会返回从最近一次计算机开机到当前的时钟周期数。在代码开始和结束时记录这个时钟周期数,就可以计算代码的运行时间。
若想得到以秒为单位的代码运行时间,可使用另一个方法cv::getTickFrequency(),它返回每秒的时钟周期数,这里假定CPU的频率是固定的(对于较新的CPU,频率并不一定是固定的)。为了获得某个函数(或代码段)的运行时间,通常需使用这样的程序模板:
const int64 start = cv::getTickCount();
colorReduce(image); // 调用函数
// 经过的时间(单位:秒)
double duration = (cv::getTickCount()-start)/cv::getTickFrequency();
对于可以预先计算的数值,要避免在循环中做重复计算,继而浪费时间。例如,这样写减色函数是很不明智的:
for (int i=0; i<image.cols * image.channels(); i++) {
*data &= mask;
*data++ += div/2;
上面的代码需要反复计算每行的像素数量和div/2 的结果。改进后的代码为:
int nc= image.cols * image.channels();
uchar div2= div>>1;
for (int i=0; i<nc; i++) {
*(data+i) &= mask;
*(data+i) += div2;
在处理的元素总数相同,使用较短的循环和多条语句通常也要比使用较长的循环和单条语句的运行效率高。
与之类似,如果你要对一个像素执行N 个不同的计算过程,那就在单个循环中执行全部计算,而不是写N 个连续的循环,每个循环执行一个计算。
2.6 扫描图像并访问相邻像素
在图像处理中经常有这样的处理函数,它在计算每个像素的数值时,需要使用周边像素的值。如果相邻像素在上一行或下一行,就需要同时扫描图像的多行。本节将介绍实现方法。
为了便于说明问题,我们将使用一个锐化图像的处理函数。它基于拉普拉斯算子。在图像处理领域有一个众所周知的结论:如果从图像中减去拉普拉斯算子部分,图像的边缘就会放大,因而图像会变得更加尖锐。
可以用以下方法计算锐化的数值:
sharpened_pixel= 5*current-left-right-up-down;
这里的 left 是与当前像素相邻的左侧像素,up 是上一行的相邻像素,以此类推。
图像扫描中使用了三个指针,一个表示当前行、一个表示上面的行、一个表示下面的行。另外,因为在计算每一个像素时都需要访问与它相邻的像素,所以有些像素的值是无法计算的,比如第一行、最后一行和第一列、最后一列的像素。这个循环可以这样写:
void sharpen(const cv::Mat &image, cv::Mat &result) {
result.create(image.size(), image.type());// 判断是否需要分配图像数据。如果需要,就分配
int nchannels= image.channels();
for (int j= 1; j<image.rows-1; j++) { // 处理所有行(除了第一行和最后一行)
const uchar* previous= image.ptr<const uchar>(j-1); // 上一行
const uchar* current= image.ptr<const uchar>(j); // 当前行
const uchar* next= image.ptr<const uchar>(j+1); // 下一行
uchar* output= result.ptr<uchar>(j); // 输出行
for (int i=nchannels; i<(image.cols-1)*nchannels; i++) {
// 应用锐化算子
*output++= cv::saturate_cast<uchar>(5*current[i]-current[i-nchannels]-current[i+nchannels]-previous[i]-next[i]);
}
}
// 把未处理的像素设为0
result.row(0).setTo(cv::Scalar(0));
result.row(result.rows-1).setTo(cv::Scalar(0));
result.col(0).setTo(cv::Scalar(0));
result.col(result.cols-1).setTo(cv::Scalar(0));
}
这个函数是如何同时适应灰度图像和彩色图像的。
若要访问上一行和下一行的相邻像素,只需定义额外的指针,并与当前行的指针一起递增,然后就可以在扫描循环内访问上下行的指针了。
在计算输出像素的值时,我们调用了cv::saturate_cast 模板函数,并传入运算结果。这是因为计算像素的数学表达式的结果经常超出允许的范围(即小于0 或大于255)。使用这个函数可把结果调整到8 位无符号数的范围内,具体做法是把小于0 的数值调整为0,大于255 的数值调整为255——这就是cv::saturate_cast<uchar>函数的作用。
此外,如果输入参数是浮点数,就会得到最接近的整数。可以在调用这个函数时显式地指定其他数据类型,以确保结果在该数据类型定义的范围之内。
由于边框上的像素没有完整的相邻像素,因此不能用前面的方法计算,需要另行处理。这里简单地把它们设置为0。有时也可以对这些像素做特殊的计算,但在大多数情况下,花时间处理这些极少数像素是没有意义的。
我们用setTo 方法来实现这个功能,此方法将对矩阵中的所有元素赋值,代码如下所示:
result.row(0).setTo(cv::Scalar(0));
鉴于滤波是图像处理中的常见操作,OpenCV 专门为此定义了一个函数,即cv::filter2D。要使用这个函数,只需要定义一个内核(以矩阵的形式),调用函数并传入图像和内核,即可返回滤波后的图像。
因此,使用这个函数重新定义锐化函数非常容易:
void sharpen2D(const cv::Mat &image, cv::Mat &result) {
// 构造内核(所有入口都初始化为0)
cv::Mat kernel(3,3,CV_32F,cv::Scalar(0));
// 对内核赋值
kernel.at<float>(1,1)= 5.0;
kernel.at<float>(0,1)= -1.0;
kernel.at<float>(2,1)= -1.0;
kernel.at<float>(1,0)= -1.0;
kernel.at<float>(1,2)= -1.0;
// 对图像滤波
cv::filter2D(image,result,image.depth(),kernel);
}
这种实现方式得到的结果与前面的完全相同(执行效率也相同)。如果处理的是彩色图像,三个通道可以应用同一个内核。注意,使用大内核的filter2D 函数是特别有利的,因为这时它使用了更高效的算法。
2.7 实现简单的图像运算
OpenCV提供了很多图像算法运算符,本节将讨论它们的用法。
我们可以使用 cv::add 函数来实现相加功能,但因为这次是想得到加权和,因此使用更精确的 cv::addWeighted函数:
cv::addWeighted(image1,0.7,image2,0.9,0.,result);
所有二进制运算函数的用法都一样:提供两个输入参数,指定一个输出参数。有时还可以指定加权系数,作为运算时的缩放因子。每个函数都可以有多种格式,cv::add 是典型的具有多种格式的函数:
// c[i]= a[i]+b[i];
cv::add(imageA,imageB,resultC);
// c[i]= a[i]+k;
cv::add(imageA,cv::Scalar(k),resultC);
// c[i]= k1*a[i]+k2*b[i]+k3;
cv::addWeighted(imageA,k1,imageB,k2,k3,resultC);
// c[i]= k*a[i]+b[i];
cv::scaleAdd(imageA,k,imageB,resultC);
看一下 cv::subtract、cv::absdiff、cv::multiply 和cv::divide 等函数的多种格式。此外还有位运算符(对像素的二进制数值进行按位运算)cv::bitwise_and、cv::bitwise_or、cv::bitwise_xor 和cv::bitwise_not。cv::min 和cv::max 运算符也非常实用,它们能找到每个元素中最大或最小的像素值。
在所有场合都要使用cv::saturate_cast 函数(详情请参见2.6 节),以确保结果在预定的像素值范围之内(避免上溢或下溢)。
这些图像必定有相同的大小和类型(如果与输入图像的大小不匹配,输出图像会重新分配)。由于运算是逐个元素进行的,因此可以把其中的一个输入图像用作输出图像。
还有运算符使用单个输入图像,它们是cv::sqrt、cv::pow、cv::abs、cv::cuberoot、cv::exp 和cv::log。
重载图像运算符
OpenCV 的大多数运算函数都有对应的重载运算符,因此调用cv::addWeighted 的语句也可以写成:
result= 0.7*image1+0.9*image2;
这种代码更加紧凑也更容易阅读。这两种计算加权和的方法是等效的。特别指出,这两种方法都会调用cv::saturate_cast 函数。
分割图像通道
我们有时需要分别处理图像中的不同通道,例如只对图像中的一个通道执行某个操作。这当然可以通过图像扫描循环实现,但也可以使用cv::split 函数,将图像的三个通道分别复制到三个cv::Mat 实例中。假设我们要把一张雨景图只加到蓝色通道中,可以这样实现:
// 创建三幅图像的向量
std::vector<cv::Mat> planes;
// 将一个三通道图像分割为三个单通道图像
cv::split(image1,planes);
// 加到蓝色通道上
planes[0]+= image2;
// 将三个单通道图像合并为一个三通道图像
cv::merge(planes,result);
2.8 图像重映射
最后一节来看看如何通过移动像素修改图像的外观。这个过程不会修改像素值,而是把每个像素的位置重新映射到新的位置。
要使用OpenCV 的remap 函数,首先需要定义在重映射处理中使用的映射参数,然后把映射参数应用到输入图像。
这里定义一个转换函数,在图像上创建波浪形效果:
void wave(const cv::Mat& image, cv::Mat& result) {
// 映射参数
cv::Mat srcX(image.rows, image.cols, CV_32F); // x-map
cv::Mat srcY(image.rows, image.cols, CV_32F); // y-map
// 创建映射参数
for (int i = 0; i < image.rows; i++) {
for (int j = 0; j < image.cols; j++) {
// (i,j)像素的新位置
srcX.at<float>(i, j) = j;
srcY.at<float>(i, j) = i + 3 * sin(j / 6.0);
}
}
// 应用映射参数
cv::remap(image, // 源图像
result, // 目标图像
srcX, // x 映射
srcY, // y 映射
cv::INTER_LINEAR); // 填补方法
}
在OpenCV中,可以用两个映射参数来说明反向映射:一个针对x 坐标,另一个针对y 坐标。它们都用浮点数型的cv::Mat 实例来表示:
// 映射参数
cv::Mat srcX(image.rows,image.cols,CV_32F); // x 方向
cv::Mat srcY(image.rows,image.cols,CV_32F); // y 方向
这些矩阵的大小决定了目标图像的大小。用下面的代码可以从原始图像获得目标图像中(i,j)像素的值:
( srcX.at<float>(i,j) , srcY.at<float>(i,j) )
图像翻转效果也可以用下面的映射参数创建:
// 创建映射参数
for (int i=0; i<image.rows; i++) {
for (int j=0; j<image.cols; j++) {
// 水平翻转
srcX.at<float>(i,j)= image.cols-j-1;
srcY.at<float>(i,j)= i;
}
}