初稿完成于2020.2.11

上节课讲了图像的滤波,滤完波之后就需要进行“锐化”(锐化的概念参考上一届中的“模糊与锐化”的区分部分),用于边缘提取——这也是这节课的主要内容。除此之外,还会讲一些阈值化操作之类的常用操作,好了,直接进入正题——

一、边缘检测

边缘指图象中灰度发生急剧变化的区域,想得到边缘,就是要分析计算各个方向上像素点灰度的梯度变化。边缘检测有一些常用的算子和滤波器(显然,这里的滤波器用于高频滤波),如下所示:

1、canny算子

我一般检测图像轮廓的时候比较喜欢用canny算子(准确度灰常高),canny算子也被很多人推崇为当今最优的边缘检测算法(嗯,总之就是,很好用就对了)。对边缘检测算法的三个主要评判标准为:

(1)低错误率:标识出尽可能多的实际边缘,同时尽可能减少噪声产生的误差;

(2)高定位性:标识出的边缘要与图像中的实际边缘尽可能接近;

(3)最小相应:图像中的边缘只能标识一次,并且可能存在的图像噪声不应标识为边缘。

那我们来看看canny算子究竟是怎么做的,能让边缘检测达到“最优化”的——

(1)高斯滤波

OpenCV中,进行canny边缘检测的第一步就是高斯滤波(当然其它的图像处理库里面也可能采用其它的滤波方式),高斯滤波在上一节中讲过了,目的也很明显嘛:消除噪声干扰,提高边缘检测的准确性。

(2)计算梯度幅值和方向

嗯,滤完波之后就需要计算梯度了,毕竟得到边缘的方法就是计算各个方向上像素点的灰度的梯度变化。理论上需要计算各个方向的梯度变化,但是实际上并不能做到(所有方向得多少个方向啊orz),在经典的canny算法中,其实只计算了0°、45°、90°、135°这四个方向的梯度幅值(就是水平、垂直、两个对角线这四个方向),用了四个梯度算子。

唔,因为计算机中存储的图像是由一个个像素点构成的嘛,也就是说它的值时离散的,所以梯度是这么表示的:
opencv边缘检测填充 opencv边缘检测的结果怎么用_c++
梯度幅值就是:
opencv边缘检测填充 opencv边缘检测的结果怎么用_边缘检测_02
梯度方向:
opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_03
刚刚说了在OpenCV的实际计算中梯度方向取了四个方向,但通常在计算的时候并不是像经典canny算法中一样,沿着四个梯度方向分别进行计算的,而是用边缘差分算子(用的其实是Sobel算子,具体运算在下面Sobel算子中有讲解)计算水平和垂直方向的差分Gx和Gy,用上式得到的方向可见,梯度角度θ范围从弧度-π到π,然后把它近似到四个方向。

(3)非极大值抑制

这一步的目的是避免边缘的重复,相同的边缘要做到只保留一个像素点的宽度(因为上一步中得到的边缘很宽,会让图像边缘不止被标识一次)。要注意的是,这里的关键是保留“梯度方向”的极大值,而不是邻域内的,不然就和腐蚀算法没有区别了。下面是梯度方向与边缘方向的关系图(要注意的是,在树莓派读取的图像中,会以左上角为坐标原点,→为x方向,↓为y方向):

opencv边缘检测填充 opencv边缘检测的结果怎么用_边缘检测_04

当然,因为实际图像的梯度方向不止四个基本方向,因此有的算法中还用了亚像素细分的操作(一般会使用线性插值来进行估计),在这里不细说。

(4)滞后阈值

现在已经得到了单个宽度的边缘了,但要注意的是——我们之前的三部操作都是基于灰度图的操作,也就是说,像素点的值是远远超出范围的。而最后我们要得到的边缘显然不能是分布在那么大范围内的灰度值,应该得到的是二值图像——也就是说,边缘应该明显区分于非边缘的点,边缘用最大值来存储,非边缘则直接置零。

在其它的边缘检测算子中,将灰度值转化为二值的操作是单阈值的操作,就是简单的设定一个阈值,将大于它的认作边缘,小于它的直接排除。这样会导致实际边缘可能会不连通(如果有一道边缘一部分处于阈值之上,一部分处于阈值之下,就会导致这个结果),因此,canny算子应用了滞后阈值,即需要两个阈值进行操作(可以类比模电中的迟滞电压比较器的高低阈值)。呐,就像这样:

opencv边缘检测填充 opencv边缘检测的结果怎么用_opencv_05

双阈值对于灰度边缘转换为二值边缘的操作为:

(1)梯度大于 maxVal 的任何边缘视为真边缘,而 minVal 以下的边缘视为非边缘。

(2)位于这两个阈值之间的边缘会基于其连通性而分类为边缘或非边缘。如果它们连接到“可靠边缘”像素,则它们被视为边缘的一部分;否则,会被丢弃。

