尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

IP102数据集Pascal VOC XML解析与YOLO格式转换实战指南

IP102数据集Pascal VOC XML解析与YOLO格式转换实战指南 简介这份数据集是面向农作物病虫害识别与检测任务的IP102扩展版资源包含水稻、玉米、小麦、甜菜、苜蓿、葡萄、柑橘和芒果共8种作物的18975张原图采用Pascal VOC格式的XML标注文件适合用于训练和评估目标检测模型。压缩包内共有2000个xml文件整体大小约493.45MB每张图片对应一个标注文件内含物体类别与边界框坐标可直接接入YOLO、Faster R-CNN等主流检测框架。类别覆盖稻纵卷叶螟、二化螟、三化螟、蚜虫、玉米螟、黏虫等数十种常见农业害虫方便研究者针对具体害虫进行细粒度识别与防治方案开发。目前已有449人学习浏览适合计算机视觉方向的学生、算法工程师及农业信息化从业者用于模型训练、算法验证或教学演示。1. IP102数据集与Pascal VOC XML标注一份适合做农业细粒度检测的18572张带框数据做农业视觉的人大概率绕不开一个尴尬时刻想复现一篇作物害虫检测论文发现作者用的是一个叫 IP102 的数据集下载下来却是一堆.jpg配一堆.xml没有现成的 COCO JSON也没有 YOLO 格式的 txt。IP102 是北京林业大学等机构在 2019 年前后发布的大规模农业害虫数据集覆盖 102 个类别原始图片合计 18975 张其标注采用经典 Pascal VOC 格式每张图对应一个 XML 文件记录目标类别和边界框坐标。相比从零标注直接拿它做迁移学习或数据增强能省掉大量清洗工作。但它也有三个让人头疼的内置问题类别间视觉差异极小、部分图像存在遮挡和光照极端、某些类别样本量明显偏少直接训练很容易出现长尾过拟合。这篇文章就围绕 IP102 的 Pascal VOC XML 标注讲清楚怎么解析、怎么校验、怎么转成 YOLO 格式、以及训练前该修哪些标注脏数据适合正在做目标检测、细粒度分类或农业 AI 落地的工程师参考。2. 解析Pascal VOC XML先把IP102的标注结构拆开看2.1 IP102的XML文件里到底存了哪些字段拿到一张IMG_0001.jpg同目录下大概率有IMG_0001.xml。打开看会发现这是标准的 Pascal VOC 标注文件根节点是annotation下面挂folder、filename、source、size、segmented和若干个object节点。对目标检测来说重要的只有三块filename用来关联图片size里的 width/height 是边界框校验的基准object里的name是类别标签bndbox是坐标本体。IP102 的标注没有segmentation信息也基本不用pose和truncated很多框是人工在标注工具里画的所以difficult字段多数为 0。我一般会先用xmllint或者任意文本编辑器打开一个 XML 确认它的编码。IP102 原始 XML 多数是 UTF-8少数工具导出的可能带 BOM解析时容易报错。需要特别说明的是这里的是逐目标枚举的一张图里有 3 只虫子就有 3 个object块每个块内部再嵌套自己的bndbox这一点和 COCO 那种一次性给全部 annotation 的风格完全不同。下面是一个 IP102 标注文件的实际结构示例annotation folderIP102/folder filenameIMG_0001.jpg/filename source databaseIP102 Dataset/database /source size width800/width height600/height depth3/depth /size segmented0/segmented object nameRice leaf roller/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin120/xmin ymin180/ymin xmax340/xmax ymax390/ymax /bndbox /object /annotation需要留个心眼IP102 的name用的是英文全称比如Rice leaf roller不是Cnaphalocrocis medinalis也不是数字编号。官方给出的 102 个类别映射表在论文里但 XML 里不会带对应的中文名所以想训练中文标签或者做细粒度分类的得先自己构建一个 name 到 label id 的映射。2.2 用Python把IP102的XML批量解析成结构化表格解析 Pascal VOC XMLPython 标准库的xml.etree.ElementTree就够了不需要额外安装依赖。但如果你要处理 18975 个 XML我建议用lxml的etree它解析速度快而且对畸形 XML 容忍度高一些。下面的脚本把 IP102 所有 XML 解析成 Pandas DataFrame方便后续清洗import os import glob import pandas as pd from lxml import etree def parse_voc_xml(xml_path): tree etree.parse(xml_path) root tree.getroot() records [] filename root.findtext(filename) width int(root.findtext(.//size/width)) height int(root.findtext(.//size/height)) for obj in root.findall(object): name obj.findtext(name) difficult int(obj.findtext(difficult)) bndbox obj.find(bndbox) xmin int(float(bndbox.findtext(xmin))) ymin int(float(bndbox.findtext(ymin))) xmax int(float(bndbox.findtext(xmax))) ymax int(float(bndbox.findtext(ymax))) records.append({ filename: filename, width: width, height: height, name: name, difficult: difficult, xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax, xml: os.path.basename(xml_path) }) return records xml_dir /data/IP102/Annotations all_records [] for xml_file in glob.glob(os.path.join(xml_dir, *.xml)): all_records.extend(parse_voc_xml(xml_file)) df pd.DataFrame(all_records) print(f解析完成{df[filename].nunique()} 张图{len(df)} 个标注框) print(df[name].value_counts().head(10))这段代码把每个 XML 里的 filename、图像宽高、每个目标的类别和边界框全部展开成行一张图有多个目标就有多行。参数上要注意findtext(.//size/width)用了相对路径能兼容size在不同层级的情况float(bndbox.findtext(xmin))是因为部分标注工具导出的是浮点坐标这里统一转成 float 再取整避免字符串强制转 int 时报错。2.3 字段含义速查表训练脚本里最常用的几个字段类型含义训练时的用途filenamestr图片文件名与 JPEG 目录做关联决定转 YOLO 时 txt 的命名width / heightint原图宽高校验 bndbox 是否越界的基准namestr目标类别英文名映射到 label id 的唯一依据bndboxxmin/ymin/xmax/ymax左上和右下坐标直接用于转 YOLO 的归一化分母truncatedint是否被截断通常不用但过滤时可以考虑difficultint是否难例YOLO 训练一般要排除避免干扰损失pose / segmentedstr/int姿态和分割信息IP102 中基本无实际意义2.4 解析时最容易踩的三个坑编码、命名空间和坐标取整Pascal VOC XML 解析本身不难难在数据集规模大、工具杂。第一个坑是编码问题用open()读取时指定encodingutf-8别依赖系统默认编码Windows 下很容易踩 GBK 报错。第二个坑是命名空间少数工具导出的 XML 根节点带xmlns前缀lxml 用find时就必须带{namespace}前缀否则返回 None 又看不出原因。第三个坑是坐标取整所有坐标虽然是字符串形式但不一定是整数直接int()会抛 ValueError后面所有文件都会断掉。我通常的做法是全部用int(float(x))包一层宁可精度损失一像素也不能让整个解析流程中断。提示如果你只需要快速看一个 XML 的内容可以用 VS Code 的 XML 插件或者直接在浏览器里打开。对 18975 个文件做批量处理时文本编辑器反而低效脚本解析是不可替代的方案。3. 用脚本校验IP102找孤儿文件、越界框和错误类别名3.1 先检查图片和XML的对应关系别让训练读到空标注IP102 发布时配备了完整的图像目录和标注目录但是网上流传的拷贝经常被二次压缩或迁移图片路径和 XML 文件名会出现错位。训练前必须做一轮对应关系检查找出有图没有 XML、有 XML 没有图、以及图名和 XML 内 filename 不一致的情况。下面这个脚本用集合求差快速定位import os, glob from lxml import etree img_dir /data/IP102/JPEGImages xml_dir /data/IP102/Annotations img_files set(os.path.splitext(os.path.basename(p))[0] for p in glob.glob(os.path.join(img_dir, *.jpg))) xml_files set(os.path.splitext(os.path.basename(p))[0] for p in glob.glob(os.path.join(xml_dir, *.xml))) print(只有图片没有标注:, len(img_files - xml_files)) print(只有标注没有图片:, len(xml_files - img_files)) for stem in sorted(img_files xml_files)[:2000]: xml_path os.path.join(xml_dir, stem .xml) tree etree.parse(xml_path) root tree.getroot() actual root.findtext(filename) if actual ! stem .jpg: print(fXML内filename不一致: {stem}.xml - {actual}) break这个脚本的精髓在于用文件主名做关联而不是直接比较完整路径这样能兼容图片是.jpg、标注是.xml的差异。文件名不一致的问题在 IP102 里相对少见但一旦出现训练时image_dataset索引会错位模型是拿图 A 的像素学图 B 的标签最后指标全乱而且很难排查。对 18975 张图来说这一步跑一遍只要十几秒值得花。3.2 边界框越界和退化框一张坏标注毁掉一整批训练解析完基本信息后下一步是检查每个 bndbox 的数值合法性。越界框xmax 超出图片宽度通常来自标注工具手抖或者导出程序 bug退化框xmin xmax 或 ymin ymax是宽度或高度为零训练时目标检测损失会计算 log(0) 导致 NaN。还有完全空心的框虽然 xmin xmax但面积只有一个像素对损失函数来说是纯粹噪声。下面的脚本在解析时直接过滤和标记invalid_by_size [] invalid_by_range [] for idx, row in df.iterrows(): W, H row[width], row[height] if row[xmax] - row[xmin] 2 or row[ymax] - row[ymin] 2: invalid_by_size.append((row[filename], row[name], row[xmin], row[ymin], row[xmax], row[ymax])) if row[xmin] 0 or row[ymin] 0 or row[xmax] W or row[ymax] H: invalid_by_range.append((row[filename], row[name], row[xmin], row[ymin], row[xmax], row[ymax])) print(退化框数量:, len(invalid_by_size)) print(越界框数量:, len(invalid_by_range)) if invalid_by_range: print(示例:, invalid_by_range[:5])处理策略上越界框不能简单 clip因为如果原标注把目标中心都标到图外了那本身语义就是错的退化的方式是把 xmax 改到 W-1、ymax 改到 H-1因为图片像素索引是从 0 到 W-1。退化框则直接看面积占比超过 0.1 的就删除该条 object面积太小的训练时也没有正样本意义。一个务实的建议是与其逐个修不如直接在训练脚本里把这些框的difficult置为 1让损失计算时忽略它们。3.3 类别名映射IP102的102类名称到标签ID的写法IP102 的标注类别用的是英文可读名例如Rice leaf roller、Rice leaf caterpillar、Paddy stem maggot训练框架通常需要数字标签。手动逐个打字既不现实又容易出错正确做法是从所有 XML 里抽取name的唯一值生成类别清单再固定映射。这个映射一旦写入训练配置之后增删类别都要保持稳定否则出现过拟合。import json label_set sorted(df[name].unique()) label_map {name: idx for idx, name in enumerate(label_set)} print(类别数量:, len(label_map)) with open(ip102_label_map.json, w, encodingutf-8) as f: json.dump(label_map, f, indent2, ensure_asciiFalse)输出 JSON 后建议自己扫一眼拼写。IP102 官方给的 102 个类别里有大小写不统一、复数形式混用的情况。比如同一个物种在若干 XML 里写作aphids另外几个写成aphid如果你不做归一化它们会被当作两个类别模型就会莫名其妙多出一个类。我一般的处理是对 name 做 strip 和 lower 后再映射同时保留原始名作为输出时的别名。3.4 可视化抽样把XML坐标画回原图确认标注质量脚本校验解决数值问题可视化解决语义问题。而labelimg这类标注工具虽然适合人工标注但拿来看两千张图片效率太低。更实操的做法是用 OpenCV 把解析出来的框一次性批量画到图上按类别抽样保存为拼图。代码核心如下import cv2 from glob import glob sample_xmls glob(/data/IP102/Annotations/*.xml)[:50] for xml_path in sample_xmls: tree etree.parse(xml_path) root tree.getroot() img_path os.path.join(/data/IP102/JPEGImages, root.findtext(filename)) img cv2.imread(img_path) for obj in root.findall(object): name obj.findtext(name) b obj.find(bndbox) xmin, ymin, xmax, ymax [int(float(b.findtext(t))) for t in (xmin, ymin, xmax, ymax)] cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, name, (xmin, max(ymin - 5, 15)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) out_path os.path.join(/data/IP102/visual_check, os.path.basename(xml_path).replace(.xml, .jpg)) cv2.imwrite(out_path, img)这段代码把前 50 张图的预测框直接叠在原图上输出到visual_check目录人眼扫一遍就能发现明显的错标问题框有没有框住完整的害虫、有没有把背景的叶片误标成目标、类别名拼写有没有明显异常。IP102 的一大特点是很多图像里的害虫很小如果发现大比例目标框只框住害虫身体的一半后续训练数据增强时就要考虑是否引入 cutout 或 mosaic 增大上下文信息。提示抽样可视化不是一次性工作。建议每清洗一轮就重新抽样一次尤其是删除过退化框之后确认删掉的确实是噪声而不是有效目标。4. 从VOC XML转YOLO格式并做分层划分这才是训练的直接输入4.1 YOLO的txt标注格式与VOC的差异YOLO 系列训练框架从 YOLOv5 到 YOLOv8使用纯文本标注每个.txt与图片同名每行表示一个目标格式是class_id x_center y_center width height所有数值都是相对图片宽高的归一化浮点数。Pascal VOC 的 bndbox 是绝对值整数转 YOLO 的核心就是做一次除法归一化。因为 IP102 的原始图像分辨率并不统一转出来后必须按每张图自己的宽高计算不能用一个全局宽高做分母。还有一点需要注意YOLO 格式里没有 difficult 字段转换时要决定是直接丢弃还是把它映射成 class_id大多数训练场景直接丢弃即可。做一个通用转换脚本时我通常会把读取 XML 和写 YOLO txt 分成两层便于单测。转换时顺带统计每个 label_id 的框数量给后续数据集划分提供依据。4.2 VOC转YOLO的转换脚本一次性跑通18975张下面是一个可以直接跑的示例输入是 XML 目录和图片目录输出是同名的.txt文件到指定 labels 目录import os import glob from lxml import etree def voc_to_yolo(xml_path, label_map, img_dir, out_dir): tree etree.parse(xml_path) root tree.getroot() filename root.findtext(filename) width int(root.findtext(.//size/width)) height int(root.findtext(.//size/height)) img_path os.path.join(img_dir, filename) if not os.path.exists(img_path): return False lines [] for obj in root.findall(object): name obj.findtext(name).strip().lower() if name not in label_map: continue b obj.find(bndbox) xmin int(float(b.findtext(xmin))) ymin int(float(b.findtext(ymin))) xmax int(float(b.findtext(xmax))) ymax int(float(b.findtext(ymax))) if xmax - xmin 2 or ymax - ymin 2: continue x_center (xmin xmax) / 2.0 / width y_center (ymin ymax) / 2.0 / height w (xmax - xmin) / width h (ymax - ymin) / height lines.append(f{label_map[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) if not lines: return False stem os.path.splitext(os.path.basename(xml_path))[0] with open(os.path.join(out_dir, stem .txt), w) as f: f.write(\n.join(lines)) return True xml_dir /data/IP102/Annotations img_dir /data/IP102/JPEGImages out_dir /data/IP102/labels os.makedirs(out_dir, exist_okTrue) label_map json.load(open(ip102_label_map.json, encodingutf-8)) converted 0 for xml_path in glob.glob(os.path.join(xml_dir, *.xml)): if voc_to_yolo(xml_path, label_map, img_dir, out_dir): converted 1 print(f转换成功: {converted} 张)这段代码有几处关键设计。第一对name做strip().lower()规避大小写混用问题第二先检查原图是否存在避免生成没有对应图像的标签第三遇到退化框直接跳过而不是报错中断因为在大规模数据集里整体回滚的成本远高于个别坏样本。归一化使用width和height作分母所以图片尺寸不一致也能正确处理。4.3 按类别分层划分数据避免102类里某些类全进验证集18975 张图划分训练集和验证集如果直接random.shuffle后按比例切会出现某些类别在验证集里一个样本都没有的情况尤其是 IP102 这类长尾分布的数据集。更稳妥的是按类别分层抽样保证每个类别在训练集和验证集中的比例相同。import random from collections import defaultdict # df 是前面解析得到的 DataFrame images_per_label defaultdict(set) for _, row in df.iterrows(): images_per_label[row[name]].add(row[filename]) train_files, val_files [], [] val_ratio 0.2 for label, imgs in sorted(images_per_label.items()): imgs sorted(imgs) n_val max(1, int(len(imgs) * val_ratio)) random.seed(42) val_set set(random.sample(imgs, n_val)) for f in imgs: (val_files if f in val_set else train_files).append(f) print(f训练集图片: {len(train_files)}验证集图片: {len(val_files)})这里以filename作为划分单位而不是以标注框为单位避免同一张图片同时出现在训练集和验证集中造成数据泄露。random.seed(42)固定随机种子保证可复现。如果后续要增加测试集改成三元划分即可但思路一样。IP102 的类别不均衡问题在这一步就能直观看到样本最多的类和最少的类差距可能在十倍以上分层划分只能保证比例一致不能解决不均衡后续要考虑类别加权采样或者使用带 focal loss 的检测头。4.4 转换结果验证直接把YOLO txt画回图片检查归一化YOLO 格式转换完成后不能直接开训练要把 txt 中的归一化坐标乘以图片宽高重新画框检查一遍。一个典型的错误是把归一化的 x_center 和 width 直接当作 xmin/xmax画出来的框会整体偏移且宽高错误模型训练时边界框回归完全学不出来。下面这段代码随机抽 20 张做检查import random import cv2 import os label_txts glob.glob(/data/IP102/labels/*.txt) random.seed(1) sample_txts random.sample(label_txts, 20) for txt_path in sample_txts: stem os.path.basename(txt_path).replace(.txt, ) img_path os.path.join(/data/IP102/JPEGImages, stem .jpg) img cv2.imread(img_path) H, W img.shape[:2] with open(txt_path, r) as f: lines f.readlines() for line in lines: parts line.strip().split() cls_id, x_center, y_center, w, h int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) xmin int((x_center - w / 2) * W) ymin int((y_center - h / 2) * H) xmax int((x_center w / 2) * W) ymax int((y_center h / 2) * H) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, str(cls_id), (xmin, max(ymin - 5, 15)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1) cv2.imwrite(f/data/IP102/yolo_check/{stem}.jpg, img)看到输出的图如果没有出现框的错位或偏移说明归一化计算正确。此时还可以顺手统计一下类别分布把labels/*.txt里所有 cls_id 出现次数做一个直方图确认转换后没有丢数据。这一环节的原则是训练前的所有验证花费的时间远小于训练完发现数据错误再返工的时间。5. 验证与补打的三种技巧CVAT二次校对、批量修改XML、转换COCO5.1 用CVAT打开IP102做二次人工校对如果发现抽样可视化里某些类别标注质量实在堪忧靠脚本清洗已经救不回来那就得上人工标注工具做二次校对。CVAT 是目前最合适的开源方案支持直接导入 Pascal VOC 格式的标注也能在浏览器里逐张查看。导入方式是在 CVAT 的Create new task时把 JPEGImages 目录作为 images 上传然后在Annotations菜单里选择Import annotations格式选 Pascal VOC。编辑完成后导出格式依然选 Pascal VOC就能得到一份修正后的 XML 集合重新跑一遍解析和转换流程。CVAT 的坑在于任务提交后默认的作业默认使用分块缓存图片量大了首次加载会卡建议按 IP102 的 102 个类别分多个 task每类一个任务降低单任务负载。对 18975 张图来说全量人工校对不现实通常只对训练表现最差的那 20 个类做修正即可。5.2 批量修改XML内容补类别别名、给困难样本打标记有些时候你不想要重新标注只是想全局改一些东西比如统一大小写、把某个拼写错误的类别替换成正确名称。用 sed 也能做但因为 XML 是多行结构而且 object 节点内还有其他同名标签我推荐用 Python 脚本按解析结果回写from lxml import etree import glob, os def fix_label(xml_path, wrong_names, right_name): tree etree.parse(xml_path) root tree.getroot() changed False for obj in root.findall(object): name_el obj.find(name) if name_el.text in wrong_names: name_el.text right_name changed True if changed: tree.write(xml_path, encodingutf-8, xml_declarationTrue) return changed for xml_path in glob.glob(/data/IP102/Annotations/*.xml): fix_label(xml_path, [aphid, Aphid], aphis)这段代码直接改写原始 XML 文件注意tree.write要指定xml_declarationTrue保留 XML 头部声明否则某些解析器会拿默认版本去读。大批量回写之前建议先跑一次 dry-run统计会被影响的文件数防止替换规则写错把整个数据集毁了。回写之后再把第 3 章的校验脚本重跑一遍确认没有产生新的非法坐标。5.3 从Pascal VOC XML转COCO JSON的路线如果你的训练框架是 Detectron2 或者使用了 MMDetection 的 COCO 格式那需要把 IP102 转成 COCO JSON。整体思路是三步第一步把label_map倒过来得到 id 到名称的映射第二步遍历 XML 填充 images 和 annotations 两个数组第三步写入 json.dump 到文件。COCO 的 annotation 里area可以直接用(xmax-xmin)*(ymax-ymin)算出来iscrowd设置为 0。只有一点要注意COCO 的图片 id 和 annotation id 必须从 1 开始递增不能从 0 开始这是很多人在评估脚本里被卡住的经典问题。5.4 验证标注质量的一个小技巧训练一轮就走不要一开始就跑 300 个 epoch。用 YOLO 或者其他检测框架开一轮训练把 batch size 拉满跑一个 epoch 后观察 loss 和 mAP 的变化趋势。如果 loss 直接从 9 掉到 2说明标注质量整体没问题如果 loss 震荡剧烈且 mAP 一直为 0很大概率是标注数据里面有类别错位或者坐标越界导致模型学不到有效特征。这时候回到第 3 章的校验脚本重点检查 loss Nan 之前处理的那批图片往往能快速定位出坏样本。用一轮训练的时间验证数据质量永远是数据处理流程里性价比最高的一步。本文还有配套的精品资源点击获取
返回列表