ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CenterNet实战:密集小目标蜜蜂检测的PyTorch实现与踩坑记录

CenterNet实战:密集小目标蜜蜂检测的PyTorch实现与踩坑记录

养了三千箱蜂的老张每年最头疼的活,不是摇蜜也不是分蜂,而是清点蜂箱里的蜜蜂数量。以前靠肉眼数,赶上蜂王产卵高峰,一脾密密麻麻糊了上万只,数到眼冒金星也数不准。后来试着用目标检测去做自动计数,一开始上了YOLO,发现小目标、密集场景下漏检率高得离谱。换成CenterNet之后,事情一下子变简单了——没有anchor匹配的负担,直接学中心点和边界框尺寸,对几百上千只目标挤在一起的蜂脾场景,反而比anchor-based方法稳得多。

这篇文章就把完整的落地过程写清楚:为什么选CenterNet、数据怎么做、PyTorch代码怎么改、训练推理踩了哪些坑。适合已经在跑目标检测但觉得YOLO系在密集小目标上不够用的朋友,也适合刚接触关键点检测、想找个完整项目练手的同学。我会把代码结构、损失函数、推理细节都摊开讲,基本照着抄就能跑起来。

1. 为什么选CenterNet而不是YOLO

先说结论:蜜蜂检测这件事的核心不是"目标大不大",而是"目标多不多、小不小、粘不粘"。蜂脾上工蜂彼此挨着,边界框重叠严重,很多框子里同时框住两三只蜜蜂。YOLO系(尤其是anchor-based版本)做密集小目标检测时,匹配策略很容易出问题——一个grid cell要挑哪些anchor来负责哪些目标,目标一多就会漏。CenterNet绕开了这条链路,本质上是把检测问题变成热力图上的峰值点检测问题,天生就对密集场景友好。

1.1 蜜蜂检测任务背后有哪些特殊难点

蜜蜂目标有两个让人头疼的特点:一是小,一只工蜂在常规拍摄距离下可能只有二三十个像素宽;二是多,巢脾上一张照片动辄几千只,框之间互相重叠挤压。还有光照问题——蜂箱里暗,脾上蜡色反光,蜜蜂的深棕色和蜂巢六边形格子的颜色接近,对比度很低。

目标检测的常规手段在这类场景里很容易失效。anchor-based方法最先遇到的问题是正负样本极度不均衡:几千个候选框里只有一小部分对应真实目标,密集区域一个位置要回归多个框,训练时互相竞争。就算调好了anchor数量和尺寸,也很难覆盖"大小不一致+密度极高"的分布。CenterNet把每个目标压成一个点,从根源上回避了anchor匹配的复杂度,只需要在热力图上找峰值,然后用尺寸分支回归框的宽高。

1.2 CenterNet的核心思想:检测目标就是检测关键点

CenterNet把检测任务建模成关键点检测框架:输入图像经过backbone提取特征,下采样到原图的四分之一分辨率,然后输出三个分支——热力图(heatmap)表示每个位置是物体中心的概率;宽高图(wh)回归每个中心点对应的目标框尺寸;偏移图(reg)修正下采样带来的中心点位置误差。推理时,直接在热力图上找局部极大值,作为目标中心,再取对应的宽高和偏移值拼出最终框。

这里有个可以打比方的理解方式:老式做法像在满地找特定形状的积木块,要从一堆模具(anchor)里挑出最像的;CenterNet则是拿着笔在图上圈出"这里有一块积木的中心",再把尺子(wh分支)伸过去量尺寸。当积木块多到互相重叠时,第一种做法容易挑花眼,第二种方法反而简单直接。

1.3 和YOLO、Faster R-CNN的对比:各自的适用边界

检测器思路蜜蜂场景的主要问题结论
Faster R-CNN区域建议+二次分类回归每张图几千个候选区域,运算量大,实时性不佳不推荐,适合大目标小数量
YOLO v5等anchor-based预定义anchor+网格回归密集小目标时漏检高,小目标anchor召回不足勉强可用,但调anchor成本和漏检率都高
CenterNet关键点检测+尺寸回归不需要anchor,峰值点检测天然适应密集场景本文选型

当然CenterNet也不是完美的。它对重叠程度极高的目标,两个中心点完全重合时,热力图峰值只有一个,就会漏检。蜜蜂场景里也有这种"两只叠在一起"的情况,后面会在推理部分讲怎么兜底。

