YAOTU INSIGHTS

VOC水稻害虫数据集转YOLOv8:标注检查、可视化与训练避坑指南

VOC水稻害虫数据集转YOLOv8:标注检查、可视化与训练避坑指南
简介面向水稻害虫检测的目标检测数据集涵盖蛀虫、蠕虫等10个类别采用VOC标注格式并附类别JSON映射文件与可视化脚本适合农业场景下的模型训练与验证。压缩包为7z格式大小约89.78MB内含2000个文件其中XML标注文件1999个、可视化脚本1个数据按train与val目录划分经测试可直接供主流检测框架使用无需额外处理。训练集含6630张图像及对应XML标注验证集含631张图像及对应标注图像均为300×300 RGB图片每张均有完整边界框部分样本采用马赛克增强。JSON字典文件便于类别索引可视化脚本可随机读取图片并绘制、保存边界框方便核对标注结果。已有327人学习适合目标检测入门实验与水稻害虫识别研究。1. 10类别水稻害虫检测数据集VOC格式下先别急着训练模型目标检测在农业害虫监测里的落地很大一部分功夫不在模型而在数据准备。手头这份“10类别水稻害虫检测数据集”是VOC标注格式自带训练集、验证集、类别json文件以及可视化脚本作为开工起点其实已经相当完整。但我的建议是先别急着把图片喂给YOLOv8先花半天把VOC结构、json内容和可视化脚本过一遍。因为这类数据集的翻车点往往不在模型选型而在标注边界、类别id错位、训练集和验证集的样本隔离这三件事。这篇文章会沿着数据集本身的格式把检查、转换、可视化验证到训练接入的完整路径走一遍适合刚入门目标检测、手头有类似数据集但不知道从哪下手的读者。2. VOC标注格式拆解从目录结构到类别json一次看懂数据集内部2.1 数据集目录里到底装了哪些文件JPEGImages、Annotations、ImageSets、classes.jsonVOC格式来自Pascal VOC竞赛后来成了目标检测数据集最常见的“公用语言”。一个标准的VOC数据集目录通常会包含四个核心部分JPEGImages存放原始图片Annotations存放与图片同名的xml标注文件ImageSets/Main存放训练集和验证集的txt索引清单外加一个独立的类别json文件。标题里提到的可视化脚本一般放在数据集根目录或scripts文件夹下。我第一次拿到这类数据集时习惯先不打开任何图片而是先跑一个目录结构检查脚本确认每个文件是否齐全、图片和标注是否一一对应。import os from collections import Counter dataset_dir ./rice_pest_voc images_dir os.path.join(dataset_dir, JPEGImages) annos_dir os.path.join(dataset_dir, Annotations) sets_dir os.path.join(dataset_dir, ImageSets, Main) image_ids [f.rsplit(., 1)[0] for f in os.listdir(images_dir) if f.lower().endswith((.jpg, .jpeg, .png))] anno_ids [f.rsplit(., 1)[0] for f in os.listdir(annos_dir) if f.endswith(.xml)] missing_anno set(image_ids) - set(anno_ids) agree_imgs set(image_ids) set(anno_ids) print(f图片总数: {len(image_ids)}) print(f标注总数: {len(anno_ids)}) print(f缺少标注的图片: {len(missing_anno)}) if os.path.exists(sets_dir): for txt in os.listdir(sets_dir): print(f划分文件: {txt}, 行数: {len(open(os.path.join(sets_dir, txt)).readlines())})这段代码先以图片文件为准统计出缺少xml标注的图片再扫描ImageSets里的划分文件。注意图片和xml是靠文件主名关联的也就是001.jpg对应001.xml任何前缀差异都会被这里查出来。建议把.png、.bmp也列进去因为水稻田现场采集的图片经常混用手机截图和遥感裁剪图扩展名不统一很常见。2.2 读懂VOC的XML标注bounding box与类别名的对应关系VOC的xml标注是有固定结构的核心是object节点。每个object里有一个name就是类别名还有bndbox下的xmin、ymin、xmax、ymax四个整数坐标。这四个值代表矩形框左上角和右下角的像素坐标注意不是中心点加宽高这是新手最容易搞混的地方。一个图里可能同时出现多种害虫所以会有多个object节点。我一般会写一个小函数把每个xml里的类别名抽出来做统计看看数据集的类别分布是否均衡。import xml.etree.ElementTree as ET def parse_voc_annotation(xml_path): tree ET.parse(xml_path) root tree.getroot() size root.find(size) width int(size.find(width).text) height int(size.find(height).text) boxes [] for obj in root.iter(object): name obj.find(name).text.strip() bndbox obj.find(bndbox) xmin int(round(float(bndbox.find(xmin).text))) ymin int(round(float(bndbox.find(ymin).text))) xmax int(round(float(bndbox.find(xmax).text))) ymax int(round(float(bndbox.find(ymax).text))) boxes.append({name: name, xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax}) return {width: width, height: height, boxes: boxes} xml_path ./rice_pest_voc/Annotations/0001.xml result parse_voc_annotation(xml_path) print(f图像尺寸: {result[width]}x{result[height]}) for box in result[boxes]: print(f类别: {box[name]}, 坐标: ({box[xmin]}, {box[ymin]}, {box[xmax]}, {box[ymax]}))这个解析函数用Python标准库xml.etree.ElementTree完成不依赖第三方库。坐标转换时先读字符串再强制转浮点最后取整是因为不少标注工具会把坐标写成6.00000000这种小数格式直接int()会报错。取整用round比直接截断更稳能减少后续YOLO归一化时的累计误差。我建议把这段函数保存成一个voc_utils.py后面可视化脚本和转换脚本都要复用。2.3 类别json文件的作用与手工校验方法类别json文件通常有两种写法一种是数组比如[稻飞虱, 二化螟, ...]另一种是字典比如{稻飞虱: 0, 二化螟: 1, ...}。前者只给类别名类别id靠数组下标决定后者直接把类别名映射到id。这两种本身都没问题问题在于和xml里的name是否完全一致。比如xml里写的是rice_leaf_rollerjson里写的是稻纵卷叶螟那训练时模型会把它当成背景或未知类。所以拿到数据集的第一件事是读json然后用上一节的解析函数扫一遍所有xml做一次全量交叉校验。import json with open(./rice_pest_voc/classes.json, r, encodingutf-8) as f: class_data json.load(f) class_list class_data if isinstance(class_data, list) else list(class_data.keys()) print(fjson中的类别数: {len(class_list)}) print(f类别列表: {class_list}) xml_names set() for xml_file in os.listdir(annos_dir): res parse_voc_annotation(os.path.join(annos_dir, xml_file)) for box in res[boxes]: xml_names.add(box[name]) extra_names xml_names - set(class_list) print(xml中出现但json里没有的类别:, extra_names)这段代码的关键在最后一行差集计算。如果extra_names不为空说明标注里有未注册的类别名需要手动决定是修正xml还是把它加进json。注意读取json时一定要加encodingutf-8否则Windows下中文类别名很容易变成乱码。另外建议顺手统计一下每个类别的框数量如果某个类别框数是个位数这个类基本废了模型不太可能学出稳定的检测能力。3. 把VOC格式水稻害虫数据接到YOLOv8转换脚本与训练集生成3.1 为什么选择YOLOv8作为检测基线生态成熟与训练参数易调接了这么多目标检测数据集我最常用的基线就是YOLOv8。原因有三第一ultralytics仓库把数据加载、增强、训练、验证全封装好了一个yaml文件就能启动训练新手友好第二YOLOv8的model.train()接口对数据集格式要求很固定只要按images/和labels/组织目录它自己会生成训练集和验证集索引第三迁移到YOLOv11也很顺模型结构差距不大。但这里有个大前提YOLOv8默认不认VOC格式它要的是每个图片对应一个同名.txt文件每一行格式是class_id cx cy w h且这些值都归一化到0到1之间。所以VOC转YOLO是绕不开的第一步也是最容易出坐标错误的一步。3.2 VOC转YOLO的转换脚本归一化坐标计算转换脚本的本质就是读取xml里的整数像素坐标除以图片宽高得到左上角归一化坐标再计算出中心点和宽高。很多新手会直接套公式cx (xmin xmax) / 2 / width结果训练时发现loss降不下去因为没考虑xmax、ymax可能超出图片边界。水稻害虫数据里靠近图像边缘的被截断目标特别常见标注时边界框经常会画出一点点图片范围。我习惯在坐标转换后统一做一次夹紧操作把越界值拉回合法区间。import json import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, class_list, output_txt_path): tree ET.parse(xml_path) root tree.getroot() width float(root.find(size).find(width).text) height float(root.find(size).find(height).text) lines [] for obj in root.iter(object): name obj.find(name).text.strip() if name not in class_list: continue class_id class_list.index(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) xmin max(0.0, min(xmin, width)) xmax max(0.0, min(xmax, width)) ymin max(0.0, min(ymin, height)) ymax max(0.0, min(ymax, height)) if xmax xmin or ymax ymin: continue cx (xmin xmax) / 2 / width cy (ymin ymax) / 2 / height box_w (xmax - xmin) / width box_h (ymax - ymin) / height lines.append(f{class_id} {cx:.6f} {cy:.6f} {box_w:.6f} {box_h:.6f}) with open(output_txt_path, w, encodingutf-8) as f: f.write(\n.join(lines)) with open(classes.json, r, encodingutf-8) as f: class_data json.load(f) class_list class_data if isinstance(class_data, list) else list(class_data.keys()) os.makedirs(labels/train, exist_okTrue) os.makedirs(labels/val, exist_okTrue) # 调用示例 voc_to_yolo(Annotations/0001.xml, class_list, labels/train/0001.txt)转换逻辑分为三步先从json读出类别列表保证xml里的类别名能通过index方法映射到整数id然后对坐标做夹紧防止越界最后除以宽高归一化。注意class_id顺序必须和训练用的data.yaml完全一致如果json是数组那就按数组下标后面在训练配置里不能再改类别顺序。box_w和box_h的精度我保留6位小数对YOLO训练足够文件也不会太臃肿。3.3 生成训练集和验证集的txt清单保证样本隔离标题里特别强调了“包含训练集和验证集”说明数据集已经给了划分。但VOC格式的划分是用txt放图片主名列表YOLOv8需要的是图片路径列表或者目录结构。这里有个隐蔽的坑如果直接拿VOC的txt去跑YOLO它有自己单独的val.txt和train.txt机制需要我们在转换脚本里一并生成。另外一个更隐蔽的问题是如果划分文件是手动生成的很可能出现同一张图的某个目标被裁到另一个目标附近导致训练集和验证集边界模糊。更稳妥的做法是检查两个集合的图片主名是否有交集有交集就是数据泄露必须换掉。import os sets_dir ./rice_pest_voc/ImageSets/Main train_file os.path.join(sets_dir, train.txt) val_file os.path.join(sets_dir, val.txt) def read_ids(txt_path): with open(txt_path, r) as f: return [line.strip() for line in f if line.strip()] train_ids read_ids(train_file) val_ids read_ids(val_file) duplicate set(train_ids) set(val_ids) print(f训练集数量: {len(train_ids)}) print(f验证集数量: {len(val_ids)}) print(f重复图片数: {len(duplicate)}) if duplicate: print(请立即重新划分否则验证集指标无效:, list(duplicate)[:5]) train_ids_str \n.join([os.path.join(images/train, f{i}.jpg) for i in train_ids]) val_ids_str \n.join([os.path.join(images/val, f{i}.jpg) for i in val_ids]) with open(train.txt, w) as f: f.write(train_ids_str) with open(val.txt, w) as f: f.write(val_ids_str)这个脚本会把txt里的主名拼接成完整图片路径方便直接给YOLO用。输出重复图片数是关键的质检步骤如果两个集合有交集验证集就形同虚设模型等于开卷考试mAP虚高到你不敢相信。还有一点转换完的图片目录结构建议只放软链接不要去复制原图水稻害虫原图动不动几十MB复制一遍会浪费大量磁盘空间。用ln -s把JPEGImages链接成images/train和images/val能省很多事。4. 用可视化脚本检查标注质量框位置、类名和漏标一眼看穿4.1 可视化脚本该画什么矩形框、标签文本与图片路径标题里专门提到数据集自带可视化脚本这类脚本的价值不是“好看”而是帮你快速发现标注问题。一张图几十只害虫光靠肉眼对xml数字根本看不出问题把框画在图上框偏了、漏了、类名错了一眼就能扫出来。我理想中的可视化脚本至少要做三件事第一用不同颜色区分类别第二在框左上角显示类别名第三把检测结果拼成网格图不然一张张看几百张图会看到怀疑人生。按钮和交互窗口反而不需要批量输出png图片最实在。4.2 一个最小可用可视化脚本OpenCVPIL绘制VOC标注OpenCV自带矩形绘制和文字绘制但默认字体不支持中文水稻害虫的类别名又几乎全是中文比如“二化螟”“褐飞虱”。所以这里要用PIL先把中文渲染到透明图层再合成到OpenCV的BGR图上。这套组合拳是解决中文乱码最通用的做法。import os import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont class_colors { 二化螟: (0, 255, 0), 褐飞虱: (0, 0, 255), # 按需补充 } def draw_annotation(image_path, boxes, font_pathsimhei.ttf): img_bgr cv2.imread(image_path) img_pil cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) pil_img Image.fromarray(img_pil) draw ImageDraw.Draw(pil_img, RGBA) font ImageFont.truetype(font_path, 32) for box in boxes: name box[name] xmin, ymin, xmax, ymax box[xmin], box[ymin], box[xmax], box[ymax] color class_colors.get(name, (255, 255, 0)) draw.rectangle([xmin, ymin, xmax, ymax], outlinecolor, width4) draw.text((xmin, max(ymin - 38, 0)), name, fontfont, fillcolor) img_rgb np.array(pil_img) img_bgr cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR) return img_bgr # 假设已有解析结果 res parse_voc_annotation(./rice_pest_voc/Annotations/0001.xml) vis draw_annotation(./rice_pest_voc/JPEGImages/0001.jpg, res[boxes], font_pathC:/Windows/Fonts/simhei.ttf) cv2.imwrite(./vis_check/0001.jpg, vis)这段代码的坑主要在字体路径。Linux服务器一般没有simhei.ttf需要自己放一个中文字体到项目目录或者在PIL.ImageFont.truetype里传入绝对路径。draw.rectangle的outlinecolor指定框颜色width4控制框粗细太细在批量缩略图里看不清。文字位置用max(ymin - 38, 0)是为了防止框在图片顶部时文字被截掉。透明图层使用的是RGBA模式这样画文字时不会遮挡后方的害虫本体。4.3 批量生成结果图按类别统计框数一个一个画图效率太低我一般会把可视化脚本升级成批量模式遍历Annotations目录解析所有xml每个图生成一张叠加框的图同时统计每个类别的框数输出一份category_count.csv。这份csv比任何训练日志都更能说明问题。比如标了3000个框里只有50个属于“稻瘿蚊”那这个类别即使训练完mAP也大概率是0。批量跑完扫一眼缩略图再对一下类别统计表数据集的可信度就心里有数了。import csv import os counts {} for xml_file in os.listdir(annos_dir): res parse_voc_annotation(os.path.join(annos_dir, xml_file)) vis draw_annotation(os.path.join(images_dir, xml_file.replace(.xml, .jpg)), res[boxes]) cv2.imwrite(os.path.join(vis_check, xml_file.replace(.xml, .jpg)), vis) for box in res[boxes]: name box[name] counts[name] counts.get(name, 0) 1 with open(category_count.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([类别, 框数]) for name, cnt in sorted(counts.items(), keylambda x: x[1], reverseTrue): writer.writerow([name, cnt])这里从annos_dir遍历出来的xml主名直接替换为jpg路径如果图片扩展名并不统一替换就会漏图。更稳的写法是先读JPEGImages目录用图片主名去拼xml路径。另外批量生成的时候建议把所有结果图缩小到720p再输出不然原始尺寸大图会一次性占满磁盘。category_count.csv用csv标准库写不需要pandas少一层依赖就少一处环境坑。5. 水稻害虫数据集避坑指南5个让训练翻车的常见标注问题5.1 现象训练loss下降但验证mAP为0模型什么也检测不到这类问题最典型的场景是转换脚本跑通了YOLOv8也能启动训练但每个epoch的验证mAP始终是0。查到最后发现classes.json里的类别顺序和转换时的class_list不一致。比如json里是数组[二化螟, 褐飞虱, ...]但我中途手动调整过json顺序或者某个脚本用了set()去重导致顺序乱掉xml里的“二化螟”被标成class_id5而训练yaml里“二化螟”对应class_id0。解决方法是把classes.json当作唯一金标准所有转换脚本里的class_list都直接从json读取禁止硬编码。转换完成后随机挑几个yolo txt文件人工检查第一列的id值是否和json下标一致。5.2 现象VOC转YOLO后框坐标出现负数或者w/h超过1这个问题多出在标注框跨越图片边界。有些数据集是采集后直接切图切图工具生成的xml会保留原坐标系导致目标在切割边缘时xmin变为负值。坐标系归一化之后本来合法的框也会变成负数宽高。解决的办法就是第3.2节里的夹紧操作越界就裁剪到边界上。如果夹紧后宽度或高度为0比如目标整体越界到图片外了直接丢弃这个框。这一步必须在转换阶段做不要拖到训练前再用数据加载器去过滤否则一批数据的xml可能几百个越界框训练会被无效框拖慢。5.3 现象验证集效果比训练集还好mAP虚高到不敢信水稻害虫检测数据集的图片往往来自同一片稻田的连续拍摄相邻帧背景高度相似。如果划分文件是随机生成而不是按田块、按拍摄时段分组的那么训练集里出现的背景和个体在验证集中几乎原样复现验证集指标自然高得像作弊。解决方法是按图片的时间戳或采集地点划分。常见做法是先把图片文件按文件名排序然后每隔N张抽1张进验证集或者按田块ID划分。更保险的办法是检查训练验证集图片的像素相似度如果两张图的平均绝对误差低于某个阈值就说明太接近了需要手动剔除一个。这一步没有现成工具但值得花时间因为验证集的可信度决定你后续调参的方向。5.4 现象可视化脚本画不出框或者中文类名显示成方块这个问题集中在OpenCV的putText不支持中文上。有人会临时装个中文字体到系统里但在服务器上往往没有管理员权限装字体还会污染其他项目。更稳妥的做法是改用PIL绘制文字就像我在第4.2节里写的那样。如果连保存图片都报错检查一下图片路径里是否有中文或空格OpenCV的imwrite对路径中的特殊字符非常敏感建议所有数据集路径统一改成英文标准命名。5.5 现象害虫目标太小训练了很多轮还是漏检水稻害虫里的稻飞虱、叶蝉成虫体长只有几毫米在1920x1080大图里可能只占十几个像素。YOLOv8的输入分辨率是640x640如果原图直接resize小目标会被压缩成一两个像素点模型根本学不到特征。这就是为什么很多从业者说小目标检测是门玄学——它其实是数据尺度的数学问题。常见做法是先用可视化脚本小图模式看分布如果框面积占全图面积的比例普遍小于0.5%那就应该用大分辨率训练或者把原图切块。比如把1920x1080切成640x640的4块各自独立标注训练检测时再拼回预测结果。这件事情要在训练前就做好而不是等到loss不降了再回头折腾。6. 进阶验证技巧用mAP和漏检热力图判断数据集值不值得继续投入前面把数据接入跑通只是第一步。真正决定这个数据集值不值得继续投入要看它在强度增强下的表现。我常用的验证方法是三步走第一步用固定随机种子跑3次YOLOv8训练每次只改数据增强强度得到一组mAP分布第二步把所有验证集图片翻转、调亮度、加噪声看看模型mAP掉多少掉得越多说明模型对表面纹理依赖越重第三步把误检样本可视化出来做成漏检热力图哪个区域标框特别少那个区域就是数据覆盖的盲区。漏检热力图做起来不复杂你可以在验证脚本里把所有漏检的检测框画到同一张空白图上叠加成一个定点坐标的密度图。水稻害虫往往聚集在叶片背面和叶鞘周围如果热力图显示漏检集中在叶片下缘那说明训练集里这个位置的样本偏少下一步采集应该专门去翻叶片下缘。这个技巧比单看mAP更有指导意义因为mAP是全局指标而热力图能告诉你数据短板到底在哪里。我也吃过亏最初拿一份水稻害虫数据集直接训练mAP到了0.82觉得挺满意后来发现验证集和训练集有部分重叠重新划分后mAP掉到0.61。从那以后我拿到任何带VOC标注的数据集第一件事永远是跑一遍重复样本检查再跑一遍可视化脚本抽查30张图。这个习惯看着麻烦但救过我好几次。如果你手头也有类似的数据集强烈建议也先做这两步再考虑训练参数调整。希望帮到你。本文还有配套的精品资源点击获取