前阵子朋友那边的表面质检工位找我帮忙:铝片来料和成品上总有划痕、凹坑、脏污这些外观问题,人工检测看久了眼睛受不了,而且反光下细小缺陷特别容易漏。当时我脑子里冒出来的第一个方案,就是拿 YOLOv8 搭一套铝片缺陷检测系统。等真正跑通“数据标注—模型训练—推理封装—桌面界面”这条全流程,我才发现这里的坑大多不在模型本身,而在数据采集和工程封装上。
这篇内容适合两类人看:一是想给车间快速落地一个可演示、可试用的表面缺陷检测Demo的工程师,二是自己搞数据集训练目标检测、但一直卡在“模型能用但没法给现场用”这一步的同学。我会把自己从算法选型到界面交付的完整过程拆开讲,代码片段可以直接抄,但更希望你能理解每一步后面的取舍。尤其是数据增强怎么设置、ONNX输出怎么解析、PySide6界面为什么必须用线程,这三块是翻车重灾区。
1. 先搞清楚铝片缺陷的真实长相,数据集的构建才不会跑偏
1.1 缺陷分类不能照搬通用目标检测的类别,要从产线异常反推
很多人拿到这类需求第一反应是去下载公开的表面缺陷数据集。我不是说公开数据不能用,而是铝片这个场景有很强的特殊性:表面反光强、纹理基底干净,但缺陷尺寸差异极大。一条划痕可能细到只有几个像素宽,凹坑可能只是一个小圆点,油污又是一大片渐变脏污。你在公开数据集里找到的类别,和现场真正需要区分的“异常表现”往往对不上。
我第一版把缺陷分成四类:划痕、凹坑、油污类脏污、边角破损。这个分类是从产线质检员嘴里问出来的,他们不看“材料结构异常”这种学术分类,只看“这张片子要不要报废、要不要返工、是哪一类的不良”。分类定太细反而麻烦,比如划痕在强光下和自然光下表现完全不同,硬拆成“细划痕”“粗划痕”会导致样本极度不均衡,标注员也会崩溃。先按外观差异最大的四类进第一版模型,后续根据产线反馈再把容易混淆的类别单独拆分,这才是比较稳的路线。
我整理了一份缺陷表现参考表,训练前建议自己对着实物照片过一遍:
| 缺陷类别 | 典型外观 | 打光下的表现 | 对下游的影响 |
|---|---|---|---|
| 划痕 | 细长线状,方向不固定 | 低角度光下反光明暗对比明显 | 外观不良,严重时影响后续喷涂 |
| 凹坑 | 点状或小片凹陷 | 边缘有阴影,中心高光 | 冲压、成型工序会放大 |
| 油污脏污 | 块状或条状灰度差异 | 与正常铝面亮度不同,边界模糊 | 影响清洁度和后续粘接 |
| 边角破损 | 边缘缺损、毛刺 | 轮廓异常,缺口处反光断裂 | 直接导致装配不良,优先级最高 |
1.2 采集和标注:打光、分辨率、标注规范一个都不能省
数据采集这件事我一开始低估了。朋友那边最初给我的图片是车间手机随手拍的,光线乱七八糟,铝片上既有车间顶灯的反光,也有手部阴影。这种图里模型很容易把“反光条”当成“划痕”,因为两者的梯度特征高度相似。后来我让他们用固定支架配合环形光源重新拍了一批,环形光的好处是能把划痕的明暗对比稳定呈现出来,缺陷在图片里的样子不再随拍摄角度剧烈变化。如果你没有工业光源,至少也要在单一方向灯下固定拍摄角度,保证同一类缺陷在不同图片里看起来一致。
分辨率方面,我建议图片不要低于 1280×960。铝片上的细划痕在 640×480 的图里往往只剩两三个像素宽,训练时再缩放到 640 输入,特征基本就丢没了。标注工具我用的是开源标注工具,快捷键设置好后效率不低;标注规范一定要提前定好:细长缺陷用外接矩形框,框可以比缺陷大一点,但绝对不能截断缺陷;同一张图上多个缺陷重叠时,每个缺陷都要单独标,不要合并成一个框。这些规范如果在标注中途改,前期所有框都要返工,很痛苦。
还有一个特别容易被忽略的点:数据集划分不能随机划分,要按铝片ID划分。同一张铝片多角度拍出来的图,如果一部分进了训练集、一部分进了验证集,模型等于提前“见过”这张铝片,验证指标会虚高得离谱。我第一版就是这么干的,mAP看起来不错,一上现场立刻原形毕露。按工件维度划分之后,指标才真正反映模型对不同铝片的泛化能力。
1.3 数据增强的边界:别把细划痕“增”没了
YOLOv8默认的数据增强很强,但强是给通用物体检测设计的,搬到表面缺陷检测上要做减法。我最开始直接用默认参数训练,色域增强太大,金属铝面的色调、饱和度被随意偏移,油污和氧化色差的特征都被“洗”掉了。后来把HSV扰动幅度大概压了50%,误检明显下降。注意这不是说增强没用,而是增强幅度要匹配“缺陷特征本来就微弱”这个现实。
更难办的是细划痕。低对比度、低像素占比的目标,在mosaic增强里会被进一步缩小,甚至缩到只剩一两个像素,模型根本学不到东西。我的做法是保留一批原图不经mosaic直接参与训练,同时在训练后期让模型完全脱离mosaic,把注意力收回到真实分布上。
负样本也一定要加。很多教程只讲“正样本”,但表面缺陷检测里的误检,很大一部分来自“正常但反光强烈”的铝片。我单独整理了一批正常铝片图,不打任何标注,混在训练数据里让模型明白“高光区域不等于缺陷”。这一步对后期压误检的帮助,比我调任何超参都大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. YOLOv8选型的判断过程:为什么是它,以及训练里的关键取舍
2.1 YOLOv8与v5、v9的取舍,我从部署效率角度看
模型选型时我认真对比过YOLOv5、YOLOv8和YOLOv9。YOLOv5足够经典,社区资料多,坑都被踩得差不多了,但它是Anchor-Based结构,接口和推理代码相对松散;YOLOv9在精度上有不少新东西,可成熟案例和部署生态在当时还没跟上;YOLOv8是Anchor-Free结构,训练、验证、导出、预测在同一个框架里能用一套命令走完,封装得比较完整。对一个目标是把模型装进桌面界面、快速交付现场的项目来说,YOLOv8的总拥有成本最低。
如果你只是做算法对比实验,选哪个都可以。但你要做的是一个从数据到界面的系统,不是一篇论文,那就得考虑“训练完之后谁来做推理封装”。YOLOv8的yolo命令一行就能导出ONNX,输出格式相对固定,社区里能查到的解析代码也多,后续出了问题容易排查。工业项目里“可维护性”往往比“指标高零点几个点”重要得多。
2.2 预训练权重和输入分辨率:小数据量下的第一道选择题
模型规模这块,我开始犯了个常见的错误。数据量大概三千张,我第五个epoch就想换大模型,觉得模型越大精度越高。后来发现完全不是这么回事:在工业现场跑桌面软件,推理速度直接影响工位节奏,大模型带来的精度收益在小数据量下也很有限。最后我采用的组合是yolov8n预训练权重加imgsz=960输入,精度接近yolov8m加imgsz=640的组合,速度却快不少。这里的本质是“高分辨率小模型”和“低分辨率大模型”的权衡:缺陷是小目标,分辨率提升带来的收益通常比模型容量提升更直接。
显存不足的时候也别硬拉大batch。batch=16跑不动就降到batch=8,再配合梯度累积。表面缺陷数据集通常不会特别大,小batch多几个step并不会显著拖慢训练,但BatchNorm的统计量会稳定很多,后期val曲线不会乱跳。
我给出一份可以直接抄的数据集配置文件,放在datasets/aluminum_defect/aluminum.yaml:
yaml复制path: D:/datasets/aluminum_defect
train: images/train
val: images/val
names:
0: scratch
1: dent
2: oil_stain
3: edge_break
训练命令参考这样写:
bash复制yolo detect train \
data=aluminum.yaml \
model=yolov8n.pt \
epochs=120 \
imgsz=960 \
batch=8 \
device=0 \
mosaic=0.6 \
close_mosaic=10 \
patience=20
2.3 训练超参:控制增强、早停和损失权重的基本思路
上面命令里的几个参数是我反复试出来的。mosaic=0.6是刻意调低的,因为缺陷检测的目标尺寸变化范围不像通用目标检测那么大,mosaic比例太高反而容易让细划痕被“拼”没了。close_mosaic=10意思是最后10个epoch不再使用mosaic增强,让模型从增强分布平稳过渡到真实分布,收敛更稳定。patience=20是早停参数,训练后期模型经常出现过拟合,指标不再变好时自动停,能省不少时间。
训练一轮后,我拿到的参考指标大致是这个水平:
| 缺陷类别 | 样本数 | mAP@0.5 |
|---|---|---|
| scratch | 1200 | 0.93 |
| dent | 500 | 0.89 |
| oil_stain | 700 | 0.88 |
| edge_break | 300 | 0.94 |
这个数据不通用,但能给新手一个大致预期:缺陷类别如果只有几个像素大,mAP@0.5在0.85以上就算能用了,真正能不能上现场还得看漏检和误检的具体表现,别只看平均值。训练日志里除了看mAP曲线,我还会把验证集的预测结果图翻出来逐张看,很多漏检是mAP曲线完全反映不出来的。
损失权重这块我建议第一版先不改,用默认值。默认的box、cls、dfl权重是在大量数据集上调出来的,你的数据分布再特殊,第一版也不至于差到需要调损失权重。只有当模型出现“框很准但分类错”或“分类对但框偏移大”这种明显倾向时,才去动cls或box的权重。上来就调参,往往是在没有足够证据的前提下瞎折腾。
3. 把模型从训练脚本变成稳定推理服务:导出与封装中的细节
3.1 导出ONNX时最容易忽略的三个参数
训练结束后,产线电脑上不可能都装PyTorch和GPU驱动,所以我选择把模型导出成ONNX,再用ONNXRuntime来做推理。导出命令很简单:
bash复制yolo export model=runs/detect/train/weights/best.pt format=onnx opset=12 imgsz=960
但三个细节一定要确认。第一,opset别设太高,我在开发机上用了opset 17,导出没问题,结果拿到车间一台老电脑上直接报算子不支持。后来固定在opset=12,兼容性好很多。第二,输入分辨率在导出时就已经固化了,如果导出是imgsz=960,推理前就必须把图片缩放到960并做letterbox填充,否则出来的坐标全是偏的。第三,输出维度长度是4 + 类别数,不是固定的80。我的数据集只有4类,输出是8维,套用网上通用的COCO 80类解析代码会全部算错。导出后用Netron看一眼输出shape,能省一晚上的排查时间。
这里也说下为什么不推荐在界面里直接调用YOLO("best.pt")。方便是真方便,但每次实例化都会带上框架层面的预处理和后处理,出问题时调试很不透明,显存释放也不够干脆。我自己封装一个轻量Detector类,反而对每一步都心里有数。
3.2 Detector推理类的封装:从输出张量到可用的检测框
核心代码框架大致是这样的:
python复制import cv2
import numpy as np
import onnxruntime as ort
class AluDetector:
def __init__(self, onnx_path, conf_thres=0.2, iou_thres=0.45):
self.conf_thres = conf_thres
self.iou_thres = iou_thres
self.input_size = 960
self.session = ort.InferenceSession(
onnx_path,
providers=["CPUExecutionProvider"]
)
def preprocess(self, img):
# BGR转RGB、letterbox缩放、归一化、转CHW并增加batch维度
...
def postprocess(self, output, scale, pad):
# output shape: (1, 8, 18900)
# 8 = 4个坐标 + 4个类别分数
# 转置后按置信度过滤,再按类别做NMS
...
后处理这里我多说几句。YOLOv8的输出是(1, 8, 18900),其中18900是三个尺度特征图展平后相加的结果,960分辨率下大概是120×120加60×60加30×30。拿到输出后先转置成(18900, 8),把中心点加宽高的坐标形式转成左上角加右下角的xyxy形式,再用置信度过滤一次,最后按类别分别做NMS。NMS的IoU阈值我设在0.45,比通用检测常用值略高一点,因为同一缺陷位置可能出现多个重叠框,阈值太低会把相邻的重复框全保留下来。
3.3 为什么没有直接把推理逻辑写进界面代码里
这算是我这次项目里比较得意的一个设计决策。推理逻辑独立成类,界面层、命令行脚本、未来的Web接口都可以复用同一个Detector,不用重写。另一个好处是排查问题时有清晰边界:如果界面显示“检测不到框”,先单独跑一段脚本喂同一张图给Detector,看是模型问题还是界面问题,而不是在一堆UI代码里翻找原因。
ONNXRuntime在CPU机器上还有两个实用配置。一是intra_op_num_threads,直接决定CPU占用率和单帧延迟的平衡,车间电脑是四核的话设4,吞吐和流畅度都比较合适;二是providers里不用把CUDA放前面,如果机器上没装GPU环境,代码会在初始化时报错。用CPUExecutionProvider反而最稳。工业现场的环境乱七八糟,能少一个依赖就少一个依赖。
4. 让车间师傅愿意用:PySide6检测界面的设计与线程处理
4.1 功能边界:不要试图把检测界面做成万能软件
到了界面这一步,我的第一原则是做减法。车间场景真正需要的只是几件事:选一张图看结果、选一个文件夹批量跑、把检测结果显示成红色框加“OK/NG”结论、最后把历史记录导出来。摄像头实时检测不是必须的,统计报表也不是越复杂越好。我最初还加了一个“放大镜”功能,后来师傅反馈说没怎么用,因为缺陷图本来就保存在本地方便查看,界面主要起到确认和记录的作用。
框架上我选了PySide6。Gradio或Flask做个Web页面确实快,但车间电脑要求离线可用,不能让师傅每次打开一个检测页面都先启动Python服务再开浏览器。PySide6打包后是个独立的桌面程序,双击就能用,文件写入权限好控制,后续加自定义配置也方便。如果你的现场明确允许局域网Web访问,走Flask也行,但在我这里桌面方案是更顺手的路径。
界面布局就是我常用的“左图右参数、下方表格”结构:左侧大图区域负责显示原图和检测框,右侧放置信度阈值、IoU阈值、NG判定阈值,下方是图片列表和检测结果表格。大图反馈是最重要的,师傅不关心算法细节,只看“框有没有画对地方、结论是不是符合直觉”。
4.2 用QThread保障界面的流畅性和推理的安全性
界面和推理之间最重要的一件事就是线程。GUI的事件循环不能阻塞,但检测循环是重度计算,如果你直接把推理放在按钮的点击回调里,界面会直接卡死,拖动窗口都像幻灯片。解决办法是把推理放到QThread里,用Signal把结果传回主线程:
python复制class DetectWorker(QThread):
result_ready = Signal(str, np.ndarray, list)
def __init__(self, image_path, detector, parent=None):
super().__init__(parent)
self.image_path = image_path
self.detector = detector
def run(self):
boxes, scores, labels = self.detector.infer(self.image_path)
result_img = draw_boxes(self.image_path, boxes, labels, scores)
status = judge(boxes, labels)
self.result_ready.emit(status, result_img, boxes)
这里有两个隐蔽坑。第一,Detector不能在run()里每次创建,ONNXRuntime的Session初始化很慢,而且同时开多个Session会占大量内存;应该把它做成全局单例,界面初始化时创建一次。第二,传回主线程的QImage或numpy数组要深拷贝,信号槽传对象时如果传的是同一个底层缓冲,等主线程显示时数据可能已经被释放了,表现就是界面偶尔黑屏或花屏。两种问题我都遇到过,排查起来都很费时间。
批量检测时还要注意并发控制。一开始我用多个线程同时跑批量任务,结果CPU在核显环境下直接打满,检测结果偶发错乱。后来改成单任务队列,上一张处理完再处理下一张,界面用一个“开始检测”按钮配进度条,看起来朴素但稳得很。
4.3 良品/坏品的二次判定逻辑:模型给的是分数,产线要的是结论
模型输出的是缺陷类别、框和置信度,但产线问的是“这个铝片要不要判NG”。这两个问题之间需要一层业务规则。我在界面里加了一个judge()函数,规则大致如下:
| 缺陷类别 | 判定策略 | 默认参数 |
|---|---|---|
| edge_break | 出现任意一个框即NG | 无面积下限 |
| dent | 数量超过1个,或单个面积占比大于阈值 | 面积占比0.02 |
| scratch | 单条长度占比超过阈值,或总面积占比超阈值 | 长度占比0.15 |
| oil_stain | 面积占比超过阈值 | 面积占比0.05 |
这套规则完全在模型之外,好处是调规则不用重训模型。不同客户对缺陷的容忍度差异很大,有些要求“一个凹坑都不行”,有些只看“会不会影响下一道工序”,把规则做成界面里的可调参数,到现场改起来非常快。模型负责“看见缺陷”,规则负责“决定要不要”,两层拆开以后,整个系统的可维护性会好很多。
4.4 记录与报表:让每一次检测都可追溯
检测结果一定要留痕迹,不然出了问题说不清楚。我用SQLite记录每次检测,表结构很简单:
sql复制CREATE TABLE records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_path TEXT,
result TEXT,
defects TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
批量检测结束后,把结果导出成CSV给质检部门。字段包括图片路径、判定结果、检出的每个缺陷类别和置信度、检测耗时。注意检测后的标注图也要保存下来。这些图不只是给人看的,更是下一轮训练迭代的难例池。我已经数不清有多少次是靠历史记录里的漏检图重新补标注、重新训练,才把模型一点点磨到能用的。
5. 实测阶段反复翻车的三个坑:漏检、误检、界面卡顿
5.1 漏检率高的排查链路:参数、分辨率、标注三层归因
模型第一版在验证集上mAP看着还行,一到现场漏检率就上来了。我没有直接去调阈值,而是先做了一次完整的漏检归因。步骤大概是这样的:先把测试集里被漏检的图全部导出来,逐张看,发现漏检高度集中在两类样本:浅色背景上的细划痕,以及距离相机较远的小面积缺陷。
第一步检查标注,发现部分细划痕在标注时确实是标过的,但训练时被色域增强把对比度抹平了,模型根本没学到特征。于是我把HSV增强幅度下调,又单独给划痕类加了平移和轻微旋转增强。第二步把置信度阈值从0.25降到0.15,召回确实涨了,但误检也开始冒头。第三步把输入分辨率从640提到960,小划痕的召回提升非常明显,单帧耗时从大概45毫秒涨到110毫秒,对桌面交互来说仍然可接受。第四步把现场漏检图补标注后加入训练集,模型又涨了一截。
这条排查链路走下来,我的结论是:漏检很少是单一原因造成的,但绝大多数时候“数据质量”排在“模型能力”前面。先看漏检图本身是不是模型没见过、标注是不是不规范,再谈调参。如果一上来就降置信度,只会用一种误检换另一种漏检。
5.2 误检集中的场景:反光区与边角毛刺,得靠数据和规则同时压
误检比漏检更难处理,因为它往往来自训练数据里没有覆盖到的“正常背景”。我第一版模型把两个地方的误检特别严重:一是铝片上的高光反射区,二是边缘毛刺区域。高光反射的梯度和划痕非常像,模型被逼着在这些区域找缺陷,当然会误报。
针对高光误检,我补了一批“表面正常但有明显反光”的负样本进训练集,没有标注任何框,模型在后续迭代里很快学会了把这类区域当作背景处理。针对边缘毛刺误检,我在规则层加了“边缘区域忽略”的临时方案,把图片上下左右固定范围的检测结果过滤掉。这个方案只能作为兜底,因为现场光照变化后反光区位置也会变,但作为第一版上线方案是够用的。
这里要强调一个取舍:不要为了压误检把置信度阈值拉到很高。表面缺陷里的划痕本来就和正常纹理容易混淆,阈值太高会把真实缺陷也过滤掉。我宁愿让界面保留少量误检,让师傅在检测结果列表里二次确认,也不会让机器把漏检风险放大。在工业质检里,漏一件不良品出去,比多拦一件良品回来麻烦得多。
5.3 界面卡顿不是显示问题,是线程和对象生命周期问题
界面卡顿这个问题,我一开始还以为是图片显示控件性能不行,折腾半天渲染优化,结果发现根因全在别的地方。第一阶段是把推理放在UI线程里直接跑,界面自然卡死。这个最容易定位,把推理挪到QThread就解决大半。
第二阶段发现花屏和偶发黑屏,排查后发现是信号里传的图片内存被提前释放了。Qt信号槽默认传的是引用或者浅拷贝,工作线程里继续处理的numpy数组生命周期和主线程不一致,主线程拿到时数据已经不是原来的内容了。解决办法是信号发射前做一次img.copy(),为显示单独保留一份数据。
第三阶段是批量检测时CPU被打满,检测结果乱掉。这个前面提过,是多个推理线程同时访问同一个OnnxRuntimeSession导致的。我之前为了图快用了QThreadPool,后来改回单任务队列,同时用intra_op_num_threads限制了ONNXRuntime的线程数,界面交互才真正流畅起来。回头看,这三个问题没有一个出在“模型不准”上,全是工程封装的经验问题,也正说明全流程项目里工程实现一点不比算法轻松。
做完这套系统几个月后,我最大的感受是:从数据到界面的全流程,最大的成本从来不是训练模型,而是数据状态的整理和界面工程里的隐形坑。如果让我再做一遍,我会先花三天把采集、标注、划分规范彻底定死,再花一天训练,留一周处理工程细节,顺序反过来基本都要返工。下一步我打算把现场漏检样本做成在线回流机制,再把判定规则开放成可编辑的配置文件,让不同产线能自己调整容忍度。这套体系跑稳之后,缺陷检测这件事才算真正从“算法Demo”变成了“能用、敢用、愿意用”的工具。
