【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)=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−10+1−10-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= 0−10−15−10−10

这样就很直观了,中心的5,对应的就是当前像素 I ( i , j ) I(i,j) I(i,j)的权重,上下左右4个-1,就是上下左右相邻像素的权重。这样,等式①和等式②是完全等价的。

代码实现

预操作

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

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

cpp 复制代码
#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风格的二维数组指针,通过行、列指针对数组中的每个元素进行遍历,并修改它们的值。

具体代码如下(注释中解释了每行代码的作用)

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

cpp 复制代码
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。要使用该方法,需要先确定掩码矩阵:

cpp 复制代码
Mat kernel = (Mat_<char>(3, 3) << 0, -1, 0,
								-1, 5, -1,
								0, -1, 0);

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

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

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

完整代码

cpp 复制代码
//#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));
}

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

使用默认参数的运行结果如下:


奇怪的时,OpenCV的官方文档中说,filter2D的用时比自定义的方法要少很多。可能是因为我是在debug模式中测试的。

在release模式下测试,运行结果如下:

filter2D方法的用时还不到自定义方法的一半。

结论

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

相关推荐
IVEN_2 天前
Python OpenCV: RGB三色识别的最佳工程实践
python·opencv
西岸行者8 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
悠哉悠哉愿意8 天前
【单片机学习笔记】串口、超声波、NE555的同时使用
笔记·单片机·学习
别催小唐敲代码8 天前
嵌入式学习路线
学习
毛小茛8 天前
计算机系统概论——校验码
学习
babe小鑫8 天前
大专经济信息管理专业学习数据分析的必要性
学习·数据挖掘·数据分析
winfreedoms8 天前
ROS2知识大白话
笔记·学习·ros2
在这habit之下8 天前
Linux Virtual Server(LVS)学习总结
linux·学习·lvs
我想我不够好。8 天前
2026.2.25监控学习
学习
im_AMBER8 天前
Leetcode 127 删除有序数组中的重复项 | 删除有序数组中的重复项 II
数据结构·学习·算法·leetcode