2. 数据准备:标注规范和数据增强直接决定效果上限

2.1 采集与标注的基本要求

目标检测里数据质量比模型结构更影响结果。对于蜜蜂检测,采集原始图像时要注意几点:保持统一视角,不要太斜;避免过度压缩导致蜜蜂细节糊成一团;尽量覆盖不同光照条件,尤其要拍一些蜂箱内暗光环境的照片。

标注上,我用LabelImg以Pascal VOC格式标注。这里强调一个必须遵守的原则——"宁可漏标,不可错标"。让标注员把每一只清楚可见的蜜蜂都框住,工作量确实巨大,但漏标多的数据集会让模型学歪,因为未标注的目标在训练中会被当成负样本,相当于告诉模型"这个位置没有目标",非常致命。实际操作中我给多少蜜蜂打标?一张1080p的蜂脾照片,如果蜂群密集,先放大再逐只打框,通常能标出500到2000只不等。这个工作至少占整个项目周期的一半时间。

2.2 数据增强策略:面向密集小目标的实际选择

数据增强可以极大缓解蜜蜂数据不足的问题。常用的增强手段:

  • 随机裁剪:把高分辨率原图按512x512或384x384随机裁剪,既能扩大数据集,又降低了单张图的输入负担
  • 翻转和旋转:水平翻转、±30度内的旋转。蜜蜂朝向不影响检测任务,做了也不会引入标注偏差
  • 色彩抖动:调亮度、对比度、饱和度。蜂箱里光源不恒定,这一项对鲁棒性帮助大
  • 模拟运动模糊:蜜蜂是活物,翅膀高频振动、身体快速爬动,实际拍摄会出现运动模糊。在训练集中加入少量高斯模糊增强样本,可以防止模型对"清晰轮廓"产生过度依赖

我实际用下来,最有效的是"随机裁剪"和"色彩抖动",它们一个解决尺度多样性,一个解决光照变化。旋转增强对蜜蜂来说要适度,角度大了标注框就变成了带角度的框,而CenterNet默认输出轴对齐矩形框,反而会学不准确。

2.3 训练集划分时的隐藏陷阱

蜜蜂图像往往是从视频里抽帧来的,相邻帧高度相似。如果随机划分训练集和验证集,同一段视频的相邻帧会同时出现在两边,验证集指标虚高。正确做法是按视频片段或拍摄时间段来划分。某联盟蜂场的实际项目中,某开发者一开始随机划分,验证集mAP到了0.95,换到新蜂场实测直接掉到0.7以下。改成按"蜂箱号"整体划分后,验证指标才真实反映泛化能力。

3. PyTorch代码实现:模型结构到训练流程的一次性梳理

3.1 网络结构搭建:backbone和输出分支

CenterNet的backbone可以替换成多种结构,原版论文用的是Hourglass-104,性能好但内存占用大。我的实践推荐DLA-34或ResNet-18加反卷积。DLA-34在精度和速度之间平衡较好,能在单张1080p输入下达到25到30 FPS(配合TensorRT之后)。蜜蜂检测不需要区分特别细的类别,backbone可以轻量化,ResNet-18在实际效果中也够用。

核心代码结构如下:

import torch import torch.nn as nn import torch.nn.functional as F class CenterNetHead(nn.Module): def __init__(self, in_channels, num_classes, num_layers=3): super().__init__() self.num_classes = num_classes # 热力图分支:预测每个位置属于某个类别中心的概率 self.heatmap = self._make_head(in_channels, num_classes) # 尺寸分支:预测中心点对应目标框的宽和高 self.wh = self._make_head(in_channels, 2) # 偏移分支:预测下采样带来的中心点位置偏移 self.reg = self._make_head(in_channels, 2) def _make_head(self, in_channels, out_channels): layers = [] for i in range(3): layers.append(nn.Conv2d(in_channels, in_channels, kernel_size=3, padding=1)) layers.append(nn.ReLU(inplace=True)) layers.append(nn.BatchNorm2d(in_channels)) layers.append(nn.Conv2d(in_channels, out_channels, kernel_size=1)) return nn.Sequential(*layers) def forward(self, x): return { 'heatmap': self.heatmap(x), 'wh': self.wh(x), 'reg': self.reg(x) }

这里的关键细节是最后的输出头,用的是1x1卷积调整通道数。输入到Head的特征图是通过上采样恢复到输入的1/4分辨率(stride=4)。训练时要保证heatmap的每个像素位置对应原图上一个4x4的区域,因此中心点标注也要先除以4再落到热力图上。

