ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

机器视觉缺陷检测落地:从打光标注到误检率治理

机器视觉缺陷检测落地:从打光标注到误检率治理

简介:面向机器视觉入门者与工业缺陷检测工程实践者的专题PDF,围绕光源、相机、镜头及图像采集卡的选型思路,常用图像处理方法与算法库展开,帮助读者建立从硬件搭建到缺陷识别的整体认知。压缩包内含1个PDF文件,大小约1.26MB,内容集中,便于通读、检索和按主题查阅。已有1192人学习下载。整份文档结合工件尺寸测量、表面划痕与斑点孔洞检测、Logo印刷完整性判断等场景,系统梳理可见光与不可见光光源、背向与前向照明及同轴光、低角度光等照明方式,并涉及相机分辨率、帧率、色彩深度等参数与预处理、边缘检测、模板匹配等算法。读者可据此理解选型依据、打光思路和缺陷检测流程,适合作为项目前期方案讨论与知识查漏的参考。

1. 一条产线的误检率从 8% 降到 0.6%,靠的不是换模型

有个做连接器外观检测的团队,检测网络在验证集上 mAP 做到 0.92,上线一周就被产线判了「不可用」——日产两万件,8% 的误检意味着每天有一千六百多件要拉去人工复检,产线主管直接叫停。后来他们没有换网络结构,只改了三件事:把环形光的入射角压低 15 度、把标注规范从「框住缺陷」改成「按缺陷最小外接矩形标注并单独立类」、把固定判定阈值换成按批次自适应。误检率落到 0.6%。

机器视觉工业缺陷检测这件事,难点很少在模型本身。它是一套「成像—标注—建模—判定—复检」的闭环,任何一环松了,后面全是白做。这篇笔记按落地顺序拆开讲:光怎么打、数据怎么攒、模型怎么选、产线上怎么判、翻车点埋在哪。适合正在做或准备做外观检测的一线工程师,也适合被误检率反复折磨的现场负责人。

2. 打光与成像:缺陷检测的地基为什么在相机之外

2.1 先定缺陷的物理特征,再倒推光源类型

新手最容易犯的顺序错误是:先选相机、再选镜头、最后随手配个环形光。正确的顺序反过来,先问「这个缺陷在光学上表现为哪种差异」,再倒推光源。缺陷本质上是「局部物理量偏离正常区域」,可能是反射率差、高度差、透光率差、颜色差,也可能是偏振特性差。不同的差异,对应完全不同的打光方案。

我一般会让现场工程师先拿手持显微镜对着不良品看十分钟,把缺陷拍成微距图,判断它是「凸起/凹陷」还是「材质/颜色异常」还是「几何缺件」。这一步做完,光源方案基本就定了,后面选相机只是在算分辨率。

下面这张表是我在实际项目中反复用到的对照关系,可以直接拿去对:

缺陷表现光学特征推荐光源关键参数
划痕、凹坑、压伤表面起伏,漫反射方向突变低角度环形光(暗场)入射角 10°~20°,角度越低起伏越突出
高反光金属曲面镜面反射淹没纹理穹顶光(圆顶漫射)内壁白色漫射涂层,光源从顶部进
印刷字符、标签、丝印表面平整但有颜色/反射差同轴光半反半透镜,正对镜头方向出光
缺件、轮廓缺口、针脚歪斜投影剪影差异背光(平行背光)平行度高,边缘过渡小
色差、污渍、油墨偏色光谱反射率差异白光 + 彩色相机,或特定窄带波长与材料吸收峰匹配
细微裂纹、透明件内部缺陷透射/散射差异低角度暗场 + 偏振片正交偏振压掉表面反光只留散射

一个常被忽略的参数是波长。短波长(比如 450nm 蓝光)的衍射极限更小,对微米级划痕的分辨能力明显更好;长波长穿透性强,适合表层下有结构的材料。我在一个透明注塑件的内应力检测里试过白光和蓝光对比,蓝光下应力纹的对比度提升了将近一倍,就是因为散射强度对波长敏感。这类东西没有普适公式,只能靠实测,但知道「波长是个可调参数」能省掉很多瞎试。

