☰
铝片表面缺陷检测实战:YOLO数据集处理与训练调参全攻略
2026/10/8 7:54:25 网站建设 项目流程

简介:YOLO铝片表面缺陷检测数据集是一套面向工业视觉与目标检测学习者的标注图像资源,能够直接用于YOLO系列模型的缺陷识别训练与算法验证。压缩包共2000个文件,大小约60.69MB,标签部分提供VOC(xml)、COCO(json)和YOLO(txt)三种格式标注,并配套HTML格式的Windows/Linux环境搭建与训练教程、Python划分脚本及YAML配置文件,适合不同环境下的快速上手。已有932人浏览学习,热度稳定。除高质量标注数据外,还附有三套数据集划分脚本,可按照实际需求灵活生成训练集、验证集和测试集;教程覆盖环境安装、案例修改到自定义数据训练,能帮助初学者少走弯路,也方便中高级开发者直接复用数据流程。

1. 铝片表面缺陷检测为什么值得用 YOLO 自己做一套

金属铝材在轧制、冲压、运输过程中,表面容易出现划痕、凹坑、氧化斑、压伤这几类典型缺陷。产线上常见的做法是靠老师傅肉眼盯,速度慢、标准不统一,而且细划痕在特定光照下才看得到,漏检率一直压不下来。用 YOLO 做铝片表面缺陷检测,就是把“老师傅的眼睛”换成模型:输入一张铝片表面图像,输出每个缺陷的位置框和类别。相比传统机器视觉的边缘检测、阈值分割方案,YOLO 这类单阶段检测器对小目标、低对比度缺陷更鲁棒,而且一套标注数据训完,换产线只需增量标注,不用重写算法。

这套数据集的组合拳——1000 张图片 + voc/coco/yolo 三种格式标签 + 划分脚本 + 训练教程,正好对应了缺陷检测落地最常被卡住的四件事:标注格式不统一、数据划分不合理、训练参数不会调、训练完不知道模型到底行不行。下面直接按这个顺序把这四条链路拆开讲,每一步给可复现的命令和代码。

2. 三种标注格式的本质区别与互转方法

2.1 为什么同一个数据集要同时存在 voc、coco、yolo 三种标签

刚接触目标检测的人最容易困惑的是:明明都是画框,为什么同一张图要存三份标签?根源在于不同训练框架读取标签的方式不同。

  • VOC(PASCAL VOC)格式:一张图对应一个 XML 文件,框坐标用xmin, ymin, xmax, ymax绝对像素值表示,类别用字符串写在<name>标签里。适合人读,适合标注工具导出,但训练框架读取时需要额外解析。
  • COCO 格式:所有标注汇总到一个 JSON 文件,按images、annotations、categories三个数组组织。框坐标是[x, y, width, height]浮点绝对像素值。它的优势是能同时表达检测、分割、关键点信息,MMDetection、Detectron2 原生支持,但手写 JSON 极易出错。
  • YOLO 格式(darknet / ultralytics 通用):每张图对应一个 txt 文件,每行是class_id x_center y_center width height,坐标全部归一化到 0-1 区间,类别用整数 id 表示。这种格式最紧凑,训练读取最快,但坐标是相对值,一旦图片尺寸变了,框会跟着图等比缩放,不会错位。

工业界常见做法是:标注工具(如 LabelImg、X-AnyLabeling)导出 VOC,然后自己写转换脚本生成 COCO 和 YOLO。这样做的好处是,标注阶段永远只维护一份源标注,另外两种格式随时可以重新生成,不会出现三份标签改了一份忘了另外两份的同步事故。

2.2 用 Python 脚本把 VOC 转成 YOLO 格式

假设标注目录结构如下:

Annotations/ (存放 xml 文件) JPEGImages/ (存放 jpg 图片)

转换脚本如下:

import xml.etree.ElementTree as ET import os # 类别列表:顺序就是 YOLO 训练时的 class_id CLASSES = ["scratch", "indentation", "oxidation", "stain"] def voc_to_yolo(xml_path, out_dir, img_width, img_height): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.iter("object"): name = obj.find("name").text if name not in CLASSES: continue # 跳过未定义类别 class_id = CLASSES.index(name) bndbox = obj.find("bndbox") xmin = float(bndbox.find("xmin").text) ymin = float(bndbox.find("ymin").text) xmax = float(bndbox.find("xmax").text) ymax = float(bndbox.find("ymax").text) # 除 0 保护(正常情况下 VOC 坐标不会越界) x_center = ((xmin + xmax) / 2) / img_width y_center = ((ymin + ymax) / 2) / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height # 裁剪到合法区间,防止浮点误差 x_center = min(max(x_center, 0), 1) y_center = min(max(y_center, 0), 1) w = min(max(w, 0), 1) h = min(max(h, 0), 1) lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") if not lines: return False # 没有有效标注的图片直接跳过 txt_name = os.path.splitext(os.path.basename(xml_path))[0] + ".txt" with open(os.path.join(out_dir, txt_name), "w") as f: f.write("\n".join(lines)) return True

这段代码的核心是坐标系的换算:VOC 存的是左上角和右下角的绝对像素值,YOLO 要的是中心点和宽高的相对值。除以img_width和img_height这一步,是把像素坐标拉回到 0-1 区间,和图片本身分辨率无关了。注意CLASSES列表的顺序一旦确定就不能随便调整,否则之前生成的 txt 标注全部会错位。这里还加了边界裁剪,如果标注框框出图像边缘(标注工具常见手滑),归一化后会出现大于 1 的值,YOLO 训练时通常会直接报错。

2.3 VOC 转 COCO 时最容易踩的坑

COCO 的 JSON 结构比 YOLO 复杂得多,很多人手动拼接容易在category_id上出错。COCO 的类别 id 不要求从 0 开始,也不要求连续,但训练框架在加载时依赖categories数组里的 id 和annotations里的category_id严格对应。

import json import xml.etree.ElementTree as ET import os from PIL import Image def voc_to_coco(image_dir, xml_dir, output_json): categories = [ {"id": 1, "name": "scratch"}, {"id": 2, "name": "indentation"}, {"id": 3, "name": "oxidation"}, {"id": 4, "name": "stain"}, ] # 构建 name -> id 映射 cat_map = {c["name"]: c["id"] for c in categories} images, annotations = [], [] ann_id = 1 for idx, xml_name in enumerate(os.listdir(xml_dir)): if not xml_name.endswith(".xml"): continue img_name = xml_name.replace(".xml", ".jpg") img_path = os.path.join(image_dir, img_name) with Image.open(img_path) as img: w, h = img.size images.append({ "id": idx + 1, "file_name": img_name, "width": w, "height": h, }) tree = ET.parse(os.path.join(xml_dir, xml_name)) root = tree.getroot() for obj in root.iter("object"): name = obj.find("name").text if name not in cat_map: continue bndbox = obj.find("bndbox") xmin = float(bndbox.find("xmin").text) ymin = float(bndbox.find("ymin").text) xmax = float(bndbox.find("xmax").text) ymax = float(bndbox.find("ymax").text) annotations.append({ "id": ann_id, "image_id": idx + 1, "category_id": cat_map[name], "bbox": [xmin, ymin, xmax - xmin, ymax - ymin], "area": (xmax - xmin) * (ymax - ymin), "iscrowd": 0, }) ann_id += 1 coco = {"images": images, "annotations": annotations, "categories": categories} with open(output_json, "w") as f: json.dump(coco, f, indent=2) print(f"done: {len(images)} images, {len(annotations)} annotations")

这里特别提醒两点:第一,COCO 的bbox是[x, y, width, height],不是两点坐标,漏掉这个转换会让框在可视化时全部偏到右下角;第二,image_id必须是整数且在images数组中存在对应记录,否则 MMDetection 在评估阶段会报 KeyError。铝片缺陷这个场景里,一张图上可能有十几个细划痕,每个划痕都要单独一个 annotation 对象,ann_id自增不能重置。

3. 划分脚本的正确写法:分层采样和同源去重

3.1 随机划分在缺陷检测里的隐患

很多人拿到数据集第一件事就是train_test_split(test_size=0.2, random_state=42),这在自然图像分类上问题不大,但在工业缺陷检测上会出状况。铝片缺陷数据往往是连拍的:同一个缺陷区域从不同角度、不同光照拍了多张图。如果这些图被随机分配到训练集和验证集,验证集里就会出现和训练集高度相似的图,mAP 会虚高到 0.9 以上,一上产线直接崩回 0.3。这就是典型的“数据泄漏”。