3.2 损失函数:热力图用focal loss,其余用L1

CenterNet的成功有一大半来自损失函数设计。热力图分支使用penalty-reduced focal loss,相较于原始focal loss,额外加入一个惩罚项,让中心点周围的高斯响应区域也能得到合理监督,不至于把相邻位置全部压成零。尺寸分支和偏移分支用L1 loss。

关键实现如下:

class CenterNetLoss(nn.Module): def __init__(self, alpha=2.0, beta=4.0): super().__init__() self.alpha = alpha self.beta = beta def forward(self, predictions, targets): heatmap_pred = predictions['heatmap'] wh_pred = predictions['wh'] reg_pred = predictions['reg'] heatmap_gt = targets['heatmap_gt'] # [B, C, H, W] wh_gt = targets['wh_gt'] # [B, M, 2] M为最大目标数量 reg_gt = targets['reg_gt'] # [B, M, 2] indices = targets['indices'] # [B, M] 中心点展平索引 mask = targets['mask'] # [B, M] 是否有效目标 # penalty-reduced focal loss pos_mask = heatmap_gt.eq(1).float() neg_mask = heatmap_gt.lt(1).float() pos_loss = -torch.log(heatmap_pred.clamp(min=1e-8)) * torch.pow(1 - heatmap_pred, self.alpha) * pos_mask neg_loss = -torch.log(1 - heatmap_pred.clamp(min=1e-8)) * torch.pow(1 - heatmap_gt, self.beta) * torch.pow(heatmap_pred, self.alpha) * neg_mask focal_loss = (pos_loss.sum() + neg_loss.sum()) / max(1, pos_mask.sum()) # 回归损失只对有效目标计算 batch_size = heatmap_pred.shape[0] wh_pred_flat = wh_pred.permute(0, 2, 3, 1).contiguous().view(batch_size, -1, 2) reg_pred_flat = reg_pred.permute(0, 2, 3, 1).contiguous().view(batch_size, -1, 2) idx = indices.long().unsqueeze(-1).expand_as(wh_gt) wh_pred_selected = wh_pred_flat.gather(1, idx)[mask > 0] wh_gt_selected = wh_gt[mask > 0] reg_pred_selected = reg_pred_flat.gather(1, idx)[mask > 0] reg_gt_selected = reg_gt[mask > 0] wh_loss = F.l1_loss(wh_pred_selected, wh_gt_selected, reduction='sum') / max(1, mask.sum()) reg_loss = F.l1_loss(reg_pred_selected, reg_gt_selected, reduction='sum') / max(1, mask.sum()) return focal_loss + 0.1 * wh_loss + reg_loss

训练时需要注意一点:heatmap_gt不是简单地把中心点位置标为1,而是以每个目标中心为均值生成一个高斯核(半径根据目标尺寸自适应),这点在后续"训练细节"部分会细说。损失里面focal loss占据绝对主导,回归损失的比例如果在训练后期出现震荡,可以适当调低wh_loss的系数。

3.3 数据加载与中心点标签生成

CenterNet的数据加载比YOLO复杂一些,核心在标签生成环节。每张图的目标框标注需要转换成三个矩阵。heatmap的尺寸是输入图像的1/4,wh和reg与heatmap同分辨率。对每个目标,取框中心点坐标,映射到热力图上对应像素,然后以该像素为中心生成一个高斯响应区域。高斯核半径的设计与目标大小相关,通常取目标宽高的某种比例,避免响应区域过大导致相邻目标混淆。

关键代码如下:

def gaussian_radius(h, w, min_overlap=0.7): # 计算高斯核半径,使得两个相邻目标在radius内不会产生过大重叠 a1 = 1 b1 = (h + w) c1 = w * h * (1 - min_overlap) / (1 + min_overlap) b2 = h + w c2 = w * h * (1 - min_overlap) / (1 - min_overlap) return max(int((b1 + math.sqrt(b1*b1 - 4*a1*c1)) / (2*a1)), int((b2 + math.sqrt(b2*b2 - 4*b2*c2)) / (2))) def build_targets(batch_boxes, batch_size, num_classes, feat_h, feat_w): heatmap_gt = torch.zeros(batch_size, num_classes, feat_h, feat_w) wh_gt = torch.zeros(batch_size, max_boxes, 2) reg_gt = torch.zeros(batch_size, max_boxes, 2) indices = torch.zeros(batch_size, max_boxes, dtype=torch.long) mask = torch.zeros(batch_size, max_boxes) for b in range(batch_size): for i, box in enumerate(batch_boxes[b]): cx = (box[0] + box[2]) / 2 / 4.0 cy = (box[1] + box[3]) / 2 / 4.0 w = (box[2] - box[0]) / 4.0 h = (box[3] - box[1]) / 4.0 ix = int(cx) iy = int(cy) radius = gaussian_radius(int(h), int(w)) # 在heatmap_gt[b, 0, iy, ix]周围画高斯圆 ... return heatmap_gt, wh_gt, reg_gt, indices, mask

