刚开始接触 OpenCV 的时候,我印象最深的不是那张彩色图片变成灰蒙蒙的灰度图,而是第一次看到程序把一堆乱七八糟的轮廓线从图像里“描”出来的瞬间。那种感觉就像在一堆杂物里面用画笔圈出每一件物体的边界,清晰、直观、有成就感。轮廓查找和绘制可以说是 OpenCV 里最常用的功能之一,不管是做物体计数、外形识别、还是做图像分割的后续处理,都绕不开findContours和drawContours这一对组合。
这篇文章我打算把我的实操经验完整拿出来分享。适合刚学 OpenCV 不久的 Python 开发者,也适合已经会用但没深入理解轮廓机制、被各种检索模式搞晕的人。我会从原理讲到代码,从代码讲到坑,最后给你一套能直接改来用的完整示例。
1. 轮廓检测到底是什么,先想清楚再动手
很多教程一上来就贴代码,然后你复制粘贴跑通了,回头换个图片就出错,根本原因就是没搞懂轮廓的本质。
1.1 轮廓和边缘不是一回事
我见过不少初学者会把“边缘检测”和“轮廓查找”直接画等号,其实这两个东西差别非常大。
边缘是图像中像素灰度突变的位置,是一堆离散的像素点。用 Canny 算子得到的边缘图,你放大看会发现很多细碎的短线,有的线中间还会断掉,连不成闭合的一条。
轮廓则是一个完整的、有序的点集合,它描述的是一个连通区域的边界,是一条或多条闭合曲线。OpenCV 里,轮廓的数据结构是[ [x1, y1], [x2, y2], ... ]这种点的序列,它本质上比边缘“高一层”。你可以理解为:边缘是散落的珠子,轮廓是把这些珠子串起来的链子。
所以要找轮廓,前提是这个区域能被“干净地”从背景里分离出来。这也是为什么我强烈建议你在找轮廓之前,把图像认真处理成二值图(黑白图),因为灰度图、彩色图里的噪声实在太多,直接找轮廓会找出一堆毫无意义的碎片。
1.2 检索模式别硬背,理解后再选
findContours的第一个重点参数是mode,也就是检索模式。OpenCV 提供了四种:
| 模式 | 含义 | 适用场景 |
|---|---|---|
RETR_EXTERNAL | 只提取最外层轮廓 | 物体不嵌套、背景简单,只要大外形 |
RETR_LIST | 提取所有轮廓,不建立层级关系 | 不需要知道谁套着谁,只要拿到所有的边界 |
RETR_CCOMP | 提取所有轮廓,组织为两层结构 | 内外层关系只需区分一层的情况 |
RETR_TREE | 提取所有轮廓,建立完整的层级树 | 需要分析嵌套关系,比如文字里的数字孔洞 |
我之前在一个零件检测的模拟项目里,需要在传送带上统计圆形金属垫片的数量。垫片中间有个通孔,如果用RETR_EXTERNAL,那就一个垫片只找到一个外轮廓,计数毫不费力。但如果你要做的是区分“实心圆”和“圆环”,那就必须用RETR_TREE或者RETR_CCOMP,因为你得知道哪些轮廓内部还有子轮廓。
如果你只是想把所有形状的边界画出来,不想管谁套谁,RETR_LIST就够了。这个模式处理起来快,逻辑也简单。我最常犯的一个错就是把所有模式全部记混,后来我总结了一个笨办法:做计数和简单检测用RETR_EXTERNAL,做带孔洞的物体识别用RETR_TREE,其他情况一律RETR_LIST。
1.3 近似方法让轮廓点少一半以上
findContours的另一个关键参数是method,也就是轮廓的近似方法,常见的有CHAIN_APPROX_NONE和CHAIN_APPROX_SIMPLE。
CHAIN_APPROX_NONE会把边缘上的每一个像素点都保存下来,非常精确,但数据量巨大。一个 500x500 的矩形轮廓,四个边加起来能有两千多个点。
CHAIN_APPROX_SIMPLE会压缩冗余的点,只保留“关键转折点”。还是那个矩形,用 SIMPLE 模式就只保存四个顶点,压缩率非常可观。
我一开始图省事总想用CHAIN_APPROX_NONE,觉得信息越全越好。后来在做轮廓周长计算的时候发现,两种模式算出来的周长可能有几个像素的偏差,NONE 模式因为包含细微的锯齿,算出来会偏长。而且 NONE 模式处理一张高分辨率图片时,内存占用明显更高,实时性也受影响。现在除了某些需要逐点还原边缘的场合,我基本都用CHAIN_APPROX_SIMPLE。
提示:
CHAIN_APPROX_SIMPLE并没有“丢失”轮廓信息,它保留的顶点已经足够描述形状。用 matplotlib 绘制该轮廓时依然是闭合的、一致的外形。
2. 动手准备:图像预处理是成败关键
我说句实话:找轮廓这件事,预处理做得好不好,直接决定你后面省不省事。轮廓查得乱七八糟,八成不是findContours的问题,而是输入图像没准备好。
2.1 从彩色到二值:一步都不能少
标准的预处理链路是:读图 → 灰度化 → 滤波去噪 → 二值化(或边缘检测) → 找轮廓。
灰度化很简单:cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)。为什么不能直接在彩色图上找轮廓?因为轮廓检测需要的是一个单通道、能区分前景和背景的图。彩色图有三个通道,渐变和阴影会让同一个物体的边界变得不连续,二值图就没有这个问题,非黑即白,边界一目了然。
二值化我推荐优先尝试cv2.threshold配合THRESH_BINARY或THRESH_BINARY_INV。用固定阈值的时候,怎么选这个值是个经验活。光照好的拍摄环境,阈值 127 往往就够用。但如果你拍摄环境不稳定,同一个物体有时候亮有时候暗,固定阈值就会失效。
这时候就要上cv2.adaptiveThreshold,自适应阈值。它会给图像分成很多小块,每一块单独算一个阈值,对光照不均的图像特别管用。我做过一个扫描文档轮廓提取的小实验,文档边缘有一块被阴影盖住了,固定阈值怎么调都让文档“缺角”,换成自适应阈值之后就完整了。
如果场景简单、物体和背景对比强烈,用大津法THRESH_OTSU自动算阈值是最省事的。代码也很简洁:
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)注意这里阈值参数填 0,因为 OTSU 会自动算,不需要你指定。
2.2 边缘检测到底该不该用,怎么用
很多教程喜欢先跑一遍 Canny 再去找轮廓,理由是 Canny 能把边界提得更干净。这个说法对,但也不全对。
Canny 输出的是一张边缘图,边缘像素是白色的,背景是黑色的。你在这种图上找轮廓,找到的其实是一堆“边缘片段”围起来的区域。如果边缘有断口,轮廓就跟着断;如果边缘太密,轮廓就会交错、粘连。
我的经验是:物体和背景是两种完全不同材质、颜色差异明显的时候,二值化之后直接找轮廓,比过了 Canny 再找要稳。比如深色物体放白色桌面,大津法一下就分开了。
Canny 更适合那种背景复杂、纹理多、靠灰度阈值分不开的情况。比如在一张风景照里找特定形状的物体,Canny 先抽象出“边界线”,你再在这些边缘线上找轮廓。但这时候一定要控制好 Canny 的两个阈值:threshold1和threshold2。一般经验是 threshold1 取 threshold2 的一半或三分之一,比如(50, 150)。
如果你用了 Canny,在找轮廓之前我建议多做一步:用cv2.dilate膨胀一下,把边缘的断口补上。
edges = cv2.Canny(gray, 50, 150) kernel = np.ones((3, 3), np.uint8) edges = cv2.dilate(edges, kernel, iterations=1)膨胀会让边缘变粗一点,让本来差一两个像素就能接上的断口彻底连起来。代价是轮廓会比实际稍大一点点,但对后续识别影响很小。腐蚀和膨胀这对操作在预处理阶段是神器,你要学会根据实际效果微调 kernel 的大小和迭代次数。
2.3 预处理阶段的常用抗干扰手段
我在实际项目里总结出来几个特别常用的预处理组合,分享给你:
- 如果图像里有细小的颗粒噪点,先做一次
cv2.medianBlur中值滤波,它对椒盐噪声有奇效,而且比高斯滤波更能保留边缘锐度。 - 如果二值化之后前景区域背景里有零散的小白点,用
cv2.morphologyEx做一次MORPH_OPEN(开运算,先腐蚀再膨胀),可以清掉这些小杂点。 - 如果物体边缘不够平滑,轮廓画出来锯齿感严重,可以做一个
MORPH_CLOSE(闭运算,先膨胀再腐蚀),把边缘的小缺口填平。
这些听起来都是小事,但处理过的图像和没处理过的图像,找出来的轮廓效果差距非常明显。预处理做的到位,后面轮廓筛选的代码量能少一半。
3. 核心环节:findContours 与 drawContours 实战
现在进入正题。准备一张简单的图片,比如白底上一个黑色的圆形和方形,我要把它们的轮廓画出来。
3.1 版本差异别掉坑,返回值到底几个
先说一个非常常见的坑:OpenCV 版本不同,findContours的返回参数数量不一样。
在 OpenCV 3 和 OpenCV 4 中,函数签名是:
contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)在更早的 OpenCV 2.x 里,它返回的是三个值:
image, contours, hierarchy = cv2.findContours(...)那是不是旧版的 image 参数就是处理后的图?不是,旧版会把查找后的图像也返回,你把它直接赋值给一个新变量就行。
我在帮别人调试代码时经常遇到这种问题:明明代码看起来没问题,但一运行就报not enough values to unpack。解决方法是先确认你的cv2.__version__,然后对着版本调整赋值。最稳妥的写法是用contours和hierarchy两个变量接返回值,因为现在主流环境基本都是 OpenCV 4.x。
3.2 完整代码走一遍
直接上代码。这是我最常用的一个最小可用示例:
import cv2 import numpy as np # 读取图像 img = cv2.imread("shapes.png") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯滤波降噪 blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 二值化:背景变黑,物体变白 _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 查找轮廓 contours, hierarchy = cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 在原始图像的副本上绘制轮廓 result = img.copy() cv2.drawContours(result, contours, -1, (0, 255, 0), 2) cv2.imshow("Contours", result) cv2.waitKey(0) cv2.destroyAllWindows()注意几个细节:
- 二值化我把目标物体变成了白色、背景变黑色,用的
THRESH_BINARY_INV。如果你的原图是黑色物体在白色背景上,那用THRESH_BINARY就行。 - 绘制目标我用的是
img.copy(),不要在原图上直接画。这个习惯能让你随时对比原图和绘制后的图,方便排查。 drawContours的第三个参数传-1表示把找到的所有轮廓都画出来。颜色用的(0, 255, 0)是 BGR 顺序下的绿色,注意 OpenCV 里颜色通道顺序是 BGR,跟常规 RGB 相反。- 线宽
2表示 2 像素。调大一点,轮廓更醒目。
跑完这段代码,你应该能看到两个图形边缘被绿线描出来了。这个示例虽然简单,但包含了完整的链路,后面所有复杂操作都是在这个基础上扩展。
3.3 绘制参数详解:怎么只画某个轮廓
drawContours的第三个参数contourIdx很关键,它的含义是“第几条轮廓”。
-1:绘制所有轮廓0:只绘制第一条轮廓1:只绘制第二条轮廓- 其他正整数:按索引绘制
轮廓列表contours的顺序其实是随机的,不是你肉眼从上到下、从左到右的顺序,而是根据图像检测到的连通域顺序。所以你想画“某一个特定形状”的轮廓时,第一反应不应该是手写索引,而是先通过条件筛选轮廓,把目标轮廓放到列表里的特定位置,再一起画。
我通常的做法是这样:
for c in contours: area = cv2.contourArea(c) if area > 500: # 过滤掉太小的轮廓 cv2.drawContours(result, [c], -1, (0, 255, 0), 2)注意我把单个轮廓包成了[c],因为drawContours第二个参数需要的是一个“轮廓列表”,不是单个轮廓。这个细节很容易被忽略,但你直接传一个contour对象进去会报错。
3.4 hierarchy 层级关系怎么看
hierarchy这个返回值很多人不关心,但在处理嵌套图形时它是必不可少的。
它的数据结构是一个多维数组,每个轮廓对应一组四个值,顺序是:
[下一个轮廓索引, 上一个轮廓索引, 第一个子轮廓索引, 父轮廓索引]
- 如果某个值是
-1,表示对应的那个方向没有轮廓。 - 通过这四个值,你能知道某个轮廓是不是在另一个轮廓的“里面”。
举个例子,假设图像里有圆形外框,框内有个矩形。用RETR_TREE模式得到两个轮廓,索引 0 是外框,索引 1 是内矩形,那轮廓 1 的父轮廓索引就是 0。这种情况下你得遍历hierarchy才能知道谁是谁的子轮廓。
我写过一个简单的判断逻辑:如果某个轮廓的父轮廓索引不是-1,就把这个轮廓统一画成红色,表示它是“内部孔洞”。这种操作在识别带孔垫片、检查 PCB 焊盘开孔时都很有用。
for i, c in enumerate(contours): parent = hierarchy[0][i][3] color = (0, 0, 255) if parent != -1 else (0, 255, 0) cv2.drawContours(result, [c], -1, color, 2)4. 只会找轮廓还不够,日常项目里怎么用
findContours只是起点。真正的工作量在找完轮廓之后。
4.1 用面积和周长过滤小噪点
找完全部轮廓后,列表里会有很多小到离谱的轮廓,它们是二值图上的噪点、细线、或者是图像边缘切割产生的不完整物体。
过滤逻辑非常简单:遍历每个轮廓,算面积和周长,不满足条件的直接跳过。
for c in contours: area = cv2.contourArea(c) perimeter = cv2.arcLength(c, True) if area < 100 or perimeter < 40: continue # 到这里就说明是一个可靠的轮廓我把这个逻辑称为“轮廓体检”。cv2.contourArea算的是轮廓包围的面积,注意它要求轮廓是闭合的,cv2.arcLength的第二个参数传True也同样是告诉函数这个轮廓是闭合的。
面积筛选对阈值非常敏感,具体设多少得看你图片的分辨率和目标大小。我一般做法是:先打印出所有轮廓的面积,看一眼分布,再在中间找一个合适的 cutoff 值。盲目拍脑袋填数字,填高了一漏一大片,填低了噪声全进来了。
4.2 轮廓排序和位置定位
前面说了,轮廓列表的顺序是无序的。但很多场景你需要知道“哪个物体在最左边”“哪个物体在最上面”。
我用过的最朴素可靠的排序方法:按轮廓外接矩形的左上角坐标排序。
def sort_contours(contours): bounding_boxes = [cv2.boundingRect(c) for c in contours] sorted_pairs = sorted(zip(contours, bounding_boxes), key=lambda b: b[1][0]) return [item[0] for item in sorted_pairs], [item[1] for item in sorted_pairs]cv2.boundingRect(c)返回(x, y, w, h),其中x是外接矩形左上角的横坐标。按x从小到大排序,就能让轮廓从左到右排列。如果你需要从上到下,就按y排序。
在 OCR 类项目里,这个排序操作几乎是必须的。因为文字识别时你必须保证文字轮廓是按阅读顺序排列的,否则识别出来的内容顺序是乱的。
4.3 轮廓外接矩形和形状判断
拿到轮廓以后,最常见的需求就是把它框出来定位。有两种框:一种叫正矩形cv2.boundingRect,一种叫最小外接矩形cv2.minAreaRect。
- 正矩形:无论物体怎么旋转,框都是水平和垂直的。
- 最小外接矩形:会跟着物体的旋转角度一起转,能包含物体的最小面积。
x, y, w, h = cv2.boundingRect(c) cv2.rectangle(result, (x, y), (x + w, y + h), (255, 0, 0), 2) rect = cv2.minAreaRect(c) box = cv2.boxPoints(rect) box = np.int0(box) cv2.drawContours(result, [box], 0, (255, 0, 0), 2)注意cv2.boxPoints返回的是四个顶点,需要转换成整数才能绘制。
如果你想判断轮廓大致是圆形还是方形,可以用一种很巧妙的近似方法:取轮廓的轮廓近似cv2.approxPolyDP,看它得到的顶点数量。
epsilon = 0.02 * cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, epsilon, True) if len(approx) == 3: shape = "triangle" elif len(approx) == 4: shape = "rectangle" elif len(approx) >= 8: shape = "circle"epsilon是近似精度,数值越大,近似结果越粗糙。我习惯取周长的 2%,效果比较稳定。注意approxPolyDP是图像处理里非常经典的多边形拟合算法,理解它以后做形状识别会很顺手。
5. 常见问题与排查实录
这部分是我长期踩坑的记录,每个问题都真实发生过,排查思路也都验证过。
5.1 轮廓总数和预期不一样,多出一堆碎片
现象:图像里明明只有两个物体,结果找出来 20 个轮廓。
排查思路:首先在画轮廓前打印一下每个轮廓的面积,确认是不是一堆面积特别小的轮廓。如果是,多半是二值化的时候把背景噪点变成了白色像素。回到预处理阶段,加一步中值滤波或开运算。
还可以直接调整findContours的检索模式。如果噪点出现在物体内部,把它们从RETR_EXTERNAL改成RETR_LIST再筛选,看看能不能通过面积过滤掉。
我的建议:不要一上来就调findContours参数,先去看二值图长什么样。二值图是干净的,轮廓就不大可能脏。
5.2 画出来的轮廓是断的、不闭合
现象:轮廓线有一段是空的,或者整个轮廓断成两截。
排查思路:这是典型的边缘信息不连续。二值化后的物体和背景之间存在灰度渐变区域,导致中间一部分像素被分到另一侧。解决方法是加大膨胀的迭代次数,或者换用MORPH_CLOSE闭运算先补洞再找轮廓。
另外检查一下轮廓是否是闭合的:cv2.isContourConvex(c)可以判断凸性,但无法判断闭合。闭合与否取决于输入轮廓本身,膨胀往往是最直接的解法。
5.3 实时视频里轮廓抖得厉害
现象:在视频流里做轮廓检测,轮廓位置忽大忽小,边界跳来跳去。
排查思路:视频的每一帧都是独立处理的,轻微的噪点变化会导致轮廓边界抖动。解决办法是不要让每一帧的检测结果“裸奔”——做一个简单的平滑,比如把最近几帧的轮廓中心坐标取平均。
positions = [] def update_center(new_center): positions.append(new_center) if len(positions) > 5: positions.pop(0) return np.mean(positions, axis=0)用轮廓中心点做平滑,比直接平滑轮廓本身简单得多,而且足够稳定。如果你要的效果是边界稳定,那得用更复杂的跟踪算法,但对于大部分检测需求,中心点平滑已经够用了。
5.4 性能慢,实时跟不上
现象:处理一帧要几百毫秒,根本跑不动。
排查思路:性能瓶颈通常不在findContours本身,而在前面的预处理和后面的绘制。图像分辨率太高是最大杀手,我经常先把图像cv2.resize到合适的尺寸再处理,速度能快好几倍。
另外一个隐蔽的坑是drawContours里面传了太多轮廓。每画一个轮廓都是一次绘制操作,如果轮廓成百上千,耗时明显上升。先筛选,再绘制,只画需要的轮廓,这是最有效的优化手段。
6. 一个完整的实战案例:检测并标注图像中的最大物体
把前面所有知识点串起来,我来写一个稍微完整的案例:输入一张图片,自动找到最大的那个物体,并把它标注出来。
import cv2 import numpy as np def find_largest_contour(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) contours, hierarchy = cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: return None max_contour = max(contours, key=cv2.contourArea) return max_contour image = cv2.imread("scene.jpg") largest = find_largest_contour(image) if largest is not None: x, y, w, h = cv2.boundingRect(largest) cv2.rectangle(image, (x, y), (x + w, y + h), (0, 0, 255), 3) cv2.drawContours(image, [largest], -1, (0, 255, 0), 2) cv2.imshow("Result", image) cv2.waitKey(0) cv2.destroyAllWindows()这个案例的价值在于它演示了怎么把“找到的轮廓”和“要用的结果”分开:max函数配合key=cv2.contourArea直接选出面积最大的轮廓,非常简洁。RETR_EXTERNAL保证了我们不考虑内部孔洞,这样孔洞再多也不会把“带孔区域”误判成多个物体。
如果你需要把所有轮廓都画出来,但只标注最大的那个,那就把drawContours的contourIdx传成-1,然后单独画最大轮廓的框。
我在实际项目里最后发现,真正最花时间的不是写代码,而是调参和看图。一个排查技巧是:把每个处理步骤的中间结果都cv2.imshow出来,灰度图、二值图、可能的边缘图、轮廓绘制图,一层一层看,哪一层出了问题一眼就能定位。
这是我个人最推荐的排查方式,比闷头改参数高效得多。你把这套方法用熟了,以后不管碰到什么图像处理的活,心里都会有一条清晰的排查路径。