解决思路是:按缺陷样本的“来源批次”或“文件名前缀”做分组划分,保证同一个来源的所有图片只进一个集合。如果标注文件里没有批次信息,常见的做法是给图片文件名的前几位加上炉批号或产线编号,工业采集软件一般都能配置。

3.2 带分层逻辑的划分脚本实现

下面给出一个适合 1000 张级别的划分脚本,支持两种模式:按文件名前缀分组划分,以及按类别分层划分。

import os import random import shutil from collections import defaultdict def group_split_by_prefix(file_list, prefix_len=8, train_ratio=0.8): """ file_list: 图片文件名列表 prefix_len: 文件名前 N 个字符作为分组依据(如炉批号) 返回划分后的 train/val 字典 """ groups = defaultdict(list) for f in file_list: key = f[:prefix_len] # 同批次图片共享前缀 groups[key].append(f) group_keys = list(groups.keys()) random.shuffle(group_keys) split_idx = int(len(group_keys) * train_ratio) train_groups = group_keys[:split_idx] val_groups = group_keys[split_idx:] train_files = [f for k in train_groups for f in groups[k]] val_files = [f for k in val_groups for f in groups[k]] return train_files, val_files # 使用示例 all_images = [f for f in os.listdir("images") if f.endswith(".jpg")] train, val = group_split_by_prefix(all_images, prefix_len=8) print(f"train: {len(train)}, val: {len(val)}")

核心逻辑是先把图片按前缀分组,再打乱组而不是打乱单张图。这样同源图片保证进入同一个集合。prefix_len的取值要看实际文件名结构,如果前缀 8 位是炉批号,那就用 8;如果不足 8 位是日期,截到日期即可。另外,划分完要做类别分布检查:

import os def check_class_distribution(txt_dir, num_classes=4): counts = defaultdict(int) for txt_name in os.listdir(txt_dir): with open(os.path.join(txt_dir, txt_name)) as f: for line in f: class_id = int(line.split()[0]) counts[class_id] += 1 total = sum(counts.values()) for cid in range(num_classes): ratio = counts[cid] / total if total else 0 print(f"class {cid}: {counts[cid]} ({ratio:.2%})") check_class_distribution("yolo_labels_train")

这一步很有必要。1000 张工业图片里,大概率是划痕占 70%、氧化斑占 10%,少样本类别可能只有几十个框。如果验证集里某个类别一个框都没有,训练完看验证 mAP 会缺失这个类别的评估,调参方向直接跑偏。工业缺陷检测的划分目标不是机械地按比例切,而是保证验证集里每个类别都有足够样本——一般建议验证集里最少见类别不低于 5 个标注框。

3.3 划分后的目录组织建议

一套干净的目录结构比名字花哨的管理脚本更重要,推荐结构如下:

dataset/ ├── images/train/ ├── images/val/ ├── labels/train/ ├── labels/val/ ├── data.yaml └── classes.txt

data.yaml是 ultralytics YOLO 训练框架的标准配置,直接指向上述目录:

train: dataset/images/train val: dataset/images/val nc: 4 names: ['scratch', 'indentation', 'oxidation', 'stain']

一个常见低级错误是:图像在images/train,标签在labels/val,目录名不配对导致训练时全部图片都没有标签。YOLO 框架的标签加载逻辑是“根据图片目录自动找同级的 labels 目录”,如果目录层级不对,运行训练命令时不会报错,但损失函数从头到尾不下降,这种情况在排查时最容易浪费时间。

4. 训练教程:铝片缺陷数据集的参数设置与完整流程

4.1 模型选型和预训练权重

1000 张图片属于典型的小样本工业视觉数据集。用 YOLOv5s 还是 YOLOv8n?我的判断依据是:数据量越少,模型骨架越小越好。YOLOv8n 的参数量约 3.2M,YOLOv5s 约 7.2M,在小数据集上后者更容易过拟合——表现为训练集 loss 降到 0.01,验证集 mAP 却卡在 0.5 以下。铝片背景单一、目标特征简单(划痕是线性结构、凹坑是圆形暗区),不需要深层网络提取复杂语义,所以首选 YOLOv8n 或 YOLOv5s。

