小杰机器视觉(four day)——图像边缘检测、凸包特征检测、绘制图像轮廓、图像轮廓查找
1.图像边缘检测
本实验使用的算法为Canny算法,此算法进行边缘检测被誉为最优方法。
Canny算法的输入图像应该为二值化图像,包括以下步骤:
1. 高斯滤波
2. 计算图像的梯度和方向(Sobel)
3. 非极大值抑制
4. 双阈值筛选
(1)高斯滤波
高斯滤波之前已经学习,但是本章中点开此节点,不提供任何参数。因为直接使用现成的参数。
边缘检测属于对噪点比较敏感的算法,因此需要降噪,对图像进行平滑处理,这里直接使用一个5*5的高斯核对图像降噪:

效果不够可手动给其再加滤波
(2)计算图像梯度与方向
同样参数直接预设参数。
内部使用的是Sobel算子来计算图像的梯度值,分为水平和垂直方向的核:

同时计算两个方向的梯度Gx和Gy,然后根据欧式距离(L2距离)来计算具体的梯度值:
![]()
根据三角函数,可以基于直角三角形的边长求出对应的角度:
![]()
这个角度θ就是总梯度放在哪个方向,按照以下规定制定梯度角度方向:

- 黄色区域为水平梯度方向,表示垂直边缘
- 蓝色区域为垂直梯度方向,表示水平边缘
- 绿色区域为45°梯度方向,表示135°边缘
- 红色区域为135°梯度方向,表示45°边缘
(3)非极大值抑制
之前使用高斯滤波会让画面的边缘变粗,但是变粗的同时边缘的也会变得模糊,因此这种图像经过梯度计算后,得到的边缘像素点会比原始图像更多,因此需要对这些像素点进行过滤。
非极大值抑制就是过滤边缘像素点的一种方法,可以让边缘变细。假设当前的像素点为(x, y),其梯度方向是0°,梯度值为G(x, y),需要比较G(x, y)与两个相邻像素点的梯度值:G(x-1, y)和G(x+1, y)。如果G(x, y)是这三个梯度值中最大的,就保留该梯度值,否则直接抑制为0。
例如,梯度方向为0°时,比较下图中X是否大于8和4;梯度方向为45°时,比较下图中X是否大于3和7。

(4)双阈值筛选
经过了非极大值抑制之后,还需要经过双阈值筛选。如果阈值设置的太低,就会出现假边缘;如果阈值设置的太高,一些较弱的边缘就会被丢弃,因此适应双阈值再次筛选,需要设定高低阈值的比例。

- 强边缘 → A
如果像素的梯度值超过高阈值maxVal时,该像素必然是边缘像素。
- 弱边缘
如果像素的梯度值低于高阈值maxVal且高于低阈值minVal时,分为以下两种情况:
(a) 如果该点能连接到一个强边缘点,该像素为边缘像素。→ C
(b) 如果该点不能连接到一个强边缘点,该像素不是边缘像素,被称为伪边缘点。 → B
- 非边缘
如果像素的梯度值低于低阈值minVal时,该像素必然不是边缘点。
参数为高低阈值的数值:
推荐高低阈值的比例为2:1到3:1之间。
代码
import cv2
import numpy as np
if __name__ == '__main__':
# 1. 图片输入
path = 'shudu.png'
image_np = cv2.imread(path)
# 2. 灰度化
image_np_gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY)
# 3. 二值化
ret, image_np_thresh = cv2.threshold(
image_np_gray,
40,
255,
cv2.THRESH_BINARY
)
# 5. 高斯滤波
# 可以自己手动再加滤波
no_noise_image = cv2.GaussianBlur(image_np_thresh,
(7, 7),
0)
# Canny算法:高斯滤波 + 5. 计算梯度与方向 + 6. 非极大值抑制 + 7. 双阈值筛选
edges_image = cv2.Canny(
no_noise_image, # 要处理的图像
30, # 低阈值
70 # 高阈值
)
# 8. 图片输出
cv2.imshow('edges_image',edges_image)
cv2.waitKey(0)
2.凸包
凸包就是将一张图片中物体的最外层的点连接起来构成的凸多边形,它能包含物体中所有的像素点。
整个物体所有的像素点均涵盖在这个凸多边形中。
(1)基础凸包检测
1. 先找到最左边和最右边的点。

