旋转目标检测这件事,第一次碰的人十有八九会在"角度"这个概念上卡半天。水平框给四个数就能描述清楚,旋转框却要八个坐标、或者中心点加长宽再加一个角度,多出来的那一个自由度,把标注、增强、损失函数、后处理、评估全带进了另一套逻辑。MMRotate 就是把这套逻辑打包好的一个工具箱,它由 OpenMMLab 生态里的检测框架演化而来,专门处理带角度的框,DOTA 格式是它最主流的数据接口。这篇内容我打算把自己从"拿到一批自己标的旋转框数据"到"跑出可提交的检测结果"这条路上的完整流程摊开讲一遍,包括环境怎么选版本、DOTA 标注文件里每个字段是什么意思、配置文件要动哪几处、大图怎么切怎么拼、训练不收敛时先看哪里。刚上手的人可以照着抄,跑过几轮的人应该也能在里面找到几个自己踩过的坑。全文以 MMRotate 0.3.x 这条稳定分支为主线,1.0 之后的版本基于新的引擎重构,接口差别不小,我会在关键位置说明差异。
1. 为什么旋转框检测要单独用一套框架
1.1 水平框在斜置目标上的天然失真
先说清楚问题在哪儿。遥感影像里的飞机、舰船、油罐,工业质检里的 PCB 板、纺织布料上的瑕疵,这些目标有个共同特征:它们的长宽比往往很大,而且朝向是任意的。用水平外接框去套一个倾斜 45 度的细长目标,框里会塞进大量背景像素,框的面积可能是目标真实面积的两三倍。这带来两个连锁反应:一是回归目标本身就模糊,同一个目标用不同的水平框都能"框住",网络学不到稳定解;二是 NMS 阶段两个相邻的斜置目标,它们的水平框高度重叠,很容易被误抑制掉一个。
旋转框把这个问题从根上解决了。它用 (cx, cy, w, h, θ) 或者四个角点来描述目标,框和目标的贴合度高得多,密集场景下的区分度也上来了。代价是所有的环节都要重写:IoU 计算不再是简单的矩形交集,需要多边形求交;数据增强里的翻转要考虑角度怎么跟着变;损失函数里的角度回归存在周期性,跨越 ±90 度边界时会跳变。这些东西自己从零实现,光是 IoU 的 CUDA 版本就够折腾一周。
1.2 MMRotate 在工具链里的位置
我对比过几条路线。自己基于通用检测框架改,自由度最高,但每个模块都要验证;用某些商业标注平台自带的训练功能,上手快,可一旦要调结构、换 backbone、做消融实验就束手束脚。MMRotate 处在中间位置:它把旋转框相关的算子(凸多边形 IoU、旋转 NMS、角度相关的损失)都实现了,同时保留了模块化配置的灵活度,换 backbone、换 head、改数据管道都是改配置文件的事。
它的技术栈是分层的:底层依赖 MMCV 提供算子注册和运行时基础,中间层沿用检测框架的 Dataset、Runner、Hook 体系,上层才是旋转框专属的模型和评估逻辑。理解这个分层很重要,因为出问题的时候你要先判断是底层算子编译的锅、还是配置写错的锅。
选型上我一般这样判断:数据是 DOTA 或者能转成 DOTA 的四点标注,直接用;标注是 (cx, cy, w, h, θ) 格式的中心点表示,也能用,内部会做转换;标注只有两个对角点,那就得先补全成四点或者中心点形式,否则读进来会报错。
1.3 版本矩阵:先把地基夯实再动土
这一步我要重点强调,因为版本不匹配是新手最常见的翻车点。MMRotate 有两代架构:0.3.x 系列基于 MMCV 1.x 和旧版检测框架,配置文件是纯 Python 字典风格;1.0 之后迁移到新的引擎体系,配置写法、注册机制、命令行参数全变了。网上的教程混着两代写,照抄必炸。
我建议第一次上手锁死在 0.3.x,理由很实际:这一代的第三方教程、issue 讨论、预训练权重最齐全,遇到问题搜索命中率高。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.8 | 3.10 部分算子编译会报错,3.7 偏老 |
| PyTorch | 1.8.1 至 1.11 | 与 CUDA 11.x 匹配良好 |
| CUDA | 11.1 或 11.3 | 决定算子编译能否通过 |
| MMCV-full | 1.6.0 至 1.7.0 | 必须是 full 版,lite 版缺算子 |
| 检测框架 | 2.24 至 2.25 | 版本跨度不要太大 |
安装顺序不能乱:先装 PyTorch,验证torch.cuda.is_available()返回 True,再装 MMCV-full,最后装 MMRotate 本体。MMCV-full 一定要用官方给的预编译包地址按 CUDA 版本和 torch 版本精确匹配下载,源码编译虽然也行,但在没有合适编译环境的机器上动辄半小时起步,还容易因为 GCC 版本不合适失败。
提示:装完 MMCV 后跑一句
python -c "from mmcv.ops import nms_rotated; print('ok')",能打印 ok 才说明旋转算子编译成功。这一步不过,后面训练一定会在计算 loss 时崩掉。
2. DOTA 格式数据集到底长什么样
2.1 标注文件逐字段拆解
DOTA 的标注是一图一文件,纯文本,文件名和图片名一致,后缀是 .txt。开头两行是元信息:
imagesource:GoogleEarth gsd:0.146imagesource表示影像来源,gsd是地面采样距离,单位是米每像素。这两行在训练时基本被忽略,但格式上必须有,因为解析器是按行读取、跳过前两行再处理目标的。有些自己攒的数据忘了写这两行,读进来目标数直接变成负数或者干脆为零,排查半天才发现问题在这。
从第三行开始,每一行是一个目标,共九个字段:
x1 y1 x2 y2 x3 y3 x4 y4 category difficult八个坐标是四个角点,顺序上官方约定是顺时针:左上、右上、右下、左下。这个顺序在转换时会被校验,如果写成逆时针,部分转换函数会算出负的面积,进而导致训练时 loss 变成 nan。我自己写转换脚本时加了一步面积校验,只要出现负值就说明点序反了,直接整体翻转。
category是类别字符串,大小写敏感。difficult是 0 或 1,1 表示该目标在评估时不计入,训练时可以选择保留或丢弃。这里有个细节:很多自制数据把这一列漏掉了,解析器按空白切分后拿到空值,会抛异常。稳妥做法是每条都显式写 0。
2.2 从常见标注工具迁移过来
实际项目里很少有人直接手写 DOTA 文本,基本都是标注工具导出后转换。分两种情况:
第一种,工具支持导出四点多边形,导出的可能是个 JSON,里面每个 shape 有 points 数组和 label 字段。这种情况只需要把 points 的四个点按顺时针重排,写成文本行就行。重排的逻辑不难:算四个点的重心,按极角排序,再根据叉积符号调整成顺时针。
第二种,工具只给水平框。那得退回标注环节重标,或者用已有的水平框模型先跑一遍预测,把预测结果转成旋转框再人工微调。后一种做法在目标长宽比明显、背景单一的图像上效果不错,能省掉大半的标注工时,但要注意预测出的角度有 ±90 度的等价性,微调时要统一到同一个角度区间。
注意:转换脚本一定要做批量校验,至少检查四项——点数是否为 4、坐标是否越界、面积是否为正、类别是否在预设列表内。我见过因为一张图里有一个越界点,导致整个数据集加载阶段直接抛异常,训练脚本连第一个 iter 都没跑到。
2.3 目录组织与数据集划分
框架对目录结构有隐含约定。以训练验证集为例,期望的结构是:
data/自定义数据集/ ├── trainval/ │ ├── images/ │ │ ├── P0001.png │ │ └── P0002.png │ └── annfiles/ │ ├── P0001.txt │ └── P0002.txt └── test/ └── images/关键点是 images 和 annfiles 两个目录平级,文件名(不含后缀)严格一一对应。图片后缀要和配置里的img_suffix一致,DOTA 原始数据是 .png,很多人转成 .jpg 之后忘了改配置,结果报"找不到文件"。
划分训练验证集时,如果数据量不大,我一般按 8:2 随机划,但要注意同一个大图切出来的子图必须落在同一个集合里,否则会造成信息泄漏——同一片区域既在训练集又在验证集,指标会虚高得离谱。具体做法是以原始大图 ID 为分组键做划分,而不是以子图文件名为键。
2.4 大图切分:800 步长 600 的由来
DOTA 的原始影像动辄 4000 像素以上,直接送进网络显存吃不消,而且目标在缩放后可能只剩几个像素。所以标准流程是先切图。
官方给的经验参数是切 800×800 的块,相邻块之间重叠 200 像素,也就是步长 600。为什么这么设:DOTA 里最大的目标(比如大型飞机、桥梁)对角线长度接近 500 像素,重叠 200 能保证任何目标至少完整出现在一个块里;步长 600 则保证了覆盖率和块数量之间的平衡,重叠太多块数量暴涨,训练一轮的时间直接翻倍。
切分不只是裁图片,还要同步裁剪标注。这里有个取舍:一个目标被切到两个块里,在 A 块里只露出三分之一,这样的残缺标注意义不大,要丢掉。控制这个丢弃的阈值叫iof,即裁剪后保留面积与原面积的比值,常用 0.7。设太低会保留大量残缺目标,网络学到畸形的形状先验;设太高则小目标容易在边缘被裁掉,正样本变少。
配置文件长这样:
{ "nproc": 10, "iof": 0.7, "img_dirs": ["data/自定义数据集/origin/images"], "ann_dirs": ["data/自定义数据集/origin/annfiles"], "save_dir": "data/自定义数据集/split_800_600", "subsize": 800, "gap": 200, "rates": [1.0] }nproc是多进程数,按 CPU 核数给,一般设成核数的一半左右。rates是缩放比例列表,设成 1.0 表示不缩放;如果原始影像分辨率差异大,可以加上 0.5、1.5 之类的多尺度,但会把数据量成倍放大,第一次跑建议先用单尺度把流程走通。
切完之后,目录会变成 split_800_600 下面再分 images 和 annfiles,同时在根目录生成一个 pkl 文件记录切分映射关系,后面拼回原图时要用到,别删。
3. 配置文件改造:只动该动的地方
3.1 类别注册的三个位置
这是最容易出错的一环。框架里类别信息出现在三个地方,必须保持一致:
第一个是数据集类里的CLASSES元组,通常定义在数据集模块源码里。自定义类别最省事的做法是复制一份数据集定义文件,改成自己的类名元组,然后在配置文件里指向新的数据集类。直接改源码文件也行,但升级或者换项目时会污染环境,我更倾向新建一个模块文件,通过注册机制引入。
第二个是配置文件里data字典下的train、val、test三个子字典,每个都要写classes=CLASSES,不写会走默认值。
第三个是评估相关的配置。有些版本的评估脚本内部硬编码了类名列表,类别数少于原始设定时会报索引越界。这个坑很隐蔽,训练全程正常,一到评估就崩,报的还是个看不出所以然的 KeyError。
提示:改完类别后,用一个最小脚本实例化数据集,打印
dataset.CLASSES和len(dataset),再取第 0 个样本看看标注是否正常解析。这一步花两分钟,能省掉后面两小时的困惑。
3.2 数据路径与管道参数
以旋转 RetinaNet 的基准配置为底改,需要动的是这几处:
data_root = 'data/自定义数据集/split_800_600/' data = dict( samples_per_gpu=2, workers_per_gpu=2, train=dict( type='自定义数据集类名', ann_file=data_root + 'trainval/annfiles/', img_prefix=data_root + 'trainval/images/', img_scale=(1024, 1024), img_norm_cfg=img_norm_cfg, size_divisor=32, version='le90'), val=dict(...), test=dict(...))几点说明。ann_file指向的是目录而不是单个文件,这一点和普通检测框架不一样,因为旋转框是一图一标注文件。img_scale我设成 1024,比切图尺寸 800 大。为什么不设 800 保持原样?因为放大一点能让小目标保留更多像素,而 1024 又是 32 的整数倍,经过五层下采样后正好是 32,不会在特征图上产生不对齐的边界。size_divisor=32就是干这个用的,保证同一 batch 内的图填充到 32 的倍数,避免拼接时尺寸不一致。
version='le90'定义角度区间是 [-90, 90),这跟后面 head 的版本必须匹配。如果改用 [0, 90) 的oc版本,head 也要换成对应的,否则角度回归的边界处理会错。
3.3 学习率与批大小的线性关系
基准配置是 8 卡、每卡 2 张,学习率 0.008。换算成平均每张图的等效学习率是 0.0005。单卡每卡 2 张,学习率就该是 0.001;单卡每卡 4 张,就是 0.002。这个线性缩放规则在小 batch 场景下不完全成立,因为 BN 层的统计量在小 batch 时不稳定。所以我的实践是:显存允许就往上顶 batch,显存不够就保持小 batch 但把学习率从线性值往下压 20% 到 30%,训练更稳。
显存占用我实测过一组数据,以 1024 输入、旋转 RetinaNet R50 为基准:
| 每卡张数 | 显存占用 | 建议学习率 | 备注 |
|---|---|---|---|
| 1 | 约 6.5 GB | 0.0005 | 训练慢但稳 |
| 2 | 约 10 GB | 0.001 | 单卡首选 |
| 4 | 约 17 GB | 0.002 | 需要 24G 显存 |
| 8 | 溢出 | - | 需要梯度累积替代 |
梯度累积是个好办法,用optimizer_config=dict(type='GradientCumulativeOptimizerHook', cumulative_iters=4),等效 batch 翻四倍,显存不涨。但要注意 BN 的统计仍然按实际 batch 算,所以累积出来的效果和真的大 batch 有细微差别,学习率可以按等效 batch 的一半来设。
3.4 数据增强:翻转和角度要一起翻
旋转框的增强里,翻转是最需要小心处理的。水平翻转一张图,所有框的 x 坐标要镜像,角度要从 θ 变成 -θ,或者等价的 180-θ,具体看角度区间的定义。这个逻辑框架已经封装好了:
dict( type='RRandomFlip', flip_ratio=0.5, direction=['horizontal', 'vertical', 'diagonal'], version='le90')direction支持水平、垂直、对角线三个方向。对角翻转等价于转置加翻转,对遥感图像挺有用,因为卫星视角下的目标朝向本身就没有强先验。但方向别全开,三个方向各 0.5 概率,实际相当于一半样本被增强了,配比失衡反而降低有效样本量。
其他增强跟普通检测类似,多尺度训练(Resize 加 RandomCrop)、色彩抖动都可以照搬。但有两类要慎用:一是大角度的 RandomRotate,除非你能保证旋转后目标不被裁出边界,否则大量目标变成残缺;二是 Mosaic,拼接后的图像边缘会出现大量被切断的目标,标注处理不当就是把噪声喂给网络。
4. 启动训练与过程监控
4.1 启动命令与断点续训
单卡:
python tools/train.py configs/自定义配置.py --work-dir work_dirs/exp01多卡:
bash tools/dist_train.sh configs/自定义配置.py 4 --work-dir work_dirs/exp01--work-dir一定要显式指定。不指定的话日志和权重会散落在默认目录,跑两三个实验之后就分不清哪个是哪个了。我的习惯是目录名带上日期和关键参数,比如exp01_r50_1024_lr1e3,半年后回头看还能一眼认出这是什么实验。
断点续训加--resume-from work_dirs/exp01/latest.pth。这里有个细节:resume会恢复优化器状态和 epoch 计数,load-from只加载权重不恢复训练状态。想在一个训练好的模型基础上换数据集微调,用load-from;训练中断想接着跑,用resume。
4.2 日志怎么读
日志文件在 work-dir 下,日期时间命名。训练过程中控制台会滚动输出,但真正有价值的是几百行之后的信息。我一般盯三个地方。
第一个是 epoch 开始前的样本统计,会打印每个类别的实例数量。如果某个类别实例数是 0,说明数据管道有问题,可能是类别名拼写不一致导致的匹配失败。如果各类别数量极不均衡,比如某一类占了 80%,那后面调参就得考虑类别平衡策略了。
第二个是 loss 曲线。旋转检测的 loss 通常由分类 loss 和回归 loss 两部分组成。分类 loss 前期快速下降,几十个 iter 内应该能到 1.0 以下。回归 loss 下降慢,而且会有波动,因为角度维度本身有周期性。如果分类 loss 卡在某个值一动不动,八成是学习率太大或者标签有问题;如果回归 loss 突然跳到很大的值甚至出现 nan,先怀疑角度越界或者标注里有退化的框(宽或高为 0)。
第三个是验证集指标。旋转检测的评估比较耗时,默认每个 epoch 都评可能拖慢整体进度。数据量大时可以改成每 3 个 epoch 评一次,改evaluation = dict(interval=3, metric='mAP')。
4.3 训练策略的取舍
默认的 1x 策略是 12 个 epoch,前 8 个保持学习率,第 8 和第 11 个 epoch 各降一次,降 10 倍。这个设置在 DOTA 这种数据量下是够的。但自定义数据集往往只有几百上千张切图,12 个 epoch 还没收敛就降学习率了,模型欠拟合。
我的调整思路:数据少于 2000 张切图,把总 epoch 拉到 24 到 36,学习率降幅点按比例后移;数据量过万,保持 12 甚至减到 8,防止过拟合。同时把 warmup 打开,warmup='linear', warmup_iters=500, warmup_ratio=0.001,前期学习率从千分之一线性升到设定值,能明显减少前几个 iter 的 loss 尖峰。
另外,旋转框检测对小目标敏感度比水平框更高,因为框的角度在小目标上估计得更不准。如果有大量小目标,建议把 FPN 的输出层数加一层,或者在数据管道里少做下采样。这些改动都要配合重新调学习率,改完先跑两三个 epoch 看 loss 趋势再决定要不要继续。
5. 推理、拼图与结果提交
5.1 单图推理和可视化
先把模型跑通单图推理,验证权重没问题:
python demo/image_demo.py \ demo/test.png \ configs/自定义配置.py \ work_dirs/exp01/latest.pth \ --out-file result.png输出图会把框和类别画上去。看结果的时候重点看两种情况:框和目标的贴合度,以及有没有大量重叠的冗余框。贴合度差说明回归没训好,冗余框多说明 NMS 的阈值需要调。旋转框的 NMS 用的是多边形 IoU,阈值设 0.1 到 0.3 比较常见,比水平框的 0.5 低不少,因为旋转框之间的重叠面积计算更严格。
5.2 大图推理与结果回拼
切图训练,推理时也得切图,然后把每个块的结果拼回原图坐标。这一步靠切分时生成的 pkl 映射文件完成。流程是:先对切图后的测试集跑推理,用--format-only输出 txt 格式的结果;再调用回拼脚本,按映射关系把每个块里的框坐标加上偏移量,还原到原图坐标系;最后对跨块重复检测的同一个目标做合并。
合并逻辑是分块后在重叠区域会出现同一个目标被检测两次,需要按 IoU 去重。这个去重和常规 NMS 不完全一样,因为两个框可能一个完整一个残缺,IoU 算出来偏低,但它们确实是同一个目标。实践中我把重叠区的去重阈值调得比常规 NMS 更低,宁可多合并一些。合并完的结果再跑一遍整体 NMS 收尾。
5.3 评估指标怎么看
旋转检测的主指标是 mAP,IoU 阈值 0.5。这个 0.5 是旋转框的 IoU,不是水平框的,同样的预测框在两种 IoU 定义下数值差别很大,所以不能拿水平框的经验值来对比。
评估结果里除了总体 mAP,还会分类别列出 AP。分类别看比看总体有用得多:如果某一类的 AP 特别低,先查这一类的样本量,样本量正常的话再看标注质量,很多问题是某一类标注的角度习惯和其他类不一致造成的。还有一种情况是某类目标尺寸特别小或者特别大,超出了 anchor 的覆盖范围,调 anchor 的 scale 和 ratio 就能改善。
角度估计的准确度也可以单独看。有些实现会把角度误差纳入评估,如果没有,可以自己写个小脚本统计预测角和真值角的差值分布。分布集中在 0 附近说明角度回归没问题,如果在 ±90 附近也有一堆,说明存在等价角度的混淆,需要检查角度定义的区间和损失函数的处理。
6. 常见问题速查
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 训练启动即报找不到标注文件 | ann_file 指向了单个文件而非目录,或文件名前后缀不匹配 | 检查 ann_file 配置和图片后缀设置 |
| loss 出现 nan | 标注中存在面积为 0 或负的框,或角度越界 | 批量校验标注,加面积和角度合法性检查 |
| 类别数为 0 或报 KeyError | 三处类别定义不一致 | 统一数据集类、配置、评估脚本的类名列表 |
| 评估指标异常低但可视化看着还行 | 评估脚本硬编码类别数,或 IoU 阈值理解偏差 | 核对评估配置,用少量样本手工验证 IoU |
| 显存溢出 | batch 太大或输入尺度太大 | 降低 samples_per_gpu,或改梯度累积 |
| 训练 loss 正常但验证不收敛 | 训练验证集划分有信息泄漏,或验证集分布差异大 | 按原始大图 ID 分组划分 |
| 大图结果出现重复框 | 重叠区去重阈值不合适 | 调低合并阶段的 IoU 阈值 |
最后分享一个我在数据准备阶段养成的习惯:每次转换完标注,抽十张图用可视化脚本把框画出来,人工瞟一眼。这个动作花不了五分钟,但能抓住八成以上的标注格式问题——点序反了框会变成自交的蝴蝶形,坐标写错位框会跑到图像外面,类别写错会显示成未知类。等训练跑起来再回头查这些问题,成本是现在的几十倍。