资讯动态

从零手搓旋转目标检测:角度表示、回归与边界问题实战

发布时间:2026/9/11 16:00:28 来源:尧图企业网站定制
1. 为什么非要从零手搓一个旋转目标检测网络1.1 普通目标检测在哪些场景下首先失灵先聊一个我实际遇到的质检项目。当时是检测产线上密集摆放的金属零件零件长宽比大约4比1姿态任意。最开始图省事直接套用YOLOv8的常规检测头。推理结果一出来那个画面基本没法看一个零件往往被三四个水平框同时框住相邻两个零件稍微近一点两个水平框的IoU直接超过0.7NMS一压漏检一大片。更尴尬的是即便框对了下游的机械臂抓取也不知道该以什么角度去吸因为水平框根本给不出姿态信息。这不是YOLO的问题而是水平检测框这个数学表示本身就不适合这类场景。普通目标检测本质上是在预测一个与坐标轴对齐的矩形它只有中心点、宽、高三个自由度。当目标本身带有明显的方向性尤其是长条形、倾斜放置的目标水平框会把大量背景区域包进来。背景一多定位精度下降两个目标重叠区域的IoU虚高后面跟的NMS就会开始误杀。遥感图像里的船舶、飞机、油罐工业场景里的零件、药瓶、电路板文档场景里的表格、印章通通踩这个坑。旋转目标检测就是把水平框升级成带角度信息的旋转框让框的每条边都贴住目标主体的边缘。核心多出来的那一个自由度也就是旋转角θ恰恰是工业项目里最关键的信息。很多看似是“分类难”的问题本质上是“表示不对”的问题换一种框表示整个任务难度会降一个量级。1.2 手搓网络的价值工业落地从来不是调包有人会问既然已经有MMRotate、Rotated-YOLO这些现成工具箱直接拿过来训练不就行了为什么要“从零手搓”我的观点很明确现成框架能帮你快速出demo但帮不了你上线。工业项目里数据是你自己的场景是你自己的标注格式是乱的模型结构需要裁剪后处理需要定制算子需要导出到onnx甚至TensorRT。这些环节只要有一处需要改源码而你只会在config里改参数项目就会卡死在“demo能跑”和“线上能跑”之间。更重要的是旋转目标检测的核心难点比如角度回归的边界问题、旋转框的表示方式、旋转NMS的实现全都藏在数学原理和工程细节里也就是大多数框架封装好的那一层。如果不懂底层遇到loss爆炸、mAP始终上不去这类问题时你连排查方向都没有。所以我这个系列的核心思路就是从数学定义开始不借助任何封装库用PyTorch这样的基础工具一行一行把一个工业级可用的旋转目标检测网络“搓”出来。1.3 卷1「启蒙篇」到底要解决什么问题这第一篇定位为“启蒙”不追求一上来就复现Oriented R-CNN或者S2ANet而是先把旋转目标检测的底座打牢。底座是什么就三件事旋转框的几种数学表示以及它们在代码里如何互相转换旋转框可视化工具先把数据看明白这是后面所有训练的基础从数据集构造到最小网络闭环的最小可运行示例让你亲手跑通一个能输出旋转框的模型。这三件事做完你对旋转目标检测的“手感”就建立起来了。接下来卷2再上FPN、anchor设计、角度回归的工业级处理卷3再做多尺度与其他模型结构演进都是顺水推舟的事。启蒙篇最重要的目标就是让你面对任意一个旋转检测项目时心里对“这个框怎么表示、这个角度怎么学”有一套自己的判断体系而不是只能去抄config。2. 旋转目标的数学表示先别急着写网络2.1 五参数表示法里藏着一个最容易踩的坑旋转框最常用的表示方式是五参数(x, y, w, h, θ)其中(x, y)是中心点坐标w是长边h是短边θ是长边与水平坐标轴的夹角。看起来很简单但这个θ的定义不同框架、不同论文、不同标注工具之间都不一致这是旋转检测领域最隐蔽的坑之一。具体来说有三种常见定义OpenCV系θ范围是[-90°, 0°)表示水平轴顺时针旋转到矩形第一条边即宽边的角度宽和高不做大小区分长边法长边为wθ范围是[-90°, 90°)或[0°, 180°)规定w是长边h是短边θ是长边与x轴的夹角DOTA数据集格式不使用θ而是用有序四点(x1, y1, x2, y2, x3, y3, x4, y4)表示要求点按顺时针排列第一个点是“近似左上角”的顶点。这三套表示各有各的使用场景。OpenCV的表示在图像处理层面很方便因为cv2.boxPoints可以直接拿来画图长边法在模型回归时更好用因为θ对应的边是确定的DOTA四点格式在评估和标注时更直观但网络预测时一般会转换成五参数来回归。我最开始在项目里犯过的错是标注脚本输出的角度是DOTA四点法转出来的0°到180°但训练框架内部用的是长边法[-90°, 90°)两个范围混用了两天模型指标全是乱的。所以启蒙篇第一条教训动手之前把整个pipeline里所有环节的角度定义统一统一到同一种表示并且在代码里显式标注清楚。2.2 长边定义、短边定义与四点坐标工业项目应该怎么选结合工业项目的实际经验我建议按用途分层选表示标注阶段用四点坐标存原始数据为的是兼容所有标注工具也方便人工确认训练阶段内部统一使用长边法五参数因为模型回归角度、宽高时长边法的语义最稳定评估与可视化阶段再把五参数转回四点坐标以便计算多边形IoU和画图。选择长边法的另一个原因是它友善对待宽高比极端的目标。试想一个宽高比5比1的长条目标如果用短边作为θ的参考边角度稍微抖一下框的朝向就偏得离谱。而用长边作参考角度回归的梯度方向更接近目标的真实姿态变化。不过长边法也有它的代价那就是θ超过范围边界时会发生“角度跳变”长边旋转到接近90°时表示同一个框可能会有两种写法一种是θ≈89°、w是长边另一种是θ≈-89°、w和h互换。这个边界问题直接毁了很多人第一次训练旋转检测的体验后面第4节我们会展开讲怎么处理。2.3 角度周期性一个框为什么有无限种数学写法角度是一个周期性量这意味着一个物理上完全相同的旋转框对应无数个数学表示。如果模型直接在原始角度值上做回归就会遭遇到“明明框已经对了loss却巨大”的诡异现象。具体举例假设长边法定义θ范围是[-90°, 90°)有一个真实框θ-89°模型回归计算出θ89°。从数值上看两者差了178°损失函数会给出一个巨大的惩罚但直观上看这两个角度在物理上其实只差2°对应的框几乎重合。问题就出在角度在边界处被“切断”了原本连续的旋转关系被人为切成了两个很远的值。解决这个问题有两条路。第一条是在损失函数层面把角度的周期性考虑进去让0°和360°、-89°和91°在计算距离时保持很小第二条是在表示层面把角度从“一个标量”扩展成“两个标量”也就是下面的sin和cos编码。两条路不是互斥的工业项目里我一般两条都上效果最稳。这个细节先埋在这里马上在第3节给出代码。2.4 手搓一个旋转框可视化脚本先能画出来再说说再多理论都不如先把旋转框画到图上亲眼看一看。这里给一个能直接用的Python工具函数不依赖任何旋转检测库只依赖OpenCV和NumPy。import cv2 import numpy as np def rot_box_to_corners(cx, cy, w, h, angle_deg): 将长边法五参数(中心点cx,cy、长边w、短边h、长边与x轴夹角angle_deg)转为四个角点。 返回的四个角点按顺时针排列。 angle np.deg2rad(angle_deg) # 以中心点为原点先算出四个角点在局部坐标系下的坐标(短边为y方向) dx np.array([ w/2, w/2, -w/2, -w/2]) dy np.array([-h/2, h/2, h/2, -h/2]) # 旋转矩阵 cos_a, sin_a np.cos(angle), np.sin(angle) x cx dx * cos_a - dy * sin_a y cy dx * sin_a dy * cos_a return np.stack([x, y], axis1).astype(np.float32) def draw_rotated_boxes(img, boxes, color(0, 255, 0), thickness2): boxes: N x 5 的数组或列表每行是 [cx, cy, w, h, angle_deg] out img.copy() for box in boxes: pts rot_box_to_corners(*box) pts pts.reshape((-1, 1, 2)).astype(np.int32) cv2.polylines(out, [pts], isClosedTrue, colorcolor, thicknessthickness) return out # 示例生成一张512x512的黑底图画一个中心在(256,256)、长边150、短边50、角度30度的旋转框 img np.zeros((512, 512, 3), dtypenp.uint8) boxes np.array([[256, 256, 150, 50, 30.0]]) out draw_rotated_boxes(img, boxes) cv2.imwrite(rotated_box_demo.png, out)这个脚本的核心是旋转矩阵。在局部坐标系里旋转框的中心在原点长边沿x轴、短边沿y轴分布四个角点坐标非常规整然后用一个标准的二维旋转矩阵把局部坐标变换到图像坐标。这里最需要注意的是图像的y轴方向是向下的所以“顺时针”和“逆时针”的直觉可能会被反转。写代码时不要背公式直接在图上多试几个角度确认画出来的方向和自己的预期一致再往后走。我用这个脚本做的事情是把标注数据全部画一遍目测检查框与目标的贴合度顺便统计所有目标的宽高比和角度分布。这一步在项目早期能帮你发现标注格式错误、角度定义不统一等问题比训练完再去查要省钱得多。3. 手搓第一个旋转目标检测器最小闭环3.1 从检测任务拆解到网络头设计旋转目标检测和水平目标检测在任务拆解上几乎一致都要回答“在哪里”和“是什么”两个问题。“在哪里”的答案从四维(cx, cy, w, h)变成五维(cx, cy, w, h, θ)“是什么”依然是类别概率。所以你完全无需从零设计一个特殊的网络骨架用普通的CNN骨干提取特征在head上多加一个角度回归分支即可。这里我先不引入FPN和复杂的anchor匹配策略因为启蒙篇最重要的是让整个闭环“转起来”。我用一个极简结构输入是单张3通道图骨干用一个4层的简单卷积网络或者直接上torchvision里预训练的resnet18取最后一层特征图在上面接两个HEAD一个是分类头输出每个anchor的类别分数另一个是回归头输出相对于anchor的偏移量(dx, dy, dw, dh, dθ)。考虑到旋转框的特殊性角度回归头我最终输出的是两个分支(sin和cos)而不是直接一个角度值后面解释原因。Anchor的设计暂时按最简单的来每个空间位置预设一个水平anchor宽高比1比1不设多尺度。逻辑上anchor的旋转角初始为0°让网络去回归角度的偏移。这个设置在实际工业项目中显然不够用但作为启蒙实验它能把问题压缩到最小训练速度和排错速度都快。3.2 旋转框的回归目标与损失函数角度是最难搞的一个对于中心点、宽高来说回归目标和水平检测完全一样使用常见的smooth L1损失或者直接L1损失即可。中心点回归的是相对anchor中心点的偏移宽高回归的是对anchor宽高的对数比例。这些都很成熟直接照搬就行。角度回归的处理需要单独设计。直接在原始角度值上做L1损失会撞上前面说的角度周期性问题。我最常用的方案是“sin/cos编码 周期感知损失”的组合拳。sin/cos编码的含义是把待预测的角度值映射到单位圆上的两个坐标值即sin(2θ)和cos(2θ)乘以2的原因是为了让长边法下角度范围[-90°, 90°)正好覆盖单位圆的一整圈消除“同一个框两个表示”带来的歧义。这样网络输出的两个值天然具备周期性不会因为90°边界而跳变。损失函数可以直接对sin和cos各算一个L1损失并相加。另一个替代方案是直接计算两个角度之间的最小弧长差import torch def angular_loss(pred_angle_rad, target_angle_rad): 计算两组角度(弧度)之间的周期感知L1损失。 原理是把角度差投影到[-pi, pi]区间消除周期性跳变。 diff pred_angle_rad - target_angle_rad diff torch.atan2(torch.sin(diff), torch.cos(diff)) return torch.mean(torch.abs(diff))这个损失会在推理阶段直接预测角度值时特别有用但在训练早期的梯度稳定性上不如sin/cos输出分支。在实际项目中我更推荐在head输出sin和cos推理时用atan2还原角度同时可以在训练损失里额外加一个小权重的周期感知角度损失作为辅助。还有一个细节容易忽视回归目标中dw和dh的输出范围需要做归一化dx和dy一般是相对anchor宽高的比值角度不需要归一化到固定范围因为sin/cos输出自然落在[-1,1]区间。如果你直接回归角度值最后一层的激活函数要么不加要么用tanh约束到固定范围千万不能直接裸输出到一个未约束的线性层否则训练初期角度值可能满天飞。3.3 极简可跑的PyTorch训练骨架合成数据版为了让启蒙篇的闭环足够完整我这里构造一个合成数据集每张图是256×256的灰色背景随机放置5到10个旋转矩形目标矩形内部填充不同的纯色矩形中心、长宽、角度全部随机。模型只需要从这样的图中学会把矩形的位置和角度回归出来。虽然数据很简单但它能快速验证整个pipeline里所有代码是否正确。import torch import torch.nn as nn import numpy as np import cv2 class TinyCNN(nn.Module): 极简主干5层卷积 分类头 回归头输出旋转框五参数类别。 def __init__(self, num_classes1): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 16, 3, stride2, padding1), nn.ReLU(), nn.Conv2d(16, 32, 3, stride2, padding1), nn.ReLU(), nn.Conv2d(32, 64, 3, stride2, padding1), nn.ReLU(), nn.Conv2d(64, 64, 3, stride2, padding1), nn.ReLU(), ) # 特征图尺寸为 256 / 2^4 16 x 16 self.cls_head nn.Conv2d(64, num_classes 1, 1) # 含背景类 self.reg_head nn.Conv2d(64, 6, 1) # dx, dy, dw, dh, sin2theta, cos2theta def forward(self, x): feat self.features(x) cls_logits self.cls_head(feat) reg_offsets self.reg_head(feat) return cls_logits, reg_offsets def build_synthetic_batch(batch_size4, img_size256, num_boxes6): 生成合成旋转框数据。返回 - images: (B,3,H,W) 的tensor - targets: list每个元素是 Kx6 的数组 [cx, cy, w, h, theta_rad, label] images np.zeros((batch_size, img_size, img_size, 3), dtypenp.uint8) all_targets [] for b in range(batch_size): img np.full((img_size, img_size, 3), 40, dtypenp.uint8) targets [] for _ in range(num_boxes): cx np.random.uniform(40, img_size-40) cy np.random.uniform(40, img_size-40) w np.random.uniform(20, 80) h np.random.uniform(8, 24) theta np.random.uniform(-np.pi/2, np.pi/2) rect ((cx, cy), (w, h), -np.rad2deg(theta)) color (np.random.randint(120,255), np.random.randint(120,255), np.random.randint(120,255)) box_pts cv2.boxPoints(rect).astype(np.int32) cv2.fillConvexPoly(img, box_pts, color) targets.append([cx, cy, w, h, theta, 0]) images[b] img all_targets.append(np.array(targets)) images_t torch.from_numpy(images.transpose(0,3,1,2)).float() / 255.0 return images_t, all_targets训练的时候对于每个anchor位置我把与目标中心距离最近的anchor视为正样本并且要求目标中心到anchor中心距离小于一个阈值正样本负责回归对应的目标框。分类损失用简单的交叉熵回归损失用上一小节提到的角度相关损失。这里的anchor匹配策略非常简化工业项目里需要用基于IoU的匹配器但启蒙阶段先让闭环转起来才是重点。def compute_loss(cls_logits, reg_offsets, targets): # 简化实现只取与真实目标中心最近的anchor作为正样本其余为背景 # 为了给读者一个完整可跑的最小示例这里的匹配逻辑可以写得更细但这里只给出关键流程 pass训练几个epoch后把预测的旋转框画回原图上如果能看到框的位置慢慢贴近真实目标角度不再乱飘那你的第一个手搓旋转目标检测闭环就正式打通了。3.4 工业级视角从单尺度到特征金字塔上面这个极简闭环能跑通但离工业级还很远。首先单尺度特征图只能捕捉到固定尺度范围的目标一旦图里同时出现长边为20像素和200像素的目标单尺度会损失大量召回。工业级方案几乎无一例外会使用特征金字塔网络FPN把不同层级的特征做融合让每一层负责特定尺度范围的目标。但这不意味着启蒙篇里不用FPN就是错误的选择。恰恰相反我建议所有人在第一次手搓旋转检测器时先做单尺度单anchor。原因有二其一FPN和anchor多尺度会把匹配逻辑变得非常复杂一旦模型不收敛很难定位是角度回归的问题还是匹配逻辑的问题其二工业项目里你接收的数据往往本身就是从固定高度采集的比如无人机在确定高度飞行、产线相机固定在某个位置目标尺度范围远没有DOTA那种公开数据集那么离谱。先跑通再扩展这个次序在工业项目里非常管用。4. 训练中一定会踩的坑角度回归的边界黑洞4.1 角度周期性遇上回归损失loss为什么炸了真正上手之后你大概率会在某个时刻看到这样一幅画面训练loss前几十个iter正常下降突然猛增到原来的十倍随后又慢慢恢复过一会再次猛增。这个现象出现的原因九成是角度跳变。具体来说假设当前预测框的角度是88°真实目标框的角度是-89°两者物理上几乎重合。如果用普通L1损失差值是177°梯度会把预测角度强行往-89°方向拉。问题是路径上有两条路一条是穿过90°边界到-89°距离2°另一条是绕回去到-89°距离177°。普通L1会让网络选择绕远路而这个远路要跨越的数值范围非常大于是出现loss尖峰。在工业项目里如果目标物的角度分布横跨-90°到90°的边界比如零件横着摆、竖着摆都有这个问题会非常突出。对应排查方式很直接在训练过程中把每个batch里角度损失特别大的样本单独打印出来检查是不是都集中在边界附近。如果是基本可以确认是周期性导致的问题。4.2 长边法的边界跳变为什么w和h会瞬间互换长边法还有一个隐藏问题规定w是长边、h是短边之后当目标真实角度在90°附近徘徊时同一个框只需要旋转极小的角度就能让“长边”的定义发生切换此时w和h会瞬间互换。这种不连续的定义对回归问题极不友好。常见解决办法有三种采用“短边法”配合角度范围[-90°, 0°)参考OpenCV的约定w不再是长边而是固定的宽边保持长边法但在损失函数里加入一项周期性惩罚让网络在边界处对“选择哪条边作为长边”不敏感在数据预处理时把所有角度统一映射到[-90°, 90°)并把w始终调整为对应角度的边。这种做法在训练阶段常用但在推理输出时要把角度和宽高一起还原。三种方案里方案一在部署到OpenCV和onnx时最省事因为cv2.boxPoints的默认约定就是短边法负角度方案二在公开数据集上表现更平滑方案三则少有人直接用主要因为推理后处理麻烦。我的建议是你用哪个框架做部署就优先适配哪个框架的约定然后在训练和评估之间做好角度转换。4.3 常见问题速查表启蒙阶段最典型的五个问题为了让后来者少走一些弯路我把启蒙阶段看到频率最高的五个问题整理成表格方便对照排查。问题现象可能原因排查方式与解决办法训练loss一直不降cls损失正常reg损失不降回归目标没有归一化角度范围不匹配检查dx/dy/dw/dh是否归一化到合理区间检查角度是否统一到同一范围loss偶发大尖峰角度周期边界跳变切换到sin/cos输出分支或使用周期感知角度损失预测框位置正确但整体乱转角度回归未收敛或sin/cos输出后atan2用错单独可视化一个batch的角度误差检查atan2分支顺序是否与编码顺序一致训练时mAP不错NMS后严重漏检使用了水平NMS旋转框之间重叠高替换为旋转框IoU计算和旋转NMS标注看起来没问题但训练后框整体偏小一圈训练时用短边法表示推理输出未转换回标注格式统一整个pipeline的宽高定义尤其在格式转换函数里写清楚“w是长边还是宽边”启蒙阶段遇到问题不要慌先看数据定义再看代码里的角度转换最后才看模型结构。我处理过的绝大多数错误最后都定位在数据格式和角度定义上而不是模型设计上。5. 工业落地的工具链与选型经验5.1 标注工具与格式工程质量从标注阶段开始旋转目标检测的标注工具现在比较好用的有X-AnyLabeling和Label Studio两者都支持旋转矩形标注。更传统一点的是roLabelImg功能少但轻量稳定。我的经验是不纠结工具本身重点是导出格式一定要统一到有序四点坐标这样无论后续转成DOTA格式、五参数格式还是其他专用格式都有干净的数据源。工业项目里标注环节最容易出的问题是标注员用四点标注时四个点的顺序没有统一规则比如有的人从左上开始顺时针有的人从右上开始逆时针。这个问题会导致训练代码里“第一个点是左上角”的假设完全失效。应对办法是写一个标准化脚本根据四点的凸包顺序统一重排再用面积正负号判断方向把所有点整理成一致的顺时针顺序。这个小脚本值得在项目第一天就写出来。5.2 评估指标DOTA格式下的mAP和你想象的mAP不一样公开数据集评测旋转检测模型最常用的指标是基于DOTA格式的mAP其中IoU计算的是两个多边形四边形的交并比不是普通矩形框IoU。很多人第一次跑DOTA测试集时发现分数比预期低很多就是因为多边形IoU对框的贴合度要求比水平框严格得多。工业项目如果不打公开榜单我建议直接按业务口径定义指标比如抓取场景下看“角度误差小于2°的前提下中心点误差小于5像素”视为成功。这比mAP更能反映产线上的实际效果。但无论用什么指标都要注意和“旋转NMS”配合使用否则最终结果可能因后处理过于激进或保守而与训练时的评估曲线大相径庭。旋转NMS和水平NMS的区别在于计算两个框的IoU时需要先计算两个任意四边形的交集多边形面积再用交集面积除以并集面积。这个计算要用到多边形裁剪算法OpenCV里有cv2.intersectConvexConvex可以处理凸多边形的情况。旋转框都是凸四边形所以够用。5.3 工程师视角训练完到部署上线还差哪些东西训练出能画框的模型只是第一步。工业项目要上产线至少还要补三件事模型导出PyTorch模型转ONNX时旋转角度的sin/cos分支可以原样导出但atan2算子在部分推理引擎上支持得不够好我通常把atan2放到后处理代码里做用CPU算那几千个框的角度几乎不占耗时部署后处理旋转NMS要改成自己实现的C或Python版本不能继续依赖训练框架里的库精度校准旋转框对输入图像的缩放比水平框更敏感缩放会改变角度与边长比例。如果训练时做了512输入部署时为了速度改成416最好重新评估一次角度误差而不是只看mAP。这些内容属于卷3的范畴启蒙篇先点到为止。你现在只需要记住手搓网络的意义就在于上面这些环节你是理解着去做的而不是遇到了才去翻源码。6. 关于“从零手搓”这件事我的几点体会写到这里启蒙一的核心内容就差不多结束了。最后想闲聊几句。我在最初接触旋转目标检测时也踩过“拿来就用”的坑。当时项目催得紧我直接用了开源库demo跑得很好模型却怎么都训不上线每天浪费在调参上的时间比重新手搓一遍还多。后来耐住性子从旋转框的数学定义一点点捋才发现问题根源竟然是标注脚本的角度定义和训练库不一致。那之后我就养成了一个习惯凡是涉及旋转检测的项目无论工期多紧第一周一定先把“画框、转格式、算IoU”这些底层工具全部手写一遍哪怕最后不用也要用它们把数据和定义验一遍。你也不用害怕“从零手搓”看起来工程量大。事实上旋转目标检测的骨架、neck、head设计和水平目标检测高度相似你完全可以复用已有的知识储备真正需要从零理解的就是角度表示、角度回归和旋转后处理这几个部位。把这几块啃透整个领域对你来说就没有黑盒。卷1的启蒙一到这里先停。下一篇我会继续讲anchor匹配与标签分配在旋转框下的特殊处理那是从“能跑”走向“能用”的关键一役。需要提前预习的朋友可以先把我上面那个可视化脚本放到自己的数据上多跑几遍看看不同角度定义画出来的框到底长什么样有了直观感受后面讲什么你都接得住。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价