OpenCV Mat类及像素操作(持续更新)

01 Mat类及与其相关的类

1.1 Mat简介

在opencv刚出来的时候,库都是围绕C接口构建的,当时使用名为IplImage C 的结构在内存中存储图像,大多数老旧教材中经常看到。这个结构把C的缺点暴露无疑,最大的问题是需要手动管理,当代码非常大的时候就会特别难顶。

后来,c++问世了,引入了类的概念,于是产生了新的管理方式,Mat类,但也有弊端,就是一些嵌入式开发系统只支持c。

Mat类不再需要手动分配大小和手动释放。

Mat类本质上是由两个数据部分组成的类:(包含有矩阵的大小,用于存储的方法,通道数量,数据类型,数据块指针等信息的) 头部和一个数据块,数据块包含了图像中所有像素的值,头部中的指针指向数据块;

由于矩阵会非常的大,在使用的时候如果直接复制矩阵效率会非常低下,因此openCV引入了计数系统。

计数系统的思想是Mat的每个对象具有其自己的头,但他们可以通过让他们指针指向同一地址的两个实例之间达到共享该矩阵的效果(传递指针去代替直接传递矩阵)。此外,拷贝运算符将只能复制矩阵头部,即复制指针,但不是矩阵本身。

Mat A, C; //仅创建了头部
 
    A = imread(argv[1], CV_LOAD_IMAGE_COLOR);//分配矩阵
 
    Mat B(A); //使用拷贝构造函数
 
    C = A; //赋值运算符

上文中的所有对象,头部各不相同,但是指针相同,指向同一个矩阵。也就是说,使用其中一个Mat对矩阵进行修改,就会影响到其他所有的。这里说一点有趣的,你可以创建一个仅指向完整数据一小部分的头,例如,要在图像中创建兴趣区域 ( ROI) 您只需创建一个新头设置新边界。

Mat D (A, Rect(10, 10, 100, 100) ); // 用矩形界定
 
Mat E = A(Range:all(), Range(1,3)); // 用行和列来界定

这里提一下,一个矩阵可能属于多个Mat对象,但只有最后一个使用它的对象负责释放,这里使用到了计数的机制。每当有人复制Mat对象的头,矩阵的计数器被增加。每当一个头被清除,此计数器被下调。当该计数器变为零,矩阵也就被释放了。

因为有时会仍然也要复制矩阵的本身,存在着 clone() 或 copyTo() 函数。

Mat F = A.clone();
 
Mat G;
 
A.copyTo(G);

总结一下:

  • 输出图像分配 OpenCV 功能是自动 (除非另行指定,否则)。
  • 用c + + OpenCV的接口就无需考虑内存释放。
  • 赋值运算符和复制构造函数 (构造函数)只复制头。
  • 使用clone () 或copyTo () 函数将复制的图像的基础矩阵。

1.2 Mat类型初始化

  1. 显示创建一个Mat对象:
    Mat的构造函数五花八门,这里只说一种类型
    Mat M(2, 2, CV_8UC3, Scalar(0, 0, 255))前两个参数确定图像大小,行和列(两行两列);
    然后我们需要指定的数据类型,用于存储元素和每个矩阵点通道的数量。为此,我们根据以下的约定可以作出多个定义:CV_ [每一项的位数] [有符号或无符号] [类型前缀] C [通道数](CV_8UC3 意味着我们使用长为 8 位无符号的 char 类型和三通道);
    然后通过Scalar初始化矩阵的值,OpenCV的存储是以BGR的顺序存储的,因此(0, 0, 255)是指B为0,G为0,R为255;
  2. 创建特殊矩阵:
//行和列有两种不同表达方式
cv::Mat mz = cv::Mat::zeros(cv::Size(w,h),CV_8UC1); // 全零矩阵
Mat tmpdata = Mat::zeros(h, w, CV_8UC1);//h行w列的全0矩阵

cv::Mat mo = cv::Mat::ones(cv::Size(w,h),CV_8UC1);  // 全1矩阵
Mat tmpdata = Mat::ones(h, w, CV_8UC1);//h行w列的全1矩阵

cv::Mat me = cv::Mat::eye(cv::Size(w,h),CV_32FC1);  // 对角线为1的对角矩阵
Mat tmpdata = Mat::eye(h, w, CV_32FC1);//h行w列的对角矩阵

