简介:IP102数据集Pascal VOC格式XML标注包,面向农作物病虫害目标检测与识别,适合算法工程师、科研人员及农业AI开发者使用。资源基于IP102扩展,覆盖水稻、玉米、小麦、柑橘等8种作物,可标注稻纵卷叶螟、二化螟、蚜虫、玉米螟等数十种害虫,适用于YOLO、Faster R-CNN等检测模型训练。压缩包内含2000个XML文件,总大小约493.45MB,每个XML对应一张图像中害虫的边界框与类别信息,标签规范,可直接转换为COCO等格式。目前已有449人学习下载。这份标注数据能显著降低数据准备门槛,帮助研究者在农业植保场景中快速开展模型训练与验证,为虫害智能监测及精准施药提供高质量数据支撑。
1. 为什么说原版IP102不适合直接训练检测模型,这套VOC扩展集值得从头看一遍
IP102是农业视觉里绕不开的基准数据集,75,000多幅图像覆盖水稻、玉米、小麦、甜菜等8种作物,类别跨度从稻纵卷叶螟到苜蓿广肩小蜂,做病虫害分类的论文几乎都在上面跑过。但真拿它做目标检测落地时,你会发现原版IP102最初按图像分类设计,很多类别只在文件名里区分,没有统一的目标级标注。想迁移到Faster R-CNN或YOLO做bbox预测,得先从分类标签反推目标位置、清洗重复图、统一命名,这几步往往要占掉两周时间。这套扩展集恰好补上了这个断层:18,975张原图,全部按Pascal VOC XML格式标注,覆盖稻飞虱、玉米螟、小麦红蜘蛛等80多个细分类别。对做农业植保检测的工程师来说,它省掉的是最脏的数据整理环节,让你把精力直接放在模型训练和漏检优化上。
2. 拆开一个Pascal VOC XML:标注结构与数据卫生检查
2.1 从文件名推断数据血缘,先建一个标注清单
数据集里单张样本的命名规则是IP078000377_jpg.rf.4677a8e3c14e2c092f313c1a776ed95c.xml。rf后缀常见于Roboflow等标注平台导出的文件,中间32位十六进制字符串是原始图片的哈希片段。这种命名的好处是多批次图片不会因为重名互相覆盖,坏处是文件名里没有标注工具版本和标注人信息,后续做多轮质检时查不了是谁改的框。
我一般会先扫描一遍全量XML,确认文件完整性和数量,再决定后面的转换策略。
find . -name "*.xml" | wc -l find . -name "*.xml" | head -n 5这里wc -l统计XML总数量,head -n 5抽查前5个文件,确认没有损坏的空文件。如果数量与18,975张原图对不上,说明标注过程中有丢帧或漏导出的情况,需要先用文件名匹配原图目录,列出缺失清单再决定是否补标。
2.2 annotation标签树里藏着哪些关键字段
PaScaL VOC XML的核心结构是一个<annotation>根节点,下面按层级组织图像信息和每个目标的信息。上面给出的是数据集里常见字段的对照说明。
| 字段路径 | 类型 | 含义 | 转换时是否必须 |
|---|---|---|---|
<folder> | str | 图像所在目录名 | 否,可忽略 |
<filename> | str | 图像文件名 | 是,用于与原图关联 |
<source> | str | 图片来源 | 否 |
<size><width> | int | 图像宽度 | 是,坐标归一化依赖 |
<size><height> | int | 图像高度 | 是,坐标归一化依赖 |
<size><depth> | int | 通道数,通常为3 | 否 |
<object><name> | str | 目标类别名称 | 是 |
<object><difficult> | int | 是否难例,0或1 | 建议保留并单独处理 |
<object><bndbox> | 4个int | xmin, ymin, xmax, ymax | 是 |
需要注意difficult字段。VOC原版约定difficult=1表示目标模糊难辨认,评测时不计入AP,但很多深度学习框架读标签时会忽略这个字段,直接把框当背景处理,造成漏检假象。我在转YOLO格式前习惯先统计difficult数量,决定是过滤掉还是保留为普通正样本。
2.3 用ElementTree扫全量XML,摸清类别分布
这里是我的扫描脚本,输出每个类别的样本数和框数,顺便统计difficult占比。
import xml.etree.ElementTree as ET from pathlib import Path from collections import defaultdict def scan_voc_labels(xml_dir: str): xml_files = list(Path(xml_dir).glob("*.xml")) class_sample_cnt = defaultdict(int) class_box_cnt = defaultdict(int) difficult_cnt = 0 total_boxes = 0 malformed = [] for xml_path in xml_files: try: tree = ET.parse(xml_path) root = tree.getroot() except ET.ParseError: malformed.append(xml_path.name) continue seen_objects = set() for obj in root.iter("object"): name = obj.findtext("name", "").strip() if not name: continue box = obj.find("bndbox") if box is None: continue if name not in seen_objects: class_sample_cnt[name] += 1 seen_objects.add(name) class_box_cnt[name] += 1 total_boxes += 1 if int(obj.findtext("difficult", "0")) == 1: difficult_cnt += 1 print(f"XML文件总数: {len(xml_files)}") print(f"解析失败: {len(malformed)}") print(f"目标框总数: {total_boxes}, difficult框数: {difficult_cnt}") print("\n类别\t样本数\t框数") for name in sorted(class_box_cnt, key=lambda x: -class_box_cnt[x]): print(f"{name}\t{class_sample_cnt[name]}\t{class_box_cnt[name]}") scan_voc_labels("path/to/xml")逻辑说明:先用glob拿到全部XML路径,逐个解析。seen_objects用于同一张图里相同类别只算一次样本数,而class_box_cnt统计的是该类别在所有图里的框总量,两者结合可以看出同一张图里密集出现的类别。比如稻飞虱这类目标往往一张图里有几十个框,样本数不高但框数很高,这直接影响后面对小目标推理策略的选择。
difficult的统计很重要,如果占比超过5%,建议在转换时统一过滤或单独建一个难例集做测试,不要混进训练集。
3. VOC转YOLO txt:坐标归一化、类别映射与脏数据清洗
3.1 为什么要把VOC转成YOLO格式再训练
VOC XML里坐标是绝对像素值,比如xmin=120, ymin=340,这种表示对尺寸固定的输入图没问题,但YOLO类模型训练时要对图片做letterbox缩放和随机裁剪,绝对坐标在增强后就不适用了。YOLO需要的标签格式是归一化后的中心点坐标和宽高,即class_id, x_center, y_center, width, height,五个值都缩放到0到1之间。另外VOC的difficult标记在YOLO格式里没有对应字段,必须在转换时显式决定去留。
转换前还要确认一件事:类别字符串到整数id的映射。这个映射表一旦定下来,后续训练和推理都要保持一致,不能中途修改。常用做法是所有类别按首字母排序生成id,这样新增类别时中间id不会变。
3.2 类别映射表与输出目录结构
这里给出一个类别映射示例,实际以你XML文件里出现的name为准。
| 整数id | 类别名称 |
|---|---|
| 0 | 二化螟 |
| 1 | 玉米螟 |
| 2 | 稻纵卷叶螟 |
| 3 | 稻飞虱 |
| 4 | 红蜘蛛 |
| 5 | 麦长管蚜 |
| ... | ... |
转换前要先跑一遍第2章的扫描脚本,把全量类别name收集出来,生成映射表,避免转换到一半遇到没见过的类别名。
3.3 转换脚本与边界情况处理
下面这个脚本完成VOC到YOLO的转换,并做三类清洗:越界框裁剪、超小目标过滤、空标签文件剔除。
import xml.etree.ElementTree as ET from pathlib import Path CLASS_MAP = { "二化螟": 0, "玉米螟": 1, "稻纵卷叶螟": 2, "稻飞虱": 3, "红蜘蛛": 4, "麦长管蚜": 5, } def voc_to_yolo(xml_path: Path, out_dir: Path, min_side: float = 2.0): tree = ET.parse(xml_path) root = tree.getroot() filename = root.findtext("filename") width = int(root.findtext("size/width")) height = int(root.findtext("size/height")) lines = [] for obj in root.iter("object"): name = obj.findtext("name", "").strip() if name not in CLASS_MAP: continue if int(obj.findtext("difficult", "0")) == 1: 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")) xmin = max(0, min(xmin, width)) ymin = max(0, min(ymin, height)) xmax = max(0, min(xmax, width)) ymax = max(0, min(ymax, height)) if xmax - xmin < min_side or ymax - ymin < min_side: continue if xmax <= xmin or ymax <= ymin: continue x_center = ((xmin + xmax) / 2.0) / width y_center = ((ymin + ymax) / 2.0) / height box_w = (xmax - xmin) / width box_h = (ymax - ymin) / height lines.append(f"{CLASS_MAP[name]} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}") if lines: out_path = out_dir / (xml_path.stem + ".txt") out_path.write_text("\n".join(lines), encoding="utf-8") xml_dir = Path("path/to/xml") out_dir = Path("path/to/labels") out_dir.mkdir(exist_ok=True) for xml_file in xml_dir.glob("*.xml"): voc_to_yolo(xml_file, out_dir)参数说明里需要注意三点。difficult过滤逻辑直接跳过难例,如果你的模型在难例上漏检严重,可以改成保留并单独设一组难例验证集,而不是和普通样本混在一起。min_side是边长阈值,小于该像素数的框会被丢弃,目的是清掉标注误差导致的1到2像素噪声框,但密集小虫目标本身可能只有8到10像素,这个值设太大会误杀。最后的坐标转换公式里,中心点和宽高都除以图像原始宽高,保证归一化到0到1区间。
3.4 转换后校验:把标签画回原图
格式是否转对,光看数字不够,我的习惯是抽查20到50张转换后的图片,把txt标签叠加画回原图,确认框和虫体位置对齐。
import cv2 def draw_yolo_label(image_path: str, label_path: str): img = cv2.imread(image_path) h, w = img.shape[:2] for line in open(label_path, "r", encoding="utf-8"): parts = line.strip().split() if len(parts) != 5: continue cls_id, x_c, y_c, bw, bh = parts x_c, y_c, bw, bh = map(float, (x_c, y_c, bw, bh)) xmin = int((x_c - bw / 2) * w) ymin = int((y_c - bh / 2) * h) xmax = int((x_c + bw / 2) * w) ymax = int((y_c + bh / 2) * h) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) return img这段代码按反向公式把归一化中心坐标还原成像素坐标,再画框。如果发现大量框偏移半个身位,通常是VOC坐标读错或者原图分辨率与XML里size字段不一致,回查XML的<size>节点和实际图片尺寸比一比即可。
4. 分层划分数据集,再用YOLOv8把18,975张图训起来
4.1 为什么不能直接random_split
很多分类任务直接按10%切验证集没问题,但检测任务不行。这个数据集里水稻害虫类别多、框密集,小麦和苜蓿类别少,直接随机切很可能让某些类别在训练集和验证集里分布不均,验证集出现某类只有一个框的情况,mAP波动剧烈。正确做法是按类别框数分层划分,保证每个类别在训练集、验证集里的比例接近总体比例。
4.2 按类别的分层划分脚本
下面脚本以图为单位,按图中占比最高的类别做分层抽样。
import random from pathlib import Path from collections import defaultdict random.seed(42) xml_dir = Path("path/to/xml") train_ratio, val_ratio = 0.85, 0.10 class_to_files = defaultdict(list) for xml_path in xml_dir.glob("*.xml"): tree = ET.parse(xml_path) root = tree.getroot() class_cnt = defaultdict(int) for obj in root.iter("object"): class_cnt[obj.findtext("name")] += 1 major_class = max(class_cnt, key=class_cnt.get) class_to_files[major_class].append(xml_path.stem) train_set, val_set, test_set = [], [], [] for cls, items in class_to_files.items(): random.shuffle(items) n_train = int(len(items) * train_ratio) n_val = int(len(items) * val_ratio) train_set.extend(items[:n_train]) val_set.extend(items[n_train:n_train+n_val]) test_set.extend(items[n_train+n_val:]) print(f"train: {len(train_set)}, val: {len(val_set)}, test: {len(test_set)}")逻辑说明:脚本先按一张图中出现次数最多的类别作为该图的层标签,再进行分层切分。seed=42固定随机种子,保证可复现。比例上训练集85%、验证集10%、测试集5%,如果你的任务更关注最终泛化,可以把测试集扩大到10%,但训练集不能低于80%,检测模型很吃数据量。
划分结果建议写入三个txt文件,每个文件一行一个图片文件名(不带扩展名),方便后面YOLO训练直接索引。
4.3 data.yaml配置与YOLOv8训练
YOLOv8或YOLOv11的ultralytics接口都支持YOLO txt标注,只需准备一个yaml描述数据集路径和类别列表。
path: /your/dataset/root train: images/train.txt val: images/val.txt test: images/test.txt names: 0: 二化螟 1: 玉米螟 2: 稻纵卷叶螟 3: 稻飞虱 4: 红蜘蛛 5: 麦长管蚜训练命令:
yolo detect train \ data=ip102_ext.yaml \ model=yolo11s.pt \ epochs=80 \ imgsz=960 \ batch=16 \ lr0=0.005 \ mosaic=0.8 \ cos_lr=True \ project=runs/ip102_ext参数表格与推荐取值:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| imgsz | 960 | 病虫害小目标多,640输入下帧宽不足,960能明显提升小目标recall |
| epochs | 80-120 | 迁移学习下80轮可收敛,观察val loss不降后early stop |
| batch | 按显存调 | 16GB显存配960输入建议batch为16,OOM就降到8 |
| lr0 | 0.005 | 比默认0.01保守,农业数据噪声大,学习率过大容易过拟合背景 |
| mosaic | 0.8 | 保留一定真实比例,全mosaic会让模型过度依赖拼接纹理 |
训练到一半时重点看两个曲线:val/box_loss和metrics/precision。box_loss持续下降但precision抖动不升,说明模型对小目标回归不准,优先调anchor或增大imgsz,而不是加epochs。
4.4 评估阶段怎么看懂mAP
训练结束后运行:
yolo detect val \ model=runs/ip102_ext/weights/best.pt \ data=ip102_ext.yaml \ imgsz=960 \ conf=0.25输出里mAP50表示IoU阈值0.5下的平均精度,mAP50-95是0.5到0.95的平均。农业检测场景建议以mAP50为主要指标,因为虫害检测的最终目的是发现并防治,对框的像素级定位精度的要求低于自动驾驶等领域。如果mAP50和mAP50-95差距过大,说明框定位偏但没漏检,可以先不管;如果两者都低,就要回到类别分布和图片质量找原因。
5. 小目标漏检与类别失衡:mAP50后面的优化顺序
5.1 先用混淆矩阵定位短板
训练结束后,把runs/ip102_ext/confusion_matrix.png调出来看。我通常关注两类问题:一是某些类预测成背景,说明正样本特征没学够,需要增强或补充数据;二是某两类互相混淆,比如稻飞虱和白背飞虱,说明类别间视觉差异太小,单纯加数据效果有限,考虑合并成超类或改用细粒度分类头。多数情况下,这个数据集里漏检最严重的是单张图里几十个框的密集小虫,比如稻飞虱。
5.2 数据端补偿:Copy-Paste增强的适用边界
对少样本类别,YOLO训练配置里可以打开copy_paste参数,它会把某张训练图里的小目标复制粘贴到另一张图的随机位置,变相增加该类别的样本量。
yolo detect train \ model=runs/ip102_ext/weights/best.pt \ data=ip102_ext.yaml \ epochs=40 \ imgsz=960 \ copy_paste=0.5 \ mosaic=0.6这里要注意,copy_paste对密集小目标很有效,但对大目标容易贴出反逻辑画面,比如把螟虫贴到水稻叶片之外。所以copy_paste=0.5意思是50%的概率应用该增强,留一半真实样本让模型维持对自然分布的认知。
5.3 推理端SAHI切片:不做额外训练也能提升小目标recall
如果训练端优化完,稻飞虱这类小目标漏检还是严重,常见做法是用SAHI做切片推理。SAHI把大图切分成多个重叠小图,分别推理再合并结果,等效于用小输入尺寸跑高分辨率推理。
from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction detection_model = AutoDetectionModel.from_pretrained( model_type="yolov8", model_path="runs/ip102_ext/weights/best.pt", confidence_threshold=0.3, image_size=960, device="cuda:0", ) result = get_sliced_prediction( "test_images/rice_pest_001.jpg", detection_model, slice_height=640, slice_width=640, overlap_height_ratio=0.2, overlap_width_ratio=0.2, )切片尺寸用640,配合原图960训练,窗口能覆盖完整虫体。overlap_ratio=0.2是为了避免目标正好卡在切片边界被切成两半。如果切得太碎导致单窗口里只有半只虫,就调大overlap到0.3。SAHI的代价是推理时间随切片数量线性增长,批量推理时建议先按框数量排序,只对框数超过50的密集图走切片流程,普通图直接全图推理。
5.4 最后的兜底:conf阈值和NMS参数小网格搜索
模型训练到位后,在验证集上做一个简单的conf阈值网格搜索,通常能再把mAP50提升零点几个点。
for conf in 0.15 0.2 0.25 0.3; do yolo detect val \ model=runs/ip102_ext/weights/best.pt \ data=ip102_ext.yaml \ conf=$conf done比较每个阈值下的mAP50和F1,再结合实际推理场景选。害虫巡检一般更在意recall而不是precision,多检错一个框的成本低于漏检一个虫害爆发点,所以阈值别设太高。这套流程走完后,这个VOC标注的IP102扩展集训练出来的模型,在细粒度小虫目标上的mAP50表现会明显好于拿原版分类数据硬转的模型。
本文还有配套的精品资源,点击获取