ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

行李箱缺陷检测数据集:650张图YOLO+VOC双格式与训练避坑指南

行李箱缺陷检测数据集:650张图YOLO+VOC双格式与训练避坑指南

简介:一份用于行李箱表面缺陷检测的图像数据集,共包含650张清晰拍摄的行李箱图片,采用VOC与YOLO双格式标注,可直接用于目标检测模型训练。压缩包内设JPEGImages、Annotations、labels三个文件夹,分别保存jpg原图、xml标注文件与txt标签文件,三类文件各650个,合计1953个文件,压缩包大小约25.16MB。标注共分两类:damaged(损坏)与good_condition(完好),矩形框总数936个,其中damaged框199个、good_condition框737个,类别划分明确,适合训练行李箱破损检测模型。数据集未做增强处理,保留了原始场景分布,便于使用者按需扩展或进行数据预处理实验。目前已有48人学习下载,适用于计算机视觉入门实践、毕业设计及工业质检场景下的缺陷识别任务。

1. 行李箱缺陷检测数据集:650张图、2类标签、YOLO+VOC双格式,拿来就能训练

做质检类目标检测的同行都有体会:工业缺陷样本最难找的往往不是算法,而是标注干净的数据。这份行李箱缺陷检测数据集一共650张JPEG图片,每张都有对应的VOC格式xml和YOLO格式txt,标签分damaged和good_condition两类,合计936个矩形框。图片清晰度不错,没有做过增强,意味着分布相对原始。适合两类人:一是刚接触目标检测、想用一套规范双格式数据跑通训练全流程的初学者;二是手头有检测项目、需要真实工业样本做预训练或迁移评估的工程师。下面我把压缩包内部结构、格式转换、训练前处理和踩坑点完整拆一遍。

2. 拆开压缩包:目录结构、标注分布与数据自检

2.1 三个文件夹各装什么

解压后是JPEGImages、Annotations、labels三个目录,分别对应图片、VOC格式xml、YOLO格式txt。这是目标检测数据集最常见的三种组织方式,文件命名用xyxr_image加编号,三个目录里的文件名一一对应,没有额外的子目录嵌套。

unzip 数据集-行李箱缺陷检测数据集650张2类YOLO+VOC格式.zip -d luggage_defect cd luggage_defect ls -l JPEGImages | wc -l # 650 ls -l Annotations | wc -l # 650 ls -l labels | wc -l # 650

数量和目录结构没问题上,但实际生产环境里经常遇到文件对不齐的情况,所以拿到任何数据集我的习惯都是先跑一遍自检脚本,而不是直接开训。

2.2 标签分布:damaged只有199框,接近1:3.7的不平衡

类别名称是damaged和good_condition,damaged框数199,good_condition框数737,总框数936。按框数算,正常样本是缺陷样本的3.7倍,存在明显不平衡。

这个比例在工业质检里太常见了——产线上绝大多数产品是良品,缺陷本身是小概率事件。但直接影响是:如果直接训练,模型会偏向预测good_condition,因为把所有框都判成正常就能拿到约73%的正确率。后面我会讲具体怎么处理,这里先记住一个数字:199。

另外936个框分布在650张图里,平均每张1.44个框,说明大部分图片要么只有一个框,要么干脆没有标注框。如果你的训练框架要求每张图至少有一个目标,这批数据里就可能存在空标注图片,需要在预处理阶段统计出来。

2.3 用脚本核对图、xml、txt三者的一致性

文件数量对得上不代表内容对得上。xml里的文件名、尺寸和实际图片是否匹配,txt里的类别索引是否在合法范围内,这些都要批量检查。