1.3 Mat类型赋值

  1. 拷贝赋值:
  • 浅层拷贝:
Mat A = imread("x.jpg"); 
Mat B = A;
Mat c(A);

B和C就是浅层拷贝A,都只拷贝了A的的头部,当B和C被操作后A也随之改变。

  • 深层拷贝:
Mat A = imread("x.jpg"); 
Mat B = A.clone();
Mat C;
A.copyTo(C);

copyTo会调用creat方法对原有的目标图像的数据块进行修改,就是将A复制粘贴到C上(注意:copyTo的两图像数据类型要相同,不相同要用convertTo);

clone则创建一个完全相同的新图像;

这两个操作完全的复制了A的内容,操作B和C不会对A造成影响。

  1. 将数据类型为U16的dataU16赋值给数据类型为u8的dataU8(数据类型转换,矩阵遍历):
Mat dataU16 = Mat(Size(w, h), CV_16UC1);
Mat dataU8 = Mat(Size(w, h), CV_8UC1);

U16* pxvecU16 = dataU16.ptr<U16>(0);
U8* pxvecU8 = dataU8.ptr<U8>(0);

for (int i = 0; i < dataU16.rows; i++)
{
	pxvecU16 = dataU16.ptr < U16>(i);
	pxvecU8 = dataU8.ptr<U8>(i);
	for (int j = 0; j < dataU16.cols; j++)
    {
        	pxvecU8[j] = (U8)pxvecU16[j];
      }
}
  1. 重新分配数据块
    image.creat(Size(100,100), CV_8U)creat方法可以分配或重新分配图像的数据块。如果图像已被分配,则会先释放原来的内容。出于对性能的考虑,如果新的尺寸和类型和原来一样,就不会重新分配内存。

1.3 其他相关的类

  1. 输入和输出数组:
    cv::InputArray类型是一个简单的代理类,用来概括Opencv的数组概念;
  2. 返回数组:
    cv::OutputArray,这个代理类用来指定某些方法或函数的返回数组;
  3. 处理小矩阵:
    处理小矩阵,可以用模板类cv::Matx及他的子类
//3*3双精度型矩阵
cv::Matx33d matrix(3.0, 2.0, 1.0,
                  						2.0 ,1.0, 3.0,
                  						1.0, 2.0, 3.0);
//3*1矩阵
cv::Matx31d vector(5.0, 1.0, 3.0);
//相乘
cv::Matx31d result = matrix*vector;

注意:是列乘行

相关的类就简单介绍一下,现在知道这个东西就行,后面遇到再详细做笔记。

02 加载 修改 保存图像

2.1 加载图像

加载图像使用APIcv::imread(),该API的功能是加载图像文件成为一个Mat对象。

其中第一个参数表示图像文件名称

第二个参数表示以什么类型加载图像,一般有三个可选参数:

  • IMREAD_UNCHANGED,就是加载原图,不做任何改变,跟不输入第二个参数效果相同;
  • IMREAD_GRAYSCALE,表示把原图作为灰度图加载进来;
  • IMREAD_COLOR,表示把原图作为RGB图像加载进来;

注意:OpenCV支持JPG,PNG,TIFF等常见格式图像文件加载。

2.2 显示图像

主要有两个API,cv::namedwindow()cv::imshow()

cv::namedwindos()是创建一个OpenCV窗口,它是由OpenCV自动创建与释放,无需手动销毁。

第一个参数是窗口的名字,第二个参数有两个选项:

  • WINDOW_AUTOSIZE会自动根据图像大小,显示窗口大小,不能认为改变窗口大小;
  • WINDOW_NORMAL,则允许修改窗口大小;

cv::imshow()则是根据窗口的名称显示图像到指定的窗口上去,第一个参数是窗口名称,第二个参数是Mat对象。如果在使用该API前没有用上一个API创建窗口,则会自动执行nameWindow创建一个不能修改大小的窗口。

2.3 保存图像

imwrite("name", image);

这个API比较简单,不过多赘述

03 操作像素

