图像轮廓
一、基础概念
本小节旨在介绍图像轮廓的概念以及找出并绘制轮廓的办法,主要用到以下两个函数: cv.findContours(), cv.drawContours()
用法如下:
- cv.findContours( image, mode, method[, contours[, hierarchy[, offset]]] ) -> contours, hierarchy
- cv.drawContours( image, contours, contourIdx, color[, thickness[, lineType[, hierarchy[, maxLevel[, offset]]]]] ) -> image
1. 轮廓是什么?
轮廓可以简单地解释为连接所有连续点(沿边界)的曲线,具有相同的颜色或强度。 轮廓可用于形状分析和对象检测和识别。在使用函数之前,我们需要了解一下内容:
- 为了轮廓识别更加精准,我们要使用二值图像,所以我们要对输入图像进行灰度化和阈值操作
- 再OpenCV 3.2 之后,drawContours函数不再直接在原图像上面作画了
- 在 OpenCV 中,寻找轮廓就像从黑色背景中寻找白色物体。 所以请记住,要找到的对象(前景ROI)应该是白色的,背景应该是黑色的,所以在阈值操作中应设定合适的阈值以及阈值类型。
import cv2 as cv
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
%matplotlib inline
def cv_show(name, img):
cv.imshow(name, img)
cv.waitKey(0)
cv.destroyAllWindows()
def compare(imgs):
# for i in range(len(imgs)):
# imgs[i][:,-3:-1,:] = [255,255,255]
res = np.vstack(imgs)
cv_show('Compare', res)
# 先读入图像并进行预处理:灰度化,二值化
img = cv.imread('lena.png')
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
# 这里要注意 threshold 函数的返回值:(ret, img),依次为是否处理成功?,返回图像
thresh = cv.threshold(gray, 127, 255, cv.THRESH_BINARY)[1]
cv_show('Thresh', thresh)
cv2.findContours(img, mode, method)
mode:轮廓检测的模式
- RETR_EXTERNAL:只检测最外面的轮廓
- RETR_LIST:检测所有轮廓,并将其保存到一条链表
- RETR_CCOMP:检测所有轮廓,并将它们组织为两层
- 顶层是各部分的外部边界
- 次层是空洞的边界
- RETR_TREE(最常用):检测所有的轮廓,并重构整个嵌套轮廓的整个层次
method:轮廓逼近方法
- CHIAN_APPROX_NONE:以Freeman链码的方式输出轮廓,所有其他方法输出多边形(顶点的序列)
- CHIAN_APPROX_SIMPLE:压缩水平的、垂直和斜的部分,namely,函数只保留他们终点的部分
为了提高轮廓检测的准确率,使用二值图像 检测步骤:
- 读入图像
- 转变为灰度图(可以和第一步融合)
- 设置阈值,转化为二值图像
- 轮廓检测
2. 轮廓和边缘的区别:
边缘不一定连续,但是轮廓一定连续
# 提取轮廓,注意findContours函数也是返回(contours, hierarchy),我们只需要用到第一个返回参数
contours = cv.findContours(thresh,cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE )[0]
可以看出,cv.findContours() 函数中有三个参数,第一个是源图像,第二个是轮廓检索模式,第三个是轮廓逼近方法。 它输出轮廓和层次结构。 Contours 是图像中所有轮廓的 Python 列表。 每个单独的轮廓都是对象边界点的 (x,y) 坐标的 Numpy 数组(所以每个轮廓的长短不一,因为边界段数可能不同,导致边界点个数不同)。
- 注意:稍后我们将详细讨论第二个和第三个参数以及层次结构。 在那之前,代码示例中给他们的值将适用于所有图像,也就是说,findContours的第二、三个参数一般都是使用:cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE
3. 如何绘制图像轮廓
要绘制轮廓,则使用 cv.drawContours 函数。 只要您有边界点,它也可以用于绘制任何形状。 它的第一个参数是源图像,第二个参数是应该作为 Python 列表传递的轮廓,第三个参数是轮廓的索引(在绘制单个轮廓时很有用。要绘制所有轮廓,传递 -1),其余参数是颜色、厚度 等等
绘制出所有轮廓:
res = cv.drawContours(img.copy(), contours, -1, (0, 255, 0), 2)
compare([img, res])
绘制单个轮廓,例如绘制第701个轮廓(下标值从 0 开始)
len(contours)
1324
res = cv.drawContours(img.copy(), contours, 700, (255, 0, 0), 2)
compare([img, res])
# 一般情况下,我们会使用如下方法绘制单一轮廓:
# cnt = contours[700]
# res = cv.drawContours(img.copy(), [cnt], 0, (0, 255, 0), 2)
4. 轮廓近似方法
这是cv.findContours 函数中的第三个参数,它实际上表示了什么呢? 之前我们介绍说轮廓是具有相同强度的形状的边界。它存储形状边界的 (x,y) 坐标。但它是否存储所有坐标?这是由这种轮廓近似方法指定的。
- 如果传递 cv.CHAIN_APPROX_NONE,则存储所有边界点。但实际上我们需要所有的点吗?例如,您找到了一条直线的轮廓。你需要线上的所有点来代表那条线吗?不,我们只需要那条线的两个端点。
- 这就是 cv.CHAIN_APPROX_SIMPLE 所做的。它去除所有冗余点并压缩轮廓,从而节省内存。
下面的矩形图像演示了这种技术。只需在轮廓数组中的所有坐标上画一个圆圈(以蓝色绘制)。第一张图片显示了我使用 cv.CHAIN_APPROX_NONE 获得的点集(734 个点),第二张图片显示了使用 cv.CHAIN_APPROX_SIMPLE 获得的点集(只有 4 个点)。看看,它节省了多少内存!!!
从上图可以看出, cv.CHAIN_APPROX_NONE存储了所有检测到的边界点集。而cv.CHAIN_APPROX_SIMPLE只存储了四个角点。(因为直线并不需要所有直线上的点来存储整条直线轮廓,所以就节省了大量的存储)
二、轮廓特征
本小节主要包括了轮廓特征,如面积、周长、重心和边界矩形等。
1. cv.Moment函数
这个函数可以帮助您计算一些特征,例如物体的质心、物体的面积等。函数 cv.moments()给出了所有矩值计算的字典。 见下文:
用法如下
- cv.moments( array[, binaryImage] ) -> retval
# 读入图像并进行阈值处理
img = cv.imread('xy.png')
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
thresh = cv.threshold(gray, 100, 255, cv.THRESH_BINARY)[1]
# 1, 2 就是上述cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE的两个值
contours = cv.findContours(thresh, 1, 2)[0]
res = cv.drawContours(img.copy(), contours, -1,(0, 255, 0), 2)
compare([img, res])
len(contours)
517
cnt = contours[110]
M = cv.moments(cnt)
print( M )
{'m00': 4790.0, 'm10': 2953206.0, 'm01': 2675188.333333333, 'm20': 1826158098.3333333, 'm11': 1648018327.4166665, 'm02': 1496420814.6666665, 'm30': 1132586365825.2, 'm21': 1018325494140.7999, 'm12': 921083702883.5667, 'm03': 838363720540.9, 'mu20': 5401171.728740215, 'mu11': -1330782.6313500404, 'mu02': 2343023.684875965, 'mu30': 34675123.888427734, 'mu21': 67312725.28397799, 'mu12': -26681262.426547766, 'mu03': 3894986.3204345703, 'nu20': 0.23540569160438699, 'nu11': -0.05800108225426321, 'nu02': 0.10211878804903941, 'nu30': 0.021836309332163604, 'nu21': 0.04238950943683394, 'nu12': -0.016802255749493407, 'nu03': 0.002452828327628262}
从上面可以得出一些信息,如面积,质心等。质心由关系式给出,,。这可以通过以下方式完成:
# 计算对应轮廓的重心
cx = int(M['m10']/M['m00'])
cy = int(M['m01']/M['m00'])
(cx, cy)
(616, 558)
2. 轮廓面积
可以通过cv.contourArea函数或者利用moment函数所得结果:M['m00']。
- cv.contourArea函数用法如下: cv.contourArea( contour[, oriented] ) -> retval
a = cv.contourArea(cnt)
(a == M['m00'], a)
(True, 4790.0)
3. 轮廓周长
也称为弧长。 可以使用 cv.arcLength() 函数获取。 第二个参数指定 shape 是闭合轮廓(如果传递 True,传入False则为非连续轮廓),还是只是曲线(不闭合,需要传入False)。
perimeter = cv.arcLength(cnt,True)
perimeter
448.9604585170746
4. 轮廓近似
轮廓近似根据我们指定的精度将轮廓形状近似为具有较少顶点数的另一个形状。 它是 Douglas-Peucker 算法的一种实现。
为了理解这一点,假设您试图在图像中找到一个正方形,但由于图像中的一些问题,您没有得到一个完美的正方形,而是一个“坏形状”(如下图第一张所示)。 现在您可以使用此函数来近似形状。 在此,第二个参数称为 epsilon,它是从轮廓到近似轮廓的最大距离。 它是一个精度参数。 需要明智地选择 epsilon 以获得正确的输出。
- 核心思想就是设定一个阈值,然后在此阈值范围内,以直代曲
下面,在第二张图片中,绿线显示了 epsilon = 10% 弧长的近似曲线。 第三张图片显示了相同的 epsilon = 1% 的弧长。 第三个参数指定曲线是否闭合。
# 一般基于轮廓的弧长(周长)来规定偏移量epsilon的大小
img = cv.imread('Outlook.png',-1)
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
thresh = cv.threshold(gray, 127, 255, cv.THRESH_BINARY)[1]
cons, hierarchy = cv.findContours(thresh, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE )
con_0 = cons[11]
res0 = cv.drawContours(img.copy(), [con_0], 0, (0, 0, 255), 2) #原轮廓
epsilon = 0.05*cv.arcLength(con_0, True) # epsilon越小,则越接近原轮廓,否则便越偏离
approx = cv.approxPolyDP(con_0,epsilon, True)
res_005 = cv.drawContours(img.copy(), [approx], -1, (0, 0, 255), 2)
epsilon = 0.10 * cv.arcLength(con_0, True)
approx = cv.approxPolyDP(con_0, epsilon, True)
res_010 = cv.drawContours(img.copy(), [approx], -1, (0, 0, 255), 2)
compare([res0, res_005,res_010])
对比图如下,第一章为原图,epsilon的权值依次为:0.05、0.10
5. 凸包
Convex Hull 看起来类似于轮廓近似,但实际上并非如此(在某些情况下两者可能提供相同的结果)。一般说来,cv.convexHull() 函数检查曲线是否存在凸面缺陷并进行纠正。 凸曲线是总是凸出或至少平坦的曲线。 如果它向内凸出,则称为凸面缺陷。 例如,检查下面的手图像。 红线表示手的凸包。 双边箭头标记表示凸面缺陷,即凸包与轮廓的局部最大偏差。
-
使用方法如下: cv.convexHull( points[, hull[, clockwise[, returnPoints]]] ) -> hull
-
参数细节:
- points:我们传入的轮廓。
- hull: 输出,通常我们不会使用它。
- clockwise:方向标志。 如果为 True,则输出凸包为顺时针方向。 否则,它是逆时针方向的。
- returnPoints :默认情况下,True。 然后它返回凸包点的坐标。 如果为 False,则返回与凸包点对应的轮廓点的索引。
hull = cv.convexHull(cnt)
hull
array([[[629, 514]],
[[644, 515]],
[[649, 517]],
[[653, 521]],
[[683, 553]],
[[683, 582]],
[[568, 615]],
[[567, 615]],
[[566, 614]],
[[565, 612]],
[[563, 607]],
[[558, 594]],
[[558, 591]],
[[559, 588]],
[[570, 562]],
[[571, 560]],
[[574, 555]],
[[579, 550]],
[[615, 516]],
[[617, 515]],
[[624, 514]]], dtype=int32)
但是如果要查找凸性缺陷,则需要传入 returnPoints = False(也即是找到凸型缺陷所在的轮廓索引)。 我们将使用上面的矩形图像为例进行说明, 首先我检测出它的轮廓集合为cnt。 现在我找到了returnPoints = True的凸包,我得到了以下值:[[[234 202]],[[ 51 202]],[[ 51 79]],[[234 79]]],它们是 矩形的四个角点。 现在如果对 returnPoints = False 做同样的事情,我会得到以下结果:[[129],[67],[0],[142]]。 这些是轮廓中对应点的索引。 例如,检查第一个值: cnt[129] = [[234, 202]] 与第一个结果相同(其他结果以此类推)。
6. 凸度检验
有一个函数可以检验一条曲线是否是凸的,cv.iscontour凸()。它只是返回 True 或 False。
k = cv.isContourConvex(cnt)
k
False
7. 边界矩形
边界矩形有很多种类。
a.直边界矩形
它是一个直的矩形,它不考虑对象的旋转。 所以边界矩形的面积不会是最小的。 它由函数 cv.boundingRect() 检测出来。
- 用法如下:
令 (x,y) 为矩形的左上角坐标, (w,h) 为其宽度和高度。
img = cv.imread('Outlook.png')
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
thresh = cv.threshold(gray, 127, 255, cv.THRESH_BINARY)[1]
contours = cv.findContours(thresh, 1, 2)[0]
# 在绘制边界矩形的时候要注意外轮廓和内轮廓的区别
con = contours[0]
res = cv.drawContours(img.copy(), [con], 0, (0, 0, 255), 2)
# 绘制边界矩形(基于内轮廓)
x,y,w,h = cv.boundingRect(con)
BoundingBox = cv.rectangle(img.copy(), (x, y), (x+w, y+h), (0, 255, 0),2)
compare([res,BoundingBox])
b. 旋转矩形
这个函数用最小面积绘制边框,因此它也考虑了旋转。使用的函数是 cv.minAreaRecet()。它返回一个 Box2D 结构,其中包含以下细节: (中心(x,y) ,(宽度,高度) ,旋转角度)。但是要绘制这个矩形,我们需要矩形的4个角。它是通过函数 cv.boxPoints()获得的。
- 用法如下:
- cv.minAreaRect( points ) -> retval
- cv.boxPoints( box[, points]) -> points(可用于轮廓绘制的轮廓点集)
con = contours[33]
res = cv.drawContours(img.copy(), [con], 0, (0, 0, 255), 2)
# 绘制边界矩形(基于内轮廓)
x,y,w,h = cv.boundingRect(con)
BoundingBox = cv.rectangle(img.copy(), (x, y), (x+w, y+h), (0, 255, 0),2)
box2d = cv.minAreaRect(con)
# 转化为四个角点
box = cv.boxPoints(box2d)
# 将坐标转化为整数
box = np.int0(box)
# 下面的绘制轮廓的方式,box只是一个数组,要进行转化
res_2 = cv.drawContours(img.copy(), [box], 0, (0, 255, 255), 2)
compare([res,BoundingBox, res_2])
8. 最小外接圆
接下来我们使用函数 cv.minEnclosureCircle() 找到对象的外接圆。 它是一个以最小面积完全覆盖物体的圆。
(x,y),radius = cv.minEnclosingCircle(con)
center = (int(x),int(y))
radius = int(radius)
res = cv.circle(img,center,radius,(0,255,0),2)
cv_show('Test', res)
9. 最小外接椭圆
ellipse = cv.fitEllipse(con)
res = cv.ellipse(img,ellipse,(0,255,0),2)
cv_show('Test', res)
10. 拟合线
同样,我们可以将一条线拟合到一组点。 下图包含一组白点。 我们可以近似为一条直线。
- 函数用法如下: cv.fitLine( points, distType, param, reps, aeps[, line] ) -> line
img = cv.imread('Outlook.png')
(x, y), r = cv.minEnclosingCircle(con)
center = (int(x), int(y))
r = int(r)
rows,cols = img.shape[:2]
[vx,vy,x,y] = cv.fitLine(con, cv.DIST_L2,0,0.01,0.01)
lefty = int((-x*vy/vx) + y)
righty = int(((cols-x)*vy/vx)+y)
res = cv.line(img.copy(),(cols-1,righty),(0,lefty),(0,255,0),2)
res = cv.circle(res, center, r, (0, 0, 255), 2)
cv_show('Test', res)