import os import xml.etree.ElementTree as ET img_dir = 'JPEGImages' xml_dir = 'Annotations' txt_dir = 'labels' img_names = {f.split('.')[0] for f in os.listdir(img_dir) if f.endswith('.jpg')} xml_names = {f.split('.')[0] for f in os.listdir(xml_dir) if f.endswith('.xml')} txt_names = {f.split('.')[0] for f in os.listdir(txt_dir) if f.endswith('.txt')} missing_xml = img_names - xml_names missing_txt = img_names - txt_names print('缺xml:', len(missing_xml), '缺txt:', len(missing_txt)) # 抽查10个xml的尺寸是否与图片匹配 from PIL import Image import random for name in random.sample(sorted(img_names), 10): img = Image.open(os.path.join(img_dir, name + '.jpg')) tree = ET.parse(os.path.join(xml_dir, name + '.xml')) root = tree.getroot() size = root.find('size') w, h = int(size.find('width').text), int(size.find('height').text) if (w, h) != img.size: print(f'尺寸不匹配: {name}, xml={w}x{h}, 实际={img.size}') print('抽检完成')

这段脚本的核心逻辑是集合差运算,快速找出三个目录里对不上的文件。后面那段是抽查xml里记录的width和height与图片实际分辨率是否一致,防止源数据标注时用了缩放后的尺寸。注意到这个数据集只有650张,全量校验xml也不耗时,我一般会直接去掉sample,把random.sample换成全量遍历。

3. VOC与YOLO互转:坐标归一化、类别映射与边界校验

3.1 两种格式的本质差异

VOC格式用xml存储,每个目标用xmin、ymin、xmax、ymax表示左上角和右下角的绝对像素坐标;YOLO格式用txt存储,每行是“class_id x_center y_center width height”,其中中心点坐标和宽高都除以图片宽高做了归一化。区别在于:VOC给人看的,YOLO给模型吃的。

实际训练时,不同框架对标注格式要求不同。YOLOv5、YOLOv8系列原生读txt,而某些检测框架或开源代码直接读VOC xml。拿到这份双格式数据后,大多数时候不需要转换,但如果你要自己调整数据、合并其他数据集,或者修改类别,就必须手工转格式。我一般保留VOC作为“母版”,因为xml里信息全,转为txt只是算术题,反过来要补回xml反而麻烦。

3.2 xml转txt的完整脚本与参数说明

import os import xml.etree.ElementTree as ET classes = ['damaged', 'good_condition'] # 必须与数据集标签顺序一致 def convert_xml_to_txt(xml_path, txt_path, img_w, img_h): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.findall('object'): name = obj.find('name').text if name not in classes: print(f'未知类别: {name} in {xml_path}') continue class_id = classes.index(name) box = obj.find('bndbox') xmin = float(box.find('xmin').text) ymin = float(box.find('ymin').text) xmax = float(box.find('xmax').text) ymax = float(box.find('ymax').text) # 归一化并转成中心点+宽高格式 x_center = ((xmin + xmax) / 2) / img_w y_center = ((ymin + ymax) / 2) / img_h width = (xmax - xmin) / img_w height = (ymax - ymin) / img_h # 修正浮点误差导致的越界 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) width = min(width, 1.0) height = min(height, 1.0) lines.append(f'{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}') with open(txt_path, 'w') as f: f.write('\n'.join(lines)) # 批量转换 xml_dir = 'Annotations' txt_dir = 'labels' img_dir = 'JPEGImages' from PIL import Image for xml_name in os.listdir(xml_dir): if not xml_name.endswith('.xml'): continue name = xml_name[:-4] img = Image.open(os.path.join(img_dir, name + '.jpg')) w, h = img.size convert_xml_to_txt( os.path.join(xml_dir, xml_name), os.path.join(txt_dir, name + '.txt'), w, h )

这段代码里最值得注意的不是坐标换算公式,而是两个细节。第一,classes列表的顺序定义了txt里class_id的编号,0对应damaged,1对应good_condition,一旦顺序错了,训练时类别就全乱了。第二,xmin和xmax相加除以2再除以宽度,如果标注框刚好贴在图片边缘,浮点运算可能算出1.0000001这种越界值,所以输出前做了clip操作。这种小问题不会让训练崩溃,但会让后续的mAP计算出现奇怪误差。

