☰
OpenCV工业视觉缺陷检测:划痕、黑点、崩缺一次搞定
2026/10/2 19:57:40 网站建设 项目流程

做工业视觉这一行,最烦的不是算法有多难,而是同一张图上同时出现好几种形态完全不同的缺陷:一条细长的划痕、一个孤立的小黑点、边角处少了一块的崩缺。三种问题,灰度特征不一样,形状特征不一样,连最合适的处理思路都不一样。我前阵子用 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, dtype=np.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]], dtype=np.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, connectivity=8) 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, rho=1, theta=np.pi / 180, threshold=60, minLineLength=80, maxLineGap=8 ) 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)

这段代码里几个参数是我反复试过之后固定下来的,简单解释一下:

  • threshold=60:一条直线至少要有 60 个投票点才被认为是直线,数值越小越容易检测出碎线,越大越容易漏检细划痕。
  • minLineLength=80:线段最短长度,小于这个长度的线段直接丢弃,用来排除黑点边缘产生的小线段。
  • maxLineGap=8:允许同一线段上的最大断点间距,如果划痕被噪声打断,只要断点间隔在 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, key=cv2.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、背景灰度 220,160 正好卡在两者中间。当然,面板内部原本就有划痕和黑点,它们灰度远低于 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, threshold=60, minLineLength=80, maxLineGap=8) 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, key=cv2.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 崩缺数量: 1

result.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 图、连通域标签图到最后的凸包差分图,全部落盘。这样如果最终结果不对,往回翻一眼就知道是哪一步出了问题,而不是对着最终图瞎猜。这套“逐步落盘排查法”帮我省下的时间,远比多写几行保存代码要多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询