
做缺陷检测算是工业视觉里最常碰到的需求之一。产线上一个零件送过来你得判断它表面有没有划痕、有没有凹坑、边缘有没有破损靠人眼去看的话效率和一致性都很难保证。这篇实例是这个系列的第三十二篇主题很直接用 OpenCV 在一张工件表面图上把三类缺陷自动抓出来——划痕、凹坑、边缘破损。为什么选这个题目因为缺陷检测这个场景基本把 OpenCV 图像处理项目里最常用的一套流程完整串起来了灰度化、滤波、阈值分割、形态学操作、轮廓发现、特征筛选、结果标注。做完这一遍你对 OpenCV“处理一张图”的完整链路就有了整体手感。文章会从环境配置讲到最后能跑通的完整代码把每一步选型的原因、参数背后的逻辑都交代清楚适合那些有 Python 基础但还没独立写过完整视觉小项目的人也适合想快速了解传统视觉方案如何做缺陷判断的工程师。1. 项目目标与方案设计1.1 场景假设与三个缺陷的定义先把问题定义清楚不然后面全是糊涂账。我这边模拟的检测对象是一块经过喷砂处理的金属垫片表面本身带有不均匀的纹理这会增加分割难度。常见的表面缺陷很多但在实际项目里缺陷种类通常是工艺人员提前给出的所以我就把问题收敛成三类这也是本实例“抓出三个缺陷”的含义划痕亮色的细长条状区域通常是装配或转运过程中被硬物蹭到灰度上和周围背景有差异形态上表现为长宽比很大的连通域。凹坑暗色的近圆形小区域像磕碰或油污残留特征是面积不大、形状接近圆形。边缘破损工件轮廓边缘附近的缺损或毛刺位置靠近图像边界形状和面积都不固定。这三类缺陷在形态特征上有明显差异非常适合用来练习 OpenCV 的轮廓分析。需要注意的是工程上的缺陷定义永远先于算法方案你不可能脱离具体的缺陷定义去做“万能检测”这个顺序一定不能反。1.2 为什么用传统图像处理而不是深度学习现在一提缺陷检测很多人第一时间想到深度学习。确实深度方案在复杂纹理背景、缺陷种类繁多的场景下上限更高但它需要大量标注样本、GPU 训练环境调参周期也长。而固定工件、固定光照、固定相机位置的产线场景缺陷的种类和表观通常相对稳定传统视觉方案的优势就很明显小样本、低成本、可解释性强。“可解释”这点很重要。规则写死了就是写死了出了问题你能一眼看出是分割参数的问题还是分类阈值的问题。深度学习更像黑盒效果不好时你很难定位是数据问题、网络结构问题还是训练策略问题。另外工业视觉商业软件里常见的 Halcon 确实很强大但授权费用不低OpenCV 开源免费、跨平台、资料丰富很多中小项目用它完成 80% 的需求是完全够的。本实例就是典型的传统 CV 解题思路。1.3 整体流程的主线我的处理管线分四段预处理灰度化 高斯滤波降低噪声对后续分割的干扰。缺陷分割自适应阈值将前景缺陷区域从背景中分离出来。形态学清理闭运算连接断裂目标开运算去除孤立噪声点。轮廓分析与分类提取每个连通域的面积、长宽比、圆度、位置信息按规则区分三种缺陷。这套流程是传统视觉做缺陷检测的基本盘后面所有代码都围绕这四段展开。先记住主线再往下看细节就不会乱。2. 环境准备与核心 API 速览2.1 环境配置里最容易踩的三个坑先说环境。我的测试环境是 Windows 10 Python 3.8 OpenCV 4.5.5下面的代码在 OpenCV 4.x 上都能跑。安装直接用pip install opencv-python但有几个点要提前说清楚。第一如果你机器里之前装过旧版本或者同时装过opencv-python和opencv-contrib-python很容易出现版本冲突。建议先统一卸载再重新装一个干净的版本。我见过不少同行卡在import cv2直接报错最后发现是这两个包打架。第二电脑里同时装了 Anaconda、系统 Python、虚拟环境的人特别容易踩坑。pip install装到一个环境IDE 却指向另一个环境结果就是“明明装成功了却找不到 cv2”。装完在终端里跑一句python -c import cv2; print(cv2.__version__)在哪能跑通就在哪个环境里用。第三OpenCV 报错信息里经常会带一长串 Windows 路径比如c:\users\appveyor\appdata\local\temp\1\pip-req-build-xxx。很多新手看到这个就慌了以为配置出了问题。其实那只是 OpenCV 打包时的源代码路径关键信息永远在最后一行错误描述里前边的路径可以直接忽略。2.2 核心函数清单每个函数是干什么的本实例用到的 OpenCV 函数不多但每一个都很关键先用一张表说明它们的位置函数作用在本实例中的用途cv2.cvtColor颜色空间转换将 BGR 图像转为灰度图cv2.GaussianBlur高斯滤波抑制噪声避免分割出碎片cv2.adaptiveThreshold自适应阈值把缺陷区域从非均匀背景中分离cv2.morphologyEx形态学操作闭运算连接目标、开运算去噪点cv2.findContours轮廓发现提取二值图里的连通域边界cv2.contourArea轮廓面积计算筛选不同大小的缺陷cv2.arcLength轮廓周长计算计算圆度特征cv2.boundingRect外接矩形获取目标位置和长宽比cv2.rectangle绘制矩形在结果图上标注缺陷位置cv2.putText绘制文字在标注框上写缺陷类型这些函数组合起来就是“分割 特征提取 规则判断”的完整链路和深度学习里的“检测 分类”在思路上有对应关系但实现逻辑完全不同。3. 预处理与分割先把缺陷从背景里“挖”出来3.1 灰度化和去噪细节不能丢噪声必须压原始图像是 BGR 彩色图但缺陷检测任务里颜色信息通常帮不上忙反而会增加计算量所以第一步直接转灰度import cv2 import numpy as np img cv2.imread(defect_sample.png) if img is None: print(图像读取失败请检查路径是否存在) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0)这里为什么要做高斯滤波因为喷砂表面的纹理会在阈值分割时产生大量细小前景区域。如果不先滤波这些纹理噪声会直接影响后续的轮廓分析导致缺陷和噪声混在一起。高斯滤波的本质是加权平均用邻域像素的加权和替代当前像素值核越大图像越模糊。核大小取(5, 5)是一个折中。核取(3, 3)去噪效果太弱取(7, 7)则可能把细小的划痕也糊掉因为划痕本身就是细长结构滤波核越大对比度损失越严重。实测下来工件表面检测类任务(5, 5)是一个比较稳的起点具体项目里可以再微调。3.2 阈值分割用自适应阈值应对不均匀背景分割阶段我推荐用自适应阈值而不是固定阈值或 Otsubinary cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 51, 15 )为什么不用固定阈值喷砂工件表面各区域的亮度并不完全一致光照角度稍有变化一个固定全局阈值就会出现“这边缺陷出来了、那边背景也跟着出来了”的情况。自适应阈值相当于给每个像素算一个局部参考值只有像素灰度偏离局部背景足够远时才判为前景对光照不均非常友好。这也是我做这类项目时优先考虑它的原因。blockSize51表示计算局部均值时取 51x51 的邻域C15表示从前景判定中再减去一个常量偏移C越大判定越严格分割出的前景越少。这两个值需要根据实际图像微调我后面会专门讲调试技巧。如果你偏好 Otsu 自动阈值可以在简单背景下用但在这个场景里它容易把纹理背景切成大片碎片。工程上“够用”和“稳”是两回事自适应阈值虽然计算量略大但换来的是少调参。3.3 形态学操作清理二值图的“残渣”二值图出来后里面往往有断裂的目标和孤立的噪点这时要用形态学操作整理kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations2) opened cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel, iterations1)闭运算是先膨胀再腐蚀作用是填平小孔、连接断开的区域。划痕这类细长缺陷在二值化后很容易出现中间断节闭运算可以把它们重新连接成完整目标。开运算是先腐蚀再膨胀作用是删除孤立的小噪点。这里的结构元素用椭圆而不用矩形是因为椭圆更接近真实缺陷的形状特征处理圆形凹坑时不容易改变其几何形态。顺序上必须先闭后开。如果先开运算去噪再去连接断裂的划痕可能被开运算进一步拆散后续更难连接。形态学的顺序选择不是玄学而是由目标形态决定的。这一步做完轮廓分析就有干净的输入了。3.4 建议先养成看中间结果的调试习惯预处理写完了我希望你养成一个习惯把每一阶段的中间结果都可视化出来。很多初学者一口气把代码写完最后发现结果不对却不知道问题出在哪一步。我的习惯是在binary、closed、opened后面各加一句cv2.imshow(binary, binary) cv2.waitKey(0) cv2.imshow(morph, opened) cv2.waitKey(0)这样你一眼就能看出是阈值分割出了问题还是形态学操作把目标弄坏了。缺陷检测这类任务中间结果的可靠性比最终代码的简练更重要。4. 核心环节实现用轮廓特征抓出三个缺陷4.1 轮廓发现与基础特征计算预处理之后进入本实例真正核心的部分。先对形态学处理后的二值图找轮廓contours, hierarchy cv2.findContours( opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE )这里有两个容易翻车的点。第一OpenCV 4.x 的findContours返回两个值contours, hierarchy但 OpenCV 3.x 返回三个值网上老代码很多是按三个值写的直接搬过来就会报“not enough values to unpack”。第二RETR_EXTERNAL表示只提取最外层轮廓因为缺陷区域内部可能还有小孔或者其他次级结构我们只需要外边界来算形状特征。CHAIN_APPROX_SIMPLE则压缩轮廓点存储只保留拐点减少内存占用对特征计算没有影响。拿到轮廓后对每一个轮廓计算基础特征这一步是分类的前提h, w gray.shape[:2] results [] for cnt in contours: area cv2.contourArea(cnt) if area 20: continue x, y, bw, bh cv2.boundingRect(cnt) aspect_ratio bw / max(bh, 1e-6) perimeter cv2.arcLength(cnt, True) roundness 4.0 * np.pi * area / (perimeter * perimeter 1e-6) is_edge (x 10) or (y 10) or (x bw w - 10) or (y bh h - 10)先解释area 20这个过滤条件。形态学处理后仍可能残留一些面积很小的连通域它们面积太小不可能是有效缺陷却在分类时干扰判断所以直接跳过。这个最小面积阈值也要根据实际图像分辨率调整图像越大这个值应该相应增大。roundness是圆度计算公式是4π面积 / 周长^2。正圆的圆度接近 1细长条的圆度趋近于 0。在分类凹坑时这个特征很关键。分母加1e-6是防止perimeter为 0 导致除零错误。4.2 划痕用长宽比特征识别细长结构划痕最直观的几何特征是长宽比。因为它是细长条外接矩形的宽度远大于高度aspect_ratio通常很大。我用这样一个规则if aspect_ratio 3.0 and area 30 and roundness 0.3: label scratch这里把三个条件组合在一起单独一个条件不可靠。比如一根粗短的划痕面积可能超过 30但长宽比不够大一个长条形的背景纹理残留长宽比可能达标但面积极小。三条件组合能有效降低误检。有人会问为什么不用cv2.HoughLinesP检测直线来做划痕识别那是一个常见思路我也试过。但霍夫直线检测更适合图像里线条本身比较完整、边界清晰的情况而划痕在二值化后往往是不规则连通域用直线检测反而会把同一条划痕拆分成多段还得额外做线段合并麻烦得多。轮廓特征直接基于连通域整体更稳。4.3 凹坑与斑点用圆度特征识别近圆形缺陷凹坑和油污斑点在形态上接近圆形所以核心特征是圆度elif roundness 0.65 and area 800: label pitroundness 0.65保证它是近圆形area 800用于排除一些大块的近圆形背景残留这个面积上限同样是经验值要根据实际样本的凹坑大小调整。如果凹坑面积本身有大有小就不要把上限卡太死宁可先让一部分边缘缺陷误入凹坑再用位置条件做二次过滤。圆度阈值选 0.65 不是随便拍的。我测量过一批真实凹坑样本圆度基本落在 0.7 到 0.95 之间而划痕的圆度普遍低于 0.3两者之间有明显的间隔带。所以 0.65 这个值是从样本统计里推出来的不是拍脑袋。建议你做类似项目时也这样做拿真实样本的特征值统计作为阈值依据而不是靠感觉。4.4 边缘破损用边界位置特征识别轮廓缺损边缘破损的判断逻辑和前面两类完全不同它靠的不是形状而是位置。破损发生在工件轮廓和图像边缘的交界处所以只要目标的外接矩形贴近图像边界就说明它很可能是边缘破损elif is_edge and area 50: label edge_breakis_edge的判断规则是外接矩形四条边离图像边界小于 10 个像素就算边缘。这里的“10”是一个边界容差因为分割出来的目标不一定会和图像边缘完全无缝紧贴留一点余量更稳妥。这类缺陷最麻烦的地方在于形态不稳定可能是一小片缺损也可能是一长条毛刺所以不宜用长宽比或圆度去约束它。位置条件加最小面积条件就够了。实际项目中边缘上出现的大块暗色区域往往确实是破损或异物把它们识别出来再让人工复核比算法一步到位更现实。4.5 分类优先级与完整可运行脚本这三个分类条件是并列的但目标可能同时满足多个条件比如“位于边缘的细长划痕”既满足划痕特征也满足边缘位置条件。我的选择是给划痕最高优先级因为划痕对工件质量的影响通常最直接宁可在边缘上少报一个破损也不能漏报划痕。分类顺序就是if / elif / elif用条件顺序自然实现优先级。下面给出完整可运行脚本直接保存成defect_detector.py就能用import cv2 import numpy as np def classify_defect(img_path): img cv2.imread(img_path) if img is None: print(图像读取失败请检查路径是否存在) return None gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) binary cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 51, 15 ) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations2) opened cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel, iterations1) contours, hierarchy cv2.findContours( opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) h, w gray.shape[:2] labels {scratch: 0, pit: 0, edge_break: 0} colors { scratch: (0, 255, 0), pit: (0, 0, 255), edge_break: (255, 0, 0), } result img.copy() for cnt in contours: area cv2.contourArea(cnt) if area 20: continue x, y, bw, bh cv2.boundingRect(cnt) perimeter cv2.arcLength(cnt, True) aspect_ratio bw / max(bh, 1e-6) roundness 4.0 * np.pi * area / (perimeter * perimeter 1e-6) is_edge (x 10) or (y 10) or (x bw w - 10) or (y bh h - 10) if aspect_ratio 3.0 and area 30 and roundness 0.3: label scratch elif roundness 0.65 and area 800: label pit elif is_edge and area 50: label edge_break else: continue labels[label] 1 color colors[label] cv2.rectangle(result, (x, y), (x bw, y bh), color, 2) cv2.putText( result, label, (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2 ) print(检测结果划痕 {} 处凹坑 {} 处边缘破损 {} 处.format( labels[scratch], labels[pit], labels[edge_break] )) return result if __name__ __main__: result_img classify_defect(defect_sample.png) if result_img is not None: cv2.imshow(detect_result, result_img) cv2.waitKey(0) cv2.destroyAllWindows()这个脚本把前面所有知识点串起来了。跑通它你就拥有一个最简版缺陷检测器。后面要做的所有优化都是在这个框架上做加法。5. 常见问题与调试技巧实录5.1 阈值怎么调都不稳定怎么办自适应阈值的两个参数blockSize和C是最影响结果的参数。如果你发现分割结果时好时坏先从这两个方向排查。blockSize太小局部均值参考范围过小背景纹理容易变成前景blockSize太大局部均值趋近全局均值自适应退化成固定阈值又丢掉了自适应的意义。一般从图像宽度的 1/20 到 1/10 开始试比如 500 像素宽的图blockSize取 25 到 51 之间比较合理。C是判定偏移量它决定前景提取的严格程度。C太小背景容易混入C太大真正的缺陷可能被漏掉。我的调试方法是二分法先选一个中间值看结果里噪声多还是缺陷漏多再朝相反方向调整每次只调一个参数不要同时改两个。另外别忘了先看中间结果。如果opened二值图里目标形态是完整的那问题肯定出在后面的分类规则如果二值图本身就是一团糟那调后面的分类参数没有任何意义。定位问题所在阶段永远是第一步。5.2 轮廓特征对不上、分类分错怎么办分类分错有几种典型原因逐一排查形态学破坏了目标结构。特别是iterations开得太大会让两个挨得很近的缺陷融合成一个轮廓特征全部变形。把iterations调小看看。长宽比算反了。boundingRect返回的w和h不保证谁大谁小如果没有像我一样用max保护宽度小于高度时aspect_ratio会小于 1导致划痕判不出来。多个条件同时命中。先用最宽松的条件把所有候选轮廓画出来逐个记录它们的面积、长宽比、圆度对照实际缺陷类型调整判定顺序。最小面积阈值设太大。有些细小划痕面积刚好低于阈值被过滤了表面看起来是“分类分错”实际上是被提前排除了。我建议你在调试时打印每个轮廓的特征值把数字和图像对应起来看。视觉直觉很容易骗人数据不会。5.3 运行报错速查表与定位方法最后整理一个报错速查表都是实测中高频出现的问题报错现象常见原因解决办法cv2.error: OpenCV(4.x) ...后面跟一长串c:\users\...路径OpenCV 源码编译路径本身不是错误忽略前边路径直接看最后一行冒号后的描述findContours报错或返回值数量不对OpenCV 3.x 返回 3 个值4.x 返回 2 个值确认版本后改用对应的接收参数cv2.imread返回None图片路径不对或路径里有中文用英文路径顺手加if img is None检查adaptiveThreshold报错输入不是单通道灰度图确保输入是gray而不是 BGR 图ImportError: No module named cv2OpenCV 装到了别的 Python 环境在 IDE 对应的终端里重新pip install opencv-python定位报错有个通用技巧OpenCV 报错信息比较长很多内容是从 C 源码层透传出来的和你的代码没关系。从最后一行往上翻找到第一个in module或line xxx那才是你代码里真正出错的位置。不要被一堆内部路径吓住。6. 我在这个实例上最想分享的三条经验这类缺陷检测实例写下来代码不复杂但真正把它用于实际项目时我有几条很深的体会。第一特征阈值不要一开始就钉死。先把流程跑通再拿一批真实的缺陷样本把每个候选轮廓的面积、长宽比、圆度全部打印出来用统计数据去定阈值而不是拍脑袋定一个“看起来合理”的数。我在实际项目里见过太多新手把长宽比写死成 3.5换一张图效果就崩原因是阈值没经过样本验证。第二缺陷检测的上限不在算法而在分割。分类规则再复杂输入的二值图是乱的一切都白搭。花最多的时间去调阈值和形态学参数让目标在二值图里既完整又不带噪声后续分类会轻松很多。第三不要试图在一个脚本里解决所有缺陷。本实例抓三个缺陷看起来简单但实际项目里缺陷类型更多、形态更杂把每种缺陷拆成独立模块来处理逻辑才清晰。下一步如果要做得更深入可以给每种缺陷单独写一个判定器或者把检测到的缺陷区域小图保存下来为以后上深度学习积累训练集。我就是这么一步步从规则判断走向更复杂方案的。