☰
OpenCV实战:金属零件表面污渍、划痕与崩边检测
2026/10/2 5:29:59 网站建设 项目流程

1. 场景和思路

1.1 一个零件上的三种缺陷要同时抓出来

做 OpenCV 图像处理这些年,最常被问的一句话就是:“这不就是阈值分割加找轮廓吗?”等你真把产线图片摆到面前,就会发现所谓“抓缺陷”根本不是刷一个 threshold 就能交差的。前段时间有个来料质检的需求:一个金属圆片零件,表面要求干净、边缘完整。项目目标写得很简单——抓出三个缺陷,分别是表面污渍、表面划痕、边缘崩边。

这个需求本质上属于工业视觉里的外观缺陷检测。零件图是固定的工位拍的,光照相对稳定,背景也固定,缺陷一共有三类:

  • 污渍:表现为局部暗斑,形状不规则,面积可大可小;
  • 划痕:表现为细长的高亮线条,方向不确定,可能有断点;
  • 崩边:表现为圆形轮廓边缘少了一块,轮廓形状不完整。

如果是真人质检员,这三种缺陷他扫一眼就能判断。但换到程序里,问题就不一样了:污渍是“面”的特征,划痕是“线”的特征,崩边是“边界形状”的特征。三种缺陷对应三种完全不同的图像特征,需要分开处理。

这篇实例适合谁看?如果你是刚开始接触 OpenCV、想拿一个真实场景练手的同学,可以直接照着代码跑一遍。如果你在项目里也遇到过“缺陷检测总是误报一堆”的情况,那参数调整和避坑的部分更值得看。

1.2 为什么这类项目首选 OpenCV

这里先回答一个新人常问的问题:OpenCV 是什么?它是一套开源的计算机视觉函数库,把图像读取、滤波、边缘提取、阈值分割、轮廓查找这些基础操作都封装成了现成函数。你不需要从零写图像处理算法,只要会用这些函数组合出你要的效果就行。

这个项目选 OpenCV 而不是 Halcon,也不是一上来就深度学习,原因很简单:

  • 缺陷形态相对固定,场景受控,传统图像处理足够解决;
  • OpenCV 免费开源,Python 接口写起来快,调参也直观;
  • 深度学习需要大量缺陷样本来训练,初期往往没有那么多坏件图。

当然我也遇到过同行问我“Halcon 不是更专业吗”。后面第五节专门讲两者怎么选,这里先说结论:中小自动化项目的视觉检测,OpenCV 能覆盖大多数场景,没必要一上来就上重型商用库。

1.3 全局设计:一图拆三路

拿到一张零件图,别急着直接二值化。我的习惯是先看“缺陷和正常区域之间到底差在哪”,然后按特征分路处理。

这个项目里,三路检测是这样拆的:

缺陷类型灰度特征几何特征对应处理路线
污渍局部灰度明显偏低面积有限的块状区域阈值分割 + 形态学去噪 + 连通域面积筛选
划痕局部灰度偏高或偏低,呈细长条长宽比大、连续性好Canny 边缘 + 霍夫直线检测 / 形态学顶帽
崩边边缘区域与模板有差异轮廓局部凹陷或缺失模板差值 / 轮廓几何拟合

这样做的好处是互不干扰。污渍检测不会因为划痕细长就把整条线当成一个块,崩边检测也不会因为表面有个污渍点就误报。后面代码也是按照这三路来的。

2. 三种缺陷的检测原理

2.1 污渍:从“灰度坑”到连通域

把灰度图想象成一张海拔图,零件表面是平整的高原,污渍就是高原上凹下去的一个坑。阈值分割做的就是“把低于某个海拔的区域挖出来”。

阈值选多少是关键。固定阈值比如90,在光照变化时很容易失效:光线稍微暗一点,正常区域也可能低于 90;光线强一点,污渍又可能高于 90。所以代码里我用 Otsu 自动阈值。Otsu 的原理可以理解为:它自动找一个灰度值,让前景和背景这两部分各自的类内方差最小、类间方差最大。简单说,就是程序自己看灰度直方图上的“两个山峰之间”最合适的位置去切。

