技术笔记
OpenCV 教程(2):图像转换
深入 OpenCV 图像变换:色彩空间转换、几何变换、阈值处理、平滑滤波、形态学操作与梯度计算。
关键结论
- 滤波的选择取决于噪声类型与是否需要保边:高斯适合通用平滑,中值适合椒盐噪声,双边更重视边缘。
- 阈值、形态学与几何变换应以任务目标为准,不存在对所有图像都最好的固定参数。
- 调参时保留原图、输出图和参数记录,才能判断变化来自算法还是数据。
本文的使用方式
这一篇把常见的图像变换放在同一条工作链里:先理解颜色空间与几何关系,再用滤波控制噪声,最后根据目标使用阈值、形态学或梯度。它不是“记住函数列表”,而是建立选择方法的依据。
如果你正在处理自己的图片,建议一次只改变一个参数,并把结果和原图并排比较。这样更容易判断是细节被抹掉、边缘被增强,还是阈值把有效区域误删了。
运行环境与公共工具
下面的初始化代码只负责加载样例和统一展示输出。读正文时可以把注意力放在“输入经过什么变换、输出为什么变化”,而不是反复分辨绘图样板代码。
图形平滑
主要包括: 卷积/滤波/模糊/降噪
总结:
- 在不知道用什么滤波器好的时候,优先高斯滤波 cv2.GaussianBlur(),然后均值滤波cv2.blur()
- 斑点和椒盐噪声优先使用中值滤波cv2.medianBlur()
- 要去除噪点的同时尽可能保留更多的边缘信息,使用双边滤波cv2.bilateralFilter()
- 线性滤波方式:均值滤波、方框滤波、高斯滤波(速度相对快)
- 非线性滤波方式:中值滤波、双边滤波(速度相对慢)
2D 卷积:均值滤波与模糊
关于滤波和模糊,很多人分不清:
- 它们都属于卷积,不同滤波方法之间只是卷积核不同(对线性滤波而言)
- 低通滤波器是模糊,高通滤波器是锐化
卷积运算可看作是加权求和的过程,如要求某一个像素的卷积,那么需要将卷积核的中心锚点与此像素对齐,然后求此元素及其周边元素与卷积核的乘积之和,得到的值就是此像素的新的值。对于在边上的元素可以采用 padding 技术将其补齐,如下图:

对于卷积核有以下几个要求:
- 滤波器的大小应该是奇数,这样它才有一个中心,例如3x3,5x5或者7x7。有中心了,也有了半径的称呼,例如5x5大小的核的半径就是2。
- 滤波器矩阵所有的元素之和应该要等于1,这是为了保证滤波前后图像的亮度保持不变。当然了,这不是硬性要求了。
- 如果滤波器矩阵所有元素之和大于1,那么滤波后的图像就会比原图像更亮,反之,如果小于1,那么得到的图像就会变暗。如果和为0,图像不会变黑,但也会非常暗。
- 对于滤波后的结构,可能会出现负数或者大于255的数值。对这种情况,我们将他们直接截断到0和255之间即可。对于负数,也可以取绝对值。
低通滤波器(LPF) 就是允许低频信号通过,在图像中边缘和噪点都相当于高频部分,所以低通滤波器用于去除噪点、平滑和模糊图像。高通滤波器(HPF)则反之,用来增强图像边缘,进行锐化处理。
常见噪声有椒盐噪声和高斯噪声,椒盐噪声可以理解为斑点,随机出现在图像中的黑点或白点;高斯噪声可以理解为拍摄图片时由于光照等原因造成的噪声;这样解释并不准确,只要能简单分辨即可
OpenCV提供的函数 cv.filter2D()可以对一幅图像进行卷积操作。均值滤波是一种最简单的滤波处理,它取的是卷积核区域内元素的均值,用cv2.blur() 实现,核说白了就是一个固定大小的数值数组。该数组带有一个 锚点 ,一般位于数组中央
,如3×3的卷积核(其中因此的分母为权重矩阵所有元素的和):
【参考】
- 图像处理:基础(模板、卷积运算)
- opencv filter2D
- 图像卷积与滤波的一些知识点