提示:光源的频闪同步一定要和相机曝光对齐,否则会出现亮度条纹。产线变频器、伺服电机是常见的干扰源,光源用独立直流供电比从产线取电稳得多。

2.2 分辨率怎么算:最小可检缺陷决定相机和镜头

真正决定成像是否够用的,不是相机的「多少万像素」这种营销参数,而是「像素当量」——一个像素对应实物多长。算法线上有个经验值:缺陷在图像上至少占 3×3 个像素,边缘检测和分类才有稳定余量;如果要做亚像素级的尺寸测量,得做到缺陷横跨 5~8 个像素。

换算关系很直接:

像素当量 (mm/px) = 最小可检缺陷尺寸 (mm) / 需要的像素数 视场 FOV (mm) = 像素当量 (mm/px) × 传感器长边像素数

举个例子,某类密封圈上最小的毛刺是 0.1mm,按 3 像素算,像素当量要 ≤ 0.033mm/px。选一台 2448×2048 的 500 万像素相机,视场长边 = 0.033 × 2448 ≈ 81mm。也就是说,这台相机只能覆盖 81mm 宽的视野。如果被测件有 150mm 宽,要么换更高分辨率的相机,要么用两个工位拼接,要么改用线扫相机配合运动。

除了像素当量,还有三个参数必须一起核:

  • 景深:产线工件有上下浮动,景深不够会出现边缘件模糊。景深随放大倍率下降,高倍率下往往只有零点几毫米,这时候要考虑加自动对焦或者用远心镜头。
  • 远心度:测尺寸、测间隙这类任务,普通镜头的透视误差会让不同高度的物体投影尺寸不同。远心镜头能把这个误差压到很小,代价是视野小、价格高、工作距离固定。
  • 畸变:广角镜头边缘畸变能到百分之几,如果缺陷分布在视野边缘,同一尺寸的缺陷在中心和边缘的像素数不一样,会直接拉低召回。要么选低畸变镜头,要么做一次标定校正。

我把这套算法整理成一个可执行的检查清单,选型评审时逐条过:

  1. 最小缺陷尺寸量出来(不是猜),单位 mm;
  2. 按任务类型定像素数(分类 3px,测量 5~8px);
  3. 算像素当量,反推视场;
  4. 核对工件尺寸是否落在视场内,超了就拆工位或换线扫;
  5. 核对景深是否覆盖工件高度浮动;
  6. 确认工作距离能装进设备空间;
  7. 最后才看相机接口、帧率、快门类型。

顺序错了,就会陷入「买了相机发现视野不够、换了镜头发现景深不够」的循环。

2.3 一段最小采集脚本,先把成像质量量出来

选完硬件别急着标数据,先写个脚本把图像质量量化出来。很多项目后期返工,根源是成像阶段没有客观指标,全靠「看着还行」。下面这段用开源视觉库做采集和三项质量评估,跑一遍就知道当前成像能不能支撑后续标注。

import cv2 import numpy as np # 采集参数:分辨率、曝光、增益全部锁死,禁止自动模式 cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 2448) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 2048) cap.set(cv2.CAP_PROP_EXPOSURE, -6) # 手动曝光,自动曝光会让批次间亮度漂移 cap.set(cv2.CAP_PROP_GAIN, 0) # 增益拉满会放大噪声,宁可加长曝光 def image_metrics(gray): contrast = float(gray.std()) # 全局对比度 sharpness = float(cv2.Laplacian(gray, cv2.CV_64F).var()) # 拉普拉斯方差,越大越清晰 # 信噪比:用中值滤波结果近似「信号」,残差近似「噪声」 denoised = cv2.medianBlur(gray, 5) noise = gray.astype(np.float32) - denoised.astype(np.float32) snr = 20 * np.log10(float(denoised.std()) / (float(noise.std()) + 1e-6)) return contrast, sharpness, snr ok, frame = cap.read() if ok: gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) c, s, snr = image_metrics(gray) print(f"对比度={c:.2f} 清晰度={s:.1f} 信噪比={snr:.2f}dB") cv2.imwrite("sample_raw.png", frame) # 存原图,别存压缩后的 jpg cap.release()

