OpenCV轮廓查找与绘制:从二值化到层级分析的实战攻略 做图像处理的时候我经常跟朋友说一句话如果你只会看像素值那你看到的就是一堆数字但如果你会看轮廓你看到的才是一个个物体。OpenCV里最常被问到的功能之一就是用Python做查找并绘制轮廓。今天不整虚的直接把这个功能从概念到代码、从参数到坑完整捋一遍。无论你是刚入门要交作业还是已经写了不少代码但总觉得自己对findContours只是“照着抄”这篇都值得你花十分钟认真看完。很多人第一次接触轮廓是被它的“画线效果”吸引的但真正用起来才发现轮廓真正值钱的地方不是“画出来好看”而是它给了你一套描述物体形状的底层语言面积、周长、外接矩形、多边形近似、层级关系全都从这一个小小的轮廓数据结构里长出来。你可以用它做工业零件计数、图片中的文字块定位、手势轮廓识别、甚至拍一张照片自动抠出桌上的杯子。掌握了轮廓查找和绘制你基本就拿到了OpenCV入门到实战之间最关键的那把钥匙。1. 轮廓到底是什么先想清楚这件事后面代码才有意义1.1 最容易搞混的一件事轮廓不是边缘我见过太多新手把**轮廓Contour和边缘Edge**混为一谈觉得Sobel、Canny找出来的边缘就是轮廓。这个误会如果不纠正后面代码会写得非常别扭。边缘是图像中像素灰度发生剧烈变化的地方它是像素级的概念。你用Canny检测一张图得到的是零零散散的白色线条这些线条可能中间断开可能在纹理丰富的地方糊成一团。而轮廓是物体级的概念它描述的是连通区域的边界线是一整条闭合的曲线。一个圆Canny检测出来可能是一圈断断续续的弧段但轮廓查出来就是一条完整的、首尾相接的圆环。用一个生活化的例子边缘像是你在一面墙上看到的裂缝痕迹哪里颜色突变哪里就有“边缘”而轮廓像是你用描图纸把一个物体的剪影完整地拓下来它是闭合的、成体系的。所以findContours和Canny的关系其实是协作关系不是替代关系——很多人用Canny得到的边缘图再去查轮廓但更常见、更稳定的做法是先做二值化让目标区域变成一块完整的白色区域然后让OpenCV去“拓”这块白色区域的边界。1.2 轮廓在OpenCV里的真实长相一个List加一串层级编号搞清轮廓的数据结构是避免后续各种玄学报错的关键。在Python的OpenCV接口里cv2.findContours返回的轮廓是一个Python列表list列表里的每一个元素是一个轮廓而每个轮廓本身是一个numpy.ndarray形状是(N, 1, 2)其中N是这个轮廓上的点的数量。:contours[0] # 第一个轮廓 # 输出类似 # array([[[ 32, 44]], # [[ 33, 45]], # [[ 35, 47]], # ...为什么每个点要包两层括号因为OpenCV的C底层使用vectorvectorPoint来存储轮廓转换到Python后就成了这种“每个点都是Nx1x2数组”的结构。你在遍历坐标时会经常写for point in contour: x point[0][0]; y point[0][1]第一次见会觉得多此一举知道它是历史遗留结构就好。同时返回的hierarchy才是真正的重头戏。它是一个形状为(1, N, 4)的数组N是轮廓总数。每一行对应当前轮廓的信息[Next, Previous, First_Child, Parent]分别表示同级别的下一条轮廓、同级别的上一条轮廓、第一条子轮廓的索引、父轮廓的索引。没有对应项时值为-1。听起来枯燥但它是你在做嵌套物体分离时的唯一依据。我后面会用测试图实际演示。2. 找轮廓前的预处理三板斧做不对后面全是白忙2.1 为什么findContours只认二值图打开findContours的官方文档第一个输入参数写的是“8位单通道灰度图像”但你要真拿一张普通的灰度图丢进去会得到一堆毫无意义的乱轮廓。因为findContours内部会把非零像素当成前景零像素当成背景它本质上是基于“哪个像素点不是0”来判断边界的。所以灰度图里大量灰度值介于1到254之间的像素全都会被当成前景处理结果就是噪点连成片、目标糊成一团。正确姿势是先做二值化让图像只剩下两类像素0和255。前景目标区域是255背景是0。这样查出来的轮廓才是一个个干净的物体边界。这也是为什么预处理的三板斧特别重要——它们做的所有事情都是为了让你手里的图变成一张“白纸黑字”的二值图。2.2 预处理三段式实操读图、降噪、二值化标准物理流程是读入彩色图 - 转灰度 - 降噪 - 二值化。我贴一段我常用的基础操作import cv2 import numpy as np # 1. 读入彩色图 img cv2.imread(shapes.png) # 2. 转灰度 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 3. 高斯模糊去噪核大小建议奇数实测(5,5)最常用 blur cv2.GaussianBlur(gray, (5, 5), 0) # 4. 固定阈值二值化127是经验值后面我会讲怎么自动选 _, binary cv2.threshold(blur, 127, 255, cv2.THRESH_BINARY)高斯模糊这一步经常有人偷懒直接跳过但如果你拍的照片有轻微噪点找出来的轮廓边缘会像锯齿一样还可能出现很多细碎的伪轮廓。高斯模糊相当于把图抹平一点让噪点不再成为独立的“小岛”。(5, 5)是性价比很高的选择没有必要一上来就上(15, 15)那种大核轮廓边缘会被磨得太圆几何测量就不准了。2.3 只用固定阈值是不够的两个自动阈值方案固定阈值threshold(blur, 127, 255, ...)依赖于“前景和背景亮度差别很大”这个假设。但在真实拍摄场景里光照不均匀是常态这时固定阈值就会翻车暗区背景被并进前景亮区前景被划成背景。针对这种情况我推荐两个思路第一个是大津法Otsu。它不直接给一个固定阈值而是自动计算一个能把前景背景分得最开的阈值_, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU)注意这里的阈值参数写0会被忽略Otsu会自己算。实测下来纸面文档、二维码、均匀光照下的零件用Otsu效果都很好。第二个是自适应阈值它适用于光照有明显渐变的情况binary cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2 )自适应阈值每个像素的阈值由它周围一个小邻域算出来所以能应对局部明暗不均。11是邻域窗口大小2是一个常量修正项这两个值都不是固定的需要根据你的图微调。我一般先用Otsu如果效果不行再上自适应阈值没必要一开始就追求复杂方案。3. 两个核心函数不只查出来还要画得明白3.1findContours返回三个值还是两个值全看你的版本现在OpenCV主流的Python版本4.x里cv2.findContours返回三个值contours, hierarchy cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)其实这条代码在OpenCV 3.x以上都会报错因为返回的是三个值image, contours, hierarchy。这个版本差异坑了无数人。完整兼容写法是这样result cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if len(result) 3: # OpenCV 3.x / 4.x _, contours, hierarchy result else: # OpenCV 2.x contours, hierarchy result第一个返回值是被修改过的源图但官方早已不建议使用直接丢弃即可。后面所有代码我按3.x/4.x的写法来。性能上提一句findContours的算法核心是图像拓扑分析复杂度跟像素数和轮廓点数有关。实测一张1920x1080的图轮廓数量在几百个以内时耗时都在几十毫秒级别完全够用于实时视频处理。所以不用焦虑性能问题先把正确性搞定。3.2 检索模式从“只要最外层”到“连爹带儿子全要”findContours第二个参数是检索模式官方给了四个我按实用场景拆给你检索模式含义适用场景RETR_EXTERNAL只返回最外层轮廓统计图中一共有几个物体时最合适嵌套的内部结构不关心RETR_LIST返回所有轮廓但所有轮廓都在同一级没有父子关系只想把图里所有闭合边界都拿出来的简单场景RETR_CCOMP按两层结构组织外层一层、内层一层适合带孔洞的物体比如圆环垫片、带字符的字符块RETR_TREE完整树形层级关系需要做嵌套物体分离、层级分析时首选新手最容易犯的错误是不管什么场景一律用RETR_TREE结果是同一个物体因为内部嵌套结构被拆成好几个轮廓统计数量时翻车。如果你只是数一数画面里有几个快递盒用RETR_EXTERNAL最稳因为每个盒子最外层只有一条轮廓。如果你想分析一个表盘上的刻度、指针和表壳的嵌套关系那必须上RETR_TREE。3.3 近似方法细节控和性能党之间的平衡第三个参数是轮廓近似方法常用两个CHAIN_APPROX_NONE和CHAIN_APPROX_SIMPLE。CHAIN_APPROX_NONE会存储轮廓上每一个像素点描述最精细但数据量很大。CHAIN_APPROX_SIMPLE则会把水平、垂直、对角方向上的冗余点压缩掉只保留真正改变方向的点。理论上一个矩形SIMPLE模式下只存4个顶点NONE模式可能存上百个点。绝大多数业务场景用SIMPLE就够了因为后面算面积、外接矩形、多边形近似时缺那点中间点完全不影响精度还省内存。但如果你要做非常精细的轮廓形状分析比如曲线细节还原可以考虑NONE。还有一种CHAIN_APPROX_TC89_L1和CHAIN_APPROX_TC89_KCOS用了泰希纳算法做进一步压缩我实际项目中很少用了解即可。3.4drawContours最后一个参数决定你是描边还是填色有了轮廓数据绘制就交给cv2.drawContours。它有一个容易被忽略的宝藏参数thickness。当thickness 0时画出来的是描边当thickness -1时直接把这个轮廓围起来的区域全部填充.# 把所有轮廓画在原图上红色描边线宽2 cv2.drawContours(img, contours, -1, (0, 0, 255), 2) # 只画第0个轮廓并且填充成绿色 cv2.drawContours(img, contours, 0, (0, 255, 0), -1)第三个参数contourIdx如果传-1就表示绘制列表里的全部轮廓指定具体索引就只画那一个。这个参数在调试时极其好用你怀疑某个轮廓不对直接把它单独画出来看比打印一堆坐标直观多了。还有个点需要记牢drawContours会直接修改你传入的图像。如果你不想破坏原图务必先做一次img.copy()再画不然调试完原图已经五花八门了。4. 完整实操从一张测试图到轮廓可视化4.1 自己造一张测试图最快理解轮廓的一切网上下载图片容易涉及版权还要处理文件路径。我现在最推荐的做法也是在这篇文章里我会用的方案直接用numpy和cv2画一张包含多个形状的测试图里面放一个圆形、一个矩形、一个三角形再加一个中间有洞的图形。这样做的最大好处是你自己心里完全清楚每个目标的真实位置验证轮廓查找有没有找对时就特别有底气。import cv2 import numpy as np img np.zeros((480, 640, 3), dtypenp.uint8) # 画一个实心圆 cv2.circle(img, (150, 150), 80, (255, 255, 255), -1) # 画一个实心矩形 cv2.rectangle(img, (340, 60), (540, 240), (255, 255, 255), -1) # 画一个实心三角形 triangle np.array([[500, 380], [300, 400], [540, 450]], dtypenp.int32) cv2.fillPoly(img, [triangle], (255, 255, 255)) # 画一个“甜甜圈”白色大圆中间再挖掉一个黑色小圆 cv2.circle(img, (120, 400), 60, (255, 255, 255), -1) cv2.circle(img, (120, 400), 30, (0, 0, 0), -1) cv2.imwrite(shapes.png, img)这里白色部分是前景黑色是背景。其中甜甜圈这个图很关键它能帮你直观地看到RETR_TREE模式下父轮廓和子轮廓的区别——外圈是父里面的黑圈边界是子。4.2 完整链路代码灰度化、阈值化、找轮廓、绘制测试图生成之后直接进入正题# 读图 img cv2.imread(shapes.png) img_copy img.copy() # 灰度化 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊 (3x3就够因为这张图没什么噪点) blur cv2.GaussianBlur(gray, (3, 3), 0) # Otsu自动阈值二值化 _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 查找轮廓使用树形层级 简单近似 result cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if len(result) 3: _, contours, hierarchy result else: contours, hierarchy result print(f找到 {len(contours)} 条轮廓) # 把轮廓都画出来红色线宽2 cv2.drawContours(img_copy, contours, -1, (0, 0, 255), 2) cv2.imshow(Contours, img_copy) cv2.waitKey(0) cv2.destroyAllWindows()到这里你已经从一张测试图里拿到了轮廓并在原图上描出来了。这套代码是后面所有玩法的基础强烈建议你亲手跑一遍再换自己的图片试试。你会明显感觉到找轮廓这事在预处理得当的情况下是又快又稳的。4.3 运行效果与层级数据解读我跑上面这段代码时一共找到5条轮廓每条轮廓的含义是圆形外边界最外层轮廓矩形外边界最外层轮廓三角形外边界最外层轮廓甜甜圈的外圈边界是一个父轮廓甜甜圈中间黑洞的边界是外圈的子轮廓。打印hierarchy会发现子轮廓的Parent字段指向了父轮廓的索引而父轮廓的First_Child字段指向子轮廓。这就是RETR_TREE的意义它把嵌套结构完整地记录下来了。如果你改成RETR_EXTERNAL会发现中间黑洞的子轮廓直接没了只剩4条外轮廓数量上立刻就不一样了。建议你实际对比一次理解和记忆会深很多。5. 从“画出来”到“用起来”轮廓的几种高频进阶玩法5.1 拿面积和周长做筛选几行代码干掉一堆噪点拿到轮廓之后第一个实际需求往往是筛选把画面里一堆小噪点过滤掉只保留面积大于某个阈值的真正目标。这个操作极度高频我几乎在每一个轮廓相关的项目里都会用for cnt in contours: area cv2.contourArea(cnt) if area 1000: # 过滤掉面积小于1000像素的噪声 continue # 周长 peri cv2.arcLength(cnt, True) print(f面积: {area}, 周长: {peri})True这个参数表示轮廓是否闭合传True按闭合图形计算周长。contourArea算出来的是像素面积具体数值跟图像分辨率有关所以阈值建议根据你的实际图像尺寸去调不要照抄网上的数字。有个细节contourArea是根据格林公式算的对自交轮廓结果可能不准但绝大多数场景都是简单轮廓放心用。5.2 外接矩形与最小外接矩形定位目标、判断旋转如果要给目标画一个框用cv2.boundingRect直接得到正矩形x, y, w, h cv2.boundingRect(cnt) cv2.rectangle(img, (x, y), (x w, y h), (0, 255, 0), 2)正矩形的特点是它永远与图像坐标轴平行适合目标本身不旋转或者你只需要大概位置的场景。但如果目标本身是斜着放的正矩形会框进大片背景区域这时候就要用cv2.minAreaRect它会返回一个可以旋转的最小外接矩形rect cv2.minAreaRect(cnt) # 返回 ((cx, cy), (w, h), angle) box cv2.boxPoints(rect) # 得到四个顶点 box np.intp(box) # 转成整数坐标 cv2.polylines(img, [box], True, (0, 255, 255), 2)rect里的角度angle在不同版本的OpenCV里表示方式有过调整我看到过不少人在这个问题上踩坑。我的建议是不要依赖它的正负号来判方向要结合矩形的宽高对比和你的实际语义去解释。5.3 用多边形近似识别形状三角形、四边形还是圆轮廓代表形状但形状识别需要一个桥梁cv2.approxPolyDP。它能把一条轮廓近似的多边形顶点序列顶点个数少到一定程度时就成了识别形状的线索peri cv2.arcLength(cnt, True) approx cv2.approxPolyDP(cnt, 0.04 * peri, True) corners len(approx) if corners 3: shape Triangle elif corners 4: shape Quadrilateral else: shape Circle这里的0.04 * peri是近似精度精度值越小拟合出来的多边形越接近原轮廓。这个0.04是一个经验值图形越复杂、噪声越多越要调大一点否则会得到几十个顶点的“多边形”。实际使用时我习惯先打印corners慢慢调不闭着眼睛用固定值。有了形状标签之后配合面积筛选和位置信息一个简单的视觉分拣模块就搭起来了。5.4 用层级关系做目标分离只画指定层级的轮廓如果你的图像里存在明显的嵌套关系比如外框里套一个内框且你只需要内框的数据层次结构就能派上用场。hierarchy数组里的每一行和contours列表的索引是一一对应的。我经常会这样筛选“没有父轮廓”的最外层轮廓或者“有父轮廓”的内部嵌套轮廓for i, h in enumerate(hierarchy[0]): parent h[3] if parent ! -1: # 这个轮廓是某个轮廓的子轮廓 print(f轮廓 {i} 的父轮廓是 {parent}) cv2.drawContours(img, contours, i, (0, 255, 0), 2)这种写法在处理包含多个子区域的图表、表单、嵌套零件时很实用。比如识别一个表格里每一格的边界所有格子轮廓都有一个共同的父轮廓表格外框你就能把最外层外框单独过滤掉专注提取每个单元格。6. 避坑指南与问题排查实录这些坑我一踩就是半天6.1 返回值个数总是对不上版本差异必须分清这个问题我前面提过一次但因为它出现频率实在太高值得单独拉出来说。很多网上的老教程基于OpenCV 2.x写的contours, hierarchy cv2.findContours(...)拿到3.x或4.x上就报ValueError: not enough values to unpack。我至今还在一些技术群里看到新人被这个问题卡住。最省事的做法是像我前面那样写一个兼容判断一劳永逸。另外一种诡异情况是明明返回值个数对了hierarchy却是None。这通常发生在某些特定模式下没有层级信息时比如RETR_EXTERNAL返回的所有轮廓都没有父级虽然hierarchy正常存在但每行全是-1。特判一下就行。6.2 阈值总选不好固定阈值失灵的两类元凶固定阈值在均匀光照下很好用但换了环境就翻车。我踩过的坑主要是两类第一类目标和背景的灰度分布有重叠。比如深灰色的零件放在浅灰色的传送带上单靠阈值很难一刀切干净。这时候我会考虑先用边缘信息或其他手段强化目标而不是死磕阈值。第二类光照不均。图像一侧偏亮一侧偏暗固定阈值必然会在暗侧把背景误判成前景。这种图就老老实实改用自适应阈值或者先做一个背景扣除再做二值化。还有一个小技巧在使用Otsu之前如果图像本身是彩色或低对比度的先做cv2.equalizeHist直方图均衡化很多时候二值化效果会显著提升。6.3 轮廓画了但看不见颜色、线宽和图层的问题新手常问明明drawContours执行了画面却什么都没多出来。最多的情况是从灰度图转彩色图时用成了单通道的灰度图但你传进去的颜色却是BGR三通道OpenCV会报错或者画出来一团灰。解决方法是确保你绘制用的图像是BGR三通道图比如img cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)之后再画。还有人是把轮廓画在了纯黑或纯白的二值图上线的颜色又是黑色或白色自然看不见。绘制调试时我习惯用(0, 0, 255)红色画线线宽设2保证在任何背景下都能看清。不要为了省事把线宽设成1在图像里太容易被背景干扰视线。6.4 轮廓断断续续不成型多半是二值图的问题如果findContours找到的轮廓数量特别多而且每个都很短、不闭合那基本可以断定是二值图太“碎”了。最典型的场景是从彩色图里直接阈值分割出的是离散的白色小点没有连成区域。这时候形态学操作是关键补救手段kernel np.ones((5, 5), np.uint8) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)闭运算先膨胀再腐蚀能把断裂的小缝隙给“焊”起来让本来分开的区域连成一体。处理完再查轮廓效果立竿见影。需要提醒的是闭运算会让轮廓边缘变粗如果后面要做高精度几何测量要小心核大小别开太大(3, 3)和(5, 5)先用小的试。6.5 性能优化图大轮廓多的时候先缩小再做findContours虽然快但如果视频分辨率很高比如4K或者一帧里有几千上万条轮廓实时处理还是会有压力。我在做实时轮廓检测时一般先对图像做缩放处理完再把坐标按比例映射回原图这样效果差不多速度能快好几倍scale 0.5 small cv2.resize(img, None, fxscale, fyscale) # 在 small 上找轮廓 # 拿到坐标后乘上 1/scale 映射回原图这样操作下来面积和周长等几何量也要做相应换算面积乘以1/scale**2周长乘以1/scale。如果你只是做人机交互式工具而不是实时系统这步优化可以先不做但要心里有数。最后再说几句实在话这个功能我用了这么多年最大的体会是轮廓查找本身并不复杂复杂的是你喂给它的图。预处理做好了findContours能还你一份惊喜预处理偷懒了你会被各种莫名其妙的轮廓数量折磨到怀疑人生。所以遇到问题时先别急着调findContours的参数回头检查二值图是不是干净的。再分享一个小技巧调试轮廓的时候别光看画出来的效果多打印轮廓点数和面积把数据变化和图像变化对应起来看。你很快就能建立起一种直觉——看到一张图大致就能猜到阈值合不合适、该用哪种层级模式。这种直觉比死记一堆API参数值珍贵得多。