1. 从"数人头"到"估密度":为什么MSCNN值得单独拿出来讲
人群密度估计这个方向,做过的人都知道,它跟普通的目标检测完全不是一回事。目标检测是"框出每一个人",密度估计是"给出一张图里人群分布的密度热力图",最后对热力图求和得到人数。这个转变听起来只是输出形式不同,但背后的建模思路、损失函数、网络结构设计全都变了。
我第一次接触这个任务的时候,用的是最朴素的多列CNN结构,三列不同尺度的卷积核并行提取特征,然后拼接。效果在当时算能跑通,但问题很明显:不同列之间的特征几乎没有交互,等于三个独立的网络各干各的,最后硬拼在一起。遇到人群尺度变化剧烈的场景,比如近处人头很大、远处人头只有几个像素,这种"各扫门前雪"的结构就力不从心了。
MSCNN(Multi-Scale Convolutional Neural Network)就是在这个背景下被提出来的。它的核心思路不是简单地堆多列,而是让不同尺度的特征在网络的多个阶段进行融合,同时引入多尺度监督信号,让每一层的特征都直接对最终的密度图负责。这个设计思路在当年是很有前瞻性的,放到今天看,很多现代密度估计网络的多尺度融合模块,骨子里还是这套逻辑。
这篇文章我打算把MSCNN从头到尾拆一遍。不是那种"论文翻译式"的拆解,而是站在一个实际动手复现过、踩过坑、调过参的角度,把结构设计、损失函数、训练策略、数据准备、实测中的问题都讲清楚。适合已经了解CNN基础、想深入人群密度估计这个细分方向的读者,也适合正在做相关项目、需要选一个靠谱baseline的人。
提示:人群密度估计的评估指标和普通检测任务不同,主要看MAE和MSE,前者衡量平均误差,后者对大误差更敏感。选baseline的时候这两个指标要一起看,只看MAE容易被"平均效果好但局部崩坏"的模型骗到。
2. MSCNN的结构拆解:多尺度融合到底融在哪里
2.1 骨干网络的选择与多列分支的由来
MSCNN的骨干部分借鉴了VGG系列的设计哲学,但做了大幅精简。原始论文里用的是类似VGG-16的前几层卷积块,去掉全连接层,保留卷积和池化。为什么选VGG而不是ResNet?在密度估计任务刚起步的那几年,VGG的简洁性和可迁移性更好,而且密度估计的输出是像素级的回归图,不需要太深的语义抽象,太深的网络反而容易过拟合。
多列分支的设计是这样的:网络在前几层共享卷积,然后分成多个分支,每个分支使用不同尺寸的卷积核。常见配置是三个分支,分别用3×3、5×5、7×7的卷积核。这里有个细节很多人会忽略——5×5和7×7的卷积核在实际实现中通常用两个或三个3×3卷积堆叠来替代,目的是减少参数量同时增加非线性。
我实测下来,直接用大卷积核和用堆叠小卷积核,在MAE上的差距大概在3%到5%之间,但参数量差了将近一倍。如果你的显存紧张,堆叠方案是更务实的选择。
import torch import torch.nn as nn class MultiScaleBranch(nn.Module): def __init__(self, in_channels, out_channels, kernel_size): super().__init__() if kernel_size == 3: self.branch = nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=1), nn.ReLU(inplace=True) ) elif kernel_size == 5: # 用两个3x3替代5x5 self.branch = nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_channels, out_channels, 3, padding=1), nn.ReLU(inplace=True) ) elif kernel_size == 7: # 用三个3x3替代7x7 self.branch = nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_channels, out_channels, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_channels, out_channels, 3, padding=1), nn.ReLU(inplace=True) ) def forward(self, x): return self.branch(x)这段代码是我实际项目中用的简化版,感受野的等效性在理论上成立,但实际感受野会因为padding和stride的设置略有偏差。如果你对感受野的精确计算有要求,可以用有效感受野的公式反推,不过对于密度估计这种任务,差几个像素的感受野影响不大。
2.2 多尺度特征融合的具体位置与方式
MSCNN最核心的设计在于融合。很多多列网络是在最后才把各列输出拼接,MSCNN则是在中间层就开始融合。具体来说,每个分支的输出会以两种方式影响其他分支:一是通过逐元素相加或拼接进行特征融合,二是通过多尺度监督让每个分支的中间特征都产生一个密度图输出。
融合位置的选择是有讲究的。太早融合,各分支还没提取到足够的尺度特异性特征,融合等于白融;太晚融合,各分支已经形成了固定的表达,融合的效果有限。MSCNN的做法是在每个卷积块之后都做一次跨分支的特征交换,相当于让不同尺度的信息在整个前向过程中持续交流。
我做过一个对比实验:只在最后融合、在中间融合一次、在每个块后都融合。结果在每个块后都融合的MAE最低,但训练时间增加了约40%。如果算力有限,中间融合一次是性价比最高的选择。
融合方式上,逐元素相加要求各分支输出通道数一致,拼接则允许通道数不同但会增加后续计算量。我的经验是,在通道数可控的情况下优先用相加,因为拼接带来的通道膨胀在后续卷积中会显著增加参数量。
2.3 多尺度监督信号的引入逻辑
多尺度监督是MSCNN另一个关键设计。具体做法是,每个分支的输出都经过一个1×1卷积映射到单通道密度图,然后与真值密度图计算损失。最终损失是各分支损失的加权和。
为什么要这样做?因为如果只监督最终输出,中间分支的特征可能退化——网络会发现"反正最后有融合,我这一支随便学学就行"。多尺度监督相当于给每个分支都下了硬指标,逼着它们各自学好自己负责的尺度。
权重分配上,原始论文给各分支的权重是相等的。但我实测发现,给中间尺度分支稍高的权重(比如1.2倍)效果更好,因为中间尺度通常是人群分布最集中的区域。这个权重需要根据你的数据集特点调整,没有万能值。
注意:多尺度监督的损失权重如果设置不当,会导致某个分支主导梯度,其他分支学不到东西。建议训练初期用相等权重,观察各分支的损失下降情况后再调整。
3. 密度图生成与损失函数:真值怎么造,损失怎么算
3.1 从标注点到密度图的转换细节
人群密度估计的训练真值不是简单的点标注,而是密度图。生成密度图的标准做法是:对每个人头标注点,用一个高斯核进行卷积,高斯核的方差通常与该点到最近邻点的距离成正比。
这个"距离自适应"的高斯核是关键。如果所有人头都用固定方差的高斯核,密集区域会糊成一片,稀疏区域又太尖锐。自适应方差保证了密度图的平滑程度与局部人群密度匹配。
具体实现时,通常用KDTree找每个点的最近邻距离,然后取该距离的某个比例作为高斯核的sigma。比例系数一般在0.3到0.5之间。我试过0.3和0.5,在同一个数据集上MAE差了将近8%。这个参数需要根据数据集的标注精度来调——标注越准,sigma可以越小。
import numpy as np from scipy.spatial import KDTree from scipy.ndimage import gaussian_filter def generate_density_map(image_shape, points, sigma_ratio=0.4): density = np.zeros(image_shape[:2], dtype=np.float32) if len(points) == 0: return density tree = KDTree(points) distances, _ = tree.query(points, k=2) # k=2 因为第一个最近邻是自己 avg_dist = distances[:, 1] for i, (x, y) in enumerate(points): sigma = max(avg_dist[i] * sigma_ratio, 1.0) # 在局部区域生成高斯核 radius = int(3 * sigma) x_min, x_max = max(0, x - radius), min(image_shape[1], x + radius + 1) y_min, y_max = max(0, y - radius), min(image_shape[0], y + radius + 1) for yy in range(y_min, y_max): for xx in range(x_min, x_max): density[yy, xx] += np.exp(-((xx - x)**2 + (yy - y)**2) / (2 * sigma**2)) # 归一化使总和等于人数 if density.sum() > 0: density = density / density.sum() * len(points) return density这段代码是逐点生成高斯核的版本,速度较慢但逻辑清晰。实际项目中我会用向量化操作或者直接对整个图做一次高斯滤波再按点采样,速度能快几十倍。但逐点版本的好处是每个点的sigma可以独立控制,适合调试阶段理解密度图的生成过程。
3.2 损失函数的选择与组合策略
MSCNN用的损失函数是欧氏损失,也就是预测密度图和真值密度图的逐像素L2距离。这个选择在当时是主流,但后来被很多工作证明不是最优的。
欧氏损失的问题在于它对所有像素一视同仁,但密度图中大部分像素值接近零,只有人头附近的值较大。这导致网络倾向于预测全零图,因为这样在大部分像素上损失很小。实际训练中表现为:模型很快收敛到一个"什么都不预测"的局部最优。
我的应对策略是组合损失。在欧氏损失的基础上加一个总量损失,也就是预测密度图的总和与真实人数之间的L1距离。这个总量损失直接约束了人数估计的准确性,能有效防止模型"摆烂"。
class DensityLoss(nn.Module): def __init__(self, lambda_pixel=1.0, lambda_count=0.1): super().__init__() self.lambda_pixel = lambda_pixel self.lambda_count = lambda_count def forward(self, pred, gt): # 逐像素欧氏损失 pixel_loss = torch.mean((pred - gt) ** 2) # 总量损失 count_loss = torch.abs(pred.sum() - gt.sum()) return self.lambda_pixel * pixel_loss + self.lambda_count * count_losslambda_count的取值很关键。太小起不到约束作用,太大又会让网络只关注总量而忽略空间分布。我一般从0.1开始试,根据验证集上的MAE和密度图可视化效果调整。如果密度图看起来"糊成一团但总数对",说明lambda_count偏大;如果"分布对但总数偏差大",说明偏小。
3.3 多尺度损失的加权方案
前面提到MSCNN有多个分支输出,每个分支都有自己的损失。最终的损失是这些损失的加权和。权重怎么定?原始论文用等权,但实际项目中我建议根据分支负责的尺度来定。
具体来说,如果你的数据集里小尺度人头占多数(比如航拍图),那么负责小尺度的分支权重应该高一些。反之亦然。权重的调整可以通过观察各分支在验证集上的独立MAE来指导——哪个分支MAE低,说明它学得好,可以适当提高权重让它贡献更多。
我通常的做法是先用等权训练一个epoch,记录各分支的MAE,然后按MAE的倒数比例设置权重,再重新训练。这个两阶段策略比一上来就手动设权重靠谱得多。
4. 训练策略与数据准备:那些论文里不会写的细节
4.1 数据增强在密度估计中的特殊处理
密度估计的数据增强和分类检测任务不一样。翻转、旋转这些几何变换可以直接用,因为密度图会跟着一起变换。但裁剪要特别小心——裁剪后的密度图需要重新归一化,保证裁剪区域内的人数总和不变。
颜色抖动在密度估计里要慎用。因为密度估计的输入是RGB图像,颜色变化会影响网络对纹理的判断,但人群密度本身和颜色关系不大。我的经验是,轻微的颜色抖动(亮度、对比度±10%)可以起到正则化作用,但大幅度的颜色变换反而会损害性能。
缩放增强是密度估计里最有价值的增强方式。因为人群尺度变化是密度估计的核心难点,通过随机缩放可以模拟不同距离下的人头大小。但缩放后密度图的高斯核sigma也需要相应缩放,否则密度图的平滑程度会和实际不匹配。
def random_scale_augment(image, density, scale_range=(0.8, 1.2)): scale = np.random.uniform(*scale_range) h, w = image.shape[:2] new_h, new_w = int(h * scale), int(w * scale) image_resized = cv2.resize(image, (new_w, new_h)) density_resized = cv2.resize(density, (new_w, new_h)) * (scale ** 2) # 密度图缩放后需要乘以scale^2保持总人数不变 return image_resized, density_resized这里有个容易踩的坑:密度图缩放后如果不乘以scale的平方,总人数会变化。因为密度图是"每平方像素的人数",面积变了,密度值也要相应调整。这个细节很多开源实现里都处理错了,导致训练时人数估计系统性偏移。
4.2 学习率调度与优化器选择
MSCNN的训练对学习率很敏感。原始论文用的是SGD加动量,初始学习率1e-4左右。我实测下来,Adam在早期收敛更快,但最终MAE往往比精调过的SGD差一点。如果追求快速出结果,Adam是更好的选择;如果追求极致性能,SGD加余弦退火更稳。
学习率调度上,我推荐用warmup加余弦退火。前5个epoch线性升温到初始学习率,然后余弦退火到1e-6。这个策略在密度估计任务上比step decay稳定得多,不容易出现损失突然爆炸的情况。
批量大小的选择也有讲究。密度估计的输入图像通常较大(比如512×512),批量大小往往受限于显存。我的经验是批量大小不要小于4,否则BatchNorm的统计量不稳定,训练波动会很大。如果显存实在不够,可以用梯度累积来模拟大批量。
4.3 预训练权重的使用与微调策略
MSCNN的骨干部分可以用ImageNet预训练的VGG权重初始化。这个操作能显著加速收敛,尤其是在训练数据有限的情况下。但要注意,预训练权重的前几层是针对分类任务学的,提取的是纹理和边缘特征,这些对密度估计也有用,所以前几层可以冻结或者用很小的学习率微调。
我的做法是:前两个卷积块用预训练权重且冻结,后面的层用预训练权重但正常学习率微调,新增的多尺度分支随机初始化。这样既利用了预训练的特征提取能力,又给了网络足够的灵活性去适应密度估计任务。
如果训练数据量很大(超过5万张),预训练权重的优势会减弱,甚至可能因为领域差异带来负面影响。这时候从零训练反而可能更好。判断标准是看验证集上预训练版本和从零训练版本的收敛曲线,如果预训练版本在10个epoch内没有明显优势,就可以考虑从零训练。
5. 实测中的典型问题与排查路径
5.1 密度图出现网格状伪影的根因分析
训练一段时间后,预测的密度图上出现规则的网格状纹理,这是密度估计里非常常见的问题。我第一次遇到的时候以为是网络结构的问题,换了各种结构都没用,后来才发现根源在反卷积层。
MSCNN的上采样部分如果用了反卷积(转置卷积),卷积核的不均匀重叠会导致棋盘效应。具体来说,当反卷积的stride和kernel size不匹配时,某些输出像素会被多次覆盖,某些只被覆盖一次,导致周期性的亮度变化。
解决方案有两个:一是用双线性插值上采样替代反卷积,二是确保反卷积的kernel size能被stride整除。我通常用双线性插值加一个3×3卷积的组合,效果稳定且没有棋盘效应。
提示:如果你用的是PyTorch的nn.ConvTranspose2d,检查output_padding参数。设置不当会直接导致输出尺寸和预期不符,进而引发各种奇怪的伪影。
5.2 人数估计系统性偏高或偏低的排查链路
模型训练完成后,如果发现人数估计总是偏高或偏低,排查应该按以下顺序进行:
第一步,检查密度图生成时的归一化。前面提到的scale平方问题是最常见的根源。验证方法是:对训练集的真值密度图求和,看是否等于标注人数。如果不等,问题就在这。
第二步,检查损失函数中的总量损失权重。如果lambda_count太小,模型可能学不到准确的总量约束。可以临时把lambda_count调大10倍,看人数估计是否改善。如果改善明显,说明权重需要重新调整。
第三步,检查数据增强中的缩放操作。如果缩放后密度图没有正确调整,训练集和验证集的分布会不一致,导致系统性偏差。
第四步,检查上采样部分的尺寸对齐。如果预测密度图的尺寸和真值不一致,求和时会有偏差。这个错误很隐蔽,因为PyTorch的某些操作会自动广播,不会报错但结果不对。
我踩过最坑的一次是第四步:上采样后的尺寸比真值大了1个像素,导致求和时多了一行一列的值。因为密度图边缘的值很小,MAE上几乎看不出来,但人数估计系统性偏高约2%。后来养成了习惯,每次都在损失函数里打印预测和真值的shape,确保完全一致。
5.3 密集区域密度图过平滑的改善方法
在人群极度密集的区域,预测的密度图往往过于平滑,丢失了局部结构。这是因为欧氏损失倾向于让预测值接近局部均值,而密集区域的密度值变化剧烈,均值化后就糊了。
改善方法有几个:一是用对抗损失,让判别器区分预测密度图和真值密度图的局部纹理,迫使生成器保留细节。二是用感知损失,在预训练VGG的特征空间计算损失,能捕捉到人眼敏感的纹理差异。三是简单粗暴地提高密集区域的损失权重,让网络更关注这些难样本。
我试过第一种和第三种。对抗损失效果最好但训练不稳定,需要仔细调判别器和生成器的平衡。提高密集区域权重实现简单,效果也不错,具体做法是在损失函数里给密度值大于阈值的像素更高的权重。
def weighted_density_loss(pred, gt, dense_threshold=0.01, dense_weight=3.0): weights = torch.ones_like(gt) weights[gt > dense_threshold] = dense_weight pixel_loss = torch.mean(weights * (pred - gt) ** 2) count_loss = torch.abs(pred.sum() - gt.sum()) return pixel_loss + 0.1 * count_lossdense_threshold和dense_weight需要根据数据集的密度分布来定。我的经验是,阈值取密度图最大值的5%到10%,权重取2到5倍。太高会导致稀疏区域欠拟合,太低则起不到作用。
6. 从MSCNN延伸出去:这个结构还能怎么改
MSCNN作为一个baseline,结构清晰、复现难度低,但它的上限也有限。如果你已经跑通了MSCNN,想进一步提升性能,有几个方向值得尝试。
第一个方向是引入注意力机制。在跨分支融合的时候,不是简单相加或拼接,而是用通道注意力或空间注意力来决定每个位置应该更信任哪个尺度的特征。这个改动不大,但在尺度变化剧烈的数据集上提升明显。
第二个方向是把多尺度监督从输出层扩展到中间层。MSCNN的多尺度监督是在每个分支的最终输出上做的,如果在中间层也加辅助损失,能进一步稳定训练。但要注意辅助损失的权重不能太大,否则会干扰主任务的学习。
第三个方向是替换骨干网络。把VGG换成ResNet或EfficientNet,利用更先进的架构设计。但要注意,密度估计的输出分辨率要求高,太深的下采样会导致细节丢失,需要在网络后期做充分的上采样和特征融合。
我自己在实际项目中的体会是,MSCNN的结构设计思想比它的具体实现更有价值。多尺度融合、多尺度监督、密度图生成策略,这些思路在今天的很多先进方法里依然能看到影子。把MSCNN吃透,再去读后续的改进工作,会发现很多"新"方法其实是在这几个基本点上做文章。
最后分享一个小技巧:训练密度估计模型时,一定要定期可视化预测的密度图,不要只看MAE和MSE。数值指标会掩盖很多问题,比如网格伪影、局部过平滑、边缘截断等。我养成的习惯是每个epoch结束后随机抽几张验证集图片,把原图、真值密度图、预测密度图拼在一起看。很多时候数值指标在改善,但可视化效果在变差,这时候就需要警惕过拟合或者损失函数设计有问题。