简介:飞机型号识别数据集(04)是一份面向目标检测与细粒度图像分类研究者的可见光遥感数据集,采集自俄罗斯机场,覆盖苏霍伊、米格、安东诺夫、伊尔、雅克、图波列夫等47种军民机型,适合军机识别、飞机检测等算法训练与验证。资源包共2001个文件,含1000张1024×768的RGB图像、1000个labelimg标注的xml标签及1个说明txt,压缩包约250.43MB,图像与标签一一对应,可直接接入YOLO、Faster R-CNN等主流检测框架。目前已有218人学习下载。该批次与02、03、05等批次采集地点和机型种类均不相同,可组合使用以扩充数据多样性,帮助研究者快速搭建实验基线、验证模型泛化能力并排查标注与训练问题。
1. 从一批俄罗斯机场的军机照片说起:这个数据集到底能干什么
如果你正在做飞机型号识别或者军机识别相关的目标检测项目,大概率会遇到一个很现实的问题:公开的 COCO、VOC 里飞机只有一个笼统的airplane类,根本分不出苏-27 和米格-29。想自己从零标注,光是查机型图鉴就够喝一壶。这个飞机型号识别数据集(04)解决的就是这件事——1000 张采集自俄罗斯机场的可见光 RGB 图像,1024×768 分辨率,47 种军民飞机细分类别,覆盖苏霍伊、米格、安东诺夫、伊尔、雅克、图波列夫等系列,标签用 labelimg 标注成标准 xml 格式。它适合做细粒度分类、目标检测微调、小样本迁移学习的人,也适合想验证自己检测框架在细分类场景下到底行不行的人。下面我按实际拆包、转格式、训练、排错的顺序,把这份资源怎么落地讲清楚。
2. 拆开压缩包先看什么:目录结构、类别分布与标签格式核对
拿到数据集别急着写 dataloader,先把目录结构和标签质量摸一遍。这一步花二十分钟,能省后面几小时的 debug。
2.1 目录组织与文件命名规律
从项目正文给出的文件名看,图片命名是RUS-04-XXXX.jpg这种格式,RUS代表采集地俄罗斯,04是批次号,后面四位是序号。常见做法是图片和 xml 放在同一级目录,或者images/与annotations/分开。我一般先跑一段脚本统计文件配对情况,确认没有孤儿图片或孤儿标签。
import os from pathlib import Path img_dir = Path("RUS-04/images") xml_dir = Path("RUS-04/annotations") imgs = {p.stem for p in img_dir.glob("*.jpg")} xmls = {p.stem for p in xml_dir.glob("*.xml")} print("图片数:", len(imgs)) print("标签数:", len(xmls)) print("有图无标签:", sorted(imgs - xmls)[:10]) print("有标签无图:", sorted(xmls - imgs)[:10])这段脚本做的是集合差运算。imgs - xmls就是有图没标签的,反过来是有标签没图的。参数上注意glob的大小写,有些批次图片后缀可能是.JPG,那就得改成glob("*.[jJ][pP][gG]")或者统一转小写。如果差集不为空,先别训练,要么补标要么剔除,否则 dataloader 会在某个 epoch 突然报KeyError。
2.2 解析 xml 统计 47 类的真实分布
xml 是 Pascal VOC 格式,核心字段是filename、size、object下的name和bndbox。47 类听着美好,但实际分布大概率是长尾的——苏-27、米格-29 这种常见机型样本多,安-124 或者某些雅克改型可能只有个位数。先统计再决定要不要做类别合并或重采样。
import xml.etree.ElementTree as ET from collections import Counter counter = Counter() box_sizes = [] for xml_path in xml_dir.glob("*.xml"): tree = ET.parse(xml_path) root = tree.getroot() for obj in root.findall("object"): name = obj.find("name").text.strip() counter[name] += 1 bbox = obj.find("bndbox") w = float(bbox.find("xmax").text) - float(bbox.find("xmin").text) h = float(bbox.find("ymax").text) - float(bbox.find("ymin").text) box_sizes.append((w, h)) print("类别总数:", len(counter)) for name, cnt in counter.most_common(): print(f"{name}: {cnt}") import numpy as np ws = np.array([b[0] for b in box_sizes]) hs = np.array([b[1] for b in box_sizes]) print("宽 min/median/max:", ws.min(), np.median(ws), ws.max()) print("高 min/median/max:", hs.min(), np.median(hs), hs.max())逻辑说明:遍历所有 xml,用Counter累计每个name出现次数,同时把每个 bbox 的宽高收集起来。参数上,xmax - xmin得到的是像素宽,1024×768 的图里如果中位数宽只有几十像素,那说明大量目标偏小,后面训练要考虑小目标检测的策略,比如提高输入分辨率或者用 FPN 多尺度。如果发现某个类别只有 1 到 2 个样本,直接训练基本学不动,常见做法是合并到父类或者先做数据增强扩充。
2.3 标签格式核对:VOC xml 转 YOLO txt 的边界处理
现在主流检测框架里 YOLO 系用 txt,每行class_id cx cy w h且全部归一化到 0 到 1。转格式本身不难,坑在边界裁剪和类别映射。
import xml.etree.ElementTree as ET from pathlib import Path classes = sorted(counter.keys()) # 沿用上一步统计结果 cls2id = {c: i for i, c in enumerate(classes)} out_dir = Path("RUS-04/labels") out_dir.mkdir(exist_ok=True) for xml_path in xml_dir.glob("*.xml"): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") W = int(size.find("width").text) H = int(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() bbox = obj.find("bndbox") xmin = max(0, float(bbox.find("xmin").text)) ymin = max(0, float(bbox.find("ymin").text)) xmax = min(W, float(bbox.find("xmax").text)) ymax = min(H, float(bbox.find("ymax").text)) 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}") (out_dir / (xml_path.stem + ".txt")).write_text("\n".join(lines))关键点有三个:一是max(0, ...)和min(W, ...)做边界裁剪,标注时手抖画出图外的框很常见,不裁的话归一化后会出现负数或大于 1 的值,训练时 loss 直接 NaN;二是退化框判断,xmax <= xmin说明框宽为零或负,必须跳过;三是类别映射用sorted保证每次运行 id 一致,否则训练和推理的类别对不上,这个坑我踩过,模型输出全是乱的。归一化保留六位小数足够,YOLO 官方也是这个精度。
3. 用这份数据训练检测模型:从配置到跑通第一个 epoch
数据摸清楚了,接下来落到训练。这里以 YOLO 系为例,因为它的数据配置最直观,换成 MMDetection 或 Detectron2 思路一样,只是配置文件写法不同。
3.1 数据集划分与 data.yaml 配置
1000 张图不算多,按 8:1:1 划分训练、验证、测试比较稳妥。划分时要注意同一架飞机的多张照片尽量别跨集,否则验证集精度会虚高。常见做法是按文件名前缀或者拍摄时段分组划分,但这份数据文件名只有序号,那就随机划分并固定随机种子。
import random from pathlib import Path import shutil random.seed(42) stems = sorted(p.stem for p in Path("RUS-04/images").glob("*.jpg")) random.shuffle(stems) n = len(stems) train = stems[:int(n*0.8)] val = stems[int(n*0.8):int(n*0.9)] test = stems[int(n*0.9):] for split, items in [("train", train), ("val", val), ("test", test)]: for sub in ["images", "labels"]: (Path("dataset") / split / sub).mkdir(parents=True, exist_ok=True) for s in items: shutil.copy(f"RUS-04/images/{s}.jpg", f"dataset/{split}/images/{s}.jpg") shutil.copy(f"RUS-04/labels/{s}.txt", f"dataset/{split}/labels/{s}.txt")random.seed(42)保证每次划分一致,方便复现。shutil.copy是物理复制,如果磁盘紧张可以用软链接。划分完检查一下每个 split 的类别分布,如果验证集里某类一个样本都没有,那这个类在验证指标上就是空的,评估时要注意。
data.yaml 写法:
path: ./dataset train: train/images val: val/images test: test/images nc: 47 names: ['An-124', 'An-24', 'Il-76', ...] # 按 cls2id 顺序填全nc必须和 names 长度一致,names 顺序必须和转格式时的cls2id完全一致。我见过有人手写 names 时漏了一个类,训练不报错但推理时类别全错位,这种属于血泪经验。
3.2 训练参数怎么设:小目标与长尾类的取舍
1024×768 的图,如果直接 resize 到 640 训练,小目标会缩得更小。建议imgsz设 1024 或者至少 896,batch 根据显存调,8G 显存跑 1024 大概 batch=4 到 8。学习率用默认的 0.01 配 SGD,或者 AdamW 配 1e-3。长尾类的问题,可以在 loss 里加类别权重,或者对稀有类做复制增强。
yolo detect train \ data=data.yaml \ model=yolov8s.pt \ imgsz=1024 \ epochs=100 \ batch=8 \ lr0=0.01 \ lrf=0.01 \ warmup_epochs=3 \ cos_lr=True \ close_mosaic=10 \ patience=20 \ device=0参数说明:imgsz=1024保留原始分辨率信息,对小目标友好;close_mosaic=10表示最后 10 个 epoch 关闭 mosaic 增强,让模型在真实分布上收敛;patience=20是早停,验证指标 20 轮不升就停,防止过拟合;cos_lr=True余弦退火,比阶梯下降更平滑。如果显存不够,把imgsz降到 768,但小目标召回会掉,这个取舍要自己跑对比实验。
3.3 训练过程看什么指标:mAP50 与混淆矩阵
跑起来之后别只盯着 loss。检测任务看mAP50和mAP50-95,前者是 IoU 0.5 下的平均精度,后者是 0.5 到 0.95 多阈值平均,后者更能反映框的定位质量。如果 mAP50 高但 mAP50-95 低,说明分类对了但框不够准,可能是标注框松紧不一致。混淆矩阵能看出哪些类互相混,比如苏-27 和苏-30 外形接近,混是正常的,如果米格-29 混到苏-27 那就要查标注。
from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") metrics = model.val(data="data.yaml", imgsz=1024) print("mAP50:", metrics.box.map50) print("mAP50-95:", metrics.box.map) print("每类AP:", metrics.box.ap50)metrics.box.ap50是数组,顺序对应 names。找出 AP 最低的几个类,单独看它们的验证图,大概率是样本太少或者标注有问题。常见做法是把低 AP 类的样本抽出来重新检查标注,或者对这些类做针对性增强。
4. 避坑与排查:标注、格式、训练里最容易翻车的几件事
这一章全是实际踩过的坑,按现象、原因、解决三段写,遇到对应情况直接对号入座。
4.1 现象:训练 loss 变 NaN,几轮后中断
原因:xml 里有框坐标超出图像边界,归一化后出现负值或大于 1 的值,或者存在宽高为零的退化框。YOLO 在计算 CIoU loss 时对非法框没有兜底,直接产生 NaN。
解决:回到 2.3 的转换脚本,确认边界裁剪和退化框跳过都生效。转换完再跑一遍校验,检查所有 txt 里的数值是否都在 0 到 1 之间。
bad = [] for txt in Path("dataset").rglob("*.txt"): for line in txt.read_text().splitlines(): parts = line.split() vals = [float(v) for v in parts[1:]] if any(v < 0 or v > 1 for v in vals): bad.append((txt, line)) print("非法行数:", len(bad))4.2 现象:验证集 mAP 很高,但拿新图推理全是错类
原因:类别 id 映射不一致。转格式时用sorted得到一套顺序,训练时 data.yaml 里 names 手写又用了另一套顺序,模型学到的 id 和推理时解析的 id 对不上。
解决:把cls2id字典存成 json,data.yaml 的 names 直接从 json 生成,杜绝手写。推理后处理也用同一份 json 反查类别名。
4.3 现象:某些类别 AP 始终为 0
原因:该类别样本数极少,或者验证集里根本没有这个类。1000 张图分 47 类,平均每类 20 张左右,长尾类可能只有 1 到 2 张,划分后验证集里就没了。
解决:先统计每类在 train/val/test 的分布,对样本数少于 5 的类,要么合并到相近父类,要么在划分时保证每个 split 都有该类的样本。如果坚持保留,用过采样或 copy-paste 增强把样本数补到 10 以上再训。
4.4 现象:训练速度极慢,GPU 利用率低
原因:imgsz=1024加上 dataloader 的workers设太小,CPU 预处理跟不上 GPU。或者图片没做缓存,每个 epoch 都从磁盘读原图解码。
解决:把workers设成 CPU 核数的 0.7 倍左右,比如 8 核设 6。开启cache=True把图片缓存到内存,1000 张 1024×768 的图大概占 2 到 3G 内存,一般机器扛得住。如果还慢,检查是不是在做在线增强时用了太重的变换。
4.5 现象:同一张图里多个目标,只检测出一个
原因:NMS 的 IoU 阈值设太高,两个相邻飞机的框被互相抑制。或者标注时两个目标框重叠严重,模型学到的特征纠缠。
解决:推理时把 NMS 的 IoU 阈值从默认 0.7 降到 0.5 到 0.6 试试。如果是标注重叠问题,回看原图确认是不是真的两个独立目标,必要时重新标。另外 1024 分辨率下小目标密集时,可以开agnostic_nms或者调max_det。
5. 进阶玩法:用这份数据做迁移与细粒度验证的几个技巧
数据跑通之后,如果想再压榨一点价值,可以试试下面几个方向。第一个是跨批次迁移:这份 04 批采集自俄罗斯机场,02、03、05 批采集地不同,机型分布也有差异。拿 04 批预训练,在另一批上微调,能验证模型对采集域变化的鲁棒性。具体做法是冻结 backbone 前几层,只训 head 和后面几层,学习率调小到 1e-4。
yolo detect train \ data=data_05.yaml \ model=runs/detect/train/weights/best.pt \ imgsz=1024 \ epochs=50 \ batch=8 \ lr0=0.0001 \ freeze=10 \ device=0freeze=10表示冻结前 10 层,适合小数据微调,防止过拟合。如果目标域差异大,可以只冻结前 5 层。
第二个技巧是细粒度验证。47 类里苏霍伊系列内部差异很小,可以单独抽出一个二分类任务,比如苏-27 vs 苏-30,看模型在极相似类上的表现。做法是把这两类的框裁出来,训练一个分类器,用混淆矩阵看误判率。如果误判率高,说明检测模型提取的特征对细粒度不够,可以考虑换更大的 backbone 或者加注意力模块。
第三个是标注质量抽检。随机抽 50 张图,把模型预测框和原始 xml 框画在一起对比,看有没有漏标、错标。我一般用下面这段脚本生成对比图,肉眼过一遍。
import cv2 import xml.etree.ElementTree as ET from pathlib import Path import random random.seed(0) samples = random.sample(list(Path("RUS-04/images").glob("*.jpg")), 50) for img_path in samples: img = cv2.imread(str(img_path)) xml_path = Path("RUS-04/annotations") / (img_path.stem + ".xml") tree = ET.parse(xml_path) for obj in tree.getroot().findall("object"): b = obj.find("bndbox") x1, y1 = int(b.find("xmin").text), int(b.find("ymin").text) x2, y2 = int(b.find("xmax").text), int(b.find("ymax").text) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, obj.find("name").text, (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite(f"check/{img_path.name}", img)跑完打开check/目录翻一遍,重点看框是不是把机翼、尾翼都包进去了,有没有把地面车辆误标成飞机。抽检发现的问题如果超过 5%,建议全量复查,否则模型学到的就是错的。
从那以后我每次拿到新数据集,都强制先跑一遍配对检查、类别统计、边界校验这三步,再开始转格式和训练。这套流程帮我省下了至少三次通宵 debug。希望这份拆解能帮到你,把这份飞机型号识别数据集真正用起来。
本文还有配套的精品资源,点击获取