2. 连接上面的两个点连接,将点集分为上半区和下半区,以上半区为例:

3. 找到上半区中离直线最远的点。
假设直线的方程为:
,则点(x0,y0)到直线的公式为:
![]()
4. 把最远的像素点与之前最左和最右的像素点连接,把这两条直线明明为y1和y2。

5. 求出y1和y2的直线方程,把上半区所有的点带入计算与y1和y2的距离(分正负):
![]()
- d=0表示点在y1或y2上,可以忽略
- d>0时,表示点在y1或y2上方,这表示需要更新凸包点
- d<0时,表示点在y1和y2下方,忽略
6. 当上一步出现d>0的情况时,需要更新凸包点,重新连接,形成新的y2和y3:

7. 在新的y2和y3中重新反复计算新凸包点,直到上半区计算完成。
8. 下半区执行跟上半区相似的操作,但是判断条件改为寻找d<0的点。
上面的过程都是基于已知坐标点进行的处理,实际上对于未处理的图像,可能并不能直接获取点的坐标,特别是彩色图像,需要转换为二值化后的图像,并通过轮廓检测算法获取边界点的坐标。
(2)Graham 扫描法
Graham扫描法是一种用于寻找二维平面上点集凸包的算法,也是OpenCV凸包检测的基本算法。其基本思想是先找一个基点,从这个基点出发,按照逆时针方向逐个寻找凸包点。
算法的主要步骤如下:
1. 寻找基点(P0)
通常选择y坐标最小的点,如果有多个这样的点,则选择x坐标更小的点作为基点P0,以P0为原点构建二维直角坐标系。
2. 计算剩下所有点相对P0的极角α(即该点与P0连线与x轴正方向的夹角)。

按照极角从小到大的顺序对点进行排序。如果极角相同,则比较与P0的距离,距离更近的排在前面。
3. 初始化一个栈来记录凸包点,把P0和P1入栈。
4. 扫描点集
把P2入栈,将栈顶的两个点相连(P1和P2),得到一条直线,观察下一个点P3在此直线的右侧还是左侧。
- 如果在右侧
说明P2不是凸包点,P2出栈,重新执行第四步(下一个点入栈)。
- 如果在左侧或直线上
说明下一个点P3凸包点,P3入栈,重新执行第四步(下一个点入栈)。
5. 遍历完所有点,栈中剩下的点就是凸包的顶点。按照栈的顺序(从栈底到栈顶,即逆时针顺序)输出所有的凸包点。
(3)QuickHull 法
在二维空间中,QuickHull用于计算凸包,采用分而治之的策略,通过递归将点集划分为更小的子集,并构建这些子集的凸包,用于OpenCV的大量计算加速。如下所示:
这里的AB等都是向量,有方向的。

(4)Andrew扫描链法



(5)实验
1.寻找轮廓

轮廓指的是前景的轮廓,参数如下:
- 轮廓的查找方式
先使用默认参数,细节3大标题讲解。
- 轮廓的近似办法
先使用默认参数,细节3大标题讲解。
2.查找凸包
寻找凸包节点不提供参数
3.绘制轮廓
绘制轮廓节点参数如下:
color:颜色
thickness:线宽

代码


