资讯动态

YOLO+传统图像处理:工业瑕疵检测双阶段实战

发布时间:2026/10/21 1:18:41 来源:尧图企业网站定制
1. 为什么我会把传统图像处理方法和YOLO硬凑在一起先说个背景。之前做了一个瑕疵检测的小项目目标是在产线上识别某类工件的表面缺陷。一开始我直接上YOLOv5训练集标注了两千多张结果在真实工况下召回率惨不忍睹。后来和一个做机器视觉的老师傅聊了一次他一句话点醒我“你光盯着深度学习传统算子那套东西还在那儿吃灰呢。”于是我开始认真研究把传统图像处理方法和YOLO模型结合。说白了YOLO负责“在哪儿找目标”传统方法负责“目标内部到底有没有问题”。听起来像是新瓶装旧酒但在实际项目中这套组合拳能解决的场景比单纯YOLO多得多。这篇内容我会从自己的踩坑经历出发讲讲到底哪些地方适合用传统方法帮YOLO擦屁股哪些地方别硬结合顺便给出完整的实现思路和代码骨架。适合正在做工业质检、遥感目标检测、或者任何“小目标复杂背景”检测任务的朋友参考。2. 传统方法在YOLO流程里到底扮演什么角色2.1 传统图像处理方法不是“淘汰技术”很多人一听传统图像处理脑子里就是边缘检测、阈值分割、形态学操作觉得这些是上古时代的东西。但传统方法有一个深度学习没法替代的优点可解释性。你用一个阈值把缺陷灰度值卡出来为什么卡在128而不是200你可以明明白白说清楚。YOLO给出的框你很难解释它为什么认为这里是缺陷。所以在实际项目里传统方法最合适的角色不是在和YOLO抢活而是干三件事角色典型任务与YOLO的关系图像预处理光照补偿、去噪、对比度增强喂给YOLO之前先把图弄干净候选区域修正对YOLO输出的框做边缘精修、去粘连替代一部分回归分支的工作二次验证在YOLO框内用传统算子做最终判断过滤误检、降低假阳率这三件事里用得最多也最容易出效果的是第三个——二次验证。原因很简单YOLO训练数据里能覆盖的缺陷形态永远是有限的但实际产线上出现的干扰因素却是无限的。传统方法正好可以充当一个“物理规则过滤器”把那些YOLO看走眼的正常纹理、反光、脏污重新筛一遍。2.2 最适合结合的几种典型场景我总结了一下下面这几类任务特别适合“传统YOLO”的组合工业表面缺陷检测划痕、凹坑、污渍背景纹理相对固定但缺陷对比度变化大。传统方法对灰度异常非常敏感可以辅助YOLO确认缺陷区域。遥感目标检测小目标多、密集排列YOLO容易漏检。用传统形态学做候选区域预筛选能显著降低漏检率。文字检测与识别YOLO检测文字框传统连通域或投影法可以用来修正框的边界提高OCR精度。医疗影像中的微小病灶病灶边缘模糊YOLO框不精准传统分割算法可以精修边缘。注意这些场景有一个共同点目标内部有丰富的纹理或灰度信息值得在检测框内部再做一次精细分析。如果你的场景是行人检测、车辆检测这种目标本身就是一个整体框进去就算完事那就没必要二次验证YOLO直接出框就够了。3. 实战案例YOLOv8 灰度直方图 纹理方差的双阶段瑕疵检测3.1 问题背景为什么单靠YOLO搞不定项目具体是这样的某工件表面会随机出现两种瑕疵一种是长条形划痕另一种是点状凹坑。划痕通常有方向性凹坑则是圆形暗斑。产线光照环境不稳定有些正常区域因为反光也会在图像里呈现暗斑。我用YOLOv8训练了一个检测模型单独跑的时候发现问题集中在两处一是反光暗斑被大量误检成凹坑假阳率一度到了15%二是划痕太长太细标注框覆盖不全YOLO框出来的区域经常只包含划痕的一部分导致后续测量长度完全不可用。后来我的解决思路很直接让YOLO负责找出“疑似凹坑”和“疑似划痕”的候选框然后对每个候选框提取传统特征用简单的规则或一个轻量分类器做最终判断。3.2 传统特征怎么选灰度均值、标准差、梯度方向直方图YOLO输出的每个候选框本质上是一个人工设定的矩形区域。对于凹坑检测我提取了三个特征灰度均值凹坑区域通常比周围暗均值明显偏低。灰度标准差凹坑内部灰度均匀标准差小反光暗斑内部会有高光点标准差偏大。局部对比度框中心区域和框边缘区域的平均灰度差凹坑是中心暗边缘亮反光是中心亮边缘也亮。对于划痕检测额外增加了梯度方向直方图划痕有明确的方向性梯度方向会集中在某个角度附近而工件表面拉丝纹理虽然也有方向性但周期性更强梯度幅值分布更均匀。这组特征提取代码不算长核心部分长这样import cv2 import numpy as np def extract_candidate_features(img, bbox): x1, y1, x2, y2 [int(v) for v in bbox] roi img[y1:y2, x1:x2] if roi.size 0: return None # 中心区域和边缘区域划分 h, w roi.shape[:2] cy, cx h // 2, w // 2 r_h, r_w max(1, h // 4), max(1, w // 4) center roi[cy - r_h:cy r_h, cx - r_w:cx r_w] # 灰度特征 gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) if len(roi.shape) 3 else roi gray_center cv2.cvtColor(center, cv2.COLOR_BGR2GRAY) if len(center.shape) 3 else center mean_all gray.mean() std_all gray.std() mean_center gray_center.mean() contrast mean_center - mean_all # 梯度方向直方图简化版 gx cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize3) gy cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize3) mag, ang cv2.cartToPolar(gx, gy, angleInDegreesTrue) # 只统计梯度幅值较大的像素 mask mag 20 if mask.sum() 0: hist cv2.calcHist([ang], [0], mask.astype(np.uint8), [18], [0, 180]) hist hist / hist.sum() else: hist np.zeros(18) return np.hstack([mean_all, std_all, contrast, hist.flatten()])这段代码有两个细节值得说第一为什么要分中心区域和边缘区域。凹坑的形状是中心凹陷、周围隆起灰度上中心比边缘暗反光则完全相反。直接拿整个框的均值来区分区分度不够但中心减去边缘的对比度能放大这种差异。第二梯度方向直方图为什么分18个bin。180度方向分18个bin每个bin宽度10度既能保留方向信息又不会因为bin太细导致统计不稳定。实测下来18个bin比9个bin的AUC高约3%比36个bin低不到1%但特征维度更小。3.3 用轻量分类器代替复杂规则拿到特征之后有两种做法一种是直接设阈值比如灰度均值小于某个值且对比度大于某个值就判为凹坑另一种是喂给一个逻辑回归或决策树分类器。我的建议是如果你的样本量很少直接用阈值规则如果候选框数量多几千个以上上分类器更稳。阈值规则的问题是特征之间有相关性比如“灰度均值低”和“对比度低”可能同时发生你很难用手调的方式找到最优决策边界。我用的是逻辑回归输入这组特征输出是否为真实瑕疵。训练数据不需要单独标注直接用YOLO的预测结果配合人工修正标签——YOLO框出来觉得是瑕疵但实际不是的标为负样本YOLO框出来的确实是瑕疵的标为正样本。这种方式能快速积累几百个样本省去从零标注的功夫。逻辑回归的代码很简单from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler def train_filter(X, y): scaler StandardScaler() X_scaled scaler.fit_transform(X) clf LogisticRegression(max_iter1000) clf.fit(X_scaled, y) return scaler, clf def predict_filter(scaler, clf, features): return clf.predict_proba(scaler.transform(features.reshape(1, -1)))[0, 1]别小看这个逻辑回归它在这个场景里把假阳率从15%直接压到了2%以下。YOLO本身的召回率没有变但最终输出给业务系统的精确率有了质的提升。4. 不止是二次验证传统预处理让YOLO更能打4.1 光照不均问题顶帽变换的妙用工业现场打光不可能做到绝对均匀尤其是工件表面有弧度的地方容易出现中心亮、边缘暗的情况。YOLO对这种光照变化很敏感同一个缺陷在不同亮度区域特征可能差异巨大。传统方法里有一个特别适合处理光照不均的工具叫顶帽变换。简单说就是用形态学开运算估计背景光照然后用原图减去背景得到去除光照影响后的图像。def top_hat_correct(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) bg cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel) corrected cv2.subtract(gray, bg) return corrected核大小选15是因为我们工件的暗斑直径大概在10-20像素之间核比目标稍大一圈才能准确估计背景而不把目标吃掉。做完顶帽变换后再喂给YOLO模型在跨光照条件下的mAP直接提升了6个点。后来我甚至直接把顶帽结果和原图做通道拼接让YOLO自己学到底该用哪种信息效果比单用变换图更稳定。4.2 小目标增强CLAHE vs 普通直方图均衡另一个常见问题是小目标对比度太低YOLO在卷积下采样过程中很容易把这些弱特征抹掉。传统图像处理里的对比度增强方法最常用的是直方图均衡化但它有个毛病会把背景噪声一起放大。更好的选择是CLAHE对比度受限的自适应直方图均衡。它的原理是把图像分成多个小块每个小块单独做直方图均衡同时限制对比度增强幅度防止噪声放大。def clahe_enhance(gray, clip2.0, grid(8, 8)): clahe cv2.createCLAHE(clipLimitclip, tileGridSizegrid) return clahe.apply(gray)clip值我一般从2.0开始试grid大小根据目标尺寸调整。如果目标特别小、全图只有几十个像素grid取4x4这样局部增强更充分如果目标稍大8x8更稳妥。这里有个经验做了CLAHE之后最好把增强图和原始灰度图一起作为两个通道输入而不是直接替换。原因很简单CLAHE在增强小目标的同时也会改变背景纹理的灰度分布YOLO可能学到一些虚假的特征。双通道给模型一个选择的余地它自己会权衡哪个通道更可信。4.3 预处理在训练和推理时保持一致这恐怕是最容易被忽略的一点。很多人训练时做了预处理推理时忘了加或者反过来导致模型精度崩盘。我自己的习惯是把所有预处理步骤封装成一个函数训练和推理共用同一份代码。def preprocess(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) corrected top_hat_correct(gray) enhanced clahe_enhance(corrected) return np.stack([gray, enhanced], axis-1)注意模型输入通道数从3变成2YOLO的配置文件里也要对应修改。用这种方法mAP提升会很明显但代价是推理时间增加了大概20-30毫秒。对于大多数产线场景完全能接受。5. 候选框修正用传统分割替YOLO找回精度5.1 YOLO框不准的锅传统分割来补YOLO的检测框本质上是矩形而且预测的是中心点、宽度和高度。对于圆形凹坑矩形框其实绰绰有余但对于长条形划痕矩形框很难贴合边界尤其是在划痕弯曲、分叉的情况下。这种场景我采用的办法是YOLO负责定位划痕的大致区域然后在框内用传统分割算法提取精确前景。5.2 自适应阈值分割提取划痕划痕和背景的灰度对比通常比较明显但在边缘处会有过渡区。用固定阈值要么切割不全要么把背景划进来。我用的是Otsu自适应阈值它在框内自动计算最优分割阈值。def refine_crack(roi_gray): # 先做高斯滤波去噪 blur cv2.GaussianBlur(roi_gray, (5, 5), 0) # Otsu阈值分割 _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 形态学闭运算连通断开的划痕 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations2) contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) return contours拿到轮廓之后可以做几件事用轮廓的最小外接矩形重新生成检测框用轮廓面积过滤小噪点用轮廓的拟合直线计算划痕的角度。5.3 修正后的框对下游任务的影响最直观的影响是长度测量的精度。YOLO原始框的长度和真实划痕长度之间的误差大概在15-25%修正之后误差降到了5%以内。原因是YOLO框只能反映“划痕所在的区域范围”而轮廓提取直接给出了划痕的像素级边界。这里还存在一个实际问题如果划痕断开成几段YOLO可能输出多个框传统分割后可以用轮廓之间的距离判断是不是同一条划痕从而合并。判断规则很简单两个轮廓的最小外接矩形中心距离小于某个阈值并且角度差小于20度就认为是同一条。这种后处理逻辑全部可以写在一个类里结构清晰class BboxRefiner: def __init__(self, merge_dist30, angle_diff20): self.merge_dist merge_dist self.angle_diff angle_diff def refine(self, img, yolo_boxes): refined [] for box in yolo_boxes: contours self._extract_contours(img, box) filtered [c for c in contours if cv2.contourArea(c) 20] if filtered: refined.append(self._contours_to_bbox(filtered)) else: refined.append(box) return self._merge_boxes(refined)关于合并阈值我建议在测试集上做一次统计看不同目标尺寸下的分布再定不要拍脑袋写死。6. YOLO输出置信度分数怎么和传统特征融合6.1 直接相乘没那么简单很多人想到的第一种融合方式是最终分数 YOLO置信度 × 分类器置信度。听起来合理但实际效果往往不佳。因为YOLO的置信度本来就偏高通常输出0.8甚至0.9以上而分类器的置信度区间可能只有0.6-0.9。二者相乘之后分数分布会被YOLO主导传统特征的作用被稀释。更合理的方式是加权求和final_score alpha * yolo_conf (1 - alpha) * clf_confalpha需要你在验证集上搜索最优值。我一般是先设定0.6左右然后在0.3-0.9之间以0.05为步长遍历选F1最高的一组。6.2 用OOD分布来调整阈值还有一个进阶思路YOLO训练集和实际场景的分布差异可以通过传统特征来检测。如果某个候选框的传统特征和训练分布差异很大说明这块区域可能是新出现的干扰YOLO可能也没有见过。逻辑回归输出的概率其实可以映射到一个距离——特征到决策边界的距离。用这个距离阈值能把异常的候选框单独拎出来。比如之前我们遇到过一种新的反光形式YOLO高置信度地误检了但传统特征的决策边界距离明显偏离正常分布。用这个方法提前拦截了很多类似误检比单纯调YOLO阈值有效得多。6.3 工程上如何确保融合后的分数稳定建议不要直接在模型代码里改推理逻辑而是把后处理作为一个独立的服务或模块。因为YOLO和分类器的迭代节奏不同YOLO可能几个月不更新分类器可能每周都要调参。解耦之后调分类器不影响YOLO的推理速度。我的实践是写一个简单的流水线类class HybridDetector: def __init__(self, yolo_model, filter_model, refiner): self.yolo yolo_model self.filter filter_model self.refiner refiner self.alpha 0.6 def detect(self, img): # 阶段1: 预处理 prepared preprocess(img) # 阶段2: YOLO检测 results self.yolo(prepared) boxes results.boxes.xyxy.cpu().numpy() scores results.boxes.conf.cpu().numpy() # 阶段3: 传统特征精修与验证 final_boxes, final_scores [], [] for box, score in zip(boxes, scores): refined_box self.refiner.refine(img, [box])[0] features extract_candidate_features(img, refined_box) if features is not None: clf_score predict_filter(self.filter, features) final_score self.alpha * score (1 - self.alpha) * clf_score else: final_score score final_boxes.append(refined_box) final_scores.append(final_score) return final_boxes, final_scores这样每个模块都能单独调试出了问题也容易定位。7. 踩坑记录这三个坑让我的模型白训练了两周7.1 预处理与YOLO的归一化顺序要一致我最开始写预处理时先做了CLAHE然后直接喂给YOLO。YOLO内部的归一化会将每个通道的像素除以255但CLAHE输出的灰度范围虽然还是0-255局部直方图分布已经完全变了。结果就是模型训练时输入的分布和推理时不统一精度忽高忽低。解决方式很简单把预处理和YOLO归一化当成同一个数据流来处理在预处理函数末尾加一个归一化步骤确保输出分布符合YOLO预期。或者干脆把预处理后的图像存下来看看灰度直方图和YOLO训练时的分布对比一下。7.2 形态学核大小对框合并的影响做划痕轮廓合并时核太小断开的划痕合不到一起核太大会把附近正常的纹理误连成一条划痕。我一开始用了5x5的椭圆核结果两条平行划痕中间距离只有十几像素闭运算直接把它们连成一条了导致计数少了一半。后来我先统计了所有划痕的最小宽度发现大部分在3像素左右而两条相邻划痕的距离至少8像素于是把核大小从5调到了3迭代次数保持2次问题才解决。所以形态学参数不能拍脑袋要实际测量目标尺寸和间距。7.3 逻辑回归的正负样本平衡训练二次分类器时正样本真实瑕疵只有200多个负样本YOLO误检却有1500多个。如果不处理直接训练逻辑回归会倾向于把所有样本都预测为负因为这样总体准确率已经很高了。解决办法有两个第一负样本下采样从1500个里面随机抽300个和正样本比例控制到1.5:1第二对正样本做简单的数据增强比如旋转、亮度抖动翻倍到600个。我实测下来下采样负样本效果更好因为负样本的多样性对决策边界影响很大全部保留会让边界过于保守。8. 这套方案还能怎么扩展传统特征的更多可能性8.1 频域特征傅里叶变换找周期性纹理有些工件表面有规律性纹理比如拉丝、网格。正常纹理在频域里有明显的能量峰值瑕疵会破坏这种周期性。可以用傅里叶变换提取频域特征作为YOLO候选框的另一种判别依据。def extract_freq_feature(roi_gray): f np.fft.fft2(roi_gray) fshift np.fft.fftshift(f) magnitude np.log(np.abs(fshift) 1) # 计算环带能量比中心环低频外环高频 center_energy magnitude[height//2-10:height//210, width//2-10:width//210].sum() total_energy magnitude.sum() return center_energy / total_energy正常纹理的环带能量比通常集中在几个特定的频带而瑕疵会引入新的频率分量这个比值会发生偏移。8.2 局部二值模式的纹理统计局部二值模式LBP是一种古老但有效的纹理描述子。对于表面脏污和真实瑕疵的区分很有帮助。比如油污和凹坑在灰度上都表现为暗斑但油污的纹理更模糊LBP直方图的均匀度更高。如果你觉得手写特征太麻烦也可以直接把LBP直方图喂给一个随机森林。反正原则不变传统方法提取特征机器学习做判断不一定要上深度学习。8.3 和分割模型的三级联动如果预算允许可以引入一个轻量分割模型比如U-Net但只对YOLO框选出来的小区域做分割。这样分工是YOLO找目标传统特征验证分割模型精修边界。这种三级联动在医学影像里很常见在工业检测里其实同样适用。9. 训练流程改进如何让YOLO主动去学传统特征9.1 在损失函数里加一个辅助分支如果你的工程能力比较强可以尝试给YOLO加一个辅助分支让它同时预测候选框内是否存在传统意义上的“异常灰度”。这个分支的输出不是瑕疵类别而是一个二分类标签——该候选框是否需要在后处理中走传统验证流程。加这个分支的本质是让YOLO学习“什么情况下自己拿不准”把这个信息显式地编码到特征图里。训练时把人工标注的“难例”即YOLO预测错了但传统方法判断对了的样本作为标签这样辅助分支能学到这些难例的特征模式。实际效果是YOLO在遇到新的难例时虽然最终分类仍然可能错误但辅助分支已经知道要触发传统验证。相当于给了YOLO一个自我怀疑机制。9.2 数据增强也要考虑传统处理后的格式做数据增强时不能只做水平翻转、旋转还要考虑传统预处理的结果。比如图像经过CLAHE之后原有的一些几何变换参数需要重新调整吗其实不需要。但有一个细节如果做了裁剪那裁剪区域之外的背景会影响到后续的Otsu分割——因为框内区域变了背景灰度分布变了阈值也会变。所以训练时最好也随机模拟这种“框内背景分布变化”比如给候选框内随机加入一些高斯噪声或局部亮度扰动让分类器更鲁棒。10. 结合方案的实际收益一组说服力很强的数据之前那套YOLOv8单独跑的方案在未经优化的情况下精确率Precision88.6%召回率Recall81.2%F1大约0.85假阳率15%左右。加上传统二次验证和候选框修正后同样的测试集1000张图指标单独YOLO传统方法YOLO精确率88.6%97.8%召回率81.2%85.9%F10.850.91单张推理耗时58ms93ms召回率提升主要来自于候选框修正——YOLO漏检的那些长划痕其实在初步检测时是输出了一些低置信度框的但被NMS或置信度阈值过滤掉了。我把置信度阈值调低让更多候选框存活再用传统方法验证就救回来了一部分。精确率提升更明显从88.6%到97.8%。假阳率从15%降到了不到2%产线那边的操作工终于不用每隔几分钟就过去删一个误报警了。这个数据其实也印证了一个观点深度学习和传统方法不是替代关系而是互补关系。深度学习可以很快扫一遍图像告诉你哪里可疑传统方法可以仔细审视这些可疑区域用明确的物理规则告诉你哪些可疑是真实的。11. 什么时候不要硬结合这些场景纯YOLO更快更好不是所有任务都值得加传统方法。下面这几种情况直接纯YOLO就行目标本身结构完整比如车牌识别、人体检测、人脸检测目标框进去就是完整的物体框内部没有需要额外分析的细节。目标差异完全由外观决定比如猫狗分类、不同型号的零件分类这些类别之间的差异是整体的不是局部细微纹理差异。光照环境非常稳定如果场景里光照固定背景单一YOLO本身就能学到稳定的特征传统预处理反而可能破坏原始特征分布。推理延迟要求极高传统后处理即使再快也会增加几毫秒到几十毫秒不等的延迟。如果应用在实时视频流且要求30fps以上就必须掂量一下。标注数据和训练场景高度一致如果验证集表现已经很完美也不存在各种干扰因素那就没必要引入传统方法增加系统复杂度。所以到底要不要结合还是得回到场景本身来回答。我的判断标准是如果存在“深度学习容易误判、但人眼可以用明确规则判断”的情况那传统方法就有价值否则就别给自己找麻烦。12. 最终经验从传统方法和YOLO结合中学到的三件事这套方案做完之后我把整个项目回顾了一遍最深的体会可以浓缩成三条。第一别把YOLO当成万能检测器。YOLO本质上是一个“高效的特征匹配器”它能学到训练集里出现的模式但很难泛化到训练集之外的新干扰。传统方法恰恰提供了这种泛化能力——它不基于学习而是基于物理规则和图像本质。两者结合才能在真实场景里站得住脚。第二特征选择要有业务逻辑。我选择灰度均值、对比度、梯度方向直方图不是随便挑的而是经过和工艺工程师讨论理解了凹坑和划痕的物理形成机制后才对应地设计出这些特征。如果为了用特征而用特征很可能方向就错了。第三传统方法和深度学习的结合应该发生在“流程”层面而不是“模型”层面。把传统处理步骤插在YOLO前后形成一个管线而不是硬改网络结构。这样每个环节都可解释、可调试、可替换。真出了性能问题你很快能定位是YOLO的问题还是后处理的问题而不是在复杂的损失函数里翻来覆去找原因。最后分享一个小技巧。如果你想快速验证传统方法是否对你的场景有帮助别急着训练完整的逻辑回归或随机森林先挑五十张YOLO误检的图手工标注一下哪些是假阳哪些是漏检直接在图上用直方图和边缘检测看特征分布是否可分。如果连明显的分离趋势都看不到那传统方法也救不了你趁早换数据增强策略反而更靠谱。这一步两小时就能做完能帮你省掉一整周的无效折腾。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