这段代码的逻辑说明:CAP_PROP_EXPOSURE在不同驱动下取值含义不同,有的平台是绝对值、有的是对数刻度,必须以现场实测亮度为准,不能照抄。medianBlur(5)的核大小要和噪声尺度匹配,核太大把真实细节也当噪声抹掉了,信噪比会虚高。Laplacian方差对失焦非常敏感,同一个工件在合焦和轻微失焦下这个值能差三到五倍,是个好用的对焦辅助指标。

参数上给三档经验阈值,用来判断成像是否达到可标注水平:

指标偏弱可用良好处理方向
对比度(灰度 std)< 1515~35> 35调光源角度/亮度,换波段
清晰度(Laplacian var)< 5050~200> 200检查对焦、景深、运动模糊
信噪比< 20dB20~30dB> 30dB降增益、加平均、稳住供电

采集时还有一个必做动作:存图必须存无损格式。现场有人图省事存 jpg,压缩块效应在高频纹理区会凭空造出「伪缺陷」,标注员标了一批不存在的瑕疵,模型学到的是压缩伪影。这个坑我踩过一次,返工了两周。

3. 缺陷数据集:从采样、标注到增强的完整链路

3.1 正样本好攒,负样本才是要设计的部分

缺陷检测的数据集结构和常规目标检测不一样。常规检测是「有什么标什么」,缺陷检测是「正常品几乎无穷多,不良品少得可怜」。一条产线良率 99.5%,你想攒 500 张不良样本,得等十万件产品过线,按一秒一件也要一天多,而且不良类型还不一定齐。

我的做法是把数据分成三块,分别管理:

  • 正常样本池:良品图像,量大,用来训练「正常」的分布边界,也用来做对比学习或者异常检测。这类样本不需要标注,攒起来成本极低。
  • 缺陷样本池:按缺陷类型分类存储,每一类至少攒到 50~100 个实例,低于这个量级模型学不到稳定的类内变化。
  • 边界样本池:这是最值钱的一块——「疑似但不判」的样本,比如轻微色差、极浅划痕、正常工艺纹路。这些样本决定了阈值定在哪里。现场判定标准往往是模糊的,质量部门口头说的「轻微不影响」和实际客诉标准经常对不上,只有把这些边界样本单独拿出来给质量部门逐张确认,才能把判定标准固化下来。

主动采样比被动等更有效。我会让产线在换料、换模、换批次的前 200 件里强制留图,因为缺陷往往集中在切换后的稳定期之前。另外,把历史客诉的图片全部收回来,哪怕只有几十张,也比随机采样一百张有用。

一个容易被忽略的问题是样本泄漏。同一个工件拍了多张图,或者连续帧里截出来的图,被随机分到训练集和验证集两侧,验证指标会虚高一大截。正确做法是按「工件 ID」或「时间批次」分组划分,同一工件、同一批次只能出现在一侧。

3.2 标注规范:bbox、旋转框还是像素级 mask

标注形式选错,后面模型怎么调都补不回来。三类标注各有适用场景,选型看缺陷的几何特性:

标注形式适用缺陷优点代价
水平框 bbox缺陷大致呈块状、长宽比接近 1标注快,模型成熟细长缺陷框内混入大量背景
旋转框划痕、裂纹、长条形缺陷紧贴缺陷走向,背景占比低标注成本高,部分框架支持有限
像素级 mask不规则轮廓、面积/形状要量化精度最高,可算面积周长标注成本最高,一个实例几分钟

连接器、五金件上的划痕基本都是细长形,用水平框标注时一个 200×8 像素的划痕会被框成 200×200,框内 96% 是背景,模型很容易学到背景纹理当特征。这种场景必须上旋转框或者直接用分割。我在一个金属外壳项目上做过对比,同样的网络,水平框的召回 0.71,换成旋转框后到 0.89,差距全在背景干扰上。

