2026/10/15 16:40:55

CSRNet源码深度拆解:从密度图生成到空洞卷积的工程实践

CSRNet源码深度拆解:从密度图生成到空洞卷积的工程实践 1. 从一张拥挤的街景图说起CSRNet到底在解决什么问题第一次接触人群密度估计这个方向是因为一个很实际的需求一张地铁站台的监控截图密密麻麻全是人靠人工去数是根本不可能的但业务方又需要一个相对准确的当前区域大概有多少人的数字。传统做法是用目标检测把人一个个框出来再计数可一旦人群密度上去遮挡问题就会让检测器彻底崩溃——后排的人只露出半个脑袋甚至只露出一个肩膀检测框根本画不出来。CSRNetCVPR 2018就是冲着这个痛点来的。它不去做先检测再计数这件事而是直接回归一张密度图然后对密度图做积分得到人数。这个思路其实不是CSRNet首创但CSRNet把该深的地方深、该浅的地方浅这件事做得非常干净用VGG-16的前端做特征提取后端接一组空洞卷积dilated convolution来扩大感受野同时保持分辨率不塌陷。整篇论文的核心贡献就一句话用空洞卷积替代池化层在保持高分辨率特征图的同时获得大感受野从而生成高质量的密度图。这篇文章我打算把CSRNet的源码从头到尾拆一遍。不是那种贴一段代码解释一句的流水账而是按照一个真正要复现、要改、要调参的人的思路来走数据是怎么标注和生成的、网络每一层为什么这么设计、损失函数为什么用欧氏距离而不是别的、训练时那些论文里没写但实际会踩的坑。适合已经看过论文但对着源码发懵的人也适合想把这个模型迁移到自己场景比如农业计数、细胞计数、交通流量的人。先说清楚一个前提CSRNet的官方代码是基于PyTorch的结构非常清晰但清晰不代表好懂。它的好懂是建立在你看懂了密度图生成逻辑和空洞卷积作用的前提下的。所以下面我会先把这两块地基打牢再往上盖楼。2. 密度图不是标签是造出来的标注到真值的完整链路2.1 为什么不能直接拿人头坐标当标签人群密度估计的数据集标注形式通常是人头中心点的坐标。比如上海科技数据集ShanghaiTech的Part_B部分每张图给一个.mat文件里面是一堆(x, y)坐标。问题来了坐标是离散的点而网络输出的是连续的密度图这两者怎么对齐最朴素的想法是把坐标做成一个脉冲图——每个头的位置放一个1其余为0。但这样的图有两个致命问题第一它和网络输出的平滑密度图在数值分布上差异巨大直接做回归会让网络去拟合一堆尖峰训练极不稳定第二脉冲图对坐标标注的微小误差极其敏感标注偏几个像素损失就剧烈变化。所以标准做法是把每个点用一个高斯核扩散成一个高斯斑所有高斯斑叠加起来形成密度图并且保证整张密度图的积分等于人数。这样标签本身就是平滑的、连续的网络学起来稳定得多。2.2 固定高斯核与几何自适应高斯核这里有个关键分岔。早期工作比如MCNN用的是固定方差的高斯核不管这个人离镜头远近都用同一个sigma。但现实是近处的人头大、远处的人头小如果用同一个sigma远处密集区域的高斯斑会糊成一片近处稀疏区域又显得太尖。CSRNet论文里用的是几何自适应高斯核思路是每个人头的sigma由它到最近K个邻居的平均距离决定。距离越近说明越密集、通常也越远sigma越小距离越远sigma越大。公式大致是sigma_i beta * d_i其中d_i是第i个人到最近K个邻居的平均距离beta是个经验系数论文里取0.3。我实测下来的体会是几何自适应核在密集场景确实比固定核好但在极稀疏场景比如一张图就三五个人反而可能因为邻居距离过大导致sigma爆炸把密度图糊得没法看。所以实际工程里我会加一个sigma的上限截断比如不超过某个像素值。这个细节论文不会写但你跑ShanghaiTech Part_A的稀疏子集时一定会遇到。2.3 生成密度图的代码逻辑拆解官方代码里生成密度图的部分核心逻辑大致是这样几步读入标注坐标构建一个KDTree用于快速找最近邻对每个点查询最近的K个邻居K通常取3算平均距离d_i用sigma_i beta * d_i计算该点的高斯核方差在密度图对应位置叠加一个以该点为中心、sigma_i为标准差的高斯斑最后对整张密度图做归一化使其积分等于人数。这里有个容易忽略的点高斯斑的叠加是在原图分辨率上做的但网络输出通常是原图的1/8分辨率。所以要么在生成标签时就下采样要么在计算损失时把预测上采样。官方代码选择的是前者——标签直接生成在1/8分辨率上这样损失计算时尺寸天然对齐。但下采样会带来一个副作用如果两个头在1/8分辨率下落到同一个像素它们的密度会叠加这在极密集区域是合理的但在中等密度区域可能引入微小误差。我的经验是只要不是那种密到变态的场景这个误差可以忽略。提示如果你要迁移到自己的数据集务必先确认标注是人头中心还是人身边界框。如果是边界框需要先转成中心点否则密度图会整体偏移。3. CSRNet的网络骨架VGG前端加空洞后端每一层都有理由3.1 前端为什么直接砍掉VGG的全连接层CSRNet的前端就是VGG-16的前13层卷积到conv4_3为止把后面的全连接层和最后的池化层全砍了。这个选择非常务实VGG-16在ImageNet上预训练过的卷积核对纹理、边缘、局部模式有很强的提取能力而人群密度估计本质上就是靠局部纹理判断密度所以迁移过来非常合适。砍掉全连接层的原因更直接全连接层会把空间信息压成一维向量而密度图是逐像素的预测任务空间结构必须保留。所以前端只保留卷积部分输出一个通道数512、空间尺寸为原图1/8的特征图。这里有个实操细节官方代码加载VGG-16预训练权重时是逐层手动拷贝的而不是直接torchvision.models.vgg16(pretrainedTrue)然后切片。为什么因为torchvision的VGG16结构里包含了池化层和全连接层直接切片容易把层索引搞错。手动拷贝虽然笨但每一层的对应关系清清楚楚改起来也方便。我建议你第一次复现时就用这种笨办法别图省事。3.2 后端空洞卷积用膨胀换感受野不牺牲分辨率这是CSRNet最核心的设计。传统CNN靠池化层降分辨率来扩大感受野但池化会丢失空间细节而密度图恰恰需要精细的空间定位。空洞卷积的思路是在卷积核的元素之间插入空洞让同样的3x3卷积核覆盖更大的区域。举个例子一个dilation2的3x3卷积核实际覆盖的是5x5的区域但参数量还是9个。CSRNet后端用了六层空洞卷积dilation依次为2, 2, 2, 2, 2, 2论文里配置略有差异有的实现是2,2,2,2,2,2有的中间穿插不同值通道数从512逐步降到256、128、64最后用一个1x1卷积输出单通道密度图。为什么是六层因为要覆盖足够大的感受野让网络看到足够多的人头才能判断局部密度。为什么dilation都是2因为dilation太大会导致网格效应gridding artifact即卷积核采样点之间出现规律性的空隙输出图上会出现棋盘状的伪影。dilation2是一个在感受野和伪影之间的平衡点。我踩过的一个坑空洞卷积层如果初始化不当训练初期输出会全零或全饱和。官方代码用的是默认初始化但我在自己的数据上发现把空洞卷积层的权重用更小的方差初始化比如乘以0.1前几个epoch的损失下降会平滑很多。这个技巧在论文里找不到但确实管用。3.3 输出层与上采样为什么最后要放大8倍网络输出的是1/8分辨率的密度图但评估和可视化时通常需要原图分辨率。官方代码在推理阶段用双线性插值把输出放大8倍。这里有个细节放大操作是在计算损失之前还是之后答案是之后。训练时损失直接在1/8分辨率上算因为标签也是1/8分辨率推理时才放大用于可视化。但如果你要做精细的局部计数比如只关心某个小区域的人数放大8倍带来的插值误差可能不可忽略。我的做法是如果业务允许直接在1/8分辨率上做区域积分精度反而更高因为避免了插值引入的额外平滑。4. 损失函数的选择为什么是欧氏距离而不是交叉熵4.1 密度图回归的本质是逐像素回归很多人第一反应是密度图每个像素的值在0到1之间归一化后那是不是可以用交叉熵或者BCE答案是不行。因为密度图的像素值不是概率而是该像素对应区域的人数密度它是一个连续的实数值可能大于1在极密集区域一个像素可能对应好几个人。交叉熵要求标签是概率分布这里不满足。所以CSRNet用的是欧氏距离损失MSE即预测密度图和真值密度图逐像素求差的平方再求平均。这个选择看似简单但背后有讲究MSE对密度图的整体形状敏感能促使网络学到平滑的密度分布而不是去拟合个别尖峰。4.2 MSE的弱点与我的补救经验MSE有个众所周知的弱点对大误差敏感对小的系统性偏差不敏感。在人群密度估计里这表现为网络在稀疏区域预测得还行但在极密集区域容易欠预测——因为密集区域的密度值大MSE会倾向于把预测值往均值拉导致峰值被削平。我的补救办法有两个。第一个是在损失里加一个绝对误差项L1让损失变成MSE和MAE的加权和权重我一般设0.1到0.3之间。L1对峰值更友好能缓解削平问题。第二个是对密度值做对数变换后再算损失即用log(1pred)和log(1gt)做MSE这样密集区域的权重被相对降低稀疏区域被相对提高整体更均衡。这两个技巧我在多个数据集上试过MAE通常能降5%到10%。注意如果你用了对数变换推理时记得做逆变换否则输出的人数会偏小。4.3 多尺度损失一个可选的进阶方案CSRNet原文只用了单尺度损失但后续很多工作比如CAN、SANet引入了多尺度损失即把网络中间层的特征也上采样到密度图尺寸分别算损失再加权求和。这个思路对CSRNet同样适用你可以在后端空洞卷积的中间层引出一条支路输出一个辅助密度图和最终输出一起算损失。辅助损失的权重一般设0.1到0.5。我实测下来多尺度损失能让收敛更快最终精度也有小幅提升但代价是显存占用增加训练时间变长。如果你的显卡够用值得一试。5. 训练过程的实操细节论文不会告诉你的那些事5.1 数据增强裁剪、翻转与密度保持人群密度估计的数据增强有个特殊约束增强后的图密度图必须同步变换且积分不变。比如随机裁剪裁剪后的密度图积分应该等于裁剪区域内原有的人数而不是原图总人数。官方代码里用的是随机裁剪加随机水平翻转裁剪尺寸通常是原图的1/4到1/2。我额外加了一个随机缩放缩放范围0.8到1.2。为什么因为不同场景下人头大小差异很大缩放能让网络对尺度更鲁棒。但缩放时要注意密度图的积分必须保持不变所以缩放后要对密度图做相应的数值调整乘以缩放比例的平方的倒数。这个细节很容易搞错搞错了网络学到的就是错误的人数映射。5.2 学习率策略步进衰减比余弦退火更稳官方代码用的是步进式学习率衰减初始学习率1e-5对非常小每训练一定epoch后乘以0.1。为什么初始学习率这么小因为VGG前端是预训练过的后端是随机初始化的如果学习率太大后端的大梯度会冲垮前端已经学好的特征。小学习率让后端慢慢适应前端微调。我试过余弦退火发现前期损失下降确实更快但后期容易在局部最优附近震荡最终精度反而不如步进衰减。所以我的建议是如果你追求稳定复现就用步进衰减如果你有时间做大量调参可以试试带热重启的余弦退火。5.3 批大小的选择与显存权衡CSRNet的输入通常是原图裁剪后的patch尺寸比如256x256或512x512。批大小受显存限制8GB显存下256x256的patch大概能跑batch size 8到12。这里有个权衡批大小太小BatchNorm的统计量不准训练不稳定批大小太大显存不够而且梯度更新次数少收敛慢。我的经验是如果显存允许batch size至少8如果实在不够可以把BatchNorm换成GroupNorm这样对小批大小更鲁棒。官方代码用的是BatchNorm但在小批大小下GroupNorm确实更稳。这个替换很简单一行代码的事但效果立竿见影。5.4 训练中的常见异常与排查训练CSRNet时我遇到过几种典型异常这里列出来供你排查异常现象可能原因排查方法损失不下降学习率过大或过小打印每层梯度范数看是否爆炸或消失损失震荡剧烈批大小太小或数据未打乱增大批大小检查DataLoader的shuffle预测密度图全零输出层初始化不当检查最后一层权重方差尝试小方差初始化预测人数系统性偏大密度图归一化错误检查标签生成时的积分是否等于人数密集区域预测偏小MSE削峰效应加入L1损失或对数变换这张表是我自己踩坑总结的不一定覆盖所有情况但能帮你快速定位大部分问题。6. 评估与推理MAE、MSE之外你该关注什么6.1 MAE和MSE的局限标准评估指标是MAE平均绝对误差和MSE均方误差都是针对整张图的总人数。但这两个指标有个盲区它们不关心密度图的空间分布是否正确。举个例子一张图真实人数100预测也是100但预测的密度图可能把人都堆在了左上角右下角全空。MAE和MSE都会给满分但这样的密度图在实际应用里是没用的。所以我在实际项目里会额外看两个东西一是局部区域的计数误差比如把图分成4x4的网格看每个网格的计数误差二是密度图的峰值位置用简单的峰值检测看预测的峰值是否落在真实人头附近。这两个指标能暴露MAE掩盖的问题。6.2 推理速度与部署考量CSRNet的推理速度不算快因为VGG前端本身比较重后端还有六层空洞卷积。在单张1080Ti上一张512x512的图大概要几十毫秒。如果要做实时视频流分析这个速度可能不够。我的优化思路有三个。第一把VGG前端换成更轻的骨干比如MobileNet或ShuffleNet精度会掉一点但速度能快好几倍。第二用TensorRT做推理优化FP16量化后速度能提升2到3倍精度损失很小。第三降低输入分辨率比如从512降到384速度提升明显但密集区域的精度会下降需要根据场景权衡。6.3 从密度图到业务指标积分、阈值与告警拿到密度图后业务方通常要的不是图而是数字和告警。积分得到总人数是最基本的。但实际场景里我还会做两件事一是设置密度阈值当某个区域的密度超过阈值时触发告警比如地铁站台某块区域密度超过每平方米5人二是做时间序列平滑因为单帧的密度估计有噪声用滑动平均或卡尔曼滤波平滑后人数曲线会稳定很多告警也不会频繁抖动。这些后处理逻辑虽然不属于CSRNet本身但决定了这个模型能不能真正落地。我见过太多人把模型训到MAE很低但一到实际场景就发现数字跳得没法用问题就出在后处理上。7. 迁移到自己的数据几个必须调整的地方7.1 标注格式的适配如果你的数据标注是边界框而不是中心点需要先转成中心点。但边界框转中心点有个问题如果框标的是全身而不是人头中心点会偏低导致密度图整体下移。我的做法是如果框的高度明显大于宽度说明是全身框就把中心点上移到框的上三分之一处近似人头位置。这个启发式规则在多数场景下够用。7.2 密度图分辨率的重新考量官方代码用1/8分辨率但这不一定适合你的场景。如果你的图分辨率很低比如320x2401/8后就只剩40x30密度图会糊得没法看。这时候可以考虑用1/4分辨率代价是网络后端需要调整上采样倍数。反过来如果图分辨率很高比如4K1/8可能还是太大显存吃不消可以考虑1/16。7.3 预训练权重的取舍VGG-16在ImageNet上的预训练权重对自然场景图很有用但如果你的数据是红外图、X光图或者显微镜图ImageNet的特征可能不太匹配。这时候有两个选择一是仍然用预训练权重但用更小的学习率微调二是从头训练但需要更多数据和更长的训练时间。我的经验是如果数据量少于1万张还是用预训练权重更稳哪怕领域不匹配低层边缘特征也是通用的。8. 我在实际项目里踩过的三个坑第一个坑是密度图归一化。早期我自己写标签生成代码时忘了最后做积分归一化导致标签的总和是高斯斑的个数而不是人数因为每个高斯斑的峰值是1但积分不是1。网络学出来的密度图积分总是偏大MAE看着还行但人数系统性偏高。排查了很久才发现是标签的问题。所以你现在如果发现预测人数总是偏大或偏小第一件事就是检查标签的积分。第二个坑是空洞卷积的网格效应。有一版代码我把dilation设成了4想着感受野更大更好结果输出密度图上出现了明显的棋盘状纹理MAE反而变差了。后来查资料才知道dilation太大会导致采样点之间空隙过大卷积核覆盖不均匀。改回2之后问题消失。所以dilation不是越大越好2是一个经过验证的平衡点。第三个坑是BatchNorm在推理时的统计量。训练时用的是mini-batch的统计量推理时用的是滑动平均。如果训练时批大小太小滑动平均的统计量不准推理时输出会明显偏移。我有一版模型训练时batch size只有4推理时密度图整体偏亮人数偏大。后来换成GroupNorm问题解决。所以如果你显存小、批大小上不去强烈建议换GroupNorm。9. 这个模型还能怎么改几个值得尝试的方向CSRNet的结构很干净这意味着它也很容易被改。我自己尝试过几个方向这里分享出来供参考。第一个方向是注意力机制。在空洞卷积后端加入通道注意力比如SE模块或空间注意力让网络更关注有人头的区域。我试过在每层空洞卷积后加SE模块MAE能降3%左右参数量增加很少性价比很高。第二个方向是多任务学习。除了密度图让网络同时预测人头的大致尺度比如用一个小分支回归局部平均人头大小。这个辅助任务能让前端学到更尺度敏感的特征对密度估计有正向帮助。我试过加一个尺度回归分支MAE降了约5%但训练复杂度增加需要调辅助损失的权重。第三个方向是时序信息。如果你的场景是视频流单帧密度估计会有抖动可以把连续几帧的密度图做时序融合比如用简单的LSTM或者3D卷积。这个改动比较大但效果通常很明显人数曲线会平滑很多。这些改动都不是必须的CSRNet原版在很多场景下已经够用。但如果你追求极致精度或者场景特别复杂这些方向值得投入时间。最后说一句关于复现的心态CSRNet的代码量不大但细节很多第一次复现时MAE比论文高个10%到20%是很正常的。别急着怀疑自己的实现先检查标签生成、学习率、批大小这几个最容易出错的点。多数时候问题不在网络结构而在数据和训练策略上。