import cv2
import numpy as np
if __name__ == '__main__':
# 1. 图片输入
path = 'tubao2.jpg'
image_np = cv2.imread(path)
# 2. 灰度化
image_np_gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY)
# 3. 二值化
ret, image_np_thresh = cv2.threshold(
image_np_gray,
127,
255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU
)
print('thresh:', ret)
# 4. 寻找轮廓
# 返回值1:轮廓的点坐标,使用array存储n个轮廓的信息
# 返回值2:多个轮廓之间的层级关系
contours, hierarchy = cv2.findContours(
image_np_thresh, # 二值化图像
cv2.RETR_EXTERNAL, # 轮廓的查找方式
cv2.CHAIN_APPROX_SIMPLE # 轮廓近似办法
)
print(len(contours)) # 轮廓的数量
# print(contours)
# print(hierarchy)
# 拿到第一个轮廓的数据
cnt = contours[0]
# print(cnt)
# 1394表示前景轮廓的像素点,1表示占位,2表示二维点
print(cnt.shape) # (1394, 1, 2)
# 5. 查找凸包
# 返回轮廓凸包点
hull = cv2.convexHull(cnt) # 参数也可以替换为其他的轮廓,cnt是第一个轮廓
print(hull)
print(hull.shape) # 包含凸包点数量
# 6. 绘制轮廓
# 绘制多条线
cv2.polylines(
image_np, # 在哪个图上画线
[hull], # 绘制的轮廓列表
isClosed=True, # 轮廓是否封闭
color=(0, 0, 255), # 颜色
thickness=2 # 粗细
)
# 7. 图片输出
cv2.imshow('image_np', image_np)
cv2.waitKey(0)
3.绘制图像轮廓
(1)概念
轮廓是目标物体或者区域在图像外部的边界线,通常由一系列像素点相连组成,这些像素点共同构成了一个封闭的形状,这样形状就是轮廓。
轮廓与边缘不同:
- 轮廓是连续的,边缘可以连续也可以离散
- 轮廓是完整的,边缘可以是完成的也可以不完整
轮廓主要分析物体的形态,比如计算物体的周长与面积;边缘作为图像特征使用,比如区分脸和手。

(2)寻找轮廓
# 4. 寻找轮廓
# 返回值1:所有轮廓的点坐标,是一个list列表
# 返回值2:轮廓的层级关系
contours, hierarchy = cv2.findContours(
image = image_np_thresh, # 二值化之后的图像
mode = cv2.RETR_EXTERNAL, # 默认的轮廓查找方式
method = cv2.CHAIN_APPROX_SIMPLE # 默认的轮廓近似办法
)
- contours 轮廓s
tuple类型,所有轮廓的点(每个元素是Numpy数组),可以通过[n-1]取出第n个轮廓的数据
- hierarchy 轮廓关系
如果没有检测到轮廓,则返回的数组为空,通常不会出现。
对于第n个轮廓:
- hierarchy[n][0]表示当前轮廓的后一条轮廓
- hierarchy[n][1]表示当前轮廓的前一条轮廓
- hierarchy[n][2]表示当前轮廓的第一个子轮廓
- hierarchy[n][3]表示当前轮廓的父轮廓
如果出现-1表示没有。
此参数较少使用,通常用于处理复杂画面效果。
- image 输入图像
要求8位的二值化图像,前景色白色,背景色黑色
- mode 轮廓的查找方式
- RETR_EXTERNAL 查找最外层轮廓(不查找内层轮廓)
在hierarchy层级结构中,每一个轮廓只有前后轮廓的索引,没有父轮廓与子轮 廓的索引。
-
- RETR_LIST 查找所有轮廓(包括外层和内层轮廓)
在hierarchy层级结构中,每一个轮廓只有前后轮廓的索引,没有父轮廓与子轮 廓的索引。
-
- RETR_CCOMP 查找所有轮廓(包括外层和内层轮廓)
在hierarchy层级结构中,每一个轮廓只有前后轮廓的索引,有父轮廓与子轮 廓的索引,轮廓为两层结构。
-
- RETR_TREE (包括外层和内层轮廓)
在hierarchy层级结构中,每一个轮廓只有前后轮廓的索引,有父轮廓与子轮 廓的索引,轮廓为树状结构。
- method 轮廓的近似办法
- CHAIN_APPROX_NONE 存储所有轮廓点
- CHAIN_APPROX_SIMPLE 只存储有用的点
比如轮廓中的直线,则只存储起点和终点;比如轮廓是四边形,则只存储四个顶点。
-
- CHAIN_APPROX_TC89_L1 使用Teh_Chin链进行轮廓逼近
精度高(优化后的点更少),使用少。
(3)绘制轮廓
使用绘制轮廓的方式把上一步的寻找的轮廓点连接在一起进行绘制,这一步绘制的是前景轮廓。
相关参数如下:

- contourIdx 轮廓编号
-1表示绘制所有轮廓
- color 轮廓颜色
- thickness 绘制的线宽
(4)轮廓检测算法 Suzuki 铃木 (了解)
外文论文可以通过下面的网站搜索:
Satoshi Suzuki and others . Topological structural analysis of digitized binary images by border following . Computer Vision , Graphics , and Image Processing , 30 ( 1 ): 32 – 46 , 1985 .
原始论文中的相关术语:
- frame 框架
一张图片的最上行、最下行、最左列和最右列。
- 0-pixel 灰度值为0的像素
即黑色背景像素
- 1-pixel 灰度值为1的像素
即白色前景像素
- (i, j) 第i行第j列
不是图像的xy坐标
第i行第j列的像素点灰度值
整个图片的灰度值用集合F表示,F={
}
- 1/0连通域(component)
白色/黑色的连续区域,判断像素点是否连通的方式有以下几种方式:

如果0连通域S中包含frame,则S为背景(background),否则为孔洞(hole)。

- 边界点(border point)
如果一个1像素,周围八连通区域内有0像素,那么这个1像素就是一个边界点,若个边界点组成边界。
- 环绕连通域
在一副二值化图像中有两个连通域S1和S2,如果S2中任何一个像素点从任何一个方向(上下左右)到frame的路径上都存在S1的像素点,那么称S1环绕S2。
如果S1环绕S2,且S1和S2之间存在边界点,那么称为S1直接环绕S2。

上图中S1环绕S2,S1环绕S4,但是S1直接环绕S2,不直接环绕S4。
- 外边界(outer border)和孔边界(hole border)
如果0连通域直接环绕1连通域,则它们之间的边界被称为外边界;如果1连通域直接环绕0连通域,则它们之间的边界为孔边界。

- 父边界
假设有1连通域S1和S3,0连通域S2。S2直接环绕S1,S3直接环绕S2,S1和S2的边界为B1,S2和S3的边界为B2,B2是B1的父边界。

如果S2是背景,那么B1的父边界为frame

- 光栅扫描(RasterScan)
是指从左往右,从上向下,先扫描完一行,再移动到下一行的起始位置继续扫描。
- NBD(新边界ID)
表示当前正在跟踪的边界编号,边界扫描后每条边界都可以有一个编号。
- LNBD(上一个边界ID)
在扫描的过程中,上一个边界B'的编号。
一个概念示意图:

步骤比较复杂,不需要掌握,留个印象:
假设输入图像为F={},将NBD和LNBD都设置为1,把F的frame看做第一个边界。使用光栅扫描图像F,当扫描到某个像素点(i,j)的灰度值≠0时执行下面的步骤1。另外在光栅扫描的过程中,每次扫描到新行时,将LNBD重置为1。

步骤1,需要判断以下几种情况。

![]()
![]()
![]()
需要注意的是,本身图像是二值化的,因此像素点只有0或1,但是此算法在计算的过程中可能某个像素点的值会变成大于1的整数或小于0的整数。
步骤2,根据上一个边界B'和新遇到的边界B类型,得到当前的边界B的父边界(如果是第一次扫描就标记为外边界)。这一步主要服务于返回值hiearchy,其他步骤不太依赖于这一步的结果。
![]()

步骤3,从边界点(i, j)开始进行边界跟踪。
- 3.1

- 3.2
![]()
- 3.3

- 3.4

- 3.5

步骤4

最终结果为:

结果分析:
- 正整数
不同的正整数像素点表示最终形成的不同边界,即第几个边界。
- 负整数
中间的计算产物,这些像素点不属于最终的边界。
OpenCV把上面的算法都封装好了,可以直接调用。
代码

