1. 先把问题缩到最小单目标检测到底在算什么单目标检测这个词听起来像个正经的视觉任务但把它剥干净之后你会发现它其实只要求网络回答两个问题图里有没有我要找的那个东西如果有它在哪个矩形里。注意是一个矩形不是一堆。这个约束一旦确立整个算法的复杂度会掉下来一个数量级——多目标检测里那些让新手头皮发麻的机制比如先验框聚类、正负样本分配策略、NMS 阈值调参在这套方案里全都可以不要。我之所以想把这个东西逐行写一遍是因为它是目前少数几个你能在两小时内从一张白纸写到能出结果的视觉任务。多目标检测你写三天可能还在调 anchor 的尺度和长宽比单目标检测你写完网络、写完损失、跑两百个 step 就能看到热图往目标身上聚。这种即时反馈对建立手感特别重要尤其对手里没有大规模标注数据、只想快速验证一个想法的人来说。这篇文章面向三类人刚学完卷积网络想找个能落地的练手任务的同学手上有产线定位、票据区域裁剪、卡片回正、单个人脸对齐这类一图一物需求用通用检测框架又嫌重的工程师以及单纯想搞明白坐标回归到底是怎么学出来的这类底层问题的人。我会从方案选型讲到逐行代码再讲到训练曲线上每个拐点意味着什么最后把踩过的坑整理成一张表。需要提前说明的是文中所有具体超参、数据规模、收敛步数都来自我自己在合成数据和几个小规模真实数据集上的实测不是从哪篇论文抄来的标准答案。你的场景如果差异很大这些数字当起点用别当真理供着。2. 三条实现路线的真实差别与最终选型2.1 回归式、先验框式、热图式各自适合谁在动手之前得先想清楚坐标是怎么从网络里吐出来的。这一步选错了后面调参能耗掉你一整周。第一种是直接回归特征图全局池化之后接全连接一口气输出四个归一化数字比如 cx、cy、w、h。写法最简单但问题在于全局池化会把空间信息揉碎。你可以把它理解成让一个人蒙着眼摸一张图然后告诉你目标在左上角还是右下角中间层能提供的定位线索被平均掉了。实测在目标位置随机分布的数据上这种做法的中心点误差经常卡在 15% 到 20% 图像边长的水平再往下压不动。第二种是先验框式也就是把 YOLO 那一套简化过来在某层特征图上每个格子撒几个框格子负责预测相对偏移。定位精度确实好因为每个格子只负责自己附近的目标空间分辨率保留了下来。代价是你得处理一堆配套逻辑——框的尺寸怎么定、哪个格子算正样本、正负样本比例怎么控。单目标场景下这些逻辑的边际收益很低因为整张图只有一个框要出你撒了几千个框最后只有一个是有用的。第三种是热图式网络输出一张单通道的分数图每个位置表示目标中心点在这里的概率然后通过 soft-argmax 或者取最大值拿到亚像素级的中心坐标宽高再用一条独立分支回归。这条路线的妙处在于它同时保留了两个好处——空间信息没被池化毁掉而且整条链路可导可以直接端到端训练不需要任何不可微的 argmax 操作。我最后选的是第三种。理由很实在它把在哪和多大拆成两个互不干扰的子问题。在哪由热图负责本质上是一个逐像素的二分类加权问题信号强、好收敛多大由宽高分支负责是一个两维回归问题尺度变化平滑。你把它们捆在一起回归四个数时梯度会打架收敛慢而且容易在某个尺度上崩掉。拆开之后两边各自收敛调起来心里有数。2.2 我最终定下来的网络结构整体是四层下采样的全卷积骨架输入 256×256×3输出特征图 16×16×64。在这个特征图上分三个头热图头1×1 卷积压到单通道得到 16×16 的 logitssoftmax 之后算期望坐标。宽高头全局平均池化接两层全连接输出两个经 sigmoid 限制在 (0,1) 的数代表相对图像边长的宽和高。存在性头同样池化接全连接输出一个 logit判断图里到底有没有目标。为什么存在性要单独一个头而不是直接看热图峰值因为 16×16 只有 256 个格子softmax 之后的峰值理论上限就是 1实际正样本峰值大概在 0.05 到 0.2 之间负样本也能到 0.01 上下两者区分度不够阈值极难定。单独的分类头把有没有变成一个语义级的判断稳定性高得多实测负样本上的输出 logit 能压到 -4 以下正样本能推到 3 以上中间留的余量足够你随便定 0.5 这个阈值。这个结构还有个隐性好处参数量小。width 取 16 的时候整个模型大约 6 万参数CPU 上单张推理 3 毫秒左右扔到边缘设备上一点压力都没有。2.3 坐标系协议必须在写第一行代码前定死这是新手最容易翻车的地方。图像坐标有像素绝对坐标和归一化相对坐标两套宽高有左上右下和中心加尺寸两种表示如果训练时用一种、推理时用另一种你会在可视化里看到框飘到天上去然后花两小时怀疑人生。我这套方案里统一成项目表示取值范围说明中心点(cx, cy)[0, 1]相对图像宽高像素中心对齐宽高(w, h)[0, 1]相对图像宽高框转坐标x1cx-w/2可能 0推理时再裁剪到 [0,1]热图坐标同上[0, 1]格子中心为 (i0.5)/16特别强调那个0.5。特征图第 i 个格子覆盖的图像范围是 [i/16, (i1)/16]它的几何中心是 (i0.5)/16不是 i/16。少加这半个格子你在 16×16 上的系统偏差就是 3.1% 的图像边长256 图上是 8 个像素。这个错很隐蔽因为框看起来差不多对但 IoU 就是上不去。3. 逐行写数据、网络、损失3.1 先用合成数据把管线跑通这一步不能省我强烈建议第一版不要在真实数据上写。原因很简单真实数据出问题时你分不清是标注错了、增强写错了、还是模型本身有问题。合成数据的真值是程序生成的100% 准确只要管线通了、损失降了、框能对上你就知道代码没问题后面再换成真实数据出问题必然是数据侧的。合成样本的逻辑是这样一张 256×256 的图背景是 0.3 左右的暗噪声六成概率画一个随机位置、随机大小的纯色矩形四成概率什么都不画。矩形亮度比背景高模拟一个亮块目标。import torch def synth_sample(size256, has_objNone): img torch.rand(3, size, size) * 0.3 if has_obj is None: has_obj torch.rand(1).item() 0.6 if not has_obj: zero torch.zeros(2) return img, torch.tensor(0.), zero, zero # 宽高限制在 10%~50%避免目标太小或糊满全图 w torch.empty(1).uniform_(0.10, 0.50).item() h torch.empty(1).uniform_(0.10, 0.50).item() # 留 0.02 的边距保证框完全落在图内 cx torch.empty(1).uniform_(w / 2 0.02, 1 - w / 2 - 0.02).item() cy torch.empty(1).uniform_(h / 2 0.02, 1 - h / 2 - 0.02).item() x1, x2 int((cx - w / 2) * size), int((cx w / 2) * size) y1, y2 int((cy - h / 2) * size), int((cy h / 2) * size) color torch.rand(3, 1, 1) * 0.7 0.3 img[:, y1:y2, x1:x2] color img (img torch.randn_like(img) * 0.05).clamp(0, 1) return img, torch.tensor(1.), torch.tensor([cx, cy]), torch.tensor([w, h])这段代码里有个细节值得说宽高上限我卡在 0.5不是随便定的。如果允许目标占满 80% 的图像中心点的取值范围就被压缩到很窄的区间模型在中心点上几乎不用学就能蒙对你会在训练前期看到中心点损失掉得飞快误以为收敛了其实只是数据分布太简单。故意让目标尺寸有变化、位置有覆盖损失曲线才有信息量。注意合成数据一定要保留一部分无目标样本比例我一般取 40%。全是正样本的话存在性头永远学不会说没有部署到真实场景里碰到空图它会硬给你画一个框出来。3.2 Dataset 与数据增强的实际写法增强这块要克制。单目标检测里最容易搞出事的是裁剪类增强——随机裁掉一块目标可能被裁掉一半甚至裁到图外这时候你的标签就不对了但你又没改标签模型就在学错的映射。我见过有人照搬分类任务的 RandomResizedCrop训练出来的模型对贴边的目标完全没有响应。我的做法是只上三类安全增强水平翻转同时翻转 cx 和宽高没影响、亮度对比度微扰、小幅度的高斯噪声。import torchvision.transforms.functional as TF import random class SynthDataset(torch.utils.data.Dataset): def __init__(self, n6000, size256, trainTrue): self.n, self.size, self.train n, size, train def __len__(self): return self.n def __getitem__(self, idx): img, has, center, wh synth_sample(self.size) if self.train: if random.random() 0.5: img TF.hflip(img) if has.item() 0.5: center torch.stack([1 - center[0], center[1]]) if random.random() 0.3: img (img * random.uniform(0.8, 1.2)).clamp(0, 1) return { image: img, has_obj: has, center: center, wh: wh, }翻转时cx要变成1 - cx这个一行代码的错误非常常见忘了写的话模型学到的就是左右镜像的错误中心表现是预测框总是偏离目标且偏离方向跟目标在左半边还是右半边相关。排查这种问题时把预测框和真值框同时画出来看误差有没有方向性一眼就能看出来。3.3 骨架与三个预测头骨架部分没什么花活四组卷积 BN ReLU步长全为 2通道数 16→32→64→64。import torch.nn as nn import torch.nn.functional as F def conv_bn(cin, cout, stride): return nn.Sequential( nn.Conv2d(cin, cout, 3, stride, 1, biasFalse), nn.BatchNorm2d(cout), nn.ReLU(inplaceTrue), ) class TinyBackbone(nn.Module): def __init__(self, width16): super().__init__() self.body nn.Sequential( conv_bn(3, width, 2), # 256 - 128 conv_bn(width, width * 2, 2), # 128 - 64 conv_bn(width * 2, width * 4, 2),# 64 - 32 conv_bn(width * 4, width * 4, 2),# 32 - 16 ) self.out_ch width * 4 def forward(self, x): return self.body(x)预测部分重点是 soft-argmax 的实现。这里不用argmax因为argmax不可导梯度到这就断了。做法是对热图做 softmax 得到概率分布然后用坐标的期望值代替最大值位置。class SingleObjectDetector(nn.Module): def __init__(self, width16): super().__init__() self.backbone TinyBackbone(width) c self.backbone.out_ch self.hm_head nn.Sequential( nn.Conv2d(c, c, 3, 1, 1, biasFalse), nn.BatchNorm2d(c), nn.ReLU(inplaceTrue), nn.Conv2d(c, 1, 1), ) self.wh_head nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(c, 64), nn.ReLU(inplaceTrue), nn.Linear(64, 2), ) self.cls_head nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(c, 64), nn.ReLU(inplaceTrue), nn.Linear(64, 1), ) def forward(self, x): f self.backbone(x) hm_logits self.hm_head(f) # B,1,H,W B, _, H, W hm_logits.shape prob F.softmax(hm_logits.view(B, -1), dim1).view(B, 1, H, W) # 格子中心坐标注意 0.5 ys (torch.arange(H, devicex.device).float() 0.5) / H xs (torch.arange(W, devicex.device).float() 0.5) / W cx (prob.sum(2).view(B, W) * xs).sum(1) cy (prob.sum(3).view(B, H) * ys).sum(1) center torch.stack([cx, cy], dim1) # B,2 wh torch.sigmoid(self.wh_head(f)) # B,2 属于 (0,1) logit self.cls_head(f).squeeze(1) # B return hm_logits, center, wh, logitprob.sum(2)是把高度维消掉得到每个 x 位置的总概率再和 xs 加权求和就是 x 方向的期望。这个操作等价于把热图沿一个方向压扁成一条曲线然后求这条曲线的重心物理意义很直白。soft-argmax 有个已知的弱点当热图比较平比如负样本时期望值会趋近图像中心输出 0.5, 0.5。这在推理阶段没影响因为你会先用分类头筛掉但在训练阶段有影响——如果负样本也算了中心点损失模型会被拽着把所有空图都预测成中心点。所以下面损失函数里中心点和宽高这两项只在正样本上计。3.4 高斯热图目标怎么造真值不能是单点 1、其余 0那样太稀疏了模型学起来很吃力因为正样本只有 1 个格子梯度信号极弱。标准做法是以中心点为峰造一个二维高斯让目标周围的格子也有递降的软标签。def gaussian_target(cx, cy, w, h, size16, k0.12): ys (torch.arange(size).float() 0.5) / size xs (torch.arange(size).float() 0.5) / size gy, gx torch.meshgrid(ys, xs, indexingij) sigma max(max(w, h) * k, 1.0 / size) # 至少覆盖一个格子 g torch.exp(-((gx - cx) ** 2 (gy - cy) ** 2) / (2 * sigma ** 2)) return g.unsqueeze(0) # 1,H,Wk0.12这个系数决定了高斯的胖瘦。取太大了热图糊成一片soft-argmax 的期望值会被邻近区域拉偏定位精度掉取太小了热图接近单点梯度稀疏收敛慢。我试过 0.05 到 0.25 这个区间0.10 到 0.15 是比较稳的甜区。另外那个max(..., 1/size)的下限也重要目标很小时比如 8 个像素如果不设下限sigma 会小到 0.005热图上只有一个格子是 1其余全是零等于退化成单点标签了。3.5 三个损失怎么配比def compute_loss(pred, target, hm_weight4.0, ct_weight2.0, wh_weight1.0, cls_weight1.0): hm_logits, center, wh, logit pred has target[has_obj] pos has 0.5 # 分类全样本参与 loss_cls F.binary_cross_entropy_with_logits(logit, has) # 热图正样本用高斯软标签负样本用全零 B hm_logits.shape[0] gt_hm torch.zeros_like(hm_logits) for i in range(B): if pos[i]: gt_hm[i] gaussian_target( target[center][i, 0], target[center][i, 1], target[wh][i, 0], target[wh][i, 1], ) loss_hm F.mse_loss(torch.sigmoid(hm_logits), gt_hm) # 位置与尺寸只算正样本 if pos.any(): loss_ct F.l1_loss(center[pos], target[center][pos]) loss_wh F.l1_loss(wh[pos], target[wh][pos]) else: loss_ct center.sum() * 0.0 loss_wh wh.sum() * 0.0 total (hm_weight * loss_hm ct_weight * loss_ct wh_weight * loss_wh cls_weight * loss_cls) return total, { hm: loss_hm.item(), ct: loss_ct.item(), wh: loss_wh.item(), cls: loss_cls.item(), }热图用 MSE 而不是 BCE是因为标签是软的高斯值本身是 0 到 1 的连续数MSE 对软标签更自然梯度也更平滑。我在小数据上对比过BCE 配软标签也能训但正样本附近的梯度更陡前期容易抖。注意torch.sigmoid(hm_logits)这步不能漏MSE 是对概率算的不是对 logits 算的。权重配比这块4:2:1:1 是我实测比较省心的组合。热图权重最高因为它是定位的主干信号中心点其次因为它直接决定最终框的位置宽高和分类各占 1。如果发现宽高迟迟不收敛可以临时把它提到 2等进入平台期再降回来相当于先帮它扶上马。注意loss_ct和loss_wh在 batch 内没有正样本时要返回一个挂着计算图的零不能直接返回 Python 的0.0。直接返回浮点数的话total.backward()会报梯度图断开的错。上面用center.sum() * 0.0就是这个目的。4. 训练循环、超参与曲线怎么读4.1 训练循环的骨架训练循环本身没什么新东西但有几个位置容易写错我把它写全。import torch from torch.utils.data import DataLoader def train_one_epoch(model, loader, opt, device): model.train() agg {hm: 0., ct: 0., wh: 0., cls: 0., n: 0} for batch in loader: img batch[image].to(device) tgt {k: v.to(device) for k, v in batch.items() if k ! image} pred model(img) loss, parts compute_loss(pred, tgt) opt.zero_grad(set_to_noneTrue) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) opt.step() for k in (hm, ct, wh, cls): agg[k] parts[k] agg[n] 1 return {k: agg[k] / max(agg[n], 1) for k in (hm, ct, wh, cls)}clip_grad_norm_这一行别省。热图分支在训练初期如果一批里连续出现几个小目标梯度会突然窜高不裁剪的话学习率稍微给大一点就 NaN 了。阈值 5.0 是个经验值一般够用。主流程device cuda if torch.cuda.is_available() else cpu model SingleObjectDetector(width16).to(device) train_ds SynthDataset(n6000, trainTrue) val_ds SynthDataset(n600, trainFalse) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, drop_lastTrue, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) opt torch.optim.AdamW(model.parameters(), lr2e-3, weight_decay1e-4) sched torch.optim.lr_scheduler.OneCycleLR( opt, max_lr3e-3, total_steps40 * len(train_loader), pct_start0.15, anneal_strategycos) for epoch in range(40): stats train_one_epoch(model, train_loader, opt, device) if sched.last_epoch sched.total_steps - 1: sched.step() print(fep{epoch:02d} hm{stats[hm]:.4f} ct{stats[ct]:.4f} fwh{stats[wh]:.4f} cls{stats[cls]:.4f})drop_lastTrue在这里是必须的。如果最后一个 batch 里全是负样本那批的中心点和宽高损失都是零虽然代码不会崩但那一批只更新了分类头对回归分支是浪费。6000 样本、batch 32 的话一共产出 187 个 batch最后一两个丢掉没有影响。4.2 学习率、batch 与步数的取值逻辑batch 32 是我在 256 分辨率下的默认值。往下调到 16 也能训但 BN 的统计噪声会变大前几十步损失会抖往上加到 64 效果更好但显存吃得多如果你手上显卡显存充足64 是更优选择学习率相应乘 1.5 倍左右。学习率我用的是 OneCycle峰值 3e-3。为什么用 OneCycle 而不是常数学习率因为这个任务的损失曲面在前 15% 步数里降得特别猛分类和热图两项很快就掉下去之后进入长尾中心点和宽高的亚像素精度爬坡。OneCycle 前段升温帮模型快速跳出初始化附近后段余弦退火帮它在最优点附近细磨比固定学习率省大概三分之一的总步数。这个结论我是拿同一份数据跑了两组对照得出的不是凭感觉。总步数按 40 个 epoch 算。实测在合成数据上第 25 个 epoch 左右 val 的 IoU 就到了 0.90 以上40 个 epoch 结束时大约 0.96。你如果着急看结果20 个 epoch 就能画出像样的框了。真实数据一般需要更多步因为有更多样化的外观变化。4.3 训练曲线怎么读四个损失我都单独打印因为它们的下降模式完全不同混在一起看没意义。热图损失通常在 300 步以内掉到 0.01 以下。如果它掉得很快但 val 上的框明显偏移八成是高斯目标的 sigma 给太大了热图糊成一片模型学会了造一个大概在中间的热图就能让 MSE 很低。这时候把 k 从 0.12 降到 0.08 试试。分类损失从 0.69 附近开始那是 0.5 概率对应的 BCE通常在第 2 个 epoch 掉到 0.1 以下。它掉不下去通常意味着数据有问题比如正负样本比例极端失衡或者分类头学到了捷径比如靠背景亮度判断有没有目标。合成数据里我特意把目标颜色随机化就是为了断掉这条捷径——如果目标永远是同一种颜色模型会退化成颜色检测器换个场景立刻失效。中心点损失是最值得盯的。它的下降应该是单调的从 0.25 左右随机猜测的期望误差一路降到 0.01 以下。如果它在 0.05 附近来回震荡不往下走多半是 soft-argmax 出的期望值被热图尾部的低概率区域拖住了。可以试试把热图 softmax 换成一个带温度的版本或者直接对热图做一次平方再归一化压掉尾部。宽高损失收敛最慢通常要 15 个 epoch 才进入平台期。因为全局池化丢掉了空间信息模型只能靠整体特征统计来估尺寸本质上是一个难度更高的回归。如果它长期卡在 0.03 以上可以考虑把宽高改成对 log 空间回归exp激活代替sigmoid尺度分布会更均匀。我暂时没换是因为 sigmoid 版本在 0.1 到 0.5 这个尺寸范围内已经够用。5. 推理封装与精度验证5.1 从热图到像素坐标中间只差一次裁剪推理阶段的代码比训练还短但有两个细节不能含糊阈值判断动作要在中心点计算之前坐标要裁到合理范围。torch.no_grad() def predict(model, img, conf_thr0.5): model.eval() hm, center, wh, logit model(img.unsqueeze(0)) score torch.sigmoid(logit)[0].item() if score conf_thr: return None, score, hm[0, 0].cpu() cx, cy center[0].tolist() w, h wh[0].tolist() box [cx - w / 2, cy - h / 2, cx w / 2, cy h / 2] box [min(max(v, 0.0), 1.0) for v in box] # 裁剪后宽高可能被压扁重新算一遍保证自洽 if box[2] - box[0] 1e-3 or box[3] - box[1] 1e-3: return None, score, hm[0, 0].cpu() return box, score, hm[0, 0].cpu()裁剪之后重新检查宽高这个动作是在一次实际测试里被逼出来的。当时有个目标的中心点落在图像边缘外一点点框裁完之后宽度变成 0下游的裁剪函数直接抛异常。加了这个判断之后这类边缘情况就当作检测失败处理比崩掉好。把热图也返回出来是个很实用的习惯。调试的时候把热图叠在原图上一眼就能看出模型注意力在哪。热图糊在中间、没聚到目标上说明训练不足或数据有问题热图聚到了目标上但框偏了说明问题在中心点或宽高的回归跟热图无关。这一步能省掉大量瞎猜。5.2 IoU 与 AP 怎么算评估这块我不追求和标准检测指标完全对齐只算两个数IoU 和 AP50。def iou(a, b): ix1, iy1 max(a[0], b[0]), max(a[1], b[1]) ix2, iy2 min(a[2], b[2]), min(a[3], b[3]) iw, ih max(0., ix2 - ix1), max(0., iy2 - iy1) inter iw * ih ua (a[2] - a[0]) * (a[3] - a[1]) (b[2] - b[0]) * (b[3] - b[1]) - inter return inter / ua if ua 1e-9 else 0.0AP 的计算按分数从高到低排序逐个判断是 TPIoU ≥ 0.5还是 FP累加后算 11 点插值的均值。单目标场景下每个图最多一个预测所以这个流程简化得只剩一层循环。def evaluate(model, ds, conf_thr0.5): model.eval() scores, tps [], [] for i in range(len(ds)): s ds[i] box, score, _ predict(model, s[image], conf_thr) scores.append(score) if s[has_obj].item() 0.5: tps.append(0) # 空图任何预测都是假阳 elif box is None: tps.append(0) # 漏检 else: gt s[center].tolist() s[wh].tolist() gt_box [gt[0] - gt[2] / 2, gt[1] - gt[3] / 2, gt[0] gt[2] / 2, gt[1] gt[3] / 2] tps.append(1 if iou(box, gt_box) 0.5 else 0) order sorted(range(len(scores)), keylambda i: -scores[i]) tp_cum, fp_cum, total_pos 0, 0, sum(1 for i in range(len(ds)) if ds[i][has_obj].item() 0.5) recalls, precisions [0.], [1.] for idx in order: if tps[idx]: tp_cum 1 else: fp_cum 1 recalls.append(tp_cum / max(total_pos, 1)) precisions.append(tp_cum / max(tp_cum fp_cum, 1)) ap 0. for r in [i / 10 for i in range(11)]: p max([precisions[j] for j in range(len(recalls)) if recalls[j] r], default0.) ap p / 11 return ap我在这套合成数据上跑出来的结果大致是这样训练 40 epoch 后val 集 AP50 约 0.95平均 IoU 0.88负样本误报率 3% 左右。这个数字谈不上惊艳但作为一个六万参数的小模型已经够看。你要拿它做真实场景的基线先跑出这个量级再说后续优化。5.3 换成真实数据只需要动三个地方合成数据跑通之后换成自己的数据其实只改三处第一处是 Dataset 的__getitem__。把synth_sample换成读图和读标注标注格式我一般用最简单的文本每行一个目标has cx cy w h五个数has 用 1/0 表示有没有。空图也要有对应的文件里面就一行 0否则 DataLoader 根本不知道有这个样本。第二处是输入尺寸。我这个网络是全卷积加全局池化理论上支持任意尺寸但 BN 的统计和感受野是跟训练尺寸绑定的测试时最好保持一致。如果你的原图是 4K没必要等比缩到 256 这么狠可以改成 512 输入把骨架多加一组下采样热图保持 16×16 或者 32×32。第三处是宽高的分布先验。如果你的目标尺寸集中在一个很窄的范围比如全是占图 20% 到 25% 的方块那宽高分支几乎不用学模型会很快收敛到一个中间值。这种情况我反而建议你把宽高分支砍掉直接用固定尺寸把算力全给中心点定位精度能再上一层。6. 踩坑记录与故障速查6.1 现象、原因、处置对照表这套东西我从头写了两遍第一遍在各种地方翻车第二遍顺手很多。把两次踩的坑整理成表你可以当手册用。现象最可能的原因处置损失变 NaN学习率过大或某个 batch 梯度爆炸加clip_grad_norm_lr 降到 1e-3训练损失降但 val IoU 不动模型记住了合成数据的固定纹理加颜色扰动、加噪声检查是否存在捷径特征框大小对了但整体平移0.5格子偏移没加检查arange那两行框总是偏左或偏右翻转增强时忘了改 cxcenter[0] → 1 - center[0]空图上总画框正负样本失衡或分类权重太低负样本提到 40%cls_weight 提到 2小目标完全检测不到高斯 sigma 太小或下采样太狠k 降到 0.08或热图改 32×32热图聚在中间不散负样本也算了中心点损失确认 pos mask 生效宽高输出恒定不变池化后再回归丢失了太多信息换 log 空间回归或从 32×32 特征图上池化推理比训练慢很多忘了model.eval()和no_grad两行都补上边缘目标框出界中心点期望值落在边界外裁剪 宽高下限检查这张表里最值得展开的是框整体平移那一条。它的隐蔽性在于比例很小的时候比如只偏 2 个像素你不会立刻发现只觉得 IoU 怎么都上不去而 IoU 这个指标在 0.85 到 0.95 之间本来就难涨。我当时的排查办法是把所有 val 样本的预测中心 - 真值中心存成一个向量场画出来一看全部指向同一个方向误差模长几乎恒定那答案就唯一了——一个系统性的常量偏差必然是坐标变换里漏了东西。6.2 几条不太写在文档里的经验别一上来就调网络结构。单目标检测这个任务的上限八成由数据质量和坐标协议决定网络结构的贡献大概只有两成。我见过太多人在定位不准的时候第一反应是加层、加通道、上注意力结果改了一圈发现原始代码里有个int()把坐标截断了。先看热图再看损失。损失曲线只能告诉你有没有在学热图能告诉你在学什么。训练早期每 200 步存一张热图可视化比盯着 loss 数值有用得多。我习惯是热图叠加在原图上红黄蓝伪彩一眼就能看出注意力有没有聚到目标上。合成数据是个很好的回归测试。后面你不管是改网络、改损失还是改增强都先回合成数据上跑 20 个 epochAP 掉超过 3 个点就说明改动有问题。这个流程帮我拦下过好几次以为在优化其实在退步的改动。阈值不要迷信 0.5。部署时应该拿 val 集画一条 score 的分布曲线正样本和负样本的分数分布重叠区域在哪阈值就定在重叠区的中间偏正样本那一侧。宁可多报一点也不要漏报这个取舍要看你的下游怎么用——后面还有一道人工复核的话宁可放过。模型小有小的好处但别忘了它是真的小。六万参数意味着它能学的东西有限。如果你的场景里目标外观变化很大比如不同颜色的工件、不同角度的零件这个容量大概率不够你可能需要把 width 提到 32 或者 48或者干脆用个现成的轻量骨干。容量不够的表现很典型训练损失降到某个值就死死卡住怎么调学习率都不动。这时候调参是浪费时间直接加容量。最后说一下我自己的体会。这个算法我最初写它是为了搞清楚 soft-argmax 到底能不能替代传统的框回归写完之后的结论是在单目标、目标不重叠、位置分布均匀的条件下它明显更稳、更好调但只要目标开始有重叠、或者需要输出多个框它就不适用了因为 softmax 的归一化性质决定了它只能表达一个峰。知道一个方法的边界在哪比知道它能干什么更重要。
阅读完成 · 觉得有帮助?