简介:面向计算机科学与技术等相关专业学生与深度学习初学者,这份毕业设计文档给出了一套可参考的完整课题实现路径:将YOLOv8目标检测框架用于印制电路板缺陷识别,替代低效且易受人为因素影响的人工目检。文档依次展开绪论、关键技术、系统分析、系统设计与实现等章节,涵盖Python开发环境与PyCharm工具选型、可行性分析、功能与非功能需求、数据集准备与标注、YOLOv8网络结构、损失函数及训练推理流程,并通过实验对比检测速度与准确率,讨论应用前景与改进方向,可作为选题开题、方案设计与论文写作的范本。压缩包共1个docx文件,约3.66MB,含中英文摘要、关键词与目录,结构完整,便于直接修改套用。该资源已有502人学习下载,适合需要快速搭建缺陷检测课题框架、梳理技术路线与撰写规范论文的读者参考借鉴。
1. 600 张图、6 类缺陷、mAP@0.5 0.92:这套 YOLOv8 PCB 检测毕设能不能直接复现
做毕业设计找资源,最怕拿到的是只有论文没有代码的空壳;做工程的看这类包,第一反应是这东西离产线还差多远。手上这套基于 YOLOv8 的 PCB 板缺陷检测系统,骨架算清楚:600 张板卡缺陷图按 5:1 切成 500 张训练、100 张验证,覆盖缺失孔、老鼠咬痕、开路、短路、毛刺、铜渣六类缺陷,训完的权重挂到一层桌面界面上,支持单图、批量、视频、摄像头四种输入,结果可批量落盘。论文给出的指标是 mAP@0.5 各类别均在 0.86 以上、平均 0.92。
它适合三类人:赶毕业设计需要一条从数据到界面的完整链路的人;想把工业质检里的小目标检测流程走一遍的人;以及手里已经有一批板卡图、想找个能改的基线的人。不适合指望开箱即上产线的人——600 张图的量级只够验证方法可行性,真上产线数据量和标注规范都得重做。下面按「拆包看结构 → 数据怎么备 → 模型怎么训 → 界面怎么接 → 坑在哪 → 怎么验收」的顺序走一遍。
2. 拆包先看骨架:C2f、Anchor-Free 与六类缺陷标签怎么对上
拿到压缩包先别急着 pip install,花十分钟把目录结构和依赖看清楚,能省掉后面一半的报错。这套资源的组织方式属于常见做法:训练脚本、推理脚本、界面代码、权重、数据集配置各占一块,requirements 里锁定了 ultralytics、torch、opencv、PyQt 这几样。判断它是不是「能跑的空壳」,看两点就够了——有没有训练好的 .pt 权重,有没有一份类别数和标注文件对得上的 data.yaml。
2.1 目录骨架与依赖环境,先跑通一次前向再说
典型的包内结构大致如下,具体文件名以你手上那份为准,但功能分区基本一致:
| 路径 | 作用 | 缺失后的典型症状 |
|---|---|---|
weights/best.pt | 训练好的检测权重 | 界面能开,点检测没反应或直接抛异常 |
datasets/pcb/images、labels | 图片与 YOLO 格式标注 | 训练启动即报「No labels found」 |
data.yaml | 类别名、类别数、训练/验证路径 | 类别数对不上,输出永远是单一类别 |
train.py/detect.py | 训练与单图推理入口 | 只能看界面,改不了参数 |
ui/或main.py | PyQt 界面与四个检测入口 | 只有算法没有演示,答辩演示环节缺一半 |
requirements.txt | 依赖清单 | 版本随意装,torch 与 CUDA 打架 |
环境这一步我一般的顺序是:先确认显卡驱动与 CUDA 是否可用,再装 torch,最后装 ultralytics。顺序反了,pip 会顺手给你拉一个 CPU 版 torch,训练能启动但慢到怀疑人生。
# 1) 建独立环境,别往 base 里塞 conda create -n pcb_yolo python=3.10 -y conda activate pcb_yolo # 2) 先装与显卡匹配的 torch(示例为 CUDA 11.8 轮子,按你机器的驱动版本改) pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 3) 再装 ultralytics 与界面依赖,不写死版本号,装当前稳定版 pip install ultralytics opencv-python pyqt5 pandas # 4) 验证 GPU 是否真的被识别 python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.device_count())"最后一行输出里True才是真的能用 GPU。如果打印的是False,先别改代码,回头查驱动与轮子版本,这一步排查掉,后面训练速度能差出五到十倍。
环境好了之后加载一次权重,做一次「模型自检」,把输入输出形状、类别名、参数量都打出来。这一步比直接开界面有用得多,因为界面会把异常吞掉,而自检会把异常原样抛给你。
from ultralytics import YOLO # 加载包内权重;换成 yolov8n.pt 则是加载官方预训练权重用于对照 model = YOLO("weights/best.pt") # 打印类别映射:确认 6 类缺陷的 id 与顺序和你的认知一致 print(model.names) # 打印模型信息:层数、参数量、GFLOPs,用来判断是不是被换成了很小的模型 model.info() # 跑一次空推理,看输入输出是否正常(全灰图,只为验证链路) import numpy as np dummy = np.full((640, 640, 3), 128, dtype=np.uint8) r = model.predict(dummy, conf=0.25, verbose=False) print(r[0].boxes.shape, r[0].speed)model.names打印出来的字典是后面所有工作的基准,类别顺序错了,训练再久也是白练。r[0].speed里会给出预处理、推理、后处理三段的毫秒数,这个数字直接决定视频检测能不能跑到实时,后面调参会反复用到。model.info()的参数量如果只有两三百万,说明这是 nano 级别的模型,精度上限就摆在那,别拿它跟论文里的指标硬比。
2.2 C2f、Anchor-Free 与 Decoupled-Head:为什么这套结构适配 PCB 缺陷
YOLOv8 属于单阶段检测器,主干负责提特征,颈部做多尺度融合,头部直接回归框和类别。三个改动对 PCB 场景影响最大。
第一是主干里的 C2f 模块。它把特征分成两路,一路走卷积堆叠、一路直连,最后拼接,梯度可以从多条路径回流。PCB 缺陷里有相当一部分是细长结构(开路、短路走线)和极小斑点(铜渣、缺失孔),这类目标在深层特征图里很容易被稀释掉,梯度分流对这种「少量像素决定类别」的样本更友好。
第二是 Anchor-Free。传统 YOLO 版本要预设一组锚框尺寸,先聚类出长宽比,再让网络在锚框基础上做偏移。PCB 缺陷的长宽比分布非常散,一条短路的框可能是一比十几,一个缺失孔接近正方形,锚框聚类很难同时覆盖。去掉锚框之后,网络直接在特征点位置上回归到框的四条边距离,配 DFL(Distribution Focal Loss)把边界做成分布回归,对细长目标的边界定位更细腻。
第三是 Decoupled-Head,分类分支和回归分支各走各的卷积,不再共享最后一层。分类关心「这是什么」,回归关心「边界在哪」,两者解耦之后在小目标上互相干扰会小一些。配合 TaskAlignedAssigner 做正负样本分配,按分类得分与定位质量的联合指标挑正样本,避免大量低质量正样本把回归带偏。
损失这边是 VFL(分类)+ DFL 与 CIoU(回归)的组合。这里不需要背公式,只要理解一件事:分类损失对难样本加权,回归损失同时看重叠度和中心点距离。放到调参上,意味着你改box和cls的增益系数时,本质上是在「框得准」和「分得对」之间调平衡,PCB 场景类别不均衡明显时,优先动cls。
但这套结构有明确边界:640 分辨率下,一个铜渣可能只剩 6×8 个像素,C2f 再强也提不出稳定特征。所以论文里 0.92 的均值是在它自己的数据分布上得到的,换成产线上更低对比度、更小尺寸的板卡图,掉点是正常的。想在小目标上再要一截,方向是提高输入分辨率或做切片推理,不是换更大的模型。
2.3 六类缺陷的标签 id:先定死再开训
类别定义必须一次定死。这套资源的六类缺陷大致对应如下,中文名以包内data.yaml为准:
| id | 英文标签(示例) | 中文 | 形态特征 | 易混类 |
|---|---|---|---|---|
| 0 | missing_hole | 缺失孔 | 小、圆、面积小 | 铜渣 |
| 1 | mouse_bite | 老鼠咬痕 | 边缘缺口、不规则 | 毛刺 |
| 2 | open_circuit | 开路 | 细长、沿走线 | 短路 |
| 3 | short | 短路 | 细长、跨线 | 开路 |
| 4 | spur | 毛刺 | 突出的小尖刺 | 老鼠咬痕 |
| 5 | copper | 铜渣 | 小面积残留 | 缺失孔 |
两个实战建议。其一,命名统一用英文小写下划线,别混中文和空格,标注工具与训练脚本对路径的容忍度不一样。其二,如果标注量不大且毛刺/老鼠咬痕经常标混,先合并成一个大类跑一轮基线,拿到能用的召回之后再拆开重训,比一上来就六类互相打架要快。拆分类时只改data.yaml的names和标签文件里的首列,别动图片目录结构。
提示:类别 id 必须从 0 开始连续递增。把 id 从 1 开始写,训练不会报错,但推理结果会整体错位一类,是最难查的一类问题。
3. 数据集与训练:600 张图怎么切、怎么标、怎么把 mAP 顶到 0.86 以上
数据这一章是整套资源的价值核心,也是最容易被忽略的部分。500 张训练 + 100 张验证这个 5:1 的切法本身没问题,问题在于「怎么切」和「切完之后验证集还剩多少」。这一步做扎实,后面调参才有意义。
3.1 5:1 随机划分的隐患与按类别分层抽样
600 张图、六类缺陷,平均一类 100 张。但真实标注分布从来不是均匀的——缺失孔和铜渣往往占大头,开路和短路可能各只有三四十张。纯随机切,验证集里某类可能只剩四五个实例,单个类别的 AP 抖动会非常夸张,你在训练日志里看到的「mAP 忽高忽低」,很大概率不是模型的问题,是验证集太小。
我一般用按类别分层的方式切,保证每一类在验证集里的占比与全集一致。下面是可直接跑的划分脚本,思路是扫描标签目录统计实例数,再按实例做分层抽样,最后输出两个 txt 清单。
import os import random from collections import defaultdict from pathlib import Path random.seed(42) # 固定种子,保证每次划分结果一致,便于复现实验 IMG_DIR = Path("datasets/pcb/images") LBL_DIR = Path("datasets/pcb/labels") VAL_RATIO = 0.2 # 1) 统计每张图包含哪些类别,一张图可能同时有多种缺陷 img_to_classes = defaultdict(set) for lbl in LBL_DIR.glob("*.txt"): with open(lbl, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue cls_id = int(float(line.split()[0])) img_to_classes[lbl.stem].add(cls_id) # 2) 按类别分组,每组内部打乱后按比例抽取,作为验证集的候选 per_class = defaultdict(list) for stem, classes in img_to_classes.items(): for c in classes: per_class[c].append(stem) val_stems = set() for c, stems in per_class.items(): stems = sorted(stems) random.shuffle(stems) k = max(1, int(len(stems) * VAL_RATIO)) # 每类至少留 1 张进验证集 val_stems.update(stems[:k]) # 3) 含多类别的图会被重复抽到,这里保证训练/验证互斥 train_stems = [s for s in img_to_classes if s not in val_stems] val_stems = list(val_stems) # 4) 写清单文件,训练时直接用 txt 指定,不依赖目录结构 Path("splits").mkdir(exist_ok=True) Path("splits/train.txt").write_text( "\n".join(str(IMG_DIR / f"{s}.jpg") for s in sorted(train_stems)), encoding="utf-8") Path("splits/val.txt").write_text( "\n".join(str(IMG_DIR / f"{s}.jpg") for s in sorted(val_stems)), encoding="utf-8") print(f"train={len(train_stems)} val={len(val_stems)}")逻辑有三处值得说明。random.seed(42)是后悔药,论文里写「随机划分」但实际每次结果不同,答辩被问一句就露馅,固定种子才能复现。max(1, ...)保证样本极少的类别在验证集里至少有一张,否则那类的 AP 直接是 0,均值被拉低。第三步的去重很关键,同一张图不能既在训练集又在验证集,否则指标虚高,这是数据泄漏最常见的形式。参数方面,VAL_RATIO在数据量少于 1000 张时不要低于 0.15,验证集太小指标不可信;数据量上千之后可以降到 0.1。
3.2 标注格式与 data.yaml:一份能直接喂给 YOLOv8 的配置
YOLO 格式的标签是同名 txt,每行一个目标:class_id x_center y_center width height,后四个都是相对图片宽高归一化到 0~1 的浮点数。用标注工具导出时最容易出两种错:一是坐标没归一化(值大于 1),二是类别名映射到了错误的 id。所以训练前必须跑一次标签体检。
from pathlib import Path LBL_DIR = Path("datasets/pcb/labels") bad = [] for lbl in LBL_DIR.glob("*.txt"): for i, line in enumerate(lbl.read_text(encoding="utf-8").splitlines(), 1): if not line.strip(): continue parts = line.split() if len(parts) != 5: bad.append((lbl.name, i, "字段数不是5")) continue cls_id = int(float(parts[0])) coords = [float(x) for x in parts[1:]] # 类别 id 越界,或坐标未归一化(大于1),或宽高为0 if cls_id < 0 or cls_id > 5: bad.append((lbl.name, i, f"类别id越界:{cls_id}")) if any(c < 0 or c > 1 for c in coords): bad.append((lbl.name, i, "坐标未归一化")) if coords[2] <= 0 or coords[3] <= 0: bad.append((lbl.name, i, "宽高为0")) print(f"问题条目 {len(bad)} 条") for b in bad[:20]: print(b)体检结果为空再往下走。这份脚本查的是格式问题,查不出语义问题——框标错位置、类别标反,只能靠抽检可视化。抽检方法很简单:把标签画回原图看几十张,PCB 这种规则纹理的图,框偏一点点肉眼一眼能看出来。
配置文件按下面的结构写,路径用绝对路径最稳,相对路径在切换工作目录后经常找不到:
# data.yaml path: /home/user/pcb_yolo/datasets/pcb # 数据集根目录,绝对路径 train: /home/user/pcb_yolo/splits/train.txt # 上一步生成的训练清单 val: /home/user/pcb_yolo/splits/val.txt nc: 6 # 类别数,必须与 names 长度一致 names: 0: missing_hole 1: mouse_bite 2: open_circuit 3: short 4: spur 5: coppernc和names长度不一致时,训练不一定报错,但评估阶段会崩在类别映射上。改类别定义之后,务必回头核对一遍已经训好的权重,旧权重的输出维度对不上新配置,这是换类别后最常见的翻车点。
3.3 训练命令与超参:三次对照实验把基线立住
不要一上来就冲论文指标。我一般的打法是三次对照:第一次用 nano 权重、640 分辨率跑通链路;第二次把分辨率提到 1024 看小目标召回变化;第三次换 s 级别权重看精度与速度的平衡点。三次都记录下来,答辩时「为什么选这个配置」就有话说了。
# 第一次:跑通链路,确认数据、标签、路径全对 yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ device=0 \ workers=4 \ project=runs/pcb \ name=baseline_640# 第二次:提高输入分辨率,针对铜渣、缺失孔这类小目标 yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=1024 \ batch=8 \ lr0=0.01 \ patience=40 \ close_mosaic=15 \ cache=True \ device=0 \ name=exp_1024参数逐个说清楚。imgsz是输入边长,640 是默认值,1024 对小目标更友好但显存占用约翻倍,batch 要相应降下来,否则直接 OOM。epochs给到 100~150 是常规量级,600 张图的规模很快就会过拟合,靠patience早停兜底,验证指标连续 40 轮不涨就停。close_mosaic=15表示最后 15 轮关闭马赛克增强,让模型在真实分布上收敛,这个参数对最终指标的影响比很多人想象的大。cache=True把图片缓存进内存,小数据集上能明显缩短每个 epoch 的时间,内存不够就关掉。workers在 Windows 上建议设 0 或 2,设太高容易卡在数据加载上。
3.4 曲线怎么读:PR、混淆矩阵与 mAP50-95 的取舍
训练跑完后runs/pcb/exp_*/下面会有一堆图,真正需要看的是四样。
results.csv里看metrics/mAP50-95和train/box_loss、val/box_loss的关系。训练损失持续降、验证损失先降后升,是典型过拟合,这种时候要减模型或加增强,不是加轮数。两条曲线一起震荡且幅度很大,多半是 batch 太小或学习率太高。
PR 曲线看每个类别的形状。曲线靠右上且拐点不明显,说明该类在不同阈值下都稳定;某个类别曲线塌在左下,先别调模型,回去数这一类在验证集里有几个实例,很可能样本太少。
混淆矩阵看错检流向。如果「短路」大量被判成 background,说明召回不足,降conf阈值或增加该类样本;如果「毛刺」和「老鼠咬痕」互相大量混淆,说明两个类别在视觉上本身界限模糊,标注规范需要重新对齐一遍再训。
mAP50 和 mAP50-95 要一起看。mAP50 只要求 IoU 大于 0.5 就算对,宽松;mAP50-95 从 0.5 到 0.95 取十个阈值平均,考验框的贴合度。PCB 场景里如果 mAP50 有 0.9 而 mAP50-95 只有 0.5,说明「能检出来但框不准」,工业上裁切复检会直接受影响,这时该加回归损失权重或提高分辨率,而不是继续刷 mAP50。
4. 集成四个检测入口:单图、批量、视频流与摄像头的落地写法
论文里那部分「系统实现」写得比较薄,四个入口(单图、批量、视频、摄像头)本质上只有一件事不一样——输入从哪来。把推理封装成一个只加载一次模型的类,四个入口都是它的调用方,代码量能砍掉一大半。
4.1 模型只加载一次:推理封装与阈值取舍
界面里最忌讳的写法是每次点检测都YOLO("best.pt")。加载一次权重少则几百毫秒多则几秒,用户点两下就以为程序卡死了。正确做法是启动时加载一次,之后只调predict。
import time import cv2 import numpy as np from ultralytics import YOLO class PCBDetector: def __init__(self, weight_path, conf=0.25, iou=0.45, imgsz=640, device="0"): # 只在这里加载一次,之后复用 self.model = YOLO(weight_path) self.conf = conf self.iou = iou self.imgsz = imgsz self.device = device def detect(self, img_bgr): """输入 BGR 图,返回绘制后的图、目标列表、三段耗时""" t0 = time.perf_counter() results = self.model.predict( img_bgr, conf=self.conf, # 置信度阈值:低于此值的框直接丢弃 iou=self.iou, # NMS 的 IoU 阈值:越高保留的重叠框越多 imgsz=self.imgsz, device=self.device, verbose=False, ) cost = (time.perf_counter() - t0) * 1000 # 毫秒 r = results[0] boxes = [] for box in r.boxes: cls_id = int(box.cls.item()) boxes.append({ "cls": self.model.names[cls_id], "conf": float(box.conf.item()), "xyxy": [round(v, 1) for v in box.xyxy[0].tolist()], }) # r.plot() 直接返回带框和标签的 BGR 图,省掉手写绘制 vis = r.plot(labels=True, conf=True) return vis, boxes, r.speedconf和iou这两个阈值是推理阶段唯一值得反复调的东西。conf调低召回上升、误检也上升,产线上误检意味着停机复检,通常宁可稍高一点;iou控制 NMS 合并强度,PCB 上密集排列的焊盘之间容易互相压制,iou设太高会留下重复框,设太低会把相邻的真实缺陷合并成一个。r.speed里自带preprocess、inference、postprocess三段耗时,比自己在外面掐表准,因为后处理(NMS)的时间经常被漏算。
4.2 批量与视频:跳帧、编码器与结果落盘
批量检测的本质是循环,但有两处容易翻车。一是路径,中文目录在部分 OpenCV 版本上imread直接返回 None,读图统一走cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR)更稳。二是保存,imwrite遇到中文路径同样写不进去,用cv2.imencode加tofile绕开。
from pathlib import Path import cv2 import numpy as np def imread_unicode(path): # 解决中文路径读图返回 None 的问题 data = np.fromfile(str(path), dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) def imwrite_unicode(path, img): ext = Path(path).suffix or ".jpg" ok, buf = cv2.imencode(ext, img) if ok: buf.tofile(str(path)) return ok def batch_detect(detector, img_dir, out_dir): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) rows = [] for p in sorted(Path(img_dir).glob("*.jpg")): img = imread_unicode(p) if img is None: print(f"跳过无法读取的图: {p.name}") continue vis, boxes, speed = detector.detect(img) imwrite_unicode(out_dir / f"{p.stem}_pred.jpg", vis) rows.append({"file": p.name, "count": len(boxes), "ms": speed["inference"]}) return rows视频检测在循环外多两件事:cv2.VideoCapture打开源,cv2.VideoWriter写结果。编码器是血泪经验区,mp4v兼容性最好但文件偏大,avc1需要系统里有对应编码库,装不上就静默失败,最后得到一个 0 字节的视频。写完一定要检查writer.isOpened(),并且落盘后用播放器打开确认帧数正常。
实时性上,逐帧推理在 30fps 视频上跑不动是常态,常规做法是跳帧:设定stride=3,每三帧推理一次,中间帧复用上一次的框。观感上几乎看不出差别,吞吐直接翻三倍。摄像头输入则是同样的循环,把VideoCapture(0)当源,注意在界面关闭时release(),否则摄像头会被一直占用,下次打开报「设备忙」。
4.3 界面不卡的关键:把推理塞进 QThread
PyQt 主线程里跑推理,界面必然假死。正确做法是推理放在QThread子类里,通过信号把结果传回主线程更新控件,主线程只负责画。
from PyQt5.QtCore import QThread, pyqtSignal import numpy as np class DetectWorker(QThread): finished_one = pyqtSignal(object, list, dict) # 可视化图、目标列表、耗时 failed = pyqtSignal(str) def __init__(self, detector, source, mode="image"): super().__init__() self.detector = detector self.source = source # 单图路径 / 目录 / 视频路径 / 0 self.mode = mode self._running = True def stop(self): self._running = False def run(self): try: if self.mode in ("image",): import cv2 img = cv2.imread(self.source) if img is None: self.failed.emit("图片读取失败,检查路径或格式") return vis, boxes, speed = self.detector.detect(img) self.finished_one.emit(vis, boxes, speed) # video / camera 分支为帧循环,每帧 emit 一次,配合 stop() 可中断 except Exception as e: self.failed.emit(str(e)) # 异常必须在子线程里捕获,否则界面无提示pyqtSignal(object, ...)用object类型传 numpy 数组是常规写法,Qt 不认识 ndarray,直接用np.ndarray声明信号会报类型错误。_running标志位配合stop()是必须的,否则用户切页面时旧线程还在跑,多个线程同时推理会抢显存,表现为随机崩溃。异常在run()里捕获后通过信号抛给主线程弹窗,是最省事也最不容易漏的做法。
4.4 检测用时与目标计数:统计口径要一致
界面上展示的「检测用时」和「目标总数」看起来简单,口径不统一就会出笑话。用时建议取r.speed里的inference单项,别把读图和画框算进去,否则同一模型在不同分辨率显示器上跑出不同数字。目标计数按类别分组统计,同时给出总框数,因为 NMS 之后同一缺陷可能被重复计数,展示总数时最好同时展示「按类别去重后」的数字。
批量落盘时多写一份 CSV,把文件名、各类别数量、置信度均值、耗时记下来。答辩演示时这一份表比截图有说服力,产线侧也能直接拿去做缺陷分布统计。
5. 避坑与排查:这类毕设包最容易翻车的五个点
5.1 环境与依赖:装完就报 CUDA 相关错误
现象:训练启动即抛CUDA error: no kernel image is available for execution on the device,或者torch.cuda.is_available()返回 False 但驱动明明装了。
原因:pip 装上的 torch 轮子与显卡架构或驱动版本不匹配,最常见的是装成了 CPU 版,或者 CUDA 版本高于驱动支持的上限。这类包袱在毕设包里特别常见,因为requirements.txt往往只写了包名不写版本。
解决:先跑nvidia-smi看驱动支持的最高 CUDA 版本,再去装不高于该版本的 torch 轮子,装完立刻用torch.cuda.is_available()和一次小张量运算验证。还是不行就退回 CPU 先跑通全流程,确认代码没问题之后再解决 GPU,别让环境问题挡住对代码本身的判断。
5.2 数据与标签:训练正常但预测全是同一类
现象:训练日志里 loss 正常下降,mAP 也有个不算难看的数字,但拿图去推理,所有框都标成同一个类别。
原因:标签文件的类别 id 与data.yaml里names的顺序不一致,或者类别 id 从 1 开始写、训练时被当成 0 处理,整体错位一位。这种错误不会让训练崩,只会让模型学到一个错误的映射。
解决:训练前跑一遍标签体检脚本,确认 id 最小值是 0、最大值是nc-1,且分布合理。训完之后抽十张验证集图做可视化,把预测类别和标注类别并排看,错位一眼就能发现。这份资源里六类缺陷的顺序一旦定下,任何环节都不要改。
5.3 文档与代码不一致:照着说明书装环境白折腾
现象:按文档里的「系统运行环境」一节去装,发现写的是 Java、MySQL、Tomcat 那一套,跟 Python + PyTorch 的代码完全对不上,装完一圈发现根本用不上。
原因:毕设模板在流转过程中残留了其他题目的章节,这类模板污染在打包资源里不算罕见,属于文档层面的疏漏,不代表代码有问题。
解决:以代码为准。判断技术栈只看三样——requirements.txt、代码顶部的 import、以及有没有data.yaml和.pt权重。文档里关于部署环境的那一段直接跳过,按 import 反推依赖,缺什么装什么,比照着文档装快得多。答辩材料如果需要修订,把这一段改成真实的技术栈描述即可。
5.4 验证集太小:指标忽高忽低像是模型不稳
现象:同样的配置连训两次,mAP50 从 0.88 掉到 0.79,或者某一类的 AP 在 0.3 和 0.8 之间反复横跳。
原因:100 张验证图分到六个类别,样本少的类别可能只有四五个实例,单张图判对判错就能让该类 AP 大幅波动。这不是模型不稳定,是评估本身的方差大。
解决:用分层抽样重做划分,保证每类在验证集里有足够实例;同时固定随机种子,让两次实验的验证集完全一致,才有可比性。如果数据实在少,用 5 折交叉验证取均值,比单次划分的数字可信得多,代价是训练时间乘以五,600 张图的规模完全承受得起。
5.5 视频保存后打不开:得到一个 0 字节文件
现象:视频检测跑完了,输出目录里有个文件,双击打不开,属性里大小是 0 或者几百字节。
原因:cv2.VideoWriter指定的编码器在当前系统上不可用,构造函数静默失败,后面write()全部无效。用avc1在没装对应编码库的机器上必然踩这个坑。
解决:写入前检查writer.isOpened(),为 False 就换mp4v编码并确认输出分辨率、帧率与源一致。写完release()之后再读一次文件大小,为 0 就说明整条链路有问题。稳妥起见,输出容器统一用.mp4+mp4v,兼容性最好。
6. 验收不止看 mAP:三种交叉验证与一个小目标技巧
论文里的 0.92 是它自己数据上的均值,换一批板卡图未必还成立。交付或答辩前,我一般会补三种验证,把「指标好看」和「真的能用」分开看。
第一种是留出集验证。从原始数据里单独扣一批既不在训练也不在验证的图,最好来自不同批次、不同拍摄角度、不同光照条件,只跑推理不做任何调参,看召回和误检。这一步能暴露过拟合,很多在验证集上 0.9 的模型,换一批图直接掉到 0.6。
第二种是阈值扫描。固定权重,把conf从 0.1 到 0.6 按步长 0.05 扫一遍,记录每档的召回率、误检数、平均耗时,做成表来判断业务上能接受的工作点。
| conf | 召回率 | 每图误检数 | 建议场景 |
|---|---|---|---|
| 0.15 | 0.94 | 3.8 | 抽检、人工复核兜底 |
| 0.25 | 0.89 | 1.4 | 常规质检,默认工作点 |
| 0.40 | 0.76 | 0.4 | 自动判定、误检代价高 |
| 0.55 | 0.61 | 0.1 | 只做严重缺陷告警 |
这张表比一个 mAP 数字有说服力得多,因为它回答的是「我该把阈值设在哪」,而不是「模型有多好」。
第三种是可视化抽检。按置信度分档各抽二十张,低分档重点看漏检,高分档重点看框贴合度。这一步没有技术含量,但翻车最多,我见过太多次指标漂亮、框偏半个焊盘的例子。
最后说一个具体技巧:小目标召回上不去时,优先试切片推理,而不是换大模型。把 1024 以上的原图按 640 的窗口、20% 重叠切块,逐块推理后再把框映射回原图坐标做一次全局 NMS。铜渣、缺失孔这类在小图里只剩几个像素的目标,切片之后相对尺寸变大,召回提升通常比换模型更明显,代价是推理耗时按块数线性增长,适合离线批量检测而不是实时视频。
def slice_infer(detector, img, tile=640, overlap=0.2): """滑窗切片推理:小目标先切块放大检测,再映射回原图""" h, w = img.shape[:2] step = int(tile * (1 - overlap)) all_boxes = [] for y in range(0, max(h - tile, 0) + step, step): for x in range(0, max(w - tile, 0) + step, step): patch = img[y:y + tile, x:x + tile] if patch.shape[0] < tile or patch.shape[1] < tile: continue _, boxes, _ = detector.detect(patch) for b in boxes: # 把块内坐标平移回原图坐标系 x1, y1, x2, y2 = b["xyxy"] b["xyxy"] = [x1 + x, y1 + y, x2 + x, y2 + y] all_boxes.append(b) return all_boxes # 返回后需再跑一次跨块 NMS 去重参数上,overlap不要低于 0.15,否则跨越切缝的缺陷会被切成两半,两边都检不全;tile与训练时的imgsz保持一致,分布才对得上。返回的框必须再做一次全局 NMS,不然同一条短路会在相邻两块里各出一个框。
从那以后我每次交付这类检测包,都强制走一遍「留出集 + 阈值扫描 + 分档抽检」,不管训练日志里的 mAP 有多漂亮。希望帮到你。
本文还有配套的精品资源,点击获取