3.3 反向转换与坐标合法性检查

反过来txt转xml,核心是乘回去:xmin = (x_center - width/2) * img_w。但这里有个坑:txt里的坐标被归一化到0到1之间,如果原标注框尺寸很小,转回整数像素时可能因为四舍五入变成0宽或0高的退化框,需要强制检查width和height大于0。

def txt_to_voc_bbox(txt_line, img_w, img_h): parts = txt_line.strip().split() class_id = int(parts[0]) x_center = float(parts[1]) y_center = float(parts[2]) width = float(parts[3]) height = float(parts[4]) xmin = int((x_center - width / 2) * img_w) ymin = int((y_center - height / 2) * img_h) xmax = int((x_center + width / 2) * img_w) ymax = int((y_center + height / 2) * img_h) if xmax <= xmin or ymax <= ymin: raise ValueError(f'非法框: {txt_line}') return xmin, ymin, xmax, ymax

这个反向转换在数据合并场景下很有用。比如你要把另一批行李箱图片加进来,那边的标注是YOLO格式,想统一成VOC再做二次清洗,就必须经历这道换算。抛出异常而不是静默跳过,是为了让问题暴露出来,否则脏数据混进去后期排查成本更高。

4. 训练前准备:数据集划分、有效增强与类别不平衡处理

4.1 划分train/val/test,别让同一张图出现在两个集合

650张图对深度学习来说属于小规模数据集,划分更要讲究。常见做法是train:val:test按8:1:1切分,但必须保证三个集合里没有重复图片,而且damaged和good_condition的比例在train和val里都要维持原分布。

import os import random from collections import Counter random.seed(42) # 固定随机种子,保证结果可复现 img_dir = 'JPEGImages' txt_dir = 'labels' img_names = [f[:-4] for f in os.listdir(img_dir) if f.endswith('.jpg')] random.shuffle(img_names) # 先切test,再从剩余里切val test_split = int(len(img_names) * 0.1) val_split = int(len(img_names) * 0.1) train_names = img_names[test_split + val_split:] val_names = img_names[test_split:test_split + val_split] test_names = img_names[:test_split] # 统计每个集合里damaged框的数量 def count_damaged(names): cnt = 0 for name in names: txt_path = os.path.join(txt_dir, name + '.txt') if not os.path.exists(txt_path): continue for line in open(txt_path): if line.split()[0] == '0': cnt += 1 return cnt print('train damaged:', count_damaged(train_names)) print('val damaged:', count_damaged(val_names)) print('test damaged:', count_damaged(test_names))

随机种子42是约定俗成的选择,也可以用其他值,但一旦定了就别改,否则每次跑出来的划分结果不一样,实验对比就没有意义了。我见过有人用dict来存储划分结果list,不同集合间出现重叠没发现,训练时验证集里混进训练图片,mAP虚高,模型一上产线就露馅。

4.2 650张太少:增强策略怎么选

这份数据集标注是“是否增强:否”,说明图片都是原始帧,没做过处理。训练时增强操作得自己做。Mosaic增强在YOLOv5系列里是默认选项,把小图拼成大图,能有效缓解小目标漏检,但对行李箱这类中等大小目标效果远不如对密集小目标明显。另外行李箱表面纹理单一,大面积随机擦除反而会破坏缺陷特征。

我一般建议轻度几何增强:水平翻转、小角度旋转、轻微缩放。色彩抖动要谨慎,因为damaged类里的划痕、凹陷对光照变化敏感,调饱和度或亮度可能把浅划痕直接抹掉。下面是用Albumentations库的配置示例:

import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomRotate90(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=10, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), ], bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels']))