方框滤波
使用 cv2.boxFilter() 方框滤波跟均值滤波很像,当可选参数 normalize 为True的时候,方框滤波就是均值滤波。当 normalize 为 False 的时候,相当于区域内像素求和,当和大于 25 5时值就为 255,因此我们就看到下面的第三张图只有部分物体显示了出来,其他值都被置为了 255 。

高斯模糊(滤波)
现在把卷积核换成高斯核,简单的说方框不变,将原来每个方框的值是相等的,现在里面的值是符合高斯分布的,方框中心的值最大,其余方框根据距离中心元素的距离递减,构成一个高斯小山包,原来的求平均数变成求加权平均数,权就是方框里的值。如下图:

高斯滤波器是一个低通滤波器,可以使用 cv2.GaussianBlur() 来实现,参数如下:
- src: 原图像,
- ksize: 高斯核的大小,
- sigmaX、sigmaY: X Y 轴的方差,如果我们只指定了X方向的标准差,Y方向也会取相同值,如果两个标准差都是0.那么函数会根据核函数的大小自己计算,高斯滤波可以有效的从图像中去除高斯噪音。
也可以使用 cv2.getGaussianKernel()自己构建一个高斯核.

中值模糊
中值又叫中位数,是所有值排序后取中间的值。中值滤波就是用区域内的中值来代替本像素值,所以那种孤立的斑点,如0或255很容易消除掉,适用于去除椒盐噪声和斑点噪声。中值是一种非线性操作,效率相比前面几种线性滤波要慢。
使用 cv2.medianBlur() 完成操作

双边滤波
模糊操作基本都会损失掉图像细节信息,尤其前面介绍的线性滤波器,图像的边缘信息很难保留下来。然而,边缘edge信息是图像中很重要的一个特征,所以这才有了双边滤波。用cv2.bilateralFilter()函数实现,他能在保持边界清晰的情况下有效的去除噪音,但比较慢。
高斯滤波器只考虑像素之间的空间关系,而不会考虑像素值之间的关系(像素的相似度),所以这种方法不会考虑一个像素是否位于边界,因此边界也会被模糊掉。
双边滤波在同时使用空间高斯权重和灰度值相似性高斯权重。空间高斯函数确保只有邻近区的像素对中心点有影响,灰度值相似性高斯函数确保只有与中心像素灰度值相近的才会被用来做模糊运算。所以能保证边界不会被模糊,因此边界处的灰度值变化比较大。

形态学操作
形态学操作其实就是改变物体的形状,比如腐蚀就是”变瘦”,膨胀就是”变胖”,看下图就明白了:

一般情况下对二值化图像进行操作,来连接相邻的元素或分离成独立的元素。需要两个参数,一个是原始图像,第二个被称为结构化元素或者核,它是用来决定操作的性质的。基本操作为腐蚀和膨胀,腐蚀和膨胀是针对图片中的白色部分,他们的变体构成了开运算,闭运算,梯度等。
因为形态学操作其实也是应用卷积来实现的,所以结构元素也叫做核。结构元素可以是矩形/椭圆/十字形,可以用 cv2.getStructuringElement() 来生成不同形状的结构元素,比如:

【参考】
- Python+OpenCV教程12:腐蚀与膨胀
腐蚀
腐蚀的效果是把图片”变瘦”,其原理是在原图的小区域内取局部最小值。因为是二值化图,只有0和255,所以小区域内有一个是0该像素点就为0:


膨胀
膨胀与腐蚀相反,取的是局部最大值,效果是把图片”变胖”。一般在去噪音时先腐蚀再膨胀,因为腐蚀再去掉白噪音的同时,也会使前景对象变小,所以我们再膨胀。这时噪音已经被去除,不会再回来了,但是前景还在并会增加,膨胀也可以用来连接两个分开的物体。
膨胀使用 cv2.dilation() 操作,参数 iterations 表示膨胀在图片上的迭代次数。

开运算与闭运算
先进行腐蚀再进行膨胀就叫做开运算(因为先腐蚀会分开物体,这样容易记住),其作用是:分离物体,消除小区域,被用来去除噪音。

