多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

OpenCV轮廓提取实战:从二值化到物体计数与测量

OpenCV轮廓提取实战:从二值化到物体计数与测量 简介这份资源面向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_TREEfindContours的第二个参数决定轮廓之间的组织关系选错了要么漏目标要么多出一堆内孔。常见取值和适用场景检索模式含义典型场景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) # 自适应阈值blockSize31 适合中等尺寸目标C5 抑制背景噪点 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_a200, max_a50000, min_compact0.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.intpnp.int0在新版 NumPy 里已弃用。5.5 轮廓顺序不稳定导致编号跳变现象同一目标在连续帧里轮廓索引一直变跟踪逻辑错乱。原因findContours的返回顺序跟扫描顺序和连通域发现顺序有关不保证跨帧一致。解决不要依赖索引改用轮廓的质心moments或外接矩形中心做匹配按位置排序后再编号。我一般按质心 x 坐标从左到右排序产线分拣时编号就稳定了。6. 把轮廓提取接进实际项目的进阶技巧真正上项目时单帧轮廓提取只是第一步稳定性和吞吐才是考验。我一般会加三个东西ROI 掩膜、轮廓缓存跟踪、以及基于轮廓的简单分类。ROI 掩膜是把非检测区直接置零减少无效轮廓。做法是先用np.zeros建一张和原图同尺寸的掩膜在感兴趣区域画白色多边形再和二值图做按位与。这样背景再乱也不会干扰findContours的负担也小。mask np.zeros(gray.shape, dtypenp.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 的判为圆形面积相近但周长明显偏大的判为带缺口的不合格品。这套规则在零件分拣里能覆盖八成情况剩下两成再交给模型。最后说个我自己的习惯每调一次预处理参数都把二值图、轮廓图、筛选后的结果图三张并排存下来文件名带上参数值。项目后期要复现某个效果时翻这些图比翻代码快得多。轮廓提取这活儿参数是玄学但记录不是。希望帮到你。本文还有配套的精品资源点击获取
返回列表