标注规范里还必须写死几条「不许自由发挥」的规则,否则不同标注员之间的一致性会崩:

  1. 一个缺陷被光照分成两段,是否合并成一个实例?(建议合并,按物理连通性判断)
  2. 缺陷贴着图像边缘被截断,标不标?(建议标,但打上 truncated 标记,训练时降低权重)
  3. 面积小于多少像素的忽略?(建议给一个下限,比如 5×5 像素,低于此不标,避免噪声当缺陷)
  4. 同类缺陷相邻但不连通,是分开标还是合并?(建议分开,除非物理上是一个整体)

规则写进标注文档后,做一轮交叉校验:抽 10% 的图让两个人独立标,算 IoU 一致性,低于 0.7 就说明规则还有歧义,得回去改文档。这一步花两天,能省掉后面两周的返工。

3.3 小样本增强:别只做翻转和亮度抖动

数据增强在缺陷检测里的作用比常规检测更大,因为样本本来就少。但常见的翻转、旋转、亮度抖动只覆盖了「几何和光度」的变化,遗漏了两个对工业场景更关键的方向:成像链路变化和缺陷形态变化。

成像链路变化指的是相机噪声、曝光波动、镜头脏污、光源衰减。这些在产线上天天发生,但训练集里一张都没有,模型一遇到就翻车。对应的增强手段是加高斯噪声、随机伽马、局部模糊、随机遮挡小块。缺陷形态变化指的是同一类缺陷的不同表现,比如划痕有长有短、有深有浅,可以用弹性形变和局部仿射来模拟。

下面这段用常见的图像增强库组合出这套策略:

