NWPU VHR-10遥感数据集YOLO实战:从标注转换到训练避坑
简介本资源为面向计算机、电子信息工程等专业学生及算法初学者的遥感目标检测数据集基于NWPU VHR-10构建可直接用于YOLO系列模型的训练与课程设计、期末大作业、毕业设计等场景。压缩包共1600个文件包含800张jpg遥感图像与800个一一对应的xml标注文件标注覆盖飞机、轮船、储罐、棒球场、网球场、篮球场、地面跑道、港口、桥梁和车辆共10个类别整体约73.71MB图像与标注配对完整省去自行标注与格式转换的繁琐流程。目前已有1395人学习下载适合需要快速验证检测算法、调试数据加载流程或对比模型性能的读者。配套代码采用参数化编程参数修改方便思路清晰且注释详细便于理解YOLO训练与推理的完整链路也可作为多类别遥感检测实验的基准数据使用。1. 遥感目标检测落地为什么我盯着这份 NWPU VHR-10 标注包看了半天遥感图像目标检测跟常规自然图像检测完全不是一回事。同一张 800×800 的航拍图里飞机可能只占 30 个像素储罐却横跨半个画面尺度差异大到让默认 anchor 直接失效。我最近在做一个港口与飞机混检的小项目手头缺一份干净、类别齐全、标注格式统一的遥感数据集翻了不少公开资源要么类别残缺要么 xml 里 bbox 越界要么图像和标注对不上号。直到拆开这份 NWPU VHR-10 的 YOLO 配套包——800 张图像、对应已标注 xml、10 个类别飞机、轮船、储罐、棒球场、网球场、篮球场、地面跑道、港口、桥梁、车辆文件名从 000083 到 000329 这种连续编号第一反应是终于能直接喂进训练脚本了。它适合谁做课程设计、期末大作业、毕业设计的学生以及想快速验证遥感检测 pipeline 的工程师。下面我按自己复现的路径把这份资源从结构、转换、训练到踩坑完整走一遍。2. 拆包先看结构800 张图像与 xml 的对应关系怎么核拿到一个 .rar 资源包最忌讳的就是解压完直接train.py一把梭。遥感数据集的坑往往藏在文件名、类别分布和标注完整性里先花十分钟做结构核查能省掉后面几小时的报错排查。2.1 目录组织与文件命名规律解压后典型结构是这样的不同打包方式可能略有差异但核心就这几层NWPU_VHR-10/ ├── positive_image_set/ # 800 张正样本图像 │ ├── 000083.jpg │ ├── 000084.jpg │ ├── 000085.jpg │ └── ... ├── ground_truth/ # 对应的 xml 标注 │ ├── 000083.xml │ ├── 000084.xml │ └── ... └── negative_image_set/ # 负样本无目标部分版本含图像和 xml 靠同名 stem对应这是最稳的关联方式。项目正文里列出的 000084.jpg、000311.jpg、000312.jpg、000115.jpg、000085.jpg、000313.jpg、000314.jpg、000329.jpg、000083.jpg、000089.jpg 就是这批文件的抽样编号不连续是正常的——NWPU VHR-10 原始集就是按采集批次编号中间有跳号。核查对应关系用一段脚本最快import os img_dir NWPU_VHR-10/positive_image_set xml_dir NWPU_VHR-10/ground_truth imgs {os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.endswith(.jpg)} xmls {os.path.splitext(f)[0] for f in os.listdir(xml_dir) if f.endswith(.xml)} print(图像数:, len(imgs)) print(标注数:, len(xmls)) print(有图无标注:, sorted(imgs - xmls)[:10]) print(有标注无图:, sorted(xmls - imgs)[:10])逻辑说明用集合差集找出孤儿文件。参数上img_dir和xml_dir按你实际解压路径改。如果有图无标注不为空说明这批图是负样本或标注缺失训练前必须决定是丢弃还是当背景图用。我一般会把它们单独挪到background/目录避免训练时读不到 label 直接崩。2.2 十类目标的分布统计与长尾问题NWPU VHR-10 的 10 个类别里飞机、储罐、车辆这类小目标数量多港口、地面跑道、桥梁这类大目标数量少天然长尾。先统计再决定要不要做重采样import xml.etree.ElementTree as ET from collections import Counter xml_dir NWPU_VHR-10/ground_truth counter Counter() for f in os.listdir(xml_dir): if not f.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, f)) for obj in tree.findall(object): counter[obj.find(name).text.strip()] 1 for cls, n in counter.most_common(): print(f{cls:12s} {n})逻辑说明遍历每个 xml 的object节点累加类别名。参数上注意name.text.strip()——有些 xml 里类别名带空格或换行不 strip 会导致同一类被拆成两个 key。跑完你会看到类似 airplane 700、vehicle 300、harbor 几十的分布。如果某类少于 50 个实例训练时建议开类别权重或做简单过采样否则模型对这类基本视而不见。提示统计完先别急着改数据把分布记下来后面评估时按类别看 mAP长尾类的掉点往往就是这里埋的。3. xml 转 YOLO txt坐标归一化与四个边界坑YOLO 系列v5/v8/v11 都吃这套训练要的是每张图一个.txt每行class_id cx cy w h全部归一化到 0~1。xml 里给的是绝对像素坐标xmin ymin xmax ymax转换本身不难难的是边界处理。3.1 转换脚本与类别映射表先定类别顺序这个顺序一旦定了就不能改否则训练和推理的 class_id 对不上import os import xml.etree.ElementTree as ET from PIL import Image CLASSES [airplane, ship, storage_tank, baseball_diamond, tennis_court, basketball_court, ground_track, harbor, bridge, vehicle] cls2id {c: i for i, c in enumerate(CLASSES)} xml_dir NWPU_VHR-10/ground_truth img_dir NWPU_VHR-10/positive_image_set out_dir labels os.makedirs(out_dir, exist_okTrue) for f in os.listdir(xml_dir): if not f.endswith(.xml): continue stem os.path.splitext(f)[0] img_path os.path.join(img_dir, stem .jpg) if not os.path.exists(img_path): continue w, h Image.open(img_path).size tree ET.parse(os.path.join(xml_dir, f)) lines [] for obj in tree.findall(object): name obj.find(name).text.strip() if name not in cls2id: continue bb obj.find(bndbox) xmin float(bb.find(xmin).text) ymin float(bb.find(ymin).text) xmax float(bb.find(xmax).text) ymax float(bb.find(ymax).text) # 边界裁剪防止越界 xmin, xmax max(0, xmin), min(w, xmax) ymin, ymax max(0, ymin), min(h, ymax) if xmax xmin or ymax ymin: continue cx (xmin xmax) / 2 / w cy (ymin ymax) / 2 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{cls2id[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(os.path.join(out_dir, stem .txt), w) as fp: fp.write(\n.join(lines))逻辑说明CLASSES顺序就是最终模型输出的类别索引务必和训练配置里的names一致。Image.open拿真实宽高做归一化不能用固定值。坐标裁剪那两行是关键——遥感 xml 里偶尔出现 xmax 超过图像宽度的情况不裁会得到大于 1 的归一化值YOLO 训练时直接报 assert 错误。if xmax xmin过滤掉退化框。参数上.6f保留六位小数足够YOLO 内部会再处理。3.2 四个必须处理的边界坑坑一坐标越界。现象是训练启动即报AssertionError: normalized coordinates out of range。原因是 xml 标注时手抖或工具问题xmax 写成了图像宽度 1。解决就是上面脚本里的min(w, xmax)裁剪。坑二类别名大小写与空格。现象是统计时发现Airplane和airplane两个类。原因是不同批次标注工具不一致。解决是在cls2id查找前统一.lower().strip()或者建一个别名映射表。坑三宽高为 0 的退化框。现象是训练 loss 出现 NaN。原因是 xminxmax。解决是转换时直接continue跳过别硬塞进去。坑四图像与标注尺寸不一致。现象是框整体偏移。原因是 xml 里可能记录了原始尺寸但 jpg 被压缩过。解决是永远以Image.open读到的实际尺寸为准别信 xml 里的size节点。注意转换完抽 5 张图用可视化脚本画框核对一遍比看日志靠谱得多。我一般会随机抽 000083、000115、000311 这几张肉眼确认框贴合目标。4. 训练配置与参数从 data.yaml 到置信度门限数据转好了接下来是把它喂进 YOLO。这里以 YOLOv8 的目录约定为例v5/v11 大同小异重点讲参数怎么设、为什么这么设。4.1 数据集划分与 data.yamlYOLO 要求images/和labels/平行train/val 分开dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml划分脚本8:2import os, random, shutil random.seed(42) src_img NWPU_VHR-10/positive_image_set src_lbl labels stems [os.path.splitext(f)[0] for f in os.listdir(src_lbl) if f.endswith(.txt)] random.shuffle(stems) split int(len(stems) * 0.8) for i, stem in enumerate(stems): phase train if i split else val shutil.copy(f{src_img}/{stem}.jpg, fdataset/images/{phase}/{stem}.jpg) shutil.copy(f{src_lbl}/{stem}.txt, fdataset/labels/{phase}/{stem}.txt)逻辑说明random.seed(42)保证划分可复现换机器结果一致。参数0.8是训练占比遥感数据量小的时候可以调到 0.85但验证集别少于 100 张否则 mAP 波动大。data.yaml内容path: ./dataset train: images/train val: images/val nc: 10 names: [airplane, ship, storage_tank, baseball_diamond, tennis_court, basketball_court, ground_track, harbor, bridge, vehicle]nc必须等于 10names顺序必须和转换脚本里的CLASSES完全一致错一个位置整个模型就废了。4.2 关键训练参数与置信度门限调整启动训练yolo detect train datadataset/data.yaml modelyolov8s.pt epochs100 imgsz640 batch16 patience20参数逐个说modelyolov8s.pt选 s 而不是 n是因为遥感小目标多n 的感受野和通道数不够容易漏检飞机和车辆显存够就上 m。imgsz640是平衡速度和精度的常用值如果小目标特别密可以提到 800 甚至 1024但 batch 要相应降到 8。patience20是早停遥感数据容易过拟合20 轮没提升就停省时间。batch16按显存调8G 显存跑 640 大概能到 16。推理时置信度门限是遥感检测的玄学点yolo detect predict modelruns/detect/train/weights/best.pt sourcetest.jpg conf0.25 iou0.5conf0.25是默认值但遥感场景我一般先设 0.1 看召回再往上调。因为小目标置信度天然偏低门限设 0.5 会漏掉一大片飞机。iou0.5控制 NMS 重叠阈值密集排列的储罐和车辆如果被合并就把它调到 0.6~0.7。提示调门限别凭感觉写个脚本遍历 conf 从 0.05 到 0.5画 P-R 曲线找 F1 最高的点比拍脑袋强。5. 避坑与排查遥感训练里最费时间的五件事这一章是我踩过的血泪经验每条按现象、原因、解决写照着排查能少走弯路。现象一训练 loss 正常下降但验证 mAP 一直是 0。原因大概率是data.yaml里names顺序和 label 里的 class_id 对不上或者 val 路径写错导致读到空标注。解决先跑yolo detect val看能否读到验证集再抽查一个 val 的 txt 和对应图确认 class_id 在 0~9 范围内。现象二小目标飞机、车辆几乎全漏检。原因是 imgsz 太小下采样后小目标特征被抹掉。解决把imgsz提到 1024或者改用带 P2 检测头的模型配置YOLOv8 支持加 P2 层P2 专门抓小目标。代价是显存和推理时间上升。现象三同一目标被框出好几个重叠框。原因是 NMS 的 iou 阈值太低密集目标没被正确抑制。解决推理时iou0.6起步密集场景到 0.7。但别调太高否则相邻的储罐会被误合并成一个。现象四训练到一半 loss 突然变 NaN。原因通常是学习率过大或数据里有退化框宽高为 0。解决先按第 3 章的脚本过滤退化框再把初始 lr 从默认 0.01 降到 0.001加warmup_epochs3。现象五换了台机器同样的代码 mAP 差好几个点。原因是随机种子、CUDA 版本、甚至图像解码库版本不同。解决固定seed42记录环境版本验证集划分用脚本固化而不是每次随机。遥感数据量小划分一变结果就飘。注意排查顺序永远是先看数据、再看配置、最后怀疑模型。我见过太多人一上来就换模型结构结果发现是 label 路径写错了。6. 进阶技巧用类别权重和切片推理把长尾类拉回来基础流程跑通后真正决定这份数据集能不能出好结果的是长尾类和超大图这两个问题。NWPU VHR-10 里 harbor、ground_track、bridge 这三类实例数少默认训练下 mAP 经常只有 0.3 出头而 airplane 能到 0.9。我的做法是两步训练时给长尾类加权推理时对超大图做切片。类别权重可以在损失里手动加YOLOv8 支持通过自定义 dataset 或改loss.py实现简单点用cls_pwclass positive weight思路——按类别频率的倒数开方作为权重import numpy as np freq np.array([700, 400, 300, 200, 150, 120, 80, 60, 50, 300], dtypefloat) weights (1.0 / freq) ** 0.5 weights weights / weights.mean() print(dict(zip(CLASSES, weights.round(2))))逻辑说明freq换成你第 2 章统计出的真实实例数。开方是为了不让权重过于极端否则稀有类会主导梯度反而把常见类拉垮。算出来的权重在自定义 loss 里乘到分类损失上。这一步不是必须但长尾类掉点严重时值得试。切片推理针对的是遥感图分辨率高、目标小的情况。把大图切成带重叠的小块分别推理再合并from PIL import Image def slice_image(path, size640, overlap128): img Image.open(path) w, h img.size step size - overlap tiles [] for y in range(0, h, step): for x in range(0, w, step): box (x, y, min(x size, w), min(y size, h)) tiles.append((box, img.crop(box))) return tiles逻辑说明size640和训练输入一致overlap128保证跨切片的目标准确拼接。切完逐块推理再把框按偏移量映射回原图坐标最后统一做一次 NMS。代价是推理时间翻几倍但小目标召回能明显提升。我一般只在最终出结果时用训练阶段不用。从那以后我每次拿到新的遥感数据集都强制先跑一遍结构核查和类别统计再动训练脚本——这个习惯帮我省下的返工时间比任何模型改进都值。希望帮到你。本文还有配套的精品资源点击获取