☰
YOLOv5 OBB旋转框训练实战:角度定义、DOTA滑窗与NMS评测
2026/10/27 13:05:55 网站建设 项目流程

简介:面向需要处理倾斜目标检测的开发者与算法学习者,这份YOLOv5 OBB旋转框训练demo在原生YOLOv5基础上引入带方向角的旋转框标注与训练流程,可用于遥感影像、车辆、树木等非矩形目标的定位实践,适合具备一定深度学习基础、希望从水平框迁移到OBB的中级读者。压缩包共583个文件,约445.58MB,包含71个Python脚本、38个YAML配置、166个XML标注、95个TXT标签及93张PNG与23张JPG示例图,另有预训练权重、ONNX模型、CUDA算子与部署脚本等,覆盖数据处理、参数配置、训练评估与模型导出的完整链路。已有1822人学习下载,说明该示例在旋转框任务上具备一定参考价值。读者可据此复现OBB标注转换、旋转框损失计算、验证可视化与推理部署,并对照目录结构梳理工程组织与排错思路,对理解方向敏感型目标检测的工程实现颇有帮助。

1. 从一张飞机图说起:为什么水平框在这里是错的

跑道边的飞机用水平框框出来,框里一半是跑道、一半是旁边的机库;密集停放的车辆用水平框一框,几个框叠在一起,NMS 一压,剩下的框和真实目标对不上。航拍和遥感这一类俯视图里,目标的朝向分布极其分散,水平框(HBB)的表达能力一开始就不够用。这个yolov5 obb 旋转框训练 demo就是干这件事的:在 YOLOv5 的检测头上增加一个角度分支,把输出从(cx, cy, w, h)扩展成(cx, cy, w, h, θ),再配套 DOTA 格式的数据流水线、旋转框的 NMS 和后处理,形成一个能训练、能推理、能出结果的完整源码包。它适合两类人:一类是手里已经有 DOTA 或类似八点标注数据、想快速跑通一条旋转框 baseline 的;另一类是做过水平框检测、想搞清楚角度回归到底难在哪的。下面按我拆包复现的顺序写,从角度定义一路写到评测合并。

2. 角度怎么定:三种旋转框表示、一个转换函数和两次自检

旋转框比水平框多出来的那个 θ,是所有坑的源头。一个矩形在几何上只有一种形状,但写成(cx, cy, w, h, θ)却有无数种等价形式:θ 加 180 度是同一个框,θ 加 90 度同时把 w 和 h 交换也是同一个框。只要标注、数据加载、损失计算、后处理这四个环节里有两个用了不同的约定,训练就会变成在拟合一个周期函数,loss 曲线看着在降,mAP 死活上不去。

2.1 OpenCV 定义、le90 与 le135 为什么会打架

先把三种常见约定摆出来。OpenCV 的cv2.minAreaRect返回的(w, h)顺序是不保证的,它可能把短边放在 w 上,θ 的范围也会随版本变化;le90 把长边固定为 w,θ 落在[-90, 0);le135 同样固定长边为 w,但 θ 落在[-45, 135)。这三个定义单独用都没问题,混用就出事。

定义θ 范围w / h 约定典型来源
OpenCV(oc)随版本浮动,常见 [0, 180)不保证 w ≥ hcv2.minAreaRect直接返回
le90[-90, 0)w 为长边部分旋转检测库
le135[-45, 135)w 为长边部分标注工具与 OBB 实现

更隐蔽的一层是标注工具。有些旋转框标注工具在 XML 里存的是弧度,导出成 txt 时才转成度;如果转换脚本直接float()读进来当度用,1.57 度的角度看起来"合理",实际是 90 度。这种错不会报异常,只会让模型学到一个恒定的偏置。

这个 demo 走的是 OpenCV 定义这条路:统一把长边放到 w 上,θ 归一化到[0, 180)。选它的理由是转换链路最短,minAreaRect一步就能从八点标注算出旋转框,不需要额外的分支判断。代价是 θ 在 0 和 180 附近存在边界跳变,这一点后面在角度损失那节还会提到。我一般会先把这个约定写进项目的 README,改代码的人在动 angle 相关逻辑前必须先看一眼。

