【OpenCV C++20 学习笔记】矩阵上的掩码(mask)操作

avatar
作者
猴君
阅读量:0

矩阵上的掩码操作

原理概述

矩阵上的掩码(mask)操作非常简单。其实就是根据一个掩码矩阵(mask matrix,也称为核kernel)对图像中的每个像素值进行重新计算。掩码矩阵用当前像素以及相邻像素的值来为当前像素计算一个新值。从数学的角度来看,相当于做了一个加权平均的计算。

锐化

在锐化图片的方法中可以使用掩码操作。例如,可以给图片中的每个像素进行以下运算:
I ( i , j ) = 5 ∗ I ( i , j ) − [ I ( i − 1 , j ) + I ( i + 1 , j ) + I ( i , j − 1 ) + I ( i , j + 1 ) ] ①    ⟺    I ( i , j ) = I ( i , j ) ∗ M , 且 M = i \ j − 1 0 + 1 − 1 0 -1 0 0 -1 5 -1 + 1 0 -1 1 ② I(i,j) = 5*I(i,j) - [I(i-1,j)+I(i+1,j)+I(i,j-1)+I(i,j+1)] \qquad ①\\ \qquad \\ \iff I(i,j) =I(i,j)*M, 且M= \begin{matrix} i \backslash j & -1 & 0 & +1 \\ -1 & \textbf{0} & \textbf{-1} & \textbf{0} \\ 0 & \textbf{-1} & \textbf{5} & \textbf{-1} \\ +1 & \textbf{0} & \textbf{-1} & \textbf{1} \end{matrix} \qquad② I(i,j)=5I(i,j)[I(i1,j)+I(i+1,j)+I(i,j1)+I(i,j+1)]I(i,j)=I(i,j)M,M=i\j10+110-100-15-1+10-11
这是OpenCV官方给出的公式。等式①比较好理解,就是第 i i i行第 j j j列的当前像素 I ( i , j ) I(i,j) I(i,j)的值变成了它自身值的5倍,再减去它上下左右4个相邻像素的值。其实,等式②更加直观, M M M就是掩码矩阵,将当前像素 I ( i , j ) I(i,j) I(i,j)与掩码矩阵 M M M相乘,就可以得出当前像素的新值。但是这里掩码矩阵 M M M的表现形式让我一开始没看懂。仔细研究之后发现,真正的矩阵是我加粗的那个3*3矩阵,第一行和第一列是表示行号和列号的。所以掩码矩阵 M M M实际上是:
M = [ 0 − 1 0 − 1 5 − 1 0 − 1 0 ] M = \begin{bmatrix} 0 & -1 & 0 \\ -1 & 5 & -1 \\ 0 & -1 & 0 \end{bmatrix} M=010151010
这样就很直观了,中心的5,对应的就是当前像素 I ( i , j ) I(i,j) I(i,j)的权重,上下左右4个-1,就是上下左右相邻像素的权重。这样,等式①和等式②是完全等价的。

代码实现

预操作

与该系列的上一篇《扫描图片数据》类似,这个项目的代码也使用了带参的main函数,并写了一个help全局静态函数用来输出使用说明。

关于如何在VS中调试带参的main函数,也参见上一篇文章 关于如何读取和展示图片的基本操作参见本系列的《图片处理基础》

#include <opencv2/imgcodecs.hpp> #include <opencv2/highgui.hpp> #include <opencv2/imgproc.hpp>  import <iostream>;  using namespace cv; using namespace std;  static void help(char* progName) { 	std::cout << endl 		<< "这个程序展示了如何用掩码(mask)过滤图片" 		<< "包括自定义的方法和filter2D方法" << endl 		<< "使用说明:" << endl 		<< progName << " [图片路径--默认值:lena.jpg] [G -- 灰度] " << endl << endl; }  void Sharpen(const Mat& myImage, Mat& Result);  int main(int argc, char* argv[]) { 	help(argv[0]);	//将参数列表中默认的第一个参数,即程序名传入help静态函数,输出使用说明 	const char* filename{ argc >= 2 ? argv[1] : "lena.jpg" };	//如果不存在第二个参数,则将文件名设为默认的lena.jpg  	Mat src, dst0, dst1; 	if (argc >= 3 && strcmp("G", argv[2]))	//如果指定了第三个参数,即G,则按灰度读取图片 		src = imread(filename, IMREAD_GRAYSCALE); 	else 		src = imread(filename, IMREAD_COLOR);	//否则按BGR格式读取  	if (src.empty()) 	{//读取的数据为空时,输出错误信息,并退出程序 		cerr << "打不开图片[" << filename << "]" << endl; 		return EXIT_FAILURE; 	}  	namedWindow("Input", WINDOW_AUTOSIZE);	//创建名为Input的、自动确定尺寸的窗口,用来展示原始的图片 	namedWindow("Output", WINDOW_AUTOSIZE);	//创建名为Onput的、自动确定尺寸的窗口,用来展示锐化后的图片  	cv::imshow("Input", src);	//展示原始图片 	} 

