简介:这份资源面向计算机视觉开发者与目标检测学习者,提供基于Python与YOLOv5的旋转目标检测完整实现,用于解决倾斜、旋转物体难以用常规水平矩形框精确定位的问题,适用于遥感影像、航拍、文字检测等场景。压缩包共150个文件,约6.26MB,以66个py脚本和33个yaml配置为主,涵盖模型训练、推理与参数配置;同时包含cpp、cu、h、hpp等C++/CUDA源码,用于旋转框NMS与多边形IoU的加速计算,另有md说明、sh脚本、Dockerfile及少量图片与字体文件,工程结构较为完整。资源已有852人学习下载。内容围绕Oriented Bounding Box展开,涉及角度回归、旋转数据增强、GIOU/DIoU损失调整、优化器与学习率调度、旋转NMS后处理等关键环节,读者可据此搭建训练与评估流程,理解旋转检测从数据标注到推理部署的完整链路,并借助CUDA算子实现高效计算。
1. 旋转框检测到底难在哪:从 YOLOv5 的轴对齐框说起
普通 YOLOv5 输出的是轴对齐矩形框,也就是xywh四个值,框的边永远平行于图像坐标轴。这个假设在多数通用检测场景里够用,但一旦目标本身带方向——遥感影像里的舰船、航拍里的飞机、工业质检里的 PCB 板、密集排列的货架商品——轴对齐框就会把大量背景像素裹进来,两个相邻目标的外接矩形还会大面积重叠,NMS 一压就丢目标。旋转目标检测要解决的就是这个问题:让模型直接回归带角度的框,用xywha或四点坐标描述目标,把框紧紧贴在目标真实朝向上。
这篇讲的是基于 Python 的 YOLOv5 实现旋转目标检测,从环境配置、数据标注格式、角度回归头的改法,到训练参数、后处理旋转 NMS、部署推理,走一遍能复现的路径。适合已经跑通过原版 YOLOv5、想把手里的检测任务升级到带角度框的从业者,也适合刚配好 conda 和 vscode python 环境、想找一个有明确落地价值的方向练手的人。旋转框不是换个 loss 就完事,标注、角度定义、损失函数、后处理每一环都有坑,下面按顺序拆。
2. 旋转框的表示与 YOLOv5 改造点:先想清楚角度怎么定义
2.1 三种旋转框表示法,选错一个后面全乱
旋转框主流有三种表示:OpenCV 的(cx, cy, w, h, angle)、四点(x1,y1,x2,y2,x3,y3,x4,y4)、以及长边定义的(cx, cy, longside, shortside, theta)。YOLOv5 的旋转版本(社区常见做法是在 Detect 头基础上加一个角度分支)大多采用(cx, cy, w, h, angle),其中 angle 的取值范围直接决定训练稳不稳。
| 表示法 | 角度范围 | 优点 | 坑 |
|---|---|---|---|
| OpenCV 定义 | [-90, 0) | 与 cv2.minAreaRect 一致 | 边界跳变,w/h 会互换 |
| 长边定义 | [-90, 90) | 角度连续,回归稳定 | 需要额外判断长边 |
| 四点回归 | 无角度 | 表达能力强 | 损失难设计,易自交 |
我一般用长边定义,把 w 固定为长边、h 为短边,angle 落在[-90, 90)。这样角度在边界处不会因为 w/h 互换产生 180 度跳变,回归头学起来平滑得多。如果你直接用 cv2.minAreaRect 的输出喂进去,它给的是[-90, 0)且 w/h 不保证长短边,训练时 loss 会周期性炸一下,这就是很多人说的玄学不收敛。
2.2 角度回归头的加法与损失选择
原版 YOLOv5 的 Detect 头每个 anchor 输出(tx, ty, tw, th, obj, cls...)。旋转版常见做法是在回归分支多加一个ta,输出角度。加完之后 head 的输出通道从na * (5 + nc)变成na * (6 + nc)。改的地方集中在models/yolo.py的 Detect 类和models/common.py,以及 loss 里的 box loss。
角度回归的损失有两种主流选择:一是把角度当普通回归量用 SmoothL1,二是用 CSL(Circular Smooth Label)把角度分类化。CSL 把 180 度离散成 180 个 bin,用交叉熵学,能缓解边界问题,但推理时要解码回连续角度,且 bin 数是个超参。我实测在数据量中等(几千张)时 SmoothL1 配合长边定义已经够用,CSL 更适合角度精度要求极高的遥感场景。
# 角度归一化:把任意角度映射到 [-90, 90) import math def normalize_angle(theta): # theta 单位:度 while theta >= 90: theta -= 180 while theta < -90: theta += 180 return theta def longside_form(rect): # rect: cv2.minAreaRect 返回的 ((cx,cy),(w,h),angle) (cx, cy), (w, h), angle = rect if w < h: w, h = h, w angle += 90 angle = normalize_angle(angle) return cx, cy, w, h, angle这段代码做两件事:normalize_angle把角度压到[-90, 90),longside_form保证 w 是长边。参数上注意angle += 90是在交换 w/h 后补偿的,漏掉这步角度就整体偏 90 度,训练 loss 会卡在一个高位下不去。标注转换、数据增强、后处理三处都要用同一个函数,任何一处用了不同定义,结果就是框看着对但 mAP 死活上不去。
2.3 数据标注:DOTA 格式与 YOLO 旋转格式互转
旋转框标注常用 DOTA 格式,每行是x1 y1 x2 y2 x3 y3 x4 y4 category difficult。YOLOv5 旋转版训练一般要转成class cx cy w h angle的归一化格式。转换脚本要处理归一化和角度定义两件事。
import os import cv2 import numpy as np def dota_to_yolo_rot(dota_line, img_w, img_h): parts = dota_line.strip().split() coords = list(map(float, parts[:8])) cls_name = parts[8] pts = np.array(coords, dtype=np.float32).reshape(4, 2) rect = cv2.minAreaRect(pts) # 得到 ((cx,cy),(w,h),angle) cx, cy, w, h, angle = longside_form(rect) # 归一化到 0-1 cx /= img_w; cy /= img_h w /= img_w; h /= img_h angle = angle / 90.0 # 归一到 [-1,1) return f"{cls_id(cls_name)} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f} {angle:.6f}"逻辑说明:先用cv2.minAreaRect求最小外接旋转矩形,再过longside_form统一成长边定义,最后把 cx、cy、w、h 按图像宽高归一化,角度除以 90 映射到[-1,1)。参数上angle / 90.0这个缩放要和训练时角度分支的解码保持一致,训练代码里如果乘回 90 而这里没除,角度就差了 90 倍。转换完建议抽 20 张用 cv2 画出来肉眼核对,别信脚本一次就对。
3. 环境配置与训练跑通:conda、数据集、超参一次说清
3.1 conda 建环境与依赖安装
先建独立环境,别和系统 python 混。YOLOv5 对 torch 版本敏感,旋转版还依赖 opencv 和 pycocotools。
conda create -n yolov5_rot python=3.8 -y conda activate yolov5_rot # 按显卡 CUDA 版本装 torch,这里以 cu118 为例 pip install torch==1.13.1+cu118 torchvision==0.14.1+cu118 -f https://download.pytorch.org/whl/torch_stable.html pip install opencv-python pycocotools matplotlib tqdm scipy参数说明:python 3.8 是兼容性最稳的版本,3.10 以上部分旧版 torch 轮子找不到。torch 版本要和 CUDA 驱动匹配,nvidia-smi右上角显示的 CUDA Version 是驱动支持上限,装不超过它的版本。装完用python -c "import torch; print(torch.cuda.is_available())"验证,返回 False 就先查驱动和 torch 版本,别急着往下走。
3.2 数据集目录与 yaml 配置
YOLOv5 要求的数据集结构是 images/labels 分开,train/val 分目录。旋转版 label 每行 6 个值,比原版多一个角度。
# data/rot_dataset.yaml path: /data/rot_dataset train: images/train val: images/val nc: 4 names: ['ship', 'plane', 'storage-tank', 'harbor']注意nc和names长度必须一致,names 顺序要和标注转换时cls_id的映射完全对应。我见过把 names 顺序写反导致模型学出来的类别全错位的情况,loss 正常下降但验证时框的类别乱跳,排查半天才发现是 yaml 里顺序错了。
3.3 训练命令与关键超参
python train.py \ --data data/rot_dataset.yaml \ --cfg models/yolov5s_rot.yaml \ --weights yolov5s.pt \ --epochs 300 \ --batch-size 16 \ --imgsz 1024 \ --hyp data/hyp.rot.yaml \ --device 0参数说明:--imgsz 1024是旋转检测的常见选择,遥感和小目标场景分辨率低了角度信息损失严重;--batch-size按显存调,1024 输入下 16 大概要 12G 显存,不够就降到 8 并开--accumulate。--weights用 COCO 预训练权重初始化主干,旋转头是随机初始化的,前几个 epoch loss 偏高正常。--hyp里重点调box、cls、obj三个 loss 权重,旋转任务里 box loss 权重可以适当调高,因为角度回归误差对最终 IoU 影响大。
训练过程中盯三个指标:box_loss是否稳定下降、val/mAP_0.5是否上升、以及角度分支的单独 loss(如果代码里拆出来了)。如果 box_loss 降到某个值就不动,大概率是角度定义和标注不一致,回去查 2.1 节。
4. 旋转 NMS 与后处理:轴对齐那套直接搬会丢框
4.1 为什么普通 NMS 在旋转框上会翻车
普通 NMS 用轴对齐 IoU 算重叠度。两个斜着的相邻目标,它们的外接轴对齐矩形重叠可能超过 0.5,但真实旋转框几乎不重叠,普通 NMS 会把其中一个当冗余压掉。密集场景下这个问题尤其明显,召回率直接掉一截。旋转检测必须用旋转 IoU 做 NMS。
4.2 旋转 IoU 的两种实现
旋转 IoU 没有闭式解,主流两种做法:一是用 shapely 做多边形交集,二是用 OpenCV 的cv2.rotatedRectangleIntersection。前者精度高但慢,后者快但边界情况要处理。
import cv2 import numpy as np def rotated_iou(box1, box2): # box: (cx, cy, w, h, angle) angle 单位度 r1 = ((box1[0], box1[1]), (box1[2], box1[3]), box1[4]) r2 = ((box2[0], box2[1]), (box2[2], box2[3]), box2[4]) inter_type, inter_pts = cv2.rotatedRectangleIntersection(r1, r2) if inter_type == cv2.INTERSECT_NONE: return 0.0 inter_area = cv2.contourArea(inter_pts) area1 = box1[2] * box1[3] area2 = box2[2] * box2[3] return inter_area / (area1 + area2 - inter_area + 1e-7)逻辑说明:rotatedRectangleIntersection返回交集类型和交点,contourArea算交集面积,最后按标准 IoU 公式算。参数上1e-7防除零。注意rotatedRectangleIntersection在 OpenCV 不同版本对相切、包含等边界情况返回不一致,生产环境建议加一层 shapely 兜底,或者直接用mmcv.ops.nms_rotated这类成熟实现,别自己造轮子。
4.3 后处理完整流程
推理输出是(batch, num_anchors, 6+nc),后处理顺序是:解码 xywh 和 angle、按 obj 阈值筛、按类别做旋转 NMS、映射回原图坐标。
def decode_and_nms(pred, conf_thres=0.25, iou_thres=0.45): # pred: (num_anchors, 6+nc) obj = pred[:, 4] mask = obj > conf_thres pred = pred[mask] if pred.shape[0] == 0: return [] # 解码:cx,cy,w,h 是相对 grid 的偏移,angle 是 [-1,1) boxes = decode_boxes(pred) # 返回 (cx,cy,w,h,angle) scores = pred[:, 5:].max(1) classes = pred[:, 5:].argmax(1) keep = [] for c in classes.unique(): idx = (classes == c).nonzero().squeeze(1) boxes_c = boxes[idx] scores_c = scores[idx] order = scores_c.argsort(descending=True) while order.numel() > 0: i = order[0] keep.append(idx[i].item()) if order.numel() == 1: break ious = torch.tensor([rotated_iou(boxes_c[i], boxes_c[j]) for j in order[1:]]) order = order[1:][ious < iou_thres] return keep参数说明:conf_thres和iou_thres是后处理两个核心阈值,旋转场景下iou_thres通常比轴对齐调低一点(0.4 左右),因为旋转 IoU 本身数值偏小。decode_boxes里角度要乘回 90 还原成度,这一步和 2.3 节标注时的/90对应。这段逐类 NMS 用 python 循环写便于理解,实际部署要换成向量化或 C++ 实现,否则帧率上不去。
5. 避坑与排查:旋转检测最容易栽的五个地方
5.1 训练 loss 不降或周期性震荡
现象:box_loss 在某个值附近来回跳,mAP 不涨。原因:角度定义在标注、增强、损失三处不一致,或者用了 OpenCV 的[-90,0)定义导致边界跳变。解决:统一用长边定义[-90,90),写一个normalize_angle函数在三处都调用,转换后抽图核对。
5.2 验证时框位置对但角度整体偏 90 度
现象:可视化出来框能框住目标,但长宽方向反了。原因:longside_form里交换 w/h 后忘了angle += 90补偿,或者标注转换和推理解码用了不同的长短边约定。解决:检查 2.2 节那段代码,确保交换和补偿成对出现,推理解码时也走同一个函数。
5.3 密集场景召回率明显偏低
现象:单目标检测正常,密集排列时漏检多。原因:还在用轴对齐 NMS,相邻目标外接矩形重叠被误压。解决:换成旋转 IoU NMS,iou_thres从 0.5 降到 0.4 左右试,同时检查 anchor 尺寸是否匹配目标长宽比。
5.4 显存爆掉或 batch 上不去
现象:--imgsz 1024时 OOM。原因:旋转头多了一个输出通道,激活值比原版大,加上高分辨率输入。解决:降 batch 并开梯度累积,或者用--imgsz 768先跑通再往上加。混合精度--amp能省不少显存,但角度回归在 fp16 下可能精度损失,建议先 fp32 跑通再试 amp。
5.5 部署时后处理耗时占比过高
现象:模型推理 20ms,后处理 80ms。原因:python 循环逐对算旋转 IoU,anchor 多的时候是 O(n²)。解决:先按 obj 阈值大幅筛掉低分框再做 NMS,或者换mmcv.ops.nms_rotated、TensorRT 的旋转 NMS 插件。树莓派 5 这类边缘设备上部署自己训练的 yolov5 旋转模型时,后处理建议直接上 C++ 或 ONNX Runtime 的自定义算子,python 那套只能用来验证。
6. 进阶:把角度分支换成 CSL 并验证角度精度
前面用的是 SmoothL1 直接回归角度,简单但角度精度有上限。如果场景对角度敏感——比如遥感舰船朝向、工业零件装配角度——可以换成 CSL(Circular Smooth Label)。做法是把 180 度离散成 180 个 bin,角度分支输出 180 维,用交叉熵学,推理时取期望或最大值解码回连续角度。
改造点在 head 和 loss 两处。head 里角度分支输出维度从 1 变成 180;loss 里角度部分从 SmoothL1 换成 CrossEntropy,且要对标签做 one-hot 加高斯窗平滑,窗口半径是个超参,一般取 6 左右。
import torch import torch.nn.functional as F def csl_loss(angle_pred, angle_gt, radius=6): # angle_pred: (N, 180) logits # angle_gt: (N,) 单位度,范围 [-90, 90) bins = torch.arange(180, device=angle_gt.device).float() - 90 # [-90, 90) diff = (bins.unsqueeze(0) - angle_gt.unsqueeze(1)).abs() diff = torch.where(diff > 90, 180 - diff, diff) # 环形距离 target = torch.exp(-(diff ** 2) / (2 * radius ** 2)) target = target / target.sum(1, keepdim=True) return -(target * F.log_softmax(angle_pred, dim=1)).sum(1).mean()逻辑说明:bins是 180 个角度中心,diff算预测 bin 和真值的环形距离(超过 90 度取补角),高斯窗生成软标签,最后算交叉熵。参数radius控制软标签宽度,太小退化成硬标签、边界不连续,太大角度分辨率下降,6 是常见起点。换 CSL 后训练前期 loss 会比 SmoothL1 高,因为交叉熵量级不同,别慌,看 mAP 就行。
验证角度精度不能只看 mAP,要单独统计角度误差。做法是把验证集预测框和真值框按中心距离匹配上,算角度差的绝对值,画直方图。如果误差集中在 0 附近说明角度学得好,如果双峰分布说明有 180 度歧义,回去查长边定义。我自己的习惯是每换一次角度表示或损失,都先跑 20 个 epoch 看角度误差直方图,比等 300 epoch 再看 mAP 省时间。旋转检测这行,角度定义统一比模型结构重要得多,血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取