做图像预处理这行的朋友大概都绕不开这么一件事:手上有一张二值图,要把里面一个个目标的边界拎出来,再把它画回原图上看效果。这套动作在 OpenCV 里对应两个函数,findContours负责找轮廓,drawContours负责画轮廓。看着就两个 API,参数加起来不到十个,但真到项目里落地,坑能从预处理一路排到结果输出。我见过不少人卡在“为什么轮廓重复了一圈”“为什么边界上的物体轮廓不闭合”“为什么 3.x 的代码搬到 4.x 直接报错解包异常”这类问题上,一卡就是半天。
这篇内容就是围绕 OpenCV 找轮廓、画轮廓这条主线展开的。从二值图该怎么做、检索模式和近似方法怎么选、轮廓特征怎么计算和筛选,到 drawContours 的填充逻辑、整套流水线的参数基线,再到实际踩过的坑和排查速查表,都会讲到。适合刚接触 OpenCV 想快速上手的人,也适合已经能跑通但结果不稳定、想搞清楚每一行代码在干什么的人。文中所有代码都基于 Python 接口,参数和逻辑对 C++ 版本同样适用,只是语法要换一下。
1. 先把 findContours 到底在找什么说清楚
1.1 边缘和轮廓不是一回事
很多人第一次用会把 Canny 检测出来的边缘直接丢给 findContours,然后发现轮廓数量比预期多一倍,或者出现一条一条断开的碎线。原因在于这两件事在数学定义上就不一样。
Canny 这类算子做的是梯度极值检测,它输出的是一条“带状”的边界线,一条粗线的两侧各有一条边缘,所以一个矩形物体经过 Canny 之后,你会得到内外两圈近似平行的线,再加上边缘可能断裂,findContours 就会把这些线当成多个独立的轮廓对象。而 findContours 期望的输入是“区域”的边界,它沿着值为非零的像素块走一圈,把这一圈的外沿或者内沿的坐标串成点集。
这个差别直接决定了预处理路线。如果只是想知道有几个目标、每个目标多大、外接矩形在哪,走阈值分割加形态学的路子更稳;如果确实需要亚像素级别的边缘位置,那就得先接受 Canny 的双边问题,用闭运算把两条线合起来,或者干脆只在 Canny 结果上做轮廓统计而不做面积计算。
我给的建议很直接:面积、周长、外接框这类几何量,一定在阈值二值图上算;边缘位置精度要求高的场景,才考虑 Canny,并且要做好后续的去重。混着用是绝大多数“轮廓数量不对”问题的根源。
还有一点容易被忽略:findContours 只接受单通道 8 位图,彩色图直接传进去会抛异常。有些人图省事直接传原图,报错信息又不够直观,来回试很久。养成习惯,进函数之前先断言一下binary.ndim == 2 and binary.dtype == np.uint8,能省掉大量无意义的调试时间。
1.2 二值图的质量决定了后面所有环节的上限
轮廓检测本质上是连通区域边界的追踪,所以二值图里每一个孤立的噪点都会变成一个面积很小但确实存在的轮廓。我在一个分拣设备的项目里遇到过这种情况:光源有一处反光,二值图上多了十几个 1 到 3 个像素的白点,findContours 返回的轮廓数量从 8 个直接变成 30 多个,后面的排序逻辑全部错位。
处理办法有两层。第一层是预处理阶段做形态学开运算,用 3×3 的矩形核跑一次cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel),小于核尺寸的孤立点会被直接抹掉。这里强调一下,核尺寸别一上来就用 5×5 或者更大,开运算的腐蚀步骤会把细长目标的端部啃掉一截,面积和周长都会偏小。先在 3×3 上试,效果不够再往上加,每加一档都要重新核对目标尺寸有没有变化。
第二层是结果阶段做面积过滤。这是必备动作,不是可选项。就算预处理做得很干净,图像边缘也常常会因为镜头渐晕产生零星的白斑。
import cv2 import numpy as np img = cv2.imread('demo.png') gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) found = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours, hierarchy = found if len(found) == 2 else found[1:] MIN_AREA = 200 contours = [c for c in contours if cv2.contourArea(c) >= MIN_AREA]这段代码里的MIN_AREA不是拍脑袋定的。我的习惯是先跑一遍不做过滤的版本,把所有轮廓面积打印出来排序,观察“噪点面积”和“真实目标面积”之间有没有明显的数量级断层。有断层,阈值取在断层中间;没断层,说明预处理还不够,回去继续调光照或者换分割方法,不要在阈值上硬凑。
1.3 三行代码的版本兼容,别在这上面栽跟头
OpenCV 3.x 和 4.x 在 findContours 的返回值上做了不兼容的改动。3.x 返回三个值(image, contours, hierarchy),4.x 返回两个值(contours, hierarchy)。写得死一点就是:
# OpenCV 4.x contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # OpenCV 3.x _, contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)如果代码要在不同机器上跑,用长度判断做兼容最省事:
found = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours, hierarchy = found if len(found) == 2 else found[1:]还有一个隐蔽的差异:3.x 版本里 findContours会修改输入的图像,4.x 不会。如果你的下游还要用同一张二值图做别的处理,在 3.x 环境下必须传binary.copy(),否则拿到的图已经被涂改过了。这个坑非常难受,因为报错不会发生,只是结果莫名其妙地不对。
顺便提醒,hierarchy 的返回形状是(1, N, 4)而不是(N, 4),索引的时候要用hierarchy[0][i]。N 为 0 的时候有些版本会返回 None,取用之前判空一次。
2. 检索模式与近似方法:四个参数里最关键的两个
2.1 RETR_EXTERNAL / LIST / CCOMP / TREE 怎么选
findContours 的第二个参数叫检索模式,它决定函数要不要额外建立起轮廓之间的父子关系。四种模式的区别,用一张带孔洞的零件图来打比方会很好理解。
| 检索模式 | 返回什么轮廓 | 建不建父子关系 | 典型场景 |
|---|---|---|---|
| RETR_EXTERNAL | 只返回最外层轮廓 | 不建 | 目标计数、外接框定位 |
| RETR_LIST | 返回全部轮廓 | 不建,全部平铺 | 只关心几何量,不关心层级 |
| RETR_CCOMP | 全部轮廓,组织成两层 | 外层 + 内层孔洞 | 需要区分实体和孔洞 |
| RETR_TREE | 全部轮廓,完整树形 | 完整嵌套关系 | 形状分析、复杂零件 |
RETR_EXTERNAL是日常用得最多的。做目标计数、求外接矩形、算总面积,只要最外层就够,返回的轮廓数量少,后续遍历也快。它有个附带的好处:如果目标内部有噪点或者小孔,这些都会被自动忽略掉,天然带一层去噪效果。
RETR_LIST把内层轮廓也吐出来。有个细节值得注意,在 LIST 模式下 hierarchy 里除了首尾,next 和 prev 是串起来的,但 parent 和 child 字段全是 -1。所以拿到 LIST 的结果不要试图去查父子关系,查了也查不到。
RETR_TREE是我在做形状分析时的首选。比如要判断一个零件上有几个通孔,用 TREE 模式拿到完整层级,然后遍历 hierarchy 找 parent 不等于 -1 的那些,就是内层轮廓。
hier = hierarchy[0] holes = [] for i, cnt in enumerate(contours): if hier[i][3] != -1: # 有父轮廓,说明是内层 holes.append(cnt) print('孔洞数量:', len(holes))需要提醒的是,TREE 模式下轮廓总数会明显变多,一个带纹理的表面可能返回几百个轮廓,遍历和面积计算的开销要提前评估。我在一个 4000×3000 的图上用 TREE 模式跑过,比 EXTERNAL 慢了将近三倍,最后是通过先裁剪 ROI 才把耗时压下来的。
2.2 CHAIN_APPROX_NONE 和 SIMPLE 的差距比你想的大
第三个参数控制轮廓点集的存储方式,只有三个取值,但影响的不只是内存。
CHAIN_APPROX_NONE保存边界上的每一个像素坐标。一个直径 500 像素的圆,点集大概有 1500 个点,numpy 数组占几十 KB,几百个轮廓下来内存就起来了。它的好处是点集完整,做精确的边界分析、算曲率、做亚像素拟合时不会丢信息。
CHAIN_APPROX_SIMPLE会把水平、垂直、对角方向上连续的点压缩掉,只保留拐点。同样那个圆,压缩后可能只剩几十个点。绝大多数场景,包括算面积、算周长、求外接框、画轮廓,用 SIMPLE 完全够用,因为这些计算依赖的是多边形的顶点序列,不是逐像素的稠密点。
CHAIN_APPROX_TC89_L1和TC89_KCOS用的是 Teh-Chin 链逼近算法,属于更激进的折线简化,一般用在需要极简描述子的场合。实际项目里我用得很少,因为简化程度不好控制,容易把本该保留的细节抹掉。
选型的判断标准很简单:只要轮廓是拿去画图、算面积、算外接框、做匹配模板,一律用 SIMPLE;只有当你要做逐点的法向量、曲率或者亚像素边缘拟合时,才切到 NONE。我自己的项目里 95% 的调用都是 SIMPLE。
有一个隐藏的坑:SIMPLE 模式对矩形物体返回的是 4 个点,但对带圆角的矩形可能返回 8 个甚至更多。如果有人写死了len(cnt) == 4来判断是不是矩形,一旦遇到圆角就会判断失败。判断形状应该用多边形逼近加顶点数,而不是直接数轮廓点数。
2.3 点集格式与 numpy 形状的那些事
findContours 返回的 contours 是一个 Python list,每个元素是(N, 1, 2)形状的 int32 ndarray。多出来的那个维度经常让新手困惑,cnt.reshape(-1, 2)转成(N, 2)之后就好处理了。
for cnt in contours: pts = cnt.reshape(-1, 2) x_min, y_min = pts.min(axis=0) x_max, y_max = pts.max(axis=0)注意 dtype 是 int32,不是 float。做插值或者坐标变换的时候记得先astype(np.float32),否则小数部分会被静默截断。我有一次做旋转校正,直接用 int32 的点集算变换矩阵,结果精度差了将近一个像素,排查了很久才定位到 dtype 上。
轮廓为空的情况也要处理。findContours 在完全黑的二值图上返回的是空 tuple 或者(contours=None, hierarchy=None),具体行为跟版本有关。稳妥的做法是在函数入口先判断if not np.any(binary): return [],直接短路掉,避免后面遍历 None 报错。
3. 从轮廓到可用数据:特征计算与筛选
3.1 面积和周长:先做尺度归一化再设阈值
cv2.contourArea(cnt)算的是轮廓围成的多边形面积,用格林公式对顶点序列做积分。它返回的是浮点数,单位是像素平方。cv2.arcLength(cnt, True)算周长,第二个参数 closed 表示轮廓是否闭合,对 findContours 的结果永远传 True。
这两个值看起来简单,但直接拿绝对数值做阈值是最容易出问题的写法。同一套代码换一个分辨率、换一个镜头距离,原来的阈值就全废了。我的做法是按图像面积做归一化:
h, w = binary.shape[:2] img_area = h * w MIN_RATIO = 0.0005 # 占总面积 0.05% 以下视为噪点 MAX_RATIO = 0.6 # 超过 60% 视为背景大面积区域 valid = [] for cnt in contours: a = cv2.contourArea(cnt) ratio = a / img_area if MIN_RATIO <= ratio <= MAX_RATIO: valid.append(cnt)比例值的确定方法:拿一批正常样本跑一遍,把每个轮廓的面积占比打印出来,取最小值往下留三到五倍余量作为下界。这样即使换分辨率,阈值仍然有意义。
另一个容易忽略的问题是,contourArea对自交轮廓会算错。如果一个轮廓自己绕了个圈,格林公式的结果是两块相减,可能得到负数或者很小的值。正常情况下 findContours 不会返回自交轮廓,但如果做了形态学操作之后又去改轮廓点集,就可能造出这种情况。遇到面积异常小的轮廓,先可视化看一下形状。
3.2 外接矩形、最小外接矩形、外接圆,到底选哪个
这三个函数经常被混用,其实用途差别很大。
cv2.boundingRect(cnt)返回正外接矩形(x, y, w, h),边永远平行于图像坐标轴。它最快,结果也最稳定,适合做 ROI 裁剪、目标计数、简单的尺寸测量。缺点是对于斜放的物体,外接框会明显偏大,用它算长宽会严重失真。我见过一个倾斜 45 度放置的零件,正外接框的宽高都是实际尺寸的 1.4 倍左右。
cv2.minAreaRect(cnt)返回最小面积外接矩形,格式是((cx, cy), (w, h), angle)。注意它返回的是中心点加宽高加角度,不是四个角点,要画图得先用cv2.boxPoints()转成 4 个点再取整。
rect = cv2.minAreaRect(cnt) box = cv2.boxPoints(rect) box = np.int0(box) # 新版 OpenCV 用 np.int32(box) cv2.drawContours(img, [box], 0, (0, 255, 0), 2)关于 angle 的取值范围,OpenCV 4.5 之后做过调整,不同小版本之间可能不一样,有的返回[-90, 0),有的返回[0, 90]。写代码时不要假设 angle 一定是正还是负,需要方向判断的话,先用((cx, cy), (w, h), angle)里的w和h大小关系做一次归一化,把长边统一映射到某一边再算角度。
cv2.minEnclosingCircle(cnt)返回圆心和半径,适合圆形目标的粗定位。它的结果对轮廓的毛刺很敏感,一个凸起的小尖角就能把半径撑大不少。要更精确的圆,用cv2.fitEllipse或者最小二乘拟合,前提是轮廓点数够。
三种方式的对比如下:
| 方式 | 返回格式 | 适用目标 | 主要缺点 |
|---|---|---|---|
| boundingRect | (x, y, w, h) | 正放矩形、任意目标 ROI | 斜置目标框偏大 |
| minAreaRect | 中心 + 宽高 + 角度 | 斜置矩形、方向判断 | 角度定义随版本变 |
| minEnclosingCircle | 圆心 + 半径 | 圆形、快速定位 | 对毛刺敏感 |
3.3 多边形逼近与凸包:判断形状的两把刀
想知道一个轮廓是三角形、四边形还是圆形,最直接的办法是cv2.approxPolyDP。
peri = cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) print('顶点数:', len(approx))第二个参数 epsilon 是逼近精度,常用的经验值是按周长的 1% 到 5% 取。这个值太小,顶点数会很多,圆角被当成多个顶点;太大,四边形会被压成三角形。我一般先用 2% 试,如果发现圆角矩形被判成五边形或六边形,就往 3% 到 4% 调;如果直角被削掉,就往 1% 调。
判断的时候也不要死盯顶点数。正放的方形用len(approx) == 4没问题,但稍微旋转一点,逼近可能返回 4 个点也可能返回 5 个点。更稳的做法是结合面积比:
area = cv2.contourArea(cnt) rect = cv2.minAreaRect(cnt) (w, h) = rect[1] rect_area = w * h extent = area / rect_area # 矩形度,接近 1 说明贴合外接矩形extent超过 0.9 基本可以认定是矩形,圆形的 extent 大约在 0.785(π/4)附近。
凸包用cv2.convexHull(cnt),配合面积能算出solidity(实体度):
hull = cv2.convexHull(cnt) hull_area = cv2.contourArea(hull) solidity = cv2.contourArea(cnt) / hull_area实心凸形状的 solidity 接近 1,带凹口的形状会明显小于 1。这个指标在做缺陷检测时特别有用,比如判断一个零件边缘有没有缺口。
3.4 几何矩与形状匹配:少量样本下的分类利器
cv2.moments(cnt)返回一堆矩,其中m00是面积,m10/m00和m01/m00就是重心坐标。算重心是为了在轮廓上标注序号或者做跟踪关联。
M = cv2.moments(cnt) if M['m00'] != 0: cx = int(M['m10'] / M['m00']) cy = int(M['m01'] / M['m00'])这里一定要判m00是否为 0。一个退化的、面积为零的轮廓会让除零异常直接抛出来,而这种情况在低质量二值图上并不罕见。
cv2.matchShapes用 Hu 矩做形状相似度比较,返回值越小越相似,对旋转、缩放、平移都不敏感。我在一个只有几十个样本的小项目里用过它做粗分类,效果比想象中好。三种比较方法CONTOURS_MATCH_I1/I2/I3的差异在于归一化方式,实测 I1 和 I3 在多数场景下差别不大,先用 I1。
需要注意的是 matchShapes 对轮廓的完整性有要求。如果轮廓被图像边界截断,形状变成半个,匹配分数会非常差。所以做匹配之前一定要保证目标是完整落在画面里的。
4. drawContours 的用法与视觉呈现
4.1 thickness 取 -1 的填充逻辑
cv2.drawContours的第四个参数是线宽,取 -1 表示填充。这一行代码能干的事比看上去多得多。
最常见的用法是做掩膜。比如要在原图上只保留目标区域:
mask = np.zeros(binary.shape, dtype=np.uint8) cv2.drawContours(mask, contours, -1, 255, -1) result = cv2.bitwise_and(img, img, mask=mask)还有一个非常实用的技巧是填孔。目标内部如果因为反光或者纹理出现了暗区,二值图上会有个黑洞,导致轮廓检测出内层,面积算不准。用 RETR_EXTERNAL 拿外轮廓,然后一次性填充,孔洞就被抹平了。
filled = np.zeros_like(binary) cv2.drawContours(filled, contours, -1, 255, -1)注意thickness=-1在多条轮廓一起画的时候,如果轮廓之间有嵌套,填充顺序会影响最终结果。drawContours 是按 list 里的顺序依次画的,后画的覆盖先画的。如果外轮廓在后面、内轮廓在前面,内轮廓就被填掉了;反过来就会看到孔洞。要精确控制,就一条一条画,或者根据层级排序。
第三个参数contourIdx传 -1 表示画全部,传具体索引只画那一条。做调试图的时候我习惯逐条画并配上不同颜色,这样一眼能看出哪条是哪一个。
4.2 在彩色图上画轮廓的正确姿势
drawContours 的第一个参数会被原地修改,这一点跟很多人的直觉不符。也就是说cv2.drawContours(img, ...)执行完,img本身就变了,不需要接收返回值。如果你的原图后面还要用,先img.copy()。
在灰度图上画没有意义,因为轮廓颜色和背景可能都是灰的。要画彩色轮廓,先转成 BGR:
canvas = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) cv2.drawContours(canvas, contours, -1, (0, 255, 0), 2)颜色顺序是 BGR 不是 RGB。写(255, 0, 0)得到的是蓝色,不是红色,这个错我刚接触时犯过不止一次。
最后一个参数 lineType 决定了线条的渲染方式。默认是cv2.LINE_8,线条有锯齿;换成cv2.LINE_AA会做抗锯齿,视觉上漂亮很多,代价是绘制速度慢一些。做输出给客户看的可视化图,一律用 LINE_AA;做内部的批量处理,用默认值就行,没必要多花时间。
还有个小细节:线宽建议不小于 2。线宽为 1 的轮廓在缩小显示的图片上几乎看不清,尤其是在深色背景上。
4.3 编号标注与调试图层
实际项目里,光画出轮廓往往不够,还要知道每个轮廓的编号、面积、位置。我通常会封装一个调试函数,把轮廓序号和关键指标一起标上去。
def draw_debug(img, contours): canvas = img.copy() for i, cnt in enumerate(contours): area = cv2.contourArea(cnt) M = cv2.moments(cnt) if M['m00'] == 0: continue cx = int(M['m10'] / M['m00']) cy = int(M['m01'] / M['m00']) color = (0, 255, 0) if area > 1000 else (0, 165, 255) cv2.drawContours(canvas, [cnt], -1, color, 2) cv2.putText(canvas, f'#{i} {int(area)}', (cx - 20, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA) return canvas按面积分色是个很有效的做法,一眼就能看出哪些是被过滤掉的噪点、哪些是真实目标。我一般在调参阶段会保存这张调试图,等参数稳定了再关掉,避免影响处理速度。
putText 的坐标是文字左下角,不是中心,所以要根据文字长度做一次偏移,否则编号会偏到轮廓外面去。中文字符 putText 不支持,要显示中文得先转成 PIL 再用字体文件绘制,这一点别在 putText 上浪费时间。
5. 完整流水线的参数基线与实测记录
5.1 一条可直接抄的基线代码
把前面所有环节串起来,得到下面这条流水线。参数是先按常见场景设的默认值,实际用时按注释里的方向调。
import cv2 import numpy as np def detect_contours(img, min_area_ratio=0.0005, blur_ksize=5, morph_ksize=3): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊压高频噪声,核必须是奇数 blur = cv2.GaussianBlur(gray, (blur_ksize, blur_ksize), 0) # Otsu 自动阈值,适合前景背景对比明显的场景 _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 开运算去孤立噪点 if morph_ksize > 0: kernel = cv2.getStructuringElement( cv2.MORPH_RECT, (morph_ksize, morph_ksize)) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 边缘留一圈黑边,解决贴边目标轮廓不闭合的问题 padded = cv2.copyMakeBorder(binary, 1, 1, 1, 1, cv2.BORDER_CONSTANT, value=0) found = cv2.findContours(padded, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours, _ = found if len(found) == 2 else found[1:] # 坐标减回 1,抵消 padding 的偏移 contours = [c - 1 for c in contours] h, w = binary.shape[:2] img_area = h * w valid = [c for c in contours if cv2.contourArea(c) >= min_area_ratio * img_area] # 按面积从大到小排序,方便取 TopN valid.sort(key=cv2.contourArea, reverse=True) return valid, binary这里有一个非常实用的技巧值得单独拎出来说:copyMakeBorder加一圈黑边。当目标和图像边界相交时,它的轮廓是一条开放的曲线,findContours 会沿着这条曲线走,然后在图像边界处直接接上,导致轮廓的面积和周长都不对,外接框也会贴着边。加一圈黑边之后,目标在扩展图上变成完整的闭合区域,轮廓就正常了。代价是所有坐标要减 1 修正回去,这一行别忘了写。
5.2 预处理组合的实测对比
我在同一张 1280×960 的测试图上,用几种常见的预处理组合各跑了一遍,结果记录如下。测试图内容是深色背景上的 8 个浅色矩形块,其中一个块贴近右边界。
| 预处理组合 | 轮廓数 | 贴边目标是否完整 | 面积误差 |
|---|---|---|---|
| 仅 Otsu | 12 | 否 | 贴边目标偏小 30% 以上 |
| Otsu + 开运算 3×3 | 9 | 否 | 贴边目标仍偏小 |
| Otsu + 开运算 3×3 + 加边 | 8 | 是 | 误差 2% 以内 |
| 高斯 + Otsu + 开运算 + 加边 | 8 | 是 | 误差 2% 以内 |
| Canny + 闭运算 + 加边 | 16 | 部分 | 每个目标出现内外双轮廓 |
结论很清晰:高斯模糊加 Otsu 加开运算加加边这个组合,在这类目标计数场景下是最省的。Canny 路线虽然边缘看起来更精细,但双轮廓带来的去重成本远高于它带来的收益,除非有特殊的边缘精度需求,不建议作为默认选择。
关于高斯核的选择,我自己的经验是:图像分辨率在 1000 像素量级时,5×5 就够;超过 3000 像素,用 7×7 或者先 resize 到 1000 左右再处理。大分辨率配大核,耗时增长是指数级的,而精度提升非常有限。
5.3 面积过滤与排序策略的取舍
过滤策略看着简单,其实有几种不同的思路,选错了会让逻辑变得很别扭。
第一种是按绝对面积过滤,前面吐槽过了,跨分辨率就不通用。第二种是按占比过滤,也就是基线代码里的做法,通用性好。第三种是按排序取 TopN,适合目标数量固定或者以最大目标为主的场景。
top3 = valid[:3]TopN 的问题是,如果实际目标少于 N 个,就会把噪点也拿进来。所以稳妥的写法是 TopN 加一个面积下限兜底,两个条件同时满足才采纳。
排序的另一个用途是做左右顺序编号。工业检测里经常需要按从左到右的顺序给零件编号,直接按外接框的 x 坐标排就行:
valid.sort(key=lambda c: cv2.boundingRect(c)[0])注意这里用的是 boundingRect 的 x,不是重心。重心排序在目标大小差异大时会乱序,外接框左边界更符合人的直觉。
如果目标分布成多行,还要先按 y 分行再按 x 排序。分行时用 y 坐标做聚类,或者简单点,用h // 2之类的粗分界,具体看目标尺寸的均一程度。
6. 踩坑记录与排查速查表
6.1 问题现象、原因与处理的一览
下面这张表是我这些年遇到过、也被别人问过最多的情形,按现象归类整理,排查的时候从现象反查会比较快。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 报错 unpack 数量不匹配 | 3.x/4.x 返回值个数不同 | 用长度判断做兼容 |
| 轮廓数量是目标的两倍 | 用了 Canny,边缘是双边 | 改用阈值分割,或闭运算合并 |
| 贴边目标面积明显偏小 | 轮廓在图像边界处未闭合 | copyMakeBorder 加黑边 |
| 大量微小轮廓 | 二值图噪点、反光 | 开运算 3×3 + 面积过滤 |
| 内部孔洞被当成独立目标 | 用了 RETR_LIST 或 TREE | 换 RETR_EXTERNAL |
| 面积算出来是 0 或负数 | 轮廓退化或自交 | 检查二值图,加面积下限 |
| 外接矩形比实际大很多 | 用 boundingRect 处理斜置目标 | 换 minAreaRect |
| 画出来的轮廓是蓝色 | 颜色顺序写成了 RGB | 改成 BGR 顺序 |
| drawContours 后原图被改了 | 函数原地修改输入 | 传 copy 或接受原地修改 |
| 中文标注显示成问号 | putText 不支持中文 | 转 PIL 用字体文件绘制 |
6.2 几个不写进文档的细节
轮廓点集的减法要写成c - 1。前面加边那一步,返回的坐标整体偏移了 (1, 1)。用 numpy 的减法看着简单,但如果写成循环逐点减,速度会慢一个数量级。c - 1会生成新的数组,不影响原数据,这个写法最省心。
minAreaRect 的宽高顺序不保证。它返回的(w, h)里哪个是长边、哪个是短边跟角度有关,不要假设 w 一定大于 h。需要长宽比的时候,先long_side = max(w, h)、short_side = min(w, h)再算比值。
findContours 的输入如果是 ROI,坐标是局部的。从原图裁出 ROI 处理完之后,轮廓坐标全部是相对 ROI 左上角的。要把框画回原图,必须加上(roi_x, roi_y)。我见过不止一次忘了加偏移,画出来的框全部堆在左上角。
roi = img[y0:y0+h0, x0:x0+w0] contours, _ = detect_contours(roi) shifted = [c + np.array([x0, y0]) for c in contours]轮廓数量在 0 和 1 之间的差别。有些版本在只有一条轮廓时,返回的 list 长度是 1,看起来正常;但有些版本在轮廓为空时返回的是空 tuple 而不是空 list,len()一样是 0,但后续contours[0]会抛 IndexError。统一用contours = contours or []转一下最保险。
颜色阈值比灰度阈值更稳的情况。如果目标和背景在灰度上接近但色相差异明显,转到 HSV 空间做 inRange 比灰度阈值靠谱得多。这种情况在彩色物体分类里很常见,我一般会先试灰度,效果不行立刻切 HSV,不硬调阈值。
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, (35, 60, 60), (85, 255, 255))性能优化的第一刀是缩小处理尺寸。如果只是做目标定位,把 4K 图缩到 800 宽度处理,速度能提升十几倍,坐标再按比例放大回去。前提是精度要求不高,而且目标不能小到缩放后只剩几个像素。
批量处理时的内存问题。一次处理几百张图,如果每张都保留 contours 和中间结果,内存很快吃满。处理完一张就把大数组置空,或者用生成器逐张产出结果,别把所有中间量都屯在 list 里。
我在实际项目里最大的体会是,findContours 本身几乎没有调试难度,难的全在它前面那张二值图。同一套参数换一张光照条件不同的图,结果可能完全不一样。所以每次接到新场景,我的第一步不是写检测逻辑,而是先把二值图存下来,用看图软件翻一遍——如果二值图上目标边界清晰、内部干净、没有贴边,后面的参数基本调两次就定了;如果二值图本身就一团糟,在轮廓环节怎么折腾都是白费。
另外一个后来才养成的小习惯,是把每一版的中间结果都存成带时间戳的图。当时觉得占硬盘,出了问题时翻出来对比,能立刻看出是哪一步开始跑偏的,比重新跑一遍再猜快得多。