OpenCV复习(二) 操作像素

主要内容:

  • 存取像素值
  • 使用指针遍历图像
  • 使用迭代器遍历图像
  • 编写高效的图像遍历循环
  • 遍历图像和邻域操作
  • 进行简单的图像算数
  • 定义感兴趣区域

引言

存取、修改和创建图像上个博客简单复习了一下。

这个博客主要是如何操作图像的基本元素,就是像素。

必须得了解下高效的处理方法,因为每张图片的像素可能很多(数以万计)。

本质上来说,一张图像是由数值组成的矩阵,cv::Mat这个数据结构也是由此而来。

矩阵的每一个元素代表一个像素。

存取像素值

要存取像素值,需要在代码中指定元素所在的行和列。程序会返回相应的元素。

若图像是单通道的,返回是单个数值;如果图像是多通道的,返回值则是一组向量(Vector)。

来通过一个椒盐噪声函数,体验一下对像素值的操作吧。

实现方法

首先是图像相加。当我们需要一些图像特效或者在图像上叠加信息时,就需要用到图像加法。

我们通过调用函数cv::add ,更准确地说时cv::addWeighted来完成图像加法,因为我们需要地是加权和。函数调用如下:

cv::addWeighted(image1,0.7,image2,0.9,0.,result);

作用原理

创建一个函数,它的第一个参数是一张图片,该函数会修改此图像。

为达到这个目的,我们使用传引用的参数传递方式,

第二个参数是我们设定的白色像素点的数目:

void salt(cv::Mat &image,int n)
{
for(int k = 0;k < n;k++)
    {
        //rand()是随机数生成函数
        int i = rand()%image.cols;
        int j = rand()%image.rows;

        if(image.channels()==1)
            {
                image.at<uchar>(j,i) = 255;
            }
        else if(image.channels()==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;
            }
    }
}

上面的函数由单层循环构成。每次循环将一个随机选取的像素值设置为255。

随机选取的像素行号i和列号j是通过一个随机函数得到的。rand(),这里为啥用对255的求余操作呢?想一想

接下来还得检查下到底是灰度图还是彩色图,怎么检查呢?cv::Mat的类成员函数channels()。

对于灰度图,怎么赋值,彩色图怎么赋值?看看代码想一想。

看看主函数中怎么操作吧

//打开图像
cv::Mat image = cv::imread("boldt.jpg");
//调用函数增加噪点
salt(image,3000);
//显示图像
cv::namedWindow("Image");
cv::imshow("Image",image);

作用原理?

类cv::Mat有若干成员函数可以获取图像的属性。

公有成员变量cols和rows给出了图像的宽和高。成员函数at(int y,int x)可以用来存取图像元素。

但是必须在编译期知道图像的数据类型,因为cv::Mat可以存放任意数据类型的元素。

这也是这个函数用模板来实现的原因。

意味着,当调用该函数时,需要使用以下方式指定数据类型:

image.at<uchar>(j,i) = 255;

注意:一定要保证指定的数据类型和矩阵中的数据类型相符合。at方法本身不会进行任何数据类型的转换。

对于彩色图像,每个像素由三个部分组成:红、绿和蓝三个通道。

因此,一个包含彩色图像的cv::Mat会返回一个由三个八位数组成的向量。OpenCV将此类向量定义为cv::Vec3b,即由三个 unsigned char组成的向量。

这解释了为什么存取彩色图像像素的代码可以写成如下形式:

image.at<cv::Vec3b>(j,i)[channel] = value;

其中,索引值channel标明了颜色通道号。

类似地,OpenCV还有二元素向量类型和四元素类型(cv::Vec2b和cv::Vec4b)。

OpenCV同样拥有针对其他数据类型的向量类型,如s代表short,i代表int,f代表float,d代表double。

所有这些类型都是使用模板类cv::Vect<T,N>定义的,其中T代表类型,N代表向量中的元素个数。

扩展阅读