注意bbox_params里的format='yolo',对应txt的归一化中心点格式。如果前面转换时用了VOC像素坐标,这里就要写format='pascal_voc'。增强参数不是越大越好,rotate_limit超过15度后,行李箱的边框形状会扭曲到不像实物,模型反而学到错误的几何特征。

4.3 damaged框太少:复制粘贴增强与损失函数权重

damaged只有199框,这是比图片少更棘手的问题。每张图平均0.3个缺陷框,意味着大部分图片里压根没有damaged目标,模型很容易收敛到“什么都别预测”的懒惰解。两种常见处理方案:

第一种是复制粘贴增强,把含damaged区域的图片裁剪出来,随机粘贴到不含缺陷的行李箱图片上,同时复制对应的标注框。这种做法在工业缺陷检测中效果显著,因为缺陷和背景往往是独立分布的,拼接后视觉上不会穿帮。注意粘贴时不要让缺陷框之间重叠过多,否则训练时NMS会把两个目标当成一个。

第二种是调整损失权重。YOLO系列在分类损失上支持给每个类别设置权重,把damaged的权重调高到3到5倍,迫使模型更关注这个样本量少的类别。实际操作中我一般两种都做,但只在验证集上盯着damaged的AP变化,防止损伤good_condition的精度。

5. 避坑与常见问题:标注校验、格式幻觉与训练翻车

5.1 图片、xml、txt数量对不上

现象:解压后发现JPEGImages有650张,Annotations却只有648个xml,labels文件夹也只有645个txt。

原因:在数据采集过程中,照片拍了但标注漏了,或者标注完成后导出中断。这种问题在网上下载的数据集里很常见,上传压缩包时也容易因为文件名编码问题丢失个别文件。

解决:先用第2.3节的集合差脚本找出缺失名单,再去三个目录里核对。如果只是个别缺失,直接补一个空标注文件对齐数量,或者从训练列表里剔除这几张图。千万不要让训练脚本直接遍历JPEGImages目录,然后用find(xml_path)的方式硬找xml,找到不存在的路径报错还是小事,更坑的是某些脚本里这种情况会返回None,然后在解析时崩掉。

5.2 归一化坐标出现负数或大于1

现象:训练时报错“box out of image bounds”,或者loss在初期就出现NaN。

原因:原始VOC标注里,有个别框的xmin、ymin小于0,或者xmax、ymax超出了图片宽度高度。可能是标注软件操作时框拉过头了,也可能是批量标注脚本的边界处理逻辑有bug。转YOLO格式时归一化后就变成负坐标或超过1的坐标。

解决:第3.2节的转换脚本里已经加了clip操作,但那只适合框偏差很小的情况。如果某个框整体都在图片外部,clip之后变成一条贴在边缘的细线,同样会污染训练。正确做法是在转换时计算框的面积,如果clip前后面积变化超过20%,直接丢弃这个框并在日志里输出文件名和坐标值。

5.3 类别索引错位

现象:训练时loss下降正常,但验证集上damaged的召回率几乎为0,而good_condition的AP却异常高。

原因:txt里class_id是0和1,但训练配置文件里把类别列表写成了['good_condition', 'damaged'],顺序和数据集标注时的classes列表不一致。模型实际把damaged的框当成good_condition来学,语义全部错位,但loss曲线看起来完全正常。

解决:训练前用脚本统计一遍txt里每个class_id的出现次数,和数据集描述里的框数对比。damaged应为199,good_condition应为737。如果发现数字对不上,立即检查classes映射顺序。这个检查只需两分钟,但能省下十几个小时的无效训练。另一个类似坑是模型的类别数写成3,用了索引0、1、2,最后那个空类别被强行塞进计算图里,mAP输出会有一列恒为0。

5.4 增强把“缺陷”增强没了

现象:训练几百轮后验证集mAP不低,但把模型放到真实产线图片上,划痕和凹陷一个都检测不出来。