import albumentations as A from albumentations.pytorch import ToTensorV2 train_tf = A.Compose([ # 几何:小角度旋转 + 轻微缩放,模拟工件摆放偏差 A.ShiftScaleRotate(shift_limit=0.03, scale_limit=0.08, rotate_limit=12, border_mode=0, p=0.6), A.HorizontalFlip(p=0.5), # 光度:模拟光源衰减与相机自动曝光波动 A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.5), A.RandomGamma(gamma_limit=(80, 120), p=0.3), # 成像链路:噪声 + 局部模糊 + 传感器脏点 A.GaussNoise(var_limit=(5.0, 25.0), p=0.4), A.OneOf([A.MotionBlur(blur_limit=3), A.GaussianBlur(blur_limit=3)], p=0.25), A.CoarseDropout(max_holes=6, max_height=16, max_width=16, fill_value=0, p=0.2), ToTensorV2(), ])

逻辑说明和参数边界:ShiftScaleRotate的旋转角不要超过 15°,超过后缺陷的物理形态就失真了,模型会学到不存在的模式;scale_limit控制在 0.1 以内,因为像素当量是固定的,大幅缩放等于模拟了不存在的相机。GaussNoise的方差上限要参考 2.3 节实测的信噪比,噪声加得比真实噪声还大,等于在训练模型对抗一个不存在的敌人。CoarseDropout模拟镜头脏点,但遮挡面积不能大,否则会把小缺陷整个盖掉,导致漏检样本被当成正样本训练。

还有一个针对小目标的技巧:如果缺陷在原图上只占几十个像素,训练时用 640 的输入尺寸会把缺陷缩到十几个像素,特征几乎消失。做法是把原图按缺陷位置切块(比如切成 512×512 的滑窗,保证每块至少包含一个缺陷),用切块训练,推理时再滑窗拼接。切图会带来重复检测的问题,需要一个 NMS 或基于位置的去重逻辑,这个后面在避坑章节会讲。

4. 模型选型与训练:传统算子、检测网络与分割头的取舍

4.1 传统算子什么时候还值得用

现在一提缺陷检测就往深度模型上冲,但有一类场景传统方法依然是更优解:缺陷与背景的差异可以用一个明确的数学量描述,且产线不允许出现不可解释的输出。典型例子是背光下的轮廓缺口(面积、周长、圆度)、规则纹理上的周期性异常(频域峰值)、高对比度场景下的尺寸超差(边缘拟合)。

这类场景用传统算子有三个实际好处:样本需求几乎为零、单帧耗时稳定在毫秒级、判定逻辑可以画成流程图给质量部门签字确认。深度模型给不出的东西恰恰是第三点——质量部门问「为什么这张判不良」,你没法拿一个 0.87 的置信度回答。

传统算子要写得好,关键在预处理而不是算子本身。下面这段是「背光剪影 + 形态学 + 轮廓特征」的最小可跑流程,适合缺件、毛刺、破洞这类场景:

import cv2 import numpy as np def inspect_silhouette(img_gray, min_area=120, max_circularity=0.85): # 1) 高斯去噪,核大小按噪声尺度取奇数 blur = cv2.GaussianBlur(img_gray, (5, 5), 0) # 2) Otsu 自动阈值,适用于背景与目标对比度稳定的背光场景 _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 3) 开运算去掉毛刺噪点,闭运算填补小孔洞 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) clean = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) clean = cv2.morphologyEx(clean, cv2.MORPH_CLOSE, kernel) # 4) 轮廓分析 contours, _ = cv2.findContours(clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) defects = [] for cnt in contours: area = cv2.contourArea(cnt) if area < min_area: continue peri = cv2.arcLength(cnt, True) circularity = 4 * np.pi * area / (peri ** 2 + 1e-6) if circularity < max_circularity: # 圆度低,说明轮廓有缺口或毛刺 defects.append((area, circularity, cv2.boundingRect(cnt))) return defects

参数说明:min_area是面积下限,直接对应「多小的缺陷不判」,这个值必须和质量部门一起定,不能工程师自己拍。max_circularity是圆度阈值,1.0 是完美圆,实际工件轮廓本身就不是圆,所以阈值要按良品的圆度分布来定——拿一批良品跑一遍,取圆度分布的 1% 分位作为阈值,这叫「按良品标定」。Otsu阈值适合双峰直方图,如果背景有渐变,得换成自适应阈值或者先做背景减除。

传统方法的局限要心里有数:光照一变阈值就失效、纹理复杂的表面没法用、多类缺陷要写一堆 if-else。所以实际项目里最常见的架构是「传统算子做快速筛查 + 深度模型做疑难分类」,前者拦掉 80% 的明显缺陷,后者只处理剩下的模糊样本,算力压力小很多。

4.2 三类模型怎么选:一张决策表

到了深度模型这一层,选型不是「哪个 SOTA 用哪个」,而是看标注形式、节拍预算和可解释性要求三个约束的交集。

模型类型适用场景标注需求单帧耗时(中端 GPU)可解释性
单阶段检测器缺陷位置和类别都要,节拍要求高水平框或旋转框8~20ms中,有框和置信度
语义/实例分割缺陷轮廓不规则,需要算面积像素级 mask20~60ms中高,能看到分割结果
异常检测(无监督)缺陷类型未知、样本极少只需良品5~15ms低,只有异常分数

选择逻辑我一般这样走:

  • 缺陷有明确类别、每类样本过百、节拍在 30ms 以内 → 单阶段检测器,走旋转框或分割头。
  • 缺陷形状不规则、要输出面积/周长、节拍宽松(>50ms)→ 分割网络。
  • 缺陷类型说不清、良品多不良品少、项目还在探索期 → 先用异常检测跑一版,把异常分数分布画出来,再决定要不要转监督。
  • 节拍极紧(<5ms)且缺陷特征明确 → 回到传统算子,或者用极轻量的分类网络做「有/无」二分类。

一个反直觉的经验:很多项目一开始上分割,后来发现检测器加旋转框就够了,因为质量部门最终只关心「有没有、在哪、多大」,并不需要像素级轮廓。反过来也有——做面积量化的项目用检测框算面积,误差能到 30%,这时候分割就是刚需。选型前先问清楚下游怎么用这个结果。

4.3 训练脚本骨架与五个必调参数

训练部分给一个最小可跑的骨架,重点不在框架 API,而在几个决定成败的参数。

import torch from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, scaler, device, cls_weight): model.train() total_loss = 0.0 for images, targets in loader: images = images.to(device, non_blocking=True) targets = [{k: v.to(device) for k, v in t.items()} for t in targets] optimizer.zero_grad(set_to_none=True) with torch.cuda.amp.autocast(): # 混合精度,省显存也提速 loss_dict = model(images, targets) # 分类损失加权:缺陷样本远少于背景,不加权会被背景淹没 loss = sum(v * cls_weight.get(k, 1.0) for k, v in loss_dict.items()) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) # 防梯度爆炸 scaler.step(optimizer) scaler.update() total_loss += loss.item() return total_loss / len(loader)

五个必须现场调的参数,按重要性排:

  1. 输入分辨率。它不是越大越好。分辨率翻倍,显存和耗时大约翻四倍,而小缺陷的召回提升往往只有几个点。正确做法是按 2.2 节算出的像素当量来定:输入侧一个像素对应的实物尺寸,不要比成像侧大太多,否则等于主动降采样。
  2. 正负样本比例与分类损失权重。缺陷像素占比常常低于 0.5%,不加权模型会直接学会「全判正常」也能拿到 99.5% 的准确率。权重不是拍脑袋,按类别频率的倒数开方来给,再用验证集上的召回微调。
  3. 学习率与 warmup。小数据集上大学习率会直接训崩。我一般用 1e-4 起步,前 500 步线性 warmup,后面余弦退火。如果 loss 在前 200 步就震荡,先降一个数量级。
  4. 锚框尺寸或分割的采样策略。缺陷普遍是小目标,默认锚框尺寸往往偏大。用训练集真实框做一次 k-means 聚类,把锚框尺寸换成聚出来的值,召回通常能涨 3~5 个点。
  5. 早停与验证指标。验证指标不要用 mAP,用「在固定误检率下的召回」。产线真正关心的是「每千件误判几件的前提下能抓到多少不良」,这个指标才和业务对齐。

训练集和验证集的划分再强调一次:按批次分组,不要随机。我见过验证集 mAP 0.95、上线召回 0.6 的项目,回头一查,训练集里同一个工件的连续五帧被分到了两侧,模型其实是在「认工件」而不是「认缺陷」。

4.4 导出与推理侧前处理对齐

模型训完导出成通用中间格式,最容易翻车的不是导出本身,而是前后处理不一致。训练时的归一化均值方差、resize 的插值方式、RGB/BGR 通道顺序,任何一项对不上,推理精度都会莫名其妙掉一截,而且不掉到零,只是稳定地掉 5~10 个点,特别难查。

import torch model.eval() dummy = torch.randn(1, 3, 640, 640).to(device) torch.onnx.export( model, dummy, "defect.onnx", input_names=["images"], output_names=["preds"], opset_version=12, # 12 兼容性较好,别盲目追新 dynamic_axes={"images": {0: 1}}, # batch 固定为 1,产线逐帧推理 do_constant_folding=True, ) # 关键:导出前后跑同一张图,比对输出差异 with torch.no_grad(): torch_out = model(dummy) import onnxruntime as ort sess = ort.InferenceSession("defect.onnx") ort_out = sess.run(None, {"images": dummy.cpu().numpy()}) # 差异应在 1e-4 量级以内,超过就说明有算子没对上

这段的重点是最后那步对比验证。导出不是「跑通就完事」,必须拿同一张输入图分别过原始模型和导出模型,逐元素比对输出。差异超过 1e-4 就说明某个算子被替换成了近似实现,或者常量折叠改变了数值路径。这个比对脚本要固化成导出流程的一部分,每次导出都跑。

推理侧还要注意两件事:一是 letterbox 填充的值要和训练一致(常见是 114 灰),填充值不匹配会让边缘区域的缺陷特征偏移;二是后处理的 NMS 阈值要按切图策略调整,切图训练、滑窗推理时,同一缺陷会出现在相邻窗口里,NMS 的 IoU 阈值要调低一些才能去重干净。

5. 产线落地避坑:误检、节拍、复检的 5 条血泪记录

5.1 现象:白天误检率正常,夜班翻倍

现象:同一套模型,白班误检率 2%,夜班涨到 6%~8%,第二天早上又恢复。

原因:产线夜班电压波动更大,光源亮度随之下漂,图像整体对比度下降,缺陷与背景的分离度变差。而模型是在白天采集的数据上训的,没见过低对比度的分布。有人会怀疑是模型「玄学」,实际上是一个纯物理问题。

解决:三步走。第一,光源改成独立稳压供电,并在光源前加一块漫射板降低对电流波动的敏感度;第二,采集端加一个亮度监控,每帧算平均灰度,偏离基准超过阈值就报警而不是硬跑;第三,训练集里按亮度分档采样,把不同亮度下的图像都覆盖到。做完这三步,夜班误检率回到 2.5% 以内。

5.2 现象:换一批来料就大面积误判

现象:模型上线两周稳定,换了供应商的一批料之后,误检率突然从 1.5% 飙到 12%,图像肉眼看着没差别。

原因:不同供应商的材料表面粗糙度、涂层厚度、甚至是同一种颜色的色号都有细微差异,这些差异在图像上表现为整体纹理统计量的偏移。模型把「供应商 A 的纹理」当成了正常分布的一部分,遇到供应商 B 就全判异常。

解决:短期靠「按批次自适应阈值」——统计当前批次前 100 件的异常分数分布,用分位数重新定阈值,而不是用训练时的固定值。长期靠数据治理:每引入一个新供应商,先采集 200 件良品做一次小样本微调,只训最后一层,20 分钟能跑完。这件事必须写进换料流程,否则每次都要重新踩一遍。

5.3 现象:训练集 0.98,产线只有 0.7

现象:验证集指标漂亮,上线后召回明显偏低,把产线漏检的图拿回来一看,缺陷在图像上确实存在,但模型没框出来。

原因:八成的概率是数据泄漏——同一工件的连续帧被随机分到了训练集和验证集。模型记住了工件本身的特征(划痕位置、背景纹理),而不是缺陷特征。验证集里那些「长得像训练集」的样本自然全对。另外两成是标注质量问题,比如小缺陷漏标,模型把它当成了负样本。

解决:第一,重新按工件 ID 或时间批次分组划分数据集,重训一遍,指标会掉下来,但这个才是真实水平。第二,抽 200 张验证图人工复核标注,特别是面积小于 20×20 像素的小目标,漏标率往往超过 15%。第三,在训练里加一个「难例挖掘」环节,把验证集上漏检的图按 3 倍权重加回训练集。

5.4 现象:单帧推理 30ms,整线节拍还是不达标

现象:模型单帧耗时实测 30ms,产线要求 100ms 一件,理论上绰绰有余,实际却经常超时报警。

原因:单帧耗时只算了模型前向。实际链路是「触发—采集—传输—预处理—推理—后处理—通信—PLC 动作」,任何一环都可能成为瓶颈。最常见的是采集端的软触发延迟和后处理的 NMS 在大分辨率下耗时暴涨,还有一个隐蔽的黑匣子是图像从相机到内存的拷贝,用错 API 会多走一次全图复制。

解决:给链路每一段打时间戳,找出真正的瓶颈。经验上,预处理里的 resize 用硬件加速能省 5~8ms,后处理 NMS 换成排序后贪心能省掉一半。另一个常被忽略的点是推理批大小:产线逐帧推理时 batch=1,GPU 利用率其实很低,如果有多个工位,可以把同一时刻的多工位图像拼成一个 batch 一起推,吞吐能提升两三倍。最后留 20% 的时间余量,别把节拍卡死在理论值上。

5.5 现象:模型迭代后,老缺陷突然漏检

现象:为了修一类新缺陷重训了模型,新缺陷召回上去了,但原来已经解决的老缺陷又开始漏。

原因:新数据加入后,训练集的类别分布变了,模型容量有限时会「牺牲」旧类去拟合新类,这叫灾难性遗忘。在小数据集上尤其明显,因为旧类的样本量本来就不多。

解决:建一个固定不变的「回归测试集」,包含历史上所有出现过的缺陷类型,每次模型更新必须在这个集合上跑一遍,任何一类召回下降超过 2 个点就回滚。同时训练时把旧数据按比例混入,不要只训新数据。工程上更稳的做法是模型集成——给每类缺陷单独训一个二分类器,新增缺陷只训新模型,老模型不动,代价是推理耗时线性增长,需要按节拍权衡。

6. 上线之后:阈值自适应与模型漂移的监控手法

模型上线那天不是终点,是漂移的起点。产线环境在变(光源老化、镜头积灰、来料波动),模型不会主动告诉你它不行了,它只会安静地多判几个不良,然后产线开始抱怨「最近误检又多了」。

我现在的习惯是给每个检测工位配一套轻量的漂移监控,核心是三张图:输入分布图、输出分数分布图、判定结果趋势图。输入分布看平均灰度和图像清晰度的日趋势,任何一项连续三天单向漂移,就说明成像链路在老化;输出分数看每天异常分数的中位数和分位数间距,正常品的中位分数如果开始往上爬,就是漂移的早期信号;判定结果趋势看每日判废率的移动平均,突增或突降都要查。

阈值这件事,固定值是省事但脆弱的方案。我一般用基于批次分位数的自适应阈值,思路是假设当前批次的绝大多数样本是良品,用它们的分数分布来定「什么算异常」:

import numpy as np def adaptive_threshold(scores, base=0.5, k=3.0, floor=0.2, ceil=0.9): """ scores: 当前批次所有样本的异常分数(假设 >95% 为良品) base: 兜底基准值,样本量不足时使用 k: 稳健标准差倍数,越大越宽松 """ scores = np.asarray(scores, dtype=np.float32) if scores.size < 50: return base # 样本太少,别乱自适应 med = np.median(scores) mad = np.median(np.abs(scores - med)) + 1e-6 # 中位绝对偏差,抗离群点 th = med + k * 1.4826 * mad # 1.4826 是把 MAD 折算成正态标准差的系数 return float(np.clip(th, floor, ceil))

这段的关键在1.4826这个系数,它让 MAD 在正态假设下等价于标准差,但比标准差抗离群点——产线批次里偶尔混进几个真不良,用标准差会被拉偏,用 MAD 不会。k=3.0意味着阈值落在良品分布的 99.7% 分位附近,实际调这个值就是在调误检率和漏检率的平衡点,质量部门要求严就调小,产线嫌复检多就调大。floor和ceil是安全边界,防止某一批次数据异常(比如整批都是不良)导致阈值被拉到极端值。

监控之外还有一个具体技巧值得单独说:用复检结果反哺模型。产线人工复检时判「误检」的图,和判「漏检」补标的图,是两种完全不同价值的数据。误检图告诉你模型的决策边界太松,漏检图告诉你训练分布有盲区。我一般要求复检系统把这两类图自动归档,每周导出一次,误检图只做阈值调整的参考不重训(因为会引入分布偏移),漏检图经人工确认后加入训练集。这个闭环跑三个月,模型的实际召回通常能再涨 5 到 8 个点。

最后说个我自己的教训:早期做项目时,我总想着一次把模型做到最好再上线,结果拖了四个月,产线等不及先上了人工全检。后来我改成「先上 70 分的版本,把复检闭环跑起来,靠真实数据迭代」,反而三个月就到了 90 分。缺陷检测这件事,产线上的真实数据比实验室里的调参重要得多,早上线一天就多一天的数据。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表