2.2 从八点到五点:poly2rbox 的写法与归一化口径

DOTA 格式的标注是八个数:x1 y1 x2 y2 x3 y3 x4 y4 类别名 difficult,四个角点按顺时针或逆时针排列。要喂给 YOLO,得先算最小外接矩形。下面这段是整个数据链路里最不能出错的一段:

import cv2 import numpy as np def poly2rbox_oc(poly): """八点标注 -> OpenCV 定义旋转框 poly: [x1,y1,x2,y2,x3,y3,x4,y4] 返回: (cx, cy, w, h, theta),theta 单位为度,范围 [0, 180) """ pts = np.asarray(poly, dtype=np.float32).reshape(4, 2) (cx, cy), (w, h), theta = cv2.minAreaRect(pts) # minAreaRect 不保证 w >= h,统一把长边放到 w 上,角度同步 +90 if w < h: w, h = h, w theta += 90.0 theta = theta % 180.0 # 归一化到 [0, 180) return cx, cy, w, h, theta def rbox2poly(cx, cy, w, h, theta): """旋转框 -> 八点,后处理可视化时要用""" t = np.deg2rad(theta) dx, dy = w / 2.0, h / 2.0 corners = np.array([[-dx, -dy], [dx, -dy], [dx, dy], [-dx, dy]], dtype=np.float32) R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]], dtype=np.float32) return (corners @ R.T + np.array([cx, cy], dtype=np.float32)).reshape(-1)

poly2rbox_oc里if w < h那个分支是关键:交换 w 和 h 的同时必须给 θ 加 90 度,否则框会"转向"90 度,形状对但朝向错。theta % 180.0负责兜住minAreaRect在个别版本里返回的负角度。rbox2poly则是反向操作,先以中心为原点算四个角,再旋转平移回去,注意这里用的是corners @ R.T,转置写反了框会镜像。

归一化口径要在这一层就定死。常见做法是中心点和宽高都除以图幅尺寸,角度除以 180,全部落到[0, 1)区间。角度除以 180 的好处是输出分支可以直接用 sigmoid,不用处理负值;坏处是 0 和 1 是同一个物理角度,边界处依然会跳。这是个取舍,不是银弹。

2.3 用一趟往返转换自检标注

转换逻辑写完别急着开训,先花十分钟做往返校验。原理很简单:如果poly → rbox → poly → rbox两轮的结果一致,说明转换是无损的;如果不一致,说明有分支没走对。

def check_roundtrip(poly, tol=1e-3): """两次转换结果必须一致,角度按 180 度周期比较""" cx, cy, w, h, th = poly2rbox_oc(poly) cx2, cy2, w2, h2, th2 = poly2rbox_oc(rbox2poly(cx, cy, w, h, th)) assert abs(w - w2) < tol and abs(h - h2) < tol, "边长来回变了,检查 w/h 交换分支" assert abs(cx - cx2) < tol and abs(cy - cy2) < tol, "中心点漂了" diff = abs(((th - th2 + 90.0) % 180.0) - 90.0) assert diff < tol, f"角度不自洽: {th} vs {th2}" # 顺手扫一遍训练集的标注,把异常样本挑出来 bad = 0 for line in open('/data/DOTA/train/labelTxt/0001.txt', encoding='utf-8'): parts = line.strip().split() if len(parts) < 10: continue try: check_roundtrip(list(map(float, parts[:8]))) except AssertionError as e: bad += 1 print(parts[8], e) print('异常样本数:', bad)

跑完之后如果异常样本数不是 0,八成是碰到了三个问题里的一个:八点顺序不是凸多边形顺序(minAreaRect会算出一个错的框)、某个点的坐标是-1之类的占位符、或者标注里有极扁的长条(w 或 h 接近 0,数值上不稳定)。这三种我都会直接从训练集里剔掉,宁可少几百个样本,也别让模型去学一个自相矛盾的目标。