这样就避免了边缘如果刚好卡在阈值上时,出现的边缘断断续续的情况。要注意的是,一般来说,高阈值maxVal推荐是低阈值minVal的2~3倍。

下面我们来看看OpenCV提供给我们的有关canny算子的API接口函数(上面说的一堆原理在我们实际使用的时候其实并不需要自己操作,我们只需要调用现成的一个函数就好了):

void Canny(
    InputArray image,	//输入图像,需为单通道8位图像
    OutputArray edges,	//输出的边缘图
    double threshold1,	//低阈值
	double threshold2,	//高阈值
    int apertureSize=3,	//使用的Sobel算子孔径大小
    bool L2gradient=false);//计算图像梯度幅值的标识,有默认值false
Canny(src,dst,n,n*3,3);

唔,要求这个函数的输入图像为单通道8位图像(也就是灰度图像),但是我试了一下,似乎直接把彩色图像写进去也不会报错orz,不过还是转换一下比较好(转化图像类型的CTVColor函数在part2中讲过了),呐:

//假设之前srcImage已定义为一幅彩色图像
Mat grayImage, imgCanny;
cvtColor(srcImage, grayImage, COLOR_BGR2GRAY);//将原图像转换为灰度图像
Canny(grayImage, imgCanny, 50, 150, 3);//进行canny边缘检测
2、Sobel算子

Sobel算子的运算就没有canny算子那样复杂了,它只进行了求取各个像素点的梯度幅值,然后只取一个阈值,进行边缘判断的操作。Sobel算子对噪声具有平滑作用,提供较为精确的边缘方向信息,边缘定位精度不够高。当对精度要求不是很高时,是一种较为常用的边缘检测方法。

嗯,它首先会从水平和垂直两个方向分别求取梯度——注意这里是两个方向分别求取啊!要得到整体方向的Sobel梯度,只需要将Gx和Gy相加即可。

那么,怎么求取x和y方向的梯度呢?其实方法也很简单,就是用下面两个算子(以3*3为例)分别对原图像进行卷积,得到两副图像:
opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_06
然后将两者进行合成,视为图像的整体梯度图。理论上讲应该这么合成:
opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_07
但是实际上,为了减少计算量是这么合成的:
opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_08
对,就是去掉方向直接相加就完事儿了。这样会得到一个梯度幅值的灰度图——最后再设定一个阈值,高于这个阈值的视为边缘,低于这个阈值的直接舍弃,这样就得到了最终梯度的二值图,也就是边缘检测的效果图。

好了,让我们来看看OpenCV提供的Sobel算子的API函数:

void Sobel (
    InputArray src,	//输入图像
    OutputArray dst,//目标图像
    int ddepth,		//输出图像的深度
    int dx, 		//x方向上的差分阶数
    int dy, 		//y方向上的差分阶数
    int ksize=3,	//表示Sobel核的大小,必须取1,3,5或7
    			//一般都是用ksize×ksize内核来计算导数的
    		//但当ksize=1,往往用3×1或1×3的内核,且这种情况下并没有进行高斯平滑
    double scale=1,	//计算导数值时可选的缩放因子,默认情况下是没有应用缩放的
    double delta=0, //表示在结果存入目标图之前可选的delta值
    int borderType=BORDER_DEFAULT );//边界模式

//其中,输出图像的深度,支持如下src.depth()和ddepth的组合:
src.depth() = CV_8U			ddepth =-1/CV_16S/CV_32F/CV_64F
src.depth() = CV_16U/CV_16S	ddepth =-1/CV_32F/CV_64F
src.depth() = CV_32F		ddepth =-1/CV_32F/CV_64F
src.depth() = CV_64F		ddepth = -1/CV_64F

大家可以看到,一般来说在调用OpenCV提供的Sobel函数的接口时,你需要自己分别提取x和y方向的梯度幅值,然后进行合成。一般取【xorder=1,yorder=0,ksize=3】计算X方向的导数;【xorder=0,yorder=1,ksize=3】计算Y方向的导数。你问为什么?嗯你可以试试不这么办,会发现提取的边缘细且模糊,只有在分方向提取了图像的边缘,然后进行合成的情况下,得到的边缘效果才好。呐,求X方向的梯度就像这样:

Sobel(srcIamge, grad_x, CV_16S, 1, 0, 3, 1, 1, BORDER_DEFAULT);//求X方向梯度

嗯,然后如果你直接用imshow对它进行显示,你会发现显示不出来你想要的效果——回头看一眼grad_x的图像类型,是CV_16S诶,16位深度的(OpenCV常用这个类型来表示视图差,因为emmm8位的可能不够大,卷积完大概率有超过255的像素);而imshow函数只适用于8位深度的图像!如果你塞给它16位的,它会把所有大于255的像素值都搞成255来显示——所以你当然看不出图像的边缘了。

