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

原理概述

矩阵上的掩码(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函数,代码简洁、运行效率也高、同时也能更直观地看见掩码矩阵的应用。

  • 20
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值