提示:往返校验对角度的容差不要设成 0,浮点运算在 0 和 180 的边界处一定会给你一点误差,按 180 周期比较才是对的做法。

3. 数据流水线:把 8000 像素的大图切成 1024 的滑窗并转成五点标注

DOTA 的原图动辄几千甚至上万像素,直接缩到 1024 训练,一艘船会变成一个像素点。所以这条流水线的标准动作是先把大图滑窗切成 1024 的小片,再在小片上训练。切图这一步看起来只是裁一裁,实际上split_size、gap、thresh三个参数直接决定了模型能学到什么。

3.1 环境先对齐:torch、CUDA 和编译扩展

这个 demo 不是纯 Python 能跑完的,旋转框的 NMS 和部分框 IoU 计算依赖编译扩展,所以环境必须一次装对。我一般按这个顺序来:

# 1. 先确认驱动和 CUDA 运行时版本,后面所有版本号都要跟这个对齐 nvidia-smi # 2. 装 torch 时显式指定 cuda 版本,不要让它自己猜 pip install torch==1.13.1+cu117 torchvision==0.14.1+cu117 \ --extra-index-url https://download.pytorch.org/whl/cu117 # 3. 编译旋转框扩展前,先告诉编译器要为目标卡的算力编译 # 30 系消费卡是 8.6,A100 是 8.0,T4 是 7.5,按实际改 export TORCH_CUDA_ARCH_LIST="8.6" cd utils/nms_rotated && python setup.py build_ext --inplace && cd ../.. # 4. 验证扩展能 import,且能对两个旋转框算出 IoU python -c "from utils.nms_rotated import nms_rotated; print('ok')"

第三条最容易翻车。TORCH_CUDA_ARCH_LIST不设的话,编译器会把一堆老架构都编一遍,编译时间翻好几倍,而且在部分环境下会直接因为算力参数不识别而中断。另外 gcc 版本别太高,我遇到过 gcc 11 编 torch 1.13 的扩展报一堆模板错误,降到 gcc 9 就好了。这条属于玄学范畴,但确实有规律:扩展的编译器和编译 torch 时的编译器差太多就容易出问题。

3.2 滑窗切图:split_size、gap、thresh 三个参数怎么定

切图用 DOTA_devkit 那套就行,核心是splitbase:

import sys sys.path.append('./DOTA_devkit') from DOTA_devkit.ImgSplit_multi_process import splitbase if __name__ == '__main__': basepath = '/data/DOTA/train/' # 原始大图 images/ + labelTxt/ outpath = '/data/DOTA/train_split/' # 切完的输出目录 split = splitbase(basepath, outpath, split_size=1024, # 单个切片的边长 gap=200, # 相邻切片的重叠像素 thresh=0.7, # 目标被裁后剩余面积占比的保留阈值 num_process=8) # 多进程数,按核数给 split.splitdata(1) # rate=1 表示不做采样,全量切

三个参数的取值逻辑分别是这样:

split_size跟训练分辨率绑死。设成 1024 就用--imgsz 1024训,设成 800 就用 800,两边不一致的话坐标缩放会引入额外的插值误差,小目标首当其冲。我不建议为了省显存把split_size调到 512,那样一辆车在图上就十几个像素,再多的角度分支也救不回来。

gap是重叠区,目的是别让目标正好卡在切片缝上被切成两半。经验值是gap ≥ 最大目标尺寸的一半。机场跑道这种超长目标,切图之后必然还是断的,那就在数据集层面单独处理,别指望靠调 gap 解决。我一般用 200,512 的切片配 100。

thresh控制被裁目标的取舍。一个目标被切片边缘裁掉之后,如果剩余面积占原面积的比例低于这个值,就直接丢掉不标;高于这个值就把裁剩的那部分单独作为一个目标保留下来。设得高(比如 0.9)会丢掉大量边缘样本,设得低(比如 0.3)会留下一堆"半截目标"让模型困惑。0.7 是个比较稳的中间值。

