简介:这份资源是面向计算机视觉初学者与OpenCV入门者的图像缺陷识别实战示例,围绕图像处理与缺陷检测这一典型场景,演示从读取图像到输出缺陷统计的完整流程。压缩包共11个文件,约254KB,以6张jpg示例图片配合Visual Studio工程文件(sln、vcxproj、filters)和1个cpp源码为主,图片用于测试与效果对照,工程文件与源码则构成可直接编译运行的演示项目。已有6559人学习下载,说明其在入门群体中具有一定参考价值。代码覆盖灰度转换、二值化、连通域标记与缺陷统计等关键环节,读者可据此理解cvtColor、threshold、findContours等函数的实际用法,并通过对比各阶段输出图像掌握处理流程的变化,为后续更复杂的图像分析与工业检测应用打下基础。
1. 从一张划痕照片说起:OpenCV 图像缺陷识别到底能干什么
产线质检工位上摆着一台普通工业相机,拍回来的金属件表面有一道细划痕,人眼一眼能看出来,可要让程序自动判断“这张图有没有缺陷”,很多刚接触 OpenCV 图像处理的人第一反应是上深度学习,结果数据集凑不齐、标注成本高、模型还跑不动。其实相当一部分表面缺陷——划痕、脏污、缺料、孔洞、边缘毛刺——用 OpenCV 这套经典图像处理流程就能做出一个能跑、能看、能调的识别例子,不需要训练、不需要 GPU,一台普通办公机就能验证思路。
这份资源就是一个基于 OpenCV 的图像缺陷识别简单例子,走的是“灰度化 → 滤波去噪 → 阈值分割/边缘检测 → 形态学处理 → 轮廓提取与筛选”的经典管线,适合刚入门图像处理、想快速跑通一个完整缺陷检测闭环的开发者,也适合需要给产线做一个低成本预筛原型的从业者。它不追求通用性,追求的是把每一步的参数含义和翻车点讲清楚,让你拿到自己的图能改、能调、能判断这条路走不走得通。
2. 缺陷识别的图像处理管线:为什么是这套组合而不是直接上深度学习
2.1 经典管线的四个阶段与选型理由
OpenCV 做缺陷识别,核心逻辑不是“识别”,而是“把缺陷从背景里分离出来”。整条管线可以拆成四个阶段:预处理、分割、形态学清理、轮廓筛选。每个阶段解决一个具体问题,缺一环结果就会崩。
预处理阶段做灰度化和滤波。灰度化把三通道压成单通道,减少计算量,也让阈值分割有统一的比较基准;滤波用高斯模糊或中值滤波压掉相机噪声和光照不均带来的高频干扰。分割阶段用阈值或边缘算子把“疑似缺陷”从背景里抠出来,常见的是固定阈值、Otsu 自适应阈值、自适应阈值(adaptiveThreshold)三种。形态学阶段用开运算去掉小噪点、闭运算把断裂的缺陷连起来。轮廓阶段用 findContours 提取连通域,再按面积、长宽比、圆度等几何特征筛掉误检。
为什么不上深度学习?因为这份例子面向的是“样本少、缺陷形态相对固定、光照可控”的场景。深度学习要几百上千张标注图,而经典管线只要你会调三四个参数就能出结果。代价是它对光照变化和背景纹理敏感,换个工件、换盏灯可能就得重调——这个边界必须先说清楚,别指望一套参数打天下。
2.2 环境准备与最小可运行骨架
先把环境搭起来。Python 3.8 以上,装 opencv-python 和 numpy 即可,不需要 contrib 模块。
pip install opencv-python numpy下面是一个最小可运行的骨架,把读图、灰度、滤波、阈值、形态学、轮廓串起来,先跑通再谈调参。
import cv2 import numpy as np # 1. 读图,灰度模式直接读入,省一次转换 img = cv2.imread("part_01.jpg", cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError("图片路径不对,检查文件名和目录") # 2. 高斯滤波压噪,核大小取奇数,3x3 适合小图,5x5 适合噪声明显的图 blur = cv2.GaussianBlur(img, (5, 5), 0) # 3. Otsu 自适应阈值,自动找分割点,适合缺陷与背景灰度差明显的场景 _, thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 4. 形态学开运算去小噪点,核大小决定去掉多大的噪点 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) cleaned = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=1) # 5. 找轮廓,RETR_EXTERNAL 只要最外层,CHAIN_APPROX_SIMPLE 压缩存储 contours, _ = cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 6. 按面积筛选,面积太小的当噪声丢掉 result = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) for cnt in contours: area = cv2.contourArea(cnt) if area < 50: # 面积阈值,按实际图分辨率调 continue x, y, w, h = cv2.boundingRect(cnt) cv2.rectangle(result, (x, y), (x + w, y + h), (0, 0, 255), 2) cv2.imwrite("result.png", result)这段代码的逻辑是:灰度图进来,先模糊把噪声压下去,再用 Otsu 自动算阈值做反相二值化(缺陷通常比背景暗,反相后缺陷变白),开运算去掉零星噪点,最后找轮廓并按面积过滤。参数里最需要动的是高斯核大小、形态学核大小和面积阈值这三个,后面章节会逐个讲怎么调。
2.3 阈值分割的三种打法与适用边界
固定阈值最简单,cv2.threshold(blur, 127, 255, cv2.THRESH_BINARY_INV),127 这个数靠试。它适合光照极其稳定、背景灰度基本不变的场景,比如封闭打光箱里的检测。一旦环境光有波动,固定阈值就会大面积误检。
Otsu 法(上面代码用的)会自动遍历所有阈值,找类间方差最大的那个点,适合缺陷和背景灰度分布呈双峰的图。它的坑在于:如果图里缺陷面积占比很小,或者背景本身有大片渐变,Otsu 算出来的阈值会偏,分割结果要么全白要么全黑。
自适应阈值cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)按局部邻域算阈值,适合光照不均的图,比如大工件上局部反光。参数 11 是邻域块大小(必须奇数),2 是常数 C,从局部均值里减掉,C 越大分割越保守。代价是它容易把背景纹理也当成缺陷,需要配合形态学清理。
我一般会先拿 Otsu 跑一遍看直方图分布,如果双峰明显就用 Otsu,如果背景有明显渐变就换自适应阈值,固定阈值只在打光完全可控时才用。
3. 形态学与轮廓筛选:把误检压下去的关键两步
3.1 开闭运算的核大小怎么定
形态学操作是缺陷识别里最容易被低估的一步。开运算是先腐蚀后膨胀,作用是去掉比核小的亮斑(反相二值化后缺陷是白的,所以去掉的是小噪点);闭运算是先膨胀后腐蚀,作用是把断裂的缺陷连成一片。核的形状和大小直接决定效果。
核大小怎么定?看你的噪点尺寸。如果噪点是 2 到 3 像素的孤立点,3x3 的椭圆核开运算一次就够;如果缺陷本身很细(比如发丝划痕只有 1 到 2 像素宽),开运算会把缺陷一起腐蚀掉,这时候要么减小核,要么改用闭运算先连接再处理。这是个典型的取舍:核越大去噪越狠,但细缺陷也越容易丢。
# 细划痕场景:先闭运算连接断裂,再用小核开运算去噪 kernel_close = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel_close, iterations=1) kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2)) cleaned = cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel_open, iterations=1)这里闭运算用 5x5 矩形核,是因为矩形核在水平和垂直方向连接能力更强,适合把断续的划痕接上;开运算换成 2x2 椭圆核,是为了尽量少伤细缺陷。iterations 控制重复次数,一般 1 到 2 次,次数多了缺陷会被啃掉。
3.2 轮廓特征筛选:面积之外还能看什么
只按面积筛,误检率往往压不下来。实际项目里我会同时看几个几何特征:面积、外接矩形长宽比、轮廓面积与外接矩形面积的比值(填充度)、圆度。划痕类缺陷长宽比通常很大(比如大于 5),脏污类缺陷填充度低(形状不规则),孔洞类缺陷圆度高。
for cnt in contours: area = cv2.contourArea(cnt) if area < 50: continue x, y, w, h = cv2.boundingRect(cnt) aspect = max(w, h) / max(min(w, h), 1) # 长宽比,防除零 rect_area = w * h fill_ratio = area / rect_area if rect_area > 0 else 0 # 填充度 # 划痕:细长且填充度高;脏污:填充度低 if aspect > 5 and fill_ratio > 0.5: label = "scratch" elif fill_ratio < 0.4: label = "stain" else: continue # 不符合任何一类,丢弃 cv2.rectangle(result, (x, y), (x + w, y + h), (0, 0, 255), 2) cv2.putText(result, label, (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)这段代码在面积过滤之后加了长宽比和填充度两个判据,把轮廓粗分成划痕和脏污两类。参数 5 和 0.5、0.4 都是经验值,得拿你自己的样本图跑一批统计出来再定,别照抄。统计方法很简单:把所有轮廓的特征值打印出来,看两类缺陷的分布区间在哪,取中间值当阈值。
3.3 把处理中间结果存下来看
调参最怕黑匣子,不知道哪一步出了问题。我的习惯是每一步都 imwrite 存一张中间图,出问题时逐张看。
cv2.imwrite("debug_01_blur.png", blur) cv2.imwrite("debug_02_thresh.png", thresh) cv2.imwrite("debug_03_cleaned.png", cleaned) cv2.imwrite("debug_04_result.png", result)这四张图对应管线的四个关键节点。如果 thresh 图里缺陷根本没被分出来,问题在阈值;如果 cleaned 图里缺陷被啃没了,问题在形态学核太大;如果 result 里框了一堆背景纹理,问题在轮廓筛选条件太松。按这个顺序排查,比盯着最终结果瞎调快得多。
4. 避坑与排查:五个真实会翻车的点
4.1 现象:换一张图结果全黑或全白 → 原因:Otsu 遇到单峰直方图 → 解决:先看直方图再选阈值法
Otsu 的前提是灰度直方图呈双峰。如果拍回来的图背景和缺陷灰度几乎一样,直方图是单峰,Otsu 算出的阈值会落在极端位置,二值化结果要么全黑要么全白。排查方法是先画直方图:cv2.calcHist([img],[0],None,[256],[0,256]),看有没有明显两个峰。没有双峰就说明这张图本身对比度不够,要么改打光,要么换自适应阈值,别硬调 Otsu。
4.2 现象:细划痕在结果里消失了 → 原因:形态学开运算核太大 → 解决:核降到 2x2 或改用闭运算优先
开运算的腐蚀步骤会把比核窄的结构吃掉。发丝级划痕只有 1 到 2 像素宽,用 3x3 核开运算一次就没了。解决办法是把开运算核降到 2x2,或者调整顺序:先闭运算把划痕加粗连接,再用小核开运算去噪。如果划痕实在太细,考虑先对图像做一次轻微膨胀再走后续流程,但要注意膨胀也会放大噪点。
4.3 现象:背景纹理被大量框成缺陷 → 原因:轮廓筛选条件只看面积 → 解决:加长宽比和填充度双判据
金属拉丝表面、织物纹理、纸张纤维,这些背景本身就有大量小连通域,只按面积筛根本压不住。加长宽比能筛掉接近方形的纹理块,加填充度能筛掉形状不规则的噪声。如果还压不住,考虑在预处理阶段加一次中值滤波(cv2.medianBlur(img, 5)),中值滤波对椒盐噪声和细纹理的抑制比高斯滤波更狠,代价是会让细缺陷边缘变钝。
4.4 现象:同一批图有的能检有的检不出 → 原因:光照不均导致全局阈值失效 → 解决:换自适应阈值并调块大小
固定阈值和 Otsu 都是全局阈值,整张图用一个分割点。工件表面有反光或阴影时,亮区和暗区的合适阈值完全不同,全局阈值必然顾此失彼。换成adaptiveThreshold后,每个像素按自己邻域的均值算阈值,能适应光照渐变。块大小(第三个参数)取图像宽度的 1/20 到 1/50 左右比较合适,太小会把噪声当缺陷,太大会退化成全局阈值。常数 C 从 2 开始试,分割太激进就加大。
4.5 现象:轮廓数量对但位置偏移 → 原因:findContours 的坐标基于二值图,画框时用了原图尺寸但通道数不匹配 → 解决:统一用灰度图画框再转彩色
findContours返回的坐标是相对输入二值图的,只要二值图和原图同尺寸就没问题。常见翻车是画框时把灰度图当彩色图用,cv2.rectangle在单通道图上画彩色框会报错或颜色异常。正确做法是先cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)转成三通道再画,或者直接在灰度图上画白色框。另外注意findContours在 OpenCV 4.x 里返回两个值(contours, hierarchy),3.x 返回三个,版本不同写法不同,装错版本会直接报 unpack 错误。
5. 进阶技巧:用轮廓面积分布反推阈值,而不是靠眼睛试
调面积阈值最土的办法是改一个数跑一次看结果,效率极低。我后来固定用一个统计法:把所有轮廓的面积打印出来排序,看面积分布的断层在哪,断层就是天然的分界点。
areas = sorted([cv2.contourArea(c) for c in contours], reverse=True) for i, a in enumerate(areas[:30]): # 只看前 30 个最大的 print(f"rank {i}: area={a:.1f}")跑一批正常图和缺陷图,分别看面积排序。正常图的轮廓面积通常集中在很小的区间(背景噪点),缺陷图的轮廓面积会明显跳一个量级。两个区间之间的空档就是阈值该放的位置。这个方法比拍脑袋定 50 或 100 靠谱得多,而且换工件时重新统计一遍就能快速定新阈值。
再进一步,可以把面积、长宽比、填充度三个特征做成一个简单的打分规则,而不是硬阈值切分:
| 特征 | 划痕典型范围 | 脏污典型范围 | 噪点典型范围 |
|---|---|---|---|
| 面积(像素) | 200 以上 | 100 以上 | 50 以下 |
| 长宽比 | 大于 5 | 1 到 3 | 接近 1 |
| 填充度 | 大于 0.5 | 小于 0.4 | 无规律 |
这张表不是标准答案,是给你一个起点。拿自己的样本跑一遍统计,把表里的数换成你实测的分布区间,规则才站得住。我吃过一次亏:直接照搬别人博客里的面积阈值 100,结果自己相机分辨率高了一倍,缺陷面积普遍在 300 以上,100 这个阈值把一半真缺陷当噪点滤掉了,排查了半天才发现是分辨率不匹配。从那以后我每次换相机或换镜头,第一件事就是重新统计一遍轮廓面积分布,把阈值按实际像素重新定,绝不沿用旧参数。希望这个习惯也能帮到你,少走一段我走过的弯路。
本文还有配套的精品资源,点击获取