这里有两个细节需要注意。第一,坐标除以4时如果正好落在边缘,要约束不要越界。第二,一个像素位置如果对应多个目标中心,代码里一般只保留最近的那个,但由此带来的漏检需要通过后面的峰值提取阶段来缓解。

3.4 训练配置和优化器选择

训练配置方面,我一次实践中使用的关键参数如下:

超参数数值备注
输入分辨率384x384或512x512越大对小目标召回越高,但显存和速度压力大
优化器Adam学习率从1e-3开始,配合余弦退火
预热前5个epoch线性预热避免初始阶段loss爆炸
总epoch80到120数据量少时配合更大增强,避免过拟合
batch size16(依赖显存)512x512输入时可能降到8
预热warmup=1000 steps前几个batch用很小的学习率过渡

在实际跑训练时需要注意,CenterNet的loss变化和大目标检测训练不同,focal loss的数值本来就比较小(通常在个位数水平),回归损失被压低到0.1倍后对总loss影响不大,训练表现更像一个"热力图生成任务"。前期关注的是heatmap_pred能不能出现清晰的中心点分布,如果训练了30个epoch热力图还是一团糊,优先检查高斯核半径是否过小或过大。

4. 训练实战:调参经验和对踩坑记录的复盘

4.1 Adam还是SGD?学习率怎么调

我一开始按原论文用Adam,学习率调到1e-3,训练得很快但后期loss下降不明显。换成余弦退火之后,收敛稳定很多,最终得到的精度比固定学习率高出约2到3个mAP点。另一个心得是不要让回归损失一开始就占太大比重——wh和reg分支的尺度与热力图不一致,L1数值偏大,如果系数不当,容易让模型只讨好回归分支而忽略了最关键的热力图学习。把wh_loss系数从默认0.1继续压到0.05,在蜜蜂场景中定位框会更加稳定。

4.2 高密度场景下,漏检还是错检哪个更致命

蜜蜂计数场景,漏检比错检更要命。一个框稍微偏一点哪怕IoU只有0.5,计数仍然算得中,但如果两只蜜蜂只检测出一只,那数量就少了。训练后期,如果单张图里有一千只蜜蜂,漏检五十只看着不显眼,可相对整个蜂群来说就是百分之五的误差,放到三千箱的大场里,差出来的量非常可观。实践中我采取的策略是适当降低置信度阈值,从0.5降到0.3,接受一些误检,确保高召回率。然后在后处理中做按时间窗口的置信度过滤,或者引入简单的跟踪机制来剔除短时出现的虚警。实测下来,这个策略在计数场景中效果显著。

4.3 推理和部署的细节优化

CenterNet推理非常快,因为不需要NMS。热力图本身就是经过3x3最大池化后提取的峰值点,天然抑制了局部重复框。这一步通过一个3x3的max pooling层实现,计算量可以忽略。具体来说,对每个类别,从heatmap中取topK个局部极大值,每个峰值再映射回原图坐标,配合wh和reg输出得到最终的预测框。

我按这个流程做了一次验证:模型在384x384输入下,单张图片的测速约为6到8毫秒(GPU环境下),加上前后处理,整体管线能跑到实时水平。如果想要更高的吞吐量,可以配合TensorRT把模型导出成engine文件,能再提升20%到30%。

代码片段如下:

def decode_centernet(heatmap, wh, reg, topk=100): # 对热力图做3x3 max pooling抑制重复峰值 hmax = F.max_pool2d(heatmap, kernel_size=3, stride=1, padding=1) keep = (hmax == heatmap).float() heatmap = heatmap * keep # 将热力图展平后取topk个峰值 batch_size, num_classes, h, w = heatmap.shape scores, indices = heatmap.view(batch_size, num_classes, -1).topk(topk, dim=2) xs = indices % w ys = indices // w # 结合wh和reg分支得到最终边界框 ... return detections