原因:选用了过强的色彩抖动或模糊增强。比如把RandomBrightnessContrast的contrast_limit设成0.5,浅划痕和背景的对比度被拉平,模型在增强图上看到的damaged样本已经丧失了关键视觉特征。这是我见过最隐蔽的翻车点,因为训练集上loss一直在降,只有到现场测试才暴露。

解决:对缺陷检测任务,控制几何增强强度,色彩类增强要么不用,要么在验证集上做A/B对比。我在某次行李箱检测实验里,把亮度变化上限从0.3调到0.1,damaged的AP直接涨了6个点。增强的目的是增加样本多样性,不是制造新样本。

5.5 训练mAP高但实际检测差:先怀疑数据划分

现象:test集上mAP_0.5有0.85,但换了另一批行李箱图片,检测效果大幅下降,误检漏检一大堆。

原因:test集太干净了,和训练集几乎同分布。这份数据集只有650张,如果划分时随机种子固定,每次实验都用同一份val和test,模型很容易在test上过拟合。另外如果图片是从视频里逐帧抽的,相邻帧高度相似,train和test之间可能存在隐性重复。

解决:用第4.1节脚本划分后,再检验一下test集和train集的图片相似度。如果发现连续性过强,应该按时间或批次分组而不是纯随机划分。做交叉验证也行,5折每折130张测试,虽然训练成本高5倍,但评估结果靠谱得多。

6. 最后一道工序:把标注画回原图做抽检

6.1 抽检脚本:一眼看出标注质量

训练前画的框是给人看的,直接决定模型学什么。我每次拿到新数据集,不管描述里说标注多准确,都会抽50张图把框画回去人工看一遍。这一步看起来原始,但确实能发现脚本校验发现不了的问题:比如框打偏了、框太小只框住缺陷的一部分、或者把行李箱的轮子也标成了damaged。

import cv2 import os img_dir = 'JPEGImages' txt_dir = 'labels' out_dir = 'visual_check' os.makedirs(out_dir, exist_ok=True) colors = {'damaged': (0, 0, 255), 'good_condition': (0, 255, 0)} for name in os.listdir(img_dir)[:50]: img_path = os.path.join(img_dir, name) img = cv2.imread(img_path) h, w = img.shape[:2] txt_path = os.path.join(txt_dir, name.replace('.jpg', '.txt')) if not os.path.exists(txt_path): continue for line in open(txt_path): parts = line.strip().split() cls_id = int(parts[0]) x_center = float(parts[1]) * w y_center = float(parts[2]) * h box_w = float(parts[3]) * w box_h = float(parts[4]) * h x1 = int(x_center - box_w / 2) y1 = int(y_center - box_h / 2) x2 = int(x_center + box_w / 2) y2 = int(y_center + box_h / 2) label = 'damaged' if cls_id == 0 else 'good_condition' cv2.rectangle(img, (x1, y1), (x2, y2), colors[label], 2) cv2.putText(img, label, (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors[label], 1) cv2.imwrite(os.path.join(out_dir, name), img)

画框脚本不涉及任何训练逻辑,只是把txt的归一化坐标还原成像素坐标,然后画矩形和类别文字。抽检时重点关注三类问题:框是否贴合目标边缘、damaged框是否把正常部位也包进去了、有没有目标完全没被框住。如果这50张里发现问题超过5处,这份数据的标注质量就要打问号,宁可花时间清洗也别直接开训。

6.2 从抽检延展出来的一个习惯

从那以后,我每次拿到新数据集都强制走一遍完整流程:解压、数量校验、格式转换复查、划分、可视化抽检,最后才轮到训练。这五步看起来繁琐,但对650张这种小数据集来说,总共也就半小时工作量,却能避免大部分训练翻车。

希望帮到你。这份行李箱缺陷检测数据集素材本身是规范的,主要工作量在训练前的数据工程上,把不平衡问题和格式校验处理好,后面训练就是水到渠成的事。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进