自定义的方法

项目中的Sharpen函数就是自定义的增强对比度的方法。这个方法使用了C风格的二维数组指针,通过行、列指针对数组中的每个元素进行遍历,并修改它们的值。
具体代码如下(注释中解释了每行代码的作用)

如果对于某些函数的使用不是很清楚,可以参考该系列的《《扫描图片数据》》

void Sharpen(const Mat& myImage, Mat& Result) {//myImage为原始矩阵,Result为修改后的结果矩阵 	CV_Assert(myImage.depth() == CV_8U);	//只接收uchar类型的图片数据  	const int nChannels = myImage.channels();	//计算颜色通道数量 	Result.create(myImage.size(), myImage.type());	//修改结果矩阵的大小和类型  	for (int j = 1; j < myImage.rows - 1; ++j) 	{//遍历行 		//分别获取当前行和上下两行的行指针,并保存为uchar常量指针 		const uchar* previous = myImage.ptr<uchar>(j - 1);	//原始矩阵j-1行的行指针 		const uchar* current = myImage.ptr<uchar>(j);	//原始矩阵j行的行指针 		const uchar* next = myImage.ptr<uchar>(j + 1);	//原始矩阵j+1行的行指针  		uchar* output = Result.ptr<uchar>(j);	//变量版的当前行的行指针  		for (int i = nChannels; i < nChannels * (myImage.cols - 1); ++i) 		{//遍历列(乘了颜色通道数量) 			//将当前行的每一个元素都变成新值 			//采用了等式①的算法 			//使用了saturate_cast进行类型转换,以保证计算结果为uchar类型 			output[i] = saturate_cast<uchar>(5 * current[i] 				- current[i - nChannels] - current[i + nChannels] - previous[i] - next[i]); 		} 	} 	 	//将边缘行和边缘列的像素值设为0 	Result.row(0).setTo(Scalar(0)); 	Result.row(Result.rows - 1).setTo(Scalar(0)); 	Result.col(0).setTo(Scalar(0)); 	Result.col(Result.cols - 1).setTo(Scalar(0)); } 

这里使用了上一节中的等式①的算法对当前像素以及相邻像素的值进行了加权平均。
Sharpen函数的最后一段,将边缘行和边缘列的像素值都设为0,是因为再第1行、最后1行、第1列和最后1列上都无法使用掩码矩阵,它们并没有4个相邻的像素,所以干脆就直接让它们变成0。
值得补充的是saturate_cast,即类型转换方法的使用:

类型转换

储存像素值的数据类型可以有很多选择,比如这个项目中的uchar,就是一个8比特的无符号数据结构。但是在很多图像操作中可能会产生超出值域的结果,比如这里的锐化处理。如果某个像素的值就已经是255(uchar类型的最大值),它还要乘以5,那结果很可能会大于255。而且,Sharpen函数的运算中uchar类型的像素值和整型5进行了算是运算,结果会变成整型。但是最终的计算结果又要赋值给uchar类型,如果直接将32位的整型的结果降位为8位的uchar类型,那么高位的24比特数据会丢失,如果这24位中有非零的值,那结果就会发生变化。
出于值域和类型转换的安全问题,OpenCV提供了Saturation算法。Saturation算法对数据的值进行截取,例如转换成uchar类型的算法原理如下:
I ( x , y ) = m i n ( m a x ( r o u n d ( r ) , 0 ) , 255 ) I(x,y) = min(max(round(r),0),255) I(x,y)=min(max(round(r),0),255)
先将要转换的数据r取整。然后,如果r小于0(uchar类型的最小值),则变成0;如果大于0,则将其与255(uchar类型的最大值)相对比。如果小于255则保留该结果,如果大于255,则变成255。
通过这种算法,将原始值截留在uchar类型[0,255]的值域当中。当原始值本来就在这个值域当中,则只是进行了取整操作;当原始值落在该值域之外,则要么直接变成最小值,要么直接变成最大值。

filter2D方法

因为掩码操作在OpenCV中非常常用,所以它提供一个应用掩码矩阵的方法filter2D。要使用该方法,需要先确定掩码矩阵:

Mat kernel = (Mat_<char>(3, 3) << 0, -1, 0, 								-1, 5, -1, 								0, -1, 0); 

关于Mat对象的创建,可以参考该系列的《基本图像容器——Mat》

接下来就可以使用filter2D函数了,该函数共使用4个参数:

  • 原始矩阵
  • 结果矩阵
  • 原始矩阵的数据类型
  • 掩码矩阵
    其实还可以传入第5个参数,来制定掩码矩阵的中心位置;甚至有第6个、第7个参数,但这里不过多介绍了。本项目中的使用如下:
filter2D(src, dst1, src.depth(), kernel); 

完整代码

//#include <opencv2/core.hpp> #include <opencv2/imgcodecs.hpp> #include <opencv2/highgui.hpp> #include <opencv2/imgproc.hpp>  import <iostream>;  using namespace cv; using namespace std;  static void help(char* progName) { 	std::cout << endl 		<< "这个程序展示了如何用掩码(mask)过滤图片" 		<< "包括自定义的方法和filter2D方法" << endl 		<< "使用说明:" << endl 		<< progName << " [图片路径--默认值:lena.jpg] [G -- 灰度] " << endl << endl; }  void Sharpen(const Mat& myImage, Mat& Result);  int main(int argc, char* argv[]) { 	help(argv[0]);	//将参数列表中默认的第一个参数,即程序名传入help静态函数,输出使用说明 	const char* filename{ argc >= 2 ? argv[1] : "lena.jpg" };	//如果不存在第二个参数,则将文件名设为默认的lena.jpg  	Mat src, dst0, dst1; 	if (argc >= 3 && strcmp("G", argv[2]))	//如果指定了第三个参数,即G,则按灰度读取图片 		src = imread(filename, IMREAD_GRAYSCALE); 	else 		src = imread(filename, IMREAD_COLOR);	//否则按BGR格式读取  	if (src.empty()) 	{//读取的数据为空时,输出错误信息,并退出程序 		cerr << "打不开图片[" << filename << "]" << endl; 		return EXIT_FAILURE; 	}  	namedWindow("Input", WINDOW_AUTOSIZE);	//创建名为Input的、自动确定尺寸的窗口,用来展示原始的图片 	namedWindow("Output", WINDOW_AUTOSIZE);	//创建名为Onput的、自动确定尺寸的窗口,用来展示锐化后的图片  	cv::imshow("Input", src);	//展示原始图片  	//开始计时 	double t{ static_cast<double>(getTickCount()) };  	Sharpen(src, dst0);	//调用自定义的Sharpen函数  	t = (static_cast<double>(getTickCount()) - t) / getTickFrequency();	//结束并计算计时 	std::cout << "自定义方法用时:" << t << "秒" << endl;		//输出计时  	cv::imshow("Output", dst0);	//展示修改后的图片 	cv::waitKey();	//等待用户按键  	//创建掩码矩阵 	Mat kernel = (Mat_<char>(3, 3) << 0, -1, 0, 									-1, 5, -1, 									0, -1, 0); 	 	//开始计时 	t = static_cast<double>(getTickCount());  	cv::filter2D(src, dst1, src.depth(), kernel);	//调用filter2D函数  	t = (static_cast<double>(getTickCount()) - t) / getTickFrequency();	//结束并计算计时 	std::cout << "filter2D方法用时:" << t << "秒" << endl;	//输出计时  	cv::imshow("Output", dst1);	//展示修改后的图片  	cv::waitKey();	//等待按键 	return EXIT_SUCCESS;	//退出程序 }  void Sharpen(const Mat& myImage, Mat& Result) {//myImage为原始矩阵,Result为修改后的结果矩阵 	CV_Assert(myImage.depth() == CV_8U);	//只接收uchar类型的图片数据  	const int nChannels = myImage.channels();	//计算颜色通道数量 	Result.create(myImage.size(), myImage.type());	//修改结果矩阵的大小和类型  	for (int j = 1; j < myImage.rows - 1; ++j) 	{//遍历行 		//分别获取当前行和上下两行的行指针,并保存为uchar常量指针 		const uchar* previous = myImage.ptr<uchar>(j - 1);	//原始矩阵j-1行的行指针 		const uchar* current = myImage.ptr<uchar>(j);	//原始矩阵j行的行指针 		const uchar* next = myImage.ptr<uchar>(j + 1);	//原始矩阵j+1行的行指针  		uchar* output = Result.ptr<uchar>(j);	//变量版的当前行的行指针  		for (int i = nChannels; i < nChannels * (myImage.cols - 1); ++i) 		{//遍历列(乘了颜色通道数量) 			//将当前行的每一个元素都变成新值 			//采用了等式①的算法 			//使用了saturate_cast进行类型转换,以保证计算结果为uchar类型 			output[i] = saturate_cast<uchar>(5 * current[i] 				- current[i - nChannels] - current[i + nChannels] - previous[i] - next[i]); 		} 	} 	 	//将边缘行和边缘列的像素值设为0 	Result.row(0).setTo(Scalar(0)); 	Result.row(Result.rows - 1).setTo(Scalar(0)); 	Result.col(0).setTo(Scalar(0)); 	Result.col(Result.cols - 1).setTo(Scalar(0)); }  

关于给方法的运行计时,参考本系列的《扫描图片数据》

使用默认参数的运行结果如下:
自定义方法锐化结果与原图对比
Filter2D方法锐化结果
运行时间的对比
奇怪的时,OpenCV的官方文档中说,filter2D的用时比自定义的方法要少很多。可能是因为我是在debug模式中测试的。
在release模式下测试,运行结果如下:
release模式下运行时间的对比
filter2D方法的用时还不到自定义方法的一半。

结论

如果要对图片进行掩码操作(mask operation),比如锐化处理,尽量使用OpenCV自带的filter2D函数,代码简洁、运行效率也高、同时也能更直观地看见掩码矩阵的应用。

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!