4. OpenCV基础(三):边缘检测、轮廓与形态学操作
各位同学,欢迎来到第四章。前两章我们把图像的基本操作和滤波讲完了,今天要聊的内容,是真正让计算机“看懂”物体形状的关键步骤。
说白了,边缘检测和轮廓查找,就是给图像做“素描”。而形态学操作,则是给这张“素描”做“修图”。这三板斧在YOLO的预处理和后处理中,几乎天天都要用到。我个人习惯在标注数据前,先用这套流程快速验证一下目标是否清晰可见。
4.1 边缘检测:Canny算法
边缘检测的算法很多,但Canny是公认的“黄金标准”。为什么?因为它不是简单地算个梯度就完事,而是有一套完整的“去伪存真”流程。
4.1.1 Canny的四个步骤
我记得刚学OpenCV那会儿,总觉得Canny是个黑盒子。后来自己手推了一遍,才发现它其实很“讲道理”:
- 高斯滤波去噪:先用高斯模糊把图像弄平滑一点,避免噪声被当成边缘。
- 计算梯度幅值与方向:用Sobel算子算出每个像素的梯度大小和方向。
- 非极大值抑制:这一步很关键。只保留梯度方向上的局部最大值,把“胖”边缘变“瘦”。
- 双阈值检测与边缘连接:设定高低两个阈值,高阈值确定强边缘,低阈值保留弱边缘,但只保留与强边缘相连的弱边缘。
核心参数:Canny函数只有两个关键参数——低阈值和高阈值。我一般建议高阈值设为低阈值的2到3倍。比如低阈值50,高阈值150,效果通常不错。
4.1.2 代码实战
import cv2
import numpy as np
# 读取图像,转为灰度
img = cv2.imread('cat.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 高斯模糊,降噪
blurred = cv2.GaussianBlur(gray, (5, 5), 1.5)
# Canny边缘检测
edges = cv2.Canny(blurred, 50, 150)
# 显示结果
cv2.imshow('Original', img)
cv2.imshow('Edges', edges)
cv2.waitKey(0)
cv2.destroyAllWindows()
避坑指南:我曾经在项目中直接对彩色图做Canny,结果边缘乱七八糟。记住,Canny只接受单通道灰度图。另外,如果边缘断断续续,试试降低低阈值;如果噪声太多,就提高高阈值。
4.2 轮廓查找与绘制
边缘检测得到的是“像素级”的线条,而轮廓查找得到的是“物体级”的边界点集。你想想看,我们要做目标检测,最终关心的是“这个框里有什么”,而不是“这个像素是不是边缘”。
4.2.1 findContours 详解
OpenCV的findContours函数,输入必须是二值图。我通常把Canny的结果直接丢进去,或者用阈值分割后的图。
# 在边缘检测结果上查找轮廓
contours, hierarchy = cv2.findContours(
edges,
cv2.RETR_EXTERNAL, # 只检测最外层轮廓
cv2.CHAIN_APPROX_SIMPLE # 压缩水平、垂直、对角线段,只保留端点
)
这里有两个参数需要重点理解:
| 参数 | 选项 | 说明 |
|---|---|---|
| 检索模式 | RETR_EXTERNAL |
只检测最外层轮廓,忽略内部空洞 |
RETR_LIST |
检测所有轮廓,不建立层级关系 | |
RETR_TREE |
检测所有轮廓,建立完整的层级树 | |
| 近似方法 | CHAIN_APPROX_NONE |
存储所有轮廓点,不压缩 |
CHAIN_APPROX_SIMPLE |
压缩冗余点,只保留拐点 |
版本注意:OpenCV 3.x 以后,findContours只返回两个值(contours, hierarchy),不再返回图像本身。如果你还在用旧版代码,记得更新。
4.2.2 drawContours 绘制
找到轮廓之后,用drawContours画出来,直观感受一下效果:
# 在原始图像上绘制轮廓
img_copy = img.copy()
cv2.drawContours(
img_copy,
contours,
-1, # -1表示绘制所有轮廓
(0, 255, 0), # 绿色
2 # 线条粗细
)
cv2.imshow('Contours', img_copy)
cv2.waitKey(0)
嗯,这里要注意:drawContours会直接修改输入图像。所以我习惯先.copy()一份,避免污染原图。
4.2.3 轮廓特征提取
找到轮廓之后,我们能做的事情就多了。比如计算面积、周长、外接矩形等:
for cnt in contours:
# 计算面积
area = cv2.contourArea(cnt)
# 计算周长
perimeter = cv2.arcLength(cnt, True)
# 获取外接矩形
x, y, w, h = cv2.boundingRect(cnt)
# 过滤掉太小的轮廓(噪声)
if area < 500:
continue
# 绘制矩形框
cv2.rectangle(img_copy, (x, y), (x+w, y+h), (255, 0, 0), 2)
项目经验:我在做工业缺陷检测时,经常用轮廓面积和长宽比来过滤干扰。比如螺丝的轮廓面积通常在1000-5000像素之间,长宽比接近1:1。超出这个范围的,基本可以判定为划痕或脏污。
4.3 形态学操作
形态学操作,说白了就是“膨胀”和“腐蚀”这两个基本动作的组合。为什么需要它们?因为Canny出来的边缘可能有断裂,二值图里可能有噪点。形态学操作就是用来“修图”的。
4.3.1 腐蚀与膨胀
这两个操作是对偶的:
- 腐蚀:让白色区域“瘦”一圈,可以消除小白点噪声。
- 膨胀:让白色区域“胖”一圈,可以连接断裂的物体。
# 定义结构元素(核)
kernel = np.ones((5, 5), np.uint8)
# 腐蚀
eroded = cv2.erode(binary_img, kernel, iterations=1)
# 膨胀
dilated = cv2.dilate(binary_img, kernel, iterations=1)
核的大小直接影响效果。核越大,变化越剧烈。我一般从3x3开始试,不够再加。
4.3.2 开运算与闭运算
开运算 = 先腐蚀后膨胀。闭运算 = 先膨胀后腐蚀。
你想想看:
- 开运算:先腐蚀去掉小噪点,再膨胀恢复物体大小。适合去噪。
- 闭运算:先膨胀填平小孔洞,再腐蚀恢复物体大小。适合填充。
# 开运算
opening = cv2.morphologyEx(binary_img, cv2.MORPH_OPEN, kernel)
# 闭运算
closing = cv2.morphologyEx(binary_img, cv2.MORPH_CLOSE, kernel)
实战技巧:我曾经在检测电路板焊点时,焊点内部有反光造成的空洞。用闭运算一次就填平了,轮廓查找的准确率从70%提升到了95%。
4.4 本章知识体系
下面这张图把今天的内容串起来了。从原始图像到最终的目标轮廓,每一步都有它的作用:
4.5 综合实战:从边缘到轮廓
最后,我们把这些知识点串起来,写一个完整的示例。假设我们要检测图像中的硬币:
import cv2
import numpy as np
# 1. 读取图像
img = cv2.imread('coins.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 2. 高斯滤波
blurred = cv2.GaussianBlur(gray, (7, 7), 2)
# 3. Canny边缘检测
edges = cv2.Canny(blurred, 30, 90)
# 4. 形态学闭运算,连接断裂边缘
kernel = np.ones((5, 5), np.uint8)
closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
# 5. 查找轮廓
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 6. 绘制结果
result = img.copy()
for cnt in contours:
area = cv2.contourArea(cnt)
if area < 1000: # 过滤小噪声
continue
x, y, w, h = cv2.boundingRect(cnt)
cv2.rectangle(result, (x, y), (x+w, y+h), (0, 255, 0), 2)
cv2.putText(result, f'Area:{int(area)}', (x, y-5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
cv2.imshow('Result', result)
cv2.waitKey(0)
cv2.destroyAllWindows()
效果预期:这段代码跑下来,每个硬币应该都能被一个绿色矩形框住。如果框太多或太少,调整Canny阈值和面积过滤值就行。我在做硬币分拣项目时,就是靠这套流程先定位硬币位置,再根据面积判断面值。