ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenCV工业视觉缺陷检测:划痕、黑点、崩缺一次搞定

OpenCV工业视觉缺陷检测:划痕、黑点、崩缺一次搞定 做工业视觉这一行最烦的不是算法有多难而是同一张图上同时出现好几种形态完全不同的缺陷一条细长的划痕、一个孤立的小黑点、边角处少了一块的崩缺。三种问题灰度特征不一样形状特征不一样连最合适的处理思路都不一样。我前阵子用 OpenCV 把这些缺陷一次性抓出来最后把流程精简成了一个大概两百行的脚本效果出奇地稳。这篇就把完整思路和可运行代码记录下来适合正在折腾 OpenCV 图像处理项目、打算搭一套轻量质检逻辑的朋友参考。1. 三类缺陷一起抓为什么要分开设计检测逻辑先把我当时的需求说清楚。检测对象是一块表面相对均匀的深灰色面板背景是浅色载具。需要抓的缺陷有三种面板内部的划痕面板内部的孤立黑点以及面板边缘的崩缺。这三种东西如果放在同一张二值图里看前两种都是“暗色小区域”后一种却是“边缘轮廓异常”。用一套固定阈值加连通域分析去通吃结果必然是边缘崩缺漏掉或者把面板自身的边界阴影当成缺陷爆出来。所以我一开始就定了分工每种缺陷走各自独立的检测通道最后把结果汇总。缺陷类型典型灰度特征形状特征核心技术路线划痕低于面板灰度的暗色长条长宽比大近似直线连通域长宽比分类 概率霍夫直线检测黑点低于面板灰度的孤立暗斑紧凑、接近圆形连通域统计 面积与圆度过滤边缘崩缺面板边缘处形状缺失轮廓向内凹陷凸包掩膜与实际掩膜做差分这个分工很重要它让每一步都只面对一种形态特征参数调节非常直观。划痕通道只看长条黑点通道只看紧凑斑块崩缺通道只看轮廓凹陷。三者互不干扰。有人可能会问直接上深度学习目标检测不就行了如果缺陷种类多到几十种、背景又复杂确实应该考虑。但这种三类固定缺陷的场景样本量少、环境可控传统视觉方案反而有优势一是几十行 OpenCV 代码就能跑不需要标注几百张图二是每个缺陷的判定逻辑都能解释给产线的人听出问题时好追溯三是在树莓派这种低算力设备上也能实时跑。后面我会专门对比一下 OpenCV 和 Halcon 的选型问题这里先记住结论简单场景先把传统视觉吃透比盲目堆模型靠谱。2. 从一张能复现的测试图开始样本构造与基础预处理调试这种检测脚本最大的障碍是缺图。产线上拍回来的图涉及隐私、又没有标注我一般会先用程序生成一张和真实场景灰度分布一致的测试图把代码逻辑跑通再换成真实样本调参数。生成测试图的原理很简单用np.full创建浅灰背景用cv2.rectangle画深灰面板然后在面板内部画一条斜线模拟划痕、画一个实心圆模拟黑点最后在面板上边缘用cv2.fillPoly抠掉一块三角形模拟崩缺。import cv2 import numpy as np # 构造测试图浅灰背景深灰面板 img np.full((480, 640, 3), 220, dtypenp.uint8) cv2.rectangle(img, (60, 60), (580, 420), (120, 120, 120), -1) # 缺陷一斜向划痕 cv2.line(img, (150, 130), (460, 320), (40, 40, 40), 6) # 缺陷二孤立黑点 cv2.circle(img, (320, 240), 20, (25, 25, 25), -1) # 缺陷三面板上边缘的崩缺 poly np.array([[220, 60], [300, 60], [260, 130]], dtypenp.int32) cv2.fillPoly(img, [poly], (220, 220, 220)) cv2.imwrite(defect_demo.png, img)为什么要先把灰度值设计成这样的层次因为后面所有阈值都建立在这个灰度差上。面板灰度大约是 120背景是 220划痕是 40黑点是 25。也就是说面板和背景之间的灰度差有 100面板和缺陷之间的灰度差至少也有 40。这给阈值分割留了非常充裕的间隔。读图之后第一步是灰度化这个不用多说。然后做第一层分割把所有比面板暗的目标统一提取出来。这里我直接用固定阈值THRESH_BINARY_INV灰度小于 100 的区域变成白色其余变成黑色。src cv2.imread(defect_demo.png) gray cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) # 提取所有暗色缺陷划痕 黑点 _, dark cv2.threshold(gray, 100, 255, cv2.THRESH_BINARY_INV)运行后dark这张二值图里白色区域只有两处一条斜向划痕和一个圆形黑点。背景和面板都已经是黑色这就是“抓出三个缺陷”的第一层地基。这里我要多说一句阈值怎么选。不要拍脑袋填 100打开cv2.calcHist画一下直方图背景在 220 附近一个峰面板在 120 附近一个峰缺陷在 25 到 40 之间接近零散分布。阈值选在两个峰之间的谷底就行。对于固定场景测一次光照变化范围之后把阈值钉在 100 上下是一个很稳的选择。如果现场光照漂移大就改用自适应阈值或者 OTSU但 OTSU 在多峰直方图上容易把面板也算进去我后面在避坑章节会详细说。3. 抓划痕从连通域分类到概率霍夫直线检测只看dark二值图划痕和黑点都是白色连通域必须先把它们分开。我采用的方法是connectedComponentsWithStats它能一次性给出每个连通域的左上角坐标、宽、高和面积。划痕的本质是长条所以它的外接矩形宽高比会非常大黑点则是接近正方形的紧凑结构。num, labels, stats, cents cv2.connectedComponentsWithStats(dark, connectivity8) for i in range(1, num): area stats[i, cv2.CC_STAT_AREA] x, y, w, h stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP], \ stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT] aspect max(w, h) / (min(w, h) 1e-6) if aspect 5 and area 200: print(f线状缺陷中心({cents[i][0]:.0f}, {cents[i][1]:.0f})面积{area})在我的示例图里划痕连通域的外接矩形宽约 300、高约 200长宽比接近 1.5其实并不满足aspect 5。这是因为我画的这条斜线斜度很大外接矩形覆盖了很大一块空白区域。这种情况很常见所以不能只用外接矩形长宽比来判断要改用更稳的办法——对连通域内的像素做直线拟合或者用霍夫变换去找真正的直线结构。我实际采用的方案是对dark二值图做 Canny 边缘检测然后跑概率霍夫直线检测edges cv2.Canny(dark, 50, 150) lines cv2.HoughLinesP( edges, rho1, thetanp.pi / 180, threshold60, minLineLength80, maxLineGap8 ) scratches [] if lines is not None: for line in lines: x1, y1, x2, y2 line[0] scratches.append((x1, y1, x2, y2)) cv2.line(src, (x1, y1), (x2, y2), (0, 255, 0), 2)这段代码里几个参数是我反复试过之后固定下来的简单解释一下threshold60一条直线至少要有 60 个投票点才被认为是直线数值越小越容易检测出碎线越大越容易漏检细划痕。minLineLength80线段最短长度小于这个长度的线段直接丢弃用来排除黑点边缘产生的小线段。maxLineGap8允许同一线段上的最大断点间距如果划痕被噪声打断只要断点间隔在 8 像素以内就会自动连接。这里有一个很容易踩的坑用 Canny 处理 6 像素宽的划痕时会在划痕的两侧各产生一条边缘线最终可能检测出两条平行线。表现为划痕数量翻倍画出来的绿线成对出现。解决思路有两个一个是在霍夫检测之后把距离相近、方向一致的线段合并另一个更省事把minLineLength调到划痕实际长度的一半以上这样两侧的短边缘段会被过滤掉只保留跨越整条划痕的主线段。我之所以选择HoughLinesP而不是返回角度和距离的HoughLines核心原因是概率霍夫直接返回线段端点坐标画图、统计数量、做后续过滤都方便不需要自己根据角度和距离反算端点。对于抓缺陷这种需要“标出来”的场景HoughLinesP是更顺手的选择。4. 抓黑点面积、圆度和位置三重过滤黑点的检测在拿到dark掩膜之后其实已经完成一半了剩下要做的是把它和划痕、噪声区分开。划痕已经在上一步被霍夫直线识别我只需要在连通域分析时把“紧凑型”目标留下来。单纯设置面积范围是不够的。一块面积 800 像素的污渍可能被当成黑点但一条短划痕面积也可能是 800。所以我额外引入了圆度判据。圆度公式是circularity 4 * pi * area / (perimeter^2)这个值的物理意义很直观圆的圆度是 1正方形约 0.78长条越小。所以我把圆度阈值设在 0.65大于这个值就认为是近似圆形的孤立黑点。blobs [] for i in range(1, num): area stats[i, cv2.CC_STAT_AREA] x, y, w, h stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP], \ stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT] aspect max(w, h) / (min(w, h) 1e-6) if area 50 or area 3000 or aspect 5: continue comp np.zeros_like(gray) comp[labels i] 255 contours, _ cv2.findContours(comp, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: continue cnt contours[0] peri cv2.arcLength(cnt, True) circ 4 * np.pi * area / (peri * peri 1e-6) if circ 0.65: cx, cy cents[i][0], cents[i][1] radius max(w, h) // 2 blobs.append((cx, cy, radius, area)) cv2.circle(src, (int(cx), int(cy)), radius, (0, 0, 255), 2)几个过滤条件的设置逻辑area 50过滤掉单个像素级的噪声点。area 3000过滤掉大块污渍那种情况应该走单独的大面积缺陷判定逻辑不适合和黑点混在一起。aspect 5把线状目标提前踢出去减少圆度计算的干扰。circ 0.65最终确认它是“近似圆形”的孤立点。这套逻辑实际跑下来20 像素半径的黑色圆点会被准确识别并画上红圈。而那条斜向划痕虽然面积大、也会进入连通域列表但因为圆度只有 0.2 左右直接被过滤掉不会误报成黑点。有一个经验值得分享如果黑点经常贴着边缘出现轮廓的圆弧会被截断导致圆度明显下降。这时不要急着把圆度阈值往下调先确认载具和相机位置保证面板完整成像再考虑降低阈值到 0.55。否则很容易把边缘毛刺或者面板边角阴影误判成黑点。5. 抓边缘崩缺凸包与掩膜差分定位凹陷前面两招都是处理面板内部的暗色缺陷边缘崩缺是另一种思路——它不是“多了什么”而是“少了什么”。面板边缘被磕掉一块之后实际轮廓线会向内凹陷但整体还是一个矩形。我采用的方法是先提取面板轮廓求它的凸包然后用凸包掩膜和实际面板掩膜做差分凹进去的地方自然就暴露出来了。先说人话凸包就是“把整个面板外围的轮廓顶点用一条橡皮筋勒出来的最小凸多边形”。正常矩形面板的凸包和它自己几乎完全重合但边缘有崩缺时橡皮筋绷紧后会把缺口处拉直于是凸包比实际面板多出来一块多出来的那块就是崩缺区域。实现代码如下# 面板掩膜灰度小于160的是面板区域 _, panel cv2.threshold(gray, 160, 255, cv2.THRESH_BINARY_INV) # 闭运算补一下面板内部的细小缺口防止内部缺陷干扰外轮廓 panel cv2.morphologyEx(panel, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)) contours, _ cv2.findContours(panel, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnt max(contours, keycv2.contourArea) hull cv2.convexHull(cnt) hull_mask np.zeros_like(panel) cv2.drawContours(hull_mask, [hull], -1, 255, -1) # 崩缺区域 凸包掩膜 - 实际面板掩膜 chips cv2.bitwise_and(hull_mask, cv2.bitwise_not(panel)) chip_contours, _ cv2.findContours(chips, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cc in chip_contours: if cv2.contourArea(cc) 50: x, y, w, h cv2.boundingRect(cc) cv2.rectangle(src, (x, y), (x w, y h), (255, 0, 0), 2)这段代码里的面板阈值选 160是因为面板灰度 120、背景灰度 220160 正好卡在两者中间。当然面板内部原本就有划痕和黑点它们灰度远低于 160所以也会被算进面板掩膜里。但这不会影响边缘崩缺检测因为后面的差分运算只关心“凸包覆盖但实际掩膜缺失”的区域面板内部的暗缺陷在实际掩膜里同样是白色取反之后变成黑色不会被误提取。闭运算这一步值得强调。如果面板内部划痕恰好延伸到边缘面板掩膜外轮廓会被划痕切出一个缺口求凸包时这个缺口也可能被当成崩缺。闭运算能在一定程度上填掉这类小裂口让外轮廓更干净。我用的核是 7x7对于轮廓缺口不超过 7 像素的场景够用如果崩缺本身很小也要小心不要把真正的缺陷给补没了。凸包差分法最大的优点是代码量少、直观。但如果你检测的面板本身就带凹槽设计比如齿轮件、带卡槽的结构件凸包法会把正常的凹槽也判定为崩缺。这种情况下就不能用凸包了要改成和标准模板做轮廓比对提前存好“理想轮廓”的掩膜每一帧做absdiff再结合位置偏差判断。模板比对是更通用但更复杂的路线建议先把凸包法用熟遇到凹形件再升级。6. 把三个缺陷汇成一份检测报告三个检测通道分别跑通了接下来要做的就是把它们整合进一个函数输出可视化结果和统计信息。我习惯用颜色区分缺陷类型绿色线段表示划痕红色圆圈表示黑点蓝色矩形框表示崩缺。整合后的代码如下可以直接运行import cv2 import numpy as np def detect_defects(image_path): src cv2.imread(image_path) gray cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) result src.copy() # 第一层所有暗色缺陷 _, dark cv2.threshold(gray, 100, 255, cv2.THRESH_BINARY_INV) num, labels, stats, cents cv2.connectedComponentsWithStats(dark, 8) # 缺陷一划痕概率霍夫直线 edges cv2.Canny(dark, 50, 150) lines cv2.HoughLinesP(edges, 1, np.pi / 180, threshold60, minLineLength80, maxLineGap8) scratch_count 0 if lines is not None: for line in lines: x1, y1, x2, y2 line[0] cv2.line(result, (x1, y1), (x2, y2), (0, 255, 0), 2) scratch_count 1 # 缺陷二黑点面积 圆度过滤 blob_count 0 for i in range(1, num): area stats[i, cv2.CC_STAT_AREA] x, y, w, h (stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_TOP], stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT]) aspect max(w, h) / (min(w, h) 1e-6) if area 50 or area 3000 or aspect 5: continue comp np.zeros_like(gray) comp[labels i] 255 contours, _ cv2.findContours(comp, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: continue cnt contours[0] peri cv2.arcLength(cnt, True) circ 4 * np.pi * area / (peri * peri 1e-6) if circ 0.65: cx, cy cents[i][0], cents[i][1] radius max(w, h) // 2 cv2.circle(result, (int(cx), int(cy)), radius, (0, 0, 255), 2) blob_count 1 # 缺陷三边缘崩缺凸包差分 _, panel cv2.threshold(gray, 160, 255, cv2.THRESH_BINARY_INV) panel cv2.morphologyEx(panel, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)) contours, _ cv2.findContours(panel, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnt max(contours, keycv2.contourArea) hull cv2.convexHull(cnt) hull_mask np.zeros_like(panel) cv2.drawContours(hull_mask, [hull], -1, 255, -1) chips cv2.bitwise_and(hull_mask, cv2.bitwise_not(panel)) chip_contours, _ cv2.findContours(chips, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) chip_count 0 for cc in chip_contours: if cv2.contourArea(cc) 50: x, y, w, h cv2.boundingRect(cc) cv2.rectangle(result, (x, y), (x w, y h), (255, 0, 0), 2) chip_count 1 print(f划痕数量: {scratch_count}) print(f黑点数量: {blob_count}) print(f崩缺数量: {chip_count}) cv2.imwrite(result.png, result) return result if __name__ __main__: detect_defects(defect_demo.png)跑完这段代码控制台输出应该是划痕数量: 1 黑点数量: 1 崩缺数量: 1result.png里三种缺陷分别被绿线、红圈、蓝框标出一眼就能确认检测是否准确。这套结构在生产环境部署时也有一个优势三个缺陷检测完全解耦任何一条通道的逻辑改动都不会影响另外两条。比如换了相机之后黑点经常漏检只需要调整circ和area那几行划痕和崩缺的代码可以完全不动。7. 实战避坑光照、参数鲁棒性、OpenCV环境问题与Halcon选型对比代码能跑通只是第一步拿到真实产线上大概率会遇到各种意外。这一章把我踩过的坑集中整理一下顺手把几个高频环境问题也说清楚。7.1 光照不均是所有阈值方案的头号杀手我上面用固定阈值 100 把暗缺陷从面板里分离出来前提是面板本身灰度均匀。但实际产线的光源往往不均匀面板中心亮、四周暗或者有反光、阴影。这种情况下灰度 100 这条线就变得很不靠谱中心区域的缺陷可能只有 80四周的正常面板也可能暗到 90。处理思路有两个。一个是在硬件层面用低角度环形光源或者同轴光源让背景尽量均匀这是治本。另一个是算法层面先用形态学顶帽/黑帽变换做背景补偿。对于暗缺陷用黑帽变换cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)它能从原图里扣掉大尺度的背景趋势只留下比周围暗的小目标然后再对黑帽结果做阈值分割抗光照不均的能力会强很多。但要注意黑帽变换的核尺寸很有讲究。核太小会把划痕这种长条缺陷当作背景消掉核太大又会把光照阴影的边缘留下来。我的经验是核的大小至少要比最粗的缺陷宽 2 倍以上同时对光照变化的尺度要有预判这个需要拿现场的图多试几次。7.2 参数不要一套用到底先跑样本集再固定另一个常见问题是参数过拟合到某一帧测试图。我在示例代码里用的都是固定阈值、固定圆度、固定最小直线长度一旦换了相机或者换了产品型号这些参数几乎必然要重新标定。比较务实的做法是收集 30 到 50 张涵盖好品、各缺陷类型的现场图把关键参数阈值、面积上下限、圆度下限、最小直线长度各设几个候选值跑一遍脚本统计漏检率和误检率。漏检率必须压到 0误检率可以按实际规格放宽。把检测率和参数范围画成一张表最后取中间值作为固定参数。这个流程听起来麻烦但能省掉后边反复救火的精力。参数示例值调节方向误调风险暗缺陷灰度阈值100调低减少误检调太高会把正常阴影当缺陷黑点面积下限50调高过滤噪声调太高会漏掉小黑点黑点圆度下限0.65调低捕捉椭圆斑调太低会把污渍当黑点最小直线长度80调高过滤碎线调太高会漏检短划痕崩缺面积阈值50调高忽略微小崩缺调太高会漏掉轻微掉角7.3 OpenCV环境问题从安装到版本报错很多朋友卡在“明明装了却用不了”这一步。最常见的两个报错一个是ModuleNotFoundError: No module named opencv。这个其实不是真的没装而是装错了包名。OpenCV 的 Python 包有两个opencv-python包含标准模块opencv-contrib-python还额外包含ximgproc这类扩展模块。安装方法是用 pip 装完整包名而不是直接import opencv。正确做法是pip install opencv-python然后验证python -c import cv2; print(cv2.__version__)如果打印出 4.x.x 就说明环境正常。另一个容易混淆的点是有人把 conda 里某个环境装好了却在另一个环境里运行脚本导致“安装成功却找不到 cv2”。排查时先确认sys.executable指向的是不是你正在装包的那个解释器再考虑重新安装。还有一类是cv2.error: OpenCV(4.4.0) ...这种带路径的底层报错。90% 的情况不是 OpenCV 坏了而是传进去的图像数据有问题imread返回了None路径错或者路径含中文、图像是空数组、或者某个函数要求输入 8 位单通道图你却传了 3 通道BGR。遇到这种报错先打印img.shape和img.dtype比去搜报错文本快得多。OpenCV 4.4 之后很多函数对dtype的要求变得很严格尤其是connectedComponentsWithStats和findContours输入必须确保是np.uint8单通道。7.4 OpenCV和Halcon怎么选视觉行业里绕不开的一个问题是用 OpenCV 还是 Halcon。我的看法很直接如果三层固定缺陷、产量不高、控制 Louence 预算OpenCV 完全够用如果缺陷种类复杂、需要做高精度的测量标定、或者产线要求几毫秒级响应且有完善售后那 Halcon 的算法库确实更成熟但它按授权收费还有不低的入门成本。OpenCV 真正的优势是灵活和透明算法原理都开源你可以随时打日志、画中间结果、从底层去优化。Halcon 的优势在算子封装度高比如一个var_threshold就集成了很多自适应分割逻辑但对新手来说黑盒成分更大出了问题反而不容易定位。如果你当前的项目就是我这篇的形态先别纠结选型把 OpenCV 这套流程跑明白你自然能判断自己需不需要更重的工具。关于树莓派这类嵌入式平台的部署还有一个建议在低算力设备上跑这套流程先看看耗时的瓶颈在哪里。最常见瓶颈是 HoughLinesP 在大图上比较慢。优化手段有三个一是先把图像resize到合理尺寸比如 640x480二是只对缺陷检测区域做 ROI不处理全图三是把视频流从 CPU 解码改成 GPU 硬解码。只要把 ROI 圈好树莓派上跑到 20 帧以上并不难。最后再分享一个小技巧也是我每次调 OpenCV 视觉项目都会做的事在调试阶段把每个中间结果都用cv2.imwrite保存成png从原始灰度图、阈值图、Canny 图、连通域标签图到最后的凸包差分图全部落盘。这样如果最终结果不对往回翻一眼就知道是哪一步出了问题而不是对着最终图瞎猜。这套“逐步落盘排查法”帮我省下的时间远比多写几行保存代码要多。
返回列表