import cv2
import numpy as np
if __name__ == '__main__':
# 1. 图片输入
path = 'kun.jpg'
image_np = cv2.imread(path)
# 2. 灰度化
image_np_gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY)
# 3. 二值化
ret, image_np_thresh = cv2.threshold(image_np_gray, 127, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
print(ret)
# 4. 寻找轮廓
contours, hierarchy = cv2.findContours(
image_np_thresh, # 二值化之后的图像
cv2.RETR_EXTERNAL, # 查找方式
cv2.CHAIN_APPROX_SIMPLE # 近似办法
)
print(len(contours))
for i in contours:
print(i.shape)
# 5. 绘制轮廓
image_np = cv2.drawContours(
image_np, # 在哪个图上绘制
contours, # 轮廓数据列表
contourIdx=-1, # 绘制轮廓的id,-1表示全绘制
color=(0, 0, 255), # 绘制的颜色
thickness=2 # 线宽
)
# 6. 图片输出
cv2.imshow('image_np', image_np)
cv2.waitKey(0)
4.图像轮廓特征的查找

- contour_type 轮廓类型,包括:
- boundingRect 外接矩形
- minAreaRect 最小外接矩形
- minEnclosingCircle 最小外接圆
- color 线段颜色
- thickness 线段粗细
(1)外接矩阵
下图中绿的是外接矩形,表示不考虑旋转并且能包含整个轮廓的矩形,外接矩形可根据获得的轮廓坐标中最上、最下、最左和最右四个坐标值绘制。

代码
import cv2
import numpy as np
if __name__ == '__main__':
# 1. 图片输入
path = 'kun.jpg'
image_np = cv2.imread(path)
# 2. 灰度化
image_np_gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY)
# 3. 二值化
ret, image_np_thresh = cv2.threshold(image_np_gray, 127, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
print(ret)
# 4. 寻找轮廓
contours, hierarchy = cv2.findContours(
image_np_thresh, # 二值化之后的图像
cv2.RETR_EXTERNAL, # 查找方式
cv2.CHAIN_APPROX_SIMPLE # 近似办法
)
print(len(contours))
for i in contours:
print(i.shape)
# 5. 绘制轮廓
image_np = cv2.drawContours(
image_np, # 在哪个图上绘制
contours, # 轮廓数据列表
contourIdx=-1, # 绘制轮廓的id,-1表示全绘制
color=(0, 0, 255), # 绘制的颜色
thickness=2 # 线宽
)
# 6. 外界轮廓
contour_image = image_np.copy()
for i in contours:
# 计算轮廓的外接矩形
# 返回值:矩形的左上角xy坐标和矩形的宽高
x, y, w, h = cv2.boundingRect(i)
print('外接矩形的左上角坐标:', x, y)
print('外接矩形的宽高:', w, h)
# 绘制外接矩形
cv2.rectangle(
contour_image,
(x, y),
(x + w, y + h),
(0, 255, 0),
2
)
# 6. 图片输出
cv2.imshow('image_np', image_np)
cv2.imshow('contour_image', contour_image)
cv2.waitKey(0)
(2)最小外接矩阵
最小外接矩形就是上图中蓝色的矩形,寻找最小外接矩形的算法叫做旋转卡壳法,计算过程如下:
旋转卡壳法有一个重要的前提条件:对于多边形P的一个外接矩形(最小外接矩形)存在一条边与多边形P共线。
假设某轮廓的凸包图如下所示:

步骤1:起始边共线:找高度
根据前提条件,凸多边形的最小外接矩形与凸多边形的某条边是共线的,因此选择凸多边形的一条边为起始边,然后按照逆时针方向计算每个凸包点与起始边的距离,并将距离最大的点记录下来。

如上图所示,首先以ab为起始边,计算出e点离起始边的距离最远,那么e到起始边的距离就是一个矩形的高度。
步骤2:投影找宽度
对于矩形的最右边,以向量为基准,分别计算凸包点在向量上的投影长度,投影长度最长的凸包点所在的垂直与起始边的直线就是矩形最右边的直线。

如上图所示,d点就是在向量上投影长度最长的点,那么通过d点垂直与直线ab的直线就是外接矩形右边界所在的直线。
矩形的左边界也是这样计算的,不同的是使用的向量是而不是,如下图所示。

步骤3:更换起始边反复计算
矩形的高就是e点到直线ab的距离,矩形的宽就是是h点在向量的投影加上d点在向量上的投影减去ab的长度:![]()
这样就构成了外接矩形,计算其面积。
然后更换起始边,计算新的起始边对应的外接矩形面积,即凸多边形有几个边就计算几次外接矩形,然后找到所有面积中最小的矩形作为凸多边形的最小外接矩形。
代码
import cv2
import numpy as np
if __name__ == '__main__':
# 1. 图片输入
path = 'kun.jpg'
image_np = cv2.imread(path)
# 2. 灰度化
image_np_gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY)
# 3. 二值化
ret, image_np_thresh = cv2.threshold(image_np_gray, 127, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
print(ret)
# 4. 寻找轮廓
contours, hierarchy = cv2.findContours(
image_np_thresh, # 二值化之后的图像
cv2.RETR_EXTERNAL, # 查找方式
cv2.CHAIN_APPROX_SIMPLE # 近似办法
)
print(len(contours))
for i in contours:
print(i.shape)
# 5. 绘制轮廓
image_np = cv2.drawContours(
image_np, # 在哪个图上绘制
contours, # 轮廓数据列表
contourIdx=-1, # 绘制轮廓的id,-1表示全绘制
color=(0, 0, 255), # 绘制的颜色
thickness=2 # 线宽
)
# 6. 外界轮廓
contour_image = image_np.copy()
for i in contours:
# 计算最小外接矩形
# 返回值:包含最小外接矩形参数的对象
rect = cv2.minAreaRect(i)
# 包含矩形对角线角点的坐标和矩形的角度
print(rect)
# 把矩形数据转换为四个角点坐标
points = cv2.boxPoints(rect)
print(points) # 浮点型
# 转换为整形
box = np.int32(points)
print(box)
# 绘制轮廓
cv2.drawContours(
contour_image,
[box],
-1,
(0, 255, 255),
2
)
# 6. 图片输出
cv2.imshow('image_np', image_np)
cv2.imshow('contour_image', contour_image)
cv2.waitKey(0)
(3)最小外接圆
寻找最小外接圆的算法是随机增量算法(Welzl算法)。
给定n个点Sn={P0, P1,..., Pn-1}的最小外接圆,
Welzl算法基于下面的前提:如果点集Si={P0, P1,..., Pi-1}的最小外接圆为Di,那么Di需要包含原外位于圆Di的另一个点Pi,则新的最小外接圆Di+1一定在圆上包含Pi点。

如果该点在圆内,那么最小覆盖圆不变。
如果该点在圆外,根据上述定理,该点一定在想要求得的最小覆盖圆的圆周上,又因为三个点才能确定一个圆,所以需要枚举P点之前的点来找其余的两个点。当找到与P点组成的圆能够将所有点都包含在圆内或圆上,该圆就是这些点的最小外接圆。
代码
import cv2
import numpy as np
if __name__ == '__main__':
# 1. 图片输入
path = 'kun.jpg'
image_np = cv2.imread(path)
# 2. 灰度化
image_np_gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY)
# 3. 二值化
ret, image_np_thresh = cv2.threshold(image_np_gray, 127, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
print(ret)
# 4. 寻找轮廓
contours, hierarchy = cv2.findContours(
image_np_thresh, # 二值化之后的图像
cv2.RETR_EXTERNAL, # 查找方式
cv2.CHAIN_APPROX_SIMPLE # 近似办法
)
print(len(contours))
for i in contours:
print(i.shape)
# 5. 绘制轮廓
image_np = cv2.drawContours(
image_np, # 在哪个图上绘制
contours, # 轮廓数据列表
contourIdx=-1, # 绘制轮廓的id,-1表示全绘制
color=(0, 0, 255), # 绘制的颜色
thickness=2 # 线宽
)
# 6. 外界轮廓
contour_image = image_np.copy()
for i in contours:
# 计算最小外接圆
# 返回值:圆心和半径
(x, y), radius = cv2.minEnclosingCircle(i)
print((x, y), radius)
# 转换为整形
x, y, radius = int(x), int(y), int(radius)
# 画圆
cv2.circle(
contour_image,
(x, y), # 圆心
radius, # 半径
(200, 150, 0), # 颜色
2 # 粗度
)
# 6. 图片输出
cv2.imshow('image_np', image_np)
cv2.imshow('contour_image', contour_image)
cv2.waitKey(0)
更多推荐


所有评论(0)