有时候使用cv::Mat的成员函数会很麻烦,因为返回值的类型必须通过在调用时通过模板参数指定。

因此,OpenCV提供了类cv::Mat_,它是cv::Mat的一个模板子类。

在事先知道矩阵类型的情况下,使用cv::Mat_可以带来一些遍历。

这个类额外定义了一些方法,但是没有任何成员变量,所以此类的指针或者引用可以直接进行相互类型转换。

这个类重载了操作符(),允许我们可以通过它直接存取矩阵元素。因此,假设有一个uchar型的矩阵,我们可以这样写:

cv::Mat_<uchar> im2 = image; //im2指向image
im2(50,100) = 0;//存取第50行,100列

由于cv::Mat_的元素类型在创建实例的时候已经声明,操作符()在编译期就知道需要返回的数据类型。使用操作符()得到的返回值和使用cv::Mat的at方法得到的返回值完全一致,而且更加简洁。

使用指针遍历图像

高效遍历图像非常重要。(因为像素太多了)

我们先体验下指针算数。(图像遍历循环方法)

准备工作

一个例子:减少图像中地颜色数目??

直接截图吧。

 

话说下面那个又乘以N,我有点看不懂。那就看代码吧,

实现方法

void colorReduce(cv::Mat &image,int div = 64)

用户提供一个图像和缩减因子。处理过程是In-place的,意味着输入图像的像素值会被此函数修改,

整个处理过程通过一个双重循环来遍历所有的像素值:

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);
//显示图像
cv::namedWindow("Image");
cv::imshow("Image",image);

作用原理:

在一个彩色图像中,图像数据缓冲区中的前三个字节对应图像左上角像素的三个通道值,接下来的三个字节对应第一行的第二个像素,请以此类推。

注意OpenCVmore五年使用BGR通道顺序。

一个宽为W、高为H的图像需要由一个大小WxHx3个uchar构成的内存块。

但是,出于效率的考虑,每行会填补一些额外像素。

这是因为,如果行的长度是4或者8的倍数,一些多媒体处理芯片可以更高效地处理图像。

每行的像素值数可以通过如下语句得到:

int nc = image.cols*image.channels();

为了简化指针运算,cv::Mat提供了ptr函数可以得到 任意行的首地址。

ptr函数是一个模板函数,它返回第j行的首地址:

uchar* data = image.ptr<uchar>(j);

注意,在处理语句中,我们可以等效地使用指针运算符从一行移动到下一行,所以,我们也可以这么写:

*data++ = *data/div*div + div/2;

扩展

颜色缩减函数不知这一种。

我们再看一种更通用的版本,它允许用户分别指定输入和输出图像。

另外,图像遍历还可以通过利用图像数据的连续性,使得整个过程更高效。

我们可以通过常规的指针运算来遍历图像。

直接用位运算的效率会很高,这里有点不太懂~~~~

2. 使用输入和输出参数

上面我们写的代码,颜色变换是直接发发生在输入图像上的,称之为In-place变换。不需要额外的图像来保存输出结果,必要时候,这样的做法可以节省内存。

但是,有时候我们并不希望原始图像被改变。

这样,我们需要再调用函数之前创建一份输入图像的拷贝。最简单的创建一个图像的“深拷贝”的方式是调用clone函数,如

//装载图像
image = cv::imread("boldt.jpg");
//克隆图像
cv::Mat imageClone = image.clone();
//处理克隆图像
//原始图像保持不变
colorReduce(imageClone);
//显示结果
cv::namedWindow("Image Result");
cv::imshow("Image Result",imageClone);

这种额外复制,可以通过一种实现技巧来避免。在这种实现中,给用户选择到底是否采用In-place的处理方式。函数的实现是这样的:

void colorReduce(const cv::Mat &image,cv::Mat &result,int div=64);

注意,输入图像是通过常量引用传递的,这意味着这个图像不会被函数修改。当选择In-place的处理方式时,用户可以将输入输出指定为同一个变量:

colorReduce(image,image);

否则,用户必须提供另外一个cv::Mat的实例,如:

cv::Mat result;
colorReduce(image,result);

注意,这里必须检查输入图像和输出图像的大小和元素类型一致。

cv::Mat的create成员函数内置了这个检查操作。

如果需要根据新的尺寸和数据类型对一个矩阵进行重新分配,我们可以调用create成员函数。而且,如果新的指定的尺寸和数据类型与原有的一样,create函数会直接返回,变更不会对本实例做任何更改。

所以,我们需要先调用create函数来创建一个与输入图像尺寸和类型相同的矩阵:

result.create(image.rows,image.cols,image.type());

注意:create函数创建的图像的内存都是连续的,create函数不会对图像进行填补。分配的内存大小为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;i++)
        {
        //处理每个像素---------------
            data_out[i] = data_in[i]/div*div +div/2;
        //像素处理完成
        }//行处理结束
}

如果输入输出图像是同一幅图像,那么上面代码与我们之前的完全等价。

3. 高效遍历连续图像

怎样利用图像的连续性呢?

还是看代码吧

void colorReduce(cv::Mat &image,int div=64)
{
int nl = image.rows;
int nc = image.cols*image.channels();
if(image.isContinuous())
{
nc = nc*nl;
nl = 1;
}
//对于连续图像,本循环只执行一次
for(int j=0;j<nl;j++)
{
uchar* data = image.ptr<uchar>(j);
    for(int i =0;i<nc;i++)
        {
        data[i] = data[i]/div*div + div/2;
        }
}
}

看了代码,懂了什么叫图像的连续性。

 讨论一下上面的代码:通过isContinuous函数得知图像没有进行填补之后,我i们可以将宽设为1,高度设为WxH,从而消除外层循环。

注意我们也可以使用reshape方法来实现:

if(image.isContinuous())
{
    //no padded pixels
    image.reshape(1,image.cols*image.rows);
}
int nl = image.rows;//列数
int nc = image.cols*image.channels();

reshape不需要内存拷贝或者重新分配就能改变矩阵的维度。两个参数分别为新的通道数和新的行数。

矩阵的列数可以通过通过新的通道数和行数来自适应。

4. 底层指针运算

在类cv::Mat中,图像数据以unsigned char形式保存在一块内存中。这块内存的首地址可以通过data成员变量得到。data是一个unsigned char型的指针,所以循环可以以如下方式进行:

uchar *data = image.data;

从当前行到下一行可以通过对指针加上行宽完成:

data+ = image.step;

step代表图像的行宽(包括填补像素)。通常而言,我们可以通过以下方式获得第j行,i列像素的地址:

//(j,i)处的像素地址为 &image.at(j,i)
data = image.data + j*image.step + i*image.elemSize();

以上方式容易出错。

使用迭代器遍历图像

面向对象编程中,遍历数据通常是通过迭代器来完成的。

迭代器是什么?

迭代器是一种特殊的类,它专门用来遍历各个几何中的各个元素 ,同时隐藏了在给定几何上元素迭代的具体实现方式。

这种黑盒子做法使得遍历集合更加容易。

此外,不管数据类型是什么,我们都可以用相似的方式遍历。

标准模板库(STL)为每个容器类都提供了迭代器。

OpenCV当然为cv::Mat提供了与STL迭代器兼容的迭代器。

cv::Mat实例的迭代器可以通过创建一个cv::Mat Iterator_的实例来得到。

类似于子类cv::Mat_,下划线意味着cv::Mat Iterator_是一个模板类。

之所以如此是由于通过迭代器来存取图像的元素,就必须在编译期知道图像元素的数据类型。声明方式如下:

cv::Mat Iterator_<cv::Vec3b> it;

另一种方式是使用定义在Mat_内部的迭代器类型:

cv::Mat_<cv::Vec3b>::iterator it;