函数可以使用cv2.morphotogyEx() , 参数为:
- src: 原图像,
- op: 操作,值为cv2.MORPH_OPEN 着 cv2.MORPH_CLOSE,
- kernel: 进行操作的核

其他形态操作
形态梯度
膨胀图减去腐蚀图,dilation - erosion,这样会得到物体的轮廓。一样使用 cv2.morphologyEx() 来完成操作,只是第二个参数变成了cv2.MORPH_GRADIENT.

顶帽
原始图像与进行开运算之后得到的图像的差,src - opening, 使用 cv2.MORPH_TOPHAT

黑帽
闭运算后的图减去原图:closing - src, 使用 cv2.MORPH_BLACKHAT

图像梯度
图像梯度原理:简单来说就是求导。
如果你还记得高数中用一阶导数来求极值的话,就很容易理解了:把图片想象成连续函数,因为边缘部分的像素值是与旁边像素明显有区别的,所以对图片局部求极值,就可以得到整幅图片的边缘信息了。不过图片是二维的离散函数,导数就变成了差分,这个差分就称为图像的梯度。
【参考】
- Python+OpenCV教程番外篇8:图像梯度
垂直边缘的提取
滤波是应用卷积来实现的,卷积的关键就是卷积核,我们来考察下面这个卷积核:
这个核是用来提取图片中的垂直边缘的,怎么做到的呢?看下图:


Sobel算子
上面的这种差分方法就叫Sobel算子,它先在垂直方向计算梯度Gx=k1×src,再在水平方向计算梯度Gy=k2×src,最后求出总梯度:$G=\sqrt{Gx^2+Gy^2}$
Sobel算子是高斯平滑与微分操作的结合体,它的抗噪音能力很好。可以设定求导的方向(xorder或yorder)。还可以设定使用的卷积核的大小(ksize),如果ksize=-1,会使用3x3的Scharr滤波器,效果会更好,若速度相同,在使用3x3滤波器时尽量使用Scharr。
我们可以把前面的代码用Sobel算子更简单地实现:
很多人疑问,Sobel算子的卷积核这几个值是怎么来的呢?事实上,并没有规定,你可以用你自己的。当然,3×3下另外一个卷积核相比Sobel更好用,叫Scharr算子,大家可以了解下:$K = \left[ \begin{matrix} -3 & 0 & 3 \newline -10 & 0 & 10 \newline -3 & 0 & 3 \end{matrix} \right]$
Laplacian算子
高数中用一阶导数求极值,在这些极值的地方,二阶导数为0,所以可以通过求二阶导计算梯度:
可假设其离散实现类似于二阶Sobel导数,事实上OpenCV在计算拉普拉斯算子时直接调用Sobel算子。

边缘检测
使用 cv2.Canny() 来完成。实现步骤如下:
1. 使用5×5高斯滤波消除噪声:边缘检测本身属于锐化操作,对噪点比较敏感,所以需要进行平滑处理。
2. 计算图像梯度的方向:首先使用 Sobel 算子计算梯度 Gx 和 Gy ,然后算出梯度的方向:θ=arctan(Gy/Gx),保留这四个方向的梯度:0°/45°/90°/135°.梯度的方向一般总是与边界垂直。梯度方向被归为四类:垂直,水平,和两条对角线。
3. 取局部极大值:梯度其实已经表示了轮廓,为了进一步筛选,可以在上面的四个角度方向上再取局部极大值: 比如,A点在45°方向上大于B/C点,那就保留它,把B/C设置为0。

4. 滞后阈值:经过前面三步,就只剩下0和可能的边缘像素值了,为了最终确定下来,需要设定高低阈值: 像素点的值大于最高阈值,那肯定是边缘(上图A)同理像素值小于最低阈值,那肯定不是边缘像素值介于两者之间,如果与高于最高阈值的点连接,也算边缘,所以上图中C算,B不算

Canny推荐的高低阈值比在2:1到3:1之间。其实很多情况下,阈值分割后再检测边缘,效果会更好.

