多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PASCAL VOC标注的IP102子集:9997张图的目标检测实战指南

PASCAL VOC标注的IP102子集:9997张图的目标检测实战指南 简介IP102昆虫数据集的PASCAL VOC格式标注文件包面向目标检测、图像分类方向的算法研究人员与开发者适用于农作物害虫识别模型的训练与评估。压缩包内共2000个XML标注文件每个文件记录对应图片中害虫目标的边界框坐标及类别标签可直接接入Faster R-CNN、YOLO、SSD等主流检测框架免除手动标注与格式转换工作。PASCAL VOC格式是计算机视觉领域通用标准兼容性高文件结构清晰便于二次开发与数据筛选。资源整体约408.18MB命名与原始图片一一对应可快速匹配和批量处理。目前已有421人学习下载适合需要构建标准化害虫检测数据集、开展模型微调或数据增强实验的读者。配合原始图片即可组成完整训练验证流程帮助高效产出可复现的实验结果。1. PASICALvoc标注的IP102子集9997张图能撑起一个什么项目标题里的PASICALvoc其实就是我们常说的PASCAL VOC标注格式这个子集从完整IP102数据集中筛出9997张原始图片每张都配了同名的XML标注文件。别看数量只有完整版的零头对搞目标检测的工程人来说反而更省心数据量刚好够完整跑通一次检测训练标注结构透明类别集中在农业害虫场景不需要像原始IP102那样花大量时间做清洗。适合三类人想跑通检测全流程的学生、做小规模迁移学习的算法工程师、GPU资源有限但想快速出结果的团队。先确认它值不值得投入再看怎么把它用好。2. 先拆数据集VOC三件套的结构与IP102类别体系的对应关系2.1 JPEGImages、Annotations、ImageSetsVOC三件套分别存什么拿到数据集第一件事不是急着训练而是先摸清目录组织。常见的VOC格式数据集解压之后长这样ip102_voc_subset/ ├── JPEGImages/ # 9997张原始图片绝大多数是jpg ├── Annotations/ # 与图片同名的xml标注文件一个图对应一个xml ├── ImageSets/ │ └── Main/ # train.txt / val.txt / trainval.txt └── classes.txt # 类别清单部分发布版本提供JPEGImages放图片Annotations放标注两者靠文件名关联——比如001234.jpg必然对应001234.xml。ImageSets/Main下面放的是不带扩展名的文件名列表每行一个IDtrain.txt和val.txt分别记录参与训练和验证的图片名trainval.txt是两者合并。这个设计在PASCAL VOC时代是为了统一评测协议现在被各种检测框架继承了下来。这里要提醒一句有些版本会把classes.txt直接放在根目录有些则没有。如果发布页里没有显式给出类别清单也别慌完全可以自己从Annotations里遍历所有XML提取name字段这点在后面的统计脚本里会一起处理。另外完整版IP102自带train/val/test划分但那个划分是按分类任务设计的检测任务建议自己按需求重新划分别直接沿用分类的名单。2.2 XML里的bndbox与difficult检测训练真正读取的字段VOC的XML标注文件结构固定随便打开一个核心部分长这样annotation folderJPEGImages/folder filename001234.jpg/filename source databaseIP102/database /source size width500/width height375/height depth3/depth /size segmented0/segmented object nameRice leaf roller/name poseUnspecified/pose truncated0/truncated difficult0/difficult bndbox xmin100/xmin ymin80/ymin xmax220/xmax ymax200/ymax /bndbox /object /annotation训练时真正读取的字段就这几个filename对应图片名size里是图片宽高和通道数object里的name是类别名bndbox是左上角和右下角坐标。注意这里的坐标是像素绝对值不是归一化的。truncated表示目标是否被截断difficult表示是否难识别——VOC时代这两个字段用于控制评测时是否计入现代检测框架大多数直接忽略转YOLO格式时你也可以选择性保留或丢弃。一个值得留意的细节同一张图里如果有多个物体XML里就会有多个object块转换脚本需要遍历全部。还有IP102的标注框大多是害虫主体不要指望像人像数据集那样有精准的轮廓标注检测框的紧致程度一般转YOLO后如果发现框偏大或偏小先检查这个原始XML别急着改代码。2.3 IP102的类别命名习惯学名、俗名与中文名的映射IP102原始数据集的102个类别全是英文学名或英文俗名比如水稻上的稻纵卷叶螟标成Rice leaf roller二化螟标成Asiatic rice borer棉花上的棉铃虫标成Cotton bollworm。这个命名习惯直接影响后续类别映射因为很多人拿到手第一反应是我要中文名然后手写一个classes.txt结果和XML里的大小写、空格对不上训练时全部标注被当成未知类别丢弃。我一般会先跑一个小脚本把XML里所有的类别名去重打印看过实际值再决定怎么做映射。注意IP102类别名里大小写不统一是常态Rice leaf roller和rice leaf roller同时出现的情况在社区版数据集里并不罕见。如果你要做中文显示或中文标签标准做法是维护一个英文到中文的字典做映射而不是直接改XML里的name字段——改XML容易改坏坐标结构字典映射出问题至少还能回退。这一步做完再进入训练前的体检阶段。3. 训练前的体检用脚本核对图片、标注与类别分布3.1 一个Python脚本统计102类各自的框数和图片数很多人拿数据集直接开训训完发现某些类完全没学到回头一看才知道该类别在整个数据集里只有十几张图。训练前花两分钟跑一下分布统计能省掉后面几天的困惑。下面这个脚本我每次拿到VOC格式数据集都会先跑一遍import os import xml.etree.ElementTree as ET from collections import Counter annotations_dir Annotations class_box_count Counter() # 每个类别的框总数 class_img_count Counter() # 每个类别出现的图片数 img_with_obj 0 img_without_obj 0 for xml_name in os.listdir(annotations_dir): if not xml_name.endswith(.xml): continue xml_path os.path.join(annotations_dir, xml_name) tree ET.parse(xml_path) root tree.getroot() objs root.findall(object) if len(objs) 0: img_without_obj 1 continue img_with_obj 1 seen set() for obj in objs: cls obj.find(name).text.strip() class_box_count[cls] 1 if cls not in seen: seen.add(cls) class_img_count[cls] 1 print(f有标注的图片数: {img_with_obj}) print(f空标注图片数: {img_without_obj}) print(f类别总数: {len(class_box_count)}) print(前20个高频类别框数:) for cls, cnt in class_box_count.most_common(20): print(f {cls}: {cnt} 框, 涉及 {class_img_count[cls]} 张图)逻辑说明遍历Annotations目录下所有XML用ET.parse解析出所有object同时统计两个维度——框数和图片数。class_box_count记录总框数class_img_count记录出现过该类别的图片数用seen集合避免一张图里多个同类框被重复计入图片数。参数说明annotations_dir改成你自己的路径如果你的XML里有namespace命名空间findall(object)会失效需要改成带完整命名空间的写法比如findall({http://...}object)。运行后重点看两件事空标注图片多不多以及类别分布有没有严重的长尾。一个102类的数据集如果头部类别占了40%以上的框后面训练必须考虑类别不平衡处理这部分在第6章再展开。3.2 图片与XML对不上后缀混用和空标注文件的三种表现体检第二步是检查图片和XML的对齐关系。VOC格式里图片是jpg标注是xml但整理数据集时经常出现后缀丢失、大小写不一致.JPG vs .jpg、或者图片压根没进文件夹的情况。下面这个检查脚本很实用import os images_dir JPEGImages annotations_dir Annotations image_stems set() xml_stems set() for f in os.listdir(images_dir): if f.lower().endswith((.jpg, .jpeg, .png)): image_stems.add(os.path.splitext(f)[0].lower()) for f in os.listdir(annotations_dir): if f.endswith(.xml): xml_stems.add(os.path.splitext(f)[0].lower()) only_images image_stems - xml_stems only_xml xml_stems - image_stems print(f图片总数: {len(image_stems)}) print(fXML总数: {len(xml_stems)}) print(f有图无标注: {len(only_images)}) print(f有标注无图: {len(only_xml)}) if only_images: print(示例:, list(only_images)[:5]) if only_xml: print(示例:, list(only_xml)[:5])逻辑说明把JPEGImages和Annotations下的文件名提取出来去掉扩展名统一转小写后放进集合做差集。图片集合里多出来的就是有图没标注XML集合里多出来的就是有标注没图。参数说明扩展名过滤里我写了jpg、jpeg、png三种如果你确定数据全是jpg可以只留.jpg。转小写这一步很重要Windows导出的文件名经常会有IMG_0001.JPG这种写法不转小写会把同名文件误判成不同文件。跑完如果发现有图无标注或者有标注无图量少可以直接删掉对应文件量多就要回头找发布方核对不然训练时数据加载会随机报错而且这种错不会第一时间暴露往往拖到某个epoch才崩。3.3 坐标越界与图片尺寸不一致bndbox超出图片范围时训练会怎样体检第三步验证坐标合法性。XML里的size字段记录了图片宽高但实际图片可能被压缩过、旋转过导致size和真实图片尺寸对不上。坐标越界在YOLO训练里表现很隐蔽loss正常下降但验证集的mAP始终上不去或者预测框跑到图片外面去。import os from PIL import Image import xml.etree.ElementTree as ET annotations_dir Annotations images_dir JPEGImages bad_size 0 bad_box 0 for xml_name in os.listdir(annotations_dir): if not xml_name.endswith(.xml): continue xml_path os.path.join(annotations_dir, xml_name) tree ET.parse(xml_path) root tree.getroot() filename root.find(filename).text img_path os.path.join(images_dir, filename) if not os.path.exists(img_path): continue with Image.open(img_path) as img: real_w, real_h img.size size root.find(size) xml_w int(size.find(width).text) xml_h int(size.find(height).text) if real_w ! xml_w or real_h ! xml_h: bad_size 1 for obj in root.findall(object): bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) if xmin 0 or ymin 0 or xmax real_w or ymax real_h: bad_box 1 print(fsize字段与图片实际尺寸不一致的XML: {bad_size}) print(f存在越界框的XML: {bad_box})逻辑说明用PIL读取图片真实宽高和XML里size记录对比同时检查每个框的坐标是否落在图片范围内。这段代码能同时暴露两类问题——尺寸欺诈和坐标越界。参数说明脚本依赖PIL库没装的话pip install pillow。如果你的图片里有损坏文件Image.open会抛异常最好在项目里加上try/except跳过坏图。我实际跑过的一个公开数据集发现size字段和真实尺寸不一致的比例高达8%全是整理时统一压缩图片但没有重新生成XML导致的。遇到这种情况转换坐标前必须先修正否则归一化的中心点坐标全是错的模型预测框必然整体偏移。4. 把VOC转成YOLO格式转换脚本与五个边界处理4.1 为什么检测训练默认要YOLO的txt而不是XMLYOLO系列的训练脚本默认读取txt格式标注每行一个目标格式是类别ID x_center y_center width height坐标全部归一化到0到1。相比之下VOC的XML虽然信息更全但解析开销大且每个框架都要重写一套解析器。txt格式一行一个框读取快没有层次结构处理起来极其省心。常见做法是写一个一次性转换脚本把VOC转成YOLO之后再也不碰XML。这个转换的核心就是三件事类别名换类别ID靠classes.txt的顺序、像素坐标归一化、处理各种边界情况。转换脚本本身不复杂但边界情况处理不好会埋雷——比如框坐标越界、框宽高为0、类别名大小写不统一、一张图里多个目标、标注框恰好贴在图片边缘。下面这个脚本把这些情况都考虑进去了。4.2 voc2yolo.py标注框归一化与类别ID映射的完整实现import os import xml.etree.ElementTree as ET def voc2yolo(xml_dir, out_dir, classes): os.makedirs(out_dir, exist_okTrue) skipped {no_object: 0, empty_box: 0, unknown_class: 0} for xml_name in os.listdir(xml_dir): if not xml_name.endswith(.xml): continue xml_path os.path.join(xml_dir, xml_name) tree ET.parse(xml_path) root tree.getroot() size root.find(size) width int(size.find(width).text) height int(size.find(height).text) objs root.findall(object) if len(objs) 0: skipped[no_object] 1 continue out_lines [] for obj in objs: cls_name obj.find(name).text.strip() if cls_name not in classes: skipped[unknown_class] 1 continue cls_id classes.index(cls_name) bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) # 关键越界裁剪防止归一化值超出[0,1] xmin max(0, min(xmin, width)) xmax max(0, min(xmax, width)) ymin max(0, min(ymin, height)) ymax max(0, min(ymax, height)) if xmax xmin or ymax ymin: skipped[empty_box] 1 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 x_center min(max(x_center, 0.0), 1.0) y_center min(max(y_center, 0.0), 1.0) box_w min(max(box_w, 0.0), 1.0) box_h min(max(box_h, 0.0), 1.0) out_lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) if not out_lines: skipped[empty_box] 1 continue txt_name os.path.splitext(xml_name)[0] .txt with open(os.path.join(out_dir, txt_name), w) as f: f.write(\n.join(out_lines) \n) print(跳过统计:, skipped)逻辑说明脚本核心是坐标归一化。x_center (xmin xmax) / 2 / width就是取框中心点像素位置除以图片宽度把像素值映射到0到1区间box_w则是框宽占图片宽的比例。在归一化之前先做越界裁剪框的坐标值永远不会超出图片边界这是避免后续训练报错的关键一步。参数说明xml_dir指向Annotations目录out_dir是转换后txt的输出目录classes是类别列表顺序决定了类别ID必须和后续训练配置完全一致否则类别全部错位。skipped字典统计了三种跳过的原因no_object是没有目标的XMLunknown_class是XML里的类别名不在classes列表中empty_box是裁剪后框宽高为0。这个统计能帮你快速判断问题出在标注本身还是类别名映射。调用示例classes [Rice leaf roller, Rice leaf caterpillar, Asiatic rice borer] voc2yolo(Annotations, labels, classes)注意只要遇到unknown_class数量偏多赶紧打印classes列表和XML实际类别名做对比多半是大小写或空格问题。这里有个我踩过的坑IP102的类别名里有Leafhopper和leaf hopper这种带空格和大小写差异的写法肉眼很难看出来但脚本一统计就露馅了。4.3 划分train/val按图片划分而不是按框划分转换完标注下一步是划分数据集。很多新手直接按txt文件数量比例切分或者从XML里按框的数量切分这都会出问题。正确做法是先把图片文件列表shuffle再按图片划分然后根据划分结果分别生成train.txt和val.txt。import os import random images_dir JPEGImages val_ratio 0.2 seed 42 image_files [f for f in os.listdir(images_dir) if f.lower().endswith((.jpg, .jpeg, .png))] image_files.sort() random.seed(seed) random.shuffle(image_files) val_count int(len(image_files) * val_ratio) val_files image_files[:val_count] train_files image_files[val_count:] def write_split(file_list, out_path, labels_dir): with open(out_path, w) as f: for img in file_list: stem os.path.splitext(img)[0] txt_path os.path.join(labels_dir, stem .txt) if os.path.exists(txt_path): f.write(os.path.join(images_dir, img) \n) write_split(train_files, train.txt, labels) write_split(val_files, val.txt, labels) print(ftrain图片数: {len(train_files)}) print(fval图片数: {len(val_files)})逻辑说明先用sort保证文件顺序稳定再用random.shuffle打乱。shuffle前必须设随机种子保证每次运行划分结果一致否则同一份数据每次跑出来验证集不同实验之间没法对比。write_split函数把图片路径写入txt同时检查对应的txt标注是否存在因为转换时空标注的图片不会生成txt这些图即使有标签也训练不了直接跳过。参数说明val_ratio一般设0.1到0.29997张图取0.2大约2000张做验证集。这里强调按图片划分是因为同一张图里的多个框天然属于同一个数据样本如果把框拆开分到训练和验证就是典型的数据泄漏验证集精度会虚高到失真。5. 避坑训练前最容易翻车的5件事5.1 现象loss降到很低但mAP为0模型像在胡说八道原因类别名匹配失败。classes列表写的类别名和XML里实际的名字存在大小写差异、首尾空格或拼写变体转换时所有目标全被当成unknown_class跳过生成的txt全是空文件。训练时模型看到的是没有标注的图片loss一路下降但模型什么也没学到。解决训练前先跑一遍第3章的统计脚本把XML里真实的类别名打印出来拿它去生成classes.txt。转换脚本跑完手动打开labels目录里的一两个txt确认第一列是0到类别数减1之间的整数。5.2 现象模型预测框整体偏移预测位置比实际目标偏左或偏上原因XML里的size字段和图片真实尺寸不一致。数据集整理过程可能统一压缩过图片但XML没同步更新。归一化时用错了width和height导致所有中心点坐标系统性偏移。解决用第3.3节的脚本检查size字段一致性发现问题后以图片实际尺寸为准先修正XML的size再转YOLO。如果图片量不大可以直接在转换脚本里用PIL读取真实宽高替代XML的size值一劳永逸。5.3 现象训练中途报错IndexError或KeyError崩溃在数据加载环节原因数据集里混入了格式不支持的文件或者某个类别ID超出设定范围。常见情况是图片里有少量PNG但你的数据加载器只认jpg又或者classes.txt顺序调整过但转换生成的txt还是按老顺序写的ID类别ID和配置文件对不上。解决按第3.2节的检查脚本过滤非jpg文件转换脚本和训练配置必须共用同一个classes列表——建议把classes列表单独存成一份文件转换和训练都从这份文件读取别在两处手工维护。5.4 现象验证集精度虚高到离谱一换数据就崩原因划分数据集时按框划分或者划分后没有检查重叠同一张图片既出现在train.txt又出现在val.txt。模型见过验证图精度自然好看但这不代表真实泛化能力。解决划分时用第4.3节的按图片划分方法划分完做一次文件交集检查train_ids set(open(train.txt).read().splitlines()) val_ids set(open(val.txt).read().splitlines()) print(train与val重叠数量:, len(train_ids val_ids))输出应为0。如果重叠数量不为0检查是不是train.txt和val.txt生成了两次。5.5 现象验证集上头部类别精度还行尾部类别全部漏检原因IP102类别分布天然长尾部分类别整个数据集只有二三十张图训练时被高频类别淹没模型倾向把目标预测成高频类。解决先看第3.1节统计结果框数低于50的类别要单独处理重采样或调整损失函数权重切忌直接开训。具体做法在第6章展开。6. 进阶不平衡类别下的训练策略与冻结backbone微调6.1 类别重采样怎么动train.txt而不动原图遇到长尾分布最简单的处理是修改train.txt里的采样权重不用复制任何图片。以YOLO训练为例可以给每个类别设置一个采样倍率框数少的类别多采样几遍import os from collections import Counter def rebalance_train(txt_path, class_id_to_label, min_count100): 根据类别统计对小类别做过采样返回加权后的图片路径列表。 with open(txt_path) as f: img_paths [line.strip() for line in f if line.strip()] class_count Counter() for img_path in img_paths: txt img_path.replace(/JPEGImages/, /labels/).replace(.jpg, .txt).replace(.png, .txt) if os.path.exists(txt): with open(txt) as f2: for line in f2: cls_id int(line.split()[0]) class_count[cls_id] 1 scale {} for cls_id, cnt in class_count.items(): scale[cls_id] min_count / cnt if cnt min_count else 1.0 balanced [] for img_path in img_paths: txt img_path.replace(/JPEGImages/, /labels/).replace(.jpg, .txt).replace(.png, .txt) if os.path.exists(txt): with open(txt) as f2: ids [int(l.split()[0]) for l in f2] if ids: weight max(scale.get(cid, 1.0) for cid in ids) balanced.extend([img_path] * int(weight)) return balanced逻辑说明先统计每个类别的图片数然后设定一个阈值min_count低于阈值的类别按比例放大采样次数高于阈值的不动。返回的balanced列表里小类别的图片会重复出现多次相当于过采样但文件本身没有被复制磁盘空间不受影响。参数说明min_count建议根据你的训练轮数和类别数来定9997张图分102类设100是比较保守的起点。如果你的类别分布特别极端可以把阈值提到200。注意这个脚本只重写了训练列表验证集不要做任何重采样验证集必须保持原始分布否则评估结果没有参考价值。6.2 用预训练权重微调backbone冻结到哪一层9997张图做检测训练从零初始化几乎必然过拟合正确姿势是拿在大型数据集上预训练过的权重做迁移学习。关键决策是冻结多少层backbone。以常见检测框架的freeze参数为例yolo train dataip102.yaml modelyolov5s.pt epochs100 batch16 freeze10freeze10表示冻结模型前10层通常覆盖整个backbone的底层特征提取部分。冻结的好处是显存占用低训练速度快小数据集不容易跑偏。坏处是如果目标域和预训练域差异太大底层特征不够用模型精度反而受限。农业害虫图片背景复杂、目标小建议从freeze10开始试如果训练loss收敛后验证集精度不理想再逐步减少冻结层数比如freeze6或freeze4让更多层参与微调。还有一个实用习惯每轮训练都保存best模型但验证集的划分必须固定。我有一次为了让精度好看点重新划分了数据结果下一次实验怎么都复现不了那个数字最后排查才发现验证集重叠了。从那以后我的规矩就是数据集版本、划分种子、classes列表顺序全部固化任何一项变更都当作一次新实验记录不混用。冻结层数、重采样策略、输入尺寸这些超参数在9997张图的小数据集上非常敏感多试几组组合每次只动一个变量才能摸清这个子集的上限。上面这套流程走完你手里的PASICALvoc标注子集基本就能跑出一个可信的结果希望帮到你。本文还有配套的精品资源点击获取
返回列表