两种声明迭代器的方法,仔细想一想。

这样就可以通过常规的begin和end这两个迭代器方法来遍历所有像素了。

如果使用后一种声明迭代器的方法,必须要使用对应的模板化版本(区代码中体会吧)。

void colorReduce(cv::Mat &image,int div=64)
{
//得到初始位置的迭代器
cv::Mat_<cv::Vec3b>::iterator it = image.begin<cv::Vec3b>();
//得到终止位置的迭代器
cv::Mat_<cv::Vec3b>::iterator itend = image.end<cv::Vec3b>();
//遍历所有元素
for (;it!=itend;++i)
{
    //处理每个元素------
    (*it)[0] = (*it)[0]/div*div + div/2;
    (*it)[1] = (*it)[1]/div*div + div/2;
    (*it)[2] = (*it)[2]/div*div + div/2;
    //处理像素完成------
}
}

注意:这里处理的是彩色图像,所以迭代器返回的是cv::Vec3b。每个颜色分量可以通过操作符[]得到。

讨论下吧

使用迭代器遍历任何形式的集合都遵循同样的模式。

首先创建一个迭代器特化版本的实例。

在上面的代码中,就是 cv::Mat_<cv::Vec3b>::iterator(或者cv::Mat  Iterator_<cv::Vec3b>).

然后,使用集合初始位置(在上面的代码中,指的是图像的左上角)的迭代器对其进行初始化。

初始位置的迭代器通常是通过begin获得的。

对于一个cv::Mat的实例,我们可以通过image.begin<cv::Vec3b>()来得到图像左上角位置的迭代器。

当然也可以通过迭代器运算。例如想从图像的第二行开始:

image.begin<cv::Vec3b>()+image.rows来初始化迭代器。

end方法得到的迭代器其实已经超出了集合。

一旦迭代器初始化完成后,我们就可以创建一个遍历所有元素知道终止位置的循环。

典型的while循环如下:

while(it!=itend)
{
//处理每个像素-----
...
//处理像素完成-----
++it;
}

操作符 ++ 用来将迭代器从当前位置移动到下一个位置。当然,可以选择用更大的步长,比如 it+=10每次将迭代器移动10px。

在循环体内部,可以我们可以用解引用操作符*来读写当前元素。读操作使用 element =*it,写操作使用*it = element。

注意,如果操作对象是const cv::Mat,或者像强调当前循环不会对cv::Mat的实例进行修改,那就应该创建常量迭代器。常量迭代器声明如下;

cv::Mat ConstIterator_<cv::Vec3b> it;

或者

cv::Mat_<cv::Vec3b>::const_iterator it;

对比下上面的普通迭代器的声明,记住某些东西。

看下下面的代码,领悟一些东西吧

cv::Mat_<cv::Vec3b> cimage = image;
cv::Mat_<cv::Vec3b>::iterator it = cimage.begin();
cv::Mat_<cv::Vec3b>::iterator itend = cimage.end();

编写高效的图像遍历循环

关键词,效率。

但是效率虽然重要,也不能影响性能和可维护性。(这部分先掠过)

遍历图像和邻域操作

举个例子,对图像进行锐化。基于拉普拉斯算子(后面会学到)。

众所周知,将一幅图像减去它经过路普拉斯滤波之后的图像,这幅图像的边缘部分将被放大,即细节部分更加锐利。这个锐化算子的计算方式如下:

sharpened_pixel = 5*current - left - right - up - down;

实现方式

这次图像处理不能以In-place方式进行了,必须提供一个输出图像。

图像遍历用到了三个指针:一个指向当前行,一个指向向上一行,一个指向向下一行。由于每个像素的计算都需要它的上下左右四个邻居像素,所以不能对图像的第一行、最后一行、第一列和最后一列进行计算。

且看下面循环体的代码:

void sharpen(const cv::Mat &image,cv::Mat &result)
{
//如有必要则分配图像
result.create(image.size(),image.type());
for(int j=1;j<image.rows-1;j++)
{
//除了第一行和最后一行以外的所有行
const uchar* previous = image.ptr<uchar>(j-1);  //上一行
const uchar* current = image.ptr<uchar>(j);  //当前行
const uchar* next = image.ptr<uchar>(j+1);  //下一行
uchar* output = result.ptr<uchar>(j);  //输出行
for (int i = 1;i<image.cols-1;i++)
{
*output++ = cv::saturate_cast<uchar>(5*current[i]-current[i-1]-current[i+1]-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被用来对计算结果进行截断。

应改变。

对于一个三通道的彩色图像,我们需要使用cv::Scalar(a,b,c)来指定像素三个通道的目标值。

扩展阅读

当计算是在像素邻域上进行时,通常可以将其用一个核矩阵表示。核描述了牵扯到的像素在计算过程中是如何组合从而得到目标值的。

想象一下本例中的锐化滤波器。

将这么一个核应用到图像上,就是信号处理中卷积的基础。一个核定义了一个图像滤波器。由于滤波是一种常规的图像处理方法,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效率更高。

进行简单地图像算数

作用原理

所有的二元算数函数工作方式都是一样的,它接受两个输入变量和一个输出变量。

在一些情况下,还需要指定权重作为运算中的标量因子。

每种函数都有几个不同的形式,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);

对于某些函数,可以指定一个图像掩模:

//if(mask[i]) c[i] = a[i] + b[i];
cv::add(imageA,imageB,resultC,mask);

理解下什么是掩模。。。

如果指定了图像掩模,那么运算会只在掩模对应像素不为null的像素上进行(掩模必须是单通道的)。

除了add之外,cv::subtract、cv::absdiff、cv::multiply和cv::divide函数也有几种不同的变形。

OpenCV中还提供了位运算函数:cv::bitwise_and、cv::bitwise_or、cv::bitwise_xor、cv::bitwise_not。

cv::min和cv::max也很有用,它们用来找到矩阵中最小或者最大的像素值。

所有的运算都使用cv::saturate_cast来保证输出图像的像素值在合理范围内(不会向上或者向下溢出)。

参与运算的图像必须相同的大小和类型。

此外,还有一些只接受一个输入的操作符,如cv::sqrt、cv::pow、cv::abs、cv::cuberoot、cv::exp和cv::log。

OpenCV几乎拥有所有我们需要的图像操作运算符。

定义感兴趣区域

我们现在想把一张图片放到另一张图片上。

由于cv::add要求 两个输入图像具有相同的尺寸,所以我们不能直接使用cv::add,二十需要在使用之前定义感兴趣区域(ROI)。只要感兴趣区域的大小与LOGO图像的大小相同,cv::add就能够工作。ROI的位置决定了LOGO图像被插入的位置。

实现方法

首先要定义ROI。一旦定义之后,ROI就可以当作一个普通的cv::Mat实例来处理。关键之处是,ROI和它的父图指向同一块内存缓冲区。插入LOGO的操作可以通过如下代码完成:

//定义图像ROI
cv::Mat imageROI;
imageROI = image(cv::Rect(385,270,logo.cols,logo.rows));
//插入logo
cv::addWeighted(imageROI,1.0,logo,0.3,0.,imageROI);

理解下上面的代码。

作用原理

定义ROI的一种方法是使用cv::Rect。顾名思义,cv::Rect表示一个矩形区域。指定矩形的左上角坐标(构造函数的前两个参数)和矩形的长宽(构造函数的后两个参数)就可以定义一个矩形区域。

另一种定义ROI的方式是指定感兴趣行或列的范围(Range)。Range是值从起始索引到终止索引(不包含终止索引)的一段连续序列。cv::Range可以用来定义Range。

代码:

cv::Mat imageROI = image(cv::Range(270,270+logo.rows),cv::Range(385,385+logo.cols));

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值