污渍检测有个容易踩坑的地方:如果零件背景也是暗的,直接反阈值会把整片黑色背景都当成污渍。所以我会先把“亮表面区域”单独提取出来,生成一个掩膜,再在这个掩膜内部找暗斑。这个操作很多人会漏,漏掉之后误报率会高到你怀疑程序写错了。

2.2 划痕:直线检测和形态学顶帽

划痕和污渍不一样。划痕是一条细线,灰度上表现为比周围亮或暗的长条形。直接用阈值分割,细线很容易碎成一段一段,没法形成完整的连通域。

所以抓划痕我提供两条路线:

第一条是 Canny 边缘检测加霍夫直线检测。划痕两侧存在灰度跳变,Canny 能把这种跳变提取成边缘线;HoughLinesP 再从边缘点里找连续直线段。只要能找到足够长、超过设定阈值的直线段,就认为是划痕。

第二条是形态学顶帽。顶帽变换的计算是“原图减去开运算结果”。开运算对细线有“抹平”效果,细线在开运算结果里会消失,原图减去开运算结果之后,细线就被单独留下来了。用这种方法抓弯曲的划痕特别有效,霍夫直线只对近似直线的划痕有效,一遇到弯弯扭扭的划痕就抓瞎。

如果要做更精细的划痕长度统计,可以接着做骨架提取,把划痕区域细化成单像素线,再统计像素个数,就能估算真实长度。OpenCV 没有直接提供细化函数,但可以用 Zhang-Suen 算法的思路迭代腐蚀,或者借助形态学重建来实现。这个属于进阶玩法,项目里划痕基本是直线时,用 HoughLinesP 就够了。

2.3 崩边:模板差值与几何拟合

崩边是边缘形状问题。它不像污渍和划痕那样在表面形成一块区域,而是在轮廓边界上缺了个口子。

最直观的方法是模板差值法:拿一张没有缺陷的标准件图像,和当前待检图做像素级相减。如果当前零件的边缘崩了一块,那一块的灰度会和模板明显不同,差值图里就会出现一个亮区。把差值图二值化、形态学处理、找轮廓,就能定位崩边位置。

模板差值法的前提是对齐。零件位置、旋转角度、镜头亮度都要基本一致,否则整个边缘都会出现像素差异,误报成大面积崩边。如果产线没有精确夹具,就得改用几何法:提取零件外轮廓,对轮廓做最小外接圆拟合,然后逐个计算轮廓点到圆心的距离,再和半径比较。崩边位置的轮廓点距离会明显小于正常半径,连续出现足够多这样的点,就判定崩边。

几何法不要求严格对齐,哪怕零件在画面里平移几个像素也能正常工作,代价是代码量稍微多一点,而且要求零件本身的几何形状比较规则。

2.4 三种方案选型对照表

缺陷类型推荐方案优势局限
污渍Otsu 阈值 + 形态学 + 面积筛选实现简单,速度快光照不均时全局阈值不稳
直线划痕Canny + HoughLinesP定位准确,能画直线弯曲划痕容易漏检
弯曲划痕顶帽变换 + 骨架提取对线形不敏感参数多,需要反复调
崩边模板差值直观,适用于固定工位对位置对齐要求高
崩边轮廓圆拟合抗平移零件必须近似圆形或规则形状

实际项目里,我不是闭着眼睛选,而是先拿 20 张正常件和 10 张缺陷件跑一遍,看哪个方案误报最少再定。这也是我要在这里强调的:方案不是越复杂越好,而是跟你的相机工位条件匹配最好。

3. 核心代码与参数调优

3.1 预处理环节

先准备好两张图:一张待检零件图part_sample.jpg,一张标准件模板part_template.jpg。如果手头没有现成图片,也可以用手机拍一张干净的圆形物体做模板,再拍一张带缺陷的做测试。代码先把图读成灰度图,再做高斯滤波。