用预训练权重而不是随机初始化,在这个场景下收益非常明显。虽然 ImageNet 上没有铝片缺陷这类工业图像,但预训练模型已经具备了边缘、纹理、形状的基础特征提取能力,微调时只需要把最后的检测头适配到 4 类缺陷上。

4.2 核心训练命令与参数调优

ultralytics YOLOv8 训练命令:

yolo detect train \ model=yolov8n.pt \ data=data.yaml \ epochs=100 \ imgsz=640 \ batch=16 \ patience=20 \ optimizer=SGD \ lr0=0.01 \ augment=True \ project=aluminum_defect \ name=exp1

参数说明:

  • imgsz=640:输入分辨率。铝片划痕是细长小目标,建议 640 起步,如果 GPU 显存足够(8G 以上)可以试 832,小目标召回率通常能提升 3-5 个点。
  • batch=16:受显存限制。训练时观察 GPU 利用率,如果低于 70%,加大 batch 或提高 imgsz。
  • patience=20:验证集 mAP 连续 20 轮不上升就早停。1000 张图大约 80-100 轮就会收敛,设 100 epochs 配早停是合理组合。
  • optimizer=SGD:缺陷检测建议用 SGD 而不是 Adam。Adam 收敛快但最终精度往往不如 SGD,小数据集上差距更明显。
  • lr0=0.01:这个值在 COCO 上有效,但铝片数据集只有 4 类,若训练 loss 剧烈震荡,降到 0.005 再试。

训练时的数据增强策略也要针对铝片表面特征调整。随机旋转参数默认是 0 度,不要打开;铝片有明确的纹理方向,旋转 90 度会让划痕方向语义错乱(水平划痕变成垂直划痕)。在data.yaml同级目录放一个augment.yaml覆盖默认增强:

# augment.yaml fliplr: 0.5 flipud: 0.0 hsv_h: 0.015 hsv_s: 0.7 hsv_v: 0.5 translate: 0.1 scale: 0.3 mosaic: 0.5

铝片表面是金属反光材质,光照不均很常见,所以 HSV 增强里的饱和度和亮度扰动适度加大有助于泛化,但色相扰动不要超过0.015——氧化斑的颜色是特征之一,色相扰动过大会让模型把正常铝面误判为氧化斑。

4.3 训练过程中看什么指标

训练日志里重点盯三个数值:train/box_loss、val/box_loss、metrics/mAP50。

  • train/box_loss前 10 轮应从 0.08 左右平滑降到 0.03 以下,如果 10 轮后还在 0.08 以上震荡,基础学习率太高。
  • val/mAP50到第 30 轮左右应超过 0.6,如果还在 0.3 附近徘徊,优先怀疑标签问题——用可视化脚本画几张图看标注框是否贴合缺陷边缘。
  • 如果出现train loss不断下降但val loss出现拐点回升,说明过拟合开始,减少 epochs 或加强增强。

一个铝片检测特有的问题:划痕类目标的长宽比极端(细长条),YOLO 默认的 anchor 是 COCO 统计出来的通用值,对长条形目标不友好。如果训练时发现划痕类 AP 明显低于其他类,且召回率(recall)特别低,用聚类脚本重新生成 anchor。YOLOv8 的 anchor 是自适应解耦头,不需要手动设 anchor 尺寸,但如果用 YOLOv5,需要在训练前跑:

python train.py --data data.yaml --epochs 100 --hyp data/hyps/hyp.scratch-low.yaml --multi-scale

--multi-scale每 10 轮随机缩放输入尺寸(0.5-1.5 倍),等于给模型做了尺度扰动,对尺寸分布宽的缺陷目标有帮助。

4.4 训练完成后必须做的验证

训练结束不要直接拿best.pt就去部署。先用测试集(划分脚本里留出的那部分图片)跑推理,再算一下各类别的完整指标。ultralytics 自带的验证命令:

yolo detect val model=runs/aluminum_defect/exp1/weights/best.pt data=data.yaml split=test

这里注意split=test这个参数,如果data.yaml里没有定义test字段,它会自动 fallback 到 val,输出结果会高估真实表现。正确做法是在data.yaml里补上:

test: dataset/images/test

