资讯详情

OpenCV轮廓检测:findContours与drawContours的层级及maxLevel解析

📅 2026/10/17 11:00:35 | 华诺云谱 👁 阅读
OpenCV轮廓检测:findContours与drawContours的层级及maxLevel解析
写这篇内容的起因是后台有读者连着问了几个关于轮廓检测的问题问来问去都绕不开findContours和drawContours这两个函数。特别是“轮廓提取的两种方法”和“绘制时最大等级maxLevel到底怎么控制”这两块很多人对着官方文档依然一头雾水。我在项目里用OpenCV做形状分析和缺陷检测也有几年了这两个函数的坑基本都踩过一遍干脆把轮廓检测这套东西从头到尾捋一遍把关键细节和底层逻辑讲透。这篇文章不端着就是实打实的工程经验分享适合刚接触OpenCV图像处理的新手也适合用了很久但对hierarchy层级关系始终模模糊糊的老手。1. 轮廓检测到底是什么findContours又在其中扮演什么角色1.1 从“找边缘”到“找轮廓”的思维转换很多人刚学OpenCV时会把边缘检测Canny、Sobel和轮廓检测findContours搞混以为这俩是同一个东西。其实差别非常大。边缘检测输出的是像素级别的“哪些点属于边界”信息是离散的、零散的而轮廓检测输出的是“一条完整的、有序的边界点集”它把这些边界点串成了一条条闭合或非闭合的曲线并且能告诉你这些曲线之间的嵌套关系。我用一个生活化的例子解释如果说边缘检测是把一幅图的“骨架”草图勾出来那么轮廓检测就是把这些草图线条整理成一条条有名字、有编号、有上下级关系的“路径”。在后期的目标识别里你往往不关心某个像素点是不是边界你关心的是“图像里有没有一个圆形区域”“这个圆形里面还套着一个小方块”这类结构性信息这恰恰是轮廓检测擅长的事。1.2 findContours的输入输出版本差异是先要避开的坑findContours函数的经典调用在OpenCV 4.x的Python接口下是这样contours, hierarchy cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)注意在OpenCV 3.x之后它返回两个值contours轮廓列表和hierarchy层级关系。但如果你在网上翻到老教程会看到有人写image, contours, hierarchy cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)这是OpenCV 2.x时代的老接口当时返回三个值第一个是修改后的原图。OpenCV 3.x之后这个函数会直接修改输入图像所以不再返回图像本身。我第一次在项目里切换版本时就因为这行代码报错“无法解包”排查了半天才发现是版本差异导致的。这个点虽然小但足够坑掉一批刚入门的同学。再强调一点findContours处理的输入图像必须是8位单通道二值图。二进制图上只有“前景”和“背景”两种状态轮廓检测的本质是基于连通域的拓扑分析。如果你拿一张彩色图或者灰度渐变图直接传进去出来的结果会非常诡异。官方文档说“可以使用Canny边缘检测的结果作为输入”但更稳妥的做法是先用阈值threshold或自适应阈值把图像彻底二值化。1.3 轮廓检测的核心价值与典型应用场景轮廓检测在真实项目里出镜率极高。我做过的工业检测项目里用它定位零件位置、统计工件数量、判断产品是否有缺损做过的一个医疗图像分割Demo里用它抠出器官区域的边界还有OCR前处理用它做文字区域的定位与笔画拆分。轮廓检测之所以成为图像处理的高频操作根本原因是它把“像素矩阵”变成了“几何对象”。一旦你有了轮廓你就可以计算面积、周长、凸包、外接矩形、最小外接圆、形状匹配等等后续所有几何分析都建立在这个基础上。2. 轮廓提取的两种核心方法只取外轮廓与全量层级提取2.1 方法一RETR_EXTERNAL只要最外面的那层壳先讲第一种方法也是理解起来最容易的cv2.RETR_EXTERNAL。这个模式的中文官方名称叫“只提取最外层轮廓”。它做的事情是扫描二值图时只把每个连通域的最外边界提取出来内部的孔洞、嵌套形状一律无视。举例说明。假设图像里有一个“回”字——外面一个大矩形里面还有一个小矩形。用RETR_EXTERNAL提取时你只会得到外面那个大矩形的轮廓里面那个小矩形不会出现。再比如一个圆环用RETR_EXTERNAL时只得到一个外圆轮廓内圆的边界不会被提取。这段代码可以直观看到效果import cv2 import numpy as np # 构造一张包含嵌套形状的测试图背景黑前景白 img np.zeros((400, 400), dtypenp.uint8) cv2.rectangle(img, (50, 50), (350, 350), 255, -1) # 外层大矩形 cv2.rectangle(img, (120, 120), (280, 280), 0, -1) # 内部挖空 cv2.circle(img, (200, 200), 50, 255, -1) # 中间实心圆 contours, hierarchy cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) print(f轮廓数量: {len(contours)})这段代码最终打印出的轮廓数量通常是 1。因为RETR_EXTERNAL只关心最外层的连通域边界。整张图里最大的外边界就是那个大矩形的外沿中间那个实心圆是嵌在黑色“挖空”区域里的独立白色连通域它本身并没有被任何白色轮廓包围所以它作为独立白色对象在最外层判断时又会多出一个轮廓……这里其实有个容易理解偏的细节连通域的判断是看“前景像素白色的连通情况”中圆的白色连通域确实和外矩形白边不连通所以它会作为独立对象被提取。为了测试纯粹上面的图其实会得到两个轮廓大矩形外框 中间圆。我建议读者自己跑一下亲眼看看结果比记文档管用。适用场景非常明确当你不关心物体内部结构只需要“图里有多少个目标”或者“每个目标的整体边界在哪”时就用RETR_EXTERNAL。比如统计一箱螺丝的数量、定位零件的大致区域这类场景下它又简单又高效因为提取出的轮廓数量少后续处理压力小。2.2 方法二RETR_TREE完整提取并建立父子等级关系第二种核心方法就是cv2.RETR_TREE它会提取图像中所有轮廓并且完整建立轮廓之间的嵌套等级关系。官方文档管这个叫“提取所有轮廓并重建轮廓层级”。前面那个“回”字图用RETR_TREE时会得到两个轮廓外矩形和内矩形并且hierarchy会告诉你内矩形是外矩形的“子轮廓”。RETR_TREE是所有检索模式里信息量最全的。它把“谁包含谁”这种拓扑关系完整记录下来形成一棵多叉树。这棵树就是后面讲绘制等级分析的基础。我实际项目里用到RETR_TREE最多的是字符识别和电路板检测。比如识别印刷体字母“O”时外轮廓是字母的外部笔画边界内轮廓是中间的空白区域两者是父子关系。如果你想知道“这个字母是不是O”单看外部轮廓形状还不够必须结合内部孔洞的有无来判别这时就必须用RETR_TREE把内外关系拿全。同样用一段代码演示contours, hierarchy cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) print(f轮廓数量: {len(contours)}) for i, h in enumerate(hierarchy[0]): print(f轮廓 {i}: Next{h[0]}, Previous{h[1]}, First_Child{h[2]}, Parent{h[3]})这里hierarchy是一个(1, N, 4)的数组N等于轮廓总数。每一行代表一个轮廓的四个连接关系Next同一层级的下一个轮廓、Previous同一层级的上一个轮廓、First_Child第一个子轮廓、Parent父轮廓索引。所有索引都是轮廓在contours列表里的下标没有对应关系时值为-1。2.3 三种检索模式横向对比别只停留在“知道”层面虽然标题说“两种方法”但OpenCV里一共提供了四种检索模式RETR_EXTERNAL、RETR_LIST、RETR_CCOMP、RETR_TREE。很多人学完EXTERNAL和TREE就以为够了其实LIST和CCOMP在特定场景下非常好用把它们一起对比能帮你更深刻地理解前面两种的定位。检索模式提取范围层级关系典型场景RETR_EXTERNAL只提取最外层轮廓无层级或只有根层目标计数、整体区域定位RETR_LIST提取所有轮廓无层级所有轮廓平级只需要所有边界、不关心包含关系时RETR_CCOMP提取所有轮廓两级组织外层轮廓内层孔洞轮廓需要区分“实体边界”和“孔洞边界”的场景RETR_TREE提取所有轮廓完整树状层级父子关系明确字符识别、嵌套结构分析、复杂缺陷检测再补一句我踩过的坑RETR_EXTERNAL虽然号称“只提取最外层”但前面那种“外矩形内部独立连通域”的情况它也会提取出内部那个独立连通域的轮廓因为内部那个圆在拓扑上并不属于“内部孔洞”而是一个独立的外层对象。真正的“内部孔洞”是指被白色前景包围的黑色区域这种区域在RETR_EXTERNAL下才绝对不会出现在结果里。理解这一点你就不会再对“为什么外部模式还提取出了多个轮廓”感到困惑。3. 绘制轮廓的maxLevel等级分析hierarchy才是真正的主角3.1 drawContours的基本用法轮廓提取出来不是终点绝大多数场景下你都要把它画出来看看效果确认算法有没有按预期工作。这时用的就是drawContours函数。canvas np.zeros_like(img) canvas cv2.cvtColor(canvas, cv2.COLOR_GRAY2BGR) cv2.drawContours(canvas, contours, -1, (0, 0, 255), 2)第三个参数contourIdx如果传-1就会绘制全部轮廓如果传具体的索引值就只绘制那一条。颜色和线宽按需指定。这一切看起来很简单但真正让人懵的是第五个参数maxLevel——官方文档的解释是“绘制轮廓的最大层级”概念抽象不看源码很难体会它到底控制了什么。3.2 maxLevel参数到底是什么它控制的不是“数量”而是“深度”直接说结论maxLevel控制的是绘制轮廓时从某个根轮廓出发向下追溯子轮廓的深度。这里“深度”指的是层级树上的纵向关系而不是轮廓的数量。默认情况下maxLevel0表示只绘制指定轮廓本身不绘制它的子轮廓、孙子轮廓。maxLevel1表示绘制指定轮廓及其直接子轮廓。maxLevel2表示绘制轮廓、子轮廓、子轮廓的子轮廓以此类推。为了彻底理解我建议你用嵌套三层以上的图形做实验。比如构造一个大矩形里面套一个中矩形中矩形里面再套一个小圆。用RETR_TREE提取后你会得到三个轮廓层级关系是大矩形level 0→ 中矩形level 1→ 小圆level 2。然后分别用不同maxLevel值绘制效果差异一目了然# 单独绘制索引为0的大矩形轮廓看看不同maxLevel的影响 cv2.drawContours(canvas, contours, 0, (0, 0, 255), 2, cv2.LINE_8, hierarchy, 0) # 只画大矩形 cv2.drawContours(canvas, contours, 0, (0, 0, 255), 2, cv2.LINE_8, hierarchy, 1) # 画大矩形中矩形 cv2.drawContours(canvas, contours, 0, (0, 0, 255), 2, cv2.LINE_8, hierarchy, 2) # 三层全画这就是标题里“最大等级分析”的实际操作内核。你可以把hierarchy想象成家族谱maxLevel就是你往下一代查几层。maxLevel越大绘制出的嵌套结构越完整但它只沿“亲子链”向下走不会横向绘制同一层级的兄弟轮廓。3.3 顺着hierarchy手工计算“最大等级”很多人画了半天图还是说不清自己的轮廓树到底有多深。项目里我们经常需要知道“当前这棵轮廓树的最大等级/最大深度”这需要自己写一段遍历逻辑。轮廓树的最大等级其实就是从根节点出发一路沿着First_Child走下去能走几层。定义个递归函数很快就解决def get_max_level(hierarchy, contour_idx): child hierarchy[0][contour_idx][2] if child -1: return 0 return 1 get_max_level(hierarchy, child) # 找所有根轮廓Parent -1分别计算每个根往下最深有几层 max_level_global 0 for i, h in enumerate(hierarchy[0]): if h[3] -1: # 是根轮廓 depth get_max_level(hierarchy, i) max_level_global max(max_level_global, depth) print(f根轮廓 {i} 向下最大等级: {depth}) print(f整张图的轮廓最大等级: {max_level_global})这个代码的逻辑是每个轮廓的hierarchy四元组第三项是First_Child顺着它一路往下钻每钻一层计数加1直到没有子轮廓为止。这其实就是你在drawContours里设置maxLevel时OpenCV内部做的事情只不过OpenCV把它封装进了参数里。自己写一遍这个递归你才算真正理解了hierarchy的结构。3.4 只有TREE和CCOMP才有真正的“等级”EXTERNAL下maxLevel基本是摆设这里要强调一个很容易搞混的点如果你用的是RETR_EXTERNAL模式提取轮廓那么hierarchy里所有轮廓的Parent都是-1First_Child也都是-1——因为根本没有层级关系。这时候你就算把maxLevel设为100drawContours也只会绘制出所有轮廓的平级集合不会出现任何“嵌套展开”的效果。RETR_CCOMP模式只建立两级层级外层是父内层孔洞是子所以它的最大等级天然就是1。只有在RETR_TREE模式下等级深度才不受限能够完整反映所有嵌套关系。这个特性在选型时要想清楚如果你的后续逻辑依赖“有多深的嵌套”必须用RETR_TREE。4. 完整实操过程从测试图到最大等级分析一次跑通4.1 构造一张带多层嵌套的测试图先动手造一张包含不同嵌套层级的图像这样后面跑起来效果直观。造图本身就是一件有讲究的事它要能同时体现“兄弟轮廓”和“父子轮廓”两种关系。import cv2 import numpy as np # 创建黑色画布 img np.zeros((500, 500), dtypenp.uint8) # 第0层大外框 cv2.rectangle(img, (20, 20), (480, 480), 255, 2) # 第1层内部矩形 cv2.rectangle(img, (80, 80), (420, 420), 255, 2) # 第2层圆 cv2.circle(img, (250, 250), 120, 255, 2) # 第2层的兄弟另一个圆 cv2.circle(img, (250, 250), 60, 255, 2) # 第3层内部小三角实际用多边形近似 pts np.array([[220, 220], [280, 220], [250, 270]], dtypenp.int32) cv2.polylines(img, [pts], True, 255, 2)注意这里我用的是线宽为2的“空心”图形刻意让所有元素都成为独立轮廓。最外层矩形是level 0第一层嵌套的矩形是level 1两个圆是level 2而且它们互为兄弟三角形是level 3。这样的结构能充分解释什么是最大等级。4.2 用RETR_TREE提取并绘制各层级轮廓提取并打印层级关系contours, hierarchy cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) print(f检测到轮廓数量: {len(contours)}) for i, h in enumerate(hierarchy[0]): print(f轮廓{i}: Next{h[0]}, Prev{h[1]}, Child{h[2]}, Parent{h[3]})输出会清楚显示哪些轮廓有父子关系、哪些是兄弟。把轮廓画到彩色画布上区分颜色显示不同索引canvas cv2.cvtColor(np.zeros_like(img), cv2.COLOR_GRAY2BGR) colors [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255)] for idx, c in enumerate(contours): cv2.drawContours(canvas, contours, idx, colors[idx % 4], 2, cv2.LINE_8, hierarchy, 0) cv2.imshow(contours by index, canvas) cv2.waitKey(0)这段代码里drawContours的maxLevel设为0所以每条轮廓只画自己不画子孙。你会看到四条轮廓分别用不同颜色标出来这样能清楚定位每条轮廓在树中的位置。4.3 演示maxLevel从0到3的绘制变化接下来是重头戏同一个画布分别用maxLevel0, 1, 2, 3绘制索引0的轮廓观察展开范围for level in range(4): canvas cv2.cvtColor(np.zeros_like(img), cv2.COLOR_GRAY2BGR) cv2.drawContours(canvas, contours, 0, (0, 0, 255), 2, cv2.LINE_8, hierarchy, level) cv2.imshow(fmaxLevel{level}, canvas) cv2.waitKey(0)maxLevel0只画最外层的矩形maxLevel1画最外层矩形 内部那个嵌套矩形maxLevel2再往下一层把两个圆也画出来maxLevel3继续往下三角形也画出来。有人会问“为什么maxLevel2时两个圆都画出来了”因为第一个子轮廓会被追踪并且通过子轮廓的Next指针横向扫描同一个层级的兄弟轮廓。所以maxLevel控制的是纵向深度但每一层内部会把所有兄弟节点一并画出。这个细节特别容易让人误判输出结果。4.4 对比RETR_EXTERNAL模式下同样的绘制操作为了加深印象把同一个测试图用RETR_EXTERNAL提取后再绘制对比会非常强烈contours_ext, hierarchy_ext cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) print(fEXTERNAL模式轮廓数量: {len(contours_ext)}) canvas cv2.cvtColor(np.zeros_like(img), cv2.COLOR_GRAY2BGR) cv2.drawContours(canvas, contours_ext, -1, (0, 0, 255), 2, cv2.LINE_8, hierarchy_ext, 3) cv2.imshow(EXTERNAL with maxLevel3, canvas) cv2.waitKey(0)由于没有嵌套的“孔洞”结构RETR_EXTERNAL模式下提取出的轮廓就是每个独立白色图形的外边界。maxLevel3对它来说毫无意义——没有子轮廓可以展开。这个对比实验能帮你建立清晰的直觉有没有层级关系才是决定maxLevel是否有用的前提。5. 常见问题与排查技巧实录5.1 为什么findContours报错不是2D数组或不是CV_8U这个报错我见得太多了。绝大多数原因是输入图像是彩色图或者图像通道数不对。解决办法是在调用前先转成灰度再二值化gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, binary cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) contours, hierarchy cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)还有一个隐蔽的情况某些深度学习框架输出的mask图正常是0/255但偶尔混进0/1虽然看起来形状一样findContours对值是0还是255其实并不敏感它只区分零和非零但画出来时因为颜色值接近0导致几乎看不见。稳妥做法是统一归一化到0/255。5.2 为什么提取出的轮廓比预期多很多这大概率是因为二值化后的图像存在大量噪点或细小毛刺。findContours会把每一个连通域都当作一个轮廓所以一个“肉眼看着很干净”的图像在像素层面可能有几十个微小空洞。我的处理习惯是在findContours之前先做一次形态学操作比如cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)去噪如果提取完发现还有离散小轮廓再用面积筛选把它们过滤掉filtered [] for c in contours: area cv2.contourArea(c) if area 100: # 阈值按实际图片调整 filtered.append(c)5.3 为什么drawContours画出来的轮廓不闭合或断断续续轮廓点顺序问题、或者图像边缘被截断是常见原因。findContours提取的轮廓本身是闭合的绘制时出现断裂多数是显示问题——比如线宽太窄、二值化边界太细。把线宽调成2或3再看看。另外一个非常重要的细节findContours会修改传入的二值图OpenCV 3.x起是in-place操作如果你后面还要用到原始二值图像必须先复制一份binary_copy binary.copy() contours, hierarchy cv2.findContours(binary_copy, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)这个坑很多人踩过而且踩完还莫名其妙以为提取轮廓影响了原图实际上就是官方实现里为了节省内存直接改成原地操作了。5.4 为什么hierarchy[0][i]访问报越界或结果混乱hierarchy的形状严格来说是(1, N, 4)所以访问单条轮廓必须写hierarchy[0][i]。如果你直接写hierarchy[i]拿到的不是一个包含四元素的一维数组而是整行数据视觉上看不出来逻辑上一处理就乱。如果你习惯用C接口hierarchy是vectorVec4i直接用hierarchy[i]访问没问题。两种语言接口的索引方式不同这也是跨语言查阅资料时容易混乱的一个点。5.5 经验之谈项目里到底怎么选轮廓模式和maxLevel我一直强调一个原则能用RETR_EXTERNAL解决问题就不用RETR_TREE。因为RETR_TREE信息量虽全但后续处理时你必须面对大量无用的内部轮廓计算量成倍增加过滤逻辑也变复杂。只有当业务本身依赖结构嵌套关系时才上RETR_TREE。maxLevel参数在实际绘制和调试里非常有价值。我之前做一个形状分析的中间结果可视化就靠调整它逐层展示轮廓树一下子把嵌套逻辑跟同事讲明白了。但要注意的是maxLevel并不能帮你筛选“哪些轮廓要画”它只负责“画多深”。你要是想只画某一条轮廓和它的子孙正确的用法是contourIdx指定那条轮廓的索引同时maxLevel指定下探深度。6. 两个容易被忽略的技术细节补充写到这里还想补充两个偏实战的细节它们不常出现在教程里但遇到的时候能救命。第一个是CHAIN_APPROX_SIMPLE和CHAIN_APPROX_NONE的差别。CHAIN_APPROX_NONE会存储轮廓的所有边界点一个都不删CHAIN_APPROX_SIMPLE会压缩水平、垂直和对角方向的冗余点只保留端点。直观来看同样一个矩形NONE模式可能有上千个点SIMPLE模式只有四个点。处理复杂轮廓时SIMPLE能显著降低存储和计算压力而且它不影响轮廓形状的还原。绝大多数项目默认用SIMPLE就够了只有当你要精确分析轮廓点的密度分布时才需要NONE。第二个是关于轮廓的方向。OpenCV轮廓点默认是顺时针排列还是逆时针排列取决于图像的坐标系和具体的参数配置。很多算法对轮廓点的方向敏感比如计算多边形面积的正负号、判断点在轮廓内外等等。如果你发现结果和自己预期的符号对不上多半是轮廓方向导致的。处理这种问题要么显式调用cv2.contourArea检查正负要么用cv2.isContourConvex辅助判断再统一做方向矫正。这些细节单看都不难难的是把它们串起来用。轮廓检测作为OpenCV里的基础能力本身不复杂但真正能把它用得利索的人都是把hierarchy、检索模式、层级深度这些“隐藏参数”吃透了的人。希望这篇分享能帮你把这个模块彻底打通。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