首页 / 资讯中心 / 文章详情

OpenCV轮廓提取:mode与hierarchy原理详解及避坑实战指南

OpenCV轮廓提取:mode与hierarchy原理详解及避坑实战指南 ★ FEATURED ARTICLE
最近有位做视觉检测的开发者跟我聊轮廓提取他用cv.findContours()统计一批零件数量阈值调了半天、形态学操作也加了好几轮结果数字还是不稳定。我让他把mode和返回值里的hierarchy打出来看看他愣了一下这两个参数他从来都是照抄网上的模板压根没细看过。这个问题太典型了。很多人把findContours()当成给我一堆轮廓点集的黑盒只关心contours这个列表却忽略了mode决定了你能拿到哪些轮廓hierarchy记录了这些轮廓之间的包含关系。说句实话轮廓数量不对、该找到的轮廓找不到、内孔被误判成独立目标八成以上都是这两个地方没吃透。这篇文章就从实际使用角度把mode和hierarchy的原理、差异、读取方式、应用场景和踩坑点一次说清楚。看懂之后你再遇到轮廓提取结果不符合直觉的情况至少知道该往哪个方向查。1. 先搞清楚findContours到底返回了什么contours只是轮廓列表hierarchy才是结构关系1.1 函数签名与两个容易被忽视的返回值cv.findContours()的签名很长但日常用得到的核心参数就三个contours, hierarchy cv.findContours(image, mode, method, offsetNone)image输入的二值图像要求前景为白色像素值不为 0背景为黑色像素值为 0。mode轮廓检索模式也就是这篇文章的主角。method轮廓近似方法常见的是cv.CHAIN_APPROX_NONE和cv.CHAIN_APPROX_SIMPLE前者保留全部点后者只保留拐点。返回值contours是一个列表列表里每个元素是一个(N, 1, 2)的数组表示一个轮廓上所有点的坐标。绝大多数教程到这里就结束了剩下一个返回值hierarchy基本被无视。hierarchy的官方名称叫层级结构它的形状很反直觉它是一个三维数组形状为(1, N, 4)其中N和contours里的轮廓数量一致。也就是说contours[i]对应hierarchy[0][i]。很多人第一次打印hierarchy的时候会被形状搞晕下意识觉得一个四元组怎么外面还套了一层括号。其实这只是 OpenCV 为了兼容多个轮廓链而保留的维度hierarchy[0]才是我们真正要操作的那一行。1.2 hierarchy中每个元素都是四元组 [next, prev, child, parent]hierarchy[0][i]是长度为 4 的整数数组四个位置的含义分别是位置含义若不存在0当前轮廓的同一层级下一条轮廓的索引-11当前轮廓的同一层级上一条轮廓的索引-12当前轮廓的第一个子轮廓索引-13当前轮廓的父轮廓索引-1这里要理解同一层级是什么意思。举个例子假设一张图里有两个互不相交的圆它们都在第一层那么这两个圆之间的 next/prev 就会互相指向对方。如果左边的圆内部还有一个内孔这个内孔就是左边圆的子轮廓层级比左边圆低一层这个内孔的父轮廓就是左边的圆。一句话概括next 和 prev 管的是平级兄弟child 和 parent 管的是跨级父子。它们共同组成一棵轮廓树树根是最外层轮廓越往里层级越深。很多新手以为hierarchy是轮廓的几何信息比如外接矩形、面积、周长实际上这些都要靠cv.boundingRect()、cv.contourArea()另外算。hierarchy的唯一用途就是告诉算法谁套着谁。1.3 让结果可视化用同一个测试图观察不同mode下的返回差异为了后面解释清楚先构造一个三层嵌套图像白色大背景里挖一个黑色方孔方孔里再放一个白色小方块。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.rectangle(img, (170, 170), (230, 230), 255, -1) contours, hierarchy cv.findContours(img, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE) print(轮廓数量:, len(contours)) print(hierarchy.shape:, hierarchy.shape) print(hierarchy[0])在RETR_TREE模式下这段代码会输出 3 个轮廓。打印出来的hierarchy[0]大致长这样[[ 1 -1 -1 -1] [ 2 0 2 0] [-1 1 -1 1]]含义是链路上的三个轮廓依次形成父子关系。第一个轮廓没有父轮廓是树根第二个轮廓的父轮廓是 0同时它自己又有一个子轮廓 2第三个轮廓的父轮廓是 1。这个输出本身就是一棵轮廓树的压缩表示。理解了这个基础结构下面再来看mode参数怎么影响这棵树。2. mode四兄弟逐个拆解四种模式在嵌套形状下的真实表现2.1 RETR_EXTERNAL只拿最外层边界适合数物体但会漏掉洞cv.RETR_EXTERNAL是最容易被误解的一种模式。它的字面意思是只检索最外侧轮廓实现结果就是内部的所有孔洞、嵌套轮廓统统不返回。沿用刚才的三层嵌套图像在RETR_EXTERNAL模式下contours, hierarchy cv.findContours(img, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE) print(len(contours)) # 输出 1最终只会得到最外层大方块的外边界黑色方孔和小白方块的边缘都不会出现。hierarchy里对应的那个轮廓parent和child都是 -1因为输出的本身就是最外层没有必要再挂父子关系。这个模式非常适合只数外部物体、不关心内部结构的场景。比如用相机统计传送带上的工件数量工件表面有没有凹槽、字符、通孔通常不影响计数用RETR_EXTERNAL最省心。但要注意如果你需要同时提取工件上的通孔边缘这个模式就不够用了它会把所有孔洞信息直接丢掉。2.2 RETR_LIST全部轮廓一视同仁hierarchy预留但没有父子关系cv.RETR_LIST的含义是所有轮廓都返回但不建立任何层级关系。也就是说孔洞轮廓和外部轮廓在结果里是平级的谁也不是谁的父或者子。还是用上面的测试图RETR_LIST模式下轮廓数量是 3。三个轮廓都会出现在contours里但hierarchy[0][i]中的child和parent基本都是 -1next/prev 则用来表示同一列表里的相邻关系相当于把三个轮廓串成一条链。这个模式适合什么情况当你只需要对每个轮廓独立计算面积、周长、外接矩形并且不关心它们之间的包含关系时RETR_LIST的开销比RETR_TREE小逻辑也简单。缺点是它会把内孔和外部目标一视同仁如果你想要焊盘外边界却把焊盘内孔也统计进去那结果就翻倍了。从实际经验看RETR_LIST用的频率不如RETR_EXTERNAL和RETR_TREE高。它更像一个中间过渡态想要全部轮廓又不想承担层级管理的额外成本选它合理但如果后续处理真的需要知道谁包含谁我们还是老老实实回到树结构。2.3 RETR_CCOMP一级外轮廓、一级内孔的两层模型cv.RETR_CCOMP把轮廓组织成最多两层第一层是外部边界第二层是内部孔洞的边界。这个设计非常契合一类工业需求——检查一个区域内是否存在孔洞、孔洞是否在目标内部。继续用三层嵌套图像验证RETR_CCOMP模式下 OpenCV 通常仍会返回 3 个轮廓但在层级关系上不再形成三等分。外层大方块的外边界是第一层大方块内部的黑色方孔被看作洞挂在第一层的子节点上而方孔里面的白色小方块从逻辑上已经不属于第三层嵌套OpenCV 把它当作与大方块同级的独立对象来处理。这个洞里套对象也重新算外层的特性让不少人在实际项目里困惑过。但如果你只是想区分外部目标和内部洞RETR_CCOMP是非常自然的模型。比如检查金属垫片垫片外轮廓是顶层中间通孔是第二层两者通过parent参数可以迅速建立对应关系。需要注意RETR_CCOMP在不同 OpenCV 版本上的边界行为历史上出现过一些细微差异。我的建议是不要凭记忆假设输出拿到自己的图像后先把hierarchy打印出来看一眼用真实数据确认层级关系再写筛选逻辑。2.4 RETR_TREE完整家谱信息量最大RETR_TREE是四种常用模式里信息量最全的它把所有轮廓之间的父子、祖先、后代关系完整保存下来。嵌套多少层它就能记录多少层没有任何截断和压缩。前面三层嵌套图像在RETR_TREE下会得到一条三层链路大方块外边界 → 大方块内孔黑色方孔边界→ 方孔内小白方块外边界。每一层的parent和child都指向正确的索引形成一个真正的轮廓树。正因为如此RETR_TREE是很多复杂图像处理任务的默认选择。比如分析 PCB 板上走线、焊盘、过孔的彼此包含关系或者处理带有嵌套图案的字符区域比如字母 O 中间还有独立图形只有完整层级结构才能准确还原它们的空间关系。它的代价是后续处理时轮廓数量更多、层级判断更繁琐而且你往往需要额外写一段递归或者循环去遍历这棵树。如果业务需求根本用不到嵌套信息选RETR_TREE属于给自己增加工作量。2.5 用表格对比四种模式模式返回轮廓范围父子层级树的深度典型使用场景RETR_EXTERNAL仅最外层轮廓无只有根节点计数、外部形状筛选RETR_LIST全部轮廓无父子关系无层级平铺独立轮廓特征统计RETR_CCOMP全部轮廓外层与内孔两层最多两层外部形状与孔洞配对RETR_TREE全部轮廓完整父子关系不限层数嵌套结构分析、轮廓树遍历除了这四个OpenCV 还有RETR_FLOODFILL但它应用场景比较特殊常规轮廓分析很少用这里不展开。日常项目中你基本就是在RETR_EXTERNAL、RETR_CCOMP和RETR_TREE之间做选择RETR_LIST偶尔用一下。3. hierarchy的实际读取如何用它筛选外轮廓、内孔和指定层级的轮廓3.1 通过parent过滤最外层轮廓hierarchy最直接的应用就是筛选出最外层轮廓。最外层轮廓没有父轮廓所以对应的parent等于 -1。outer_contours [] for i, cnt in enumerate(contours): parent hierarchy[0][i][3] if parent -1: outer_contours.append(cnt)这段代码在RETR_TREE模式下尤其有用。当你需要所有轮廓来确认信息但又只想处理外边界时不需要重新用RETR_EXTERNAL跑一遍直接对已有结果过滤即可。同理如果你想筛掉那些被别的轮廓包含的杂散小轮廓也可以基于parent -1做条件过滤。这是很多工业项目里清理噪声轮廓的常用手段。3.2 通过child找到包含内孔的轮廓first_child表示当前轮廓的第一个子轮廓索引。如果某个轮廓的child ! -1说明它内部至少包含一个孔洞或者嵌套对象。在RETR_CCOMP模式下外部轮廓和内部孔洞的配对关系正是通过 child/parent 建立的。假设我们要找哪些轮廓内部存在孔洞可以这么写contours_with_holes [] for i, cnt in enumerate(contours): first_child hierarchy[0][i][2] if first_child ! -1: contours_with_holes.append(cnt)这个需求在实际项目中很常见。比如某种垫片必须有中心孔如果检测时发现某个垫片轮廓没有子轮廓就说明它的孔可能被堵住了可以直接判为缺陷。还需要注意一点first_child只是第一个子轮廓一个父轮廓可能有多个子轮廓。如果想遍历某个父轮廓的所有直属子轮廓需要沿着子轮廓的next指针一条一条走current hierarchy[0][i][2] while current ! -1: print(子轮廓索引:, current) current hierarchy[0][current][0]这套父找子、子找兄弟的遍历方式本质上就是在一棵轮廓树上做广度优先遍历理解了 next/prev/child/parent 四元组含义之后写起来并不复杂。3.3 连续嵌套时的层级深度判断有时候只判断有没有父轮廓还不够你还需要知道一个轮廓嵌在第几层。比如检查多层嵌套的工件结构只有第二层和第三层才符合要求。可以递归向上统计父轮廓数量def get_depth(index, hierarchy): depth 0 parent hierarchy[0][index][3] while parent ! -1: depth 1 parent hierarchy[0][parent][3] return depth这个函数从任意轮廓出发不断找父轮廓直到查到 -1 为止返回的深度就是当前轮廓在整棵树中的层数。看起来简单但它是很多嵌套分析的基础。在 OpenCV 的实现里遍历hierarchy数组时一定不要忘记hierarchy[0]最外面那层索引。我见过不止一个同事在 Python 里写hierarchy[i][3]结果直接报维度错误就是因为少看了一层结构。3.4 一个可抄作业的小函数按层级筛选并绘制把上面几个方法整合一下可以写一个通用的按层级筛选 可视化工具函数def select_contours_by_hierarchy(contours, hierarchy, require_parentTrue): selected [] for i, cnt in enumerate(contours): parent hierarchy[0][i][3] if require_parent and parent -1: selected.append(cnt) elif not require_parent and parent ! -1: selected.append(cnt) return selected outer select_contours_by_hierarchy(contours, hierarchy, require_parentTrue) inner select_contours_by_hierarchy(contours, hierarchy, require_parentFalse) canvas cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) cv2.drawContours(canvas, outer, -1, (0, 0, 255), 2) cv2.drawContours(canvas, inner, -1, (0, 255, 0), 2)这样在调试阶段可以很直观地看到红色轮廓是没有父轮廓的外边界绿色轮廓是被包含的内层轮廓。先可视化再写正式逻辑比直接对着hierarchy数字猜要高效得多。4. 不同业务场景下mode到底怎么选从OCR字符框到缺陷检测的取舍4.1 OCR与文字区域提取为什么推荐从RETR_EXTERNAL开始做 OCR 或者文档区域提取的时候常见目标是把文本行或者字符块外边界拿出来。字符区域内部可能包含笔画镂空、内部圈、噪点这些细节如果也被当成独立轮廓返回会干扰外接矩形和文本行的拼接。举个例子英文字母O本身内部是一个孔洞区域。如果用RETR_TREE或者RETR_LIST你会同时拿到字母外部轮廓和内部孔洞轮廓而用RETR_EXTERNAL就只拿到一个外部包围轮廓直接用于cv.boundingRect()得到字符框。所以我的习惯是只要后续要做的是框选、裁剪、拼接先默认用RETR_EXTERNAL。如果发现某些字符因为断笔、闭合不完整导致轮廓缺失再去考虑RETR_CCOMP或RETR_TREE并配合内部关系过滤。这样处理的轮廓数少逻辑也干净。4.2 焊盘与通孔检测CCOMP的两层模型更贴合工艺规则在电路板视觉检测里一个焊盘中心往往有通孔。单纯数外轮廓用RETR_EXTERNAL就够了但如果你需要同时确认焊盘是否存在通孔是否存在通孔是否在焊盘内部RETR_CCOMP的两层模型就非常顺手。外轮廓是顶层通孔边界作为子轮廓挂在下面。判断逻辑变成如果一个外层轮廓没有子轮廓说明该焊盘可能没有通孔异常。如果一个内孔轮廓的父轮廓不存在说明这个孔不属于任何焊盘可能是多余的杂散孔。这种外层轮廓和孔洞一一配对的思维恰好就是RETR_CCOMP的设计初衷。你在RETR_TREE里写一堆递归来判断父级是否合理还不如直接用RETR_CCOMP一层 parent 判断来得省事。4.3 零件内部嵌套结构分析TREE是唯一靠谱选择如果图像里出现大结构套小结构、小结构又套更小结构的情况比如检查多层嵌套的机械零件、分析材料切片中的岛屿结构RETR_CCOMP的两层模型就不够用了因为第三层信息会被压缩。只有RETR_TREE能原原本本保存在嵌套层级。这时候轮廓树能回答很多问题最外层轮廓是谁它的直接子轮廓有几个某个深度为 2 的轮廓是否落在期望区域等等。配合前面写的get_depth()函数可以精确筛选任意层级的轮廓。不过要提醒一句RETR_TREE信息全不代表你每次都要把整棵树用完。实际项目里我通常会先打印完整hierarchy人工确认关系的规律再写有针对性的筛选条件。跳过这一步直接上递归遍历很容易把自己绕晕。4.4 关于性能补充一句很多初学者担心RETR_TREE是不是比RETR_EXTERNAL慢很多。就我的实测经验看findContours()本身的耗时主要花在图像扫描和边界追踪上mode对提取时间的影响远没有想象中大真正拉开差距的是轮廓数量以及后续特征计算量。换句话说RETR_TREE返回了一堆内孔轮廓你就得对每个轮廓做面积、外接矩形、坐标判断这部分消耗比函数本身大得多。如果产品只需要外边界用RETR_EXTERNAL能直接砍掉大量无用的后续计算系统整体帧率自然就上去了。5. 踩坑记录版本差异、二值化预处理和hierarchy索引错位5.1 OpenCV 3.x与4.x返回值解包差异这是一个非常典型的兼容性坑。OpenCV 4.x 中findContours()返回两个值即contours, hierarchy但更早的 OpenCV 3.x 返回三个值顺序是image, contours, hierarchy。如果你在网上找到一份老代码原样搬到新环境里很可能直接报too many values to unpack。最稳妥的写法是不要盲目解包先看自己环境版本result cv.findContours(img, cv.RETR_TREE, cv.CHAIN_APPROX_SIMPLE) if len(result) 2: contours, hierarchy result else: _, contours, hierarchy result这种写法在 3.x 和 4.x 上都能跑也省得切来切去。5.2 前景和背景颜色搞反mode效果全乱OpenCV 的findContours()默认在白色前景上找轮廓也就是说目标应该用非零像素表示。如果你的二值图像是白色背景、黑色物体那就不叫前景OpenCV 会把白色背景当成目标黑色物体当成孔洞结果完全反过来。我遇到过好几次类似问题算法返回的轮廓不是物体外边界而是整个图像外周加物体内边界。处理办法很简单cv.bitwise_not()翻转一下或者用cv.threshold()时把类型设成THRESH_BINARY_INV确保前景为白背景为黑。5.3 hierarchy[0]与contours索引错位排查hierarchy相关 bug 时最常见的提示是类似IndexError: index 7 is out of bounds。原因通常是直接对hierarchy取了某个索引却忘了它形状是(1, N, 4)索引必须写成hierarchy[0][i]。还有一个容易被忽略的点contours是 listhierarchy是 NumPy 数组。某些处理函数只接受 NumPy 数组你直接把 list 传进去就会报类型错误。整理成数组时注意保持轮廓数量和索引顺序一致否则会错位。5.4 mode不等于边缘连接预处理才是第一步最后强调一件很重要的事mode和hierarchy只负责组织轮廓并不负责像素层面的连通。如果目标因为光照、模糊、噪声导致边缘断裂findContours()会认为它是多个轮廓mode怎么换都救不回来。我在项目里处理类似问题时流程通常是先做灰度化和平滑再根据直方图做阈值分割必要时用cv.morphologyEx()做闭运算把断裂边缘接上最后再调findContours()。很多轮廓数量不稳定的情况根本不是mode选错而是二值图本身是碎的。所以当你面对轮廓异常先花时间看二值图长什么样再谈mode和hierarchy怎么配。这一步做好了后面才不会白折腾。6. 我现在的使用习惯调轮廓这块踩过不少坑之后我慢慢固定了一个习惯任何一张新图第一次跑findContours()永远先用RETR_TREE因为只有它能给出最完整的关系信息。拿到结果后打印hierarchy看一眼嵌套层数再回到需求里判断到底用哪个模式最合适。如果只是数一下有几个外部目标直接换RETR_EXTERNAL如果关心外部形状和内部孔洞的配对关系用RETR_CCOMP只有真正需要分析多层嵌套时才保留RETR_TREE。这个方法帮我省掉了大量反复调参的时间。最后再分享一个小技巧调试层级关系时别只盯着数字看把轮廓用不同颜色按parent关系画出来一眼就能看清谁套着谁。轮廓提取是个经验活把mode和hierarchy这两个参数吃透你遇到的很多怪问题都会变得有迹可循。
阅读完成 · 觉得有帮助?
咨询建站