简介:面向苹果外观缺陷检测的Pascal VOC与YOLO双格式数据集,适合需要可直接用于模型训练的标注数据的算法工程师和计算机视觉学习者,覆盖病果、好果、烂果、次果四类,由labelImg完成标注,共17936个标注框。其中烂果样本约14556框,病果、次果和好果样本相对较少,类别分布信息可帮助使用者在数据增强或样本均衡时做出判断。数据集标注规模为6970张苹果图片,每张均有对应的VOC格式XML与YOLO格式TXT标注,不含分割路径,省去格式转换环节;缺陷类别齐全,双格式兼顾VOC训练流程和YOLO系列直接加载需求。资源包共2000个文件,以XML标注文件为主,配TXT说明,7z压缩后约292.72MB。已有755人学习下载,适合作为苹果分选、品质检测等项目训练的起点数据,可直接用于YOLO系列、Faster R-CNN等目标检测模型的训练与效果验证。
1. 苹果缺陷检测数据集:6970张图、双格式、四类缺陷,到底省了多少事
苹果缺陷检测数据集这个压缩包,我第一次解压后做的第一件事不是开训,而是先核对VOC和YOLO两套标注是否对齐。做表面缺陷检测的人都有体会,红苹果上的碰伤、果面疤痕和腐烂在RGB图里灰度接近、边界模糊,模型最终学成什么样,多半不取决于网络结构,而是取决于标注干不干净、格式统不统一。这份资源一共6970张图,四个类别,VOC格式的XML和YOLO格式的TXT都给你备好了,等于把“标注格式不统一”这个最大的坑提前填了。
适合正在做YOLO系列训练、果实表面缺陷检测、农产品质检算法落地的人。新手可以拿它完整走一遍“解压→校验→划分→训练→调参”的流程,老手则可以直接复用它跑网络结构对比实验,省掉半天捣鼓标注的时间。对我来说,这种双格式数据集最大的价值不是图多,而是它天然适合做“格式转换→数据校验→训练验证”的完整演练,后面你换任何检测框架都有一份可对照的基准。
2. 拆开数据集看门道:VOC的XML和YOLO的TXT在苹果表面上各记了什么
2.1 一张图三份文件:JPEG、XML、TXT的真实对应关系
解压之后,目录结构通常是下面这种经典组织方式:
apple_defect_dataset/ ├── images/ # 原图,全部JPG │ ├── apple_00001.jpg │ └── apple_06970.jpg ├── voc_annotations/ # VOC格式标注,XML文件 │ ├── apple_00001.xml │ └── apple_06970.xml ├── yolo_labels/ # YOLO格式标注,TXT文件 │ ├── apple_00001.txt │ └── apple_06970.txt └── classes.txt # 类别清单,按id顺序排列每张JPG对应一个XML和一个TXT,三个文件的主文件名一致。VOC格式的XML给人复核用的,里面包含了图像宽高、目标类别名和绝对像素坐标的bndbox;YOLO格式的TXT是给训练器直接吃的,每一行是“类别id + 归一化中心点x + 中心点y + 归一化宽 + 归一化高”,五个数字以空格分隔。同一目标在两种格式下的数值差别很大,但描述的是同一个物理位置。
<annotation> <filename>apple_00001.jpg</filename> <size><width>800</width><height>600</height></size> <object> <name>bruise</name> <bndbox> <xmin>120</xmin><ymin>95</ymin><xmax>180</xmax><ymax>145</ymax> </bndbox> </object> </annotation>对应上面的XML,TXT里那一行是:
0 0.1875 0.2000 0.0750 0.08330.1875的计算过程是 (120+180)/2/800,中心点x除以图像宽;0.0750是(180-120)/800,框宽也除以图像宽。YOLO训练器读取TXT时不会回查原图的像素尺寸,它默认你给的五个数都已经归一化到0-1区间。所以VOC转YOLO的脚本本身不难,难在类别顺序对齐和边界框取值是否精准,这两个点后面章节会专门展开。
提示:如果你在某个目录里发现TXT文件是空的,先别急着删图。空TXT在YOLO里代表这张图没有目标,会被当作纯背景训练,有时是数据提供方有意混入的负样本。
2.2 类别分布与框宽高比:四类缺陷在数据里的真实面貌
训练前先统计每个类别的框数量,这个习惯能救回不少实验事故。拿YOLO格式的TXT做统计最直接,因为每行就是一个目标,不用解析XML。
from collections import Counter from pathlib import Path label_dir = Path("yolo_labels") counts = Counter() aspect_ratios = [] for txt in label_dir.glob("*.txt"): for line in txt.read_text().strip().splitlines(): parts = line.split() if len(parts) != 5: print("异常行:", txt.name, line) continue cls = int(parts[0]) w = float(parts[3]) h = float(parts[4]) counts[cls] += 1 aspect_ratios.append(w / h) for cls in sorted(counts): print(f"class {cls}: {counts[cls]} boxes")这段脚本同时做了两件事:按类别累计框数,并记录每个框的宽高比。类别id转int是因为TXT第一列在YOLO里恒为整数;w/h小于0.3或大于3的框要留意,苹果表面缺陷大多是近圆形区域,宽高比极端化的框往往标到了果梗、叶片遮挡物或其他噪声。
类别分布能直接决定训练策略。四类样本量如果大致均衡,直接用默认损失函数就行;如果某一类只占5%,后面训练时就要考虑给该类加权重,或者用复制粘贴增强去补样本。6970张图配上四类缺陷,多数情况下分布是够用的,但每份数据的实际情况不一样,统计完再决定下一步更稳。
2.3 配对校验:先查缺失再谈训练
YOLO训练时会按图片路径去找同名TXT,找不到就默认这张图没有标注。最坑的是图片加载成功、训练也不报错,但损失函数全程不变,最后精度全挂在背景上。
from pathlib import Path img_dir = Path("images") voc_dir = Path("voc_annotations") yolo_dir = Path("yolo_labels") all_images = list(img_dir.glob("*.jpg")) print("图片总数:", len(all_images)) for img in all_images: stem = img.stem if not (voc_dir / f"{stem}.xml").exists(): print("VOC缺失:", stem) if not (yolo_dir / f"{stem}.txt").exists(): print("YOLO缺失:", stem)跑完这个脚本,把缺失文件补上或剔除对应图片,再进训练流程。常见做法是直接重命名对齐,但更推荐在切分脚本里做一次过滤,把不完整的样本干干净净筛掉,而不是在原目录里来回搬文件。
注意:VOC和YOLO两套标注都齐全不代表内容一致。有时间的话随机抽20张图,把XML里的bndbox和TXT里的归一化框反算回像素坐标对比一遍,确认数据提供方没有在转格式时把某类标错。双格式数据集最常见的隐患,就是两套标注里的类别序号对不上。
3. 把VOC转成YOLO并喂进YOLOv8:转换脚本、7:2:1切分与data.yaml一次配齐
3.1 用Python解析VOC的XML到YOLO的TXT:核心是归一化
虽然这份数据集自带YOLO格式,但实际工作中经常遇到只有VOC标注的兄弟项目,或者你想把这份标注转成自己团队的中间格式。自己写一次转换脚本,比任何教程都更能帮你理解坐标体系。
import xml.etree.ElementTree as ET from pathlib import Path # 类别映射,务必与目标yaml的names顺序一致 # 实际使用时改成压缩包里classes.txt里的四个类别名 class_map = {"bruise": 0, "rot": 1, "scar": 2, "stem": 3} voc_dir = Path("voc_annotations") out_dir = Path("yolo_labels_custom") out_dir.mkdir(exist_ok=True) for xml_file in voc_dir.glob("*.xml"): tree = ET.parse(xml_file) root = tree.getroot() width = float(root.findtext("size/width")) height = float(root.findtext("size/height")) lines = [] for obj in root.findall("object"): name = obj.findtext("name") if name not in class_map: continue # 跳过未映射类别,避免写进脏数据 box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) cx = (xmin + xmax) / 2 / width cy = (ymin + ymax) / 2 / height w = (xmax - xmin) / width h = (ymax - ymin) / height lines.append(f"{class_map[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") out_path = out_dir / (xml_file.stem + ".txt") out_path.write_text("\n".join(lines))width、height用的float而不是int,是为了防止某些标注工具写了带小数点的尺寸时被截断。找到obj.findall("object")遍历每个目标对象,再往里取bndbox的四个值。continue那行是防呆设计:数据里偶尔出现未纳入分类的杂目标,直接跳过比写入错误类别id更安全。输出保留6位小数,对800×600级别的图足够。
这段脚本跑完,可以用上一章的统计脚本再跑一遍,对比转换前后的类别框数是否一致。数字对不上,多半是XML里存在重复的object节点或空bndbox。
3.2 按7:2:1切分并固定随机种子:让每一轮实验都可复现
训练集、验证集、测试集的划分直接影响你对模型能力的判断。七成训练、两成验证、一成测试是单数据集场景下的默认起手式,关键是切分时固定随机种子。
import random from pathlib import Path import shutil random.seed(42) imgs = sorted(Path("images").glob("*.jpg")) random.shuffle(imgs) n = len(imgs) n_train = int(n * 0.7) n_val = int(n * 0.2) train_imgs = imgs[:n_train] val_imgs = imgs[n_train:n_train + n_val] test_imgs = imgs[n_train + n_val:] for split, split_imgs in [("train", train_imgs), ("val", val_imgs), ("test", test_imgs)]: img_out = Path("dataset") / "images" / split label_out = Path("dataset") / "labels" / split img_out.mkdir(parents=True, exist_ok=True) label_out.mkdir(parents=True, exist_ok=True) for img in split_imgs: shutil.copy(img, img_out / img.name) txt = Path("yolo_labels") / (img.stem + ".txt") if txt.exists(): shutil.copy(txt, label_out / txt.name) print(f"train={len(train_imgs)} val={len(val_imgs)} test={len(test_imgs)}")seed=42是约定俗成的固定值,保证你和同事分别在各自机器上跑,切分结果完全一致。排序后再shuffle也很重要,直接从文件系统拿到的顺序往往和采集时间相关,不洗牌会让某些类集中到单一split,训练集里某类缺陷全是同一光照条件拍的,验证时就露馅。test集只在最终评估时用一次,平时调参只看val指标。
3.3 写data.yaml并跑第一条训练命令
YOLOv8和YOLOv5都吃同一套data.yaml结构。路径推荐写绝对路径,避免换机器跑训练时相对路径解析错位。
# dataset/data.yaml path: /absolute/path/to/dataset # 改成你的实际路径 train: images/train val: images/val test: images/test nc: 4 names: ["bruise", "rot", "scar", "stem"]names的顺序必须和转换脚本里class_map的value一一对应。这个对应关系错位时,训练不报错、loss照常下降,但最终mAP会非常难看,因为模型学的标签和真实标签是错位的。写完yaml,先用一行命令验证标签可读性,再正式开训。
yolo detect train data=dataset/data.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16 lr0=0.01 patience=20这条命令里epochs=100是初始值,patience=20表示连续20轮验证集指标不涨就提前停;imgsz=640是YOLOv8默认输入尺寸,下面的章节会解释为什么这个值对缺陷检测不能随意调低。第一次跑不要动太多超参,先把基线跑出来,后续对比实验才有参照物。
4. 训练前把好关:标注质量三重校验与第一批参数的保守设置
4.1 第一重校验:归一化坐标越界与类别id溢出
标注文件里的坐标越界是最隐蔽的问题。XML和TXT在数据提供方手里可能被手工编辑过,或者转换脚本里除整数时精度丢失,导致归一化坐标落在0-1之外。
import cv2 from pathlib import Path img_dir = Path("images") yolo_dir = Path("yolo_labels") for txt in yolo_dir.glob("*.txt"): img = cv2.imread(str(img_dir / (txt.stem + ".jpg"))) if img is None: print("坏图:", txt.stem) continue h, w = img.shape[:2] for line in txt.read_text().strip().splitlines(): parts = line.split() if len(parts) != 5: print("格式错误:", txt.name, line) continue cls, cx, cy, bw, bh = map(float, parts) if not (0 <= float(cx) <= 1 and 0 <= float(cy) <= 1): print("中心点越界:", txt.name, line) if cls not in (0, 1, 2, 3): print("类别id溢出:", txt.name, cls) if bw <= 0 or bh <= 0: print("宽高非法:", txt.name, line)cv2.imread读取失败会返回None,cv2.imread在路径包含中文时会静默失败,所以上面先判空。越界坐标在训练时会被Mosaic增强或RandCrop进一步放大,一个原本只偏出几个像素的框可能变成边界外的全黑区域,损失函数瞬间异常。类别id溢出的危害更直接:模型输出维度是4,你给它一个5,训练直接崩。
4.2 第二重校验:空标签文件与小目标面积占比
空TXT文件在2.3节的配对脚本里不会被发现,因为文件存在,只是内容为空。YOLO训练器对这类文件的处理是当作背景图,少量背景图没问题,但如果几十张图全是空的,等于人为制造了负样本干扰。
from pathlib import Path empty_files = [] tiny_boxes = 0 total_boxes = 0 for txt in Path("yolo_labels").glob("*.txt"): lines = txt.read_text().strip().splitlines() if not lines: empty_files.append(txt.name) continue for line in lines: parts = line.split() if len(parts) != 5: continue bw, bh = float(parts[3]), float(parts[4]) area_ratio = bw * bh total_boxes += 1 if area_ratio < 0.001: tiny_boxes += 1 print("空文件数量:", len(empty_files)) print("小目标框占比:", tiny_boxes / total_boxes if total_boxes else 0)小目标框占比是判断模型选型的硬指标。苹果图多半是整果入镜,果面上的碰伤可能只有几十像素,换算成归一化面积常常不到0.001。YOLOv8n的检测头对这类小目标召回偏弱,如果你统计下来小目标占比超过三成,后面直接考虑在输入图层面做切片,或者换用更大输入尺寸。
4.3 第三重校验:完全重叠的重复标注框
重复标注在人工标注环节很难避免,尤其是多人协作时两个人对同一个缺陷各标一次。完全重叠或高度重叠的框会让模型学出“一个目标必须输出两个框”的错觉,推理时置信度被稀释。
from pathlib import Path import itertools def iou(a, b): x1, y1, w1, h1 = a x2, y2, w2, h2 = b ax1, ay1, ax2, ay2 = x1, y1, x1 + w1, y1 + h1 bx1, by1, bx2, by2 = x2, y2, x2 + w2, y2 + h2 ix1, iy1 = max(ax1, bx1), max(ay1, by1) ix2, iy2 = min(ax2, bx2), min(ay2, by2) inter = max(0, ix2 - ix1) * max(0, iy2 - iy1) union = w1 * h1 + w2 * h2 - inter return inter / union if union > 0 else 0 txt_path = Path("yolo_labels") / "apple_00001.txt" boxes = [] for line in txt_path.read_text().strip().splitlines(): parts = list(map(float, line.split())) boxes.append((parts[1], parts[2], parts[3], parts[4])) for a, b in itertools.combinations(boxes, 2): if iou(a, b) > 0.9: print("疑似重复标注:", txt_path.name, a, b)这段脚本按文件遍历盒子,两两计算IoU,超过0.9判定为重复框。如果你的数据集里重复标注比例很高,最简单的做法不是删框而是整图剔除,因为重叠框周围的上下文信息已经不可靠了。
4.4 第一批训练参数:为什么是yolov8n、imgsz=640、epochs=100
第一次跑这份苹果缺陷数据集,我建议用yolov8n起手,不要一上来就上yolov8l。n模型只有约3M参数,在单张显卡上十几分钟就能跑完100个epoch,它的价值是快速验证数据链路是否通、标注是否有问题。跑通之后再换s或m模型做正式训练,即使后面发现数据有坑,浪费的也只是十几分钟。
imgsz=640是YOLOv8的默认值,但对苹果缺陷检测来说,640意味着把整果压到边长640后再检测。缺陷区域太小的话,640不一定够。有V100这类显卡资源时,我一般会把imgsz设到800或960试一轮,对比mAP变化;如果提升不明显再退回640,毕竟高分辨率直接拉高显存占用和推理耗时。
epochs=100配合patience=20是稳妥组合。loss曲线如果到80个epoch还在稳定下降,说明数据里还有信息可学,这时候再手动加epochs续训,而不是从头再来。lr0用默认0.01,第一次跑不要动它。
5. 避坑:用苹果缺陷数据集训YOLO最容易翻车的五个位置
5.1 训练跑到一半loss全部变nan
现象:某个epoch开始,box_loss和cls_loss同时变成nan,训练日志里一片红字。
原因:最常见是学习率过高加上批次内混入了数值异常图,比如纯黑图或压缩损坏图。其次是特征图在深层网络里出现数值溢出,YOLOv8的bn层在数据分布极端时方差爆炸。
解决:先查数据,用4.1节的脚本跑一遍,剔除坏图和越界标注。再把lr0从0.01降到0.001,warmup_epochs从3提到5。如果问题依旧,用单卡小batch从零开始训练,排除多卡同步带来的数值精度问题。
5.2 训练完mAP全是0,但loss曲线很好看
现象:loss正常下降,甚至降到很低的平台期,但val/epoch的mAP@0.5显示0。
原因:数据集的TXT里类别id和data.yaml的names顺序错位了。比如TXT里的0实际是碰伤,yaml里0却是腐烂,模型学的内容和验证集对不上,所有指标归零。
解决:打印yaml里的names和TXT里的第一列类别id做对照。最简单的方法是用3.1节的转换脚本重新生成一遍TXT,确保class_map的value和yaml顺序完全一致。这个错位在训练时不会报任何警告,血泪教训。
5.3 验证集召回率低,小碰伤框不住
现象:mAP@0.5有0.7以上,但小碰伤接近一半漏检,小目标框的置信度普遍低于0.3。
原因:苹果整图在缩放到640后,几十像素的缺陷区域只剩个位数像素宽。YOLOv8的每个检测头有固定感受野,小目标分配给P3层,但特征图的原始分辨率限制了小目标表达能力。
解决:imgsz从640提到896,小目标框数量不降反升。同时把mosaic增强比例调大,让训练时更多看到多尺度拼接图。如果硬件条件不允许,就做离线切片,把原图切成四块分别训练。
5.4 混淆矩阵对角线加起来不等于100%
现象:训练结束看混淆矩阵,把每个类别的对角线数值相加,发现离100%差很远,怀疑自己训练错了。
原因:YOLOv8的confusion matrix按行归一化并且包含背景类别,每一行的含义是“某个真实类别是否预测正确、误判成了哪些类”,行和列共用同一个100%基数。拿对角线求和去对准确率,本身是错误用法。
解决:看指标以mAP50、mAP50-95和各类的precision、recall为准。混淆矩阵只用来分析哪两个类别互相误判,比如碰伤和疤痕被反复搞混,那就回去看标注边界是否符合人工判断习惯。
5.5 验证集表现正常,部署在产线图上小框误报一堆
现象:测试集指标合格,但把模型部署到实际产线图片,同一条苹果上冒出一串小框。
原因:训练数据里同一缺陷被重复标注,或者把果面反光、果粉残留这些背景特征当作正样本标了进去。模型学到了“高亮小区域就是缺陷”的捷径。
解决:逐张检查重复标注框,用4.3节的IoU脚本把所有高度重叠框过滤掉。另外用完全没参与训练的林间实拍图做泛化测试,只跑推理不看指标,肉眼判断框落点是否合理。误报多大概率是标注里的错误先验,不是模型结构问题。
6. 上线前做个增强回放:用一次“小白鼠”训练验证Mosaic与HSV参数
YOLOv8默认开启Mosaic、HSV扰动、随机翻转等增强,这些参数在COCO类数据集上调得还行,但苹果表面缺陷有自己的特殊性:颜色扰动太大会把红苹果的红色学歪,Mosaic拼图太碎会把缺陷裁得只剩半个。正式训练前用epochs=1跑一个“小白鼠”训练,把增强结果回放出来看一眼,能避免训练完才发现增强策略不合身。
yolo detect train data=dataset/data.yaml model=yolov8n.pt epochs=1 imgsz=640 \ mosaic=0.9 fliplr=0.5 hsv_h=0.015 hsv_s=0.5 hsv_v=0.4 \ close_mosaic=10这一条命令会完整跑一遍增强流程但只训练一个epoch,权重没意义,有意义的是训练日志里保存的增强中间图。重点看三处:Mosaic拼接后缺陷目标有没有被裁出边界;HSV扰动后的颜色是否还像苹果;翻转方向上缺陷的光影是否出现违背常理的镜像。
发现缺陷框大量被裁掉一半,就把mosaic降到0.5;发现颜色完全失真,hsv_h降到0.005;发现水平翻转对果面疤痕纹理不友好,fliplr设为0.3。我一般会在每个参数上单独试一次增强回放,然后拿着增强图让标过数据的同事过目,他们说“这图还能认出是苹果”,这批参数才敢正式进训练。
从那以后,我每次拿到VOC+YOLO双格式数据集,第一件事先用脚本统计类别分布、检查配对缺失,再跑一次增强回放确认预处理不毁数据,最后才谈训练调参。这套流程看着多花了半小时,实际省掉的是三个通宵排障的时间。希望帮到你。
本文还有配套的精品资源,点击获取