轮廓是一系列相连的点组成的曲线,具有相同的颜色或者灰度,代表了物体的基本外形。
谈起轮廓不免想到边缘,它们确实很像。简单的说,轮廓是连续的,边缘并不全都连续(下图)。其实边缘主要是作为图像的特征使用,比如可以用边缘特征可以区分脸和手,而轮廓主要用来分析物体的形态,比如物体的周长和面积等,可以说边缘包括轮廓。

寻找轮廓的操作一般用于二值化图,所以通常会使用阈值分割或Canny边缘检测先得到二值图。
寻找轮廓是针对白色物体的,一定要保证物体是白色,而背景是黑色,不然很多人在寻找轮廓时会找到图片最外面的一个框
轮廓基础
寻找轮廓
使用 cv2.findContours() 寻找轮廓,返回图像、轮廓、层级,参数如下:
- image: 输入图像, 单通道图像
- mode: 轮廓的查找方式,一般使用 cv2.RETR_TREE,表示提取所有的轮廓并建立轮廓间的层级
- method: 轮廓的近似方法。比如对于一条直线,我们可以存储该直线的所有像素点,也可以只存储起点和终点。使用 cv2.CHAIN_APPROX_SIMPLE 就表示用尽可能少的像素点表示轮廓

绘制轮廓
轮廓找出来后,为了方便观看,可以像前面图中那样用红色画出来:cv2.drawContours(),此函数的参数为:
- image: 在那张图片上绘制,
- contours: 轮廓数据,
- contourIdx: 轮廓数据的索引值, 如果是负值,表示绘制所有的轮廓点
- color: 绘制线的颜色,
- thickness: 线的粗细,
- lineType: 绘制线的类型

轮廓特征
要学习轮廓的特征首先要找到轮廓。使用上面龙猫与花的轮廓。

计算
面积
注意轮廓特征计算的结果并不等同于像素点的个数,而是根据几何方法算出来的,所以有小数。
如果统计二值图中像素点个数,应尽量避免循环,可以使用 cv2.countNonZero(),更加高效
轮廓周长
图像矩
矩可以理解为图像的各类几何特征:M中包含了很多轮廓的特征信息,比如M['m00']表示轮廓面积,与前面cv2.contourArea()计算结果是一样的。质心也可以用算:cx, cy = M['m10'] / M['m00'], M['m01'] / M['m00']
外接矩形
形状的外接矩形有两种,如下图,绿色的叫外接矩形,表示不考虑旋转并且能包含整个轮廓的矩形。蓝色的叫最小外接矩,考虑了旋转:


最小外接圆
外接圆跟外接矩形一样,找到一个能包围物体的最小圆:

拟合椭圆

轮廓相似

轮廓的层级
前面我们使用 cv2.findContours() 寻找轮廓时,参数3表示轮廓的寻找方式(RetrievalModes),当时我们传入的是 cv2.RETR_TREE,它表示什么意思呢?另外,函数返回值 hierarchy 有什么用途呢?
【参考】
- Python+OpenCV教程番外篇9:轮廓层级
理解轮廓层级
很多情况下,图像中的形状之间是有关联的,比如说下图:

图中总共有8条轮廓,2和2a分别表示外层和里层的轮廓,3和3a也是一样。从图中看得出来:
- 轮廓0/1/2是最外层的轮廓,我们可以说它们处于同一轮廓等级:0级
- 轮廓2a是轮廓2的子轮廓,反过来说2是2a的父轮廓,轮廓2a算一个等级:1级
- 同样3是2a的子轮廓,轮廓3处于一个等级:2级
- 类似的,3a是3的子轮廓,等等…………
这里面OpenCV关注的就是两个概念:同一轮廓等级和轮廓间的子属关系。
OpenCV中轮廓等级的表示
如果我们打印出 cv2.findContours() 函数的返回值hierarchy,会发现它是一个包含4个值的数组:[Next, Previous, First Child, Parent]
- Next:与当前轮廓处于同一层级的下一条轮廓
举例来说,前面图中跟0处于同一层级的下一条轮廓是1,所以Next=1;同理,对轮廓1来说,Next=2;那么对于轮廓2呢?没有与它同一层级的下一条轮廓了,此时Next=-1。
- Previous:与当前轮廓处于同一层级的上一条轮廓
跟前面一样,对于轮廓1来说,Previous=0;对于轮廓2,Previous=1;对于轮廓1,没有上一条轮廓了,所以Previous=-1。
- First Child:当前轮廓的第一条子轮廓
比如对于轮廓2,第一条子轮廓就是轮廓2a,所以First Child=2a;对轮廓3a,First Child=4。
- Parent:当前轮廓的父轮廓
比如2a的父轮廓是2,Parent=2;轮廓2没有父轮廓,所以Parent=-1。