4.4 模型长期运行的稳定性问题

蜂场环境不是实验室,系统要长期在蜂箱旁边运行,拍摄角度、光照、蜂群密度随季节变化。我遇到过两种典型的退化问题。第一种是场景漂移:训练数据里全是巢脾俯拍照,换到蜂箱入口侧拍,模型检测率掉得很厉害。解决办法是尽量在采集阶段就包含多个视角,如果做不到,至少要在部署周期里定期加入新数据做小规模微调。第二种是光照突变:早中晚光线差异巨大,模型在阴影里漏检较多。这个可以通过在数据增强时加入大幅亮度扰动来缓解。我后来加了一条规则——推理前先做一次自适应直方图均衡化,让光照分布回归到训练时的水平,效果立竿见影。

5. 常见问题排查:蜜蜂检测项目的独门避坑记录

5.1 训练不收敛怎么办

先区分方向:是focal loss降不下去,还是回归loss震荡?前者通常问题在热力图标签生成或者正负样本不均衡;后者往往是学习率太大或wh系数太大。我常做的检查手段是把验证集中的图像输入模型,可视化输出热力图叠加在原图上,看看中心点分布是否合理。这一步比看loss曲线要直观得多,也最容易暴露问题。

症状可能原因解决办法
focal loss数值很大且不降高斯核半径设置过大,或大量中心点漏标注减小radius,检查标注覆盖度
回归loss震荡学习率偏高,或尺寸分支尺度跨度大降低学习率到3e-4,观察是否能稳下来
训练能收敛但验证集mAP很低划分不当或过拟合拍摄条件按蜂箱或时间段重新划分布局,加强增强
验证集上重复框很多没有正确用max pooling做峰值抑制检查解码部分的池化是否生效

5.2 推理速度够快,但内存占用太高

如果用的是Hourglass-104 backbone,单张512x512输入显存能吃到10GB往上。蜜蜂检测这种任务没必要上这么重的backbone。换成ResNet-18或DLA-34,显存占用立减一半。精度损失没有想象中严重,因为蜜蜂这类目标和大型物体不同,backbone的channel宽度主要影响特征表达能力,轻量化模型在小目标检测上并不比厚重模型差,关键还是看训练数据够不够。

5.3 面对超密集目标,单帧检测能打到多好

说个严肃的结论:单帧图像要做到蜜蜂计数100%正确,目前的检测模型都做不到。普通蜂脾图像上,目标可达上千只,互相遮挡严重,一部分蜜蜂只露出半截身体,就算人眼也不一定数得清。实际项目中,一帧图像的检测精度能到90%召回,已经是非常好的结果了。如果想继续提,可以从两个方向走:一是用高帧率视频做时序上的多帧合并去重,蜜蜂不是静止的,同一只蜂在相邻帧会移动,利用跟踪算法可以补全单帧漏检;二是用更大分辨率的输入再加TTA(测试时增强),让两边的峰值互补。后者的代价是推理速度变慢,要按实际需求权衡。

5.4 新数据上表现退化严重

这个现象我碰到过一次。原有模型在A蜂场效果不错,换到B蜂场(背景颜色、脾框种类不同)效果就下滑。查了一轮发现,训练集中大量图像来自同一批采集,背景过强导致模型把"背景纹理"当成了蜜蜂的特征之一。解决方式是加入背景增强:从新场景拍摄的背景图像中随机裁剪区域,作为负样本灌进训练集,让模型学会区分"蜜蜂轮廓"和"背景纹理"。这个操作很简单,但对场景迁移的帮助极大。

结尾小节

CenterNet做蜜蜂检测这件事,到目前为止最大的体会是:选对算法框架能让很多传统难题自动消失。YOLO系里为anchor调参、做NMS、匹配正负样本的功夫,在关键点检测下都不需要了。但相应的,标签处理和模型调试的精细度要求更高——heatmap怎么生成、峰值怎么提取、回归分支怎么配平,每一步都有讲究。我踩过的坑主要有两个,第一个是对训练标签的检查不够导致长期不收敛,第二个是在训练阶段没有做足够的背景增强导致换场景效果断崖式下跌。如果你打算上手这个项目,建议一上来先把数据准备的规范定好,再动手写模型。数据规范了,后面模型哪怕不调参,效果普遍也不会差到哪里去。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进