验证输出里metrics/precision(B)和metrics/recall(B)是总体精度和召回率,但更关键的是看每个类别的 AP。用分类别指标判断是整体问题还是少数类问题:如果scratch类的 AP 只有 0.4,而其他类在 0.8 以上,不要盲目调全局参数,优先尝试单独对该类做样本增强(在训练图像上叠加模拟划痕)。

5. loss 异常和 mAP 低的两类典型问题排查

训练到中途 loss 变 NaN 是工业数据集最常见的翻车现场,常见原因和处理方式如下表:

现象可能原因处理动作
训练第 5-10 轮 loss 变 NaN学习率过高,梯度爆炸lr0从 0.01 降到 0.001,或改用optimizer=AdamW
loss 从第二轮开始就是 NaN标签坐标出现 0 或负值检查 YOLO 标签文件中是否有超出 0-1 区间的坐标值
刚开始训练就报错CUDA error: device-side assert triggerednc数量与标签中最大类别 id 不匹配检查data.yaml中的nc是否等于类别数,标签中是否有大于nc-1的 id
loss 不下降、反复横跳batch size 太小或背景占比过高调大 batch,或检查是否大量图片标注为空(背景图占比超过 30%)

第二个高发问题:mAP50 不低但 mAP50-95 特别低。这通常意味着框的位置基本正确,但 IoU 阈值拉高后评分下降,也就是“框得不够准”。1000 张铝片图上标注框如果有 1-2 像素的偏差,mAP50-95 就会从 0.7 掉到 0.5。缓解手段有两个:一是检测头的回归分支更敏感,选iou=0.2的 NMS 阈值(默认 0.45 对密集小目标过于激进);二是使用--box 0.05增大边框回归损失权重(YOLOv8 用box=0.05参数直接控制)。

还有一个被低估的收益点:类别不平衡导致的误检。氧化斑和正常铝面反光在灰度特征上极其相似,如果数据集中氧化斑只有 30 张图,模型大概率把高光区域误检为氧化斑。对这种难例,最有效的手段不是调参而是加数据。albumentations做在线增强时,可以对训练图像叠加灰度渐变模拟不均匀光照:

import albumentations as A transform = A.Compose([ A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), A.GaussNoise(var_limit=(10.0, 50.0), p=0.3), ])

这个增强策略针对的是铝片反光特性:产线光照不可能完全均匀,训练时让模型见过明暗不均的输入,推理时才能对高光区域有免疫力。

6. 部署时的一个实用技巧:置信度阈值校准

训练完模型部署到产线时,大部分人直接把置信度阈值设为 0.5,这是把训练时的评估阈值直接搬到了推理场景。铝片检测和通用目标检测有个重要差异:产线上漏检一块缺陷的代价远高于多检一次(多检顶多停机人工复检,漏检直接流到客户手里变成客诉)。

看验证集输出的results.csv(ultralytics 会自动生成),找到每个类别的 PR 曲线数据。挑选阈值时以召回率为优先约束:

import pandas as pd df = pd.read_csv("runs/aluminum_defect/exp1/results.csv") # 取每行各类别的 confidence 阈值 # 目标是整体 recall 不低于 0.95 target_recall = 0.95 for conf in [0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5]: metrics = df[df["conf"] == conf] recall = metrics["recall"].mean() if recall >= target_recall: print(f"selected conf: {conf}, recall: {recall:.4f}") break

推理时的置信度阈值是单独传参的,YOLO 的 Python API 写法:

from ultralytics import YOLO model = YOLO("best.pt") results = model("inspection_image.jpg", conf=0.15, iou=0.45) for r in results: for box in r.boxes: x1, y1, x2, y2 = box.xyxy[0].tolist() conf = float(box.conf[0]) cls = int(box.cls[0]) print(f"缺陷类别 {cls},置信度 {conf:.3f},位置 ({x1:.0f},{y1:.0f})-({x2:.0f},{y2:.0f})")

conf=0.15看起来很低,实际部署时这个值是否合理要看误检代价。如果现场误检频繁导致停机复检成本过高,再往上调 0.05 步进,每次调整后至少留 200 张没有缺陷的良品图做负样本验证,确保误检率在可接受范围内。这一步在缺陷检测里常常被忽略,但它在实际应用中的作用比调整模型网络结构更大。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询