这个时候,我们就需要用到这个函数:convertScaleAbs。老规矩,看一下函数原型:

void cv::convertScaleAbs(
	cv::InputArray src,	// 输入数组
	cv::OutputArray dst,// 输出数组
	double alpha = 1.0,	// 乘数因子
	double beta = 0.0);	// 偏移量

嗯,这个函数一般是用来干嘛的呢?用于实现图像的增强——你看上面函数允许你输入乘数因子和偏移量,可以将图像整体实现增强的效果。但是在这里我们并不是为了增强图像,是为了将原本没法用imshow进行显示的16位深度的图像变为8位深度的图像,因为这个函数不会直接将大于255的像素点转化为255,而是实现的线性变化来使图像归一化。调用的时候,呐,直接像这样就可以了:

convertScaleAbs(grad_x, abs_grad_x);//使用线性变换转换输入数组元素成8位无符号整型

嗯,现在就可以用imshow显示一下图像看看效果了。好了,再用同样的方法可以得到y方向的梯度图:

Sobel(grayImage, grad_y, CV_16S, 0, 1, 3, 1, 1, BORDER_DEFAULT);//求Y方向梯度
convertScaleAbs(grad_y, abs_grad_y);//使用线性变换转换输入数组元素成8位无符号整型

然后我们需要对图像进行合成。那么问题来了,怎么合成呢?来来来,再介绍一个函数:addWeighted。它能实现图像的线性混合(其实就是计算图像的加权和),函数原型:

void addWeighted(
    InputArray src1,//要叠加的第一个图像
    double alpha,	//第一个叠加图像的权重
    InputArray src2,//要叠加的第二个图像,需和第一个拥有同样的尺寸和通道
    double beta,	//第二个叠加图像的权重
    double gamma,	//累加到权重总和上的偏差
    OutputArray dst,//输出图像
    int dtype = -1);//输出阵列的深度

这个函数实现的操作其实就是:dst = src1[i] * alpha + src2[i] * beta + gamma。于是我们将两幅图像进行梯度的合并,就像这样:

addWeighted(abs_grad_x, 0.5, abs_grad_y, 0.5, 0,dstIamge);//合并梯度(近似)

好了,Sobel算子这样就计算完成了。

3、Scharr滤波器

Sobel算子的缺点是,在核比较小(比如3*3)的情况下,得到的结果并不是很准确,可能会产生明显误差。因此OpenCV提供了Scharr算子,其内核是这个样子滴:
opencv边缘检测填充 opencv边缘检测的结果怎么用_opencv边缘检测填充_09
可以看出,由于都是卷积运算,所以Scharr算子和Sobel算子的运行速度是一样的。但是因为Scharr算子比Sobel算子的数值要大,因此对于灰度变化较为敏感,会得到较强的边缘强度,不过细节就会不太好。具体的区别可以打开随附代码part4,运行一下进行比较。(嗯,你会发现Scharr算子边缘似乎识别的更清晰一些,但是干扰也比较多,因为它没有在内部进行滤波操作)

看一下它的函数原型:

void Scharr(
    InputArray src,	//输入图像
    outputArray dst,//输出图像
    int ddepth,		//输出图像的深度
    int dx,		//x方向上的差分阶数
    int dy,		//y方向上的差分阶数
    double scale = 1,//计算导数值时可选的缩放因子
    double delta = 0,//在结果存入目标图之前可选的delta值
    intborderType = BORDER_DEFAULT);//边界模式

你会发现它的接口参数和Sobel算子差不多。嗯,用起来也差不多。就这样:

Mat grayImage, dstImage;
GaussianBlur(srcImage, srcImage, Size(5, 5), 0, 0, BORDER_DEFAULT);
cvtColor(srcImage, grayImage, COLOR_BGR2GRAY);//将原图像转换为灰度图像

Mat grad_x, grad_y, abs_grad_x, abs_grad_y;
//求 X方向梯度
Scharr(grayImage, grad_x, CV_16S, 1, 0);
convertScaleAbs(grad_x, abs_grad_x);
imshow("X方向Scharr", abs_grad_x);
//求Y方向梯度
Scharr(grayImage, grad_y, CV_16S, 0, 1);
convertScaleAbs(grad_y, abs_grad_y);
imshow("Y方向Scharr", abs_grad_y);

//合并梯度(近似)
addWeighted(abs_grad_x, 0.5, abs_grad_y, 0.5, 0, dstImage);
imshow("整体方向Scharr", dstImage);
4、Laplace算子

上面的Sobel算子计算梯度,其实是模拟一阶求导。呐,就像这样:

opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_10

