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.10,OpenCV 用的 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, method="left-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), key=lambda b: (b[1][i], b[1][0]), reverse=reverse)) return cnts, bounding_boxes调用的时候:
contours, boxes = sort_contours(contours, method="left-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.minAreaRect:
rect = 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], default=0) 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 默认 BGR,matplotlib 默认 RGB | 用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转换后再显示 |
| ROI 切片报错 | boundingRect 返回浮点或坐标越界 | 用np.int0(rect)转整型,并对边界做裁剪 |
排查问题时我一般先画二值图,再看连续轮廓之间的面积关系,最后才考虑算法参数。顺序反了的代价是时间成倍消耗,因为你会不停怀疑逻辑代码有 bug,其实问题早就出在预处理阶段。
5. 我对这一期内容的验证心得:一个调试习惯带来的效率提升
跑完这一期内容,一个比较深刻的体会是:形状识别这个任务真正的难点极少在“findContours 调用”本身,而在于你前一步的二值图质量。只要目标轮廓是干净独立的白色连通区域,返回的轮廓几乎不会出问题;一旦二值图里混入噪点、网格、反色,后面再怎么调参数都像隔靴搔痒。所以我现在看别人代码出 bug,第一反应永远是让他打印中间的二值图,十次里有八次问题都在那个环节。
顺带分享一个长期有用的调试习惯:我第一次调轮廓代码时,会在 pipeline 里塞一个“可视化调试函数”,把灰度、模糊、阈值、形态学结果、最终轮廓全部并排显示出来,用不同窗口标题标好阶段。改一个参数,马上能看到每个阶段的变化,而不是等到最后画轮廓时才判断对错。这个习惯后来帮我处理过很多图像预处理问题,光是省下的调参时间就值回票价。
如果你准备把这一期的内容延伸到真实项目,建议按需选方向:文档识别方向可以给每个字符轮廓做顺时针排序,配合模板匹配做字体分类;工业检测方向可以用 RETR_CCOMP 提取有孔洞的零件内外轮廓,再用圆度和凸度判断缺陷;如果把轮廓面积、周长、凸包比这些特征做成向量,还能接一个简单的分类器,对常见形状做更细的分类。PyImgSearch 这个系列后续经常会把轮廓、透视变换、OCR 几个主题串起来,轮廓是它们最底下那一层基石,前几期的基础打牢了,之后学透视矫正和文本识别会顺很多。