OpenCV轮廓检测实战:从二值化到形状识别的完整指南
PyImgSearch 博客中文翻译做到第十期这次选的是一篇很经典的轮廓检测实战文章。最近好几个读者私信问我做图像处理从哪里入门容易出效果我的建议基本都是同一个先玩明白轮廓contour和形状识别。这个方向不依赖复杂模型只需要把 OpenCV 的几行函数吃透就能处理不少真实问题给产品图片数数量、定位票据上的印章区域、把扫描件里的表格单元格挨个切出来、甚至标注显微图像里的细胞数量。这一期的核心内容包括怎么把一张彩色图变成适合找轮廓的二值图、cv2.findContours 各种参数到底怎么选、如何按位置给轮廓排序、再利用顶点数量做简单的形状识别。文章里的代码我都会标注版本兼容点因为我本地测试时也踩了返回值版本差异的坑。不管你是刚开始学 OpenCV还是已经在做自动化视觉定位但经常被预处理折磨这篇都能给你一些能直接抄的思路。建议准备一个 Python 环境装好 opencv-python、numpy 和 matplotlib跟着跑一遍理解会比只看文档深刻很多。1. 轮廓检测在图像处理里的位置为什么第十期选这个主题1.1 轮廓不是边缘检测两者差在哪很多新手学 OpenCV 时会混淆轮廓Contour和边缘Edge。边缘检测算子例如 Canny、Sobel 只看像素局部亮度突变输出是一张二值图上的白色点这些点之间谁跟谁连着、谁在谁里面各点之间没有顺序关系。轮廓则不同它是把边缘点按连通关系组织成的一条或多条闭合链每个轮廓是一个有序的点集可以直接计算面积、周长、外接矩形甚至做形状匹配。这就像一堆积木散落在地上是边缘检测而把每块积木沿着外边缘描一圈并编好号就是轮廓提取。实际工程管道里经常是先算边缘或者阈值得到二值图再调用 findContours 把二值图里白色区域的外边界提取出来。PyImgSearch 很多教程都是这个思路先用阈值或 Canny 把前景“捞”出来再把轮廓作为后续处理的输入。对新手而言值得先想明白一件事轮廓的数量取决于二值图里有几个独立的白色连通区域而不是边缘图里有多少条线。理解了这个后面调参数才不会被一堆莫名其妙的轮廓吓到。1.2 本地验证环境与 OpenCV 版本选择为了模拟原文示例我在本地新建了一个干净的虚拟环境。Python 用的 3.10OpenCV 用的 4.8 系列装包命令pip install opencv-python numpy matplotlib如果机器上没有虚拟环境也可以直接用pip install --user注意别和系统自带的 OpenCV 冲突就行。我测试时特意避开了 OpenCV 3.x因为从 3.0 开始 findContours 的返回值从“三个变量”变成了“两个变量”原来的写法定会报错。后面第 4 节我会专门讲这个坑。另外原文示例常会带 argparse、imutils 这类库翻译时为了减少障碍我把它精简成纯函数版本。用 argparse 做命令行参数当然更规范但初学者测试时直接在文件里定义图片路径更直观。核心要跑的代码只有几十行不需要额外依赖。2. 预处理与核心参数findContours 不是点一下就完事2.1 灰度、降噪、二值化的先后顺序不能乱cv2.findContours 只能接收单通道图像所以第一步永远是转灰度。有人会问彩色图里的颜色信息难道没帮助吗对于轮廓提取这种任务颜色会把同一个物体的不同色块拆成多个区域导致轮廓数量成倍增加。绝大多数场景下先转灰度把重点关注在亮度结构上结果反而稳定。得到灰度图以后我习惯先做轻度降噪。高斯模糊的卷积核尺寸通常取 (3, 3) 或 (5, 5)别用太大否则细小物体的边缘会被磨平轮廓取出来缩了一圈。如果图像里的噪点是比较明显的胡椒盐噪声可以用中值滤波cv2.medianBlur(img, 5)它对孤立噪点更有效。然后是二值化处理。最简单的做法是固定阈值_, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)THRESH_OTSU会自动计算最佳阈值对光照相对均匀的图很省事。如果背景亮度变化大比如扫描纸边缘有阴影固定阈值容易把阴影也变成前景这时改用自适应阈值binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)无论用哪种方式请务必确认目标区域是白色、背景是黑色。findContours 在 OpenCV 中默认把白色视为前景黑色视为背景。如果图像是白纸黑字直接用THRESH_BINARY的字是黑的会找出一条横跨整张纸的巨大外轮廓几乎必然翻车。这种情况要加_INV反转成白字黑底或者用cv2.bitwise_not()手动反转。2.2 形态学操作预处理里比想象中更常用的步骤二值图上经常有细碎的噪点、连着的背景网格、文字笔画的毛刺会影响轮廓提取。单纯依赖阈值还不够形态学操作是工程上兜底的手段。开运算先腐蚀后膨胀能去掉小白点适合清理前景里的小碎块kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)闭运算先膨胀后腐蚀能填补物体内部的小空洞把断开的笔画连成整体binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)调整核尺寸可以控制处理强度核越大影响范围越广。我的经验是尽量从 3×3 开始试不要一上来就用 7×7 这种大核否则两个本来相邻的物体可能被连通成一个轮廓。形态学的意义在预处理链路里相当于给二值图“搓澡”搓太狠会伤皮肉搓轻了又除不净需要依图调试。2.3 findContours 参数详解检索模式和近似方法函数签名在 OpenCV 4 中是这样的contours, hierarchy cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)第一个参数是二值图第二个是检索模式第三个是近似方法。很多教程会把这两个参数随便填结果一换图就出问题所以这里值得花时间说透。检索模式控制的是“哪些轮廓返回给你”和“轮廓间的父子关系要不要记录”常见四种模式行为适合场景RETR_EXTERNAL只提取最外层轮廓忽略内部所有嵌套物体计数、外框定位RETR_LIST提取所有轮廓但不建立父子层级扁平场景无嵌套时很轻量RETR_CCOMP建立两级层次外层轮廓和内部孔洞分开记录有凹洞的物体如环、轴承RETR_TREE建立完整树形结构记录所有嵌套关系分析图像内嵌层次如文档套娃区域对于大多数“识别图片里有几个物体”的需求RETR_EXTERNAL 够用轮廓数量少、干扰少。要识别带有内部镂空的形状比如圆形垫圈的里外两个圆就得升级到 RETR_CCOMP然后从 hierarchy 里过滤出第二层轮廓。RETR_TREE 信息最全但轮廓数量也会暴涨效率会下降。近似方法这一参数则影响轮廓点集的稠密程度CHAIN_APPROX_SIMPLE 会把水平、垂直、对角线方向上冗余的点压缩掉只保留拐点。比如一个矩形轮廓只保留四个角点。CHAIN_APPROX_NONE 保留链上每一个点数据量大但不丢形状细节。做形状识别时强烈建议用 CHAIN_APPROX_SIMPLE因为它省内存而且为后面的 approxPolyDP 做近似时输入更干净。只有要做高精度弧长测量时才考虑用 NONE。还有个小细节findContours 会修改传入的二值图虽然不同版本表现不一致但我现在都养成了传副本的习惯contours, hierarchy cv2.findContours(binary.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)省得后续还要使用二值图时发现内容被改掉了。3. 实操过程轮廓排序、面积过滤、形状识别全流程3.1 为什么轮廓需要排序以及怎么排findContours 返回的轮廓顺序并不能保证和图像上的空间位置有什么对应关系。对物体计数场景顺序无所谓但做 OCR 字符切分、表格识别、扫描件区域整理时必须按从左到右、从上到下的顺序处理轮廓否则输出顺序是乱的下游环节会集体错乱。排序思路很简单取每个轮廓的外接矩形用左上角的 (x, y) 坐标排序。常见的做法是先按行排再按列排也就是先按 y 排序再按 x 排序或者用一个二元组直接按 (y, x) 排序import cv2 def sort_contours(cnts, methodleft-to-right): reverse False i 0 if method right-to-left or method bottom-to-top: reverse True if method top-to-bottom or method bottom-to-top: i 1 bounding_boxes [cv2.boundingRect(c) for c in cnts] (cnts, bounding_boxes) zip(*sorted(zip(cnts, bounding_boxes), keylambda b: (b[1][i], b[1][0]), reversereverse)) return cnts, bounding_boxes调用的时候contours, boxes sort_contours(contours, methodleft-to-right)这个函数的关键是外接矩形 x 表示列位置y 表示行位置。把排序维度拆成“先 y 后 x”还是“先 x 后 y”取决于你的版面。票据这种一行一行排的TOP_TO_DOWN 或 LEFT_TO_RIGHT 都行。我是建议实际项目里把基准坐标对齐到外接矩形中心因为轮廓有时不是一个完美的矩形左上角坐标受形状拖影影响中心点更稳定。但在绝大多数演示代码里(y, x) 排序已经足够。3.2 面积过滤先砍掉一切没用的碎轮廓从真实图片里拿出的轮廓尤其是经过边缘检测再 findContours数量往往比实际物体多得多。几个像素的噪点、一条小划痕都会变成独立轮廓。直接做形状识别会把小玩意全识别成“多边形”很离谱。常见的处理是面积过滤。我习惯用动态阈值先找到所有轮廓里的最大面积再设置一个比例下限比如max(area) * 0.02面积太小的直接忽略。这样不同分辨率、不同拍摄距离下都能自适应不用写死像素数。写死的做法偶尔用于已知固定场景比如针孔成像拍出来的摄像头位置不动目标大小范围明确时固定阈值反而更快。这类问题在很多 OCR 预处理里尤其明显文档扫描后的装订孔、边框线、噪点都会变成轮廓。先把小面积载体过滤干净形状识别才会准确。我用的一张测试图上有 40 多个轮廓面积过滤之后只剩 4 个大的整体流程效率立刻提升一个档次。3.3 approxPolyDP 与形状判定的核心逻辑拿到干净轮廓之后下一步是识别形状。基础思路是看轮廓被压缩后剩几个顶点。用 cv2.approxPolyDP 对轮廓点集做多边形近似peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True)这里的理论依据是道格拉斯-普克算法给定一条曲线判断曲线上各点到“候选直线段”的距离如果某些点的偏差超过阈值就保留它们作为节点继续递归逼近。阈值由 epsilon 控制通常取轮廓周长的一定比例。epsilon 太大曲线会被压得过于粗糙圆都可能变成三角形epsilon 太小近似结果接近原始点集起不到降维作用。0.02 倍周长是个很稳的起步值多边形轮廓基本不会出错曲线轮廓也已经能压缩出少量关键点。判断顶点数if len(approx) 3: shape triangle elif len(approx) 4: shape rectangle elif len(approx) 5: shape pentagon else: shape circle对四边形的细分要再算宽高比。标准矩形长宽比接近 1 就是正方形差别大是普通矩形。但要注意物体本身有一定旋转角度时直接用外接矩形宽高比会不稳定最好改用最小外接矩形 cv2.minAreaRectrect cv2.minAreaRect(c) (w, h) rect[1] ratio max(w, h) / min(w, h) if ratio 1.05: shape rectangle else: shape square对圆形除了顶点数量多还可以用圆度加一层验证。圆度的定义是4π * area / perimeter^2标准圆接近 1正方形约 0.785三角形更小。加这个判断能减少把齿轮、圆角方形误判成圆形的情况。3.4 完整示例代码与运行效果我写了一个可直接运行的 Demo测试图是一张纯色背景上放了几个白色三角形、矩形、正方形、六边形、圆形。步骤是读取 - 灰度 - 高斯模糊 - OTSU 阈值 - 形态学开运算 - findContours - 按面积过滤 - approxPolyDP 识别 - 绘制标注并显示结果。import cv2 import numpy as np def detect_shapes(image_path): image cv2.imread(image_path) if image is None: raise FileNotFoundError(image not found) gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) _, binary cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) contours, _ cv2.findContours(binary.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) max_area max([cv2.contourArea(c) for c in contours], default0) for c in contours: area cv2.contourArea(c) if area max_area * 0.05: continue peri cv2.arcLength(c, True) approx cv2.approxPolyDP(c, 0.02 * peri, True) shape_name identify_shape(c, approx) x, y, w, h cv2.boundingRect(c) cv2.drawContours(image, [c], -1, (0, 255, 0), 2) cv2.putText(image, shape_name, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imshow(result, image) cv2.waitKey(0) def identify_shape(c, approx): vertices len(approx) if vertices 3: return triangle if vertices 5: return pentagon if vertices 6: return hexagon if vertices 4: rect cv2.minAreaRect(c) (w, h) rect[1] ratio max(w, h) / min(w, h) return rectangle if ratio 1.05 else square return circle if __name__ __main__: detect_shapes(shapes.png)用我造的模拟图跑下来四个基础图形能全部正确识别每个轮廓都画出绿色边框并且标了形状名称。代码是针对固定场景的如果检测目标换成手工画的草图阈值和面积比例大概率要调。这类代码的调试流程永远是先把二值图显示出来确认目标轮廓是白色且独立再确认轮廓数量符合预期最后才进入形状判断逻辑。3.5 更进一步的判断凸包和圆度顶点数量只能回答“这个轮廓像什么多边形”。真实场景里同一个“四边形”可能是矩形也可能是平行四边形或者梯形同一个“圆形”可能是真圆也可能是椭圆。想要更稳定的判断可以在基础判定之后叠加几何度量。凸包检测能帮我们识别形状有没有明显凹陷。cv2.convexHull(c) 返回轮廓的凸包点集进一步cv2.isContourConvex(c)可以直接判断轮廓是否为凸。五角星形是非凸的而凸五边形是凸的两个形状都有五个主顶点但凸度指标完全不同。对于有凹槽的齿轮一类的目标凸度比值cv2.contourArea(c) / cv2.contourArea(cv2.convexHull(c))很有参考价值越接近 1 说明凹陷越浅。对圆形的验证圆度比外接矩形宽高比更靠谱。长宽比接近 1 的椭圆也会有接近 1 的比例但圆度会拉开差距。我之前遇到过把胶囊形状误判成圆形的案例就是因为只看了宽高比而胶囊的两端是圆弧、中间是直线整体圆度只有 0.8 左右远达不到圆的判定线。调试多了就会发现几何指标交叉验证永远比单一指标可靠。4. 常见问题与排查技巧实录我踩过的那些坑4.1 findContours 返回值数量不固定的版本坑写代码最烦一类问题同一段代码在一台机器上跑得好好的换台机器就报not enough values to unpack (expected 3, got 2)。这个锅多半是 OpenCV 版本差异背。早期 OpenCV 2.x 时代findContours 的返回值是三个图像、轮廓列表、层级信息。从 OpenCV 3.0 开始官方把第一个返回值去掉了变成两个轮廓列表、层级信息。所以你会发现网上很多老教程这样写img, contours, hierarchy cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)在 OpenCV 3 及以上直接炸。排查方法很简单先打印版本号print(cv2.__version__)如果是 4.x统一改成两个变量的写法。如果你的代码要多环境兼容可以用try-except的写法但说实话我后来都直接放弃兼容旧版了新项目一律用 4.x。另外一个隐藏点有些环境里安装的是 opencv-contrib-python接口一致不会出现差异。4.2 白色背景、黑色物体的反直觉结果有次读者给我发代码说“我的图片明明只有一个圆形为什么 findContours 检测整个图片作为轮廓”。我让他打印一下二值图果然目标圆形在图上就是一片黑色白色的是整个背景。findContours 把白色区域当成前景于是整个背景边界就成了“最大轮廓”。解法非常直接翻转极性把目标变成白色背景变成黑色。用阈值时直接选择THRESH_BINARY_INV或者在已经被二值化的图上执行binary cv2.bitwise_not(binary)排查这类问题有一个通用技巧调试时永远先把中间二值图画出来看一眼而不是拿一组轮廓去猜。二值图错了后面所有步骤都白搭。我在本地测试时甚至习惯把所有中间结果拼在一张画布上显示稍微多写几行代码能省掉大量来回试的时间。4.3 噪点、网格线、伪轮廓成堆怎么办二值化后图像上有细碎噪点是很常见的。如果轮廓数量比你预期多了十倍大概率是噪点和小碎块被当成轮廓了。应对手段优先级如下高斯模糊或中值滤波把高频噪点抹掉。用开运算把孤立小点清掉。在 findContours 之后按面积下限过滤。如果前景不是目标的伪区域更大比如纸张纹理形成的斑块就得提高阈值方法的敏感度或者增加降噪强度。网格线干扰是另一个高频问题。表格图片的边框线会连出很多长条形轮廓如果目标是单元格中的手写内容两条边框线会把内容夹在中间导致识别轮廓被割裂。我一般先考虑把网格线从预处理阶段清除比如用水平核和垂直核分别腐蚀掉细线再做连通区域分析。处理顺序很关键先腐蚀横线、再腐蚀竖线、掩膜合并挨个排查。面积过滤是保底手段但过滤比例不能乱拍。我现在的习惯是先打印所有轮廓面积看一眼数量分布再设阈值。有一次相机分辨率从 1080p 换成 4K固定阈值还留在 200 像素结果小字全被过掉了改成“最大轮廓面积 × 0.02”动态过滤后才恢复。4.4 绘制与坐标转换的细节cv2.drawContours 的第三个参数是轮廓索引传 -1 表示绘制全部轮廓。第二个参数必须是一个列表即使只想画一个轮廓也要写成[c]直接传单个数组会报错。颜色顺序是 BGR不是 RGB。在 OpenCV 画出来正常用 matplotlib 显示时记得转rgb_image cv2.cvtColor(image, cv2.COLOR_BGR2RGB)外接矩形坐标要注意类型。cv2.boundingRect 在某些情况下返回浮点数直接拿去构造 ROI 切片会出问题我习惯用np.int0(rect)做一次转整。另外轮廓点如果延伸出图像边界绘制不会崩但对超出边界的 ROI 做切片会报错或者得到尺寸不符的结果建议在切片前用max(0, x)、min(w, width - x)这类裁剪逻辑把矩形限制在图像范围内。4.5 常见问题速查表问题原因解决方案返回not enough values to unpack (expected 3, got 2)OpenCV 版本从 3.0 开始返回值为两个改用contours, hierarchy cv2.findContours(...)检测到的最大轮廓是整张图的边缘目标区域是黑色、背景白色前景选取反了使用cv2.threshold(..., cv2.THRESH_BINARY_INV, ...)反转极性轮廓数量远多于预期二值图上有噪点、纹理干扰加高斯模糊/中值滤波执行开运算再按面积过滤矩形和正方形区分不出来外接矩形宽高比对旋转物体不稳定改用cv2.minAreaRect求最小外接矩形的宽高比圆形误判成多边形轮廓点被 approxPolyDP 压缩得过少调大 epsilon 测试或用圆度4πA / P²辅助验证显示颜色偏蓝红互换OpenCV 默认 BGRmatplotlib 默认 RGB用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转换后再显示ROI 切片报错boundingRect 返回浮点或坐标越界用np.int0(rect)转整型并对边界做裁剪排查问题时我一般先画二值图再看连续轮廓之间的面积关系最后才考虑算法参数。顺序反了的代价是时间成倍消耗因为你会不停怀疑逻辑代码有 bug其实问题早就出在预处理阶段。5. 我对这一期内容的验证心得一个调试习惯带来的效率提升跑完这一期内容一个比较深刻的体会是形状识别这个任务真正的难点极少在“findContours 调用”本身而在于你前一步的二值图质量。只要目标轮廓是干净独立的白色连通区域返回的轮廓几乎不会出问题一旦二值图里混入噪点、网格、反色后面再怎么调参数都像隔靴搔痒。所以我现在看别人代码出 bug第一反应永远是让他打印中间的二值图十次里有八次问题都在那个环节。顺带分享一个长期有用的调试习惯我第一次调轮廓代码时会在 pipeline 里塞一个“可视化调试函数”把灰度、模糊、阈值、形态学结果、最终轮廓全部并排显示出来用不同窗口标题标好阶段。改一个参数马上能看到每个阶段的变化而不是等到最后画轮廓时才判断对错。这个习惯后来帮我处理过很多图像预处理问题光是省下的调参时间就值回票价。如果你准备把这一期的内容延伸到真实项目建议按需选方向文档识别方向可以给每个字符轮廓做顺时针排序配合模板匹配做字体分类工业检测方向可以用 RETR_CCOMP 提取有孔洞的零件内外轮廓再用圆度和凸度判断缺陷如果把轮廓面积、周长、凸包比这些特征做成向量还能接一个简单的分类器对常见形状做更细的分类。PyImgSearch 这个系列后续经常会把轮廓、透视变换、OCR 几个主题串起来轮廓是它们最底下那一层基石前几期的基础打牢了之后学透视矫正和文本识别会顺很多。