简介面向目标检测与农业视觉识别任务的中量级数据集聚焦小麦田中杂草检测场景。数据已按VOC标注格式整理标注标签与实拍图像可直接配套使用无需额外处理整个压缩包共两千个文件包含一千四百一十三个XML标注文件、五百八十五张JPG图像另附Python脚本及JSON类别映射文件包体约八百六十七兆字节目录结构清晰便于按需取用。数据集涵盖八类杂草目标JSON文件提供类别对应关系可快速适配YOLO、Faster R-CNN等常见检测框架图像采集自小麦田间包含不同时间与角度样本贴近真实农业场景对熟悉VOC标注的学生、研究人员及算法工程师特别适合用于模型调优、消融实验或农业识别项目的数据支撑。目前已有五十人学习或下载规范的数据组织方式能显著减少数据清洗与格式转换的时间成本。1. 小麦杂草目标检测数据集为什么VOC格式能省掉你三天标注时间做农业视觉检测项目的人都有同感模型选型、调参都有章可循最不可控的是数据。小麦田杂草检测这个场景自己下地拍照、清洗模糊样本、用LabelImg一张张画框再手工整理成训练格式起步就要一两周。而这份小麦杂草目标检测数据集直接以VOC标注格式交付图像和XML标签成套拿到手就能进训练管线。对正在做智慧农业、植保无人机或田间机器人项目的工程师来说它省掉的恰恰是标注和格式整理这两段最脏最累的活。我按这个顺序讲目录结构、XML关键字段、复现训练、踩坑记录、最后迁移到YOLOv8每一步都会给出能直接抄的脚本。2. VOC标注格式拆解目录结构、XML字段与类别映射2.1 数据集目录结构与文件清单VOCPASCAL VOC是目标检测领域历史最悠久的公开评测体系它的核心约定是用XML文件描述每张图像中所有目标的位置和类别。相比YOLO的txt归一化坐标和COCO的json字典VOC的XML对人类可读性最好文本编辑器打开就能核对也最容易向其他格式转换所以几乎每个检测框架都自带VOC读取器。这份数据集在根目录下按标准VOC布局组织wheat_weed_dataset/ ├── JPEGImages/ # 全部原始图像jpg格式 ├── Annotations/ # 与图像同名的xml标注文件 ├── ImageSets/ │ └── Main/ # 训练、验证、测试划分文件 │ ├── train.txt │ ├── val.txt │ └── test.txt └── class_names.txt # 类别清单每行一个类别名JPEGImages里放的是采集回来的原始图像文件名一般是时间戳或设备编号风格比如IMG_20230415_093021.jpg。文件名本身只起标识作用训练时框架靠ImageSets/Main下的txt文件来索引图像而不是直接扫描整个目录这一点和YOLO按data.yaml路径读图的方式差别很大。class_names.txt是类别清单行号就是训练时的类别索引这个文件在转换格式时是核心参照丢了它整个数据集就没法用了。提示拿到数据集后第一步不是直接训练而是先对比JPEGImages和Annotations两个目录的文件数量。两个目录各有多少文件、文件名是否一一对应直接决定训练进程能不能跑起来。我习惯先写一个校验脚本把两个目录的文件名抽出来做集合差确认完全对齐再往下走。这种“先校验再训练”的习惯最初是吃了一次亏才养成的当时有个数据集缺了7张图的标注Faster R-CNN跑了快两小时才在某个epoch中途报KeyError前面全部白算。2.2 XML标注文件里的关键字段XML标注是这套数据集的核心资产。打开Annotations目录下任意一个文件结构大致如下annotation folderJPEGImages/folder filenameIMG_20230415_093021.jpg/filename source databasewheat_weed_dataset/database /source size width1920/width height1080/height depth3/depth /size segmented0/segmented object namealopecurus/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin356/xmin ymin180/ymin xmax512/xmax ymax410/ymax /bndbox /object object namegalium_aparine/name poseUnspecified/pose truncated0/truncated difficult1/difficult bndbox xmin1200/xmin ymin760/ymin xmax1380/xmax ymax880/ymax /bndbox /object /annotation这里逐个字段说清楚因为后面转换和训练时很多坑都出在这些字段上。size块记录图像宽度、高度和通道数单位是像素。width和height是训练时做归一化坐标换算的基准如果XML里的尺寸和图像实际分辨率不一致转YOLO格式时所有框都会错位。depth通常是3对应RGB三通道训练时基本用不上但转换脚本一般会读取所以必须保证它合法。object块是真正的重点。每个object描述一个杂草目标name是类别名bndbox下的xmin、ymin、xmax、ymax是目标框的像素坐标。注意VOC坐标系的原点在图像左上角x轴向右、y轴向下。truncated表示目标是否被图像边缘截断difficult表示目标是否难以辨认difficult1的框在训练时通常被跳过因为强行学习模糊样本会拖慢收敛。类别名值得单独说。这套数据集的类别名采用小写英文加下划线风格比如alopecurus看麦娘、galium_aparine猪殃殃。这种命名有三个好处一是避免中文编码在不同操作系统间的兼容问题二是和训练框架的字符串映射逻辑天然匹配三是下划线比空格更安全因为很多脚本解析配置文件时把空格当分隔符类别名里带空格会直接切碎。2.3 类别映射与标签分布class_names.txt里按行列出全部类别顺序就是训练时的类别索引顺序。这个顺序决定模型输出层的节点排列训练完做推理时类别索引必须和这份文件严格对齐。常见的小麦田杂草类别映射参考类别名中文名训练索引alopecurus看麦娘0descurainia播娘蒿1capsella荠菜2galium_aparine猪殃殃3veronica婆婆纳4具体类别以数据集里的class_names.txt为准上面这张表只是常见情况的参照。拿到数据后建议先统计各类别的框数量分布这一步直接决定模型能不能训练出可用结果。统计方法不复杂遍历所有XML对name计数import os import xml.etree.ElementTree as ET ann_dir Annotations class_count {} for xml_name in os.listdir(ann_dir): if not xml_name.endswith(.xml): continue tree ET.parse(os.path.join(ann_dir, xml_name)) for obj in tree.getroot().findall(object): name obj.find(name).text class_count[name] class_count.get(name, 0) 1 for name, count in sorted(class_count.items(), keylambda x: -x[1]): print(f{name}: {count})这段脚本逻辑很简单遍历每个XML取出所有object的name字段用字典做累加。跑完你会看到类别分布比如看麦娘可能有两千多个框婆婆纳可能只有三百来个。看到这种长尾分布不用慌这是田间杂草数据的常态。但如果某个类别框数量连一百都不到那就要考虑类别融合或者干脆删掉这一类的样本否则训练时该类别几乎学不到东西。3. 复现训练流程数据校验、加载器与超参设定3.1 数据校验一张图都不能少训练任何检测模型之前我习惯先做三件套文件对齐检查、XML结构检查、类别名校验。这三步走完训练过程基本不会因为数据问题中途崩掉。第一件是文件对齐。JPEGImages和Annotations里的文件名必须是完全一致的集合多余的和缺失的都要处理import os img_dir JPEGImages ann_dir Annotations img_names {os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.lower().endswith((.jpg, .jpeg, .png))} xml_names {os.path.splitext(f)[0] for f in os.listdir(ann_dir) if f.endswith(.xml)} missing_ann img_names - xml_names missing_img xml_names - img_names print(f图像总数: {len(img_names)}) print(f标注总数: {len(xml_names)}) print(f缺标注的图像: {len(missing_ann)} - {sorted(missing_ann)[:5]}) print(f缺图像的标注: {len(missing_img)} - {sorted(missing_img)[:5]})这里用集合的差运算一次性找出两边不对齐的文件。missing_ann是“有图无标注”missing_img是“有标注无图”。前者更常见处理办法是删除对应图像或补标后者一般出现在数据集打包时漏拷贝了部分图像建议直接补回原图。第二件是XML结构检查。重点检查每个XML是否包含size和至少一个object除非图像本身是背景图以及bndbox坐标是否都在图像范围内。这一步能拦截掉LabelImg画框时误操作产生的越界框和空标注文件import os import xml.etree.ElementTree as ET def check_xml(xml_path): problems [] tree ET.parse(xml_path) 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): box obj.find(bndbox) xmin int(float(box.find(xmin).text)) ymin int(float(box.find(ymin).text)) xmax int(float(box.find(xmax).text)) ymax int(float(box.find(ymax).text)) if xmin 0 or ymin 0 or xmax w or ymax h: problems.append(f{os.path.basename(xml_path)}: bbox越界 ({xmin},{ymin},{xmax},{ymax})) if xmax xmin or ymax ymin: problems.append(f{os.path.basename(xml_path)}: 框宽或高为0) if len(root.findall(object)) 0: problems.append(f{os.path.basename(xml_path)}: 无object) return problems all_problems [] for xml_name in os.listdir(Annotations): if xml_name.endswith(.xml): all_problems.extend(check_xml(os.path.join(Annotations, xml_name))) if all_problems: print(f发现问题 {len(all_problems)} 条:) for p in all_problems[:20]: print( , p) else: print(全部XML检查通过)这段脚本把XML里所有box的坐标和size字段对比越界、宽高非正、空标注都会被记录。int(float(...))的写法是为了兼容坐标里出现小数的情况有些标注工具会写出356.0直接int()也能解析但碰到356.5就会抛异常用float转一下更稳。第三件是类别名校验。写一个脚本统计所有XML中出现的name取值集合和class_names.txt做对比。多出来的名字一定是标注阶段的拼写错误比如Alopecurus和alopecurus混用、galium_aparine和galium aparine混用这类问题会导致模型把同一个类当成两个类学mAP直接被腰斩。这一步的坑细节在第4章展开这里只要跑一遍确认名称一致即可。3.2 用PyTorch训练Faster R-CNN校验通过后就能进训练了。我在这个数据集上最先跑的是torchvision自带的Faster R-CNN因为它的VOC数据加载逻辑最简单适合作为基准模型。先写数据加载器import os import torch import xml.etree.ElementTree as ET from PIL import Image class VocDataset(torch.utils.data.Dataset): def __init__(self, root, image_set, transformsNone): self.root root self.transforms transforms self.img_dir os.path.join(root, JPEGImages) self.ann_dir os.path.join(root, Annotations) with open(os.path.join(root, ImageSets, Main, f{image_set}.txt)) as f: self.ids [line.strip() for line in f if line.strip()] # 类别索引从1开始0留给background self.class_to_idx { alopecurus: 1, descurainia: 2, capsella: 3, galium_aparine: 4, veronica: 5 } def __getitem__(self, idx): img_id self.ids[idx] img Image.open(os.path.join(self.img_dir, f{img_id}.jpg)).convert(RGB) tree ET.parse(os.path.join(self.ann_dir, f{img_id}.xml)) boxes, labels [], [] for obj in tree.findall(object): if int(obj.find(difficult).text) 1: continue 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) boxes.append([xmin, ymin, xmax, ymax]) labels.append(self.class_to_idx[obj.find(name).text]) boxes torch.as_tensor(boxes, dtypetorch.float32) labels torch.as_tensor(labels, dtypetorch.int64) return img, {boxes: boxes, labels: labels} def __len__(self): return len(self.ids)几个关键点说明class_to_idx从1开始编号background占0这是torchvision检测模型的固定约定类别索引0必须留给背景。如果class_names.txt里有5个类别最后的模型输出就是516个节点。difficult的过滤放在数据加载阶段而不是预处理阶段因为有时候想把difficult样本作为“难例挖掘”数据留着过滤时机留个开关会更灵活。boxes的坐标保持VOC原样的像素值不需要归一化。Faster R-CNN内部的backbone会自己处理尺度torchvision要求目标框坐标和图像像素坐标系一致这一点和YOLO不同——YOLO要求归一化到0-1VOC转换时特别容易搞混。训练配置import torchvision from torchvision.models.detection.faster_rcnn import FastRCNNPredictor model torchvision.models.detection.fasterrcnn_resnet50_fpn(weightsCOCO_V1) in_features model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor FastRCNNPredictor(in_features, num_classes6) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) optimizer torch.optim.SGD(model.parameters(), lr0.005, momentum0.9, weight_decay0.0005)参数选择逻辑说一下lr0.005是从COCO预训练模型做微调时的常用起点因为模型已经见过大量通用物体只需要小幅调整来适应杂草的纹理和田间背景weight_decay0.0005是Faster R-CNN论文里的默认值对农业场景的密集小目标没有特殊调整的必要——先按默认跑通再根据loss曲线决定要不要动。训练时每个epoch结束在验证集上算mAP可以用torchvision.ops.box_iou自己拼一个简单的评估循环。第一次训练建议只看两个指标loss_classifier和loss_box_reg前者发散说明类别映射有问题后者发散说明有越界框混进来了。3.3 数据划分固定随机种子VOC格式的数据集划分很简单因为ImageSets/Main下已经有现成的train.txt、val.txt、test.txt直接用就行。如果这份数据集没提供划分文件或者你想重新划分我的习惯是70%训练、15%验证、15%测试同时固定随机种子import os import random random.seed(42) all_ids [os.path.splitext(f)[0] for f in os.listdir(JPEGImages) if f.endswith(.jpg)] all_ids.sort() # 先排序再shuffle保证任何环境下初始顺序一致 random.shuffle(all_ids) n len(all_ids) train_ids all_ids[:int(n*0.7)] val_ids all_ids[int(n*0.7):int(n*0.85)] test_ids all_ids[int(n*0.85):] def write_split(path, ids): os.makedirs(os.path.dirname(path), exist_okTrue) with open(path, w) as f: f.write(\n.join(ids)) write_split(ImageSets/Main/train.txt, train_ids) write_split(ImageSets/Main/val.txt, val_ids) write_split(ImageSets/Main/test.txt, test_ids)固定随机种子不是玄学它保证你调整网络结构或超参数后前后两次实验的差异完全来自模型本身而不是数据划分的随机波动这是做消融实验的基本功。注意划分之后一定要检查三个集合的类别分布是否和全集大致一致。如果某个类别在某个子集中完全没出现训练时会报class index out of range或者评估时mAP直接少一类。检查方法很简单对每个集合跑一遍类别计数对比比例。4. VOC数据集避坑指南四个高频翻车现场4.1 LabelImg画框画到图像外训练时损失直接NaN现象训练第一个epoch结束时loss正常第二个epoch中途loss变成NaN然后一直是NaN。有些版本的Faster R-CNN会直接报AssertionError: bounding boxes must be non-empty或者提示坐标越界。原因LabelImg允许鼠标拖到图像边界外保存的xmax或ymax会大于图像宽高。XML里size字段的宽高是原始图像的但坐标越界后传给模型的框和目标特征图尺寸不匹配。另一个隐藏原因是VOC坐标从1开始计数代码里忘了减1导致最大坐标值等于图像宽度某些严格检查会把它判成越界。解决写脚本把所有越界框的坐标裁剪到图像范围内或者直接丢弃该框。我采用裁剪而不是丢弃因为裁剪能保留更多训练数据def clamp_bbox(xmin, ymin, xmax, ymax, img_w, img_h): xmin max(0, min(xmin, img_w - 1)) ymin max(0, min(ymin, img_h - 1)) xmax max(xmin 1, min(xmax, img_w - 1)) ymax max(ymin 1, min(ymax, img_h - 1)) return xmin, ymin, xmax, ymax注意xmax在裁剪后至少要比xmin大1否则会出现宽为零的退化框。改完重新跑一遍第3章的校验脚本确认所有框都合法再训练。4.2 类别名混用大小写和空格模型把一类学成两类现象训练能跑完但验证集mAP远低于预期尤其是某几类之间互相误检。查看预测结果时发现同一个杂草在图上被两个不同的类别名同时检出。原因标注过程中不同标注员的输入习惯不同比如Galium aparine和galium_aparine同时存在XML解析时两个名字被当成两个独立类别。类别数从计划的5变成6甚至7模型被迫区分两批实际上同一类但名称不同的框分类头容量被浪费mAP掉得厉害。解决写脚本统一类别名。先统计所有XML中出现的name取值建立归一化映射表再批量重写XML文件import os import xml.etree.ElementTree as ET alias_map { Galium aparine: galium_aparine, galium aparine: galium_aparine, GALIUM_APARINE: galium_aparine, Alopecurus: alopecurus, Alopecurus aequalis: alopecurus, } ann_dir Annotations for xml_name in os.listdir(ann_dir): if not xml_name.endswith(.xml): continue path os.path.join(ann_dir, xml_name) tree ET.parse(path) for obj in tree.getroot().findall(object): name obj.find(name) if name.text in alias_map: name.text alias_map[name.text] tree.write(path, encodingutf-8)重写之后把class_names.txt同步更新成归一化后的集合再跑一遍类别统计脚本确认没有遗漏的别名。这类问题在多人协作标注的数据集里特别常见拿到数据先跑这个脚本能省掉后面好几个小时的排错。4.3 杂草苗期小目标太多模型漏检严重现象训练完的模型对成株杂草检测效果不错但苗期杂草大量漏检。统计测试结果时发现漏检目标里超过70%的框面积小于32×32像素。原因这不是数据集格式的问题而是模型和数据的适配问题。Faster R-CNN的FPN对小目标有基本能力但默认锚框尺寸是针对COCO数据集设计的COCO里中小物体占比高而小麦田杂草的分布更极端——大量早期杂草在图像中的宽度不到50像素。直接拿COCO预训练权重微调模型对小目标的响应天然偏弱。解决两件事先做锚框适配再做训练策略调整。锚框调整比较麻烦需要改模型内部的anchor_generator更省事的方案是换用YOLOv8或者具备自适应锚框的框架——这就是第5章做迁移的动机之一。如果坚持用Faster R-CNN我试过有效的方法是训练时把输入图像从短边800提到短边1000相当于等效放大了小目标的尺寸mAP能回升3到5个百分点代价是训练时间增加约30%。4.4 Windows下路径分隔符把转换脚本搞崩现象在Windows上跑VOC转TFRecord或转YOLO的脚本报FileNotFoundError手动检查路径发现文件明明存在。原因VOC的XML里folder字段记录的是创建数据集时的目录名常见值是JPEGImages但有些标注工具会写成完整路径比如D:\datasets\wheat_weed\JPEGImages。代码如果用os.path.join(folder, filename)拼路径Windows的反斜杠在转义时被破坏或者换到Linux环境时反斜杠被当成非法字符。解决强制只用XML里的filename字段folder字段一律忽略图像路径统一从自己配置的数据集根目录拼接同时把所有路径统一转换成/分隔img_path os.path.join(JPEGImages, img_id .jpg).replace(\\, /)这个小动作能同时兼容Windows和Linux环境避免团队协作时因为操作系统不同而反复踩同一个坑。我的习惯是所有涉及文件路径的脚本入口处统一做一次replace(\\, /)后续逻辑就不再关心当前跑在什么平台上了。5. 进阶技巧把VOC数据集迁移到YOLOv8训练管线5.1 一步转换脚本YOLOv8用txt归一化坐标每行五个数值类别索引 中心x 中心y 宽度 高度。核心思路是读取XML的size和bndbox做一次坐标空间变换import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, class_names): tree ET.parse(xml_path) root tree.getroot() w int(root.find(size).find(width).text) h int(root.find(size).find(height).text) lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_names: continue cls_id class_names.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) lines.append(f{cls_id} {(xminxmax)/2/w:.6f} {(yminymax)/2/h:.6f} {(xmax-xmin)/w:.6f} {(ymax-ymin)/h:.6f}) return \n.join(lines)转换时保留difficult1的过滤逻辑和训练保持一致。逐张图跑一遍输出到yolo_labels目录再用YOLOv8的data.yaml指向该目录即可。5.2 增强时bbox同步修改YOLOv8训练时如果做在线增强需要保证变换后的框和图像同步。Albumentations的BboxParams直接支持pascal_voc格式省去手动同步的麻烦import albumentations as A transform A.Compose([ A.RandomSizedBBoxSafeCrop(640, 640, p0.5), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.3), ], bbox_paramsA.BboxParams(formatpascal_voc, label_fields[labels]))这里formatpascal_voc告诉Albumentations传入的bndbox是[xmin, ymin, xmax, ymax]形式增强内部会转成归一化坐标处理再转回pascal_voc输出全程不用自己算坐标。5.3 迁移后验证mAP对齐迁移完不要直接上新模型先做一个对齐验证用同一份测试集分别跑VOC版本的Faster R-CNN和YOLOv8对比mAP差异。差异在0.5%以内说明转换正确如果YOLOv8的mAP明显低先检查转换后的txt里坐标是否越界、类别索引是否和data.yaml里的names顺序一致这两个问题是迁移后效果跳水的头号原因。从那以后我每次拿到新的检测数据集都强制走一遍“文件对齐检查→XML结构校验→类别名归一化→坐标裁剪”的固定流程再决定直接训练还是转格式。这套流程跑下来十分钟但能省掉后面至少十个小时的排错时间。希望这份VOC格式的小麦杂草检测数据集笔记能帮到你少踩几个我已经踩过的坑。本文还有配套的精品资源点击获取