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