Mat
特性
Mat 是 opencv 最基本的存储图像的容器,将图像以矩阵的形式存储了下来。
在围绕 c 语言进行开发时,图象被存储在 IplImage 的 C 结构中,需要手动分配和解除内存,这在大项目中是一个很麻烦的问题。C++的出现以及类的概念使得可以自动管理内存,解决了这一问题。Mat 的第一特点在于不需要手动分配或清除内存。
Mat 是包含两个数据部分的类,矩阵标头和指向像素矩阵的指针,矩阵表头包含矩阵大小、存储方法等信息,一般来说大小恒定;而像素矩阵因为图片的不同相差也比较大。
1 | Mat A, C; // creates just the header parts |
图像数据本身是很大的,因此 Mat 对象在复制或者赋值的过程中一般只是复制了矩阵表头以及像素矩阵指针,使得它们指向同一个数据矩阵,这就避免了大量数据的反复复制。而在实际操作中,这些不同的数据对象也只是为同一个基础数据提供不同的访问方式。(上述几个矩阵指向的都是同一数据)
当然,如果想要复制数据本身,则可以使用 cv::Mat::clone()
和 cv::Mat::copyTo()
:
1 | Mat F = A.clone(); |
存储方式
有多种的颜色存储方式,其中有RGB、HSV 和 HLS、YCrCb 以及 CIE Lab* ,opencv 所使用的存储方式是 BGR。
创建 Mat 对象
自行创建一个 Mat 对象:
1 | Mat M(2,2, CV_8UC3, Scalar(0,0,255)); |
首先定义了行和列为 2*2 的矩阵,然后需要定义存储的数据类型以及每个矩阵点的通道数:
1 | CV_[The number of bits per item(位数)][Signed or Unsigned(有无符号)][Type Prefix(类型前缀)]C[The channel number(通道号)] |
CV_8UC3
意味着我们使用 8 位长的无符号字符类型,每个像素有三个这样的字符类型来形成三个通道。
cv::Scalar
是四元素短向量,是一个用于表示颜色或其他多通道数据的类,通常用于初始化,同时 Scalar::all()
是 Scalar 类的一个静态成员函数,用于创建一个所有通道值相同的 Scalar 对象。
也可以使用 M.create() 函数进行初始化:
1 | M.create(4,4, CV_8UC(2)); |
opencv 像素遍历、表以及时间测量
对于三通道图片来说,数据量庞大,算法在处理的过程中会有很大的工作量,为此需要对色彩空间进行缩减
通过以上公式可以将色值以 10 为间隔进行空间缩减,但如果使用此公式对每个像素进行处理,同样会有很大的运算量,而色值是有明显的上下限的,所以可以考虑建一个所有可能情况的表,再根据表进分配,这样就省去了计算的过程。
时间测量
在如何测量某段程序运行所需时间的问题上,cv 提供了两个函数:cv::getTickCount()
和cv::getTickFrequency()
。cv::getTickCount()
给出了到运行指令时系统的 CPU 晶振次数(从开机或者某个时间点开始),cv::getTickFrequency()
给出了系统每秒的晶振次数,通过这两个函数可以计算某个代码块的时间,如下所示:
1 | double t = (double)getTickCount(); |
建表
1 | int divideWith = 0; // convert our input string to number - C++ style |
首先使用 C++ 字符串流类将命令行第三个参数从文本转换成整数,然后将 256 个数据按照规定的间隔进行稀释。(过程和 opencv 无关)
像素遍历
法一,指针方法
对于多通道图像,列包含的子列数与通道数一样多,一般情况下,矩阵数据是以一维数组的形式存储在内存中的,而 isContinuous()
函数则用于检查这个数组是否是按照行优先的顺序连续存储的。如果矩阵是连续存储的,那么可以通过简单的指针操作来访问矩阵的每一个元素,而不需要考虑行与行之间的边界。
根据指针操作方式(性能最佳),给出以下遍历像素缩减色彩空间的代码:
1 | Mat& ScanImageAndReduceC(Mat& I, const uchar* const table) |
Mat 的 data 属性返回第一行的指针,如果存储连续,可以直接用它进行遍历。
1 | uchar* p = I.data; |
法二,迭代器方法
指针方法最为高效,但是如果考虑到我们遍历的是正确的数据,而且还要确保数据之间没有出现空隙,那么迭代器方法是最为安全的方法:
1 | Mat& ScanImageAndReduceIterator(Mat& I, const uchar* const table) |
Vec3b
是一个模板类,表示一个长度为 3 的向量,每个元素的类型是 uchar
,即无符号 8 位整数。uchar
是 OpenCV 中的数据类型,表示无符号 8 位整数,用于表示图像像素的数值范围在 0 到 255 之间的像素值。
法三,即时地址计算
此方法不建议用于遍历像素上,它主要是用来获取或修改图像上的随机元素的,它的基本用法是指定要访问的项目的行号和列号。
1 | Mat& ScanImageAndReduceRandomAccess(Mat& I, const uchar* const table) |
对于三通道的情况,在循环中直接修改原始图像 I 的像素值可能会导致意外的结果,因为每次修改像素值都会直接影响原始图像。所以,为了避免修改原始图像的像素值,使用临时矩阵 _I 来存储修改后的像素值是一种更安全的做法。最后再将临时矩阵_I 的值赋值给原始图像 I,以更新图像的像素值。
法四,查找表操作
cv::LUT()
是 OpenCV 中的一个函数,用于对图像应用查找表(Look-Up Table)操作:
1 | void cv::LUT(InputArray src, InputArray lut, OutputArray dst); |
参数说明:
- src:输入图像,可以是单通道或多通道的。
- lut:查找表,通常是一个一维数组,用于指定每个像素值的映射关系。
- dst:输出图像,用于存储经过查找表操作后的图像。
1 | Mat lookUpTable(1, 256, CV_8U); |
四种方式下,LUT 函数是最快捷的,而如果打算进行简单的图像扫描,也可以使用指针方式,迭代器只是在此基础上保证了安全,放慢了速度。即时计算是最慢的,他这是提供了一种可行的办法,因此,在没有特殊要求的情况下,使用系统内置的 LUT。
矩阵掩码操作<滤波>(Mask operations on matrices)
矩阵的 mask 运算相当简单,就是通过 mask 矩阵(也称为 kernel)重新计算图像中的每个像素值,mask 能够调整临近像素值以及当前像素值对新像素值的影响程度,从数学的角度来看就是用指定的值做了加权平均。
以对比度增强为例
为了实现对比度增强,想要对图像的每个像素应用以下公式:
1 | void Sharpen(const Mat& myImage,Mat& Result) |
首先,函数确保了输入数据的格式,然后创建与输入相同格式的输出图像。因为需要同时访问多行,所以需要确定好上一行、当前行以及下一行的指针,以及指向保存位置的指针。而保存位置的指针相比于原始图像其实是缺少了图像边界的(第一行/列和最后一行/列),因此需要对边界进行处理,在这里将边界设为了零值。因为缺少邻域,所以无法对边界同样的去应用算法。
总的来说,就是确定好范围然后进行循环计算,将计算结果输出到新的图像中。
filter2D 函数
以上函数代码对图像进行了二维卷积操作,对应于 opencv 函数则是 filter2D 函数,此函数原型如下:
1 | void cv::filter2D( |
而如果要通过 filter2D 实现以上函数功能,则如下代码所示:
1 | Mat kernel = (Mat_<char>(3,3) << 0, -1, 0, |
src 和 dst1 分别对应输入和输出,给定卷积核就可以完成对应功能。
卷积
卷积是一种在两个函数间产生新函数的数学操作,在离散情况下,卷积的公式为:
其中,f 和 g 是两个离散函数,* 表示卷积操作,[n] 表示函数在离散点 n 处的值。在实际应用中,通常会有限制卷积范围以处理有限的输入信号。在连续情况下,卷积的公式如下:
卷积的物理意义大概可以理解为:系统某一时刻的输出是由多个输入共同作用(叠加)的结果,在图像上,卷积核上所有作用点依次作用于原始像素点后(即乘起来),线性叠加的输出结果,即是最终卷积的输出,也是我们想要的结果,我们称为destination pixel.
为什么要使用卷积核
先以上述程序实现的锐化为例,图像的锐化和边缘检测很像,我们先检测边缘,然后把边缘叠加到原来的边缘上,原本图像边缘的值如同被加强了一般,亮度没有变化,但是更加锐利。
那么边缘是如何检测出来的?
边缘是图像中灰度变化最明显的地方,因此可以直接根据灰度梯度的变化进行边缘检测,这也是最常见的边缘检测方法之一。这种方法大致相当于通过一阶导数检测边缘,但是一阶导数通常会产生较宽的边缘响应,而不是一个明确的边缘位置,因为一阶导数响应的是图像灰度值的变化率,而不是图像中边缘的位置。
一阶导数在边缘附近会产生一个峰值,但它的宽度较大,这意味着它会检测到边缘的变化范围比较宽。这样的话,如果直接使用一阶导数来检测边缘,可能会导致边缘检测结果比较模糊,不够精确。相比之下,二阶导数对图像中的边缘位置更加敏感。在边缘附近,二阶导数的值会出现一个明显的零交叉点,这个零交叉点可以用来确定边缘的位置。因此,通过二阶导数可以更精确地检测图像中的边缘。
我们最关注的是一种各向同性的滤波器,这种滤波器的响应与滤波器作用的图像的突变方向无关。也就是说,各向同性滤波器是旋转不变的,即将原图像旋转之后进行滤波处理,与先对图像滤波再旋转的结果应该是相同的。
而最简单的各向同性微分算子是拉普拉斯算子,其定义为(针对二维图像f(x,y)):
图像像素下函数是离散的,因此使用一阶差分和二阶差分来描述一阶导数、二阶导数。以 x,y 为坐标轴中心点,以矩阵形式表达上述公式:
由于拉普拉斯是一种微分算子,因此其应用强调的是图像中的灰度突变,所以中心点应该为正值进行突出,边界点应该为负值进行淡化。将原图像和拉普拉斯图像叠加在一起,从而得到锐化后的结果,于是矩阵变为:
如上所述就得到了卷积核模板,强化了边缘,使得图像变得更加锐利。
图像操作
从文件中读取图像:
1 | Mat img = imread(filename); |
图像文件格式由扩展名决定。另外 imdecode
和 imencode
函数是读取和写入到内存,而不是文件。
图像基本操作
获取像素灰度值(intensity values):
为了获取图像灰度值,需要知道图像的数据类型和通道数。
下面是单通道灰度图像(8UC1)和像素坐标(x,y):
1 | Scalar intensity = img.at<uchar>(y, x);//注意是行、列坐标(column对应x,row对应y,所以先行后列对应的是先y后x) |
intensity.val[0] 包含的就是该点的灰度值,如果想要先 x 后 y,则可以使用:
1 | Scalar intensity = img.at<uchar>(Point(x, y)); |
接下来是 BGR 格式的三通道图像:
1 | Vec3b intensity = img.at<Vec3b>(y, x);//注意是点的行、列位置 |
也可以使用相同方法来更改像素灰度:
1 | img.at<uchar>(y, x) = 128; |
opencv 中有一些函数,尤其是 calib3d 模块中,如 cv::projectPoints
,它们以 2D 或 3D 点数组构成 Mat,矩阵应该只包含一列,每行对应一个点,矩阵类型应该相应为 32FC2 或 32FC3。这样的矩阵可以用 std::vector 来构造。
1 | vector<Point2f> points; |
内存管理和引用计数
上文提过,Mat 的数据类型是由包含矩阵或者图像的参数(行数、列数、数据类型等)和一个指向数据的指针构成。所以对应同一个数据,我们可以创建多个Mat对象。Mat 使用引用计数的方法来确定当一个 Mat 对象销毁时,数据是否需要随之销毁。下面是在不复制数据的情况下创建两个矩阵的示例:
1 | std::vector<Point3f> points; |
通过上述代码,最终得到的是一个具有 3 列的 32FC1 矩阵,而不是具有 1 列的 32FC3 矩阵。pointsMat 使用来自 points 的数据,并且在销毁时不会释放内存。在这种情况下,points 的使用寿命必须必 pointsMat 长才行。数据拷贝需要 cv::Mat::copyTo 或 cv::Mat::clone。
在函数中将空的 Mat 对象进行输出时,函数内部会调用 Mat::create
,为该矩阵分配数据内存,如果非空而且大小、数据类型均正确,就什么也不做。如果不同就会释放其原来的数据,分配新的数据,如下:
1 | Mat img = imread("image.jpg"); |
常规图像操作
1 | img = Scalar(0);//这里img是一副灰度图像,将其赋值为黑色图像 |
对于 32FC* 系列图像格式的解释:
32F:表示数据类型为单精度浮点数(float),每个像素占用 32 位(4 字节)存储空间。
C1、C2、C3:表示通道数,即图像或矩阵的颜色通道数。C1 表示单通道,C2 表示双通道,C3 表示三通道,以此类推。
- 32FC1:表示单通道的单精度浮点数图像或矩阵,每个像素用单个浮点数表示。
- 32FC2:表示双通道的单精度浮点数图像或矩阵,每个像素用两个浮点数表示。
- 32FC3:表示三通道的单精度浮点数图像或矩阵,每个像素用三个浮点数表示。
图像显示
常规图像显示:
1 | Mat img = imread("image.jpg"); |
32F 格式的图片需要转为 8U 类型才能进行显示(因为 imshow 函数默认假定图像的像素值范围是 [0, 255],并且使用整型来表示像素值。):
1 | Mat img = imread("image.jpg"); |
图像融合
通过下面的公式可以实现两幅图像的线性融合:
通过改变 α 在 0 到 1 之间的值,就能够形成两张图片的交融,类似于幻灯片的溶解效果。
1 |
|
上述程序实现了两幅图像融合的功能,其核心在于 addWeighted() 函数,该函数的两幅输入图像的大小和通道要一致。
addWeighted()
函数处理的公式为:
将各个参数按照之前的公式进行代入即可得到融合结果。
图像亮度、对比度改变
通常的图像处理过程是通过一副或者多副图像作为输入,输出一副图像作为结果,这其中有基于像素变换进行的点运算,也有基于区域变换进行的领域运算。
对于像素变换来说,每个像素的输出值只依赖于对应的输入值,通常像素变换应用于亮度和对比度调整,以及颜色校正和变换上。
像素变换的调整方式主要有两种,乘以或者加上一个常数:
其中参数 α 成为增益,β 称为偏置,分别用来控制对比度和亮度,f(x) 和 g(x) 是输入和输出图像的像素值,更常见公式如下:
1 |
|
saturate_cast()
函数用于执行安全的数据类型转换,确保被转换的值被限制在目标数据类型的有效范围内。如果值超过了目标数据类型能表示的最大值或最小值,它会被“饱和”到最大或最小值,而不会发生溢出或下溢。
在实际应用中无需使用上述循环进行处理,可以直接应用如下函数:
1 | image.convertTo(new_image, -1, alpha, beta); |
void cv::Mat::convertTo(OutputArray m, int rtype, double alpha=1, double beta=0) const;
,其中:
- m 是输出图像,即转换后的图像。
- rtype 是目标图像的数据类型。如果 rtype 为负数(例如 -1),则表示目标图像应与原始图像具有相同的数据类型。
- alpha 是可选的缩放因子,默认为 1。它用于调整像素值的强度。
- beta 是可选的偏移值,默认为 0。它用于调整像素值的偏移。
应用
下面是对前文调整亮度对比度的应用,同时介绍了伽马校正技术(校正图像亮度)。
上图中浅灰色是原始图像直方图,深灰色是亮度为 80 时的直方图,可以看出亮度的调整使得直方图整体右移,但是最大限额只有 255,由于饱和限制,超过 255 的像素只会保持在 255,因此最右侧变为竖线。
上图则是调整了 α 后的直方图,如果 α 小于 1,灰度级会压缩,对比度也会降低,图像整体往中间收缩。
β 参数会增加亮度,同时图像会出现类似于蒙纱的效果,是因为产生的饱和降低了图像对比度。此时调整 α 参数会降低这种影响,但是也会因为饱和失去较亮区域的细节。
伽马校正
伽马校正对输入输出像素进行了非线性变换,以此来校正图像的亮度:
因为非线性的特点,对于不同的像素值,变换方式是不一样的。
从上图可以看出,当 γ < 1 时,输出值会更大,直方图会向右侧移动,反之 γ > 1 时,直方图向左侧移动。
上述图像时调整 α 和 β 获得的,可以看出云朵部分就出现了过饱和失真,使用伽马校正效果如下:
可以看出,伽马校正由于其非线性的特点,失真较小,直方图对比如下,中间为原始直方图:
总结来看,伽马校正实现的是将左侧数据右移,对于右侧数据并无太大影响,尽可能的保留了图像细节,而这是线性变换无法实现的。实现代码如下,使用了查表操作简化计算量:
1 | Mat lookUpTable(1, 256, CV_8U);//建立lookup table |