边缘在源图像里表现为像素值剧烈变化的点,那么求导后,在一阶导数里就表现为极值点(忽略掉方向,其实就是极大值点)。因此,二阶求导后就变成了这个亚子:

opencv边缘检测填充 opencv边缘检测的结果怎么用_opencv_11

嗯,就表现为了值为0的点——但要注意的是,二阶求导为0的位置也可能是无意义的位置。嗯,于是Laplace算子的运算其实是这样子的:
opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_12
因为图像中能够读取的一个个像素点是离散的嘛,以3*3大小的内核距离,求二阶导其实就是进行这样的运算:

X方向的二阶偏导数——dx = f(x+1, y) + f(x-1, y) – 2*f(x, y)

Y方向的二阶偏导数——dy = f(x, y+1) + f(x, y-1) – 2*f(x, y)

整理成内核就是这个亚子:
opencv边缘检测填充 opencv边缘检测的结果怎么用_边缘检测_13
需要说明的是,因为Laplace算子在计算中用到了图像的“梯度”,所以其内部算法其实是调用了Sobel算子的。让一幅图像减去它的Laplacian可以增强对比度。

呐,函数原型给你:

void Laplacian(
    InputArray src,	//输入图像,需为单通道8位图像
    outputArray dst,//输出图像
    int ddepth,		//目标图像的深度
    int ksize=1,	//计算二阶导数的滤波器的孔径尺寸,必须为正奇数
    				// 等于1是四邻域算子,大于1改用八邻域算子
    double scale=1,	//计算拉普拉斯值时候可选的比例因子
    double delta=0,	//结果存入目标图之前可选的delta值
    intborderType=BORDER_DEFAULT );//边界模式

调用就这样:

Laplacian(srcImage, dstImage, CV_16S, 3, 1, 0, BORDER_DEFAULT);

二、阈值化

唔,阈值化就是一种简单粗暴的分割图像的方法——把高于阈值的像素点直接置于最大,或低于阈值的像素点直接置于最小,或者反过来啊什么的。原理没什么好讲的,直接看函数——OpenCV提供了两个用于阈值化的函数,一个是Threshold函数(固定阈值操作),另一个是adaptiveThreshold函数(自适应阈值操作),呐:

1、Threshold(固定阈值操作)

应用有两种。一种是对灰度图像进行阈值化操作得到二值图像;另一种是去掉噪声(通过过滤像素值很小或很大的点来实现)。函数原型:

double threshold(
    InputArray src,	//输入图像,需为单通道8或32位Mat类
    OutputArray dst,//输出图像
    double thresh,	//阈值的具体值
    double maxval,	//阈值类型的最大值
    int type);		//阈值类型

阈值类型有以下五种:

opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_14

分别对应type的0、1、2、3、4,用形状来形象表示成这个亚子:

opencv边缘检测填充 opencv边缘检测的结果怎么用_c++_15

调用也很简单,就这样:

threshold(srcImage,dstImage,100,255,0);
2、adaptiveThreshold(自适应阈值操作)

唔,既然已经有了固定阈值操作,那么为什么还要另一个自适应阈值操作呢?对于光线较为均匀的图像来说,固定阈值取得的效果必然不错,但当图片的光线不均匀时,固定阈值的效果立马就不好了——这个时候就需要自适应阈值的操作。

自适应阈值不需要确定一个固定的阈值,而是可以根据对应的自适应方法,通过图像的局部特征自适应的设定阈值,做出二值化处理。也就是说,一个像素点是否要进行阈值化操作,是由这个像素点的值与周围像素点比较而得出的,并不是由单一的固定阈值而决定。

void adaptiveThreshold(
    InputArray src,		//输入图像,需为8位单通道浮点型
    OutputArray dst,	//输出图像
    double maxValue,	//给像素赋的满足条件的非零值
    int adaptiveMethod,	//要使用的自适应阈值算法
    int thresholdType,	//阈值类型
    int blockSize,	//计算阈值大小的一个像素的领域尺寸,取值为大于1的奇数
	double C);	//减去加权平均值后的常数值,通常为正数,少数情况下也可为0或负数

其中,通过计算每个像素位置周围的blockSize x blockSize区域的加权平均值,然后减去常数C,得到输出图像。adaptiveMethod有两种:

类型

含义

ADAPTIVE_THRESH_MEAN_C

计算均值时每个像素的权值是相等的

ADAPTIVE_THRESH_GAUSSIAN_C

计算均值时每个像素的权值根据其到中心点的距离通过高斯方程得到

当中thresholdType只能取上面“固定阈值化操作”中所讲的五种固定阈值类型中的前两种!!!其它参数含义和Threshold中的参数含义一样。

调用的时候:

adaptiveThreshold(grayImage, dstImage, 255, ADAPTIVE_THRESH_MEAN_C, THRESH_BINARY, 5, 9);