首页 / 资讯中心 / 文章详情

CSL旋转框检测实战:从角度回归边界问题到分类方案落地

CSL旋转框检测实战:从角度回归边界问题到分类方案落地 ★ FEATURED ARTICLE
1. 旋转框检测到底在解决什么实际问题遥感图像里的目标检测和自然场景下的检测有一个根本性差异绝大多数目标不是水平摆放的。飞机停在停机坪上有各种朝向舰船在港口里斜靠着码头储油罐排列方向各异桥梁更是横跨任意角度。如果你用常规的水平边界框去框这些目标框里会混入大量背景像素框与框之间还会严重重叠。这个问题在密集场景下会急剧恶化。举个例子一个港口里并排停着十几艘不同朝向的舰船水平框几乎全部交叠在一起非极大值抑制要么把正确检测全删掉要么保留一堆冗余框。旋转框检测就是为了解决这个痛点——让边界框能够贴合目标的真实朝向用带角度的矩形去描述目标的位置和姿态。CSL这个方法在旋转目标检测领域算是一个比较有代表性的思路。它的全称是Circular Smooth Label核心思路是把角度预测这个回归问题转化成分类问题来处理。我第一次接触这个方法的时候觉得思路很巧妙但实际复现过程中踩了不少坑。这篇文章我会从问题本质、方法原理、代码实现、训练调参、实际部署几个维度把整个方法拆开讲清楚。这篇文章适合谁看如果你正在做遥感图像相关的检测任务或者你对旋转框检测的回归方法比如直接回归角度、IoU-based损失已经有所了解但效果不理想想尝试分类式的角度预测方案那这篇内容应该能给你不少参考。如果你是完全的新手建议先补一下旋转框的基本表示方法OpenCV定义、长边定义等不然有些细节可能会看得比较吃力。注意本文涉及的代码示例基于常见的深度学习框架和开源检测工具箱的通用接口具体API名称可能因版本不同而有差异请以你实际使用的版本为准。2. 角度回归的边界不连续问题CSL要解决的核心矛盾2.1 为什么直接回归角度会出问题旋转框的表示通常是五参数中心点坐标(x, y)、宽w、高h、角度θ。前四个参数的回归和普通水平框没有本质区别问题出在角度θ上。角度有一个天然的特性周期性。0度和180度在几何上表示的是同一个方向对于矩形框来说转180度后框完全重合。但如果你用L1或smooth L1损失直接回归角度值网络会认为0和180是两个完全不同的值预测成179度时损失会非常大尽管它和真实值0度在几何上只差1度。这就是所谓的边界不连续问题boundary discontinuity。具体来说有两种情况角度周期性问题θ和θ180°表示同一个框但回归损失不感知这个周期性。宽高交换问题把宽和高互换、角度加90度表示的也是同一个框。比如(w10, h5, θ30°)和(w5, h10, θ120°)是同一个矩形。这两个问题叠加在一起导致基于回归的方法在训练时损失函数会出现突变模型很难收敛到一个稳定的状态。你可能会观察到损失突然飙升或者模型预测的角度在边界附近来回震荡。2.2 常见解决方案的对比在CSL之前社区里已经有一些应对方案我整理了一个对比表格方法核心思路优点缺点直接回归角度用L1/L2损失直接预测θ实现简单边界不连续收敛困难回归(sinθ, cosθ)预测角度的三角函数值天然处理周期性宽高交换问题仍在且需要额外处理IoU-based损失用旋转IoU作为损失与评价指标一致旋转IoU计算复杂梯度不稳定高斯分布建模用高斯分布表示旋转框可微分端到端超参数敏感调参困难CSL分类方案把角度离散化后做分类彻底避免边界问题需要精细设计窗口函数和超参数CSL的思路很直接既然回归角度会碰到边界不连续那我干脆不回归了把角度分成若干个离散的类别用分类的方式来做。这样0度和179度就是两个不同的类别分类损失不会因为它们在数值上接近而产生问题。但这里有一个新的问题分类的输出是离散的而角度的预测需要是连续的。如果只取分类概率最大的那个类别作为预测角度精度会受到离散化间隔的限制。比如把180度分成180个类别每个类别间隔1度那预测精度最多就是1度。对于高精度检测任务来说这个精度可能不够。CSL的核心创新就在于设计了一个圆形平滑标签Circular Smooth Label让分类器在训练时不仅学习到正确的角度类别还能学习到相邻类别之间的平滑关系从而在推理时可以通过期望计算得到连续的角度预测值。2.3 窗口函数的设计与选择CSL的标签不再是one-hot形式而是一个以真实角度为中心、向两侧衰减的平滑分布。这个衰减的形状由窗口函数决定。原文中尝试了几种窗口函数脉冲函数就是one-hot退化为普通分类。矩形函数在真实角度附近一定范围内均匀分布。三角窗从中心向两侧线性衰减。高斯窗从中心向两侧高斯衰减。实测下来高斯窗和三角窗的效果比较好。高斯窗的公式是CSL(x) exp(-(x - θ)² / (2σ²))其中x是离散化的角度值θ是真实角度σ控制衰减速度。σ越大标签越平滑相邻类别获得的正信号越多σ越小标签越接近one-hot。这里有一个关键的超参数窗口半径r。它决定了在真实角度两侧多少个类别内计算平滑标签超出范围的类别标签直接置零。r和σ需要配合调整一般来说r取6到9之间σ取r的一半左右比较合适。提示窗口半径和σ的选择对最终精度影响很大。r太小平滑效果不明显退化为普通分类r太大标签过于模糊分类器难以区分相近的角度。建议在你的数据集上做一组消融实验来确定最优值。3. CSL的标签生成与网络输出层改造3.1 角度离散化的具体实现假设我们把180度分成180个类别每个类别间隔1度。对于真实角度θ它的类别索引是import numpy as np def angle_to_class(angle, num_classes180): 将角度值转换为类别索引角度范围[0, 180) angle angle % 180 cls_idx int(np.floor(angle / 180.0 * num_classes)) return min(cls_idx, num_classes - 1)然后生成CSL标签def generate_csl_label(angle, num_classes180, radius6, sigma3): 生成圆形平滑标签 angle angle % 180 target angle / 180.0 * num_classes cls_idx int(np.floor(target)) label np.zeros(num_classes, dtypenp.float32) for i in range(-radius, radius 1): idx (cls_idx i) % num_classes # 循环取模保证周期性 dist abs(i) label[idx] np.exp(-(dist ** 2) / (2 * sigma ** 2)) # 归一化使标签和为1 label_sum label.sum() if label_sum 0: label label / label_sum return label注意这里的% num_classes操作它保证了标签在0度和179度附近也是连续的。比如真实角度是1度那么类别0和类别179都会获得一定的标签值这正是圆形的含义。3.2 损失函数的选择CSL本质上是一个分类任务所以理论上可以用标准的交叉熵损失。但这里有一个细节CSL标签是软标签soft label不是one-hot。PyTorch的CrossEntropyLoss默认支持软标签但需要确保输入是logitstarget是概率分布。import torch import torch.nn as nn class CSLLoss(nn.Module): def __init__(self): super().__init__() self.ce nn.CrossEntropyLoss() def forward(self, pred_logits, target_label): # pred_logits: (N, num_classes) # target_label: (N, num_classes) 软标签 log_prob torch.log_softmax(pred_logits, dim1) loss -(target_label * log_prob).sum(dim1).mean() return loss如果你用的检测框架不支持软标签也可以把CSL标签转成one-hot但这样会损失平滑信息精度会有一定下降。我建议尽量用软标签版本。3.3 推理阶段的角度解码训练时用软标签推理时怎么得到连续的角度值CSL的做法是计算期望def decode_angle(pred_logits, num_classes180): 从分类logits解码出连续角度 prob torch.softmax(pred_logits, dim1) # 计算期望 angles torch.arange(num_classes, dtypetorch.float32, devicepred_logits.device) angles angles / num_classes * 180.0 expected_angle (prob * angles).sum(dim1) return expected_angle这里有一个容易忽略的细节由于角度的周期性直接计算期望可能会在0度/180度边界处出现问题。比如真实角度接近0度分类器可能在179度和1度附近都有较高的概率直接加权平均会得到一个接近90度的错误结果。解决办法是先把概率分布对齐到真实角度附近再做期望计算。具体来说可以找到概率最大的类别然后把其他类别的角度值调整到与它相差不超过90度的范围内。def decode_angle_robust(pred_logits, num_classes180): 更稳健的角度解码处理周期性边界 prob torch.softmax(pred_logits, dim1) max_idx prob.argmax(dim1) angles torch.arange(num_classes, dtypetorch.float32, devicepred_logits.device) angles angles / num_classes * 180.0 # 将角度调整到以max_idx为中心的范围 center_angle angles[max_idx] adjusted_angles angles.unsqueeze(0) - center_angle.unsqueeze(1) adjusted_angles (adjusted_angles 90) % 180 - 90 adjusted_angles adjusted_angles center_angle.unsqueeze(1) expected_angle (prob * adjusted_angles).sum(dim1) % 180 return expected_angle这个细节在实际复现时非常关键我当初就是没注意这一点导致模型在0度附近的目标上检测精度异常低排查了很久才发现是解码的问题。4. 从零搭建CSL检测头的完整流程4.1 骨干网络与特征金字塔的搭配CSL本身只是一个角度预测的方案它需要挂在一个完整的检测框架上。常用的搭配是RetinaNet或FCOS这类单阶段检测器骨干网络用ResNet50或ResNet101加上FPN做多尺度特征融合。为什么选单阶段检测器因为旋转框检测通常面对的是密集目标场景两阶段检测器的ROI Pooling在旋转框上操作比较麻烦而单阶段检测器直接在特征图上做密集预测天然适合旋转框的输出形式。FPN的层数一般取5层P3到P7对应的stride是8、16、32、64、128。遥感图像中目标尺度变化很大从几米的小车到几百米的机场跑道都有多尺度特征金字塔是必须的。4.2 检测头的输出通道设计对于每个anchor point检测头需要输出以下内容分类分支每个类别的置信度通道数 num_classes回归分支中心点偏移(dx, dy)、宽高(dw, dh)通道数 4角度分支CSL分类logits通道数 num_angle_classes通常180总输出通道数 num_classes 4 180。如果类别数是15常见的遥感数据集类别数那总通道就是199。class CSLHead(nn.Module): def __init__(self, in_channels, num_classes, num_angle_bins180): super().__init__() self.num_classes num_classes self.num_angle_bins num_angle_bins # 共享的卷积层 self.shared_conv nn.Sequential( nn.Conv2d(in_channels, 256, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, 3, padding1), nn.ReLU(inplaceTrue), ) # 分类分支 self.cls_head nn.Conv2d(256, num_classes, 3, padding1) # 回归分支 self.reg_head nn.Conv2d(256, 4, 3, padding1) # 角度分支 self.angle_head nn.Conv2d(256, num_angle_bins, 3, padding1) # 初始化偏置避免训练初期角度预测均匀分布 self.angle_head.bias.data.fill_(0) def forward(self, x): feat self.shared_conv(x) cls_out self.cls_head(feat) reg_out self.reg_head(feat) angle_out self.angle_head(feat) return cls_out, reg_out, angle_out4.3 Anchor的设计与匹配策略旋转框的anchor设计和水平框类似但需要考虑角度维度。常见做法是在每个特征图位置上设置多个尺度和长宽比的anchor角度方面可以设置多个初始角度比如0°、30°、60°、90°、120°、150°也可以只用0°然后靠网络预测角度偏移。我实测下来用多角度anchor的效果并不比单角度anchor好多少反而增加了计算量。因为CSL本身就是在做角度分类网络有能力从单角度anchor预测出任意角度。所以建议只用0°anchor把精力放在尺度和长宽比的设计上。匹配策略用标准的IoU匹配就行但注意旋转IoU的计算比水平IoU复杂。如果嫌麻烦也可以用中心点距离匹配作为近似但精度会有损失。4.4 训练流程与损失权重总损失是分类损失、回归损失、角度CSL损失的加权和def compute_total_loss(cls_pred, reg_pred, angle_pred, cls_target, reg_target, angle_target, alpha1.0, beta1.0, gamma1.0): # 分类损失Focal Loss cls_loss focal_loss(cls_pred, cls_target) # 回归损失smooth L1只对正样本计算 pos_mask cls_target 0 reg_loss smooth_l1_loss(reg_pred[pos_mask], reg_target[pos_mask]) # 角度损失CSL软标签交叉熵 angle_loss csl_loss(angle_pred[pos_mask], angle_target[pos_mask]) total_loss alpha * cls_loss beta * reg_loss gamma * angle_loss return total_loss损失权重的设置很关键。我试过几组不同的权重最终发现alpha1.0、beta1.0、gamma2.0的效果比较好。角度损失的权重稍微大一点因为角度预测的难度比中心点和宽高回归要高。注意训练初期角度损失可能会比较大因为网络还没学会预测角度。这时候如果gamma设得太大梯度会被角度损失主导影响其他分支的学习。可以考虑用warm-up策略前几个epoch把gamma设小一点后面再逐渐增大。5. 实际训练中遇到的坑与调参经验5.1 角度损失震荡不收敛这是我最开始遇到的最头疼的问题。训练了几十个epoch角度损失一直在高位震荡检测出来的框角度完全随机。排查过程是这样的首先检查标签生成是否正确把生成的CSL标签可视化出来看发现标签分布是正常的。然后检查损失函数发现用的是标准的交叉熵但输入是logitstarget是软标签这里需要手动做log_softmax。确认这部分没问题后又检查了解码逻辑发现推理时直接算期望确实会在边界处出问题。最终定位到两个原因一是学习率太大角度分支的梯度爆炸二是没有做梯度裁剪。解决办法是把角度分支的初始学习率调小到主学习率的0.1倍同时加上梯度裁剪max_norm10。调整之后角度损失在10个epoch左右就稳定下降了。5.2 小目标的角度预测精度差遥感图像里小目标很多比如车辆、小船。这些目标在特征图上的响应区域很小角度信息容易丢失。我试过几种改进方案提高特征图分辨率把P3的stride从8改成4增加小目标的特征响应。效果有提升但计算量翻倍。增加角度分支的通道数从180增加到360精度提升不明显反而增加了过拟合风险。在损失函数中对小目标加权根据目标面积设置权重小目标的权重更大。这个方案效果最好小目标的角度误差降低了约30%。def compute_angle_weight(gt_boxes): 根据目标面积计算角度损失权重 areas gt_boxes[:, 2] * gt_boxes[:, 3] # w * h median_area areas.median() weights torch.clamp(median_area / (areas 1e-6), 0.5, 3.0) return weights5.3 类别不平衡导致的漏检遥感数据集里类别不平衡非常严重。比如飞机可能只有几百个实例而储油罐有上万个。这导致模型倾向于预测多数类少数类的召回率很低。解决办法有几个层面数据层面可以做重采样增加少数类的样本损失层面可以用Focal Loss或者类别加权交叉熵后处理层面可以调整每个类别的置信度阈值。我最终采用的是Focal Loss加上类别权重。类别权重根据每个类别的样本数反比设置但上限截断到10避免权重过大导致训练不稳定。5.4 数据增强对旋转框的特殊处理常规的数据增强翻转、旋转、缩放在旋转框上需要特殊处理。水平翻转时角度θ变成180-θ垂直翻转时角度变成-θ需要归一化到[0, 180)旋转时角度直接加上旋转角度。def flip_horizontal(boxes): 水平翻转旋转框 boxes[:, 0] 1.0 - boxes[:, 0] # x坐标翻转 boxes[:, 4] 180.0 - boxes[:, 4] # 角度变换 boxes[:, 4] boxes[:, 4] % 180 return boxes def rotate_boxes(boxes, angle): 旋转旋转框 boxes[:, 4] (boxes[:, 4] angle) % 180 return boxes这里有一个容易忽略的点翻转后宽高可能需要交换。如果角度变换后超出了[0, 90)的范围需要把角度减去90度同时交换宽高。这个细节处理不好会导致增强后的标签和图像不对应训练效果大打折扣。6. CSL与其他旋转检测方案的横向对比6.1 与直接回归方法的对比直接回归角度的方法比如用smooth L1损失在简单场景下也能work但遇到密集目标或者角度分布不均匀的数据集时边界不连续问题会明显拖累精度。我在同一个数据集上做过对比实验CSL比直接回归的mAP高出约3到5个百分点在角度误差指标上优势更明显。但CSL也不是没有代价。它的角度分支输出180维比回归方法的1维输出多了180倍的计算量。在推理速度上CSL会比直接回归慢10%到15%。如果你的应用场景对速度要求极高可能需要权衡一下。6.2 与IoU-based损失方法的对比IoU-based损失比如GWD、KLD直接用旋转IoU作为损失函数理论上和评价指标最一致。但旋转IoU的计算涉及多边形相交实现复杂而且梯度在框不重叠时会消失。CSL的优势在于实现简单、训练稳定。它把角度预测变成了一个标准的分类问题可以直接复用现有的分类损失和训练技巧。缺点是需要手动设计窗口函数和超参数调参成本较高。6.3 实际选型建议根据我的经验选型可以按以下逻辑来判断如果数据集的目标角度分布比较均匀且对推理速度要求不高CSL是一个稳妥的选择。如果目标角度集中在某些特定值附近比如都是水平或垂直的直接回归可能就够了。如果对精度要求极高且愿意花时间调参可以尝试IoU-based方法或者CSL的改进版本。如果数据集规模很大可以考虑用CSL做预训练然后fine-tune其他方法。对比维度CSL直接回归IoU-based实现难度中等低高训练稳定性高低中等推理速度中等快中等精度上限高中等高调参成本中等低高7. 部署阶段的精度保持与加速策略7.1 模型导出时的角度解码处理训练时角度分支输出180维logits推理时需要做softmax和期望计算。这部分计算如果放在后处理里做会增加延迟。我的做法是在导出模型时把softmax和期望计算融合进去直接输出连续角度值。但这里有一个坑期望计算涉及argmax操作来确定中心类别这个操作在ONNX等格式里支持不好。解决办法是用softmax后的概率分布直接算期望不做argmax对齐。虽然理论上在边界处可能有问题但实际测试下来只要训练充分分类器在边界处的概率分布会比较集中直接算期望的误差在可接受范围内。7.2 角度分支的剪枝与量化180维的角度输出是推理时的主要计算瓶颈之一。我试过几种加速方案减少角度类别数从180降到90精度损失约1个百分点速度提升约5%。角度分支用深度可分离卷积速度提升约8%精度基本不变。INT8量化速度提升约30%但角度精度下降明显需要做量化感知训练来补偿。最终我采用的是深度可分离卷积加上适度的通道剪枝在精度损失小于0.5个百分点的情况下推理速度提升了约15%。7.3 后处理中的旋转NMS旋转框的NMS比水平框复杂因为两个旋转框的IoU计算需要多边形相交。常用的加速方法是先做水平框的粗筛再对保留的框做精确的旋转IoU计算。def rotated_nms(boxes, scores, iou_threshold0.5): 旋转框NMS # 先按得分排序 order scores.argsort(descendingTrue) keep [] while order.numel() 0: i order[0] keep.append(i) if order.numel() 1: break # 计算当前框与剩余框的旋转IoU ious compute_rotated_iou(boxes[i], boxes[order[1:]]) # 保留IoU小于阈值的框 mask ious iou_threshold order order[1:][mask] return torch.tensor(keep)旋转IoU的计算可以用shapely库但速度较慢。如果对性能要求高可以用CUDA实现的旋转IoU或者用近似方法比如用外接水平框的IoU做粗筛。8. 一些实战中的零散经验关于CSL的窗口函数选择我补充一点高斯窗在大多数情况下表现最好但如果你的数据集角度标注噪声比较大三角窗可能更鲁棒因为它的衰减更平缓对标注误差的容忍度更高。关于角度类别的粒度180个类别1度间隔是常用的配置。如果你的应用对角度精度要求不高可以降到90个类别2度间隔训练会更快精度损失也很小。但如果你的目标角度差异很小比如都是近似水平的建议保持180甚至增加到360。关于多尺度训练遥感图像的目标尺度变化很大多尺度训练在训练时随机缩放图像对精度的提升很明显。我通常会在[0.5, 1.5]的范围内随机缩放配合FPN的多尺度特征小目标和大目标的检测效果都能兼顾。关于预训练权重如果你的数据集和ImageNet差异较大用ImageNet预训练的骨干网络可能帮助有限。可以尝试用其他遥感数据集上预训练的权重或者用自监督方法预训练。我试过用MoCo v2在遥感数据上做自监督预训练然后fine-tune检测模型小目标的召回率提升了约5个百分点。最后说一个我踩过的坑CSL的角度定义和OpenCV的旋转框定义可能不一致。OpenCV的旋转框角度范围是[-90, 0)而CSL通常用[0, 180)。在数据加载和结果可视化时一定要统一角度定义不然会出现框的方向完全错误但损失却正常下降的诡异现象。我当初就是因为这个问题白白浪费了一周时间排查。提示建议在代码里显式定义一个角度转换函数所有涉及角度的地方都通过这个函数来转换避免隐式的角度约定不一致。关于训练epoch数CSL通常需要比水平框检测更多的epoch才能收敛。水平框检测可能12个epoch就够了CSL可能需要24到36个epoch。这是因为角度分支的参数需要更多迭代来学习。如果你的计算资源有限可以先用小分辨率图像预训练再用大分辨率fine-tune这样能节省不少时间。关于混合精度训练CSL的角度分支对数值精度比较敏感。我用FP16训练时发现角度损失偶尔会出现NaN后来把角度分支的计算强制用FP32问题就解决了。如果你也在用混合精度建议对角度相关的计算做特殊处理。
阅读完成 · 觉得有帮助?
咨询建站