简介:本资源为集装箱缺陷检测数据集,面向从事工业质检、目标检测算法训练与课程实践的研究者及开发者,可用于训练和验证集装箱表面缺陷识别模型。数据集采用Pascal VOC与YOLO双格式标注,包含4127张jpg图片,每张图片均配有对应的xml与txt标注文件,标注类别为Dent、Hole、Rust三类,总框数达10117个,其中Dent 4943框、Hole 1218框、Rust 3956框,使用labelImg按矩形框规则完成标注。压缩包为7z格式,共2000个文件,以1999个xml标注文件和1个说明txt为主,整体约163.61MB,目录结构清晰,便于直接接入主流检测框架进行训练与评估。目前已有348人学习下载,适合需要快速获取缺陷检测数据、开展模型对比实验或完成课程项目的读者参考使用。
1. 集装箱缺陷检测数据集:4127 张 VOC+YOLO 双格式,3 类缺陷怎么落地
做集装箱表面质检的同行大概率都遇到过这个尴尬:算法框架搭好了,模型结构也调通了,结果卡在数据上——网上能找到的公开缺陷数据集要么类别不对口,要么格式只有 VOC 没有 YOLO,要么标注质量参差不齐,光清洗就耗掉一周。这次拆的这份集装箱缺陷检测数据集,4127 张 jpg 图片,配套 4127 个 Pascal VOC 格式 xml 和 4127 个 YOLO 格式 txt,3 个类别 Dent(凹陷)、Hole(破洞)、Rust(锈蚀),总标注框 10117 个。标注工具是 labelImg,规则就是矩形框,没有分割路径的 txt,属于典型的检测任务数据包。它解决的核心问题很直接:省掉从原始图到标注再到格式转换的前半程,让你把时间花在模型和调参上。适合做工业质检、港口物流视觉、表面缺陷检测的从业者,也适合想跑通 YOLO 全流程的新手拿来练手。下面按「数据长什么样 → 怎么读进来 → 怎么训 → 坑在哪」的顺序拆一遍。
2. 数据集结构与标注分布:先看清 10117 个框的底牌
拿到一个 7z 压缩包,别急着解压完就往训练脚本里塞。先搞清楚目录结构、文件命名规则和类别分布,这三件事决定了后面要不要做重采样、要不要改 data.yaml、要不要处理空标注。
2.1 目录组织与文件对应关系
从项目正文给出的文件列表看,根目录下有一个「使用前必读.txt」,然后是大量firc_jizhuangxiang_数字.xml文件。这种命名方式说明图片和标注是同名不同后缀配对的:firc_jizhuangxiang_647.jpg对应firc_jizhuangxiang_647.xml和firc_jizhuangxiang_647.txt。解压后典型结构是这样:
dataset/ ├── 使用前必读.txt ├── JPEGImages/ # 4127 张 jpg │ ├── firc_jizhuangxiang_647.jpg │ └── ... ├── Annotations/ # 4127 个 VOC xml │ ├── firc_jizhuangxiang_647.xml │ └── ... └── labels/ # 4127 个 YOLO txt ├── firc_jizhuangxiang_647.txt └── ...实际压缩包里可能没有分文件夹,而是全部平铺在一个目录,这很常见。平铺的话,写脚本按后缀分流即可。关键点是:xml 数量、txt 数量、jpg 数量三者必须都是 4127,任何一个对不上,说明有文件丢失或命名不一致,先补齐再往下走。
2.2 三个类别的框数分布与影响
摘要里给了明确的框数统计,这个信息比图片数量更重要,因为它直接反映类别不平衡程度:
| 类别 | 英文名 | 框数 | 占比 |
|---|---|---|---|
| 凹陷 | Dent | 4943 | 48.9% |
| 破洞 | Hole | 1218 | 12.0% |
| 锈蚀 | Rust | 3956 | 39.1% |
| 合计 | — | 10117 | 100% |
Dent 和 Rust 加起来占了近 88%,Hole 只有 12%。这意味着如果直接训练,模型会明显偏向 Dent 和 Rust,Hole 的召回率大概率拉胯。常见做法是:要么在 loss 里给 Hole 加权,要么对 Hole 做过采样,要么用 mosaic、copy-paste 这类增强手段补 Hole 的样本。我一般会先跑一版 baseline,看混淆矩阵里 Hole 被误判成什么,再决定用哪种策略。
另外注意,10117 个框分布在 4127 张图上,平均每张约 2.45 个框。这个密度不算高,说明大部分图里目标不多,训练时 batch 可以适当开大一点,但要注意有些图可能只有 1 个框甚至个别图框数偏多,做 anchor 聚类时别用默认的 COCO anchor。
2.3 VOC 与 YOLO 双格式的取舍
这份数据同时给了 VOC xml 和 YOLO txt,很多人会纠结用哪个。我的建议是:训练直接用 YOLO txt,验证和可视化用 VOC xml。原因很简单,YOLO 系列读 txt 是原生支持,速度快;而 xml 里保留了图片宽高、目标尺寸等原始信息,做数据分析和可视化时更方便。
YOLO txt 每行格式是class_id x_center y_center width height,全部归一化到 0~1。VOC xml 里是绝对像素坐标xmin ymin xmax ymax。两者可以互转,但要注意归一化时的除数是图片实际宽高,不是固定值。如果图片尺寸不统一,转换脚本必须逐张读取尺寸,不能写死。
提示:解压后先跑一遍完整性校验,确认 jpg、xml、txt 三者数量一致且文件名能一一对应,再开始任何训练操作。
3. 从压缩包到可训练:解析、校验与格式转换实操
这一章是整篇的核心操作区。目标是把一个刚解压的 7z 目录,变成 YOLO 能直接吃的训练集,中间包含解析、校验、划分、配置文件生成四步。每一步都给可抄的代码。
3.1 解压与文件完整性校验
7z 在 Linux 下用7z x解压,Windows 下用 7-Zip 右键解压即可。解压后第一件事不是训练,是校验。下面这段脚本做三件事:统计三类文件数量、检查同名配对、找出孤立文件。
import os from collections import Counter root = "dataset" # 解压后的根目录 exts = {".jpg": [], ".xml": [], ".txt": []} for f in os.listdir(root): name, ext = os.path.splitext(f) if ext.lower() in exts: exts[ext.lower()].append(name) # 1. 数量统计 for ext, names in exts.items(): print(f"{ext} 文件数: {len(names)}") # 2. 同名配对检查(以 jpg 为基准) jpg_set = set(exts[".jpg"]) xml_set = set(exts[".xml"]) txt_set = set(exts[".txt"]) missing_xml = jpg_set - xml_set missing_txt = jpg_set - txt_set orphan_xml = xml_set - jpg_set orphan_txt = txt_set - jpg_set print(f"缺 xml 的图: {len(missing_xml)}") print(f"缺 txt 的图: {len(missing_txt)}") print(f"孤立 xml: {len(orphan_xml)}") print(f"孤立 txt: {len(orphan_txt)}") # 3. 打印前几个异常,方便定位 print("示例缺 xml:", list(missing_xml)[:5]) print("示例孤立 txt:", list(orphan_txt)[:5])逻辑说明:以 jpg 为基准集合,用集合差集找出缺失和孤立文件。参数上,root改成你实际解压路径即可。如果输出里missing_xml或missing_txt不为 0,说明数据包在传输或解压时出了问题,必须先把这些图剔除或补齐,否则训练时 dataloader 会直接报错中断。
3.2 解析 VOC xml 提取类别与框信息
校验通过后,用 xml 做一次类别分布复核,确认摘要里的 4943/1218/3956 是否对得上。这一步同时能发现标注里的异常框,比如宽高为 0、坐标越界。
import xml.etree.ElementTree as ET from collections import Counter xml_dir = "dataset/Annotations" class_counter = Counter() box_counter = Counter() abnormal = [] for f in os.listdir(xml_dir): if not f.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, f)) root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) for obj in root.findall("object"): cls_name = obj.find("name").text.strip() class_counter[cls_name] += 1 bbox = obj.find("bndbox") xmin = int(float(bbox.find("xmin").text)) ymin = int(float(bbox.find("ymin").text)) xmax = int(float(bbox.find("xmax").text)) ymax = int(float(bbox.find("ymax").text)) # 异常框检查 if xmax <= xmin or ymax <= ymin: abnormal.append((f, cls_name, "zero_area")) elif xmin < 0 or ymin < 0 or xmax > w or ymax > h: abnormal.append((f, cls_name, "out_of_bound")) print("类别分布:", dict(class_counter)) print("异常框数量:", len(abnormal)) print("异常示例:", abnormal[:5])逻辑说明:ET.parse逐文件解析,findall("object")拿到每个标注目标。类别名用strip()去掉可能的首尾空格,这一步很关键,因为 labelImg 有时会留下空格导致类别名对不上。异常框分两类:零面积和越界。零面积框在训练时会产生 NaN loss,越界框会被某些框架裁剪或直接报错。发现异常后,要么修正坐标,要么把对应图从训练集剔除。
参数上,xml_dir指向你的 Annotations 目录。如果类别分布和摘要给的数字有出入,比如 Dent 不是 4943,那说明这份数据可能被二次编辑过,需要重新评估。
3.3 划分训练集/验证集并生成 data.yaml
YOLO 训练需要 train/val 两个列表和一份 data.yaml。划分比例常见 8:2 或 9:1,工业缺陷数据我一般用 8:2,保证验证集有足够样本覆盖三个类别。下面脚本按类别分层抽样,避免验证集里 Hole 太少。
import os, random, shutil from collections import defaultdict random.seed(42) root = "dataset" img_dir = os.path.join(root, "JPEGImages") lbl_dir = os.path.join(root, "labels") out_dir = "yolo_dataset" # 按类别分层:读取每个 txt 的主类别 cls_to_imgs = defaultdict(list) for f in os.listdir(lbl_dir): if not f.endswith(".txt"): continue name = os.path.splitext(f)[0] with open(os.path.join(lbl_dir, f)) as fp: lines = fp.readlines() if not lines: continue # 取该图出现最多的类别作为分层依据 from collections import Counter c = Counter([int(l.split()[0]) for l in lines if l.strip()]) main_cls = c.most_common(1)[0][0] cls_to_imgs[main_cls].append(name) train_list, val_list = [], [] for cls, names in cls_to_imgs.items(): random.shuffle(names) split = int(len(names) * 0.8) train_list += names[:split] val_list += names[split:] # 建立 YOLO 目录结构 for sub in ["images/train", "images/val", "labels/train", "labels/val"]: os.makedirs(os.path.join(out_dir, sub), exist_ok=True) for name in train_list: shutil.copy(os.path.join(img_dir, name + ".jpg"), os.path.join(out_dir, "images/train", name + ".jpg")) shutil.copy(os.path.join(lbl_dir, name + ".txt"), os.path.join(out_dir, "labels/train", name + ".txt")) for name in val_list: shutil.copy(os.path.join(img_dir, name + ".jpg"), os.path.join(out_dir, "images/val", name + ".jpg")) shutil.copy(os.path.join(lbl_dir, name + ".txt"), os.path.join(out_dir, "labels/val", name + ".txt")) print(f"train: {len(train_list)}, val: {len(val_list)}")逻辑说明:先按每张图的主类别分组,再在组内按 8:2 切分,这样验证集里三个类别都有覆盖。random.seed(42)保证可复现。复制而非移动,保留原始数据不动,方便后面换划分比例重跑。
生成 data.yaml:
path: ./yolo_dataset train: images/train val: images/val nc: 3 names: ['Dent', 'Hole', 'Rust']注意names的顺序必须和 txt 里的 class_id 对应。labelImg 导出 YOLO 格式时,类别 id 是按你标注时的类别列表顺序生成的,如果顺序和这里不一致,训练出来的类别会全错。核对方法:随便打开一个 txt,看第一列的数字,再对照 xml 里的 name,确认映射关系。
注意:如果 txt 里的 class_id 从 0 开始且只有 0/1/2 三个值,说明映射正常;如果出现 3 或更大值,说明类别数不止 3 个,数据包可能混入了其他类别。
4. 训练配置与参数调优:3 类缺陷的 YOLO 落地细节
数据准备好了,接下来是训练。这一章不讲 YOLO 原理,只讲针对这份数据的具体参数怎么设、为什么这么设、以及训练过程中看什么指标。
4.1 模型选型与输入尺寸
3 类缺陷、4127 张图,属于中小规模数据集。模型不用上太大的,YOLOv8n 或 YOLOv8s 就够,再大容易过拟合。如果追求精度且显存充足,可以上 YOLOv8m,但要做强增强。输入尺寸方面,集装箱缺陷的 Dent 和 Rust 通常面积较大,Hole 可能偏小,建议用 640×640 起步,如果 Hole 召回一直上不去,再试 960×960。
训练命令以 YOLOv8 为例:
yolo detect train \ data=data.yaml \ model=yolov8s.pt \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.01 \ lrf=0.01 \ patience=30 \ augment=True \ mosaic=1.0 \ mixup=0.1 \ copy_paste=0.1 \ project=runs/container_defect \ name=exp1参数说明:epochs=150配合patience=30,30 轮没提升就早停,避免浪费时间。lr0=0.01是初始学习率,lrf=0.01是最终学习率系数,余弦退火到初始的 1%。mosaic=1.0开启马赛克增强,对小目标 Hole 有帮助。mixup=0.1和copy_paste=0.1是额外增强,copy_paste 对缺陷检测特别有效,因为它能把缺陷区域复制到其他图上,相当于免费扩充 Hole 样本。
4.2 类别不平衡的处理策略
前面说过 Hole 只有 1218 个框,占比 12%。除了 copy_paste,还有两个手段:一是 loss 加权,二是过采样。YOLOv8 默认的分类 loss 没有类别权重接口,但可以在数据集层面做。最简单的方法是把含 Hole 的图在 train 列表里重复 2~3 次,让模型每轮多见几次 Hole。
# 在生成 train_list 后,对含 Hole 的图做过采样 hole_imgs = [] for name in train_list: with open(os.path.join(lbl_dir, name + ".txt")) as fp: if any(line.startswith("1 ") for line in fp): hole_imgs.append(name) # 重复 2 次,即总共出现 3 次 train_list_oversampled = train_list + hole_imgs * 2 print(f"过采样后 train: {len(train_list_oversampled)}")逻辑说明:line.startswith("1 ")判断该图是否含 Hole(class_id=1)。重复后训练集从约 3300 张变成约 4000+ 张,Hole 的出现频率明显提升。注意不要重复太多次,否则模型会对 Hole 过拟合,把其他类误判成 Hole。
另一个手段是调整验证指标。训练时重点看metrics/mAP50-95和每个类别的metrics/mAP50,如果 Hole 的 mAP 明显低于另外两类,说明不平衡问题没解决,回去加过采样或增强。
4.3 训练过程监控与早停判断
YOLO 训练会输出一堆指标,新手容易看花眼。重点盯三个:box_loss、cls_loss、mAP50。box_loss下降说明框回归在收敛,cls_loss下降说明分类在收敛,mAP50上升说明整体在变好。如果box_loss震荡不降,可能是学习率太大或 anchor 不匹配;如果cls_loss降不下去,多半是类别不平衡或标注有噪声。
早停的判断标准:连续 30 轮mAP50-95没有提升就停。但要注意,YOLO 的早停是基于 fitness 指标,不是单纯 mAP。如果验证集太小,fitness 波动大,可能提前停。这时候可以把patience调到 50,或者手动跑满 epoch 再选最佳权重。
训练完成后,用验证集跑一次评估:
yolo detect val \ model=runs/container_defect/exp1/weights/best.pt \ data=data.yaml \ imgsz=640 \ conf=0.25 \ iou=0.5conf=0.25是置信度阈值,iou=0.5是 NMS 的 IoU 阈值。这两个值直接影响召回和误报的平衡。工业场景通常更看重召回,可以适当降低 conf 到 0.15~0.2,宁可多报不可漏报。
5. 避坑与排查:标注、格式、训练里的 5 个真实翻车点
这一章全是血泪经验。数据集用起来出问题,90% 不是模型的问题,是数据和配置的问题。下面 5 条按「现象 → 原因 → 解决」写,每条都是实际踩过的。
5.1 类别名带空格导致训练报 KeyError
现象:训练启动后报KeyError: 'Dent '或类别名找不到,或者训练能跑但类别全乱。
原因:labelImg 在某些版本里导出 xml 时,<name>标签内容会带首尾空格,比如Dent而不是Dent。YOLO 解析时按字符串精确匹配,带空格的类别名对不上 data.yaml 里的names。
解决:在解析 xml 的脚本里统一strip(),或者批量清洗 xml 文件。清洗命令:
import os, xml.etree.ElementTree as ET xml_dir = "dataset/Annotations" for f in os.listdir(xml_dir): if not f.endswith(".xml"): continue path = os.path.join(xml_dir, f) tree = ET.parse(path) root = tree.getroot() changed = False for obj in root.findall("object"): name = obj.find("name") if name.text != name.text.strip(): name.text = name.text.strip() changed = True if changed: tree.write(path, encoding="utf-8", xml_declaration=True)5.2 txt 里的 class_id 与 data.yaml 顺序不一致
现象:训练 loss 正常下降,但验证时所有 Dent 被预测成 Rust,或者混淆矩阵完全错位。
原因:labelImg 导出 YOLO 格式时,class_id 是按标注时的类别列表顺序生成的。如果标注时先标了 Rust 再标 Dent,那 Rust 的 id 可能是 0,Dent 是 1。而 data.yaml 里写的是['Dent', 'Hole', 'Rust'],顺序对不上。
解决:先确认 txt 里的 id 分布,再反推映射。打开几个 txt,看第一列的数字,对照对应 xml 里的 name。如果发现 id 0 对应的是 Rust,就把 data.yaml 改成['Rust', 'Hole', 'Dent'],或者写脚本把 txt 里的 id 重映射。
# 假设实际映射是 0->Rust, 1->Hole, 2->Dent # 目标映射是 0->Dent, 1->Hole, 2->Rust id_map = {0: 2, 1: 1, 2: 0} lbl_dir = "dataset/labels" for f in os.listdir(lbl_dir): if not f.endswith(".txt"): continue path = os.path.join(lbl_dir, f) with open(path) as fp: lines = fp.readlines() new_lines = [] for line in lines: parts = line.strip().split() if parts: parts[0] = str(id_map[int(parts[0])]) new_lines.append(" ".join(parts) + "\n") with open(path, "w") as fp: fp.writelines(new_lines)5.3 图片尺寸不统一导致归一化坐标错乱
现象:训练时框的位置明显偏移,或者验证可视化里框画到了图片外面。
原因:YOLO txt 里的坐标是归一化的,归一化除数是图片实际宽高。如果转换脚本写死了 640×640,而实际图片是 1920×1080,坐标就全错了。
解决:转换时必须逐张读取图片尺寸。用 PIL 或 OpenCV 读:
from PIL import Image import os img_dir = "dataset/JPEGImages" for f in os.listdir(img_dir): if not f.endswith(".jpg"): continue with Image.open(os.path.join(img_dir, f)) as im: w, h = im.size # 用 w, h 做归一化,不要写死如果已经生成了错误的 txt,只能重新从 xml 转一遍,确保归一化除数正确。
5.4 空标注图导致 dataloader 报错
现象:训练到某一步突然报IndexError或ZeroDivisionError,定位到某张图。
原因:有些图可能没有标注,对应的 txt 是空文件。YOLO 默认会跳过空 txt,但如果配置里skip_empty没开,或者空 txt 被当成有效样本,就会出问题。
解决:先统计空 txt 数量,决定是剔除还是保留。工业数据里,纯背景图也有价值,可以保留但要在 dataloader 里正确处理。YOLOv8 默认会跳过空标签图,如果报错,检查 txt 是否真的是空文件而不是只有换行符。
empty = [] lbl_dir = "dataset/labels" for f in os.listdir(lbl_dir): if f.endswith(".txt"): with open(os.path.join(lbl_dir, f)) as fp: if not fp.read().strip(): empty.append(f) print(f"空标注文件: {len(empty)}")5.5 验证集划分不均匀导致指标虚高
现象:验证集 mAP 很高,但实际部署时效果差很多。
原因:随机划分时,验证集可能恰好抽到了容易的图,或者某个类别在验证集里几乎没有。比如 Hole 只有 1218 个框,随机抽 20% 可能只抽到 200 个,评估结果波动大。
解决:用分层抽样,确保验证集里三个类别的框数比例和整体一致。前面 3.3 的脚本已经按主类别分层,但更严格的做法是按框数分层。如果验证集 mAP 波动超过 5 个点,说明验证集太小或不均衡,考虑增大验证集比例到 25%。
提示:每次改完数据或配置,先跑 1~2 个 epoch 看 loss 是否正常下降,再跑完整训练。别一上来就 150 epoch,浪费算力。
6. 进阶技巧:用 VOC xml 做数据增强与难例挖掘
训练跑通只是第一步,真正拉开差距的是数据层面的迭代。这份数据同时给了 VOC 和 YOLO 两种格式,很多人只用 YOLO txt 训练,把 xml 扔在一边,其实浪费了。xml 里保留了图片宽高、目标绝对坐标,做数据增强和难例分析时比 txt 方便得多。
6.1 基于 xml 的离线增强
YOLO 自带的在线增强(mosaic、mixup)已经很强,但有些增强适合离线做,比如针对 Hole 的 copy-paste。用 xml 读原图,把 Hole 区域裁剪出来,再随机贴到其他没有 Hole 的图上,同时更新 xml 和 txt。这样能定向扩充 Hole 样本,比单纯过采样更有效。
import xml.etree.ElementTree as ET from PIL import Image import random, os def crop_hole(img_path, xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() with Image.open(img_path) as im: for i, obj in enumerate(root.findall("object")): if obj.find("name").text.strip() != "Hole": continue bbox = obj.find("bndbox") xmin = int(float(bbox.find("xmin").text)) ymin = int(float(bbox.find("ymin").text)) xmax = int(float(bbox.find("xmax").text)) ymax = int(float(bbox.find("ymax").text)) patch = im.crop((xmin, ymin, xmax, ymax)) patch.save(os.path.join(out_dir, f"hole_{os.path.basename(img_path)}_{i}.png")) # 遍历所有含 Hole 的图,裁剪出 Hole 区域 for f in os.listdir("dataset/Annotations"): if f.endswith(".xml"): name = os.path.splitext(f)[0] xml_path = os.path.join("dataset/Annotations", f) img_path = os.path.join("dataset/JPEGImages", name + ".jpg") crop_hole(img_path, xml_path, "hole_patches")逻辑说明:这段脚本把所有 Hole 区域裁剪成小图,存到hole_patches。然后写另一个脚本,随机选一张无 Hole 的图,把 patch 贴上去,同时生成新的 xml 和 txt。注意贴的时候要避开已有目标区域,否则框会重叠。贴完后的坐标要重新计算,归一化除数用新图的宽高。
6.2 用验证集做难例挖掘
训练完一版模型后,用验证集跑推理,把误报和漏报的图挑出来,人工复核标注。常见做法是用yolo detect predict输出带框的图,然后按置信度排序,低置信度的真阳性、高置信度的假阳性都是重点。
yolo detect predict \ model=runs/container_defect/exp1/weights/best.pt \ source=yolo_dataset/images/val \ conf=0.1 \ save_txt=True \ save_conf=True \ project=runs/predict \ name=val_checkconf=0.1故意调低,让模型多输出一些框,方便看漏报。save_txt=True会把预测结果存成 txt,格式和标注一致,可以直接和标注对比。写个脚本算每个类别的 TP/FP/FN,找出 Hole 漏报最多的图,回去检查标注是否有遗漏或框不准。
6.3 一个具体技巧:用 Rust 的框数反推标注一致性
Rust 有 3956 个框,Dent 有 4943 个,两者数量接近,但 Rust 通常面积更大、边界更模糊,标注一致性容易出问题。我一般会抽 50 张含 Rust 的图,把 xml 里的框画出来,肉眼检查是否有漏标或框过大。如果发现某张图的 Rust 框明显偏少,说明标注员可能把锈蚀当成了背景。这种图如果留在训练集里,会教模型忽略 Rust,导致召回下降。
具体操作:用 xml 读框,用 OpenCV 画到图上,存到rust_check目录,人工过一遍。发现问题的图,要么重新标,要么从训练集剔除。这个步骤花不了半小时,但能避免后面反复调参的玄学问题。
从那以后我每次拿到新数据集,都强制走一遍「数量校验 → 类别分布复核 → 抽样可视化 → 空标注检查」这四步,再开始训练。希望帮到你。
本文还有配套的精品资源,点击获取