高斯滤波的核我选了5×5,sigma 默认取 0。核不能太大,不然划痕这类细小特征会被抹平;也不能太小,太小则噪点没有被压住。滤波的目的是让后面的阈值和边缘检测更稳定,而不是把图像变模糊。

读图时有个必须养成的习惯:判断img是不是None。很多人代码一报cv2.error就懵了,其实大部分原因是图片路径写错或者路径里有中文,imread返回了空,后面cvtColor就崩了。

3.2 抓污渍的阈值与掩膜技巧

污渍检测的核心代码如下:

import cv2 import numpy as np def detect_spots(gray, min_area=60): # 反向 Otsu 阈值,提取暗色区域 _, dark = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 正向 Otsu 阈值,提取亮表面区域作为掩膜 _, bright = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 只在亮表面区域内找暗斑,背景被排除 dark = cv2.bitwise_and(dark, bright) # 开运算去掉孤立噪点 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) dark = cv2.morphologyEx(dark, cv2.MORPH_OPEN, kernel) contours, _ = cv2.findContours(dark, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) spots = [] for cnt in contours: area = cv2.contourArea(cnt) if area >= min_area: x, y, w, h = cv2.boundingRect(cnt) spots.append((x, y, w, h, area)) return spots

逐行解释一下。三步阈值都用了 Otsu,但用途不同:dark反阈值提取的是“所有比自动阈值暗的地方”,里面既包含工件上的污渍,也包含黑色背景;bright正向阈值提取亮表面,作用是生成掩膜。bitwise_and把两个结果一交,背景就被清掉了。

这里形态学用MORPH_OPEN,也就是先腐蚀后膨胀。腐蚀会把细小噪点删掉,膨胀再把保留下来的污渍区域恢复回来。核用椭圆(3, 3),是因为椭圆核更接近真实污渍形态,比方形核圆滑。

面积阈值min_area我给了 60。这个值怎么定?先跑一张图,把cv2.contourArea的结果打印出来,看噪点和真实污渍的面积分界在哪里,再取一个中间值。它不是拍脑袋定的,是统计数据定出来的。

3.3 抓划痕的Canny与Hough参数

划痕检测代码如下:

def detect_scratches(gray, min_len=80): edges = cv2.Canny(gray, 80, 200) lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=60, minLineLength=min_len, maxLineGap=8) scratches = [] if lines is not None: for item in lines[:, 0, :]: x1, y1, x2, y2 = item length = int(np.hypot(x2 - x1, y2 - y1)) if length >= min_len: scratches.append((x1, y1, x2, y2, length)) return scratches

Canny 有两个阈值80和200。高阈值用来确定哪些边缘是“确定的”强边缘,低阈值用来把和高阈值边缘连通的弱边缘也补回来。如果两个阈值太接近,边缘会断成一截一截;阈值差距太大,又会把纹理噪声全勾出来。一般经验是低阈值取高阈值的三分之一到二分之一,我这边80 / 200是实践中比较稳的比例。

HoughLinesP里的参数值得讲一下:

  • threshold=60是投票阈值,表示一条直线至少需要多少边缘像素点支持。太低了会把零碎短线也当成划痕,太高了真实划痕又检不出来;
  • minLineLength=80是最短直线长度。这里和min_len一起兜底,设为 80 像素;
  • maxLineGap=8是允许同一线段上断裂处的最大像素间隔。划痕经常有断点,这个参数就是用来“跨过”那些小断点的。

如果表面划痕是弯曲的,或者方向复杂,我建议换顶帽路线:

kernel_line = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 1)) tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel_line) _, top_bin = cv2.threshold(tophat, 40, 255, cv2.THRESH_BINARY)

