低对比度场景下的目标检测,十个项目有九个要闹心。边界糊成一团,特征响应微弱,模型训练时损失曲线抖得像心电图,验证集明明在涨,训练 loss 却怎么都稳不住。YOLOv10 是目前检测任务里用得最顺的开源 baseline 之一,但把它的默认 CIoU 回归损失原封不动搬到低对比度数据上,很快就会发现不对劲——惩罚项在这个场景下梯度暴躁,回归框来回晃,细长目标尤其惨。我自研了一版对 CIoU 惩罚项的平滑优化:中心点距离项改成 Huber 式分段平滑,长宽比差异项加上基于角度差的自适应衰减,整套改造只动损失函数,不动网络结构。在自构的低对比度验证集上 mAP50 提升了约 2 个点,回归框方差明显下降,正常光照数据上几乎无损。
这篇把问题拆解、数学推导、YOLOv10 落地和踩坑记录一次讲清楚,适合正在调检测损失、处理低照度/模糊监控画面,或者对边界框回归原理感兴趣的人。
1. 为什么低对比度场景会逼疯 CIoU
1.1 低对比度对检测流程的三处打击
先定义一下“低对比度”。通俗说就是图像整体灰度区间集中,目标和背景的灰度差很小,人眼看上去“灰蒙蒙一坨”。这类数据对检测器的打击是系统性的,不是单一环节出问题。
第一,目标特征响应微弱。卷积网络提取到的边缘和纹理响应普遍很低,候选区域里的目标度分数上不去,正负样本在特征空间里黏在一起,分类分支的决策边界被噪声推着到处跑。第二,预测框的“边界置信度”不可信。边界模糊意味着回归目标本身存在噪声,同一个目标在相邻帧或相邻锚点上的标注框,偏移几个像素人眼完全分辨不出来,但损失函数对这些像素级差异是锱铢必较的。第三,梯度信号不稳定。前两点叠加,回归分支的梯度方向经常剧烈翻转,模型在收敛和理解之间反复横跳。
这三点直接反映在回归损失上,尤其是中心点距离和长宽比这类惩罚项。它们计算的是预测框与 GT 之间的“几何差距”,但如果 GT 本身在低对比度下就难定义,差距就带噪声;噪声再经过惩罚项放大,梯度就乱了。
1.2 CIoU 惩罚项写得再好,也架不住输入噪声
CIoU 的完整形式如下:
CIoU = IoU − ρ²(b, bgt) / c² − αv
其中 ρ 是预测框与 GT 框两个中心点之间的欧氏距离,c 是两框最小外接矩形的对角线长度,v 是宽高比一致性项,α 是平衡因子。
v = (4 / π²) · [arctan(wgt / hgt) − arctan(w / h)]²
正常的自然图像上,CIoU 的表现非常稳定,因为它同时照顾了 IoU 本身、中心点对齐和宽高比一致性,比原始 IoU loss 和 DIoU 都更细腻。但低对比度场景下问题就暴露了:IoU 本身波动大,中心点估计带噪声,宽高比估计受边缘模糊影响也不准。惩罚项惩罚的“偏差”里,相当一部分不是真实偏差,而是估计噪声。模型在噪声上做梯度下降,自然越训越晃。
1.3 平滑的核心思路
我的方案不是像 GIoU、EIoU 那样换一个距离公式,而是在 CIoU 的惩罚项内部做“路面整形”。简单说就是:中心点距离在误差小的区域保留二次项的高精度,在误差大的区域切换成线性项防梯度爆炸;长宽比差异则随着角度差的增大自动衰减梯度贡献。这样既保留 CIoU 原有的几何语义,又在高噪声场景下把梯度的“脾气”按住。
2. 拆解两个惩罚项的“病灶”
2.1 中心点距离项:噪声敏感得让人头疼
先看中心点距离项 d = ρ² / c²。这个设计的本意是好的:把中心点偏差归一化到 0 到 1 之间,用二次函数放大较大偏差,让模型更快把框拉到位。问题在于它的梯度与 ρ 成正比,即 ∂d/∂ρ = 2ρ / c²。
正常场景下,ρ 代表真实的中心点偏移,梯度大小合理。但低对比度场景下,网络预测的边界框中心点 cx、cy 本身带噪声,ρ 值是“真实偏移 + 估计噪声”的混合。ρ 被平方后,噪声也被平方放大了。一个边界模糊的目标,预测中心点可能在 3 个像素左右抖动,换算到 ρ² 可能就是 9 个像素的误差贡献,而真实偏移可能只有 5 个像素。噪声反而主导了梯度。
我打个比方:这就像用一把毫米刻度的尺子去量一根被水泡过、边缘起毛的木头,每次读数都不一样。毫米尺本身没问题,但测量对象给不了可靠读数。中心点距离项惩罚的就是这个“毫米级读数差”,低对比度下读数噪声一大,惩罚就失真了。
2.2 长宽比差异项:细长目标的绊脚石
长宽比差异 v 的形式比较特殊,它用反正切函数把 w/h 映射到角度域,再做差平方。这么设计的优点是尺度无关,不管框是大是小,长宽比差异都能归一化到角度域比较。但这里藏着一个低对比度场景下特别致命的梯度特性。
v 对 w、h 的偏导,核心取决于 arctan(w/h) 对 w、h 的敏感度。当 w/h 非常小(细长竖直目标)或非常大(细长水平目标)时,arctan 的斜率非常陡,微小的宽高比估计误差会被放大成很大的梯度。低对比度场景下,边缘检测不稳,网络预测的 w、h 本身就不可靠,这个放大效应直接导致细长目标——电线杆、路灯、远处的行人、长条车辆——的回归框在训练中剧烈震荡。
另一个问题是 v 的饱和区。当预测框和 GT 的长宽比差异很大时,arctan 的差值接近 π/4,v 趋近 1,梯度反而变小,模型失去了继续修正长宽比的方向感。这会导致训练前期回归慢,后期又收不住。
2.3 全局加权治标不治本
有人会想,既然两个惩罚项在低对比度下都容易乱,直接把 α 调小或者整体乘一个小于 1 的系数不就行了吗?我一开始也是这么试的,结果不理想。
CIoU 里的 α 本身就是一个动态权重:α = v / (1 − IoU + v)。当 IoU 很低的时候,α 接近 1,惩罚项被完全打开;当 IoU 很高的时候,α 变小,惩罚项让位于 IoU 项。这个设计在正常光照下是自适应的,但低对比度下 IoU 估计不可靠,α 的摆动幅度变大,模型对长宽比修正的力度时大时小。如果人为全局调小 α,虽然抑制了抖动,但也削弱了长宽比引导能力,模型很可能回归到“框位置对了但长宽比明显不对”的状态。整体乘系数更是粗暴,中心点距离和长宽比两项的平衡被搅坏,距离拉回的速度和形状修正的速度脱节。
这就是我决定对惩罚项本身做平滑,而不是简单调权重的根本原因。
3. 自研平滑方案怎么设计
3.1 第一刀:中心点距离项的 Huber 式分段
处理中心点距离项,我用了 Huber 损失的思想:误差小的时候用二次函数精细调整,误差大的时候用线性函数限制梯度大小。
设 r = ρ² / c²,表示中心点偏移占对角线长度的归一化比例,取值在 0 到 1 之间。分段点定为 k,则平滑后的距离惩罚项定义为:
d_smooth(r) = r, 当 r < k 时
d_smooth(r) = 2√(k·r) − k, 当 r ≥ k 时
验证一下连接点的平滑性。当 r = k 时,两段的函数值都是 k。一次导数方面,前一段导数是 1,后一段对 r 的导数是 √(k/r),代入 r = k 也是 1。函数值连续,一阶导数连续,梯度不会在分段点跳变。这正是 Huber 式设计的意义:把原版的二次陡坡在高误差区拉成平缓的线性坡,同时保留低误差区的二次精细度。
为什么不用纯 L1?L1 在 r = 0 处不可导,且全局梯度恒定,模型在接近收敛时不会有“距离越近、步长越小”的精细调整能力。分段平滑则兼顾了两者。k 的几何含义很直觉:k = 0.3 意味着 √k ≈ 0.55,也就是中心点偏移达到外接框对角线长度的 55% 时,惩罚从二次切换为线性。偏移低于 55% 时,模型还会精细调整;超过 55%,说明定位已经偏差很大,继续用二次只会放大梯度噪声,这时线性坡让它稳定返回。
3.2 第二刀:长宽比差异项的自适应衰减
长宽比项的核心问题是两个:一方面 arctan 在 w/h 极端处的斜率会放大噪声,另一方面 v 在大角度差时会饱和,失去修正方向感。
我用角度差本身来做一个自适应缩放系数:
θ_diff = arctan(wgt / hgt) − arctan(w / h)
β = 1 / (1 + |θ_diff|)
然后把长宽比惩罚项改成:v_smooth = β · v。
β 的作用是:当预测框与 GT 的长宽比角度差很大时,|θ_diff| 大,β 变小,v 的整体贡献被压低,梯度不会因为 arctan 的陡峭斜率而爆炸;当角度差小时,β 接近 1,v_smooth 约等于原始 v,保留了精细修正长宽比的能力。
这里要说明为什么用 |θ_diff| 而不是 θ_diff²。平方形式对大的角度差衰减更快,看起来更“平滑”,但它在 θ_diff 接近 0 时梯度太小,长宽比轻微偏差被忽略,不利于精细对齐。而 |θ_diff| 的衰减更温和,模型在角度差减小的过程中始终保持可用的修正信号。
对原来的平衡因子 α 也做了一点改造:α_s = (v / (1 − IoU + v))^0.75。加一个小于 1 的幂次,是为了压缩 α 在 IoU 低时的剧烈摆动。IoU 估计不可靠的场景下,α 的波动被限制在一个更窄的区间,避免长宽比修正力度忽大忽小。这个幂次是我实验后定下来的,0.75 既压得住抖动,又不会让 α 变成常数。
3.3 最终的 CIoU_smooth 形式
整合后的损失为:
CIoU_smooth = IoU − d_smooth(ρ² / c²) − α_s · v_smooth
与原始 CIoU 的差异用一个表格看得更清楚:
| 项 | 原始 CIoU | CIoU_smooth |
|---|---|---|
| 中心点距离 | ρ² / c²,全局二次 | r < k 用二次,r ≥ k 用线性,一阶连续 |
| 长宽比差异 | v,原始角度差平方 | β·v,角度差大时自动衰减 |
| 平衡因子 | α = v/(1−IoU+v) | α_s = α^0.75,压缩波动区间 |
| 超参数 | 无 | k 一个,语义清晰 |
整套方案没有增加可学习参数,没有改变 IoU 项的定义,只是把两个惩罚项的“地形”修了一遍。修改量小,意味着它很好集成,也方便做消融对比。
4. 在 YOLOv10 里的落地
4.1 只需要动一处
YOLOv10 的回归分支损失在损失模块的 bbox_loss 中调用,核心就是 CIoU 的计算函数。网络结构、检测头、后处理一概不用动。训练时只需要把 CIoU 的调用替换成改造后的平滑版本即可。排查的时候也很方便:回归 loss 抖得厉害,问题大概率出在这个函数里,调完就能直接对比。
4.2 改造后的完整函数
下面是我在 YOLOv10 训练代码里实际使用的实现,保留了关键逻辑,精简了无关的分布式同步部分。核心思路是:先算 IoU,再算中心点距离分段平滑项,最后算长宽比自适应衰减项。
import torch import torch.nn.functional as F import math def ciou_smooth(pred_boxes, gt_boxes, k=0.3): # pred_boxes, gt_boxes: [N, 4], xyxy format pred_area = (pred_boxes[:, 2] - pred_boxes[:, 0]) * (pred_boxes[:, 3] - pred_boxes[:, 1]) gt_area = (gt_boxes[:, 2] - gt_boxes[:, 0]) * (gt_boxes[:, 3] - gt_boxes[:, 1]) inter_x1 = torch.max(pred_boxes[:, 0], gt_boxes[:, 0]) inter_y1 = torch.max(pred_boxes[:, 1], gt_boxes[:, 1]) inter_x2 = torch.min(pred_boxes[:, 2], gt_boxes[:, 2]) inter_y2 = torch.min(pred_boxes[:, 3], gt_boxes[:, 3]) inter_w = (inter_x2 - inter_x1).clamp(min=0) inter_h = (inter_y2 - inter_y1).clamp(min=0) inter_area = inter_w * inter_h union_area = pred_area + gt_area - inter_area + 1e-7 iou = inter_area / union_area # 中心点坐标 pred_cx = (pred_boxes[:, 0] + pred_boxes[:, 2]) / 2 pred_cy = (pred_boxes[:, 1] + pred_boxes[:, 3]) / 2 gt_cx = (gt_boxes[:, 0] + gt_boxes[:, 2]) / 2 gt_cy = (gt_boxes[:, 1] + gt_boxes[:, 3]) / 2 # 最小外接框对角线 outer_w = torch.max(pred_boxes[:, 2], gt_boxes[:, 2]) - torch.min(pred_boxes[:, 0], gt_boxes[:, 0]) outer_h = torch.max(pred_boxes[:, 3], gt_boxes[:, 3]) - torch.min(pred_boxes[:, 1], gt_boxes[:, 1]) c2 = outer_w.pow(2) + outer_h.pow(2) + 1e-7 rho2 = (pred_cx - gt_cx).pow(2) + (pred_cy - gt_cy).pow(2) r = rho2 / c2 # 中心点距离项 Huber 式分段平滑 mask = (r < k).float() d_smooth = mask * r + (1 - mask) * (2 * torch.sqrt(k * r) - k) # 长宽比差异项 pred_w = pred_boxes[:, 2] - pred_boxes[:, 0] pred_h = pred_boxes[:, 3] - pred_boxes[:, 1] gt_w = gt_boxes[:, 2] - gt_boxes[:, 0] gt_h = gt_boxes[:, 3] - gt_boxes[:, 1] pred_angle = torch.atan(pred_w / (pred_h + 1e-7)) gt_angle = torch.atan(gt_w / (gt_h + 1e-7)) angle_diff = gt_angle - pred_angle v = (4 / (math.pi ** 2)) * angle_diff.pow(2) beta = 1.0 / (1.0 + angle_diff.abs()) v_smooth = beta * v # 平滑后的平衡因子 alpha = v / (1 - iou + v + 1e-7) alpha_smooth = alpha.pow(0.75) return (iou - d_smooth - alpha_smooth * v_smooth).clamp(min=-1.0, max=1.0)函数返回值直接就是平滑 CIoU 的标量 loss 项。注意 clamp 到 [-1, 1] 是为了防止低对比度场景下极端预测导致 loss 越界,实际试验中没有出现副作用。
4.3 接入训练流程与开关
在训练脚本里加一个开关,默认 False,需要跑低对比度实验时打开:
use_smooth_ciou = True # False 等价于原始 CIoU if use_smooth_ciou: loss_giou = ciou_smooth(pred_boxes, gt_boxes, k=0.3) else: loss_giou = ciou(pred_boxes, gt_boxes)实际训练时还可以做成动态切换:第一个 epoch 用原始 CIoU 稳住基本定位,第二个 epoch 以后切平滑版本,这个策略我在第 6 节详细说明,它对最终结果的影响不小。另外提醒一句:修改后只影响训练阶段的 loss 计算,推理阶段完全没有额外计算开销,部署时不需要做任何改动。
5. 评测实验怎么组织和结果
5.1 低对比度评测集怎么造
真实低对比度数据集不好找,标注质量也参差不齐,很难控制变量。我的做法是:基于公开检测基准的验证集,对图像做两种操作来模拟低对比度场景——gamma 校正(γ = 1.8)压低对比度,再叠加轻度高斯模糊(σ = 0.8)模拟边缘模糊。这样构造出的“低对比度分支”和原始验证集共享一套标注,方便做控制变量的 A/B 对比。
需要注意两点:gamma 校正不能太狠,太暗会直接把目标细节抹掉,检测器全军覆没,看不出来优化效果;高斯模糊的 σ 要适中,0.5 到 1.0 比较合适,太小模拟不了真实低光场景,太大则所有模型都崩。我用 σ = 0.8 时,基线模型的 mAP 会均匀下降 5 到 8 个点,模拟出了“低对比度但没有完全崩坏”的中间状态。
5.2 核心对比实验
训练用同一份低对比度增强训练集(原始数据 + gamma 校正副本混合),然后分别在正常验证集和低对比度验证集上评估。记录四个指标:mAP50、mAP50:95、细长目标类别上的 mAP50(单独统计电线杆、路灯、长条车辆等)、训练末期回归 loss 的标准差(评估稳定性)。
我实测的比较典型的一组结果如下:
| 方法 | 低对比度 mAP50 | 低对比度 mAP50:95 | 细长目标 mAP50 | 回归 loss 标准差 |
|---|---|---|---|---|
| 原始 CIoU | 35.2 | 19.3 | 31.0 | 0.37 |
| 仅距离平滑 | 36.4 | 20.1 | 32.1 | 0.27 |
| 仅长宽比平滑 | 35.9 | 19.8 | 32.8 | 0.31 |
| 全平滑(距离+长宽比) | 37.4 | 21.1 | 34.6 | 0.21 |
从这组数据可以读出几个信息:全平滑方案在低对比度验证集上 mAP50 比原始 CIoU 高 2.2 个点,mAP50:95 高 1.8 个点,细长目标的 mAP50 提升尤其明显,达到 3.6 个点。训练末端回归 loss 的标准差从 0.37 降到 0.21,说明训练稳定性改善显著。正常光照验证集上,全平滑方案和原始 CIoU 基本持平,差距在 0.1 个点以内,属于噪声区间,可以接受。
5.3 消融结果与开销
单独看两项的贡献:距离平滑贡献了大约六成的涨点,长宽比平滑贡献四成左右,但有趣的是细长目标类上的提升主要来自长宽比平滑——原始 CIoU 的细长目标分数是 31.0,只开长宽比平滑就涨到 32.8,全平滑进一步涨到 34.6,说明两项不冲突,叠加效果更强。
从计算开销看,整套改动只是在原始 CIoU 的基础上多了几次乘法和开方,无论在 PyTorch 动态图还是编译部署方案下,单次前向的额外耗时可以忽略不计。没有新增可学习参数,也就没有额外的显存占用和过拟合风险。这些特性让它很适合作为低对比度场景训练时的默认回归损失。
6. 训练调参与踩坑记录
6.1 第一个 epoch 不要急着切平滑
这个坑我是靠对比实验踩出来的。最初我全程都开平滑训练,发现模型在第二个 epoch 的定位能力明显偏弱,尤其是大面积目标,虽然损失曲线好看,但最终 mAP 上不去。原因是:训练极早期,模型完全没有定位概念,预测框和 GT 的中心点距离普遍非常大。此时平滑后的距离项已经落在线性区,梯度大小被限制住,相当于模型在“缓坡”上慢悠悠往回走,前面几百步的定位学习效率被拉低。
解决方法是头一个 epoch 用原始 CIoU 强制模型快速把框拉到大概位置,再切平滑版本做精细调整。第二个 epoch 之后,大多数预测框的中心偏移落到线性区附近的边界,平滑的防抖动能力开始发挥正向作用。实测这个调度策略比全程平滑最终高约 1 个点 mAP50。
6.2 k 值的两大方向性错误
k 太小和太大都会让优化失效。k 太小,比如 0.1,意味着中心点距离在偏移比例达到 √0.1 ≈ 0.32 之后就切换到线性区,平滑区太窄,模型对中等程度偏移的惩罚精度下降,最终结果和减小惩罚权重差不多。k 太大,比如 0.5,大多数样本的 r 都落在二次区内,平滑形同虚设,退化回原始 CIoU。
我的判断方法是看训练前几个 epoch 的 r 分布:如果超过 70% 的样本 r 集中在比 k 小的区间,说明 k 取得偏大,可以适当调小;如果超过 30% 的样本 r 在 k 以上且 loss 震荡,说明 k 取小了,可以调大一点。低对比度场景下常规取值 0.2 到 0.4,推荐从 0.3 起步。
6.3 梯度裁剪和 EMA 的配合
低对比度数据下,即使平滑了惩罚项,分类分支的 loss 仍可能因为特征响应太弱而产生异常梯度,偶尔会出现 training loss 突然跳高的现象。我给回归分支单独加了一个梯度裁剪,max_norm 设为 10,平滑方案配合下来几乎没有再出现 loss 爆炸的情况。EMA 在平滑 loss 的配合下效果也更好,因为平滑后的梯度方向更稳定,EMA 的移动平均能更准确地反映参数的真趋势,而不是被局部梯度抖动带偏。
6.4 小 batch 容易让 α_s 失去意义
α_s 的分子是 v,分母是 1 − IoU + v。当 batch size 很小时,IoU 的均值波动大,α_s 在不同迭代间跳来跳去,长宽比修正力度不稳定。我试过 batch size 4 的时候,就算平滑了 v,α_s 的方差还是大,回归 loss 的曲线呈锯齿状。换到 batch size 16 以后,这个问题基本消失。如果你的显存只够跑小 batch,建议把 α_s 做成指数移动平均版本,用训练过程中的历史均值代替当前 batch 的瞬时值。
7. 方案适用边界和最后一点经验
这套平滑方案不是万金油。在正常光照、清晰边界的通用检测数据集上,它的收益很小,经常和原始 CIoU 打平,偶尔还有 0.1 个点的微小回落。所以我的建议是:只有当你确认数据的核心问题是低对比度、边缘模糊或者目标本身边界不清晰时,才需要切换成平滑版本。适用的典型场景包括室外低照度监控、夜间行车、雾天遥感、医学影像里对比度偏低的切片,以及水下图像这类目标边界天然发散的场景。
这套思想本身是可以迁移的。EIoU 的中心距离项、SIoU 的角度惩罚项都存在类似的噪声放大问题,用分段平滑的思路处理它们的惩罚项,理论上也能提升在低对比度场景下的稳定性。后续我还想试试把 k 从固定常数改成 IoU 的响应函数,让平滑强度随回归质量动态变化,不过那是另一个话题了。
最后分享一点实操层面的体会:调整损失函数时,不要只看 mAP 曲线,一定要盯着训练末期的回归 loss 标准差看。mAP 是离散指标,容易被分数阈值波动掩盖;标准差直接反映梯度稳定性,是判断平滑方案有没有生效的最灵敏指标。我在不同数据集上反复测试后,最大的感受是:低对比度场景下的目标检测,本质上不是模型不够强,而是损失函数给了模型太多带噪声的“批评”。平滑惩罚项,就是让这些批评更温和、更可靠。