由上面可以看出,轮廓的顺序为

轮廓寻找方式
OpenCV中有四种轮廓寻找方式RetrievalModes,下面分别来看下:
RETR_LIST(retrieves list)
这是最简单的一种寻找方式,它不建立轮廓间的子属关系,也就是所有轮廓都属于同一层级。这样的话,hierarchy中的后两个值[Next, Previous, First Child, Parent] 都为-1。因为没有从属关系,所以轮廓0的下一条是1,1的下一条是2……。如果你不需要轮廓层级信息的话,cv2.RETR_LIST更推荐使用,因为性能更好。比如同样的图,我们使用RETR_LIST来寻找轮廓
RETR_TREE
cv2.RETR_TREE就是之前我们一直在使用的方式,它会完整建立轮廓的层级从属关系。
RETR_EXTERNAL
这种方式只寻找最高层级的轮廓,即最外层的轮廓,也就是它只会找到前面我们所说的3条0级轮廓:

RETR_CCOMP
相比之下cv2.RETR_CCOMP比较难理解,但其实也很简单:它把所有的轮廓只分为2个层级,不是外层的就是里层的。

将上面结果绘制在同一张图上如下:

寻找轮廓并填充
寻找轮廓并为最里层的轮廓添加填充,效果如下:


凸包及其其他轮廓特征
【参考】
- Python+OpenCV教程番外篇10:凸包及更多轮廓特征
多边形逼近
前面使用过矩形和最小矩形来包围花朵,我们还可以使用多边形来包围花朵。这里可以使用 cv2.approxPolyDP() , 参数有:
- curve : 输入的二维数据点,即轮廓点,
- epsilon: 表示多边形的轮廓接近实际轮廓的程度,值越小,越精确,
- closed: 曲线是否闭合
画线使用 cv2.polylines() ,参数如下:
- img: 作画的图片,
- pts: 多边形曲线的点,数组,
- isClosed: 是否闭合,
- color: 颜色
- thickness: 线的厚度

凸包
凸包跟多边形逼近很像,只不过它是物体最外层的”凸”多边形:集合A内连接任意两个点的直线都在A的内部,则称集合A是凸形的。如下图,红色的部分为手掌的凸包,双箭头部分表示凸缺陷(Convexity Defects),凸缺陷常用来进行手势识别等:

使用 cv2.convexHull 来寻找凸包的角点, 他有个可选参数returnPoints,默认是True,代表返回角点的x/y坐标;如果为False的话,表示返回轮廓中是凸包角点的索引。
当使用 cv2.convexityDefects() 计算凸包缺陷时,cv2.convexHull 的returnPoints需为False 。cv2.convexityDefects()的返回值为:
- start_index: 凸缺陷在轮廓中的起点索引,
- end_index: 凸缺陷在轮廓中的止点索引,
- farthest_pt_index: 凸缺陷在轮廓中的最远点,
- fixpt_depth:最远点到凸包的近似距离
也可以使用 cv2.isContourConvex 判断是不是凸包。

点到轮廓的距离
使用 cv2.pointPolygonTest() 函数计算点到轮廓的最短距离(也就是垂线),又称多边形测试. 函数的第三个参数为True时表示计算距离值:点在轮廓外面值为负,点在轮廓上值为0,点在轮廓里面值为正;参数3为False时,只返回-1/0/1表示点相对轮廓的位置,不计算距离。