这个(15, 1)结构元专门抓水平方向的细亮线。如果划痕还有垂直方向的,就再建一个(1, 15)的结构元分两次处理,最后把两张二值图叠加。顶帽抓出来的是线状区域,再用细化或连通域分析统计长度,效果比霍夫直线更能适应弯曲划痕。

3.4 抓崩边的模板差值实现

模板差值法代码如下:

def detect_edge_missing(gray, template_gray, min_area=120): diff = cv2.absdiff(template_gray, gray) _, zone = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) zone = cv2.morphologyEx(zone, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8), iterations=2) contours, _ = cv2.findContours(zone, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) missing = [] for cnt in contours: area = cv2.contourArea(cnt) if area >= min_area: x, y, w, h = cv2.boundingRect(cnt) missing.append((x, y, w, h, area)) return missing

absdiff把模板和待检图逐像素相减,崩边区域会明显变亮。阈值用固定值30就好,因为是在差值图上操作,正常区域差值为 0,有差异区域超过 30 就触发。这里不能再用 Otsu,因为差值图上大多数像素都是 0,Otsu 会把阈值算到很低,导致边缘微弱的光照差异全部变成误报。

MORPH_OPEN用5×5的结构元迭代两次,是为了把模板对齐误差造成的小面积边缘噪点清掉。一般正常件和模板之间会有几个像素的成像差异,开运算能把这种细碎的边缘差异抹掉,只留下真正的崩边区域。

如果你那边工位不固定,零件会平移旋转,那模板差值法就不适用了。崩边的几何检测思路是:先提取外轮廓,再用cv2.minEnclosingCircle拟合最小外接圆,计算每个轮廓点到圆心的距离,距离明显小于半径且连续的弧段就是崩边位置。这个方法能够容忍几个像素的平移。

3.5 结果标注与判定输出

三路检测完,最后把它们画到同一张图上:

def main(): img, gray = load_gray("part_sample.jpg") tmp_img, tmp_gray = load_gray("part_template.jpg") spots = detect_spots(gray) scratches = detect_scratches(gray) missing = detect_edge_missing(gray, tmp_gray) result = img.copy() for x, y, w, h, area in spots: cv2.rectangle(result, (x, y), (x + w, y + h), (0, 0, 255), 2) cv2.putText(result, "spot", (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) for x1, y1, x2, y2, length in scratches: cv2.line(result, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(result, "scratch", (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) for x, y, w, h, area in missing: cv2.rectangle(result, (x, y), (x + w, y + h), (255, 0, 0), 2) cv2.putText(result, "missing", (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2) is_ng = len(spots) + len(scratches) + len(missing) > 0 cv2.putText(result, "NG" if is_ng else "OK", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2) cv2.imshow("result", result) cv2.waitKey(0) cv2.destroyAllWindows()

颜色约定建议固定下来:红色框表示污渍,绿色线表示划痕,蓝色框表示崩边。三个缺陷都抓出来后,只要任意一类缺陷存在,整张图判为 NG。

cv2.putText的坐标要注意,OpenCV 里图像原点在左上角,y方向向下。文字放在框上方时,如果框顶贴到图像边缘,文字会跑到图外,这时候可以加一个判断,把文字位置移进画面内。

4. 常见问题与避坑实录

4.1 环境报错与读图返回None

这个标题对应的是网上被问烂的一类问题,比如ModuleNotFoundError: No module named 'cv2',或者cv2.error: OpenCV(4.4.0) ...。

No module named 'cv2'基本就是没装好,或者装到了别的 Python 环境。用pip install opencv-python安装后,在代码运行前先确认一下:

import cv2 print(cv2.__version__)

打印不出版本号,说明环境不对。尤其用 PyCharm 或者 Anaconda 的时候,经常是终端里能 import,IDE 里不能 import,这种情况多半是 IDE 的 Python 解释器和你安装包的 pip 不是同一个。去 IDE 设置里把解释器切到安装过 cv2 的那个环境即可。

cv2.error类报错更有迷惑性,因为错误信息经常指向某个底层函数,比如cv2.cvtColor或cv2.findContours。但根子往往在前一步:img是None。读图的路径不能有中文,保持全英文路径最稳。

另外注意cv2.findContours在不同版本里返回值不一样。OpenCV 3.x 返回image, contours, hierarchy,OpenCV 4.x 返回contours, hierarchy。网上老代码经常写_, contours, _ = cv2.findContours(...),在 4.x 里会直接把contours解构成hierarchy导致报错。统一改成:

contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

4.2 光照不均引起大量误报

这是缺陷检测项目里最容易翻车的点。零件表面如果受环境光影响,四角的亮度明显比中心低,固定阈值或者 Otsu 会把暗角区域也当成污渍。

我之前就被这个坑过一次:一张正常的零件图,因为边缘反光偏暗,污渍检测在四角框出一大片红色矩形。后来想明白,这是全局阈值在光照不均下的经典失效。

处理办法主要有三种:

第一,用高斯滤波或均值滤波估计背景亮度,然后原图减背景:

bg = cv2.GaussianBlur(gray, (101, 101), 0) flat = cv2.subtract(gray, bg) # 消除低频亮度差异

核越大,估计出来的背景越接近大范围光照趋势,flat里保留的就是局部灰度起伏。这个操作在我经历过的多个项目里都管用。

第二,直接用cv2.adaptiveThreshold,它在每个像素周围的小窗口里算阈值,天然抗全局光照不均,但容易把局部纹理放大成噪声。

第三,如果拍照位置允许,把光源调均匀比任何算法都有效。机器视觉圈有句老话:好的打光方案能解决一半以上的图像问题。别光在代码里折腾阈值,先把光布好。

4.3 形态学操作把缺陷弄丢

形态学操作是双刃剑。开运算去噪时如果结构元选得太大,会把细小的划痕或者小污渍整个腐蚀掉;闭运算补洞时如果结构元选得太大,又会把两段不相关的区域连成一个。

这个问题在三种缺陷同时存在时特别明显。污渍检测里为了去噪用3×3开运算,没问题;但如果你为了清除污渍内部的纹理,把核放大到7×7,那旁边的细微划痕就会被一起清掉。虽然污渍检测不需要管划痕,但划痕自己有独立的检测通道,不受影响。

需要注意的是,不要图省事只保留一张二值图,让三个检测共用。正确做法是三个检测各自拥有从原图灰度出发、独立处理中间结果的权利。表面污渍、表面划痕、边缘崩边,三类特征尺度不同,形态学参数必须分开调。

调参顺序我建议这样走:先不加形态学,看原始阈值结果,分清哪些是目标缺陷、哪些是噪声;再把形态学操作加上去,一项一项确认是否误删了目标区域;最后调节面积阈值,把边缘情况收敛。

4.4 三类检测相互干扰的处置

有时候崩边检测框出的区域,同时也会触发污渍检测。原因很直接:崩边处边缘缺失,往往伴随暗色背景暴露,在污渍的暗斑检测里也会形成一个区域。表面看起来“三个缺陷都被抓到了”,但这其实是重复报警,会给后端判定带来困扰。

处置办法是加一个区域屏蔽步骤。先跑崩边检测,得到崩边区域的矩形框之后,在污渍检测里把落在这些矩形内的轮廓过滤掉:

for spot in spots[:]: x, y, w, h, _ = spot for mx, my, mw, mh, _ in missing: # 判断污渍中心是否落在崩边框内 cx, cy = x + w / 2, y + h / 2 if mx <= cx <= mx + mw and my <= cy <= my + mh: spots.remove(spot) break

这种处理在所有多缺陷检测项目里都有用。缺陷 A 的高亮区域可能构成缺陷 B 的一部分特征,必须明确优先级和屏蔽逻辑。我一般是把形状上更容易定义的缺陷(崩边、大块污渍)放在前面跑,再让其他检测避开它们已经占用的区域。

5. 工程落地与扩展建议

5.1 批量跑图与NG记录

单张图能跑出结果只是第一步,真正交付时要处理成百上千张图。我是这么组织批量处理的:用一个glob遍历目录下所有图片,逐张检测,把 NG 结果保存到输出目录,同时写进 CSV 记录。

import glob import os import csv results = [] for path in glob.glob("samples/*.jpg"): img, gray = load_gray(path) spots = detect_spots(gray) scratches = detect_scratches(gray) missing = detect_edge_missing(gray, tmp_gray) is_ng = len(spots) + len(scratches) + len(missing) > 0 if is_ng: result = draw_results(img, spots, scratches, missing) cv2.imwrite(f"out/{os.path.basename(path)}", result) results.append([path, is_ng, len(spots), len(scratches), len(missing)]) with open("ng_report.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["path", "ng", "spots", "scratches", "missing"]) writer.writerows(results)

输出目录里只保留 NG 图,既省空间又方便复检。CSV 里的缺陷类型数量很有价值,如果一整天统计下来“划痕”明显变多,可以反推是上游工序出了问题。视觉检测不只是判 OK/NG,它还能当过程统计工具用。

5.2 OpenCV 与 Halcon 怎么选

说到工业视觉,经常会有人拿 OpenCV 和 Halcon 做对比。

OpenCV 开源免费,算法透明,几乎什么都能做,但很多时候需要自己拼装处理流程,工程化细节要自己踩。Halcon 是商业闭源库,里面有大量经过工业验证的算子,特别是畸变校正、亚像素轮廓处理、精密测量这些方向,封装得非常成熟,视觉效果调试工具也好用。缺点是贵,而且授权机制比较严,项目交付时客户环境也得考虑授权问题。

我的建议很直接:如果项目里主要是“抓缺陷、找轮廓、算尺寸”这种常规任务,而且相机工位固定、现场可控,OpenCV 完全够用。如果涉及高精度计量、复杂标定、非标准镜头畸变的处理,客户又明确要求成熟可靠,那再考虑 Halcon。

这个项目里用到的所有操作,OpenCV 都能完成,性能也足够。实际跑一块1920×1080的图,三路检测加起来大概几十毫秒到一百多毫秒,完全能满足产线节拍。

5.3 什么情况下再上深度学习

接触过视觉检测的人总会受“深度学习是不是更准”这个问题困扰。传统 OpenCV 方法的弱点是特征靠人去定义,遇到没见过的缺陷形态容易漏检;深度学习的强项恰恰是可以自动学习背景和异常的区别。

但深度学习不是万能的。首先你得有足够多的缺陷样本,几百张起步,而且缺陷形态要覆盖全面。其次模型训练需要不断调参、试错、验证,周期比传统方法长。最后,深度学习的“不可解释性”在工业交付中是个大问题,客户会问“为什么把这个区域判定为缺陷”,模型很难给出明确答案。

我的意见是:像“抓出三个缺陷”这种类型相对固定的项目,先用 OpenCV 把漏检压到最低;只有当你发现缺陷形态实在太随机、背景变化太大、传统方法怎么调都误报一片的时候,再把深度学习当成下一层防线用。传统方法打在明处,深度学习补在暗处,两者并不矛盾。


做这类项目到最后,我最大的体会是:缺陷检测没有一个通用的“银弹”方法。污渍靠面积,划痕靠形状,崩边靠轮廓,每一种缺陷都要找到它最敏感的“那个维度”。代码写到最后反而都很朴素,阈值、滤波、找轮廓,三板斧而已,真正的功夫在参数怎么定、坑怎么避、逻辑怎么彼此不干扰。

最后分享一个小技巧:新项目启动时,拿到十张正常件和十张缺陷件,先用最简单的阈值分割把结果打出来,再逐步加入形态学、几何约束、区域屏蔽。每加一步就问自己一句:“这一步到底是在压误报,还是在补漏检?”想清楚这个问题,比多会十个算子都管用。

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

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

立即咨询