切完之后文件名会带上偏移量,形如P0001__1024__0___0.png,标注文件同名放在labelTxt/下,坐标已经转换成相对于切片的局部坐标,这一步 devkit 帮你做了,不用自己减。

3.3 转成五点标注并生成数据集配置

切片的标注还是八点格式,要转成cls cx cy w h theta的归一化五点:

import os from tqdm import tqdm CLASSES = ['plane', 'ship', 'storage-tank', 'baseball-diamond', 'tennis-court', 'basketball-court', 'ground-track-field', 'harbor', 'bridge', 'large-vehicle', 'small-vehicle', 'helicopter', 'roundabout', 'soccer-ball-field', 'swimming-pool'] def convert_one(txt_path, img_w, img_h, img_h, out_path): ```python def convert_one(txt_path, img_w, img_h, out_path): lines = [] with open(txt_path, 'r', encoding='utf-8') as f: for raw in f: parts = raw.strip().split() if len(parts) < 10: # 少于 8 个坐标 + 类别 + difficult 的行直接跳过 continue poly = list(map(float, parts[:8])) cls_name = parts[8] if cls_name not in CLASSES: # 类别表外的目标(含 difficult 之外的杂类)丢弃 continue cx, cy, w, h, th = poly2rbox_oc(poly) # 中心点与宽高按图幅归一化,角度按 180 归一化,全部落到 [0,1) lines.append('{} {:.6f} {:.6f} {:.6f} {:.6f} {:.6f}'.format( CLASSES.index(cls_name), cx / img_w, cy / img_h, w / img_w, h / img_h, th / 180.0)) with open(out_path, 'w', encoding='utf-8') as f: f.write('\n'.join(lines))

归一化这一步有个细节:宽高分别除以img_w和img_h,不要两个都除以边长。切片是正方形的时候两者相等看不出问题,一旦换成非正方形的输入尺寸,框就会整体变形,而这个错误在正方形测试里永远不会暴露。角度除以 180 之后,回归目标落在[0, 1),输出分支配 sigmoid,这是自洽的一套。

数据集配置文件长这样:

# data/dota_split.yaml train: /data/DOTA/train_split/images val: /data/DOTA/val_split/images nc: 15 names: ['plane', 'ship', 'storage-tank', 'baseball-diamond', 'tennis-court', 'basketball-court', 'ground-track-field', 'harbor', 'bridge', 'large-vehicle', 'small-vehicle', 'helicopter', 'roundabout', 'soccer-ball-field', 'swimming-pool']

注意train和val指向的是图片目录,标注目录由代码按约定去找同级的labels/,具体路径规则看 demo 里datasets.py怎么拼的,不同分支写法不一样,别照着别的仓库的习惯套。建完之后抽三张图跑一下可视化,把预测前的 ground truth 框画出来,确认框的形状和朝向跟原图对得上,再看 anchor。

产物路径用途
切片图train_split/images/*.png训练输入
五点标注train_split/labels/*.txt训练标签
数据集配置data/dota_split.yaml路径与类别
原始大图/data/DOTA/train/images最终评测前的结果合并

4. 改头与开训:OBB 头多出的那一维、hyp 里的 angle 权重和日志判读

4.1 模型结构:输出通道从na*(5+nc)变成na*(6+nc)

YOLOv5 的检测头每个 anchor 输出5 + nc维(四个框参数、一个置信度、nc 个类别概率)。加了旋转框之后变成6 + nc,多出来的那一维就是角度。改动的位置集中在三处:models/yolo.py里 head 的输出通道数、Detect层里 decode 时的框解析逻辑、以及损失函数里角度项的构造。

配置文里通常要显式打开一个开关,形状类似:

# models/yolov5s_obb.yaml 的 head 部分 head: [[-1, 1, Conv, [512, 1, 1]], [-1, 1, nn.Conv2d, [na * (nc + 6), 1, 1]], # 注意是 nc + 6,不是 nc + 5 ]

na是每个特征层的 anchor 数量,默认 3。如果 demo 为了提升角度分辨率改成了 4 组 anchor,这里的na和 anchor 配置文件必须同步改,漏改一个就会在 decode 的时候维度对不上,报的错还很绕(形如 reshape 失败),第一次遇到能查半天。

加载预训练权重时日志里会打印Transferred xxx/xxx items,数字不是全量是对的——主干和 neck 能对上,head 的最后一层因为通道数变了会被跳过。这是预期行为,不用管。

4.2 hyp 参数:angle 权重、degrees 增强和 mosaic

超参文件是训练能不能收敛的第二道关。关键几项:

# data/hyps/hyp.rotate.yaml 关键项 lr0: 0.01 # 初始学习率,OBB 头是随机初始化的,别一上来就 0.1 lrf: 0.2 # 余弦退火终点比例 box: 0.05 # 框回归损失权重 cls: 0.5 # 分类损失权重 obj: 1.0 # 置信度损失权重 angle: 0.5 # 角度损失权重,本文档新增项 degrees: 0.0 # 旋转增强角度上限,OBB 任务里通常关掉 mosaic: 0.5 # mosaic 增强概率 flipud: 0.0 # 上下翻转,航拍图翻转后朝向分布会变,谨慎开 fliplr: 0.5 # 左右翻转,OBB 下要同步镜像角度 scale: 0.5 # 缩放增强幅度

angle这个权重是最需要调的。设太小(0.05)模型会偷懒,把所有框的角度预测成 0,因为这样框回归的 loss 反而更低;设太大(2.0)角度分支会主导梯度,框的位置和大小精度掉下去,训练后期 loss 抖得厉害。0.5 附近是个常见的起点区间,我一般从 0.3 开始,看角度分支收敛情况再往上推。

degrees关掉是血泪经验。旋转增强在 OBB 任务里有两个麻烦:一是图像仿射变换的时候八点多边形必须跟着做同样的变换,任何一个分支漏掉,标签就飞到画面外了;二是角度回归目标在大角度处会跨过 0/180 边界,sigmoid输出在这个边界上是个断崖,loss 会跳。所以先把degrees: 0.0跑通一轮拿到 baseline,再考虑 5 到 10 度的小幅度增强。

flipud建议关掉。航拍图的朝向分布和拍摄角度强相关,上下翻转会造出真实场景里不存在的朝向组合。fliplr可以开,但代码里必须把角度 θ 映射成180 - θ,这个映射没写的话标签就是错的,而且错得很隐蔽——框的形状对,朝向镜像了。

参数常用取值调大之后会发生什么
angle0.3 ~ 0.8角度更准,但位置精度可能下滑,后期 loss 抖动加剧
degrees0.0 ~ 10.0泛化略好,但需要标签同步变换,边界角度处 loss 不稳
mosaic0.3 ~ 0.5小目标召回提升,长条形目标被拼断的概率上升
scale0.3 ~ 0.7尺度鲁棒性提升,小目标进一步变小
box0.03 ~ 0.06定位更准,但训练前期收敛慢

4.3 启动训练与日志判读

启动命令大致如下,参数名在不同分支里可能差一两个字,用--help对一下:

python train.py \ --cfg models/yolov5s_obb.yaml \ --weights weights/yolov5s.pt \ --data data/dota_split.yaml \ --hyp data/hyps/hyp.rotate.yaml \ --epochs 120 \ --batch-size 8 \ --imgsz 1024 \ --workers 8 \ --device 0 \ --name dota_s_1024

--imgsz 1024必须和切图时的split_size一致;--batch-size 8是 1024 分辨率下 24G 显存能塞下的典型值,显存不够优先降 batch 而不是降分辨率;--weights给的是 ImageNet 或 COCO 预训练的主干,head 部分会自动跳过。

训起来之后盯四个数:box_loss、angle_loss、cls_loss、obj_loss。健康的曲线是前三个在 20 个 epoch 内明显下降,angle_loss降得比box_loss慢,这是正常的——角度的梯度信号本来就弱。如果angle_loss从第一个 epoch 就贴在 0.69 附近不动(0.69 约等于ln2,对应二分类瞎猜的熵),说明角度分支根本没拿到梯度,回去查数据集里 θ 那一列是不是全 0,或者归一化除错了分母。

另一个要看的指标是验证集上的 mAP。旋转框任务的 mAP 上升比水平框慢,前 30 个 epoch 可能一直贴着 0,这不一定是有问题,可能只是角度还没学出来。但如果在切图后的验证集上画出来的预测框形状明显不对(框比目标大一圈、或者朝向整体偏 90 度),那就别等了,直接停下来查数据链路。

5. 避坑与排查:五类「能跑但不对」的现象怎么定位

训练脚本不报错、loss 也在降、但结果就是不能用——这类问题最消耗时间。下面五条是我在这个 demo 上实际踩过的,按定位顺序排。

5.1 angle_loss 不动,预测框全部水平

现象:训练日志里angle_loss从第一个 epoch 开始就贴着 0.69 上下微动,几十个 epoch 过去还是这个位置。推理出来的框全都是水平的,形状和位置反而挺准。

原因:角度分支拿不到有效梯度。最可能是数据集里 θ 那一列全 0 或者全 0.5,也就是归一化的时候角度没写上或者除错了。次可能是poly2rbox_oc里那个if w < h分支把角度吃掉了,导致所有样本的角度都落在极窄的一段区间里,模型学一个常数就够了。

解决:随机抽一百行训练标注,把 θ 还原成角度打印出来看分布,正常应该是几乎铺满[0, 180)的。如果分布集中在一小段,回到转换脚本单步调试,拿一个已知角度的框喂进去看输出对不对。另外检查models/yolo.py里的 decode 逻辑,确认角度那一维的下标没有被框参数挤掉。

5.2 验证集 mAP 接近 0,但可视化看着挺准

现象:训练完在验证集上算 mAP,结果不到 0.01,可是把预测结果画到图上,框位置和朝向肉眼看着都对。

原因:评测流程错了,跟模型无关。旋转框的评测要求先把各切片的预测结果映射回原图坐标并合并,再做评测;直接在切片上算 mAP,坐标尺度对不上,全判错。另一个常见原因是类别 id 的映射顺序和评测脚本里的类别顺序不一致,第 0 类和第 3 类互换了,算出来自然一片零。

解决:先单图验证,拿一张切片跑推理,把rbox2poly转出来的四点画到原图上,跟自己眼睛对一遍。这一步过了,再检查结果合并脚本有没有正确使用文件名里的偏移量把局部坐标加回去。类别顺序就打印一下data.yaml里的 names 列表和评测脚本里的类别列表,逐项对齐。

5.3 打开 mosaic 之后框位置整体偏移

现象:mosaic: 0.0的时候训练正常,改成 0.5 之后前几个 epoch 的box_loss暴涨,训一段时间能恢复一点但精度明显不如关掉 mosaic。

原因:mosaic 会把四张图拼成一张,同时做随机缩放和裁剪,标准 YOLOv5 里这一步只变换了 HBB 的四个坐标。旋转框的八点(或者五点)在拼接时必须逐点做同样的仿射变换,中心点好算,角度和边长要重新从变换后的四点里用minAreaRect反算。很多分支在改 OBB 的时候忘了改 mosaic 那段,标签还是按 HBB 的方式缩放,框就整体偏了。

解决:把 mosaic 那段代码单独抽出来,写个单测——构造两个已知的旋转矩形,做一次确定性的平移+缩放,把变换后反算出来的框和手工计算的期望值对比。这一步做完基本能定位到是漏了平移还是漏了缩放。

5.4 编译旋转框扩展时直接报错

现象:python setup.py build_ext --inplace跑到一半报一堆模板实例化错误,或者编完了 import 时报undefined symbol。

原因:编译器版本和 torch 的 CUDA 版本不匹配,或者TORCH_CUDA_ARCH_LIST没设导致编译器试图为不支持的算力架构生成代码。

解决:先nvcc --version和python -c "import torch; print(torch.version.cuda)"对一下,两个版本主线要一致。设好TORCH_CUDA_ARCH_LIST,30 系卡是 8.6,A100 是 8.0,T4 是 7.5。gcc 版本降到 9 或 10。三个都对齐之后清掉build/目录重新编,别在旧的中间产物上接着编。

5.5 小目标几乎全丢

现象:大目标(飞机、跑道)的召回不错,小车、小船这类小目标基本检测不到。

原因:三种可能。切图时gap太小,小目标正好卡在切片缝上被切掉;anchor用的还是 COCO 的默认值,跟 DOTA 的尺度分布差了一个数量级;或者thresh设得太高,被裁的小目标因为剩余面积比例不够被整批丢弃。

解决:把gap提到 200 以上,或者按最大目标尺寸的一半来定。用autoanchor在切好的数据集上重聚类一遍 anchor,把结果固化到配置里。thresh降到 0.5 试试,看召回有没有改善。这三步按顺序做,一次只动一个,不然改完不知道是哪个起了作用。

6. 验收与进阶:单图可视化、rotate NMS 调参与合并评测

拿到训完的权重,先别急着跑全量评测,用单图可视化做第一道验收。写一个十几行的脚本,把原图、ground truth 八点多边形、预测框四点分别用不同颜色画上去,重点看三件事:框的朝向是否吻合、长边短边有没有互换、密集目标的框有没有被 NMS 错误抑制。这三条里前两条能对上,说明角度回归和数据链路是通的;第三条要看nms_thr。

旋转框的 NMS 跟水平框不一样,两个框如果中心很近但朝向不同,IoU 可能很小,反而是两个真实目标。DOTA 上常见的nms_thr在 0.05 到 0.1 之间,比水平框的 0.45 低得多。这个值调高了,密集的车辆和船只会被压成一片;调低了,同一个目标会被重复检出好几遍。我一般固定 0.1 起步,看可视化结果再上下微调。

环节参数常见取值影响
推理conf_thres0.05 ~ 0.25高了漏检小目标,低了送入 NMS 的框爆量
NMSnms_thr0.05 ~ 0.15高了密集目标互相抑制
结果合并gap等于切图时的 gap不一致会导致跨切片的同一目标合不上
评测IoU 阈值0.5跟 DOTA 官方口径保持一致

结果合并这一步最容易被跳过。切片上预测出来的坐标是局部的,必须用文件名里的__1024__0___0这类偏移量加回去,恢复成全图坐标,再用 devkit 的合并脚本按 IoU 把跨越切片边界的重复框合成一个。合并时的 IoU 阈值建议跟切图的gap挂钩,重叠区越小这个值要越低。做完这一步再去算 mAP,数字才可信。

进阶方向有两块值得试:一是角度损失的改法,直接回归 θ 在 0/180 边界处天然有断崖,换成把 θ 编码成 sin/cos 两个分量回归,或者用环形平滑标签,边界问题能缓解不少,代价是代码要改好几处;二是多尺度 TTA,在 1024 之外再加一组 1280 的预测,按 IoU 融合,密集小目标的召回通常会涨一两个点,但推理耗时翻倍,得看业务能接受多少延迟。

从我这边几次复现的经验看,旋转框任务里「能跑通」和「出得来结果」之间隔着的从来不是模型结构,而是角度定义、坐标归一化、结果合并这三件枯燥的事。从那以后我每次拿到一个新的 OBB 工程,都会先花半小时把poly2rbox和rbox2poly单独拎出来跑一遍往返校验,再抽三张图把 ground truth 画出来对一眼,这两个动作做在开训之前,能省掉后面至少一天的排查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询