像素操作可能用到的属性,方法,和概念:

  1. 在彩色图像中,图像数据缓冲区前3个字节表示左上角像素的三个通道值(BGR),接着是3字节表示第一行第二个像素,以此类推。
  2. 出于性能的考虑,数据块会用几个额外的像素来填补图像行的长度,这些额外的像素不会显示也不会被保存。Opencv把经过填充的行的长度称为有效宽度
  3. image.colsimage.rows可以得到图像的宽和高;
  4. image.step可得到以字节为单位的有效宽度;
  5. image.channels()可得到图像通道数;
  6. image.elemSize()可得到像素的大小,就是通道数乘以类型字节数;

3.1 ROI 定义感兴趣区域

在学习操作像素前,不妨先看一下对图像一整块区域的操作。以插入一个小图像到大图像为例,我们需要定义一个感兴趣区域,然后在此区域进行复制操作,即将小图像复制到感兴趣区域ROI即可。

  1. 定义感兴趣区域
    定义ROI其实与定义一个Mat是相同的,ROI的实质就是一个Mat对象,只不过ROI的指针与原图像指向了同一个数据块,并且在头部指明了ROI的坐标。
//假设image为大图像,logo为小图像
Mat imageROI(image, Rect(image.cols-logo.cols, 
                         								image.rows-logo.rows,
                        							    logo.cols,
                         								logo.rows))

除了用左上角坐标加框的长和宽的表示方法,也可以用行和列的值域来定义:

imageROI = image(Range(image.rows - logo.rows, image.rows),  //行值域
                						Range(image.cols - logo.cols, image.cols));  //列值域

感兴趣区域也可以是只由行或者列组成:

//输入行或列的开始和结束的位置即可
Mat imageROI = image.rowRange(start, end);
Mat imageROI = image.colRange(start, end);
  1. 将小图像插入ROI
    这里就用到了copyTo函数,将小图像copy到感兴趣区域,注意copyTo对两图像的要求
logo.copyTo(imageROI);
  1. 拓展:使用图像掩码
    函数或方法往往都是对图像中的所有像素进行操作,通过定义掩码可以限制这些函数或方法作用的范围。
Mat imageROI(image, Rect(image.cols-logo.cols, 
                         								image.rows-logo.rows,
                        							    logo.cols,
                         								logo.rows));
Mat mask(logo>60);   //定义一个mask,logo中像素值大于60的为True
logo.copyTo(imageROI, mask); //把logo中mask为True的像素copy到imageROI中

3.2 访问像素值

如果要访问矩阵中的每个独立的像素点,只需要指定它的行和列即可返回的函数可以是单个数值,也可以是多通道图像的数值向量。

以向图像中添加椒盐噪声为例:

//定义一个添加噪声的函数
void salt(Mat image, int num) //Mat类按值传递即可
{	
	default_random_engine generator; //定义一个生成随机数的引擎
	uniform_int_distribution<int> randomRow(0,image.rows-1);  //限定随机数范围
	uniform_int_distribution<int> randomCol(0,image.cols-1);
	int x,y;
	for(;num>0;num--)
	{
		x = randomRow(generator);  //生成随机数
		y = randomCol(generator);
		if(image.type() == CV_8UC1)  //判断图像的通道数
		{
			image.at<uchar>(x,y)=255;  //通过at函数访问像素点
		}
		else if(image.type()==CV_8UC3)
		{
			image.at<Vec3b>(x,y)[0]=255; 
			image.at<Vec3b>(x,y)[1]=255;
			image.at<Vec3b>(x,y)[2]=255;
		}
	}

可以通过cv::at<type>(int y, int x)访问元素,其中x是列号,y是行号。

在编译时该方法必须给出明确的返回类型,正应如此,at方法被实现成了一个模板,这里必须注意:at方法不会进行任何类型转换,因此必须确保指定类型与矩阵中的相同。

  • 关于类型,如果是多通道需要使用opencv定义的Vec<T,N>类型,其中T是向量元素数量,N是类型;

调用at方法,在已知矩阵类型的前提下,仍须每次都指明返回类型,因此Mat类的at方法显得冗长。这里介绍一个新的类,如果在已知矩阵类型的前提下,可以使用cv::Mat_类(Mat类的模板子类),他跟Mat类的数据类型完全相同,因此两者的指针或引用可以直接互相转换。但Mat_中定义有一些新方法,比如operator(),可以用来直接访问像素点:

cv::Mat_<uchar> img(image);
img(50,100) = 0; //通过()直接访问50行,100列的像素

operator()与at方法的产生结果是完全相同的。

3.3 指针扫描图像

大多数图像处理任务都需要对图像的所有像素进行扫描,这里先学习指针扫描。

引入减色算法(减少图像中颜色的数量,就是一个范围的像素用他们的平均值代替)进行介绍:

先说一下减色算法怎么实现,也很简单,假设减色因子为N。将图像中的所有像素除以N,再乘以N,得到离他们最近的N的倍数,再加上N/2即可,实现起来有三种形式:

//整数除法
data[i] = data[i]  / N * N + N/2;
//取模运算
data[i]  = data[i]  - data[i] %N + N/2;
//位运算
//先设置截取像素值的掩码
int n = static_cast<int>(log(static_cast<double>(N)) / log(2.0) + 0.5 );
uchar mask = 0xFF<<n;
data[i] = mask & data[i] ;
data[i] = data[i] + N>>1;

说一下位运算实现,位运算的N必须是2的n次幂。把像素值的二进制前n位进行掩码(前n为置0),即可得到最近的N的倍数。

这几种形式中,位运算运行效率很高,高其他50倍左右,因此效率为重是,尽量位运算。

减色算法实现:

void colorReduce(Mat img, int N)
{
	int i,j;
	int nc = img.cols * img.channels();
	for(i=0; i<img.rows; i++)
	{
		uchar* point = img.ptr<uchar>(i);
		for(j=0; j<nc; j++)
		{
			point[j] = point[j]/N*N-N/2; 
		}
	}
}

ptr方法可以直接访问图像中一行的起始地址,它是一个模板方法,返回第i行地址。

如果图像是连续的,就是没有填充像素,我们就可以把图像连成一个一位数组,这样只用一个for循环即可高效扫描。

判断图像是否为连续:

//第一种方法
image.isContinuous(); //如果连续,则为True
//第二种方法
image.cols * image.elemSize() == image.step; //判断两者是否相等

于是代码就变成:

void colorReduce(Mat img, int N)
{
    int nl = img.rows;
	int nc = img.cols * img.channels();
    if(img.isContinous())
    {
        nc = nc * img.rows();
        nl = 1;
    }
    int n = static_cast<int>(log(static_cast<double>(N)) / log(2.0) + 0.5 );
    uchar mask = 0xFF<<n;
	for(i=0; i<nl; i++)   //如果矩阵连续, nl就是1了
	{
		uchar* point = img.ptr<uchar>(i);
		for(j=0; j<nc; j++)
		{
				point[j] = mask & point[j] ;
				point[j] =point[j] + N>>1;
		}
	}
}

3.4 用迭代器扫描图像

除了通过指针扫描图像,还可以迭代器来扫描图片。

迭代器有两种形式,分别对应Mat类和Mat_模板子类:

  1. Mat下的迭代器:cv::MatIterator_<cv::Vec3b>;
  2. Mat_下的迭代器:cv::Mat _<cv::Vec3b>::iterator;

于是新的减色函数就成这样了:

void colorReduce(Mat img, int N)
{
    int n = static_cast<int>(log(static_cast<double>(N)) / log(2.0) + 0.5 );
    uchar mask = 0xFF<<1;
    Mat_<Vec3b>::iterator it = image.begin<Vec3b>();  //或MatIterator_<Vec3b>
    Mat_<Vec3b>::iterator itend = image.end<Vec3b>();
    
    for(;it != itend; it++)
    {
        (*it)[0] &= mask;
        (*it)[0] += N/2;
        (*it)[1] &= mask;
        (*it)[1] += N/2;
        (*it)[2] &= mask;
        (*it)[2] += N/2;       
    }    
}

说一下Mat::begin和Mat::end: 返回矩阵迭代器,把它指向第一个和最后一个元素。

注意一下(*it);

3.5 扫描图片并访问相邻像素

以锐化图像的处理函数为例,访问相邻像素在上一节扫描图像的基础上在多加几个指针追踪相邻像素即可。

先说一下怎么锐化图像:sharpened_pixel = 5*current - left - up - down,由于需要4个邻点计算,所以图像的4条边不做锐化处理。

直接上代码:

void sharpen(const Mat &image, Mat &result)
{
	result.create(image.size(), image.type());
	int nchannels = image.channels();
	for(int i=1; i<(image.rows-1); i++)
	{
		const uchar* current = image.ptr<const uchar>(i);
		const uchar* up = image.ptr<const uchar>(i-1);
		const uchar* down = image.ptr<const uchar>(i+1);
		uchar* output = result.ptr<uchar>(i);
		for(int j=1; j<(image.cols-1)*nchannels;j++)
		{
			output[j] = saturate_cast<uchar>(5*current[j]-current[j-nchannels]-up[j]-down[j]); 
		}
	}
    //边缘像素全部置0
    result.row(0).setTo(Scalar(0,0,0)); 
    result.row(result.rows - 1).setTo(Scalar(0,0,0));
    result.col(0).setTo(Scalar(0,0,0));
    result.col(result.cols-1).setTo(Scalar(0,0,0));
}

因为一个像素点可能要多次利用,所以不能在原图像上进行操作,这时就要create一个与image相同的图像。

saturate_cast<uchar>这个模板函数作用是防止计算出的结果超出允许的范围。主要就是小于0变0,大于255变255,浮点数变离它最近的整数。

row()col()方法是提取出一行或一列的ROI,该方法提取出来的ROI被修改,原图像也会被修改;

setTo()是将一个区域的所有像素设置为一个值。

拓展:其实锐化可以用卷积操作实现。鉴于filter在图像处理用得很多,opencv专门定义了一个函数cv::filter2D()

锐化函数也可以这样写:

void sharpen2D(const Mat &image, Mat &result)
{
    Mat filter(3, 3, CV_32F, Scalar(0));
    filter.at<float>(0,1) = -1;
    filter.at<float>(1,0) = -1;
    filter.at<float>(1,1) = 5;
    filter.at<float>(1,2) = -1;
    filter.at<float>(2,1) = -1;
    
    filter2D(image, result, image.depth(), filter);
}

image.depth表示图像的每个像素所用的位数。

3.6 实现简单的图像运算

在opencv中基本上所有的数学运算都被实现,加减乘除,位运算,找最大最小值,平方开根号,绝对值等等等。

由于太多,这里就只记录一个加法,其他不会去查:

//c = a + b
cv::add(imageA, imageB, imageC);
//c = a + k
cv::add(imageA, Scalar(k), imageC);
//c = k1*a + k2*b +k3
cv::addWeighted(imageA, k1, imageB, k2, k3 ,imageC);
//c = k*a +b
cv::scaleAdd(imageA, k, imageB, imageC);

这里再重点说一下saturate_cast()防溢出函数,几乎上要对图像进行运算都要使用它防止溢出。

opencv的大多数运算函数也都对应着重载运算符,c++的大多数运算符都被重载,由于太多,这里不过多介绍,但使用图像运算符可以使代码简便,大多数场合应考虑使用。

分割图像通道

有时候我们需要分别处理图像的不同通道。例如只对图像的一个通道进行操作,可以扫描图像通道,也可以用cv::split()函数,将图像三个通道分别复制到三个Mat当中。

//创建三副图像的向量
std::vector<cv::Mat> planes;
//将一个三通道图像分割为三个单通道图像
cv::split(images, planes); 
//将图像加到通道上
planes[0] += image2;
//将三个通道合并为一幅图像
cv::merge(planes , result);

merge为split的拟操作

3.7 图像重映射

前面都是在讨论如何读取和修改图像的像素值。这节学习把像素映射到新的位置,这个过程不会修改像素的值。

要实现重映射,就要使用Opencv的remap()函数,这个函数需要传入要处理的对象,输出的对象,和映射参数。

以在图像上创建波浪效果为例:

void wave(const Mat &image, Mat &result)
{
    //创建x,y方向的映射参数
    Mat srcX(image.rows, image.cols, CV_32F);
    Mat srcY(image.rows, image.cols, CV_32F);
    for(int i=0; i<image.rows; i++)
    {
        for(int j=0; j<image.rows; j++)
        {
            srcX.at<float>(i,j)=j;
            srcY.at<float>(i,j)=i+5*sin(j/10.0);
        }
    }
    remap(image, result, srcX, srcY,INTER_LINEAR); //最后一个参数为填补方法
}

关于像素插值这个概念,后面再学

04 处理图像的颜色