第一次接触CenterNet是在2019年读论文的时候,当时就被它简洁优雅的设计惊艳到了。相比那些需要预设anchor box的检测算法,CenterNet直接把目标检测问题转化为关键点预测问题,这种思路简直太妙了!我后来在多个实际项目中都采用了这个算法,实测下来效果确实很稳。
传统的目标检测算法比如YOLO、Faster R-CNN都需要预先设置一大堆anchor box,这些anchor不仅需要人工设计尺寸和比例,还会带来复杂的后处理流程。而CenterNet完全抛弃了anchor机制,直接把物体当作点来检测,输出就是简单的中心点坐标+宽高偏移量。这种设计让整个模型变得非常轻量,训练和推理速度都快了不少。
我在工业质检项目里做过对比测试,同样的硬件环境下,CenterNet的推理速度比YOLOv3快了近30%,而且准确率还略高一些。特别是在小目标检测场景下,CenterNet的表现明显优于其他算法。如果你正在寻找一个既高效又精准的目标检测方案,CenterNet绝对值得一试。
建议使用Python 3.8+和PyTorch 1.7+的组合,这个版本组合我测试过最稳定。先创建一个干净的conda环境:
bash复制conda create -n centernet python=3.8
conda activate centernet
pip install torch==1.7.1 torchvision==0.8.2
其他必要的依赖包:
bash复制pip install opencv-python numpy matplotlib tensorboard
这里有个小坑要注意:不同版本的PyTorch对CUDA的支持可能不一样。如果你要用GPU训练,建议先用nvidia-smi查看CUDA版本,然后去PyTorch官网找对应的安装命令。我在CUDA 11.0环境下测试过,上面这个组合跑起来最稳。
CenterNet对数据格式的要求比较灵活,支持VOC和COCO两种主流格式。我建议先用VOC格式练手,等熟悉了再转COCO。数据目录结构应该是这样的:
code复制VOCdevkit/
└── VOC2007/
├── Annotations/ # XML标注文件
├── JPEGImages/ # 原始图片
└── ImageSets/
└── Main/ # 训练/验证集划分文件
数据增强是提升模型鲁棒性的关键。CenterNet原论文用的是简单的随机翻转,但我在实际项目中发现加入以下增强效果更好:
python复制transform = A.Compose([
A.HorizontalFlip(p=0.5),
A.RandomBrightnessContrast(p=0.2),
A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.1, rotate_limit=5, p=0.5),
A.Resize(512, 512)
], bbox_params=A.BboxParams(format='pascal_voc'))
特别注意:CenterNet要求所有标注框必须转换为(center_x, center_y, width, height)的格式,这个转换过程要放在数据加载器里完成。我在GitHub上看到很多人复现效果不好,问题往往就出在这个数据预处理环节。
原论文用的是Hourglass网络,但这个结构计算量太大。经过多次实验,我发现用ResNet50作为backbone性价比最高。具体改造方法如下:
python复制class ResNetBackbone(nn.Module):
def __init__(self):
super().__init__()
base = torchvision.models.resnet50(pretrained=True)
# 只取前4个stage,去掉最后的全连接层
self.stem = nn.Sequential(base.conv1, base.bn1, base.relu, base.maxpool)
self.stage1 = base.layer1 # 输出1/4下采样
self.stage2 = base.layer2 # 输出1/8
self.stage3 = base.layer3 # 输出1/16
self.stage4 = base.layer4 # 输出1/32
def forward(self, x):
x = self.stem(x)
c2 = self.stage1(x)
c3 = self.stage2(c2)
c4 = self.stage3(c3)
c5 = self.stage4(c4)
return c5 # 输出1/32下采样特征图
这里有个重要细节:原版ResNet的输出是1/32下采样,但CenterNet需要1/4的特征图。所以我们需要在后面接一个Decoder来做上采样。
Decoder的作用是把1/32的特征图上采样回1/4。我参考原论文实现了这个结构:
python复制class Decoder(nn.Module):
def __init__(self, in_channels=2048):
super().__init__()
self.deconv1 = nn.ConvTranspose2d(in_channels, 256, kernel_size=4, stride=2, padding=1)
self.bn1 = nn.BatchNorm2d(256)
self.deconv2 = nn.ConvTranspose2d(256, 128, kernel_size=4, stride=2, padding=1)
self.bn2 = nn.BatchNorm2d(128)
self.deconv3 = nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1)
self.bn3 = nn.BatchNorm2d(64)
def forward(self, x):
x = F.relu(self.bn1(self.deconv1(x))) # 1/16
x = F.relu(self.bn2(self.deconv2(x))) # 1/8
x = F.relu(self.bn3(self.deconv3(x))) # 1/4
return x
每个反卷积层都用4x4的核和2的步长,这样能保证每次上采样刚好把特征图尺寸放大一倍。我在实验中发现,如果改用双线性插值上采样,效果会差不少,所以还是坚持用反卷积。
CenterNet的预测头包含三个关键组件:
实现代码如下:
python复制class Head(nn.Module):
def __init__(self, num_classes=80):
super().__init__()
# 热图分支
self.heatmap = nn.Sequential(
nn.Conv2d(64, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.Conv2d(64, num_classes, 1),
nn.Sigmoid()
)
# 宽高分支
self.wh = nn.Sequential(
nn.Conv2d(64, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.Conv2d(64, 2, 1)
)
# 偏移量分支
self.offset = nn.Sequential(
nn.Conv2d(64, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.Conv2d(64, 2, 1)
)
def forward(self, x):
return {
'heatmap': self.heatmap(x),
'wh': self.wh(x),
'offset': self.offset(x)
}
这里有个容易踩坑的地方:热图分支最后的Sigmoid激活函数绝对不能少!我第一次复现时漏了这个激活函数,结果训练完全无法收敛。另外,宽高和偏移量分支不需要加激活函数,直接输出原始值即可。
CenterNet的损失函数由三部分组成:
具体实现时要特别注意数值稳定性问题:
python复制def focal_loss(pred, target, alpha=2, beta=4):
pos_mask = target.eq(1).float()
neg_mask = target.lt(1).float()
neg_weights = torch.pow(1 - target, beta)
# 关键!必须限制预测值范围防止数值溢出
pred = torch.clamp(pred, 1e-6, 1-1e-6)
pos_loss = torch.log(pred) * torch.pow(1 - pred, alpha) * pos_mask
neg_loss = torch.log(1 - pred) * torch.pow(pred, alpha) * neg_weights * neg_mask
num_pos = pos_mask.sum()
if num_pos == 0:
return -neg_loss.sum()
else:
return -(pos_loss + neg_loss).sum() / num_pos
def l1_loss(pred, target, mask):
return F.l1_loss(pred * mask, target * mask, reduction='sum') / (mask.sum() + 1e-7)
在实际训练中,我发现宽高损失的值通常比其他两个损失大很多,所以按照论文建议,给宽高损失乘了0.1的权重:
python复制total_loss = heatmap_loss + 0.1 * wh_loss + offset_loss
经过多次实验,我总结出以下最佳训练配置:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 初始学习率 | 1e-4 | 使用预训练backbone时要设小一点 |
| 批量大小 | 16 | 根据GPU显存调整 |
| 训练轮数 | 140 | 通常120-150轮收敛 |
| 学习率衰减 | [90, 120] | 在这两个epoch衰减10倍 |
| 优化器 | Adam | 比SGD更稳定 |
训练脚本示例:
python复制optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[90,120], gamma=0.1)
for epoch in range(140):
for images, targets in train_loader:
optimizer.zero_grad()
outputs = model(images)
loss = compute_loss(outputs, targets)
loss.backward()
optimizer.step()
scheduler.step()
在训练CenterNet时,有几个典型问题需要注意:
我在实际项目中还发现,使用预训练的backbone能显著提升收敛速度。建议先用ImageNet预训练权重初始化backbone,其他部分用随机初始化。
模型输出的热图需要经过后处理才能得到最终检测框。关键步骤如下:
实现代码:
python复制def postprocess(heatmap, wh, offset, conf_thresh=0.3):
# 非极大抑制
pooled = F.max_pool2d(heatmap, 3, stride=1, padding=1)
heatmap[heatmap != pooled] = 0 # 只保留局部最大值
# 过滤低置信度点
heatmap = heatmap.squeeze()
scores, indices = heatmap.view(-1).topk(100)
selected = scores > conf_thresh
scores = scores[selected]
indices = indices[selected]
# 解析坐标
ys = indices // heatmap.size(1)
xs = indices % heatmap.size(1)
centers = torch.stack([xs, ys], dim=1).float()
# 应用偏移量
offset = offset.squeeze().permute(1,2,0).view(-1,2)
offset = offset[indices]
centers += offset
# 生成bbox
wh = wh.squeeze().permute(1,2,0).view(-1,2)
wh = wh[indices]
bboxes = torch.cat([centers - wh/2, centers + wh/2], dim=1)
return bboxes, scores
要把CenterNet部署到生产环境,我推荐以下优化方法:
python复制model = torch.quantization.quantize_dynamic(
model, {nn.Conv2d, nn.Linear}, dtype=torch.qint8)
python复制# 需要安装torch2trt
from torch2trt import torch2trt
model_trt = torch2trt(model, [input_tensor])
我在实际部署中发现,经过优化的CenterNet在Jetson Xavier上能达到50+ FPS,完全满足实时检测需求。
在工业质检项目中,我用CenterNet实现了以下改进:
关键是在数据增强环节加入了针对工业缺陷的特殊处理:
这些技巧让模型对光照变化和部分遮挡更加鲁棒。