
简介基于多列卷积神经网络MCNN的密集人群计数课程设计源码包面向机器学习与人工智能专业的在校学生、课程设计及毕设开发者解决监控场景下的人群密度估计与人数统计问题。压缩包内含完整Python工程覆盖数据预处理、模型定义、训练与测试流程并配套ShanhaiTech和UCSD两套实验数据集、已训练模型权重及说明文档。包内共2000个文件以npy数据文件为主另有9个py脚本、5个xml配置和1个md说明分别承担数据加载、网络构建、参数配置与使用指引等作用整体大小约123MB目录结构清晰便于按模块查阅。项目代码均测试运行成功可作为课程大作业、毕业设计或入门进阶的完整参考。当前已有96人学习下载对需要复现人群计数任务、理解多列卷积设计思路的读者而言是一份能直接运行的实操性资源。1. 机器学习大作业选密集人群计数多列卷积模型凭什么能拿到高分课程设计最怕的不是题难而是题目看着吓人、实际上没东西可以写。基于多列卷积的密集人群计数是这类大作业里性价比比较高的一个选项数据集公开、评价指标清楚MAE/MSE、可视化效果直观而且核心网络 MCNN 用 PyTorch 手写不到一百行不需要加载任何预训练权重。它解决的是在拥挤场景下估计单张图像里总人数的问题适合作为机器学习课程设计、深度学习入门项目也能拿来做人群密度分析的工程预研。别期待它达到商用精度但作为一篇能讲清原理、能现场跑通的高分课程设计方向完全成立。我见过太多组选目标检测被 GPU 显存卡死而密度回归这条路CPU 都能跑完小规模实验。2. 多列卷积与密度图MCNN 的三列感受野为什么能扛住密集遮挡2.1 人群计数不是数人头而是回归一张密度图第一次接触人群计数的人直觉一定是“把每个人都框出来”。这个思路在稀疏场景下没问题但密集人群里人头互相遮挡、尺度差异极大检测框的 NMS 合并一次就能把几十个人吃掉。基于多列卷积的密集人群计数走的是另一条路不数框而是让网络回归一张密度图。每个标注点(x, y)被展开成一个高斯峰整张标注图变成连续的“人头发光图”网络输出的每个像素值表示该位置的人头密集程度对全图求和就是人数。这套思路最早来自 MCNN 那篇经典工作CVPR 2016配套的 ShanghaiTech 数据集也成了后续所有计数模型的基准。课程设计选它有个天然优势损失函数就是一个像素级 MSE评价指标只有 MAE 和 MSE 两个数答辩时十分钟能把原理讲完剩下的时间都留给现场跑 demo。相比之下检测类方法要解释 anchor、NMS、正负样本均衡三句话就绕晕。密度回归把问题从“分类定位”简化成了“连续值回归”模型更容易收敛也更容易让新手理解梯度回传在做什么。这里我一般会强调一个容易被忽略的点密度图方法并不要求网络做“逐个人头匹配”所以它对遮挡天然鲁棒。只要密度分布在空间上大致正确求和之后的人数误差就能控制在可接受范围。这也是为什么密密麻麻几百人的街景图密度回归法能跑出比检测法稳定得多的计数结果。用课程设计的尺度去衡量这种“鲁棒性”就是拿高分的关键叙事。2.2 MCNN 的三列并行结构大中小感受野如何分工既然人群里的人头大小差异巨大一个固定卷积核的 CNN 就很难照顾所有尺度。MCNN 的解法是“打群架”三个独立的卷积子网络并行每列用不同大小的卷积核最后把三列特征拼到一起再回归密度图。常见实现里第一列用 9×7 的卷积核感受野最大适合捕捉近处的大人头和身体轮廓第二列用 7×5覆盖中等尺度第三列用 5×3只盯局部纹理对应远处的小人头。三列的结构有点像“三个不同视力的人同时看图”一个看全貌一个看中景一个看细节最后把三个判断拼起来。相比单列网络多列的好处是特征互补梯度在并行分支里各自回传不容易同时陷入局部最优。训练时三列不需要任何额外监督只有最终融合输出参与损失计算这让代码实现变得非常简单。你甚至可以把其中两列理解成某种“数据增强”——让同一张图以三种感受野同时过网络等于隐式做了多尺度训练。需要注意卷积层的 padding 设置。MCNN 的原始设计里各列卷积后要拼接所以在 conv 层必须要保持特征图尺寸不变常见做法是每个卷积层都手动设置 padding(kernel_h//2, kernel_w//2)池化层保持尺寸不变或只减半一次。如果哪一列的尺寸缩了后面 concat 就会直接报 shape 不匹配这是新手最容易犯的错误。2.3 损失函数与评价指标MAE 和 MSE 到底在衡量什么训练阶段用的是欧氏距离损失也就是预测密度图与真值密度图逐像素差的平方和。它假设每个像素的误差是独立同分布的把计数问题完全当作一个回归任务。选择这个损失的原因很简单标注只有点坐标展开成高斯核之后真值本身就是平滑的连续函数L2 损失能让网络学到“糊一点没关系位置要对”的倾向。评估时只看两个数字MAE 是预测人数与真实人数差值的绝对平均反映平均偏差MSE 是差值的平方平均再开方放大了那些“某张图数错几十人”的极端样本。课程设计里我建议把这两个指标都打印出来并且在文档里写一句“MAE 低代表整体稳定MSE 低代表没有灾难性误差。”这一句话就能让评分老师觉得你真的理解了指标含义而不是只会跑脚本。另外要提醒一点训练损失和评估指标并不完全一致。训练用的是逐像素 L2评估用的是全局人数 L1/L2所以可能出现训练损失下降但 MAE 不降的情况这在第 5 章会专门讲到。理解这个差异能帮你少走很多弯路。3. 数据与代码落地SHHB 密度图生成脚本和 MCNN 的 PyTorch 最小实现3.1 数据集选型ShanghaiTech 还是 UCF-QNRF课程设计最常见的选择是 ShanghaiTech 数据集尤其推荐其中的 Part_BSHHB也就是街道密集场景部分。Part_A 是开阔广场人群尺度变化更极端Part_B 是沿街人行道人物相对均匀训练更容易收敛。UCF-QNRF 更大、图像分辨率更高、难度明显上一个台阶适合用来做“我还能做得更好”的进阶实验但作为大作业主体方案容易让训练时间失控。在使用 ShanghaiTech 时我建议先把原数据集切出一个 30~50 张的 mini 子集单独跑通“读图 → 生成密度图 → 训练几轮 → 保存模型 → 推理求和”的全流程再上全量数据。这一步能帮你提前暴露所有预处理 bug而不是在正式训练两小时后才发现密度图是黑的。标注文件一般是 .mat 格式里面存储的 points 是一个二维数组第一列是 x 坐标、第二列是 y 坐标。读取时可以用 scipy.io.loadmat注意 mat 文件里可能包了一层结构需要 points[0] 或直接取 key 来拿到真正的坐标矩阵。3.2 密度图生成脚本几何自适应高斯核的三个关键参数密度图是通行做法里决定训练上限的一步。论文里用的是几何自适应高斯核对每个标注点 xi计算它到最近的 k 个人头中心的平均距离然后令 sigma spread * mean_distancespread 通常取 0.3k 取 3 或 4。距离越近说明人群越密集高斯峰就越窄距离远则峰更宽。这个逻辑对应到物理意义上就是“人群挤在一起时每个头的可视范围更小”。下面是一段可以直接用的密度图生成代码依赖 numpy 和 scipyimport numpy as np from scipy.ndimage import gaussian_filter from scipy.spatial import KDTree def generate_density_map(img_shape, points, k3, spread0.3): 根据标注点生成几何自适应密度图 img_shape: (H, W) points: N x 2 的数组每行是 (x, y) density np.zeros(img_shape, dtypenp.float32) if len(points) 0: return density # 用 KDTree 快速找每个点到其他点的距离 tree KDTree(points) # 对每个标注点计算到最近 k 个邻居的平均距离 for i, (x, y) in enumerate(points): # 转换成整数坐标防止越界 xi, yi int(round(x)), int(round(y)) if xi img_shape[1] or yi img_shape[0]: continue # 查询 k1 个邻居第一个是自身所以取 [1:] dists, _ tree.query(points[i], kk1) dists dists[1:] # 平均距离不能为 0合力避免除零 avg_dist np.mean(dists) if len(dists) 0 else 1.0 if avg_dist 1e-6: avg_dist 1.0 sigma spread * avg_dist density[yi, xi] 1.0 if sigma 0: density gaussian_filter(density, sigmasigma) return density这段代码是逐点累加再统一高斯滤波的近似写法好处是代码短、不容易在边界出错。需要注意的点有三个一是 KDTree 查询时要把自身排除否则平均距离会被一个 0 拉低二是坐标顺序是 (x, y)而行列索引是 [y, x]顺序搞反是密度图全黑或错位的最常见原因三是 sigma 太小时高斯滤波几乎不起作用密度图会退化成一堆孤立峰值这时网络学到的就不是密度而是“找点”后面测试计数会偏大。对课程设计来说如果不想做逐点累加也可以对每个点单独生成一块高斯 patch 加到全零图上但要注意边缘截断否则边缘人头会被“切”成半块导致计数系统性偏低。3.3 MCNN 网络定义与训练主循环最小可复现版本拿到密度图后网络定义就简单了。MCNN 的三列子网络都是“卷积 ReLU 最大池化”的堆叠最后拼接并输出单通道密度图。我一般会把三列的输出通道统一到 24融合后再接两个卷积层。整个模型参数量在百万级比 ResNet 小一个数量级CPU 上也能慢慢训。import torch import torch.nn as nn import torch.nn.functional as F class MCNN(nn.Module): def __init__(self): super().__init__() # 第一列大感受野核尺寸偏大 self.branch1 nn.Sequential( nn.Conv2d(3, 16, kernel_size(9, 7), padding(4, 3)), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 24, kernel_size(7, 5), padding(3, 2)), nn.ReLU(), ) # 第二列中等感受野 self.branch2 nn.Sequential( nn.Conv2d(3, 20, kernel_size(7, 5), padding(3, 2)), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(20, 24, kernel_size(5, 3), padding(2, 1)), nn.ReLU(), ) # 第三列小感受野盯细节 self.branch3 nn.Sequential( nn.Conv2d(3, 24, kernel_size(5, 3), padding(2, 1)), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(24, 24, kernel_size(3, 1), padding(1, 0)), nn.ReLU(), ) # 融合后回归密度图 self.fuse nn.Sequential( nn.Conv2d(72, 48, kernel_size3, padding1), nn.ReLU(), nn.Conv2d(48, 1, kernel_size1), ) def forward(self, x): f1 self.branch1(x) f2 self.branch2(x) f3 self.branch3(x) # 三列特征通道拼接 fused torch.cat([f1, f2, f3], dim1) density self.fuse(fused) return density这个网络里三个分支都只做了一次池化输入如果从 768×1024 进来融合后的特征图大概是 384×512计算量适中。要注意所有卷积层都按核尺寸配了 padding保证三列输出在空间维度上对齐否则 torch.cat 会直接报错。训练主循环和普通回归任务没有区别用 MSE Loss Adam 即可from torch.utils.data import Dataset, DataLoader class CrowdDataset(Dataset): def __init__(self, image_paths, density_paths): self.image_paths image_paths self.density_paths density_paths def __len__(self): return len(self.image_paths) def __getitem__(self, idx): import cv2 img cv2.imread(self.image_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (512, 384)) img img.astype(np.float32) / 255.0 img img.transpose(2, 0, 1) density np.load(self.density_paths[idx]) density cv2.resize(density, (512, 384)) density density.astype(np.float32) return torch.from_numpy(img.copy()), torch.from_numpy(density.copy()).unsqueeze(0) model MCNN() optimizer torch.optim.Adam(model.parameters(), lr1e-4) criterion nn.MSELoss() loader DataLoader(train_dataset, batch_size1, shuffleTrue) for epoch in range(50): for imgs, dens in loader: pred model(imgs) loss criterion(pred, dens) optimizer.zero_grad() loss.backward() optimizer.step()注意代码里把图像缩放到 384×512 再训练密度图要同步 resize否则人数求和会差一个缩放比例平方。Dataset 里每次读取都做 resize省去了预先存储的麻烦代价是训练时会频繁触发 CPU 数据读取建议把 num_workers 调到 4 以上。这个最小版本可以在普通笔记本 CPU 上 20 分钟跑完 50 轮小规模实验适合先验证流程再上 GPU。4. 训练参数与模型落地学习率、batch size 和模型自测的一次定档4.1 必调参数表从这些值开始别一上来就调网络结构很多大作业翻车不是模型写错而是参数一开始就定得离谱。MCNN 这种全卷积回归网络对学习率极其敏感学习率一大密度图直接变成全零或全 NaN学习率太小50 轮下去 MAE 还在原地。下面是一组我常用的课程设计基线参数可以直接作为起点。参数推荐值调整方向学习率Adam 1e-4 起步损失震荡就降一半连续 5 轮不降就 /2batch size1~2图像较大显存不够就固定 batch1训练轮数50~100看验证 MAE 曲线早停比死磕更有效输入尺寸最长边 800 或固定 384×512越小越快但密度峰会变窄高斯核 k3~4人群越密集取越小spread0.3密度图太平就降到 0.25训练不稳就升到 0.4优化器Adam 或 SGD(momentum0.9)课程设计用 Adam 省心随机种子固定 42保证答辩时结果可复现补充一个血泪经验不要一开始上 batch size 大孝。密度图回归的 target 是一张平滑图batch 里每张图的密度峰值位置差异极大batch 一大反而让梯度方向互相抵消。MCNN 原文很多实验都是 batch1我自己的实践也证明 batch1 在小数据集上更稳损失曲线更平滑。如果你发现 batch1 时损失抖动特别厉害优先检查输入是否归一化到了 0~1而不是去调学习率。还有一种常见误用是拿 ImageNet 预训练权重来初始化 MCNN 分支。MCNN 的三列输入是原始像素不需要任何预训练强行加载 ResNet encoder 反而会因为通道数和卷积核尺寸不匹配造成各种维度错误。课程设计文档里写“网络从零训练不依赖外部预训练模型”是加分的说明你真的理解了网络设计逻辑。4.2 模型保存与推理脚本先自测再交作业后悔药要提前备好训练完成后保存模型不只是为了让老师能跑更是为了给自己留后悔药。我一般每 10 轮保存一个 checkpoint只存 state_dict 和当前 MAE不存整个 model 对象这样文件小、加载快。推理脚本里有一个关键细节预测密度图求和得到人数之前一定要把“训练时做的 resize 缩放”还原回去。import torch import cv2 import numpy as np def predict_count(model, image_path, devicecpu): model.eval() img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) orig_h, orig_w img.shape[:2] # 推理尺寸需要和训练时保持一致 test_h, test_w 384, 512 img_resized cv2.resize(img, (test_w, test_h)) img_tensor torch.from_numpy(img_resized.transpose(2, 0, 1)) img_tensor img_tensor.float().unsqueeze(0) / 255.0 with torch.no_grad(): density model(img_tensor).squeeze().numpy() # 密集人群计数关键步骤密度图求和 缩放还原 count_resized density.sum() scale_factor (orig_h / test_h) * (orig_w / test_w) count_original count_resized * scale_factor return count_original, density这段代码里count_resized 是在 384×512 尺度下的人数但原图尺寸和测试尺寸不一致直接把密度图每个像素当成真实面积会导致计数偏大。缩放还原系数是面积比也就是高之比乘以宽之比。我见过有人把 resize 后的人数直接当最终答案交给老师结果原图 800 人预测输出 1200答辩现场翻车。所以我把这个还原逻辑写成一个函数每次推理自动完成避免手工换算。加载模型时还有一个容易踩的坑如果保存时用的是torch.save(model.state_dict(), path)加载就必须先实例化相同结构再load_state_dict。直接torch.load(path)再拿去预测大概率报AttributeError。把这些代码写成一个infer.py脚本训练完先跑三张训练集图片验证“[原图人数 密度图求和 × 缩放系数]”这条等式再去做完整测试集评估。4.3 可视化监控预测密度图与真值对比用眼睛找问题数值指标会骗人但图像不会。每训练 5 轮我会保存一组对比图左边是原图中间是真值密度图 heatmap右边是预测密度图 heatmap。用 matplotlib 画成三列并排一眼就能看出问题。如果预测密度图是“一堆离散亮点”而不是“一片连续雾状”说明模型学成了检测器大概率是高斯核 sigma 设太小了如果预测完全“糊成一片”没有峰值说明网络容量不够或训练不足。课程设计文档里放这种可视化对比图是特别加分的呈现方式它比丢一串 loss 数字直观得多。我会把每轮的对比图按 epoch 编号存入vis/目录答辩时挑第 10、30、50 轮的图放一起展示修正过程。这种“训练过程可视化”在评分老师眼里等同于你在认真做实验而不是在套模板。反过来如果你发现预测图比真值图“锐利”很多别急着高兴这说明网络在硬背训练集val MAE 很快就会反弹。5. 密集人群计数训练避坑五个翻车现场与对应解法5.1 密度图全黑训练损失一直是 0现象训练刚开始 loss 就是 0打印 GT 密度图发现全黑完全没有高斯峰。原因标注坐标和图像尺寸没对齐。最常见的三种一是读取 mat 时坐标顺序是 (x, y)但你在 numpy 里用 [y, x] 索引全部落到图外二是图像被 resize 后标注点没有同步缩放导致所有点跑出边界三是 mat 里 points 可能被包了一层直接取到的是对象地址而不是数组。解决生成密度图之前先打印points.min(axis0)和points.max(axis0)与img.shape对比。再做两层保险一是生成时对坐标做clip(0, W-1)和clip(0, H-1)二是把超出边界超过 10% 的样本直接丢进日志提醒。坐标顺序问题可以临时画一张图验证把标注点用 cv2.circle 画在原图上肉眼确认位置。这一步 30 秒就能完成能省下两小时的排错时间。5.2 loss 降得很低但预测人数整体偏大现象训练损失降到 0.001 以下但测试集上预测人数普遍比真实人数多 20%~30%而且原图越多人差得越离谱。原因密度图的高斯峰过于尖锐模型输出的每个峰值都“扎得很高”求和时把一个小区域的密度放大了。本质是 sigma 太小真值密度图接近 one-hot 分布网络只需要学会“哪里有人头”就能让像素级 MSE 很低但总和就不准。解决把几何自适应高斯核的 spread 从 0.3 调大到 0.4~0.5让真值密度图更平滑同时设置 sigma 下限例如sigma max(sigma, 4.0)。更稳妥的办法是在训练损失里加一项计数约束loss mse_loss 0.1 * abs(pred.sum() - gt_count)。这个附加损失直接对齐最终指标能有效压制“像素对但总数不对”的情况。注意权重不要超过 0.1否则网络会走捷径输出一个均匀分布来完成计数可视化效果很差。5.3 训练集损失下降验证集 MAE 却在上升现象前 20 轮一切正常第 25 轮开始 val MAE 突然反弹train loss 还在下降。原因典型的过拟合 学习率偏高。MCNN 在没有预训练和强数据增强的条件下通常在 30~50 轮开始记住训练集的密度峰值位置。如果输入没有做随机裁剪、随机翻转模型很容易把“训练图片的固定布局”给背下来。解决加入简单的数据增强水平翻转概率 0.5、随机亮度扰动、随机裁剪后 resize 回固定尺寸。同时把学习率调度换成ReduceLROnPlateau监测量设为 val MAEpatience 设 5factor 设 0.5。这一步做完val MAE 一般能压下来 10%~15%。如果还是反弹直接早停用第 25 轮的 checkpoint 重新评估。5.4 测试时预测密度图尺寸和输入对不上count 混乱现象训练时输入是 384×512测试时直接喂原图 1024×768模型前向成功了但密度图求和后的人数要么少一半要么大几倍。原因MCNN 全卷积理论上可以接受任意尺寸输入但池化层会把特征图缩小 1/2最后密度图的尺寸是输入的一半且长宽不一定是偶数时会出现取整差异。如果测试代码没有把 density resize 回原图尺寸就求和面积比例就错了。解决有两种处理方式。一是测试时统一把输入 resize 到训练尺寸然后按照 4.2 节的缩放系数还原人数这是标准的课程设计做法。二是测试时保持原图输入最后把预测 density 用 cv2.resize 回原图尺寸再求和注意要指定interpolationcv2.INTER_CUBIC。我推荐第一种因为它保证网络输入的分布与训练一致性能更稳定。如果你非要尝试任意尺寸输入请先跑一张非方形图片验证 count 是否合理再上完整测试集。5.5 GPU 显存爆掉batch size 调到 1 都跑不起来现象报错CUDA out of memorybatch size 已经是 1输入图 1024×768 也放不下。原因MCNN 虽然参数量小但中间特征图很大。三列分支会在不同分辨率上保留多份特征融合后的 72 通道特征图占用的显存远超 ResNet-18。一些教程里直接拿原图训练是导致显存爆炸的主因。解决把输入最长边缩到 800 或直接固定 512×384这是性价比最高的方式。还有一种常见做法是随机裁剪出 384×384 的 patch 做训练推理时再全图输入这样既控制了显存又给模型提供了多尺度样本。注意裁剪时要同步把真实的密度图裁出来并且记录裁剪偏移否则人数对不上。如果缩到 512×384 还爆可以把三个分支的通道数从 24 降到 12参数量和显存会再砍一半课程设计精度损失完全可以接受。6. 从大作业到能讲清楚的成果两个验证技巧和一条存档习惯模型训完、指标达标之后还有一个常被忽略的环节如何证明模型真的学到了“尺度不变性”而不是背住了训练集的密度分布。我常用的第一个技巧是尺度一致性验证挑一张测试图分别缩放到 0.75 倍和 1.25 倍输入网络两次预测人数应落在合理偏差范围内比如 5% 以内。如果两次计数差超过 20%说明模型只在某个固定尺度上有效泛化能力不足。应对办法是给训练集加多尺度随机缩放增强每张图在 0.8~1.2 倍之间随机缩放再 resize 到固定输入尺寸。第二个技巧是对预测密度图做一次 3×3 均值平滑后再求和。这个操作看似多余实际能有效抵消模型输出中的高频噪声。我见过同一张图直接求和得到 587 人平滑后 562 人而真实值是 551 人平滑后的结果明显更准。原因在于密度图的理想形式是“连续分布”而网络输出的峰值往往带毛刺平滑相当于在求和前做了一次低通滤波滤掉了落在人头上方的孤立尖峰。实现就一行cv2.blur(density, (3, 3))不会引入明显误差。最后说一条我认为最重要的习惯固定随机种子和输出目录。当初我调参时因为忘了固定 seed每次训练结果都微变导致我误判某个改进有效折腾了两天才发现是随机性在作怪。现在的做法是每次训练前打印torch.manual_seed(42)同时把每个 checkpoint 对应的训练参数表、loss 曲线图、可视化对比图统一丢进以时间戳命名的目录。这样回头排查“为什么这轮 MAE 高”“为什么密度图变了”时每一份记录都能对上号。教训是结果好记不住过程等于没做过实验。希望这套流程能帮你的课程设计少踩几个坑把精力花在真正值得调试的地方。本文还有配套的精品资源点击获取