简介:这份资源面向OpenCV初学者与图像处理方向的开发者,聚焦轮廓提取这一基础而关键的功能,帮助读者理解如何从二值图像中获取有序点集并完成轮廓绘制。内容围绕findContours与drawContours两个核心函数展开,逐一讲解image、contours、hierarchy、mode、method、offset等参数含义,并说明CV_RETR_EXTERNAL、CV_RETR_LIST、CV_RETR_CCOMP、CV_RETR_TREE四种检索模式与CV_CHAIN_APPROX_NONE、CV_CHAIN_APPROX_SIMPLE等近似方法的差异,同时给出Canny预处理与轨迹条调参的示例代码,便于对照实践。资源包为1个pdf文件,大小约95KB,轻量易读,适合随时查阅。目前已有2255人学习下载,可作为轮廓提取入门与参数速查的参考材料。
1. OpenCV 轮廓提取:从像素边缘到可计数对象的落地路径
做视觉项目时,真正让人头疼的往往不是模型推理,而是拿到一张图后不知道从哪下手把目标“抠”出来。OpenCV 的轮廓提取就是干这个的:它把二值图里连成一片的前景像素串成一条条闭合曲线,让你能算面积、周长、外接矩形,甚至直接数出画面里有几个零件、几颗药片。常见于工业质检、物体计数、缺陷定位这类场景。适合已经会读写图片、想进一步做测量和筛选的从业者。这篇不堆 API 手册,而是按我实际做项目的顺序,把预处理、找轮廓、筛选、测量、避坑串成一条能直接复现的链路,参数怎么调、哪里容易翻车都会讲清楚。
2. 轮廓提取前必须想明白的三件事
2.1 轮廓到底是什么:边界点序列,不是区域
很多人第一次用findContours会误以为它返回的是一个个实心区域,其实它给的是一组组边界点的坐标序列。每个轮廓就是一条由像素点首尾相连组成的折线,闭合与否取决于你用的检索模式。理解这一点很关键:后续算面积用的是格林公式对这条折线积分,算周长是把相邻点距离累加,所以轮廓点越密、越贴合真实边缘,测量越准;反过来,如果预处理把边缘糊掉了,后面所有数值都会偏。
轮廓和边缘检测(Canny、Sobel)不是一回事。边缘检测输出的是“哪些像素梯度大”,结果是一堆断断续续的亮线;轮廓提取要求输入是二值图,输出是连通的闭合边界。所以标准流程是:灰度化 → 去噪 → 二值化 → 形态学修补 → 找轮廓。跳过二值化直接对灰度图调findContours,在 OpenCV 3 之后会直接报错或给出无意义结果,这是新手最常见的翻车点。
还有一点,OpenCV 里轮廓的坐标系是图像坐标,原点在左上角,x 向右、y 向下。算出来的面积单位是像素平方,要换算成物理尺寸必须知道标定比例(比如每毫米多少像素)。我一般会在项目开始就放一把标尺拍一张,把比例先定下来,不然后面测出来的数没法用。
2.2 选版本与检索模式:RETR_EXTERNAL 还是 RETR_TREE
findContours的第二个参数决定轮廓之间的组织关系,选错了要么漏目标要么多出一堆内孔。常见取值和适用场景:
| 检索模式 | 含义 | 典型场景 |
|---|---|---|
| RETR_EXTERNAL | 只取最外层轮廓 | 物体计数、外接矩形,不关心孔洞 |
| RETR_LIST | 取出所有轮廓,不建层级 | 只做面积筛选,不需要父子关系 |
| RETR_CCOMP | 两层结构,外轮廓和孔 | 有孔零件、需要区分内外 |
| RETR_TREE | 完整层级树 | 嵌套结构、需要知道谁包含谁 |
做物体计数我几乎只用RETR_EXTERNAL,因为传送带上的零件互相不嵌套,取最外层最干净,也省得后面过滤孔洞。如果目标本身带孔(比如垫圈),又需要算实际材料面积,那就得用RETR_CCOMP,把外轮廓面积减去内孔面积。
第三个参数是轮廓点压缩方式。CHAIN_APPROX_SIMPLE只保留拐点,一条水平直线段只存两个端点,点数少、速度快,绝大多数测量场景够用;CHAIN_APPROX_NONE存下每一个边界像素,点数多但曲线更平滑,做高精度拟合或需要完整点集时才用。我默认用 SIMPLE,只有在轮廓近似多边形效果不好时才换 NONE 排查。
2.3 预处理决定上限:二值化与形态学的取舍
轮廓质量九成取决于二值图。光照不均时全局阈值threshold会一半全黑一半全白,这时候用自适应阈值adaptiveThreshold更稳。它的原理是对每个像素取邻域算局部阈值,参数blockSize是邻域大小(必须是奇数),C是从局部均值里减掉的常数,C 越大越容易把暗区判成背景。
import cv2 import numpy as np img = cv2.imread("parts.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊压掉高频噪点,核大小 5x5 是经验起点 blur = cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值:blockSize=31 适合中等尺寸目标,C=5 抑制背景噪点 binary = cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 5 ) # 开运算去小噪点,闭运算补边缘缺口,核 3x3 起步 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)这段代码里THRESH_BINARY_INV把前景变成白色,因为findContours默认把白色当目标。blockSize太小会把目标内部纹理也当成边界,太大则退化成全局阈值;我一般从 31 开始,目标越小往 11、15 调。形态学核别一上来就用 7x7,会把小目标直接抹掉,3x3 不够再逐步加。做完这步最好imshow看一眼二值图,轮廓提取出问题时,八成是这张图已经烂了。
3. 用 findContours 跑通最小可复现流程
3.1 找轮廓、画轮廓、数个数
预处理完就可以调findContours。注意 OpenCV 3 之后这个函数返回两个值(轮廓列表、层级),OpenCV 2 返回三个,网上老代码照抄会报ValueError,这是版本差异的经典坑。
# 找轮廓:只取最外层,压缩存储 contours, hierarchy = cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 在原图副本上画出来,方便肉眼核对 vis = img.copy() cv2.drawContours(vis, contours, -1, (0, 0, 255), 2) print("原始轮廓数:", len(contours)) cv2.imwrite("contours_raw.png", vis)drawContours第三个参数传 -1 表示画全部,传具体索引就只画那一个,调试单个目标时很有用。颜色(0,0,255)是 BGR 顺序的红色,线宽 2 保证小图也看得清。跑完先看contours_raw.png,如果轮廓数远大于实际目标数,说明二值图里有噪点或目标被切碎;如果远小于,说明目标粘连或阈值把目标吃掉了。这一步是后面所有筛选的基础,别急着往下走。
3.2 按面积和周长筛掉噪声轮廓
原始轮廓里混着毛刺、反光点、边框残留,必须过滤。最直接的是面积阈值,contourArea对闭合轮廓用格林公式算,对一条线或一个点返回接近 0 的值。
min_area = 200 # 小于这个像素面积的当噪声丢掉 max_area = 50000 # 大于这个的可能是背景粘连,也要警惕 valid = [] for c in contours: area = cv2.contourArea(c) if min_area < area < max_area: valid.append(c) print("过滤后轮廓数:", len(valid))min_area怎么定?我的习惯是先打印所有轮廓面积排序,看目标面积落在哪个区间,再取比最小值略小一点的值。max_area用来挡掉整幅图边框或大面积阴影。除了面积,周长arcLength(c, True)也能辅助判断,True表示轮廓闭合。有些细长毛刺面积不大但周长很长,用面积周长比(紧凑度)能进一步剔除:4 * pi * area / (perimeter ** 2),圆形接近 1,细长条接近 0。
3.3 外接矩形、最小外接矩形与旋转框
筛完轮廓通常要定位目标。boundingRect给的是正矩形,适合目标不旋转的场景;minAreaRect给的是带角度的旋转矩形,适合斜放零件。
for c in valid: x, y, w, h = cv2.boundingRect(c) cv2.rectangle(vis, (x, y), (x + w, y + h), (0, 255, 0), 2) rect = cv2.minAreaRect(c) # 返回 (中心, (宽,高), 角度) box = cv2.boxPoints(rect) # 转成四个角点 box = np.intp(box) # 新版用 intp,老代码 int0 会报错 cv2.drawContours(vis, [box], 0, (255, 0, 0), 2)boxPoints返回浮点坐标,画图前必须转整数,OpenCV 4.x 里用np.intp,用老的np.int0在部分版本会告警甚至报错。minAreaRect的角度范围在不同版本里是 [-90, 0) 或 [0, 90),跨版本迁移时如果依赖角度做分拣,一定要先打印几个样本确认符号约定,这个坑我在产线项目里踩过,角度差 90 度导致机械臂抓取方向全反。
4. 轮廓筛选与测量的参数怎么定
4.1 面积、周长、紧凑度的组合判据
单一面积阈值在复杂背景下不够用。我一般用三个判据组合:面积在区间内、周长在区间内、紧凑度大于某个值。紧凑度公式前面提过,对圆形目标能到 0.8 以上,对细长划痕只有 0.1 左右。把这三个条件写成函数,方便复用和调参。
import math def is_valid_contour(c, min_a=200, max_a=50000, min_compact=0.2): area = cv2.contourArea(c) if not (min_a < area < max_a): return False peri = cv2.arcLength(c, True) if peri == 0: return False compact = 4 * math.pi * area / (peri * peri) return compact > min_compact参数没有万能值,必须拿实际图跑一遍看分布。我的做法是先把所有轮廓的面积和紧凑度打印出来,人工标几个正负样本,再定阈值。别指望一套参数打天下,换镜头、换光源都得重调,这是血泪经验。
4.2 轮廓近似:把锯齿边压成规则多边形
二值化后的边缘往往带锯齿,直接算角度或拟合直线会抖。approxPolyDP用 Douglas-Peucker 算法把轮廓压成折线,epsilon是允许的最大偏差,越大压得越狠。
epsilon = 0.02 * cv2.arcLength(c, True) # 取周长的 2% approx = cv2.approxPolyDP(c, epsilon, True) # 顶点数能粗略判断形状:3 三角、4 矩形、多则圆形或不规则 print("近似后顶点数:", len(approx))epsilon取周长的 1% 到 3% 是常见区间。太小压不掉锯齿,太大把真实拐角也抹了。判断矩形时,顶点数等于 4 还不够,得再用isContourConvex确认是凸的,否则可能是凹多边形碰巧剩了 4 个点。做形状分类时我一般把顶点数和凸性一起用,比单纯看面积稳。
4.3 层级关系:用 hierarchy 区分外轮廓和内孔
用RETR_CCOMP或RETR_TREE时,hierarchy是个形状为 (1, N, 4) 的数组,每个轮廓对应[next, prev, first_child, parent]。要算带孔零件的实际材料面积,就得遍历层级,把父轮廓面积减去子轮廓面积。
contours, hierarchy = cv2.findContours( binary, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE ) hierarchy = hierarchy[0] # 去掉外层维度,方便索引 for i, c in enumerate(contours): # parent == -1 说明是最外层 if hierarchy[i][3] == -1: outer_area = cv2.contourArea(c) child = hierarchy[i][2] hole_area = 0 while child != -1: hole_area += cv2.contourArea(contours[child]) child = hierarchy[child][0] # 下一个同级孔 real_area = outer_area - hole_area print(f"轮廓{i} 净面积: {real_area:.1f}")这里hierarchy[i][3]是父索引,-1 表示没有父,即最外层。hierarchy[i][2]是第一个子轮廓,子轮廓之间通过next(索引 0)串起来。遍历时别忘了hierarchy = hierarchy[0],否则索引会多一层,这是很多人第一次用层级时卡住的地方。
5. 轮廓提取避坑与排查清单
5.1 轮廓数忽多忽少,先查二值图
现象:同一批图,有的数出 3 个目标,有的数出 30 个。原因:光照变化导致自适应阈值参数失效,背景噪点被当成前景。解决:固定光源,或把blockSize调大、C调大;实在不行改用固定阈值加 ROI 掩膜,把非检测区直接涂黑。排查时一定先imwrite保存二值图,别对着原图猜。
5.2 目标粘连成一个轮廓
现象:两个挨着的零件被算成一个,面积翻倍。原因:二值化后目标之间像素连通,RETR_EXTERNAL把它们并成一条外轮廓。解决:先做开运算断开细连接,核大小按粘连宽度选;或者用距离变换加分水岭分割。简单场景下,如果粘连不严重,开运算 3x3 到 5x5 就能分开,再大就会伤目标本身。
5.3 轮廓面积算出来是 0 或异常小
现象:明明有目标,contourArea返回 0。原因:轮廓点太少(少于 3 个)或所有点共线,格林公式积分为 0;也可能是轮廓没闭合,arcLength的 closed 参数传了 False。解决:过滤掉点数小于 3 的轮廓,确认findContours输入是真正的二值图(不是灰度图),检查arcLength第二个参数传 True。
5.4 版本差异导致函数返回值对不上
现象:照抄网上代码报ValueError: not enough values to unpack。原因:OpenCV 3 之前findContours返回三个值(还返回原图),3 之后返回两个。解决:统一用contours, hierarchy = cv2.findContours(...),如果环境是 OpenCV 2 就升级,别为老版本写兼容分支,维护成本不值。另外boxPoints转整数用np.intp,np.int0在新版 NumPy 里已弃用。
5.5 轮廓顺序不稳定导致编号跳变
现象:同一目标在连续帧里轮廓索引一直变,跟踪逻辑错乱。原因:findContours的返回顺序跟扫描顺序和连通域发现顺序有关,不保证跨帧一致。解决:不要依赖索引,改用轮廓的质心moments或外接矩形中心做匹配,按位置排序后再编号。我一般按质心 x 坐标从左到右排序,产线分拣时编号就稳定了。
6. 把轮廓提取接进实际项目的进阶技巧
真正上项目时,单帧轮廓提取只是第一步,稳定性和吞吐才是考验。我一般会加三个东西:ROI 掩膜、轮廓缓存跟踪、以及基于轮廓的简单分类。
ROI 掩膜是把非检测区直接置零,减少无效轮廓。做法是先用np.zeros建一张和原图同尺寸的掩膜,在感兴趣区域画白色多边形,再和二值图做按位与。这样背景再乱也不会干扰,findContours的负担也小。
mask = np.zeros(gray.shape, dtype=np.uint8) roi_poly = np.array([[100, 100], [500, 100], [500, 400], [100, 400]]) cv2.fillPoly(mask, [roi_poly], 255) binary = cv2.bitwise_and(binary, mask)fillPoly支持多个多边形,一次可以圈好几块区域。坐标点顺序无所谓,OpenCV 会自动闭合。掩膜边缘最好留几像素余量,避免目标刚好压在 ROI 边界上被切一半。
跟踪方面,如果目标是连续运动的,逐帧重新找轮廓会有抖动。我的习惯是维护一个轮廓列表,用质心距离做帧间匹配:新一帧的轮廓质心离上一帧某个质心小于阈值就认为是同一个目标,继承它的编号;连续几帧没匹配上的就删掉。这样编号稳定,计数也不会因为一帧漏检就少一个。
分类则可以用轮廓的几何特征做轻量判断,不必上深度学习。比如顶点数 4 且凸、长宽比接近 1 的判为方形;顶点数大于 6、紧凑度大于 0.8 的判为圆形;面积相近但周长明显偏大的判为带缺口的不合格品。这套规则在零件分拣里能覆盖八成情况,剩下两成再交给模型。
最后说个我自己的习惯:每调一次预处理参数,都把二值图、轮廓图、筛选后的结果图三张并排存下来,文件名带上参数值。项目后期要复现某个效果时,翻这些图比翻代码快得多。轮廓提取这活儿,参数是玄学,但记录不是。希望帮到你。
本文还有配套的精品资源,点击获取