ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

森林害虫目标检测数据集:从标注转换到YOLO训练全流程实战

森林害虫目标检测数据集:从标注转换到YOLO训练全流程实战 简介这份森林害虫目标检测数据集面向林业智能监测、农业无人机巡检及生态科研方向的算法开发者与院校师生用于构建松毛虫、松墨天牛、卷叶蛾三类常见害虫的自动识别与定位模型解决人工巡林效率低、虫害预警滞后的问题。资源共2000个文件以1715个YOLO格式txt标注、283张JPEG实景图片为主另附1个yaml数据配置与1份docx说明文档压缩包约189.26MB训练集1199张、验证集257张、测试集259张划分清晰可直接投入训练。目前已有303人学习下载。标注边界框定位准确、类别标签明确兼容YOLO、Faster R-CNN等主流框架读者可据此快速搭建虫害检测基线、开展迁移学习与泛化性对比实验也可用于林业院校的害虫识别教学实训为森林健康监测与精准防治提供可靠数据支撑。1. 森林害虫目标检测数据集从标注格式到训练落地的完整路径拿到一个森林害虫目标检测数据集第一反应往往不是“数据够不够”而是“这标注到底能不能直接喂给 YOLO”。我见过太多人把压缩包解压完看到一堆 XML、JSON 和散落的 JPEG 就卡住了——格式不统一、类别命名混乱、小目标密集遮挡这三座大山直接劝退。森林害虫检测的难点很特殊虫子体型小、背景纹理复杂树皮、叶片脉络和虫体颜色接近、类别间形态差异细微而且真实林区拍摄的光照条件极其不稳定。这个数据集要解决的核心问题就是让检测模型在复杂自然背景下稳定区分不同害虫类别适用于林业巡检、虫情测报和精准施药决策。如果你手头正好有这批数据或者准备自己标注一批接下来的内容会帮你把从解压到跑通第一条训练命令的路径全部走通。2. 先搞清楚数据长什么样目录结构、标注格式与类别分布2.1 解压后先别急着写代码用三条命令摸清家底拿到森林害虫目标检测数据集.zip第一步不是打开标注文件而是先看目录结构和文件数量。很多人跳过这一步后面发现图片和标注对不上号回头排查浪费半天。# 查看压缩包内文件列表不实际解压 unzip -l 森林害虫目标检测数据集.zip | head -50 # 解压到指定目录 unzip 森林害虫目标检测数据集.zip -d ./forest_pest_data # 统计图片数量和标注文件数量 find ./forest_pest_data -name *.jpg -o -name *.png | wc -l find ./forest_pest_data -name *.xml -o -name *.json -o -name *.txt | wc -l这三条命令分别解决三个问题压缩包内有没有嵌套目录、解压后图片总量是多少、标注文件格式是什么。如果图片数量和标注文件数量差距超过 5%说明有缺失或多余文件需要先做配对检查。常见情况是标注文件比图片多多出来的往往是空标注或者重复文件。注意有些数据集压缩包内层还有一层同名目录解压后路径会变成./forest_pest_data/森林害虫目标检测数据集/写训练配置时路径要对准实际层级。2.2 标注格式判断VOC XML、COCO JSON 还是 YOLO TXT森林害虫数据集的标注格式直接决定后续转换成本。三种常见格式的判断方法很简单格式文件特征典型内容转换难度VOC XML每张图对应一个 .xmlobjectname松墨天牛/namebndbox...低脚本成熟COCO JSON单个 .json 包含所有标注images:[...],annotations:[...]中需解析层级YOLO TXT每张图对应一个 .txt0 0.523 0.412 0.08 0.12低但需类别映射用一条命令快速判断# 查看标注文件头部内容 head -20 ./forest_pest_data/annotations/001.xml # 或者 python3 -c import json; djson.load(open(./forest_pest_data/annotations.json)); print(list(d.keys()))如果是 VOC XML重点看name标签里的类别名是否统一——我遇到过同一类虫子在不同文件里写成“松墨天牛”“松褐天牛”“Monochamus alternatus”三种写法不统一后面训练直接翻车。如果是 COCO JSON重点看categories字段的id和name映射关系。如果是 YOLO TXT重点看类别索引和classes.txt是否对应。2.3 类别分布统计别让长尾类别毁了模型森林害虫数据集通常存在严重的类别不平衡。松材线虫、美国白蛾这类高频害虫可能有上千个标注框而某些珍稀害虫只有几十个。不提前统计训练出来的模型对少数类几乎无检测能力。import os import xml.etree.ElementTree as ET from collections import Counter def count_voc_classes(annotation_dir): 统计VOC格式标注中各类别出现的次数 class_counter Counter() for xml_file in os.listdir(annotation_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(annotation_dir, xml_file)) root tree.getroot() for obj in root.findall(object): name obj.find(name).text.strip() class_counter[name] 1 return class_counter # 替换为实际标注目录路径 counter count_voc_classes(./forest_pest_data/annotations) for cls_name, count in counter.most_common(): print(f{cls_name}: {count})这段代码遍历所有 XML 文件提取每个object下的name并计数。输出结果按数量降序排列一眼就能看出哪些类别是长尾。如果最少类别样本数低于总样本数的 1%建议在训练时用类别权重或者过采样策略否则模型会直接忽略这些类。参数说明annotation_dir指向 XML 文件所在目录不要指向图片目录。如果 XML 和图片混在一起先用find命令把 XML 单独复制到一个目录再统计。3. 把标注转成 YOLO 能吃的格式脚本、参数与边界处理3.1 VOC 转 YOLO坐标归一化和类别映射的四个坑VOC 的bndbox是绝对像素坐标(xmin, ymin, xmax, ymax)YOLO 需要的是归一化中心坐标和宽高(x_center, y_center, width, height)且值在 0 到 1 之间。转换公式看起来简单但实际写脚本时有四个坑import os import xml.etree.ElementTree as ET from PIL import Image def voc_to_yolo(xml_path, img_dir, output_dir, class_list): 将单个VOC XML转换为YOLO TXT格式 class_list: 类别名列表索引即为YOLO类别id tree ET.parse(xml_path) root tree.getroot() # 获取图片尺寸 filename root.find(filename).text img_path os.path.join(img_dir, filename) with Image.open(img_path) as img: img_w, img_h img.size lines [] for obj in root.findall(object): cls_name obj.find(name).text.strip() if cls_name not in class_list: continue # 跳过未注册类别 cls_id class_list.index(cls_name) bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # 边界裁剪防止标注超出图片范围 xmin max(0, min(xmin, img_w)) xmax max(0, min(xmax, img_w)) ymin max(0, min(ymin, img_h)) ymax max(0, min(ymax, img_h)) # 跳过无效框 if xmax xmin or ymax ymin: continue # 归一化计算 x_center (xmin xmax) / 2.0 / img_w y_center (ymin ymax) / 2.0 / img_h width (xmax - xmin) / img_w height (ymax - ymin) / img_h lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}) # 写入TXT文件 txt_name os.path.splitext(filename)[0] .txt with open(os.path.join(output_dir, txt_name), w) as f: f.write(\n.join(lines)) # 批量转换 class_list [松墨天牛, 美国白蛾, 松材线虫, 天牛幼虫, 其他] xml_dir ./forest_pest_data/annotations img_dir ./forest_pest_data/images out_dir ./forest_pest_data/labels os.makedirs(out_dir, exist_okTrue) for xml_file in os.listdir(xml_dir): if xml_file.endswith(.xml): voc_to_yolo(os.path.join(xml_dir, xml_file), img_dir, out_dir, class_list)四个坑分别是第一filename字段和实际图片文件名不一致有些数据集里 XML 写的文件名带路径前缀需要os.path.basename处理第二标注框坐标超出图片边界不裁剪会导致归一化后值大于 1训练时直接报错第三类别名不在class_list里直接跳过还是报错取决于你的策略我一般选择跳过并记录日志第四空标注文件——如果一张图没有任何目标YOLO 格式应该生成一个空 TXT 文件而不是不生成否则训练时图片和标签对不上。3.2 COCO 转 YOLO处理iscrowd和嵌套类别COCO 格式的转换逻辑不同核心是从annotations数组里按image_id分组再逐条转换。需要特别注意iscrowd字段——如果为 1表示该标注是密集区域而非单个目标YOLO 不支持这种语义通常直接跳过。import json import os from PIL import Image def coco_to_yolo(json_path, img_dir, output_dir): 将COCO JSON转换为YOLO TXT格式 with open(json_path, r) as f: data json.load(f) # 构建image_id到文件名的映射 img_info {img[id]: img for img in data[images]} # 构建category_id到连续索引的映射 cat_ids sorted([cat[id] for cat in data[categories]]) cat_id_to_idx {cid: idx for idx, cid in enumerate(cat_ids)} # 按image_id分组标注 from collections import defaultdict img_anns defaultdict(list) for ann in data[annotations]: if ann.get(iscrowd, 0) 1: continue # 跳过密集区域标注 img_anns[ann[image_id]].append(ann) os.makedirs(output_dir, exist_okTrue) for img_id, anns in img_anns.items(): info img_info[img_id] img_w, img_h info[width], info[height] filename info[file_name] lines [] for ann in anns: # COCO bbox格式: [x, y, width, height] 左上角绝对坐标 x, y, w, h ann[bbox] if w 0 or h 0: continue x_center (x w / 2) / img_w y_center (y h / 2) / img_h norm_w w / img_w norm_h h / img_h cls_idx cat_id_to_idx[ann[category_id]] lines.append(f{cls_idx} {x_center:.6f} {y_center:.6f} {norm_w:.6f} {norm_h:.6f}) txt_name os.path.splitext(filename)[0] .txt with open(os.path.join(output_dir, txt_name), w) as f: f.write(\n.join(lines)) coco_to_yolo(./forest_pest_data/annotations.json, ./forest_pest_data/images, ./forest_pest_data/labels)关键参数说明cat_id_to_idx把 COCO 的原始类别 ID 映射为从 0 开始的连续索引因为 YOLO 要求类别索引必须连续。如果直接拿 COCO 的category_id当 YOLO 的cls_id遇到 ID 不连续的情况比如 1, 3, 7, 25训练时会出现索引越界或者类别错乱。3.3 生成训练所需的data.yaml和目录划分转换完标注后需要按 YOLO 要求的目录结构组织数据并生成配置文件。# 创建YOLO标准目录结构 mkdir -p ./yolo_dataset/images/train ./yolo_dataset/images/val mkdir -p ./yolo_dataset/labels/train ./yolo_dataset/labels/val # 按8:2划分训练集和验证集示例实际用脚本按随机种子划分 python3 -c import os, random, shutil random.seed(42) img_dir ./forest_pest_data/images label_dir ./forest_pest_data/labels imgs [f for f in os.listdir(img_dir) if f.endswith((.jpg,.png))] random.shuffle(imgs) split int(len(imgs) * 0.8) for i, img in enumerate(imgs): subset train if i split else val shutil.copy(os.path.join(img_dir, img), f./yolo_dataset/images/{subset}/{img}) label os.path.splitext(img)[0] .txt if os.path.exists(os.path.join(label_dir, label)): shutil.copy(os.path.join(label_dir, label), f./yolo_dataset/labels/{subset}/{label}) data.yaml内容如下path: ./yolo_dataset train: images/train val: images/val nc: 5 names: 0: 松墨天牛 1: 美国白蛾 2: 松材线虫 3: 天牛幼虫 4: 其他nc必须和names的条目数一致names的键必须从 0 开始连续。如果转换时类别索引有跳跃这里就会对不上。划分数据集时固定随机种子保证每次实验可复现。4. 训练参数怎么设小目标密集场景的调参经验4.1 输入分辨率为什么 640 不够用森林害虫在图像中往往只占几十个像素YOLO 默认的 640×640 输入经过多次下采样后小目标特征几乎消失。我的血泪经验是如果标注框平均面积小于 32×32 像素输入分辨率至少拉到 1024显存不够就换小 batch 或者用梯度累积。# YOLOv8训练命令示例输入分辨率1280 yolo detect train \ data./data.yaml \ modelyolov8m.pt \ imgsz1280 \ epochs200 \ batch8 \ patience30 \ lr00.001 \ lrf0.01 \ mosaic1.0 \ scale0.5 \ degrees15.0 \ fliplr0.5 \ hsv_h0.015 \ hsv_s0.7 \ hsv_v0.4 \ device0参数逐个说明imgsz1280提升小目标召回batch8配合大分辨率控制显存patience30早停防止过拟合mosaic1.0马赛克增强对小目标友好scale0.5随机缩放增强尺度不变性degrees15.0旋转增强模拟不同拍摄角度hsv_h/s/v颜色抖动应对林区光照变化。如果显存爆了优先降batch而不是降imgsz小目标检测分辨率是底线。4.2 锚框聚类用你的数据重新算一遍YOLOv5/v7 需要锚框YOLOv8 虽然是无锚框设计但理解锚框聚类对分析数据分布仍有价值。用 K-means 对标注框的宽高做聚类看看默认锚框和你的数据差多远。import numpy as np from sklearn.cluster import KMeans def cluster_anchors(label_dir, n_clusters9): 对YOLO格式标注框做K-means聚类输出建议锚框 boxes [] for txt_file in os.listdir(label_dir): if not txt_file.endswith(.txt): continue with open(os.path.join(label_dir, txt_file)) as f: for line in f: parts line.strip().split() if len(parts) 5: w, h float(parts[3]), float(parts[4]) boxes.append([w, h]) boxes np.array(boxes) kmeans KMeans(n_clustersn_clusters, random_state42).fit(boxes) anchors kmeans.cluster_centers_ # 按面积排序 anchors anchors[np.argsort(anchors[:, 0] * anchors[:, 1])] return anchors anchors cluster_anchors(./yolo_dataset/labels/train) for i, (w, h) in enumerate(anchors): print(fAnchor {i1}: width{w:.4f}, height{h:.4f})输出结果如果和默认锚框差异超过 20%说明数据分布特殊建议在 YOLOv5 配置里替换锚框。YOLOv8 用户不需要改锚框但可以用这个结果判断数据里目标尺度分布——如果聚类出的锚框普遍偏小进一步验证了需要高分辨率输入。4.3 类别不平衡处理加权损失与过采样前面统计出的长尾类别在训练时需要通过损失加权来补偿。YOLOv8 不直接支持类别权重参数但可以通过复制少数类样本实现过采样。import os import shutil def oversample_minority(label_dir, img_dir, target_count500): 对标注数量少于target_count的类别进行过采样 from collections import Counter cls_counter Counter() file_cls_map {} for txt_file in os.listdir(label_dir): if not txt_file.endswith(.txt): continue with open(os.path.join(label_dir, txt_file)) as f: classes [line.split()[0] for line in f if line.strip()] file_cls_map[txt_file] classes for c in classes: cls_counter[c] 1 for cls_id, count in cls_counter.items(): if count target_count: continue # 找到包含该类别的文件 files_with_cls [f for f, cls_list in file_cls_map.items() if cls_id in cls_list] need target_count - count for i in range(need): src files_with_cls[i % len(files_with_cls)] base os.path.splitext(src)[0] # 复制标注和图片加后缀避免覆盖 shutil.copy(os.path.join(label_dir, src), os.path.join(label_dir, f{base}_os{i}.txt)) for ext in [.jpg, .png]: img_src os.path.join(img_dir, base ext) if os.path.exists(img_src): shutil.copy(img_src, os.path.join(img_dir, f{base}_os{i}{ext})) break oversample_minority(./yolo_dataset/labels/train, ./yolo_dataset/images/train)这段代码对每个少数类复制包含该类别的图片和标注直到达到目标数量。注意复制后的文件名加了_os后缀避免和原文件冲突。过采样比例控制在 3 倍以内过度复制会导致过拟合。5. 避坑与排查森林害虫检测训练中最容易翻车的五个地方5.1 现象训练 loss 正常下降但 mAP 始终为 0原因类别索引和data.yaml里的names顺序不一致。转换脚本里class_list的顺序是[松墨天牛, 美国白蛾, ...]但data.yaml里写成了0: 美国白蛾, 1: 松墨天牛模型学到的类别和验证时对不上。解决写一个校验脚本从标注 TXT 里提取所有出现过的类别索引和data.yaml的names键做对比。import yaml def validate_class_mapping(label_dir, yaml_path): with open(yaml_path) as f: cfg yaml.safe_load(f) yaml_ids set(cfg[names].keys()) used_ids set() for txt_file in os.listdir(label_dir): if txt_file.endswith(.txt): with open(os.path.join(label_dir, txt_file)) as f: for line in f: if line.strip(): used_ids.add(int(line.split()[0])) missing used_ids - yaml_ids unused yaml_ids - used_ids if missing: print(f标注中存在但yaml未定义的类别id: {missing}) if unused: print(fyaml中定义但标注未使用的类别id: {unused}) if not missing and not unused: print(类别映射一致) validate_class_mapping(./yolo_dataset/labels/train, ./data.yaml)5.2 现象验证集 mAP 比训练集低 30 个点以上原因训练集和验证集划分时没有按类别分层抽样导致验证集里某些类别完全缺失或者验证集图片和训练集图片来自同一拍摄批次分布差异大。解决用sklearn.model_selection.StratifiedKFold按类别分层划分或者手动保证每个类别在验证集中至少有 10 个实例。如果数据集本身按拍摄地点分文件夹按文件夹划分比随机划分更合理。5.3 现象模型对某些类别完全无检测置信度全部低于 0.1原因该类别标注框数量太少低于总框数的 0.5%损失函数被高频类别主导。即使做了过采样如果复制时没有同步复制对应的负样本背景图模型仍然学不到判别特征。解决除了过采样还要检查该类别标注框的宽高分布。如果宽高比极端比如细长条默认锚框匹配度低需要单独调整锚框或者用 YOLOv8 的无锚框模式。另外把该类别的最小置信度阈值从 0.25 降到 0.05 看看有没有候选框如果有但置信度低说明模型学到了但不够自信继续训练或者增加该类别样本。5.4 现象训练到 50 epoch 后 mAP 突然暴跌原因学习率调度不当。lrf0.01表示最终学习率是初始学习率的 1%如果lr00.01最终学习率是 0.0001在 200 epoch 的余弦调度下50 epoch 时学习率还比较高模型在最优解附近震荡。解决把lr0降到 0.001或者改用cos_lrTrue配合warmup_epochs5。如果已经训练了一半用resume从最佳权重继续调低学习率再跑 50 epoch。5.5 现象推理时同一张图多次运行结果不一致原因推理时没有设置model.eval()或者没有固定随机种子。YOLO 推理阶段虽然默认关闭数据增强但如果用了 TTA测试时增强每次推理的增强方式不同会导致结果波动。解决推理脚本里加model.eval()和torch.manual_seed(42)关闭 TTA。如果确实需要 TTA 提升精度接受结果波动但要用多次推理的平均值作为最终输出。6. 进阶技巧用切片推理把大图小目标检测拉满森林害虫数据集里的图片往往是高分辨率林区全景图直接缩放到 1280 输入小目标仍然只有几个像素。切片推理SAHISlicing Aided Hyper Inference的思路是把大图切成重叠的小块每块单独推理后再把结果合并回原图坐标。这个技巧在遥感目标检测和病理切片分析里已经很成熟搬到森林害虫检测上效果同样明显。from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction import cv2 # 加载YOLO模型 detection_model AutoDetectionModel.from_pretrained( model_typeyolov8, model_path./runs/detect/train/weights/best.pt, confidence_threshold0.3, devicecuda:0 ) # 切片推理切片大小1024重叠比例0.2 result get_sliced_prediction( ./test_image.jpg, detection_model, slice_height1024, slice_width1024, overlap_height_ratio0.2, overlap_width_ratio0.2, perform_standard_predTrue, # 同时跑一次全图推理保留大目标 postprocess_typeNMS, postprocess_match_threshold0.5 ) # 导出可视化结果 result.export_visuals(export_dir./sahi_output/)参数说明slice_height和slice_width根据图片分辨率和目标尺寸调整一般设为输入分辨率的 1 到 2 倍overlap_height_ratio和overlap_width_ratio控制切片重叠0.2 到 0.3 之间比较平衡太低会漏掉切片边界的目标太高会增加推理时间perform_standard_predTrue保证大目标不被切片切碎后漏检postprocess_match_threshold0.5是 NMS 的 IoU 阈值切片重叠区域会产生重复检测靠 NMS 合并。实测对比同一张 4000×3000 的林区图直接缩放到 1280 推理小目标召回率约 45%用 1024 切片加 0.2 重叠推理召回率提升到 78%但推理时间从 0.3 秒增加到 2.1 秒。如果部署环境对延迟不敏感比如无人机巡检后离线处理切片推理是提升小目标检测最直接的手段。我自己的习惯是先用标准推理跑一遍验证集记录每个类别的 mAP 和小目标召回率如果小目标召回率低于 60%就上切片推理再跑一遍对比。切片推理的代码封装成函数后切换成本很低但效果提升往往比调参明显得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进