1. 从“画框”到“找点”为什么CornerNet是个好主意如果你玩过目标检测肯定对“锚框”Anchor Box不陌生。从YOLO到RetinaNet大家好像都在比赛谁画的框多、谁画的框准。但不知道你有没有这种感觉每次调模型最头疼的就是那一堆锚框的超参数到底要设置多少个尺寸长宽比怎么定不同尺度的特征图上怎么分配调来调去感觉像是在碰运气。我刚开始做项目的时候就被锚框折腾得够呛。记得有一次为了检测场景里大小悬殊的物体我设置了密密麻麻几十种锚框尺寸。结果训练慢得像蜗牛显存直接爆掉最后出来的效果还不尽如人意小物体漏检大物体框不准。那时候我就想有没有一种方法能让我们摆脱对锚框的依赖用一种更“聪明”、更本质的方式去定位物体呢2018年出现的CornerNet就给了我们一个全新的思路。它彻底抛弃了锚框回归到一个最朴素的几何事实一个矩形的物体本质上是由它的左上角和右下角这两个点唯一确定的。想想看我们人眼在图片里找东西很多时候也是先扫到物体的一个角然后顺着边缘找到另一个对角心里就大概有框了。CornerNet就是把这个人脑的直觉过程用神经网络给实现了。这个想法妙在哪里首先它极大地简化了问题。原来我们需要预测成千上万个可能重叠、可能无用的框现在只需要预测两类点所有物体的左上角集合和所有物体的右下角集合。计算复杂度直接从O(w²h²)降到了O(wh)效率提升不是一点半点。其次它天然地解决了正负样本不平衡这个老大难问题。在锚框方法里一张图里可能只有几十个物体却要生成上十万个锚框绝大部分都是负样本模型学起来很“累”。而在CornerNet里我们只关心那些真正的角点位置背景区域非角点的负样本虽然也多但通过巧妙设计的损失函数我们后面会细说可以让模型把注意力集中在真正的角点附近学习起来目标更明确。当然你可能会问光找到两个点我怎么知道哪个左上角和哪个右下角是属于同一个物体的万一图片里有一堆桌子椅子左上角点一堆右下角点也一堆岂不是乱点鸳鸯谱这正是CornerNet设计最精妙的地方之一——嵌入向量。模型在预测每个角点的位置时还会为这个点生成一个高维的“身份证”嵌入向量。属于同一个物体的两个角点它们的“身份证”会非常相似不属于同一个物体的则差异很大。这样模型在推理时就能通过计算这些“身份证”的相似度把正确的左上角和右下角“配对”起来从而形成一个完整的检测框。这个思路其实借鉴了多人姿态估计里关节点分组的思想用在这里解决目标检测的配对问题非常巧妙。2. 庖丁解牛CornerNet的核心组件与实战拆解知道了CornerNet“是什么”和“为什么好”我们得深入看看它“怎么做到的”。光说不练假把式我结合自己的代码调试经验带你一步步拆解它的核心模块。理解了这些你才能真的玩转它。2.1 骨干网络为什么是HourglassCornerNet的骨架选用的是Hourglass Network沙漏网络。你可能在其他任务比如人体姿态估计里见过它。为什么不用更常见的ResNet或者VGG呢这得从任务需求说起。检测角点是个对多尺度信息和细节信息都要求极高的任务。一个小物体的角点需要网络有足够精细的感受野去捕捉一个大物体的角点又需要网络有全局的上下文信息来确认。Hourglass网络的结构就像它的名字先“收缩”下采样捕捉全局和语义信息再“扩张”上采样恢复细节和位置信息而且中间还加入了跳跃连接把浅层的高分辨率细节特征直接传给深层。这种结构能非常好地融合不同尺度的特征对于精准定位角点这种需要“既见森林又见树木”的任务再合适不过了。在实际搭建时CornerNet对原始的Hourglass模块做了一些修改比如用步长为2的卷积代替最大池化进行下采样这样能保留更多信息。通常一个完整的CornerNet模型会堆叠多个Hourglass模块比如两个进行更深层次的特征提取和精炼。第一个Hourglass模块输出的特征会作为第二个模块的输入这种级联结构能让预测的角点热图越来越准。# 这是一个简化的Hourglass模块构建思路帮助你理解其数据流向 import torch import torch.nn as nn class HourglassModule(nn.Module): def __init__(self, depth4): super().__init__() self.depth depth # 下采样路径编码器 self.downsample nn.ModuleList([self._make_down_layer() for _ in range(depth)]) # 上采样路径解码器 self.upsample nn.ModuleList([self._make_up_layer() for _ in range(depth)]) # 跳跃连接用的1x1卷积用于调整通道数 self.skip_convs nn.ModuleList([nn.Conv2d(256, 256, 1) for _ in range(depth)]) def _make_down_layer(self): # 通常包含残差块这里用简单卷积示意 return nn.Sequential( nn.Conv2d(256, 256, kernel_size3, stride2, padding1), # 步长2实现下采样 nn.BatchNorm2d(256), nn.ReLU(inplaceTrue) ) def _make_up_layer(self): return nn.Sequential( nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Upsample(scale_factor2, modenearest) # 上采样回原尺寸 ) def forward(self, x): features [] # 保存跳跃连接的特征 # 下采样过程 for i in range(self.depth): x self.downsample[i](x) features.append(self.skip_convs[i](x)) # 处理并保存特征用于跳跃连接 # 上采样过程并融合跳跃连接的特征 for i in range(self.depth-1, -1, -1): x self.upsample[i](x) # 将对应层下采样路径的特征加回来跳跃连接 x x features[i] return x2.2 角点池化给模型一双“余光”找到角点的最大挑战是什么是角点本身可能缺乏独特的纹理或颜色信息。比如一张桌子的左上角可能就是一片纯色木板边缘的交汇处局部看过去和桌子中间的区域没啥区别。如果只靠角点那一个像素周围的局部特征模型很容易抓瞎。所以CornerNet提出了一个神来之笔——角点池化层。它的思想非常直观要判断一个点是不是左上角你需要往它的右边水平方向看看看有没有物体的上边缘同时往它的下方垂直方向看看看有没有物体的左边缘。这个操作无法用标准的最大池化或平均池化实现因此需要自定义。具体来说对于特征图上的每一个位置(i, j)角点池化层做两件事水平方向从(i, j)开始向右扫描到这一行的末尾(H)取所有特征值中的最大值。垂直方向从(i, j)开始向下扫描到这一列的末尾(W)取所有特征值中的最大值。将这两个最大值相加作为该位置输出特征图的值。这个过程相当于给模型注入了一种“空间上下文”的先验知识一个真正的左上角其右侧和下方应该能汇集到代表物体边缘的强特征。这个层没有可学习的参数但它结构性地引导网络去关注对角点定位至关重要的方向信息。在代码实现上为了高效通常会用两个反向的累积最大操作来实现避免了冗余计算。import torch import torch.nn as nn import torch.nn.functional as F class CornerPooling(nn.Module): 左上角点池化层 (Top-left Corner Pooling) def __init__(self): super().__init__() def forward(self, feature): # feature: [B, C, H, W] B, C, H, W feature.size() # 水平方向池化对每一行从右向左累积最大值 # 先翻转然后做cummax从右向左等价于翻转后从左向右再翻转回来 horizontal feature.flip(dims[3]) # 水平翻转让最右边变成最左边 horizontal horizontal.cummax(dim3)[0] # 沿宽度维度累积最大值 horizontal horizontal.flip(dims[3]) # 翻转回来 # 垂直方向池化对每一列从下向上累积最大值 vertical feature.flip(dims[2]) # 垂直翻转让最下边变成最上边 vertical vertical.cummax(dim2)[0] # 沿高度维度累积最大值 vertical vertical.flip(dims[2]) # 翻转回来 # 将两个方向的结果相加 pooled horizontal vertical return pooled2.3 预测模块热图、偏移量与嵌入向量骨干网络提取了丰富的特征角点池化层赋予了方向感知能力接下来就需要具体的预测头来输出我们想要的东西了。CornerNet为左上角和右下角各设置了一个对称的预测模块每个模块需要输出三种信息热图这是最核心的输出。一个尺寸为 H x W x C 的张量其中C是物体类别数不包括背景。热图上每个位置的值代表了该位置是某类物体角点的置信度。理想情况下一个物体的真实角点位置在热图上会呈现一个亮斑。这里训练时用了一个小技巧不仅把真实角点位置设为正样本还在其周围一个高斯半径内的点设置了衰减的监督信号。这是因为即使预测的角点稍微偏离几个像素只要两个角点配对后形成的框与真实框的重叠度IoU足够高也算一个不错的预测。这个设计让模型的学习过程更平滑容错性更强。偏移量由于网络中存在下采样比如步长卷积输入图像上的一个坐标(x, y)映射到输出热图上的坐标会是(floor(x/n), floor(y/n))这里n是下采样倍数通常是4。当我们将热图上的位置映射回原图时就会产生量化误差可能差好几个像素这对小物体的检测精度影响尤其大。偏移量预测就是为了修正这个误差。网络会为每个预测的角点额外输出两个小数值Δx, Δy用于对映射回原图的坐标进行微调。这个偏移量的监督信号就是真实的坐标与量化后坐标的差值训练时使用 Smooth L1 Loss。嵌入向量这就是前面提到的“身份证”。对于每个预测的角点网络输出一个一维的嵌入向量比如长度是1。训练的目标是属于同一个物体的两个角点其嵌入向量的距离要尽可能小属于不同物体的两个角点其嵌入向量的距离要尽可能大。损失函数通常由两部分组成“拉近损失”让配对的内点距离变小“推远损失”让不同物体的角点向量距离变大。这样在推理时我们只需要计算所有左上角点和右下角点嵌入向量之间的距离选择距离最小的进行配对即可。这三个输出是同时由预测模块的最后一个卷积层分支出三个不同的卷积头得到的。整个预测模块的结构通常是骨干网络特征 - 角点池化 - 几个卷积层 - 三个平行的分支每个分支可能包含1-2个卷积层分别输出热图、偏移量和嵌入向量。3. 训练秘籍损失函数设计与调参心得模型结构搭好了能不能训出来关键就看损失函数怎么设计。CornerNet的损失函数是一个多任务损失的加权和每一部分都有它的门道。我结合自己踩过的坑给你讲讲这里面的细节。总损失函数可以概括为L L_det α * L_offset β * L_pull γ * L_pushL_det检测损失这是针对热图预测的损失。它不是一个简单的二分类交叉熵而是Focal Loss的一个变种。Focal Loss是为了解决正负样本极度不均衡的问题而设计的它通过降低那些容易分类的样本无论是正样本还是负样本的权重让模型更专注于难分的样本。在CornerNet里对于真实角点位置正样本我们用二维高斯核生成一个“软”标签中心是1向外逐渐衰减。这样靠近真实角点的位置即使没完全预测准也会得到一定的“奖励”而不是被一棍子打死。公式看起来复杂但核心思想就是对于正样本区域鼓励预测值接近高斯核值对于负样本区域远离任何角点大力惩罚那些错误地预测出高置信度的点对于正样本附近模棱两可的区域惩罚轻一些。我常用的超参数是 α2, β4和原版Focal Loss保持一致效果比较稳定。L_offset偏移量损失这部分使用 Smooth L1 Loss。这个损失对离群点不那么敏感比L2 Loss更鲁棒。它的计算很简单就是预测的偏移量和真实偏移量之间的 Smooth L1 距离。权重α通常设得比较小比如0.1或1因为偏移量的修正量级本身就不大不能让它的梯度主导了训练。L_pull 和 L_push嵌入损失这是配对的关键。L_pull负责“拉近”对于第k个物体计算其左上角嵌入向量etk和右下角嵌入向量ebk的均值ek然后让etk和ebk都向这个均值ek靠近。L_push负责“推远”对于所有不同物体的角点对我们希望它们的嵌入向量之间的距离至少大于一个阈值Δ通常设为1。如果距离已经大于Δ则这部分损失为0。β和γ是这两个损失的权重在原论文中分别设为0.1和0.1。我的经验是初期可以稍微调大一点L_pull的权重比如0.2让模型先学会把配对做对后期再平衡。训练中的几个实用技巧学习率与热身CornerNet对学习率比较敏感。我一般会用比较小的初始学习率比如2.5e-4并配合线性热身策略。先在前500个iteration里将学习率从0线性增加到初始学习率这能帮助模型在训练初期更稳定。数据增强别小看数据增强。除了标准的随机水平翻转我还会用上随机缩放比如0.6到1.3倍、随机裁剪和颜色抖动。特别是对于角点检测随机裁剪要小心别把物体的角点给裁掉了可以设置一个阈值确保裁剪后的图片至少包含完整物体的一个部分。多尺度训练这是提升模型鲁棒性的利器。在训练时不是把图片都缩放到固定尺寸而是在一个尺寸范围内随机缩放例如[511, 543]这样模型就能学会处理不同大小的物体。注意由于Hourglass网络是全卷积的它本身就能适应不同尺寸的输入。关于批大小原论文用的批大小是49在多卡上。如果你显卡内存不够可以适当减小但可能会影响批归一化层的统计稳定性可能需要配合使用群归一化Group Norm或者同步批归一化SyncBN。4. 推理全流程从热图到检测框的代码实战模型训练好了怎么用它来实际检测图片中的物体呢这个过程比训练更考验工程实现能力。我们来一步步拆解推理流程我会把关键代码和容易出错的地方都指出来。第一步前向传播获取原始输出输入一张图片经过与训练时相同的预处理送入网络得到两组输出分别对应左上角和右下角。每组输出包含三个部分heatmaps_tl,heatmaps_br: 形状为 [1, C, H, W] 的热图。offsets_tl,offsets_br: 形状为 [1, 2, H, W] 的偏移量x和y两个方向。embeddings_tl,embeddings_br: 形状为 [1, 1, H, W] 的嵌入向量这里假设嵌入维度为1。第二步从热图中提取候选角点我们不能直接用热图上值最大的100个点因为相邻的像素可能都响应很高代表的是同一个角点。所以要先做一个非极大值抑制。这里CornerNet用了一个巧妙的操作对热图进行3x3的最大池化然后只保留那些值等于池化前原始值的点即局部最大值点。这样就能过滤掉那些在同一个峰值周围的小响应。def nms_heatmap(heatmap, kernel3): 使用3x3最大池化实现NMS pad (kernel - 1) // 2 hmax F.max_pool2d(heatmap, kernel, stride1, paddingpad) # 保留那些是局部最大值的点 keep (hmax heatmap).float() return heatmap * keep # 对每个类别的热图单独处理 top_left_heat_nms nms_heatmap(heatmaps_tl) bottom_right_heat_nms nms_heatmap(heatmaps_br)接着我们从NMS后的热图中分别选取置信度最高的前K个左上角和右下角点K通常取100。同时记录下这些点的类别、得分以及在热图上的坐标整数位置。第三步利用偏移量修正角点坐标上一步得到的是热图上的整数坐标我们需要结合预测的偏移量将其映射回输入图片的原始尺度。def decode_coords(heat_coords, offsets, stride4): heat_coords: 热图上的坐标 [K, 2] (y, x) offsets: 偏移量预测 [1, 2, H, W] stride: 下采样步长 # 1. 热图坐标 - 输入图像坐标未修正 img_coords heat_coords * stride # 2. 从offsets中提取对应位置的偏移值 # offsets的维度是[1, 2, H, W]我们需要根据heat_coords的(y, x)去索引 # 注意offsets[0, 0, y, x] 是x方向的偏移offsets[0, 1, y, x] 是y方向的偏移 offset_vals offsets[0, :, heat_coords[:, 0], heat_coords[:, 1]] # [2, K] offset_vals offset_vals.permute(1, 0).contiguous() # [K, 2] # 3. 应用偏移修正 decoded_coords img_coords offset_vals return decoded_coords # [K, 2] (y, x) top_left_coords_img decode_coords(top_left_coords, offsets_tl) bottom_right_coords_img decode_coords(bottom_right_coords, offsets_br)第四步通过嵌入向量进行角点配对这是最有趣的一步。现在我们有了两组点一组左上角一组右下角每组最多100个点每个点都有坐标、类别得分和嵌入向量。我们需要找出哪些左上角和右下角属于同一个物体。配对的原则基于两个约束类别一致配对的左上角和右下角必须预测为同一个物体类别。嵌入距离近属于同一个物体的两个角点其嵌入向量的距离比如L1距离应该很小。具体操作是对于每一个左上角点我们遍历所有右下角点。如果它们类别相同则计算它们嵌入向量的距离。如果这个距离小于一个预设的阈值比如0.5并且这个距离是所有同类右下角点中最小的那么我们就认为它们配对了。这里有一个细节为了效率通常会构建一个距离矩阵然后使用一些高效的匹配算法但核心思想就是这样。def group_corners(tl_emb, br_emb, tl_classes, br_classes, tl_scores, br_scores, dist_threshold0.5): tl_emb: 左上角嵌入向量 [K_tl, 1] br_emb: 右下角嵌入向量 [K_br, 1] ... 其他信息 boxes [] box_scores [] for i in range(len(tl_emb)): tl_class tl_classes[i] tl_vec tl_emb[i] for j in range(len(br_emb)): br_class br_classes[j] br_vec br_emb[j] # 约束1: 类别相同 if tl_class ! br_class: continue # 约束2: 嵌入距离小于阈值 dist torch.abs(tl_vec - br_vec).item() if dist dist_threshold: continue # 找到了一个候选配对 # 计算框的坐标 (x1, y1, x2, y2) x1 tl_coords_img[i, 1] y1 tl_coords_img[i, 0] x2 br_coords_img[j, 1] y2 br_coords_img[j, 0] # 确保是有效的框 (x2 x1, y2 y1) if x2 x1 and y2 y1: # 框的得分取两个角点得分的平均值 score (tl_scores[i] br_scores[j]) / 2.0 boxes.append([x1, y1, x2, y2]) box_scores.append(score) return torch.tensor(boxes), torch.tensor(box_scores)第五步后处理与输出经过配对我们得到了一系列候选框及其得分。通常还会进行最后一步非极大值抑制去除那些高度重叠的冗余框。这里可以使用标准的NMS或者Soft-NMS。最后按得分排序输出前N个比如100个检测结果作为最终输出。整个流程走下来你会发现CornerNet的推理虽然步骤多但每一步逻辑都很清晰。在实际部署时可以将这些步骤封装成一个清晰的函数方便调用和调试。我最初实现时在偏移量解码和嵌入配对那里花了最多时间调试务必注意张量维度的对齐和坐标系的转换通常是y, x还是x, y。